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,343 @@
1
+ """What ``/config add`` means: turn a choice of provider and a few answers into one model entry.
2
+
3
+ The panel asks one question at a time and ``/config add`` takes everything on one line; both end up
4
+ here, so a model added either way is the same model. This module is plain data in and data out: it
5
+ knows nothing about widgets or the file, which is what makes the rules testable on their own.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import shlex
12
+ from collections.abc import Callable, Mapping, Sequence
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING, TypeVar
15
+
16
+ from zettcode.providers import CUSTOM, Api, Provider, check_url, find_provider
17
+
18
+ if TYPE_CHECKING: # pragma: no cover - annotations only
19
+ from zettcode.sdk.types import ModelInfo
20
+
21
+ T = TypeVar("T")
22
+
23
+ USAGE = (
24
+ "/config add <provider> [--model ID] [--token KEY] [--url URL] [--api chat|responses|anthropic] "
25
+ "[--name SHOWN] [--multimodal] [--context TOKENS] [--compact PERCENT] [--temperature T] "
26
+ "[--web-search] [--search-tool TYPE]"
27
+ )
28
+
29
+ #: The smallest context window the panel accepts. A window is counted in tokens, so a small number is nearly always a
30
+ #: window written in thousands (``200``); the file itself would take it, and the model would be compacted at once.
31
+ MIN_CONTEXT_WINDOW = 1_000
32
+
33
+ #: The flags ``/config add`` takes, and whether each one carries a value.
34
+ _FLAGS = {
35
+ "--model": True,
36
+ "--token": True,
37
+ "--url": True,
38
+ "--api": True,
39
+ "--name": True,
40
+ "--multimodal": False,
41
+ "--context": True,
42
+ "--compact": True,
43
+ "--temperature": True,
44
+ "--web-search": False,
45
+ "--search-tool": True,
46
+ }
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class Draft:
51
+ """What is known about a model that is being added, filled in as it is learned.
52
+
53
+ Attributes:
54
+ provider: The described host the model is at, or ``None`` for a custom one.
55
+ api: Which protocol it speaks. Starts as the provider's, and a reader may change it.
56
+ url: The address; ``None`` is the SDK's own default.
57
+ model: The model id sent to the endpoint.
58
+ token: The credential. May stay empty: a local server wants none, and the environment can supply one.
59
+ name: What the picker shows; empty means the model id.
60
+ multimodal: Whether it takes images.
61
+ context_window: Tokens it can carry; ``None`` leaves the default.
62
+ compact_percent: The share of the window at which a request is summarized first; ``None`` leaves the default.
63
+ temperature: The sampling temperature; ``None`` sends none.
64
+ web_search: Whether the provider searches the web for the model.
65
+ search_tool: The tool type that search is sent as; empty takes the default for the API.
66
+ """
67
+
68
+ provider: Provider | None = None
69
+ api: Api = Api.CHAT
70
+ url: str | None = None
71
+ model: str = ""
72
+ token: str = ""
73
+ name: str = ""
74
+ multimodal: bool = False
75
+ context_window: int | None = None
76
+ compact_percent: float | None = None
77
+ temperature: float | None = None
78
+ web_search: bool = False
79
+ search_tool: str = ""
80
+ #: Whether the reader picked chat completions outright (from the list, or ``--api chat``), which is written as
81
+ #: ``chat = true``; a provider whose protocol is chat by default does not ask, and writes nothing.
82
+ chat: bool = False
83
+ #: Which values the reader already gave on the command line, so the panel does not ask again.
84
+ given: set[str] = field(default_factory=set)
85
+ #: The questions answered in the panel, newest last, each with what was typed, so that
86
+ #: Escape can take back the last one and show it again as it was answered.
87
+ trail: list[tuple[str, str]] = field(default_factory=list)
88
+ #: Whether the flow was entered from the panel's provider list, which is where Escape from
89
+ #: the first question goes back to; from a typed line there is nothing behind it.
90
+ from_panel: bool = False
91
+
92
+ def undo(self) -> str | None:
93
+ """Take back the last answer given in the panel; return what it was, or ``None`` if none was.
94
+
95
+ The field goes back to being unanswered, so it is the next thing asked.
96
+ """
97
+ if not self.trail:
98
+ return None
99
+ name, previous = self.trail.pop()
100
+ if name == "api":
101
+ self.api = Api.CHAT
102
+ self.chat = False
103
+ elif name == "url":
104
+ self.url = None
105
+ elif name == "model":
106
+ self.model = ""
107
+ else:
108
+ self.token = ""
109
+ self.given.discard(name)
110
+ return previous
111
+
112
+ @classmethod
113
+ def for_provider(cls, provider: Provider | None) -> Draft:
114
+ """Start a draft with what the provider already says."""
115
+ if provider is None:
116
+ return cls()
117
+ return cls(
118
+ provider=provider,
119
+ api=provider.api,
120
+ url=provider.base_url,
121
+ multimodal=provider.multimodal,
122
+ )
123
+
124
+ @property
125
+ def custom(self) -> bool:
126
+ """Return whether the host is one nobody described, so its address has to be asked."""
127
+ return self.provider is None
128
+
129
+ def missing(self) -> list[str]:
130
+ """Return what still has to be asked, in the order to ask it.
131
+
132
+ The token is asked about, not required: a draft that was not told one (``--token``) is shown to the reader
133
+ with the line empty, and an empty line is a valid answer.
134
+ """
135
+ wanted: list[str] = []
136
+ if self.custom and "api" not in self.given:
137
+ wanted.append("api")
138
+ if (self.custom or self.url is None) and "url" not in self.given and self.api is not Api.ANTHROPIC:
139
+ wanted.append("url")
140
+ if not self.model:
141
+ wanted.append("model")
142
+ if "token" not in self.given:
143
+ wanted.append("token")
144
+ return wanted
145
+
146
+
147
+ def entry_for(draft: Draft) -> dict[str, object]:
148
+ """Return the ``[[models]]`` keys a finished draft stands for.
149
+
150
+ Only the model id is required.
151
+
152
+ Raises:
153
+ ValueError: When the model id is missing, or something given is not usable.
154
+ """
155
+ if not draft.model.strip():
156
+ raise ValueError("A model id is required (--model)")
157
+ entry: dict[str, object] = {"model": draft.model.strip()}
158
+ if draft.token.strip():
159
+ entry["token"] = draft.token.strip()
160
+ if draft.name.strip():
161
+ entry["display_model"] = draft.name.strip()
162
+ if draft.url:
163
+ entry["base_url"] = check_url(draft.url)
164
+ elif draft.custom and draft.api is not Api.ANTHROPIC:
165
+ raise ValueError("A custom provider needs an address (--url)")
166
+ entry.update(draft.api.keys)
167
+ if draft.chat and draft.api is Api.CHAT:
168
+ entry["chat"] = True
169
+ if draft.multimodal:
170
+ entry["multimodal"] = True
171
+ if draft.context_window is not None:
172
+ entry["context_window"] = draft.context_window
173
+ if draft.compact_percent is not None:
174
+ entry["compact_percent"] = whole_if_whole(draft.compact_percent)
175
+ if draft.temperature is not None:
176
+ entry["temperature"] = whole_if_whole(draft.temperature)
177
+ if draft.web_search:
178
+ entry["web_search"] = True
179
+ if draft.search_tool.strip():
180
+ entry["web_search_name"] = draft.search_tool.strip()
181
+ return entry
182
+
183
+
184
+ def parse_add(argument: str, providers: Sequence[Provider]) -> Draft:
185
+ """Read what follows ``/config add`` into a draft, which may still be missing answers.
186
+
187
+ Args:
188
+ argument: The text after ``add``: a provider name (or ``custom``) and optional flags.
189
+ providers: The hosts that can be named.
190
+
191
+ Raises:
192
+ ValueError: With a sentence saying what is wrong and how to write it.
193
+ """
194
+ try:
195
+ words = shlex.split(argument)
196
+ except ValueError as error:
197
+ raise ValueError(f"cannot read that: {error}") from error
198
+ if not words or words[0].startswith("--"):
199
+ names = ", ".join([*(provider.name for provider in providers), CUSTOM])
200
+ raise ValueError(f"Name the provider: one of {names}\nUsage: {USAGE}")
201
+ name, flags = words[0].lower(), _flags(words[1:])
202
+ provider = None if name == CUSTOM else find_provider(name, providers)
203
+ if provider is None and name != CUSTOM:
204
+ known = ", ".join([*(item.name for item in providers), CUSTOM])
205
+ raise ValueError(f"Unknown provider {words[0]!r}; use one of {known}")
206
+ draft = Draft.for_provider(provider)
207
+ if "--api" in flags:
208
+ try:
209
+ draft.api = Api.parse(flags["--api"])
210
+ except ValueError as error:
211
+ raise ValueError(f"--api: {error}") from None
212
+ draft.chat = draft.api is Api.CHAT
213
+ draft.given.add("api")
214
+ if "--url" in flags:
215
+ draft.url = check_url(flags["--url"], what="--url")
216
+ draft.given.add("url")
217
+ if "--model" in flags:
218
+ draft.model = flags["--model"].strip()
219
+ if "--token" in flags:
220
+ draft.token = flags["--token"].strip()
221
+ draft.given.add("token")
222
+ if "--name" in flags:
223
+ draft.name = flags["--name"].strip()
224
+ if "--multimodal" in flags:
225
+ draft.multimodal = True
226
+ if "--web-search" in flags:
227
+ draft.web_search = True
228
+ if "--search-tool" in flags:
229
+ draft.search_tool = flags["--search-tool"].strip()
230
+ draft.web_search = True
231
+ if "--context" in flags:
232
+ draft.context_window = _flag(parse_tokens, flags["--context"], "--context")
233
+ if "--compact" in flags:
234
+ draft.compact_percent = _flag(parse_percent, flags["--compact"], "--compact")
235
+ if "--temperature" in flags:
236
+ draft.temperature = _flag(
237
+ lambda text: parse_temperature(text, draft.api), flags["--temperature"], "--temperature"
238
+ )
239
+ return draft
240
+
241
+
242
+ def _flag(parse: Callable[[str], T], text: str, flag: str) -> T:
243
+ """Read the value of a flag with ``parse``, naming the flag in what is wrong with it."""
244
+ try:
245
+ return parse(text)
246
+ except ValueError as error:
247
+ raise ValueError(f"{flag}: {error}") from None
248
+
249
+
250
+ def parse_tokens(text: str) -> int:
251
+ """Read a size in tokens: ``128000``, ``128,000``, ``128k``, ``1m`` or ``1.5m``.
252
+
253
+ Raises:
254
+ ValueError: With a sentence saying how to write it, when it is not a size or is too small to be a window.
255
+ """
256
+ cleaned = re.sub(r"[\s,_]|tokens?$", "", text.strip().lower())
257
+ found = re.fullmatch(r"(\d+(?:\.\d+)?)([km]?)", cleaned)
258
+ if found is None:
259
+ raise ValueError("write the size in tokens: 128000, 128k or 1m")
260
+ tokens = round(float(found.group(1)) * {"": 1, "k": 1_000, "m": 1_000_000}[found.group(2)])
261
+ if tokens < MIN_CONTEXT_WINDOW:
262
+ raise ValueError(
263
+ f"a context window is counted in tokens and is at least {MIN_CONTEXT_WINDOW:,}; "
264
+ "write 200k for two hundred thousand"
265
+ )
266
+ return tokens
267
+
268
+
269
+ def parse_temperature(text: str, api: Api = Api.CHAT) -> float:
270
+ """Read a sampling temperature: from 0 to 2, or from 0 to 1 for the Anthropic API.
271
+
272
+ Raises:
273
+ ValueError: With a sentence saying what is allowed.
274
+ """
275
+ limit = 1.0 if api is Api.ANTHROPIC else 2.0
276
+ try:
277
+ value = float(text.strip())
278
+ except ValueError:
279
+ raise ValueError(f"write the temperature as a number from 0 to {limit:g}, such as 0.2") from None
280
+ if not 0.0 <= value <= limit:
281
+ raise ValueError(f"the temperature is from 0 to {limit:g}" + (" for the Anthropic API" if limit == 1.0 else ""))
282
+ return value
283
+
284
+
285
+ def parse_percent(text: str) -> float:
286
+ """Read a share of the window: ``80`` or ``80%``, more than 0 and at most 100.
287
+
288
+ Raises:
289
+ ValueError: With a sentence saying what is allowed.
290
+ """
291
+ try:
292
+ percent = float(text.strip().removesuffix("%").strip())
293
+ except ValueError:
294
+ raise ValueError("write a percentage of the window, such as 80") from None
295
+ if not 0 < percent <= 100:
296
+ raise ValueError("the percentage must be more than 0 and at most 100")
297
+ return percent
298
+
299
+
300
+ def whole_if_whole(number: float) -> int | float:
301
+ """Return ``80`` for ``80.0``, so the file reads ``compact_percent = 80`` and not ``80.0``."""
302
+ return int(number) if float(number).is_integer() else number
303
+
304
+
305
+ def tokens_text(tokens: int) -> str:
306
+ """Return a size the way a reader says it: ``128,000 tokens``."""
307
+ return f"{tokens:,} tokens"
308
+
309
+
310
+ def limits_text(model: ModelInfo) -> str:
311
+ """Return what a model's window and compaction setting come to, in one line."""
312
+ percent = whole_if_whole(model.compact_percent)
313
+ return (
314
+ f"{tokens_text(model.context_window)} \u00b7 compressed at {percent}% ({model.compaction_max_tokens:,} tokens)"
315
+ )
316
+
317
+
318
+ def _flags(words: Sequence[str]) -> Mapping[str, str]:
319
+ """Read ``--flag value`` pairs; a flag that takes no value maps to the empty string."""
320
+ found: dict[str, str] = {}
321
+ index = 0
322
+ while index < len(words):
323
+ word = words[index]
324
+ if word not in _FLAGS:
325
+ raise ValueError(f"Unknown option {word!r}\nUsage: {USAGE}")
326
+ if word in found:
327
+ raise ValueError(f"{word} was given twice")
328
+ if _FLAGS[word]:
329
+ if index + 1 >= len(words) or words[index + 1].startswith("--"):
330
+ raise ValueError(f"{word} needs a value\nUsage: {USAGE}")
331
+ found[word] = words[index + 1]
332
+ index += 2
333
+ else:
334
+ found[word] = ""
335
+ index += 1
336
+ return found
337
+
338
+
339
+ def describe(entry: Mapping[str, object]) -> str:
340
+ """Return one line saying what an entry is, with the token never shown."""
341
+ api = Api.ANTHROPIC if entry.get("anthropic") else Api.RESPONSES if entry.get("responses_api") else Api.CHAT
342
+ url = entry.get("base_url") or "default address"
343
+ return f"{entry.get('display_model') or entry['model']} \u00b7 {entry['model']} \u00b7 {api} \u00b7 {url}"
@@ -0,0 +1,180 @@
1
+ """Watch a turn's waiting row sweep: ``make demo-thinking``.
2
+
3
+ The sweep normally belongs to a live request, where it is easy to miss: the
4
+ first token can arrive before the eye reaches the row. This module mounts the
5
+ same machinery in a throwaway application and walks one turn after another —
6
+ wait, then thinking, then a tool call — so the one row that moves can be watched
7
+ beside the rows that stay still.
8
+
9
+ It is a demo, not a second entry point: nothing here is imported by the shell.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ from collections.abc import Callable
16
+
17
+ from zettcode.presenters import pulse as pulse_module
18
+ from zettcode.tui import DARK, ELLIPSIS, SEPARATOR, AnyEvent, Host, Slot, Text, TuiApp, VBox, Widget, run_app
19
+ from zettcode.tui.widgets import Rule
20
+
21
+ from ..agent.conversation.transcript import Transcript
22
+ from .widgets import TranscriptView
23
+
24
+
25
+ def hint() -> str:
26
+ """Return the key hint, including the pace one pass takes."""
27
+ seconds = pulse_module.move_frames() * pulse_module.ANIMATION_SECONDS
28
+ return f" q / Esc quit {SEPARATOR} [ slower {SEPARATOR} ] faster {SEPARATOR} one pass per {seconds:.1f} s"
29
+
30
+
31
+ def frames(seconds: float) -> int:
32
+ """Return how many animation steps a stretch of seconds is.
33
+
34
+ The pace is a period, so the keys and the limits are written in seconds and turned
35
+ into steps here: a step is a hundred-and-twentieth of a second, and "five of them"
36
+ would be a twitch of the dial, not a notch.
37
+ """
38
+ return round(seconds / pulse_module.ANIMATION_SECONDS)
39
+
40
+
41
+ #: How much one keypress changes the pulse by.
42
+ PACE_STEP_SECONDS = 0.5
43
+
44
+ #: The slowest and fastest pulse the keys reach: "too fast to follow" to "almost
45
+ #: stationary".
46
+ PACE_SECONDS = (1.0, 5.0)
47
+
48
+
49
+ def nudge_pace(delta: int) -> None:
50
+ """Change the pulse pace, so the effect can be judged without a rebuild.
51
+
52
+ Args:
53
+ delta: Animation steps to add to one pulse; the pace is kept between
54
+ :data:`PACE_SECONDS`.
55
+ """
56
+ shortest, longest = frames(PACE_SECONDS[0]), frames(PACE_SECONDS[1])
57
+ pulse_module.PULSE_FRAMES = min(longest, max(shortest, pulse_module.PULSE_FRAMES + delta))
58
+
59
+
60
+ class Blinking(Widget):
61
+ """Root that keeps frames coming and moves the transcript's animation on.
62
+
63
+ The real shell does this from its own root while a request runs; the demo
64
+ needs it because no request ever ends here.
65
+ """
66
+
67
+ def __init__(self, transcript: Transcript, body: Widget) -> None:
68
+ """Keep the transcript to advance and the tree to paint."""
69
+ super().__init__()
70
+ self.transcript = transcript
71
+ self.body = body
72
+
73
+ @property
74
+ def children(self) -> tuple[Widget, ...]:
75
+ """Expose the body so it lays out and paints."""
76
+ return (self.body,)
77
+
78
+ def layout(self, rect) -> None:
79
+ """Give the body the full rectangle."""
80
+ super().layout(rect)
81
+ self.body.layout(rect)
82
+
83
+ def render(self, canvas) -> None:
84
+ """Paint the body into the shared canvas."""
85
+ self.body.render(canvas)
86
+
87
+ def cursor(self):
88
+ """Forward the cursor request to the body."""
89
+ return self.body.cursor()
90
+
91
+ def on_tick(self) -> None:
92
+ """Ask for the next frame, then advance the animation.
93
+
94
+ Re-registering the animation token on every tick is what keeps the loop
95
+ alive: the scheduler stops waiting once nothing is animating, and unlike
96
+ a request there is nothing here that would release it later.
97
+ """
98
+ if self.app is not None:
99
+ self.app.scheduler.animate("blink")
100
+ self.transcript.advance_frame()
101
+
102
+
103
+ def _quit(event: AnyEvent, host: Host) -> bool:
104
+ """Leave the demo."""
105
+ host.exit()
106
+ return True
107
+
108
+
109
+ def _pace(delta: int) -> Callable[[AnyEvent, Host], bool]:
110
+ """Return a binding that nudges the sweep period by ``delta`` frames."""
111
+
112
+ def run(event: AnyEvent, host: Host) -> bool:
113
+ nudge_pace(delta)
114
+ host.invalidate()
115
+ return True
116
+
117
+ return run
118
+
119
+
120
+ def build(transcript: Transcript | None = None) -> TuiApp:
121
+ """Build the demo application around an empty transcript."""
122
+ transcript = Transcript() if transcript is None else transcript
123
+ body = VBox(
124
+ [
125
+ Slot(Rule(), size=1),
126
+ Slot(TranscriptView(transcript, theme=DARK), flex=1),
127
+ Slot(Rule(), size=1),
128
+ Slot(Text(hint, muted=True), size=1),
129
+ ]
130
+ )
131
+ app = TuiApp(Blinking(transcript, body), theme=DARK)
132
+ app.commands.add("quit", _quit)
133
+ app.commands.add("slower", _pace(frames(PACE_STEP_SECONDS)))
134
+ app.commands.add("faster", _pace(-frames(PACE_STEP_SECONDS)))
135
+ for key in ("q", "escape", "ctrl_c"):
136
+ app.keymap.bind(key, "quit", priority="capture")
137
+ app.keymap.bind("[", "slower", priority="capture")
138
+ app.keymap.bind("]", "faster", priority="capture")
139
+ return app
140
+
141
+
142
+ async def script(transcript: Transcript) -> None:
143
+ """Walk one turn after another so each running row can be watched.
144
+
145
+ The pauses are deliberately uneven: the wait is the shortest state, because
146
+ a real model usually answers before a thinking block appears.
147
+ """
148
+ while True:
149
+ transcript.begin_turn("演示:请求已发出,等模型开口")
150
+ await asyncio.sleep(2.0)
151
+ transcript.start_thinking()
152
+ for line in (f"先看清问题{ELLIPSIS}", f"再看相关文件{ELLIPSIS}", f"最后给出答案{ELLIPSIS}"):
153
+ transcript.append_thinking(line)
154
+ await asyncio.sleep(1.2)
155
+ transcript.start_tool("call-1", "read_file", {"path": "src/zettcode/app/ui/app.py"})
156
+ await asyncio.sleep(2.0)
157
+ transcript.complete_tool("call-1", "def main() -> None:\n ...")
158
+ transcript.append_answer("演示到此结束,下一轮重新开始。\n")
159
+ await asyncio.sleep(2.0)
160
+ transcript.clear()
161
+
162
+
163
+ async def _main() -> None:
164
+ """Run the application and the scripting task together."""
165
+ transcript = Transcript()
166
+ turn = asyncio.create_task(script(transcript))
167
+ try:
168
+ await run_app(build(transcript))
169
+ finally:
170
+ turn.cancel()
171
+ await asyncio.gather(turn, return_exceptions=True)
172
+
173
+
174
+ def main() -> None:
175
+ """Console entry point for ``make demo-thinking``."""
176
+ asyncio.run(_main())
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()