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/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