zettcode 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.
zettcode/tui.py ADDED
@@ -0,0 +1,924 @@
1
+ """ZettCode component composition on the internal differential TUI framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import math
8
+ import os
9
+ import re
10
+ from collections.abc import Mapping
11
+ from contextlib import aclosing
12
+ from dataclasses import dataclass, field, replace
13
+ from pathlib import Path
14
+ from time import monotonic
15
+
16
+ from wcwidth import wcwidth
17
+ from zett_agent import AgentEvent, AgentEventDispatcher, AgentRunConfig, ToolMessage
18
+
19
+ from .runtime import ZettCodeRuntime
20
+ from .tui_framework import (
21
+ Application,
22
+ BoxChild,
23
+ Component,
24
+ EventType,
25
+ HBox,
26
+ InputEvent,
27
+ Rule,
28
+ ScrollableText,
29
+ Span,
30
+ Style,
31
+ Text,
32
+ TextInput,
33
+ TextLine,
34
+ VBox,
35
+ )
36
+
37
+ SLASH_COMMANDS = ("/new", "/sessions", "/use", "/clear", "/help", "/quit", "/exit")
38
+
39
+ GREEN = "#79b88b"
40
+ LIGHT_GREEN = "#9bddad"
41
+ MUTED = "#747d77"
42
+ DETAIL = "#a3ada6"
43
+ FOREGROUND = "#e6e9e7"
44
+ THINKING_BACKGROUND = "#26342b"
45
+ ERROR = "#dc8178"
46
+ MAX_STORED_TOOL_OUTPUT = 64_000
47
+ TOOL_PREVIEW_ROWS = 5
48
+ TOOL_EXPANDED_ROWS = 40
49
+ MAX_MARKDOWN_TABLE_ROWS = 100
50
+
51
+ STYLE_NORMAL = Style(foreground=FOREGROUND)
52
+ STYLE_MUTED = Style(foreground=MUTED)
53
+ STYLE_DETAIL = Style(foreground=DETAIL)
54
+ STYLE_GREEN = Style(foreground=GREEN, bold=True)
55
+ STYLE_USER = Style(foreground="#d6ddd8", bold=True)
56
+ STYLE_THINKING = Style(foreground="#dce8df", background=THINKING_BACKGROUND, bold=True)
57
+ STYLE_THINKING_BODY = Style(foreground="#d1ddd4", background=THINKING_BACKGROUND)
58
+ STYLE_ERROR = Style(foreground=ERROR, bold=True)
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class LineMetadata:
63
+ """Interaction identity attached to one rendered transcript line."""
64
+
65
+ thinking_id: int | None = None
66
+ thinking_header: bool = False
67
+ tool_id: int | None = None
68
+ tool_header: bool = False
69
+
70
+
71
+ @dataclass(slots=True)
72
+ class TranscriptEntry:
73
+ """One semantic terminal block kept in AgentClient event order."""
74
+
75
+ id: int
76
+ kind: str
77
+ call_id: str = ""
78
+ text: str = ""
79
+ title: str = ""
80
+ detail: str = ""
81
+ status: str = ""
82
+ expanded: bool = False
83
+ started_at: float | None = None
84
+ duration_seconds: float | None = None
85
+
86
+
87
+ @dataclass(slots=True)
88
+ class Transcript:
89
+ """Structured transcript independent from terminal rendering mechanics."""
90
+
91
+ entries: list[TranscriptEntry] = field(default_factory=list)
92
+ _next_id: int = 1
93
+ animation_frame: int = 0
94
+
95
+ @property
96
+ def text(self) -> str:
97
+ return "\n".join(line.text for line in self.lines(120))
98
+
99
+ def append(self, value: str) -> None:
100
+ self._add("raw", text=value)
101
+
102
+ def clear(self) -> None:
103
+ self.entries.clear()
104
+
105
+ def begin_turn(self, prompt: str) -> None:
106
+ self._add("user", text=prompt)
107
+
108
+ def start_thinking(self) -> TranscriptEntry:
109
+ current = self._last("thinking")
110
+ if current is not None and current.status == "running":
111
+ return current
112
+ return self._add("thinking", title="Thinking", status="running", started_at=monotonic())
113
+
114
+ def append_thinking(self, delta: str) -> None:
115
+ self.start_thinking().text += delta
116
+
117
+ def complete_thinking(self) -> None:
118
+ current = self._last("thinking")
119
+ if current is None or current.status != "running":
120
+ return
121
+ current.status = "completed"
122
+ if current.started_at is not None:
123
+ current.duration_seconds = monotonic() - current.started_at
124
+
125
+ def append_answer(self, delta: str) -> None:
126
+ current = self.entries[-1] if self.entries else None
127
+ if current is None or current.kind != "answer":
128
+ current = self._add("answer")
129
+ current.text += delta
130
+
131
+ def start_tool(self, call_id: str, name: str, arguments: Mapping[str, object]) -> None:
132
+ self.complete_thinking()
133
+ self._add(
134
+ "tool",
135
+ call_id=call_id,
136
+ title=name,
137
+ detail=_arguments_preview(arguments),
138
+ status="running",
139
+ started_at=monotonic(),
140
+ )
141
+
142
+ def complete_tool(self, call_id: str, output: str, *, status: str = "completed") -> None:
143
+ entry = self._tool(call_id)
144
+ if entry is None:
145
+ return
146
+ entry.text = _limit_stored_output(output)
147
+ entry.status = status
148
+ if entry.started_at is not None:
149
+ entry.duration_seconds = monotonic() - entry.started_at
150
+
151
+ def start_server_tool(self, call_id: str, name: str) -> None:
152
+ self.complete_thinking()
153
+ self._add("server_tool", call_id=call_id, title=name, status="running", started_at=monotonic())
154
+
155
+ def complete_server_tool(self, call_id: str, output: str, *, status: str = "completed") -> None:
156
+ entry = next(
157
+ (item for item in reversed(self.entries) if item.kind == "server_tool" and item.call_id == call_id),
158
+ None,
159
+ )
160
+ if entry is None:
161
+ return
162
+ entry.text = _limit_stored_output(output)
163
+ entry.status = status
164
+ if entry.started_at is not None:
165
+ entry.duration_seconds = monotonic() - entry.started_at
166
+
167
+ def toggle_latest_thinking(self) -> bool:
168
+ entry = self._last("thinking")
169
+ if entry is None:
170
+ return False
171
+ entry.expanded = not entry.expanded
172
+ return True
173
+
174
+ def toggle_thinking(self, entry_id: int) -> bool:
175
+ entry = self._entry(entry_id)
176
+ if entry is None or entry.kind != "thinking":
177
+ return False
178
+ entry.expanded = not entry.expanded
179
+ return True
180
+
181
+ def toggle_tool(self, entry_id: int) -> bool:
182
+ entry = self._entry(entry_id)
183
+ if entry is None or entry.kind not in ("tool", "server_tool") or entry.status == "running" or not entry.text:
184
+ return False
185
+ entry.expanded = not entry.expanded
186
+ return True
187
+
188
+ def collapse_thinking_except(self, entry_id: int | None) -> bool:
189
+ changed = False
190
+ for entry in self.entries:
191
+ if entry.kind == "thinking" and entry.expanded and entry.id != entry_id:
192
+ entry.expanded = False
193
+ changed = True
194
+ return changed
195
+
196
+ def lines(self, width: int) -> list[TextLine]:
197
+ """Render semantic blocks into rich logical lines for ScrollableText."""
198
+ result: list[TextLine] = []
199
+
200
+ def blank() -> None:
201
+ result.append(TextLine())
202
+
203
+ def plain(value: str, style: Style = STYLE_NORMAL, metadata: object | None = None) -> None:
204
+ for line in value.split("\n"):
205
+ result.append(TextLine((Span(line, style),), metadata))
206
+
207
+ for entry in self.entries:
208
+ match entry.kind:
209
+ case "welcome":
210
+ plain(entry.text, Style(foreground="#9fb6a6"))
211
+ case "user":
212
+ blank()
213
+ prompt_lines = entry.text.splitlines() or [""]
214
+ plain(f"❯ {prompt_lines[0]}", STYLE_USER)
215
+ for line in prompt_lines[1:]:
216
+ plain(f" {line}", STYLE_USER)
217
+ case "thinking":
218
+ blank()
219
+ marker = (
220
+ _activity_icon(self.animation_frame)
221
+ if entry.status == "running"
222
+ else ("▾" if entry.expanded else "▸")
223
+ )
224
+ timing = _duration(entry.duration_seconds) if entry.status == "completed" else "working"
225
+ metadata = LineMetadata(entry.id, thinking_header=True)
226
+ style = STYLE_THINKING if entry.expanded else STYLE_GREEN
227
+ if entry.status == "running":
228
+ result.append(
229
+ TextLine(
230
+ _activity_spans(
231
+ f" {marker} Thinking {timing}",
232
+ self.animation_frame,
233
+ background=style.background,
234
+ ),
235
+ metadata,
236
+ )
237
+ )
238
+ else:
239
+ plain(f" {marker} Thinking {timing}", style, metadata)
240
+ if entry.expanded:
241
+ body_metadata = LineMetadata(entry.id)
242
+ for line in entry.text.splitlines() or ["Waiting for reasoning…"]:
243
+ plain(f" {line}", STYLE_THINKING_BODY, body_metadata)
244
+ case "tool" | "server_tool":
245
+ blank()
246
+ symbol = (
247
+ _activity_icon(self.animation_frame)
248
+ if entry.status == "running"
249
+ else {"completed": "✓", "failed": "×", "skipped": "–"}.get(entry.status, "●")
250
+ )
251
+ timing = f" {_duration(entry.duration_seconds)}" if entry.duration_seconds is not None else ""
252
+ style = STYLE_ERROR if entry.status == "failed" else STYLE_GREEN
253
+ metadata = LineMetadata(tool_id=entry.id, tool_header=True)
254
+ disclosure = (
255
+ "" if entry.status == "running" or not entry.text else (" ▾" if entry.expanded else " ▸")
256
+ )
257
+ label = f" {symbol} {entry.title}{entry.detail}{timing}{disclosure}"
258
+ if entry.status == "running":
259
+ result.append(TextLine(_activity_spans(label, self.animation_frame), metadata))
260
+ else:
261
+ plain(label, style, metadata)
262
+ output = "Running…" if entry.status == "running" else entry.text
263
+ if output:
264
+ row_limit = TOOL_EXPANDED_ROWS if entry.expanded else TOOL_PREVIEW_ROWS
265
+ output_lines, omitted = _bounded_visual_lines(output, max(8, width - 6), row_limit)
266
+ plain(f" └ {output_lines[0]}", STYLE_DETAIL)
267
+ for line in output_lines[1:]:
268
+ plain(f" {line}", STYLE_DETAIL)
269
+ if omitted:
270
+ plain(f" … {omitted} more rows · click tool to expand", STYLE_MUTED)
271
+ case "answer":
272
+ blank()
273
+ result.extend(_markdown_block_lines(entry.text, width))
274
+ case _:
275
+ plain(entry.text, STYLE_DETAIL)
276
+ return result
277
+
278
+ def _add(self, kind: str, **values) -> TranscriptEntry:
279
+ entry = TranscriptEntry(self._next_id, kind, **values)
280
+ self._next_id += 1
281
+ self.entries.append(entry)
282
+ return entry
283
+
284
+ def _last(self, kind: str) -> TranscriptEntry | None:
285
+ return next((entry for entry in reversed(self.entries) if entry.kind == kind), None)
286
+
287
+ def _entry(self, entry_id: int) -> TranscriptEntry | None:
288
+ return next((entry for entry in self.entries if entry.id == entry_id), None)
289
+
290
+ def _tool(self, call_id: str) -> TranscriptEntry | None:
291
+ return next(
292
+ (entry for entry in reversed(self.entries) if entry.kind == "tool" and entry.call_id == call_id),
293
+ None,
294
+ )
295
+
296
+
297
+ class TUIEventDispatcher(AgentEventDispatcher):
298
+ """Convert AgentClient callbacks into structured transcript blocks."""
299
+
300
+ def __init__(self, transcript: Transcript) -> None:
301
+ self.transcript = transcript
302
+
303
+ def begin_turn(self, prompt: str) -> None:
304
+ self.transcript.begin_turn(prompt)
305
+
306
+ async def on_compaction_started_event(self, event: AgentEvent) -> None:
307
+ self.transcript.append("\n ◇ Compacting context…")
308
+
309
+ async def on_compaction_completed_event(self, event: AgentEvent) -> None:
310
+ status = "applied" if event.applied else "skipped"
311
+ self.transcript.append(f" Context compaction {status}")
312
+
313
+ async def on_reasoning_started_event(self, event: AgentEvent) -> None:
314
+ self.transcript.start_thinking()
315
+
316
+ async def on_reasoning_delta_event(self, event: AgentEvent) -> None:
317
+ self.transcript.append_thinking(event.delta)
318
+
319
+ async def on_reasoning_completed_event(self, event: AgentEvent) -> None:
320
+ self.transcript.complete_thinking()
321
+
322
+ async def on_text_delta_event(self, event: AgentEvent) -> None:
323
+ self.transcript.complete_thinking()
324
+ self.transcript.append_answer(event.delta)
325
+
326
+ async def on_tool_started_event(self, event: AgentEvent) -> None:
327
+ for call in event.tool_calls:
328
+ self.transcript.start_tool(call.id, call.name, call.arguments)
329
+
330
+ async def on_tool_completed_event(self, event: AgentEvent) -> None:
331
+ if isinstance(event.message, ToolMessage):
332
+ self.transcript.complete_tool(event.message.tool_call_id, _tool_output(event.message))
333
+
334
+ async def on_tool_failed_event(self, event: AgentEvent) -> None:
335
+ for call in event.tool_calls:
336
+ output = str(event.error) if event.error is not None else "Tool failed"
337
+ self.transcript.complete_tool(call.id, output, status="failed")
338
+
339
+ async def on_tool_skipped_event(self, event: AgentEvent) -> None:
340
+ for call in event.tool_calls:
341
+ output = _tool_output(event.message) if isinstance(event.message, ToolMessage) else "Skipped"
342
+ self.transcript.complete_tool(call.id, output, status="skipped")
343
+
344
+ async def on_server_tool_started_event(self, event: AgentEvent) -> None:
345
+ if event.server_tool_call is not None:
346
+ self.transcript.start_server_tool(event.server_tool_call.id, event.server_tool_call.name)
347
+
348
+ async def on_server_tool_completed_event(self, event: AgentEvent) -> None:
349
+ if event.server_tool_result is not None:
350
+ self.transcript.complete_server_tool(
351
+ event.server_tool_result.call_id,
352
+ _serialized_output(event.server_tool_result.output),
353
+ )
354
+
355
+ async def on_server_tool_failed_event(self, event: AgentEvent) -> None:
356
+ if event.server_tool_result is not None:
357
+ output = event.server_tool_result.error_code or _serialized_output(event.server_tool_result.output)
358
+ self.transcript.complete_server_tool(event.server_tool_result.call_id, output, status="failed")
359
+
360
+ async def on_run_completed_event(self, event: AgentEvent) -> None:
361
+ self.transcript.complete_thinking()
362
+
363
+
364
+ class ZettCodeRoot(Component):
365
+ """Application-specific command routing around reusable framework widgets."""
366
+
367
+ def __init__(self, tui: ZettCodeTUI, content: VBox) -> None:
368
+ super().__init__()
369
+ self.tui = tui
370
+ self.content = content
371
+
372
+ def layout(self, rect) -> None:
373
+ super().layout(rect)
374
+ self.content.layout(rect)
375
+
376
+ def render(self, canvas) -> None:
377
+ self.content.render(canvas)
378
+
379
+ def handle(self, event: InputEvent, actions) -> bool:
380
+ if event.type == EventType.KEY:
381
+ match event.key:
382
+ case "ctrl_c":
383
+ if actions.focused is self.tui.transcript_view:
384
+ selected = self.tui.transcript_view.selected_text()
385
+ if selected:
386
+ actions.copy(selected)
387
+ self.tui._status = "copied selection"
388
+ self.tui.transcript_view.clear_selection()
389
+ actions.focus(self.tui.composer)
390
+ elif self.tui.busy:
391
+ self.tui._status = "stopping"
392
+ assert self.tui._request_task is not None
393
+ self.tui._request_task.cancel()
394
+ else:
395
+ self.tui.composer.clear()
396
+ return True
397
+ case "ctrl_d" if not self.tui.busy and not self.tui.composer.text:
398
+ actions.exit()
399
+ return True
400
+ case "ctrl_l":
401
+ actions.refresh()
402
+ return True
403
+ case "ctrl_t":
404
+ return self.tui.transcript.toggle_latest_thinking()
405
+ case "escape" if actions.focused is self.tui.transcript_view:
406
+ actions.focus(self.tui.composer)
407
+ return True
408
+ if event.type in (EventType.TEXT, EventType.PASTE) and actions.focused is self.tui.transcript_view:
409
+ actions.focus(self.tui.composer)
410
+ return self.tui.composer.handle(event, actions)
411
+ return self.content.handle(event, actions)
412
+
413
+ def cursor(self) -> tuple[int, int] | None:
414
+ return self.content.cursor()
415
+
416
+
417
+ class ZettCodeTUI:
418
+ """Compose AgentClient, transcript, and internal terminal components."""
419
+
420
+ def __init__(self, runtime: ZettCodeRuntime) -> None:
421
+ self.runtime = runtime
422
+ self.transcript = Transcript()
423
+ self.transcript._add(
424
+ "welcome",
425
+ text=" ╭───╮\n │ ›_│ ZettCode\n ╰─◆─╯ A focused coding agent\n\n Type a task below, or /help for commands.",
426
+ )
427
+ self.event_dispatcher = TUIEventDispatcher(self.transcript)
428
+ self.runtime.client.event_dispatcher = self.event_dispatcher
429
+ self._request_task: asyncio.Task[None] | None = None
430
+ self._animation_frame = 0
431
+ self._status = "ready"
432
+
433
+ self.transcript_view = ScrollableText(
434
+ self.transcript.lines,
435
+ on_click=self._click_transcript,
436
+ on_hover=self._hover_transcript,
437
+ on_selection=self._selection_copied,
438
+ )
439
+ self.composer = TextInput(self._submit, prompt="› ", max_height=7, completions=SLASH_COMMANDS)
440
+ header = HBox(
441
+ [
442
+ BoxChild(Text(self._header, Style(foreground=LIGHT_GREEN, bold=True)), flex=1),
443
+ BoxChild(Text(self._model, STYLE_MUTED, align="right"), size=32),
444
+ ]
445
+ )
446
+ status = HBox(
447
+ [
448
+ BoxChild(Text(self._status_text, STYLE_MUTED), flex=1),
449
+ BoxChild(Text("^T thinking PgUp history ^C stop ", STYLE_MUTED, align="right"), size=40),
450
+ ]
451
+ )
452
+ content = VBox(
453
+ [
454
+ BoxChild(header, size=1),
455
+ BoxChild(Rule(Style(foreground="#343a36")), size=1),
456
+ BoxChild(self.transcript_view, flex=1),
457
+ BoxChild(Rule(Style(foreground="#60876b")), size=1),
458
+ BoxChild(self.composer, size=lambda width: self.composer.preferred_height(width)),
459
+ BoxChild(status, size=1),
460
+ ]
461
+ )
462
+ self.root = ZettCodeRoot(self, content)
463
+ self.application = Application(self.root)
464
+ self.application.focus(self.composer)
465
+
466
+ @property
467
+ def busy(self) -> bool:
468
+ return self._request_task is not None and not self._request_task.done()
469
+
470
+ async def run(self) -> None:
471
+ try:
472
+ await self.application.run()
473
+ finally:
474
+ if self._request_task is not None and not self._request_task.done():
475
+ self._request_task.cancel()
476
+ await asyncio.gather(self._request_task, return_exceptions=True)
477
+
478
+ def _submit(self, value: str) -> bool:
479
+ if self.busy:
480
+ self._status = "busy — Ctrl-C stops the current request"
481
+ return False
482
+ if value.startswith("/"):
483
+ self._request_task = asyncio.create_task(self._run_command(value))
484
+ else:
485
+ self._request_task = asyncio.create_task(self._run_prompt(value))
486
+ return True
487
+
488
+ async def _run_prompt(self, prompt: str) -> None:
489
+ self.event_dispatcher.begin_turn(prompt)
490
+ self._status = "running"
491
+ self.application.invalidate()
492
+ animation = asyncio.create_task(self._animate_activity())
493
+ try:
494
+ async with aclosing(
495
+ self.runtime.client.stream(prompt, config=AgentRunConfig(session_id=self.runtime.session_id))
496
+ ) as events:
497
+ async for _event in events:
498
+ self.application.invalidate()
499
+ except asyncio.CancelledError:
500
+ self.transcript.complete_thinking()
501
+ self.transcript.append("\n stopped")
502
+ except Exception as error:
503
+ self.transcript.complete_thinking()
504
+ self.transcript.append(f"\n error: {error}")
505
+ finally:
506
+ animation.cancel()
507
+ await asyncio.gather(animation, return_exceptions=True)
508
+ self.transcript.animation_frame = 0
509
+ self._status = "ready"
510
+ self.application.invalidate()
511
+
512
+ async def _animate_activity(self) -> None:
513
+ """Pulse active rows without coupling animation timing to provider events."""
514
+ while True:
515
+ await asyncio.sleep(0.12)
516
+ self._animation_frame = (self._animation_frame + 1) % 1_000_000
517
+ self.transcript.animation_frame = self._animation_frame
518
+ self.application.invalidate()
519
+
520
+ async def _run_command(self, value: str) -> None:
521
+ command, _, argument = value.partition(" ")
522
+ match command:
523
+ case "/new":
524
+ session_id = self.runtime.new_session()
525
+ self.transcript.append(f"\nStarted session {session_id}.")
526
+ case "/use" if argument.strip():
527
+ self.runtime.use_session(argument)
528
+ self.transcript.append(f"\nUsing session {self.runtime.session_id}.")
529
+ case "/use":
530
+ self.transcript.append("\nUsage: /use <session-id>")
531
+ case "/sessions":
532
+ sessions = await self.runtime.persistence.list_sessions(limit=30)
533
+ if not sessions:
534
+ self.transcript.append("\nNo persisted sessions.")
535
+ for session in sessions:
536
+ marker = "*" if session.session_id == self.runtime.session_id else " "
537
+ self.transcript.append(f"\n{marker} {session.session_id} {session.message_count} messages")
538
+ case "/clear":
539
+ self.transcript.clear()
540
+ case "/help":
541
+ self.transcript.append(
542
+ "\n/new new session\n/sessions list sessions\n/use ID switch session\n"
543
+ "/clear clear display\n/quit exit\n\n"
544
+ "Editing follows common Readline keys: Ctrl-A/E, Ctrl-U/K, Ctrl-W, Alt-B/F, history, and undo.\n"
545
+ "Click Thinking to inspect it; moving the pointer away collapses it. Ctrl-T works without a mouse."
546
+ )
547
+ case "/quit" | "/exit":
548
+ self.application.exit()
549
+ case _:
550
+ self.transcript.append(f"\nUnknown command: {command}. Use /help.")
551
+ self._status = "ready"
552
+ self.application.invalidate()
553
+
554
+ def _click_transcript(self, metadata: object | None) -> bool:
555
+ if not isinstance(metadata, LineMetadata):
556
+ return False
557
+ if metadata.thinking_header and metadata.thinking_id is not None:
558
+ return self.transcript.toggle_thinking(metadata.thinking_id)
559
+ if metadata.tool_header and metadata.tool_id is not None:
560
+ return self.transcript.toggle_tool(metadata.tool_id)
561
+ return False
562
+
563
+ def _hover_transcript(self, metadata: object | None) -> bool:
564
+ thinking_id = metadata.thinking_id if isinstance(metadata, LineMetadata) else None
565
+ return self.transcript.collapse_thinking_except(thinking_id)
566
+
567
+ def _header(self) -> str:
568
+ return f" ◈ zettcode {_compact_path(self.runtime.config.workspace)}"
569
+
570
+ def _model(self) -> str:
571
+ return f"{self.runtime.config.provider.value}/{self.runtime.config.model} "
572
+
573
+ def _status_text(self) -> str:
574
+ icon = _activity_icon(self._animation_frame) if self.busy else "●"
575
+ return f" {icon} {self._status} session {self.runtime.session_id[:8]}"
576
+
577
+ def _selection_copied(self, value: str) -> None:
578
+ self._status = f"copied {len(value)} characters"
579
+
580
+
581
+ def _tool_output(message: ToolMessage) -> str:
582
+ if isinstance(message.content, str):
583
+ return message.content or "Completed"
584
+ return message.text or "Multimodal result"
585
+
586
+
587
+ def _tool_preview(message: ToolMessage, *, limit: int = 180) -> str:
588
+ value = " ".join(_tool_output(message).split())
589
+ return value if len(value) <= limit else value[: limit - 1] + "…"
590
+
591
+
592
+ def _arguments_preview(arguments: Mapping[str, object], *, limit: int = 90) -> str:
593
+ if not arguments:
594
+ return ""
595
+ value = json.dumps(arguments, ensure_ascii=False, separators=(",", ":"))
596
+ value = value if len(value) <= limit else value[: limit - 1] + "…"
597
+ return f" {value}"
598
+
599
+
600
+ def _serialized_output(value: object) -> str:
601
+ if value is None:
602
+ return "Completed"
603
+ if isinstance(value, str):
604
+ return value
605
+ return json.dumps(value, ensure_ascii=False, indent=2, default=str)
606
+
607
+
608
+ def _duration(seconds: float | None) -> str:
609
+ if seconds is None:
610
+ return "done"
611
+ if seconds < 1:
612
+ return f"{round(seconds * 1000)} ms"
613
+ return f"{seconds:.1f} s"
614
+
615
+
616
+ def _activity_icon(frame: int) -> str:
617
+ """Return a compact green-theme activity glyph with a visible flash phase."""
618
+ return ("✦", "✧", "·", "✧")[(frame // 3) % 4]
619
+
620
+
621
+ def _activity_spans(value: str, frame: int, *, background: str | None = None) -> tuple[Span, ...]:
622
+ """Sweep one narrow shimmer over otherwise stable activity text."""
623
+ spans: list[Span] = []
624
+ center = frame % (len(value) + 8) - 3
625
+ for index, character in enumerate(value):
626
+ distance = abs(index - center)
627
+ wave = math.exp(-(distance**2) / 5.5) if distance < 7 else 0.0
628
+ spans.append(
629
+ Span(
630
+ character,
631
+ Style(
632
+ foreground=_blend_color("#617268", "#d2f8dc", wave),
633
+ background=background,
634
+ bold=wave > 0.68,
635
+ ),
636
+ )
637
+ )
638
+ return tuple(spans)
639
+
640
+
641
+ def _blend_color(start: str, end: str, ratio: float) -> str:
642
+ start_rgb = tuple(int(start[index : index + 2], 16) for index in (1, 3, 5))
643
+ end_rgb = tuple(int(end[index : index + 2], 16) for index in (1, 3, 5))
644
+ values = (round(left + (right - left) * ratio) for left, right in zip(start_rgb, end_rgb, strict=True))
645
+ return "#" + "".join(f"{value:02x}" for value in values)
646
+
647
+
648
+ def _limit_stored_output(value: str) -> str:
649
+ """Bound transcript memory while retaining useful context from both ends."""
650
+ if len(value) <= MAX_STORED_TOOL_OUTPUT:
651
+ return value
652
+ half = MAX_STORED_TOOL_OUTPUT // 2
653
+ omitted = len(value) - half * 2
654
+ return f"{value[:half]}\n… {omitted:,} characters omitted from tool output …\n{value[-half:]}"
655
+
656
+
657
+ _ANSI_ESCAPE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))")
658
+
659
+
660
+ def _terminal_safe(value: str) -> str:
661
+ value = _ANSI_ESCAPE.sub("", value).expandtabs(4)
662
+ return "".join(character if character in "\n" or character.isprintable() else "�" for character in value)
663
+
664
+
665
+ def _bounded_visual_lines(value: str, width: int, limit: int) -> tuple[list[str], int]:
666
+ """Wrap safe tool output and return a bounded viewport plus omitted-row count."""
667
+ visual: list[str] = []
668
+ for source in _terminal_safe(value).splitlines() or [""]:
669
+ current = ""
670
+ used = 0
671
+ for character in source:
672
+ character_width = wcwidth(character)
673
+ character_width = character_width if character_width >= 0 else 1
674
+ if current and used + character_width > width:
675
+ visual.append(current)
676
+ current = ""
677
+ used = 0
678
+ current += character
679
+ used += character_width
680
+ visual.append(current)
681
+ omitted = max(0, len(visual) - limit)
682
+ return visual[:limit], omitted
683
+
684
+
685
+ _INLINE_MARKDOWN = re.compile(
686
+ r"(\*\*.+?\*\*|__.+?__|`[^`]+`|\[[^]]+\]\([^)]+\)|(?<!\*)\*[^*]+\*(?!\*)|(?<!_)_[^_]+_(?!_))"
687
+ )
688
+
689
+
690
+ def _markdown_block_lines(value: str, width: int) -> list[TextLine]:
691
+ """Render Markdown blocks, including streaming-safe GFM-style tables."""
692
+ source = value.split("\n")
693
+ result: list[TextLine] = []
694
+ index = 0
695
+ in_code = False
696
+ while index < len(source):
697
+ if not in_code and index + 1 < len(source):
698
+ header = _split_table_row(source[index])
699
+ alignments = _table_alignments(source[index + 1])
700
+ if header is not None and alignments is not None and len(header) == len(alignments):
701
+ rows: list[list[str]] = []
702
+ cursor = index + 2
703
+ while cursor < len(source):
704
+ row = _split_table_row(source[cursor])
705
+ if row is None:
706
+ break
707
+ rows.append((row + [""] * len(header))[: len(header)])
708
+ cursor += 1
709
+ result.extend(_render_markdown_table(header, alignments, rows, width))
710
+ index = cursor
711
+ continue
712
+ rendered, in_code = _markdown_line(source[index], in_code=in_code)
713
+ if rendered is not None:
714
+ result.append(TextLine(tuple(rendered)))
715
+ index += 1
716
+ return result
717
+
718
+
719
+ def _split_table_row(value: str) -> list[str] | None:
720
+ """Split table cells while preserving escaped and inline-code pipes."""
721
+ stripped = value.strip()
722
+ if "|" not in stripped:
723
+ return None
724
+ if stripped.startswith("|"):
725
+ stripped = stripped[1:]
726
+ if stripped.endswith("|") and not stripped.endswith("\\|"):
727
+ stripped = stripped[:-1]
728
+
729
+ cells: list[str] = []
730
+ current: list[str] = []
731
+ escaped = False
732
+ in_code = False
733
+ for character in stripped:
734
+ if escaped:
735
+ current.append(character if character in ("|", "\\") else f"\\{character}")
736
+ escaped = False
737
+ elif character == "\\":
738
+ escaped = True
739
+ elif character == "`":
740
+ in_code = not in_code
741
+ current.append(character)
742
+ elif character == "|" and not in_code:
743
+ cells.append("".join(current).strip())
744
+ current = []
745
+ else:
746
+ current.append(character)
747
+ if escaped:
748
+ current.append("\\")
749
+ cells.append("".join(current).strip())
750
+ return cells if len(cells) >= 2 else None
751
+
752
+
753
+ def _table_alignments(value: str) -> list[str] | None:
754
+ cells = _split_table_row(value)
755
+ if cells is None:
756
+ return None
757
+ alignments: list[str] = []
758
+ for cell in cells:
759
+ marker = cell.replace(" ", "")
760
+ if re.fullmatch(r":-{3,}:", marker):
761
+ alignments.append("center")
762
+ elif re.fullmatch(r"-{3,}:", marker):
763
+ alignments.append("right")
764
+ elif re.fullmatch(r":?-{3,}", marker):
765
+ alignments.append("left")
766
+ else:
767
+ return None
768
+ return alignments
769
+
770
+
771
+ def _render_markdown_table(
772
+ header: list[str], alignments: list[str], rows: list[list[str]], width: int
773
+ ) -> list[TextLine]:
774
+ original_columns = len(header)
775
+ column_count = original_columns
776
+ while column_count > 1 and width - (3 * column_count + 1) < 3 * column_count:
777
+ column_count -= 1
778
+ header = header[:column_count]
779
+ alignments = alignments[:column_count]
780
+ rows = [row[:column_count] for row in rows]
781
+ if column_count < original_columns:
782
+ header[-1] = "…"
783
+ rows = [row[:-1] + ["…"] for row in rows]
784
+
785
+ natural = []
786
+ for column in range(column_count):
787
+ values = [header[column], *(row[column] for row in rows[:MAX_MARKDOWN_TABLE_ROWS])]
788
+ natural.append(max(3, min(32, max(_text_width(_plain_inline(value)) for value in values))))
789
+ budget = max(column_count * 3, width - (3 * column_count + 1))
790
+ column_widths = natural[:]
791
+ while sum(column_widths) > budget:
792
+ largest = max(range(column_count), key=column_widths.__getitem__)
793
+ if column_widths[largest] <= 3:
794
+ break
795
+ column_widths[largest] -= 1
796
+
797
+ border_style = Style(foreground="#587062", dim=True)
798
+ lines = [TextLine((Span(_table_border("╭", "┬", "╮", column_widths), border_style),))]
799
+ lines.append(_table_content_line(header, column_widths, ["center"] * column_count, header=True))
800
+ lines.append(TextLine((Span(_table_border("├", "┼", "┤", column_widths), border_style),)))
801
+ visible_rows = rows[:MAX_MARKDOWN_TABLE_ROWS]
802
+ lines.extend(_table_content_line(row, column_widths, alignments) for row in visible_rows)
803
+ if len(rows) > len(visible_rows):
804
+ omitted = [f"… {len(rows) - len(visible_rows)} rows omitted", *([""] * (column_count - 1))]
805
+ lines.append(_table_content_line(omitted, column_widths, ["left"] * column_count))
806
+ lines.append(TextLine((Span(_table_border("╰", "┴", "╯", column_widths), border_style),)))
807
+ return lines
808
+
809
+
810
+ def _table_border(left: str, junction: str, right: str, widths: list[int]) -> str:
811
+ return left + junction.join("─" * (width + 2) for width in widths) + right
812
+
813
+
814
+ def _table_content_line(
815
+ cells: list[str], widths: list[int], alignments: list[str], *, header: bool = False
816
+ ) -> TextLine:
817
+ border_style = Style(foreground="#587062", dim=True)
818
+ base_style = Style(foreground=FOREGROUND, bold=header)
819
+ spans: list[Span] = [Span("│", border_style)]
820
+ for value, cell_width, alignment in zip(cells, widths, alignments, strict=True):
821
+ content = _clip_inline_spans(value, cell_width, base_style)
822
+ used = sum(_text_width(span.text) for span in content)
823
+ remaining = max(0, cell_width - used)
824
+ left = remaining if alignment == "right" else remaining // 2 if alignment == "center" else 0
825
+ right = remaining - left
826
+ spans.extend((Span(" " * (left + 1), base_style), *content, Span(" " * (right + 1), base_style)))
827
+ spans.append(Span("│", border_style))
828
+ return TextLine(tuple(spans))
829
+
830
+
831
+ def _clip_inline_spans(value: str, width: int, base_style: Style) -> list[Span]:
832
+ value = value.expandtabs(4)
833
+ source = _inline_markdown(value, base_style=base_style)
834
+ needs_ellipsis = _text_width(_plain_inline(value)) > width
835
+ clipped: list[Span] = []
836
+ used = 0
837
+ truncated = False
838
+ for span in source:
839
+ chunk = ""
840
+ for character in span.text:
841
+ character_width = wcwidth(character)
842
+ character_width = character_width if character_width >= 0 else 1
843
+ reserve = 1 if needs_ellipsis else 0
844
+ if used + character_width > width - reserve:
845
+ truncated = True
846
+ break
847
+ chunk += character
848
+ used += character_width
849
+ if chunk:
850
+ clipped.append(Span(chunk, span.style))
851
+ if truncated:
852
+ break
853
+ if truncated:
854
+ clipped.append(Span("…", base_style))
855
+ return clipped
856
+
857
+
858
+ def _plain_inline(value: str) -> str:
859
+ return "".join(span.text for span in _inline_markdown(value))
860
+
861
+
862
+ def _text_width(value: str) -> int:
863
+ return sum(max(0, wcwidth(character)) for character in value)
864
+
865
+
866
+ def _markdown_line(line: str, *, in_code: bool) -> tuple[list[Span] | None, bool]:
867
+ stripped = line.strip()
868
+ if stripped.startswith("```"):
869
+ if in_code:
870
+ return None, False
871
+ language = stripped[3:].strip()
872
+ label = f" {language}" if language else ""
873
+ return ([Span(label, Style(foreground=MUTED, background="#222725"))] if label else None), True
874
+ if in_code:
875
+ return [Span(f" {line}", Style(foreground="#d9dfdb", background="#222725"))], True
876
+
877
+ heading = re.match(r"^(#{1,3})\s+(.+)$", line)
878
+ if heading:
879
+ level = len(heading.group(1))
880
+ return _inline_markdown(
881
+ heading.group(2), base_style=Style(foreground=FOREGROUND, bold=True, dim=level > 1)
882
+ ), False
883
+
884
+ bullet = re.match(r"^(\s*)[-+*]\s+(.+)$", line)
885
+ if bullet:
886
+ prefix = [Span(f"{bullet.group(1)}• ", Style(foreground=GREEN, bold=True))]
887
+ return prefix + _inline_markdown(bullet.group(2)), False
888
+
889
+ quote = re.match(r"^\s*>\s?(.*)$", line)
890
+ if quote:
891
+ return [Span("│ ", STYLE_GREEN)] + _inline_markdown(quote.group(1), base_style=STYLE_DETAIL), False
892
+
893
+ return _inline_markdown(line), False
894
+
895
+
896
+ def _inline_markdown(value: str, *, base_style: Style = STYLE_NORMAL) -> list[Span]:
897
+ fragments: list[Span] = []
898
+ position = 0
899
+ for match in _INLINE_MARKDOWN.finditer(value):
900
+ if match.start() > position:
901
+ fragments.append(Span(value[position : match.start()], base_style))
902
+ token = match.group(0)
903
+ if token.startswith(("**", "__")):
904
+ fragments.append(Span(token[2:-2], replace(base_style, bold=True)))
905
+ elif token.startswith("`"):
906
+ fragments.append(Span(token[1:-1], Style(foreground="#d9dfdb", background="#303633")))
907
+ elif token.startswith("["):
908
+ label, _, target = token[1:].partition("](")
909
+ fragments.append(Span(label, Style(foreground=LIGHT_GREEN)))
910
+ fragments.append(Span(f" <{target[:-1]}>", STYLE_MUTED))
911
+ else:
912
+ fragments.append(Span(token[1:-1], replace(base_style, italic=True)))
913
+ position = match.end()
914
+ if position < len(value) or not fragments:
915
+ fragments.append(Span(value[position:], base_style))
916
+ return fragments
917
+
918
+
919
+ def _compact_path(path: Path, *, limit: int = 38) -> str:
920
+ value = str(path)
921
+ home = str(Path.home())
922
+ if value == home or value.startswith(home + os.sep):
923
+ value = "~" + value[len(home) :]
924
+ return value if len(value) <= limit else "…" + value[-(limit - 1) :]