patch-cc 0.1.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.
- patch_cc/__init__.py +3 -0
- patch_cc/bun/__init__.py +6 -0
- patch_cc/bun/blob.py +256 -0
- patch_cc/bun/container.py +129 -0
- patch_cc/bun/elf.py +281 -0
- patch_cc/bun/errors.py +12 -0
- patch_cc/bun/macho.py +124 -0
- patch_cc/cache.py +86 -0
- patch_cc/cli.py +399 -0
- patch_cc/doctor.py +116 -0
- patch_cc/locate.py +117 -0
- patch_cc/menu.py +1135 -0
- patch_cc/patcher.py +244 -0
- patch_cc/patches/__init__.py +75 -0
- patch_cc/patches/agents.py +289 -0
- patch_cc/patches/base.py +200 -0
- patch_cc/patches/chrome.py +187 -0
- patch_cc/patches/output.py +173 -0
- patch_cc/patches/streaming.py +922 -0
- patch_cc/patches/thinking.py +88 -0
- patch_cc/ui.py +23 -0
- patch_cc-0.1.0.dist-info/METADATA +141 -0
- patch_cc-0.1.0.dist-info/RECORD +26 -0
- patch_cc-0.1.0.dist-info/WHEEL +4 -0
- patch_cc-0.1.0.dist-info/entry_points.txt +2 -0
- patch_cc-0.1.0.dist-info/licenses/LICENSE +21 -0
patch_cc/menu.py
ADDED
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
"""The interactive menu shown by bare ``patch-cc``.
|
|
2
|
+
|
|
3
|
+
A fullscreen frame: fixed title and status on top, fixed key hints at the
|
|
4
|
+
bottom, and only the patch list scrolling in between. Every choice is a picker
|
|
5
|
+
driven by what the binary itself offers -- agents and models are discovered,
|
|
6
|
+
never typed -- and typing exists only where a value is genuinely free text
|
|
7
|
+
(the startup name, the --version marker). All configuration happens in
|
|
8
|
+
centered modals floating over the dimmed list; the list itself never grows
|
|
9
|
+
sub-rows. ``s`` saves, and the same frame then shows the per-patch results.
|
|
10
|
+
|
|
11
|
+
The engine is deliberately small: ``blessed`` owns the terminal (fullscreen,
|
|
12
|
+
cbreak, parsed keystrokes, live size) and Rich owns every pixel drawn. A frame
|
|
13
|
+
is composed as Rich segments, a modal is a centered ``Panel`` composited over
|
|
14
|
+
the dimmed background, and the whole thing is painted with absolute cursor
|
|
15
|
+
moves. There is no widget toolkit, no focus system, and no event bubbling --
|
|
16
|
+
one loop, one state machine.
|
|
17
|
+
|
|
18
|
+
Pre-selection comes from the binary's own manifest when it is patched -- the
|
|
19
|
+
binary is the state -- falling back to the cached last selection, then to the
|
|
20
|
+
defaults. Nothing is written until the user saves; quitting with unsaved
|
|
21
|
+
changes asks first.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import sys
|
|
27
|
+
import threading
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
30
|
+
|
|
31
|
+
from blessed import Terminal
|
|
32
|
+
from rich import box
|
|
33
|
+
from rich.align import Align
|
|
34
|
+
from rich.console import COLOR_SYSTEMS, Console, Group
|
|
35
|
+
from rich.panel import Panel
|
|
36
|
+
from rich.segment import Segment
|
|
37
|
+
from rich.style import Style
|
|
38
|
+
from rich.text import Text
|
|
39
|
+
|
|
40
|
+
from . import cache, locate, patcher
|
|
41
|
+
from .bun import Bundle, BunError, container
|
|
42
|
+
from .patches import (
|
|
43
|
+
DEFAULT_BRAND,
|
|
44
|
+
DEFAULT_SUFFIX,
|
|
45
|
+
GROUP_ORDER,
|
|
46
|
+
Options,
|
|
47
|
+
Patch,
|
|
48
|
+
by_group,
|
|
49
|
+
derived_brand,
|
|
50
|
+
)
|
|
51
|
+
from .patches.agents import INHERIT, BuiltinAgent, discover_agents, discover_models
|
|
52
|
+
from .ui import console, err
|
|
53
|
+
|
|
54
|
+
if TYPE_CHECKING:
|
|
55
|
+
from .doctor import DryRun, Status
|
|
56
|
+
|
|
57
|
+
#: Sentinel choice: leave this agent on its built-in default.
|
|
58
|
+
_KEEP = "keep"
|
|
59
|
+
|
|
60
|
+
#: Anthropic ink-and-paper: terracotta (Claude's coral) is the brand and
|
|
61
|
+
#: everything interactive, kraft tan is a value the user wrote in, warm gold
|
|
62
|
+
#: is caution, and rules are the edge of the page.
|
|
63
|
+
_ACCENT = "#D97757"
|
|
64
|
+
_VALUE = "#D4A27F"
|
|
65
|
+
_WARN = "#E3B341"
|
|
66
|
+
_RULE = "#6f6459"
|
|
67
|
+
_PANEL_WIDTH = 72
|
|
68
|
+
#: The Claude starburst, breathing — the busy view thinks like Claude does.
|
|
69
|
+
_SPINNER = ["·", "✢", "✳", "✺", "✳", "✢"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _hints(*pairs: tuple[str, str]) -> Text:
|
|
73
|
+
"""Key hints as ``key label`` pairs: the key accented, the label quiet."""
|
|
74
|
+
text = Text()
|
|
75
|
+
for i, (key, label) in enumerate(pairs):
|
|
76
|
+
if i:
|
|
77
|
+
text.append(" · ", style="dim")
|
|
78
|
+
text.append(key, style=_ACCENT)
|
|
79
|
+
text.append(f" {label}", style="dim")
|
|
80
|
+
return text
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
#: Patches whose row opens a modal on enter instead of plain toggling.
|
|
84
|
+
_CONFIGURABLE = {"subagent-models", "branding", "version-marker"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ----------------------------------------------------------------- rows
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(slots=True)
|
|
91
|
+
class HeaderRow:
|
|
92
|
+
title: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(slots=True)
|
|
96
|
+
class PatchRow:
|
|
97
|
+
patch: Patch
|
|
98
|
+
on: bool
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(slots=True)
|
|
102
|
+
class AgentRow:
|
|
103
|
+
"""Per-agent override state; edited in the agents modal, never a list row."""
|
|
104
|
+
|
|
105
|
+
agent: BuiltinAgent
|
|
106
|
+
#: The chosen override, or ``_KEEP`` for "leave the built-in default".
|
|
107
|
+
choice: str = _KEEP
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(slots=True)
|
|
111
|
+
class TextRow:
|
|
112
|
+
"""A free-text value; edited in the input modal, never a list row."""
|
|
113
|
+
|
|
114
|
+
key: str
|
|
115
|
+
label: str
|
|
116
|
+
value: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
Row = HeaderRow | PatchRow
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(slots=True)
|
|
123
|
+
class MenuModel:
|
|
124
|
+
"""Everything the menu operates on, independent of the rendering engine."""
|
|
125
|
+
|
|
126
|
+
install: locate.Installation
|
|
127
|
+
status: "Status"
|
|
128
|
+
pristine: Bundle
|
|
129
|
+
agents: list[BuiltinAgent]
|
|
130
|
+
models: list[str]
|
|
131
|
+
patch_rows: dict[str, PatchRow] = field(default_factory=dict)
|
|
132
|
+
agent_rows: list[AgentRow] = field(default_factory=list)
|
|
133
|
+
text_rows: dict[str, TextRow] = field(default_factory=dict)
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def build(
|
|
137
|
+
cls, install: locate.Installation, status: "Status", pristine: Bundle
|
|
138
|
+
) -> "MenuModel":
|
|
139
|
+
agents = discover_agents(pristine.source)
|
|
140
|
+
models = [INHERIT, *discover_models(pristine.source)]
|
|
141
|
+
model = cls(
|
|
142
|
+
install=install,
|
|
143
|
+
status=status,
|
|
144
|
+
pristine=pristine,
|
|
145
|
+
agents=agents,
|
|
146
|
+
models=models,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
seed = model._seed()
|
|
150
|
+
for group in GROUP_ORDER:
|
|
151
|
+
for patch in by_group().get(group, []):
|
|
152
|
+
model.patch_rows[patch.id] = PatchRow(patch, patch.id in seed.patches)
|
|
153
|
+
|
|
154
|
+
for agent in agents:
|
|
155
|
+
picked = seed.options.subagent_models.get(agent.name)
|
|
156
|
+
model.agent_rows.append(
|
|
157
|
+
AgentRow(agent, picked if picked in models else _KEEP)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
brand = seed.options.brand if seed.options.rebrands else derived_brand()
|
|
161
|
+
model.text_rows["brand"] = TextRow("brand", "name", brand)
|
|
162
|
+
model.text_rows["suffix"] = TextRow(
|
|
163
|
+
"suffix", "marker", seed.options.version_suffix
|
|
164
|
+
)
|
|
165
|
+
return model
|
|
166
|
+
|
|
167
|
+
def _seed(self) -> cache.Selection:
|
|
168
|
+
"""Manifest > cached last selection > defaults."""
|
|
169
|
+
manifest = getattr(self.status, "manifest", None)
|
|
170
|
+
if manifest:
|
|
171
|
+
seed = cache.Selection()
|
|
172
|
+
seed.patches = [
|
|
173
|
+
p for p in manifest.get("patches", []) if isinstance(p, str)
|
|
174
|
+
] or seed.patches
|
|
175
|
+
seed.options = Options(
|
|
176
|
+
brand=manifest.get("brand") or DEFAULT_BRAND,
|
|
177
|
+
version_suffix=manifest.get("suffix") or DEFAULT_SUFFIX,
|
|
178
|
+
subagent_models={
|
|
179
|
+
a: m
|
|
180
|
+
for a, m in (manifest.get("models") or {}).items()
|
|
181
|
+
if isinstance(a, str) and isinstance(m, str)
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
return seed
|
|
185
|
+
return cache.load()
|
|
186
|
+
|
|
187
|
+
def rows(self) -> list[Row]:
|
|
188
|
+
rows: list[Row] = []
|
|
189
|
+
for group in GROUP_ORDER:
|
|
190
|
+
patches = by_group().get(group, [])
|
|
191
|
+
if not patches:
|
|
192
|
+
continue
|
|
193
|
+
rows.append(HeaderRow(group))
|
|
194
|
+
rows.extend(self.patch_rows[patch.id] for patch in patches)
|
|
195
|
+
return rows
|
|
196
|
+
|
|
197
|
+
def overridden(self) -> int:
|
|
198
|
+
return sum(1 for row in self.agent_rows if row.choice != _KEEP)
|
|
199
|
+
|
|
200
|
+
# -- what apply would do
|
|
201
|
+
|
|
202
|
+
def selection(self) -> cache.Selection:
|
|
203
|
+
selected = [pid for pid, row in self.patch_rows.items() if row.on]
|
|
204
|
+
options = Options()
|
|
205
|
+
overrides = {
|
|
206
|
+
row.agent.name: row.choice for row in self.agent_rows if row.choice != _KEEP
|
|
207
|
+
}
|
|
208
|
+
if "subagent-models" in selected:
|
|
209
|
+
if overrides:
|
|
210
|
+
options.subagent_models = overrides
|
|
211
|
+
else:
|
|
212
|
+
selected.remove("subagent-models")
|
|
213
|
+
if "branding" in selected:
|
|
214
|
+
brand = self.text_rows["brand"].value.strip() or derived_brand()
|
|
215
|
+
if brand == DEFAULT_BRAND:
|
|
216
|
+
selected.remove("branding")
|
|
217
|
+
else:
|
|
218
|
+
options.brand = brand
|
|
219
|
+
if "version-marker" in selected:
|
|
220
|
+
options.version_suffix = (
|
|
221
|
+
self.text_rows["suffix"].value.strip() or DEFAULT_SUFFIX
|
|
222
|
+
)
|
|
223
|
+
return cache.Selection(patches=selected, options=options)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ----------------------------------------------------------------- modals
|
|
227
|
+
#
|
|
228
|
+
# A modal is plain state plus two methods: ``handle`` mutates on a key name,
|
|
229
|
+
# ``render`` returns the centered Panel. ``finish`` is injected when pushed;
|
|
230
|
+
# calling it closes the modal and hands the result to the opener's callback.
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class PickModal:
|
|
234
|
+
"""A centered list of choices; enter picks, esc closes."""
|
|
235
|
+
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
title: str,
|
|
239
|
+
items: list[str],
|
|
240
|
+
label: Callable[[str, bool], Text],
|
|
241
|
+
*,
|
|
242
|
+
current: str | None = None,
|
|
243
|
+
on_pick: Callable[[str], None] | None = None,
|
|
244
|
+
hint: Text | None = None,
|
|
245
|
+
width: int = 46,
|
|
246
|
+
) -> None:
|
|
247
|
+
self.title = title
|
|
248
|
+
self.items = items
|
|
249
|
+
self.label = label
|
|
250
|
+
self.on_pick = on_pick
|
|
251
|
+
self.hint = (
|
|
252
|
+
hint if hint is not None else _hints(("enter", "select"), ("esc", "cancel"))
|
|
253
|
+
)
|
|
254
|
+
self.width = width
|
|
255
|
+
self.cursor = items.index(current) if current in items else 0
|
|
256
|
+
self.finish: Callable[[object], None] = lambda result: None
|
|
257
|
+
|
|
258
|
+
def handle(self, key: str) -> None:
|
|
259
|
+
if key in ("down", "j"):
|
|
260
|
+
self.cursor = (self.cursor + 1) % len(self.items)
|
|
261
|
+
elif key in ("up", "k"):
|
|
262
|
+
self.cursor = (self.cursor - 1) % len(self.items)
|
|
263
|
+
elif key == "home":
|
|
264
|
+
self.cursor = 0
|
|
265
|
+
elif key == "end":
|
|
266
|
+
self.cursor = len(self.items) - 1
|
|
267
|
+
elif key == "enter":
|
|
268
|
+
item = self.items[self.cursor]
|
|
269
|
+
if self.on_pick is not None:
|
|
270
|
+
self.on_pick(item)
|
|
271
|
+
else:
|
|
272
|
+
self.finish(item)
|
|
273
|
+
elif key in ("escape", "q"):
|
|
274
|
+
self.finish(None)
|
|
275
|
+
|
|
276
|
+
def render(self) -> Panel:
|
|
277
|
+
inner = self.width - 8
|
|
278
|
+
body = Text()
|
|
279
|
+
for i, item in enumerate(self.items):
|
|
280
|
+
current = i == self.cursor
|
|
281
|
+
line = Text()
|
|
282
|
+
line.append("❯ " if current else " ", style=_ACCENT)
|
|
283
|
+
line.append_text(self.label(item, current))
|
|
284
|
+
line.truncate(inner, overflow="ellipsis")
|
|
285
|
+
body.append_text(line)
|
|
286
|
+
body.append("\n")
|
|
287
|
+
return Panel(
|
|
288
|
+
Group(body, Align.center(self.hint)),
|
|
289
|
+
box=box.ROUNDED,
|
|
290
|
+
border_style=_ACCENT,
|
|
291
|
+
padding=(1, 3),
|
|
292
|
+
title=Text(self.title, style=f"bold {_ACCENT}"),
|
|
293
|
+
title_align="center",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class InputModal:
|
|
298
|
+
"""A centered free-text field; enter saves, esc keeps the old value."""
|
|
299
|
+
|
|
300
|
+
def __init__(
|
|
301
|
+
self, title: str, value: str, *, width: int = 52, max_len: int = 48
|
|
302
|
+
) -> None:
|
|
303
|
+
self.title = title
|
|
304
|
+
self.value = value
|
|
305
|
+
self.cur = len(value)
|
|
306
|
+
self.hint = _hints(("enter", "save"), ("esc", "cancel"))
|
|
307
|
+
self.width = width
|
|
308
|
+
self.max_len = max_len
|
|
309
|
+
self.finish: Callable[[object], None] = lambda result: None
|
|
310
|
+
|
|
311
|
+
def handle(self, key: str) -> None:
|
|
312
|
+
if key == "enter":
|
|
313
|
+
self.finish(self.value)
|
|
314
|
+
elif key == "escape":
|
|
315
|
+
self.finish(None)
|
|
316
|
+
elif key == "left":
|
|
317
|
+
self.cur = max(0, self.cur - 1)
|
|
318
|
+
elif key == "right":
|
|
319
|
+
self.cur = min(len(self.value), self.cur + 1)
|
|
320
|
+
elif key == "home":
|
|
321
|
+
self.cur = 0
|
|
322
|
+
elif key == "end":
|
|
323
|
+
self.cur = len(self.value)
|
|
324
|
+
elif key == "backspace":
|
|
325
|
+
if self.cur:
|
|
326
|
+
self.value = self.value[: self.cur - 1] + self.value[self.cur :]
|
|
327
|
+
self.cur -= 1
|
|
328
|
+
elif key == "delete":
|
|
329
|
+
self.value = self.value[: self.cur] + self.value[self.cur + 1 :]
|
|
330
|
+
else:
|
|
331
|
+
ch = " " if key == "space" else key
|
|
332
|
+
if len(ch) == 1 and ch.isprintable() and len(self.value) < self.max_len:
|
|
333
|
+
self.value = self.value[: self.cur] + ch + self.value[self.cur :]
|
|
334
|
+
self.cur += 1
|
|
335
|
+
|
|
336
|
+
def render(self) -> Panel:
|
|
337
|
+
line = Text()
|
|
338
|
+
line.append("❯ ", style=_ACCENT)
|
|
339
|
+
line.append(self.value[: self.cur])
|
|
340
|
+
at = self.value[self.cur : self.cur + 1] or " "
|
|
341
|
+
line.append(at, style="reverse")
|
|
342
|
+
line.append(self.value[self.cur + 1 :])
|
|
343
|
+
return Panel(
|
|
344
|
+
Group(line, Text(""), Align.center(self.hint)),
|
|
345
|
+
box=box.ROUNDED,
|
|
346
|
+
border_style=_ACCENT,
|
|
347
|
+
padding=(1, 3),
|
|
348
|
+
title=Text(self.title, style=f"bold {_ACCENT}"),
|
|
349
|
+
title_align="center",
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class ConfirmModal:
|
|
354
|
+
"""A centered yes/no question; caution gets an amber frame."""
|
|
355
|
+
|
|
356
|
+
def __init__(
|
|
357
|
+
self, question: str, action: str, *, tone: str = _ACCENT, width: int = 52
|
|
358
|
+
) -> None:
|
|
359
|
+
self.question = question
|
|
360
|
+
self.action = action
|
|
361
|
+
self.tone = tone
|
|
362
|
+
self.width = width
|
|
363
|
+
self.finish: Callable[[object], None] = lambda result: None
|
|
364
|
+
|
|
365
|
+
def handle(self, key: str) -> None:
|
|
366
|
+
if key in ("y", "enter"):
|
|
367
|
+
self.finish(True)
|
|
368
|
+
elif key in ("n", "escape", "q"):
|
|
369
|
+
self.finish(False)
|
|
370
|
+
|
|
371
|
+
def render(self) -> Panel:
|
|
372
|
+
return Panel(
|
|
373
|
+
Group(
|
|
374
|
+
Align.center(Text(self.question, style="bold")),
|
|
375
|
+
Text(""),
|
|
376
|
+
Align.center(_hints(("y", self.action), ("n", "cancel"))),
|
|
377
|
+
),
|
|
378
|
+
box=box.ROUNDED,
|
|
379
|
+
border_style=self.tone,
|
|
380
|
+
padding=(1, 3),
|
|
381
|
+
title=Text(self.action, style=f"bold {self.tone}"),
|
|
382
|
+
title_align="center",
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
Modal = PickModal | InputModal | ConfirmModal
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ----------------------------------------------------------------- app
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class MenuApp:
|
|
393
|
+
"""One loop, one state machine: read a key, mutate, repaint."""
|
|
394
|
+
|
|
395
|
+
def __init__(
|
|
396
|
+
self,
|
|
397
|
+
model: MenuModel,
|
|
398
|
+
*,
|
|
399
|
+
term: Terminal | None = None,
|
|
400
|
+
rich_console: Console | None = None,
|
|
401
|
+
) -> None:
|
|
402
|
+
self.term = term if term is not None else Terminal()
|
|
403
|
+
self.console = (
|
|
404
|
+
rich_console if rich_console is not None else Console(force_terminal=True)
|
|
405
|
+
)
|
|
406
|
+
self.model = model
|
|
407
|
+
self.cursor = 0
|
|
408
|
+
self.view = "select" # select | busy | report | doctor
|
|
409
|
+
self.stack: list[tuple[Modal, Callable[[object], None] | None]] = []
|
|
410
|
+
self.report: patcher.PatchReport | None = None
|
|
411
|
+
self.doctor_result: "DryRun | None" = None
|
|
412
|
+
self.busy_message = ""
|
|
413
|
+
self.flash = ""
|
|
414
|
+
self.exit_code = 0
|
|
415
|
+
self.exit_message: str | None = None
|
|
416
|
+
self._exit: int | None = None
|
|
417
|
+
#: A worker's tagged result, read by the loop: (kind, payload, error).
|
|
418
|
+
self._worker_result: tuple[str, Any, str | None] | None = None
|
|
419
|
+
self._frame = 0
|
|
420
|
+
self._scroll = 0
|
|
421
|
+
self._needs_paint = True
|
|
422
|
+
self._last_size = (0, 0)
|
|
423
|
+
color = self.console.color_system
|
|
424
|
+
self._color_system = COLOR_SYSTEMS.get(color) if color else None
|
|
425
|
+
self._clamp_cursor(0)
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------- loop
|
|
428
|
+
|
|
429
|
+
def run(self) -> int:
|
|
430
|
+
term = self.term
|
|
431
|
+
with term.fullscreen(), term.cbreak(), term.hidden_cursor():
|
|
432
|
+
self._disable_flow_control()
|
|
433
|
+
while self._exit is None:
|
|
434
|
+
size = (term.width, term.height)
|
|
435
|
+
if size != self._last_size:
|
|
436
|
+
self._needs_paint = True
|
|
437
|
+
if self.view == "busy":
|
|
438
|
+
self._frame += 1
|
|
439
|
+
self._needs_paint = True
|
|
440
|
+
if self._needs_paint:
|
|
441
|
+
self._paint(*size)
|
|
442
|
+
self._needs_paint = False
|
|
443
|
+
self._last_size = size
|
|
444
|
+
try:
|
|
445
|
+
keystroke = term.inkey(timeout=0.12 if self.view == "busy" else 0.4)
|
|
446
|
+
except KeyboardInterrupt:
|
|
447
|
+
if self.view != "busy":
|
|
448
|
+
self._on_key("ctrl+c")
|
|
449
|
+
continue
|
|
450
|
+
if self.view == "busy":
|
|
451
|
+
self._poll_worker()
|
|
452
|
+
continue
|
|
453
|
+
key = self._key_name(keystroke)
|
|
454
|
+
if key:
|
|
455
|
+
self._on_key(key)
|
|
456
|
+
return self._exit if self._exit is not None else 0
|
|
457
|
+
|
|
458
|
+
@staticmethod
|
|
459
|
+
def _disable_flow_control() -> None:
|
|
460
|
+
"""Free ctrl+s from XOFF so a stray press cannot freeze the screen."""
|
|
461
|
+
try:
|
|
462
|
+
import termios
|
|
463
|
+
|
|
464
|
+
fd = sys.stdin.fileno()
|
|
465
|
+
attrs = termios.tcgetattr(fd)
|
|
466
|
+
attrs[0] &= ~(termios.IXON | termios.IXOFF)
|
|
467
|
+
termios.tcsetattr(fd, termios.TCSANOW, attrs)
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
def _key_name(self, keystroke) -> str:
|
|
472
|
+
if not keystroke:
|
|
473
|
+
return ""
|
|
474
|
+
term = self.term
|
|
475
|
+
if keystroke.is_sequence:
|
|
476
|
+
named = {
|
|
477
|
+
term.KEY_UP: "up",
|
|
478
|
+
term.KEY_DOWN: "down",
|
|
479
|
+
term.KEY_LEFT: "left",
|
|
480
|
+
term.KEY_RIGHT: "right",
|
|
481
|
+
term.KEY_ENTER: "enter",
|
|
482
|
+
term.KEY_ESCAPE: "escape",
|
|
483
|
+
term.KEY_BACKSPACE: "backspace",
|
|
484
|
+
term.KEY_DELETE: "delete",
|
|
485
|
+
term.KEY_HOME: "home",
|
|
486
|
+
term.KEY_END: "end",
|
|
487
|
+
}
|
|
488
|
+
return named.get(keystroke.code, "")
|
|
489
|
+
ch = str(keystroke)
|
|
490
|
+
return {
|
|
491
|
+
"\r": "enter",
|
|
492
|
+
"\n": "enter",
|
|
493
|
+
" ": "space",
|
|
494
|
+
"\x7f": "backspace",
|
|
495
|
+
"\x08": "backspace",
|
|
496
|
+
"\x1b": "escape",
|
|
497
|
+
}.get(ch, ch)
|
|
498
|
+
|
|
499
|
+
# ---------------------------------------------------- input
|
|
500
|
+
|
|
501
|
+
def _on_key(self, key: str) -> None:
|
|
502
|
+
self._needs_paint = True
|
|
503
|
+
if key == "ctrl+c":
|
|
504
|
+
self.stack.clear()
|
|
505
|
+
self._request_quit()
|
|
506
|
+
return
|
|
507
|
+
if self.stack:
|
|
508
|
+
self.stack[-1][0].handle(key)
|
|
509
|
+
return
|
|
510
|
+
if self.view == "select":
|
|
511
|
+
self._key_select(key)
|
|
512
|
+
elif self.view in ("report", "doctor"):
|
|
513
|
+
if key == "q":
|
|
514
|
+
self._exit = self.exit_code
|
|
515
|
+
elif key in ("enter", "escape", "b"):
|
|
516
|
+
self.view = "select"
|
|
517
|
+
|
|
518
|
+
def _key_select(self, key: str) -> None:
|
|
519
|
+
rows = self.model.rows()
|
|
520
|
+
row = rows[self.cursor] if self.cursor < len(rows) else None
|
|
521
|
+
self.flash = ""
|
|
522
|
+
|
|
523
|
+
if key in ("q", "escape"):
|
|
524
|
+
self._request_quit()
|
|
525
|
+
elif key in ("down", "j"):
|
|
526
|
+
self._move(1)
|
|
527
|
+
elif key in ("up", "k"):
|
|
528
|
+
self._move(-1)
|
|
529
|
+
elif key == "home":
|
|
530
|
+
self.cursor = 0
|
|
531
|
+
self._clamp_cursor(1)
|
|
532
|
+
elif key == "end":
|
|
533
|
+
self.cursor = len(rows) - 1
|
|
534
|
+
self._clamp_cursor(-1)
|
|
535
|
+
elif key == "space" and isinstance(row, PatchRow):
|
|
536
|
+
row.on = not row.on
|
|
537
|
+
elif key == "enter" and isinstance(row, PatchRow):
|
|
538
|
+
self._activate(row)
|
|
539
|
+
elif key in ("s", "a"):
|
|
540
|
+
self._start_apply()
|
|
541
|
+
elif key == "d":
|
|
542
|
+
self._start_doctor()
|
|
543
|
+
elif key == "r":
|
|
544
|
+
self._confirm_restore()
|
|
545
|
+
|
|
546
|
+
def _activate(self, row: PatchRow) -> None:
|
|
547
|
+
"""Enter on a patch: toggle plain ones, configure configurable ones."""
|
|
548
|
+
patch_id = row.patch.id
|
|
549
|
+
if patch_id == "subagent-models":
|
|
550
|
+
if not self.model.agent_rows:
|
|
551
|
+
self.flash = "no agents discovered in this bundle"
|
|
552
|
+
return
|
|
553
|
+
row.on = True
|
|
554
|
+
self._open_agents_modal()
|
|
555
|
+
elif patch_id in ("branding", "version-marker"):
|
|
556
|
+
row.on = True
|
|
557
|
+
self._open_text_modal("brand" if patch_id == "branding" else "suffix")
|
|
558
|
+
else:
|
|
559
|
+
row.on = not row.on
|
|
560
|
+
|
|
561
|
+
# ---------------------------------------------------- modal flows
|
|
562
|
+
|
|
563
|
+
def _push(self, modal: Modal, on_close: Callable[[object], None] | None) -> None:
|
|
564
|
+
def finish(result: object) -> None:
|
|
565
|
+
self.stack.pop()
|
|
566
|
+
self._needs_paint = True
|
|
567
|
+
if on_close is not None:
|
|
568
|
+
on_close(result)
|
|
569
|
+
|
|
570
|
+
modal.finish = finish
|
|
571
|
+
self.stack.append((modal, on_close))
|
|
572
|
+
|
|
573
|
+
def _open_agents_modal(self) -> None:
|
|
574
|
+
model = self.model
|
|
575
|
+
rows = {row.agent.name: row for row in model.agent_rows}
|
|
576
|
+
pad = max(len(name) for name in rows) + 2
|
|
577
|
+
|
|
578
|
+
def label(name: str, selected: bool) -> Text:
|
|
579
|
+
row = rows[name]
|
|
580
|
+
text = Text(f"{name:<{pad}}", style="bold" if selected else "")
|
|
581
|
+
if row.choice == _KEEP:
|
|
582
|
+
text.append(f"keep ({row.agent.effective_model})", style="dim")
|
|
583
|
+
else:
|
|
584
|
+
text.append(row.choice, style=_VALUE)
|
|
585
|
+
return text
|
|
586
|
+
|
|
587
|
+
def pick(name: str) -> None:
|
|
588
|
+
self._open_model_modal(rows[name])
|
|
589
|
+
|
|
590
|
+
self._push(
|
|
591
|
+
PickModal(
|
|
592
|
+
"Subagent models",
|
|
593
|
+
list(rows),
|
|
594
|
+
label,
|
|
595
|
+
on_pick=pick,
|
|
596
|
+
width=52,
|
|
597
|
+
hint=_hints(("enter", "choose model"), ("esc", "done")),
|
|
598
|
+
),
|
|
599
|
+
None,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
def _open_model_modal(self, agent_row: AgentRow) -> None:
|
|
603
|
+
items = [_KEEP, *self.model.models]
|
|
604
|
+
|
|
605
|
+
def label(value: str, _selected: bool) -> Text:
|
|
606
|
+
if value == _KEEP:
|
|
607
|
+
return Text(f"keep default ({agent_row.agent.effective_model})")
|
|
608
|
+
if value == INHERIT:
|
|
609
|
+
return Text("inherit (main model)")
|
|
610
|
+
return Text(value)
|
|
611
|
+
|
|
612
|
+
def picked(choice: object) -> None:
|
|
613
|
+
if isinstance(choice, str):
|
|
614
|
+
agent_row.choice = choice
|
|
615
|
+
|
|
616
|
+
self._push(
|
|
617
|
+
PickModal(
|
|
618
|
+
f"Model for {agent_row.agent.name}",
|
|
619
|
+
items,
|
|
620
|
+
label,
|
|
621
|
+
current=agent_row.choice,
|
|
622
|
+
width=46,
|
|
623
|
+
),
|
|
624
|
+
picked,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
def _open_text_modal(self, key: str) -> None:
|
|
628
|
+
row = self.model.text_rows[key]
|
|
629
|
+
titles = {"brand": "Startup name", "suffix": "--version marker"}
|
|
630
|
+
|
|
631
|
+
def entered(value: object) -> None:
|
|
632
|
+
if isinstance(value, str) and value.strip():
|
|
633
|
+
row.value = value.strip()
|
|
634
|
+
|
|
635
|
+
self._push(InputModal(titles[key], row.value), entered)
|
|
636
|
+
|
|
637
|
+
def _confirm_restore(self) -> None:
|
|
638
|
+
def answered(restore: object) -> None:
|
|
639
|
+
if restore:
|
|
640
|
+
self._start_restore()
|
|
641
|
+
|
|
642
|
+
self._push(
|
|
643
|
+
ConfirmModal(
|
|
644
|
+
"Restore the original binary from backup?", "restore", tone=_WARN
|
|
645
|
+
),
|
|
646
|
+
answered,
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
def _request_quit(self) -> None:
|
|
650
|
+
if self.view == "select" and self._unsaved():
|
|
651
|
+
|
|
652
|
+
def answered(quit_anyway: object) -> None:
|
|
653
|
+
if quit_anyway:
|
|
654
|
+
self._exit = 0
|
|
655
|
+
|
|
656
|
+
self._push(
|
|
657
|
+
ConfirmModal("Quit without saving your changes?", "quit", tone=_WARN),
|
|
658
|
+
answered,
|
|
659
|
+
)
|
|
660
|
+
return
|
|
661
|
+
self._exit = self.exit_code if self.view in ("report", "doctor") else 0
|
|
662
|
+
|
|
663
|
+
def _unsaved(self) -> bool:
|
|
664
|
+
"""Does the current selection differ from what the binary carries?"""
|
|
665
|
+
manifest = self.model.status.manifest
|
|
666
|
+
sel = self.model.selection()
|
|
667
|
+
if not manifest:
|
|
668
|
+
return bool(sel.patches)
|
|
669
|
+
if set(sel.patches) != set(self.model.status.patch_ids):
|
|
670
|
+
return True
|
|
671
|
+
brand = manifest.get("brand") or DEFAULT_BRAND
|
|
672
|
+
chosen = sel.options.brand if "branding" in sel.patches else DEFAULT_BRAND
|
|
673
|
+
if brand != chosen:
|
|
674
|
+
return True
|
|
675
|
+
if (manifest.get("suffix") or DEFAULT_SUFFIX) != sel.options.version_suffix:
|
|
676
|
+
return True
|
|
677
|
+
return (manifest.get("models") or {}) != sel.options.subagent_models
|
|
678
|
+
|
|
679
|
+
# ---------------------------------------------------- actions
|
|
680
|
+
|
|
681
|
+
def _start_apply(self) -> None:
|
|
682
|
+
selection = self.model.selection()
|
|
683
|
+
if not selection.patches:
|
|
684
|
+
self.flash = "nothing selected — toggle at least one patch"
|
|
685
|
+
return
|
|
686
|
+
|
|
687
|
+
def confirmed(save: object) -> None:
|
|
688
|
+
if not save:
|
|
689
|
+
return
|
|
690
|
+
cache.save(selection)
|
|
691
|
+
self._busy(f"Patching Claude {self.model.install.version or '?'} …")
|
|
692
|
+
threading.Thread(
|
|
693
|
+
target=self._apply_worker, args=(selection,), daemon=True
|
|
694
|
+
).start()
|
|
695
|
+
|
|
696
|
+
count = len(selection.patches)
|
|
697
|
+
self._push(
|
|
698
|
+
ConfirmModal(
|
|
699
|
+
f"Patch Claude {self.model.install.version or '?'} "
|
|
700
|
+
f"with {count} patch{'es' if count != 1 else ''}?",
|
|
701
|
+
"save",
|
|
702
|
+
),
|
|
703
|
+
confirmed,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def _apply_worker(self, selection: cache.Selection) -> None:
|
|
707
|
+
try:
|
|
708
|
+
report = patcher.patch_installation(
|
|
709
|
+
self.model.install,
|
|
710
|
+
selection.patches,
|
|
711
|
+
selection.options,
|
|
712
|
+
bundle=self.model.pristine,
|
|
713
|
+
)
|
|
714
|
+
status = None
|
|
715
|
+
if report.output is not None:
|
|
716
|
+
# The binary just changed; recompute the header state off-loop.
|
|
717
|
+
try:
|
|
718
|
+
from . import doctor
|
|
719
|
+
|
|
720
|
+
status = doctor.status(
|
|
721
|
+
container.read(str(self.model.install.binary))
|
|
722
|
+
)
|
|
723
|
+
except (BunError, OSError):
|
|
724
|
+
status = None
|
|
725
|
+
self._worker_result = ("apply", (report, status), None)
|
|
726
|
+
except (patcher.AlreadyPatchedError, BunError, OSError) as exc:
|
|
727
|
+
self._worker_result = ("apply", None, str(exc))
|
|
728
|
+
|
|
729
|
+
def _start_doctor(self) -> None:
|
|
730
|
+
self._busy("Checking every patch against a clean bundle …")
|
|
731
|
+
threading.Thread(target=self._doctor_worker, daemon=True).start()
|
|
732
|
+
|
|
733
|
+
def _doctor_worker(self) -> None:
|
|
734
|
+
from . import doctor
|
|
735
|
+
|
|
736
|
+
self._worker_result = ("doctor", doctor.dryrun(self.model.pristine), None)
|
|
737
|
+
|
|
738
|
+
def _start_restore(self) -> None:
|
|
739
|
+
self._busy("Restoring the original binary …")
|
|
740
|
+
threading.Thread(target=self._restore_worker, daemon=True).start()
|
|
741
|
+
|
|
742
|
+
def _restore_worker(self) -> None:
|
|
743
|
+
try:
|
|
744
|
+
patcher.restore(self.model.install)
|
|
745
|
+
self._worker_result = ("restore", None, None)
|
|
746
|
+
except (FileNotFoundError, OSError) as exc:
|
|
747
|
+
self._worker_result = ("restore", None, str(exc))
|
|
748
|
+
|
|
749
|
+
def _busy(self, message: str) -> None:
|
|
750
|
+
self.view = "busy"
|
|
751
|
+
self.busy_message = message
|
|
752
|
+
self._needs_paint = True
|
|
753
|
+
|
|
754
|
+
def _poll_worker(self) -> None:
|
|
755
|
+
result = self._worker_result
|
|
756
|
+
if result is None:
|
|
757
|
+
return
|
|
758
|
+
self._worker_result = None
|
|
759
|
+
self._needs_paint = True
|
|
760
|
+
kind, payload, error = result
|
|
761
|
+
|
|
762
|
+
if kind == "apply":
|
|
763
|
+
if error is not None:
|
|
764
|
+
self.exit_code = 1
|
|
765
|
+
self.flash = error
|
|
766
|
+
self.view = "select"
|
|
767
|
+
return
|
|
768
|
+
report, status = payload
|
|
769
|
+
self.report = report
|
|
770
|
+
self.exit_code = 0 if report.output is not None else 1
|
|
771
|
+
if status is not None:
|
|
772
|
+
self.model.status = status
|
|
773
|
+
self.view = "report"
|
|
774
|
+
elif kind == "doctor":
|
|
775
|
+
self.doctor_result = payload
|
|
776
|
+
self.exit_code = 1 if payload.broken else 0
|
|
777
|
+
self.view = "doctor"
|
|
778
|
+
elif kind == "restore":
|
|
779
|
+
if error is not None:
|
|
780
|
+
self.flash = error
|
|
781
|
+
self.view = "select"
|
|
782
|
+
return
|
|
783
|
+
self.exit_message = "Restored the original binary. Restart Claude Code."
|
|
784
|
+
self._exit = 0
|
|
785
|
+
|
|
786
|
+
# ---------------------------------------------------- movement
|
|
787
|
+
|
|
788
|
+
@staticmethod
|
|
789
|
+
def _interactive(rows: list[Row]) -> list[int]:
|
|
790
|
+
return [i for i, row in enumerate(rows) if not isinstance(row, HeaderRow)]
|
|
791
|
+
|
|
792
|
+
def _clamp_cursor(self, direction: int) -> None:
|
|
793
|
+
rows = self.model.rows()
|
|
794
|
+
targets = self._interactive(rows)
|
|
795
|
+
if not targets:
|
|
796
|
+
self.cursor = 0
|
|
797
|
+
return
|
|
798
|
+
if self.cursor in targets and direction == 0:
|
|
799
|
+
return
|
|
800
|
+
if direction >= 0:
|
|
801
|
+
after = [i for i in targets if i >= self.cursor]
|
|
802
|
+
self.cursor = after[0] if after else targets[-1]
|
|
803
|
+
else:
|
|
804
|
+
before = [i for i in targets if i <= self.cursor]
|
|
805
|
+
self.cursor = before[-1] if before else targets[0]
|
|
806
|
+
|
|
807
|
+
def _move(self, delta: int) -> None:
|
|
808
|
+
rows = self.model.rows()
|
|
809
|
+
targets = self._interactive(rows)
|
|
810
|
+
if not targets:
|
|
811
|
+
return
|
|
812
|
+
if self.cursor not in targets:
|
|
813
|
+
self._clamp_cursor(delta)
|
|
814
|
+
return
|
|
815
|
+
index = targets.index(self.cursor)
|
|
816
|
+
self.cursor = targets[max(0, min(len(targets) - 1, index + delta))]
|
|
817
|
+
|
|
818
|
+
# ---------------------------------------------------- rendering
|
|
819
|
+
|
|
820
|
+
def _paint(self, width: int, height: int) -> None:
|
|
821
|
+
lines = self._compose(width, height)
|
|
822
|
+
term = self.term
|
|
823
|
+
out: list[str] = []
|
|
824
|
+
for y, segments in enumerate(lines):
|
|
825
|
+
out.append(term.move_xy(0, y))
|
|
826
|
+
out.append(self._ansi(segments))
|
|
827
|
+
sys.stdout.write("".join(out))
|
|
828
|
+
sys.stdout.flush()
|
|
829
|
+
|
|
830
|
+
def _ansi(self, segments: list[Segment]) -> str:
|
|
831
|
+
color_system = self._color_system
|
|
832
|
+
parts: list[str] = []
|
|
833
|
+
for segment in segments:
|
|
834
|
+
if segment.control:
|
|
835
|
+
continue
|
|
836
|
+
if segment.style and color_system is not None:
|
|
837
|
+
parts.append(
|
|
838
|
+
segment.style.render(segment.text, color_system=color_system)
|
|
839
|
+
)
|
|
840
|
+
else:
|
|
841
|
+
parts.append(segment.text)
|
|
842
|
+
return "".join(parts)
|
|
843
|
+
|
|
844
|
+
def _line_segments(self, text: Text, width: int) -> list[Segment]:
|
|
845
|
+
options = self.console.options.update_dimensions(width, 1)
|
|
846
|
+
return self.console.render_lines(text, options, pad=True)[0]
|
|
847
|
+
|
|
848
|
+
def _compose(self, width: int, height: int) -> list[list[Segment]]:
|
|
849
|
+
if width < 44 or height < 12:
|
|
850
|
+
notice = Text("terminal too small — need at least 44×12", style="yellow")
|
|
851
|
+
lines = [Text("")] * (height // 2) + [_center(notice, width)]
|
|
852
|
+
lines += [Text("")] * (height - len(lines))
|
|
853
|
+
return [self._line_segments(line, width) for line in lines[:height]]
|
|
854
|
+
|
|
855
|
+
panel_width = min(_PANEL_WIDTH, width - 4)
|
|
856
|
+
pad = (width - panel_width) // 2
|
|
857
|
+
|
|
858
|
+
head = self._head(panel_width)
|
|
859
|
+
foot = self._foot(panel_width)
|
|
860
|
+
body_height = max(1, height - len(head) - len(foot))
|
|
861
|
+
body, cursor_line = self._body(panel_width)
|
|
862
|
+
|
|
863
|
+
# Keep the cursor line inside the visible slice, one line of margin.
|
|
864
|
+
if cursor_line is not None:
|
|
865
|
+
if cursor_line < self._scroll + 1:
|
|
866
|
+
self._scroll = max(0, cursor_line - 1)
|
|
867
|
+
elif cursor_line > self._scroll + body_height - 2:
|
|
868
|
+
self._scroll = cursor_line - body_height + 2
|
|
869
|
+
self._scroll = max(0, min(self._scroll, max(0, len(body) - body_height)))
|
|
870
|
+
visible = body[self._scroll : self._scroll + body_height]
|
|
871
|
+
visible += [Text("")] * (body_height - len(visible))
|
|
872
|
+
|
|
873
|
+
seg_lines: list[list[Segment]] = []
|
|
874
|
+
for text in (*head, *visible, *foot):
|
|
875
|
+
text.truncate(panel_width, overflow="ellipsis")
|
|
876
|
+
line = Text(" " * pad)
|
|
877
|
+
line.append_text(text)
|
|
878
|
+
seg_lines.append(self._line_segments(line, width))
|
|
879
|
+
seg_lines = seg_lines[:height]
|
|
880
|
+
|
|
881
|
+
if self.stack:
|
|
882
|
+
dim = Style(dim=True)
|
|
883
|
+
seg_lines = [
|
|
884
|
+
list(Segment.apply_style(line, post_style=dim)) for line in seg_lines
|
|
885
|
+
]
|
|
886
|
+
seg_lines = self._overlay(seg_lines, width, height)
|
|
887
|
+
return seg_lines
|
|
888
|
+
|
|
889
|
+
def _overlay(
|
|
890
|
+
self, seg_lines: list[list[Segment]], width: int, height: int
|
|
891
|
+
) -> list[list[Segment]]:
|
|
892
|
+
modal = self.stack[-1][0]
|
|
893
|
+
modal_width = min(modal.width, width - 4)
|
|
894
|
+
options = self.console.options.update_width(modal_width)
|
|
895
|
+
modal_lines = self.console.render_lines(modal.render(), options, pad=True)
|
|
896
|
+
x0 = (width - modal_width) // 2
|
|
897
|
+
y0 = max(0, (height - len(modal_lines)) // 2)
|
|
898
|
+
for i, modal_line in enumerate(modal_lines):
|
|
899
|
+
y = y0 + i
|
|
900
|
+
if y >= height:
|
|
901
|
+
break
|
|
902
|
+
parts = list(Segment.divide(seg_lines[y], [x0, x0 + modal_width, width]))
|
|
903
|
+
left = parts[0] if parts else []
|
|
904
|
+
right = parts[2] if len(parts) > 2 else []
|
|
905
|
+
seg_lines[y] = [*left, *modal_line, *right]
|
|
906
|
+
return seg_lines
|
|
907
|
+
|
|
908
|
+
def _head(self, panel_width: int) -> list[Text]:
|
|
909
|
+
title = Text("✳ patch-cc ✳", style=f"bold {_ACCENT}")
|
|
910
|
+
return [
|
|
911
|
+
Text(""),
|
|
912
|
+
_center(title, panel_width),
|
|
913
|
+
_center(self._status_line(), panel_width),
|
|
914
|
+
Text("─" * panel_width, style=_RULE),
|
|
915
|
+
]
|
|
916
|
+
|
|
917
|
+
def _status_line(self) -> Text:
|
|
918
|
+
model = self.model
|
|
919
|
+
line = Text()
|
|
920
|
+
line.append(f"Claude {model.install.version or '?'}", style="bold")
|
|
921
|
+
line.append(" · ", style="dim")
|
|
922
|
+
if model.status.patched:
|
|
923
|
+
applied = len(model.status.patch_ids)
|
|
924
|
+
line.append("patched", style="green")
|
|
925
|
+
if applied:
|
|
926
|
+
line.append(f" ({applied})", style="dim")
|
|
927
|
+
if self.view == "select" and self._unsaved():
|
|
928
|
+
line.append(" · ", style="dim")
|
|
929
|
+
line.append("unsaved", style=_WARN)
|
|
930
|
+
else:
|
|
931
|
+
line.append("not patched", style=_WARN)
|
|
932
|
+
return line
|
|
933
|
+
|
|
934
|
+
def _foot(self, panel_width: int) -> list[Text]:
|
|
935
|
+
lines = [Text("─" * panel_width, style=_RULE)]
|
|
936
|
+
if self.view == "select":
|
|
937
|
+
if self.flash:
|
|
938
|
+
lines.append(_center(Text(self.flash, style=_WARN), panel_width))
|
|
939
|
+
rows = self.model.rows()
|
|
940
|
+
row = rows[self.cursor] if self.cursor < len(rows) else None
|
|
941
|
+
if isinstance(row, PatchRow) and row.patch.id in _CONFIGURABLE:
|
|
942
|
+
context = _hints(("enter", "configure"), ("space", "toggle"))
|
|
943
|
+
else:
|
|
944
|
+
context = _hints(("enter", "toggle"))
|
|
945
|
+
lines.append(_center(context, panel_width))
|
|
946
|
+
lines.append(
|
|
947
|
+
_center(
|
|
948
|
+
_hints(
|
|
949
|
+
("s", "save"), ("d", "doctor"), ("r", "restore"), ("q", "quit")
|
|
950
|
+
),
|
|
951
|
+
panel_width,
|
|
952
|
+
)
|
|
953
|
+
)
|
|
954
|
+
elif self.view in ("report", "doctor"):
|
|
955
|
+
lines.append(_center(_hints(("enter", "back"), ("q", "quit")), panel_width))
|
|
956
|
+
else:
|
|
957
|
+
lines.append(Text(""))
|
|
958
|
+
lines.append(Text(""))
|
|
959
|
+
return lines
|
|
960
|
+
|
|
961
|
+
def _body(self, panel_width: int) -> tuple[list[Text], int | None]:
|
|
962
|
+
return {
|
|
963
|
+
"select": self._body_select,
|
|
964
|
+
"busy": self._body_busy,
|
|
965
|
+
"report": self._body_report,
|
|
966
|
+
"doctor": self._body_doctor,
|
|
967
|
+
}[self.view](panel_width)
|
|
968
|
+
|
|
969
|
+
def _body_select(self, panel_width: int) -> tuple[list[Text], int | None]:
|
|
970
|
+
lines: list[Text] = []
|
|
971
|
+
cursor_line: int | None = None
|
|
972
|
+
for i, row in enumerate(self.model.rows()):
|
|
973
|
+
if isinstance(row, HeaderRow):
|
|
974
|
+
if lines:
|
|
975
|
+
lines.append(Text(""))
|
|
976
|
+
lines.append(Text(f" {row.title.upper()}", style="bold dim"))
|
|
977
|
+
continue
|
|
978
|
+
current = i == self.cursor
|
|
979
|
+
if current:
|
|
980
|
+
cursor_line = len(lines)
|
|
981
|
+
line = Text()
|
|
982
|
+
line.append("❯ " if current else " ", style=_ACCENT)
|
|
983
|
+
mark, mark_style = ("●", _ACCENT) if row.on else ("○", f"dim {_ACCENT}")
|
|
984
|
+
line.append(f"{mark} ", style=mark_style)
|
|
985
|
+
line.append(
|
|
986
|
+
row.patch.title, style="bold" if current else ("" if row.on else "dim")
|
|
987
|
+
)
|
|
988
|
+
note = self._row_note(row)
|
|
989
|
+
if note is not None:
|
|
990
|
+
gap = panel_width - line.cell_len - note.cell_len
|
|
991
|
+
if gap < 2:
|
|
992
|
+
note.truncate(max(0, note.cell_len + gap - 2), overflow="ellipsis")
|
|
993
|
+
gap = panel_width - line.cell_len - note.cell_len
|
|
994
|
+
line.append(" " * max(2, gap))
|
|
995
|
+
line.append_text(note)
|
|
996
|
+
lines.append(line)
|
|
997
|
+
return lines, cursor_line
|
|
998
|
+
|
|
999
|
+
def _row_note(self, row: PatchRow) -> Text | None:
|
|
1000
|
+
"""The current configuration, shown on the row itself when enabled."""
|
|
1001
|
+
if not row.on:
|
|
1002
|
+
return None
|
|
1003
|
+
model = self.model
|
|
1004
|
+
if row.patch.id == "subagent-models":
|
|
1005
|
+
count = model.overridden()
|
|
1006
|
+
if not count:
|
|
1007
|
+
return Text("defaults", style="dim")
|
|
1008
|
+
return Text(f"{count} override{'s' if count != 1 else ''}", style=_VALUE)
|
|
1009
|
+
if row.patch.id == "branding":
|
|
1010
|
+
return Text(model.text_rows["brand"].value, style=_VALUE)
|
|
1011
|
+
if row.patch.id == "version-marker":
|
|
1012
|
+
return Text(model.text_rows["suffix"].value, style=_VALUE)
|
|
1013
|
+
return None
|
|
1014
|
+
|
|
1015
|
+
def _body_busy(self, panel_width: int) -> tuple[list[Text], int | None]:
|
|
1016
|
+
spinner = _SPINNER[self._frame % len(_SPINNER)]
|
|
1017
|
+
return [
|
|
1018
|
+
Text(""),
|
|
1019
|
+
Text(""),
|
|
1020
|
+
Text(""),
|
|
1021
|
+
_center(Text(self.busy_message, style="bold"), panel_width),
|
|
1022
|
+
Text(""),
|
|
1023
|
+
_center(Text(spinner, style=f"bold {_ACCENT}"), panel_width),
|
|
1024
|
+
], None
|
|
1025
|
+
|
|
1026
|
+
def _body_report(self, panel_width: int) -> tuple[list[Text], int | None]:
|
|
1027
|
+
lines: list[Text] = []
|
|
1028
|
+
report = self.report
|
|
1029
|
+
if report is None:
|
|
1030
|
+
return lines, None
|
|
1031
|
+
for patch, outcome in report.results:
|
|
1032
|
+
missed = outcome.missed_steps()
|
|
1033
|
+
if outcome.landed and not missed:
|
|
1034
|
+
mark, style = "✓", "green"
|
|
1035
|
+
elif outcome.landed:
|
|
1036
|
+
mark, style = "~", "yellow"
|
|
1037
|
+
else:
|
|
1038
|
+
mark, style = "✗", "red"
|
|
1039
|
+
line = Text()
|
|
1040
|
+
line.append(f" {mark} ", style=style)
|
|
1041
|
+
line.append(f"{patch.title:<32}")
|
|
1042
|
+
line.append(f"{outcome.applied or '':>3}", style="dim")
|
|
1043
|
+
lines.append(line)
|
|
1044
|
+
for name in missed:
|
|
1045
|
+
lines.append(Text(f" sub-step missed: {name}", style="yellow"))
|
|
1046
|
+
|
|
1047
|
+
lines.append(Text(""))
|
|
1048
|
+
if report.output is None:
|
|
1049
|
+
line = Text()
|
|
1050
|
+
line.append(" ✗ ", style="red")
|
|
1051
|
+
line.append("No patch changed anything; binary left untouched.")
|
|
1052
|
+
lines.append(line)
|
|
1053
|
+
else:
|
|
1054
|
+
saved = (report.original_size - report.patched_size) / 1e6
|
|
1055
|
+
line = Text()
|
|
1056
|
+
line.append(" ✓ ", style="green")
|
|
1057
|
+
line.append(f"Saved to {report.output.name}", style="bold")
|
|
1058
|
+
line.append(
|
|
1059
|
+
f" · {report.patched_size / 1e6:.0f} MB ({saved:.0f} MB smaller)",
|
|
1060
|
+
style="dim",
|
|
1061
|
+
)
|
|
1062
|
+
lines.append(line)
|
|
1063
|
+
lines.append(Text(" Restart Claude Code to see it.", style="dim"))
|
|
1064
|
+
return lines, None
|
|
1065
|
+
|
|
1066
|
+
def _body_doctor(self, panel_width: int) -> tuple[list[Text], int | None]:
|
|
1067
|
+
lines: list[Text] = []
|
|
1068
|
+
result = self.doctor_result
|
|
1069
|
+
if result is None:
|
|
1070
|
+
return lines, None
|
|
1071
|
+
for check in result.checks:
|
|
1072
|
+
outcome = check.outcome
|
|
1073
|
+
missed = outcome.missed_steps()
|
|
1074
|
+
if outcome.landed and not missed:
|
|
1075
|
+
mark, style = "✓", "green"
|
|
1076
|
+
elif outcome.landed:
|
|
1077
|
+
mark, style = "~", "yellow"
|
|
1078
|
+
else:
|
|
1079
|
+
mark, style = "✗", "red"
|
|
1080
|
+
line = Text()
|
|
1081
|
+
line.append(f" {mark} ", style=style)
|
|
1082
|
+
line.append(f"{check.patch.id:<22}")
|
|
1083
|
+
line.append(
|
|
1084
|
+
f"cand={outcome.candidates:<3} applied={outcome.applied}", style="dim"
|
|
1085
|
+
)
|
|
1086
|
+
lines.append(line)
|
|
1087
|
+
lines.append(Text(""))
|
|
1088
|
+
lines.append(
|
|
1089
|
+
Text(f" agents {', '.join(a.name for a in result.agents)}", style="dim")
|
|
1090
|
+
)
|
|
1091
|
+
lines.append(Text(f" models {', '.join(result.models)}", style="dim"))
|
|
1092
|
+
return lines, None
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
def _center(text: Text, width: int) -> Text:
|
|
1096
|
+
pad = max(0, (width - text.cell_len) // 2)
|
|
1097
|
+
line = Text(" " * pad)
|
|
1098
|
+
line.append_text(text)
|
|
1099
|
+
return line
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
# ----------------------------------------------------------------- entry
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def run_menu() -> int:
|
|
1106
|
+
if not (sys.stdout.isatty() and sys.stdin.isatty()):
|
|
1107
|
+
err("The interactive menu needs a terminal; use the subcommands instead.")
|
|
1108
|
+
console.print(" [dim]patch-cc apply --help[/dim]")
|
|
1109
|
+
return 2
|
|
1110
|
+
|
|
1111
|
+
install = locate.find()
|
|
1112
|
+
if install is None:
|
|
1113
|
+
err("No Claude Code native install found.")
|
|
1114
|
+
console.print(
|
|
1115
|
+
" Install it with: [cyan]curl -fsSL https://claude.ai/install.sh | bash[/cyan]"
|
|
1116
|
+
)
|
|
1117
|
+
return 1
|
|
1118
|
+
|
|
1119
|
+
try:
|
|
1120
|
+
installed = container.read(str(install.binary))
|
|
1121
|
+
pristine = patcher.read_pristine(install)
|
|
1122
|
+
except BunError as exc:
|
|
1123
|
+
err(str(exc))
|
|
1124
|
+
return 1
|
|
1125
|
+
|
|
1126
|
+
from . import doctor
|
|
1127
|
+
|
|
1128
|
+
status = doctor.status(installed)
|
|
1129
|
+
|
|
1130
|
+
model = MenuModel.build(install, status, pristine)
|
|
1131
|
+
app = MenuApp(model)
|
|
1132
|
+
code = app.run()
|
|
1133
|
+
if app.exit_message:
|
|
1134
|
+
console.print(app.exit_message)
|
|
1135
|
+
return code
|