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.
- zettcode/app/__init__.py +63 -0
- zettcode/app/agent/__init__.py +47 -0
- zettcode/app/agent/agent/__init__.py +84 -0
- zettcode/app/agent/agent/commands.py +269 -0
- zettcode/app/agent/agent/settings.py +132 -0
- zettcode/app/agent/conversation/__init__.py +7 -0
- zettcode/app/agent/conversation/changes.py +64 -0
- zettcode/app/agent/conversation/effort_gauge.py +126 -0
- zettcode/app/agent/conversation/projection.py +265 -0
- zettcode/app/agent/conversation/rendering.py +837 -0
- zettcode/app/agent/conversation/replay.py +198 -0
- zettcode/app/agent/conversation/shimmer.py +256 -0
- zettcode/app/agent/conversation/stops.py +57 -0
- zettcode/app/agent/conversation/tips.py +86 -0
- zettcode/app/agent/conversation/transcript/__init__.py +80 -0
- zettcode/app/agent/conversation/transcript/fold.py +57 -0
- zettcode/app/agent/conversation/transcript/log.py +182 -0
- zettcode/app/agent/conversation/transcript/oneoff.py +55 -0
- zettcode/app/agent/conversation/transcript/stream.py +187 -0
- zettcode/app/agent/conversation/transcript/turn.py +323 -0
- zettcode/app/commandline/__init__.py +47 -0
- zettcode/app/commandline/arguments.py +169 -0
- zettcode/app/commandline/completion.py +262 -0
- zettcode/app/commandline/history.py +305 -0
- zettcode/app/commandline/runner.py +365 -0
- zettcode/app/errors.py +12 -0
- zettcode/app/plugins/__init__.py +110 -0
- zettcode/app/plugins/builtins.py +33 -0
- zettcode/app/plugins/groups.py +503 -0
- zettcode/app/plugins/loader.py +268 -0
- zettcode/app/py.typed +0 -0
- zettcode/app/ui/__init__.py +63 -0
- zettcode/app/ui/app.py +1507 -0
- zettcode/app/ui/bars.py +290 -0
- zettcode/app/ui/boot.py +131 -0
- zettcode/app/ui/clipboard.py +340 -0
- zettcode/app/ui/commands.py +323 -0
- zettcode/app/ui/config_form.py +343 -0
- zettcode/app/ui/demo.py +180 -0
- zettcode/app/ui/followups.py +430 -0
- zettcode/app/ui/gc_policy.py +147 -0
- zettcode/app/ui/keys.py +462 -0
- zettcode/app/ui/messaging.py +260 -0
- zettcode/app/ui/notices.py +195 -0
- zettcode/app/ui/providers.py +626 -0
- zettcode/app/ui/sessions.py +422 -0
- zettcode/app/ui/settings.py +161 -0
- zettcode/app/ui/shell.py +361 -0
- zettcode/app/ui/turns.py +115 -0
- zettcode/app/ui/updating.py +204 -0
- zettcode/app/ui/widgets/__init__.py +161 -0
- zettcode/app/ui/widgets/approval_page.py +284 -0
- zettcode/app/ui/widgets/ask_page.py +386 -0
- zettcode/app/ui/widgets/bash_page.py +90 -0
- zettcode/app/ui/widgets/completer.py +100 -0
- zettcode/app/ui/widgets/composer.py +529 -0
- zettcode/app/ui/widgets/document_page.py +159 -0
- zettcode/app/ui/widgets/effort_page.py +294 -0
- zettcode/app/ui/widgets/follow_up.py +168 -0
- zettcode/app/ui/widgets/form_page.py +380 -0
- zettcode/app/ui/widgets/input_page.py +175 -0
- zettcode/app/ui/widgets/model_page.py +63 -0
- zettcode/app/ui/widgets/model_settings_page.py +653 -0
- zettcode/app/ui/widgets/pet.py +247 -0
- zettcode/app/ui/widgets/pet_page.py +347 -0
- zettcode/app/ui/widgets/plugins_page.py +442 -0
- zettcode/app/ui/widgets/root.py +68 -0
- zettcode/app/ui/widgets/self_compact_page.py +363 -0
- zettcode/app/ui/widgets/send_kind_page.py +193 -0
- zettcode/app/ui/widgets/sessions_page.py +511 -0
- zettcode/app/ui/widgets/statusline_page.py +427 -0
- zettcode/app/ui/widgets/steering.py +126 -0
- zettcode/app/ui/widgets/transcript.py +287 -0
- zettcode/app/ui/widgets/update_page.py +65 -0
- zettcode/app/ui/widgets/welcome.py +24 -0
- zettcode/collector.py +54 -0
- zettcode/update.py +519 -0
- zettcode_app-0.4.2.dist-info/METADATA +26 -0
- zettcode_app-0.4.2.dist-info/RECORD +81 -0
- zettcode_app-0.4.2.dist-info/WHEEL +4 -0
- zettcode_app-0.4.2.dist-info/entry_points.txt +2 -0
zettcode/app/ui/bars.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""The shell's two bars: what the header and the status line draw.
|
|
2
|
+
|
|
3
|
+
The bars are plugin segments, so this part owns only the generic loop and the
|
|
4
|
+
snapshot a segment builder reads. The builtin rows themselves ship as a plugin,
|
|
5
|
+
which is what leaves the shell with one renderer rather than one per row.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
|
|
12
|
+
from zettcode.plugin_api.state import (
|
|
13
|
+
ActivityState,
|
|
14
|
+
DisplayState,
|
|
15
|
+
ModelState,
|
|
16
|
+
SessionState,
|
|
17
|
+
ShellContext,
|
|
18
|
+
UiRegion,
|
|
19
|
+
UiSide,
|
|
20
|
+
)
|
|
21
|
+
from zettcode.presenters.statusline import Row
|
|
22
|
+
from zettcode.tui import HEADER, SEPARATOR, Span, Style, TextLine
|
|
23
|
+
from zettcode.tui.render import blend, display_width
|
|
24
|
+
from zettcode.wording import UNTITLED_SESSION
|
|
25
|
+
|
|
26
|
+
from ..agent.conversation.shimmer import ShimmerFrame
|
|
27
|
+
from .shell import ShellState
|
|
28
|
+
from .turns import plan_rows
|
|
29
|
+
from .widgets.effort_page import heat_colour
|
|
30
|
+
|
|
31
|
+
#: The columns the status line keeps empty between its two sides, and the parts it gives up first, in order, when
|
|
32
|
+
#: the left side does not fit beside the keys.
|
|
33
|
+
STATUS_GAP = 2
|
|
34
|
+
SPARED_FIRST = ("speed", "cache", "tokens")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BarsMixin(ShellState):
|
|
38
|
+
"""Paint each row side from the segments the plugins registered."""
|
|
39
|
+
|
|
40
|
+
def _terminal_title(self) -> str:
|
|
41
|
+
"""Keep the brand marker and app name when the active session changes."""
|
|
42
|
+
return f"{HEADER} zettcode {SEPARATOR} {self._session_title or UNTITLED_SESSION}"
|
|
43
|
+
|
|
44
|
+
def _header_left(self) -> TextLine:
|
|
45
|
+
"""Return the header's left side, whatever the plugins put there.
|
|
46
|
+
|
|
47
|
+
While the show for a switch to the top of the scale plays, the light crossing the header goes over
|
|
48
|
+
this side too, so the whole row glints and not only the corner that changed.
|
|
49
|
+
"""
|
|
50
|
+
line = self._side("header", "left")
|
|
51
|
+
frame = self._effort_gauge()
|
|
52
|
+
return self._glint(line, frame) if isinstance(frame, ShimmerFrame) else line
|
|
53
|
+
|
|
54
|
+
def _glint(self, line: TextLine, frame: ShimmerFrame) -> TextLine:
|
|
55
|
+
"""Lay the light of the show over a line drawn from the left edge, character by character."""
|
|
56
|
+
theme = self.app.theme
|
|
57
|
+
halo = blend(blend(theme.warning, theme.text, 0.18), theme.text, 0.4)
|
|
58
|
+
spans: list[Span] = []
|
|
59
|
+
column = 0
|
|
60
|
+
for span in line.spans:
|
|
61
|
+
for character in span.text:
|
|
62
|
+
glow, core = frame.light(frame.reach - 1 - column)
|
|
63
|
+
colour = span.style.foreground or theme.muted
|
|
64
|
+
if glow > 0.0:
|
|
65
|
+
colour = blend(colour, halo, glow * 0.9 * frame.presence)
|
|
66
|
+
colour = blend(colour, theme.text, core * frame.presence)
|
|
67
|
+
bold = span.style.bold or core * frame.presence > 0.5
|
|
68
|
+
spans.append(Span(character, replace(span.style, foreground=colour, bold=bold)))
|
|
69
|
+
column += max(1, display_width(character))
|
|
70
|
+
return TextLine(tuple(spans), metadata=line.metadata)
|
|
71
|
+
|
|
72
|
+
def _header_right(self) -> TextLine:
|
|
73
|
+
"""Return the header's right side: the plugins' segments, or the gauge.
|
|
74
|
+
|
|
75
|
+
A switch between effort levels owns this row for the second it animates:
|
|
76
|
+
the model and the gauge take the place of the segments a plugin
|
|
77
|
+
registered, which is a trade worth making for one second and the one
|
|
78
|
+
moment a reader changed something here.
|
|
79
|
+
"""
|
|
80
|
+
gauge = self._effort_gauge()
|
|
81
|
+
model = self.model
|
|
82
|
+
if gauge is None or model is None:
|
|
83
|
+
# Nothing to animate the model and the gauge around: a machine with no model has no levels to switch
|
|
84
|
+
# between, so the segments a plugin registered stand.
|
|
85
|
+
return self._side("header", "right")
|
|
86
|
+
if isinstance(gauge, ShimmerFrame):
|
|
87
|
+
return self._shimmer_row(gauge, model.name)
|
|
88
|
+
theme = self.app.theme
|
|
89
|
+
muted = Style(foreground=theme.muted)
|
|
90
|
+
# The rail is the border colour brought toward the background, so an
|
|
91
|
+
# unreached cell reads as a groove rather than as a dim line of text.
|
|
92
|
+
groove = blend(theme.muted, theme.surface, 0.55)
|
|
93
|
+
spans: list[Span] = [
|
|
94
|
+
Span(model.name, muted),
|
|
95
|
+
Span(f" {SEPARATOR} ", muted),
|
|
96
|
+
]
|
|
97
|
+
away = 1.0 - gauge.presence
|
|
98
|
+
# The groove fades with the gauge, so the last thing to leave is not a rail
|
|
99
|
+
# standing alone in an otherwise plain header.
|
|
100
|
+
rail_colour = blend(groove, theme.surface, away)
|
|
101
|
+
rail = Style(foreground=rail_colour)
|
|
102
|
+
for cell in gauge.cells:
|
|
103
|
+
if cell.lit:
|
|
104
|
+
colour = blend(self._heat(cell.heat), theme.text, cell.glow * 0.8)
|
|
105
|
+
# What is missing of the gauge is made up by the groove it sits in,
|
|
106
|
+
# so it fades into the rail rather than into nothing.
|
|
107
|
+
spans.append(Span(cell.char, Style(foreground=blend(colour, rail_colour, away))))
|
|
108
|
+
else:
|
|
109
|
+
spans.append(Span(cell.char, rail))
|
|
110
|
+
word = self._heat(gauge.word_heat)
|
|
111
|
+
spans.append(Span(" ", muted))
|
|
112
|
+
for character, glow in zip(gauge.word, gauge.word_glow, strict=True):
|
|
113
|
+
colour = blend(word, theme.text, glow * 0.9)
|
|
114
|
+
# The word returns to the header's own grey, which is what it is
|
|
115
|
+
# when the gauge is gone: the last frame and the next are the same colour.
|
|
116
|
+
spans.append(Span(character, Style(foreground=blend(colour, theme.muted, away))))
|
|
117
|
+
return TextLine(tuple(spans))
|
|
118
|
+
|
|
119
|
+
def _shimmer_row(self, frame: ShimmerFrame, name: str) -> TextLine:
|
|
120
|
+
"""Paint the show a switch to the top of the scale plays: light crossing the row, right to left.
|
|
121
|
+
|
|
122
|
+
The frame says where the light is and what it has lit; the colours are chosen here, from the theme
|
|
123
|
+
like every other colour in the header. A lit column is gold, and at ``ultra`` the gauge runs on from
|
|
124
|
+
gold into the error colour toward its dear end. The halo is a pale gold and the core inside it takes
|
|
125
|
+
the text colour, which is the brightest the palette has (on a light theme that is the darkest ink,
|
|
126
|
+
so the glint reads as a glint there too). Everything returns to the resting header by what is
|
|
127
|
+
missing of the frame's presence, as the ordinary gauge does.
|
|
128
|
+
"""
|
|
129
|
+
theme = self.app.theme
|
|
130
|
+
muted = theme.muted
|
|
131
|
+
groove = blend(theme.muted, theme.surface, 0.55)
|
|
132
|
+
away = 1.0 - frame.presence
|
|
133
|
+
ultra = frame.word == "ultra"
|
|
134
|
+
gold = blend(theme.warning, theme.text, 0.18)
|
|
135
|
+
halo = blend(gold, theme.text, 0.4)
|
|
136
|
+
last = max(1, frame.cells - 1)
|
|
137
|
+
|
|
138
|
+
def lit(steady: str, column: int, rest: str, *, ignited: bool | None = None) -> str:
|
|
139
|
+
colour = steady if (frame.ignited[column] if ignited is None else ignited) else rest
|
|
140
|
+
colour = blend(colour, halo, frame.glow[column] * 0.9)
|
|
141
|
+
colour = blend(colour, theme.text, frame.core[column])
|
|
142
|
+
if frame.ignited[column]:
|
|
143
|
+
colour = blend(colour, theme.text, frame.glitter[column] * 0.8)
|
|
144
|
+
return blend(colour, rest, away)
|
|
145
|
+
|
|
146
|
+
spans: list[Span] = []
|
|
147
|
+
column = 0
|
|
148
|
+
for character in f"{name} {SEPARATOR} ":
|
|
149
|
+
if column >= frame.lead:
|
|
150
|
+
break
|
|
151
|
+
steady = blend(muted, gold, 0.3)
|
|
152
|
+
spans.append(Span(character, Style(foreground=lit(steady, column, muted))))
|
|
153
|
+
column += max(1, display_width(character))
|
|
154
|
+
for index in range(frame.cells):
|
|
155
|
+
column = frame.lead + index
|
|
156
|
+
if index < frame.lit_cells and frame.ignited[column]:
|
|
157
|
+
steady = heat_colour(theme, index / last)
|
|
158
|
+
steady = blend(steady, gold, 0.45)
|
|
159
|
+
if ultra:
|
|
160
|
+
steady = blend(steady, theme.error, 0.5 * index / last)
|
|
161
|
+
spans.append(Span("\u2501", Style(foreground=lit(steady, column, groove))))
|
|
162
|
+
else:
|
|
163
|
+
spans.append(Span("\u2500", Style(foreground=lit(groove, column, groove, ignited=False))))
|
|
164
|
+
word_colour = blend(heat_colour(theme, frame.heat), gold, 0.5)
|
|
165
|
+
if ultra:
|
|
166
|
+
word_colour = blend(word_colour, theme.error, 0.35)
|
|
167
|
+
word_start = frame.word_start
|
|
168
|
+
spans.append(Span(" ", Style(foreground=muted)))
|
|
169
|
+
spark_left = word_start - 2
|
|
170
|
+
spans.append(Span(frame.sparks[0], Style(foreground=lit(word_colour, spark_left, muted), bold=True)))
|
|
171
|
+
spans.append(Span(" ", Style(foreground=muted)))
|
|
172
|
+
for offset, character in enumerate(frame.word):
|
|
173
|
+
at = word_start + offset
|
|
174
|
+
spans.append(Span(character, Style(foreground=lit(word_colour, at, muted), bold=frame.ignited[at])))
|
|
175
|
+
spans.append(Span(" ", Style(foreground=muted)))
|
|
176
|
+
spark_right = frame.width - 1
|
|
177
|
+
spans.append(Span(frame.sparks[1], Style(foreground=lit(word_colour, spark_right, muted), bold=True)))
|
|
178
|
+
return TextLine(tuple(spans))
|
|
179
|
+
|
|
180
|
+
def _heat(self, heat: float) -> str:
|
|
181
|
+
"""Return the colour of a point on the effort scale, from cheap to dear.
|
|
182
|
+
|
|
183
|
+
The ramp runs through the theme's own accents — green, a brighter green,
|
|
184
|
+
amber, and the error colour — rather than through fixed hues, so the
|
|
185
|
+
gauge follows `/theme` and a light palette gets the darker ends of the
|
|
186
|
+
same story. Position on the scale is the only input: the same level is
|
|
187
|
+
the same colour whether it is being switched to or away from.
|
|
188
|
+
"""
|
|
189
|
+
theme = self.app.theme
|
|
190
|
+
return heat_colour(theme, heat)
|
|
191
|
+
|
|
192
|
+
def _status_left(self) -> TextLine:
|
|
193
|
+
"""Return the status line's left side, whatever the plugins put there.
|
|
194
|
+
|
|
195
|
+
The keys on the right win a narrow row and the left is cut from its end, which is where ``ctx`` stands:
|
|
196
|
+
the figure that says how near the window is to full would be the first thing a narrow terminal lost. So
|
|
197
|
+
when the side does not fit, the counters that matter least are left out whole — the speed, then the
|
|
198
|
+
cache share, then the tokens — and the end of the side is only cut when nothing more can be spared.
|
|
199
|
+
"""
|
|
200
|
+
line = self._side("status", "left")
|
|
201
|
+
room = self.app.width - self._side("status", "right").width - STATUS_GAP
|
|
202
|
+
spared: tuple[str, ...] = ()
|
|
203
|
+
for name in SPARED_FIRST:
|
|
204
|
+
if line.width <= room:
|
|
205
|
+
break
|
|
206
|
+
spared = (*spared, name)
|
|
207
|
+
line = self._side("status", "left", spare=spared)
|
|
208
|
+
return line
|
|
209
|
+
|
|
210
|
+
def _status_right(self) -> TextLine:
|
|
211
|
+
"""Return the status line's right side, whatever the plugins put there."""
|
|
212
|
+
return self._side("status", "right")
|
|
213
|
+
|
|
214
|
+
def _side(self, region: UiRegion, side: UiSide, spare: tuple[str, ...] = ()) -> TextLine:
|
|
215
|
+
"""Paint one side of one row from the segments the plugins registered.
|
|
216
|
+
|
|
217
|
+
The shell owns only this loop: the builtin rows are a plugin like any
|
|
218
|
+
other, and a plugin that overrode one of its slots already sits in its
|
|
219
|
+
place. A segment that returns nothing is skipped, one that raises is
|
|
220
|
+
dropped for that frame only, and one that returns ``(line, True)`` takes
|
|
221
|
+
the side over — the segments painted before it are dropped. A segment may
|
|
222
|
+
also return a :class:`~zettcode.plugin_api.rows.Row`, which is written out without the parts the
|
|
223
|
+
reader switched off in ``/statusline``. ``spare`` names parts to leave out as well, for a row that
|
|
224
|
+
has not the room for them.
|
|
225
|
+
"""
|
|
226
|
+
row = next((row for row in self._plugin_rows if row.region == region), None)
|
|
227
|
+
segments = () if row is None else (row.left if side == "left" else row.right)
|
|
228
|
+
style = Style(foreground=self.app.theme.muted)
|
|
229
|
+
hidden = (*self.agent.runtime.config.statusline_hidden, *spare)
|
|
230
|
+
spans: list[Span] = []
|
|
231
|
+
context: ShellContext | None = None
|
|
232
|
+
for segment in segments:
|
|
233
|
+
if context is None:
|
|
234
|
+
context = self._shell_context()
|
|
235
|
+
try:
|
|
236
|
+
value = segment.builder(context)
|
|
237
|
+
except Exception:
|
|
238
|
+
continue
|
|
239
|
+
overrides = False
|
|
240
|
+
if isinstance(value, tuple):
|
|
241
|
+
value, overrides = value
|
|
242
|
+
if isinstance(value, Row):
|
|
243
|
+
value = value.text(hidden)
|
|
244
|
+
line = value if isinstance(value, TextLine) else TextLine((Span(str(value)),)) if value else None
|
|
245
|
+
if overrides:
|
|
246
|
+
spans.clear()
|
|
247
|
+
if line is None or not line.width:
|
|
248
|
+
continue
|
|
249
|
+
if spans:
|
|
250
|
+
spans.append(Span(f" {SEPARATOR} ", style))
|
|
251
|
+
spans.extend(line.spans)
|
|
252
|
+
return TextLine(tuple(spans))
|
|
253
|
+
|
|
254
|
+
def _shell_context(self) -> ShellContext:
|
|
255
|
+
"""Snapshot what a plugin's segment builder reads, rebuilt for one paint."""
|
|
256
|
+
model = self.model
|
|
257
|
+
return ShellContext(
|
|
258
|
+
config=self.agent.runtime.config,
|
|
259
|
+
session=SessionState(
|
|
260
|
+
id=self.client.session.id,
|
|
261
|
+
title=self._session_title,
|
|
262
|
+
name=self._session_title or UNTITLED_SESSION,
|
|
263
|
+
workspace=self.client.workspace,
|
|
264
|
+
handle=self.messenger.handle if self.messenger is not None else "",
|
|
265
|
+
),
|
|
266
|
+
model=ModelState(
|
|
267
|
+
config=model,
|
|
268
|
+
name="" if model is None else model.name,
|
|
269
|
+
effort=self.client.effort,
|
|
270
|
+
efforts=self.client.efforts,
|
|
271
|
+
),
|
|
272
|
+
activity=ActivityState(
|
|
273
|
+
busy=self._busy,
|
|
274
|
+
status=self._activity,
|
|
275
|
+
note=self._note,
|
|
276
|
+
auto_shell=self.client.permission == "full",
|
|
277
|
+
permission=self.client.permission,
|
|
278
|
+
self_compact=self.client.self_compaction,
|
|
279
|
+
usage=self._usage,
|
|
280
|
+
tasks=plan_rows(self.client.session.tasks()),
|
|
281
|
+
frame=self.transcript.frame,
|
|
282
|
+
),
|
|
283
|
+
display=DisplayState(
|
|
284
|
+
theme=self.app.theme,
|
|
285
|
+
width=self.app.width,
|
|
286
|
+
height=self.app.height,
|
|
287
|
+
screen=self.app.screens.top.name,
|
|
288
|
+
scrolled_up=self.view.scrolled_up,
|
|
289
|
+
),
|
|
290
|
+
)
|
zettcode/app/ui/boot.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The first frame: the composer, in the rows the shell will fill in.
|
|
2
|
+
|
|
3
|
+
The application's own modules cost about 130 ms to import and build — a config
|
|
4
|
+
read, the runtime preview, the plugins, the widget library — and none of it is
|
|
5
|
+
needed to put an input box on screen. This module draws that box from the
|
|
6
|
+
framework alone, so the reader's first keystrokes have somewhere to land, and
|
|
7
|
+
the application replaces the tree as soon as it is built
|
|
8
|
+
(:meth:`~zettcode.tui.TuiApp.set_root`).
|
|
9
|
+
|
|
10
|
+
The rows are the shell's own, in its order and its sizes, and the ones this
|
|
11
|
+
frame has nothing to say yet are left blank rather than filled with a guess:
|
|
12
|
+
``VBox`` reserves them either way, so the swap fills them in without moving a
|
|
13
|
+
single row. The composer is the framework's :class:`~zettcode.tui.TextArea`,
|
|
14
|
+
which the real composer extends, configured the way the shell configures it —
|
|
15
|
+
so the box that is already on screen is the box that stays there.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from collections.abc import Collection
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from zettcode.plugin_api.entries import EntryKind, TextEntry
|
|
24
|
+
from zettcode.plugin_api.presenters import CONTENT_INDENT
|
|
25
|
+
from zettcode.presenters import labels
|
|
26
|
+
from zettcode.presenters.presenters import WelcomePresenter
|
|
27
|
+
from zettcode.tui import PROMPT, Canvas, Constraints, Rule, Size, StatusBar, Text, TextArea, TextLine, VBox, Widget
|
|
28
|
+
from zettcode.tui.layout import Slot
|
|
29
|
+
|
|
30
|
+
from .widgets.welcome import WELCOME
|
|
31
|
+
|
|
32
|
+
#: What the composer shows before anything is typed, matching the real one.
|
|
33
|
+
PLACEHOLDER = "Ask ZettCode to do anything"
|
|
34
|
+
|
|
35
|
+
#: Rows the real composer grows to before it scrolls, matching the shell's.
|
|
36
|
+
COMPOSER_ROWS = 8
|
|
37
|
+
|
|
38
|
+
#: The status word and session name the shell shows before anything runs. The
|
|
39
|
+
#: shell's own text, so the row it draws does not change when it arrives; the
|
|
40
|
+
#: test that compares the two frames cell by cell is what keeps them together.
|
|
41
|
+
IDLE_STATUS = "ready"
|
|
42
|
+
UNTITLED = "New session"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def boot_tree(
|
|
46
|
+
workspace: Path,
|
|
47
|
+
*,
|
|
48
|
+
resuming: bool = False,
|
|
49
|
+
model: str | None = None,
|
|
50
|
+
effort: str = labels.DEFAULT_EFFORT,
|
|
51
|
+
hidden: Collection[str] = (),
|
|
52
|
+
) -> VBox:
|
|
53
|
+
"""Return the frame drawn while the application is still loading.
|
|
54
|
+
|
|
55
|
+
From the top: the header, its rule, the transcript, the blank row the shell
|
|
56
|
+
keeps above the composer, the composer, and the status line. The header
|
|
57
|
+
knows the workspace from the command line, the model from the config file,
|
|
58
|
+
and the composer needs nothing else, so those rows are the shell's own text;
|
|
59
|
+
the transcript and the token counters arrive with the application.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
workspace: Directory the session runs in, as the header will show it.
|
|
63
|
+
resuming: A stored session is about to be loaded, so the transcript will
|
|
64
|
+
hold a conversation rather than a banner; drawing the banner would
|
|
65
|
+
only be taken away again a moment later.
|
|
66
|
+
model: Name to show at the right of the header, read from the config by
|
|
67
|
+
the caller — this frame draws before the settings are loaded. Without
|
|
68
|
+
one the corner says ``no model``, the same row a model-less machine
|
|
69
|
+
draws, rather than an empty segment the shell has to fill in.
|
|
70
|
+
effort: Reasoning effort to show beside it; dropped by
|
|
71
|
+
:func:`labels.header_right` when there is no model to reason with.
|
|
72
|
+
hidden: Names of the header and status-line parts the config file switches off, so the frame is the
|
|
73
|
+
rows the shell will draw and not a row with parts it is about to take away.
|
|
74
|
+
"""
|
|
75
|
+
composer = TextArea(
|
|
76
|
+
prompt=f"{PROMPT} ",
|
|
77
|
+
placeholder=PLACEHOLDER,
|
|
78
|
+
max_height=COMPOSER_ROWS,
|
|
79
|
+
surface=True,
|
|
80
|
+
)
|
|
81
|
+
return VBox(
|
|
82
|
+
[
|
|
83
|
+
Slot(
|
|
84
|
+
StatusBar(
|
|
85
|
+
labels.header_left(workspace, UNTITLED).text(hidden),
|
|
86
|
+
# Drawn even with no model: the first frame says `no model` rather than leaving the segment
|
|
87
|
+
# empty until the swap fills it in, and `header_right` drops the effort by itself when the
|
|
88
|
+
# model is not there.
|
|
89
|
+
labels.header_right(model or "", effort).text(hidden),
|
|
90
|
+
),
|
|
91
|
+
size=1,
|
|
92
|
+
),
|
|
93
|
+
Slot(Rule(), size=1),
|
|
94
|
+
Slot(Text("") if resuming else Welcome(), flex=1),
|
|
95
|
+
Slot(Text(""), size=1),
|
|
96
|
+
Slot(composer, size=lambda width: composer.preferred_height(width)),
|
|
97
|
+
Slot(
|
|
98
|
+
StatusBar(labels.status_left(IDLE_STATUS).text(hidden), labels.status_right().text(hidden)),
|
|
99
|
+
size=1,
|
|
100
|
+
),
|
|
101
|
+
]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Welcome(Widget):
|
|
106
|
+
"""The boot banner, drawn by the presenter the transcript will use.
|
|
107
|
+
|
|
108
|
+
The mark, its colours, the title's weight, and the hint's colour are the
|
|
109
|
+
transcript's decisions, so they are asked for here rather than rebuilt: a
|
|
110
|
+
second rendering would differ in some cell and that difference is what the
|
|
111
|
+
reader sees as the banner flashing when the application replaces this tree.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def _lines(self, width: int) -> list[TextLine]:
|
|
115
|
+
"""Return the rows the banner paints at this width."""
|
|
116
|
+
entry = TextEntry(id=0, kind=EntryKind.WELCOME, text=WELCOME)
|
|
117
|
+
return WelcomePresenter().lines(entry, max(1, width - CONTENT_INDENT), self.theme, 0)
|
|
118
|
+
|
|
119
|
+
def measure(self, constraints: Constraints) -> Size:
|
|
120
|
+
"""Ask for the rows the banner needs at the width it was offered."""
|
|
121
|
+
width = constraints.max_width if constraints.max_width is not None else 1
|
|
122
|
+
lines = self._lines(max(1, width))
|
|
123
|
+
return constraints.constrain(Size(max((line.width for line in lines), default=0), len(lines)))
|
|
124
|
+
|
|
125
|
+
def render(self, canvas: Canvas) -> None:
|
|
126
|
+
"""Paint the banner's rows into the assigned rectangle."""
|
|
127
|
+
if self.rect.empty:
|
|
128
|
+
return
|
|
129
|
+
width = max(1, self.rect.width - CONTENT_INDENT)
|
|
130
|
+
for offset, line in enumerate(self._lines(self.rect.width)[: self.rect.height]):
|
|
131
|
+
canvas.draw_spans(self.rect.x + CONTENT_INDENT, self.rect.y + offset, line.spans, max_width=width)
|