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