soothe-cli 0.1.0__py3-none-any.whl

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 (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,521 @@
1
+ """Stream display pipeline for CLI progress output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from typing import Any
8
+
9
+ from soothe_sdk.protocol import preview_first
10
+ from soothe_sdk.verbosity import VerbosityTier
11
+
12
+ from soothe_cli.cli.stream.context import PipelineContext
13
+ from soothe_cli.cli.stream.display_line import DisplayLine
14
+ from soothe_cli.cli.stream.formatter import (
15
+ format_goal_done,
16
+ format_goal_header,
17
+ format_judgement,
18
+ format_reasoning,
19
+ format_step_done,
20
+ format_step_header,
21
+ format_subagent_done,
22
+ format_subagent_milestone,
23
+ format_tool_call,
24
+ )
25
+ from soothe_cli.shared.display_policy import VerbosityLevel, normalize_verbosity
26
+ from soothe_cli.shared.essential_events import (
27
+ LOOP_REASON_EVENT_TYPE,
28
+ is_goal_start_event_type,
29
+ is_step_complete_event_type,
30
+ is_step_start_event_type,
31
+ )
32
+ from soothe_cli.shared.presentation_engine import PresentationEngine
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # Batch step events for parallel execution
37
+ BATCH_STEP_STARTED = "soothe.cognition.plan.batch.started"
38
+ BATCH_STEP_COMPLETED = "soothe.cognition.plan.batch.completed"
39
+
40
+ GOAL_COMPLETE_EVENTS = {
41
+ "soothe.cognition.agent_loop.completed",
42
+ }
43
+
44
+ # Verbosity tier mapping
45
+ _VERBOSITY_TO_TIER = {
46
+ "quiet": VerbosityTier.QUIET,
47
+ "normal": VerbosityTier.NORMAL,
48
+ "detailed": VerbosityTier.DETAILED,
49
+ "debug": VerbosityTier.DEBUG,
50
+ }
51
+
52
+
53
+ class StreamDisplayPipeline:
54
+ """Pipeline for processing events into CLI display lines.
55
+
56
+ Processes events with integrated verbosity filtering and context tracking.
57
+ Emits structured DisplayLine objects for rendering.
58
+
59
+ Usage:
60
+ pipeline = StreamDisplayPipeline(verbosity="normal")
61
+ for event in events:
62
+ lines = pipeline.process(event)
63
+ renderer.write_lines(lines)
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ verbosity: VerbosityLevel = "normal",
69
+ *,
70
+ presentation_engine: PresentationEngine | None = None,
71
+ ) -> None:
72
+ """Initialize the pipeline.
73
+
74
+ Args:
75
+ verbosity: Verbosity level for filtering.
76
+ presentation_engine: Shared engine (defaults to a new instance).
77
+ """
78
+ self._verbosity = normalize_verbosity(verbosity)
79
+ self._verbosity_tier = _VERBOSITY_TO_TIER.get(self._verbosity, VerbosityTier.NORMAL)
80
+ self._context = PipelineContext()
81
+ self._presentation = presentation_engine or PresentationEngine()
82
+ self._current_namespace: tuple[str, ...] = () # Track current namespace
83
+
84
+ def process(self, event: dict[str, Any]) -> list[DisplayLine]:
85
+ """Process an event into display lines.
86
+
87
+ Args:
88
+ event: Event dictionary with 'type' key.
89
+
90
+ Returns:
91
+ List of DisplayLine objects to render.
92
+ """
93
+ event_type = event.get("type", "")
94
+ if not event_type:
95
+ return []
96
+
97
+ # Extract namespace from event
98
+ self._current_namespace = tuple(event.get("namespace", []))
99
+
100
+ # Classify and filter
101
+ tier = self._classify_event(event_type)
102
+ if tier > self._verbosity_tier:
103
+ return []
104
+
105
+ # Dispatch to handlers
106
+ return self._dispatch_event(event_type, event)
107
+
108
+ def _classify_event(self, event_type: str) -> VerbosityTier:
109
+ """Classify event type to verbosity tier.
110
+
111
+ Args:
112
+ event_type: Event type string.
113
+
114
+ Returns:
115
+ VerbosityTier for the event.
116
+ """
117
+ from soothe_sdk.verbosity import classify_event_to_tier
118
+
119
+ # Goal events - NORMAL
120
+ if is_goal_start_event_type(event_type):
121
+ return VerbosityTier.NORMAL
122
+
123
+ # Step start events - NORMAL (user-visible step descriptions)
124
+ if is_step_start_event_type(event_type):
125
+ return VerbosityTier.NORMAL
126
+
127
+ # Goal completion - QUIET (always visible)
128
+ if event_type in GOAL_COMPLETE_EVENTS:
129
+ return VerbosityTier.QUIET
130
+
131
+ # soothe.* events: defer to SDK domain-based classification (RFC-0020)
132
+ # Step completion, tool events, subagent events all use domain defaults
133
+ if event_type.startswith("soothe."):
134
+ return classify_event_to_tier(event_type)
135
+
136
+ # Non-soothe events (from deepagents subagents)
137
+ if ".subagent." in event_type:
138
+ return VerbosityTier.NORMAL
139
+
140
+ # Default to DETAILED (hidden at normal)
141
+ return VerbosityTier.DETAILED
142
+
143
+ def _dispatch_event(self, event_type: str, event: dict[str, Any]) -> list[DisplayLine]:
144
+ """Dispatch event to appropriate handler.
145
+
146
+ Args:
147
+ event_type: Event type string.
148
+ event: Event dictionary.
149
+
150
+ Returns:
151
+ List of DisplayLine objects.
152
+ """
153
+ if is_goal_start_event_type(event_type):
154
+ return self._on_goal_started(event)
155
+
156
+ if is_step_start_event_type(event_type):
157
+ return self._on_step_started(event)
158
+
159
+ if ".subagent." in event_type and ".dispatched" in event_type:
160
+ return self._on_subagent_dispatched(event)
161
+
162
+ if ".subagent." in event_type and ".judgement" in event_type:
163
+ return self._on_subagent_judgement(event)
164
+
165
+ if ".subagent." in event_type and ".step" in event_type:
166
+ return self._on_subagent_step(event)
167
+
168
+ if ".subagent." in event_type and ".completed" in event_type:
169
+ return self._on_subagent_completed(event)
170
+
171
+ if is_step_complete_event_type(event_type):
172
+ return self._on_step_completed(event)
173
+
174
+ if event_type in GOAL_COMPLETE_EVENTS:
175
+ return self._on_goal_completed(event)
176
+
177
+ if event_type == LOOP_REASON_EVENT_TYPE:
178
+ return self._on_loop_agent_reason(event)
179
+
180
+ return []
181
+
182
+ def _on_goal_started(self, event: dict[str, Any]) -> list[DisplayLine]:
183
+ """Handle goal start event.
184
+
185
+ Args:
186
+ event: Event dictionary.
187
+
188
+ Returns:
189
+ Display lines for goal header.
190
+ """
191
+ goal = event.get("goal", event.get("goal_description", ""))
192
+ if not goal:
193
+ return []
194
+
195
+ # Reset context for new goal
196
+ self._context.reset_goal()
197
+ self._context.current_goal = goal
198
+ self._context.goal_start_time = time.time()
199
+
200
+ # Get steps count if available
201
+ steps = event.get("steps", [])
202
+ self._context.steps_total = len(steps) if steps else 0
203
+
204
+ return [
205
+ format_goal_header(
206
+ goal,
207
+ namespace=self._current_namespace,
208
+ verbosity_tier=self._verbosity_tier,
209
+ )
210
+ ]
211
+
212
+ def _on_step_started(self, event: dict[str, Any]) -> list[DisplayLine]:
213
+ """Handle step start event.
214
+
215
+ Args:
216
+ event: Event dictionary.
217
+
218
+ Returns:
219
+ Display lines for step header.
220
+ """
221
+ step_id = event.get("step_id", event.get("id", ""))
222
+ description = event.get("description", event.get("step_description", ""))
223
+
224
+ if not description:
225
+ return []
226
+
227
+ # Track step by ID for parallel execution
228
+ if step_id and step_id not in self._context._active_step_ids:
229
+ self._context._active_step_ids.append(step_id)
230
+ if step_id:
231
+ self._context.step_descriptions[step_id] = description
232
+
233
+ # Reset step context for this specific step
234
+ self._context.current_step_id = step_id
235
+ self._context.current_step_description = description
236
+ self._context.step_start_time = time.time()
237
+ self._context.step_header_emitted = True
238
+
239
+ return [
240
+ format_step_header(
241
+ description,
242
+ namespace=self._current_namespace,
243
+ verbosity_tier=self._verbosity_tier,
244
+ )
245
+ ]
246
+
247
+ def _on_subagent_dispatched(self, event: dict[str, Any]) -> list[DisplayLine]:
248
+ """Handle subagent dispatched event.
249
+
250
+ Args:
251
+ event: Event dictionary.
252
+
253
+ Returns:
254
+ Display lines (none for dispatch, just tracking).
255
+ """
256
+ # Extract name from event type: soothe.subagent.<name>.dispatched
257
+ event_type = event.get("type", "")
258
+ parts = event_type.split(".")
259
+ name = ""
260
+ # Pattern: soothe.subagent.<name>.dispatched -> parts[0]=soothe, parts[1]=subagent, parts[2]=name
261
+ # Need at least 3 parts for valid subagent event type
262
+ if len(parts) >= 3 and parts[1] == "subagent": # noqa: PLR2004
263
+ name = parts[2]
264
+ name = name or event.get("name", event.get("subagent_name", ""))
265
+ self._context.subagent_name = name
266
+ self._context.subagent_milestones.clear()
267
+
268
+ # Emit tool call for subagent dispatch
269
+ query = event.get("query", event.get("task", event.get("topic", "")))
270
+ args_summary = f'"{preview_first(query, 40)}"' if query else ""
271
+ return [
272
+ format_tool_call(
273
+ f"{name}_subagent",
274
+ args_summary,
275
+ namespace=self._current_namespace,
276
+ verbosity_tier=self._verbosity_tier,
277
+ )
278
+ ]
279
+
280
+ def _on_subagent_judgement(self, event: dict[str, Any]) -> list[DisplayLine]:
281
+ """Handle subagent judgement event.
282
+
283
+ IG-089: Shows meaningful LLM decision reasoning without raw intermediate data.
284
+
285
+ Args:
286
+ event: Event dictionary.
287
+
288
+ Returns:
289
+ Display lines for judgement.
290
+ """
291
+ judgement = event.get("judgement", "")
292
+ action = event.get("action", "")
293
+
294
+ if not judgement:
295
+ return []
296
+
297
+ return [
298
+ format_judgement(
299
+ judgement,
300
+ action,
301
+ namespace=self._current_namespace,
302
+ verbosity_tier=self._verbosity_tier,
303
+ )
304
+ ]
305
+
306
+ def _on_subagent_step(self, event: dict[str, Any]) -> list[DisplayLine]:
307
+ """Handle subagent step event (compact hybrid).
308
+
309
+ Args:
310
+ event: Event dictionary.
311
+
312
+ Returns:
313
+ Display lines for milestone (if significant).
314
+ """
315
+ # Only show query/analyze type steps
316
+ step_type = event.get("step_type", event.get("type", ""))
317
+ if step_type not in ("query", "analyze", "search", "fetch"):
318
+ return []
319
+
320
+ brief = event.get("brief", event.get("summary", ""))
321
+ if not brief:
322
+ action = event.get("action", "")
323
+ target = event.get("target", "")
324
+ brief = f"{action}: {target}" if action and target else action or target
325
+
326
+ if not brief:
327
+ return []
328
+
329
+ return [
330
+ format_subagent_milestone(
331
+ preview_first(brief, 60),
332
+ namespace=self._current_namespace,
333
+ verbosity_tier=self._verbosity_tier,
334
+ )
335
+ ]
336
+
337
+ def _on_subagent_completed(self, event: dict[str, Any]) -> list[DisplayLine]:
338
+ """Handle subagent completed event.
339
+
340
+ Args:
341
+ event: Event dictionary.
342
+
343
+ Returns:
344
+ Display lines for completion.
345
+ """
346
+ # Handle various summary fields from different subagent events
347
+ summary = event.get("summary", event.get("result", "done"))
348
+ if not summary:
349
+ # For events like ResearchCompletedEvent that have answer_length
350
+ answer_len = event.get("answer_length", 0)
351
+ result_count = event.get("result_count", 0)
352
+ if answer_len:
353
+ summary = f"{answer_len} chars"
354
+ elif result_count:
355
+ summary = f"{result_count} results"
356
+ else:
357
+ summary = "done"
358
+
359
+ duration_s = event.get("duration_s", event.get("duration_seconds", 0))
360
+
361
+ if duration_s == 0:
362
+ duration_ms = event.get("duration_ms", 0)
363
+ duration_s = duration_ms / 1000 if duration_ms else 0
364
+
365
+ return [
366
+ format_subagent_done(
367
+ preview_first(summary, 50),
368
+ duration_s,
369
+ namespace=self._current_namespace,
370
+ verbosity_tier=self._verbosity_tier,
371
+ )
372
+ ]
373
+
374
+ def _on_step_completed(self, event: dict[str, Any]) -> list[DisplayLine]:
375
+ """Handle step completed event.
376
+
377
+ Args:
378
+ event: Event dictionary.
379
+
380
+ Returns:
381
+ Display lines for step completion.
382
+ """
383
+ step_id = event.get("step_id", "")
384
+ duration_s = event.get("duration_s", event.get("duration_seconds", 0))
385
+ if duration_s == 0:
386
+ duration_ms = event.get("duration_ms", 0)
387
+ duration_s = duration_ms / 1000 if duration_ms else 0
388
+
389
+ # Use tracked start time if available
390
+ if duration_s == 0 and self._context.step_start_time:
391
+ duration_s = time.time() - self._context.step_start_time
392
+
393
+ # Resolve description robustly for parallel/async step completions
394
+ description = (
395
+ self._context.step_descriptions.get(step_id, "")
396
+ or self._context.current_step_description
397
+ or event.get("description", "")
398
+ or "Completed action"
399
+ )
400
+
401
+ # Get tool call count from event
402
+ tool_call_count = event.get("tool_call_count", 0)
403
+
404
+ # Mark step complete (updates _active_step_ids and steps_completed)
405
+ if step_id:
406
+ self._context.complete_step(step_id)
407
+ self._context.step_descriptions.pop(step_id, None)
408
+
409
+ # Reset current step context (but not _active_step_ids)
410
+ self._context.current_step_id = None
411
+ self._context.current_step_description = None
412
+ self._context.step_start_time = None
413
+
414
+ return [
415
+ format_step_done(
416
+ description,
417
+ duration_s,
418
+ tool_call_count=tool_call_count,
419
+ namespace=self._current_namespace,
420
+ verbosity_tier=self._verbosity_tier,
421
+ )
422
+ ]
423
+
424
+ def _on_goal_completed(self, event: dict[str, Any]) -> list[DisplayLine]:
425
+ """Handle goal completed event.
426
+
427
+ Args:
428
+ event: Event dictionary.
429
+
430
+ Returns:
431
+ Display lines for goal completion.
432
+ """
433
+ goal = self._context.current_goal or event.get("goal", "")
434
+ steps = self._context.steps_completed or event.get("total_steps", 0)
435
+
436
+ total_s = event.get("total_duration_s", 0)
437
+ if total_s == 0 and self._context.goal_start_time:
438
+ total_s = time.time() - self._context.goal_start_time
439
+
440
+ # Reset goal context
441
+ self._context.reset_goal()
442
+
443
+ return [
444
+ format_goal_done(
445
+ goal,
446
+ steps,
447
+ total_s,
448
+ namespace=self._current_namespace,
449
+ verbosity_tier=self._verbosity_tier,
450
+ )
451
+ ]
452
+
453
+ def _on_loop_agent_reason(self, event: dict[str, Any]) -> list[DisplayLine]:
454
+ """Handle AgentLoop Reason progress with prominent reasoning display (IG-152)."""
455
+ status = event.get("status", "")
456
+
457
+ # Extract action text (IG-152: full text, no truncation in schema or display)
458
+ action_text = event.get("next_action", "").strip() or self._derive_action_from_status(
459
+ status
460
+ )
461
+
462
+ if not action_text:
463
+ return []
464
+
465
+ # Polish: Capitalize first letter if not already
466
+ if action_text and action_text[0].islower():
467
+ action_text = action_text[0].upper() + action_text[1:]
468
+
469
+ # IG-152: Show full action text to user (no truncation)
470
+ # Word boundary respect happens at schema level (preview_first in planner)
471
+ # CLI display should show complete reasoning chain for transparency
472
+
473
+ # Deduplicate repeated actions
474
+ if not self._presentation.should_emit_action(action_text=action_text):
475
+ return []
476
+
477
+ # Determine action type
478
+ action = "complete" if status == "done" else "continue"
479
+
480
+ lines = [
481
+ format_judgement(
482
+ action_text,
483
+ action,
484
+ namespace=self._current_namespace,
485
+ verbosity_tier=self._verbosity_tier,
486
+ )
487
+ ]
488
+
489
+ # Add reasoning line if present (IG-XXX: Show internal technical analysis)
490
+ reasoning = event.get("reasoning", "").strip()
491
+ if reasoning:
492
+ lines.append(
493
+ format_reasoning(
494
+ reasoning,
495
+ namespace=self._current_namespace,
496
+ verbosity_tier=self._verbosity_tier,
497
+ )
498
+ )
499
+
500
+ return lines
501
+
502
+ def _derive_action_from_status(self, status: str) -> str:
503
+ """Fallback action text when metadata missing.
504
+
505
+ Args:
506
+ status: Reason event status field.
507
+
508
+ Returns:
509
+ Human-readable action description, or empty string if no valid status.
510
+ """
511
+ if status == "done":
512
+ return "Completing final analysis"
513
+ if status == "replan":
514
+ return "Trying alternative approach"
515
+ if status == "working":
516
+ return "Processing next step"
517
+ # No fallback for missing/empty status - better to skip than emit noise
518
+ return ""
519
+
520
+
521
+ __all__ = ["StreamDisplayPipeline"]
@@ -0,0 +1,46 @@
1
+ """CLI rendering utilities.
2
+
3
+ This module provides helper functions for creating plain-text output
4
+ to stderr, following the same visual patterns as TUI but without Rich widgets.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+
10
+ def make_tool_block(
11
+ name: str,
12
+ args_summary: str,
13
+ output: str | None = None,
14
+ status: str = "running", # noqa: ARG001
15
+ ) -> str:
16
+ """Create a tool block with dot prefix and optional output.
17
+
18
+ Args:
19
+ name: Tool name to display.
20
+ args_summary: Summary of tool arguments.
21
+ output: Optional tool output to show with tree connector.
22
+ status: Tool status - 'running', 'success', or 'error'.
23
+
24
+ Returns:
25
+ Plain text formatted as:
26
+ ⚙ ToolName(args_summary)
27
+ └ output
28
+ """
29
+ # Use gear icon for tools (matches TUI pattern)
30
+ result = f"⚙ {name}({args_summary})"
31
+
32
+ if output is not None:
33
+ # Add output with tree connector
34
+ lines = output.split("\n")
35
+ for i, line in enumerate(lines):
36
+ if i == 0:
37
+ result += f"\n └ {line}"
38
+ else:
39
+ result += f"\n {line}"
40
+
41
+ return result
42
+
43
+
44
+ __all__ = [
45
+ "make_tool_block",
46
+ ]
@@ -0,0 +1,5 @@
1
+ """CLI configuration package."""
2
+
3
+ from soothe_cli.config.cli_config import CLIConfig
4
+
5
+ __all__ = ["CLIConfig"]