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/keys.py
ADDED
|
@@ -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
|