zettcode-app 0.4.2__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.
Files changed (81) hide show
  1. zettcode/app/__init__.py +63 -0
  2. zettcode/app/agent/__init__.py +47 -0
  3. zettcode/app/agent/agent/__init__.py +84 -0
  4. zettcode/app/agent/agent/commands.py +269 -0
  5. zettcode/app/agent/agent/settings.py +132 -0
  6. zettcode/app/agent/conversation/__init__.py +7 -0
  7. zettcode/app/agent/conversation/changes.py +64 -0
  8. zettcode/app/agent/conversation/effort_gauge.py +126 -0
  9. zettcode/app/agent/conversation/projection.py +265 -0
  10. zettcode/app/agent/conversation/rendering.py +837 -0
  11. zettcode/app/agent/conversation/replay.py +198 -0
  12. zettcode/app/agent/conversation/shimmer.py +256 -0
  13. zettcode/app/agent/conversation/stops.py +57 -0
  14. zettcode/app/agent/conversation/tips.py +86 -0
  15. zettcode/app/agent/conversation/transcript/__init__.py +80 -0
  16. zettcode/app/agent/conversation/transcript/fold.py +57 -0
  17. zettcode/app/agent/conversation/transcript/log.py +182 -0
  18. zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
  19. zettcode/app/agent/conversation/transcript/stream.py +187 -0
  20. zettcode/app/agent/conversation/transcript/turn.py +323 -0
  21. zettcode/app/commandline/__init__.py +47 -0
  22. zettcode/app/commandline/arguments.py +169 -0
  23. zettcode/app/commandline/completion.py +262 -0
  24. zettcode/app/commandline/history.py +305 -0
  25. zettcode/app/commandline/runner.py +365 -0
  26. zettcode/app/errors.py +12 -0
  27. zettcode/app/plugins/__init__.py +110 -0
  28. zettcode/app/plugins/builtins.py +33 -0
  29. zettcode/app/plugins/groups.py +503 -0
  30. zettcode/app/plugins/loader.py +268 -0
  31. zettcode/app/py.typed +0 -0
  32. zettcode/app/ui/__init__.py +63 -0
  33. zettcode/app/ui/app.py +1507 -0
  34. zettcode/app/ui/bars.py +290 -0
  35. zettcode/app/ui/boot.py +131 -0
  36. zettcode/app/ui/clipboard.py +340 -0
  37. zettcode/app/ui/commands.py +323 -0
  38. zettcode/app/ui/config_form.py +343 -0
  39. zettcode/app/ui/demo.py +180 -0
  40. zettcode/app/ui/followups.py +430 -0
  41. zettcode/app/ui/gc_policy.py +147 -0
  42. zettcode/app/ui/keys.py +462 -0
  43. zettcode/app/ui/messaging.py +260 -0
  44. zettcode/app/ui/notices.py +195 -0
  45. zettcode/app/ui/providers.py +626 -0
  46. zettcode/app/ui/sessions.py +422 -0
  47. zettcode/app/ui/settings.py +161 -0
  48. zettcode/app/ui/shell.py +361 -0
  49. zettcode/app/ui/turns.py +115 -0
  50. zettcode/app/ui/updating.py +204 -0
  51. zettcode/app/ui/widgets/__init__.py +161 -0
  52. zettcode/app/ui/widgets/approval_page.py +284 -0
  53. zettcode/app/ui/widgets/ask_page.py +386 -0
  54. zettcode/app/ui/widgets/bash_page.py +90 -0
  55. zettcode/app/ui/widgets/completer.py +100 -0
  56. zettcode/app/ui/widgets/composer.py +529 -0
  57. zettcode/app/ui/widgets/document_page.py +159 -0
  58. zettcode/app/ui/widgets/effort_page.py +294 -0
  59. zettcode/app/ui/widgets/follow_up.py +168 -0
  60. zettcode/app/ui/widgets/form_page.py +380 -0
  61. zettcode/app/ui/widgets/input_page.py +175 -0
  62. zettcode/app/ui/widgets/model_page.py +63 -0
  63. zettcode/app/ui/widgets/model_settings_page.py +653 -0
  64. zettcode/app/ui/widgets/pet.py +247 -0
  65. zettcode/app/ui/widgets/pet_page.py +347 -0
  66. zettcode/app/ui/widgets/plugins_page.py +442 -0
  67. zettcode/app/ui/widgets/root.py +68 -0
  68. zettcode/app/ui/widgets/self_compact_page.py +363 -0
  69. zettcode/app/ui/widgets/send_kind_page.py +193 -0
  70. zettcode/app/ui/widgets/sessions_page.py +511 -0
  71. zettcode/app/ui/widgets/statusline_page.py +427 -0
  72. zettcode/app/ui/widgets/steering.py +126 -0
  73. zettcode/app/ui/widgets/transcript.py +287 -0
  74. zettcode/app/ui/widgets/update_page.py +65 -0
  75. zettcode/app/ui/widgets/welcome.py +24 -0
  76. zettcode/collector.py +54 -0
  77. zettcode/update.py +519 -0
  78. zettcode_app-0.4.2.dist-info/METADATA +26 -0
  79. zettcode_app-0.4.2.dist-info/RECORD +81 -0
  80. zettcode_app-0.4.2.dist-info/WHEEL +4 -0
  81. zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,837 @@
1
+ """Agent output, on its way into the transcript: a chain of render handlers.
2
+
3
+ Every kind of output the agent produces — a tool call, the reasoning channel,
4
+ the answer — is shaped by the first handler that claims it, so presentation
5
+ rules live in one place and a new one is a new link rather than an ``if`` inside
6
+ the transcript.
7
+
8
+ For tools the chain also decides the words: output should read like a log, not
9
+ like a payload, so a call shows as ``Read src/app.py`` rather than
10
+ ``read_file {"path": "src/app.py"}``. Each handler claims a family of tools —
11
+ reading, editing, deleting, running a command, searching, the plan — and the
12
+ last one claims whatever is left, which keeps an unknown tool at
13
+ ``name key=value`` instead of a JSON blob. A handler may shape the result too:
14
+ :class:`ReadTool` drops the vendor's continuation hint and names the scanner for
15
+ the file, so the row's body is highlighted as the language it is.
16
+
17
+ Text handlers work on the streamed deltas, not on whole messages: a reasoning
18
+ channel arrives in fragments, and so does an answer. That is why
19
+ :meth:`RenderHandler.text` is told whether the fragment opens a message — the
20
+ only place a chat preamble such as ``Assistant:`` can appear.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import difflib
26
+ import json
27
+ import re
28
+ from abc import ABC, abstractmethod
29
+ from collections.abc import Mapping, Sequence
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+
33
+ from zettcode.plugin_api.entries import (
34
+ DeleteEntry,
35
+ EditEntry,
36
+ EntryStatus,
37
+ MessageEntry,
38
+ ReadEntry,
39
+ ToolEntry,
40
+ WriteEntry,
41
+ )
42
+ from zettcode.presenters.text import DECLINED_NOTE, limit_output
43
+ from zettcode.tui import ELLIPSIS, SEPARATOR
44
+ from zettcode.tui.render import GENERIC, language_for
45
+
46
+ #: Output kinds the chain shapes; a link claims one or more of them.
47
+ TOOL = "tool"
48
+ THINKING = "thinking"
49
+ ANSWER = "answer"
50
+
51
+ #: Suffixes that look like a picture, copied from the read tool's accepted set: a row is opened from the arguments,
52
+ #: before the result exists, so the suffix is all it has. The durable answer is a boolean on the finished event, taken
53
+ #: from the result's own content — see :func:`is_picture_path`.
54
+ PICTURE_SUFFIXES = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp"})
55
+
56
+
57
+ def is_picture_path(path: str) -> bool:
58
+ """Return whether ``path`` looks like a picture by its suffix.
59
+
60
+ A guess, and named like one: the read tool decides by the file's signature, so a PNG copied to ``.bin`` is counted
61
+ as text and a text file named ``notes.png`` is not counted. Both are a line about a row nobody is reading closely;
62
+ the exact answer needs the boolean the result will carry (see :data:`PICTURE_SUFFIXES`).
63
+ """
64
+ return Path(path).suffix.lower() in PICTURE_SUFFIXES
65
+
66
+
67
+ #: The hint a bounded read leaves behind; it is advice for the model, not the
68
+ #: reader, and the row already shows the text it applies to.
69
+ CONTINUATION = re.compile(r"\n?\.\.\. \[(?:line truncated|output truncated|more lines)[^\]]*\]\s*\Z")
70
+
71
+ #: Wrapper tags a model may leave around its reasoning channel.
72
+ REASONING_TAGS = re.compile(r"</?(?:think(?:ing)?|reasoning)>|<\|(?:thinking|reasoning)\|>", re.IGNORECASE)
73
+
74
+ #: A chat preamble a locally hosted model sometimes repeats in front of an answer.
75
+ CHAT_PREAMBLE = re.compile(r"^\s*(?:assistant|ai)\s*[:\uff1a]\s*", re.IGNORECASE)
76
+
77
+ #: Escape sequences a model may echo from output it has read.
78
+ ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))")
79
+
80
+
81
+ #: One row of an edit's diff: ``("+", text)`` an addition, ``("-", text)`` a deletion, ``(" ", text)`` a line
82
+ #: kept for context, ``("@", label)`` the break between one edit and the next.
83
+ DiffRow = tuple[str, str]
84
+
85
+ #: Rows kept of one edit call's diff. The arguments are what the model sent and can be a whole file; the
86
+ #: row shows a handful and expands to a screenful, so what is stored only has to be more than that.
87
+ MAX_DIFF_ROWS = 400
88
+
89
+ #: Unchanged lines kept on each side of a change, so an edit reads in place without repeating the file.
90
+ DIFF_CONTEXT = 1
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class ToolRow:
95
+ """How one tool call appears as a transcript row.
96
+
97
+ Attributes:
98
+ title: The row's words, e.g. ``Read src/app.py``.
99
+ language: Scanner for the row's output when it is source code, so a
100
+ ``read_file`` body is highlighted; ``None`` draws it plain.
101
+
102
+ This is only the words. What else a tool's row carries — the lines an edit changes, the change
103
+ itself — belongs to that tool's entry, which its handler builds in :meth:`RenderHandler.open_tool`.
104
+ """
105
+
106
+ title: str
107
+ language: str | None = None
108
+
109
+
110
+ class RenderHandler(ABC):
111
+ """One link of the chain: how one kind of output is shaped before it shows.
112
+
113
+ Attributes:
114
+ kinds: Output kinds this link claims: ``"tool"``, ``"thinking"``, or
115
+ ``"answer"``.
116
+ names: Tool names, for a tool link that claims only one family; an empty
117
+ set means the link claims every tool.
118
+ """
119
+
120
+ kinds: frozenset[str] = frozenset()
121
+ names: frozenset[str] = frozenset()
122
+
123
+ @abstractmethod
124
+ def handles(self, kind: str, name: str = "") -> bool:
125
+ """Return whether this link claims one piece of output."""
126
+
127
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
128
+ """Return the words of a tool row; only a tool link changes this."""
129
+ return ToolRow(name)
130
+
131
+ def open_tool(
132
+ self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
133
+ ) -> ToolEntry:
134
+ """Return the entry for one call as it starts: the class that carries what this tool shows.
135
+
136
+ The default is a plain :class:`ToolEntry` with the words :meth:`describe` gives it. A tool
137
+ whose row shows more has its own entry class and builds it here, so the transcript, which
138
+ opens every row the same way, never has to know which tool it is.
139
+
140
+ Args:
141
+ name: The tool's name.
142
+ arguments: What the model called it with.
143
+ id: The row's identity in the transcript.
144
+ call_id: Identifier the matching result will carry.
145
+ started_at: Clock reading the row opens on.
146
+ """
147
+ row = self.describe(name, arguments)
148
+ return ToolEntry(id=id, call_id=call_id, tool=name, title=row.title, started_at=started_at)
149
+
150
+ def body(self, name: str, output: str) -> str:
151
+ """Return the text under a tool row; only a tool link changes this."""
152
+ return output
153
+
154
+ def finished(self, entry: ToolEntry, text: str, status: EntryStatus) -> None:
155
+ """Adjust a row now that its result is in — a count, a size — and change nothing by default.
156
+
157
+ Args:
158
+ entry: The row, whose body is already set and bounded.
159
+ text: The body as it came back, after :meth:`body` shaped it and before it was bounded.
160
+ status: Whether the call completed, failed or was skipped: a row may only count what worked.
161
+ """
162
+ return None
163
+
164
+ def text(self, text: str, *, opening: bool) -> str:
165
+ """Return one streamed fragment, unchanged unless a link shapes it.
166
+
167
+ A handler sees deltas, never a whole message, so its rule has to hold
168
+ for a fragment. ``opening`` marks the fragment that starts a message,
169
+ which is the only place something like a chat preamble can appear.
170
+ """
171
+ return text
172
+
173
+
174
+ class ToolHandler(RenderHandler):
175
+ """Base for the tool links: each claims one family of tool names."""
176
+
177
+ kinds = frozenset({TOOL})
178
+
179
+ def handles(self, kind: str, name: str = "") -> bool:
180
+ """Claim one tool, or every tool when the link names none."""
181
+ return kind == TOOL and (not self.names or name in self.names)
182
+
183
+
184
+ class Renderers:
185
+ """The chain: the first link that claims a piece of output shapes it.
186
+
187
+ Args:
188
+ handlers: Links in order, most specific first. The last link is expected
189
+ to claim every tool; text no link claims is shown exactly as it
190
+ arrived.
191
+ """
192
+
193
+ def __init__(self, handlers: Sequence[RenderHandler]) -> None:
194
+ self.handlers = tuple(handlers)
195
+
196
+ def _claim(self, kind: str, name: str = "") -> RenderHandler | None:
197
+ """Return the first link that claims one piece of output."""
198
+ return next((handler for handler in self.handlers if handler.handles(kind, name)), None)
199
+
200
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
201
+ """Return the row for one call, asking each link until one claims it."""
202
+ handler = self._claim(TOOL, name)
203
+ return ToolRow(name) if handler is None else handler.describe(name, arguments)
204
+
205
+ def open_tool(
206
+ self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
207
+ ) -> ToolEntry:
208
+ """Return the entry for one call, of the class its claiming link builds."""
209
+ handler = self._claim(TOOL, name)
210
+ if handler is None:
211
+ return ToolEntry(id=id, call_id=call_id, tool=name, title=name, started_at=started_at)
212
+ return handler.open_tool(name, arguments, id=id, call_id=call_id, started_at=started_at)
213
+
214
+ def body(self, name: str, output: str) -> str:
215
+ """Return the body for one call's result, through the claiming link."""
216
+ handler = self._claim(TOOL, name)
217
+ return output if handler is None else handler.body(name, output)
218
+
219
+ def finished(self, entry: ToolEntry, text: str, status: EntryStatus) -> None:
220
+ """Let the claiming link adjust a row once its result is in."""
221
+ handler = self._claim(TOOL, entry.tool)
222
+ if handler is not None:
223
+ handler.finished(entry, text, status)
224
+
225
+ def text(self, kind: str, text: str, *, opening: bool = False) -> str:
226
+ """Return one streamed fragment of ``kind``, shaped by the claiming link."""
227
+ handler = self._claim(kind)
228
+ return text if handler is None else handler.text(text, opening=opening)
229
+
230
+
231
+ def argument(arguments: Mapping[str, object], key: str, default: str = "") -> str:
232
+ """Return one argument as text, or ``default`` when it is missing or empty."""
233
+ value = arguments.get(key)
234
+ if isinstance(value, str) and value.strip():
235
+ return value.strip()
236
+ return default
237
+
238
+
239
+ def edit_changes(arguments: Mapping[str, object]) -> tuple[tuple[str, str], ...]:
240
+ """Return the text pairs an ``edit`` call replaces: one pair now, a list of them from an older transcript.
241
+
242
+ A call carries ``old_string`` and ``new_string``. A session stored before that shape gave the arguments an
243
+ ``edits`` list, and it is read the same way so the row of an old transcript still shows its diff. Anything
244
+ that is not two strings is dropped rather than guessed at.
245
+ """
246
+ legacy = arguments.get("edits")
247
+ if isinstance(legacy, Sequence) and not isinstance(legacy, str):
248
+ return tuple(
249
+ (edit["old_text"], edit["new_text"])
250
+ for edit in legacy
251
+ if isinstance(edit, Mapping)
252
+ and isinstance(edit.get("old_text"), str)
253
+ and isinstance(edit.get("new_text"), str)
254
+ )
255
+ old_string, new_string = arguments.get("old_string"), arguments.get("new_string")
256
+ if isinstance(old_string, str) and isinstance(new_string, str):
257
+ return ((old_string, new_string),)
258
+ return ()
259
+
260
+
261
+ def edited_lines(changes: Sequence[tuple[str, str]]) -> tuple[int, int]:
262
+ """Return the lines an ``edit`` call adds and removes.
263
+
264
+ The tool answers ``Replaced 1 occurrence in app.py``, which says that a change landed but not how large it was:
265
+ a two-line fix and a two-hundred-line rewrite read the same. The call carries both texts, so the difference is
266
+ counted here — with :class:`difflib.SequenceMatcher` rather than by subtracting lengths, because a replacement
267
+ that keeps the lines around it should not read as a rewrite, and that is what a reader comparing two rows is
268
+ looking at.
269
+
270
+ Args:
271
+ changes: The old and new text of each change, as :func:`edit_changes` reads them.
272
+
273
+ Returns:
274
+ ``(added, removed)`` across every change; ``(0, 0)`` when the call carried nothing countable.
275
+
276
+ A change that replaces every occurrence counts once, not once per occurrence: how many the file holds is
277
+ something only the file knows, and the tool's own answer is where that number appears.
278
+ """
279
+ added = removed = 0
280
+ for old_text, new_text in changes:
281
+ matcher = difflib.SequenceMatcher(None, old_text.splitlines(), new_text.splitlines())
282
+ for tag, old_start, old_end, new_start, new_end in matcher.get_opcodes():
283
+ if tag in ("replace", "delete"):
284
+ removed += old_end - old_start
285
+ if tag in ("replace", "insert"):
286
+ added += new_end - new_start
287
+ return (added, removed)
288
+
289
+
290
+ def edit_diff(changes: Sequence[tuple[str, str]], *, limit: int = MAX_DIFF_ROWS) -> tuple[DiffRow, ...]:
291
+ """Return the rows of the change an ``edit`` call makes.
292
+
293
+ Each change carries the text it replaces and the text it puts there, so the change can be shown
294
+ without reading the file: the lines that differ, and one unchanged line around each so it reads in
295
+ place. Changes are told apart by a labelled break when there is more than one — which only an older
296
+ transcript has — and what is kept is cut at ``limit`` rows with a last row saying so, because the
297
+ arguments can be a whole file.
298
+
299
+ Args:
300
+ changes: The old and new text of each change, as :func:`edit_changes` reads them.
301
+ limit: Most rows to keep.
302
+ """
303
+ rows: list[DiffRow] = []
304
+ for number, (old_text, new_text) in enumerate(changes, start=1):
305
+ if len(changes) > 1:
306
+ rows.append(("@", f"edit {number} of {len(changes)}"))
307
+ old_lines, new_lines = old_text.splitlines(), new_text.splitlines()
308
+ opcodes = difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes()
309
+ for index, (tag, old_start, old_end, new_start, new_end) in enumerate(opcodes):
310
+ if tag == "equal":
311
+ kept = old_lines[old_start:old_end]
312
+ first, last = index == 0, index == len(opcodes) - 1
313
+ if len(kept) > DIFF_CONTEXT * (2 - first - last):
314
+ head = [] if first else kept[:DIFF_CONTEXT]
315
+ tail = [] if last else kept[-DIFF_CONTEXT:]
316
+ rows.extend((" ", line) for line in head)
317
+ if not first and not last:
318
+ rows.append(("@", ELLIPSIS))
319
+ rows.extend((" ", line) for line in tail)
320
+ else:
321
+ rows.extend((" ", line) for line in kept)
322
+ continue
323
+ rows.extend(("-", line) for line in old_lines[old_start:old_end])
324
+ rows.extend(("+", line) for line in new_lines[new_start:new_end])
325
+ return _capped(rows, limit)
326
+
327
+
328
+ def _capped(rows: list[DiffRow], limit: int) -> tuple[DiffRow, ...]:
329
+ """Keep ``limit`` rows, and say how many were not kept."""
330
+ if len(rows) > limit:
331
+ rows = [*rows[:limit], ("@", f"{ELLIPSIS} {len(rows) - limit} more rows not kept")]
332
+ return tuple(rows)
333
+
334
+
335
+ def write_diff(content: object, *, limit: int = MAX_DIFF_ROWS) -> tuple[DiffRow, ...]:
336
+ """Return the rows of the change a ``write_file`` call makes: every line of the file, added.
337
+
338
+ A write replaces the file, and what it replaced is not in the call, so nothing can be shown as
339
+ removed and nothing as unchanged; the new content is the change. A file past ``limit`` lines is
340
+ cut there with a last row saying so, and an empty or unreadable ``content`` is no change to show.
341
+
342
+ Args:
343
+ content: The call's ``content`` argument as the model sent it.
344
+ limit: Most rows to keep.
345
+ """
346
+ if not isinstance(content, str):
347
+ return ()
348
+ return _write_rows(content.splitlines(), limit)
349
+
350
+
351
+ def _write_rows(lines: list[str], limit: int) -> tuple[DiffRow, ...]:
352
+ """The kept lines as additions, without building a row for every line of a file that is too long."""
353
+ rows: list[DiffRow] = [("+", line) for line in lines[:limit]]
354
+ if len(lines) > limit:
355
+ rows.append(("@", f"{ELLIPSIS} {len(lines) - limit} more rows not kept"))
356
+ return tuple(rows)
357
+
358
+
359
+ def written_lines(content: object) -> int:
360
+ """Return how many lines a ``write_file`` call writes: what the file will hold, not what it held.
361
+
362
+ The tool overwrites, and what it overwrote is not in the call, so this is the size of the new
363
+ file — which is the number a reader asks of a row that says it created one.
364
+ """
365
+ return len(content.splitlines()) if isinstance(content, str) else 0
366
+
367
+
368
+ def summarise(arguments: Mapping[str, object], *, limit: int = 48) -> str:
369
+ """Return ``key=value`` pairs for a tool no handler claims.
370
+
371
+ The row is for a reader: no braces, no quotes around plain text, and a long
372
+ value is cut rather than wrapped. A structured value becomes its size, which
373
+ is the only part of it a row can say in words.
374
+ """
375
+ parts: list[str] = []
376
+ for key, value in arguments.items():
377
+ collapsed = " ".join(_value_text(value).split())
378
+ parts.append(f"{key}={collapsed if len(collapsed) <= limit else collapsed[: limit - 1] + ELLIPSIS}")
379
+ return " ".join(parts)
380
+
381
+
382
+ def _value_text(value: object) -> str:
383
+ """Return one argument as words, never as JSON syntax."""
384
+ if isinstance(value, str):
385
+ return value
386
+ if value is None or isinstance(value, (bool, int, float)):
387
+ return str(value)
388
+ if isinstance(value, Mapping):
389
+ return f"{len(value)} fields"
390
+ if isinstance(value, Sequence):
391
+ return f"{len(value)} items"
392
+ return str(value)
393
+
394
+
395
+ def file_path(arguments: Mapping[str, object], default: str = "") -> str:
396
+ """Return the path a file tool was called for.
397
+
398
+ ``file_path`` is the argument now; a session stored before the rename carries ``path``, and is still shown the
399
+ file it changed rather than as an unknown one.
400
+ """
401
+ return argument(arguments, "file_path") or argument(arguments, "path") or default
402
+
403
+
404
+ def language_for_path(path: str) -> str | None:
405
+ """Return the scanner for a file's contents, or ``None`` when it is not code."""
406
+ language = language_for(Path(path).suffix.lstrip("."))
407
+ return None if language == GENERIC else language
408
+
409
+
410
+ class ReadTool(ToolHandler):
411
+ """``read_file`` and ``read``: which file, and the scanner for its contents."""
412
+
413
+ names = frozenset({"read_file", "read"})
414
+
415
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
416
+ """Show the file, and highlight the body as the language it is."""
417
+ path = file_path(arguments, "(unknown file)")
418
+ return ToolRow(f"Read {path}", language_for_path(path))
419
+
420
+ def open_tool(
421
+ self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
422
+ ) -> ToolEntry:
423
+ """A read row, which knows the language its body is in, and whether the file is a picture."""
424
+ row = self.describe(name, arguments)
425
+ path = file_path(arguments)
426
+ return ReadEntry(
427
+ id=id,
428
+ call_id=call_id,
429
+ tool=name,
430
+ title=row.title,
431
+ language=row.language,
432
+ picture=is_picture_path(path),
433
+ started_at=started_at,
434
+ )
435
+
436
+ def body(self, name: str, output: str) -> str:
437
+ """Drop the continuation hint; the row bounds the text it applies to."""
438
+ return CONTINUATION.sub("", output).rstrip()
439
+
440
+ def finished(self, entry: ToolEntry, text: str, status: EntryStatus) -> None:
441
+ """Count what the read returned, on the row, if it returned text and the call worked.
442
+
443
+ The count is of what came back — a range asked for is not the same number — and of the whole of it, not of
444
+ the preview the row keeps, so a read past the output limit is counted in full. A picture carries no lines,
445
+ and a failed call's body is the error message rather than the file, so neither is counted.
446
+ """
447
+ if status is not EntryStatus.COMPLETED or not isinstance(entry, ReadEntry) or entry.picture:
448
+ return
449
+ entry.lines = len(text.splitlines())
450
+
451
+
452
+ class WriteTool(ToolHandler):
453
+ """``write_file``: the file being written."""
454
+
455
+ names = frozenset({"write_file"})
456
+
457
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
458
+ """Show the file being written: the row is the call, so it says what the call does, not what it did."""
459
+ return ToolRow(f"Write {file_path(arguments, '(unknown file)')}")
460
+
461
+ def open_tool(
462
+ self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
463
+ ) -> ToolEntry:
464
+ """A write row, which says how many lines the file will hold and shows them as additions."""
465
+ content = arguments.get("content")
466
+ return WriteEntry(
467
+ id=id,
468
+ call_id=call_id,
469
+ tool=name,
470
+ title=self.describe(name, arguments).title,
471
+ path=file_path(arguments),
472
+ added=written_lines(content),
473
+ diff=write_diff(content),
474
+ started_at=started_at,
475
+ )
476
+
477
+
478
+ class EditTool(ToolHandler):
479
+ """``edit``: the file, and the size of the one change the call makes.
480
+
481
+ A session stored before the tool was renamed calls it ``replace_in_file``, and is still shown the same way; one
482
+ stored before a call carried a single change gave it a list under ``edits``, and that list is read too.
483
+ """
484
+
485
+ names = frozenset({"edit", "replace_in_file"})
486
+
487
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
488
+ """Show the file; the size of the change is drawn beside the title, before it lands.
489
+
490
+ `+12 -3` beside the file says whether this is a tweak or a rewrite, which is the question a reader asks
491
+ the row. One change a call is why the title counts nothing: a count of one is noise on every row.
492
+ """
493
+ return ToolRow(f"Edit {file_path(arguments, '(unknown file)')}")
494
+
495
+ def open_tool(
496
+ self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
497
+ ) -> ToolEntry:
498
+ """An edit row, which carries the lines it changes and the change itself.
499
+
500
+ The counts ride outside the title: they are drawn in the colours an addition and a
501
+ deletion get, which a title made of one string cannot be.
502
+ """
503
+ changes = edit_changes(arguments)
504
+ added, removed = edited_lines(changes)
505
+ return EditEntry(
506
+ id=id,
507
+ call_id=call_id,
508
+ tool=name,
509
+ title=self.describe(name, arguments).title,
510
+ path=file_path(arguments),
511
+ added=added,
512
+ removed=removed,
513
+ diff=edit_diff(changes),
514
+ started_at=started_at,
515
+ )
516
+
517
+
518
+ class DeleteTool(ToolHandler):
519
+ """``delete_file``: the file being removed."""
520
+
521
+ names = frozenset({"delete_file"})
522
+
523
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
524
+ """Show the file being removed."""
525
+ return ToolRow(f"Deleted {file_path(arguments, '(unknown file)')}")
526
+
527
+ def open_tool(
528
+ self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
529
+ ) -> ToolEntry:
530
+ """A delete row, which knows which file went."""
531
+ return DeleteEntry(
532
+ id=id,
533
+ call_id=call_id,
534
+ tool=name,
535
+ title=self.describe(name, arguments).title,
536
+ path=file_path(arguments),
537
+ started_at=started_at,
538
+ )
539
+
540
+
541
+ class ShellTool(ToolHandler):
542
+ """``bash``: the command itself, and the directory it runs in when the call names one."""
543
+
544
+ names = frozenset({"bash"})
545
+
546
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
547
+ """Show the command, not a JSON copy of it, and where it runs when that is not the workspace."""
548
+ command = argument(arguments, "command", "(unknown command)")
549
+ where = argument(arguments, "work_dir")
550
+ return ToolRow(f"Bash {command}" + (f" {SEPARATOR} {where}" if where else ""))
551
+
552
+
553
+ class SearchTool(ToolHandler):
554
+ """``glob`` and ``grep``: what was looked for, and where."""
555
+
556
+ names = frozenset({"glob", "grep"})
557
+
558
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
559
+ """Phrase a listing and a content search differently."""
560
+ pattern = argument(arguments, "pattern")
561
+ if name == "glob":
562
+ return ToolRow(f"Listed {pattern}" if pattern else "Listed files")
563
+ where = argument(arguments, "file_pattern") or argument(arguments, "path")
564
+ return ToolRow(f"Searched {pattern or 'the workspace'}" + (f" in {where}" if where else ""))
565
+
566
+
567
+ class WebSearchTool(ToolHandler):
568
+ """``web_search``: a search the endpoint ran, so the row says what was searched for."""
569
+
570
+ names = frozenset({"web_search"})
571
+
572
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
573
+ """Name the query; the call is the endpoint's, so nothing else of it is ours to show."""
574
+ query = argument(arguments, "query")
575
+ return ToolRow(f'Searched the web for "{query}"' if query else "Searched the web")
576
+
577
+
578
+ class AskUserTool(ToolHandler):
579
+ """``ask_user``: the one row that waited for a person, so it is named as a thing, not as a verb.
580
+
581
+ The body is what the person did, not the protocol: the tool answers with the payload the shell sent, and that
582
+ JSON is neither readable nor useful in a transcript.
583
+ """
584
+
585
+ names = frozenset({"ask_user"})
586
+
587
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
588
+ """Show the question that was put, under the name the shell gives that tool."""
589
+ question = argument(arguments, "question")
590
+ return ToolRow(f"Ask-User: {question}" if question else "Ask-User")
591
+
592
+ def body(self, name: str, output: str) -> str:
593
+ """Say what the person did: declined, answered, or answered with a picture.
594
+
595
+ Anything that is not the answer payload is left exactly as it came, so a failure or another tool's result
596
+ is never rewritten by this row.
597
+ """
598
+ payload = _ask_payload(output)
599
+ if payload is None:
600
+ return output
601
+ if payload.get("declined") is True:
602
+ return DECLINED_NOTE
603
+ answer = payload.get("answer")
604
+ if isinstance(answer, str) and answer.strip():
605
+ return f"You answered: {answer.strip()}"
606
+ if isinstance(payload.get("parts"), list) and payload["parts"]:
607
+ return "You answered with a picture."
608
+ return output
609
+
610
+
611
+ def _ask_payload(output: str) -> Mapping[str, object] | None:
612
+ """Return the payload of an ``ask_user`` result, or ``None`` when the text is not one."""
613
+ try:
614
+ result = json.loads(output)
615
+ except ValueError:
616
+ return None
617
+ payload = result.get("payload") if isinstance(result, Mapping) else None
618
+ return payload if isinstance(payload, Mapping) else None
619
+
620
+
621
+ class TodoTool(ToolHandler):
622
+ """``todo_write``: a plan, so the number of steps is the story."""
623
+
624
+ names = frozenset({"todo_write"})
625
+
626
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
627
+ """Say how much of the plan the call carries."""
628
+ todos = arguments.get("todos")
629
+ count = len(todos) if isinstance(todos, Sequence) and not isinstance(todos, str) else 0
630
+ if not count:
631
+ return ToolRow("Updated the plan")
632
+ return ToolRow(f"Updated {count} todo{'' if count == 1 else 's'}")
633
+
634
+
635
+ class SkillTool(ToolHandler):
636
+ """``read_skill``: the skill whose instructions the call loads."""
637
+
638
+ names = frozenset({"read_skill"})
639
+
640
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
641
+ """Show which skill is being read, as the catalog named it."""
642
+ return ToolRow(f"Read skill {argument(arguments, 'name', '(unknown skill)')}")
643
+
644
+
645
+ class TaskTool(ToolHandler):
646
+ """``task``: work handed to a subagent, named by the profile that runs it."""
647
+
648
+ names = frozenset({"task"})
649
+
650
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
651
+ """Say which subagent was chosen, and what it was asked to do."""
652
+ profile = argument(arguments, "subagent_type", "(unknown)")
653
+ summary = argument(arguments, "description")
654
+ title = f"Subagent {profile}"
655
+ return ToolRow(f"{title} {SEPARATOR} {summary}" if summary else title)
656
+
657
+ def body(self, name: str, output: str) -> str:
658
+ """Show the child's report, not the envelope that carried it back."""
659
+ try:
660
+ payload = json.loads(output)
661
+ except (TypeError, ValueError):
662
+ return output
663
+ if isinstance(payload, Mapping) and isinstance(payload.get("content"), str):
664
+ return payload["content"]
665
+ return output
666
+
667
+
668
+ _UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
669
+
670
+
671
+ class MessageTool(ToolHandler):
672
+ """``send_message``: a message to another session, kept whole so the row can show it."""
673
+
674
+ names = frozenset({"send_message"})
675
+
676
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
677
+ """Say who it is for: a group as ``#name`` (everyone in it reads it), a session by name or the end of its id."""
678
+ group = argument(arguments, "group")
679
+ if group:
680
+ return ToolRow(f"Message to group #{group.lstrip('#')}")
681
+ return ToolRow(f"Message to {self._recipient(argument(arguments, 'to', '(unknown)'))}")
682
+
683
+ @staticmethod
684
+ def _recipient(to: str) -> str:
685
+ """Return the recipient as a reader can use it: a name as it is, an id by its last characters."""
686
+ return f"session {to[-6:]}" if _UUID.fullmatch(to) else to
687
+
688
+ def open_tool(
689
+ self, name: str, arguments: Mapping[str, object], *, id: int, call_id: str, started_at: float
690
+ ) -> ToolEntry:
691
+ """A message row, which holds the text that was sent."""
692
+ message = arguments.get("message")
693
+ return MessageEntry(
694
+ id=id,
695
+ call_id=call_id,
696
+ tool=name,
697
+ title=self.describe(name, arguments).title,
698
+ to=argument(arguments, "to"),
699
+ group=argument(arguments, "group").lstrip("#"),
700
+ message=limit_output(message) if isinstance(message, str) else "",
701
+ started_at=started_at,
702
+ )
703
+
704
+
705
+ class HistoryTool(ToolHandler):
706
+ """``message_history``: what was said before, with one session or in a group."""
707
+
708
+ names = frozenset({"message_history"})
709
+
710
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
711
+ """Say whose history it reads: a group's as ``#name``, one session's by name or the end of its id."""
712
+ group = argument(arguments, "group")
713
+ if group:
714
+ return ToolRow(f"Read history of group #{group.lstrip('#')}")
715
+ session = argument(arguments, "session")
716
+ if session:
717
+ return ToolRow(f"Read history with {MessageTool._recipient(session)}")
718
+ return ToolRow("Read message history")
719
+
720
+
721
+ class GroupTool(ToolHandler):
722
+ """``manage_group``: make or change a group chat."""
723
+
724
+ names = frozenset({"manage_group"})
725
+
726
+ _VERBS = {"create": "Create", "add": "Add to", "remove": "Remove from", "leave": "Leave", "delete": "Delete"}
727
+
728
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
729
+ """Say what is done to which group, and with whom."""
730
+ verb = self._VERBS.get(argument(arguments, "action"), "Change")
731
+ members = argument(arguments, "members")
732
+ with_ = f" with {members}" if members and verb in ("Create", "Add to", "Remove from") else ""
733
+ return ToolRow(f"{verb} group #{argument(arguments, 'group').lstrip('#') or '(unknown)'}{with_}")
734
+
735
+
736
+ class NamespacedTool(ToolHandler):
737
+ """A tool whose name carries where it came from: ``server__tool``, so the row names the server.
738
+
739
+ The server is part of what the call was: the same ``search`` in two servers is two different tools, with
740
+ different reach and different answers, and a row that drops the server makes them look like one.
741
+
742
+ The name is all a renderer knows, so the row says what the name says — ``github/create_issue`` — and not that
743
+ the tool speaks MCP: a plugin registers a namespaced name of its own with the same shape, and the runtime does
744
+ not mark its remote tools here. A name without both parts is shown as it arrived.
745
+ """
746
+
747
+ def handles(self, kind: str, name: str = "") -> bool:
748
+ """Claim a namespaced name that no earlier link claimed."""
749
+ return kind == TOOL and "__" in name.strip("_") and not name.startswith("__")
750
+
751
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
752
+ """Show ``server/tool``, so the row names where the capability came from.
753
+
754
+ The runtime names a remote tool ``<server>__<tool>``; a name that prefixed the family with ``mcp`` —
755
+ ``mcp__github__create_issue`` — is read the same way, and one without both parts keeps its own name.
756
+ """
757
+ remote = name[5:] if name.startswith("mcp__") else name
758
+ server, _, tool = remote.partition("__")
759
+ label = f"{server}/{tool}" if server and tool else name
760
+ return ToolRow(f"{label} {summarise(arguments)}".rstrip())
761
+
762
+
763
+ class FallbackTool(ToolHandler):
764
+ """Anything no other link claims: its name and flat arguments, never JSON."""
765
+
766
+ def handles(self, kind: str, name: str = "") -> bool:
767
+ """Claim every call the earlier links passed over."""
768
+ return kind == TOOL
769
+
770
+ def describe(self, name: str, arguments: Mapping[str, object]) -> ToolRow:
771
+ """Show ``name key=value`` so an unknown tool still reads as a sentence."""
772
+ return ToolRow(f"{name} {summarise(arguments)}".rstrip())
773
+
774
+
775
+ class ReasoningHandler(RenderHandler):
776
+ """``thinking``: the reasoning channel, without the wrappers providers add.
777
+
778
+ A model may open and close its reasoning with tags of its own, or echo an
779
+ escape sequence it read in a tool result; neither belongs in the transcript,
780
+ and both are removed per fragment so a half-streamed tag never shows.
781
+ """
782
+
783
+ kinds = frozenset({THINKING})
784
+
785
+ def handles(self, kind: str, name: str = "") -> bool:
786
+ """Claim the reasoning channel."""
787
+ return kind == THINKING
788
+
789
+ def text(self, text: str, *, opening: bool) -> str:
790
+ """Strip provider tags and escapes from one reasoning fragment."""
791
+ return ANSI.sub("", REASONING_TAGS.sub("", text))
792
+
793
+
794
+ class AnswerHandler(RenderHandler):
795
+ """``answer``: the assistant's own text, without a chat preamble.
796
+
797
+ A locally hosted model sometimes repeats ``Assistant:`` before answering;
798
+ the transcript is already the assistant, so it is dropped — but only in the
799
+ fragment that opens the message, because that is the only place a preamble
800
+ can be, and a later fragment may legitimately start with the same word.
801
+ """
802
+
803
+ kinds = frozenset({ANSWER})
804
+
805
+ def handles(self, kind: str, name: str = "") -> bool:
806
+ """Claim the answer channel."""
807
+ return kind == ANSWER
808
+
809
+ def text(self, text: str, *, opening: bool) -> str:
810
+ """Strip escapes always, and a chat preamble when the message opens."""
811
+ cleaned = ANSI.sub("", text)
812
+ return CHAT_PREAMBLE.sub("", cleaned, count=1) if opening else cleaned
813
+
814
+
815
+ #: The chain the application renders with, most specific handler first.
816
+ DEFAULT_RENDERERS = Renderers(
817
+ (
818
+ ReadTool(),
819
+ WriteTool(),
820
+ EditTool(),
821
+ DeleteTool(),
822
+ ShellTool(),
823
+ SearchTool(),
824
+ WebSearchTool(),
825
+ AskUserTool(),
826
+ TodoTool(),
827
+ SkillTool(),
828
+ TaskTool(),
829
+ MessageTool(),
830
+ HistoryTool(),
831
+ GroupTool(),
832
+ NamespacedTool(),
833
+ FallbackTool(),
834
+ ReasoningHandler(),
835
+ AnswerHandler(),
836
+ )
837
+ )