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,444 @@
1
+ """CLI renderer implementing RendererProtocol for headless output.
2
+
3
+ This module provides the CliRenderer class that outputs events to
4
+ stdout (assistant text) and stderr (progress/tool events).
5
+ Uses StreamDisplayPipeline for RFC-0020 compliant progress display.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from soothe_sdk import get_tool_display_name
16
+ from soothe_sdk.verbosity import VerbosityTier
17
+
18
+ from soothe_cli.cli.stream import DisplayLine, StreamDisplayPipeline
19
+ from soothe_cli.cli.utils import make_tool_block
20
+ from soothe_cli.shared.display_policy import VerbosityLevel, normalize_verbosity
21
+ from soothe_cli.shared.message_processing import format_tool_call_args
22
+ from soothe_cli.shared.presentation_engine import PresentationEngine
23
+ from soothe_cli.shared.suppression_state import SuppressionState
24
+
25
+ if TYPE_CHECKING:
26
+ from soothe_sdk import Plan
27
+
28
+
29
+ @dataclass
30
+ class CliRendererState:
31
+ """CLI-specific display state."""
32
+
33
+ # Track if stdout needs newline before stderr output
34
+ needs_stdout_newline: bool = False
35
+
36
+ # Track if stderr was just written (to add spacing before next stdout)
37
+ stderr_just_written: bool = False
38
+
39
+ # Multi-step/agentic suppression state (IG-143)
40
+ suppression: SuppressionState = field(default_factory=SuppressionState)
41
+
42
+ # Track current plan for status display
43
+ current_plan: Plan | None = None
44
+
45
+ # Track tool call start times for duration display (RFC-0020)
46
+ tool_call_start_times: dict[str, float] = field(default_factory=dict)
47
+
48
+ # After LLM text on stdout, next stderr icon block gets one leading blank line
49
+ stderr_blank_before_next_icon_block: bool = False
50
+
51
+
52
+ class CliRenderer:
53
+ """CLI renderer for headless stdout/stderr output.
54
+
55
+ Implements RendererProtocol callbacks for CLI mode:
56
+ - Assistant text -> stdout (streaming)
57
+ - Tool calls/results -> stderr (flat stream)
58
+ - Progress events -> stderr via StreamDisplayPipeline
59
+ - Errors -> stderr
60
+
61
+ Spacing: Soothe-originated stderr lines (icons from the pipeline, tools, results,
62
+ errors) call `_stderr_begin_icon_block()`, which inserts one blank stderr line only
63
+ after LLM text was written to stdout, so icon blocks separate from answers without
64
+ extra blank lines inside the LLM stream or between consecutive stderr lines.
65
+
66
+ Usage:
67
+ renderer = CliRenderer(verbosity="normal")
68
+ processor = EventProcessor(renderer, verbosity="normal")
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ *,
74
+ verbosity: VerbosityLevel = "normal",
75
+ presentation_engine: PresentationEngine | None = None,
76
+ ) -> None:
77
+ """Initialize CLI renderer.
78
+
79
+ Args:
80
+ verbosity: Progress visibility level.
81
+ presentation_engine: Shared presentation engine (optional).
82
+ """
83
+ self._verbosity = normalize_verbosity(verbosity)
84
+ self._state = CliRendererState()
85
+ self._presentation = presentation_engine or PresentationEngine()
86
+ self._pipeline = StreamDisplayPipeline(
87
+ verbosity=verbosity,
88
+ presentation_engine=self._presentation,
89
+ )
90
+
91
+ def _rebind_presentation(self, engine: PresentationEngine) -> None:
92
+ """Attach a shared presentation engine (used by EventProcessor wiring)."""
93
+ self._presentation = engine
94
+ self._pipeline = StreamDisplayPipeline(
95
+ verbosity=self._verbosity,
96
+ presentation_engine=engine,
97
+ )
98
+
99
+ @property
100
+ def full_response(self) -> list[str]:
101
+ """Get accumulated response text."""
102
+ return self._state.suppression.full_response
103
+
104
+ @property
105
+ def multi_step_active(self) -> bool:
106
+ """Whether multi-step plan is active."""
107
+ return self._state.suppression.multi_step_active
108
+
109
+ @property
110
+ def presentation_engine(self) -> PresentationEngine:
111
+ """Shared presentation policy used with StreamDisplayPipeline and EventProcessor."""
112
+ return self._presentation
113
+
114
+ def write_lines(self, lines: list[DisplayLine]) -> None:
115
+ """Write display lines to stderr.
116
+
117
+ Args:
118
+ lines: List of DisplayLine objects to render.
119
+ """
120
+ if not lines:
121
+ return
122
+
123
+ self._stderr_begin_icon_block()
124
+
125
+ for line in lines:
126
+ sys.stderr.write(line.format() + "\n")
127
+
128
+ sys.stderr.flush()
129
+ self._state.stderr_just_written = True
130
+
131
+ def _write_stdout_final_report(self, text: str) -> None:
132
+ """Write aggregated final answer to stdout (multi-step headless mode only)."""
133
+ stripped = text.strip()
134
+ if not stripped:
135
+ return
136
+
137
+ self._state.suppression.full_response.append(stripped)
138
+
139
+ # Add newline before final report if stderr was just written (goal completion)
140
+ if self._state.stderr_just_written:
141
+ sys.stdout.write("\n")
142
+ self._state.stderr_just_written = False
143
+
144
+ sys.stdout.write(stripped)
145
+ if not stripped.endswith("\n"):
146
+ sys.stdout.write("\n")
147
+ sys.stdout.flush()
148
+ self._state.needs_stdout_newline = True
149
+ self._state.stderr_blank_before_next_icon_block = True
150
+ self._presentation.mark_final_answer_locked()
151
+
152
+ def on_assistant_text(
153
+ self,
154
+ text: str,
155
+ *,
156
+ is_main: bool,
157
+ is_streaming: bool, # noqa: ARG002
158
+ ) -> None:
159
+ """Write assistant text to stdout.
160
+
161
+ HARD SUPPRESS during multi-step execution to prevent intermediate
162
+ LLM response text from flooding output (IG-143).
163
+
164
+ Args:
165
+ text: Text content to display.
166
+ is_main: True if from main agent.
167
+ is_streaming: True if partial chunk.
168
+ """
169
+ if not is_main:
170
+ return # Subagent text not shown in CLI headless mode
171
+
172
+ # HARD BLOCK: No text during multi-step execution (IG-143)
173
+ if self._state.suppression.should_suppress_output():
174
+ # Accumulate for final report instead
175
+ self._state.suppression.accumulate_text(text)
176
+ return
177
+
178
+ # Emit only on final iteration (after flags cleared)
179
+ self._state.suppression.full_response.append(text)
180
+
181
+ if self._state.stderr_just_written:
182
+ self._state.stderr_just_written = False
183
+
184
+ # LLM stream: do not inject extra blank lines (spacing before icon stderr
185
+ # is handled in _stderr_begin_icon_block when progress resumes).
186
+ sys.stdout.write(text)
187
+ sys.stdout.flush()
188
+ self._state.needs_stdout_newline = True
189
+ self._state.stderr_blank_before_next_icon_block = True
190
+
191
+ def on_tool_call(
192
+ self,
193
+ name: str,
194
+ args: dict[str, Any],
195
+ tool_call_id: str,
196
+ *,
197
+ is_main: bool, # noqa: ARG002
198
+ ) -> None:
199
+ """Write tool call to stderr as a flat stream line.
200
+
201
+ Args:
202
+ name: Tool name.
203
+ args: Parsed arguments (may contain _raw for fallback).
204
+ tool_call_id: Tool call identifier.
205
+ is_main: True if from main agent.
206
+ """
207
+ if not self._presentation.tier_visible(VerbosityTier.NORMAL, self._verbosity):
208
+ return
209
+
210
+ # HARD SUPPRESS during multi-step execution (IG-143)
211
+ if self._state.suppression.should_suppress_output():
212
+ return
213
+
214
+ self._stderr_begin_icon_block()
215
+
216
+ display_name = get_tool_display_name(name)
217
+
218
+ # Pass args directly, including any _raw fallback
219
+ args_str = format_tool_call_args(name, {"args": args, "_raw": args.get("_raw", "")})
220
+
221
+ # Use display helper for consistency with TUI (RFC-0020 Principle 5)
222
+ tool_block = make_tool_block(display_name, args_str, status="running")
223
+
224
+ # Track start time for duration display (RFC-0020)
225
+ if tool_call_id:
226
+ self._state.tool_call_start_times[tool_call_id] = time.time()
227
+
228
+ sys.stderr.write(f"{tool_block}\n")
229
+ sys.stderr.flush()
230
+ # Mark that stderr was just written
231
+ self._state.stderr_just_written = True
232
+
233
+ def on_tool_result(
234
+ self,
235
+ name: str, # noqa: ARG002
236
+ result: str,
237
+ tool_call_id: str,
238
+ *,
239
+ is_error: bool,
240
+ is_main: bool, # noqa: ARG002
241
+ ) -> None:
242
+ """Write tool result to stderr as a flat stream line with duration.
243
+
244
+ Args:
245
+ name: Tool name.
246
+ result: Result content (truncated).
247
+ tool_call_id: Tool call identifier.
248
+ is_error: True if result indicates error.
249
+ is_main: True if from main agent.
250
+ """
251
+ if not self._presentation.tier_visible(VerbosityTier.NORMAL, self._verbosity):
252
+ return
253
+
254
+ # HARD SUPPRESS during multi-step execution (IG-143)
255
+ if self._state.suppression.should_suppress_output():
256
+ return
257
+
258
+ self._stderr_begin_icon_block()
259
+
260
+ # Calculate duration (RFC-0020)
261
+ duration_ms = 0
262
+ if tool_call_id and tool_call_id in self._state.tool_call_start_times:
263
+ start_time = self._state.tool_call_start_times.pop(tool_call_id)
264
+ duration_ms = int((time.time() - start_time) * 1000)
265
+
266
+ # Note: extract_tool_brief() may already include ✓/✗ icon
267
+ result = self._presentation.summarize_tool_result(result)
268
+ result_stripped = result.lstrip()
269
+ if result_stripped.startswith(("✓", "✗")):
270
+ result_line = result
271
+ else:
272
+ icon = "✗" if is_error else "✓"
273
+ result_line = f"{icon} {result}"
274
+ if duration_ms > 0:
275
+ result_line += f" ({duration_ms}ms)"
276
+
277
+ sys.stderr.write(result_line + "\n")
278
+ sys.stderr.flush()
279
+
280
+ def on_status_change(self, state: str) -> None:
281
+ """Handle status changes.
282
+
283
+ No-op for CLI - status tracked by event loop.
284
+
285
+ Args:
286
+ state: New daemon state.
287
+ """
288
+
289
+ def on_error(self, error: str, *, context: str | None = None) -> None:
290
+ """Write error to stderr.
291
+
292
+ Args:
293
+ error: Error message.
294
+ context: Optional error context.
295
+ """
296
+ self._stderr_begin_icon_block()
297
+ prefix = f"[{context}] " if context else ""
298
+ sys.stderr.write(f"{prefix}ERROR: {error}\n")
299
+ sys.stderr.flush()
300
+ # Mark that stderr was just written
301
+ self._state.stderr_just_written = True
302
+
303
+ def on_progress_event(
304
+ self,
305
+ event_type: str,
306
+ data: dict[str, Any],
307
+ *,
308
+ namespace: tuple[str, ...], # noqa: ARG002
309
+ ) -> None:
310
+ """Write progress event to stderr using StreamDisplayPipeline.
311
+
312
+ Args:
313
+ event_type: Event type string.
314
+ data: Event payload.
315
+ namespace: Subagent namespace.
316
+ """
317
+ # Track suppression state from event (IG-143)
318
+ final_stdout = self._state.suppression.track_from_event(event_type, data)
319
+
320
+ payload = dict(data)
321
+ payload.pop("final_stdout_message", None)
322
+
323
+ # Build event dict for pipeline
324
+ event = {"type": event_type, **payload}
325
+ lines = self._pipeline.process(event)
326
+ self.write_lines(lines)
327
+
328
+ # Emit final report on loop completion (IG-143)
329
+ if self._state.suppression.should_emit_final_report(event_type, final_stdout):
330
+ response = self._state.suppression.get_final_response(final_stdout)
331
+ self._write_stdout_final_report(response)
332
+
333
+ def on_plan_created(self, plan: Plan) -> None:
334
+ """Write plan creation to stderr.
335
+
336
+ Args:
337
+ plan: Created plan object.
338
+ """
339
+ self._state.current_plan = plan
340
+ self._state.suppression.track_from_plan(len(plan.steps))
341
+
342
+ # Use pipeline for consistent formatting
343
+ event = {
344
+ "type": "soothe.cognition.plan.creating",
345
+ "goal": plan.goal,
346
+ "steps": [{"id": s.id, "description": s.description} for s in plan.steps],
347
+ }
348
+ lines = self._pipeline.process(event)
349
+ self.write_lines(lines)
350
+
351
+ def on_plan_step_started(self, step_id: str, description: str) -> None:
352
+ """Update plan state and show step header.
353
+
354
+ Args:
355
+ step_id: Step identifier.
356
+ description: Step description.
357
+ """
358
+ # Update step status in current plan
359
+ if self._state.current_plan:
360
+ for step in self._state.current_plan.steps:
361
+ if step.id == step_id:
362
+ step.status = "in_progress"
363
+ break
364
+
365
+ # Use pipeline for consistent formatting
366
+ event = {
367
+ "type": "soothe.cognition.plan.step.started",
368
+ "step_id": step_id,
369
+ "description": description,
370
+ }
371
+ lines = self._pipeline.process(event)
372
+ self.write_lines(lines)
373
+
374
+ def on_plan_step_completed(
375
+ self,
376
+ step_id: str,
377
+ success: bool, # noqa: FBT001
378
+ duration_ms: int,
379
+ ) -> None:
380
+ """Update plan state and show step completion.
381
+
382
+ Args:
383
+ step_id: Step identifier.
384
+ success: True if step succeeded.
385
+ duration_ms: Step duration in milliseconds.
386
+ """
387
+ # Update step status in current plan
388
+ if self._state.current_plan:
389
+ for step in self._state.current_plan.steps:
390
+ if step.id == step_id:
391
+ step.status = "completed" if success else "failed"
392
+ break
393
+
394
+ # Use pipeline for consistent formatting
395
+ event = {
396
+ "type": "soothe.cognition.plan.step.completed",
397
+ "step_id": step_id,
398
+ "success": success,
399
+ "duration_ms": duration_ms,
400
+ }
401
+ lines = self._pipeline.process(event)
402
+ self.write_lines(lines)
403
+
404
+ def on_turn_end(self) -> None:
405
+ """Finalize output on turn end.
406
+
407
+ If multi_step_active was suppressing output, flush the accumulated
408
+ response to stdout now that the plan is complete.
409
+ """
410
+ # Capture state BEFORE resetting
411
+ was_multi_step = self._state.suppression.multi_step_active
412
+ accumulated_response = self._state.suppression.full_response
413
+
414
+ # Reset state for next turn FIRST (before output logic)
415
+ self._state.needs_stdout_newline = False
416
+ self._state.suppression.reset_turn()
417
+
418
+ # Multi-step mode intentionally suppresses step body output in headless CLI.
419
+ # For single-step mode, keep existing newline flush behavior.
420
+ if (not was_multi_step) and accumulated_response:
421
+ sys.stdout.write("\n")
422
+ sys.stdout.flush()
423
+
424
+ def _stderr_begin_icon_block(self) -> None:
425
+ """Prepare stderr for Soothe icon lines (progress, tools, tool results).
426
+
427
+ Ensures stdout ends with a newline, then inserts one blank stderr line
428
+ only after LLM content was written to stdout so icon streams stay visually
429
+ separated without double-spacing consecutive stderr lines.
430
+ """
431
+ self._ensure_newline()
432
+ if self._state.stderr_blank_before_next_icon_block:
433
+ sys.stderr.write("\n")
434
+ self._state.stderr_blank_before_next_icon_block = False
435
+
436
+ def _ensure_newline(self) -> None:
437
+ """Ensure stdout has newline before stderr output.
438
+
439
+ This prevents stderr output from mixing into stdout lines.
440
+ """
441
+ if self._state.needs_stdout_newline:
442
+ sys.stdout.write("\n")
443
+ sys.stdout.flush()
444
+ self._state.needs_stdout_newline = False
@@ -0,0 +1,17 @@
1
+ """CLI stream display pipeline for progress output.
2
+
3
+ This package implements RFC-0020 CLI Stream Display Pipeline,
4
+ providing a unified event-to-output pipeline with integrated
5
+ verbosity filtering and context tracking.
6
+ """
7
+
8
+ from soothe_cli.cli.stream.context import PipelineContext, ToolCallInfo
9
+ from soothe_cli.cli.stream.display_line import DisplayLine
10
+ from soothe_cli.cli.stream.pipeline import StreamDisplayPipeline
11
+
12
+ __all__ = [
13
+ "DisplayLine",
14
+ "PipelineContext",
15
+ "StreamDisplayPipeline",
16
+ "ToolCallInfo",
17
+ ]
@@ -0,0 +1,138 @@
1
+ """Pipeline context for tracking CLI display state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class ToolCallInfo:
10
+ """Information about an in-progress tool call.
11
+
12
+ Attributes:
13
+ name: Tool name.
14
+ args_summary: Truncated args summary.
15
+ start_time: Start timestamp (time.time()).
16
+ """
17
+
18
+ name: str
19
+ args_summary: str
20
+ start_time: float
21
+
22
+
23
+ @dataclass
24
+ class PipelineContext:
25
+ """Context tracking for CLI stream display pipeline.
26
+
27
+ Tracks goal, step, and tool state to produce contextual output.
28
+
29
+ Attributes:
30
+ current_goal: Current goal description.
31
+ goal_start_time: Goal start timestamp.
32
+ steps_total: Total steps in current goal.
33
+ steps_completed: Completed step count.
34
+ current_step_id: Active step ID.
35
+ current_step_description: Active step description.
36
+ step_start_time: Step start timestamp.
37
+ pending_tool_calls: Tool calls awaiting results.
38
+ parallel_mode: Whether multiple tools are running.
39
+ subagent_name: Active subagent name.
40
+ subagent_milestones: Accumulated subagent milestones.
41
+ """
42
+
43
+ # Goal state
44
+ current_goal: str | None = None
45
+ goal_start_time: float | None = None
46
+ steps_total: int = 0
47
+ steps_completed: int = 0
48
+
49
+ # Step state
50
+ current_step_id: str | None = None
51
+ current_step_description: str | None = None
52
+ step_start_time: float | None = None
53
+ step_header_emitted: bool = False # Track if step header was emitted
54
+ _active_step_ids: list[str] = field(default_factory=list) # Track parallel steps in progress
55
+ step_descriptions: dict[str, str] = field(default_factory=dict) # Track descriptions by step ID
56
+
57
+ # Parallel tool tracking
58
+ pending_tool_calls: dict[str, ToolCallInfo] = field(default_factory=dict)
59
+ parallel_mode: bool = False
60
+ parallel_header_emitted: bool = False # Track if parallel header was emitted
61
+
62
+ # Subagent tracking
63
+ subagent_name: str | None = None
64
+ subagent_milestones: list[str] = field(default_factory=list)
65
+
66
+ def reset_goal(self) -> None:
67
+ """Reset goal-related state."""
68
+ self.current_goal = None
69
+ self.goal_start_time = None
70
+ self.steps_total = 0
71
+ self.steps_completed = 0
72
+ self._active_step_ids.clear()
73
+ self.step_descriptions.clear()
74
+ self.reset_step()
75
+
76
+ def reset_step(self) -> None:
77
+ """Reset step-related state."""
78
+ self.current_step_id = None
79
+ self.current_step_description = None
80
+ self.step_start_time = None
81
+ self.step_header_emitted = False
82
+ self.pending_tool_calls.clear()
83
+ self.parallel_mode = False
84
+ self.parallel_header_emitted = False
85
+ self.subagent_name = None
86
+ self.subagent_milestones.clear()
87
+ # Don't clear _active_step_ids here - it's cleared when steps complete
88
+
89
+ def complete_step(self, step_id: str) -> None:
90
+ """Mark a step as completed and update tracking.
91
+
92
+ Args:
93
+ step_id: Step identifier to mark complete.
94
+ """
95
+ # Remove from active steps
96
+ if step_id in self._active_step_ids:
97
+ self._active_step_ids.remove(step_id)
98
+ # Increment completed count
99
+ self.steps_completed += 1
100
+
101
+ def start_tool_call(
102
+ self, tool_call_id: str, name: str, args_summary: str, start_time: float
103
+ ) -> None:
104
+ """Register a tool call as started.
105
+
106
+ Args:
107
+ tool_call_id: Tool call identifier.
108
+ name: Tool name.
109
+ args_summary: Truncated args.
110
+ start_time: Start timestamp.
111
+ """
112
+ self.pending_tool_calls[tool_call_id] = ToolCallInfo(
113
+ name=name,
114
+ args_summary=args_summary,
115
+ start_time=start_time,
116
+ )
117
+ # Enable parallel mode if multiple tools running
118
+ if len(self.pending_tool_calls) > 1:
119
+ self.parallel_mode = True
120
+
121
+ def complete_tool_call(self, tool_call_id: str) -> ToolCallInfo | None:
122
+ """Mark a tool call as completed.
123
+
124
+ Args:
125
+ tool_call_id: Tool call identifier.
126
+
127
+ Returns:
128
+ ToolCallInfo if found, None otherwise.
129
+ """
130
+ info = self.pending_tool_calls.pop(tool_call_id, None)
131
+ # Disable parallel mode when all tools complete
132
+ if not self.pending_tool_calls:
133
+ self.parallel_mode = False
134
+ self.parallel_header_emitted = False
135
+ return info
136
+
137
+
138
+ __all__ = ["PipelineContext", "ToolCallInfo"]
@@ -0,0 +1,83 @@
1
+ """DisplayLine dataclass for structured CLI output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ _MS_PER_SECOND = 1000
8
+
9
+
10
+ @dataclass
11
+ class DisplayLine:
12
+ """Structured output unit for CLI stream display.
13
+
14
+ Attributes:
15
+ level: Semantic level (1=goal, 2=step/tool, 3=result); indent is flat for all.
16
+ content: Text content to display.
17
+ icon: Icon prefix ("●", "○", "⚙", "✓", "✗", "→", etc.).
18
+ indent: Indentation string (headless stream uses no tree indent).
19
+ status: Optional status suffix ("running" for parallel tools).
20
+ duration_ms: Optional duration in milliseconds.
21
+ source_prefix: Optional source identifier for debug mode (e.g., "[main]", "[subagent:research]").
22
+ newline_before: Add newline separator before this line for improved readability.
23
+ """
24
+
25
+ level: int
26
+ content: str
27
+ icon: str
28
+ indent: str
29
+ status: str | None = None
30
+ duration_ms: int | None = None
31
+ source_prefix: str | None = None
32
+ newline_before: bool = False
33
+
34
+ def format(self) -> str:
35
+ """Format the display line as a string.
36
+
37
+ Returns:
38
+ Formatted line ready for output.
39
+ """
40
+ parts = []
41
+
42
+ # Add newline separator before if requested (for improved readability)
43
+ if self.newline_before:
44
+ parts.append("\n")
45
+
46
+ # Add source prefix first if present (debug mode)
47
+ if self.source_prefix:
48
+ parts.append(self.source_prefix)
49
+ parts.append(" ")
50
+
51
+ # Handle empty icon (connector already in indent)
52
+ if self.icon:
53
+ parts.extend([self.indent, self.icon, " ", self.content])
54
+ else:
55
+ parts.extend([self.indent, self.content])
56
+
57
+ if self.status:
58
+ parts.append(f" [{self.status}]")
59
+
60
+ if self.duration_ms is not None:
61
+ if self.duration_ms >= _MS_PER_SECOND:
62
+ parts.append(f" ({self.duration_ms / _MS_PER_SECOND:.1f}s)")
63
+ else:
64
+ parts.append(f" ({self.duration_ms}ms)")
65
+
66
+ return "".join(parts)
67
+
68
+
69
+ def indent_for_level(_level: int) -> str:
70
+ """Get indentation string for a display level.
71
+
72
+ Headless CLI uses a flat information stream (no tree connectors).
73
+
74
+ Args:
75
+ _level: Display level (1, 2, or 3); retained for API compatibility.
76
+
77
+ Returns:
78
+ Indentation string (always empty for stream layout).
79
+ """
80
+ return ""
81
+
82
+
83
+ __all__ = ["DisplayLine", "indent_for_level"]