|
35 | 35 | from apps.cli.widgets.input_area import InputArea |
36 | 36 | from apps.cli.widgets.message_list import MessageList |
37 | 37 | from apps.cli.widgets.notification import notify_success, notify_warning |
| 38 | +from apps.cli.widgets.queued_panel import QueuedWidget |
38 | 39 | from apps.cli.widgets.side_panel import SidePanel |
39 | 40 | from apps.cli.widgets.status_bar import StatusBar |
40 | 41 | from pydantic_deep.deps import DEFAULT_USAGE_LIMITS |
@@ -383,20 +384,45 @@ def _show_welcome(self) -> None: |
383 | 384 |
|
384 | 385 | # ── User input handling ─────────────────────────────────────── |
385 | 386 |
|
386 | | - def on_user_submitted(self, event: UserSubmitted) -> None: |
| 387 | + async def on_user_submitted(self, event: UserSubmitted) -> None: |
387 | 388 | """Handle user submitting a prompt.""" |
388 | 389 | text = event.text |
389 | 390 |
|
| 391 | + app = self.app |
| 392 | + queue = app.queue |
| 393 | + task = app._agent_task |
| 394 | + is_running = task is not None and not task.done() |
| 395 | + |
| 396 | + # Mid-run: route to queue. `>>` prefix = steering, plain text = follow-up. |
| 397 | + # `!` keeps meaning "shell command" regardless of agent state. |
| 398 | + if is_running and queue is not None and not text.startswith("!"): |
| 399 | + if text.startswith(">>"): |
| 400 | + steer_text = text[2:].strip() |
| 401 | + if steer_text: |
| 402 | + await queue.steer(steer_text) |
| 403 | + preview = steer_text[:40] + ("…" if len(steer_text) > 40 else "") |
| 404 | + app.notify(f"steering queued: {preview}") |
| 405 | + self._increment_queue_badge(steering=True) |
| 406 | + else: |
| 407 | + await queue.follow_up(text) |
| 408 | + app.notify("follow-up queued") |
| 409 | + self._increment_queue_badge(steering=False) |
| 410 | + return |
| 411 | + |
390 | 412 | # Shell command |
391 | 413 | if text.startswith("!"): |
392 | | - self.app.run_shell_command(text[1:]) # type: ignore[attr-defined] |
| 414 | + app.run_shell_command(text[1:]) # type: ignore[attr-defined] |
393 | 415 | return |
394 | 416 |
|
395 | 417 | # Slash command (but not things like "I used /path/to/file") |
396 | 418 | if text.startswith("/") and not text.startswith("//"): |
397 | | - self.app.handle_command(text) # type: ignore[attr-defined] |
| 419 | + app.handle_command(text) # type: ignore[attr-defined] |
398 | 420 | return |
399 | 421 |
|
| 422 | + # User typed `>>foo` while idle — strip the steering prefix and run as a normal prompt |
| 423 | + if text.startswith(">>"): |
| 424 | + text = text[2:].lstrip() |
| 425 | + |
400 | 426 | # Expand @file references — read files and append content to prompt |
401 | 427 | text = self._expand_file_refs(text) |
402 | 428 |
|
@@ -445,6 +471,7 @@ def _run_agent(self, text: str) -> None: |
445 | 471 | msg_list = self.query_one(MessageList) |
446 | 472 |
|
447 | 473 | header.is_streaming = True |
| 474 | + self.query_one(InputArea).is_agent_running = True |
448 | 475 | app.last_response = "" # type: ignore |
449 | 476 | assistant = msg_list.begin_assistant_message() |
450 | 477 |
|
@@ -491,6 +518,7 @@ async def _agent_stream_worker( # noqa: C901 |
491 | 518 |
|
492 | 519 | log.info("Agent run started", prompt_length=len(text), history_messages=len(history)) |
493 | 520 |
|
| 521 | + _follow_up_scheduled = False |
494 | 522 | pending: dict[str, tuple[dict[str, Any], float]] = {} |
495 | 523 | _run_cancelled = False |
496 | 524 | _TODO_TOOLS: frozenset[str] = frozenset() # Show all tool calls in UI |
@@ -787,6 +815,21 @@ def _parse_args(raw: Any) -> dict[str, Any]: |
787 | 815 | # Auto-save session |
788 | 816 | self._save_session() |
789 | 817 |
|
| 818 | + # Drain follow-up queue and schedule next run if pending. |
| 819 | + _queue = app.queue |
| 820 | + if _queue is not None: |
| 821 | + _follow_up_msgs = await _queue.drain_follow_up() |
| 822 | + if _follow_up_msgs: |
| 823 | + from pydantic_deep.capabilities.message_queue import ( |
| 824 | + format_follow_up as _fmt_fu, |
| 825 | + ) |
| 826 | + |
| 827 | + _follow_up_text = _fmt_fu(_follow_up_msgs) |
| 828 | + msg_list.append_user_message(_follow_up_text) |
| 829 | + self._decrement_queue_badge(len(_follow_up_msgs)) |
| 830 | + _follow_up_scheduled = True |
| 831 | + self.call_later(self._run_agent, _follow_up_text) |
| 832 | + |
790 | 833 | except asyncio.CancelledError: |
791 | 834 | _run_cancelled = True |
792 | 835 | log.info("Agent run cancelled") |
@@ -814,13 +857,58 @@ def _parse_args(raw: Any) -> dict[str, Any]: |
814 | 857 | app.is_streaming = False |
815 | 858 | header.is_streaming = False |
816 | 859 | header.is_thinking = False |
| 860 | + with contextlib.suppress(Exception): |
| 861 | + self.query_one(InputArea).is_agent_running = False |
817 | 862 | msg_list.end_assistant_message() |
818 | 863 | with contextlib.suppress(Exception): |
819 | 864 | self.query_one(InputArea).focus_input() |
820 | 865 | with contextlib.suppress(Exception): |
821 | 866 | self.query_one(HintsBar).reset() |
822 | 867 | with contextlib.suppress(Exception): |
823 | 868 | msg_list.scroll_end(animate=False) |
| 869 | + _stale_queue = app.queue |
| 870 | + if _stale_queue is not None: |
| 871 | + stale = await _stale_queue.drain_steering() |
| 872 | + if stale: |
| 873 | + n = len(stale) |
| 874 | + label = "steering message" if n == 1 else "steering messages" |
| 875 | + with contextlib.suppress(Exception): |
| 876 | + app.notify( |
| 877 | + f"{n} {label} not delivered — agent finished before next LLM call", |
| 878 | + severity="warning", |
| 879 | + timeout=6, |
| 880 | + ) |
| 881 | + # When the run was cancelled, follow-ups referring to the cancelled |
| 882 | + # task are likely stale too. Discard with a count-only notification. |
| 883 | + if _run_cancelled: |
| 884 | + stale_fu = await _stale_queue.drain_follow_up() |
| 885 | + if stale_fu: |
| 886 | + n = len(stale_fu) |
| 887 | + label = "follow-up" if n == 1 else "follow-ups" |
| 888 | + with contextlib.suppress(Exception): |
| 889 | + app.notify( |
| 890 | + f"{n} {label} discarded — run cancelled", |
| 891 | + severity="warning", |
| 892 | + timeout=6, |
| 893 | + ) |
| 894 | + if not _follow_up_scheduled: |
| 895 | + self._reset_queue_badge() |
| 896 | + else: |
| 897 | + with contextlib.suppress(Exception): |
| 898 | + self.query_one(QueuedWidget).clear_steering() |
| 899 | + |
| 900 | + def _increment_queue_badge(self, *, steering: bool) -> None: |
| 901 | + with contextlib.suppress(Exception): |
| 902 | + w = self.query_one(QueuedWidget) |
| 903 | + w.increment_steering() if steering else w.increment_follow_up() |
| 904 | + |
| 905 | + def _decrement_queue_badge(self, follow_up_count: int = 1) -> None: |
| 906 | + with contextlib.suppress(Exception): |
| 907 | + self.query_one(QueuedWidget).decrement_follow_up(follow_up_count) |
| 908 | + |
| 909 | + def _reset_queue_badge(self) -> None: |
| 910 | + with contextlib.suppress(Exception): |
| 911 | + self.query_one(QueuedWidget).reset() |
824 | 912 |
|
825 | 913 | def _expand_file_refs(self, text: str) -> str: |
826 | 914 | """Expand @file references in the prompt with file contents.""" |
|
0 commit comments