soothe-cli 0.9.6__tar.gz → 0.9.7__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 (133) hide show
  1. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/PKG-INFO +4 -3
  2. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/pyproject.toml +3 -2
  3. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/commands/loop_cmd.py +38 -2
  4. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/commands/status_cmd.py +9 -1
  5. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/execution/headless_renderer.py +3 -0
  6. soothe_cli-0.9.7/src/soothe_cli/cli/execution/launcher.py +130 -0
  7. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/main.py +11 -0
  8. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/config/cli_config.py +5 -0
  9. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/state/step_router.py +20 -2
  10. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/app/_app.py +30 -0
  11. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/app/_execution.py +47 -9
  12. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/app/_history.py +32 -138
  13. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/app/_messages_mixin.py +235 -5
  14. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/app/_startup.py +19 -5
  15. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/app/app.tcss +3 -2
  16. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/binding.py +16 -0
  17. soothe_cli-0.9.7/src/soothe_cli/tui/card_wire.py +24 -0
  18. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/commands/command_router.py +3 -3
  19. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/commands/subagent_routing.py +20 -9
  20. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/markdown_theme.py +109 -29
  21. soothe_cli-0.9.7/src/soothe_cli/tui/mermaid_render.py +96 -0
  22. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/preview_limits.py +4 -1
  23. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/textual_adapter.py +404 -385
  24. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/theme.py +6 -0
  25. soothe_cli-0.9.7/src/soothe_cli/tui/tips.py +84 -0
  26. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/tool_display.py +2 -5
  27. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/file_change_preview.py +1 -1
  28. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/loading.py +8 -6
  29. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -2
  30. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/app.py +2 -2
  31. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/assistant.py +59 -15
  32. soothe_cli-0.9.7/src/soothe_cli/tui/widgets/messages/clarification.py +548 -0
  33. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +7 -8
  34. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +1 -1
  35. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/cognition_step.py +30 -30
  36. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +139 -51
  37. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +28 -44
  38. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/diff_message.py +1 -1
  39. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/error.py +1 -1
  40. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/skill.py +1 -1
  41. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/status.py +2 -0
  42. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/welcome.py +0 -7
  43. soothe_cli-0.9.6/src/soothe_cli/cli/execution/launcher.py +0 -46
  44. soothe_cli-0.9.6/src/soothe_cli/runtime/policy/essential_events.py +0 -68
  45. soothe_cli-0.9.6/src/soothe_cli/tui/tips.py +0 -27
  46. soothe_cli-0.9.6/src/soothe_cli/tui/widgets/messages/clarification.py +0 -224
  47. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/.gitignore +0 -0
  48. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/README.md +0 -0
  49. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/__init__.py +0 -0
  50. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/__init__.py +0 -0
  51. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/commands/__init__.py +0 -0
  52. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  53. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  54. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
  55. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  56. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/execution/__init__.py +0 -0
  57. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/execution/daemon.py +0 -0
  58. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  59. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/cli/execution/headless.py +0 -0
  60. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/config/__init__.py +0 -0
  61. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/config/loader.py +0 -0
  62. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/config/logging_setup.py +0 -0
  63. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/__init__.py +0 -0
  64. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/headless/processor.py +0 -0
  65. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  66. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  67. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  68. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  69. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  70. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  71. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/presentation/id_format.py +0 -0
  72. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  73. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  74. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  75. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  76. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  77. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  78. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/token_events_debug.py +0 -0
  79. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/token_usage.py +0 -0
  80. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/transport/session.py +0 -0
  81. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  82. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  83. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  84. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/runtime/wire/messages.py +0 -0
  85. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/__init__.py +0 -0
  86. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/_cli_context.py +0 -0
  87. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/_env_vars.py +0 -0
  88. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/_version.py +0 -0
  89. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/app/__init__.py +0 -0
  90. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/app/_model.py +0 -0
  91. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/app/_module_init.py +0 -0
  92. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/app/_ui.py +0 -0
  93. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/command_registry.py +0 -0
  94. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/commands/__init__.py +0 -0
  95. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  96. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/config.py +0 -0
  97. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/file_change_notify.py +0 -0
  98. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  99. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/hooks.py +0 -0
  100. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/input.py +0 -0
  101. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/media_utils.py +0 -0
  102. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/model_config.py +0 -0
  103. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/path_utils.py +0 -0
  104. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/project_utils.py +0 -0
  105. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/sessions.py +0 -0
  106. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/shell_color.py +0 -0
  107. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/skills/__init__.py +0 -0
  108. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/skills/invocation.py +0 -0
  109. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/skills/load.py +0 -0
  110. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/spinner_labels.py +0 -0
  111. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/unicode_security.py +0 -0
  112. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/update_check.py +0 -0
  113. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  114. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/_links.py +0 -0
  115. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  116. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/chat_input.py +2 -2
  117. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  118. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/context_data.py +0 -0
  119. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
  120. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/diff.py +0 -0
  121. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/editor.py +0 -0
  122. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
  123. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/history.py +0 -0
  124. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  125. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  126. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  127. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  128. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  129. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  130. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  131. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +0 -0
  132. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/router_profile_selector.py +0 -0
  133. {soothe_cli-0.9.6 → soothe_cli-0.9.7}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.9.6
3
+ Version: 0.9.7
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
@@ -22,8 +22,9 @@ Requires-Dist: pillow<12.0.0,>=10.0.0
22
22
  Requires-Dist: python-dotenv<2.0.0,>=1.0.0
23
23
  Requires-Dist: pyyaml<7.0.0,>=6.0.0
24
24
  Requires-Dist: rich<15.0.0,>=13.0.0
25
- Requires-Dist: soothe-client-python<2.0.0,>=1.0.4
26
- Requires-Dist: soothe-sdk<2.0.0,>=1.0.2
25
+ Requires-Dist: soothe-client-python<2.0.0,>=1.0.5
26
+ Requires-Dist: soothe-sdk<2.0.0,>=1.0.7
27
+ Requires-Dist: termaid<1.0.0,>=0.7.1
27
28
  Requires-Dist: textual<10.0.0,>=8.0.0
28
29
  Requires-Dist: typer<2.0.0,>=0.9.0
29
30
  Requires-Dist: websockets<16.0,>=12.0
@@ -23,11 +23,12 @@ classifiers = [
23
23
  "Topic :: Software Development :: Libraries :: Python Modules",
24
24
  ]
25
25
  dependencies = [
26
- "soothe-sdk>=1.0.2,<2.0.0", # events/display/wire contracts
27
- "soothe-client-python>=1.0.4,<2.0.0", # WebSocket transport client
26
+ "soothe-sdk>=1.0.7,<2.0.0", # events/display/wire contracts
27
+ "soothe-client-python>=1.0.5,<2.0.0", # WebSocket transport client
28
28
  "typer>=0.9.0,<2.0.0", # CLI framework
29
29
  "textual>=8.0.0,<10.0.0", # TUI framework
30
30
  "rich>=13.0.0,<15.0.0", # Console output
31
+ "termaid>=0.7.1,<1.0.0", # Mermaid → terminal diagram (goal-completion reports)
31
32
  "pyyaml>=6.0.0,<7.0.0", # Config loading
32
33
  "python-dotenv>=1.0.0,<2.0.0", # .env loading
33
34
  "websockets>=12.0,<16.0", # WebSocket transport
@@ -9,6 +9,7 @@ All loop operations use daemon WebSocket RPC; the daemon must be running.
9
9
  from __future__ import annotations
10
10
 
11
11
  import asyncio
12
+ import logging
12
13
  import sys
13
14
  from typing import Annotated, Any
14
15
 
@@ -26,6 +27,7 @@ from soothe_client import (
26
27
  from soothe_cli.runtime import load_config
27
28
 
28
29
  console = Console()
30
+ logger = logging.getLogger(__name__)
29
31
 
30
32
  # Create loop command group
31
33
  loop_app = typer.Typer(help="Manage StrangeLoop instances")
@@ -636,11 +638,19 @@ def continue_loop(
636
638
  str | None,
637
639
  typer.Option("--prompt", "-p", help="Optional prompt to send after continuing."),
638
640
  ] = None,
641
+ resume: Annotated[
642
+ bool,
643
+ typer.Option(
644
+ "--resume",
645
+ help="Resume from the daemon's last execution step index.",
646
+ ),
647
+ ] = False,
639
648
  ) -> None:
640
649
  """Continue execution on existing loop.
641
650
 
642
651
  Behavior:
643
652
  - Resolve target loop (explicit `LOOP_ID` or most-recent loop)
653
+ - When resuming, fetch execution state (iteration/step/status) from daemon
644
654
  - Launch TUI on that loop
645
655
  - Optionally submit initial prompt in the resumed session
646
656
 
@@ -648,6 +658,7 @@ def continue_loop(
648
658
  soothe loop continue
649
659
  soothe loop continue loop_abc123
650
660
  soothe loop continue loop_abc123 --prompt "translate to chinese"
661
+ soothe loop continue loop_abc123 --resume
651
662
  """
652
663
  config = load_config()
653
664
  ws_url = websocket_url_from_config(config)
@@ -658,6 +669,31 @@ def continue_loop(
658
669
  if loop_id:
659
670
  console.print(f"[info]Continuing loop: {resolved_loop_id}[/info]")
660
671
 
672
+ # Fetch execution state to show where the loop will pick up.
673
+ # Best-effort: non-fatal if the daemon cannot answer (older daemon, etc.)
674
+ if resume:
675
+ try:
676
+ state = asyncio.run(
677
+ protocol1_rpc(
678
+ ws_url,
679
+ "loop_execution_state_fetch",
680
+ {"loop_id": resolved_loop_id},
681
+ )
682
+ )
683
+ if "error" not in state:
684
+ step_index = state.get("step_index", 0)
685
+ iteration = state.get("iteration", 0)
686
+ status = state.get("status", "unknown")
687
+ plan = state.get("plan")
688
+ console.print(
689
+ f"[info]Resuming from iteration {iteration}, "
690
+ f"step {step_index} ({status})[/info]"
691
+ )
692
+ if plan:
693
+ console.print(f"[dim] Plan: {plan}[/dim]")
694
+ except Exception:
695
+ logger.warning("Failed to fetch execution state for loop %s", resolved_loop_id[:16])
696
+
661
697
  from soothe_cli.cli.commands.run_cmd import run_impl
662
698
 
663
699
  run_impl(
@@ -677,8 +713,8 @@ def resume_loop(
677
713
  typer.Option("--prompt", "-p", help="Optional prompt to send after continuing."),
678
714
  ] = None,
679
715
  ) -> None:
680
- """Alias for ``continue`` — resume execution on existing loop."""
681
- continue_loop(loop_id, prompt)
716
+ """Resume execution on existing loop from the daemon's last step index."""
717
+ continue_loop(loop_id, prompt, resume=True)
682
718
 
683
719
 
684
720
  @loop_app.command("detach")
@@ -58,12 +58,13 @@ def _render_unified_status_table(
58
58
  daemon_live: bool = True,
59
59
  daemon_version: str | None = None,
60
60
  core_version: str | None = None,
61
+ started_at: str | None = None,
61
62
  ) -> Table:
62
63
  """Render unified status table without duplicated info.
63
64
 
64
65
  Sections:
65
66
  - Connection: WebSocket URL, Soothe Home
66
- - Daemon: Running, Threads, PID, Versions (only when daemon is live)
67
+ - Daemon: Running, Threads, PID, Versions, Started (only when daemon is live)
67
68
  """
68
69
  table = Table(title="Soothe Status")
69
70
  table.add_column("Section", style="dim", width=12)
@@ -82,6 +83,8 @@ def _render_unified_status_table(
82
83
  table.add_row("Daemon", "Status", "[green]Running[/green]")
83
84
  if daemon_pid:
84
85
  table.add_row("", "PID", str(daemon_pid))
86
+ if started_at:
87
+ table.add_row("", "Started", started_at)
85
88
  if active_threads is not None:
86
89
  table.add_row("", "Active Threads", str(active_threads))
87
90
  if daemon_version:
@@ -176,6 +179,7 @@ def daemon_status(
176
179
  "port_live": status.get("port_live", True),
177
180
  "active_threads": status.get("active_threads", 0),
178
181
  "daemon_pid": status.get("daemon_pid"),
182
+ "started_at": status.get("started_at"),
179
183
  "daemon_version": status.get("daemon_version"),
180
184
  "core_version": status.get("core_version"),
181
185
  "readiness_state": status.get("readiness_state", "unknown"),
@@ -192,6 +196,7 @@ def daemon_status(
192
196
  daemon_pid = status.get("daemon_pid")
193
197
  daemon_version = status.get("daemon_version")
194
198
  core_version = status.get("core_version")
199
+ started_at = status.get("started_at")
195
200
  # Use readiness_state from daemon_status RPC (already includes state + message)
196
201
  readiness_state_from_status = (
197
202
  {
@@ -213,6 +218,7 @@ def daemon_status(
213
218
  daemon_live=True,
214
219
  daemon_version=daemon_version,
215
220
  core_version=core_version,
221
+ started_at=started_at,
216
222
  )
217
223
  console.print(table)
218
224
 
@@ -302,6 +308,7 @@ def status_main(
302
308
  output["daemon"]["port_live"] = status.get("port_live", True)
303
309
  output["daemon"]["active_threads"] = status.get("active_threads", 0)
304
310
  output["daemon"]["daemon_pid"] = status.get("daemon_pid")
311
+ output["daemon"]["started_at"] = status.get("started_at")
305
312
  output["daemon"]["daemon_version"] = status.get("daemon_version")
306
313
  output["daemon"]["core_version"] = status.get("core_version")
307
314
  output["daemon"]["readiness_state"] = status.get("readiness_state", "unknown")
@@ -357,6 +364,7 @@ def status_main(
357
364
  daemon_live=True,
358
365
  daemon_version=daemon_version,
359
366
  core_version=core_version,
367
+ started_at=status.get("started_at"),
360
368
  )
361
369
  console.print(table)
362
370
 
@@ -3,6 +3,9 @@
3
3
  Emits RFC-614 loop-tagged assistant text for the main graph (empty LangGraph namespace)
4
4
  and loop-tagged finals (including replayed ``goal_completion`` from IG-355). Subgraph
5
5
  namespaced prose is suppressed unless loop-tagged. Stderr is used for errors.
6
+
7
+ UI transcript SoT is ``soothe.card.*`` (TUI / appkit); headless keeps raw ``messages``
8
+ phases for stdout and does not project card frames.
6
9
  """
7
10
 
8
11
  from __future__ import annotations
@@ -0,0 +1,130 @@
1
+ """TUI execution mode."""
2
+
3
+ import asyncio
4
+ import sys
5
+ from typing import Any
6
+
7
+ import typer
8
+ from soothe_client import protocol1_rpc, websocket_url_from_config
9
+
10
+ from soothe_cli.config import CLIConfig
11
+
12
+ # Module-level storage for the last TUI result (for post-exit tip display)
13
+ # Use Any to avoid circular import issues at module load time
14
+ _last_app_result: Any = None
15
+
16
+
17
+ def get_last_app_result() -> Any:
18
+ """Return the result from the most recent TUI run.
19
+
20
+ Returns:
21
+ The AppResult from the last TUI session, or None if TUI hasn't run.
22
+ """
23
+ return _last_app_result
24
+
25
+
26
+ def _resume_gate(cfg: CLIConfig, resume_loop_id: str) -> dict[str, Any] | None:
27
+ """Fetch execution state for a resumed loop before launching the TUI.
28
+
29
+ Returns the ``{plan, step_index, iteration, status}`` snapshot on success,
30
+ or ``None`` when the daemon is unreachable or the RPC fails (non-fatal —
31
+ resume proceeds regardless).
32
+ """
33
+ try:
34
+ ws_url = websocket_url_from_config(cfg)
35
+ response = asyncio.run(
36
+ protocol1_rpc(
37
+ ws_url,
38
+ "loop_execution_state_fetch",
39
+ {"loop_id": resume_loop_id},
40
+ )
41
+ )
42
+ except Exception:
43
+ return None
44
+
45
+ if isinstance(response, dict) and "error" not in response:
46
+ return response
47
+ return None
48
+
49
+
50
+ def _prompt_resume(resume_loop_id: str, state: dict[str, Any]) -> bool:
51
+ """Prompt the user to confirm resume of an active loop.
52
+
53
+ Returns ``True`` if the user confirms (Enter / y), ``False`` to discard
54
+ (n / any other input). Non-interactive stdin (no TTY) defaults to resuming.
55
+ """
56
+ step_index = state.get("step_index", 0)
57
+ iteration = state.get("iteration", 0)
58
+ status = state.get("status", "unknown")
59
+ typer.echo(
60
+ f"Active loop found: {resume_loop_id} (iteration {iteration}, step {step_index}, {status})"
61
+ )
62
+
63
+ if not sys.stdin.isatty():
64
+ # Non-interactive: auto-resume.
65
+ typer.echo("Non-interactive stdin — resuming.")
66
+ return True
67
+
68
+ try:
69
+ answer = typer.prompt(
70
+ "Resume this loop?",
71
+ default="y",
72
+ show_default=True,
73
+ )
74
+ except (EOFError, KeyboardInterrupt):
75
+ return False
76
+
77
+ return answer.strip().lower() in ("y", "yes", "")
78
+
79
+
80
+ def run_tui(
81
+ cfg: CLIConfig,
82
+ *,
83
+ resume_loop_id: str | None = None,
84
+ initial_prompt: str | None = None,
85
+ ) -> None:
86
+ """Launch the Textual TUI (with daemon auto-start)."""
87
+ global _last_app_result
88
+
89
+ # Resume gate: surface where the loop will pick up from the daemon's
90
+ # execution state before the TUI attaches.
91
+ if resume_loop_id:
92
+ state = _resume_gate(cfg, resume_loop_id)
93
+ if state is not None:
94
+ status = state.get("status", "unknown")
95
+ # Auto-resume loops in running/paused state only; terminal/idle
96
+ # states fall through to normal startup.
97
+ if status in ("running", "paused"):
98
+ should_resume = cfg.auto_resume or _prompt_resume(resume_loop_id, state)
99
+ if not should_resume:
100
+ typer.echo("Loop discarded. Starting fresh session.")
101
+ resume_loop_id = None
102
+ else:
103
+ step_index = state.get("step_index", 0)
104
+ iteration = state.get("iteration", 0)
105
+ plan = state.get("plan")
106
+ typer.echo(
107
+ f"Resuming loop {resume_loop_id}: "
108
+ f"iteration {iteration}, step {step_index} ({status})"
109
+ )
110
+ if plan:
111
+ typer.echo(f" Plan: {plan}")
112
+ else:
113
+ typer.echo(f"Loop {resume_loop_id} is {status} — starting fresh session.")
114
+ resume_loop_id = None
115
+
116
+ try:
117
+ from soothe_cli.tui import run_textual_tui
118
+
119
+ result = run_textual_tui(
120
+ config=cfg,
121
+ resume_loop_id=resume_loop_id,
122
+ initial_prompt=initial_prompt,
123
+ )
124
+ _last_app_result = result
125
+ except ImportError:
126
+ typer.echo(
127
+ "Error: Textual is required for the TUI. Install: pip install 'textual>=0.40.0'",
128
+ err=True,
129
+ )
130
+ sys.exit(1)
@@ -147,6 +147,16 @@ def main(
147
147
  ),
148
148
  ),
149
149
  ] = None,
150
+ auto_resume: Annotated[
151
+ bool,
152
+ typer.Option(
153
+ "--auto-resume/--no-auto-resume",
154
+ help=(
155
+ "Auto-resume an active loop on startup without prompting. "
156
+ "Default: prompt ([Enter] to resume / n to discard)."
157
+ ),
158
+ ),
159
+ ] = False,
150
160
  show_help: Annotated[ # noqa: FBT002
151
161
  bool,
152
162
  typer.Option("--help", "-h", is_flag=True, help="Show this message and exit."),
@@ -203,6 +213,7 @@ def main(
203
213
  output_streaming_enabled=streaming,
204
214
  output_streaming_mode=streaming_mode,
205
215
  clarification_mode=mode,
216
+ auto_resume=auto_resume,
206
217
  soothe_home=home_path,
207
218
  )
208
219
  set_runtime_config(cli_cfg)
@@ -43,6 +43,11 @@ class CLIConfig:
43
43
  clarification_mode: str | None = None
44
44
  """'manual' (relay to human) or 'auto' (veritas auto-answer). None = auto-detect from TTY."""
45
45
 
46
+ # Resume behavior: when the launcher finds an active loop on startup,
47
+ # auto-resume it (True) or prompt the user (False, default).
48
+ auto_resume: bool = False
49
+ """Auto-resume active loops on CLI startup without prompting (default: prompt)."""
50
+
46
51
  # Paths
47
52
  soothe_home: Path = field(default_factory=lambda: Path(SOOTHE_HOME))
48
53
 
@@ -11,6 +11,7 @@ this module adds TUI-oriented buffers and lifecycle for multiple concurrent step
11
11
  from __future__ import annotations
12
12
 
13
13
  from collections import deque
14
+ from collections.abc import Callable
14
15
  from dataclasses import dataclass, field
15
16
  from typing import Any, TypeAlias
16
17
 
@@ -331,7 +332,7 @@ class StepTaskRouter:
331
332
 
332
333
  Some providers emit unified ids like ``{step}:s:call_<uuid>`` that do not
333
334
  encode a task index. In that case allocate a stable per-step synthetic
334
- index so task activity rows and standalone SubAgent cards stay 1:1.
335
+ index so task activity rows stay 1:1 with delegated task markers.
335
336
  """
336
337
  sid = str(step_id).strip()
337
338
  tcid = str(tool_call_id).strip()
@@ -407,6 +408,23 @@ class StepTaskRouter:
407
408
  )
408
409
  )
409
410
 
411
+ def take_pending_main_tools_matching(
412
+ self,
413
+ predicate: Callable[[PendingMainTool], bool],
414
+ ) -> list[PendingMainTool]:
415
+ """Remove and return buffered main tools for which ``predicate`` is true."""
416
+ if not self._pending_main_tools:
417
+ return []
418
+ taken: list[PendingMainTool] = []
419
+ still: dict[str, PendingMainTool] = {}
420
+ for key, item in self._pending_main_tools.items():
421
+ if predicate(item):
422
+ taken.append(item)
423
+ else:
424
+ still[key] = item
425
+ self._pending_main_tools = still
426
+ return taken
427
+
410
428
  def route_pending_main_tools(
411
429
  self,
412
430
  step_cards: dict[str, StepWidget],
@@ -697,7 +715,7 @@ class StepTaskRouter:
697
715
  return list(self._pending_subgraph_tools.values())
698
716
 
699
717
  def discard_pending_subgraph_tool(self, ns_key: tuple[str, ...], lookup_id: str) -> None:
700
- """Drop a buffered subgraph tool after routing it to a SubAgent card."""
718
+ """Drop a buffered subgraph tool after routing it to a step/orphan card."""
701
719
  self._pending_subgraph_tools.pop(_subgraph_pending_key(ns_key, lookup_id), None)
702
720
 
703
721
  @property
@@ -37,6 +37,7 @@ from soothe_cli.tui.app._module_init import (
37
37
  )
38
38
  from soothe_cli.tui.app._startup import _StartupMixin
39
39
  from soothe_cli.tui.app._ui import _UIMixin
40
+ from soothe_cli.tui.tips import TipRotator
40
41
  from soothe_cli.tui.widgets.chat_input import ChatInput
41
42
  from soothe_cli.tui.widgets.loading import LoadingWidget
42
43
  from soothe_cli.tui.widgets.message_store import MessageStore
@@ -227,6 +228,9 @@ class SootheApp(
227
228
  self._default_session_tip: str = ""
228
229
  self._status_notification_timer: Timer | None = None
229
230
  self._status_notification_active = False
231
+ # Rotating tip source + interval timer for the status footer.
232
+ self._tip_rotator: TipRotator = TipRotator()
233
+ self._tip_rotation_timer: Timer | None = None
230
234
 
231
235
  self._chat_input: ChatInput | None = None
232
236
  self._plan_quick_view_overlay: PlanQuickViewOverlay | None = None
@@ -371,6 +375,32 @@ class SootheApp(
371
375
  if self._status_bar is not None and not self._status_notification_active:
372
376
  self._status_bar.set_session_tip(cleaned)
373
377
 
378
+ def start_tip_rotation(self, interval: float = 12.0) -> None:
379
+ """Cycle through rotating tips in the status footer at a fixed interval.
380
+
381
+ Args:
382
+ interval: Seconds between tip rotations.
383
+ """
384
+ self.stop_tip_rotation()
385
+ self._tip_rotation_timer = self.set_interval(interval, self._rotate_session_tip)
386
+
387
+ def stop_tip_rotation(self) -> None:
388
+ """Cancel the rotating-tip interval timer if one is running."""
389
+ if self._tip_rotation_timer is not None:
390
+ with suppress(Exception):
391
+ self._tip_rotation_timer.stop()
392
+ self._tip_rotation_timer = None
393
+
394
+ def _rotate_session_tip(self) -> None:
395
+ """Advance to the next tip and push it to the status footer.
396
+
397
+ Transient notifications take precedence: rotation is skipped while a
398
+ notification is active so it can run its timeout uninterrupted.
399
+ """
400
+ if self._status_notification_active:
401
+ return
402
+ self.set_default_session_tip(self._tip_rotator.next_tip())
403
+
374
404
  def _set_status_notification(self, message: str, *, timeout: float | None = None) -> None:
375
405
  """Render a transient notification in the status-tip area."""
376
406
  text = (message or "").strip()
@@ -53,6 +53,28 @@ InputMode = Literal["normal", "shell", "command"]
53
53
 
54
54
  logger = logging.getLogger(__name__)
55
55
 
56
+ _PLAN_REVIEW_ACTIONS = frozenset({"Approve", "Reject", "More comments"})
57
+
58
+
59
+ def clarification_wire_content(answers: list[str]) -> str:
60
+ """Human-readable turn content for a clarification submit (not a new goal).
61
+
62
+ Plan-review actions use a stable ``Plan review: …`` prefix so a dropped
63
+ ``clarification_answer`` flag cannot turn bare ``Reject`` into Pass1 TASK.
64
+ """
65
+ non_empty = [a for a in answers if str(a).strip()]
66
+ if not non_empty:
67
+ return ""
68
+ first = str(answers[0]).strip() if answers else ""
69
+ if first in _PLAN_REVIEW_ACTIONS:
70
+ comments = str(answers[1]).strip() if len(answers) > 1 else ""
71
+ if comments:
72
+ return f"Plan review: {first} — {comments}"
73
+ return f"Plan review: {first}"
74
+ if len(non_empty) == 1:
75
+ return non_empty[0]
76
+ return " | ".join(f"A{i + 1}: {a}" for i, a in enumerate(answers) if str(a).strip())
77
+
56
78
 
57
79
  class _ExecutionMixin:
58
80
  """Agent execution, message routing, queue, shell commands, and daemon events."""
@@ -229,6 +251,23 @@ class _ExecutionMixin:
229
251
  # the user can still see what they answered.
230
252
  adapter._clarification_input_by_step.pop(event.step_id, None)
231
253
 
254
+ # A stale empty remount (resume re-emit) can leave another interactive
255
+ # plan-review card. Disable extras so a second click cannot fire a
256
+ # "Reject" turn after clarification_answered cleared the pending flag.
257
+ for sid, other in list(adapter._clarification_input_by_step.items()):
258
+ if sid == event.step_id:
259
+ continue
260
+ try:
261
+ other._submitted = True # noqa: SLF001
262
+ other.add_class("is-submitted")
263
+ for btn in getattr(other, "_action_buttons", {}).values():
264
+ btn.disabled = True
265
+ for inp in getattr(other, "_inputs", []) or []:
266
+ inp.disabled = True
267
+ except Exception: # noqa: BLE001
268
+ logger.debug("Failed to disarm leftover clarification widget", exc_info=True)
269
+ adapter._clarification_input_by_step.pop(sid, None)
270
+
232
271
  non_empty = [a for a in event.answers if a.strip()]
233
272
  if not non_empty:
234
273
  return
@@ -237,16 +276,16 @@ class _ExecutionMixin:
237
276
  # concatenated string. ``content`` carries a human-readable summary
238
277
  # for clients that look at it; the authoritative payload is the
239
278
  # ``clarification_answers`` wire field.
240
- payload_text = (
241
- non_empty[0]
242
- if len(non_empty) == 1
243
- else " | ".join(f"A{i + 1}: {a}" for i, a in enumerate(event.answers) if a.strip())
244
- )
279
+ payload_text = clarification_wire_content(list(event.answers))
245
280
  adapter._clarification_answers_pending = list(event.answers)
281
+ # Always resume clarification from a widget submit — even if a prior
282
+ # clarification_answered cleared ``_clarification_pending`` (empty
283
+ # remount / race). Without this, "Reject" is treated as a new goal.
284
+ adapter._clarification_pending = True
246
285
 
247
286
  # Hand off to the standard turn pipeline. ``execute_task_textual``
248
- # snapshots ``adapter._clarification_pending`` (still True) and sets
249
- # the wire ``clarification_answer`` flag plus the ``clarification_answers``
287
+ # snapshots ``adapter._clarification_pending`` and sets the wire
288
+ # ``clarification_answer`` flag plus the ``clarification_answers``
250
289
  # list, then clears the persisted flag so a follow-up turn is treated
251
290
  # as a new goal.
252
291
  #
@@ -469,8 +508,7 @@ class _ExecutionMixin:
469
508
 
470
509
  # RFC-454 daemon *routing* commands (/deep_research, /plan, /browser_use, …):
471
510
  # send the full line as a normal user turn so ``parse_subagent_from_input``
472
- # runs in the daemon adapter (same as headless CLI). Without this branch,
473
- # ``cmd == "/deep_research …"`` never matches the bare single-token handlers below.
511
+ # strips the slash token and sets preferred_subagent (same as headless CLI).
474
512
  full_stripped = command.strip()
475
513
  first_word, query = parse_slash_command(full_stripped)
476
514
  if first_word: