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,323 @@
|
|
|
1
|
+
"""A request's life in the transcript: the reader's message, the wait row, the clock.
|
|
2
|
+
|
|
3
|
+
A turn is one request: the reader's message goes in, the pinned wait row opens and
|
|
4
|
+
counts for as long as the model is working, and when the request ends the row is
|
|
5
|
+
settled with the line it ended on and anything it left running is closed. The
|
|
6
|
+
animation step belongs here too, because it is the wait row's clock that it moves.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from pathlib import PurePath
|
|
13
|
+
from typing import TypeGuard
|
|
14
|
+
|
|
15
|
+
from zettcode.messaging.relayed import Relayed
|
|
16
|
+
from zettcode.plugin_api.entries import (
|
|
17
|
+
ChangesEntry,
|
|
18
|
+
Entry,
|
|
19
|
+
EntryKind,
|
|
20
|
+
EntryStatus,
|
|
21
|
+
RelayEntry,
|
|
22
|
+
TextEntry,
|
|
23
|
+
ThinkingEntry,
|
|
24
|
+
TimedEntry,
|
|
25
|
+
ToolEntry,
|
|
26
|
+
WaitEntry,
|
|
27
|
+
)
|
|
28
|
+
from zettcode.presenters.pulse import ANIMATION_SECONDS, blink_state, wait_picture
|
|
29
|
+
from zettcode.presenters.text import PROCESSING, duration_text, elapsed_text, quiet_text
|
|
30
|
+
from zettcode.tui.render import display_width
|
|
31
|
+
from zettcode.wire import VIA_EXTERNAL
|
|
32
|
+
|
|
33
|
+
from ..changes import changed_files
|
|
34
|
+
from ..tips import TipDeck
|
|
35
|
+
from .log import EntryLog
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TurnMixin(EntryLog):
|
|
39
|
+
"""Open, advance, and settle the rows that say a request is in flight."""
|
|
40
|
+
|
|
41
|
+
#: Time source for row durations; set by :class:`~.Transcript`.
|
|
42
|
+
clock: Callable[[], float]
|
|
43
|
+
#: The animation step the clock is at, which the presenters pace themselves by.
|
|
44
|
+
frame: int
|
|
45
|
+
#: What the last animation step that was taken drew; see :meth:`advance_frame`.
|
|
46
|
+
_picture_drawn: tuple[object, ...] | None
|
|
47
|
+
#: Where the tips under the waiting row come from; set by :class:`~.Transcript`.
|
|
48
|
+
tip_deck: TipDeck | None
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _is_open_row(entry: Entry) -> TypeGuard[ThinkingEntry | ToolEntry]:
|
|
52
|
+
"""Return whether an entry is a reasoning or tool row that is still running.
|
|
53
|
+
|
|
54
|
+
These are the rows a request leaves open when it ends mid-call, and the
|
|
55
|
+
ones a new wait row must not be opened beside. The wait row itself is not
|
|
56
|
+
one of them: it is settled by the request's end, with the line it ended on.
|
|
57
|
+
"""
|
|
58
|
+
return isinstance(entry, (ThinkingEntry, ToolEntry)) and entry.running
|
|
59
|
+
|
|
60
|
+
def user_message(self, text: str, *, side: bool = False) -> None:
|
|
61
|
+
"""Append a historical user message without opening a live wait row.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
text: What the reader asked.
|
|
65
|
+
side: The message was a side question, which the row shows as such.
|
|
66
|
+
"""
|
|
67
|
+
self._add(TextEntry(id=self._next_id, kind=EntryKind.USER, text=text, side=side))
|
|
68
|
+
|
|
69
|
+
def relayed_message(self, relayed: Relayed) -> None:
|
|
70
|
+
"""Append a message another session sent, without opening a wait row."""
|
|
71
|
+
self._add(
|
|
72
|
+
RelayEntry(
|
|
73
|
+
id=self._next_id,
|
|
74
|
+
peer=relayed.sender,
|
|
75
|
+
text=relayed.text,
|
|
76
|
+
outgoing=False,
|
|
77
|
+
by_agent=relayed.by_agent,
|
|
78
|
+
external=relayed.via == VIA_EXTERNAL,
|
|
79
|
+
group=relayed.group,
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def sent_message(self, peer: str, text: str, note: str = "") -> None:
|
|
84
|
+
"""Append a message this session's reader sent to another session.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
peer: The handle it was sent to.
|
|
88
|
+
text: What was sent.
|
|
89
|
+
note: What became of it, such as ``started`` or ``queued``.
|
|
90
|
+
"""
|
|
91
|
+
self._add(RelayEntry(id=self._next_id, peer=peer, text=text, outgoing=True, by_agent=False, note=note))
|
|
92
|
+
|
|
93
|
+
def begin_turn(self, prompt: str, *, side: bool = False, relayed: Relayed | None = None) -> None:
|
|
94
|
+
"""Record the user prompt and open the first wait row for the reply.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
prompt: What the reader asked, as it should appear in the transcript.
|
|
98
|
+
side: The question is a side one: answered here, never part of the
|
|
99
|
+
conversation the model carries afterwards.
|
|
100
|
+
relayed: The turn answers a message from another session, which is drawn as that
|
|
101
|
+
and not as something the reader typed.
|
|
102
|
+
"""
|
|
103
|
+
if relayed is not None:
|
|
104
|
+
self.relayed_message(relayed)
|
|
105
|
+
else:
|
|
106
|
+
self.user_message(prompt, side=side)
|
|
107
|
+
self.wait_for_model()
|
|
108
|
+
|
|
109
|
+
def wait_for_model(self, title: str = PROCESSING, *, tips: bool = True) -> bool:
|
|
110
|
+
"""Open the pinned wait row for a request, and report whether it opened.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
title: What the row says is happening; ``Processing`` for a request, and a command's own words
|
|
114
|
+
(``Recap · writing``) for work it started that will take a while.
|
|
115
|
+
tips: Attach the deck's tip for a model request. A command's row is about the command, so it takes
|
|
116
|
+
none.
|
|
117
|
+
|
|
118
|
+
Nothing is known about the response yet, and a call can take seconds, so
|
|
119
|
+
the row goes up as soon as the request starts and stays for all of it:
|
|
120
|
+
the loop calls the model again after every tool batch, and one row that
|
|
121
|
+
has been counting the whole time says more than a row per batch would.
|
|
122
|
+
"""
|
|
123
|
+
if self._open_wait() is not None:
|
|
124
|
+
return False
|
|
125
|
+
# A row an interrupted request left running is one more reason not to
|
|
126
|
+
# open a second: the transcript already says work is in progress, and
|
|
127
|
+
# the animation walks only the rows a request opens, so a stray row
|
|
128
|
+
# behind that range would never be brought up. The request's own end
|
|
129
|
+
# closes them (:meth:`finish_running_rows`), which is what keeps the
|
|
130
|
+
# range the whole truth; this is the backstop that depends on it.
|
|
131
|
+
if any(self._is_open_row(entry) for entry in self.entries):
|
|
132
|
+
return False
|
|
133
|
+
self._turn_from = len(self.entries)
|
|
134
|
+
shown = self.tip_deck.next() if tips and self.tip_deck is not None else ()
|
|
135
|
+
self._add(WaitEntry(id=self._next_id, title=title, started_at=self.clock(), tips=shown))
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
def summarize_changes(self, workspace: PurePath | None = None) -> bool:
|
|
139
|
+
"""Say which files the request changed, above the pinned row; say nothing when it changed none.
|
|
140
|
+
|
|
141
|
+
Called as the request ends, before the wait row is settled, so the line that says how long it
|
|
142
|
+
ran stays the last one. It covers the rows of this request only, and counts only calls that
|
|
143
|
+
succeeded.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
workspace: The directory paths are shown relative to.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Whether a summary was added.
|
|
150
|
+
"""
|
|
151
|
+
files = changed_files(self.entries[self._turn_from :], workspace)
|
|
152
|
+
if not files:
|
|
153
|
+
return False
|
|
154
|
+
self._add(ChangesEntry(id=self._next_id, files=files))
|
|
155
|
+
return True
|
|
156
|
+
|
|
157
|
+
def settle_wait(self, text: str, parts: tuple[str, ...] = ()) -> bool:
|
|
158
|
+
"""Close the pinned wait row with the line its request ended on.
|
|
159
|
+
|
|
160
|
+
The row is what the reader watched for the whole request, so it is where
|
|
161
|
+
the outcome belongs: it keeps its place at the bottom of the transcript
|
|
162
|
+
and its own timer becomes the elapsed time it reports. A row left running
|
|
163
|
+
— or removed, as one used to be as soon as the answer started — is how a
|
|
164
|
+
stopped request reads as one that said nothing at all.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
text: The line to leave in its place, such as
|
|
168
|
+
``Processed for 12s · 22:53``.
|
|
169
|
+
parts: The items of the line, each a segment of its own; the row's presenter joins them. When given,
|
|
170
|
+
they are the line and ``text`` is only what a reader of the row's data sees.
|
|
171
|
+
"""
|
|
172
|
+
row = self._open_wait()
|
|
173
|
+
if row is None:
|
|
174
|
+
return False
|
|
175
|
+
row.text = text
|
|
176
|
+
row.parts = parts
|
|
177
|
+
row.finish(EntryStatus.COMPLETED, self.clock())
|
|
178
|
+
self._changed(row)
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
def finish_restored_tools(self) -> None:
|
|
182
|
+
"""Mark tool calls without stored results as interrupted, not running."""
|
|
183
|
+
self.finish_running_rows("Result unavailable (session interrupted)")
|
|
184
|
+
|
|
185
|
+
def finish_running_rows(self, note: str) -> None:
|
|
186
|
+
"""Close every row a finished run left open, and say why.
|
|
187
|
+
|
|
188
|
+
A request that ends mid-call — stopped, failed, or restored from a
|
|
189
|
+
session whose turn never completed — leaves rows whose result will never
|
|
190
|
+
arrive. They keep their own timer up to the moment the request ended and
|
|
191
|
+
are marked skipped, so the transcript holds no row that still claims to
|
|
192
|
+
be working after nothing is. The wait row is not one of them: the shell
|
|
193
|
+
settles that one with the line the request ended on.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
note: What the body of an unfinished tool row is replaced with.
|
|
197
|
+
"""
|
|
198
|
+
changed = False
|
|
199
|
+
for index, entry in enumerate(self.entries):
|
|
200
|
+
if not self._is_open_row(entry):
|
|
201
|
+
continue
|
|
202
|
+
if isinstance(entry, ToolEntry) and not entry.text:
|
|
203
|
+
entry.text = note
|
|
204
|
+
entry.finish(EntryStatus.SKIPPED, self.clock())
|
|
205
|
+
self._touch(index)
|
|
206
|
+
changed = True
|
|
207
|
+
if changed:
|
|
208
|
+
self.version += 1
|
|
209
|
+
|
|
210
|
+
def advance_frame(self) -> bool:
|
|
211
|
+
"""Move the activity animation on, and bring every running timer up.
|
|
212
|
+
|
|
213
|
+
The waiting row is the one row that sweeps; a reasoning or tool row
|
|
214
|
+
above it is painted flat, so a step rebuilds it only because its timer
|
|
215
|
+
moved.
|
|
216
|
+
|
|
217
|
+
The walk covers the request's own rows and no more: a row that is not a
|
|
218
|
+
row at all can sit between two running ones — an approval or a command
|
|
219
|
+
notice lands beside the call it answered, and a batch's results can
|
|
220
|
+
arrive out of order — so every running row in the range is brought up,
|
|
221
|
+
and the range bounds the cost instead of the length of the session.
|
|
222
|
+
|
|
223
|
+
The counter is derived from the clock, one step per
|
|
224
|
+
:data:`ANIMATION_SECONDS`, rather than counted in terminal ticks: the
|
|
225
|
+
presenters pace their sweep and blink with it, so a terminal repainting
|
|
226
|
+
ten times a second and one repainting sixty times a second look the
|
|
227
|
+
same. A tick that falls inside the step it already sits on changes
|
|
228
|
+
nothing, and says so: every version bump makes the transcript view
|
|
229
|
+
measure every entry again, so the frame rate a busy shell paints at
|
|
230
|
+
must not decide how much work that is.
|
|
231
|
+
|
|
232
|
+
Most steps repeat the picture before them. A step is a hundred and twentieth of
|
|
233
|
+
a second, but the highlight sits on a whole column and crosses a word in a
|
|
234
|
+
second and a half, so it changes a few dozen times a second; the icon turns
|
|
235
|
+
over twice in a second and a half; a timer shows whole seconds. A step that lands on the picture already drawn is not
|
|
236
|
+
taken: the frame counter, the timers and the version stay where they were, the
|
|
237
|
+
cached rows stay valid, and the caller is told nothing changed, so the loop
|
|
238
|
+
can skip the frame it would have painted — the measured cost of painting every
|
|
239
|
+
one was a third of a core for a reader who was only waiting.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
Whether anything the reader can see is different from the last step taken.
|
|
243
|
+
"""
|
|
244
|
+
now = self.clock()
|
|
245
|
+
frame = int(now / ANIMATION_SECONDS)
|
|
246
|
+
if frame == self.frame:
|
|
247
|
+
return False
|
|
248
|
+
# One walk, which both the picture and the timers use: the rows of this request and
|
|
249
|
+
# no more, so what a step reads does not grow with the session.
|
|
250
|
+
running = [
|
|
251
|
+
(index, entry)
|
|
252
|
+
for index in range(self._turn_from, len(self.entries))
|
|
253
|
+
if isinstance(entry := self.entries[index], TimedEntry) and entry.running
|
|
254
|
+
]
|
|
255
|
+
self._measure_quiet(now, [entry for _, entry in running])
|
|
256
|
+
picture = self._picture(frame, now, [entry for _, entry in running])
|
|
257
|
+
if picture == self._picture_drawn:
|
|
258
|
+
return False
|
|
259
|
+
self._picture_drawn = picture
|
|
260
|
+
self.frame = frame
|
|
261
|
+
for index, entry in running:
|
|
262
|
+
entry.tick(now)
|
|
263
|
+
self._touch(index)
|
|
264
|
+
if running:
|
|
265
|
+
self.version += 1
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
def turn_said_nothing(self) -> bool:
|
|
269
|
+
"""Return whether the request that has just ended produced nothing a reader could see.
|
|
270
|
+
|
|
271
|
+
No answer, no tool, no reasoning: the model was asked and sent back an empty reply, which the stream reports
|
|
272
|
+
as a request that completed. Without a line saying so the reader is left with a row that says how long it
|
|
273
|
+
took and a conversation exactly as it was, which looks like the request was lost.
|
|
274
|
+
"""
|
|
275
|
+
wanted = (EntryKind.ANSWER, EntryKind.TOOL, EntryKind.THINKING, EntryKind.MESSAGE)
|
|
276
|
+
return not any(entry.kind in wanted for entry in self.entries[self._turn_from :])
|
|
277
|
+
|
|
278
|
+
def heard(self) -> None:
|
|
279
|
+
"""Note that the agent just said something, which is what ends a stretch of silence.
|
|
280
|
+
|
|
281
|
+
Called for every event the agent emits. It changes nothing a reader can see, so it moves no version.
|
|
282
|
+
"""
|
|
283
|
+
wait = self._open_wait()
|
|
284
|
+
if wait is not None:
|
|
285
|
+
wait.heard_at = self.clock()
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _measure_quiet(now: float, running: list[TimedEntry]) -> None:
|
|
289
|
+
"""Say how long the model has been silent, when the model is what the request is waiting for.
|
|
290
|
+
|
|
291
|
+
A request with a tool, a reasoning row or a compaction in flight is waiting for that, not for the model,
|
|
292
|
+
and a silence there is a command that takes a while: it says nothing then.
|
|
293
|
+
"""
|
|
294
|
+
waits = [entry for entry in running if isinstance(entry, WaitEntry)]
|
|
295
|
+
waiting_on_the_model = len(waits) == len(running)
|
|
296
|
+
for wait in waits:
|
|
297
|
+
since = wait.heard_at if wait.heard_at is not None else wait.started_at
|
|
298
|
+
wait.quiet = max(0.0, now - since) if waiting_on_the_model and since is not None else 0.0
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _picture(frame: int, now: float, running: list[TimedEntry]) -> tuple[object, ...]:
|
|
302
|
+
"""Return everything the running rows and the status icon would show at ``frame``.
|
|
303
|
+
|
|
304
|
+
The waiting row's highlight and rest, the icon's state, and — for every running
|
|
305
|
+
row — the text its timer reads. Equal pictures are drawn identically,
|
|
306
|
+
which is what lets a step that repeats one be skipped.
|
|
307
|
+
"""
|
|
308
|
+
shown: list[str] = []
|
|
309
|
+
wait: tuple[bool, int] = (False, 0)
|
|
310
|
+
for entry in running:
|
|
311
|
+
if entry.started_at is not None:
|
|
312
|
+
# What the row will *say*, not a number it is derived from: the wait row counts
|
|
313
|
+
# whole seconds and every other row counts tenths, and a signature that rounded
|
|
314
|
+
# the second to the first would freeze a tenths timer for as long as the rest of
|
|
315
|
+
# the picture holds still — a second at a time, which read as 0.1 jumping to 0.9.
|
|
316
|
+
elapsed = now - entry.started_at
|
|
317
|
+
shown.append(elapsed_text(elapsed) if isinstance(entry, WaitEntry) else duration_text(elapsed))
|
|
318
|
+
if isinstance(entry, WaitEntry):
|
|
319
|
+
wait = wait_picture(frame, display_width(entry.title))
|
|
320
|
+
# What the quiet clock will say, so a silence that grows is drawn as it grows and one that
|
|
321
|
+
# has not reached the threshold costs nothing.
|
|
322
|
+
shown.append(quiet_text(entry.quiet) or "")
|
|
323
|
+
return (wait, blink_state(frame), tuple(shown))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Running a command from inside the program: the history it offers, the words Tab completes, the process.
|
|
2
|
+
|
|
3
|
+
None of it draws anything. The screen is :class:`~zettcode.tui.ConsolePanel`, and
|
|
4
|
+
:mod:`zettcode.app.ui.widgets.bash_page` joins the two.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .completion import ShellCompleter
|
|
10
|
+
from .history import (
|
|
11
|
+
SOURCES,
|
|
12
|
+
BashHistory,
|
|
13
|
+
CommandHistory,
|
|
14
|
+
FishHistory,
|
|
15
|
+
HistorySource,
|
|
16
|
+
PlainHistory,
|
|
17
|
+
PowerShellHistory,
|
|
18
|
+
ZshHistory,
|
|
19
|
+
)
|
|
20
|
+
from .runner import (
|
|
21
|
+
CommandPromptLauncher,
|
|
22
|
+
CommandRunner,
|
|
23
|
+
PosixLauncher,
|
|
24
|
+
PowerShellLauncher,
|
|
25
|
+
RunResult,
|
|
26
|
+
ShellLauncher,
|
|
27
|
+
detect_launcher,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"SOURCES",
|
|
32
|
+
"BashHistory",
|
|
33
|
+
"CommandHistory",
|
|
34
|
+
"CommandPromptLauncher",
|
|
35
|
+
"CommandRunner",
|
|
36
|
+
"FishHistory",
|
|
37
|
+
"HistorySource",
|
|
38
|
+
"PlainHistory",
|
|
39
|
+
"PosixLauncher",
|
|
40
|
+
"PowerShellHistory",
|
|
41
|
+
"PowerShellLauncher",
|
|
42
|
+
"RunResult",
|
|
43
|
+
"ShellCompleter",
|
|
44
|
+
"ShellLauncher",
|
|
45
|
+
"ZshHistory",
|
|
46
|
+
"detect_launcher",
|
|
47
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""What the words after a program's name can be, for the programs whose arguments are not paths.
|
|
2
|
+
|
|
3
|
+
``git st`` is not a file name. A shell knows that because the shell's completion scripts know ``git``; this program
|
|
4
|
+
does not run those, so the knowledge a Tab needs lives here, one :class:`ArgumentCompleter` per program. The
|
|
5
|
+
completer for the whole line (:class:`~zettcode.app.commandline.ShellCompleter`) asks the one that claims the program
|
|
6
|
+
and falls back to paths and to the reader's own history when none does, which is all most programs need.
|
|
7
|
+
|
|
8
|
+
Adding a program is adding a subclass and naming it in :data:`ARGUMENTS`; nothing else knows which programs have
|
|
9
|
+
one.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import subprocess
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from zettcode.tui import CompletionItem
|
|
21
|
+
from zettcode.tui.widgets.console import COMMAND, REFERENCE
|
|
22
|
+
|
|
23
|
+
#: Seconds a helper program (``git``) may take to answer before the Tab goes on without it.
|
|
24
|
+
TIMEOUT = 2.0
|
|
25
|
+
|
|
26
|
+
#: Candidates one source offers at most.
|
|
27
|
+
LIMIT = 200
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class Suggestions:
|
|
32
|
+
"""What a program's completer offers for one word.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
items: The candidates, already filtered to the word typed.
|
|
36
|
+
exclusive: The slot takes nothing else: a path would be wrong here (the word after ``git`` is a subcommand),
|
|
37
|
+
so none is offered beside these.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
items: list[CompletionItem] = field(default_factory=list)
|
|
41
|
+
exclusive: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ArgumentCompleter(ABC):
|
|
45
|
+
"""The completion of one program's arguments.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
commands: The program names this completer answers for.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
commands: tuple[str, ...] = ()
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def suggest(self, words: Sequence[str], prefix: str, cwd: Path) -> Suggestions | None:
|
|
55
|
+
"""Return the candidates for the word being typed, or ``None`` when this completer has nothing to say.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
words: The words already typed in this command, the program first (``["git", "checkout"]``).
|
|
59
|
+
prefix: The word being typed, which may be empty.
|
|
60
|
+
cwd: The directory the command will run in.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def matching(names: Sequence[str], prefix: str, kind: str) -> list[CompletionItem]:
|
|
65
|
+
"""Return the names that begin with ``prefix`` ignoring case, those that begin with it exactly as typed first."""
|
|
66
|
+
folded = prefix.casefold()
|
|
67
|
+
found = [name for name in names if name.casefold().startswith(folded)]
|
|
68
|
+
found.sort(key=lambda name: not name.startswith(prefix))
|
|
69
|
+
return [CompletionItem(value=name, type=kind) for name in found[:LIMIT]]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class GitArguments(ArgumentCompleter):
|
|
73
|
+
"""git: the subcommands it has (aliases included), and the branches, tags and remotes the ones that take them.
|
|
74
|
+
|
|
75
|
+
The subcommands come from ``git --list-cmds``, so a newer git, a user's own ``alias.*`` and a ``git-foo`` on ``PATH``
|
|
76
|
+
are all there without this list knowing them. A name is only offered where git expects one: before the first
|
|
77
|
+
word that is not an option.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
commands = ("git",)
|
|
81
|
+
|
|
82
|
+
#: Subcommands whose arguments are, or may be, a branch, a tag or a commit-ish.
|
|
83
|
+
TAKES_REFS = frozenset(
|
|
84
|
+
{
|
|
85
|
+
"branch",
|
|
86
|
+
"checkout",
|
|
87
|
+
"cherry-pick",
|
|
88
|
+
"diff",
|
|
89
|
+
"fetch",
|
|
90
|
+
"log",
|
|
91
|
+
"merge",
|
|
92
|
+
"pull",
|
|
93
|
+
"push",
|
|
94
|
+
"rebase",
|
|
95
|
+
"reset",
|
|
96
|
+
"restore",
|
|
97
|
+
"revert",
|
|
98
|
+
"show",
|
|
99
|
+
"switch",
|
|
100
|
+
"tag",
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def __init__(self) -> None:
|
|
105
|
+
self._subcommands: list[str] | None = None
|
|
106
|
+
|
|
107
|
+
def suggest(self, words: Sequence[str], prefix: str, cwd: Path) -> Suggestions | None:
|
|
108
|
+
"""Offer subcommands in the first slot and references after a subcommand that takes them."""
|
|
109
|
+
if prefix.startswith("-"):
|
|
110
|
+
return None
|
|
111
|
+
positional = self._positional(words[1:])
|
|
112
|
+
if not positional:
|
|
113
|
+
return Suggestions(matching(self._commands(cwd), prefix, COMMAND), exclusive=True)
|
|
114
|
+
if positional[0] in self.TAKES_REFS:
|
|
115
|
+
return Suggestions(matching(self._references(cwd), prefix, REFERENCE))
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _positional(arguments: Sequence[str]) -> list[str]:
|
|
120
|
+
"""Return the arguments that are not options, nor the value of the two options that take one before the subcommand."""
|
|
121
|
+
found: list[str] = []
|
|
122
|
+
skip = False
|
|
123
|
+
for argument in arguments:
|
|
124
|
+
if skip:
|
|
125
|
+
skip = False
|
|
126
|
+
elif argument in ("-C", "-c") and not found:
|
|
127
|
+
skip = True
|
|
128
|
+
elif not argument.startswith("-"):
|
|
129
|
+
found.append(argument)
|
|
130
|
+
return found
|
|
131
|
+
|
|
132
|
+
def _commands(self, cwd: Path) -> list[str]:
|
|
133
|
+
"""Return git's subcommands, asked for once and kept."""
|
|
134
|
+
if self._subcommands is None:
|
|
135
|
+
# The ones a person types first, then the aliases, then the rest of what git offers by name.
|
|
136
|
+
groups = "list-mainporcelain,others,alias,nohelpers"
|
|
137
|
+
self._subcommands = run_git(cwd, f"--list-cmds={groups}") or run_git(
|
|
138
|
+
cwd, "--list-cmds=main,alias,nohelpers"
|
|
139
|
+
)
|
|
140
|
+
return self._subcommands
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _references(cwd: Path) -> list[str]:
|
|
144
|
+
"""Return the branches, remote branches and tags of the repository the command will run in."""
|
|
145
|
+
names = run_git(cwd, "for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes", "refs/tags")
|
|
146
|
+
return [name for name in names if not name.endswith("/HEAD")]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def run_git(cwd: Path, *arguments: str) -> list[str]:
|
|
150
|
+
"""Run ``git`` and return the lines it printed; empty when git is missing, slow, or this is not a repository."""
|
|
151
|
+
try:
|
|
152
|
+
done = subprocess.run(
|
|
153
|
+
["git", *arguments],
|
|
154
|
+
cwd=cwd,
|
|
155
|
+
capture_output=True,
|
|
156
|
+
text=True,
|
|
157
|
+
encoding="utf-8",
|
|
158
|
+
errors="replace",
|
|
159
|
+
timeout=TIMEOUT,
|
|
160
|
+
stdin=subprocess.DEVNULL,
|
|
161
|
+
check=False,
|
|
162
|
+
)
|
|
163
|
+
except (OSError, subprocess.SubprocessError):
|
|
164
|
+
return []
|
|
165
|
+
return [line for line in done.stdout.splitlines() if line] if done.returncode == 0 else []
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
#: The programs whose arguments are known, as the completer for a line consults them.
|
|
169
|
+
ARGUMENTS: tuple[type[ArgumentCompleter], ...] = (GitArguments,)
|