soothe-cli 0.7.14__tar.gz → 0.7.16__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/PKG-INFO +1 -1
  2. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/app/_execution.py +4 -0
  3. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/app/_messages_mixin.py +126 -43
  4. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/config.py +11 -5
  5. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/theme.py +1 -1
  6. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/chat_input.py +23 -0
  7. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/context_viewer.py +183 -46
  8. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/file_change_preview.py +2 -1
  9. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/loop_selector.py +8 -4
  10. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/_helpers.py +10 -7
  11. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/assistant.py +1 -1
  12. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +1 -1
  13. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +1 -1
  14. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/cognition_step.py +1 -1
  15. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +2 -0
  16. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/user.py +18 -1
  17. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +26 -1
  18. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/.gitignore +0 -0
  19. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/README.md +0 -0
  20. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/pyproject.toml +0 -0
  21. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/__init__.py +0 -0
  22. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/__init__.py +0 -0
  23. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/commands/__init__.py +0 -0
  24. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  25. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  26. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  27. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  28. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  29. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  30. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/execution/__init__.py +0 -0
  31. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/execution/daemon.py +0 -0
  32. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  33. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/execution/headless.py +0 -0
  34. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  35. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/execution/launcher.py +0 -0
  36. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/cli/main.py +0 -0
  37. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/config/__init__.py +0 -0
  38. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/config/cli_config.py +0 -0
  39. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/config/loader.py +0 -0
  40. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/config/logging_setup.py +0 -0
  41. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/__init__.py +0 -0
  42. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/headless/processor.py +0 -0
  43. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  44. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  45. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  46. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  47. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  48. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  49. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  50. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  51. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  52. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  53. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  54. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  55. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  56. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  57. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/state/step_router.py +0 -0
  58. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  59. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/state/transcript.py +0 -0
  60. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/token_events_debug.py +0 -0
  61. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/token_usage.py +0 -0
  62. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/transport/session.py +0 -0
  63. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  64. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  65. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  66. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  67. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/runtime/wire/messages.py +0 -0
  68. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/__init__.py +0 -0
  69. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/_cli_context.py +0 -0
  70. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/_env_vars.py +0 -0
  71. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/_version.py +0 -0
  72. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/app/__init__.py +0 -0
  73. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/app/_app.py +0 -0
  74. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/app/_history.py +0 -0
  75. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/app/_model.py +0 -0
  76. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/app/_module_init.py +0 -0
  77. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/app/_startup.py +0 -0
  78. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/app/_ui.py +0 -0
  79. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/app/app.tcss +0 -0
  80. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/binding.py +0 -0
  81. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/command_registry.py +0 -0
  82. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/commands/__init__.py +0 -0
  83. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/commands/command_router.py +0 -0
  84. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  85. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  86. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/file_change_notify.py +0 -0
  87. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  88. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/hooks.py +0 -0
  89. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/input.py +0 -0
  90. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/markdown_theme.py +0 -0
  91. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/media_utils.py +0 -0
  92. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/model_config.py +0 -0
  93. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/path_utils.py +0 -0
  94. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/preview_limits.py +0 -0
  95. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/project_utils.py +0 -0
  96. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/sessions.py +0 -0
  97. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/shell_color.py +0 -0
  98. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/skills/__init__.py +0 -0
  99. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/skills/invocation.py +0 -0
  100. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/skills/load.py +0 -0
  101. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/spinner_labels.py +0 -0
  102. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/textual_adapter.py +0 -0
  103. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/tips.py +0 -0
  104. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/tool_display.py +0 -0
  105. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/unicode_security.py +0 -0
  106. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/update_check.py +0 -0
  107. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  108. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/_links.py +0 -0
  109. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  110. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  111. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/context_data.py +0 -0
  112. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/diff.py +0 -0
  113. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/editor.py +0 -0
  114. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  115. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/history.py +0 -0
  116. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/loading.py +0 -0
  117. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  118. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  119. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  120. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  121. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  122. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
  123. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  124. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  125. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  126. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  127. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  128. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/status.py +0 -0
  129. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  130. {soothe_cli-0.7.14 → soothe_cli-0.7.16}/src/soothe_cli/tui/widgets/welcome.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.7.14
3
+ Version: 0.7.16
4
4
  Summary: Soothe CLI client - communicates with daemon via WebSocket
5
5
  Project-URL: Homepage, https://github.com/mirasoth/soothe
6
6
  Project-URL: Documentation, https://soothe.readthedocs.io
@@ -179,6 +179,7 @@ class _ExecutionMixin:
179
179
  self._pending_messages.append(QueuedMessage(text=value, mode=mode))
180
180
  queued_widget = QueuedUserMessage(value)
181
181
  self._queued_widgets.append(queued_widget)
182
+ self._refresh_queued_goal_tips()
182
183
  await self._mount_message(queued_widget)
183
184
  return
184
185
 
@@ -417,12 +418,14 @@ class _ExecutionMixin:
417
418
  if self._agent_running or self._shell_running:
418
419
  queued_widget = QueuedUserMessage(command)
419
420
  self._queued_widgets.append(queued_widget)
421
+ self._refresh_queued_goal_tips()
420
422
  await self._mount_message(queued_widget)
421
423
 
422
424
  async def _mount_output() -> None:
423
425
  # Remove the ephemeral queued widget, then mount real output.
424
426
  if queued_widget in self._queued_widgets:
425
427
  self._queued_widgets.remove(queued_widget)
428
+ self._refresh_queued_goal_tips()
426
429
  with suppress(Exception):
427
430
  await queued_widget.remove()
428
431
  await self._mount_message(UserMessage(command))
@@ -908,6 +911,7 @@ class _ExecutionMixin:
908
911
  if self._queued_widgets:
909
912
  widget = self._queued_widgets.popleft()
910
913
  await widget.remove()
914
+ self._refresh_queued_goal_tips()
911
915
 
912
916
  await self._process_message(msg.text, msg.mode)
913
917
  except Exception:
@@ -43,6 +43,70 @@ logger = logging.getLogger(__name__)
43
43
  class _MessagesMixin:
44
44
  """Message widget lifecycle, store management, queue, interrupt/quit, toggles, editor, and events."""
45
45
 
46
+ def _refresh_queued_goal_tips(self) -> None:
47
+ """Show queue interaction tips on the most recently queued goal only."""
48
+ if not self._queued_widgets:
49
+ return
50
+ tip_index = -1
51
+ if self._pending_messages and self._pending_messages[-1].mode == "normal":
52
+ pending_tail_index = len(self._pending_messages) - 1
53
+ if pending_tail_index < len(self._queued_widgets):
54
+ tip_index = pending_tail_index
55
+ for index, widget in enumerate(self._queued_widgets):
56
+ with suppress(Exception):
57
+ widget.set_show_queue_tips(index == tip_index if tip_index >= 0 else False)
58
+
59
+ def _has_pending_chat_input(self) -> bool:
60
+ """Return whether chat input has draft content that should be preserved."""
61
+ chat_input = self._chat_input
62
+ return bool(
63
+ chat_input
64
+ and (
65
+ chat_input.value.strip()
66
+ or chat_input.mode != "normal"
67
+ or chat_input._current_suggestions
68
+ )
69
+ )
70
+
71
+ def _can_run_queued_goal_now_from_enter(self) -> bool:
72
+ """Return whether Enter should cancel the current goal for queued-head run."""
73
+ if not self._agent_running or not self._pending_messages:
74
+ return False
75
+ if self._has_pending_chat_input():
76
+ return False
77
+ queue_head = self._pending_messages[0]
78
+ return queue_head.mode == "normal"
79
+
80
+ def run_queued_goal_now_from_enter(self) -> bool:
81
+ """Cancel the running goal so queued head can start immediately.
82
+
83
+ Returns:
84
+ `True` when an interrupt was triggered, otherwise `False`.
85
+ """
86
+ if not self._can_run_queued_goal_now_from_enter():
87
+ return False
88
+ return self._interrupt_running_goal_preserving_queue()
89
+
90
+ def _interrupt_running_goal_preserving_queue(self) -> bool:
91
+ """Interrupt current agent turn while keeping queued goals intact."""
92
+ if not (self._agent_running and self._agent_worker):
93
+ return False
94
+ if self._daemon_session is not None:
95
+ self.run_worker(
96
+ self._interrupt_daemon_agent_turn(discard_queue=False),
97
+ exclusive=False,
98
+ group="daemon-interrupt",
99
+ )
100
+ else:
101
+ self.run_worker(
102
+ self._tear_down_interrupt_ui(),
103
+ exclusive=False,
104
+ group="interrupt-ui",
105
+ )
106
+ self._cancel_worker(self._agent_worker, discard_queue=False)
107
+ self._quit_pending = False
108
+ return True
109
+
46
110
  async def _load_loop_history(
47
111
  self,
48
112
  *,
@@ -322,40 +386,42 @@ class _MessagesMixin:
322
386
  "Messages container (#messages) not found during clear; UI may be out of sync with message store"
323
387
  )
324
388
 
325
- def _pop_last_queued_message(self) -> None:
326
- """Remove the most recently queued message (LIFO).
327
-
328
- If the chat input is empty the evicted text is restored there so the
329
- user can edit and re-submit. Otherwise the message is discarded. The
330
- toast message distinguishes between the two outcomes.
331
-
332
- Caller must ensure `_pending_messages` is non-empty. A defensive guard
333
- is included in case of async TOCTOU races.
334
- """
389
+ def _pop_last_queued_entry(self) -> Any | None:
390
+ """Pop the latest queued message and paired widget, if available."""
335
391
  if not self._pending_messages:
336
- return
392
+ return None
337
393
  msg = self._pending_messages.pop()
338
394
  if self._queued_widgets:
339
395
  widget = self._queued_widgets.pop()
340
396
  widget.remove()
397
+ self._refresh_queued_goal_tips()
341
398
  else:
342
399
  logger.warning(
343
400
  "Queued-widget deque empty while pending-messages was not; widget/message tracking may be out of sync"
344
401
  )
402
+ return msg
403
+
404
+ def _restore_last_queued_goal_to_input(self) -> bool:
405
+ """Restore the latest queued goal into an empty chat input for editing."""
406
+ msg = self._pop_last_queued_entry()
407
+ if msg is None:
408
+ return False
345
409
 
346
410
  if not self._chat_input:
347
411
  logger.warning(
348
412
  "Chat input unavailable during queue pop; message text cannot be restored: %s",
349
413
  msg.text[:60],
350
414
  )
351
- self.notify("Queued message discarded", timeout=2)
352
- return
415
+ return False
353
416
 
354
- if not self._chat_input.value.strip():
355
- self._chat_input.value = msg.text
356
- self.notify("Queued message moved to input", timeout=2)
357
- else:
358
- self.notify("Queued message discarded (input not empty)", timeout=3)
417
+ if self._has_pending_chat_input():
418
+ logger.warning(
419
+ "Queue restore requested while input has draft content; skipping restore"
420
+ )
421
+ return False
422
+ self._chat_input.value = msg.text
423
+ self.notify("Queued goal moved to input", timeout=2)
424
+ return True
359
425
 
360
426
  def _discard_queue(self) -> None:
361
427
  """Clear pending messages, deferred actions, and queued widgets."""
@@ -365,6 +431,25 @@ class _MessagesMixin:
365
431
  self._queued_widgets.clear()
366
432
  self._deferred_actions.clear()
367
433
 
434
+ def _cancel_last_queued_message(self) -> bool:
435
+ """Cancel the most recently queued goal without restoring it to input."""
436
+ msg = self._pop_last_queued_entry()
437
+ if msg is None:
438
+ return False
439
+ self.notify(f"Cancelled queued goal: {msg.text[:60]}", timeout=2)
440
+ return True
441
+
442
+ def edit_queued_goal_from_up(self) -> bool:
443
+ """Move the latest queued goal back to input for editing."""
444
+ if not self._pending_messages:
445
+ return False
446
+ if self._has_pending_chat_input():
447
+ return False
448
+ queue_tail = self._pending_messages[-1]
449
+ if queue_tail.mode != "normal":
450
+ return False
451
+ return self._restore_last_queued_goal_to_input()
452
+
368
453
  def _defer_action(self, action: DeferredAction) -> None:
369
454
  """Queue a deferred action, replacing any existing action of the same kind.
370
455
 
@@ -434,7 +519,7 @@ class _MessagesMixin:
434
519
  worker.cancel()
435
520
 
436
521
  async def _interrupt_daemon_agent_turn(self, *, discard_queue: bool = True) -> None:
437
- """Stop in-flight UI, send daemon ``/cancel``, then cancel the local worker.
522
+ """Stop in-flight UI and request daemon-side cancel.
438
523
 
439
524
  UI teardown runs first so Ctrl+C does not leave the thinking spinner or
440
525
  goal-tree running rows active while the daemon winds down (which can take
@@ -442,17 +527,24 @@ class _MessagesMixin:
442
527
 
443
528
  Args:
444
529
  discard_queue: When ``False``, preserve queued user goals so they
445
- run after the cancelled turn finishes cleanup.
530
+ run after the cancelled turn finishes cleanup. In this mode,
531
+ the local worker is left running when daemon cancel succeeds.
446
532
  """
447
533
  await self._tear_down_interrupt_ui()
448
534
  session = self._daemon_session
449
535
  worker = self._agent_worker
536
+ cancel_sent = False
450
537
  if session is not None:
451
538
  try:
452
539
  await session.cancel_remote_query()
540
+ cancel_sent = True
453
541
  except Exception:
454
542
  logger.warning("Failed to send cancel to daemon", exc_info=True)
455
- if worker is not None:
543
+ # For Enter-triggered queued-goal handoff (discard_queue=False), avoid
544
+ # force-cancelling the local worker after /cancel is accepted.
545
+ # Let the active stream finish naturally so queue-drain ordering stays
546
+ # stable and the loop subscription is not torn down mid-handoff.
547
+ if worker is not None and (discard_queue or not cancel_sent):
456
548
  self._cancel_worker(worker, discard_queue=discard_queue)
457
549
 
458
550
  def action_copy_selection(self) -> None:
@@ -476,11 +568,7 @@ class _MessagesMixin:
476
568
  so Ctrl+C is reserved for interrupt/quit only.
477
569
  """
478
570
  # Check if input has pending content (text, mode, or completion)
479
- has_pending_input = self._chat_input and (
480
- self._chat_input.value.strip()
481
- or self._chat_input.mode != "normal"
482
- or self._chat_input._current_suggestions
483
- )
571
+ has_pending_input = self._has_pending_chat_input()
484
572
 
485
573
  # If shell command is running: clear input first, then kill shell
486
574
  if self._shell_running and self._shell_worker:
@@ -498,20 +586,7 @@ class _MessagesMixin:
498
586
  self._chat_input.clear_input()
499
587
  self._quit_pending = False
500
588
  return
501
- if self._daemon_session is not None:
502
- self.run_worker(
503
- self._interrupt_daemon_agent_turn(discard_queue=False),
504
- exclusive=False,
505
- group="daemon-interrupt",
506
- )
507
- else:
508
- self.run_worker(
509
- self._tear_down_interrupt_ui(),
510
- exclusive=False,
511
- group="interrupt-ui",
512
- )
513
- self._cancel_worker(self._agent_worker, discard_queue=False)
514
- self._quit_pending = False
589
+ self._interrupt_running_goal_preserving_queue()
515
590
  return
516
591
 
517
592
  # Double Ctrl+C to quit (same detach path as Ctrl+D and /quit)
@@ -534,13 +609,14 @@ class _MessagesMixin:
534
609
  self.set_timer(quit_timeout, lambda: setattr(self, "_quit_pending", False))
535
610
 
536
611
  def action_dismiss_ui(self) -> None:
537
- """Handle Escape — dismiss overlays and modals only (never cancel agent work).
612
+ """Handle Escape — dismiss overlays and optionally cancel queued goals.
538
613
 
539
614
  Priority order:
540
615
  1. If modal screen is active, dismiss it
541
616
  2. If plan quick-view overlay is open, collapse it
542
- 3. If completion popup is open, dismiss it
543
- 4. If input is in command/shell mode, exit to normal mode
617
+ 3. If input is idle and queue has a normal goal, cancel queued tail
618
+ 4. If completion popup is open, dismiss it
619
+ 5. If input is in command/shell mode, exit to normal mode
544
620
  """
545
621
  from contextlib import suppress
546
622
 
@@ -568,6 +644,13 @@ class _MessagesMixin:
568
644
 
569
645
  # Close completion popup or exit slash/shell command mode
570
646
  if self._chat_input:
647
+ # When queue has pending goals and input is idle, Esc cancels the
648
+ # latest queued normal goal (without interrupting current work).
649
+ if not self._has_pending_chat_input():
650
+ queue_tail = self._pending_messages[-1] if self._pending_messages else None
651
+ if queue_tail is not None and queue_tail.mode == "normal":
652
+ if self._cancel_last_queued_message():
653
+ return
571
654
  if self._chat_input.dismiss_completion():
572
655
  return
573
656
  if self._chat_input.exit_mode():
@@ -221,10 +221,10 @@ class CharsetMode(StrEnum):
221
221
  """Character set mode for TUI display."""
222
222
 
223
223
  UNICODE = "unicode"
224
- """Always use Unicode glyphs (e.g. `⏺`, `✓`, `…`)."""
224
+ """Always use Unicode glyphs (e.g. `●`, `✓`, `…`)."""
225
225
 
226
226
  ASCII = "ascii"
227
- """Always use ASCII-safe fallbacks (e.g. `(*)`, `[OK]`, `...`)."""
227
+ """Always use ASCII-safe fallbacks (e.g. `[*]`, `[OK]`, `...`)."""
228
228
 
229
229
  AUTO = "auto"
230
230
  """Detect charset support at runtime and pick Unicode or ASCII."""
@@ -234,7 +234,9 @@ class CharsetMode(StrEnum):
234
234
  class Glyphs:
235
235
  """Character glyphs for TUI display."""
236
236
 
237
- tool_prefix: str # vs (*)
237
+ tool_prefix: str # vs [*]
238
+ file_edit_prefix: str # ■ vs [#]
239
+ subagent_prefix: str # ◆ vs [S]
238
240
  ellipsis: str # … vs ...
239
241
  checkmark: str # ✓ vs [OK]
240
242
  error: str # ✗ vs [X]
@@ -270,7 +272,9 @@ class Glyphs:
270
272
 
271
273
 
272
274
  UNICODE_GLYPHS = Glyphs(
273
- tool_prefix="",
275
+ tool_prefix="",
276
+ file_edit_prefix="■",
277
+ subagent_prefix="◆",
274
278
  ellipsis="…",
275
279
  checkmark="✓",
276
280
  error="✗",
@@ -301,7 +305,9 @@ UNICODE_GLYPHS = Glyphs(
301
305
  """Glyph set for terminals with full Unicode support."""
302
306
 
303
307
  ASCII_GLYPHS = Glyphs(
304
- tool_prefix="(*)",
308
+ tool_prefix="[*]",
309
+ file_edit_prefix="[#]",
310
+ subagent_prefix="[S]",
305
311
  ellipsis="...",
306
312
  checkmark="[OK]",
307
313
  error="[X]",
@@ -99,7 +99,7 @@ LC_TOOL = LC_AMBER
99
99
  # Design principles:
100
100
  # - Consistent visual style across all four card types
101
101
  # - Restrained grayscale for secondary activity text
102
- # - Stateful ``⏺`` dot prefixes for card headers; ``⎿`` gutter for body lines
102
+ # - Stateful card-prefix glyphs for card headers; ``⎿`` gutter for body lines
103
103
  # ---------------------------------------------------------------------------
104
104
 
105
105
  # Grayscale base colors (neutral, non-distracting)
@@ -669,10 +669,33 @@ class ChatTextArea(TextArea):
669
669
  event.prevent_default()
670
670
  event.stop()
671
671
  value = self.text.strip()
672
+ if not value:
673
+ trigger = getattr(self.app, "run_queued_goal_now_from_enter", None)
674
+ if callable(trigger):
675
+ try:
676
+ if bool(trigger()):
677
+ return
678
+ except Exception: # noqa: BLE001
679
+ logger.debug(
680
+ "Queued-goal Enter shortcut failed; keeping empty-enter noop",
681
+ exc_info=True,
682
+ )
672
683
  if value:
673
684
  self.post_message(self.Submitted(value))
674
685
  return
675
686
 
687
+ # Up on empty input can pull the latest queued goal back for editing.
688
+ if event.key == "up" and not self.text:
689
+ trigger_edit = getattr(self.app, "edit_queued_goal_from_up", None)
690
+ if callable(trigger_edit):
691
+ try:
692
+ if bool(trigger_edit()):
693
+ event.prevent_default()
694
+ event.stop()
695
+ return
696
+ except Exception: # noqa: BLE001
697
+ logger.debug("Queued-goal edit shortcut failed", exc_info=True)
698
+
676
699
  # Up/Down arrow: only navigate history at input boundaries.
677
700
  # Up requires cursor at position (0, 0); Down requires cursor at
678
701
  # the very end. When already browsing history, either boundary