soothe-cli 1.0.4__tar.gz → 1.0.6__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 (148) hide show
  1. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/.gitignore +0 -1
  2. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/PKG-INFO +1 -1
  3. soothe_cli-1.0.6/src/soothe_cli/display/card.py +158 -0
  4. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_app.py +15 -0
  5. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_clarification.py +5 -8
  6. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_execution.py +3 -16
  7. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_messages_mixin.py +192 -28
  8. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_model.py +1 -0
  9. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_startup.py +1 -2
  10. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_ui.py +18 -0
  11. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/app.tcss +12 -0
  12. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/file_change_preview.py +1 -1
  13. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -10
  14. soothe_cli-1.0.6/src/soothe_cli/tui/widgets/messages/_helpers.py +155 -0
  15. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/app.py +1 -1
  16. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/assistant.py +2 -4
  17. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +4 -4
  18. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +1 -1
  19. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/cognition_step.py +7 -5
  20. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +29 -29
  21. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/error.py +1 -1
  22. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/skill.py +1 -1
  23. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/structured_ask_user.py +33 -21
  24. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +5 -5
  25. soothe_cli-1.0.6/src/soothe_cli/tui/widgets/queued_goals_bar.py +384 -0
  26. soothe_cli-1.0.4/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -318
  27. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/README.md +0 -0
  28. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/pyproject.toml +0 -0
  29. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/__init__.py +0 -0
  30. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/_cli_context.py +0 -0
  31. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/_env_vars.py +0 -0
  32. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/_version.py +0 -0
  33. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/card_wire.py +0 -0
  34. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/__init__.py +0 -0
  35. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/__init__.py +0 -0
  36. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  37. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  38. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  39. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  40. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  41. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  42. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/__init__.py +0 -0
  43. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/daemon.py +0 -0
  44. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  45. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/headless.py +0 -0
  46. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  47. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/execution/launcher.py +0 -0
  48. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/main.py +0 -0
  49. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/cli/soothecli.py +0 -0
  50. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/__init__.py +0 -0
  51. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/binding.py +0 -0
  52. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/command_registry.py +0 -0
  53. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/command_router.py +0 -0
  54. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/slash_commands.py +0 -0
  55. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/commands/subagent_routing.py +0 -0
  56. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/config/__init__.py +0 -0
  57. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/config/cli_config.py +0 -0
  58. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/config/loader.py +0 -0
  59. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/config/logging_setup.py +0 -0
  60. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/__init__.py +0 -0
  61. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/markdown_theme.py +0 -0
  62. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/media_utils.py +0 -0
  63. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/mermaid_render.py +0 -0
  64. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/path_utils.py +0 -0
  65. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/preview_limits.py +0 -0
  66. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/shell_color.py +0 -0
  67. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/spinner_labels.py +0 -0
  68. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/theme.py +0 -0
  69. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/tips.py +0 -0
  70. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/tool_display.py +0 -0
  71. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/display/unicode_security.py +0 -0
  72. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/loops/__init__.py +0 -0
  73. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/loops/sessions.py +0 -0
  74. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/model_config.py +0 -0
  75. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/project_utils.py +0 -0
  76. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/__init__.py +0 -0
  77. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/headless/processor.py +0 -0
  78. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  79. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  80. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  81. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  82. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  83. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/id_format.py +0 -0
  84. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  85. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  86. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/step_id_format.py +0 -0
  87. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  88. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  89. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  90. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/state/step_router.py +0 -0
  91. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  92. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/token_events_debug.py +0 -0
  93. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/token_usage.py +0 -0
  94. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/transport/session.py +0 -0
  95. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  96. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  97. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  98. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/runtime/wire/messages.py +0 -0
  99. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/__init__.py +0 -0
  100. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/_console_impl.py +0 -0
  101. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/bootstrap.py +0 -0
  102. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/core.py +0 -0
  103. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/glyphs.py +0 -0
  104. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/provider.py +0 -0
  105. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/shell_allow.py +0 -0
  106. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/skills_dirs.py +0 -0
  107. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/settings/stream_config.py +0 -0
  108. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/__init__.py +0 -0
  109. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/__init__.py +0 -0
  110. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_entrypoints.py +0 -0
  111. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_history.py +0 -0
  112. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_model_params.py +0 -0
  113. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_terminal.py +0 -0
  114. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_theme_prefs.py +0 -0
  115. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/app/_types.py +0 -0
  116. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/composer_mode.py +0 -0
  117. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/file_change_notify.py +0 -0
  118. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  119. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/input.py +0 -0
  120. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/skills/__init__.py +0 -0
  121. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/skills/invocation.py +0 -0
  122. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/skills/load.py +0 -0
  123. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/textual_adapter.py +0 -0
  124. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  125. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/_links.py +0 -0
  126. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  127. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  128. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  129. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/context_data.py +0 -0
  130. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
  131. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/diff.py +0 -0
  132. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/editor.py +0 -0
  133. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  134. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/history.py +0 -0
  135. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/loading.py +0 -0
  136. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  137. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  138. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  139. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  140. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  141. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  142. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  143. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/pinned_goal_bar.py +0 -0
  144. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/router_profile_selector.py +0 -0
  145. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/status.py +0 -0
  146. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  147. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/src/soothe_cli/tui/widgets/welcome.py +0 -0
  148. {soothe_cli-1.0.4 → soothe_cli-1.0.6}/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.6
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
@@ -0,0 +1,158 @@
1
+ """Card prefix glyph, header assembly, and body gutter alignment shared by TUI card widgets.
2
+
3
+ These helpers encode card *appearance* (prefix tone, glyph prefix, gutter
4
+ alignment, header assembly). They are intentionally free of Textual widget
5
+ lifecycle concerns; refresh throttling and animation visibility stay in the
6
+ TUI layer (``tui/widgets/messages/_helpers.py``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from textual.content import Content
12
+
13
+ from soothe_cli.display import theme
14
+ from soothe_cli.display.preview_limits import STEP_TASK_CARD_COLLAPSE_LINE_THRESHOLD
15
+ from soothe_cli.display.tool_display import display_width
16
+ from soothe_cli.settings import get_glyphs
17
+
18
+ # Preview limits imported from preview_limits module
19
+ _STEP_TOOL_PREVIEW_ROWS = STEP_TASK_CARD_COLLAPSE_LINE_THRESHOLD
20
+ """Collapsed step/task activity preview shows this many rows."""
21
+
22
+
23
+ def _card_dot_tone(
24
+ status: str,
25
+ colors: theme.ThemeColors,
26
+ *,
27
+ accent: str | None = None,
28
+ spinner_position: int = 0,
29
+ animate_running: bool = False,
30
+ ) -> str:
31
+ """Return a Textual style string for a card prefix dot from lifecycle status."""
32
+ phase = (status or "pending").strip().lower()
33
+ if phase in ("success", "done", "completed"):
34
+ return colors.card_success
35
+ if phase in ("error", "failed", "interrupted"):
36
+ return colors.card_error
37
+ if phase == "running":
38
+ # Yellow flash on every other spinner tick; dim amber in between.
39
+ if animate_running and spinner_position % 2:
40
+ return colors.card_running_muted
41
+ return colors.card_running
42
+ if phase in ("queued", "pending", "continue", "replan"):
43
+ return colors.muted
44
+ if accent:
45
+ return accent
46
+ return colors.muted
47
+
48
+
49
+ def _card_dot_prefix_content(
50
+ widget: object,
51
+ status: str,
52
+ *,
53
+ accent: str | None = None,
54
+ glyph_override: str | None = None,
55
+ spinner_position: int = 0,
56
+ animate_running: bool = False,
57
+ ) -> Content:
58
+ """Build a stateful colored card-prefix glyph."""
59
+ try:
60
+ colors = theme.get_theme_colors(widget)
61
+ except Exception: # noqa: BLE001
62
+ colors = theme.DARK_COLORS
63
+ glyph = glyph_override or get_glyphs().tool_prefix
64
+ tone = _card_dot_tone(
65
+ status,
66
+ colors,
67
+ accent=accent,
68
+ spinner_position=spinner_position,
69
+ animate_running=animate_running,
70
+ )
71
+ return Content.styled(f"{glyph} ", tone)
72
+
73
+
74
+ def _card_prefix_width(glyph_override: str | None = None) -> int:
75
+ """Display width of the card header prefix (the dot/glyph plus one space).
76
+
77
+ Every body line and tree-branch line must left-align at the right side of
78
+ this prefix, so card content never extends left of the header text. In
79
+ ASCII mode the prefix is ``"[*] "`` (4 cols) while the tree gutter glyph
80
+ ``"L"`` is only 1 col; the body gutter is padded to close that gap.
81
+ """
82
+ glyph = glyph_override or get_glyphs().tool_prefix
83
+ return display_width(glyph) + 1 # +1 for the trailing space
84
+
85
+
86
+ def _card_body_gutter(glyph_override: str | None = None) -> str:
87
+ """Body/tree-branch gutter aligned to the right of the card prefix dot.
88
+
89
+ The tree glyph (``⎿`` / ``L``) stays at the left edge of the prefix column;
90
+ trailing spaces pad the gutter so its display width equals the header
91
+ prefix width (``glyph + " "``). That makes every card line and tree branch
92
+ start at the right side of the dot space.
93
+ """
94
+ g = get_glyphs()
95
+ prefix_width = _card_prefix_width(glyph_override)
96
+ glyph_w = display_width(g.output_prefix)
97
+ pad = max(0, prefix_width - glyph_w)
98
+ return f"{g.output_prefix}{' ' * pad}"
99
+
100
+
101
+ def _card_item_indent(
102
+ extra_indent: int = 0,
103
+ *,
104
+ glyph_override: str | None = None,
105
+ ) -> str:
106
+ """Plain-space indent for activity item rows (no tree glyph).
107
+
108
+ Item rows under a section (tool rows, todo items, notes, "+N more") sit at
109
+ the card header prefix width plus ``extra_indent`` columns — aligned with
110
+ the right side of the dot space so they never extend left of the title
111
+ text, and indented under the section's ``⎿`` label.
112
+ """
113
+ prefix_width = _card_prefix_width(glyph_override)
114
+ return f"{' ' * max(0, prefix_width + extra_indent)}"
115
+
116
+
117
+ def _assemble_card_header(
118
+ widget: object,
119
+ body_part: str,
120
+ *,
121
+ status: str = "running",
122
+ accent: str | None = None,
123
+ glyph_override: str | None = None,
124
+ spinner_position: int = 0,
125
+ animate_running: bool = False,
126
+ ) -> Content:
127
+ """Build a card title: stateful prefix glyph plus foreground body (no bold).
128
+
129
+ Used for Goal, Plan, Step, and tool (including Task) headers. The glyph color
130
+ reflects lifecycle status; body lines below use the tree gutter returned by
131
+ :func:`_card_body_gutter`, which aligns to the right of the card prefix dot.
132
+
133
+ Args:
134
+ widget: Mounted widget (or any object accepted by ``get_theme_colors``).
135
+ body_part: Header text (goal, step description, etc.).
136
+ status: Card lifecycle phase for glyph color/shape.
137
+ accent: Optional override tone for the glyph (e.g. skill/error accent).
138
+ spinner_position: Toggles gray flash while ``animate_running`` is true.
139
+ animate_running: Flash the glyph gray while running (step/task/assistant cards).
140
+
141
+ Returns:
142
+ Assembled ``Content`` for a ``Static`` header.
143
+ """
144
+ try:
145
+ colors = theme.get_theme_colors(widget)
146
+ except Exception: # noqa: BLE001
147
+ colors = theme.DARK_COLORS
148
+ return Content.assemble(
149
+ _card_dot_prefix_content(
150
+ widget,
151
+ status,
152
+ accent=accent,
153
+ glyph_override=glyph_override,
154
+ spinner_position=spinner_position,
155
+ animate_running=animate_running,
156
+ ),
157
+ Content.styled(body_part, colors.foreground),
158
+ )
@@ -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(
@@ -7,19 +7,16 @@ keep the execution mixin focused on agent execution and message routing.
7
7
  from __future__ import annotations
8
8
 
9
9
  _PLAN_REVIEW_ACTIONS = frozenset({"Approve", "Reject", "Refine"})
10
- _TOOL_APPROVAL_ACTIONS = frozenset({"Approve", "Reject", "Edit"})
10
+ _TOOL_APPROVAL_ACTIONS = frozenset({"Approve", "Reject"})
11
11
 
12
12
 
13
13
  def clarification_wire_content(answers: list[str], *, origin_node: str = "") -> str:
14
14
  """Human-readable turn content for a clarification submit (not a new goal).
15
15
 
16
- Option-selector actions use a stable `<prefix>: …` header so a dropped
17
- `clarification_answer` flag cannot turn a bare action into Pass1 TASK.
18
- Plan review → `Plan review:` (Refine carries refinement text in
19
- `answers[1]`). Tool approval → `Tool approval:` (Edit carries revised
20
- instructions in `answers[1]`). When `origin_node` is empty (legacy
21
- callers) the action label alone selects the prefix, preserving the safety
22
- net for any selector-origin submit.
16
+ Option-selector actions use a stable ``<prefix>: …`` header so a dropped
17
+ ``clarification_answer`` flag cannot turn a bare action into Pass1 TASK.
18
+ Plan review → ``Plan review:`` (Refine carries refinement text in
19
+ ``answers[1]``). Tool approval → ``Tool approval:``.
23
20
  """
24
21
  non_empty = [a for a in answers if str(a).strip()]
25
22
  if not non_empty:
@@ -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;
@@ -11,6 +11,7 @@ from textual.events import Click
11
11
  from textual.widgets import Static
12
12
 
13
13
  from soothe_cli.display import theme
14
+ from soothe_cli.display.card import _assemble_card_header
14
15
  from soothe_cli.display.preview_limits import (
15
16
  TOOL_APPROVAL_BODY_MAX_LINES,
16
17
  TOOL_APPROVAL_DIFF_WIDGET_MAX_LINES,
@@ -19,7 +20,6 @@ from soothe_cli.display.preview_limits import (
19
20
  from soothe_cli.settings import get_glyphs
20
21
  from soothe_cli.tui.widgets.clipboard import screen_has_text_selection
21
22
  from soothe_cli.tui.widgets.diff import DIFF_CODE_GAP, compose_diff_line_list
22
- from soothe_cli.tui.widgets.messages._helpers import _assemble_card_header
23
23
 
24
24
  if TYPE_CHECKING:
25
25
  from textual.app import ComposeResult