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
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""The bottom panel a question from the model appears in."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from zettcode.sdk.events import QuestionAsked
|
|
8
|
+
from zettcode.tui import (
|
|
9
|
+
DONE,
|
|
10
|
+
ELLIPSIS,
|
|
11
|
+
MARKER,
|
|
12
|
+
PROMPT,
|
|
13
|
+
SEPARATOR,
|
|
14
|
+
AnyEvent,
|
|
15
|
+
Canvas,
|
|
16
|
+
Host,
|
|
17
|
+
KeyEvent,
|
|
18
|
+
RichText,
|
|
19
|
+
Span,
|
|
20
|
+
Style,
|
|
21
|
+
Text,
|
|
22
|
+
TextArea,
|
|
23
|
+
TextEvent,
|
|
24
|
+
TextLine,
|
|
25
|
+
VBox,
|
|
26
|
+
Widget,
|
|
27
|
+
event_key,
|
|
28
|
+
)
|
|
29
|
+
from zettcode.tui.core.geometry import Constraints
|
|
30
|
+
from zettcode.tui.layout import Slot
|
|
31
|
+
|
|
32
|
+
#: Options drawn at once; a longer list scrolls around the highlight, because
|
|
33
|
+
#: the model may offer twenty and the panel cannot grow to fit them all.
|
|
34
|
+
VISIBLE_OPTIONS = 6
|
|
35
|
+
|
|
36
|
+
#: What the row that finishes a multiple-choice answer looks like.
|
|
37
|
+
SEND = "\u27a4 send"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _rows(widget: Widget) -> Callable[[int], int]:
|
|
41
|
+
"""Return the height one child asks for at the width it is given."""
|
|
42
|
+
return lambda width: widget.measure(Constraints(max_width=width)).height
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AskUserPage(VBox):
|
|
46
|
+
"""One question, its choices, and the line an answer can be typed into.
|
|
47
|
+
|
|
48
|
+
Shape::
|
|
49
|
+
|
|
50
|
+
Which format should I write the summary in?
|
|
51
|
+
\u25b8 \u2713 1. Markdown <- checked: this is the answer
|
|
52
|
+
2. Plain text
|
|
53
|
+
\u203a up/down move \u00b7 enter choose \u00b7 esc cancel
|
|
54
|
+
|
|
55
|
+
A single-choice question sends the highlighted row on ``Enter``, the way every
|
|
56
|
+
other picker in the shell works. A multiple-choice one checks and unchecks
|
|
57
|
+
rows instead, and grows a ``send`` row to finish with::
|
|
58
|
+
|
|
59
|
+
\u25b8 \u2713 1. Summary
|
|
60
|
+
\u2713 2. Diff
|
|
61
|
+
\u27a4 send
|
|
62
|
+
|
|
63
|
+
Under the choices sits the answer line, with no label of its own: it shows the
|
|
64
|
+
keys until something is typed, and what is typed from then on. So a reader can
|
|
65
|
+
pick a choice or answer in their own words — and a multiple-choice answer is
|
|
66
|
+
its ticks *plus* whatever was typed beside them.
|
|
67
|
+
|
|
68
|
+
Typing takes the panel over: the choices fold away, and the line wears the
|
|
69
|
+
marker and the band the highlighted row had, because the cursor is what is
|
|
70
|
+
being written then. ``Up``/``Down`` give the choices back with the text still
|
|
71
|
+
in the line, and on a one-answer question no row is marked once that line
|
|
72
|
+
holds words of its own — the answer is not any of the rows any more.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
question: What the model asked, with its options.
|
|
76
|
+
on_answer: Receives the trimmed answer when the reader sends it.
|
|
77
|
+
on_cancel: Called when the reader presses Escape; the shell tells the
|
|
78
|
+
model the question was declined.
|
|
79
|
+
position: Reads this question's place among the ones waiting, as
|
|
80
|
+
``(index, total)`` at paint time; shown when the model asked several
|
|
81
|
+
in one response.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
question: QuestionAsked,
|
|
87
|
+
*,
|
|
88
|
+
on_answer: Callable[[str], None],
|
|
89
|
+
on_cancel: Callable[[], None],
|
|
90
|
+
position: Callable[[], tuple[int, int]] | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Build the panel, with the answer line focused and the first row highlighted."""
|
|
93
|
+
self.question = question
|
|
94
|
+
self.on_answer = on_answer
|
|
95
|
+
self.on_cancel = on_cancel
|
|
96
|
+
self.position = position
|
|
97
|
+
self.selected = 0
|
|
98
|
+
self.note = ""
|
|
99
|
+
self.chosen: list[str] = []
|
|
100
|
+
self._typing = False
|
|
101
|
+
self.answer = TextArea(
|
|
102
|
+
prompt=f"{PROMPT} ",
|
|
103
|
+
placeholder=self._hint_line(),
|
|
104
|
+
max_height=3,
|
|
105
|
+
on_submit=lambda _text: self._submit(),
|
|
106
|
+
)
|
|
107
|
+
self._heading = RichText(self._question_lines)
|
|
108
|
+
self._choices = RichText(self._option_lines)
|
|
109
|
+
self._finish = Text(self._send_line, muted=True)
|
|
110
|
+
slots = [
|
|
111
|
+
Slot(self._heading, size=_rows(self._heading)),
|
|
112
|
+
Slot(self._choices, size=_rows(self._choices)),
|
|
113
|
+
]
|
|
114
|
+
if self._needs_send_row:
|
|
115
|
+
slots.append(Slot(self._finish, size=1))
|
|
116
|
+
slots.append(Slot(self.answer, size=lambda width: self.answer.preferred_height(width)))
|
|
117
|
+
super().__init__(slots)
|
|
118
|
+
|
|
119
|
+
# -- geometry -----------------------------------------------------------
|
|
120
|
+
@property
|
|
121
|
+
def _needs_send_row(self) -> bool:
|
|
122
|
+
"""Return whether the choices need a row to finish on.
|
|
123
|
+
|
|
124
|
+
A single-choice question is finished by choosing; a multiple-choice one
|
|
125
|
+
collects ticks, so it needs somewhere to say "that is all of them".
|
|
126
|
+
"""
|
|
127
|
+
return self.question.allow_multiple and bool(self.question.options)
|
|
128
|
+
|
|
129
|
+
def preferred_height(self, width: int) -> int:
|
|
130
|
+
"""Return how many rows this question needs, so the panel fits it."""
|
|
131
|
+
return self.measure(Constraints(max_width=width)).height
|
|
132
|
+
|
|
133
|
+
# -- painting -----------------------------------------------------------
|
|
134
|
+
def _question_lines(self) -> tuple[TextLine, ...]:
|
|
135
|
+
"""Return the heading: the question, which question this is, and any note.
|
|
136
|
+
|
|
137
|
+
The note lives here rather than under the answer line because it has to
|
|
138
|
+
be readable while that line holds what the reader typed — which is
|
|
139
|
+
exactly when a note like "one answer only" matters.
|
|
140
|
+
"""
|
|
141
|
+
theme = self.theme
|
|
142
|
+
heading = self.question.question
|
|
143
|
+
if self.position is not None:
|
|
144
|
+
index, total = self.position()
|
|
145
|
+
if total > 1:
|
|
146
|
+
heading = f"({index} of {total}) {heading}"
|
|
147
|
+
spans = [Span(heading, Style(foreground=theme.text, bold=True))]
|
|
148
|
+
tail = self.note
|
|
149
|
+
if tail:
|
|
150
|
+
tone = theme.warning if self.note else theme.muted
|
|
151
|
+
spans.append(Span(f" {SEPARATOR} {tail}", Style(foreground=tone)))
|
|
152
|
+
return (TextLine(tuple(spans)),)
|
|
153
|
+
|
|
154
|
+
def _option_lines(self) -> tuple[TextLine, ...]:
|
|
155
|
+
"""Return the choice rows, windowed around the highlight.
|
|
156
|
+
|
|
157
|
+
While the reader is typing they are folded away, because the cursor
|
|
158
|
+
belongs to what is being written then; the rows stay (blank, so the panel
|
|
159
|
+
keeps its shape and the answer line stays where it is) and the first of
|
|
160
|
+
them says which keys still apply.
|
|
161
|
+
"""
|
|
162
|
+
theme = self.theme
|
|
163
|
+
rows: list[TextLine] = []
|
|
164
|
+
start, stop = self._window()
|
|
165
|
+
if self._typing:
|
|
166
|
+
folded = (1 if start > 0 else 0) + stop - start + (1 if stop < len(self.question.options) else 0)
|
|
167
|
+
keys = TextLine((Span(f" {self._keys_line()}", Style(foreground=theme.muted)),))
|
|
168
|
+
return (keys, *[TextLine() for _ in range(folded - 1)])
|
|
169
|
+
if start > 0:
|
|
170
|
+
rows.append(TextLine((Span(f" {ELLIPSIS} {start} above", Style(foreground=theme.muted)),)))
|
|
171
|
+
for index in range(start, stop):
|
|
172
|
+
highlighted = index == self.selected
|
|
173
|
+
checked = self._checked(index)
|
|
174
|
+
marker = f"{MARKER} " if highlighted else " "
|
|
175
|
+
# Three states read at a glance: the row the cursor is on is banded
|
|
176
|
+
# strongest, a chosen one keeps a raised background of its own, and
|
|
177
|
+
# the check itself carries the accent colour.
|
|
178
|
+
if highlighted:
|
|
179
|
+
style = Style(foreground=theme.text, background=theme.selection, bold=True)
|
|
180
|
+
elif checked:
|
|
181
|
+
style = Style(foreground=theme.text, background=theme.surface)
|
|
182
|
+
else:
|
|
183
|
+
style = Style(foreground=theme.text)
|
|
184
|
+
# The box is three cells either way, so the option texts line up
|
|
185
|
+
# whether or not a row is checked.
|
|
186
|
+
box = Span(f"{DONE} " if checked else " ", Style(foreground=theme.accent, background=style.background))
|
|
187
|
+
label = Span(f"{index + 1}. {self.question.options[index]}", style)
|
|
188
|
+
rows.append(TextLine((Span(f" {marker}", style), box, label)))
|
|
189
|
+
if stop < len(self.question.options):
|
|
190
|
+
rows.append(
|
|
191
|
+
TextLine(
|
|
192
|
+
(Span(f" {ELLIPSIS} {len(self.question.options) - stop} below", Style(foreground=theme.muted)),)
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
return tuple(rows)
|
|
196
|
+
|
|
197
|
+
def _send_line(self) -> str:
|
|
198
|
+
"""Return the row that finishes a multiple-choice answer, empty while typing."""
|
|
199
|
+
if self._typing:
|
|
200
|
+
return ""
|
|
201
|
+
picked = len(self._picked())
|
|
202
|
+
marker = f"{MARKER} " if self._on_send_row else " "
|
|
203
|
+
return f" {marker}{SEND} ({picked} chosen)" if picked else f" {marker}{SEND}"
|
|
204
|
+
|
|
205
|
+
def _keys_line(self) -> str:
|
|
206
|
+
"""Return the keys this question answers to."""
|
|
207
|
+
if not self.question.options:
|
|
208
|
+
return f"type an answer {SEPARATOR} enter sends {SEPARATOR} esc cancels"
|
|
209
|
+
if self._typing:
|
|
210
|
+
# The same line whichever kind of question it is: while the reader
|
|
211
|
+
# writes, the panel looks the same either way.
|
|
212
|
+
return f"enter sends {SEPARATOR} up/down for the choices {SEPARATOR} esc cancels"
|
|
213
|
+
if self.question.allow_multiple:
|
|
214
|
+
return f"up/down move {SEPARATOR} enter ticks {SEPARATOR} send finishes {SEPARATOR} esc"
|
|
215
|
+
return f"up/down choose, enter sends {SEPARATOR} or type an answer {SEPARATOR} esc cancels"
|
|
216
|
+
|
|
217
|
+
def _hint_line(self) -> str:
|
|
218
|
+
"""Return what the answer line shows while it is empty: the keys."""
|
|
219
|
+
if self._typing:
|
|
220
|
+
return f"enter sends what you typed {SEPARATOR} up/down for the choices {SEPARATOR} esc cancels"
|
|
221
|
+
return self._keys_line()
|
|
222
|
+
|
|
223
|
+
def _sync_input(self) -> None:
|
|
224
|
+
"""Point the answer line's prompt and placeholder at the current state.
|
|
225
|
+
|
|
226
|
+
While the reader types, the line wears the selection marker: the choices
|
|
227
|
+
have folded away, so the highlight belongs to the text being written.
|
|
228
|
+
"""
|
|
229
|
+
self.answer.placeholder = self._hint_line()
|
|
230
|
+
self.answer.prompt = f"{MARKER} " if self._typing else f"{PROMPT} "
|
|
231
|
+
|
|
232
|
+
def render(self, canvas: Canvas) -> None:
|
|
233
|
+
"""Fill the panel, then paint the question, the choices, and the answer line."""
|
|
234
|
+
if self.rect.empty:
|
|
235
|
+
return
|
|
236
|
+
# A page over the conversation owes the screen a surface: without the
|
|
237
|
+
# fill, whatever the previous frame left in these cells shows through.
|
|
238
|
+
canvas.fill(
|
|
239
|
+
self.rect.x,
|
|
240
|
+
self.rect.y,
|
|
241
|
+
self.rect.width,
|
|
242
|
+
self.rect.height,
|
|
243
|
+
Style(background=self.theme.surface_alt),
|
|
244
|
+
)
|
|
245
|
+
if self._typing and not self.answer.rect.empty:
|
|
246
|
+
# The row the reader is writing in is the highlighted one.
|
|
247
|
+
canvas.fill(
|
|
248
|
+
self.answer.rect.x,
|
|
249
|
+
self.answer.rect.y,
|
|
250
|
+
self.answer.rect.width,
|
|
251
|
+
self.answer.rect.height,
|
|
252
|
+
Style(background=self.theme.selection),
|
|
253
|
+
)
|
|
254
|
+
super().render(canvas)
|
|
255
|
+
|
|
256
|
+
# -- reading the state --------------------------------------------------
|
|
257
|
+
def _window(self) -> tuple[int, int]:
|
|
258
|
+
"""Return the slice of options to draw, keeping the highlight inside it."""
|
|
259
|
+
total = len(self.question.options)
|
|
260
|
+
if total <= VISIBLE_OPTIONS:
|
|
261
|
+
return 0, total
|
|
262
|
+
row = min(self.selected, total - 1)
|
|
263
|
+
start = min(max(0, row - VISIBLE_OPTIONS + 1), total - VISIBLE_OPTIONS)
|
|
264
|
+
return start, start + VISIBLE_OPTIONS
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def _on_send_row(self) -> bool:
|
|
268
|
+
"""Return whether the highlight is on the row that finishes the answer."""
|
|
269
|
+
return self._needs_send_row and self.selected >= len(self.question.options)
|
|
270
|
+
|
|
271
|
+
def _picked(self) -> list[str]:
|
|
272
|
+
"""Return the ticks the answer carries, in the order they were chosen."""
|
|
273
|
+
return list(self.chosen)
|
|
274
|
+
|
|
275
|
+
def _checked(self, index: int) -> bool:
|
|
276
|
+
"""Return whether an option reads as the answer.
|
|
277
|
+
|
|
278
|
+
A ticked row follows its tick. A one-answer question has no ticks: its
|
|
279
|
+
box follows the answer line — the choice ``Enter`` would make while that
|
|
280
|
+
line is empty, and nothing at all once it holds words of the reader's
|
|
281
|
+
own, because then the answer is not any of the rows.
|
|
282
|
+
"""
|
|
283
|
+
option = self.question.options[index]
|
|
284
|
+
if self.question.allow_multiple:
|
|
285
|
+
return option in self.chosen
|
|
286
|
+
written = self.answer.text.strip()
|
|
287
|
+
return (written == option) if written else (index == self.selected)
|
|
288
|
+
|
|
289
|
+
# -- input --------------------------------------------------------------
|
|
290
|
+
def capture_event(self, event: AnyEvent, host: Host) -> bool:
|
|
291
|
+
"""Take the list keys and leave the editor everything else."""
|
|
292
|
+
key = event_key(event)
|
|
293
|
+
if key == "escape":
|
|
294
|
+
self.on_cancel()
|
|
295
|
+
return True
|
|
296
|
+
if key in ("up", "down"):
|
|
297
|
+
self._leave_typing()
|
|
298
|
+
return self._move(-1 if key == "up" else 1, host)
|
|
299
|
+
if key == "enter":
|
|
300
|
+
# Typing turns the panel into an answer line: Enter then sends the
|
|
301
|
+
# text, which is what a reader who just typed expects. With nothing
|
|
302
|
+
# typed, Enter acts on the highlighted row, the way the shell's other
|
|
303
|
+
# pickers do.
|
|
304
|
+
return self._submit() if (self._typing or not self.question.options) else self._act(host)
|
|
305
|
+
if self._is_typing(event):
|
|
306
|
+
if not self._typing:
|
|
307
|
+
self._typing = True
|
|
308
|
+
self._sync_input()
|
|
309
|
+
host.request_layout()
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _is_typing(event: AnyEvent) -> bool:
|
|
314
|
+
"""Return whether an event is the reader writing in the answer line."""
|
|
315
|
+
return isinstance(event, TextEvent) or (isinstance(event, KeyEvent) and len(event.key) == 1)
|
|
316
|
+
|
|
317
|
+
def _leave_typing(self) -> None:
|
|
318
|
+
"""Give the marker back to the choices, if the reader was writing."""
|
|
319
|
+
if self._typing:
|
|
320
|
+
self._typing = False
|
|
321
|
+
self._sync_input()
|
|
322
|
+
|
|
323
|
+
def _move(self, step: int, host: Host) -> bool:
|
|
324
|
+
"""Move the highlight, stopping at both ends."""
|
|
325
|
+
rows = self._row_count()
|
|
326
|
+
if rows == 0:
|
|
327
|
+
return False
|
|
328
|
+
moved = min(max(0, self.selected + step), rows - 1) != self.selected
|
|
329
|
+
self.selected = min(max(0, self.selected + step), rows - 1)
|
|
330
|
+
self.note = ""
|
|
331
|
+
self._sync_input()
|
|
332
|
+
# The choice block is one slot whose height follows the window around
|
|
333
|
+
# the highlight, so moving it is a layout change, not just a repaint.
|
|
334
|
+
host.request_layout()
|
|
335
|
+
return moved
|
|
336
|
+
|
|
337
|
+
def _row_count(self) -> int:
|
|
338
|
+
"""Return how many rows the highlight can sit on."""
|
|
339
|
+
return len(self.question.options) + (1 if self._needs_send_row else 0)
|
|
340
|
+
|
|
341
|
+
def _act(self, host: Host) -> bool:
|
|
342
|
+
"""Act on the highlighted row: choose it, tick it, or finish."""
|
|
343
|
+
if self._on_send_row:
|
|
344
|
+
return self._submit()
|
|
345
|
+
if not self.question.options:
|
|
346
|
+
return False
|
|
347
|
+
option = self.question.options[self.selected]
|
|
348
|
+
if not self.question.allow_multiple:
|
|
349
|
+
written = self.answer.text.strip()
|
|
350
|
+
if written and written != option and not self.note:
|
|
351
|
+
# One answer only, and the line already holds something that is
|
|
352
|
+
# not this choice: say so and let the reader decide, rather than
|
|
353
|
+
# throwing away what they typed on one keystroke.
|
|
354
|
+
self.note = "one answer only, enter again to replace what you wrote"
|
|
355
|
+
self._sync_input()
|
|
356
|
+
host.request_layout()
|
|
357
|
+
return True
|
|
358
|
+
self.answer.set_text(option)
|
|
359
|
+
self.note = ""
|
|
360
|
+
self._sync_input()
|
|
361
|
+
host.invalidate()
|
|
362
|
+
return self._submit()
|
|
363
|
+
if option in self.chosen:
|
|
364
|
+
self.chosen.remove(option)
|
|
365
|
+
else:
|
|
366
|
+
self.chosen.append(option)
|
|
367
|
+
self.note = ""
|
|
368
|
+
self._sync_input()
|
|
369
|
+
host.request_layout()
|
|
370
|
+
return True
|
|
371
|
+
|
|
372
|
+
def _submit(self) -> bool:
|
|
373
|
+
"""Send the answer, refusing an empty one.
|
|
374
|
+
|
|
375
|
+
A multiple-choice answer is its ticks and then whatever was typed beside
|
|
376
|
+
them, comma separated; a one-answer one is the line itself.
|
|
377
|
+
"""
|
|
378
|
+
parts = [*self.chosen, self.answer.text.strip()] if self.question.allow_multiple else [self.answer.text.strip()]
|
|
379
|
+
# A word typed that is already ticked is the same answer, not a second one.
|
|
380
|
+
answer = ", ".join(dict.fromkeys(part for part in parts if part))
|
|
381
|
+
if not answer:
|
|
382
|
+
self.note = "type an answer, or choose one of the options"
|
|
383
|
+
self._sync_input()
|
|
384
|
+
return False
|
|
385
|
+
self.on_answer(answer)
|
|
386
|
+
return True
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The page ``/bash`` opens: a command line over the workspace, with the history and the Tab completion of a shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
|
|
8
|
+
from zettcode.app.commandline import CommandHistory, CommandRunner, ShellCompleter
|
|
9
|
+
from zettcode.presenters import labels
|
|
10
|
+
from zettcode.tui import ConsolePanel
|
|
11
|
+
|
|
12
|
+
#: What ends the page when typed, as it ends a shell.
|
|
13
|
+
LEAVING = frozenset({"exit", "quit"})
|
|
14
|
+
|
|
15
|
+
#: What empties the output, under the names shells use for it.
|
|
16
|
+
CLEARING = frozenset({"clear", "cls"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BashPage(ConsolePanel):
|
|
20
|
+
"""Run commands one at a time in the workspace and show what they print.
|
|
21
|
+
|
|
22
|
+
The screen is :class:`~zettcode.tui.ConsolePanel`; this joins it to a :class:`CommandRunner`, which starts the
|
|
23
|
+
processes, the reader's shell histories, which Up and Ctrl-R walk, and a :class:`ShellCompleter`, which Tab
|
|
24
|
+
asks. Commands the reader types here are appended to ZettCode's own history file, so they are there the next
|
|
25
|
+
time, whichever shell they were meant for.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
runner: Starts the commands and keeps the directory they run in.
|
|
29
|
+
history: Where earlier commands are read from and new ones are written.
|
|
30
|
+
entries: The earlier commands, oldest first (read beforehand, off the event loop).
|
|
31
|
+
on_close: Called when the reader leaves, instead of the page popping itself.
|
|
32
|
+
initial: A command to run as soon as the page is on screen.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
runner: CommandRunner,
|
|
38
|
+
history: CommandHistory,
|
|
39
|
+
entries: Sequence[str] = (),
|
|
40
|
+
*,
|
|
41
|
+
on_close: Callable[[], None] | None = None,
|
|
42
|
+
initial: str = "",
|
|
43
|
+
) -> None:
|
|
44
|
+
self.completer = ShellCompleter(lambda: runner.cwd, entries)
|
|
45
|
+
super().__init__(
|
|
46
|
+
title=f"Shell ({runner.launcher.name})" if runner.launcher.name else "Shell",
|
|
47
|
+
location=labels.compact_path(runner.cwd, limit=None),
|
|
48
|
+
prompt=runner.launcher.prompt,
|
|
49
|
+
completer=self.completer,
|
|
50
|
+
history=entries,
|
|
51
|
+
on_submit=self.run,
|
|
52
|
+
on_interrupt=runner.stop,
|
|
53
|
+
on_close=on_close,
|
|
54
|
+
)
|
|
55
|
+
self.runner = runner
|
|
56
|
+
self.history = history
|
|
57
|
+
self.initial = initial
|
|
58
|
+
self._task: asyncio.Task[None] | None = None
|
|
59
|
+
|
|
60
|
+
def on_mount(self) -> None:
|
|
61
|
+
"""Run the command ``/bash`` was given, now that the page has a clock to time it by."""
|
|
62
|
+
if self.initial:
|
|
63
|
+
command, self.initial = self.initial, ""
|
|
64
|
+
self.run(command)
|
|
65
|
+
|
|
66
|
+
def run(self, command: str) -> None:
|
|
67
|
+
"""Run a command the way Enter does: the page's own words first, then the reader's shell."""
|
|
68
|
+
word = command.strip()
|
|
69
|
+
if word in CLEARING:
|
|
70
|
+
self.clear()
|
|
71
|
+
return
|
|
72
|
+
if word in LEAVING:
|
|
73
|
+
self.close()
|
|
74
|
+
return
|
|
75
|
+
self.history.record(command)
|
|
76
|
+
self.completer.history = (*self.completer.history, command)
|
|
77
|
+
self.echo(command)
|
|
78
|
+
self.begin()
|
|
79
|
+
self._task = asyncio.ensure_future(self._execute(command))
|
|
80
|
+
|
|
81
|
+
async def _execute(self, command: str) -> None:
|
|
82
|
+
"""Run to the end, then say how it went and where the next command will run."""
|
|
83
|
+
result = await self.runner.run(command, self.write)
|
|
84
|
+
self.set_location(labels.compact_path(self.runner.cwd, limit=None))
|
|
85
|
+
self.finish(result.text)
|
|
86
|
+
|
|
87
|
+
def on_unmount(self) -> None:
|
|
88
|
+
"""Stop what is still running when the page goes, so no command outlives the screen that shows it."""
|
|
89
|
+
if self._task is not None and not self._task.done():
|
|
90
|
+
self._task.cancel()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Completion for the composer's slash commands and ``@`` resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from zettcode.mentions import MentionRegistry
|
|
9
|
+
from zettcode.plugin_api.commands import Command
|
|
10
|
+
from zettcode.tui import OPTION, SEPARATOR, Completer, CompletionItem
|
|
11
|
+
|
|
12
|
+
#: A draft's text before the word under the cursor, when that word is the one right after a command.
|
|
13
|
+
COMMAND_PREFIX = re.compile(r"(/\S+)[ \t]+")
|
|
14
|
+
|
|
15
|
+
KEY_HELP = (
|
|
16
|
+
f" Enter send {SEPARATOR} Shift-Enter or Alt-Enter newline {SEPARATOR} Ctrl-C stop or clear\n"
|
|
17
|
+
f" Ctrl-T thinking {SEPARATOR} PgUp/PgDn scroll {SEPARATOR} {OPTION.capitalize()}-\u2191/\u2193 jump to a message\n"
|
|
18
|
+
f" Ctrl-L redraw {SEPARATOR} Ctrl-D exit\n"
|
|
19
|
+
f" While a request runs, Enter asks whether a message should steer it or follow up after it;\n"
|
|
20
|
+
f" {OPTION.capitalize()}-\u2191/\u2193 select a follow-up {SEPARATOR} \u2191/\u2193 move the highlight "
|
|
21
|
+
f"{SEPARATOR} {OPTION.capitalize()}-\u2191/\u2193 reorder {SEPARATOR} Enter edit "
|
|
22
|
+
f"{SEPARATOR} Del remove {SEPARATOR} Esc done"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CommandCompleter(Completer):
|
|
27
|
+
"""Complete the slash commands, their first word, and ``@`` resources on the cursor's line.
|
|
28
|
+
|
|
29
|
+
A leading ``/`` filters the commands; a leading ``@`` filters the resources
|
|
30
|
+
the mention providers offer, skills among them. After a command that lists
|
|
31
|
+
subcommands and a space, the menu offers those (``/config `` then ``add``,
|
|
32
|
+
``remove``, ...). Any further space ends the suggestion: ``/resume abc``
|
|
33
|
+
has moved on to a session id, so the menu steps aside instead of filtering
|
|
34
|
+
anything down to nothing.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, commands: Sequence[Command], mentions: MentionRegistry | None = None) -> None:
|
|
38
|
+
"""Keep the commands to offer; the shell builds them once at startup."""
|
|
39
|
+
self.commands = tuple(commands)
|
|
40
|
+
self.mentions = mentions
|
|
41
|
+
|
|
42
|
+
def __call__(self, text: str, position: int) -> tuple[CompletionItem, ...]:
|
|
43
|
+
"""Return the matching commands for the token ending at ``position``.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
text: Full draft, newlines included.
|
|
47
|
+
position: Cursor as a code-point index into ``text``.
|
|
48
|
+
"""
|
|
49
|
+
# The token is the run of non-space characters under the cursor, so an
|
|
50
|
+
# ``@`` resource is still offered when it sits inside a sentence.
|
|
51
|
+
start = position
|
|
52
|
+
while start > 0 and not text[start - 1].isspace():
|
|
53
|
+
start -= 1
|
|
54
|
+
token = text[start:position]
|
|
55
|
+
if token.startswith("@"):
|
|
56
|
+
if self.mentions is None or any(character.isspace() for character in token):
|
|
57
|
+
return ()
|
|
58
|
+
return tuple(
|
|
59
|
+
CompletionItem(mention.token, description=mention.description, type=mention.kind)
|
|
60
|
+
for mention in self.mentions.candidates(token[1:])
|
|
61
|
+
)
|
|
62
|
+
if not token.startswith("/") or any(character.isspace() for character in token):
|
|
63
|
+
return self._subcommands(text[:start], token)
|
|
64
|
+
return tuple(
|
|
65
|
+
CompletionItem(command.name, description=command.description, type=command.type)
|
|
66
|
+
for command in self.commands
|
|
67
|
+
if command.name.startswith(token)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def _subcommands(self, before: str, token: str) -> tuple[CompletionItem, ...]:
|
|
71
|
+
"""Return the words a command takes first, when the cursor is on the word right after its name.
|
|
72
|
+
|
|
73
|
+
Only the second word of the draft is offered: ``before`` must be nothing but a command and the
|
|
74
|
+
spaces after it. A word further on is the command's own argument (a session id, a model's
|
|
75
|
+
settings), which the menu has no business filtering.
|
|
76
|
+
"""
|
|
77
|
+
match = COMMAND_PREFIX.fullmatch(before)
|
|
78
|
+
if match is None or any(character.isspace() for character in token):
|
|
79
|
+
return ()
|
|
80
|
+
command = next((command for command in self.commands if command.name == match.group(1)), None)
|
|
81
|
+
if command is None:
|
|
82
|
+
return ()
|
|
83
|
+
return tuple(
|
|
84
|
+
CompletionItem(
|
|
85
|
+
option.name,
|
|
86
|
+
description=" ".join(
|
|
87
|
+
part for part in (option.description, "(current)" if option.current else "") if part
|
|
88
|
+
),
|
|
89
|
+
final=not option.needs_more,
|
|
90
|
+
current=option.current,
|
|
91
|
+
)
|
|
92
|
+
for option in command.options()
|
|
93
|
+
if option.name.startswith(token)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def help_text(commands: Sequence[Command]) -> str:
|
|
98
|
+
"""Return the ``/help`` body as Markdown, generated from the command table."""
|
|
99
|
+
rows = [f"- `{command.name}` \u2014 {command.description} (`{command.type}`)" for command in commands]
|
|
100
|
+
return "\n".join(["**Commands**", *rows, "", "**Keys**", "", KEY_HELP])
|