soothe-cli 0.6.8__tar.gz → 0.6.9__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 (115) hide show
  1. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/PKG-INFO +1 -1
  2. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/commands/autopilot_cmd.py +74 -13
  3. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/transport/session.py +8 -0
  4. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/app/_app.py +1 -0
  5. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/app/_execution.py +3 -0
  6. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/app/_model.py +57 -1
  7. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/app/_startup.py +7 -0
  8. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/commands/slash_commands.py +7 -0
  9. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/input.py +37 -26
  10. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/preview_limits.py +2 -2
  11. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/textual_adapter.py +24 -1
  12. soothe_cli-0.6.9/src/soothe_cli/tui/widgets/autopilot_dashboard.py +614 -0
  13. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/autopilot_screen.py +4 -3
  14. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/chat_input.py +37 -5
  15. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/messages.py +75 -7
  16. soothe_cli-0.6.8/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -318
  17. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/.gitignore +0 -0
  18. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/README.md +0 -0
  19. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/pyproject.toml +0 -0
  20. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/__init__.py +0 -0
  21. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/__init__.py +0 -0
  22. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/commands/__init__.py +0 -0
  23. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  24. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  25. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
  26. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/execution/__init__.py +0 -0
  27. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/execution/daemon.py +0 -0
  28. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  29. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/execution/headless.py +0 -0
  30. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  31. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/execution/launcher.py +0 -0
  32. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/cli/main.py +0 -0
  33. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/config/__init__.py +0 -0
  34. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/config/cli_config.py +0 -0
  35. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/config/loader.py +0 -0
  36. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/config/logging_setup.py +0 -0
  37. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/__init__.py +0 -0
  38. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/headless/processor.py +0 -0
  39. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  40. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/parse/_utils.py +0 -0
  41. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  42. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  43. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/parse/tool_message_format.py +0 -0
  44. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  45. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  46. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  47. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  48. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/presentation/async_renderer_protocol.py +0 -0
  49. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  50. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  51. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  52. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  53. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  54. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  55. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  56. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/state/step_router.py +0 -0
  57. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  58. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/state/transcript.py +0 -0
  59. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/task_scope.py +0 -0
  60. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  61. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  62. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  63. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  64. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  65. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/runtime/wire/messages.py +0 -0
  66. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/__init__.py +0 -0
  67. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/_cli_context.py +0 -0
  68. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/_env_vars.py +0 -0
  69. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/_version.py +0 -0
  70. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/app/__init__.py +0 -0
  71. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/app/_commands.py +0 -0
  72. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/app/_history.py +0 -0
  73. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/app/_messages_mixin.py +0 -0
  74. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/app/_module_init.py +0 -0
  75. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/app/_ui.py +0 -0
  76. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/app/app.tcss +0 -0
  77. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/binding.py +0 -0
  78. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/command_registry.py +0 -0
  79. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/commands/__init__.py +0 -0
  80. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/commands/command_router.py +0 -0
  81. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  82. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/config.py +0 -0
  83. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/file_change_notify.py +0 -0
  84. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  85. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/hooks.py +0 -0
  86. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/media_utils.py +0 -0
  87. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/model_config.py +0 -0
  88. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/path_utils.py +0 -0
  89. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/project_utils.py +0 -0
  90. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/sessions.py +0 -0
  91. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/skills/__init__.py +0 -0
  92. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/skills/invocation.py +0 -0
  93. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/skills/load.py +0 -0
  94. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/theme.py +0 -0
  95. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/tips.py +0 -0
  96. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/tool_display.py +0 -0
  97. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/unicode_security.py +0 -0
  98. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/update_check.py +0 -0
  99. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  100. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/_links.py +0 -0
  101. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  102. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  103. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/diff.py +0 -0
  104. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/editor.py +0 -0
  105. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  106. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/history.py +0 -0
  107. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/loading.py +0 -0
  108. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  109. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  110. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  111. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  112. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  113. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/status.py +0 -0
  114. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  115. {soothe_cli-0.6.8 → soothe_cli-0.6.9}/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.6.8
3
+ Version: 0.6.9
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
@@ -10,6 +10,7 @@ import asyncio
10
10
  import os
11
11
  import sys
12
12
  import time
13
+ from collections import Counter
13
14
  from pathlib import Path
14
15
 
15
16
  import typer
@@ -124,12 +125,40 @@ def submit(
124
125
 
125
126
  @app.command("status")
126
127
  def status() -> None:
127
- """Show overall autopilot state from the daemon."""
128
+ """Show overall autopilot state and goal DAG summary."""
128
129
  client = _require_daemon_http()
129
130
  data = client.status()
130
- typer.echo(f"Autopilot state: {data.get('state', data.get('status', 'unknown'))}")
131
- if "active_goals" in data:
132
- typer.echo(f"Active goals: {len(data['active_goals'])}")
131
+ state = data.get("state", data.get("status", "unknown"))
132
+ running = data.get("running", False)
133
+ dreaming = data.get("dreaming", False)
134
+ typer.echo(f"Autopilot state: {state}")
135
+ typer.echo(f"Scheduling loop: {'running' if running else 'stopped'}")
136
+ if dreaming:
137
+ typer.echo("Dreaming: yes")
138
+ loop_pool = data.get("loop_pool")
139
+ if isinstance(loop_pool, dict) and loop_pool:
140
+ typer.echo(f"Worker pool: {loop_pool}")
141
+
142
+ jobs = client.list_jobs().get("jobs") or []
143
+ goals = client.list_goals().get("goals") or []
144
+ typer.echo(f"\nJobs (root goals): {len(jobs)}")
145
+ if goals:
146
+ counts = Counter(str(g.get("status", "pending")) for g in goals)
147
+ typer.echo(f"Goals in DAG: {len(goals)}")
148
+ for stat, count in sorted(counts.items()):
149
+ typer.echo(f" {stat}: {count}")
150
+ elif not jobs:
151
+ typer.echo("Goals in DAG: 0")
152
+
153
+ if jobs:
154
+ typer.echo("\nJobs:")
155
+ for j in jobs:
156
+ jid = str(j.get("id", "?"))
157
+ sid = jid[:8]
158
+ sstat = j.get("status", "pending")
159
+ sdesc = preview_first(j.get("description", ""), 50)
160
+ typer.echo(f" [{sid}] {sstat:10s} {sdesc}")
161
+ typer.echo("\nFull DAG for a job: soothe autopilot job <job_id>")
133
162
 
134
163
 
135
164
  @app.command("list")
@@ -139,8 +168,20 @@ def list_jobs(
139
168
  """List jobs (root goals) from the daemon autopilot.
140
169
 
141
170
  Jobs are user-submitted tasks. Subgoals created during autonomous
142
- execution are not shown here; use 'goal <id>' to inspect subgoals.
171
+ execution are not shown here; use ``goals`` or ``goal <id>`` for details.
143
172
  """
173
+ _list_jobs_impl(status_filter)
174
+
175
+
176
+ @app.command("jobs")
177
+ def list_jobs_alias(
178
+ status_filter: str = typer.Option("", "--status", "-s", help="Filter by status."),
179
+ ) -> None:
180
+ """Alias for ``list`` — list root autopilot jobs."""
181
+ _list_jobs_impl(status_filter)
182
+
183
+
184
+ def _list_jobs_impl(status_filter: str) -> None:
144
185
  client = _require_daemon_http()
145
186
  payload = client.list_jobs()
146
187
  jobs = payload.get("jobs") or []
@@ -158,6 +199,29 @@ def list_jobs(
158
199
  typer.echo(f" [{sid}] {sstat:10s} pri={spri:3d} {sdesc}")
159
200
 
160
201
 
202
+ @app.command("goals")
203
+ def list_goals(
204
+ status_filter: str = typer.Option("", "--status", "-s", help="Filter by status."),
205
+ ) -> None:
206
+ """List all goals in the daemon autopilot DAG (including subgoals)."""
207
+ client = _require_daemon_http()
208
+ payload = client.list_goals()
209
+ goals = payload.get("goals") or []
210
+ if not goals:
211
+ typer.echo("No goals found.")
212
+ return
213
+
214
+ for g in goals:
215
+ if status_filter and g.get("status", "") != status_filter:
216
+ continue
217
+ gid = str(g.get("id", "?"))[:8]
218
+ parent = g.get("parent_id")
219
+ parent_s = f" parent={str(parent)[:8]}" if parent else ""
220
+ desc = preview_first(g.get("description", ""), 50)
221
+ stat = g.get("status", "pending")
222
+ typer.echo(f" [{gid}] {stat:10s}{parent_s} {desc}")
223
+
224
+
161
225
  def _render_dag_tree(dag: dict, root_id: str) -> None:
162
226
  """Render DAG as ASCII tree for job visualization."""
163
227
  nodes = {n["id"]: n for n in dag.get("nodes", [])}
@@ -199,9 +263,9 @@ def _render_dag_tree(dag: dict, root_id: str) -> None:
199
263
 
200
264
  @app.command("job")
201
265
  def show_job(
202
- job_id: str = typer.Argument(..., help="Job ID to show details and DAG."),
266
+ job_id: str = typer.Argument(..., help="Job ID to show details and goal DAG."),
203
267
  ) -> None:
204
- """Show job status and DAG tree visualization.
268
+ """Show job status and goal DAG tree visualization.
205
269
 
206
270
  A job is a root goal submitted by the user. This command shows
207
271
  the job's details and the complete goal DAG under it.
@@ -211,7 +275,7 @@ def show_job(
211
275
  payload = client.get_job(job_id)
212
276
  except RuntimeError as exc:
213
277
  typer.echo(str(exc), err=True)
214
- raise typer.Exit(1)
278
+ raise typer.Exit(1) from exc
215
279
 
216
280
  job = payload.get("job")
217
281
  dag = payload.get("dag")
@@ -228,11 +292,9 @@ def show_job(
228
292
  typer.echo(f"Workspace: {job['workspace']}")
229
293
  created = job.get("created_at", "")
230
294
  if created:
231
- # Truncate timestamp for readability
232
295
  created_short = created[:19] if len(created) > 19 else created
233
296
  typer.echo(f"Created: {created_short}")
234
297
 
235
- # DAG stats from response
236
298
  active = payload.get("active_goals", 0)
237
299
  completed = payload.get("completed_goals", 0)
238
300
  total = payload.get("total_goals", 0)
@@ -240,10 +302,9 @@ def show_job(
240
302
  typer.echo(f"Completed goals: {completed}")
241
303
  typer.echo(f"Total goals: {total}")
242
304
 
243
- # DAG tree
244
- typer.echo("\nDAG:")
305
+ typer.echo("\nGoal DAG:")
245
306
  if dag:
246
- _render_dag_tree(dag, job_id)
307
+ _render_dag_tree(dag, str(job.get("id") or job_id))
247
308
  else:
248
309
  typer.echo(" (no subgoals)")
249
310
 
@@ -45,6 +45,7 @@ class TuiDaemonSession:
45
45
  self._client = WebSocketClient(url=ws_url)
46
46
  self._rpc_client = WebSocketClient(url=ws_url)
47
47
  self._loop_id: str | None = None
48
+ self._autopilot_mode: str | None = None
48
49
  self._read_lock = asyncio.Lock()
49
50
  self._rpc_lock = asyncio.Lock()
50
51
  self._rpc_connected = False
@@ -57,6 +58,11 @@ class TuiDaemonSession:
57
58
  """Active StrangeLoop id for this WebSocket session."""
58
59
  return self._loop_id
59
60
 
61
+ @property
62
+ def autopilot_mode(self) -> str | None:
63
+ """Active loop Solo/Autopilot mode when known."""
64
+ return self._autopilot_mode
65
+
60
66
  async def connect(self, *, resume_loop_id: str | None = None) -> dict[str, Any]:
61
67
  """Connect and bootstrap a daemon loop session."""
62
68
  await connect_websocket_with_retries(self._client)
@@ -77,6 +83,8 @@ class TuiDaemonSession:
77
83
  if status_event.get("type") == "error":
78
84
  raise RuntimeError(str(status_event.get("message", "daemon bootstrap failed")))
79
85
  self._loop_id = status_event.get("loop_id")
86
+ mode = status_event.get("autopilot_mode")
87
+ self._autopilot_mode = str(mode) if mode in ("solo", "autopilot") else None
80
88
  return status_event
81
89
 
82
90
  def _resolve_stream_delivery_mode(self) -> str:
@@ -177,6 +177,7 @@ class SootheApp(
177
177
  # Active StrangeLoop id; LangGraph stores it as configurable.thread_id.
178
178
  # Named `_lc_loop_id` to avoid colliding with Textual's App._thread_id.
179
179
  self._lc_loop_id = resume_loop_id
180
+ self._loop_autopilot_mode: str = "solo"
180
181
 
181
182
  self._initial_prompt = initial_prompt
182
183
 
@@ -570,6 +570,7 @@ class _ExecutionMixin:
570
570
  )
571
571
  self._session_state.loop_id = new_loop_id
572
572
  self._lc_loop_id = new_loop_id
573
+ self._apply_loop_autopilot_mode(status_event.get("autopilot_mode"))
573
574
  try:
574
575
  banner = self.query_one("#welcome-banner", WelcomeBanner)
575
576
  banner.update_loop_id(new_loop_id)
@@ -650,6 +651,8 @@ class _ExecutionMixin:
650
651
  await self._submit_autopilot_job(args)
651
652
  elif cmd == "/autopilot-dashboard":
652
653
  await self._show_autopilot_dashboard()
654
+ elif cmd == "/autopilot-toggle":
655
+ await self._toggle_autopilot_mode()
653
656
  elif cmd == "/mcp":
654
657
  await self._show_mcp_viewer()
655
658
  elif cmd == "/theme":
@@ -265,12 +265,26 @@ class _ModelMixin:
265
265
  return None
266
266
  return resp.get("servers")
267
267
 
268
+ def _apply_loop_autopilot_mode(self, mode: str | None) -> None:
269
+ """Sync local Solo/Autopilot mode from daemon bootstrap or toggle events."""
270
+ if mode not in ("solo", "autopilot"):
271
+ return
272
+ self._loop_autopilot_mode = mode
273
+ if self._status_bar is not None:
274
+ label = "Autopilot" if mode == "autopilot" else "Solo"
275
+ self._status_bar.set_session_tip(f"Mode: {label}")
276
+
268
277
  async def _show_autopilot_dashboard(self) -> None:
269
278
  """Show autopilot dashboard as a screen overlay."""
270
279
  from soothe_cli.tui.widgets.autopilot_screen import AutopilotScreen
271
280
 
272
281
  is_narrow = self.size.width < 100
273
- screen = AutopilotScreen(is_narrow=is_narrow)
282
+ mode = (
283
+ self._loop_autopilot_mode
284
+ if self._loop_autopilot_mode in ("solo", "autopilot")
285
+ else "solo"
286
+ )
287
+ screen = AutopilotScreen(is_narrow=is_narrow, mode=mode)
274
288
 
275
289
  def handle_result(result: None) -> None: # noqa: ARG001
276
290
  if self._chat_input:
@@ -278,6 +292,48 @@ class _ModelMixin:
278
292
 
279
293
  self.push_screen(screen, handle_result)
280
294
 
295
+ async def _toggle_autopilot_mode(self) -> None:
296
+ """Toggle autopilot mode (solo ↔ autopilot) via daemon RPC.
297
+
298
+ Sends command_request to daemon and updates local dashboard mode.
299
+ """
300
+ from soothe_cli.tui.widgets.messages import ErrorMessage, UserMessage
301
+
302
+ await self._mount_message(UserMessage("/autopilot-toggle"))
303
+
304
+ lid = self._loop_id
305
+ if not lid:
306
+ await self._mount_message(ErrorMessage("No active loop for autopilot toggle"))
307
+ return
308
+
309
+ if not self._client:
310
+ await self._mount_message(ErrorMessage("Not connected to daemon"))
311
+ return
312
+
313
+ try:
314
+ from soothe_cli.tui.commands.command_router import handle_rpc_command
315
+ from soothe_cli.tui.commands.slash_commands import COMMANDS
316
+
317
+ entry = COMMANDS.get("/autopilot-toggle")
318
+ if not entry:
319
+ await self._mount_message(ErrorMessage("Command /autopilot-toggle not found"))
320
+ return
321
+
322
+ await handle_rpc_command(
323
+ entry,
324
+ "autopilot-toggle",
325
+ None,
326
+ self._console,
327
+ self._client,
328
+ loop_id=lid,
329
+ )
330
+ new_mode = "solo" if self._loop_autopilot_mode == "autopilot" else "autopilot"
331
+ self._apply_loop_autopilot_mode(new_mode)
332
+
333
+ except Exception as exc:
334
+ logger.exception("Autopilot toggle failed")
335
+ await self._mount_message(ErrorMessage(f"Failed to toggle autopilot: {exc}"))
336
+
281
337
  async def _submit_autopilot_job(self, task: str) -> None:
282
338
  """Submit an autopilot job via HTTP REST (like CLI `soothe autopilot run`).
283
339
 
@@ -478,6 +478,13 @@ class _StartupMixin:
478
478
  if self._session_state is not None:
479
479
  self._session_state.loop_id = status_loop_id
480
480
 
481
+ autopilot_mode = event.status_event.get("autopilot_mode")
482
+ if autopilot_mode not in ("solo", "autopilot") and event.session is not None:
483
+ autopilot_mode = getattr(event.session, "autopilot_mode", None)
484
+ self._apply_loop_autopilot_mode(
485
+ str(autopilot_mode) if autopilot_mode in ("solo", "autopilot") else None
486
+ )
487
+
481
488
  try:
482
489
  banner = self.query_one("#welcome-banner", WelcomeBanner)
483
490
  banner.set_connected(self._mcp_tool_count)
@@ -272,6 +272,13 @@ COMMANDS: dict[str, dict[str, Any]] = {
272
272
  "requires_loop": True,
273
273
  "handler": show_autopilot_dashboard,
274
274
  },
275
+ "/autopilot-toggle": {
276
+ "location": "daemon",
277
+ "type": "rpc",
278
+ "daemon_command": "autopilot_toggle",
279
+ "description": "Toggle autopilot mode (solo ↔ autopilot)",
280
+ "requires_loop": True,
281
+ },
275
282
  # Daemon routing commands (3)
276
283
  "/plan": {"location": "daemon", "type": "routing", "description": "Trigger plan mode"},
277
284
  "/tacitus": {
@@ -16,9 +16,6 @@ from soothe_cli.tui.path_utils import path_exists, path_is_dir, path_is_file
16
16
  from soothe_cli.tui.preview_limits import (
17
17
  CHAT_INPUT_PASTE_ABBREVIATE_CHAR_COUNT,
18
18
  CHAT_INPUT_PASTE_ABBREVIATE_LINE_COUNT,
19
- CHAT_INPUT_PASTE_PREVIEW_HEAD_LINES,
20
- CHAT_INPUT_PASTE_PREVIEW_LINE_MAX_CHARS,
21
- CHAT_INPUT_PASTE_PREVIEW_TAIL_LINES,
22
19
  )
23
20
 
24
21
  logger = logging.getLogger(__name__)
@@ -806,16 +803,44 @@ def should_abbreviate_pasted_input(text: str) -> bool:
806
803
  return len(text) > CHAT_INPUT_PASTE_ABBREVIATE_CHAR_COUNT
807
804
 
808
805
 
809
- def _truncate_paste_preview_line(line: str) -> str:
810
- """Truncate a single preview line for the chat input."""
811
- if len(line) <= CHAT_INPUT_PASTE_PREVIEW_LINE_MAX_CHARS:
812
- return line
813
- budget = CHAT_INPUT_PASTE_PREVIEW_LINE_MAX_CHARS - 1
814
- return f"{line[:budget]}…"
806
+ def compose_paste_into_input(
807
+ existing_text: str,
808
+ pasted_text: str,
809
+ *,
810
+ replace_start: int | None = None,
811
+ replace_end: int | None = None,
812
+ ) -> str:
813
+ """Return input text after applying a paste at the given replacement range.
814
+
815
+ Args:
816
+ existing_text: Current input text before paste.
817
+ pasted_text: Raw pasted payload.
818
+ replace_start: Inclusive replacement start offset. Defaults to the end.
819
+ replace_end: Exclusive replacement end offset. Defaults to start.
820
+
821
+ Returns:
822
+ Full input text after replacing ``[replace_start:replace_end]`` with
823
+ ``pasted_text``.
824
+ """
825
+ text_len = len(existing_text)
826
+ if replace_start is None:
827
+ start = text_len
828
+ else:
829
+ start = max(0, min(replace_start, text_len))
830
+
831
+ if replace_end is None:
832
+ end = start
833
+ else:
834
+ end = max(0, min(replace_end, text_len))
835
+
836
+ if end < start:
837
+ start, end = end, start
838
+
839
+ return f"{existing_text[:start]}{pasted_text}{existing_text[end:]}"
815
840
 
816
841
 
817
842
  def abbreviate_pasted_input_display(text: str) -> str:
818
- """Build a short multi-line preview for a large pasted payload.
843
+ """Build a compact one-line preview for a large pasted payload.
819
844
 
820
845
  The full ``text`` is retained separately for submission; this string is
821
846
  only for on-screen display in the input widget.
@@ -824,25 +849,11 @@ def abbreviate_pasted_input_display(text: str) -> str:
824
849
  text: Full pasted content.
825
850
 
826
851
  Returns:
827
- Abbreviated display text with a header and head/tail line previews.
852
+ Abbreviated display text containing only a summary header.
828
853
  """
829
854
  lines = text.splitlines()
830
855
  if not lines and text:
831
856
  lines = [text]
832
857
  n_lines = len(lines) if lines else 1
833
858
  n_chars = len(text)
834
- header = f"[pasted {n_lines} lines, {n_chars} characters]"
835
-
836
- head_n = min(CHAT_INPUT_PASTE_PREVIEW_HEAD_LINES, n_lines)
837
- tail_n = min(CHAT_INPUT_PASTE_PREVIEW_TAIL_LINES, max(0, n_lines - head_n))
838
- omitted = n_lines - head_n - tail_n
839
-
840
- parts: list[str] = [header]
841
- parts.extend(_truncate_paste_preview_line(line) for line in lines[:head_n])
842
- if omitted > 0:
843
- parts.append(f"… ({omitted} more lines) …")
844
- if tail_n > 0:
845
- tail_start = n_lines - tail_n
846
- if tail_start > head_n:
847
- parts.extend(_truncate_paste_preview_line(line) for line in lines[tail_start:])
848
- return "\n".join(parts)
859
+ return f"[pasted {n_lines} lines, {n_chars} characters]"
@@ -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] = 5
18
+ STEP_CARD_TOOL_ACTIVITY_PREVIEW_COUNT: Final[int] = 3
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] = 5
21
+ STEP_TASK_CARD_COLLAPSE_LINE_THRESHOLD: Final[int] = 3
22
22
 
23
23
  # --- Skill invocation cards (`SkillMessage` collapsed SKILL.md body) ---
24
24
  SKILL_CARD_PREVIEW_LINES: Final[int] = 4
@@ -1482,6 +1482,26 @@ def _log_turn_event_stats(
1482
1482
  )
1483
1483
 
1484
1484
 
1485
+ def _should_show_clarification_prompt(
1486
+ *, event_data: dict[str, Any], fallback_mode: str | None
1487
+ ) -> bool:
1488
+ """Return True when TUI should show the interactive clarification card.
1489
+
1490
+ In auto mode, clarifications are resolved/deferred by policy and should not
1491
+ render "Awaiting your answer" UI prompts in the TUI message stream.
1492
+ """
1493
+ mode = event_data.get("mode")
1494
+ if isinstance(mode, str) and mode.strip():
1495
+ normalized = mode.strip().lower()
1496
+ elif isinstance(fallback_mode, str) and fallback_mode.strip():
1497
+ normalized = fallback_mode.strip().lower()
1498
+ else:
1499
+ normalized = "auto"
1500
+ if normalized not in {"auto", "manual"}:
1501
+ normalized = "auto"
1502
+ return normalized == "manual"
1503
+
1504
+
1485
1505
  async def execute_task_textual(
1486
1506
  user_input: str,
1487
1507
  assistant_id: str | None,
@@ -2511,7 +2531,10 @@ async def execute_task_textual(
2511
2531
  questions_list = [
2512
2532
  str(q) for q in raw_questions if str(q).strip()
2513
2533
  ]
2514
- if questions_list:
2534
+ if questions_list and _should_show_clarification_prompt(
2535
+ event_data=data,
2536
+ fallback_mode=clarification_mode,
2537
+ ):
2515
2538
  # The ask_user step card was put into "running" by
2516
2539
  # ``step_started`` just before await_clarification.
2517
2540
  # Surface the pending questions on it so the user