soothe-cli 0.5.29__tar.gz → 0.5.30__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.29 → soothe_cli-0.5.30}/.gitignore +1 -0
  2. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/PKG-INFO +1 -1
  3. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/commands/autopilot_cmd.py +126 -12
  4. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/transport/session.py +11 -2
  5. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_execution.py +24 -7
  6. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_model.py +51 -0
  7. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_startup.py +5 -1
  8. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/command_registry.py +7 -1
  9. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/commands/slash_commands.py +6 -0
  10. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/chat_input.py +29 -2
  11. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/README.md +0 -0
  12. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/pyproject.toml +0 -0
  13. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/__init__.py +0 -0
  14. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/__init__.py +0 -0
  15. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/commands/__init__.py +0 -0
  16. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  17. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  18. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/__init__.py +0 -0
  19. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/daemon.py +0 -0
  20. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  21. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/headless.py +0 -0
  22. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  23. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/launcher.py +0 -0
  24. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/cli/main.py +0 -0
  25. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/config/__init__.py +0 -0
  26. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/config/cli_config.py +0 -0
  27. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/config/loader.py +0 -0
  28. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/config/logging_setup.py +0 -0
  29. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/__init__.py +0 -0
  30. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/headless/processor.py +0 -0
  31. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  32. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/parse/_utils.py +0 -0
  33. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
  34. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  35. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/parse/tool_message_format.py +0 -0
  36. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
  37. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  38. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  39. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  40. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/async_renderer_protocol.py +0 -0
  41. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  42. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  43. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  44. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  45. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  46. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  47. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  48. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/state/step_router.py +0 -0
  49. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  50. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/state/transcript.py +0 -0
  51. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/task_scope.py +0 -0
  52. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  53. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  54. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  55. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/wire/display_text.py +0 -0
  56. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/wire/message_text.py +0 -0
  57. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/runtime/wire/messages.py +0 -0
  58. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/__init__.py +0 -0
  59. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/_cli_context.py +0 -0
  60. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/_env_vars.py +0 -0
  61. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/_version.py +0 -0
  62. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/__init__.py +0 -0
  63. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_app.py +0 -0
  64. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_commands.py +0 -0
  65. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_history.py +0 -0
  66. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_messages_mixin.py +0 -0
  67. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_module_init.py +0 -0
  68. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_ui.py +0 -0
  69. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/app.tcss +0 -0
  70. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/binding.py +0 -0
  71. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/commands/__init__.py +0 -0
  72. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/commands/command_router.py +0 -0
  73. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  74. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/config.py +0 -0
  75. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/file_change_notify.py +0 -0
  76. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  77. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/hooks.py +0 -0
  78. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/input.py +0 -0
  79. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/media_utils.py +0 -0
  80. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/model_config.py +0 -0
  81. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/path_utils.py +0 -0
  82. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/preview_limits.py +0 -0
  83. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/project_utils.py +0 -0
  84. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/sessions.py +0 -0
  85. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/skills/__init__.py +0 -0
  86. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/skills/invocation.py +0 -0
  87. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/skills/load.py +0 -0
  88. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/textual_adapter.py +0 -0
  89. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/theme.py +0 -0
  90. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/tips.py +0 -0
  91. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/tool_display.py +0 -0
  92. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/unicode_security.py +0 -0
  93. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/update_check.py +0 -0
  94. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  95. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/_links.py +0 -0
  96. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  97. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +0 -0
  98. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  99. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
  100. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/diff.py +0 -0
  101. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/editor.py +0 -0
  102. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
  103. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/history.py +0 -0
  104. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/loading.py +0 -0
  105. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/loop_selector.py +0 -0
  106. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  107. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  108. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/messages.py +0 -0
  109. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  110. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  111. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/status.py +0 -0
  112. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  113. {soothe_cli-0.5.29 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/welcome.py +0 -0
@@ -218,3 +218,4 @@ __MACOSX
218
218
  .qoder
219
219
  .soothe
220
220
  deploy/config.yml
221
+ .backups/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: soothe-cli
3
- Version: 0.5.29
3
+ Version: 0.5.30
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
@@ -133,27 +133,121 @@ def status() -> None:
133
133
 
134
134
 
135
135
  @app.command("list")
136
- def list_goals(
136
+ def list_jobs(
137
137
  status_filter: str = typer.Option("", "--status", "-s", help="Filter by status."),
138
138
  ) -> None:
139
- """List goals from the live daemon autopilot DAG."""
139
+ """List jobs (root goals) from the daemon autopilot.
140
+
141
+ Jobs are user-submitted tasks. Subgoals created during autonomous
142
+ execution are not shown here; use 'goal <id>' to inspect subgoals.
143
+ """
140
144
  client = _require_daemon_http()
141
- payload = client.list_goals()
142
- goals = payload.get("goals") or []
143
- if not goals:
144
- typer.echo("No goals found.")
145
+ payload = client.list_jobs()
146
+ jobs = payload.get("jobs") or []
147
+ if not jobs:
148
+ typer.echo("No jobs found.")
145
149
  return
146
150
 
147
- for g in goals:
148
- if status_filter and g.get("status", "") != status_filter:
151
+ for j in jobs:
152
+ if status_filter and j.get("status", "") != status_filter:
149
153
  continue
150
- sid = g.get("id", "?")[:8]
151
- sdesc = preview_first(g.get("description", ""), 60)
152
- sstat = g.get("status", "pending")
153
- spri = g.get("priority", 50)
154
+ sid = j.get("id", "?")[:8]
155
+ sdesc = preview_first(j.get("description", ""), 60)
156
+ sstat = j.get("status", "pending")
157
+ spri = j.get("priority", 50)
154
158
  typer.echo(f" [{sid}] {sstat:10s} pri={spri:3d} {sdesc}")
155
159
 
156
160
 
161
+ def _render_dag_tree(dag: dict, root_id: str) -> None:
162
+ """Render DAG as ASCII tree for job visualization."""
163
+ nodes = {n["id"]: n for n in dag.get("nodes", [])}
164
+ edges = dag.get("edges", [])
165
+
166
+ # Build children map from edges
167
+ children: dict[str, list[str]] = {}
168
+ for edge in edges:
169
+ src = edge.get("source")
170
+ tgt = edge.get("target")
171
+ if src and tgt:
172
+ if src not in children:
173
+ children[src] = []
174
+ children[src].append(tgt)
175
+
176
+ def render_node(goal_id: str, indent: str = "", is_last: bool = True) -> None:
177
+ node = nodes.get(goal_id)
178
+ if not node:
179
+ return
180
+
181
+ # Prefix for this level
182
+ if indent:
183
+ prefix = indent + ("└─ " if is_last else "├─ ")
184
+ else:
185
+ prefix = ""
186
+
187
+ status = node.get("status", "pending")
188
+ desc = preview_first(node.get("description", ""), 50)
189
+ typer.echo(f'{prefix}{goal_id[:8]} ({status}) "{desc}"')
190
+
191
+ # Render children
192
+ child_ids = children.get(goal_id, [])
193
+ for i, child_id in enumerate(child_ids):
194
+ child_indent = indent + (" " if is_last else "│ ")
195
+ render_node(child_id, child_indent, i == len(child_ids) - 1)
196
+
197
+ render_node(root_id)
198
+
199
+
200
+ @app.command("job")
201
+ def show_job(
202
+ job_id: str = typer.Argument(..., help="Job ID to show details and DAG."),
203
+ ) -> None:
204
+ """Show job status and DAG tree visualization.
205
+
206
+ A job is a root goal submitted by the user. This command shows
207
+ the job's details and the complete goal DAG under it.
208
+ """
209
+ client = _require_daemon_http()
210
+ try:
211
+ payload = client.get_job(job_id)
212
+ except RuntimeError as exc:
213
+ typer.echo(str(exc), err=True)
214
+ raise typer.Exit(1)
215
+
216
+ job = payload.get("job")
217
+ dag = payload.get("dag")
218
+
219
+ if not job:
220
+ typer.echo(f"Job '{job_id}' not found.", err=True)
221
+ raise typer.Exit(1)
222
+
223
+ # Job header
224
+ typer.echo(f"Job ID: {job.get('id')}")
225
+ typer.echo(f"Status: {job.get('status', 'pending')}")
226
+ typer.echo(f"Priority: {job.get('priority', 50)}")
227
+ if job.get("workspace"):
228
+ typer.echo(f"Workspace: {job['workspace']}")
229
+ created = job.get("created_at", "")
230
+ if created:
231
+ # Truncate timestamp for readability
232
+ created_short = created[:19] if len(created) > 19 else created
233
+ typer.echo(f"Created: {created_short}")
234
+
235
+ # DAG stats from response
236
+ active = payload.get("active_goals", 0)
237
+ completed = payload.get("completed_goals", 0)
238
+ total = payload.get("total_goals", 0)
239
+ typer.echo(f"Active goals: {active}")
240
+ typer.echo(f"Completed goals: {completed}")
241
+ typer.echo(f"Total goals: {total}")
242
+
243
+ # DAG tree
244
+ typer.echo("\nDAG:")
245
+ if dag:
246
+ _render_dag_tree(dag, job_id)
247
+ else:
248
+ typer.echo(" (no subgoals)")
249
+
250
+
157
251
  @app.command("goal")
158
252
  def show_goal(
159
253
  goal_id: str = typer.Argument(..., help="Goal ID to show details for."),
@@ -216,6 +310,26 @@ def reject_goal(
216
310
  typer.echo(f"Confirmation rejected: {result.get('goal_id', goal_id)}")
217
311
 
218
312
 
313
+ @app.command("resume")
314
+ def resume_goal(
315
+ goal_id: str = typer.Argument(..., help="Goal ID to resume."),
316
+ ) -> None:
317
+ """Resume a suspended or blocked goal.
318
+
319
+ Reactivates a paused goal back to pending status so the scheduler
320
+ can pick it up for execution. Use 'jobs' to list goals and their status.
321
+ """
322
+ client = _require_daemon_http()
323
+ try:
324
+ result = client.resume(goal_id)
325
+ except RuntimeError as exc:
326
+ typer.echo(str(exc), err=True)
327
+ raise typer.Exit(1) from exc
328
+ typer.echo(
329
+ f"Goal resumed: {result.get('goal_id', goal_id)} → {result.get('new_status', 'pending')}"
330
+ )
331
+
332
+
219
333
  @app.command("wake")
220
334
  def wake() -> None:
221
335
  """Exit dreaming mode — resume active execution."""
@@ -32,7 +32,13 @@ _POST_IDLE_DRAIN_DEADLINE_S = 2.5
32
32
  class TuiDaemonSession:
33
33
  """Own the daemon websocket session used by the TUI."""
34
34
 
35
- def __init__(self, cfg: Any, *, workspace: str | None = None) -> None:
35
+ def __init__(
36
+ self,
37
+ cfg: Any,
38
+ *,
39
+ workspace: str | None = None,
40
+ post_idle_drain_deadline: float = _POST_IDLE_DRAIN_DEADLINE_S,
41
+ ) -> None:
36
42
  self._cfg = cfg
37
43
  self._workspace = workspace
38
44
  ws_url = websocket_url_from_config(cfg)
@@ -43,6 +49,7 @@ class TuiDaemonSession:
43
49
  self._rpc_lock = asyncio.Lock()
44
50
  self._rpc_connected = False
45
51
  self._streaming = False
52
+ self._post_idle_drain_deadline = post_idle_drain_deadline
46
53
  self.turn_event_stats = TurnEventStats()
47
54
 
48
55
  @property
@@ -178,7 +185,9 @@ class TuiDaemonSession:
178
185
  ) -> Any:
179
186
  """Yield stream chunks that arrive just after ``idle`` (headless client parity)."""
180
187
  loop = asyncio.get_running_loop()
181
- deadline = loop.time() + _POST_IDLE_DRAIN_DEADLINE_S
188
+ deadline = loop.time() + getattr(
189
+ self, "_post_idle_drain_deadline", _POST_IDLE_DRAIN_DEADLINE_S
190
+ )
182
191
  exp = expected_loop_id
183
192
  while loop.time() < deadline:
184
193
  try:
@@ -340,10 +340,13 @@ class _ExecutionMixin:
340
340
  self._shell_process = None
341
341
  self._shell_running = False
342
342
  self._shell_worker = None
343
- if was_interrupted:
344
- await self._mount_message(AppMessage("Command interrupted"))
343
+
344
+ # Restore input focus first so the user can type immediately.
345
345
  if self._chat_input:
346
346
  self._chat_input.set_cursor_active(active=True)
347
+
348
+ if was_interrupted:
349
+ await self._mount_message(AppMessage("Command interrupted"))
347
350
  try:
348
351
  await self._maybe_drain_deferred()
349
352
  except Exception:
@@ -490,7 +493,7 @@ class _ExecutionMixin:
490
493
  elif cmd == "/help":
491
494
  await self._mount_message(UserMessage(command))
492
495
  help_body = (
493
- "Commands: /quit, /clear, /editor, /autopilot, /mcp, "
496
+ "Commands: /quit, /clear, /editor, /autopilot <task>, /autopilot-dashboard, /mcp, "
494
497
  "/model [--model-params JSON] [--default], /notifications, "
495
498
  "/reload, /skill:<name>, /theme, "
496
499
  "/tokens, /resume, "
@@ -633,7 +636,19 @@ class _ExecutionMixin:
633
636
  args = command.strip()[len("/skill-creator") :].strip()
634
637
  rewritten = f"/skill:skill-creator {args}" if args else "/skill:skill-creator"
635
638
  await self._handle_skill_command(rewritten)
636
- elif cmd == "/autopilot":
639
+ elif cmd == "/autopilot" or cmd.startswith("/autopilot "):
640
+ # Submit autopilot job via HTTP REST (like CLI `soothe autopilot run`)
641
+ args = command.strip()[len("/autopilot") :].strip()
642
+ if not args:
643
+ await self._mount_message(UserMessage(command))
644
+ await self._mount_message(
645
+ AppMessage(
646
+ "Usage: /autopilot <task description>\nExample: /autopilot refactor the auth module"
647
+ )
648
+ )
649
+ return
650
+ await self._submit_autopilot_job(args)
651
+ elif cmd == "/autopilot-dashboard":
637
652
  await self._show_autopilot_dashboard()
638
653
  elif cmd == "/mcp":
639
654
  await self._show_mcp_viewer()
@@ -961,12 +976,14 @@ class _ExecutionMixin:
961
976
  self._agent_running = False
962
977
  self._agent_worker = None
963
978
 
964
- # Remove spinner if present
965
- await self._set_spinner(None)
966
-
979
+ # Restore input focus FIRST so the user can start typing immediately
980
+ # while remaining cleanup (spinner, tokens, deferred) runs.
967
981
  if self._chat_input:
968
982
  self._chat_input.set_cursor_active(active=True)
969
983
 
984
+ # Remove spinner if present
985
+ await self._set_spinner(None)
986
+
970
987
  # Ensure token display is restored (in case of early cancellation).
971
988
  # Pass the cached approximate flag so an interrupted "+" isn't clobbered.
972
989
  self._show_tokens(approximate=self._tokens_approximate)
@@ -278,6 +278,57 @@ class _ModelMixin:
278
278
 
279
279
  self.push_screen(screen, handle_result)
280
280
 
281
+ async def _submit_autopilot_job(self, task: str) -> None:
282
+ """Submit an autopilot job via HTTP REST (like CLI `soothe autopilot run`).
283
+
284
+ Args:
285
+ task: Task description for autonomous execution.
286
+ """
287
+ import os
288
+
289
+ from soothe_sdk.client import (
290
+ AutopilotHttpClient,
291
+ http_rest_url_from_config,
292
+ is_daemon_live,
293
+ websocket_url_from_config,
294
+ )
295
+
296
+ from soothe_cli.runtime import load_config
297
+ from soothe_cli.tui.widgets.messages import ErrorMessage, UserMessage
298
+
299
+ await self._mount_message(UserMessage(f"/autopilot {task}"))
300
+
301
+ cfg = load_config()
302
+ ws_url = websocket_url_from_config(cfg)
303
+
304
+ # Check daemon is running
305
+ if not await is_daemon_live(ws_url, timeout=5.0):
306
+ await self._mount_message(
307
+ ErrorMessage("Daemon not running. Start with 'soothed start'.")
308
+ )
309
+ return
310
+
311
+ base_url = http_rest_url_from_config(cfg)
312
+ workspace = self._cwd if hasattr(self, "_cwd") else os.getcwd()
313
+
314
+ try:
315
+ client = AutopilotHttpClient(base_url)
316
+ result = client.submit(task, workspace=workspace)
317
+ except RuntimeError as exc:
318
+ await self._mount_message(ErrorMessage(str(exc)))
319
+ return
320
+ except Exception as exc: # noqa: BLE001
321
+ logger.exception("Autopilot submit failed")
322
+ await self._mount_message(ErrorMessage(f"Failed to submit autopilot job: {exc}"))
323
+ return
324
+
325
+ goal_id = result.get("goal_id", "")
326
+ if goal_id:
327
+ self.notify(f"Autopilot job submitted: {goal_id[:8]}", timeout=5)
328
+ logger.info("Submitted autopilot job %s: %s", goal_id, task[:50])
329
+ else:
330
+ await self._mount_message(ErrorMessage("No goal_id returned from daemon"))
331
+
281
332
  async def _show_loop_selector(self) -> None:
282
333
  """Show interactive loop selector as a modal screen."""
283
334
  from functools import partial
@@ -452,7 +452,11 @@ class _StartupMixin:
452
452
  f"Please start the daemon with: soothed start"
453
453
  )
454
454
 
455
- session = TuiDaemonSession(self._daemon_config, workspace=self._cwd)
455
+ session = TuiDaemonSession(
456
+ self._daemon_config,
457
+ workspace=self._cwd,
458
+ post_idle_drain_deadline=0.3,
459
+ )
456
460
  status_event = await session.connect(resume_loop_id=self._lc_loop_id)
457
461
  except Exception as exc:
458
462
  self.post_message(self.ServerStartFailed(error=exc))
@@ -57,9 +57,15 @@ class SlashCommand:
57
57
  COMMANDS: tuple[SlashCommand, ...] = (
58
58
  SlashCommand(
59
59
  name="/autopilot",
60
+ description="Submit autopilot job (usage: /autopilot <task>)",
61
+ bypass_tier=BypassTier.QUEUED,
62
+ hidden_keywords="goals autonomous job submit",
63
+ ),
64
+ SlashCommand(
65
+ name="/autopilot-dashboard",
60
66
  description="Open autopilot dashboard",
61
67
  bypass_tier=BypassTier.IMMEDIATE_UI,
62
- hidden_keywords="goals autonomous",
68
+ hidden_keywords="goals status workers",
63
69
  ),
64
70
  SlashCommand(
65
71
  name="/clear",
@@ -259,6 +259,12 @@ COMMANDS: dict[str, dict[str, Any]] = {
259
259
  "params_schema": {"loop_id": {"type": "string", "required": True}},
260
260
  },
261
261
  "/autopilot": {
262
+ "location": "daemon",
263
+ "type": "routing",
264
+ "description": "Submit autopilot job (usage: /autopilot <task>)",
265
+ "requires_query": True,
266
+ },
267
+ "/autopilot-dashboard": {
262
268
  "location": "daemon",
263
269
  "type": "rpc",
264
270
  "daemon_command": "autopilot_dashboard",
@@ -841,6 +841,13 @@ class ChatInput(Vertical):
841
841
  - Autocomplete for @ (files) and / (commands)
842
842
  """
843
843
 
844
+ _PATH_INDICATOR_CHARS: ClassVar[frozenset[str]] = frozenset("/\\~")
845
+ """Characters that indicate text might be a file path.
846
+
847
+ Used by `_is_dropped_path_payload` as a fast-path guard to skip
848
+ filesystem I/O when text cannot possibly be a path.
849
+ """
850
+
844
851
  DEFAULT_CSS = """
845
852
  ChatInput {
846
853
  height: auto;
@@ -1038,7 +1045,7 @@ class ChatInput(Vertical):
1038
1045
  "Cannot update slash commands: controller not initialized (widget not yet mounted)"
1039
1046
  )
1040
1047
 
1041
- def on_text_area_changed(self, event: TextArea.Changed) -> None:
1048
+ async def on_text_area_changed(self, event: TextArea.Changed) -> None:
1042
1049
  """Detect input mode and update completions."""
1043
1050
  text = event.text_area.text
1044
1051
  self._sync_media_tracker_to_text(text)
@@ -1076,6 +1083,11 @@ class ChatInput(Vertical):
1076
1083
  # or prefix stripping, which never need path detection.
1077
1084
  is_path_payload = self._is_dropped_path_payload(text)
1078
1085
 
1086
+ # Yield to the event loop so pending key events are not starved
1087
+ # when the path check above did filesystem I/O.
1088
+ if is_path_payload:
1089
+ await asyncio.sleep(0)
1090
+
1079
1091
  # Guard: skip mode re-detection after we programmatically stripped
1080
1092
  # a prefix character.
1081
1093
  if self._stripping_prefix:
@@ -1193,13 +1205,28 @@ class ChatInput(Vertical):
1193
1205
  """Return whether text is a dropped-path payload for existing files."""
1194
1206
  if len(text) < 2: # noqa: PLR2004 # Need at least '/' + one char
1195
1207
  return False
1208
+ # Short texts without path-like prefixes are never valid payloads.
1209
+ # Avoids stat(2) calls for slash-command fragments like "/h" or "/he".
1210
+ if len(text) < 3 and not text.startswith(("'", '"', "~/")): # noqa: PLR2004
1211
+ return False
1196
1212
  from soothe_cli.tui.input import parse_pasted_path_payload
1197
1213
 
1198
1214
  return parse_pasted_path_payload(text, allow_leading_path=True) is not None
1199
1215
 
1200
1216
  def _is_dropped_path_payload(self, text: str) -> bool:
1201
1217
  """Return whether current text looks like a dropped file-path payload."""
1202
- if not text:
1218
+ if not text or len(text) < 2:
1219
+ return False
1220
+ # Fast path: skip filesystem I/O when text has no path indicators.
1221
+ # Dropped paths always contain at least one of: / (POSIX), \ (Windows),
1222
+ # ~ (home), or a quote prefix ('/path' or "/path"). Plain prose and
1223
+ # slash-command text (e.g. "/help") without path separators never
1224
+ # triggers dropped-path parsing.
1225
+ if (
1226
+ not text.startswith(("'", '"'))
1227
+ and not text.startswith("file://")
1228
+ and not any(c in self._PATH_INDICATOR_CHARS for c in text)
1229
+ ):
1203
1230
  return False
1204
1231
  if self._is_existing_path_payload(text):
1205
1232
  return True
File without changes
File without changes