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,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])