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/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"]