soothe-cli 0.5.26__tar.gz → 0.5.28__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 (113) hide show
  1. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/.gitignore +1 -0
  2. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/PKG-INFO +1 -1
  3. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/daemon.py +0 -1
  4. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/main.py +15 -0
  5. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/config/cli_config.py +4 -0
  6. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/headless/processor.py +2 -5
  7. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/policy/display_policy.py +5 -8
  8. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/transport/session.py +36 -8
  9. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/turn/prepare.py +2 -2
  10. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_app.py +5 -3
  11. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_execution.py +61 -0
  12. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_messages_mixin.py +66 -9
  13. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_startup.py +8 -1
  14. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/app.tcss +9 -4
  15. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/preview_limits.py +2 -2
  16. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/textual_adapter.py +175 -1
  17. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/messages.py +353 -74
  18. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/status.py +106 -19
  19. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/README.md +0 -0
  20. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/pyproject.toml +0 -0
  21. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/__init__.py +0 -0
  22. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/__init__.py +0 -0
  23. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/commands/__init__.py +0 -0
  24. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
  25. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  26. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  27. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/__init__.py +0 -0
  28. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  29. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/headless.py +0 -0
  30. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  31. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/cli/execution/launcher.py +0 -0
  32. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/config/__init__.py +0 -0
  33. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/config/loader.py +0 -0
  34. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/config/logging_setup.py +0 -0
  35. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/__init__.py +0 -0
  36. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  37. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/parse/_utils.py +0 -0
  38. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  39. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  40. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/parse/tool_message_format.py +0 -0
  41. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  42. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  43. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  44. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/async_renderer_protocol.py +0 -0
  45. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  46. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  47. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  48. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  49. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  50. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  51. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  52. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/state/step_router.py +0 -0
  53. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  54. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/state/transcript.py +0 -0
  55. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/task_scope.py +0 -0
  56. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  57. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  58. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  59. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  60. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/runtime/wire/messages.py +0 -0
  61. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/__init__.py +0 -0
  62. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/_cli_context.py +0 -0
  63. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/_env_vars.py +0 -0
  64. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/_version.py +0 -0
  65. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/__init__.py +0 -0
  66. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_commands.py +0 -0
  67. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_history.py +0 -0
  68. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_model.py +0 -0
  69. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_module_init.py +0 -0
  70. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/app/_ui.py +0 -0
  71. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/binding.py +0 -0
  72. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/command_registry.py +0 -0
  73. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/commands/__init__.py +0 -0
  74. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/commands/command_router.py +0 -0
  75. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
  76. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  77. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/config.py +0 -0
  78. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/file_change_notify.py +0 -0
  79. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  80. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/hooks.py +0 -0
  81. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/input.py +0 -0
  82. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/media_utils.py +0 -0
  83. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/model_config.py +0 -0
  84. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/path_utils.py +0 -0
  85. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/project_utils.py +0 -0
  86. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/sessions.py +0 -0
  87. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/skills/__init__.py +0 -0
  88. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/skills/invocation.py +0 -0
  89. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/skills/load.py +0 -0
  90. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/theme.py +0 -0
  91. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/tips.py +0 -0
  92. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/tool_display.py +0 -0
  93. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/unicode_security.py +0 -0
  94. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/update_check.py +0 -0
  95. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  96. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/_links.py +0 -0
  97. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  98. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
  99. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  100. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
  101. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  102. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/diff.py +0 -0
  103. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/editor.py +0 -0
  104. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  105. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/history.py +0 -0
  106. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/loading.py +0 -0
  107. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  108. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  109. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  110. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  111. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  112. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  113. {soothe_cli-0.5.26 → soothe_cli-0.5.28}/src/soothe_cli/tui/widgets/welcome.py +0 -0
@@ -217,3 +217,4 @@ _bmad
217
217
  __MACOSX
218
218
  .qoder
219
219
  .soothe
220
+ deploy/config.yml
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.5.26
3
+ Version: 0.5.28
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
@@ -91,7 +91,6 @@ async def _run_headless_session_once(
91
91
  status_event = await bootstrap_loop_session(
92
92
  client,
93
93
  resume_loop_id=resume_loop_id,
94
- verbosity="normal",
95
94
  stream_delivery=stream_delivery,
96
95
  workspace=cli_ws,
97
96
  subscribe_timeout_s=_SESSION_BOOTSTRAP_TIMEOUT_S,
@@ -120,6 +120,17 @@ def main(
120
120
  help="Path to additional MCP server config (JSON/YAML) to merge into daemon config.",
121
121
  ),
122
122
  ] = None,
123
+ mode: Annotated[
124
+ str | None,
125
+ typer.Option(
126
+ "--mode",
127
+ help=(
128
+ "Clarification mode: 'manual' (relay AI questions to you) or "
129
+ "'auto' (veritas auto-answers). Default: 'manual' when stdin is "
130
+ "a TTY, 'auto' otherwise."
131
+ ),
132
+ ),
133
+ ] = None,
123
134
  show_help: Annotated[ # noqa: FBT002
124
135
  bool,
125
136
  typer.Option("--help", "-h", is_flag=True, help="Show this message and exit."),
@@ -154,6 +165,9 @@ def main(
154
165
  raise typer.Exit
155
166
 
156
167
  home_path = Path(soothe_home).expanduser() if soothe_home else Path(SOOTHE_HOME)
168
+ if mode is not None and mode not in ("manual", "auto"):
169
+ typer.echo(f"Invalid --mode {mode!r}; expected 'manual' or 'auto'.", err=True)
170
+ raise typer.Exit(code=2)
157
171
  cli_cfg = CLIConfig(
158
172
  daemon_host=daemon_host,
159
173
  daemon_port=daemon_port,
@@ -161,6 +175,7 @@ def main(
161
175
  render_markdown=render_markdown,
162
176
  output_streaming_enabled=streaming,
163
177
  output_streaming_mode=streaming_mode,
178
+ clarification_mode=mode,
164
179
  soothe_home=home_path,
165
180
  )
166
181
  set_runtime_config(cli_cfg)
@@ -37,6 +37,10 @@ class CLIConfig:
37
37
  output_streaming_mode: str | None = None
38
38
  """Override daemon streaming mode: 'streaming' or 'batch'."""
39
39
 
40
+ # RFC-622: clarification relay mode
41
+ clarification_mode: str | None = None
42
+ """'manual' (relay to human) or 'auto' (veritas auto-answer). None = auto-detect from TTY."""
43
+
40
44
  # Paths
41
45
  soothe_home: Path = field(default_factory=lambda: Path(SOOTHE_HOME))
42
46
 
@@ -273,8 +273,6 @@ class EventProcessor:
273
273
  """Display logic for RFC-614 loop-tagged assistant messages (IG-317 / IG-343)."""
274
274
  if phase not in LOOP_ASSISTANT_OUTPUT_PHASES:
275
275
  return
276
- if not self._presentation.tier_visible(VerbosityTier.QUIET):
277
- return
278
276
 
279
277
  accum_key = _loop_msg_accum_event_key(phase)
280
278
  streaming_config = self._get_effective_streaming_config()
@@ -925,8 +923,7 @@ class EventProcessor:
925
923
  etype = data.get("type", "")
926
924
 
927
925
  if self._headless_output:
928
- category = classify_event_to_tier(etype, namespace)
929
- if category == VerbosityTier.QUIET and "error" in etype:
926
+ if etype.startswith("soothe.error."):
930
927
  error_text = data.get("error", data.get("message", str(etype)))
931
928
  self._renderer.on_error(error_text)
932
929
  return
@@ -938,7 +935,7 @@ class EventProcessor:
938
935
  # Update plan state and call specific hooks
939
936
  if etype == PLAN_CREATED:
940
937
  self._handle_plan_created(data)
941
- elif category == VerbosityTier.QUIET and "error" in etype:
938
+ elif etype.startswith("soothe.error."):
942
939
  error_text = data.get("error", data.get("message", str(etype)))
943
940
  self._renderer.on_error(error_text)
944
941
  elif self._presentation.tier_visible(category):
@@ -38,13 +38,10 @@ from soothe_sdk.ux import classify_event_to_tier
38
38
  # Event types that should NEVER be shown (internal implementation details)
39
39
  INTERNAL_EVENT_TYPES: frozenset[str] = frozenset()
40
40
 
41
- # Event types to skip in progress display (handled by plan update mechanism or not rendered)
42
- SKIP_EVENT_TYPES = frozenset(
43
- {
44
- # Plan events handled by renderer's plan update mechanism
45
- "soothe.cognition.plan.batch.started",
46
- }
47
- )
41
+ # Event types to skip in progress display (handled by plan update mechanism or not rendered).
42
+ # `soothe.cognition.plan.batch.started` was moved to `soothe.internal.plan.batch.started`
43
+ # and is now filtered automatically by `is_internal_event`.
44
+ SKIP_EVENT_TYPES: frozenset[str] = frozenset()
48
45
 
49
46
  PLAN_EVENT_TYPES = frozenset(
50
47
  {
@@ -152,7 +149,7 @@ class DisplayPolicy:
152
149
 
153
150
  def is_internal_event(self, event_type: str) -> bool:
154
151
  """Check if this is an internal (never-shown) event."""
155
- return event_type in INTERNAL_EVENT_TYPES or "internal" in event_type
152
+ return event_type in INTERNAL_EVENT_TYPES or event_type.startswith("soothe.internal.")
156
153
 
157
154
 
158
155
  # =============================================================================
@@ -64,7 +64,6 @@ class TuiDaemonSession:
64
64
  status_event = await bootstrap_loop_session(
65
65
  self._client,
66
66
  resume_loop_id=resume_loop_id,
67
- verbosity="normal",
68
67
  stream_delivery=stream_delivery,
69
68
  workspace=self._workspace,
70
69
  )
@@ -147,6 +146,9 @@ class TuiDaemonSession:
147
146
  model: str | None = None,
148
147
  model_params: dict[str, Any] | None = None,
149
148
  attachments: list[dict[str, str]] | None = None,
149
+ clarification_mode: str | None = None,
150
+ clarification_answer: bool = False,
151
+ clarification_answers: list[str] | None = None,
150
152
  ) -> None:
151
153
  """Send a new user turn to the daemon."""
152
154
  if not self._loop_id:
@@ -160,6 +162,9 @@ class TuiDaemonSession:
160
162
  model=model,
161
163
  model_params=model_params,
162
164
  attachments=attachments,
165
+ clarification_mode=clarification_mode,
166
+ clarification_answer=clarification_answer,
167
+ clarification_answers=clarification_answers,
163
168
  )
164
169
 
165
170
  async def cancel_remote_query(self) -> None:
@@ -343,16 +348,31 @@ class TuiDaemonSession:
343
348
  await self._ensure_rpc_connected()
344
349
  return await self._rpc_client.get_mcp_status(timeout=15.0)
345
350
 
346
- async def invoke_skill(self, skill: str, args: str = "") -> dict[str, Any]:
351
+ async def invoke_skill(
352
+ self,
353
+ skill: str,
354
+ args: str = "",
355
+ *,
356
+ clarification_mode: str | None = None,
357
+ ) -> dict[str, Any]:
347
358
  """Resolve ``SKILL.md`` on the daemon and receive UI echo before the turn streams.
348
359
 
349
360
  Uses the loop WebSocket (``_client``), not the metadata RPC socket. The daemon
350
361
  enqueues the composed prompt on ``_client_subscribed_loop_id``; the RPC-only
351
362
  connection never receives ``loop_subscribe``, so skill turns would otherwise
352
363
  never start (no ``loop_input`` queue entry).
364
+
365
+ ``clarification_mode`` is forwarded so slash-skill turns honor the
366
+ TUI's Manual/Auto badge instead of always falling back to the daemon's
367
+ configured default (RFC-622).
353
368
  """
354
369
  async with self._read_lock:
355
- return await self._client.invoke_skill(skill, args, timeout=120.0)
370
+ return await self._client.invoke_skill(
371
+ skill,
372
+ args,
373
+ timeout=120.0,
374
+ clarification_mode=clarification_mode,
375
+ )
356
376
 
357
377
  async def _ensure_rpc_connected(self) -> None:
358
378
  """Ensure dedicated RPC client is connected."""
@@ -404,6 +424,7 @@ class TuiDaemonSession:
404
424
  values: dict[str, Any],
405
425
  *,
406
426
  timeout: float = 10.0,
427
+ as_node: str | None = None,
407
428
  ) -> None:
408
429
  """Merge partial state into the loop on the daemon host (``loop_state_update`` RPC).
409
430
 
@@ -411,6 +432,9 @@ class TuiDaemonSession:
411
432
  loop_id: AgentLoop id.
412
433
  values: Channel updates (e.g. ``messages``) in JSON-serializable form.
413
434
  timeout: RPC wait budget in seconds.
435
+ as_node: Optional LangGraph node to attribute the write to. When
436
+ omitted, the daemon picks a sensible default for the underlying
437
+ agent graph.
414
438
  """
415
439
  lid = str(loop_id or "").strip()
416
440
  if not lid:
@@ -420,14 +444,18 @@ class TuiDaemonSession:
420
444
  if not isinstance(payload_values, dict):
421
445
  return
422
446
 
447
+ payload: dict[str, Any] = {
448
+ "type": "loop_state_update",
449
+ "loop_id": lid,
450
+ "values": payload_values,
451
+ }
452
+ if as_node:
453
+ payload["as_node"] = as_node
454
+
423
455
  async with self._rpc_lock:
424
456
  await self._ensure_rpc_connected()
425
457
  await self._rpc_client.request_response(
426
- {
427
- "type": "loop_state_update",
428
- "loop_id": lid,
429
- "values": payload_values,
430
- },
458
+ payload,
431
459
  response_type="loop_state_update_response",
432
460
  timeout=timeout,
433
461
  )
@@ -18,7 +18,6 @@ from soothe_sdk.core.events import (
18
18
  AGENT_LOOP_STEP_QUEUED,
19
19
  AGENT_LOOP_STEP_STARTED,
20
20
  )
21
- from soothe_sdk.core.verbosity import VerbosityTier
22
21
  from soothe_sdk.ux.classification import classify_event_to_tier
23
22
  from soothe_sdk.ux.loop_stream import assistant_output_phase
24
23
  from soothe_sdk.ux.stream_tool_wire import STREAM_TOOL_CALL_UPDATE, TOOL_CALL_UPDATES_BATCH
@@ -165,7 +164,8 @@ def _prepare_custom_chunk(
165
164
  prepared.skip = True
166
165
  return prepared
167
166
 
168
- if category == VerbosityTier.QUIET and "error" not in event_type:
167
+ # Output events have a dedicated renderer path; drop the generic copy.
168
+ if event_type.startswith("soothe.output."):
169
169
  prepared.skip = True
170
170
  return prepared
171
171
 
@@ -203,6 +203,10 @@ class SootheApp(
203
203
 
204
204
  self._model_params_override: dict[str, Any] | None = None
205
205
 
206
+ # RFC-622: clarification relay mode. Seeded from --mode flag (CLIConfig);
207
+ # default to Auto so loops keep moving when the user hasn't opted in.
208
+ self._clarification_mode: str = getattr(daemon_config, "clarification_mode", None) or "auto"
209
+
206
210
  self._mcp_tool_count = sum(len(s.tools) for s in (mcp_server_info or []))
207
211
 
208
212
  self._status_bar: StatusBar | None = None
@@ -352,6 +356,4 @@ class SootheApp(
352
356
  image_tracker=self._image_tracker,
353
357
  id="input-area",
354
358
  )
355
-
356
- # Status bar at bottom
357
- yield StatusBar(cwd=self._cwd, id="status-bar")
359
+ yield StatusBar(cwd=self._cwd, id="status-bar")
@@ -40,6 +40,7 @@ from soothe_cli.tui.widgets.chat_input import ChatInput
40
40
  from soothe_cli.tui.widgets.messages import (
41
41
  AppMessage,
42
42
  AssistantMessage,
43
+ ClarificationInputMessage,
43
44
  ErrorMessage,
44
45
  QueuedUserMessage,
45
46
  UserMessage,
@@ -188,6 +189,65 @@ class _ExecutionMixin:
188
189
  if self._status_bar:
189
190
  self._status_bar.set_mode(event.mode)
190
191
 
192
+ async def on_clarification_input_message_submitted(
193
+ self,
194
+ event: ClarificationInputMessage.Submitted,
195
+ ) -> None:
196
+ """Forward a clarification answer to the daemon and refresh the step card.
197
+
198
+ Wired to ``ClarificationInputMessage.Submitted`` (RFC-622). The inline
199
+ widget collects per-question answers; here we render them on the
200
+ matching step card and trigger ``_run_agent_task`` with the answer
201
+ text. ``execute_task_textual`` reads ``adapter._clarification_pending``
202
+ and attaches ``clarification_answer=True`` to the wire so the daemon
203
+ resumes the suspended loop graph rather than starting a new turn.
204
+ """
205
+ event.stop()
206
+ adapter = self._ui_adapter
207
+ if adapter is None:
208
+ return
209
+
210
+ # Render answers on the corresponding step card so the user sees
211
+ # confirmation in-place. ``set_clarification_details`` handles styling
212
+ # and the detail-area layout.
213
+ step_widget = adapter._current_step_messages.get(event.step_id)
214
+ if step_widget is not None:
215
+ try:
216
+ step_widget.set_clarification_details(
217
+ questions=list(event.questions),
218
+ answers=list(event.answers),
219
+ source="human",
220
+ confidence=None,
221
+ )
222
+ except Exception: # noqa: BLE001
223
+ logger.debug("Failed to render clarification answers on step card", exc_info=True)
224
+
225
+ # Drop tracking; the inline widget itself stays mounted (disabled) so
226
+ # the user can still see what they answered.
227
+ adapter._clarification_input_by_step.pop(event.step_id, None)
228
+
229
+ non_empty = [a for a in event.answers if a.strip()]
230
+ if not non_empty:
231
+ return
232
+ # Send the answers as a structured list so the daemon resumes the
233
+ # graph with one answer per question instead of broadcasting a single
234
+ # concatenated string. ``content`` carries a human-readable summary
235
+ # for clients that look at it; the authoritative payload is the
236
+ # ``clarification_answers`` wire field.
237
+ payload_text = (
238
+ non_empty[0]
239
+ if len(non_empty) == 1
240
+ else " | ".join(f"A{i + 1}: {a}" for i, a in enumerate(event.answers) if a.strip())
241
+ )
242
+ adapter._clarification_answers_pending = list(event.answers)
243
+
244
+ # Hand off to the standard turn pipeline. ``execute_task_textual``
245
+ # snapshots ``adapter._clarification_pending`` (still True) and sets
246
+ # the wire ``clarification_answer`` flag plus the ``clarification_answers``
247
+ # list, then clears the persisted flag so a follow-up turn is treated
248
+ # as a new goal.
249
+ await self._run_agent_task(payload_text)
250
+
191
251
  async def _handle_shell_command(self, command: str) -> None:
192
252
  """Handle a shell command (! prefix).
193
253
 
@@ -821,6 +881,7 @@ class _ExecutionMixin:
821
881
  ),
822
882
  turn_stats=turn_stats,
823
883
  skip_daemon_send_turn=skip_daemon_send_turn,
884
+ clarification_mode=getattr(self, "_clarification_mode", None),
824
885
  )
825
886
  except Exception as e: # Resilient tool rendering
826
887
  logger.exception("Agent execution failed")
@@ -396,17 +396,14 @@ class _MessagesMixin:
396
396
  """Handle Ctrl+C - interrupt agent or quit on double press.
397
397
 
398
398
  Priority order:
399
- 0. If text is selected, copy it (Textual screen.copy_text semantics)
400
399
  1. If shell command is running, kill it
401
400
  2. If agent is running, interrupt it (preserve input)
402
401
  3. If double press (quit_pending), quit
403
402
  4. Otherwise clear draft input and show quit hint
404
- """
405
- from soothe_cli.tui.widgets.clipboard import copy_selection_to_clipboard
406
403
 
407
- if copy_selection_to_clipboard(self):
408
- self._quit_pending = False
409
- return
404
+ Note: Copying selected text is bound to Ctrl+Y (`action_copy_selection`)
405
+ so Ctrl+C is reserved for interrupt/quit only.
406
+ """
410
407
  # If shell command is running, cancel the worker
411
408
  if self._shell_running and self._shell_worker:
412
409
  self._cancel_worker(self._shell_worker)
@@ -572,11 +569,34 @@ class _MessagesMixin:
572
569
  super().exit(result=result, return_code=return_code, message=message)
573
570
 
574
571
  def action_shift_tab(self) -> None:
575
- """Shift+Tab: navigate loop selector filters."""
572
+ """Shift+Tab: navigate loop selector when active, otherwise flip relay mode.
573
+
574
+ - In the LoopSelectorScreen, defer to its filter navigation.
575
+ - On the main screen, toggle the clarification relay mode
576
+ (Auto ↔ Manual) so users can switch between the veritas
577
+ auto-answerer and human-in-the-loop relay at any point (RFC-622).
578
+ """
576
579
  from soothe_cli.tui.widgets.loop_selector import LoopSelectorScreen
577
580
 
578
581
  if isinstance(self.screen, LoopSelectorScreen):
579
582
  self.screen.action_focus_previous_filter()
583
+ return
584
+ self.toggle_clarification_mode()
585
+
586
+ def toggle_clarification_mode(self) -> None:
587
+ """Flip clarification mode between Auto and Manual and refresh the badge.
588
+
589
+ The new mode is held on the app (``self._clarification_mode``) and
590
+ attached to every subsequent ``send_turn`` via the
591
+ ``clarification_mode`` field of the daemon's ``loop_input`` payload
592
+ (RFC-622). The status-bar badge updates immediately; no toast is
593
+ emitted because the badge itself is the visual feedback.
594
+ """
595
+ current = getattr(self, "_clarification_mode", "auto")
596
+ new_mode = "manual" if current == "auto" else "auto"
597
+ self._clarification_mode = new_mode
598
+ if self._status_bar is not None:
599
+ self._status_bar.set_clarification_mode(new_mode)
580
600
 
581
601
  def action_toggle_tool_output(self) -> None:
582
602
  """Toggle expand/collapse of the most recent skill or tool card."""
@@ -648,16 +668,30 @@ class _MessagesMixin:
648
668
  When the user opens a link via `webbrowser.open`, OS focus shifts to
649
669
  the browser. On returning to the terminal, Textual fires `AppFocus`
650
670
  (requires a terminal that supports FocusIn events). Re-focusing the chat
651
- input here keeps it ready for typing.
671
+ input here keeps it ready for typing — but only when no other focusable
672
+ widget (e.g., an inline clarification Input) currently owns focus, so
673
+ the user does not lose an in-progress answer to a tab-out and back.
652
674
  """
653
675
  if not self._chat_input:
654
676
  return
655
677
  if self.screen.is_modal:
656
678
  return
679
+ focused = self.focused
680
+ if focused is not None and not self._is_input_focused():
681
+ return
657
682
  self._chat_input.focus_input()
658
683
 
659
684
  def on_click(self, _event: Click) -> None:
660
- """Handle clicks anywhere in the terminal to focus on the command line."""
685
+ """Focus the chat input when the click landed on non-focusable chrome.
686
+
687
+ Original intent: clicking the dead transcript area should drop the
688
+ caret back in the prompt. But this handler bubbles for *every* click,
689
+ so an unconditional refocus also steals focus from inline focusable
690
+ widgets (e.g., the ClarificationInputMessage answer field) on the same
691
+ click that Textual just used to focus them. Skip the refocus whenever
692
+ the click landed on a focusable widget — Textual's default focus
693
+ handling already does the right thing there.
694
+ """
661
695
  if not self._chat_input:
662
696
  return
663
697
  # Preserve an active text selection (focus would clear highlight for copy).
@@ -665,8 +699,31 @@ class _MessagesMixin:
665
699
 
666
700
  if screen_has_text_selection(self.screen):
667
701
  return
702
+ if self._click_landed_on_focusable(_event):
703
+ return
668
704
  self.call_after_refresh(self._chat_input.focus_input)
669
705
 
706
+ def _click_landed_on_focusable(self, event: Click) -> bool:
707
+ """Return True if the click target (or any ancestor) is focusable.
708
+
709
+ Walks up from `event.widget` toward the screen. Stops at the screen
710
+ so non-focusable container chrome (Containers, Statics) does not
711
+ suppress the dead-area refocus behavior.
712
+ """
713
+ widget = getattr(event, "widget", None)
714
+ if widget is None:
715
+ return False
716
+ node: Any = widget
717
+ screen = self.screen
718
+ while node is not None and node is not screen:
719
+ try:
720
+ if getattr(node, "can_focus", False):
721
+ return True
722
+ except Exception: # noqa: BLE001
723
+ pass
724
+ node = getattr(node, "parent", None)
725
+ return False
726
+
670
727
  def on_text_selected(self, _event: TextSelected) -> None:
671
728
  """Copy selected transcript text on mouse release.
672
729
 
@@ -119,6 +119,9 @@ class _StartupMixin:
119
119
  self._status_bar = self.query_one("#status-bar", StatusBar)
120
120
  self._chat_input = self.query_one("#input-area", ChatInput)
121
121
 
122
+ # Seed the badge from the app-level clarification mode (CLI flag, default Auto).
123
+ self._status_bar.set_clarification_mode(self._clarification_mode)
124
+
122
125
  with suppress(NoMatches):
123
126
  banner = self.query_one("#welcome-banner", WelcomeBanner)
124
127
  self._status_bar.set_session_tip(banner.session_tip)
@@ -378,7 +381,11 @@ class _StartupMixin:
378
381
  )
379
382
  return
380
383
  try:
381
- resp = await self._daemon_session.invoke_skill(skill_name, args)
384
+ resp = await self._daemon_session.invoke_skill(
385
+ skill_name,
386
+ args,
387
+ clarification_mode=getattr(self, "_clarification_mode", None),
388
+ )
382
389
  except RuntimeError as exc:
383
390
  await self._mount_message(UserMessage(command))
384
391
  await self._mount_message(AppMessage(str(exc)))
@@ -52,10 +52,11 @@ Screen {
52
52
  height: auto;
53
53
  }
54
54
 
55
- /* Bottom app container - holds ChatInput (now inside scroll) */
55
+ /* Bottom app container - holds thinking row, ChatInput, and status bar. */
56
56
  #bottom-app-container {
57
57
  height: auto;
58
58
  margin-top: 0;
59
+ margin-bottom: 1;
59
60
  padding: 0 1;
60
61
  }
61
62
 
@@ -73,11 +74,15 @@ Screen {
73
74
  max-height: 25;
74
75
  }
75
76
 
76
- /* Status bar */
77
+ /* Status bar lives inside #bottom-app-container — share its padding only. */
77
78
  #status-bar {
78
79
  height: 1;
79
- dock: bottom;
80
- margin-bottom: 1;
80
+ padding: 0;
81
+ }
82
+
83
+ #status-bar ClarificationModeBadge {
84
+ margin-left: 1;
85
+ margin-right: 1;
81
86
  }
82
87
 
83
88
  /* Non-blocking filesystem change previews (write / edit / delete) */
@@ -15,10 +15,10 @@ from typing import Final
15
15
  STEP_CARD_SHOW_TOOL_ROW_DETAILS: Final[bool] = False
16
16
 
17
17
  # Latest per-tool invocation lines shown per scope (task branch vs main-agent branch).
18
- STEP_CARD_TOOL_ACTIVITY_PREVIEW_COUNT: Final[int] = 3
18
+ STEP_CARD_TOOL_ACTIVITY_PREVIEW_COUNT: Final[int] = 5
19
19
 
20
20
  # When estimated body lines exceed this count, the card auto-collapses (strict `>`).
21
- STEP_TASK_CARD_COLLAPSE_LINE_THRESHOLD: Final[int] = 3
21
+ STEP_TASK_CARD_COLLAPSE_LINE_THRESHOLD: Final[int] = 5
22
22
 
23
23
  # --- Skill invocation cards (`SkillMessage` collapsed SKILL.md body) ---
24
24
  SKILL_CARD_PREVIEW_LINES: Final[int] = 4