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/__init__.py +6 -0
- zettcode/cli.py +78 -0
- zettcode/config.py +72 -0
- zettcode/runtime.py +121 -0
- zettcode/tui.py +924 -0
- zettcode/tui_framework/__init__.py +29 -0
- zettcode/tui_framework/application.py +91 -0
- zettcode/tui_framework/components.py +821 -0
- zettcode/tui_framework/events.py +37 -0
- zettcode/tui_framework/input.py +163 -0
- zettcode/tui_framework/screen.py +214 -0
- zettcode/tui_framework/terminal.py +118 -0
- zettcode-0.1.0.dist-info/METADATA +109 -0
- zettcode-0.1.0.dist-info/RECORD +17 -0
- zettcode-0.1.0.dist-info/WHEEL +4 -0
- zettcode-0.1.0.dist-info/entry_points.txt +2 -0
- zettcode-0.1.0.dist-info/licenses/LICENSE +21 -0
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) :]
|