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,80 @@
1
+ """The conversation model: an ordered list of entries, and what the agent does to it.
2
+
3
+ The projector writes into this model and the UI reads it, so it lives on the agent
4
+ side of the application and never imports the shell. The entries are plain data
5
+ (``entries.py``); how one is drawn is its presenter's business (``presenters.py``);
6
+ the scrollable view over the list is ``app.ui.widgets.transcript``.
7
+
8
+ What is left is the list and the verbs on it, which this package splits by what each
9
+ is responsible for, the way the shell splits its own: a shared state class, and a
10
+ mixin for each kind of thing done to it.
11
+
12
+ ============== ==========================================================
13
+ ``log`` :class:`EntryLog` — the list: where an entry goes, how many
14
+ are kept, what changed since the view last looked
15
+ ``turn`` :class:`TurnMixin` — a request's life: the reader's message,
16
+ the pinned wait row, the clock, closing what was left open
17
+ ``stream`` :class:`StreamMixin` — what the model produces as it arrives:
18
+ reasoning, a compaction, the answer, tool calls
19
+ ``oneoff`` :class:`OneOffMixin` — rows written once and left alone:
20
+ a banner, a notice, an error, an announcement, Markdown
21
+ ``fold`` :class:`FoldMixin` — opening and closing reasoning and tools
22
+ ============== ==========================================================
23
+
24
+ A mixin inherits :class:`EntryLog` only to be type-checked against the surface it
25
+ shares with the rest. :class:`Transcript` is the composition, and the one place
26
+ that holds what no single part owns: the clock, the renderers that phrase tool rows,
27
+ and the presenters the view draws with.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from collections.abc import Callable
33
+ from time import monotonic
34
+
35
+ from zettcode.config import DEFAULT_TRANSCRIPT_MAX_ENTRIES
36
+ from zettcode.plugin_api.commands import CommandTranscript
37
+ from zettcode.presenters.presenters import DEFAULT_PRESENTERS, Presenters
38
+
39
+ from ..rendering import DEFAULT_RENDERERS, Renderers
40
+ from ..tips import TipDeck
41
+ from .fold import FoldMixin
42
+ from .log import EntryLog
43
+ from .oneoff import OneOffMixin
44
+ from .stream import StreamMixin
45
+
46
+ __all__ = ["EntryLog", "Transcript"]
47
+
48
+
49
+ # The contract is last on purpose: the mixins above implement its methods and must be found first.
50
+ class Transcript(OneOffMixin, FoldMixin, StreamMixin, EntryLog, CommandTranscript):
51
+ """The conversation: entries in order, and the verbs the agent's events map onto."""
52
+
53
+ def __init__(
54
+ self,
55
+ *,
56
+ clock: Callable[[], float] = monotonic,
57
+ renderers: Renderers = DEFAULT_RENDERERS,
58
+ presenters: Presenters = DEFAULT_PRESENTERS,
59
+ max_entries: int = DEFAULT_TRANSCRIPT_MAX_ENTRIES,
60
+ ) -> None:
61
+ """Start an empty transcript whose elapsed times come from ``clock``.
62
+
63
+ Args:
64
+ clock: Time source for row durations, injectable for tests.
65
+ renderers: Chain that phrases tool rows; tests can pass their own to
66
+ pin the wording a row would show.
67
+ presenters: How each row is drawn; the view reads this so a
68
+ transcript given other presenters is drawn with them.
69
+ max_entries: Conversation entries kept for display; older ones are
70
+ dropped, while the session file keeps the whole tree. Must be
71
+ positive.
72
+ """
73
+ super().__init__(max_entries=max_entries)
74
+ self.clock = clock
75
+ self.renderers = renderers
76
+ self.presenters = presenters
77
+ self.frame = 0
78
+ #: Where the tips under the waiting row come from; ``None`` shows none.
79
+ self.tip_deck: TipDeck | None = None
80
+ self._picture_drawn = None
@@ -0,0 +1,57 @@
1
+ """Opening and closing the rows that can be opened: reasoning and tool output.
2
+
3
+ This is the one thing the reader does to the transcript rather than the agent. It
4
+ changes how a row looks without changing what happened, so it only edits an entry
5
+ in place and tells the log it did.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from zettcode.plugin_api.entries import ThinkingEntry, ToolEntry
11
+
12
+ from .log import EntryLog
13
+
14
+
15
+ class FoldMixin(EntryLog):
16
+ """Expand and collapse the reasoning and tool rows."""
17
+
18
+ def toggle(self, entry_id: int) -> bool:
19
+ """Expand or collapse one collapsible entry and report whether it changed.
20
+
21
+ Args:
22
+ entry_id: Id from the click or key that triggered the toggle; a
23
+ running tool row, or one with no body, refuses to expand.
24
+ """
25
+ entry = self.entry(entry_id)
26
+ if not isinstance(entry, (ThinkingEntry, ToolEntry)):
27
+ return False
28
+ if isinstance(entry, ToolEntry) and not entry.foldable:
29
+ return False
30
+ entry.expanded = not entry.expanded
31
+ self._changed(entry)
32
+ return True
33
+
34
+ def toggle_latest_thinking(self) -> bool:
35
+ """Expand or collapse the newest thinking block."""
36
+ entry = self._last(ThinkingEntry)
37
+ if entry is None:
38
+ return False
39
+ entry.expanded = not entry.expanded
40
+ self._changed(entry)
41
+ return True
42
+
43
+ def collapse_thinking_except(self, entry_id: int | None) -> bool:
44
+ """Collapse every expanded thinking block except the one to keep open.
45
+
46
+ Args:
47
+ entry_id: Block to leave expanded, or ``None`` to collapse them all.
48
+ """
49
+ changed = False
50
+ for index, entry in enumerate(self.entries):
51
+ if isinstance(entry, ThinkingEntry) and entry.expanded and entry.id != entry_id:
52
+ entry.expanded = False
53
+ self._touch(index)
54
+ changed = True
55
+ if changed:
56
+ self.version += 1
57
+ return changed
@@ -0,0 +1,182 @@
1
+ """The ordered list of entries, and the bookkeeping every verb on it shares.
2
+
3
+ Whatever the transcript does to the conversation comes down to four things: put an
4
+ entry in, change one, drop the old ones, and tell the view what moved. This is the
5
+ part that is the same for all of them, and nothing here knows what a tool call or an
6
+ answer is — the verbs that do are the mixins in this package, each of which inherits
7
+ :class:`EntryLog` to be type-checked against the surface it shares with the rest.
8
+
9
+ It holds exactly one rule about order, because the list cannot be kept without it:
10
+ while a request runs, its wait row is the last entry, and everything new lands above.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TypeVar
16
+
17
+ from zettcode.plugin_api.entries import Entry, WaitEntry
18
+
19
+ #: The entry subtype an add/look-up call works with, pinned by the caller.
20
+ E = TypeVar("E", bound=Entry)
21
+
22
+ #: Smallest batch a trim drops, so the list shift and the view rebuild that
23
+ #: follow a trim are paid once per several appends instead of per append. The
24
+ #: real batch is a share of the cap, so a large cap trims off more at a time.
25
+ MIN_TRIM_SLACK = 8
26
+
27
+ #: Share of the cap the list may grow past before a trim: 1/32 of it. Small caps
28
+ #: keep a proportionally small overrun, large caps trim fewer times.
29
+ TRIM_SLACK_SHARE = 32
30
+
31
+
32
+ class EntryLog:
33
+ """An ordered list of entries that knows what changed in it since it was last read.
34
+
35
+ Attributes:
36
+ entries: The conversation, oldest first.
37
+ version: Moves whenever anything in the list changed, which is what a view
38
+ compares to know it has to look again.
39
+ max_entries: How many entries are kept for display.
40
+ """
41
+
42
+ def __init__(self, *, max_entries: int) -> None:
43
+ """Start an empty list that keeps at most ``max_entries`` entries on display.
44
+
45
+ Args:
46
+ max_entries: Conversation entries kept for display; older ones are
47
+ dropped, while the session file keeps the whole tree. Must be
48
+ positive.
49
+ """
50
+ if max_entries < 1:
51
+ raise ValueError("max_entries must be positive")
52
+ self.entries: list[Entry] = []
53
+ self.max_entries = max_entries
54
+ self._trim_threshold = max_entries + max(MIN_TRIM_SLACK, max_entries // TRIM_SLACK_SHARE)
55
+ self.version = 0
56
+ self._next_id = 1
57
+ # Where the current request's own rows begin: the wait row is opened
58
+ # against the content already there, and everything above it until the
59
+ # next request is this request's work. The animation walks this range
60
+ # and no further, so a step's cost follows the request, not the session.
61
+ self._turn_from = 0
62
+ # Earliest entry touched since the view last took the mark, so it can
63
+ # re-measure only the changed suffix instead of the whole transcript.
64
+ self._dirty_from: int | None = None
65
+
66
+ # -- telling the view what moved ------------------------------------------
67
+ def take_dirty(self) -> int | None:
68
+ """Return the earliest changed entry index and reset the mark.
69
+
70
+ A view calls this once per version and rebuilds its line boundaries
71
+ from that index on; everything before it is unchanged and can be kept.
72
+ The mark is consumed rather than merely read so a later append starts
73
+ from the new tail instead of the oldest edit ever made. ``None`` means
74
+ no precise mark is available and the caller should rebuild in full.
75
+ """
76
+ dirty = self._dirty_from
77
+ self._dirty_from = None
78
+ return dirty
79
+
80
+ def _touch(self, index: int) -> None:
81
+ """Record that the entry at ``index`` changed, keeping the earliest one."""
82
+ self._dirty_from = index if self._dirty_from is None else min(self._dirty_from, index)
83
+
84
+ def _touch_entry(self, entry: Entry) -> None:
85
+ """Mark one entry changed, checking the tail before searching the list."""
86
+ last = len(self.entries) - 1
87
+ if last >= 0 and self.entries[last] is entry:
88
+ self._touch(last)
89
+ return
90
+ index = next((index for index, item in enumerate(self.entries) if item is entry), None)
91
+ if index is not None:
92
+ self._touch(index)
93
+
94
+ def _changed(self, entry: Entry) -> None:
95
+ """Say that an entry was edited in place: mark it, and move the version.
96
+
97
+ Every verb that changes a row ends here, so the mark and the version cannot
98
+ be updated one without the other.
99
+ """
100
+ self._touch_entry(entry)
101
+ self.version += 1
102
+
103
+ # -- where an entry goes --------------------------------------------------
104
+ def _open_wait(self) -> WaitEntry | None:
105
+ """Return the wait row while it is open, which is what keeps it pinned.
106
+
107
+ The row is the last entry for as long as the request runs, so "open"
108
+ and "at the tail" are the same thing: anything that lands below it would
109
+ be something the reader is still waiting for.
110
+ """
111
+ last = self.entries[-1] if self.entries else None
112
+ if isinstance(last, WaitEntry) and last.running:
113
+ return last
114
+ return None
115
+
116
+ def _add_index(self) -> int:
117
+ """Return where a new entry belongs: above the pinned wait row, or at the end.
118
+
119
+ The one place the pin is written down as an index, so the add path and
120
+ the answer path cannot disagree about where the row sits.
121
+ """
122
+ return len(self.entries) - 1 if self._open_wait() is not None else len(self.entries)
123
+
124
+ def _add(self, entry: E) -> E:
125
+ """Add a typed entry above the wait row, or append it when none is open.
126
+
127
+ The wait row is pinned to the bottom of the transcript while the request
128
+ runs: reasoning, tool calls, and the answer all land above it, so the one
129
+ line that says work is going on stays where the reader is already
130
+ looking instead of scrolling off with the reply that replaced it.
131
+ """
132
+ self._next_id += 1
133
+ index = self._add_index()
134
+ self._touch(index)
135
+ self.entries.insert(index, entry)
136
+ self._trim()
137
+ self.version += 1
138
+ return entry
139
+
140
+ def _trim(self) -> None:
141
+ """Drop the oldest entries once the display cap is well past.
142
+
143
+ Trimming from the front shifts every remaining index, so the render
144
+ view is told to rebuild its boundaries from zero. That rebuild is cheap
145
+ at an unchanged width — cached blocks only re-key — and it happens once
146
+ per trim batch, so the amortized cost per new entry stays flat. The
147
+ store keeps the full conversation; this bounds what is shown.
148
+ """
149
+ if len(self.entries) <= self._trim_threshold:
150
+ return
151
+ dropped = len(self.entries) - self.max_entries
152
+ del self.entries[:dropped]
153
+ # Every index the transcript is holding on to moves with the list.
154
+ self._turn_from = max(0, self._turn_from - dropped)
155
+ self._dirty_from = 0
156
+
157
+ # -- finding and replacing ------------------------------------------------
158
+ def _last(self, entry_type: type[E]) -> E | None:
159
+ """Return the newest entry of one type, or None when there is none."""
160
+ return next((entry for entry in reversed(self.entries) if isinstance(entry, entry_type)), None)
161
+
162
+ def entry(self, entry_id: int) -> Entry | None:
163
+ """Return the entry with this id, or None when it has been cleared."""
164
+ return next((item for item in self.entries if item.id == entry_id), None)
165
+
166
+ def clear(self) -> None:
167
+ """Drop every entry, for instance when starting a new session."""
168
+ self.entries.clear()
169
+ self._turn_from = 0
170
+ self._dirty_from = 0
171
+ self.version += 1
172
+
173
+ def replace(self, entries: list[Entry]) -> None:
174
+ """Replace visible entries with the chosen session's rebuilt history."""
175
+ self.entries[:] = entries
176
+ self._next_id = max((entry.id for entry in entries), default=0) + 1
177
+ # A rebuilt history is all in the past: no request is open in it, so the
178
+ # animation has no range of its own to walk.
179
+ self._turn_from = len(self.entries)
180
+ self._trim()
181
+ self._dirty_from = 0
182
+ self.version += 1
@@ -0,0 +1,55 @@
1
+ """One-off rows: a banner, a notice, an error, an announcement, a block of Markdown.
2
+
3
+ None of these is part of a request. They are written once, land where the log puts
4
+ them, and never change — so they need nothing from the wait row or the clock, only
5
+ the log's ``_add``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from zettcode.plugin_api.entries import EntryKind, MarkdownEntry, NoticeLevel, TextEntry
11
+
12
+ from .log import EntryLog
13
+
14
+
15
+ class OneOffMixin(EntryLog):
16
+ """Append the rows that say one thing and stay as they are."""
17
+
18
+ def welcome(self, text: str) -> None:
19
+ """Append the banner shown once at startup."""
20
+ self._add(TextEntry(id=self._next_id, kind=EntryKind.WELCOME, text=text))
21
+
22
+ def notice(self, text: str) -> None:
23
+ """Append a muted one-off status line."""
24
+ self._add(TextEntry(id=self._next_id, kind=EntryKind.NOTICE, text=text))
25
+
26
+ def error(self, text: str) -> None:
27
+ """Append a one-off failure line, painted as an error."""
28
+ self._add(TextEntry(id=self._next_id, kind=EntryKind.NOTICE, text=text, level=NoticeLevel.ERROR))
29
+
30
+ def announce(self, text: str) -> None:
31
+ """Announce a change as one centered line between horizontal rules."""
32
+ self._add(TextEntry(id=self._next_id, kind=EntryKind.ANNOUNCEMENT, text=text))
33
+
34
+ def model_changed(self, previous: str, current: str) -> None:
35
+ """Announce a model change in the conversation; an empty previous is the first model there has been."""
36
+ self.announce(f"Model changed from {previous} to {current}." if previous else f"Model set to {current}.")
37
+
38
+ def permission_changed(self, previous: str, current: str) -> None:
39
+ """Announce a shell-permission change the way a model change is shown."""
40
+ self.announce(f"Shell permission changed from {previous} to {current}.")
41
+
42
+ def effort_changed(self, previous: str, current: str) -> None:
43
+ """Announce a reasoning-effort change the way a model change is shown."""
44
+ self.announce(f"Reasoning effort changed from {previous} to {current}.")
45
+
46
+ def markdown(self, text: str) -> None:
47
+ """Append a Markdown block, parsed and rendered like an answer.
48
+
49
+ Use it for command output, which is usually a list or a table; a notice
50
+ is the terse alternative for one-line status such as ``stopped``.
51
+ """
52
+ entry = MarkdownEntry(id=self._next_id, kind=EntryKind.MESSAGE)
53
+ entry.markdown.append(text)
54
+ entry.text = text
55
+ self._add(entry)
@@ -0,0 +1,187 @@
1
+ """What the model produces as it arrives: reasoning, a compaction, an answer, tool calls.
2
+
3
+ Each of these is a row that grows while a request runs, or opens when the agent starts
4
+ something and closes when it reports back. They land above the wait row (the log's
5
+ rule), and a tool's result ends by telling the reader that work continues, which is
6
+ why this builds on :class:`~.turn.TurnMixin` rather than on the log alone.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable, Mapping
12
+
13
+ from zettcode.plugin_api.entries import EntryKind, EntryStatus, MarkdownEntry, ThinkingEntry, ToolEntry
14
+ from zettcode.presenters.text import COMPACTING_LABEL, limit_output
15
+
16
+ from ..rendering import ANSWER, THINKING, Renderers
17
+ from .turn import TurnMixin
18
+
19
+
20
+ class StreamMixin(TurnMixin):
21
+ """Open, grow, and close the rows that carry the agent's output."""
22
+
23
+ #: Chain that phrases tool rows and shapes streamed text; set by :class:`~.Transcript`.
24
+ renderers: Renderers
25
+ clock: Callable[[], float]
26
+
27
+ # -- reasoning ------------------------------------------------------------
28
+ def restore_thinking(self, text: str, duration_ns: int | None) -> None:
29
+ """Append completed reasoning from one stored assistant response."""
30
+ duration = duration_ns / 1_000_000_000 if duration_ns is not None else None
31
+ self._add(
32
+ ThinkingEntry(
33
+ id=self._next_id,
34
+ text=self.renderers.text(THINKING, text, opening=True),
35
+ status=EntryStatus.COMPLETED,
36
+ duration=duration,
37
+ )
38
+ )
39
+
40
+ def start_thinking(self) -> ThinkingEntry:
41
+ """Open a reasoning row, which lands above the wait row below it.
42
+
43
+ The wait row is not turned into this one: it stays where it is, saying
44
+ that a model call is still running, and the reasoning grows above it.
45
+ """
46
+ current = self._last(ThinkingEntry)
47
+ if current is not None and current.running:
48
+ return current
49
+ return self._add(ThinkingEntry(id=self._next_id, started_at=self.clock()))
50
+
51
+ def append_thinking(self, delta: str) -> None:
52
+ """Append a reasoning delta to the running thinking block."""
53
+ entry = self.start_thinking()
54
+ entry.text += self.renderers.text(THINKING, delta, opening=not entry.text)
55
+ self._changed(entry)
56
+
57
+ def complete_thinking(self) -> None:
58
+ """Close the running thinking block and stamp its elapsed time."""
59
+ entry = self._last(ThinkingEntry)
60
+ if entry is None or not entry.running:
61
+ return
62
+ entry.finish(EntryStatus.COMPLETED, self.clock())
63
+ self._changed(entry)
64
+
65
+ # -- compaction -----------------------------------------------------------
66
+ def start_compaction(self) -> ThinkingEntry:
67
+ """Open the row that shows the summarizer working.
68
+
69
+ A compaction is a model call streamed back like any other, so it gets
70
+ the same row as reasoning — marker, and timer included — under its own
71
+ label. The two never borrow each other's row: a compaction that happens
72
+ between reasoning spans keeps its text out of the thinking one.
73
+ """
74
+ current = self._last(ThinkingEntry)
75
+ if current is not None and current.running and current.title == COMPACTING_LABEL:
76
+ return current
77
+ return self._add(
78
+ ThinkingEntry(
79
+ id=self._next_id,
80
+ title=COMPACTING_LABEL,
81
+ started_at=self.clock(),
82
+ )
83
+ )
84
+
85
+ def append_compaction(self, delta: str) -> None:
86
+ """Append one summary fragment to the running compaction row."""
87
+ row = self.start_compaction()
88
+ row.text += delta
89
+ self._changed(row)
90
+
91
+ def complete_compaction(self, text: str = "") -> None:
92
+ """Close the running compaction row and stamp its elapsed time.
93
+
94
+ Args:
95
+ text: What to leave in place of the streamed summary, when the pass
96
+ ended before writing one — a stop, for example. The row is
97
+ expanded so the note is on its face rather than in a fold.
98
+ """
99
+ row = self._last(ThinkingEntry)
100
+ if row is None or row.title != COMPACTING_LABEL or not row.running:
101
+ return
102
+ if text:
103
+ row.text = text
104
+ row.expanded = True
105
+ row.finish(EntryStatus.COMPLETED, self.clock())
106
+ self._changed(row)
107
+
108
+ # -- the answer -----------------------------------------------------------
109
+ def append_answer(self, delta: str) -> None:
110
+ """Stream a delta into the answer block, opening one if needed.
111
+
112
+ The wait row stays open below it: the answer is being written, not
113
+ finished, and the pinned row is how that still reads while it streams.
114
+ """
115
+ # The answer is the entry the reply is writing into: the tail, or the
116
+ # one the pinned wait row sits under.
117
+ index = self._add_index() - 1
118
+ current = self.entries[index] if index >= 0 else None
119
+ if not isinstance(current, MarkdownEntry) or current.kind is not EntryKind.ANSWER:
120
+ current = MarkdownEntry(id=self._next_id, kind=EntryKind.ANSWER)
121
+ self._add(current)
122
+ delta = self.renderers.text(ANSWER, delta, opening=not current.text)
123
+ current.markdown.append(delta)
124
+ current.text += delta
125
+ self._changed(current)
126
+
127
+ # -- tools ----------------------------------------------------------------
128
+ def start_tool(self, call_id: str, name: str, arguments: Mapping[str, object]) -> None:
129
+ """Open a tool row, closing the reasoning span it interrupts.
130
+
131
+ Args:
132
+ call_id: Identifier the matching result will carry.
133
+ name: Tool name; the renderer chain turns it into the row's words.
134
+ arguments: Arguments the chain reads to phrase the call.
135
+ """
136
+ self.complete_thinking()
137
+ # The tool's own handler decides which entry the call is, so what a row carries is its
138
+ # tool's business and this opens every one the same way.
139
+ self._add(self.renderers.open_tool(name, arguments, id=self._next_id, call_id=call_id, started_at=self.clock()))
140
+
141
+ def restate_tool(self, call_id: str, arguments: Mapping[str, object]) -> None:
142
+ """Phrase a row again with arguments that arrived after it opened.
143
+
144
+ A hosted tool's input may stream in fragments after the call started, so the row opens knowing only the tool's
145
+ name. This is where the query it really ran appears, before the row is closed.
146
+
147
+ Args:
148
+ call_id: Identifier the matching result will carry.
149
+ arguments: What the call turned out to be, which the chain phrases.
150
+ """
151
+ entry = next(
152
+ (item for item in reversed(self.entries) if isinstance(item, ToolEntry) and item.call_id == call_id), None
153
+ )
154
+ if entry is None:
155
+ return
156
+ entry.title = self.renderers.describe(entry.tool, arguments).title
157
+ self._changed(entry)
158
+
159
+ def complete_tool(
160
+ self, call_id: str, output: str, *, status: EntryStatus = EntryStatus.COMPLETED, wait: bool = True
161
+ ) -> None:
162
+ """Attach a tool result to its row, ignoring calls that are no longer on screen.
163
+
164
+ Args:
165
+ call_id: Identifier from the matching ``start_tool`` call.
166
+ output: Raw output; bounded before it is stored.
167
+ status: Final status, normally ``completed``, ``failed``, or
168
+ ``skipped``.
169
+ """
170
+ entry = next(
171
+ (item for item in reversed(self.entries) if isinstance(item, ToolEntry) and item.call_id == call_id), None
172
+ )
173
+ if entry is None:
174
+ return
175
+ # An unknown status is refused before the row is touched at all.
176
+ outcome = EntryStatus(status)
177
+ body = self.renderers.body(entry.tool, output)
178
+ entry.text = limit_output(body)
179
+ # The result, unbounded: a count is of what came back, not of the preview the row keeps, and what it says about
180
+ # the body depends on whether the call worked.
181
+ self.renderers.finished(entry, body, outcome)
182
+ entry.finish(outcome, self.clock())
183
+ self._changed(entry)
184
+ # The loop calls the model again once the batch has produced its
185
+ # results, so the reader is told that work continues.
186
+ if wait:
187
+ self.wait_for_model()