soothe-cli 0.5.28__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 (120) hide show
  1. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/.gitignore +1 -0
  2. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/PKG-INFO +1 -1
  3. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/commands/autopilot_cmd.py +127 -13
  4. soothe_cli-0.5.30/src/soothe_cli/runtime/parse/_utils.py +14 -0
  5. soothe_cli-0.5.30/src/soothe_cli/runtime/parse/message_processing.py +51 -0
  6. soothe_cli-0.5.30/src/soothe_cli/runtime/parse/tool_message_format.py +17 -0
  7. soothe_cli-0.5.30/src/soothe_cli/runtime/parse/tool_result.py +15 -0
  8. soothe_cli-0.5.30/src/soothe_cli/runtime/state/transcript.py +24 -0
  9. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/transport/session.py +62 -2
  10. soothe_cli-0.5.30/src/soothe_cli/runtime/wire/display_text.py +15 -0
  11. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/wire/message_text.py +3 -3
  12. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_app.py +3 -0
  13. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_execution.py +38 -10
  14. soothe_cli-0.5.30/src/soothe_cli/tui/app/_history.py +335 -0
  15. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_model.py +58 -2
  16. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_startup.py +6 -2
  17. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/app.tcss +26 -6
  18. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/command_registry.py +9 -3
  19. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/commands/slash_commands.py +6 -0
  20. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/model_config.py +10 -4
  21. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/sessions.py +74 -27
  22. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/textual_adapter.py +2 -2
  23. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/tips.py +1 -1
  24. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/autopilot_dashboard.py +2 -0
  25. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/chat_input.py +29 -2
  26. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/clipboard.py +30 -0
  27. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/file_change_preview.py +64 -60
  28. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/loop_selector.py +64 -9
  29. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/messages.py +12 -17
  30. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/status.py +6 -6
  31. soothe_cli-0.5.28/src/soothe_cli/runtime/parse/_utils.py +0 -30
  32. soothe_cli-0.5.28/src/soothe_cli/runtime/parse/message_processing.py +0 -551
  33. soothe_cli-0.5.28/src/soothe_cli/runtime/parse/tool_message_format.py +0 -115
  34. soothe_cli-0.5.28/src/soothe_cli/runtime/parse/tool_result.py +0 -141
  35. soothe_cli-0.5.28/src/soothe_cli/runtime/state/transcript.py +0 -208
  36. soothe_cli-0.5.28/src/soothe_cli/runtime/wire/display_text.py +0 -64
  37. soothe_cli-0.5.28/src/soothe_cli/tui/app/_history.py +0 -969
  38. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/README.md +0 -0
  39. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/pyproject.toml +0 -0
  40. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/__init__.py +0 -0
  41. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/__init__.py +0 -0
  42. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/commands/__init__.py +0 -0
  43. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
  44. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
  45. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/__init__.py +0 -0
  46. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/daemon.py +0 -0
  47. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
  48. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/headless.py +0 -0
  49. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
  50. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/execution/launcher.py +0 -0
  51. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/cli/main.py +0 -0
  52. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/config/__init__.py +0 -0
  53. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/config/cli_config.py +0 -0
  54. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/config/loader.py +0 -0
  55. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/config/logging_setup.py +0 -0
  56. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/__init__.py +0 -0
  57. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/headless/processor.py +0 -0
  58. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
  59. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
  60. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
  61. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
  62. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
  63. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/async_renderer_protocol.py +0 -0
  64. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
  65. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/engine.py +0 -0
  66. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/explore_task_display.py +0 -0
  67. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
  68. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
  69. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
  70. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/state/session_stats.py +0 -0
  71. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/state/step_router.py +0 -0
  72. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
  73. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/task_scope.py +0 -0
  74. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
  75. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/turn/prepare.py +0 -0
  76. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/wire/chunk_filter.py +0 -0
  77. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/runtime/wire/messages.py +0 -0
  78. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/__init__.py +0 -0
  79. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/_cli_context.py +0 -0
  80. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/_env_vars.py +0 -0
  81. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/_version.py +0 -0
  82. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/__init__.py +0 -0
  83. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_commands.py +0 -0
  84. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_messages_mixin.py +0 -0
  85. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_module_init.py +0 -0
  86. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/app/_ui.py +0 -0
  87. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/binding.py +0 -0
  88. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/commands/__init__.py +0 -0
  89. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/commands/command_router.py +0 -0
  90. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
  91. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/config.py +0 -0
  92. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/file_change_notify.py +0 -0
  93. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/file_change_renderers.py +0 -0
  94. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/hooks.py +0 -0
  95. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/input.py +0 -0
  96. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/media_utils.py +0 -0
  97. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/path_utils.py +0 -0
  98. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/preview_limits.py +0 -0
  99. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/project_utils.py +0 -0
  100. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/skills/__init__.py +0 -0
  101. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/skills/invocation.py +0 -0
  102. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/skills/load.py +0 -0
  103. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/theme.py +0 -0
  104. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/tool_display.py +0 -0
  105. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/unicode_security.py +0 -0
  106. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/update_check.py +0 -0
  107. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/__init__.py +0 -0
  108. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/_links.py +0 -0
  109. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
  110. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/autopilot_screen.py +0 -0
  111. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/diff.py +0 -0
  112. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/editor.py +0 -0
  113. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/history.py +0 -0
  114. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/loading.py +0 -0
  115. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
  116. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/message_store.py +0 -0
  117. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
  118. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
  119. {soothe_cli-0.5.28 → soothe_cli-0.5.30}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
  120. {soothe_cli-0.5.28 → 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.28
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
@@ -91,7 +91,7 @@ def run(
91
91
  detail = client.get_goal(goal_id)
92
92
  goal = detail.get("goal") or {}
93
93
  status = goal.get("status", "unknown")
94
- if status in ("completed", "failed", "suspended"):
94
+ if status in ("completed", "failed", "cancelled", "suspended"):
95
95
  typer.echo(f"Goal {goal_id[:8]}: {status}")
96
96
  if status == "failed":
97
97
  sys.exit(1)
@@ -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."""
@@ -0,0 +1,14 @@
1
+ """Re-export shim for ``soothe_sdk.display._text_utils`` (RFC-413).
2
+
3
+ These utilities live in the SDK so the daemon-resident ``CardBinder`` can
4
+ reuse them. This module preserves the original CLI import path.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from soothe_sdk.display._text_utils import (
10
+ normalize_tool_name,
11
+ text_looks_like_error,
12
+ )
13
+
14
+ __all__ = ["normalize_tool_name", "text_looks_like_error"]
@@ -0,0 +1,51 @@
1
+ """Re-export shim for ``soothe_sdk.display.message_processing`` (RFC-413).
2
+
3
+ These helpers live in the SDK so the daemon-resident ``CardBinder`` can
4
+ reuse them. This module preserves the original CLI import path used
5
+ across the runtime, TUI, and tests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # Underscore-prefixed names below are re-exported intentionally — they are
11
+ # imported by other CLI modules (tool_call_resolution, widgets/messages, etc.)
12
+ # and CLI tests. Keep them in __all__ to keep `ruff` from stripping them.
13
+ from soothe_sdk.display.message_processing import (
14
+ _normalize_tool_name_for_arg_map,
15
+ _pending_or_overlay_id_matches_lookup,
16
+ _resolve_pending_lookup_tool_name,
17
+ accumulate_tool_call_chunks,
18
+ coerce_tool_call_args_to_dict,
19
+ coerce_tool_call_entry_to_dict,
20
+ extract_tool_args_dict,
21
+ extract_tool_brief,
22
+ finalize_pending_tool_call,
23
+ ingest_tool_call_stream_state,
24
+ normalize_tool_calls_list,
25
+ richest_pending_args_for_lookup,
26
+ seed_pending_tool_calls_from_message,
27
+ tool_calls_have_any_arg_dict,
28
+ tool_ids_touched_by_stream_message,
29
+ tool_lookup_step_id,
30
+ try_parse_pending_tool_call_args,
31
+ )
32
+
33
+ __all__ = [
34
+ "_normalize_tool_name_for_arg_map",
35
+ "_pending_or_overlay_id_matches_lookup",
36
+ "_resolve_pending_lookup_tool_name",
37
+ "accumulate_tool_call_chunks",
38
+ "coerce_tool_call_args_to_dict",
39
+ "coerce_tool_call_entry_to_dict",
40
+ "extract_tool_args_dict",
41
+ "extract_tool_brief",
42
+ "finalize_pending_tool_call",
43
+ "ingest_tool_call_stream_state",
44
+ "normalize_tool_calls_list",
45
+ "richest_pending_args_for_lookup",
46
+ "seed_pending_tool_calls_from_message",
47
+ "tool_calls_have_any_arg_dict",
48
+ "tool_ids_touched_by_stream_message",
49
+ "tool_lookup_step_id",
50
+ "try_parse_pending_tool_call_args",
51
+ ]
@@ -0,0 +1,17 @@
1
+ """Re-export shim for ``soothe_sdk.display.tool_message_format`` (RFC-413)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from soothe_sdk.display.tool_message_format import (
6
+ format_content_block_for_tool_display,
7
+ format_tool_message_content,
8
+ run_python_envelope_indicates_failure,
9
+ try_parse_run_python_result_envelope,
10
+ )
11
+
12
+ __all__ = [
13
+ "format_content_block_for_tool_display",
14
+ "format_tool_message_content",
15
+ "run_python_envelope_indicates_failure",
16
+ "try_parse_run_python_result_envelope",
17
+ ]
@@ -0,0 +1,15 @@
1
+ """Re-export shim for ``soothe_sdk.display.tool_result`` (RFC-413)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from soothe_sdk.display.tool_result import (
6
+ ToolResultPayload,
7
+ extract_tool_result_payload,
8
+ infer_tool_output_suggests_error,
9
+ )
10
+
11
+ __all__ = [
12
+ "ToolResultPayload",
13
+ "extract_tool_result_payload",
14
+ "infer_tool_output_suggests_error",
15
+ ]
@@ -0,0 +1,24 @@
1
+ """Transcript message models for TUI display.
2
+
3
+ These types live in ``soothe_sdk.display.transcript_types`` so they can be
4
+ shared with the daemon-resident ``CardBinder`` (RFC-413). This module
5
+ re-exports them to preserve the CLI's existing import paths.
6
+
7
+ DOM virtualization lives in ``soothe_cli.tui.widgets.message_store``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from soothe_sdk.display.transcript_types import (
13
+ UPDATABLE_FIELDS,
14
+ MessageData,
15
+ MessageType,
16
+ ToolStatus,
17
+ )
18
+
19
+ __all__ = [
20
+ "UPDATABLE_FIELDS",
21
+ "MessageData",
22
+ "MessageType",
23
+ "ToolStatus",
24
+ ]
@@ -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:
@@ -381,6 +390,57 @@ class TuiDaemonSession:
381
390
  await connect_websocket_with_retries(self._rpc_client)
382
391
  self._rpc_connected = True
383
392
 
393
+ async def fetch_loop_cards(self, loop_id: str) -> SimpleNamespace:
394
+ """Fetch the daemon's bound display-card snapshot for a loop.
395
+
396
+ RFC-413: returns a populated ledger (eagerly backfilled if the loop
397
+ has no ``cards.jsonl`` yet) so resume can render through the same
398
+ binder that produced the original cards.
399
+
400
+ Args:
401
+ loop_id: AgentLoop id.
402
+
403
+ Returns:
404
+ ``SimpleNamespace`` with ``cards: list[dict]``, ``seq: int``,
405
+ ``success: bool``. On error, ``cards=[]`` and ``success=False`` so
406
+ the caller can fall back to the legacy resume path.
407
+ """
408
+ lid = str(loop_id or "").strip()
409
+ if not lid:
410
+ return SimpleNamespace(cards=[], seq=0, success=False)
411
+
412
+ async with self._rpc_lock:
413
+ await self._ensure_rpc_connected()
414
+ try:
415
+ resp = await self._rpc_client.request_response(
416
+ {"type": "loop_cards_fetch", "loop_id": lid},
417
+ response_type="loop_cards_fetch_response",
418
+ timeout=30.0,
419
+ )
420
+ except Exception:
421
+ logger.warning(
422
+ "loop_cards_fetch failed for loop %s",
423
+ lid[:16],
424
+ exc_info=True,
425
+ )
426
+ return SimpleNamespace(cards=[], seq=0, success=False)
427
+
428
+ raw_cards = resp.get("cards")
429
+ cards = list(raw_cards) if isinstance(raw_cards, list) else []
430
+ seq = int(resp.get("seq") or 0)
431
+ context_tokens_raw = resp.get("context_tokens")
432
+ context_tokens = (
433
+ context_tokens_raw
434
+ if isinstance(context_tokens_raw, int) and context_tokens_raw >= 0
435
+ else 0
436
+ )
437
+ return SimpleNamespace(
438
+ cards=cards,
439
+ seq=seq,
440
+ context_tokens=context_tokens,
441
+ success=True,
442
+ )
443
+
384
444
  async def aget_loop_state(self, loop_id: str) -> Any:
385
445
  """Load agent-loop state channels from the daemon (``loop_state_get`` RPC).
386
446
 
@@ -0,0 +1,15 @@
1
+ """Re-export shim for ``soothe_sdk.display.text_extract`` (RFC-413)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from soothe_sdk.display.text_extract import (
6
+ extract_ai_text_for_display,
7
+ extract_user_text_for_display,
8
+ normalize_stream_message,
9
+ )
10
+
11
+ __all__ = [
12
+ "extract_ai_text_for_display",
13
+ "extract_user_text_for_display",
14
+ "normalize_stream_message",
15
+ ]
@@ -25,7 +25,7 @@ def extract_text_from_message_content(content: Any) -> str:
25
25
  parts.append(block)
26
26
  elif isinstance(block, dict) and "text" in block:
27
27
  parts.append(str(block["text"]))
28
- return "".join(parts)
28
+ return "\n".join(parts)
29
29
  return ""
30
30
 
31
31
 
@@ -41,7 +41,7 @@ def extract_plain_text_from_stream_message(msg: Any) -> str:
41
41
  if text:
42
42
  texts.append(str(text))
43
43
  if texts:
44
- return "".join(texts)
44
+ return "\n".join(texts)
45
45
  if hasattr(msg, "content"):
46
46
  return extract_text_from_message_content(getattr(msg, "content", None))
47
47
  if isinstance(msg, dict):
@@ -54,7 +54,7 @@ def extract_plain_text_from_stream_message(msg: Any) -> str:
54
54
  text = block.get("text", "")
55
55
  if text:
56
56
  texts.append(str(text))
57
- return "".join(texts)
57
+ return "\n".join(texts)
58
58
  content = body.get("content", "")
59
59
  if isinstance(content, str):
60
60
  return content
@@ -224,6 +224,9 @@ class SootheApp(
224
224
 
225
225
  self._agent_running = False
226
226
 
227
+ self._bg_event_worker: Worker[None] | None = None
228
+ """Background daemon event consumer worker (cancelled on active turn start)."""
229
+
227
230
  self._server_startup_error: str | None = None
228
231
  """Set when daemon bootstrap fails; persists for the session lifetime."""
229
232
 
@@ -246,7 +246,12 @@ class _ExecutionMixin:
246
246
  # the wire ``clarification_answer`` flag plus the ``clarification_answers``
247
247
  # list, then clears the persisted flag so a follow-up turn is treated
248
248
  # as a new goal.
249
- await self._run_agent_task(payload_text)
249
+ #
250
+ # Use ``_send_to_agent`` (not a direct ``await _run_agent_task``) so the
251
+ # resumed turn runs in a Textual worker. Awaiting the task inline blocks
252
+ # the message handler — and therefore the event loop — until the loop
253
+ # next pauses, which freezes scrolling and chat-input focus.
254
+ await self._send_to_agent(payload_text)
250
255
 
251
256
  async def _handle_shell_command(self, command: str) -> None:
252
257
  """Handle a shell command (! prefix).
@@ -335,10 +340,13 @@ class _ExecutionMixin:
335
340
  self._shell_process = None
336
341
  self._shell_running = False
337
342
  self._shell_worker = None
338
- if was_interrupted:
339
- await self._mount_message(AppMessage("Command interrupted"))
343
+
344
+ # Restore input focus first so the user can type immediately.
340
345
  if self._chat_input:
341
346
  self._chat_input.set_cursor_active(active=True)
347
+
348
+ if was_interrupted:
349
+ await self._mount_message(AppMessage("Command interrupted"))
342
350
  try:
343
351
  await self._maybe_drain_deferred()
344
352
  except Exception:
@@ -485,10 +493,10 @@ class _ExecutionMixin:
485
493
  elif cmd == "/help":
486
494
  await self._mount_message(UserMessage(command))
487
495
  help_body = (
488
- "Commands: /quit, /clear, /editor, /autopilot, /mcp, "
496
+ "Commands: /quit, /clear, /editor, /autopilot <task>, /autopilot-dashboard, /mcp, "
489
497
  "/model [--model-params JSON] [--default], /notifications, "
490
498
  "/reload, /skill:<name>, /theme, "
491
- "/tokens, /loops, "
499
+ "/tokens, /resume, "
492
500
  "/research, /explore, /plan, /«subagent» (when configured), "
493
501
  "/update, /auto-update, /changelog, /docs, /feedback, /help\n\n"
494
502
  "Interactive Features:\n"
@@ -571,7 +579,7 @@ class _ExecutionMixin:
571
579
  await self._mount_message(AppMessage(f"Started new loop: {new_loop_id}"))
572
580
  elif cmd == "/editor":
573
581
  await self.action_open_editor()
574
- elif cmd == "/loops":
582
+ elif cmd == "/resume":
575
583
  await self._show_loop_selector()
576
584
  elif cmd == "/update":
577
585
  await self._handle_update_command()
@@ -628,7 +636,19 @@ class _ExecutionMixin:
628
636
  args = command.strip()[len("/skill-creator") :].strip()
629
637
  rewritten = f"/skill:skill-creator {args}" if args else "/skill:skill-creator"
630
638
  await self._handle_skill_command(rewritten)
631
- 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":
632
652
  await self._show_autopilot_dashboard()
633
653
  elif cmd == "/mcp":
634
654
  await self._show_mcp_viewer()
@@ -813,6 +833,12 @@ class _ExecutionMixin:
813
833
  return
814
834
  self._agent_running = True
815
835
 
836
+ # Cancel background event consumer so it doesn't compete for
837
+ # WebSocket reads with the active turn's iter_turn_chunks().
838
+ if self._bg_event_worker is not None:
839
+ self._bg_event_worker.cancel()
840
+ self._bg_event_worker = None
841
+
816
842
  if self._chat_input:
817
843
  self._chat_input.set_cursor_active(active=False)
818
844
 
@@ -950,12 +976,14 @@ class _ExecutionMixin:
950
976
  self._agent_running = False
951
977
  self._agent_worker = None
952
978
 
953
- # Remove spinner if present
954
- await self._set_spinner(None)
955
-
979
+ # Restore input focus FIRST so the user can start typing immediately
980
+ # while remaining cleanup (spinner, tokens, deferred) runs.
956
981
  if self._chat_input:
957
982
  self._chat_input.set_cursor_active(active=True)
958
983
 
984
+ # Remove spinner if present
985
+ await self._set_spinner(None)
986
+
959
987
  # Ensure token display is restored (in case of early cancellation).
960
988
  # Pass the cached approximate flag so an interrupted "+" isn't clobbered.
961
989
  self._show_tokens(approximate=self._tokens_approximate)