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.
- soothe_cli/__init__.py +5 -0
- soothe_cli/cli/__init__.py +1 -0
- soothe_cli/cli/commands/__init__.py +1 -0
- soothe_cli/cli/commands/autopilot_cmd.py +410 -0
- soothe_cli/cli/commands/config_cmd.py +277 -0
- soothe_cli/cli/commands/run_cmd.py +87 -0
- soothe_cli/cli/commands/status_cmd.py +121 -0
- soothe_cli/cli/commands/subagent_names.py +17 -0
- soothe_cli/cli/commands/thread_cmd.py +657 -0
- soothe_cli/cli/execution/__init__.py +6 -0
- soothe_cli/cli/execution/daemon.py +194 -0
- soothe_cli/cli/execution/headless.py +99 -0
- soothe_cli/cli/execution/launcher.py +31 -0
- soothe_cli/cli/main.py +509 -0
- soothe_cli/cli/renderer.py +444 -0
- soothe_cli/cli/stream/__init__.py +17 -0
- soothe_cli/cli/stream/context.py +138 -0
- soothe_cli/cli/stream/display_line.py +83 -0
- soothe_cli/cli/stream/formatter.py +412 -0
- soothe_cli/cli/stream/pipeline.py +521 -0
- soothe_cli/cli/utils.py +46 -0
- soothe_cli/config/__init__.py +5 -0
- soothe_cli/config/cli_config.py +155 -0
- soothe_cli/plan/__init__.py +5 -0
- soothe_cli/plan/rich_tree.py +54 -0
- soothe_cli/shared/__init__.py +107 -0
- soothe_cli/shared/command_router.py +246 -0
- soothe_cli/shared/config_loader.py +68 -0
- soothe_cli/shared/display_policy.py +413 -0
- soothe_cli/shared/essential_events.py +68 -0
- soothe_cli/shared/event_processor.py +823 -0
- soothe_cli/shared/message_processing.py +393 -0
- soothe_cli/shared/presentation_engine.py +173 -0
- soothe_cli/shared/processor_state.py +80 -0
- soothe_cli/shared/renderer_protocol.py +158 -0
- soothe_cli/shared/rendering.py +43 -0
- soothe_cli/shared/slash_commands.py +354 -0
- soothe_cli/shared/subagent_routing.py +63 -0
- soothe_cli/shared/suppression_state.py +188 -0
- soothe_cli/shared/tool_formatters/__init__.py +27 -0
- soothe_cli/shared/tool_formatters/base.py +109 -0
- soothe_cli/shared/tool_formatters/execution.py +297 -0
- soothe_cli/shared/tool_formatters/fallback.py +128 -0
- soothe_cli/shared/tool_formatters/file_ops.py +299 -0
- soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
- soothe_cli/shared/tool_formatters/media.py +291 -0
- soothe_cli/shared/tool_formatters/structured.py +202 -0
- soothe_cli/shared/tool_formatters/web.py +143 -0
- soothe_cli/shared/tool_output_formatter.py +227 -0
- soothe_cli/shared/tui_trace_log.py +40 -0
- soothe_cli/tui/__init__.py +5 -0
- soothe_cli/tui/_ask_user_types.py +50 -0
- soothe_cli/tui/_cli_context.py +27 -0
- soothe_cli/tui/_env_vars.py +56 -0
- soothe_cli/tui/_session_stats.py +114 -0
- soothe_cli/tui/_version.py +21 -0
- soothe_cli/tui/app.py +4992 -0
- soothe_cli/tui/app.tcss +302 -0
- soothe_cli/tui/command_registry.py +310 -0
- soothe_cli/tui/config.py +2381 -0
- soothe_cli/tui/daemon_session.py +233 -0
- soothe_cli/tui/file_ops.py +409 -0
- soothe_cli/tui/formatting.py +28 -0
- soothe_cli/tui/hooks.py +23 -0
- soothe_cli/tui/input.py +782 -0
- soothe_cli/tui/media_utils.py +471 -0
- soothe_cli/tui/model_config.py +518 -0
- soothe_cli/tui/output.py +69 -0
- soothe_cli/tui/project_utils.py +188 -0
- soothe_cli/tui/sessions.py +1248 -0
- soothe_cli/tui/skills/__init__.py +5 -0
- soothe_cli/tui/skills/invocation.py +74 -0
- soothe_cli/tui/skills/load.py +93 -0
- soothe_cli/tui/textual_adapter.py +1430 -0
- soothe_cli/tui/theme.py +838 -0
- soothe_cli/tui/tool_display.py +297 -0
- soothe_cli/tui/unicode_security.py +502 -0
- soothe_cli/tui/update_check.py +447 -0
- soothe_cli/tui/widgets/__init__.py +9 -0
- soothe_cli/tui/widgets/_links.py +63 -0
- soothe_cli/tui/widgets/approval.py +430 -0
- soothe_cli/tui/widgets/ask_user.py +392 -0
- soothe_cli/tui/widgets/autocomplete.py +666 -0
- soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
- soothe_cli/tui/widgets/autopilot_screen.py +64 -0
- soothe_cli/tui/widgets/chat_input.py +1834 -0
- soothe_cli/tui/widgets/clipboard.py +128 -0
- soothe_cli/tui/widgets/diff.py +240 -0
- soothe_cli/tui/widgets/editor.py +140 -0
- soothe_cli/tui/widgets/history.py +221 -0
- soothe_cli/tui/widgets/loading.py +194 -0
- soothe_cli/tui/widgets/mcp_viewer.py +352 -0
- soothe_cli/tui/widgets/message_store.py +693 -0
- soothe_cli/tui/widgets/messages.py +1720 -0
- soothe_cli/tui/widgets/model_selector.py +988 -0
- soothe_cli/tui/widgets/notification_settings.py +155 -0
- soothe_cli/tui/widgets/status.py +403 -0
- soothe_cli/tui/widgets/theme_selector.py +158 -0
- soothe_cli/tui/widgets/thread_selector.py +1865 -0
- soothe_cli/tui/widgets/tool_renderers.py +148 -0
- soothe_cli/tui/widgets/tool_widgets.py +254 -0
- soothe_cli/tui/widgets/tools.py +165 -0
- soothe_cli/tui/widgets/welcome.py +330 -0
- soothe_cli-0.1.0.dist-info/METADATA +100 -0
- soothe_cli-0.1.0.dist-info/RECORD +107 -0
- soothe_cli-0.1.0.dist-info/WHEEL +4 -0
- 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"]
|