soothe-cli 1.0.1__tar.gz → 1.0.4__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 (145) hide show
  1. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/.gitignore +1 -0
  2. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/PKG-INFO +2 -2
  3. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/pyproject.toml +1 -1
  4. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/main.py +2 -2
  5. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/commands/binding.py +3 -3
  6. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/commands/slash_commands.py +1 -1
  7. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/config/cli_config.py +2 -2
  8. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/tips.py +1 -1
  9. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_app.py +17 -15
  10. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_execution.py +13 -0
  11. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_messages_mixin.py +25 -38
  12. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_startup.py +6 -1
  13. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_ui.py +12 -0
  14. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/composer_mode.py +22 -16
  15. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/textual_adapter.py +9 -6
  16. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/help_screen.py +1 -1
  17. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/loading.py +71 -1
  18. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +13 -2
  19. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/cognition_step.py +20 -2
  20. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/structured_ask_user.py +118 -57
  21. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +2 -2
  22. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/status.py +22 -65
  23. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/README.md +0 -0
  24. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/__init__.py +0 -0
  25. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/_cli_context.py +0 -0
  26. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/_env_vars.py +0 -0
  27. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/_version.py +0 -0
  28. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/card_wire.py +0 -0
  29. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/__init__.py +0 -0
  30. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/commands/__init__.py +0 -0
  31. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  32. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  33. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  34. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  35. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  36. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  37. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/execution/__init__.py +0 -0
  38. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/execution/daemon.py +0 -0
  39. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  40. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/execution/headless.py +0 -0
  41. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  42. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/execution/launcher.py +0 -0
  43. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/cli/soothecli.py +0 -0
  44. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/commands/__init__.py +0 -0
  45. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/commands/command_registry.py +0 -0
  46. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/commands/command_router.py +0 -0
  47. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/commands/subagent_routing.py +0 -0
  48. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/config/__init__.py +0 -0
  49. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/config/loader.py +0 -0
  50. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/config/logging_setup.py +0 -0
  51. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/__init__.py +0 -0
  52. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/markdown_theme.py +0 -0
  53. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/media_utils.py +0 -0
  54. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/mermaid_render.py +0 -0
  55. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/path_utils.py +0 -0
  56. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/preview_limits.py +0 -0
  57. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/shell_color.py +0 -0
  58. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/spinner_labels.py +0 -0
  59. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/theme.py +0 -0
  60. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/tool_display.py +0 -0
  61. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/display/unicode_security.py +0 -0
  62. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/loops/__init__.py +0 -0
  63. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/loops/sessions.py +0 -0
  64. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/model_config.py +0 -0
  65. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/project_utils.py +0 -0
  66. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/__init__.py +0 -0
  67. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/headless/processor.py +0 -0
  68. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  69. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  70. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  71. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  72. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  73. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/presentation/id_format.py +0 -0
  74. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  75. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  76. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/presentation/step_id_format.py +0 -0
  77. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  78. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  79. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  80. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/state/step_router.py +0 -0
  81. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  82. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/token_events_debug.py +0 -0
  83. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/token_usage.py +0 -0
  84. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/transport/session.py +0 -0
  85. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  86. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  87. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  88. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/runtime/wire/messages.py +0 -0
  89. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/settings/__init__.py +0 -0
  90. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/settings/_console_impl.py +0 -0
  91. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/settings/bootstrap.py +0 -0
  92. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/settings/core.py +0 -0
  93. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/settings/glyphs.py +0 -0
  94. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/settings/provider.py +0 -0
  95. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/settings/shell_allow.py +0 -0
  96. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/settings/skills_dirs.py +0 -0
  97. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/settings/stream_config.py +0 -0
  98. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/__init__.py +0 -0
  99. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/__init__.py +0 -0
  100. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_clarification.py +0 -0
  101. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_entrypoints.py +0 -0
  102. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_history.py +0 -0
  103. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_model.py +0 -0
  104. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_model_params.py +0 -0
  105. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_terminal.py +0 -0
  106. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_theme_prefs.py +0 -0
  107. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/_types.py +0 -0
  108. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/app/app.tcss +0 -0
  109. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/file_change_notify.py +0 -0
  110. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  111. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/input.py +0 -0
  112. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/skills/__init__.py +0 -0
  113. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/skills/invocation.py +0 -0
  114. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/skills/load.py +0 -0
  115. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  116. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/_links.py +0 -0
  117. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  118. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  119. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  120. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/context_data.py +0 -0
  121. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
  122. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/diff.py +0 -0
  123. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/editor.py +0 -0
  124. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  125. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/history.py +0 -0
  126. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  127. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  128. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  129. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  130. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  131. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  132. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  133. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  134. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
  135. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  136. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  137. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  138. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  139. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  140. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  141. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/pinned_goal_bar.py +0 -0
  142. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/router_profile_selector.py +0 -0
  143. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  144. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/tui/widgets/welcome.py +0 -0
  145. {soothe_cli-1.0.1 → soothe_cli-1.0.4}/src/soothe_cli/update_check.py +0 -0
@@ -235,3 +235,4 @@ __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.1
3
+ Version: 1.0.4
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
@@ -21,7 +21,7 @@ Requires-Dist: pillow<12.0.0,>=10.0.0
21
21
  Requires-Dist: python-dotenv<2.0.0,>=1.0.0
22
22
  Requires-Dist: pyyaml<7.0.0,>=6.0.0
23
23
  Requires-Dist: rich<15.0.0,>=13.0.0
24
- Requires-Dist: soothe-client-python<2.0.0,>=1.0.18
24
+ Requires-Dist: soothe-client-python<2.0.0,>=1.0.21
25
25
  Requires-Dist: soothe-sdk<2.0.0,>=1.0.12
26
26
  Requires-Dist: termaid<1.0.0,>=0.7.1
27
27
  Requires-Dist: textual<10.0.0,>=8.0.0
@@ -23,7 +23,7 @@ classifiers = [
23
23
  ]
24
24
  dependencies = [
25
25
  "soothe-sdk>=1.0.12,<2.0.0", # events/display/wire contracts
26
- "soothe-client-python>=1.0.18,<2.0.0", # WebSocket transport client; matched to latest release v1.0.18
26
+ "soothe-client-python>=1.0.21,<2.0.0", # WebSocket transport client; matched to latest release v1.0.21
27
27
  "typer>=0.9.0,<2.0.0", # CLI framework
28
28
  "textual>=8.0.0,<10.0.0", # TUI framework
29
29
  "rich>=13.0.0,<15.0.0", # Console output
@@ -198,10 +198,10 @@ def main(
198
198
  help=(
199
199
  "Auto-show the in-flow plan panel while a goal is executing "
200
200
  "and hide it when the goal completes. Ctrl+t toggles "
201
- "thereafter. Default: enabled."
201
+ "thereafter. Default: disabled (hidden)."
202
202
  ),
203
203
  ),
204
- ] = True,
204
+ ] = False,
205
205
  show_version: Annotated[ # noqa: FBT002
206
206
  bool,
207
207
  typer.Option("--version", help="Show version and exit."),
@@ -155,14 +155,14 @@ def message_to_widget(data: MessageData) -> Widget:
155
155
  is_tool_approval = origin == "tool_approval"
156
156
  refine_label = "Edit" if is_tool_approval else "Refine"
157
157
  question_spec = {
158
- "question": "Action for this plan: Approve, Refine, or Reject?"
158
+ "question": "Action for this plan: Approve, Reject, or Refine?"
159
159
  if not is_tool_approval
160
160
  else "Approve this tool call?",
161
161
  "header": "Plan review" if not is_tool_approval else "Tool approval",
162
162
  "options": [
163
163
  {"label": "Approve", "description": "Accept and proceed."},
164
- {"label": refine_label, "description": "Request changes."},
165
164
  {"label": "Reject", "description": "Reject."},
165
+ {"label": refine_label, "description": "Request changes."},
166
166
  ],
167
167
  }
168
168
  w = StructuredAskUserWidget(
@@ -261,7 +261,7 @@ def message_from_widget(widget: Widget) -> MessageData:
261
261
  # Awaiting state for HITL origins.
262
262
  if is_hitl and not widget._submitted: # noqa: SLF001
263
263
  if is_plan_review:
264
- content = "Plan review: awaiting Approve / Refine / Reject"
264
+ content = "Plan review: awaiting Approve / Reject / Refine"
265
265
  else:
266
266
  content = "Tool approval: awaiting Approve / Edit / Reject"
267
267
  return MessageData(type=MessageType.APP, content=content, id=widget_id)
@@ -154,7 +154,7 @@ KEYBOARD_SHORTCUTS: dict[str, str] = {
154
154
  "Ctrl+V": "Paste image from clipboard as [image N] attachment",
155
155
  "Ctrl+T": "Toggle plan panel above thinking row",
156
156
  "Ctrl+O": "Toggle expand/collapse of the most recent skill or tool card",
157
- "Shift+Tab": "Cycle composer mode (Auto → Manual → Plan → Ask)",
157
+ "Shift+Tab": "Cycle composer mode (Auto → Bypass → Manual → Plan → Ask)",
158
158
  }
159
159
 
160
160
 
@@ -33,9 +33,9 @@ class CLIConfig:
33
33
  """Markdown appearance preset (``match-app``, ``langchain``, ``standard``, …)."""
34
34
 
35
35
  # Plan panel visibility preference
36
- plan_panel_default_visible: bool = True
36
+ plan_panel_default_visible: bool = False
37
37
  """When True, auto-show the in-flow plan panel while a goal is executing and
38
- hide it when the goal completes. Default True (Ctrl+t to close)."""
38
+ hide it when the goal completes. Default False (hidden; Ctrl+t to open)."""
39
39
 
40
40
  # Output streaming overrides (RFC-614)
41
41
  output_streaming_enabled: bool | None = None
@@ -1,4 +1,4 @@
1
- """Rotating session tips for the status bar below the chat input."""
1
+ """Rotating session tips for the thinking row shown when the loop is idle."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -40,7 +40,7 @@ from soothe_cli.tui.app._types import (
40
40
  from soothe_cli.tui.app._ui import _UIMixin
41
41
  from soothe_cli.tui.composer_mode import normalize_composer_mode
42
42
  from soothe_cli.tui.widgets.chat_input import ChatInput
43
- from soothe_cli.tui.widgets.loading import LoadingWidget
43
+ from soothe_cli.tui.widgets.loading import LoadingWidget, TipRow
44
44
  from soothe_cli.tui.widgets.message_store import MessageStore
45
45
  from soothe_cli.tui.widgets.messages import (
46
46
  AssistantMessage,
@@ -226,10 +226,11 @@ class SootheApp(
226
226
  self._mcp_tool_count = sum(len(s.tools) for s in (mcp_server_info or []))
227
227
 
228
228
  self._status_bar: StatusBar | None = None
229
+ self._tip_row: TipRow | None = None
229
230
  self._default_session_tip: str = ""
230
231
  self._status_notification_timer: Timer | None = None
231
232
  self._status_notification_active = False
232
- # Rotating tip source + interval timer for the status footer.
233
+ # Rotating tip source + interval timer for the thinking row.
233
234
  self._tip_rotator: TipRotator = TipRotator()
234
235
  self._tip_rotation_timer: Timer | None = None
235
236
 
@@ -381,11 +382,11 @@ class SootheApp(
381
382
  """Persist and render the fallback tip shown when no notification is active."""
382
383
  cleaned = (tip or "").strip()
383
384
  self._default_session_tip = cleaned
384
- if self._status_bar is not None and not self._status_notification_active:
385
- self._status_bar.set_session_tip(cleaned)
385
+ if self._tip_row is not None and not self._status_notification_active:
386
+ self._tip_row.set_tip(cleaned)
386
387
 
387
388
  def start_tip_rotation(self, interval: float = 12.0) -> None:
388
- """Cycle through rotating tips in the status footer at a fixed interval.
389
+ """Cycle through rotating tips in the thinking row at a fixed interval.
389
390
 
390
391
  Args:
391
392
  interval: Seconds between tip rotations.
@@ -401,7 +402,7 @@ class SootheApp(
401
402
  self._tip_rotation_timer = None
402
403
 
403
404
  def _rotate_session_tip(self) -> None:
404
- """Advance to the next tip and push it to the status footer.
405
+ """Advance to the next tip and push it to the thinking row.
405
406
 
406
407
  Transient notifications take precedence: rotation is skipped while a
407
408
  notification is active so it can run its timeout uninterrupted.
@@ -411,7 +412,7 @@ class SootheApp(
411
412
  self.set_default_session_tip(self._tip_rotator.next_tip())
412
413
 
413
414
  def _set_status_notification(self, message: str, *, timeout: float | None = None) -> None:
414
- """Render a transient notification in the status-tip area."""
415
+ """Render a transient notification in the thinking-row tip area."""
415
416
  text = (message or "").strip()
416
417
  if not text:
417
418
  return
@@ -420,8 +421,8 @@ class SootheApp(
420
421
  self._status_notification_timer.stop()
421
422
  self._status_notification_timer = None
422
423
  self._status_notification_active = True
423
- if self._status_bar is not None:
424
- self._status_bar.set_notification_message(text)
424
+ if self._tip_row is not None:
425
+ self._tip_row.set_notification(text)
425
426
 
426
427
  duration = timeout if timeout and timeout > 0 else 3.0
427
428
  self._status_notification_timer = self.set_timer(duration, self._clear_status_notification)
@@ -430,8 +431,8 @@ class SootheApp(
430
431
  """Restore the default tip after transient notification timeout."""
431
432
  self._status_notification_timer = None
432
433
  self._status_notification_active = False
433
- if self._status_bar is not None:
434
- self._status_bar.set_session_tip(self._default_session_tip)
434
+ if self._tip_row is not None:
435
+ self._tip_row.set_tip(self._default_session_tip)
435
436
 
436
437
  def notify( # type: ignore[override]
437
438
  self,
@@ -442,7 +443,7 @@ class SootheApp(
442
443
  timeout: float | None = None,
443
444
  markup: bool = True, # noqa: ARG002
444
445
  ) -> None:
445
- """Display notifications in the status-tip area instead of toast bubbles."""
446
+ """Display notifications in the thinking-row tip area instead of toast bubbles."""
446
447
  text = str(message or "").strip()
447
448
  if not text:
448
449
  return
@@ -488,14 +489,15 @@ class SootheApp(
488
489
  # In-flow plan panel: sits above the sticky bottom chrome so Ctrl+t
489
490
  # never covers the thinking row or chat input. Auto-shows while a goal
490
491
  # is executing when CLIConfig.plan_panel_default_visible is True
491
- # (default); --no-plan-panel disables it.
492
- plan_visible = getattr(self._daemon_config, "plan_panel_default_visible", True)
492
+ # (--plan-panel); default is False (hidden, Ctrl+t to open).
493
+ plan_visible = getattr(self._daemon_config, "plan_panel_default_visible", False)
493
494
  yield PlanQuickViewOverlay(
494
495
  id="plan-quick-view-overlay",
495
496
  default_visible=plan_visible,
496
497
  )
497
498
  with Container(id="bottom-app-container"):
498
- yield Container(id="thinking-status")
499
+ with Container(id="thinking-status"):
500
+ yield TipRow(id="tip-row")
499
501
  yield ChatInput(
500
502
  cwd=self._cwd,
501
503
  image_tracker=self._image_tracker,
@@ -341,6 +341,10 @@ class _ExecutionMixin:
341
341
  # next pauses, which freezes scrolling and chat-input focus.
342
342
  await self._send_to_agent(payload_text)
343
343
 
344
+ # Return focus to the chat input so the user can type immediately.
345
+ with suppress(Exception):
346
+ self.focus_primary_input()
347
+
344
348
  async def _resolve_default_clarification_mode(self) -> str:
345
349
  """Fetch the daemon's default clarification mode for plan approval.
346
350
 
@@ -937,6 +941,10 @@ class _ExecutionMixin:
937
941
  )
938
942
  )
939
943
  return
944
+ except Exception as exc:
945
+ logger.warning("Unexpected error connecting to daemon", exc_info=True)
946
+ await self._mount_message(ErrorMessage(f"Unexpected connection error. {exc}"))
947
+ return
940
948
  self._agent_running = True
941
949
 
942
950
  # Stop passive reads so active turn streaming has sole websocket access.
@@ -1089,6 +1097,11 @@ class _ExecutionMixin:
1089
1097
  continue
1090
1098
  except (ConnectionError, OSError, TimeoutError):
1091
1099
  pass
1100
+ except Exception:
1101
+ logger.debug(
1102
+ "Unexpected error during daemon reconnect retry",
1103
+ exc_info=True,
1104
+ )
1092
1105
  raise
1093
1106
  except Exception as e: # Resilient tool rendering
1094
1107
  # Attach-only idle timeout is benign: the prior turn had already
@@ -793,20 +793,22 @@ class _MessagesMixin:
793
793
  def action_quit_or_interrupt(self) -> None:
794
794
  """Handle Ctrl+C — clear input, interrupt work, or exit on double-press.
795
795
 
796
- Priority order when a task is running (agent/shell):
797
- 1. If input has pending text, clear it (first Ctrl+C).
798
- 2. If input is empty, interrupt the running task.
799
- Interrupting a task resets the idle double-press exit state.
800
-
801
- When idle, Ctrl+C arms a double-press exit:
802
- 1. First press clears any pending draft and shows a hint.
803
- 2. Second press within the timeout window exits the TUI.
804
-
805
- Note: Copying selected text is bound to Ctrl+Y (`action_copy_selection`)
806
- so Ctrl+C is reserved for interrupt/exit behavior.
796
+ Priority order: HITL review card (abandon + focus chat) → running
797
+ task (clear input, then interrupt) → idle double-press exit.
798
+ Ctrl+Y copies selection, so Ctrl+C stays reserved for this path.
807
799
  """
808
800
  current_time = time.monotonic()
809
801
 
802
+ # HITL review card (plan/tool): abandon and return focus to chat.
803
+ if self._has_active_qa_widget():
804
+ widget = self._active_plan_review_widget()
805
+ if widget is not None and not getattr(widget, "_submitted", False):
806
+ widget._abandon()
807
+ self._ctrl_c_pressed_time = None
808
+ if self._chat_input:
809
+ self._chat_input.focus_input()
810
+ return
811
+
810
812
  # Check if input has pending content (text, mode, or completion).
811
813
  has_pending_input = self._has_pending_chat_input()
812
814
 
@@ -981,7 +983,7 @@ class _MessagesMixin:
981
983
  """Shift+Tab: navigate loop selector when active, otherwise cycle mode.
982
984
 
983
985
  - In the LoopSelectorScreen, defer to its filter navigation.
984
- - On the main screen, cycle composer mode Auto → Manual → Plan → Ask.
986
+ - On the main screen, cycle composer mode Auto → Bypass → Manual → Plan → Ask.
985
987
  """
986
988
  from soothe_cli.tui.widgets.loop_selector import LoopSelectorScreen
987
989
 
@@ -993,15 +995,9 @@ class _MessagesMixin:
993
995
  def cycle_composer_mode(self) -> None:
994
996
  """Advance composer mode and refresh the status-bar badge.
995
997
 
996
- The mode is held on the app (`self._composer_mode`). Auto/Manual map
997
- to the `clarification_mode` wire field; Plan sets
998
- `interaction_mode=plan` on subsequent turns. The badge updates
999
- immediately; no toast is emitted because the badge is the feedback.
1000
-
1001
- When a goal is running and the new mode is Auto or Manual, the mode is
1002
- also hot-swapped on the daemon's live `LoopRuntimeContext` via the
1003
- `loop_set_clarification_mode` RPC so the next clarification uses the
1004
- new policy without waiting for the next turn.
998
+ Cycle: Auto → Bypass → Manual → Plan → Ask → Auto. When a goal is
999
+ running and the new mode is Auto or Manual, hot-swaps the daemon's
1000
+ live clarification policy via `loop_set_clarification_mode`.
1005
1001
  """
1006
1002
  from soothe_cli.tui.composer_mode import next_composer_mode
1007
1003
 
@@ -1072,25 +1068,21 @@ class _MessagesMixin:
1072
1068
  inputs.append(inp)
1073
1069
  return inputs
1074
1070
 
1075
- def _active_plan_review_action_focus(self) -> Widget | None:
1076
- """Prefer the Approve button when a plan-review or tool-approval card is active."""
1071
+ def _active_plan_review_widget(self) -> Widget | None:
1072
+ """Return the active unsubmitted HITL review card, if any."""
1077
1073
  adapter = getattr(self, "_ui_adapter", None)
1078
1074
  if adapter is None:
1079
1075
  return None
1080
1076
  by_step = getattr(adapter, "_clarification_input_by_step", None) or {}
1081
- # Both option-selector origins render Approve / Refine(Edit) / Reject buttons.
1082
1077
  for message in by_step.values():
1083
1078
  if getattr(message, "_submitted", False):
1084
1079
  continue
1085
1080
  # String compare — avoid MagicMock truthiness on property access in tests.
1086
- if getattr(message, "_origin_node", None) not in ("plan_mode_review", "tool_approval"):
1087
- continue
1088
- buttons = getattr(message, "_action_buttons", None) or {}
1089
- if not isinstance(buttons, dict):
1090
- continue
1091
- approve = buttons.get("approve")
1092
- if approve is not None and not getattr(approve, "disabled", False):
1093
- return approve
1081
+ if getattr(message, "_origin_node", None) in (
1082
+ "plan_mode_review",
1083
+ "tool_approval",
1084
+ ):
1085
+ return message
1094
1086
  return None
1095
1087
 
1096
1088
  def _non_chat_focusable_inputs(self) -> list[Input]:
@@ -1107,18 +1099,13 @@ class _MessagesMixin:
1107
1099
  """Return the single input that should receive typing focus, if unambiguous.
1108
1100
 
1109
1101
  When an inline clarification card is active, its answer field takes
1110
- precedence over the bottom chat prompt. Planner-subagent review
1111
- focuses the Approve action. On modal screens, a lone
1102
+ precedence over the bottom chat prompt. On modal screens, a lone
1112
1103
  filter box is focused automatically.
1113
1104
  """
1114
1105
  clar_inputs = self._active_clarification_inputs()
1115
1106
  if clar_inputs:
1116
1107
  return clar_inputs[0]
1117
1108
 
1118
- plan_action = self._active_plan_review_action_focus()
1119
- if plan_action is not None:
1120
- return plan_action
1121
-
1122
1109
  non_chat = self._non_chat_focusable_inputs()
1123
1110
  if len(non_chat) == 1:
1124
1111
  return non_chat[0]
@@ -24,6 +24,7 @@ from soothe_cli.tui.app._types import (
24
24
  TextualSessionState,
25
25
  )
26
26
  from soothe_cli.tui.widgets.chat_input import ChatInput
27
+ from soothe_cli.tui.widgets.loading import TipRow
27
28
  from soothe_cli.tui.widgets.messages import (
28
29
  AppMessage,
29
30
  ErrorMessage,
@@ -129,6 +130,7 @@ class _StartupMixin:
129
130
  self.watch(chat, "scroll_y", self._on_chat_scroll_y_changed)
130
131
 
131
132
  self._status_bar = self.query_one("#status-bar", StatusBar)
133
+ self._tip_row = self.query_one("#tip-row", TipRow)
132
134
  self._chat_input = self.query_one("#input-area", ChatInput)
133
135
  with suppress(NoMatches):
134
136
  from soothe_cli.tui.widgets.plan_quick_view_overlay import PlanQuickViewOverlay
@@ -142,8 +144,11 @@ class _StartupMixin:
142
144
  # hard-coded "auto" fallback before the daemon-default seed runs).
143
145
  self._status_bar.set_clarification_mode(self._composer_mode)
144
146
 
145
- # Seed the status footer with the first rotating tip and start rotation.
147
+ # Seed the thinking-row tip with the first rotating tip and start rotation.
148
+ # The tip row is visible while the loop is idle; `_set_spinner` hides it
149
+ # when the agent starts working and restores it when the loop goes idle.
146
150
  self.set_default_session_tip(self._tip_rotator.next_tip())
151
+ self._show_tip_row()
147
152
  self.start_tip_rotation()
148
153
 
149
154
  # Focus the input immediately so the cursor is visible on first paint
@@ -421,8 +421,10 @@ class _UIMixin:
421
421
  await self._loading_widget.remove()
422
422
  self._loading_widget = None
423
423
  self._connect_spinner_start_mono = None
424
+ self._show_tip_row()
424
425
  return
425
426
 
427
+ self._hide_tip_row()
426
428
  thinking_status = self.query_one("#thinking-status", Container)
427
429
 
428
430
  connect_start_mono: float | None = None
@@ -481,6 +483,16 @@ class _UIMixin:
481
483
  if self._loading_widget is not None:
482
484
  self._loading_widget.resume()
483
485
 
486
+ def _show_tip_row(self) -> None:
487
+ """Show the rotating tip in the thinking row when the loop is idle."""
488
+ if self._tip_row is not None:
489
+ self._tip_row.display = True
490
+
491
+ def _hide_tip_row(self) -> None:
492
+ """Hide the tip row so the thinking spinner gets the full row."""
493
+ if self._tip_row is not None:
494
+ self._tip_row.display = False
495
+
484
496
  def action_toggle_plan_quick_view(self) -> None:
485
497
  """Expand or collapse the in-flow plan panel above the thinking row."""
486
498
  if self.screen.is_modal:
@@ -1,4 +1,8 @@
1
- """Sticky TUI composer modes: Auto / Manual / Plan / Ask."""
1
+ """TUI composer modes — working-mode hierarchy.
2
+
3
+ Agent sub-modes (auto, bypass, manual) grouped first, then plan and ask.
4
+ Shift+Tab cycle: Auto → Bypass → Manual → Plan → Ask → Auto.
5
+ """
2
6
 
3
7
  from __future__ import annotations
4
8
 
@@ -8,15 +12,25 @@ COMPOSER_MODE_AUTO = "auto"
8
12
  COMPOSER_MODE_MANUAL = "manual"
9
13
  COMPOSER_MODE_PLAN = "plan"
10
14
  COMPOSER_MODE_ASK = "ask"
15
+ COMPOSER_MODE_BYPASS = "bypass"
11
16
 
17
+ # Agent sub-modes grouped first, then standalone working modes.
12
18
  COMPOSER_MODE_ORDER: tuple[str, ...] = (
13
19
  COMPOSER_MODE_AUTO,
20
+ COMPOSER_MODE_BYPASS,
14
21
  COMPOSER_MODE_MANUAL,
15
22
  COMPOSER_MODE_PLAN,
16
23
  COMPOSER_MODE_ASK,
17
24
  )
18
25
  VALID_COMPOSER_MODES: frozenset[str] = frozenset(COMPOSER_MODE_ORDER)
19
26
 
27
+ # Agent working mode sub-modes (full mutating tool surface).
28
+ AGENT_SUB_MODES: frozenset[str] = frozenset(
29
+ {COMPOSER_MODE_AUTO, COMPOSER_MODE_MANUAL, COMPOSER_MODE_BYPASS}
30
+ )
31
+ # Standalone working modes (not agent sub-modes).
32
+ STANDALONE_WORKING_MODES: frozenset[str] = frozenset({COMPOSER_MODE_PLAN, COMPOSER_MODE_ASK})
33
+
20
34
 
21
35
  @dataclass(frozen=True)
22
36
  class ComposerWireFields:
@@ -35,11 +49,7 @@ def normalize_composer_mode(mode: str | None) -> str:
35
49
 
36
50
 
37
51
  def next_composer_mode(current: str) -> str:
38
- """Advance Auto → Manual → Plan → Ask → Auto.
39
-
40
- Unknown values normalize to `auto` (the default, same as a first
41
- Shift+Tab from a garbage seed), without advancing past Auto in that step.
42
- """
52
+ """Advance to the next mode in the cycle. Unknown values reset to `auto`."""
43
53
  if current not in VALID_COMPOSER_MODES:
44
54
  return COMPOSER_MODE_AUTO
45
55
  idx = COMPOSER_MODE_ORDER.index(current)
@@ -47,23 +57,19 @@ def next_composer_mode(current: str) -> str:
47
57
 
48
58
 
49
59
  def resolve_composer_wire_fields(mode: str) -> ComposerWireFields:
50
- """Map composer mode to wire `clarification_mode`, sticky subagent, and
51
- `interaction_mode`.
52
-
53
- Args:
54
- mode: Composer mode (`auto`, `manual`, `plan`, or `ask`).
60
+ """Map composer mode to wire fields.
55
61
 
56
- Returns:
57
- A :class:`ComposerWireFields`. Plan mode sends `clarification_mode=auto`
58
- plus `interaction_mode=plan` (read-only plan graph); ask mode sends
59
- `clarification_mode=auto` plus `interaction_mode=ask`. Slash routing
60
- in the message still wins over the sticky hint at send time.
62
+ auto → clarification=auto, interaction=None; manual → clarification=manual;
63
+ bypass → clarification=auto, interaction=bypass; plan → interaction=plan;
64
+ ask → interaction=ask. Slash routing in the message wins over the sticky hint.
61
65
  """
62
66
  normalized = normalize_composer_mode(mode)
63
67
  if normalized == COMPOSER_MODE_PLAN:
64
68
  return ComposerWireFields(COMPOSER_MODE_AUTO, None, "plan")
65
69
  if normalized == COMPOSER_MODE_ASK:
66
70
  return ComposerWireFields(COMPOSER_MODE_AUTO, None, COMPOSER_MODE_ASK)
71
+ if normalized == COMPOSER_MODE_BYPASS:
72
+ return ComposerWireFields(COMPOSER_MODE_AUTO, None, COMPOSER_MODE_BYPASS)
67
73
  if normalized == COMPOSER_MODE_MANUAL:
68
74
  return ComposerWireFields(COMPOSER_MODE_MANUAL, None, None)
69
75
  return ComposerWireFields(COMPOSER_MODE_AUTO, None, None)
@@ -4121,7 +4121,15 @@ async def execute_task_textual(
4121
4121
  goal_loop_start_monotonic=goal_loop_start_monotonic,
4122
4122
  turn_start_monotonic=start_time,
4123
4123
  )
4124
- if adapter._goal_tree_message is not None:
4124
+ follow_on = (
4125
+ data.get("follow_on_exec")
4126
+ if isinstance(data, dict)
4127
+ else None
4128
+ )
4129
+ # Skip the terminal footer when a follow-on exec
4130
+ # goal is pending: the daemon immediately enqueues
4131
+ # it, so showing "Done" here is misleading.
4132
+ if adapter._goal_tree_message is not None and not follow_on:
4125
4133
  goal_elapsed_start = _goal_loop_elapsed_start(
4126
4134
  goal_loop_start_monotonic=goal_loop_start_monotonic,
4127
4135
  turn_start_monotonic=start_time,
@@ -4164,11 +4172,6 @@ async def execute_task_textual(
4164
4172
  # row stays active through the plan→exec transition;
4165
4173
  # the exec goal's ``STRANGE_LOOP_STARTED`` will
4166
4174
  # re-anchor and replace it.
4167
- follow_on = (
4168
- data.get("follow_on_exec")
4169
- if isinstance(data, dict)
4170
- else None
4171
- )
4172
4175
  if adapter._set_spinner and not clarification_pending:
4173
4176
  if follow_on:
4174
4177
  adapter._plan_approve_follow_on_pending = True
@@ -69,7 +69,7 @@ def build_keyboard_shortcut_rows() -> list[tuple[str, str]]:
69
69
  "Toggle plan panel (shown by default; --no-plan-panel disables auto-show)",
70
70
  ),
71
71
  ("Ctrl+O", "Toggle expand/collapse of the most recent skill or tool card"),
72
- ("Shift+Tab", "Cycle composer mode (Auto → Manual → Plan → Ask)"),
72
+ ("Shift+Tab", "Cycle composer mode (Auto → Bypass → Manual → Plan → Ask)"),
73
73
  ("@filename", "Autocomplete files and inject content"),
74
74
  ("/command", "Slash commands (e.g. /help, /clear, /quit)"),
75
75
  ("!command", "Run shell commands directly"),
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
6
  from time import monotonic
7
- from typing import TYPE_CHECKING
7
+ from typing import TYPE_CHECKING, Any
8
8
 
9
9
  from textual.content import Content
10
10
  from textual.widgets import Static
@@ -305,3 +305,73 @@ class LoadingWidget(Static):
305
305
  def stop(self) -> None:
306
306
  """Stop the animation (widget will be removed by caller)."""
307
307
  self._stop_timer()
308
+
309
+
310
+ class TipRow(Static):
311
+ """Rotating tip / transient notice shown in the thinking row when idle.
312
+
313
+ Sits in the same ``#thinking-status`` container as :class:`LoadingWidget`.
314
+ When the loop is inactive (no spinner) the tip is visible; when the loop
315
+ is active the spinner replaces it and this row is hidden by the caller.
316
+ """
317
+
318
+ DEFAULT_CSS = """
319
+ TipRow {
320
+ height: 1;
321
+ padding: 0 1;
322
+ margin-top: 1;
323
+ }
324
+
325
+ TipRow.notification {
326
+ color: $warning;
327
+ text-style: bold;
328
+ }
329
+ """
330
+
331
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
332
+ """Initialize the tip row with no content."""
333
+ super().__init__(*args, **kwargs)
334
+ self._is_notification = False
335
+ self._tip_text: str = ""
336
+
337
+ def _render_tip(self, text: str) -> Content:
338
+ colors = theme.get_theme_colors(self)
339
+ prefix = Content.styled("💡 ", colors.muted)
340
+ body = Content.styled(text, colors.muted)
341
+ return Content.assemble(prefix, body)
342
+
343
+ def _render_notice(self, text: str) -> Content:
344
+ colors = theme.get_theme_colors(self)
345
+ prefix = Content.styled("⚠ ", colors.warning)
346
+ body = Content.styled(text, colors.warning)
347
+ return Content.assemble(prefix, body)
348
+
349
+ def _refresh_content(self) -> None:
350
+ """Re-render the stored tip/notice text, styled only when mounted."""
351
+ if not self.is_mounted:
352
+ return
353
+ text = self._tip_text
354
+ if not text:
355
+ self.update("")
356
+ elif self._is_notification:
357
+ self.update(self._render_notice(text))
358
+ else:
359
+ self.update(self._render_tip(text))
360
+
361
+ def on_mount(self) -> None:
362
+ """Render the initial tip content once the widget is in the DOM."""
363
+ self._refresh_content()
364
+
365
+ def set_tip(self, text: str) -> None:
366
+ """Show a default rotating tip (muted, non-bold)."""
367
+ self._is_notification = False
368
+ self.remove_class("notification")
369
+ self._tip_text = (text or "").strip()
370
+ self._refresh_content()
371
+
372
+ def set_notification(self, text: str) -> None:
373
+ """Show a transient notice (warning-colored, bold)."""
374
+ self._is_notification = True
375
+ self.add_class("notification")
376
+ self._tip_text = (text or "").strip()
377
+ self._refresh_content()
@@ -343,9 +343,20 @@ class CognitionGoalTreeMessage(Vertical):
343
343
  return f"↑{format_token_count(total_in)} ↓{format_token_count(total_out)}"
344
344
 
345
345
  def mark_loop_started(self, started_at: float | None = None) -> None:
346
- """Anchor plan-level elapsed time (matches thinking-row turn start)."""
347
- if self._loop_started_at is None:
346
+ """Anchor plan-level elapsed time (matches thinking-row turn start).
347
+
348
+ On reuse after a terminal footer (plan-approve → exec follow-on),
349
+ clear the stale footer + step rows so the panel returns to live state.
350
+ """
351
+ if self._loop_started_at is None or self._footer_visible:
348
352
  self._loop_started_at = started_at if started_at is not None else time()
353
+ if self._footer_visible:
354
+ self._footer_visible = False
355
+ self._footer_plain = ""
356
+ self._footer_tone = "muted"
357
+ self._step_order.clear()
358
+ self._steps.clear()
359
+ self._sync_goal_tree_widgets()
349
360
 
350
361
  def _indent_prefix(self) -> str:
351
362
  """Body gutter aligned to the right of the goal header prefix dot.