soothe-cli 1.0.4__tar.gz → 1.0.5__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 (146) hide show
  1. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/.gitignore +0 -1
  2. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/PKG-INFO +1 -1
  3. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_app.py +15 -0
  4. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_execution.py +3 -16
  5. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_messages_mixin.py +192 -28
  6. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_model.py +1 -0
  7. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_startup.py +1 -2
  8. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_ui.py +18 -0
  9. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/app.tcss +12 -0
  10. soothe_cli-1.0.5/src/soothe_cli/tui/widgets/queued_goals_bar.py +384 -0
  11. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/README.md +0 -0
  12. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/pyproject.toml +0 -0
  13. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/__init__.py +0 -0
  14. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/_cli_context.py +0 -0
  15. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/_env_vars.py +0 -0
  16. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/_version.py +0 -0
  17. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/card_wire.py +0 -0
  18. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/__init__.py +0 -0
  19. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/commands/__init__.py +0 -0
  20. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  21. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  22. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  23. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  24. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  25. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  26. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/execution/__init__.py +0 -0
  27. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/execution/daemon.py +0 -0
  28. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  29. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/execution/headless.py +0 -0
  30. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  31. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/execution/launcher.py +0 -0
  32. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/main.py +0 -0
  33. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/cli/soothecli.py +0 -0
  34. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/commands/__init__.py +0 -0
  35. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/commands/binding.py +0 -0
  36. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/commands/command_registry.py +0 -0
  37. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/commands/command_router.py +0 -0
  38. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/commands/slash_commands.py +0 -0
  39. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/commands/subagent_routing.py +0 -0
  40. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/config/__init__.py +0 -0
  41. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/config/cli_config.py +0 -0
  42. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/config/loader.py +0 -0
  43. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/config/logging_setup.py +0 -0
  44. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/__init__.py +0 -0
  45. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/markdown_theme.py +0 -0
  46. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/media_utils.py +0 -0
  47. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/mermaid_render.py +0 -0
  48. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/path_utils.py +0 -0
  49. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/preview_limits.py +0 -0
  50. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/shell_color.py +0 -0
  51. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/spinner_labels.py +0 -0
  52. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/theme.py +0 -0
  53. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/tips.py +0 -0
  54. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/tool_display.py +0 -0
  55. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/display/unicode_security.py +0 -0
  56. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/loops/__init__.py +0 -0
  57. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/loops/sessions.py +0 -0
  58. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/model_config.py +0 -0
  59. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/project_utils.py +0 -0
  60. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/__init__.py +0 -0
  61. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/headless/processor.py +0 -0
  62. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  63. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  64. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  65. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  66. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  67. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/presentation/id_format.py +0 -0
  68. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  69. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  70. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/presentation/step_id_format.py +0 -0
  71. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  72. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  73. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  74. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/state/step_router.py +0 -0
  75. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  76. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/token_events_debug.py +0 -0
  77. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/token_usage.py +0 -0
  78. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/transport/session.py +0 -0
  79. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  80. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  81. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  82. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/runtime/wire/messages.py +0 -0
  83. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/settings/__init__.py +0 -0
  84. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/settings/_console_impl.py +0 -0
  85. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/settings/bootstrap.py +0 -0
  86. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/settings/core.py +0 -0
  87. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/settings/glyphs.py +0 -0
  88. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/settings/provider.py +0 -0
  89. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/settings/shell_allow.py +0 -0
  90. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/settings/skills_dirs.py +0 -0
  91. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/settings/stream_config.py +0 -0
  92. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/__init__.py +0 -0
  93. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/__init__.py +0 -0
  94. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_clarification.py +0 -0
  95. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_entrypoints.py +0 -0
  96. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_history.py +0 -0
  97. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_model_params.py +0 -0
  98. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_terminal.py +0 -0
  99. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_theme_prefs.py +0 -0
  100. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/app/_types.py +0 -0
  101. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/composer_mode.py +0 -0
  102. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/file_change_notify.py +0 -0
  103. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  104. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/input.py +0 -0
  105. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/skills/__init__.py +0 -0
  106. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/skills/invocation.py +0 -0
  107. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/skills/load.py +0 -0
  108. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/textual_adapter.py +0 -0
  109. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  110. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/_links.py +0 -0
  111. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  112. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  113. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  114. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/context_data.py +0 -0
  115. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
  116. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/diff.py +0 -0
  117. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/editor.py +0 -0
  118. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  119. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  120. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/history.py +0 -0
  121. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/loading.py +0 -0
  122. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  123. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  124. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  125. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  126. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  127. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  128. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  129. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
  130. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  131. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/cognition_step.py +0 -0
  132. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
  133. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  134. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  135. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  136. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/structured_ask_user.py +0 -0
  137. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  138. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  139. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  140. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/pinned_goal_bar.py +0 -0
  141. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +0 -0
  142. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/router_profile_selector.py +0 -0
  143. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/status.py +0 -0
  144. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  145. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/tui/widgets/welcome.py +0 -0
  146. {soothe_cli-1.0.4 → soothe_cli-1.0.5}/src/soothe_cli/update_check.py +0 -0
@@ -235,4 +235,3 @@ __MACOSX
235
235
 
236
236
  # Accidental MemU test persist_dir artifacts
237
237
  packages/soothe/MagicMock/
238
- uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: soothe-cli
3
- Version: 1.0.4
3
+ Version: 1.0.5
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
@@ -48,6 +48,7 @@ from soothe_cli.tui.widgets.messages import (
48
48
  )
49
49
  from soothe_cli.tui.widgets.pinned_goal_bar import PinnedGoalBar
50
50
  from soothe_cli.tui.widgets.plan_quick_view_overlay import PlanQuickViewOverlay
51
+ from soothe_cli.tui.widgets.queued_goals_bar import QueuedGoalsBar
51
52
  from soothe_cli.tui.widgets.status import StatusBar
52
53
  from soothe_cli.tui.widgets.welcome import WelcomeBanner
53
54
 
@@ -133,6 +134,13 @@ class SootheApp(
133
134
  show=False,
134
135
  priority=True,
135
136
  ),
137
+ Binding(
138
+ "ctrl+q",
139
+ "focus_queued_goals",
140
+ "Focus Queued Goals",
141
+ show=False,
142
+ priority=True,
143
+ ),
136
144
  ]
137
145
  """App-level keybindings for dismiss, quit, and navigation."""
138
146
 
@@ -496,6 +504,13 @@ class SootheApp(
496
504
  default_visible=plan_visible,
497
505
  )
498
506
  with Container(id="bottom-app-container"):
507
+ # Queued goals bar — inside the docked bottom container, above
508
+ # the thinking row and chat input. Hidden when the queue is
509
+ # empty; shows all pending queued goals as selectable rows.
510
+ # Placed inside #bottom-app-container (rather than as a separate
511
+ # docked sibling) because Textual does not stack multiple
512
+ # docked-to-same-edge widgets — they overlap instead.
513
+ yield QueuedGoalsBar(id="queued-goals")
499
514
  with Container(id="thinking-status"):
500
515
  yield TipRow(id="tip-row")
501
516
  yield ChatInput(
@@ -189,10 +189,7 @@ class _ExecutionMixin:
189
189
  await self._process_message(value, mode)
190
190
  return
191
191
  self._pending_messages.append(QueuedMessage(text=value, mode=mode))
192
- queued_widget = QueuedUserMessage(value)
193
- self._queued_widgets.append(queued_widget)
194
192
  self._refresh_queued_goal_tips()
195
- await self._mount_message(queued_widget)
196
193
  return
197
194
 
198
195
  await self._process_message(value, mode)
@@ -576,14 +573,12 @@ class _ExecutionMixin:
576
573
  if self._agent_running or self._shell_running:
577
574
  queued_widget = QueuedUserMessage(command)
578
575
  self._queued_widgets.append(queued_widget)
579
- self._refresh_queued_goal_tips()
580
576
  await self._mount_message(queued_widget)
581
577
 
582
578
  async def _mount_output() -> None:
583
579
  # Remove the ephemeral queued widget, then mount real output.
584
580
  if queued_widget in self._queued_widgets:
585
581
  self._queued_widgets.remove(queued_widget)
586
- self._refresh_queued_goal_tips()
587
582
  with suppress(Exception):
588
583
  await queued_widget.remove()
589
584
  await self._mount_message(UserMessage(command))
@@ -677,6 +672,7 @@ class _ExecutionMixin:
677
672
  elif cmd == "/clear":
678
673
  self._pending_messages.clear()
679
674
  self._queued_widgets.clear()
675
+ self._refresh_queued_goal_tips()
680
676
  await self._clear_messages()
681
677
  self._reset_loop_token_usage(None)
682
678
  # Clear status message (e.g., "Interrupted" from previous session)
@@ -1165,12 +1161,7 @@ class _ExecutionMixin:
1165
1161
  self._processing_pending = True
1166
1162
  try:
1167
1163
  msg = self._pending_messages.popleft()
1168
-
1169
- # Remove the ephemeral queued-message widget
1170
- if self._queued_widgets:
1171
- widget = self._queued_widgets.popleft()
1172
- await widget.remove()
1173
- self._refresh_queued_goal_tips()
1164
+ self._refresh_queued_goal_tips()
1174
1165
 
1175
1166
  await self._process_message(msg.text, msg.mode)
1176
1167
  except Exception:
@@ -1301,11 +1292,7 @@ class _ExecutionMixin:
1301
1292
  if self._pending_messages:
1302
1293
  msg = self._pending_messages.popleft()
1303
1294
  prompt = msg.text
1304
- if self._queued_widgets:
1305
- widget = self._queued_widgets.popleft()
1306
- with suppress(Exception):
1307
- await widget.remove()
1308
- self._refresh_queued_goal_tips()
1295
+ self._refresh_queued_goal_tips()
1309
1296
  if msg.mode != "normal":
1310
1297
  # Shell/command still need their normal handlers.
1311
1298
  await self._process_message(msg.text, msg.mode)
@@ -38,6 +38,7 @@ from soothe_cli.tui.widgets.messages import (
38
38
  SkillMessage,
39
39
  SummarizationMessage,
40
40
  )
41
+ from soothe_cli.tui.widgets.queued_goals_bar import QueuedGoalsBar
41
42
 
42
43
  logger = logging.getLogger(__name__)
43
44
 
@@ -66,17 +67,40 @@ class _MessagesMixin:
66
67
  """Message widget lifecycle, store management, queue, interrupt/quit, toggles, editor, and events."""
67
68
 
68
69
  def _refresh_queued_goal_tips(self) -> None:
69
- """Show queue interaction tips on the most recently queued goal only."""
70
- if not self._queued_widgets:
70
+ """Sync the queued goals bar with the current queue state.
71
+
72
+ Feeds the current ``_pending_messages`` snapshot to the
73
+ ``QueuedGoalsBar`` so it re-renders all rows. Shows queue
74
+ interaction tips on the bar when the tail goal is a normal-mode
75
+ goal and the agent is running.
76
+ """
77
+ self._sync_queued_goals_bar()
78
+ if not self._pending_messages:
71
79
  return
72
- tip_index: int | None = None
80
+ show_tips = False
73
81
  if self._pending_messages and self._pending_messages[-1].mode == "normal":
74
- pending_tail_index = len(self._pending_messages) - 1
75
- if pending_tail_index < len(self._queued_widgets):
76
- tip_index = pending_tail_index
77
- for index, widget in enumerate(self._queued_widgets):
78
- with suppress(Exception):
79
- widget.set_show_queue_tips(tip_index is not None and index == tip_index)
82
+ show_tips = True
83
+ try:
84
+ bar = self.query_one("#queued-goals", QueuedGoalsBar)
85
+ except Exception:
86
+ # NoMatches, ScreenStackError, or no app/screen in test stubs.
87
+ return
88
+ with suppress(Exception):
89
+ bar.set_show_tips(show_tips)
90
+
91
+ def _sync_queued_goals_bar(self) -> None:
92
+ """Push the current ``_pending_messages`` snapshot to the bar widget.
93
+
94
+ Safe to call when the bar is not yet mounted (e.g. during startup
95
+ or in unit tests using stubs) — the call is silently skipped.
96
+ """
97
+ try:
98
+ bar = self.query_one("#queued-goals", QueuedGoalsBar)
99
+ except Exception:
100
+ # NoMatches, ScreenStackError, or no app/screen in test stubs.
101
+ return
102
+ goals = list(self._pending_messages)
103
+ bar.set_goals(goals)
80
104
 
81
105
  def _has_pending_chat_input(self) -> bool:
82
106
  """Return whether chat input has draft content that should be preserved."""
@@ -369,7 +393,11 @@ class _MessagesMixin:
369
393
  return False
370
394
 
371
395
  def _assistant_card_already_visible(self, card: Any) -> bool:
372
- """True when stream already mounted this assistant body (avoid double report)."""
396
+ """True when the stream already mounted this assistant body.
397
+
398
+ Also suppresses the raw plan ASSISTANT card when a plan-review
399
+ `StructuredAskUserWidget` with overlapping body is mounted.
400
+ """
373
401
  from soothe_sdk.display.transcript_types import MessageType
374
402
 
375
403
  adapter = getattr(self, "_ui_adapter", None)
@@ -388,6 +416,54 @@ class _MessagesMixin:
388
416
  return True
389
417
  # Only inspect the latest assistant card.
390
418
  break
419
+ # Plan-mode: suppress ASSISTANT card when a plan-review widget already
420
+ # shows the same plan body.
421
+ if self._plan_review_widget_shows_text(text):
422
+ return True
423
+ return False
424
+
425
+ @staticmethod
426
+ def _text_overlaps_plan_body(text: str, body: str) -> bool:
427
+ """True when `text` and `body` overlap (exact, prefix, substring, or 200-char prefix).
428
+
429
+ Substring checks require the shorter string to be at least 50 chars
430
+ to avoid false positives.
431
+ """
432
+ if not text or not body:
433
+ return False
434
+ if text == body or text.startswith(body) or body.startswith(text):
435
+ return True
436
+ # Substring containment: handles preface trimming (e.g. "Here is the
437
+ # plan.\n\n## Plan:..." card vs "## Plan:..." widget body). Require the
438
+ # shorter string to be at least 50 chars to avoid false positives.
439
+ shorter = body if len(body) <= len(text) else text
440
+ longer = text if shorter is body else body
441
+ if len(shorter) >= 50 and shorter in longer:
442
+ return True
443
+ # Handle truncation: compare a significant prefix of the shorter text.
444
+ prefix_len = min(200, len(text), len(body))
445
+ if prefix_len >= 50 and text[:prefix_len] == body[:prefix_len]:
446
+ return True
447
+ return False
448
+
449
+ def _plan_review_widget_shows_text(self, text: str) -> bool:
450
+ """True when a mounted plan-review widget's body overlaps `text`.
451
+
452
+ Only plan-review widgets (`_is_plan_review`) are considered, so
453
+ non-plan ASSISTANT cards are never suppressed.
454
+ """
455
+ adapter = getattr(self, "_ui_adapter", None)
456
+ if adapter is None:
457
+ return False
458
+ by_step = getattr(adapter, "_clarification_input_by_step", None) or {}
459
+ for widget in by_step.values():
460
+ if not getattr(widget, "_is_plan_review", False):
461
+ continue
462
+ body = str(getattr(widget, "_body_markdown", "") or "").strip()
463
+ if not body:
464
+ continue
465
+ if self._text_overlaps_plan_body(text, body):
466
+ return True
391
467
  return False
392
468
 
393
469
  def _cognition_reason_already_visible(self, card: Any) -> bool:
@@ -624,18 +700,15 @@ class _MessagesMixin:
624
700
  await self._set_spinner(None)
625
701
 
626
702
  def _pop_last_queued_entry(self) -> Any | None:
627
- """Pop the latest queued message and paired widget, if available."""
703
+ """Pop the latest queued message, if available.
704
+
705
+ Removes the tail entry from ``_pending_messages`` and refreshes
706
+ the ``QueuedGoalsBar`` so the row disappears.
707
+ """
628
708
  if not self._pending_messages:
629
709
  return None
630
710
  msg = self._pending_messages.pop()
631
- if self._queued_widgets:
632
- widget = self._queued_widgets.pop()
633
- widget.remove()
634
- self._refresh_queued_goal_tips()
635
- else:
636
- logger.warning(
637
- "Queued-widget deque empty while pending-messages was not; widget/message tracking may be out of sync"
638
- )
711
+ self._refresh_queued_goal_tips()
639
712
  return msg
640
713
 
641
714
  def _restore_last_queued_goal_to_input(self) -> bool:
@@ -661,11 +734,10 @@ class _MessagesMixin:
661
734
  return True
662
735
 
663
736
  def _discard_queue(self) -> None:
664
- """Clear pending messages, deferred actions, and queued widgets."""
737
+ """Clear pending messages, deferred actions, and queued goals bar."""
665
738
  self._pending_messages.clear()
666
- for w in self._queued_widgets:
667
- w.remove()
668
739
  self._queued_widgets.clear()
740
+ self._refresh_queued_goal_tips()
669
741
  self._deferred_actions.clear()
670
742
 
671
743
  def _cancel_last_queued_message(self) -> bool:
@@ -687,6 +759,86 @@ class _MessagesMixin:
687
759
  return False
688
760
  return self._restore_last_queued_goal_to_input()
689
761
 
762
+ def cancel_queued_goal_at_index(self, index: int) -> bool:
763
+ """Cancel the queued goal at ``index`` without restoring it to input.
764
+
765
+ Args:
766
+ index: Zero-based index into ``_pending_messages`` (0 = head/oldest).
767
+
768
+ Returns:
769
+ ``True`` when a goal was cancelled, ``False`` when the index is
770
+ out of range.
771
+ """
772
+ if index < 0 or index >= len(self._pending_messages):
773
+ return False
774
+ # deque doesn't support del[] — convert to list, remove, rebuild.
775
+ items = list(self._pending_messages)
776
+ msg = items.pop(index)
777
+ self._pending_messages.clear()
778
+ self._pending_messages.extend(items)
779
+ self._refresh_queued_goal_tips()
780
+ self.notify(f"Cancelled queued goal: {msg.text[:60]}", timeout=2)
781
+ return True
782
+
783
+ def edit_queued_goal_at_index(self, index: int) -> bool:
784
+ """Move the queued goal at ``index`` into the chat input for editing.
785
+
786
+ Only normal-mode goals can be edited; shell/command goals are skipped
787
+ to avoid clobbering the input with a mode prefix.
788
+
789
+ Args:
790
+ index: Zero-based index into ``_pending_messages`` (0 = head/oldest).
791
+
792
+ Returns:
793
+ ``True`` when the goal was moved to input, ``False`` otherwise.
794
+ """
795
+ if index < 0 or index >= len(self._pending_messages):
796
+ return False
797
+ if self._has_pending_chat_input():
798
+ return False
799
+ items = list(self._pending_messages)
800
+ msg = items[index]
801
+ if msg.mode != "normal":
802
+ return False
803
+ items.pop(index)
804
+ self._pending_messages.clear()
805
+ self._pending_messages.extend(items)
806
+ if self._chat_input:
807
+ self._chat_input.value = msg.text
808
+ self._refresh_queued_goal_tips()
809
+ self.notify("Queued goal moved to input", timeout=2)
810
+ return True
811
+
812
+ def submit_queued_goal_at_index(self, index: int) -> bool:
813
+ """Submit the queued goal at ``index`` for immediate execution.
814
+
815
+ Pops the goal from the queue and processes it right away, bypassing
816
+ the normal FIFO order. Only valid when no agent/worker is already
817
+ running, since submitting while busy would just re-queue the goal.
818
+
819
+ Args:
820
+ index: Zero-based index into ``_pending_messages`` (0 = head/oldest).
821
+
822
+ Returns:
823
+ ``True`` when the goal was submitted, ``False`` when the index is
824
+ out of range or an agent is already running.
825
+ """
826
+ if index < 0 or index >= len(self._pending_messages):
827
+ return False
828
+ if self._agent_running or self._shell_running or self._processing_pending:
829
+ self.notify("Agent is busy — goal stays queued", timeout=2)
830
+ return False
831
+ items = list(self._pending_messages)
832
+ msg = items.pop(index)
833
+ self._pending_messages.clear()
834
+ self._pending_messages.extend(items)
835
+ self._refresh_queued_goal_tips()
836
+ import asyncio
837
+
838
+ asyncio.ensure_future(self._process_message(msg.text, msg.mode))
839
+ self.notify(f"Submitting: {msg.text[:60]}", timeout=2)
840
+ return True
841
+
690
842
  def _defer_action(self, action: DeferredAction) -> None:
691
843
  """Queue a deferred action, replacing any existing action of the same kind.
692
844
 
@@ -891,13 +1043,25 @@ class _MessagesMixin:
891
1043
 
892
1044
  # Close completion popup or exit slash/shell command mode
893
1045
  if self._chat_input:
894
- # When queue has pending goals and input is idle, Esc cancels the
895
- # latest queued normal goal (without interrupting current work).
1046
+ # When queue has pending goals and input is idle, Esc cancels a
1047
+ # queued normal goal (without interrupting current work). If the
1048
+ # QueuedGoalsBar is focused, cancel the selected goal; otherwise
1049
+ # cancel the latest (tail) goal.
896
1050
  if not self._has_pending_chat_input():
897
- queue_tail = self._pending_messages[-1] if self._pending_messages else None
898
- if queue_tail is not None and queue_tail.mode == "normal":
899
- if self._cancel_last_queued_message():
900
- return
1051
+ cancelled = False
1052
+ try:
1053
+ bar = self.query_one("#queued-goals", QueuedGoalsBar)
1054
+ except Exception:
1055
+ # NoMatches, ScreenStackError, or no app/screen in test stubs.
1056
+ bar = None
1057
+ if bar is not None and self.focused is bar:
1058
+ cancelled = bar.cancel_selected()
1059
+ if not cancelled:
1060
+ queue_tail = self._pending_messages[-1] if self._pending_messages else None
1061
+ if queue_tail is not None and queue_tail.mode == "normal":
1062
+ cancelled = self._cancel_last_queued_message()
1063
+ if cancelled:
1064
+ return
901
1065
  if self._chat_input.dismiss_completion():
902
1066
  return
903
1067
  if self._chat_input.exit_mode():
@@ -486,6 +486,7 @@ class _ModelMixin:
486
486
  # Clear conversation (similar to /clear, without creating a new loop)
487
487
  self._pending_messages.clear()
488
488
  self._queued_widgets.clear()
489
+ self._refresh_queued_goal_tips()
489
490
  await self._clear_messages()
490
491
  self._reset_loop_token_usage(None)
491
492
  self._update_status("")
@@ -646,9 +646,8 @@ class _StartupMixin:
646
646
  # Discard any messages queued while connecting
647
647
  if self._pending_messages:
648
648
  self._pending_messages.clear()
649
- for w in self._queued_widgets:
650
- w.remove()
651
649
  self._queued_widgets.clear()
650
+ self._refresh_queued_goal_tips()
652
651
  self._deferred_actions.clear()
653
652
 
654
653
  @staticmethod
@@ -20,6 +20,7 @@ from soothe_cli.runtime.token_events_debug import TokenEventTrace
20
20
  from soothe_cli.tui.widgets.loading import LoadingWidget
21
21
  from soothe_cli.tui.widgets.messages import AssistantMessage, UserMessage
22
22
  from soothe_cli.tui.widgets.pinned_goal_bar import PinnedGoalBar
23
+ from soothe_cli.tui.widgets.queued_goals_bar import QueuedGoalsBar
23
24
 
24
25
  logger = logging.getLogger(__name__)
25
26
  _monotonic = time.monotonic
@@ -520,6 +521,23 @@ class _UIMixin:
520
521
  # scroll position.
521
522
  self._sync_pinned_goal_visibility()
522
523
 
524
+ def action_focus_queued_goals(self) -> None:
525
+ """Focus the queued goals bar (Ctrl+q).
526
+
527
+ When the bar has queued goals, moves keyboard focus to it so the
528
+ user can navigate with arrow keys, edit with Enter, or cancel
529
+ with Esc. When the queue is empty, this is a no-op.
530
+ """
531
+ if self.screen.is_modal:
532
+ return
533
+ try:
534
+ bar = self.query_one("#queued-goals", QueuedGoalsBar)
535
+ except NoMatches:
536
+ return
537
+ if not bar.has_goals:
538
+ return
539
+ bar.activate()
540
+
523
541
  def _on_chat_scroll_y_changed(self, _old: float, _new: float) -> None:
524
542
  """React to chat scroll position changes to show/hide the pinned bar.
525
543
 
@@ -32,6 +32,18 @@ Screen {
32
32
  display: none;
33
33
  }
34
34
 
35
+ /* Queued goals bar — inside #bottom-app-container, above the thinking row
36
+ and chat input. Hidden when the queue is empty; shows all pending queued
37
+ goals as selectable rows. Not docked separately because Textual overlaps
38
+ multiple docked-to-same-edge widgets. Focus styling (background, border,
39
+ color, text-style) is defined in QueuedGoalsBar.DEFAULT_CSS. */
40
+ #queued-goals {
41
+ height: auto;
42
+ max-height: 40vh;
43
+ width: 100%;
44
+ display: none;
45
+ }
46
+
35
47
  /* Chat area - main scrollable messages area */
36
48
  #chat {
37
49
  height: 1fr;