lisa-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.
- hosts/__init__.py +0 -0
- hosts/cli/__init__.py +0 -0
- hosts/cli/main.py +2692 -0
- hosts/cli/render.py +535 -0
- hosts/cli/transport.py +1623 -0
- lisa_cli-0.1.0.dist-info/METADATA +26 -0
- lisa_cli-0.1.0.dist-info/RECORD +10 -0
- lisa_cli-0.1.0.dist-info/WHEEL +5 -0
- lisa_cli-0.1.0.dist-info/entry_points.txt +2 -0
- lisa_cli-0.1.0.dist-info/top_level.txt +1 -0
hosts/cli/transport.py
ADDED
|
@@ -0,0 +1,1623 @@
|
|
|
1
|
+
"""Prompt-mode Rich transport for the CLI host.
|
|
2
|
+
|
|
3
|
+
This is the terminal-transport implementation of
|
|
4
|
+
:class:`runtime.callbacks.TransportCallbacks` for ``lisa prompt`` — the CLI's
|
|
5
|
+
only turn path. A prompt run is input/output in the caller's shell: another
|
|
6
|
+
pipeline stage, not a session with a screen. Instead of a persistent Live
|
|
7
|
+
transcript it renders:
|
|
8
|
+
|
|
9
|
+
* a one-line transient activity indicator (spinner + mode + in-flight
|
|
10
|
+
tool label + models/tools/elapsed) via an overwrite-in-place Live; and
|
|
11
|
+
* each tool/model/fork row printed to real scrollback as it *completes*,
|
|
12
|
+
so the trajectory survives piping/tee.
|
|
13
|
+
|
|
14
|
+
Tokens are per-turn: a ``prompt`` invocation is one process and one turn,
|
|
15
|
+
so the footer reports a single total (the turn/session split collapses).
|
|
16
|
+
|
|
17
|
+
Non-tty consoles (``console.is_terminal`` False) skip the spinner entirely
|
|
18
|
+
and emit only the completed-event lines — safe under ``| grep``.
|
|
19
|
+
|
|
20
|
+
Streamed tool output (``stream_output`` tools, e.g. bash_run) follows the
|
|
21
|
+
same tty split: non-tty consoles print every streamed line to scrollback as
|
|
22
|
+
it arrives (the legacy transcript, safe under tee); on a tty the in-flight
|
|
23
|
+
lines stage into a bounded tail box inside the activity display (older
|
|
24
|
+
lines age out, the title flags how much was elided) so a churning command
|
|
25
|
+
never floods the screen — the completion row's ``⎿ N lines`` summary
|
|
26
|
+
carries the shape, and a failed tool flushes the tail for error context.
|
|
27
|
+
|
|
28
|
+
Raw mode (``raw=True``) writes the response verbatim to raw stdout — no
|
|
29
|
+
Rule header, no Markdown re-render — so redirected/piped output stays
|
|
30
|
+
machine-readable; all activity output lives on the transport console
|
|
31
|
+
(stderr when the prompt command runs in raw mode).
|
|
32
|
+
|
|
33
|
+
Stage-line legend
|
|
34
|
+
-----------------
|
|
35
|
+
* ``→ op / → tool / → mcp / ↦`` — tool-kind glyphs (in-process operation,
|
|
36
|
+
pack tool, MCP call, transition).
|
|
37
|
+
* ``✓ / ✗ / …`` — tool-completion status glyphs.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import contextlib
|
|
43
|
+
import logging
|
|
44
|
+
import sys
|
|
45
|
+
from collections.abc import Callable
|
|
46
|
+
from time import perf_counter
|
|
47
|
+
from typing import Any, ClassVar
|
|
48
|
+
|
|
49
|
+
from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
|
|
50
|
+
from rich.live import Live
|
|
51
|
+
from rich.markdown import Markdown
|
|
52
|
+
from rich.panel import Panel
|
|
53
|
+
from rich.rule import Rule
|
|
54
|
+
from rich.spinner import Spinner
|
|
55
|
+
from rich.text import Text
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
# ── Visual style constants (kept in sync with the Lisa palette) ──
|
|
60
|
+
STYLE_BORDER_RESPONSE = "dark_sea_green4"
|
|
61
|
+
STYLE_BORDER_ERROR = "indian_red"
|
|
62
|
+
STYLE_STAGE = "grey58"
|
|
63
|
+
STYLE_TEXT_MUTED = "grey58"
|
|
64
|
+
STYLE_TEXT_PRIMARY = "bright_white"
|
|
65
|
+
STYLE_GRAPHIC = "grey58"
|
|
66
|
+
STYLE_ROW_TRANSITION = "gold3"
|
|
67
|
+
STYLE_ROW_MODEL = "dark_sea_green4"
|
|
68
|
+
STYLE_ROW_TOOL = "cornflower_blue"
|
|
69
|
+
STYLE_ROW_ERROR = "indian_red"
|
|
70
|
+
STYLE_ROW_SUCCESS = "green3"
|
|
71
|
+
STYLE_STREAM_ANSWER = "bright_white"
|
|
72
|
+
STYLE_STREAM_BOX = "grey50"
|
|
73
|
+
STYLE_STREAM_BOX_BORDER = "grey35"
|
|
74
|
+
STYLE_OUTPUT_REGION = "grey42"
|
|
75
|
+
STYLE_TOOL_NAME = "bold bright_white"
|
|
76
|
+
STYLE_ARG_PREVIEW = "grey58"
|
|
77
|
+
STYLE_DETAIL_KEY = "grey58"
|
|
78
|
+
|
|
79
|
+
#: Glyphs for tool-dispatch completion status.
|
|
80
|
+
_TOOL_STATUS_GLYPH = {"ok": "✓", "error": "✗", "running": "…", "": "·"}
|
|
81
|
+
|
|
82
|
+
#: Maximum length of the primary tool argument shown inline on a tool row.
|
|
83
|
+
_ARG_PREVIEW_MAX = 72
|
|
84
|
+
|
|
85
|
+
#: Minimum terminal width below which the right-aligned status column is
|
|
86
|
+
#: skipped (narrow consoles keep the compact left-aligned row).
|
|
87
|
+
_STATUS_ALIGN_MIN_WIDTH = 80
|
|
88
|
+
|
|
89
|
+
#: Column reserved for the right-aligned status+duration segment.
|
|
90
|
+
_STATUS_ALIGN_COLUMN = 24
|
|
91
|
+
|
|
92
|
+
#: Token-count formatting thresholds for the compact footer figures.
|
|
93
|
+
_TOKENS_MILLION = 1_000_000
|
|
94
|
+
_TOKENS_THOUSAND = 1_000
|
|
95
|
+
|
|
96
|
+
#: Budget-bar glyphs: filled pips, empty pips (rendered in stage color), and
|
|
97
|
+
#: the near-cap warning threshold ratio at which the bar switches to gold.
|
|
98
|
+
_BUDGET_BAR_FILLED = "▰"
|
|
99
|
+
_BUDGET_BAR_EMPTY = "▱"
|
|
100
|
+
_BUDGET_BAR_MAX_PIPS = 8
|
|
101
|
+
_BUDGET_BAR_WARN_RATIO = 0.75
|
|
102
|
+
|
|
103
|
+
#: Maximum pretty-printed argument lines shown at detail level 2.
|
|
104
|
+
_DETAIL_ARGUMENT_MAX_LINES = 12
|
|
105
|
+
|
|
106
|
+
#: Tool-detail verbosity levels (``--tool-detail`` / ``-v`` count).
|
|
107
|
+
_TOOL_DETAIL_SUMMARY = 1
|
|
108
|
+
_TOOL_DETAIL_FULL = 2
|
|
109
|
+
|
|
110
|
+
#: Elapsed-time format switch: below this many seconds the activity line
|
|
111
|
+
#: shows plain seconds; at/above it shows minutes:seconds.
|
|
112
|
+
_ELAPSED_MINUTE_SECONDS = 60
|
|
113
|
+
#: Bounded thinking/answer stream window: maximum lines kept visible inside
|
|
114
|
+
#: the transient activity panel (a tail window — older lines age out, so
|
|
115
|
+
#: scrollback never fills with streamed chatter).
|
|
116
|
+
_STREAM_BOX_MAX_LINES = 8
|
|
117
|
+
#: Minimum terminal width for rendering the stream box (narrow consoles
|
|
118
|
+
#: keep the plain one-line indicator).
|
|
119
|
+
_STREAM_BOX_MIN_WIDTH = 60
|
|
120
|
+
#: Characters below which the stream box renders bare (no rounded border)
|
|
121
|
+
#: to keep the activity display light during very short generations.
|
|
122
|
+
_STREAM_BOX_BORDER_CHARS = 240
|
|
123
|
+
|
|
124
|
+
#: Bounded in-flight tool-output window (tty): streamed command lines held
|
|
125
|
+
#: inside the activity display while the tool runs, same tail-window shape
|
|
126
|
+
#: as the model stream box. Only the tail is kept; on completion the row's
|
|
127
|
+
#: ``⎿ N lines`` summary reports the shape (a ✗ flushes the tail to
|
|
128
|
+
#: scrollback for error context).
|
|
129
|
+
_TOOL_BOX_MAX_LINES = 12
|
|
130
|
+
#: Minimum terminal width for the tool-output box (below it, lines print
|
|
131
|
+
#: to scrollback as before — narrow consoles keep the plain transcript).
|
|
132
|
+
_TOOL_BOX_MIN_WIDTH = 60
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
#: Minimum distinct samples before a velocity estimate is trusted.
|
|
136
|
+
_VELOCITY_MIN_SAMPLES = 2
|
|
137
|
+
#: Stream velocity display window (seconds): the rolling chars/s estimate
|
|
138
|
+
#: is computed from samples inside this window.
|
|
139
|
+
_VELOCITY_WINDOW_S = 4.0
|
|
140
|
+
#: Minimum window span (seconds) before a velocity figure renders — below
|
|
141
|
+
#: this the estimate is noise.
|
|
142
|
+
_VELOCITY_MIN_SPAN_S = 0.75
|
|
143
|
+
#: Minimum sampled delta (chars) for a velocity figure to be worth showing.
|
|
144
|
+
_VELOCITY_MIN_CHARS = 64
|
|
145
|
+
|
|
146
|
+
#: Seconds without a delta before the generation reads as stalled. Below
|
|
147
|
+
#: this the stream is assumed to be pausing between chunks (reasoning
|
|
148
|
+
#: models regularly go quiet for several seconds while composing); at/above
|
|
149
|
+
#: it the status line shows a no-data age and switches the spinner, so a
|
|
150
|
+
#: stalled upstream reads as a stall, not a hung frame.
|
|
151
|
+
_STALL_AFTER_S = 15.0
|
|
152
|
+
#: No-data age (seconds) at which the stall segment escalates from gold
|
|
153
|
+
#: to red — roughly the point where an operator starts reaching for ^C.
|
|
154
|
+
_STALL_ALERT_S = 60.0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _format_velocity(chars_per_s: float) -> str:
|
|
158
|
+
"""Format a stream rate compactly (``812c/s``, ``1.4k/s``)."""
|
|
159
|
+
if chars_per_s >= _TOKENS_THOUSAND:
|
|
160
|
+
return f"{chars_per_s / _TOKENS_THOUSAND:.1f}k/s"
|
|
161
|
+
return f"{chars_per_s:.0f}c/s"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _truncate(value: str, limit: int = _ARG_PREVIEW_MAX) -> str:
|
|
165
|
+
"""Truncate ``value`` to ``limit`` chars with an ellipsis."""
|
|
166
|
+
value = value.replace("\n", " ").strip()
|
|
167
|
+
if len(value) <= limit:
|
|
168
|
+
return value
|
|
169
|
+
return value[: limit - 1] + "…"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _format_tokens(tokens: int) -> str:
|
|
173
|
+
"""Format a token count compactly for the footer (``256k``)."""
|
|
174
|
+
tokens = int(tokens or 0)
|
|
175
|
+
if tokens <= 0:
|
|
176
|
+
return "—"
|
|
177
|
+
if tokens >= _TOKENS_MILLION:
|
|
178
|
+
return f"{tokens / _TOKENS_MILLION:g}M"
|
|
179
|
+
if tokens >= _TOKENS_THOUSAND:
|
|
180
|
+
return f"{tokens / _TOKENS_THOUSAND:g}k"
|
|
181
|
+
return str(tokens)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _budget_bar(used: int, total: int) -> str:
|
|
185
|
+
"""Render a compact consumed/total bar (``▰▰▱▱``).
|
|
186
|
+
|
|
187
|
+
``total`` <= 0 means unlimited — return "" so callers fall back to the
|
|
188
|
+
plain counter (never fabricate a denominator). The bar warns gold at
|
|
189
|
+
``>= _BUDGET_BAR_WARN_RATIO`` consumption; the color is applied by the
|
|
190
|
+
caller (the style choice differs per surface).
|
|
191
|
+
"""
|
|
192
|
+
if total <= 0 or used < 0:
|
|
193
|
+
return ""
|
|
194
|
+
pips = max(1, min(_BUDGET_BAR_MAX_PIPS, total))
|
|
195
|
+
filled = pips * used // total
|
|
196
|
+
return _BUDGET_BAR_FILLED * filled + _BUDGET_BAR_EMPTY * (pips - filled)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _format_elapsed(seconds: int) -> str:
|
|
200
|
+
"""Format whole-second elapsed time for the activity line.
|
|
201
|
+
|
|
202
|
+
Under a minute it stays a plain second count (``3s``), past a minute
|
|
203
|
+
it switches to minutes:seconds (``1:23``) so long generations stay
|
|
204
|
+
legible at a glance.
|
|
205
|
+
"""
|
|
206
|
+
seconds = max(0, int(seconds))
|
|
207
|
+
if seconds < _ELAPSED_MINUTE_SECONDS:
|
|
208
|
+
return f"{seconds}s"
|
|
209
|
+
return f"{seconds // _ELAPSED_MINUTE_SECONDS}:{seconds % _ELAPSED_MINUTE_SECONDS:02d}"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _TrailingSpinner:
|
|
213
|
+
"""A Live-compatible renderable that puts the spinner *after* the line.
|
|
214
|
+
|
|
215
|
+
The line content is pulled fresh on every frame via ``_line_fn`` so
|
|
216
|
+
live counters (elapsed, thinking size) tick between stage events
|
|
217
|
+
instead of freezing at the value built when the Live started.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
__slots__ = ("_line_fn", "_spinner_fn", "_style")
|
|
221
|
+
|
|
222
|
+
def __init__(
|
|
223
|
+
self,
|
|
224
|
+
line_fn: Callable[[], Text],
|
|
225
|
+
spinner_fn: Callable[[], str],
|
|
226
|
+
style: str,
|
|
227
|
+
) -> None:
|
|
228
|
+
self._line_fn = line_fn
|
|
229
|
+
self._spinner_fn = spinner_fn
|
|
230
|
+
self._style = style
|
|
231
|
+
|
|
232
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
233
|
+
yield self.render(console.get_time())
|
|
234
|
+
|
|
235
|
+
def render(self, time: float) -> RenderableType:
|
|
236
|
+
name = self._spinner_fn() or "dots"
|
|
237
|
+
spinner = Spinner(name, style=self._style)
|
|
238
|
+
frame = spinner.render(time)
|
|
239
|
+
glyph = frame.plain if isinstance(frame, Text) else ""
|
|
240
|
+
out = Text()
|
|
241
|
+
out.append(self._line_fn())
|
|
242
|
+
if glyph:
|
|
243
|
+
out.append(" ")
|
|
244
|
+
out.append(glyph, style=self._style)
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class PromptTransport:
|
|
249
|
+
"""Vertically efficient transport for the ``prompt`` command.
|
|
250
|
+
|
|
251
|
+
See the module docstring for the rendering contract. The two
|
|
252
|
+
post-turn flags the command path reads:
|
|
253
|
+
|
|
254
|
+
* ``approval_unresolved`` — set when a response arrives carrying an
|
|
255
|
+
un-decided approval request. A prompt run has no interactive loop to
|
|
256
|
+
decide approvals, so the caller exits with the approval-required code
|
|
257
|
+
instead of emitting approval text into redirected output. Never
|
|
258
|
+
cleared here; owned by the command path.
|
|
259
|
+
* ``yield_unresolved`` / ``yield_question`` — set when the turn ended
|
|
260
|
+
in a model yield (the model asked the operator a question instead
|
|
261
|
+
of finishing). The question goes to stderr only; stdout gets
|
|
262
|
+
nothing, so pipelines never receive the question as if it were the
|
|
263
|
+
requested artifact.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
_ACTIVITY_WIDTH: int = 100
|
|
267
|
+
|
|
268
|
+
# ── tool_dispatch legible rendering ──────────────────────────────
|
|
269
|
+
_TOOL_KIND_GLYPH: ClassVar[dict[str, str]] = {
|
|
270
|
+
"operation": "→ op",
|
|
271
|
+
"pack_tool": "→ tool",
|
|
272
|
+
"mcp_tool": "→ mcp",
|
|
273
|
+
"transition": "↦",
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
def __init__(
|
|
277
|
+
self,
|
|
278
|
+
*,
|
|
279
|
+
console: Console | None = None,
|
|
280
|
+
quiet: bool = False,
|
|
281
|
+
raw: bool = False,
|
|
282
|
+
tool_detail: int = 0,
|
|
283
|
+
streaming_mode: str = "none",
|
|
284
|
+
) -> None:
|
|
285
|
+
self.console: Console = console or Console()
|
|
286
|
+
self.quiet = quiet
|
|
287
|
+
self.raw = raw
|
|
288
|
+
#: Verbosity for tool-call detail. 0 = one-line rows (default);
|
|
289
|
+
#: 1 = + result-preview line under each completed row; 2 = + the
|
|
290
|
+
#: full pretty-printed arguments under the row.
|
|
291
|
+
self.tool_detail = max(0, min(2, int(tool_detail)))
|
|
292
|
+
#: Runtime ``--streaming`` mode (none|stage|text|hybrid). Controls
|
|
293
|
+
#: the bounded stream box inside the activity display: ``text``
|
|
294
|
+
#: streams the visible answer, ``hybrid`` streams reasoning (with a
|
|
295
|
+
#: char-counter fallback when the provider sends none); ``none``
|
|
296
|
+
#: and ``stage`` keep the plain one-line indicator.
|
|
297
|
+
self.streaming_mode = (streaming_mode or "none").strip().lower()
|
|
298
|
+
self.approval_unresolved: bool = False
|
|
299
|
+
self.yield_unresolved: bool = False
|
|
300
|
+
self.yield_question: str = ""
|
|
301
|
+
# Pending approval payload captured from ``data["approval"]`` on the
|
|
302
|
+
# final response. When non-None the approval panel just rendered;
|
|
303
|
+
# the command path reads it for exit-code decisions. Cleared on the
|
|
304
|
+
# next turn start (a decision was made or is about to be).
|
|
305
|
+
self.pending_approval: dict[str, Any] | None = None
|
|
306
|
+
# ── Activity-line state ─────────────────────────────────────
|
|
307
|
+
self._active_label: str = ""
|
|
308
|
+
self._last_tool_data: dict[str, Any] | None = None
|
|
309
|
+
self._last_tool_streaming: bool = False
|
|
310
|
+
# Streamed-output line count for the in-flight tool (drives the
|
|
311
|
+
# ``⎿ N lines`` result-shape summary on the completion row).
|
|
312
|
+
self._last_tool_output_lines: int = 0
|
|
313
|
+
self._activity: Live | None = None
|
|
314
|
+
self._thinking_spinner: str = "dots"
|
|
315
|
+
# ── Budget caps (stamped by the kernel on every stage payload; ──
|
|
316
|
+
# 0 = unlimited). Captured for the activity-line progress bars.
|
|
317
|
+
self._cap_max_iterations: int = 0
|
|
318
|
+
self._cap_max_tool_calls: int = 0
|
|
319
|
+
self._cap_max_handoffs: int = 0
|
|
320
|
+
# ── Turn counters ───────────────────────────────────────────
|
|
321
|
+
self._turn_started_perf: float = 0.0
|
|
322
|
+
self._current_mode: str = ""
|
|
323
|
+
self._status_current_model: str = ""
|
|
324
|
+
self._status_model_provider: str = ""
|
|
325
|
+
# Global thread turn number of the turn being rendered, stamped by
|
|
326
|
+
# the host on the ``model_call`` stage payload (``turn_index``).
|
|
327
|
+
# Rendered on the Response rule and the ⚡ footer; -1 = unknown. A
|
|
328
|
+
# genuine ``turn 0`` (fresh thread) is valid and renders. Not reset
|
|
329
|
+
# in ``start_thinking``: a pre-armed-decision run dispatches a second
|
|
330
|
+
# message on the same transport and each capture replaces it.
|
|
331
|
+
self._turn_number: int = -1
|
|
332
|
+
self._status_turn_tokens: int = 0
|
|
333
|
+
self._status_prompt_tokens: int = 0
|
|
334
|
+
self._status_completion_tokens: int = 0
|
|
335
|
+
self._status_cached_tokens: int = 0
|
|
336
|
+
self._status_billed_tokens: int = 0
|
|
337
|
+
self._status_reasoning_chars: int = 0
|
|
338
|
+
self._iteration_durations: list[float] = []
|
|
339
|
+
self._model_call_count: int = 0
|
|
340
|
+
self._handoff_count: int = 0
|
|
341
|
+
self._tool_call_count: int = 0
|
|
342
|
+
self._tool_ok_count: int = 0
|
|
343
|
+
self._tool_error_count: int = 0
|
|
344
|
+
self._fork_children: dict[int, dict[str, Any]] = {}
|
|
345
|
+
self._fork_total: int = 0
|
|
346
|
+
# Per-model per-token prices (USD) injected by the command path:
|
|
347
|
+
# ``{model_id: (prompt_price, completion_price)}``. Empty = the
|
|
348
|
+
# footer omits the cost estimate (manifest unreadable/unknown model).
|
|
349
|
+
self.model_prices: dict[str, tuple[float, float]] = {}
|
|
350
|
+
# Per-model prompt-cache prices (USD/token) keyed the same way; the
|
|
351
|
+
# footer's estimate blends the read rate for cache-hit prompts.
|
|
352
|
+
# Empty/missing keys = the estimate treats every prompt as uncached.
|
|
353
|
+
self.model_cache_prices: dict[str, tuple[float, float]] = {}
|
|
354
|
+
# Whether any on_text_chunk visible-answer delta arrived this turn —
|
|
355
|
+
# informational only: the answer renders once, fully (markdown), from
|
|
356
|
+
# on_response regardless.
|
|
357
|
+
self._streamed_during_turn: bool = False
|
|
358
|
+
# The visible-answer deltas stage into this buffer purely so a
|
|
359
|
+
# mid-turn error path can flush partial work; on_response clears it.
|
|
360
|
+
self._answer_staging: Text = Text()
|
|
361
|
+
# ── Bounded stream box (tail window) ────────────────────────
|
|
362
|
+
# Plain-text accumulation of the streamed content shown in the
|
|
363
|
+
# activity panel: the answer (``text`` mode) or the model's
|
|
364
|
+
# thinking (``hybrid`` mode). Only a tail window is ever rendered,
|
|
365
|
+
# so long generations keep a bounded footprint on screen.
|
|
366
|
+
self._active_reasoning_model: bool = False
|
|
367
|
+
self._stream_answer_buf: str = ""
|
|
368
|
+
self._stream_answer_chars: int = 0
|
|
369
|
+
self._stream_reasoning_buf: str = ""
|
|
370
|
+
self._iteration_current: int = 0
|
|
371
|
+
self._iteration_max: int = 0
|
|
372
|
+
# ── Bounded in-flight tool-output window (tty) ────────────
|
|
373
|
+
# Tail of the in-flight tool's streamed lines. On a tty the lines
|
|
374
|
+
# render inside the activity display (bounded, so a churning
|
|
375
|
+
# command never floods scrollback); non-tty consoles print each
|
|
376
|
+
# line to scrollback as it arrives — the legacy transcript.
|
|
377
|
+
self._tool_output_lines: list[str] = []
|
|
378
|
+
#: Pre-first-event label shown on the activity line while the
|
|
379
|
+
#: runtime boots (profile/manifest/MCP warmup) — the window that
|
|
380
|
+
#: otherwise reads as a bare spinner. Cleared on the first stage.
|
|
381
|
+
self._phase_label: str = "starting runtime"
|
|
382
|
+
# ── Stream velocity (observable, not manifest-trusted) ──────
|
|
383
|
+
self._init_velocity_state()
|
|
384
|
+
|
|
385
|
+
def _init_velocity_state(self) -> None:
|
|
386
|
+
"""Initialize the velocity-sampling state (one statement block).
|
|
387
|
+
|
|
388
|
+
The manifest ``reasoning_model`` flag lies for some providers
|
|
389
|
+
(glm-5.3-family bills reasoning without flagging it), so
|
|
390
|
+
"reasoning in flight" is derived from what actually arrives:
|
|
391
|
+
any non-empty reasoning delta arms the meter for the call
|
|
392
|
+
(``_reasoning_seen``). Rolling velocity samples are
|
|
393
|
+
``(perf_counter, total_stream_chars)`` pairs; chars/s =
|
|
394
|
+
(last - first) / span across the window, pruned per read.
|
|
395
|
+
"""
|
|
396
|
+
self._reasoning_seen: bool = False
|
|
397
|
+
self._velocity_samples: list[tuple[float, int]] = []
|
|
398
|
+
# Wall clock of the most recent delta (answer or reasoning) of the
|
|
399
|
+
# in-flight model call; 0 before the first delta of a call. Ages
|
|
400
|
+
# into the stall segment when a generation goes quiet (the
|
|
401
|
+
# ``_STALL_AFTER_S`` / ``_STALL_ALERT_S`` thresholds).
|
|
402
|
+
self._last_delta_perf: float = 0.0
|
|
403
|
+
|
|
404
|
+
def start_turn(self, message: str) -> None:
|
|
405
|
+
"""Begin prompt-mode rendering: echo the command, arm the activity line.
|
|
406
|
+
|
|
407
|
+
No-op when ``quiet`` (the response alone prints later).
|
|
408
|
+
"""
|
|
409
|
+
if self.quiet:
|
|
410
|
+
return
|
|
411
|
+
width = self.console.size.width or self._ACTIVITY_WIDTH
|
|
412
|
+
line = Text()
|
|
413
|
+
line.append("λ ", style="bold steel_blue3")
|
|
414
|
+
line.append(
|
|
415
|
+
_truncate(message.strip(), max(0, width - 6)), style="bold " + STYLE_TEXT_PRIMARY
|
|
416
|
+
)
|
|
417
|
+
self.console.print(line)
|
|
418
|
+
self.start_activity()
|
|
419
|
+
|
|
420
|
+
def start_activity(self) -> None:
|
|
421
|
+
"""Show the one-line transient activity display (tty only)."""
|
|
422
|
+
if self.quiet or self._activity is not None or not self.console.is_terminal:
|
|
423
|
+
return
|
|
424
|
+
activity = Live(
|
|
425
|
+
self._activity_renderable(),
|
|
426
|
+
console=self.console,
|
|
427
|
+
refresh_per_second=8,
|
|
428
|
+
transient=True,
|
|
429
|
+
)
|
|
430
|
+
with contextlib.suppress(Exception):
|
|
431
|
+
activity.start()
|
|
432
|
+
self._activity = activity
|
|
433
|
+
|
|
434
|
+
def stop_activity(self) -> None:
|
|
435
|
+
activity = self._activity
|
|
436
|
+
self._activity = None
|
|
437
|
+
if activity is not None:
|
|
438
|
+
with contextlib.suppress(Exception):
|
|
439
|
+
activity.stop()
|
|
440
|
+
|
|
441
|
+
def start_thinking(self, stage: str = "dispatch", **kwargs: Any) -> None:
|
|
442
|
+
"""Prompt mode has no Live transcript; (re)arm the activity line.
|
|
443
|
+
|
|
444
|
+
Accepts the TransportCallbacks kwargs (``session_tokens``,
|
|
445
|
+
``message``) for protocol compatibility and resets the per-turn
|
|
446
|
+
counters. Any pending approval from a prior render is disarmed —
|
|
447
|
+
a decision was made (or is about to be).
|
|
448
|
+
"""
|
|
449
|
+
self.pending_approval = None
|
|
450
|
+
self._iteration_durations = []
|
|
451
|
+
self._model_call_count = 0
|
|
452
|
+
self._handoff_count = 0
|
|
453
|
+
self._tool_call_count = 0
|
|
454
|
+
self._tool_ok_count = 0
|
|
455
|
+
self._tool_error_count = 0
|
|
456
|
+
self._status_turn_tokens = 0
|
|
457
|
+
self._status_prompt_tokens = 0
|
|
458
|
+
self._status_completion_tokens = 0
|
|
459
|
+
self._status_cached_tokens = 0
|
|
460
|
+
self._status_billed_tokens = 0
|
|
461
|
+
self._status_reasoning_chars = 0
|
|
462
|
+
self._fork_children = {}
|
|
463
|
+
self._fork_total = 0
|
|
464
|
+
self._current_mode = ""
|
|
465
|
+
self._status_current_model = ""
|
|
466
|
+
self._status_model_provider = ""
|
|
467
|
+
self._active_label = ""
|
|
468
|
+
self._phase_label = "starting runtime"
|
|
469
|
+
self._last_tool_data = None
|
|
470
|
+
self._last_tool_streaming = False
|
|
471
|
+
self._last_tool_output_lines = 0
|
|
472
|
+
self._cap_max_iterations = 0
|
|
473
|
+
self._cap_max_tool_calls = 0
|
|
474
|
+
self._cap_max_handoffs = 0
|
|
475
|
+
self._iteration_current = 0
|
|
476
|
+
self._iteration_max = 0
|
|
477
|
+
self._active_reasoning_model = False
|
|
478
|
+
self._stream_answer_buf = ""
|
|
479
|
+
self._stream_answer_chars = 0
|
|
480
|
+
self._stream_reasoning_buf = ""
|
|
481
|
+
self._tool_output_lines = []
|
|
482
|
+
self._turn_started_perf = perf_counter()
|
|
483
|
+
is_dispatch = stage == "dispatch"
|
|
484
|
+
self._thinking_spinner = "dots2" if is_dispatch else "dots"
|
|
485
|
+
self._answer_staging = Text()
|
|
486
|
+
self._streamed_during_turn = False
|
|
487
|
+
self._reasoning_seen = False
|
|
488
|
+
self._velocity_samples = []
|
|
489
|
+
self._last_delta_perf = 0.0
|
|
490
|
+
self.start_activity()
|
|
491
|
+
|
|
492
|
+
def stop_thinking(
|
|
493
|
+
self,
|
|
494
|
+
*,
|
|
495
|
+
flush_transcript: bool = False,
|
|
496
|
+
flush_stream: bool = False,
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Stop the activity indicator if it is running.
|
|
499
|
+
|
|
500
|
+
The activity line is always transient — the flush flags exist for
|
|
501
|
+
TransportCallbacks compatibility and do not resurrect it once the
|
|
502
|
+
answer/error is ready to print. Completed event lines already live
|
|
503
|
+
in real scrollback, so only the staged partial answer may need
|
|
504
|
+
flushing on a diagnostic (error) path.
|
|
505
|
+
"""
|
|
506
|
+
if flush_transcript and flush_stream and self._answer_staging:
|
|
507
|
+
with contextlib.suppress(Exception):
|
|
508
|
+
self.console.print(self._answer_staging)
|
|
509
|
+
self._answer_staging = Text()
|
|
510
|
+
self.stop_activity()
|
|
511
|
+
|
|
512
|
+
# ── Stage events ────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
async def on_stage(self, label: str, data: dict[str, Any] | None = None) -> None:
|
|
515
|
+
"""Update counters + activity line; print tool rows only on completion."""
|
|
516
|
+
if self.quiet:
|
|
517
|
+
return
|
|
518
|
+
data = data or {}
|
|
519
|
+
prev_mode = self._current_mode
|
|
520
|
+
# Budget caps ride every kernel stage payload (emit_stage setdefaults
|
|
521
|
+
# them). Capture for the activity-line progress bars; 0 = unlimited.
|
|
522
|
+
for cap_field, payload_key in (
|
|
523
|
+
("_cap_max_iterations", "max_iterations"),
|
|
524
|
+
("_cap_max_tool_calls", "max_tool_calls"),
|
|
525
|
+
("_cap_max_handoffs", "max_handoffs"),
|
|
526
|
+
):
|
|
527
|
+
cap_raw = data.get(payload_key)
|
|
528
|
+
if isinstance(cap_raw, int) and cap_raw > 0:
|
|
529
|
+
setattr(self, cap_field, cap_raw)
|
|
530
|
+
# The first real stage event ends the boot phase.
|
|
531
|
+
self._phase_label = ""
|
|
532
|
+
if label.startswith("model_call"):
|
|
533
|
+
self._handle_model_stage(label, data)
|
|
534
|
+
elif label.startswith("tool_"):
|
|
535
|
+
self._handle_tool_stage(label, data)
|
|
536
|
+
elif label.startswith("fork_"):
|
|
537
|
+
self._handle_fork_stage(label, data)
|
|
538
|
+
# else: bookkeeping-only events update nothing visible in prompt mode.
|
|
539
|
+
|
|
540
|
+
if self._current_mode and self._current_mode != prev_mode:
|
|
541
|
+
self._handoff_count += 1
|
|
542
|
+
handoff = Text("↦ ", style=STYLE_ROW_TRANSITION)
|
|
543
|
+
handoff.append(
|
|
544
|
+
f"{prev_mode or 'start'} → {self._current_mode}", style=STYLE_ROW_TRANSITION
|
|
545
|
+
)
|
|
546
|
+
self._print_event_line(handoff)
|
|
547
|
+
self._redraw_activity()
|
|
548
|
+
|
|
549
|
+
# ── Streaming / response ───────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
async def on_text_chunk(self, text: str, reasoning: str = "") -> None:
|
|
552
|
+
"""Stage answer deltas / count reasoning; feed the bounded stream box.
|
|
553
|
+
|
|
554
|
+
Deltas accumulate into plain-text tail-window buffers that render
|
|
555
|
+
inside the transient activity display (``text`` mode shows the
|
|
556
|
+
answer stream; ``hybrid`` shows the thinking stream). Status lines
|
|
557
|
+
stay put — only the tail of what the model is producing scrolls
|
|
558
|
+
inside the box. The answer always re-renders once, fully
|
|
559
|
+
markdown-formatted, from ``on_response``.
|
|
560
|
+
|
|
561
|
+
Every non-empty delta records a velocity sample (wall clock vs
|
|
562
|
+
total stream chars) so the status line can show the generation
|
|
563
|
+
rate — the manifest ``reasoning_model`` flag is NOT trusted for
|
|
564
|
+
arming the meter: a non-empty reasoning delta does that, since
|
|
565
|
+
some providers (glm-5.3 family) bill reasoning without flagging
|
|
566
|
+
the model.
|
|
567
|
+
"""
|
|
568
|
+
now = perf_counter()
|
|
569
|
+
if reasoning:
|
|
570
|
+
self._status_reasoning_chars += len(reasoning)
|
|
571
|
+
self._stream_reasoning_buf += reasoning
|
|
572
|
+
self._reasoning_seen = True
|
|
573
|
+
if text:
|
|
574
|
+
self._stream_answer_buf += text
|
|
575
|
+
self._stream_answer_chars += len(text)
|
|
576
|
+
self._answer_staging.append(text, style=STYLE_STREAM_ANSWER)
|
|
577
|
+
self._streamed_during_turn = True
|
|
578
|
+
if reasoning or text:
|
|
579
|
+
self._last_delta_perf = now
|
|
580
|
+
self._velocity_samples.append((now, self._stream_total_chars()))
|
|
581
|
+
self._redraw_activity()
|
|
582
|
+
|
|
583
|
+
async def on_response(self, text: str, data: dict[str, Any] | None = None) -> None:
|
|
584
|
+
"""Print the answer, then the compact ⚡ footer (or nothing if quiet).
|
|
585
|
+
|
|
586
|
+
Raw mode writes ``text`` verbatim to raw stdout — no Rule header, no
|
|
587
|
+
Markdown re-render — keeping redirected/piped output machine-readable;
|
|
588
|
+
the ⚡ footer prints on the transport console (stderr in raw mode).
|
|
589
|
+
|
|
590
|
+
An approval that reaches the response un-decided is a hard stop: a
|
|
591
|
+
prompt run has no interactive loop to answer it, so the request id
|
|
592
|
+
is stashed (``approval_unresolved``) and nothing is written to
|
|
593
|
+
stdout — the caller exits non-zero rather than emitting an
|
|
594
|
+
interactive panel (or the bare approval text) into redirected
|
|
595
|
+
output.
|
|
596
|
+
|
|
597
|
+
A model yield (the ``yield`` terminal action — the model asked the
|
|
598
|
+
operator a question instead of finishing) is the same contract: the
|
|
599
|
+
question is stashed (``yield_unresolved``), printed to stderr only,
|
|
600
|
+
and stdout gets nothing — the caller exits non-zero instead of
|
|
601
|
+
writing the question into redirected output as if it were the
|
|
602
|
+
requested result.
|
|
603
|
+
"""
|
|
604
|
+
self.stop_activity()
|
|
605
|
+
self._answer_staging = Text()
|
|
606
|
+
self._stream_answer_buf = ""
|
|
607
|
+
self._stream_reasoning_buf = ""
|
|
608
|
+
approval = (data or {}).get("approval")
|
|
609
|
+
if isinstance(approval, dict) and approval.get("request_id"):
|
|
610
|
+
self._render_unresolved_approval(approval)
|
|
611
|
+
return
|
|
612
|
+
# A model yield (ask-the-operator question) is an unresolved turn, not
|
|
613
|
+
# an answer. The host surfaces it structurally on
|
|
614
|
+
# ``data["yielded"]`` (and ``HostCommandResult.yielded_question``),
|
|
615
|
+
# with the yield context, resume mode, and remediation lines beside it.
|
|
616
|
+
yield_question = str((data or {}).get("yielded") or "")
|
|
617
|
+
if yield_question:
|
|
618
|
+
self._render_yield_question(yield_question, dict(data or {}))
|
|
619
|
+
return
|
|
620
|
+
if text:
|
|
621
|
+
if self.raw:
|
|
622
|
+
sys.stdout.write(text)
|
|
623
|
+
if not text.endswith("\n"):
|
|
624
|
+
sys.stdout.write("\n")
|
|
625
|
+
sys.stdout.flush()
|
|
626
|
+
else:
|
|
627
|
+
self._render_response_text(text)
|
|
628
|
+
# The footer is activity output: in raw mode it prints on the
|
|
629
|
+
# transport console (stderr) so stdout stays machine-readable.
|
|
630
|
+
if not self.quiet:
|
|
631
|
+
self._print_footer()
|
|
632
|
+
|
|
633
|
+
def _render_yield_question(self, yield_question: str, data: dict[str, Any]) -> None:
|
|
634
|
+
"""Stash + render a model yield (stdout gets nothing).
|
|
635
|
+
|
|
636
|
+
The yield is a question the operator must answer, not a completed
|
|
637
|
+
turn — so it gets a dedicated panel: the question first (that is
|
|
638
|
+
what the operator reads), then why the turn stopped
|
|
639
|
+
(``data["yield_context"]``) and where a reply resumes
|
|
640
|
+
(``data["yield_mode_id"]``), with the rendered remediation command
|
|
641
|
+
lines (``data["remediation"]``) last so the operator can act on
|
|
642
|
+
stalled work without re-deriving the command. Fields the runtime did
|
|
643
|
+
not supply (an in-place model ask carries no context or target)
|
|
644
|
+
render nothing rather than an empty label.
|
|
645
|
+
"""
|
|
646
|
+
self.yield_unresolved = True
|
|
647
|
+
self.yield_question = yield_question
|
|
648
|
+
body: list[RenderableType] = [Markdown(yield_question)]
|
|
649
|
+
context = str(data.get("yield_context") or "").strip()
|
|
650
|
+
target_mode = str(data.get("yield_mode_id") or "").strip()
|
|
651
|
+
remediation = [str(x).strip() for x in data.get("remediation") or () if str(x).strip()]
|
|
652
|
+
meta: list[str] = []
|
|
653
|
+
if context:
|
|
654
|
+
meta.append(f"Why: {context}")
|
|
655
|
+
if target_mode:
|
|
656
|
+
meta.append(f"Reply resumes in mode: {target_mode}")
|
|
657
|
+
if remediation:
|
|
658
|
+
meta.append("Remediation: " + ", ".join(f"`{x}`" for x in remediation))
|
|
659
|
+
if meta:
|
|
660
|
+
body.append(Text())
|
|
661
|
+
body.append(Text("\n".join(meta), style=STYLE_TEXT_MUTED))
|
|
662
|
+
self.console.print(
|
|
663
|
+
Panel(
|
|
664
|
+
Group(*body),
|
|
665
|
+
title="Asked you a question (turn not finished)",
|
|
666
|
+
title_align="left",
|
|
667
|
+
border_style=STYLE_ROW_TRANSITION,
|
|
668
|
+
padding=(0, 1),
|
|
669
|
+
)
|
|
670
|
+
)
|
|
671
|
+
if not self.console.is_terminal:
|
|
672
|
+
Console(stderr=True).print(
|
|
673
|
+
"[red]The model asked a question instead of producing a "
|
|
674
|
+
"result. A one-shot prompt run cannot answer it. Re-run "
|
|
675
|
+
"with the missing information in the prompt.[/red]"
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def _render_response_text(self, text: str) -> None:
|
|
679
|
+
"""Render the final answer: themed Markdown under a turn/model rule.
|
|
680
|
+
|
|
681
|
+
The rule title carries the global thread turn index when known —
|
|
682
|
+
the same number stamped on the thread entry, so it can be replayed
|
|
683
|
+
with ``lisa last N`` or inspected with ``lisa debug N`` without
|
|
684
|
+
counting from the top.
|
|
685
|
+
"""
|
|
686
|
+
rule_title = "Response"
|
|
687
|
+
if self._status_current_model:
|
|
688
|
+
rule_title = f"Response · {self._status_current_model}"
|
|
689
|
+
if self._turn_number >= 0:
|
|
690
|
+
rule_title += f" · turn {self._turn_number}"
|
|
691
|
+
self.console.print(Rule(title=rule_title, style=STYLE_BORDER_RESPONSE))
|
|
692
|
+
self.console.print(Markdown(text, code_theme="github-dark", hyperlinks=True))
|
|
693
|
+
|
|
694
|
+
def _render_unresolved_approval(self, approval: dict[str, Any]) -> None:
|
|
695
|
+
"""Capture a pending approval and surface the decision path.
|
|
696
|
+
|
|
697
|
+
A tty console renders the approval panel (the hints point at
|
|
698
|
+
`lisa approve <id>`); a piped/redirected console
|
|
699
|
+
fails fast on stderr instead of printing a panel the operator can
|
|
700
|
+
never answer.
|
|
701
|
+
"""
|
|
702
|
+
from hosts.cli.render import render_approval_panel # noqa: PLC0415
|
|
703
|
+
|
|
704
|
+
self.pending_approval = approval
|
|
705
|
+
self.approval_unresolved = True
|
|
706
|
+
if self.console.is_terminal:
|
|
707
|
+
render_approval_panel(approval, console=self.console)
|
|
708
|
+
return
|
|
709
|
+
# Keep each decision hint short so a 120-col console never wraps
|
|
710
|
+
# inside a copyable command.
|
|
711
|
+
request_id = str(approval.get("request_id", ""))
|
|
712
|
+
target = str(approval.get("target_id", "action"))
|
|
713
|
+
err_console = Console(stderr=True, width=200)
|
|
714
|
+
err_console.print(
|
|
715
|
+
f"[red]Approval required for {target} (request {request_id}).\n"
|
|
716
|
+
f"Decide without a session: lisa approve {request_id} "
|
|
717
|
+
f"or lisa deny {request_id}\n"
|
|
718
|
+
f"(add --remember <prefix> to approve-and-persist), "
|
|
719
|
+
f"then re-run the original command.[/red]"
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
async def on_debug_context(self, context: dict[str, Any]) -> None:
|
|
723
|
+
"""No-op: the debug context panel was removed with the detail tiers.
|
|
724
|
+
|
|
725
|
+
Kept to satisfy the ``TransportCallbacks`` protocol; the runtime only
|
|
726
|
+
invokes it when debug mode is active, which the CLI no longer exposes.
|
|
727
|
+
"""
|
|
728
|
+
return
|
|
729
|
+
|
|
730
|
+
async def on_error(self, message: str, exc: Exception | None = None) -> None:
|
|
731
|
+
"""Stop the activity line and render the error panel.
|
|
732
|
+
|
|
733
|
+
Completed event lines already printed to scrollback as they finished,
|
|
734
|
+
so the trajectory up to the failure is preserved without a flush.
|
|
735
|
+
Any staged partial answer flushes first so partial work isn't
|
|
736
|
+
silently lost before the error panel shows.
|
|
737
|
+
"""
|
|
738
|
+
self.stop_thinking(flush_transcript=True, flush_stream=True)
|
|
739
|
+
body = message
|
|
740
|
+
if exc is not None:
|
|
741
|
+
body = f"{type(exc).__name__}: {exc}\n\n{message}"
|
|
742
|
+
logger.error("transport error: %s", body)
|
|
743
|
+
self.console.print(Panel(body, title="Error", border_style=STYLE_BORDER_ERROR))
|
|
744
|
+
|
|
745
|
+
# ── Internals ───────────────────────────────────────────────────
|
|
746
|
+
|
|
747
|
+
def _capture_turn_number(self, data: dict[str, Any]) -> None:
|
|
748
|
+
"""Capture the host's global thread turn index from a stage payload.
|
|
749
|
+
|
|
750
|
+
Rendered on the Response rule, the ⚡ footer, and the in-flight
|
|
751
|
+
status line. Only a non-negative int replaces the sentinel; a
|
|
752
|
+
genuine ``turn 0`` (fresh thread) is valid and renders.
|
|
753
|
+
"""
|
|
754
|
+
turn_raw = data.get("turn_index")
|
|
755
|
+
if isinstance(turn_raw, int) and turn_raw >= 0:
|
|
756
|
+
self._turn_number = turn_raw
|
|
757
|
+
|
|
758
|
+
def _handle_model_stage(self, label: str, data: dict[str, Any]) -> None:
|
|
759
|
+
"""Route model_call* stage events.
|
|
760
|
+
|
|
761
|
+
``model_call`` captures the host's global thread turn index, tracks
|
|
762
|
+
the active model/mode and counts the call;
|
|
763
|
+
``model_call_complete`` accumulates duration + tokens for the
|
|
764
|
+
footer; ``model_call_failed`` prints the ✗ row immediately.
|
|
765
|
+
"""
|
|
766
|
+
if label == "model_call":
|
|
767
|
+
self._capture_turn_number(data)
|
|
768
|
+
model_id = data.get("model_id")
|
|
769
|
+
if model_id not in (None, "", 0):
|
|
770
|
+
provider = data.get("provider")
|
|
771
|
+
self._status_current_model = str(model_id)
|
|
772
|
+
self._status_model_provider = str(provider) if provider not in (None, "") else ""
|
|
773
|
+
self._model_call_count += 1
|
|
774
|
+
self._active_reasoning_model = bool(data.get("reasoning_model"))
|
|
775
|
+
# A new model call resets the stall clock: pre-call silence is
|
|
776
|
+
# transport warmup, not a stalled stream.
|
|
777
|
+
self._last_delta_perf = 0.0
|
|
778
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
779
|
+
iter_raw = data.get("_state.iteration") or data.get("iteration")
|
|
780
|
+
if iter_raw not in (None, "", 0):
|
|
781
|
+
self._iteration_current = int(iter_raw)
|
|
782
|
+
max_iter_raw = data.get("max_iterations")
|
|
783
|
+
if isinstance(max_iter_raw, int) and max_iter_raw > 0:
|
|
784
|
+
self._iteration_max = max_iter_raw
|
|
785
|
+
if data.get("mode_id"):
|
|
786
|
+
self._current_mode = str(data["mode_id"])
|
|
787
|
+
return
|
|
788
|
+
if label == "model_call_complete":
|
|
789
|
+
d_raw = data.get("duration_ms")
|
|
790
|
+
if d_raw not in (None, "", 0):
|
|
791
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
792
|
+
self._iteration_durations.append(float(d_raw))
|
|
793
|
+
total_raw = data.get("_state.total_tokens") or data.get("total_tokens")
|
|
794
|
+
if total_raw not in (None, "", 0):
|
|
795
|
+
self._status_turn_tokens += int(total_raw)
|
|
796
|
+
# Split counts feed the footer's cost estimate (manifest prices
|
|
797
|
+
# are per prompt/completion token). Cache-hit counts let the
|
|
798
|
+
# estimate price cached prompts at the manifest's cache-read rate.
|
|
799
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
800
|
+
self._status_prompt_tokens += int(data.get("prompt_tokens") or 0)
|
|
801
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
802
|
+
self._status_completion_tokens += int(data.get("completion_tokens") or 0)
|
|
803
|
+
cached_now = 0
|
|
804
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
805
|
+
cached_now = int(data.get("cached_tokens") or 0)
|
|
806
|
+
self._status_cached_tokens += cached_now
|
|
807
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
808
|
+
prompt_now = int(data.get("prompt_tokens") or 0)
|
|
809
|
+
# Billed basis (2026-10-12 perf review): the walk figure counts
|
|
810
|
+
# every cache hit at full size; the operator-facing "what did
|
|
811
|
+
# this turn actually cost" is uncached prompt + completion.
|
|
812
|
+
self._status_billed_tokens += max(0, prompt_now - cached_now) + int(
|
|
813
|
+
data.get("completion_tokens") or 0
|
|
814
|
+
)
|
|
815
|
+
return
|
|
816
|
+
if label == "model_call_failed":
|
|
817
|
+
self._print_event_line(
|
|
818
|
+
self._render_model_call(data, status_glyph="✗"), style=STYLE_ROW_ERROR
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
def _handle_tool_stage(self, label: str, data: dict[str, Any]) -> None:
|
|
822
|
+
"""Route tool_* stage events to the prompt-mode scrollback lines.
|
|
823
|
+
|
|
824
|
+
``tool_dispatch`` arms the activity label only; ``tool_output``
|
|
825
|
+
prints streamed lines as they arrive (a dim rail marks them as tool
|
|
826
|
+
output, not model prose); ``tool_dispatch_complete`` prints the one
|
|
827
|
+
completed row — rendered from the dispatch payload (the completion
|
|
828
|
+
event does not re-send arguments) with status/duration applied.
|
|
829
|
+
"""
|
|
830
|
+
if label == "tool_dispatch":
|
|
831
|
+
self._tool_call_count += 1
|
|
832
|
+
self._active_label = self._render_tool_dispatch(data).plain
|
|
833
|
+
self._last_tool_data = data
|
|
834
|
+
self._last_tool_streaming = bool(data.get("stream_output"))
|
|
835
|
+
self._last_tool_output_lines = 0
|
|
836
|
+
self._tool_output_lines = []
|
|
837
|
+
if self.tool_detail >= _TOOL_DETAIL_FULL:
|
|
838
|
+
self._print_detail_arguments(data)
|
|
839
|
+
return
|
|
840
|
+
if label == "tool_output":
|
|
841
|
+
# Prompt mode has no Live transcript region — streamed lines
|
|
842
|
+
# either print to real scrollback as they arrive (piped runs
|
|
843
|
+
# keep them under tee/grep) or, on a tty, stage into the
|
|
844
|
+
# bounded in-flight box inside the activity display so a
|
|
845
|
+
# churning command never floods scrollback. Quiet mode drops
|
|
846
|
+
# them either way.
|
|
847
|
+
text = str(data.get("text") or "")
|
|
848
|
+
stream_label = str(data.get("stream") or "stdout")
|
|
849
|
+
if text and not self.quiet:
|
|
850
|
+
for raw_line in text.split("\n"):
|
|
851
|
+
line = raw_line.rstrip()
|
|
852
|
+
if line:
|
|
853
|
+
self._last_tool_output_lines += 1
|
|
854
|
+
if self._tool_box_active():
|
|
855
|
+
self._stage_tool_box_line(line, stream=stream_label)
|
|
856
|
+
else:
|
|
857
|
+
self._print_stream_line(line, stream=stream_label)
|
|
858
|
+
return
|
|
859
|
+
if label == "tool_dispatch_complete":
|
|
860
|
+
self._print_completion_row(data)
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
def _print_completion_row(self, data: dict[str, Any]) -> None:
|
|
864
|
+
"""Print the one completed tool row (+ summary + detail lines)."""
|
|
865
|
+
ok = data.get("ok")
|
|
866
|
+
status = "ok" if ok is True else ("error" if ok is False else "")
|
|
867
|
+
if status == "ok":
|
|
868
|
+
self._tool_ok_count += 1
|
|
869
|
+
elif status == "error":
|
|
870
|
+
self._tool_error_count += 1
|
|
871
|
+
# The in-flight tool-output box closes with the row: a succeeded tool
|
|
872
|
+
# discards its window (the ``⎿ N lines`` summary reports the shape),
|
|
873
|
+
# a failed one flushes the tail to scrollback so the operator still
|
|
874
|
+
# sees what the command printed around the failure.
|
|
875
|
+
staged = self._tool_output_lines
|
|
876
|
+
self._tool_output_lines = []
|
|
877
|
+
if staged and status == "error":
|
|
878
|
+
for line in staged[-_TOOL_BOX_MAX_LINES:]:
|
|
879
|
+
self._print_stream_line(line)
|
|
880
|
+
glyph = _TOOL_STATUS_GLYPH.get(status, _TOOL_STATUS_GLYPH[""])
|
|
881
|
+
row_data = self._last_tool_data or data
|
|
882
|
+
summary = self._result_shape_summary(row_data, data)
|
|
883
|
+
row = self._render_tool_dispatch(
|
|
884
|
+
row_data, status_glyph=glyph, complete=data, summary=summary
|
|
885
|
+
)
|
|
886
|
+
self._print_event_line(row)
|
|
887
|
+
if self.tool_detail >= _TOOL_DETAIL_SUMMARY:
|
|
888
|
+
self._print_detail_lines(row_data, data)
|
|
889
|
+
self._active_label = ""
|
|
890
|
+
self._last_tool_data = None
|
|
891
|
+
self._last_tool_streaming = False
|
|
892
|
+
self._last_tool_output_lines = 0
|
|
893
|
+
|
|
894
|
+
def _handle_fork_stage(self, label: str, data: dict[str, Any]) -> None:
|
|
895
|
+
"""Route fork_* stage events to the prompt-mode scrollback lines.
|
|
896
|
+
|
|
897
|
+
``fork_start`` seeds the child table and prints the header;
|
|
898
|
+
``fork_child_start`` records the child (activity label only, no
|
|
899
|
+
print); ``fork_child_complete`` records the outcome and, when every
|
|
900
|
+
child is done, prints the combined per-child table; ``fork_complete``
|
|
901
|
+
flushes any remaining table.
|
|
902
|
+
"""
|
|
903
|
+
if label == "fork_start":
|
|
904
|
+
self._fork_children = {}
|
|
905
|
+
self._fork_total = int(data.get("children", 0)) or 0
|
|
906
|
+
self._print_event_line(self._render_fork_stage())
|
|
907
|
+
return
|
|
908
|
+
if label == "fork_child_start":
|
|
909
|
+
idx = int(data.get("child_index", -1))
|
|
910
|
+
if idx >= 0:
|
|
911
|
+
self._fork_children[idx] = {
|
|
912
|
+
"mode_id": data.get("mode_id", ""),
|
|
913
|
+
"stage_id": data.get("stage_id", ""),
|
|
914
|
+
"status": "running",
|
|
915
|
+
"query_preview": data.get("query_preview", ""),
|
|
916
|
+
}
|
|
917
|
+
self._active_label = f"fork #{idx} {data.get('mode_id', '')}".strip()
|
|
918
|
+
return
|
|
919
|
+
if label == "fork_child_complete":
|
|
920
|
+
idx = int(data.get("child_index", -1))
|
|
921
|
+
if idx >= 0:
|
|
922
|
+
existing = self._fork_children.get(idx, {})
|
|
923
|
+
existing.update(
|
|
924
|
+
{
|
|
925
|
+
"status": data.get("status", "ok"),
|
|
926
|
+
"duration_ms": data.get("duration_ms"),
|
|
927
|
+
}
|
|
928
|
+
)
|
|
929
|
+
self._fork_children[idx] = existing
|
|
930
|
+
self._active_label = ""
|
|
931
|
+
if self._fork_children and all(
|
|
932
|
+
c.get("status") != "running" for c in self._fork_children.values()
|
|
933
|
+
):
|
|
934
|
+
self._print_event_line(self._render_fork_stage())
|
|
935
|
+
self._fork_children = {}
|
|
936
|
+
return
|
|
937
|
+
if label == "fork_complete":
|
|
938
|
+
if self._fork_children:
|
|
939
|
+
self._print_event_line(self._render_fork_stage())
|
|
940
|
+
self._fork_children = {}
|
|
941
|
+
self._fork_total = 0
|
|
942
|
+
|
|
943
|
+
def _result_shape_summary(self, row_data: dict[str, Any], complete: dict[str, Any]) -> str:
|
|
944
|
+
"""One-line shape of what the tool returned (``⎿ 14 lines``).
|
|
945
|
+
|
|
946
|
+
Prefers the streamed-line count observed this dispatch (tools that
|
|
947
|
+
declare ``stream_output``), falls back to the kernel-provided
|
|
948
|
+
``result_chars`` (true output size) or a compact char count of the
|
|
949
|
+
``result_preview`` snippet, else "". The summary rides inline on the
|
|
950
|
+
completion row — muted, so it reads as metadata rather than content.
|
|
951
|
+
"""
|
|
952
|
+
if row_data.get("stream_output") and self._last_tool_output_lines > 0:
|
|
953
|
+
n = self._last_tool_output_lines
|
|
954
|
+
return f"⎿ {n} line{'s' if n != 1 else ''}"
|
|
955
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
956
|
+
chars = int(complete.get("result_chars") or 0)
|
|
957
|
+
if chars > 0:
|
|
958
|
+
return f"⎿ {_format_tokens(chars)} chars"
|
|
959
|
+
preview = str(complete.get("result_preview") or "").strip()
|
|
960
|
+
if preview and preview != "Done":
|
|
961
|
+
return f"⎿ {_format_tokens(len(preview))} chars"
|
|
962
|
+
return ""
|
|
963
|
+
|
|
964
|
+
def _print_detail_arguments(self, data: dict[str, Any]) -> None:
|
|
965
|
+
"""Detail level 2: print the full call arguments under the row."""
|
|
966
|
+
arguments = data.get("arguments")
|
|
967
|
+
if not isinstance(arguments, dict) or not arguments:
|
|
968
|
+
return
|
|
969
|
+
try:
|
|
970
|
+
import json # noqa: PLC0415
|
|
971
|
+
|
|
972
|
+
rendered = json.dumps(arguments, indent=2, sort_keys=True, default=str)
|
|
973
|
+
except (TypeError, ValueError):
|
|
974
|
+
return
|
|
975
|
+
for line in rendered.splitlines()[:_DETAIL_ARGUMENT_MAX_LINES]:
|
|
976
|
+
self.console.print(Text(f" {line}", style=STYLE_ARG_PREVIEW))
|
|
977
|
+
|
|
978
|
+
def _print_detail_lines(self, row_data: dict[str, Any], complete: dict[str, Any]) -> None:
|
|
979
|
+
"""Detail level >= 1: print the result preview under the row.
|
|
980
|
+
|
|
981
|
+
The kernel sends ``result_preview`` on every completion: a compact
|
|
982
|
+
payload-first summary for JSON-object outputs (content quoted
|
|
983
|
+
first, then short scalars), else the first 200 chars of raw text.
|
|
984
|
+
At level 2 the full snippet shows as a plain indented block;
|
|
985
|
+
level 1 shows only the first line.
|
|
986
|
+
"""
|
|
987
|
+
preview = str(complete.get("result_preview") or "").strip()
|
|
988
|
+
if preview and preview != "Done":
|
|
989
|
+
lines = preview.splitlines() or [preview]
|
|
990
|
+
shown = lines if self.tool_detail >= _TOOL_DETAIL_FULL else lines[:_TOOL_DETAIL_SUMMARY]
|
|
991
|
+
for line in shown:
|
|
992
|
+
self.console.print(Text(f" ⎿ {line}", style=STYLE_OUTPUT_REGION))
|
|
993
|
+
|
|
994
|
+
def _print_event_line(self, text: Text | str, *, style: str | None = None) -> None:
|
|
995
|
+
"""Print one completed scrollback line, replacing the activity line.
|
|
996
|
+
|
|
997
|
+
``text`` may be a pre-styled ``Text`` (preferred — spans carry their
|
|
998
|
+
own colors and markup is never parsed, so raw tool arguments can
|
|
999
|
+
never raise ``MarkupError``) or a legacy plain string styled whole.
|
|
1000
|
+
"""
|
|
1001
|
+
self.stop_activity()
|
|
1002
|
+
if isinstance(text, Text):
|
|
1003
|
+
line = Text(" ")
|
|
1004
|
+
line.append(text)
|
|
1005
|
+
self.console.print(line)
|
|
1006
|
+
else:
|
|
1007
|
+
self.console.print(f" {text}", style=style or STYLE_TEXT_MUTED)
|
|
1008
|
+
self.start_activity()
|
|
1009
|
+
|
|
1010
|
+
def _print_stream_line(self, line: str, *, stream: str = "stdout") -> None:
|
|
1011
|
+
"""Print one streamed tool-output line to scrollback (prompt mode).
|
|
1012
|
+
|
|
1013
|
+
Unlike ``_print_event_line`` this does NOT stop/restart the activity
|
|
1014
|
+
line per line — a churning command would flicker the spinner. The
|
|
1015
|
+
activity Live redraws over its own region on the next refresh, so
|
|
1016
|
+
printed lines persist above it.
|
|
1017
|
+
"""
|
|
1018
|
+
if stream == "stderr" and not line.startswith("[stderr]"):
|
|
1019
|
+
line = f"[stderr] {line}"
|
|
1020
|
+
self.console.print(Text(f" │ {line}", style=STYLE_OUTPUT_REGION))
|
|
1021
|
+
|
|
1022
|
+
def _redraw_activity(self) -> None:
|
|
1023
|
+
if self._activity is not None:
|
|
1024
|
+
with contextlib.suppress(Exception):
|
|
1025
|
+
self._activity.update(self._activity_renderable())
|
|
1026
|
+
|
|
1027
|
+
# ── Bounded in-flight tool-output box (tty) ─────────────────────
|
|
1028
|
+
|
|
1029
|
+
def _tool_box_active(self) -> bool:
|
|
1030
|
+
"""Whether streamed tool output stages into the bounded box.
|
|
1031
|
+
|
|
1032
|
+
Gated on: tty, not quiet, no Live-less degenerate console, and a
|
|
1033
|
+
wide-enough width. Non-tty consoles (piped runs, tests) print each
|
|
1034
|
+
line to scrollback as it arrives — the legacy transcript contract.
|
|
1035
|
+
"""
|
|
1036
|
+
return (
|
|
1037
|
+
not self.quiet
|
|
1038
|
+
and self.console.is_terminal
|
|
1039
|
+
and (self.console.size.width or 0) >= _TOOL_BOX_MIN_WIDTH
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
def _stage_tool_box_line(self, line: str, *, stream: str = "stdout") -> None:
|
|
1043
|
+
"""Stage one streamed tool-output line into the bounded window.
|
|
1044
|
+
|
|
1045
|
+
The list holds only the tail window (older lines age out); the
|
|
1046
|
+
``_last_tool_output_lines`` counter keeps the true line count so
|
|
1047
|
+
the box title can report how much was elided.
|
|
1048
|
+
"""
|
|
1049
|
+
if stream == "stderr" and not line.startswith("[stderr]"):
|
|
1050
|
+
line = f"[stderr] {line}"
|
|
1051
|
+
self._tool_output_lines.append(line)
|
|
1052
|
+
if len(self._tool_output_lines) > _TOOL_BOX_MAX_LINES:
|
|
1053
|
+
del self._tool_output_lines[:-_TOOL_BOX_MAX_LINES]
|
|
1054
|
+
|
|
1055
|
+
def _tool_box_renderable(self) -> RenderableType | None:
|
|
1056
|
+
"""Render the staged in-flight tool output as a bordered tail window.
|
|
1057
|
+
|
|
1058
|
+
The title flags elided head lines so the operator knows the window
|
|
1059
|
+
is a tail, not the whole output.
|
|
1060
|
+
"""
|
|
1061
|
+
lines = self._tool_output_lines
|
|
1062
|
+
if not lines:
|
|
1063
|
+
return None
|
|
1064
|
+
title = "output"
|
|
1065
|
+
elided = self._last_tool_output_lines - len(lines)
|
|
1066
|
+
if elided > 0:
|
|
1067
|
+
title = f"output · +{elided} lines"
|
|
1068
|
+
return Panel(
|
|
1069
|
+
Text("\n".join(lines), style=STYLE_STREAM_BOX),
|
|
1070
|
+
title=title,
|
|
1071
|
+
title_align="left",
|
|
1072
|
+
border_style=STYLE_STREAM_BOX_BORDER,
|
|
1073
|
+
padding=(0, 1),
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
# ── Bounded stream box (thinking/answer tail window) ────────────
|
|
1077
|
+
|
|
1078
|
+
def _stream_box_active(self) -> bool:
|
|
1079
|
+
"""Whether the bounded stream box should render this turn.
|
|
1080
|
+
|
|
1081
|
+
Gated on: tty, not quiet, an enabled streaming mode, a wide-enough
|
|
1082
|
+
console, and at least one model_call in a mode whose box content
|
|
1083
|
+
could still arrive. The box lives inside the transient Live, so it
|
|
1084
|
+
never touches scrollback.
|
|
1085
|
+
"""
|
|
1086
|
+
if self.quiet or not self.console.is_terminal:
|
|
1087
|
+
return False
|
|
1088
|
+
if self.streaming_mode not in {"text", "hybrid"}:
|
|
1089
|
+
return False
|
|
1090
|
+
width = self.console.size.width or 0
|
|
1091
|
+
return width >= _STREAM_BOX_MIN_WIDTH and self._model_call_count > 0
|
|
1092
|
+
|
|
1093
|
+
def _stream_box_lines(self) -> list[str]:
|
|
1094
|
+
"""Return the tail window of the active stream as wrapped lines.
|
|
1095
|
+
|
|
1096
|
+
Picks the content source from the streaming mode and what has
|
|
1097
|
+
actually arrived: ``text`` mode → the answer stream; ``hybrid`` →
|
|
1098
|
+
the reasoning stream, falling back to nothing (the status-line
|
|
1099
|
+
thinking char counter carries that case). Lines wrap to the box
|
|
1100
|
+
width; the last ``_STREAM_BOX_MAX_LINES`` are returned, oldest
|
|
1101
|
+
first. Long single-line chunks (minified JSON, URLs) hard-wrap on
|
|
1102
|
+
their own — a box line never exceeds the console width.
|
|
1103
|
+
"""
|
|
1104
|
+
if self.streaming_mode == "text":
|
|
1105
|
+
buf = self._stream_answer_buf
|
|
1106
|
+
else:
|
|
1107
|
+
buf = self._stream_reasoning_buf
|
|
1108
|
+
if not buf:
|
|
1109
|
+
return []
|
|
1110
|
+
width = max(20, (self.console.size.width or self._ACTIVITY_WIDTH) - 4)
|
|
1111
|
+
# Walk from the tail: keep only as much text as can fill the box.
|
|
1112
|
+
# Rough char budget before wrapping (wraps can only shrink visible
|
|
1113
|
+
# chars per line), with slack for hard-wrapped long words.
|
|
1114
|
+
budget = width * _STREAM_BOX_MAX_LINES
|
|
1115
|
+
tail = buf[-budget:] if len(buf) > budget else buf
|
|
1116
|
+
lines: list[str] = []
|
|
1117
|
+
for para in tail.replace("\r\n", "\n").split("\n"):
|
|
1118
|
+
remaining = para
|
|
1119
|
+
if not remaining:
|
|
1120
|
+
lines.append("")
|
|
1121
|
+
continue
|
|
1122
|
+
while len(remaining) > width:
|
|
1123
|
+
lines.append(remaining[:width])
|
|
1124
|
+
remaining = remaining[width:]
|
|
1125
|
+
lines.append(remaining)
|
|
1126
|
+
return lines[-_STREAM_BOX_MAX_LINES:]
|
|
1127
|
+
|
|
1128
|
+
def _stream_box_renderable(self) -> RenderableType | None:
|
|
1129
|
+
"""Render the active stream box as a bordered, dim tail window.
|
|
1130
|
+
|
|
1131
|
+
The content is deliberately rendered as *plain dim text*, not the
|
|
1132
|
+
styled markdown of the final response — visually signalling "this
|
|
1133
|
+
is live preview, not the formatted answer." Under
|
|
1134
|
+
``_STREAM_BOX_BORDER_CHARS`` of content the box renders bare (no
|
|
1135
|
+
border) to stay light for short generations.
|
|
1136
|
+
"""
|
|
1137
|
+
lines = self._stream_box_lines()
|
|
1138
|
+
if not lines:
|
|
1139
|
+
return None
|
|
1140
|
+
body = Text("\n".join(lines), style=STYLE_STREAM_BOX)
|
|
1141
|
+
if self._stream_total_chars() < _STREAM_BOX_BORDER_CHARS:
|
|
1142
|
+
return body
|
|
1143
|
+
title = "streaming" if self.streaming_mode == "text" else "thinking"
|
|
1144
|
+
velocity = self._stream_velocity()
|
|
1145
|
+
if velocity > 0:
|
|
1146
|
+
title = f"{title} · ~{_format_velocity(velocity)}"
|
|
1147
|
+
else:
|
|
1148
|
+
title = f"{title} · {_format_tokens(self._stream_total_chars())}"
|
|
1149
|
+
return Panel(
|
|
1150
|
+
body,
|
|
1151
|
+
title=title,
|
|
1152
|
+
title_align="left",
|
|
1153
|
+
border_style=STYLE_STREAM_BOX_BORDER,
|
|
1154
|
+
padding=(0, 1),
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
def _stream_total_chars(self) -> int:
|
|
1158
|
+
"""Total streamed chars this turn (answer or reasoning, by mode)."""
|
|
1159
|
+
if self.streaming_mode == "text":
|
|
1160
|
+
return self._stream_answer_chars
|
|
1161
|
+
return self._status_reasoning_chars
|
|
1162
|
+
|
|
1163
|
+
def _stream_velocity(self) -> float:
|
|
1164
|
+
"""Rolling stream rate (chars/s) over the recent-sample window.
|
|
1165
|
+
|
|
1166
|
+
Samples older than ``_VELOCITY_WINDOW_S`` fall out of the
|
|
1167
|
+
estimate; below the min-span/min-chars thresholds it returns 0
|
|
1168
|
+
(the caller omits the figure rather than showing noise). A
|
|
1169
|
+
stalled stream naturally decays to 0 as the window slides —
|
|
1170
|
+
which is exactly the signal the operator wants.
|
|
1171
|
+
"""
|
|
1172
|
+
now = perf_counter()
|
|
1173
|
+
window_start = now - _VELOCITY_WINDOW_S
|
|
1174
|
+
samples = [s for s in self._velocity_samples if s[0] >= window_start]
|
|
1175
|
+
self._velocity_samples = samples
|
|
1176
|
+
if len(samples) < _VELOCITY_MIN_SAMPLES:
|
|
1177
|
+
return 0.0
|
|
1178
|
+
first_t, first_c = samples[0]
|
|
1179
|
+
last_t, last_c = samples[-1]
|
|
1180
|
+
span = last_t - first_t
|
|
1181
|
+
if span < _VELOCITY_MIN_SPAN_S:
|
|
1182
|
+
return 0.0
|
|
1183
|
+
delta = last_c - first_c
|
|
1184
|
+
if delta < _VELOCITY_MIN_CHARS:
|
|
1185
|
+
return 0.0
|
|
1186
|
+
return delta / span
|
|
1187
|
+
|
|
1188
|
+
def _activity_renderable(self) -> RenderableType:
|
|
1189
|
+
"""The transient activity display: status line + bounded stream box.
|
|
1190
|
+
|
|
1191
|
+
The first row is the one-line status (mode · model · in-flight
|
|
1192
|
+
label · thinking-meter · iter counters · budget bars · elapsed)
|
|
1193
|
+
with a trailing spinner. Below it, when a streaming mode streams
|
|
1194
|
+
into the box, the tail window of the model's live output renders
|
|
1195
|
+
inside a bounded panel — so long generations show visible motion
|
|
1196
|
+
without pushing completed event rows (or the footer) too far back.
|
|
1197
|
+
|
|
1198
|
+
Everything is PULLED per frame: the Live holds this proxy
|
|
1199
|
+
renderable, and each refresh re-reads transport state (elapsed,
|
|
1200
|
+
thinking size, box tail), so counters tick during quiet model
|
|
1201
|
+
generations instead of freezing at construction values.
|
|
1202
|
+
|
|
1203
|
+
Wide-tty surfaces additionally show a tool-call budget bar
|
|
1204
|
+
(``▰▰▱▱ 4/8``) when the kernel stamped a finite cap on the stage
|
|
1205
|
+
payloads — the denominator rides every event, so this costs one
|
|
1206
|
+
capture per turn. Unlimited caps fall back to the plain counter;
|
|
1207
|
+
the bar warns gold past ``_BUDGET_BAR_WARN_RATIO``.
|
|
1208
|
+
"""
|
|
1209
|
+
return self
|
|
1210
|
+
|
|
1211
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
1212
|
+
yield self._activity_frame()
|
|
1213
|
+
|
|
1214
|
+
def _activity_frame(self) -> RenderableType:
|
|
1215
|
+
"""Build one frame of the activity display from current state."""
|
|
1216
|
+
transport = self
|
|
1217
|
+
width = transport.console.size.width or transport._ACTIVITY_WIDTH
|
|
1218
|
+
line = transport._status_line(width)
|
|
1219
|
+
boxes: list[RenderableType] = []
|
|
1220
|
+
tool_box = transport._tool_box_renderable()
|
|
1221
|
+
if tool_box is not None:
|
|
1222
|
+
boxes.append(tool_box)
|
|
1223
|
+
box = transport._stream_box_renderable() if transport._stream_box_active() else None
|
|
1224
|
+
if box is not None:
|
|
1225
|
+
boxes.append(box)
|
|
1226
|
+
if len(boxes) == 1:
|
|
1227
|
+
return Group(line, boxes[0])
|
|
1228
|
+
if boxes:
|
|
1229
|
+
return Group(line, *boxes)
|
|
1230
|
+
return line
|
|
1231
|
+
|
|
1232
|
+
def _status_line(self, width: int) -> _TrailingSpinner:
|
|
1233
|
+
"""Compose the one-line status row with a trailing spinner.
|
|
1234
|
+
|
|
1235
|
+
Returns a dynamic spinner renderable: its ``line_fn``/``spinner_fn``
|
|
1236
|
+
pull from live transport state each frame, so elapsed and the
|
|
1237
|
+
thinking meter tick during quiet generations.
|
|
1238
|
+
"""
|
|
1239
|
+
return _TrailingSpinner(
|
|
1240
|
+
lambda: self._compose_status_body(width), self._activity_spinner, STYLE_GRAPHIC
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
def _compose_status_body(self, width: int) -> Text:
|
|
1244
|
+
"""Build the status-line body from current state (one frame)."""
|
|
1245
|
+
parts: list[Text] = self._status_leading_parts()
|
|
1246
|
+
self._append_meter_parts(parts)
|
|
1247
|
+
self._append_stall_part(parts)
|
|
1248
|
+
if self._phase_label and not parts:
|
|
1249
|
+
parts.append(Text(self._phase_label, style=STYLE_STAGE))
|
|
1250
|
+
if self._iteration_max:
|
|
1251
|
+
parts.append(
|
|
1252
|
+
Text(f"iter {self._iteration_current}/{self._iteration_max}", style=STYLE_STAGE)
|
|
1253
|
+
)
|
|
1254
|
+
parts.append(Text(f"models {self._model_call_count}", style=STYLE_STAGE))
|
|
1255
|
+
parts.append(self._tools_counter_part())
|
|
1256
|
+
parts.append(Text(_format_elapsed(self._elapsed_seconds()), style=STYLE_STAGE))
|
|
1257
|
+
body = Text()
|
|
1258
|
+
for i, part in enumerate(parts):
|
|
1259
|
+
if i:
|
|
1260
|
+
body.append(" · ")
|
|
1261
|
+
body.append(part)
|
|
1262
|
+
body.truncate(max(0, width - 4))
|
|
1263
|
+
return body
|
|
1264
|
+
|
|
1265
|
+
def _status_leading_parts(self) -> list[Text]:
|
|
1266
|
+
"""Leading status segments: turn, mode, model, in-flight label."""
|
|
1267
|
+
parts: list[Text] = []
|
|
1268
|
+
if self._turn_number >= 0:
|
|
1269
|
+
parts.append(Text(f"turn {self._turn_number}", style=STYLE_ROW_MODEL))
|
|
1270
|
+
if self._current_mode:
|
|
1271
|
+
parts.append(Text(f"[{self._current_mode}]", style=STYLE_ROW_TRANSITION))
|
|
1272
|
+
if self._status_current_model:
|
|
1273
|
+
model = self._status_current_model
|
|
1274
|
+
if self._status_model_provider:
|
|
1275
|
+
model = f"{self._status_model_provider}:{model}"
|
|
1276
|
+
parts.append(Text(model, style=STYLE_STAGE))
|
|
1277
|
+
if self._active_label:
|
|
1278
|
+
parts.append(Text(_truncate(self._active_label, 48), style=STYLE_STAGE))
|
|
1279
|
+
return parts
|
|
1280
|
+
|
|
1281
|
+
def _append_meter_parts(self, parts: list[Text]) -> None:
|
|
1282
|
+
"""Append the thinking meter + velocity segments when armed.
|
|
1283
|
+
|
|
1284
|
+
The thinking meter arms on observed reasoning deltas — not the
|
|
1285
|
+
manifest flag (glm-5.3-family bills reasoning without flagging
|
|
1286
|
+
the model) — so the figure appears the moment reasoning actually
|
|
1287
|
+
arrives. Velocity rides after the meter; a stalled stream decays
|
|
1288
|
+
it away.
|
|
1289
|
+
"""
|
|
1290
|
+
if not (self._active_reasoning_model or self._reasoning_seen):
|
|
1291
|
+
return
|
|
1292
|
+
total = self._status_reasoning_chars or self._stream_total_chars()
|
|
1293
|
+
if not total:
|
|
1294
|
+
return
|
|
1295
|
+
parts.append(Text(f"thinking {_format_tokens(total)}", style=STYLE_STAGE))
|
|
1296
|
+
velocity = self._stream_velocity()
|
|
1297
|
+
if velocity > 0:
|
|
1298
|
+
parts.append(Text(f"~{_format_velocity(velocity)}", style=STYLE_STAGE))
|
|
1299
|
+
|
|
1300
|
+
def _no_data_age_s(self) -> float:
|
|
1301
|
+
"""Seconds since the last delta of the in-flight generation, or -1.
|
|
1302
|
+
|
|
1303
|
+
Returns -1 while no generation is in flight or before the first
|
|
1304
|
+
delta has arrived — the caller only reads a stall once streaming
|
|
1305
|
+
has *started then stopped*.
|
|
1306
|
+
"""
|
|
1307
|
+
if self._last_delta_perf <= 0.0:
|
|
1308
|
+
return -1.0
|
|
1309
|
+
return max(0.0, perf_counter() - self._last_delta_perf)
|
|
1310
|
+
|
|
1311
|
+
def _append_stall_part(self, parts: list[Text]) -> None:
|
|
1312
|
+
"""Append the no-data age once a generation goes quiet.
|
|
1313
|
+
|
|
1314
|
+
Streams regularly pause between chunks; only a pause that has
|
|
1315
|
+
crossed ``_STALL_AFTER_S`` reads as a stall. The segment escalates
|
|
1316
|
+
gold → red at ``_STALL_ALERT_S`` so a multi-minute upstream hang
|
|
1317
|
+
(the 2026-10-06 glm-5.3:cloud mid-stream freeze) is legible as
|
|
1318
|
+
*stalled*, not as a frozen frame.
|
|
1319
|
+
"""
|
|
1320
|
+
age = self._no_data_age_s()
|
|
1321
|
+
if age < _STALL_AFTER_S:
|
|
1322
|
+
return
|
|
1323
|
+
style = STYLE_ROW_ERROR if age >= _STALL_ALERT_S else STYLE_ROW_TRANSITION
|
|
1324
|
+
parts.append(Text(f"no data {age:.0f}s", style=style))
|
|
1325
|
+
|
|
1326
|
+
def _activity_spinner(self) -> str:
|
|
1327
|
+
"""Pick the spinner for the current phase.
|
|
1328
|
+
|
|
1329
|
+
Active reasoning (observed deltas or the manifest flag) gets a
|
|
1330
|
+
visually distinct, calmer spinner so a long think doesn't read as
|
|
1331
|
+
a stalled fetch. A stalled stream switches to a slow clock —
|
|
1332
|
+
visibly "waiting", not "working".
|
|
1333
|
+
"""
|
|
1334
|
+
if self._no_data_age_s() >= _STALL_AFTER_S:
|
|
1335
|
+
return "clock"
|
|
1336
|
+
if (self._active_reasoning_model or self._reasoning_seen) and self._model_call_count > 0:
|
|
1337
|
+
return "aesthetic"
|
|
1338
|
+
return self._thinking_spinner
|
|
1339
|
+
|
|
1340
|
+
def _tools_counter_part(self) -> Text:
|
|
1341
|
+
"""Tools counter with a budget bar when the kernel stamped a cap."""
|
|
1342
|
+
if self._cap_max_tool_calls > 0:
|
|
1343
|
+
bar = _budget_bar(self._tool_call_count, self._cap_max_tool_calls)
|
|
1344
|
+
bar_style = (
|
|
1345
|
+
STYLE_ROW_TRANSITION
|
|
1346
|
+
if self._tool_call_count >= _BUDGET_BAR_WARN_RATIO * self._cap_max_tool_calls
|
|
1347
|
+
else STYLE_STAGE
|
|
1348
|
+
)
|
|
1349
|
+
return Text(
|
|
1350
|
+
f"tools {bar} {self._tool_call_count}/{self._cap_max_tool_calls}",
|
|
1351
|
+
style=bar_style,
|
|
1352
|
+
)
|
|
1353
|
+
return Text(f"tools {self._tool_call_count}", style=STYLE_STAGE)
|
|
1354
|
+
|
|
1355
|
+
def _elapsed_seconds(self) -> int:
|
|
1356
|
+
"""Whole seconds elapsed since ``start_thinking`` for this turn."""
|
|
1357
|
+
if self._turn_started_perf <= 0:
|
|
1358
|
+
return 0
|
|
1359
|
+
return int(perf_counter() - self._turn_started_perf)
|
|
1360
|
+
|
|
1361
|
+
def _print_footer(self) -> None:
|
|
1362
|
+
"""Render the single-line prompt-mode footer.
|
|
1363
|
+
|
|
1364
|
+
``⚡ 22s · turn 41 · 5 tools · 2 models · 8.4k tok · $0.02`` — one
|
|
1365
|
+
line, per-turn only; a ``prompt`` invocation is one process and one
|
|
1366
|
+
turn, so the turn/session token split collapses to a single total.
|
|
1367
|
+
The ``turn N`` segment is the global thread turn index (the number
|
|
1368
|
+
stamped on the thread entry, and the number ``lisa last N`` /
|
|
1369
|
+
``lisa debug N`` address). Each segment carries its own color:
|
|
1370
|
+
elapsed gold, turn green, tools blue (red annotation on failures),
|
|
1371
|
+
models green, tokens green, cost cyan. Plain text is identical to
|
|
1372
|
+
the legacy flat line.
|
|
1373
|
+
"""
|
|
1374
|
+
model_time_ms = round(sum(self._iteration_durations))
|
|
1375
|
+
tools_total = self._tool_call_count
|
|
1376
|
+
tool_failures = self._tool_error_count
|
|
1377
|
+
footer = Text()
|
|
1378
|
+
footer.append(f"⚡ {self._elapsed_seconds()}s", style=STYLE_ROW_TRANSITION)
|
|
1379
|
+
if self._turn_number >= 0:
|
|
1380
|
+
footer.append(" · ", style=STYLE_TEXT_MUTED)
|
|
1381
|
+
footer.append(f"turn {self._turn_number}", style=STYLE_ROW_MODEL)
|
|
1382
|
+
footer.append(" · ", style=STYLE_TEXT_MUTED)
|
|
1383
|
+
footer.append(f"{tools_total} tool{'s' if tools_total != 1 else ''}", style=STYLE_ROW_TOOL)
|
|
1384
|
+
if tool_failures:
|
|
1385
|
+
footer.append(f" ({tools_total - tool_failures} ✓ ", style=STYLE_ROW_SUCCESS)
|
|
1386
|
+
footer.append(f"{tool_failures} ✗", style=STYLE_ROW_ERROR)
|
|
1387
|
+
footer.append(")", style=STYLE_ROW_SUCCESS)
|
|
1388
|
+
footer.append(" · ", style=STYLE_TEXT_MUTED)
|
|
1389
|
+
model_part = f"{self._model_call_count} model{'s' if self._model_call_count != 1 else ''}"
|
|
1390
|
+
if model_time_ms:
|
|
1391
|
+
model_part += f" ({model_time_ms}ms)"
|
|
1392
|
+
footer.append(model_part, style=STYLE_ROW_MODEL)
|
|
1393
|
+
if self._handoff_count:
|
|
1394
|
+
footer.append(" · ", style=STYLE_TEXT_MUTED)
|
|
1395
|
+
handoff_part = f"{self._handoff_count} handoff{'s' if self._handoff_count != 1 else ''}"
|
|
1396
|
+
route = self._current_mode
|
|
1397
|
+
if route:
|
|
1398
|
+
handoff_part += f" → {route}"
|
|
1399
|
+
footer.append(handoff_part, style="steel_blue3")
|
|
1400
|
+
if self._status_turn_tokens:
|
|
1401
|
+
footer.append(" · ", style=STYLE_TEXT_MUTED)
|
|
1402
|
+
footer.append(f"{_format_tokens(self._status_turn_tokens)} tok", style="green3")
|
|
1403
|
+
# Cache-split figure: when the turn walked context with cache
|
|
1404
|
+
# hits, the walk is not the billed basis. Show what the turn
|
|
1405
|
+
# actually consumed next to the walk (2026-10-12 perf review).
|
|
1406
|
+
if self._status_cached_tokens > 0 and self._status_billed_tokens:
|
|
1407
|
+
footer.append(
|
|
1408
|
+
f" · billed {_format_tokens(self._status_billed_tokens)}",
|
|
1409
|
+
style="cyan",
|
|
1410
|
+
)
|
|
1411
|
+
cost = self._estimate_turn_cost()
|
|
1412
|
+
if cost:
|
|
1413
|
+
footer.append(" · ", style=STYLE_TEXT_MUTED)
|
|
1414
|
+
footer.append(f"${cost:g}", style="cyan")
|
|
1415
|
+
self.console.print(footer)
|
|
1416
|
+
|
|
1417
|
+
def _estimate_turn_cost(self) -> float:
|
|
1418
|
+
"""Estimate the turn's USD cost from the manifest per-token prices.
|
|
1419
|
+
|
|
1420
|
+
Uses the split prompt/completion token counts accumulated from
|
|
1421
|
+
``model_call_complete`` events against the per-model prices injected
|
|
1422
|
+
by the command path (``model_prices``). Returns 0 when either side
|
|
1423
|
+
is missing — a missing manifest must never block the footer.
|
|
1424
|
+
|
|
1425
|
+
When the manifest publishes cache prices (``model_cache_prices``),
|
|
1426
|
+
cache-hit prompt tokens are priced at the cache-read rate instead
|
|
1427
|
+
of the uncached prompt rate.
|
|
1428
|
+
"""
|
|
1429
|
+
prices = self.model_prices.get(self._status_current_model)
|
|
1430
|
+
if not prices:
|
|
1431
|
+
return 0.0
|
|
1432
|
+
prompt_price, completion_price = prices
|
|
1433
|
+
cache_prices = self.model_cache_prices.get(self._status_current_model) or (0.0, 0.0)
|
|
1434
|
+
cache_read_price, _cache_write_price = cache_prices
|
|
1435
|
+
cached = min(self._status_cached_tokens, self._status_prompt_tokens)
|
|
1436
|
+
uncached_prompt = self._status_prompt_tokens - cached
|
|
1437
|
+
prompt_read = uncached_prompt * prompt_price + cached * (cache_read_price or prompt_price)
|
|
1438
|
+
cost = prompt_read + self._status_completion_tokens * completion_price
|
|
1439
|
+
# Sub-cent figures round to zero display; keep one significant digit.
|
|
1440
|
+
return round(cost, 4)
|
|
1441
|
+
|
|
1442
|
+
# ── Model/tool row rendering (shared with event lines) ──────────
|
|
1443
|
+
|
|
1444
|
+
def _render_model_call(
|
|
1445
|
+
self,
|
|
1446
|
+
data: dict[str, Any],
|
|
1447
|
+
*,
|
|
1448
|
+
status_glyph: str = "",
|
|
1449
|
+
complete: dict[str, Any] | None = None,
|
|
1450
|
+
) -> Text:
|
|
1451
|
+
"""Render a model call as a one-liner: stage, model, iteration, status.
|
|
1452
|
+
|
|
1453
|
+
``stage: model_call haiku #1 ✓ 340ms``. On completion
|
|
1454
|
+
(``status_glyph`` set) the status and duration are appended.
|
|
1455
|
+
|
|
1456
|
+
Returns a styled ``Text``: the label segment is muted, the model id
|
|
1457
|
+
is bright, the status is success/error colored. Plain text is
|
|
1458
|
+
identical to the legacy string form.
|
|
1459
|
+
"""
|
|
1460
|
+
out = Text("stage: model_call", style=STYLE_TEXT_MUTED)
|
|
1461
|
+
model_id = data.get("model_id")
|
|
1462
|
+
if model_id not in (None, "", 0):
|
|
1463
|
+
out.append(f" {model_id}", style=STYLE_TOOL_NAME)
|
|
1464
|
+
iter_raw = data.get("iteration") or data.get("_state.iteration")
|
|
1465
|
+
if iter_raw not in (None, "", 0):
|
|
1466
|
+
out.append(f" #{iter_raw}", style=STYLE_TEXT_MUTED)
|
|
1467
|
+
if status_glyph:
|
|
1468
|
+
out.append(f" {status_glyph}", style=STYLE_ROW_ERROR)
|
|
1469
|
+
duration = (complete or data).get("duration_ms")
|
|
1470
|
+
if status_glyph and duration not in (None, "", 0):
|
|
1471
|
+
out.append(f" {round(float(duration))}ms", style=STYLE_TEXT_MUTED)
|
|
1472
|
+
return out
|
|
1473
|
+
|
|
1474
|
+
def _render_tool_dispatch(
|
|
1475
|
+
self,
|
|
1476
|
+
data: dict[str, Any],
|
|
1477
|
+
*,
|
|
1478
|
+
status_glyph: str = "",
|
|
1479
|
+
complete: dict[str, Any] | None = None,
|
|
1480
|
+
summary: str = "",
|
|
1481
|
+
) -> Text:
|
|
1482
|
+
"""Render a tool call as a one-liner: glyph, tool, primary arg, status.
|
|
1483
|
+
|
|
1484
|
+
``→ tool brave_search("query") ✓ 120ms``. On completion
|
|
1485
|
+
(``status_glyph`` set) the status and duration are appended,
|
|
1486
|
+
right-aligned on wide terminals so a column of completions scans
|
|
1487
|
+
vertically. ``summary`` (the ``⎿ N lines`` result shape) rides
|
|
1488
|
+
after the status; its length is reserved in the alignment math so
|
|
1489
|
+
the row never wraps.
|
|
1490
|
+
|
|
1491
|
+
Returns a styled ``Text``: glyph in the tool-kind color, tool name
|
|
1492
|
+
bright, argument preview dim, status success/error colored. Plain
|
|
1493
|
+
text (non-tty) is identical to the legacy string form — the
|
|
1494
|
+
right-alignment pads with spaces, which reads the same either way.
|
|
1495
|
+
"""
|
|
1496
|
+
tool = str(data.get("tool") or "")
|
|
1497
|
+
kind = str(data.get("tool_kind") or "")
|
|
1498
|
+
glyph = self._TOOL_KIND_GLYPH.get(kind, "→")
|
|
1499
|
+
|
|
1500
|
+
out = Text()
|
|
1501
|
+
if kind == "transition":
|
|
1502
|
+
target = ""
|
|
1503
|
+
if data.get("handoff_to"):
|
|
1504
|
+
target = f"→ {data['handoff_to']}"
|
|
1505
|
+
elif data.get("transition") == "fork":
|
|
1506
|
+
n = data.get("fork_children") or 0
|
|
1507
|
+
target = f"fork ({n} children)"
|
|
1508
|
+
elif data.get("transitioned_to"):
|
|
1509
|
+
target = f"→ {data['transitioned_to']}"
|
|
1510
|
+
out.append("stage: tool_dispatch ", style=STYLE_TEXT_MUTED)
|
|
1511
|
+
out.append(glyph, style=STYLE_ROW_TRANSITION)
|
|
1512
|
+
out.append(f" {tool}", style=STYLE_TOOL_NAME)
|
|
1513
|
+
if target:
|
|
1514
|
+
out.append(f" {target}", style=STYLE_ROW_TRANSITION)
|
|
1515
|
+
else:
|
|
1516
|
+
out.append(glyph, style=STYLE_ROW_TOOL)
|
|
1517
|
+
out.append(f" {tool}", style=STYLE_TOOL_NAME)
|
|
1518
|
+
arg = self._primary_arg_preview(data)
|
|
1519
|
+
if arg:
|
|
1520
|
+
out.append(f" {arg}", style=STYLE_ARG_PREVIEW)
|
|
1521
|
+
|
|
1522
|
+
if status_glyph:
|
|
1523
|
+
status_style = STYLE_ROW_ERROR if status_glyph == "✗" else STYLE_ROW_SUCCESS
|
|
1524
|
+
duration = (complete or data).get("duration_ms")
|
|
1525
|
+
tail = status_glyph
|
|
1526
|
+
if duration not in (None, "", 0):
|
|
1527
|
+
tail += f" {round(float(duration))}ms"
|
|
1528
|
+
self._append_aligned_status(out, tail, status_style, suffix=summary)
|
|
1529
|
+
if summary:
|
|
1530
|
+
out.append(" ", style=STYLE_TEXT_MUTED)
|
|
1531
|
+
out.append(summary, style=STYLE_TEXT_MUTED)
|
|
1532
|
+
return out
|
|
1533
|
+
|
|
1534
|
+
def _append_aligned_status(self, row: Text, tail: str, style: str, *, suffix: str = "") -> None:
|
|
1535
|
+
"""Append ``tail`` right-aligned in a fixed status column (tty only).
|
|
1536
|
+
|
|
1537
|
+
Interactive terminals (>= ``_STATUS_ALIGN_MIN_WIDTH``) pad the row so
|
|
1538
|
+
the status glyphs line up vertically across consecutive tool rows —
|
|
1539
|
+
the Claude-Code "result column" scan. Non-tty consoles (piped, tee,
|
|
1540
|
+
tests) keep the legacy single-space compact join so scripted output
|
|
1541
|
+
and pinned assertions stay byte-identical. ``suffix`` (the result
|
|
1542
|
+
summary that follows the status) reserves its width in the padding
|
|
1543
|
+
so a long row never wraps onto a second line.
|
|
1544
|
+
"""
|
|
1545
|
+
width = self.console.size.width or 0
|
|
1546
|
+
if not self.console.is_terminal or width < _STATUS_ALIGN_MIN_WIDTH:
|
|
1547
|
+
row.append(" ", style=style)
|
|
1548
|
+
row.append(tail, style=style)
|
|
1549
|
+
return
|
|
1550
|
+
reserved = len(tail) + len(suffix) + 4
|
|
1551
|
+
pad = max(1, _STATUS_ALIGN_COLUMN - len(tail) - 1)
|
|
1552
|
+
pad = max(pad, width - row.cell_len - reserved)
|
|
1553
|
+
if row.cell_len + pad + len(tail) + len(suffix) + 1 >= width:
|
|
1554
|
+
# Not enough room: keep the compact join rather than wrapping.
|
|
1555
|
+
row.append(" ", style=style)
|
|
1556
|
+
row.append(tail, style=style)
|
|
1557
|
+
return
|
|
1558
|
+
row.append(" " * pad)
|
|
1559
|
+
row.append(tail, style=style)
|
|
1560
|
+
|
|
1561
|
+
@staticmethod
|
|
1562
|
+
def _primary_arg_preview(data: dict[str, Any]) -> str:
|
|
1563
|
+
"""Extract the primary call argument as a short inline preview.
|
|
1564
|
+
|
|
1565
|
+
Prefers the kernel-resolved ``display`` label (rendered from the
|
|
1566
|
+
tool's ``display_template`` — e.g. bash_run's command line), falling
|
|
1567
|
+
back to the first present among the well-known primary-argument keys
|
|
1568
|
+
for tools whose payload pre-dates the field or whose template failed.
|
|
1569
|
+
"""
|
|
1570
|
+
display = data.get("display")
|
|
1571
|
+
if isinstance(display, str) and display.strip():
|
|
1572
|
+
return _truncate(display.strip())
|
|
1573
|
+
# Fallback: first non-empty primary-argument string. The kernel emits
|
|
1574
|
+
# the full argument dict under ``arguments``; a tool without a
|
|
1575
|
+
# ``display_template`` (or one whose template failed) previews its
|
|
1576
|
+
# primary argument here.
|
|
1577
|
+
arguments = data.get("arguments")
|
|
1578
|
+
sources: list[dict[str, Any]] = [data]
|
|
1579
|
+
if isinstance(arguments, dict):
|
|
1580
|
+
sources.append(arguments)
|
|
1581
|
+
for source in sources:
|
|
1582
|
+
for key in ("arg_preview", "query", "path", "command", "input"):
|
|
1583
|
+
value = source.get(key)
|
|
1584
|
+
if isinstance(value, str) and value.strip():
|
|
1585
|
+
return _truncate(value)
|
|
1586
|
+
return ""
|
|
1587
|
+
|
|
1588
|
+
def _render_fork_stage(self, summary: tuple[int, int] | None = None) -> Text:
|
|
1589
|
+
"""Render the fork fan-out as one header line plus one line per child.
|
|
1590
|
+
|
|
1591
|
+
Returns a multi-line styled ``Text``: header in transition gold,
|
|
1592
|
+
child rows muted with the mode id bright. Plain text is identical
|
|
1593
|
+
to the legacy string form (the tests pin it).
|
|
1594
|
+
"""
|
|
1595
|
+
out = Text()
|
|
1596
|
+
if summary is not None:
|
|
1597
|
+
ok, failed = summary
|
|
1598
|
+
header = f"stage: fork complete ({ok} ok, {failed} failed)"
|
|
1599
|
+
else:
|
|
1600
|
+
header = f"stage: fork ({self._fork_total} children)"
|
|
1601
|
+
out.append(header, style=STYLE_ROW_TRANSITION)
|
|
1602
|
+
for idx in sorted(self._fork_children):
|
|
1603
|
+
child = self._fork_children[idx]
|
|
1604
|
+
mode_id = child.get("mode_id", "?")
|
|
1605
|
+
stage_id = child.get("stage_id", "")
|
|
1606
|
+
status = child.get("status", "running")
|
|
1607
|
+
out.append("\n")
|
|
1608
|
+
out.append(f" #{idx} ", style=STYLE_TEXT_MUTED)
|
|
1609
|
+
out.append(mode_id, style=STYLE_TOOL_NAME)
|
|
1610
|
+
status_part = f" · {status}"
|
|
1611
|
+
if stage_id:
|
|
1612
|
+
status_part = f" · {stage_id} · {status}"
|
|
1613
|
+
out.append(status_part, style=STYLE_TEXT_MUTED)
|
|
1614
|
+
preview = child.get("query_preview", "")
|
|
1615
|
+
if preview:
|
|
1616
|
+
out.append(f" · {preview}", style=STYLE_ARG_PREVIEW)
|
|
1617
|
+
duration_ms = child.get("duration_ms")
|
|
1618
|
+
if duration_ms is not None:
|
|
1619
|
+
out.append(f" · {duration_ms}ms", style=STYLE_TEXT_MUTED)
|
|
1620
|
+
return out
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
__all__ = ["PromptTransport"]
|