soothe-cli 0.10.2__tar.gz → 0.10.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 (130) hide show
  1. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/PKG-INFO +4 -5
  2. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/pyproject.toml +3 -4
  3. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/autopilot_cmd.py +39 -1
  4. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/cron_cmd.py +7 -4
  5. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/daemon_errors.py +20 -0
  6. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/main.py +4 -3
  7. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/config/cli_config.py +3 -2
  8. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_app.py +4 -3
  9. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_execution.py +134 -17
  10. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/command_registry.py +47 -18
  11. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/commands/slash_commands.py +2 -0
  12. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/textual_adapter.py +12 -1
  13. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/autocomplete.py +10 -2
  14. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/help_screen.py +2 -1
  15. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +38 -10
  16. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/status.py +4 -17
  17. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/.gitignore +0 -0
  18. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/README.md +0 -0
  19. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/__init__.py +0 -0
  20. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/__init__.py +0 -0
  21. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/__init__.py +0 -0
  22. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
  23. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  24. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  25. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  26. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/__init__.py +0 -0
  27. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/daemon.py +0 -0
  28. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/headless.py +0 -0
  29. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  30. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/cli/execution/launcher.py +0 -0
  31. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/config/__init__.py +0 -0
  32. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/config/loader.py +0 -0
  33. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/config/logging_setup.py +0 -0
  34. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/__init__.py +0 -0
  35. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/headless/processor.py +0 -0
  36. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  37. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  38. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  39. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  40. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  41. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  42. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/id_format.py +0 -0
  43. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  44. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  45. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/step_id_format.py +0 -0
  46. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
  47. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  48. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  49. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/state/step_router.py +0 -0
  50. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  51. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/token_events_debug.py +0 -0
  52. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/token_usage.py +0 -0
  53. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/transport/session.py +0 -0
  54. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  55. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  56. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  57. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/runtime/wire/messages.py +0 -0
  58. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/__init__.py +0 -0
  59. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/_cli_context.py +0 -0
  60. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/_env_vars.py +0 -0
  61. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/_version.py +0 -0
  62. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/__init__.py +0 -0
  63. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_history.py +0 -0
  64. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_messages_mixin.py +0 -0
  65. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_model.py +0 -0
  66. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_module_init.py +0 -0
  67. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_startup.py +0 -0
  68. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/_ui.py +0 -0
  69. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/app/app.tcss +0 -0
  70. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/binding.py +0 -0
  71. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/card_wire.py +0 -0
  72. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/commands/__init__.py +0 -0
  73. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/commands/command_router.py +0 -0
  74. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  75. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/composer_mode.py +0 -0
  76. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/config.py +0 -0
  77. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/file_change_notify.py +0 -0
  78. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  79. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/hooks.py +0 -0
  80. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/input.py +0 -0
  81. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/markdown_theme.py +0 -0
  82. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/media_utils.py +0 -0
  83. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/mermaid_render.py +0 -0
  84. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/model_config.py +0 -0
  85. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/path_utils.py +0 -0
  86. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/preview_limits.py +0 -0
  87. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/project_utils.py +0 -0
  88. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/sessions.py +0 -0
  89. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/shell_color.py +0 -0
  90. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/skills/__init__.py +0 -0
  91. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/skills/invocation.py +0 -0
  92. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/skills/load.py +0 -0
  93. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/spinner_labels.py +0 -0
  94. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/theme.py +0 -0
  95. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/tips.py +0 -0
  96. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/tool_display.py +0 -0
  97. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/unicode_security.py +0 -0
  98. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/update_check.py +0 -0
  99. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  100. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/_links.py +0 -0
  101. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  102. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  103. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/context_data.py +0 -0
  104. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/context_viewer.py +0 -0
  105. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/diff.py +0 -0
  106. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/editor.py +0 -0
  107. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  108. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/history.py +0 -0
  109. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/loading.py +0 -0
  110. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  111. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  112. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  113. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
  114. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
  115. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
  116. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/assistant.py +0 -0
  117. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
  118. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
  119. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
  120. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/cognition_step.py +0 -0
  121. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +0 -0
  122. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
  123. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
  124. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
  125. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
  126. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  127. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  128. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/router_profile_selector.py +0 -0
  129. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  130. {soothe_cli-0.10.2 → soothe_cli-0.10.4}/src/soothe_cli/tui/widgets/welcome.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.10.2
3
+ Version: 0.10.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
@@ -14,16 +14,15 @@ Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
- Classifier: Programming Language :: Python :: 3.14
18
17
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
- Requires-Python: <3.15,>=3.11
19
+ Requires-Python: <3.14,>=3.11
21
20
  Requires-Dist: pillow<12.0.0,>=10.0.0
22
21
  Requires-Dist: python-dotenv<2.0.0,>=1.0.0
23
22
  Requires-Dist: pyyaml<7.0.0,>=6.0.0
24
23
  Requires-Dist: rich<15.0.0,>=13.0.0
25
- Requires-Dist: soothe-client-python<2.0.0,>=1.0.12
26
- Requires-Dist: soothe-sdk<2.0.0,>=1.0.7
24
+ Requires-Dist: soothe-client-python<2.0.0,>=1.0.15
25
+ Requires-Dist: soothe-sdk<2.0.0,>=1.0.8
27
26
  Requires-Dist: termaid<1.0.0,>=0.7.1
28
27
  Requires-Dist: textual<10.0.0,>=8.0.0
29
28
  Requires-Dist: typer<2.0.0,>=0.9.0
@@ -8,7 +8,7 @@ dynamic = ["version"]
8
8
  description = "Soothe CLI client - communicates with daemon via WebSocket"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
11
- requires-python = ">=3.11,<3.15"
11
+ requires-python = ">=3.11,<3.14"
12
12
  keywords = ["soothe", "cli", "client", "tui", "websocket"]
13
13
  classifiers = [
14
14
  "Development Status :: 3 - Alpha",
@@ -18,13 +18,12 @@ classifiers = [
18
18
  "Programming Language :: Python :: 3.11",
19
19
  "Programming Language :: Python :: 3.12",
20
20
  "Programming Language :: Python :: 3.13",
21
- "Programming Language :: Python :: 3.14",
22
21
  "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
22
  "Topic :: Software Development :: Libraries :: Python Modules",
24
23
  ]
25
24
  dependencies = [
26
- "soothe-sdk>=1.0.7,<2.0.0", # events/display/wire contracts
27
- "soothe-client-python>=1.0.12,<2.0.0", # WebSocket transport client; matched to latest release v1.0.12
25
+ "soothe-sdk>=1.0.8,<2.0.0", # events/display/wire contracts
26
+ "soothe-client-python>=1.0.15,<2.0.0", # WebSocket transport client; matched to latest release v1.0.15
28
27
  "typer>=0.9.0,<2.0.0", # CLI framework
29
28
  "textual>=8.0.0,<10.0.0", # TUI framework
30
29
  "rich>=13.0.0,<15.0.0", # Console output
@@ -66,7 +66,8 @@ _STYLE_METRIC_RAIL = "bold bright_green"
66
66
  # Compact description previews at the row tail so metrics stay visible first.
67
67
  _TOP_DESC_JOB = 32
68
68
  _TOP_DESC_GOAL = 32
69
- _TOP_DESC_STEP = 28
69
+ # Steps can show a bit more than job/goal: deps trail after the desc (`→id`).
70
+ _TOP_DESC_STEP = 40
70
71
  _STATUS_STYLE: dict[str, str] = {
71
72
  "active": _STYLE_ACTIVE,
72
73
  "pending": "yellow",
@@ -560,6 +561,43 @@ def resume_goal(
560
561
  typer.echo(f"Goal resumed: {resumed_id[:8]} → {result.get('new_status', 'pending')}")
561
562
 
562
563
 
564
+ @app.command("guide")
565
+ def guide_job(
566
+ job_id: str = typer.Argument(..., help="Job (root goal) ID."),
567
+ text: str = typer.Argument(..., help="Guidance text for the next Autopilot dispatch."),
568
+ goal_id: str | None = typer.Option(
569
+ None,
570
+ "--goal",
571
+ "-g",
572
+ help="Optional goal under the job (default: job-root / job-wide scope).",
573
+ ),
574
+ ) -> None:
575
+ """Absorb operator guidance into ContextEngine (does not create goals).
576
+
577
+ Text is stored on the job/goal for Autopilot cognition and applied on the
578
+ next worker dispatch. It does not spawn or inject new goals.
579
+ """
580
+ cleaned = (text or "").strip()
581
+ if not cleaned:
582
+ typer.echo("Guidance text must be non-empty.", err=True)
583
+ raise typer.Exit(1)
584
+ client = _require_daemon_ws()
585
+ try:
586
+ result = client.job_guidance(job_id, cleaned, goal_id=goal_id)
587
+ except RuntimeError as exc:
588
+ typer.echo(str(exc), err=True)
589
+ raise typer.Exit(1) from exc
590
+ absorbed = bool(result.get("absorbed"))
591
+ target = str(result.get("goal_id") or goal_id or job_id)
592
+ if not absorbed:
593
+ typer.echo(
594
+ f"Guidance not absorbed for job {job_id[:8]} goal {target[:8]}.",
595
+ err=True,
596
+ )
597
+ raise typer.Exit(1)
598
+ typer.echo(f"Guidance absorbed: job {job_id[:8]} → goal {target[:8]}")
599
+
600
+
563
601
  def _short_loop_id(loop_id: str, *, head: int = 4, tail: int = 4) -> str:
564
602
  """Shorten loop ids as ``prefix…suffix`` (4 chars each by default)."""
565
603
  text = str(loop_id or "")
@@ -29,7 +29,10 @@ from soothe_client import (
29
29
 
30
30
  from soothe_cli.runtime import load_config
31
31
 
32
- console = Console()
32
+ # Rich reads width from the TTY; under CI / test runners (no TTY) it falls back
33
+ # to a narrow default that truncates the Job ID column. Use a comfortable fixed
34
+ # width when stdout is not interactive so IDs render in full.
35
+ console = Console() if sys.stdout.isatty() else Console(width=100)
33
36
 
34
37
  app = typer.Typer(help="Manage scheduled cron jobs — natural language scheduled tasks.")
35
38
 
@@ -148,7 +151,7 @@ def list_jobs(
148
151
  return
149
152
 
150
153
  table = Table(title="Scheduled Jobs")
151
- table.add_column("Job ID", style="cyan", width=12)
154
+ table.add_column("Job ID", style="cyan", no_wrap=True, width=12)
152
155
  table.add_column("Description", width=40)
153
156
  table.add_column("Status", style="green", width=10)
154
157
  table.add_column("Next Run", style="yellow", width=20)
@@ -156,8 +159,8 @@ def list_jobs(
156
159
 
157
160
  for job in jobs:
158
161
  desc = job.get("description", "")
159
- if len(desc) > 40:
160
- desc = desc[:37] + "..."
162
+ if len(desc) > 28:
163
+ desc = desc[:25] + "..."
161
164
  table.add_row(
162
165
  job.get("id", "?")[:12],
163
166
  desc,
@@ -23,6 +23,15 @@ _FRIENDLY_DAEMON_CONNECTION_LOST = (
23
23
  "Send your message again to reconnect and continue this loop."
24
24
  )
25
25
 
26
+ # Partial match for the attach-only idle timeout raised by ``iter_turn_chunks``
27
+ # when a stale ``live`` probe attaches to a loop whose runner already exited.
28
+ ATTACH_IDLE_TIMEOUT_MARKER = "attach window"
29
+
30
+ _FRIENDLY_ATTACH_IDLE_TIMEOUT = (
31
+ "No follow-on turn started within the attach window; the prior turn had "
32
+ "already completed. Ready for your next message."
33
+ )
34
+
26
35
 
27
36
  def is_daemon_connection_error(exc: BaseException | str) -> bool:
28
37
  """Return whether an error indicates the daemon WebSocket is unavailable."""
@@ -51,6 +60,8 @@ def friendly_daemon_execution_error(exc: BaseException | str) -> str:
51
60
  """Map known daemon failures to concise, actionable copy."""
52
61
  if is_daemon_connection_error(exc):
53
62
  return _FRIENDLY_DAEMON_CONNECTION_LOST
63
+ if is_attach_idle_timeout(exc):
64
+ return _FRIENDLY_ATTACH_IDLE_TIMEOUT
54
65
  if isinstance(exc, RuntimeError) and DAEMON_WORKER_SUBPROCESS_LOST in str(exc):
55
66
  return _FRIENDLY_WORKER_SUBPROCESS_LOST
56
67
  if isinstance(exc, RuntimeError) and DAEMON_WORKER_THREAD_LOST in str(exc):
@@ -73,3 +84,12 @@ def is_daemon_worker_thread_lost(exc: BaseException | str) -> bool:
73
84
  """Return whether an error indicates a pool worker thread exited mid-query."""
74
85
  text = str(exc)
75
86
  return DAEMON_WORKER_THREAD_LOST in text
87
+
88
+
89
+ def is_attach_idle_timeout(exc: BaseException | str) -> bool:
90
+ """Return whether an error is the attach-only idle timeout.
91
+
92
+ Raised by ``iter_turn_chunks`` when a stale ``live`` probe attached to a
93
+ loop whose runner had already exited (no follow-on turn materialized).
94
+ """
95
+ return ATTACH_IDLE_TIMEOUT_MARKER in str(exc)
@@ -217,11 +217,12 @@ def main(
217
217
  typer.Option(
218
218
  "--plan-panel/--no-plan-panel",
219
219
  help=(
220
- "Auto-show the in-flow plan panel when a goal plan is active. "
221
- "Ctrl+t toggles thereafter. Default: disabled."
220
+ "Auto-show the in-flow plan panel while a goal is executing "
221
+ "and hide it when the goal completes. Ctrl+t toggles "
222
+ "thereafter. Default: enabled."
222
223
  ),
223
224
  ),
224
- ] = False,
225
+ ] = True,
225
226
  show_version: Annotated[ # noqa: FBT002
226
227
  bool,
227
228
  typer.Option("--version", help="Show version and exit."),
@@ -33,8 +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 = False
37
- """Auto-show the in-flow plan panel when a goal plan is active. Ctrl+t toggles."""
36
+ plan_panel_default_visible: bool = True
37
+ """Auto-show the in-flow plan panel while a goal is executing and hide it
38
+ when the goal completes. Ctrl+t toggles the panel thereafter."""
38
39
 
39
40
  # Output streaming overrides (RFC-614)
40
41
  output_streaming_enabled: bool | None = None
@@ -477,9 +477,10 @@ class SootheApp(
477
477
  )
478
478
  yield Container(id="messages")
479
479
  # In-flow plan panel: sits above the sticky bottom chrome so Ctrl+t
480
- # never covers the thinking row or chat input. Auto-shows when a plan
481
- # is active if CLIConfig.plan_panel_default_visible is True.
482
- plan_visible = getattr(self._daemon_config, "plan_panel_default_visible", False)
480
+ # never covers the thinking row or chat input. Auto-shows while a goal
481
+ # is executing and auto-hides when it completes (default) unless
482
+ # CLIConfig.plan_panel_default_visible is False.
483
+ plan_visible = getattr(self._daemon_config, "plan_panel_default_visible", True)
483
484
  yield PlanQuickViewOverlay(
484
485
  id="plan-quick-view-overlay",
485
486
  default_visible=plan_visible,
@@ -20,6 +20,7 @@ from textual.style import Style as TStyle
20
20
 
21
21
  from soothe_cli.cli.execution.daemon_errors import (
22
22
  friendly_daemon_connection_error,
23
+ is_attach_idle_timeout,
23
24
  is_daemon_connection_error,
24
25
  )
25
26
  from soothe_cli.cli.execution.daemon_errors import (
@@ -271,6 +272,15 @@ class _ExecutionMixin:
271
272
  non_empty = [a for a in event.answers if a.strip()]
272
273
  if not non_empty:
273
274
  return
275
+
276
+ # When a plan is approved for execution, switch composer mode to Auto
277
+ # so subsequent turns use standard loop routing without planner hint.
278
+ first_answer = str(event.answers[0]).strip() if event.answers else ""
279
+ if first_answer == "Approve":
280
+ self._composer_mode = "auto"
281
+ if self._status_bar is not None:
282
+ self._status_bar.set_clarification_mode("auto")
283
+
274
284
  # Send the answers as a structured list so the daemon resumes the
275
285
  # graph with one answer per question instead of broadcasting a single
276
286
  # concatenated string. ``content`` carries a human-readable summary
@@ -848,6 +858,51 @@ class _ExecutionMixin:
848
858
  else:
849
859
  await self._mount_message(AppMessage("Agent not configured for this session."))
850
860
 
861
+ async def _daemon_loop_is_live(self) -> bool:
862
+ """Return True when the subscribed daemon loop has a live runner now.
863
+
864
+ Used to attach with ``skip_daemon_send_turn`` instead of enqueueing a
865
+ duplicate ``loop_input`` (stale-reader / ghost follow-on goals).
866
+
867
+ The active-runner signal (``active_runner``) is authoritative: a loop's
868
+ metadata ``status`` field can lag ``"running"`` for up to the 5-minute
869
+ reconciliation window after its runner exits, so status alone produces
870
+ false positives that leave the TUI attached to a phantom follow-on turn
871
+ for minutes. When the daemon exposes ``active_runner`` we require it;
872
+ only as a fallback for daemons without the field do we fall back to the
873
+ status check, and even then we treat a ``None`` (unknown) signal as
874
+ not-live to avoid the stale-status hang.
875
+ """
876
+ session = self._daemon_session
877
+ state = self._session_state
878
+ if session is None or state is None:
879
+ return False
880
+ loop_id = str(getattr(state, "loop_id", "") or "").strip()
881
+ if not loop_id:
882
+ return False
883
+ try:
884
+ exec_state = await session.fetch_execution_state(loop_id)
885
+ except Exception:
886
+ logger.debug("live probe via fetch_execution_state failed", exc_info=True)
887
+ return False
888
+ status = str(getattr(exec_state, "status", "") or "").strip().lower()
889
+ if status != "running":
890
+ return False
891
+ active_runner = getattr(exec_state, "active_runner", None)
892
+ if active_runner is True:
893
+ return True
894
+ # ``None`` = daemon older than the field, or probe failed. Only then do
895
+ # we consult the card-ledger live-goal index — and only as a weak hint
896
+ # that still requires status=="running" (already checked above).
897
+ if active_runner is None:
898
+ try:
899
+ history = await session.fetch_loop_history(loop_id)
900
+ if getattr(history, "live_goal_index", None) is not None:
901
+ return True
902
+ except Exception:
903
+ logger.debug("live probe via fetch_loop_history failed", exc_info=True)
904
+ return False
905
+
851
906
  async def _run_agent_task(
852
907
  self,
853
908
  message: str,
@@ -883,6 +938,14 @@ class _ExecutionMixin:
883
938
  try:
884
939
  for attempt in (1, 2):
885
940
  try:
941
+ attach_only = skip_daemon_send_turn
942
+ if (
943
+ not attach_only
944
+ and self._daemon_session is not None
945
+ and await self._daemon_loop_is_live()
946
+ ):
947
+ attach_only = True
948
+ logger.info("Daemon loop already live; attaching without send_turn")
886
949
  await execute_task_textual(
887
950
  user_input=message,
888
951
  daemon_session=self._daemon_session,
@@ -898,7 +961,7 @@ class _ExecutionMixin:
898
961
  router_profile=self._router_profile_override,
899
962
  ),
900
963
  turn_stats=turn_stats,
901
- skip_daemon_send_turn=skip_daemon_send_turn,
964
+ skip_daemon_send_turn=attach_only,
902
965
  clarification_mode=wire_clar,
903
966
  sticky_preferred_subagent=sticky_subagent,
904
967
  is_shutting_down=lambda: getattr(self, "_exit", False),
@@ -923,23 +986,40 @@ class _ExecutionMixin:
923
986
  pass
924
987
  raise
925
988
  except Exception as e: # Resilient tool rendering
926
- logger.exception("Agent execution failed")
927
- if is_daemon_connection_error(e):
928
- display_err = friendly_daemon_connection_error(e)
929
- error_title = "Daemon connection error"
989
+ # Attach-only idle timeout is benign: the prior turn had already
990
+ # completed and no follow-on turn materialized. Surface it as an
991
+ # informational message (not a red error) and fall through to
992
+ # normal cleanup so the TUI returns to a ready state.
993
+ if is_attach_idle_timeout(e):
994
+ logger.info("Attach-only idle timeout: no follow-on turn; returning to ready")
995
+ try:
996
+ await self._mount_message(AppMessage(_friendly_agent_execution_error(e)))
997
+ except Exception:
998
+ logger.debug(
999
+ "Could not mount attach-timeout message (app closing?)", exc_info=True
1000
+ )
930
1001
  else:
931
- display_err = _friendly_agent_execution_error(e)
932
- error_title = "Agent error"
933
- # Ensure any in-flight tool calls don't remain stuck in "Running..."
934
- # when streaming aborts before tool results arrive.
935
- if self._ui_adapter:
936
- self._ui_adapter.finalize_pending_tools_with_error(f"{error_title}: {display_err}")
937
- self._ui_adapter.finalize_pending_steps_with_error(f"{error_title}: {display_err}")
938
- await self._try_recover_goal_completion_from_ledger()
939
- try:
940
- await self._mount_message(ErrorMessage(f"{error_title}. {display_err}"))
941
- except Exception:
942
- logger.debug("Could not mount error message (app closing?)", exc_info=True)
1002
+ logger.exception("Agent execution failed")
1003
+ if is_daemon_connection_error(e):
1004
+ display_err = friendly_daemon_connection_error(e)
1005
+ error_title = "Daemon connection error"
1006
+ else:
1007
+ display_err = _friendly_agent_execution_error(e)
1008
+ error_title = "Agent error"
1009
+ # Ensure any in-flight tool calls don't remain stuck in "Running..."
1010
+ # when streaming aborts before tool results arrive.
1011
+ if self._ui_adapter:
1012
+ self._ui_adapter.finalize_pending_tools_with_error(
1013
+ f"{error_title}: {display_err}"
1014
+ )
1015
+ self._ui_adapter.finalize_pending_steps_with_error(
1016
+ f"{error_title}: {display_err}"
1017
+ )
1018
+ await self._try_recover_goal_completion_from_ledger()
1019
+ try:
1020
+ await self._mount_message(ErrorMessage(f"{error_title}. {display_err}"))
1021
+ except Exception:
1022
+ logger.debug("Could not mount error message (app closing?)", exc_info=True)
943
1023
  finally:
944
1024
  # Merge turn stats before cleanup — _cleanup_agent_task may raise
945
1025
  # during teardown (widget removal on a torn-down DOM), and stats
@@ -1049,5 +1129,42 @@ class _ExecutionMixin:
1049
1129
  )
1050
1130
  )
1051
1131
 
1132
+ # If the daemon already admitted a follow-on goal (serial input queue /
1133
+ # successor turn), re-attach without sending another loop_input.
1134
+ if (
1135
+ not self._exit
1136
+ and self._daemon_session is not None
1137
+ and self._ui_adapter is not None
1138
+ and self._runtime_backend_ready()
1139
+ and await self._daemon_loop_is_live()
1140
+ ):
1141
+ await self._attach_to_live_daemon_turn()
1142
+ return
1143
+
1052
1144
  # Process next message from queue if any
1053
1145
  await self._process_next_from_queue()
1146
+
1147
+ async def _attach_to_live_daemon_turn(self) -> None:
1148
+ """Attach the TUI reader to an already-running daemon turn.
1149
+
1150
+ When a local queue head exists, mount it as the user echo then attach
1151
+ with ``skip_daemon_send_turn`` (prompt already on the daemon). Otherwise
1152
+ attach with an empty prompt so activity keeps streaming.
1153
+ """
1154
+ prompt = ""
1155
+ if self._pending_messages:
1156
+ msg = self._pending_messages.popleft()
1157
+ prompt = msg.text
1158
+ if self._queued_widgets:
1159
+ widget = self._queued_widgets.popleft()
1160
+ with suppress(Exception):
1161
+ await widget.remove()
1162
+ self._refresh_queued_goal_tips()
1163
+ if msg.mode != "normal":
1164
+ # Shell/command still need their normal handlers.
1165
+ await self._process_message(msg.text, msg.mode)
1166
+ return
1167
+ if prompt.strip():
1168
+ await self._mount_message(UserMessage(prompt))
1169
+ logger.info("Attaching TUI to live daemon turn (skip send_turn)")
1170
+ await self._send_to_agent(prompt, skip_daemon_send_turn=True)
@@ -34,6 +34,19 @@ class BypassTier(StrEnum):
34
34
  """Must wait in the queue when the app is busy."""
35
35
 
36
36
 
37
+ class EnterAction(StrEnum):
38
+ """How Enter behaves when a slash autocomplete suggestion is selected.
39
+
40
+ Tab always completes without submitting. Click also completes only.
41
+ """
42
+
43
+ EXECUTE = "execute"
44
+ """One-stage: insert the command token and submit immediately."""
45
+
46
+ COMPLETE = "complete"
47
+ """Two-stage: insert the command token (with trailing space) for more typing."""
48
+
49
+
37
50
  @dataclass(frozen=True, slots=True, kw_only=True)
38
51
  class SlashCommand:
39
52
  """A single slash-command definition."""
@@ -53,6 +66,9 @@ class SlashCommand:
53
66
  aliases: tuple[str, ...] = ()
54
67
  """Alternative names (e.g. `("/q",)` for `/quit`)."""
55
68
 
69
+ enter_action: EnterAction = EnterAction.EXECUTE
70
+ """Enter key behavior when completing this command from autocomplete."""
71
+
56
72
 
57
73
  COMMANDS: tuple[SlashCommand, ...] = (
58
74
  SlashCommand(
@@ -60,12 +76,14 @@ COMMANDS: tuple[SlashCommand, ...] = (
60
76
  description="Submit autopilot job (usage: /autopilot <task>)",
61
77
  bypass_tier=BypassTier.QUEUED,
62
78
  hidden_keywords="goals autonomous job submit",
79
+ enter_action=EnterAction.COMPLETE,
63
80
  ),
64
81
  SlashCommand(
65
82
  name="/cron",
66
83
  description="Add scheduled job (usage: /cron <natural language>)",
67
84
  bypass_tier=BypassTier.QUEUED,
68
85
  hidden_keywords="schedule timer reminder",
86
+ enter_action=EnterAction.COMPLETE,
69
87
  ),
70
88
  SlashCommand(
71
89
  name="/clear",
@@ -124,22 +142,26 @@ COMMANDS: tuple[SlashCommand, ...] = (
124
142
  name="/deep_research",
125
143
  description="Route prompt to deep_research subagent (usage: /deep_research <query>)",
126
144
  bypass_tier=BypassTier.QUEUED,
145
+ enter_action=EnterAction.COMPLETE,
127
146
  ),
128
147
  SlashCommand(
129
148
  name="/academic_research",
130
149
  description="Route prompt to academic_research subagent (usage: /academic_research <query>)",
131
150
  bypass_tier=BypassTier.QUEUED,
151
+ enter_action=EnterAction.COMPLETE,
132
152
  ),
133
153
  SlashCommand(
134
154
  name="/browser_use",
135
155
  description="Route prompt to browser_use subagent (usage: /browser_use <task>)",
136
156
  bypass_tier=BypassTier.QUEUED,
137
157
  hidden_keywords="browser automation web",
158
+ enter_action=EnterAction.COMPLETE,
138
159
  ),
139
160
  SlashCommand(
140
161
  name="/plan",
141
162
  description="Send input in plan mode (usage: /plan or /plan <prompt>)",
142
163
  bypass_tier=BypassTier.QUEUED,
164
+ enter_action=EnterAction.COMPLETE,
143
165
  ),
144
166
  SlashCommand(
145
167
  name="/reload",
@@ -229,6 +251,29 @@ def resolve_command_head(command: str) -> str:
229
251
  return _ALIAS_TO_CANONICAL.get(head, head)
230
252
 
231
253
 
254
+ _CANONICAL_ENTER_ACTION: dict[str, EnterAction] = {cmd.name: cmd.enter_action for cmd in COMMANDS}
255
+
256
+
257
+ def enter_action_for(command: str) -> EnterAction:
258
+ """Resolve Enter autocomplete behavior for a completed slash token.
259
+
260
+ Dynamic skill rows (`/skill:<name>`) always use two-stage complete so the
261
+ operator can append arguments before submitting.
262
+
263
+ Args:
264
+ command: Completed suggestion label (e.g. ``/clear`` or ``/skill:foo``).
265
+
266
+ Returns:
267
+ `EnterAction.EXECUTE` for one-stage submit, or `EnterAction.COMPLETE`
268
+ for insert-only. Unknown static commands default to execute.
269
+ """
270
+ stripped = command.strip()
271
+ if stripped.startswith(("/skill:", "/skills:")):
272
+ return EnterAction.COMPLETE
273
+ head = resolve_command_head(stripped)
274
+ return _CANONICAL_ENTER_ACTION.get(head, EnterAction.EXECUTE)
275
+
276
+
232
277
  # ---------------------------------------------------------------------------
233
278
  # Derived bypass-tier frozensets
234
279
  # ---------------------------------------------------------------------------
@@ -299,15 +344,6 @@ def parse_skill_command(command: str) -> tuple[str, str]:
299
344
  return skill_name, args
300
345
 
301
346
 
302
- _STATIC_SKILL_ALIASES: frozenset[str] = frozenset()
303
- """Skill names that have a dedicated top-level slash command in `COMMANDS`.
304
-
305
- Only list skills whose `/skill:<name>` autocomplete entry is redundant because
306
- a `/<name>` convenience alias exists. Do **not** add every command name here —
307
- that would suppress unrelated user skills that share a name with a slash command.
308
- """
309
-
310
-
311
347
  def build_skill_commands_from_wire(
312
348
  rows: list[dict[str, Any]],
313
349
  ) -> list[tuple[str, str, str]]:
@@ -322,7 +358,7 @@ def build_skill_commands_from_wire(
322
358
  tuples: list[tuple[str, str, str]] = []
323
359
  for row in rows:
324
360
  name = str(row.get("name", "")).strip().lower()
325
- if not name or name in _STATIC_SKILL_ALIASES:
361
+ if not name:
326
362
  continue
327
363
  desc = str(row.get("description", "")).strip()
328
364
  tuples.append((f"/skill:{name}", desc, name))
@@ -338,17 +374,10 @@ def build_skill_commands(
338
374
  Each skill becomes a `/skill:<name>` entry with its description
339
375
  and the skill name as a hidden keyword for fuzzy matching.
340
376
 
341
- Skills listed in `_STATIC_SKILL_ALIASES` are excluded to avoid duplicate
342
- autocomplete entries when a top-level alias exists.
343
-
344
377
  Args:
345
378
  skills: List of discovered skill metadata.
346
379
 
347
380
  Returns:
348
381
  List of `(name, description, hidden_keywords)` tuples.
349
382
  """
350
- return [
351
- (f"/skill:{skill['name']}", skill["description"], skill["name"])
352
- for skill in skills
353
- if skill["name"] not in _STATIC_SKILL_ALIASES
354
- ]
383
+ return [(f"/skill:{skill['name']}", skill["description"], skill["name"]) for skill in skills]
@@ -153,6 +153,8 @@ def show_cron_add(console: Console, data: dict[str, Any]) -> None:
153
153
  # ---------------------------------------------------------------------------
154
154
 
155
155
  KEYBOARD_SHORTCUTS: dict[str, str] = {
156
+ "Enter": "Submit message; with slash suggestions, run or fill for more input",
157
+ "Tab": "Accept slash or @ file autocomplete (never submits)",
156
158
  "Esc": "Dismiss modal, plan overlay, or autocomplete",
157
159
  "Ctrl+D": "Type exit, quit, or /quit to exit the TUI",
158
160
  "Ctrl+C": "Clear input or interrupt running agent/shell",
@@ -535,6 +535,15 @@ _EXECUTE_WAVE_UI_COALESCE_SEC = 0.2
535
535
  _CHUNK_YIELD_INTERVAL = 12
536
536
  _CHUNK_YIELD_BUDGET_SEC = 0.016
537
537
 
538
+ # Hard cap on how long an attach-only read (``skip_daemon_send_turn``) waits
539
+ # for the first progress event before giving up. A stale ``live`` probe can
540
+ # leave the TUI attached to a phantom follow-on turn whose runner already
541
+ # exited; without this bound the thinking row spins for minutes until the
542
+ # daemon's 5-minute status reconciliation catches up. ``TimeoutError`` raised
543
+ # here is caught by the normal agent-execution error path, which falls back to
544
+ # ``_process_next_from_queue``.
545
+ _ATTACH_ONLY_IDLE_TIMEOUT_S = 45.0
546
+
538
547
 
539
548
  class TurnToolUiCoalescer:
540
549
  """Batch tool-card repaints, dedupe wire kwargs, and yield during dense streams."""
@@ -3139,7 +3148,9 @@ async def execute_task_textual(
3139
3148
  adapter._clarification_answers_pending = None
3140
3149
  try:
3141
3150
  if skip_daemon_send_turn:
3142
- chunk_source = daemon_session.iter_turn_chunks()
3151
+ chunk_source = daemon_session.iter_turn_chunks(
3152
+ idle_timeout_s=_ATTACH_ONLY_IDLE_TIMEOUT_S
3153
+ )
3143
3154
  else:
3144
3155
  subagent_name, routed_text = parse_subagent_from_input(final_input)
3145
3156
  if subagent_name is None and sticky_preferred_subagent:
@@ -17,6 +17,7 @@ from enum import StrEnum
17
17
  from pathlib import Path
18
18
  from typing import TYPE_CHECKING, Protocol
19
19
 
20
+ from soothe_cli.tui.command_registry import EnterAction, enter_action_for
20
21
  from soothe_cli.tui.path_utils import path_is_file
21
22
  from soothe_cli.tui.project_utils import find_project_root
22
23
 
@@ -39,7 +40,7 @@ class CompletionResult(StrEnum):
39
40
 
40
41
  IGNORED = "ignored" # Key not handled, let default behavior proceed
41
42
  HANDLED = "handled" # Key handled, prevent default
42
- SUBMIT = "submit" # Key triggers submission (e.g., Enter on slash command)
43
+ SUBMIT = "submit" # Complete + submit (one-stage slash Enter)
43
44
 
44
45
 
45
46
  class CompletionView(Protocol):
@@ -235,6 +236,10 @@ class SlashCommandController:
235
236
  def on_key(self, event: events.Key, _text: str, cursor_index: int) -> CompletionResult:
236
237
  """Handle key events for navigation and selection.
237
238
 
239
+ Enter follows each command's `enter_action`: one-stage commands submit
240
+ immediately; two-stage commands (skills, query routers) only complete
241
+ into the input. Tab always completes without submitting.
242
+
238
243
  Returns:
239
244
  CompletionResult indicating how the key was handled.
240
245
  """
@@ -247,7 +252,10 @@ class SlashCommandController:
247
252
  return CompletionResult.HANDLED
248
253
  return CompletionResult.IGNORED
249
254
  case "enter":
250
- if self._apply_selected_completion(cursor_index):
255
+ selected = self._suggestions[self._selected_index][0]
256
+ if not self._apply_selected_completion(cursor_index):
257
+ return CompletionResult.HANDLED
258
+ if enter_action_for(selected) == EnterAction.EXECUTE:
251
259
  return CompletionResult.SUBMIT
252
260
  return CompletionResult.HANDLED
253
261
  case "down":
@@ -49,7 +49,8 @@ def build_keyboard_shortcut_rows() -> list[tuple[str, str]]:
49
49
  """
50
50
  newline = newline_shortcut()
51
51
  return [
52
- ("Enter", "Submit message"),
52
+ ("Enter", "Submit message; with slash suggestions, run or fill for more input"),
53
+ ("Tab", "Accept slash or @ file autocomplete (never submits)"),
53
54
  (
54
55
  f"{newline}, Shift+Enter, Alt+Enter, Ctrl+Enter",
55
56
  "Insert newline in the chat input",