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,462 @@
1
+ """The shell's keys: the keymap and every binding it registers.
2
+
3
+ The bindings are the shell's input vocabulary — scroll, redraw, attach, quit,
4
+ and the completion menu — and they live together so the keymap reads as one
5
+ table. :class:`ZettCodeApp` installs them once and never touches a key again.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from zettcode.error_text import describe_error
13
+ from zettcode.tui import AnyEvent, Host
14
+ from zettcode.tui.clipboard import copy_route
15
+
16
+ from .clipboard import read_image
17
+ from .shell import ASK_SCREEN, PAGE_SCREEN, ShellState
18
+ from .widgets.completer import COMMAND_PREFIX
19
+
20
+ if TYPE_CHECKING: # pragma: no cover - annotations only
21
+ from zettcode.plugin_api.commands import Command
22
+
23
+
24
+ class KeysMixin(ShellState):
25
+ """Register the keymap and handle the keys the shell owns."""
26
+
27
+ def _install_keymap(self) -> None:
28
+ """Register the global commands and their key bindings."""
29
+ self.app.commands.add("interrupt", self._interrupt)
30
+ self.app.commands.add("redraw", self._redraw)
31
+ self.app.commands.add("toggle_thinking", self._toggle_thinking)
32
+ self.app.commands.add("scroll_up", self._scroll_up)
33
+ self.app.commands.add("scroll_down", self._scroll_down)
34
+ self.app.commands.add("scroll_end", self._scroll_end)
35
+ self.app.commands.add("previous_message", self._previous_message)
36
+ self.app.commands.add("next_message", self._next_message)
37
+ self.app.commands.add("attach_image", self._attach_image)
38
+ self.app.commands.add("quit", self._quit)
39
+ self.app.commands.add("complete_next", self._complete_next)
40
+ self.app.commands.add("complete_previous", self._complete_previous)
41
+ self.app.commands.add("interrupt_run", self._interrupt_run)
42
+ self.app.commands.add("complete_accept", self._complete_accept)
43
+ self.app.commands.add("complete_run", self._complete_run)
44
+ self.app.commands.add("stop_working_command", self._stop_working_command)
45
+ self.app.commands.add("complete_dismiss", self._complete_dismiss)
46
+ self.app.commands.add("follow_up_up", self._follow_up_up)
47
+ self.app.commands.add("follow_up_down", self._follow_up_down)
48
+ self.app.commands.add("follow_up_previous", self._follow_up_previous)
49
+ self.app.commands.add("follow_up_next", self._follow_up_next)
50
+ self.app.commands.add("follow_up_edit", self._follow_up_edit)
51
+ self.app.commands.add("follow_up_delete", self._follow_up_delete)
52
+ self.app.commands.add("follow_up_steer", self._follow_up_steer)
53
+ self.app.commands.add("follow_up_done", self._follow_up_done)
54
+ self.app.keymap.bind("ctrl_c", "interrupt", priority="capture")
55
+ self.app.keymap.bind("ctrl_l", "redraw", priority="capture")
56
+ self.app.keymap.bind("ctrl_t", "toggle_thinking", priority="capture")
57
+ # Capture priority is what lets the predicate win: the composer would
58
+ # otherwise swallow Ctrl-D as "delete forward" even on an empty draft.
59
+ self.app.keymap.bind(
60
+ "ctrl_d",
61
+ "quit",
62
+ priority="capture",
63
+ when=lambda: (
64
+ self.app.screens.top.name != PAGE_SCREEN
65
+ and not self._busy
66
+ and not self.composer.text
67
+ and not self._updating
68
+ ),
69
+ )
70
+ # The follow-up list. The modifier key (Option on a Mac) is the list's own: with
71
+ # nothing highlighted it walks into the list from the composer's side, and with a
72
+ # message highlighted it moves that message. VS Code's terminal rewrites Option-Up and
73
+ # Option-Down before the terminal sees them — it has a keybinding that sends the bytes of
74
+ # Ctrl-Up and Ctrl-Down in their place — so the program is told Ctrl, never Alt, and
75
+ # both spellings are the same key here. Nothing else in the program uses Ctrl with an
76
+ # arrow, and on a Mac the system keeps Ctrl-Up for itself, so this takes nothing away.
77
+ # The plain arrows move the highlight,
78
+ # which they only do while one is highlighted — until then they are the composer's,
79
+ # history and the cursor. Enter edits, Delete removes, Escape lets go. They are
80
+ # capture bindings because the composer reads Up and Down as history and Enter as
81
+ # send, and they come before the Escape and Enter bindings below: a highlighted
82
+ # message is the most specific thing the reader is doing, so its keys win.
83
+ self.app.keymap.bind("alt_up", "follow_up_up", priority="capture", when=self._follow_ups_listed)
84
+ self.app.keymap.bind("alt_down", "follow_up_down", priority="capture", when=self._follow_ups_listed)
85
+ self.app.keymap.bind("ctrl_up", "follow_up_up", priority="capture", when=self._follow_ups_listed)
86
+ self.app.keymap.bind("ctrl_down", "follow_up_down", priority="capture", when=self._follow_ups_listed)
87
+ self.app.keymap.bind("up", "follow_up_previous", priority="capture", when=self._follow_up_chosen)
88
+ self.app.keymap.bind("down", "follow_up_next", priority="capture", when=self._follow_up_chosen)
89
+ self.app.keymap.bind("enter", "follow_up_edit", priority="capture", when=self._follow_up_chosen)
90
+ # Ctrl-S sends the highlighted message now, as steering. Not a letter: typing anything
91
+ # lets go of the highlight, so a plain "s" would be the composer's. It is the editor's
92
+ # usual "send", and the terminal's flow-control meaning of it is off in raw mode.
93
+ self.app.keymap.bind("ctrl_s", "follow_up_steer", priority="capture", when=self._follow_up_chosen)
94
+ # Backspace is what the key labelled "delete" sends on a Mac, and Delete is the one
95
+ # that sends it everywhere else, so both remove the message.
96
+ self.app.keymap.bind("backspace", "follow_up_delete", priority="capture", when=self._follow_up_chosen)
97
+ self.app.keymap.bind("delete", "follow_up_delete", priority="capture", when=self._follow_up_chosen)
98
+ self.app.keymap.bind("escape", "follow_up_done", priority="capture", when=self._follow_up_chosen)
99
+ # Escape stops a running turn, above the uses below: a reader who wants the
100
+ # request stopped should not have to remember that Ctrl-C is the key for
101
+ # it, and a panel, a question, the menu, and a scrolled transcript keep
102
+ # their own claim on the key first.
103
+ self.app.keymap.bind("escape", "interrupt_run", priority="capture", when=self._escaping_a_run)
104
+ self.app.keymap.bind("page_up", "scroll_up")
105
+ self.app.keymap.bind("page_down", "scroll_down")
106
+ # Alt (Option) and Ctrl with Up and Down walk through the messages the reader sent: the previous one goes
107
+ # to the top of the view, and past the last one the view returns to the newest line. They are the follow-up
108
+ # list's keys too, and the list wins them while it has something in it (bound above, with a predicate), so a
109
+ # reader with follow-ups queued keeps what they had and everyone else gets the jump. Capture priority, or the
110
+ # composer would read Up and Down as history.
111
+ for key, command in (
112
+ ("alt_up", "previous_message"),
113
+ ("ctrl_up", "previous_message"),
114
+ ("alt_down", "next_message"),
115
+ ("ctrl_down", "next_message"),
116
+ ):
117
+ self.app.keymap.bind(key, command, priority="capture", when=self._messages_can_be_walked)
118
+ # Escape is the keyboard twin of the transcript's return badge; capture
119
+ # priority lets it win, because the composer would otherwise swallow Esc.
120
+ self.app.keymap.bind("escape", "scroll_end", priority="capture", when=self._transcript_scrolled_up)
121
+ # The terminal cannot deliver a pasted image, so this asks the desktop.
122
+ self.app.keymap.bind("ctrl_v", "attach_image", priority="capture")
123
+ # Capture priority is what lets the menu win the keys it needs: the
124
+ # composer would otherwise read Up and Down as history navigation and
125
+ # would treat Tab as its own inline completion.
126
+ self.app.keymap.bind("down", "complete_next", priority="capture", when=self._menu_open)
127
+ self.app.keymap.bind("up", "complete_previous", priority="capture", when=self._menu_open)
128
+ self.app.keymap.bind("tab", "complete_accept", priority="capture", when=self._menu_open)
129
+ self.app.keymap.bind("escape", "complete_dismiss", priority="capture", when=self._menu_open)
130
+ self.app.keymap.bind("enter", "complete_run", priority="capture", when=self._run_on_enter)
131
+ # Enter with nothing typed stops a command that handed over work: a row the reader did not ask for is not
132
+ # something to be held behind, and the draft (when there is one) is the command's own submit.
133
+ self.app.keymap.bind("enter", "stop_working_command", priority="capture", when=self._stopping_work)
134
+ self.app.keymap.bind("enter", "complete_accept", priority="capture", when=self._accept_on_enter)
135
+
136
+ def _messages_can_be_walked(self) -> bool:
137
+ """Return whether Alt/Ctrl with Up or Down mean "another message of mine": no page is open, no follow-up list."""
138
+ return self.app.screens.top.name not in (PAGE_SCREEN, ASK_SCREEN) and not self._follow_ups
139
+
140
+ def _previous_message(self, event: AnyEvent, host: Host) -> bool:
141
+ """Scroll back to the message the reader sent before the one at the top of the view."""
142
+ self.view.jump_to_message(-1)
143
+ return True
144
+
145
+ def _next_message(self, event: AnyEvent, host: Host) -> bool:
146
+ """Scroll forward to the next message the reader sent, or to the newest line after the last."""
147
+ self.view.jump_to_message(1)
148
+ return True
149
+
150
+ def _follow_ups_listed(self) -> bool:
151
+ """Return whether there are follow-ups to move through, and nothing is on top of them."""
152
+ return bool(self._follow_ups) and self.app.screens.top.name not in (PAGE_SCREEN, ASK_SCREEN)
153
+
154
+ def _follow_up_chosen(self) -> bool:
155
+ """Return whether a follow-up is highlighted, and nothing is on top of the list."""
156
+ return self._follow_up_selecting and self.app.screens.top.name not in (PAGE_SCREEN, ASK_SCREEN)
157
+
158
+ def _follow_up_up(self, event: AnyEvent, host: Host) -> bool:
159
+ """Option-Up: send the highlighted follow-up sooner, or walk into the list to begin."""
160
+ if self._follow_up_selecting:
161
+ self._move_follow_up(-1)
162
+ else:
163
+ self._select_follow_up(-1)
164
+ return True
165
+
166
+ def _follow_up_down(self, event: AnyEvent, host: Host) -> bool:
167
+ """Option-Down: send the highlighted follow-up later, or walk into the list to begin."""
168
+ if self._follow_up_selecting:
169
+ self._move_follow_up(1)
170
+ else:
171
+ self._select_follow_up(1)
172
+ return True
173
+
174
+ def _follow_up_previous(self, event: AnyEvent, host: Host) -> bool:
175
+ """Highlight the previous follow-up."""
176
+ self._select_follow_up(-1)
177
+ return True
178
+
179
+ def _follow_up_next(self, event: AnyEvent, host: Host) -> bool:
180
+ """Highlight the next follow-up; past the last one the highlight leaves the list."""
181
+ self._select_follow_up(1)
182
+ return True
183
+
184
+ def _follow_up_edit(self, event: AnyEvent, host: Host) -> bool:
185
+ """Take the highlighted follow-up out of the list and into the composer."""
186
+ return self._edit_follow_up()
187
+
188
+ def _follow_up_steer(self, event: AnyEvent, host: Host) -> bool:
189
+ """Send the highlighted follow-up to the running request as steering."""
190
+ self._steer_follow_up()
191
+ return True
192
+
193
+ def _follow_up_delete(self, event: AnyEvent, host: Host) -> bool:
194
+ """Remove the highlighted follow-up, so it is never sent."""
195
+ self._delete_follow_up()
196
+ return True
197
+
198
+ def _follow_up_done(self, event: AnyEvent, host: Host) -> bool:
199
+ """Let go of the highlighted follow-up."""
200
+ self._deselect_follow_up()
201
+ return True
202
+
203
+ def _menu_open(self) -> bool:
204
+ """Return whether the slash-command menu is showing."""
205
+ return self.app.screens.top.name != PAGE_SCREEN and self.completions.visible
206
+
207
+ def _escaping_a_run(self) -> bool:
208
+ """Return whether Escape means "stop the request" here.
209
+
210
+ The key has other jobs, and each of them is answered elsewhere: a panel
211
+ and the question the model is waiting on own Escape while they are up, the
212
+ menu asks for it to dismiss itself, a scrolled transcript takes it as
213
+ "back to the newest line", a highlighted follow-up takes it to let go,
214
+ and a selection belongs to Ctrl-C, which copies. What is left is the case this binding exists for: a turn is in
215
+ flight and Escape has nothing else to do.
216
+ """
217
+ if not self._busy or self._task is None or self._task.done():
218
+ return False
219
+ if self.app.screens.top.name in (PAGE_SCREEN, ASK_SCREEN):
220
+ return False
221
+ if self._menu_open() or self.view.has_selection() or self._follow_up_selecting:
222
+ return False
223
+ return not self._transcript_scrolled_up()
224
+
225
+ def _interrupt_run(self, event: AnyEvent, host: Host) -> bool:
226
+ """Stop the turn that is running, as Ctrl-C does and no more.
227
+
228
+ Ctrl-C does more than this — it copies a selection, declines a question,
229
+ closes a panel, and clears a draft — and this is only the part of it a
230
+ reader reaches for while the shell is busy.
231
+ """
232
+ if not self._busy or self._task is None or self._task.done():
233
+ return False
234
+ self._task.cancel()
235
+ host.invalidate()
236
+ return True
237
+
238
+ def _transcript_scrolled_up(self) -> bool:
239
+ """Return whether Escape should jump the conversation back to its tail."""
240
+ return self.app.screens.top.name != PAGE_SCREEN and self.view.scrolled_up
241
+
242
+ def _scroll_end(self, event: AnyEvent, host: Host) -> bool:
243
+ """Follow the newest line again, as the transcript's return badge does."""
244
+ self.view.scroll_end()
245
+ return True
246
+
247
+ def _redraw(self, event: AnyEvent, host: Host) -> bool:
248
+ """Repaint the frame, for a terminal that lost its contents."""
249
+ host.refresh()
250
+ return True
251
+
252
+ def _scroll_up(self, event: AnyEvent, host: Host) -> bool:
253
+ """Move the transcript one page toward older lines."""
254
+ self.view.scroll_by(-3)
255
+ return True
256
+
257
+ def _scroll_down(self, event: AnyEvent, host: Host) -> bool:
258
+ """Move the transcript one page toward newer lines."""
259
+ self.view.scroll_by(3)
260
+ return True
261
+
262
+ def _quit(self, event: AnyEvent, host: Host) -> bool:
263
+ """Leave the application."""
264
+ host.exit()
265
+ return True
266
+
267
+ def _attach_image(self, event: AnyEvent, host: Host) -> bool:
268
+ """Attach the clipboard's image to the draft, or say why there is none.
269
+
270
+ Ctrl-V rather than the terminal's paste key: an image never reaches the
271
+ program as input, so the shell has to go and look for it.
272
+
273
+ Over ``ssh`` there is nothing to look at. The clipboard this process can read is the
274
+ server's, an image the reader copied on their own machine never crosses the
275
+ connection — a terminal carries text, not pictures — and a server rarely has a
276
+ clipboard tool at all. So it does not look, and says so, instead of reporting "no
277
+ image" and sending the reader to copy it again. ``ZETTCODE_CLIPBOARD=system`` is the
278
+ reader saying the server's clipboard is the right one.
279
+ """
280
+ if copy_route() == "terminal":
281
+ self.transcript.notice(
282
+ "Ctrl-V cannot reach your clipboard over ssh — a terminal carries text, not pictures. "
283
+ "Put the file on the server and refer to it by path, "
284
+ "or set ZETTCODE_CLIPBOARD=system to read this machine's clipboard"
285
+ )
286
+ host.request_layout()
287
+ return True
288
+ try:
289
+ image = read_image()
290
+ except Exception as error:
291
+ # Reading a clipboard is the shell reaching into the desktop, and a desktop can
292
+ # fail in ways no tool list anticipates. The session is the reader's work: a
293
+ # failed paste says what failed and leaves everything else as it was.
294
+ self.transcript.error(f"could not read the clipboard: {describe_error(error)}")
295
+ host.request_layout()
296
+ return True
297
+ if image is None:
298
+ self.transcript.notice("no image on the clipboard")
299
+ elif self.model is None:
300
+ # Nothing would take the picture: say what to set up rather than reporting the model's limits.
301
+ self.transcript.error("no model is configured yet \u2014 /config adds one, then the image can go with it")
302
+ elif not self.model.multimodal:
303
+ self.transcript.error(f"{self.model.name} does not take images")
304
+ else:
305
+ label = self.composer.attach_image(*image)
306
+ self._note = f"attached {label}"
307
+ host.request_layout()
308
+ return True
309
+
310
+ def _stop_working_command(self, event: AnyEvent, host: Host) -> bool:
311
+ """Enter on an empty draft stops a command that handed over work."""
312
+ if not self.stop_working_command():
313
+ return False
314
+ host.invalidate()
315
+ return True
316
+
317
+ def _stopping_work(self) -> bool:
318
+ """Return whether Enter should stop a working command instead of sending an empty draft."""
319
+ return self._command_working and not self.composer.text.strip()
320
+
321
+ def _run_on_enter(self) -> bool:
322
+ """Return whether Enter should take the highlighted word and send the draft.
323
+
324
+ That is a word that says everything the command needs (a model, a palette, a level): picking
325
+ it is the whole job, so a second Enter to run it would only be the reader asking again.
326
+ """
327
+ item = self.completions.current
328
+ return item is not None and item.final
329
+
330
+ def _complete_run(self, event: AnyEvent, host: Host) -> bool:
331
+ """Drop the highlighted word into the draft and send it."""
332
+ item = self.completions.current
333
+ if item is None:
334
+ return False
335
+ self.composer.replace_token(item.value)
336
+ self.composer.submit()
337
+ host.request_layout()
338
+ return True
339
+
340
+ def _accept_on_enter(self) -> bool:
341
+ """Return whether Enter should complete the draft instead of running it.
342
+
343
+ Completing is only useful while the highlighted candidate differs from
344
+ the word being typed; once that word already spells it, Enter runs the
345
+ draft instead of filling in the same text twice. Right after a command
346
+ and a space nothing has been typed of the second word yet, so the menu
347
+ is only a list of what could follow and Enter runs the command.
348
+ """
349
+ item = self.completions.current
350
+ token = self.composer.text[self.composer.token_start() : self.composer.position]
351
+ return item is not None and not item.final and bool(token) and item.value != token
352
+
353
+ def _refresh_completions(self) -> None:
354
+ """Mirror the composer's slash-command candidates into the menu."""
355
+ # Nothing is offered over an entry the reader is only walking past: the menu would own
356
+ # Up and Down from then on, and the walk through the history would stop at the first
357
+ # command it met. Editing the entry (or typing from scratch) ends the walk and brings it back.
358
+ candidates = () if self.composer.browsing_history else self.composer.completion_candidates()
359
+ if candidates == self.completions.items:
360
+ return
361
+ # The highlight starts on what is in force (a setting the reader is choosing among), so Enter
362
+ # straight after the space asks for no change.
363
+ start = next((index for index, item in enumerate(candidates) if item.current), 0)
364
+ self.completions.set_items(candidates, selected=start)
365
+ self._sync_preview()
366
+ self.app.request_layout()
367
+
368
+ def _menu_command(self) -> Command | None:
369
+ """Return the command whose subcommand menu is open under the cursor, if one is."""
370
+ if not self.completions.visible:
371
+ return None
372
+ match = COMMAND_PREFIX.fullmatch(self.composer.text[: self.composer.token_start()])
373
+ if match is None:
374
+ return None
375
+ return next((command for command in self.commands if command.name == match.group(1)), None)
376
+
377
+ def _sync_preview(self) -> None:
378
+ """Show the highlighted word of a menu whose command can preview it, or put things back.
379
+
380
+ The word that is already in force is not previewed (it is what is on screen, and a palette the reader
381
+ adjusted would be swapped for the stock one of its name just by opening the menu); neither is anything
382
+ once the menu is gone. Ending a preview restores what the command changed, so closing the menu with
383
+ Escape, typing on, or running the command leaves the choice to the command and nothing behind.
384
+ """
385
+ command = self._menu_command()
386
+ item = self.completions.current
387
+ if command is not None and command.preview is not None and item is not None and not item.current:
388
+ self._previewing = command
389
+ command.preview(item.value)
390
+ return
391
+ if self._previewing is not None:
392
+ previewing, self._previewing = self._previewing, None
393
+ if previewing.preview is not None:
394
+ previewing.preview(None)
395
+
396
+ def _complete_next(self, event: AnyEvent, host: Host) -> bool:
397
+ """Highlight the next command."""
398
+ self.completions.move(1)
399
+ self._sync_preview()
400
+ host.invalidate()
401
+ return True
402
+
403
+ def _complete_previous(self, event: AnyEvent, host: Host) -> bool:
404
+ """Highlight the previous command."""
405
+ self.completions.move(-1)
406
+ self._sync_preview()
407
+ host.invalidate()
408
+ return True
409
+
410
+ def _complete_accept(self, event: AnyEvent, host: Host) -> bool:
411
+ """Drop the highlighted command into the composer and close the menu."""
412
+ item = self.completions.current
413
+ if item is None:
414
+ return False
415
+ # The trailing space is what closes the menu: the draft is no longer a
416
+ # bare command token, so the next Enter runs the command.
417
+ self.composer.replace_token(item.value, suffix=" ")
418
+ host.request_layout()
419
+ return True
420
+
421
+ def _complete_dismiss(self, event: AnyEvent, host: Host) -> bool:
422
+ """Hide the menu until the draft changes again."""
423
+ self.completions.set_items(())
424
+ self._sync_preview()
425
+ host.request_layout()
426
+ return True
427
+
428
+ def _interrupt(self, event: AnyEvent, host: Host) -> bool:
429
+ """Copy a selection, otherwise stop the running turn or clear the draft."""
430
+ selected = self.view.selected_text() or host.screen_selection_text()
431
+ if selected:
432
+ host.copy(selected)
433
+ self.view.clear_selection()
434
+ host.clear_screen_selection()
435
+ host.invalidate()
436
+ return True
437
+ # A question the model is waiting on is answered or declined here: Ctrl-C
438
+ # means the same thing Escape does on that panel, never a silent close
439
+ # that would leave the tool call suspended.
440
+ if self._asks:
441
+ self._cancel_ask(self._asks[0])
442
+ host.invalidate()
443
+ return True
444
+ if self.app.screens.top.name == PAGE_SCREEN:
445
+ if self.app.screens.top.widget.interrupt():
446
+ host.invalidate()
447
+ return True
448
+ self.close_page()
449
+ host.invalidate()
450
+ return True
451
+ if self._busy and self._task is not None and not self._task.done():
452
+ self._task.cancel()
453
+ elif self.composer.text:
454
+ self.composer.clear()
455
+ host.invalidate()
456
+ return True
457
+
458
+ def _toggle_thinking(self, event: AnyEvent, host: Host) -> bool:
459
+ """Show or hide the newest reasoning block."""
460
+ if self.transcript.toggle_latest_thinking():
461
+ host.invalidate()
462
+ return True