aicp-cli 0.3.0__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.
- aicp/__init__.py +18 -0
- aicp/_keyreader.py +210 -0
- aicp/_skills_data/commit.md +90 -0
- aicp/_skills_data/safe-git-push/SKILL.md +55 -0
- aicp/_skills_data/safe-git-push/scripts/safe_push.py +248 -0
- aicp/_utils.py +183 -0
- aicp/budget.py +158 -0
- aicp/cli.py +595 -0
- aicp/config.py +434 -0
- aicp/contracts.py +144 -0
- aicp/gitflow.py +456 -0
- aicp/i18n.py +228 -0
- aicp/menu.py +1013 -0
- aicp/notify.py +87 -0
- aicp/present.py +303 -0
- aicp/quota.py +115 -0
- aicp/runner.py +560 -0
- aicp/secrets.py +291 -0
- aicp/skills.py +679 -0
- aicp/timing.py +116 -0
- aicp_cli-0.3.0.dist-info/METADATA +298 -0
- aicp_cli-0.3.0.dist-info/RECORD +25 -0
- aicp_cli-0.3.0.dist-info/WHEEL +4 -0
- aicp_cli-0.3.0.dist-info/entry_points.txt +2 -0
- aicp_cli-0.3.0.dist-info/licenses/LICENSE +20 -0
aicp/menu.py
ADDED
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
"""``--config`` settings menu and ``--swap-ai`` picker.
|
|
2
|
+
|
|
3
|
+
Ported from ``_aicp_config``/``_aicp_config_fallback``/``_aicp_cfg_*`` and
|
|
4
|
+
``_aicp_swap_ai``/``_aicp_print_cli_order`` in ``~/scripts/bin/aicp``.
|
|
5
|
+
|
|
6
|
+
**Saves as you go.** Every pick writes to ``.aicprc`` immediately, through
|
|
7
|
+
:func:`~aicp.config.persist_key`, so there is no save key to forget and
|
|
8
|
+
quitting can never discard a change. That also means every write goes
|
|
9
|
+
through the same preserve-unknown-lines, temp-file-then-rename path the
|
|
10
|
+
config layer owns — this module never writes the file itself.
|
|
11
|
+
|
|
12
|
+
**Two surfaces, one row model.** An arrow-key TUI on a terminal and a
|
|
13
|
+
numbered typed-choice fallback anywhere else (a pipe, CI, this repo's
|
|
14
|
+
suite). The fallback is not a lesser copy bolted on: it is the reference
|
|
15
|
+
implementation's own design, and the surface the tests drive, since a
|
|
16
|
+
harness has no TTY to press keys on.
|
|
17
|
+
|
|
18
|
+
.. rubric:: Adding a row
|
|
19
|
+
|
|
20
|
+
:data:`ROWS` is data, not parallel ``case`` arms. To add a setting, append
|
|
21
|
+
one :class:`Row` to that tuple and nothing else changes: numbering, drawing,
|
|
22
|
+
prompt text ("1-N"), bounds checking and dispatch are all derived from the
|
|
23
|
+
tuple's length and each row's own callables. A row needs a config ``key``,
|
|
24
|
+
a ``group`` heading (``None`` to share the previous row's), ``label``/``help``
|
|
25
|
+
as ``(msgid, english)`` pairs, a ``value`` renderer and a ``cycle`` mutator
|
|
26
|
+
that returns the string to persist. Nothing in this module hardcodes a count.
|
|
27
|
+
|
|
28
|
+
The Skills and Doctor rows were added exactly that way, and are the reason
|
|
29
|
+
a row may carry an ``action`` instead of a ``cycle``: they *do* something
|
|
30
|
+
(install skills, print a health report) rather than persist a setting, and
|
|
31
|
+
they show live status in their value column. They are rows and not
|
|
32
|
+
subcommands on purpose — ``aicp`` and ``aicp --config`` are the only two
|
|
33
|
+
things this tool ever asks anyone to remember.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import contextlib
|
|
39
|
+
import io
|
|
40
|
+
import os
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
from collections.abc import Callable, Sequence
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import IO
|
|
47
|
+
|
|
48
|
+
from . import __version__, gitflow, i18n, skills
|
|
49
|
+
from ._keyreader import is_interactive, key_session, pending, read_key, read_line
|
|
50
|
+
from ._utils import BLUE, BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW, color_supported
|
|
51
|
+
|
|
52
|
+
# _KEY_RE and _SYSTEM_RESOLVED are read, not copied: the doctor reports on the
|
|
53
|
+
# loader's own verdict about a line, so it has to ask with the loader's own key
|
|
54
|
+
# rule, and has to name the keys that may never come from a file at all.
|
|
55
|
+
from .config import (
|
|
56
|
+
_KEY_RE,
|
|
57
|
+
_SYSTEM_RESOLVED,
|
|
58
|
+
DENYLIST,
|
|
59
|
+
Settings,
|
|
60
|
+
_read_json_object,
|
|
61
|
+
load_config,
|
|
62
|
+
persist_key,
|
|
63
|
+
resolve,
|
|
64
|
+
timeout_bin,
|
|
65
|
+
)
|
|
66
|
+
from .contracts import ROSTER
|
|
67
|
+
from .present import render_panel, width
|
|
68
|
+
|
|
69
|
+
__all__ = ["ROWS", "MenuState", "Row", "config_menu", "swap_ai"]
|
|
70
|
+
|
|
71
|
+
_ROSTER_NAMES: tuple[str, ...] = tuple(c.name for c in ROSTER)
|
|
72
|
+
#: What the AI CLI order row puts between two names, and the unit the order
|
|
73
|
+
#: animation slides by — one name plus one separator is exactly one rotation.
|
|
74
|
+
_SEPARATOR = " → "
|
|
75
|
+
_ELLIPSIS = "…"
|
|
76
|
+
_MIN_VALUE_COLUMNS = 12
|
|
77
|
+
#: Long enough to read as motion rather than a jump, short enough that it is
|
|
78
|
+
#: over before a held-down arrow key feels laggy.
|
|
79
|
+
_MOTION_SECONDS = 0.15
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _t(lang: str, msgid: str, english: str, *args: object) -> str:
|
|
83
|
+
""":func:`aicp.i18n.t` with the language passed in rather than resolved.
|
|
84
|
+
|
|
85
|
+
``i18n.t`` resolves ``AICP_LANG`` once at import time, which is right for
|
|
86
|
+
a one-shot run and wrong here: the language row has to repaint the menu
|
|
87
|
+
in the language just picked, in the same process, before anything else
|
|
88
|
+
happens. Same catalogue, same fallback — a missing msgid degrades to the
|
|
89
|
+
English at the call site, never to a blank line.
|
|
90
|
+
"""
|
|
91
|
+
text = i18n.CATALOG.get(msgid, english) if lang != "en" else english
|
|
92
|
+
return text % args if args else text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class MenuState:
|
|
97
|
+
"""The live values a menu session is editing, plus where they persist."""
|
|
98
|
+
|
|
99
|
+
path: Path
|
|
100
|
+
do_commit: bool
|
|
101
|
+
do_push: bool
|
|
102
|
+
lang: str
|
|
103
|
+
chain: list[str]
|
|
104
|
+
#: Probe caches for the two action rows. The panel repaints on every
|
|
105
|
+
#: keypress, and both of those rows show live status in their value
|
|
106
|
+
#: column — walking the filesystem and shelling out to git once per
|
|
107
|
+
#: repaint would be a real, per-keystroke cost. Probed once per session
|
|
108
|
+
#: instead, and dropped by the Skills action whenever it changes anything.
|
|
109
|
+
skills_status: list[skills.SkillStatus] | None = None
|
|
110
|
+
health: list[tuple[str, str]] | None = None
|
|
111
|
+
|
|
112
|
+
@classmethod
|
|
113
|
+
def from_settings(cls, settings: Settings) -> MenuState:
|
|
114
|
+
return cls(
|
|
115
|
+
path=settings.path,
|
|
116
|
+
do_commit=settings.do_commit,
|
|
117
|
+
do_push=settings.do_push,
|
|
118
|
+
lang=settings.lang,
|
|
119
|
+
chain=list(settings.cli_chain),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class Row:
|
|
125
|
+
"""One setting, or one action. See the module docstring for how to add one.
|
|
126
|
+
|
|
127
|
+
A row either *persists* (``key`` + ``cycle``) or *acts* (``action``, with
|
|
128
|
+
an empty ``key``): the Skills and Doctor rows run something and write no
|
|
129
|
+
setting at all. Everything else — numbering, the value column, bounds,
|
|
130
|
+
dispatch — is identical either way, which is the whole point of keeping
|
|
131
|
+
the menu a list of data rather than a switch statement.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
key: str
|
|
135
|
+
group: tuple[str, str] | None
|
|
136
|
+
label: tuple[str, str]
|
|
137
|
+
help: tuple[str, str]
|
|
138
|
+
value: Callable[[MenuState], str]
|
|
139
|
+
accent: Callable[[MenuState], str]
|
|
140
|
+
cycle: Callable[[MenuState, int], str] | None = None
|
|
141
|
+
#: ``(state, stdin, stdout)``. Must never block on a non-TTY stdin —
|
|
142
|
+
#: ``read_line`` returns ``None`` at EOF and every prompt here reads that
|
|
143
|
+
#: as "no", because aicp runs in CI.
|
|
144
|
+
action: Callable[[MenuState, IO[str], IO[str]], None] | None = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _on_off(state: MenuState, flag: bool) -> str:
|
|
148
|
+
return _t(state.lang, "config_on", "On") if flag else _t(state.lang, "config_off", "Off")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _toggle_commit(state: MenuState, _direction: int) -> str:
|
|
152
|
+
state.do_commit = not state.do_commit
|
|
153
|
+
return "1" if state.do_commit else "0"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _toggle_push(state: MenuState, _direction: int) -> str:
|
|
157
|
+
state.do_push = not state.do_push
|
|
158
|
+
return "1" if state.do_push else "0"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _toggle_lang(state: MenuState, _direction: int) -> str:
|
|
162
|
+
state.lang = "zh-TW" if state.lang == "en" else "en"
|
|
163
|
+
return state.lang
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _rotate_chain(state: MenuState, direction: int) -> str:
|
|
167
|
+
"""Rotation, not a swap: ``--swap-ai`` trades the pick with whoever holds
|
|
168
|
+
#1, which is right for "jump straight to this one" and wrong for an arrow
|
|
169
|
+
key, where pressing → down the list should walk the whole chain and come
|
|
170
|
+
back to where it started rather than ping-pong between two names."""
|
|
171
|
+
if direction > 0:
|
|
172
|
+
state.chain = [*state.chain[1:], state.chain[0]]
|
|
173
|
+
else:
|
|
174
|
+
state.chain = [state.chain[-1], *state.chain[:-1]]
|
|
175
|
+
return " ".join(state.chain)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ── Skills and Doctor: the two rows that act instead of persisting ───────────
|
|
179
|
+
|
|
180
|
+
_WARN = "⚠"
|
|
181
|
+
_OK = "✓"
|
|
182
|
+
_SKIP = "·"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _ask(state: MenuState, stdin: IO[str], out: IO[str], msgid: str, english: str, *args: object) -> bool:
|
|
186
|
+
"""A yes/no prompt that defaults to NO — including at EOF.
|
|
187
|
+
|
|
188
|
+
``read_line`` returns ``None`` on a dead or empty pipe, so an unattended
|
|
189
|
+
run answers "no" to everything instead of blocking: the safe default for
|
|
190
|
+
both questions asked here (install nothing, overwrite nothing).
|
|
191
|
+
"""
|
|
192
|
+
print(_t(state.lang, msgid, english, *args), file=out)
|
|
193
|
+
answer = read_line(stdin)
|
|
194
|
+
return answer is not None and answer.strip().lower() in ("y", "yes")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _skill_states(state: MenuState) -> list[skills.SkillStatus]:
|
|
198
|
+
if state.skills_status is None:
|
|
199
|
+
state.skills_status = skills.full_status()
|
|
200
|
+
return state.skills_status
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _skills_value(state: MenuState) -> str:
|
|
204
|
+
"""The Skills row's inline status, e.g. ``⚠ codex missing``."""
|
|
205
|
+
live = [s for s in _skill_states(state) if s.state != skills.NOT_INSTALLED]
|
|
206
|
+
if not live:
|
|
207
|
+
return _t(state.lang, "skills_none", "no CLI configured")
|
|
208
|
+
missing = sorted({s.cli for s in live if s.state == skills.MISSING})
|
|
209
|
+
older = sorted({s.cli for s in live if s.state == skills.OURS_OLDER})
|
|
210
|
+
if missing:
|
|
211
|
+
return _t(state.lang, "skills_missing", f"{_WARN} %s missing", ", ".join(missing))
|
|
212
|
+
if older:
|
|
213
|
+
return _t(state.lang, "skills_older", f"{_WARN} %s outdated", ", ".join(older))
|
|
214
|
+
if any(s.state == skills.FOREIGN for s in live):
|
|
215
|
+
return _t(state.lang, "skills_yours", f"{_OK} yours, kept")
|
|
216
|
+
return _t(state.lang, "skills_ok", f"{_OK} installed")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _skills_accent(state: MenuState) -> str:
|
|
220
|
+
return YELLOW if _WARN in _skills_value(state) else GREEN
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _report(state: MenuState, results: list[skills.InstallResult], out: IO[str]) -> None:
|
|
224
|
+
"""Say what actually happened — and never call a kept file a failure."""
|
|
225
|
+
texts = {
|
|
226
|
+
skills.INSTALLED: ("skills_installed", "%s installed into %s"),
|
|
227
|
+
skills.UPGRADED: ("skills_upgraded", "%s upgraded for %s"),
|
|
228
|
+
skills.KEPT: ("skills_kept", "%s: kept your own copy — aicp will use it"),
|
|
229
|
+
}
|
|
230
|
+
for result in results:
|
|
231
|
+
if result.action == skills.OVERWRITTEN:
|
|
232
|
+
print(
|
|
233
|
+
f" {GREEN}{_OK}{RESET} "
|
|
234
|
+
+ _t(
|
|
235
|
+
state.lang,
|
|
236
|
+
"skills_overwritten",
|
|
237
|
+
"%s replaced for %s — your copy is at %s",
|
|
238
|
+
result.skill,
|
|
239
|
+
result.cli,
|
|
240
|
+
result.backup,
|
|
241
|
+
),
|
|
242
|
+
file=out,
|
|
243
|
+
)
|
|
244
|
+
elif result.action in texts:
|
|
245
|
+
msgid, english = texts[result.action]
|
|
246
|
+
args = (
|
|
247
|
+
(result.skill, result.cli)
|
|
248
|
+
if result.action != skills.KEPT
|
|
249
|
+
else (f"{result.cli}/{result.skill}",)
|
|
250
|
+
)
|
|
251
|
+
print(f" {GREEN}{_OK}{RESET} " + _t(state.lang, msgid, english, *args), file=out)
|
|
252
|
+
# The install just changed what is on disk, so both rows' cached probes
|
|
253
|
+
# are stale — including the Doctor row's, which reports on skills too.
|
|
254
|
+
state.skills_status = None
|
|
255
|
+
state.health = None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _skills_action(state: MenuState, stdin: IO[str], out: IO[str]) -> None:
|
|
259
|
+
"""Per-CLI status, then the installer's own two questions.
|
|
260
|
+
|
|
261
|
+
Both default to no. The second one only exists because a same-name skill
|
|
262
|
+
with no aicp sidecar is the user's own file: aicp keeps it, and says so
|
|
263
|
+
as the good outcome it is.
|
|
264
|
+
"""
|
|
265
|
+
every = _skill_states(state)
|
|
266
|
+
live = [s for s in every if s.state != skills.NOT_INSTALLED]
|
|
267
|
+
labels = {
|
|
268
|
+
skills.MISSING: (_WARN, YELLOW, "skills_state_missing", "not installed yet"),
|
|
269
|
+
skills.FOREIGN: (_OK, GREEN, "skills_state_foreign", "your own file — aicp will use it"),
|
|
270
|
+
}
|
|
271
|
+
print(file=out)
|
|
272
|
+
# The Skills row's own label, on purpose: one word, one msgid. A second id
|
|
273
|
+
# for the same English is how a report and the row it belongs to drift.
|
|
274
|
+
print(_t(state.lang, "config_skills", "Skills"), file=out)
|
|
275
|
+
for status in live:
|
|
276
|
+
if status.state == skills.CURRENT:
|
|
277
|
+
mark, color, text = _OK, GREEN, _t(state.lang, "skills_state_current", "installed (%s)", status.version)
|
|
278
|
+
elif status.state == skills.OURS_OLDER:
|
|
279
|
+
mark, color, text = _WARN, YELLOW, _t(
|
|
280
|
+
state.lang, "skills_state_older", "from an older aicp (%s) — can be upgraded", status.version
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
mark, color, msgid, english = labels[status.state]
|
|
284
|
+
text = _t(state.lang, msgid, english)
|
|
285
|
+
print(f" {color}{mark}{RESET} {status.cli:<8} {status.skill:<14} {text}", file=out)
|
|
286
|
+
# One line for every CLI that has no config dir, rather than two rows
|
|
287
|
+
# each: they are not a problem to solve, and on a six-CLI roster they
|
|
288
|
+
# would otherwise be most of the list.
|
|
289
|
+
skipped = sorted({s.cli for s in every if s.state == skills.NOT_INSTALLED})
|
|
290
|
+
if skipped:
|
|
291
|
+
print(
|
|
292
|
+
f" {DIM}{_SKIP} "
|
|
293
|
+
+ _t(state.lang, "skills_skipped", "not installed here: %s", ", ".join(skipped))
|
|
294
|
+
+ f"{RESET}",
|
|
295
|
+
file=out,
|
|
296
|
+
)
|
|
297
|
+
print(file=out)
|
|
298
|
+
|
|
299
|
+
stale = [s for s in live if s.state in (skills.MISSING, skills.OURS_OLDER)]
|
|
300
|
+
foreign = [s for s in live if s.state == skills.FOREIGN]
|
|
301
|
+
if stale and _ask(
|
|
302
|
+
state, stdin, out, "skills_install_q", "Install/upgrade %s skill(s)? [y/N]: ", len(stale)
|
|
303
|
+
):
|
|
304
|
+
_report(state, skills.install(), out)
|
|
305
|
+
if foreign:
|
|
306
|
+
print(
|
|
307
|
+
f"{DIM}"
|
|
308
|
+
+ _t(
|
|
309
|
+
state.lang,
|
|
310
|
+
"skills_keep_note",
|
|
311
|
+
"Keeping your own %s skill(s) — aicp will use them, which is fine.",
|
|
312
|
+
len(foreign),
|
|
313
|
+
)
|
|
314
|
+
+ f"{RESET}",
|
|
315
|
+
file=out,
|
|
316
|
+
)
|
|
317
|
+
if _ask(
|
|
318
|
+
state,
|
|
319
|
+
stdin,
|
|
320
|
+
out,
|
|
321
|
+
"skills_force_q",
|
|
322
|
+
"Replace them with aicp's copies? Yours are backed up first [y/N]: ",
|
|
323
|
+
):
|
|
324
|
+
_report(state, skills.install(force=True), out)
|
|
325
|
+
if not stale and not foreign:
|
|
326
|
+
print(f"{DIM}" + _t(state.lang, "skills_nothing", "Nothing to do.") + f"{RESET}", file=out)
|
|
327
|
+
print(file=out)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _config_health(state: MenuState) -> list[tuple[str, str]]:
|
|
331
|
+
"""Which ``config.json`` keys the loader refused — otherwise entirely
|
|
332
|
+
silent.
|
|
333
|
+
|
|
334
|
+
Derived from :func:`~aicp.config.load_config`'s own verdict rather than a
|
|
335
|
+
second copy of its rules: a key that is in the file and not in what it
|
|
336
|
+
returned is, by definition, a key that did not take effect.
|
|
337
|
+
"""
|
|
338
|
+
if not state.path.exists():
|
|
339
|
+
return [(_OK, _t(state.lang, "health_no_config", "no %s yet — built-in defaults apply", state.path))]
|
|
340
|
+
raw = _read_json_object(state.path)
|
|
341
|
+
# The loader announces a denied key on stderr as it goes; this report is
|
|
342
|
+
# about to say the same thing in the panel, so the second copy is noise —
|
|
343
|
+
# and the panel repaints, which would print it again on every keypress.
|
|
344
|
+
with contextlib.redirect_stderr(io.StringIO()):
|
|
345
|
+
accepted = load_config(state.path)
|
|
346
|
+
refused = [key for key in raw if _KEY_RE.match(key) and key not in accepted]
|
|
347
|
+
if not refused:
|
|
348
|
+
return [(_OK, _t(state.lang, "health_config_ok", "%s: %s setting(s) read", state.path.name, len(accepted)))]
|
|
349
|
+
return [
|
|
350
|
+
(
|
|
351
|
+
_WARN,
|
|
352
|
+
_t(
|
|
353
|
+
state.lang,
|
|
354
|
+
"health_env_only",
|
|
355
|
+
"%s is ignored in %s — that knob is environment/PATH only",
|
|
356
|
+
key,
|
|
357
|
+
state.path.name,
|
|
358
|
+
)
|
|
359
|
+
if key in DENYLIST or key in _SYSTEM_RESOLVED
|
|
360
|
+
else _t(
|
|
361
|
+
state.lang,
|
|
362
|
+
"health_dropped",
|
|
363
|
+
"%s was dropped from %s — its value has characters that are not allowed",
|
|
364
|
+
key,
|
|
365
|
+
state.path.name,
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
for key in refused
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _skills_health(state: MenuState) -> list[tuple[str, str]]:
|
|
373
|
+
live = [s for s in _skill_states(state) if s.state != skills.NOT_INSTALLED]
|
|
374
|
+
if not live:
|
|
375
|
+
return [
|
|
376
|
+
(_OK, _t(state.lang, "health_skills_none", "no AI CLI config dir found — nothing to install skills into"))
|
|
377
|
+
]
|
|
378
|
+
lines = [
|
|
379
|
+
(
|
|
380
|
+
_WARN if status.state in (skills.MISSING, skills.OURS_OLDER) else _OK,
|
|
381
|
+
_t(
|
|
382
|
+
state.lang,
|
|
383
|
+
{
|
|
384
|
+
skills.MISSING: "health_skill_missing",
|
|
385
|
+
skills.OURS_OLDER: "health_skill_older",
|
|
386
|
+
skills.FOREIGN: "health_skill_foreign",
|
|
387
|
+
}[status.state],
|
|
388
|
+
{
|
|
389
|
+
skills.MISSING: "%s: the %s skill is not installed — the Skills row installs it",
|
|
390
|
+
skills.OURS_OLDER: "%s: the %s skill is from an older aicp — the Skills row upgrades it",
|
|
391
|
+
skills.FOREIGN: "%s: the %s skill is your own — aicp will use it",
|
|
392
|
+
}[status.state],
|
|
393
|
+
status.cli,
|
|
394
|
+
status.skill,
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
for status in live
|
|
398
|
+
if status.state != skills.CURRENT
|
|
399
|
+
]
|
|
400
|
+
return lines or [(_OK, _t(state.lang, "health_skills_ok", "skills are installed for every configured CLI"))]
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _git_health(state: MenuState) -> list[tuple[str, str]]:
|
|
404
|
+
branch = gitflow.current_branch()
|
|
405
|
+
if branch is None:
|
|
406
|
+
return [
|
|
407
|
+
(
|
|
408
|
+
_WARN,
|
|
409
|
+
_t(
|
|
410
|
+
state.lang,
|
|
411
|
+
"health_no_branch",
|
|
412
|
+
"not on a branch here (detached HEAD, or not a git repo) — aicp needs one to push",
|
|
413
|
+
),
|
|
414
|
+
)
|
|
415
|
+
]
|
|
416
|
+
# gitflow._out is this project's one git-subprocess call site (it already
|
|
417
|
+
# swallows a missing git and a vanished cwd); a second copy here would be
|
|
418
|
+
# the same three lines with worse error handling.
|
|
419
|
+
if not gitflow._out(None, "remote"):
|
|
420
|
+
return [
|
|
421
|
+
(_WARN, _t(state.lang, "health_no_remote", "this repo has no git remote — nothing to push to"))
|
|
422
|
+
]
|
|
423
|
+
return [(_OK, _t(state.lang, "health_git_ok", "on %s, remote %s", branch, gitflow.remote_for(branch)))]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _health(state: MenuState) -> list[tuple[str, str]]:
|
|
427
|
+
"""Everything that is otherwise silent, as ``(mark, message)`` pairs."""
|
|
428
|
+
if state.health is None:
|
|
429
|
+
binary = timeout_bin()
|
|
430
|
+
state.health = [
|
|
431
|
+
(_OK, _t(state.lang, "health_timeout_ok", "per-CLI timeout uses %s", binary))
|
|
432
|
+
if binary
|
|
433
|
+
else (
|
|
434
|
+
_WARN,
|
|
435
|
+
_t(
|
|
436
|
+
state.lang,
|
|
437
|
+
"health_timeout_missing",
|
|
438
|
+
"no timeout/gtimeout on PATH — each AI CLI call runs with no time limit",
|
|
439
|
+
),
|
|
440
|
+
),
|
|
441
|
+
*_config_health(state),
|
|
442
|
+
*_skills_health(state),
|
|
443
|
+
*_git_health(state),
|
|
444
|
+
]
|
|
445
|
+
return state.health
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _doctor_value(state: MenuState) -> str:
|
|
449
|
+
"""The Doctor row's inline status, e.g. ``⚠ 3 warnings``."""
|
|
450
|
+
count = sum(1 for mark, _ in _health(state) if mark == _WARN)
|
|
451
|
+
if not count:
|
|
452
|
+
return _t(state.lang, "doctor_clear", f"{_OK} all clear")
|
|
453
|
+
if count == 1:
|
|
454
|
+
return _t(state.lang, "doctor_one", f"{_WARN} 1 warning")
|
|
455
|
+
return _t(state.lang, "doctor_many", f"{_WARN} %s warnings", count)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _doctor_action(state: MenuState, _stdin: IO[str], out: IO[str]) -> None:
|
|
459
|
+
"""Print the report. Asks nothing, so there is nothing to block on."""
|
|
460
|
+
lines = _health(state)
|
|
461
|
+
print(file=out)
|
|
462
|
+
print(_t(state.lang, "config_doctor", "Health check"), file=out) # the row's own label
|
|
463
|
+
for mark, text in lines:
|
|
464
|
+
print(f" {YELLOW if mark == _WARN else GREEN}{mark}{RESET} {text}", file=out)
|
|
465
|
+
if any(mark == _WARN for mark, _ in lines):
|
|
466
|
+
print(
|
|
467
|
+
f"{DIM}"
|
|
468
|
+
+ _t(
|
|
469
|
+
state.lang,
|
|
470
|
+
"health_footer",
|
|
471
|
+
"Warnings are things to know about, not failures — aicp runs either way.",
|
|
472
|
+
)
|
|
473
|
+
+ f"{RESET}",
|
|
474
|
+
file=out,
|
|
475
|
+
)
|
|
476
|
+
print(file=out)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
#: The menu, in display order. Append to extend — see the module docstring.
|
|
480
|
+
ROWS: tuple[Row, ...] = (
|
|
481
|
+
Row(
|
|
482
|
+
key="AICP_DO_COMMIT",
|
|
483
|
+
group=("config_group_steps", "Steps"),
|
|
484
|
+
label=("config_do_commit", "Run the /commit step"),
|
|
485
|
+
help=(
|
|
486
|
+
"config_help_commit",
|
|
487
|
+
"Off: never stage or commit — only push what is already committed.",
|
|
488
|
+
),
|
|
489
|
+
value=lambda s: _on_off(s, s.do_commit),
|
|
490
|
+
accent=lambda s: GREEN if s.do_commit else DIM,
|
|
491
|
+
cycle=_toggle_commit,
|
|
492
|
+
),
|
|
493
|
+
Row(
|
|
494
|
+
key="AICP_DO_PUSH",
|
|
495
|
+
group=None,
|
|
496
|
+
label=("config_do_push", "Run the /safe-git-push step"),
|
|
497
|
+
help=(
|
|
498
|
+
"config_help_push",
|
|
499
|
+
"Off: commit and stop, leaving the branch ahead of its remote.",
|
|
500
|
+
),
|
|
501
|
+
value=lambda s: _on_off(s, s.do_push),
|
|
502
|
+
accent=lambda s: GREEN if s.do_push else DIM,
|
|
503
|
+
cycle=_toggle_push,
|
|
504
|
+
),
|
|
505
|
+
Row(
|
|
506
|
+
key="AICP_LANG",
|
|
507
|
+
group=("config_group_general", "General"),
|
|
508
|
+
# Named in its own script in both languages, so the row stays
|
|
509
|
+
# readable to someone who just switched to a language they cannot
|
|
510
|
+
# read — the one moment they most need to find this row again.
|
|
511
|
+
label=("config_language", "Language"),
|
|
512
|
+
help=(
|
|
513
|
+
"config_help_lang",
|
|
514
|
+
"Language of every message, Telegram notifications included.",
|
|
515
|
+
),
|
|
516
|
+
value=lambda s: "繁體中文" if s.lang == "zh-TW" else "English",
|
|
517
|
+
accent=lambda _s: CYAN,
|
|
518
|
+
cycle=_toggle_lang,
|
|
519
|
+
),
|
|
520
|
+
Row(
|
|
521
|
+
key="AICP_CLI_ORDER",
|
|
522
|
+
group=None,
|
|
523
|
+
label=("config_cli_order", "AI CLI order"),
|
|
524
|
+
help=(
|
|
525
|
+
"config_help_cli",
|
|
526
|
+
"Full fallback order, tried left to right; ←/→ rotates it. Missing CLIs are skipped.",
|
|
527
|
+
),
|
|
528
|
+
value=lambda s: _SEPARATOR.join(s.chain),
|
|
529
|
+
accent=lambda _s: CYAN,
|
|
530
|
+
cycle=_rotate_chain,
|
|
531
|
+
),
|
|
532
|
+
# Rows, not subcommands: only `aicp` and `aicp --config` are ever meant to
|
|
533
|
+
# be memorised, so skills management and the health check live here.
|
|
534
|
+
Row(
|
|
535
|
+
key="",
|
|
536
|
+
group=("config_group_tools", "Tools"),
|
|
537
|
+
label=("config_skills", "Skills"),
|
|
538
|
+
help=(
|
|
539
|
+
"config_help_skills",
|
|
540
|
+
"Installs aicp's /commit and /safe-git-push into each AI CLI. Your own files are kept.",
|
|
541
|
+
),
|
|
542
|
+
value=_skills_value,
|
|
543
|
+
accent=_skills_accent,
|
|
544
|
+
action=_skills_action,
|
|
545
|
+
),
|
|
546
|
+
Row(
|
|
547
|
+
key="",
|
|
548
|
+
group=None,
|
|
549
|
+
label=("config_doctor", "Health check"),
|
|
550
|
+
help=(
|
|
551
|
+
"config_help_doctor",
|
|
552
|
+
"What is otherwise silent: timeout, skipped config.json keys, skills, git remote.",
|
|
553
|
+
),
|
|
554
|
+
value=_doctor_value,
|
|
555
|
+
accent=lambda s: YELLOW if _WARN in _doctor_value(s) else GREEN,
|
|
556
|
+
action=_doctor_action,
|
|
557
|
+
),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _terminal_size(out: IO[str]) -> os.terminal_size:
|
|
562
|
+
"""The terminal *out* draws on; 80×24 when it is not one.
|
|
563
|
+
|
|
564
|
+
Measured from the stream being written to rather than through
|
|
565
|
+
``shutil.get_terminal_size``: the repaint arithmetic below is only
|
|
566
|
+
correct against the surface it actually draws on, and a captured stdout
|
|
567
|
+
(a pipe, this project's suite) has to resolve the same way on every run
|
|
568
|
+
instead of inheriting whatever terminal happened to launch it.
|
|
569
|
+
"""
|
|
570
|
+
try:
|
|
571
|
+
return os.get_terminal_size(out.fileno())
|
|
572
|
+
except (AttributeError, OSError, ValueError):
|
|
573
|
+
return os.terminal_size((80, 24))
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _fit(text: str, columns: int) -> str:
|
|
577
|
+
"""*text* clipped to *columns* visible columns, ending in … when clipped."""
|
|
578
|
+
if width(text) <= columns:
|
|
579
|
+
return text
|
|
580
|
+
kept: list[str] = []
|
|
581
|
+
used = 0
|
|
582
|
+
for char in text:
|
|
583
|
+
used += width(char)
|
|
584
|
+
if used > columns - 1:
|
|
585
|
+
break
|
|
586
|
+
kept.append(char)
|
|
587
|
+
return "".join(kept) + _ELLIPSIS
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _fit_columns(state: MenuState, out: IO[str]) -> tuple[int, int]:
|
|
591
|
+
"""``(frame, value)`` column budgets for a panel that fits this terminal.
|
|
592
|
+
|
|
593
|
+
The panel sizes itself to its content, and that content is routinely
|
|
594
|
+
wider than the 80 columns a terminal gives by default: the Chinese help
|
|
595
|
+
line and the six-CLI chain put it at 87-89. A frame wider than the
|
|
596
|
+
terminal is not merely ugly — every one of its lines wraps, so a frame
|
|
597
|
+
with 15 lines occupies 30 rows, the repaint below walks the cursor up 15
|
|
598
|
+
and lands in the middle of its own last frame, and each keypress leaves
|
|
599
|
+
another half-frame behind until the screen is a column of borders. So
|
|
600
|
+
the content is fitted to the terminal first and the repaint arithmetic
|
|
601
|
+
stays exact.
|
|
602
|
+
"""
|
|
603
|
+
frame = _terminal_size(out).columns - 2
|
|
604
|
+
labels = [f" {i}) {_t(state.lang, *row.label)}" for i, row in enumerate(ROWS, start=1)]
|
|
605
|
+
labels += [_t(state.lang, *row.group) for row in ROWS if row.group]
|
|
606
|
+
return frame, max(frame - 6 - max(width(label) for label in labels), _MIN_VALUE_COLUMNS)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _panel(
|
|
610
|
+
state: MenuState,
|
|
611
|
+
selected: int | None = None,
|
|
612
|
+
out: IO[str] | None = None,
|
|
613
|
+
order_value: str | None = None,
|
|
614
|
+
) -> list[str]:
|
|
615
|
+
"""The framed settings box, numbered for the typed-choice surface.
|
|
616
|
+
|
|
617
|
+
Always titled with aicp's own version, so a bug report or a screenshot
|
|
618
|
+
names the build it came from. ``selected`` (only ever given by the
|
|
619
|
+
arrow-key TUI, which is the one surface with a single current row) adds
|
|
620
|
+
two more lines inside the frame: that row's own help text — 各項目說明,
|
|
621
|
+
reusing exactly the ``help`` every row already carries, never a second
|
|
622
|
+
copy of it — and the arrow-key hint (操作說明). The numbered fallback has
|
|
623
|
+
no single current row, so it gets only the digit-choice hint instead.
|
|
624
|
+
|
|
625
|
+
``order_value`` swaps in an already-rendered motion frame for the AI CLI
|
|
626
|
+
order row (see :func:`_order_motion`); everything else about the panel is
|
|
627
|
+
drawn exactly as it is at rest, so a frame mid-slide is the same shape as
|
|
628
|
+
the frame it settles into.
|
|
629
|
+
"""
|
|
630
|
+
out = out if out is not None else sys.stdout
|
|
631
|
+
frame_columns, value_columns = _fit_columns(state, out)
|
|
632
|
+
rows: list[tuple[str, str]] = []
|
|
633
|
+
for i, row in enumerate(ROWS, start=1):
|
|
634
|
+
if row.group:
|
|
635
|
+
rows.append((_t(state.lang, *row.group), ""))
|
|
636
|
+
marker = "›" if selected == i else " "
|
|
637
|
+
value = (
|
|
638
|
+
order_value
|
|
639
|
+
if order_value is not None and row.key == "AICP_CLI_ORDER"
|
|
640
|
+
else _fit(row.value(state), value_columns)
|
|
641
|
+
)
|
|
642
|
+
label = f"{marker} {i}) {_t(state.lang, *row.label)}"
|
|
643
|
+
# The cursor row stands out by weight, not a new hue: RESET cancels
|
|
644
|
+
# render_panel's own DIM before BOLD applies, matching the group
|
|
645
|
+
# headings above and keeping every hue's existing meaning intact.
|
|
646
|
+
if selected == i:
|
|
647
|
+
label = f"{RESET}{BOLD}{label}{RESET}"
|
|
648
|
+
rows.append(
|
|
649
|
+
(
|
|
650
|
+
label,
|
|
651
|
+
f"{row.accent(state)}{value}{RESET}",
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
title = f"{_t(state.lang, 'config_title', 'aicp config')} (v{__version__})"
|
|
655
|
+
if selected is not None:
|
|
656
|
+
texts = [
|
|
657
|
+
_t(state.lang, *ROWS[selected - 1].help),
|
|
658
|
+
"",
|
|
659
|
+
_t(state.lang, "config_keys_tui", "↑↓ select · ←→ change · ⏎ change/run · q/Ctrl-C quit · saves as you go"),
|
|
660
|
+
]
|
|
661
|
+
else:
|
|
662
|
+
texts = [_t(state.lang, "config_keys_plain", "1-%s change · q quit · saves as you go", len(ROWS))]
|
|
663
|
+
notes = [f"{DIM}{_fit(text, frame_columns - 4)}{RESET}" for text in texts]
|
|
664
|
+
# A frame taller than the terminal cannot be repainted in place either —
|
|
665
|
+
# its top scrolls off, and the cursor can never walk back up to it. The
|
|
666
|
+
# notes are what a short window gives up, help text first: the key hints
|
|
667
|
+
# are the line someone stuck in an unfamiliar menu actually needs.
|
|
668
|
+
while notes and len(rows) + len(notes) + 4 > _terminal_size(out).lines:
|
|
669
|
+
notes.pop(0)
|
|
670
|
+
return render_panel(rows, title, BLUE, notes=notes)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _write(
|
|
674
|
+
state: MenuState,
|
|
675
|
+
row: Row,
|
|
676
|
+
direction: int,
|
|
677
|
+
stdin: IO[str],
|
|
678
|
+
out: IO[str],
|
|
679
|
+
typed: Callable[[], contextlib.AbstractContextManager[None]] = contextlib.nullcontext,
|
|
680
|
+
) -> bool:
|
|
681
|
+
if row.action is not None:
|
|
682
|
+
# An action row asks its questions with read_line, which needs the
|
|
683
|
+
# line discipline the arrow-key session holds suspended.
|
|
684
|
+
with typed():
|
|
685
|
+
row.action(state, stdin, out)
|
|
686
|
+
return True # an action row has nothing to persist
|
|
687
|
+
if row.cycle is None: # a row is either cycle or action, never neither
|
|
688
|
+
return True
|
|
689
|
+
value = row.cycle(state, direction)
|
|
690
|
+
if persist_key(row.key, value, state.path):
|
|
691
|
+
# Every persisting row lands here, and the Doctor row reports on the
|
|
692
|
+
# very file that was just written — so the invalidation belongs at
|
|
693
|
+
# this one choke point, not next to whichever row happened to change
|
|
694
|
+
# something (which is how it went stale the first time).
|
|
695
|
+
state.health = None
|
|
696
|
+
return True
|
|
697
|
+
print(
|
|
698
|
+
f"{RED}{_t(state.lang, 'persist_failed', '✗ failed to write %s', state.path)}{RESET}",
|
|
699
|
+
file=out,
|
|
700
|
+
)
|
|
701
|
+
return False
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def config_menu(
|
|
705
|
+
*,
|
|
706
|
+
settings: Settings | None = None,
|
|
707
|
+
path: Path | None = None,
|
|
708
|
+
stdin: IO[str] | None = None,
|
|
709
|
+
stdout: IO[str] | None = None,
|
|
710
|
+
) -> int:
|
|
711
|
+
"""Run the settings menu; 0 on a clean exit, 1 on a failed write."""
|
|
712
|
+
state = MenuState.from_settings(settings or resolve())
|
|
713
|
+
if path is not None:
|
|
714
|
+
state.path = path
|
|
715
|
+
out = stdout or sys.stdout
|
|
716
|
+
stream = stdin or sys.stdin
|
|
717
|
+
if is_interactive(stream, out):
|
|
718
|
+
return _tui(state, stream, out)
|
|
719
|
+
return _numbered(state, stream, out)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _numbered(state: MenuState, stdin: IO[str], out: IO[str]) -> int:
|
|
723
|
+
"""Typed-choice surface. EOF is "quit" — never a block, so CI is safe."""
|
|
724
|
+
while True:
|
|
725
|
+
for line in _panel(state, out=out):
|
|
726
|
+
print(line, file=out)
|
|
727
|
+
print(
|
|
728
|
+
_t(
|
|
729
|
+
state.lang,
|
|
730
|
+
"config_prompt",
|
|
731
|
+
"Pick a setting to change (1-%s, q to quit): ",
|
|
732
|
+
len(ROWS),
|
|
733
|
+
),
|
|
734
|
+
file=out,
|
|
735
|
+
)
|
|
736
|
+
choice = read_line(stdin)
|
|
737
|
+
if choice is None or choice == "" or choice.lower() == "q":
|
|
738
|
+
return 0
|
|
739
|
+
if choice.isdigit() and 1 <= int(choice) <= len(ROWS):
|
|
740
|
+
if not _write(state, ROWS[int(choice) - 1], 1, stdin, out):
|
|
741
|
+
return 1
|
|
742
|
+
else:
|
|
743
|
+
print(
|
|
744
|
+
f"{RED}{_t(state.lang, 'config_bad_number', '⚠ Enter one of the setting numbers shown above.')}{RESET}",
|
|
745
|
+
file=out,
|
|
746
|
+
)
|
|
747
|
+
print(file=out)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _tape(before: Sequence[str], after: Sequence[str]) -> tuple[str, int, int]:
|
|
751
|
+
"""The order before and after a rotation, laid end to end, plus the window
|
|
752
|
+
offsets that read as each of them.
|
|
753
|
+
|
|
754
|
+
A rotation moves every name along by one slot and wraps the one that
|
|
755
|
+
falls off the end back round to the other side. Writing the name that
|
|
756
|
+
wraps next to the order it left makes a strip where **one window of it is
|
|
757
|
+
always a real order**, and sliding that window by one name turns the old
|
|
758
|
+
order into the new one — every other name displaced on the way, the
|
|
759
|
+
wrapping one leaving by one edge and arriving at the other. That is the
|
|
760
|
+
entire animation: one strip, one offset, no per-name bookkeeping.
|
|
761
|
+
"""
|
|
762
|
+
if after[0] == before[1]: # → : the chain walks left, the head wraps to the tail
|
|
763
|
+
return _SEPARATOR.join((*before, before[0])), 0, width(before[0] + _SEPARATOR)
|
|
764
|
+
return _SEPARATOR.join((after[0], *before)), width(after[0] + _SEPARATOR), 0
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _lit(piece: str, bright: Sequence[bool], accent: str) -> str:
|
|
768
|
+
"""*piece* with the *bright* columns picked out, back to *accent* after."""
|
|
769
|
+
parts: list[str] = []
|
|
770
|
+
on = False
|
|
771
|
+
for char, hot in zip(piece, bright):
|
|
772
|
+
if hot != on:
|
|
773
|
+
parts.append(f"{GREEN}{BOLD}" if hot else f"{RESET}{accent}")
|
|
774
|
+
on = hot
|
|
775
|
+
parts.append(char)
|
|
776
|
+
return "".join(parts) + (f"{RESET}{accent}" if on else "")
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _eased(progress: float) -> float:
|
|
780
|
+
"""How much of the slide's time is spent by the time it is *progress*
|
|
781
|
+
along. A shallow curve: the chain leaves briskly and eases into its new
|
|
782
|
+
order instead of stopping dead on the last column."""
|
|
783
|
+
return 1 - (1 - progress) ** (2 / 3)
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _order_motion(
|
|
787
|
+
before: Sequence[str], after: Sequence[str], columns: int, accent: str
|
|
788
|
+
) -> list[tuple[str, float]]:
|
|
789
|
+
"""The chain sliding *before* → *after*: one frame per column it travels,
|
|
790
|
+
each paired with how long to hold it.
|
|
791
|
+
|
|
792
|
+
One column per frame is what makes this read as motion rather than as a
|
|
793
|
+
jump, so the easing lives in the timing and not in the distance — a
|
|
794
|
+
curve applied to the offset instead would round several frames onto the
|
|
795
|
+
same column and stall there. Every frame is exactly *columns* wide, so
|
|
796
|
+
the panel never changes shape mid-slide.
|
|
797
|
+
"""
|
|
798
|
+
tape, start, end = _tape(before, after)
|
|
799
|
+
bright = [False] * len(tape)
|
|
800
|
+
promoted = after[0]
|
|
801
|
+
at = tape.find(promoted)
|
|
802
|
+
while at != -1: # the wrapping name is on the strip twice — light both
|
|
803
|
+
bright[at : at + len(promoted)] = [True] * len(promoted)
|
|
804
|
+
at = tape.find(promoted, at + len(promoted))
|
|
805
|
+
travel = abs(end - start)
|
|
806
|
+
step = 1 if end > start else -1
|
|
807
|
+
frames = []
|
|
808
|
+
for moved in range(travel + 1):
|
|
809
|
+
offset = start + step * moved
|
|
810
|
+
# The settled frame is the last one and is held by whatever comes
|
|
811
|
+
# next, not by the slide.
|
|
812
|
+
held = (
|
|
813
|
+
_MOTION_SECONDS * (_eased((moved + 1) / travel) - _eased(moved / travel))
|
|
814
|
+
if moved < travel
|
|
815
|
+
else 0.0
|
|
816
|
+
)
|
|
817
|
+
frames.append((_lit(tape[offset : offset + columns], bright[offset:], accent), held))
|
|
818
|
+
return frames
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _order_line() -> int:
|
|
822
|
+
"""Which line of :func:`_panel`'s output carries the AI CLI order row.
|
|
823
|
+
|
|
824
|
+
Derived from :data:`ROWS` rather than counted by hand, for the same
|
|
825
|
+
reason nothing else in this module hardcodes a row number: the panel is
|
|
826
|
+
one title line, then each row preceded by its group heading when it
|
|
827
|
+
opens one.
|
|
828
|
+
"""
|
|
829
|
+
line = 1
|
|
830
|
+
for row in ROWS:
|
|
831
|
+
if row.group:
|
|
832
|
+
line += 1
|
|
833
|
+
if row.key == "AICP_CLI_ORDER":
|
|
834
|
+
return line
|
|
835
|
+
line += 1
|
|
836
|
+
raise AssertionError("no AICP_CLI_ORDER row") # pragma: no cover - ROWS is a constant
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _animates(lines: list[str], out: IO[str]) -> bool:
|
|
840
|
+
"""Whether motion can be drawn at all.
|
|
841
|
+
|
|
842
|
+
Colour, and a frame that is not wrapping: a wrapped line cannot be
|
|
843
|
+
rewritten on its own (``\\033[K`` clears one screen row, not one logical
|
|
844
|
+
line), and a terminal too narrow to hold the panel has a redraw problem
|
|
845
|
+
to fix before it has an animation to watch.
|
|
846
|
+
"""
|
|
847
|
+
return color_supported(out) and _frame_rows(lines, out) == len(lines)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def _repaint_line(out: IO[str], lines: list[str], index: int, text: str) -> None:
|
|
851
|
+
"""Rewrite one line of the frame already on screen, leaving the rest be.
|
|
852
|
+
|
|
853
|
+
An animation step changes one row, and erasing the whole frame to redraw
|
|
854
|
+
it 12 times a second is what makes motion flicker — so the cursor walks
|
|
855
|
+
up to just that line, overwrites it, and comes straight back. Nothing is
|
|
856
|
+
scrolled: no newline is ever written.
|
|
857
|
+
"""
|
|
858
|
+
up = _frame_rows(lines[index:], out)
|
|
859
|
+
out.write(f"\033[{up}A\r\033[K{text}\033[{up}B\r")
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _frame_rows(lines: list[str], out: IO[str]) -> int:
|
|
863
|
+
"""Physical terminal rows *lines* occupies once wrapped at the real
|
|
864
|
+
terminal width.
|
|
865
|
+
|
|
866
|
+
Not the same as ``len(lines)``: a row's visible width (CJK glyphs count
|
|
867
|
+
two columns) routinely exceeds an 80-column terminal, so the terminal
|
|
868
|
+
itself wraps that one logical line into two on-screen rows. Erasing by
|
|
869
|
+
``len(lines)`` then moves the cursor up too few rows, leaves the old
|
|
870
|
+
frame's wrapped tail on screen, and the next redraw piles another tail
|
|
871
|
+
on top of that one — the "whole panel smears down the screen" bug.
|
|
872
|
+
"""
|
|
873
|
+
columns = max(_terminal_size(out).columns, 1)
|
|
874
|
+
return sum(-(-width(line) // columns) or 1 for line in lines)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _tui(state: MenuState, stdin: IO[str], out: IO[str]) -> int:
|
|
878
|
+
"""Arrow-key surface. Repaints in place by walking back up the frame it
|
|
879
|
+
just drew; ``\\033[J`` erases to the end of the screen because switching
|
|
880
|
+
to a language with narrower rows would otherwise leave the previous,
|
|
881
|
+
wider frame's right-hand border on screen as a second column of │."""
|
|
882
|
+
selected = 1
|
|
883
|
+
lines = _panel(state, selected, out)
|
|
884
|
+
for line in lines:
|
|
885
|
+
print(line, file=out)
|
|
886
|
+
try:
|
|
887
|
+
with key_session(stdin, out) as typed:
|
|
888
|
+
while True:
|
|
889
|
+
key = read_key(stdin, out)
|
|
890
|
+
if key == "quit":
|
|
891
|
+
return 0
|
|
892
|
+
if key == "up":
|
|
893
|
+
selected = selected - 1 if selected > 1 else len(ROWS)
|
|
894
|
+
elif key == "down":
|
|
895
|
+
selected = selected + 1 if selected < len(ROWS) else 1
|
|
896
|
+
elif key in ("left", "right", "enter"):
|
|
897
|
+
row = ROWS[selected - 1]
|
|
898
|
+
before = list(state.chain)
|
|
899
|
+
if not _write(state, row, -1 if key == "left" else 1, stdin, out, typed):
|
|
900
|
+
return 1
|
|
901
|
+
if row.action is not None:
|
|
902
|
+
# An action prints below the frame; redraw under its
|
|
903
|
+
# output rather than scrolling back up over what it
|
|
904
|
+
# just said.
|
|
905
|
+
lines = _panel(state, selected, out)
|
|
906
|
+
for line in lines:
|
|
907
|
+
print(line, file=out)
|
|
908
|
+
continue
|
|
909
|
+
if row.key == "AICP_CLI_ORDER" and _animates(lines, out):
|
|
910
|
+
# The whole chain slides one name over, so the change
|
|
911
|
+
# is watched rather than noticed after the fact. Only
|
|
912
|
+
# the order row is rewritten per step — the rest of
|
|
913
|
+
# the frame is already right, and redrawing it is
|
|
914
|
+
# what flickers.
|
|
915
|
+
index = _order_line()
|
|
916
|
+
columns = width(
|
|
917
|
+
_fit(_SEPARATOR.join(state.chain), _fit_columns(state, out)[1])
|
|
918
|
+
)
|
|
919
|
+
for frame, held in _order_motion(
|
|
920
|
+
before, state.chain, columns, row.accent(state)
|
|
921
|
+
):
|
|
922
|
+
lines = _panel(state, selected, out, order_value=frame)
|
|
923
|
+
_repaint_line(out, lines, index, lines[index])
|
|
924
|
+
out.flush()
|
|
925
|
+
if pending(stdin):
|
|
926
|
+
break # a key is already waiting; land on the
|
|
927
|
+
# settled frame now rather than making it queue
|
|
928
|
+
# behind a slide nobody is still watching
|
|
929
|
+
time.sleep(held)
|
|
930
|
+
else:
|
|
931
|
+
continue
|
|
932
|
+
out.write(f"\033[{_frame_rows(lines, out)}A\033[J")
|
|
933
|
+
lines = _panel(state, selected, out)
|
|
934
|
+
for line in lines:
|
|
935
|
+
print(line, file=out)
|
|
936
|
+
except KeyboardInterrupt:
|
|
937
|
+
# cbreak leaves Ctrl+C a signal rather than a byte, and quitting a
|
|
938
|
+
# menu that saves as it goes has nothing to roll back.
|
|
939
|
+
print(file=out)
|
|
940
|
+
return 0
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def swap_ai(
|
|
944
|
+
*,
|
|
945
|
+
settings: Settings | None = None,
|
|
946
|
+
path: Path | None = None,
|
|
947
|
+
stdin: IO[str] | None = None,
|
|
948
|
+
stdout: IO[str] | None = None,
|
|
949
|
+
) -> int:
|
|
950
|
+
"""``--swap-ai``: move the picked CLI to #1, trading places with whoever
|
|
951
|
+
holds it. Never runs an AI CLI and never commits or pushes — the swap
|
|
952
|
+
only; the next ordinary ``aicp`` run is what applies the new priority.
|
|
953
|
+
|
|
954
|
+
The menu always lists the full roster, not just what is installed, so a
|
|
955
|
+
prior swap or a missing binary never hides a choice (missing CLIs are
|
|
956
|
+
skipped at call time by the runner instead).
|
|
957
|
+
"""
|
|
958
|
+
state = MenuState.from_settings(settings or resolve())
|
|
959
|
+
if path is not None:
|
|
960
|
+
state.path = path
|
|
961
|
+
out = stdout or sys.stdout
|
|
962
|
+
chain = state.chain
|
|
963
|
+
|
|
964
|
+
print(_t(state.lang, "swap_current_order", "Current fallback order:"), file=out)
|
|
965
|
+
for i, name in enumerate(chain, start=1):
|
|
966
|
+
first = _t(state.lang, "swap_current_first", " ← current #1") if i == 1 else ""
|
|
967
|
+
print(f" {i}) {name}{DIM}{first}{RESET}", file=out)
|
|
968
|
+
print(_t(state.lang, "swap_prompt", "Pick a CLI to move to #1 (1-%s): ", len(chain)), file=out)
|
|
969
|
+
|
|
970
|
+
choice = read_line(stdin)
|
|
971
|
+
if choice is None or choice == "":
|
|
972
|
+
return 0
|
|
973
|
+
if not choice.isdigit() or not 1 <= int(choice) <= len(chain):
|
|
974
|
+
print(
|
|
975
|
+
f"{RED}{_t(state.lang, 'swap_invalid', '✗ invalid choice: %s', choice)}{RESET}",
|
|
976
|
+
file=out,
|
|
977
|
+
)
|
|
978
|
+
return 1
|
|
979
|
+
picked = int(choice)
|
|
980
|
+
if picked == 1:
|
|
981
|
+
print(
|
|
982
|
+
f"{DIM}{_t(state.lang, 'swap_already_first', '▸ %s is already #1 — no change', chain[0])}{RESET}",
|
|
983
|
+
file=out,
|
|
984
|
+
)
|
|
985
|
+
return 0
|
|
986
|
+
|
|
987
|
+
chain[picked - 1], chain[0] = chain[0], chain[picked - 1]
|
|
988
|
+
if not persist_key("AICP_CLI_ORDER", " ".join(chain), state.path):
|
|
989
|
+
print(
|
|
990
|
+
f"{RED}{_t(state.lang, 'persist_failed', '✗ failed to write %s', state.path)}{RESET}",
|
|
991
|
+
file=out,
|
|
992
|
+
)
|
|
993
|
+
return 1
|
|
994
|
+
print(
|
|
995
|
+
f"{GREEN}{_t(state.lang, 'swap_new_order', '✓ new order:')}{RESET} {' -> '.join(chain)}",
|
|
996
|
+
file=out,
|
|
997
|
+
)
|
|
998
|
+
print(
|
|
999
|
+
f"{DIM}{_t(state.lang, 'swap_saved', ' saved to %s — the next aicp run uses it', state.path)}{RESET}",
|
|
1000
|
+
file=out,
|
|
1001
|
+
)
|
|
1002
|
+
return 0
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def cli_chain(settings: Settings | None = None) -> Sequence[str]:
|
|
1006
|
+
"""The resolved fallback chain, for callers that already have Settings.
|
|
1007
|
+
|
|
1008
|
+
Thin alias over ``Settings.cli_chain`` so a caller never has to know
|
|
1009
|
+
whether the order came from the environment, ``.aicprc`` or the default
|
|
1010
|
+
roster — see :func:`aicp.config.resolve_cli_chain`, the contract T2 and
|
|
1011
|
+
T4 consume.
|
|
1012
|
+
"""
|
|
1013
|
+
return (settings or resolve()).cli_chain
|