magus-cli 7.3.1 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  13. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  14. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  15. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  16. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  17. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  18. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  19. package/src/__tests__/blind/child-run.ts +114 -0
  20. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  21. package/src/__tests__/component-badge.test.ts +20 -36
  22. package/src/__tests__/footer-hints.test.ts +1 -1
  23. package/src/__tests__/manifest.test.ts +25 -17
  24. package/src/__tests__/marketplace-badge.test.ts +4 -4
  25. package/src/__tests__/models-screen-state.test.ts +36 -13
  26. package/src/__tests__/scope-squares.test.tsx +28 -114
  27. package/src/__tests__/shutdown.test.ts +578 -0
  28. package/src/__tests__/styles-manager.test.ts +4 -4
  29. package/src/__tests__/styles-screen-state.test.ts +26 -12
  30. package/src/__tests__/tabbar-layout.test.ts +44 -32
  31. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  32. package/src/__tests__/tool-probe.test.ts +47 -22
  33. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  34. package/src/cli/ansi.ts +2 -2
  35. package/src/cli/bootstrap.ts +33 -2
  36. package/src/cli/doctor.ts +105 -14
  37. package/src/cli/install.ts +8 -2
  38. package/src/cli/profile.ts +18 -1
  39. package/src/cli/router.ts +31 -18
  40. package/src/cli/update-view.ts +1 -1
  41. package/src/data/cli-tools.ts +9 -1
  42. package/src/services/claude-settings.ts +151 -73
  43. package/src/services/cli-tool-commands.ts +15 -5
  44. package/src/services/cli-tool-version.ts +101 -0
  45. package/src/services/hook-registration.ts +23 -6
  46. package/src/services/manifest.ts +117 -52
  47. package/src/services/plugin-manager.ts +19 -5
  48. package/src/services/profile-adopt.ts +7 -2
  49. package/src/services/profile-startup.ts +87 -7
  50. package/src/services/profiles.ts +36 -23
  51. package/src/services/settings-file.ts +158 -4
  52. package/src/services/settings-manager.ts +19 -5
  53. package/src/services/skills-manager.ts +46 -0
  54. package/src/services/tool-probe.ts +272 -45
  55. package/src/services/toolchain.ts +39 -0
  56. package/src/services/update-engine.ts +5 -0
  57. package/src/services/update-plan.ts +9 -0
  58. package/src/tui.tsx +48 -21
  59. package/src/ui/App.tsx +58 -197
  60. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  61. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  62. package/src/ui/adapters/settingsAdapter.ts +0 -21
  63. package/src/ui/adapters/skillsAdapter.ts +27 -8
  64. package/src/ui/adapters/stylesAdapter.ts +15 -11
  65. package/src/ui/components/CategoryHeader.tsx +7 -1
  66. package/src/ui/components/EmptyFilterState.tsx +2 -2
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  69. package/src/ui/components/TabBar.tsx +93 -21
  70. package/src/ui/components/layout/FooterHints.tsx +6 -27
  71. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  72. package/src/ui/components/layout/index.ts +1 -5
  73. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  74. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  75. package/src/ui/components/modals/MessageModal.tsx +3 -15
  76. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  77. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  78. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  79. package/src/ui/components/primitives/MetaText.tsx +1 -1
  80. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  81. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  82. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  83. package/src/ui/components/primitives/index.ts +4 -3
  84. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  85. package/src/ui/input/KeyRouter.tsx +180 -0
  86. package/src/ui/input/key-dispatch.ts +149 -0
  87. package/src/ui/keymap.ts +898 -0
  88. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  89. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  90. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  91. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  92. package/src/ui/renderers/modelRenderers.tsx +12 -13
  93. package/src/ui/renderers/modelVisuals.tsx +0 -2
  94. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  95. package/src/ui/renderers/profileRenderers.tsx +32 -37
  96. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  97. package/src/ui/renderers/skillRenderers.tsx +237 -190
  98. package/src/ui/renderers/styleRenderers.tsx +13 -2
  99. package/src/ui/runtime/shutdown.ts +268 -0
  100. package/src/ui/screens/AliasScreen.tsx +118 -100
  101. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  102. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  103. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  104. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  105. package/src/ui/screens/McpScreen.tsx +47 -33
  106. package/src/ui/screens/ModelsScreen.tsx +59 -104
  107. package/src/ui/screens/PluginsScreen.tsx +123 -127
  108. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  109. package/src/ui/screens/SkillsScreen.tsx +184 -204
  110. package/src/ui/screens/StylesScreen.tsx +104 -157
  111. package/src/ui/state/AppContext.tsx +6 -0
  112. package/src/ui/state/DimensionsContext.tsx +1 -10
  113. package/src/ui/state/reducer.ts +5 -8
  114. package/src/ui/state/types.ts +11 -9
  115. package/src/ui/text-width.ts +341 -0
  116. package/src/ui/theme-mode.ts +34 -71
  117. package/src/ui/theme.ts +50 -28
  118. package/src/utils/request-gate.ts +37 -0
  119. package/src/ui/components/ScopeIndicator.tsx +0 -63
  120. package/src/ui/components/SearchInput.tsx +0 -57
  121. package/src/ui/hooks/index.ts +0 -2
  122. package/src/ui/hooks/useKeyboard.ts +0 -24
  123. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -0,0 +1,268 @@
1
+ /**
2
+ * runtime/shutdown.ts — ONE owner and ONE idempotent path for every exit: the quit key,
3
+ * SIGINT, SIGTERM, a fatal signal, normal completion, and a thrown error. Never call
4
+ * `process.exit()` anywhere else; a live renderer owns the terminal, and exiting around
5
+ * it leaves raw mode on and ghost cells the renderer can no longer clear.
6
+ *
7
+ * THIS MODULE IS THE SOLE SIGNAL OWNER, and the bootstrap must keep it that way:
8
+ *
9
+ * await createCliRenderer({ exitSignals: [], exitOnCtrlC: false, ... })
10
+ *
11
+ * Two owners is not a style question, it is a measured bug. The renderer is constructed
12
+ * before `installShutdown` runs, so its handler is registered first and fires first; a
13
+ * real SIGTERM against the pinned 0.4.5 renderer produced
14
+ * renderer.destroy → disposer → root.unmount → renderer.destroy
15
+ * — the React tree torn down AFTER the terminal was already restored, and `destroy()`
16
+ * run twice. With `exitSignals: []` upstream's `addExitListeners()` returns early on
17
+ * `this.exitSignals.length === 0` and registers nothing, so the order below is the only
18
+ * one there is. (`[]` survives upstream's `config.exitSignals || [...defaults]` because an
19
+ * empty array is truthy — this is the documented "disables signal-based cleanup
20
+ * entirely" switch, not an accident.)
21
+ *
22
+ * CTRL+C IS NOT A SIGNAL HERE. The renderer puts stdin in raw mode, which clears ISIG, so
23
+ * the terminal driver never raises SIGINT from a keypress — it delivers the 0x03 byte.
24
+ * `exitOnCtrlC: false` stops the renderer quitting behind us, so the bootstrap owns the
25
+ * key itself:
26
+ * renderer.keyInput.on("keypress", (k) => { if (k.ctrl && k.name === "c") void shutdown() })
27
+ * Our SIGINT handler is still load-bearing — it catches `kill -INT` from outside the tty.
28
+ *
29
+ * Bridge site B1: imports from core and react, calls no construct. */
30
+
31
+ import { constants } from "node:os";
32
+ import type { CliRenderer } from "@opentui/core";
33
+ import type { Root } from "@opentui/react";
34
+
35
+ /**
36
+ * Every signal `installShutdown` registers: OpenTUI's documented default set, complete,
37
+ * SIGINT included — because the renderer is now told to register none of them.
38
+ *
39
+ * NEVER hand this to `createCliRenderer`. It was previously exported as `EXIT_SIGNALS`
40
+ * for precisely that purpose, and that export WAS the second owner. The rename is the
41
+ * point: a stale `exitSignals: EXIT_SIGNALS` bootstrap now fails to compile instead of
42
+ * silently reinstating the double-destroy above.
43
+ *
44
+ * SIGBREAK is Windows-only. `process.on` accepts it on darwin and it simply never fires,
45
+ * so registration is attempted for every entry and only what actually registered is
46
+ * tracked for removal. */
47
+ export const OWNED_SIGNALS: NodeJS.Signals[] = [
48
+ "SIGINT",
49
+ "SIGTERM",
50
+ "SIGQUIT",
51
+ "SIGABRT",
52
+ "SIGHUP",
53
+ "SIGBREAK",
54
+ "SIGPIPE",
55
+ "SIGBUS",
56
+ ];
57
+
58
+ /** Long enough for the restored terminal to flush its last frame, short enough that a
59
+ * quit still feels immediate. */
60
+ const FLUSH_MS = 50;
61
+
62
+ /**
63
+ * EXIT CODE POLICY — `128 + signum`, the shell's own convention. Deliberately NOT
64
+ * re-raising the signal, though re-raising is the textbook answer:
65
+ *
66
+ * 1. It loses the cause on this very stack. Bun reports a re-raised darwin SIGBUS to its
67
+ * parent as `signalCode: "SIGUSR1"` (signal 10, read back through a Linux table), so
68
+ * the one signal we are required to keep distinguishable is the one re-raise corrupts.
69
+ * 2. It needs `process.kill(pid, "SIGBREAK")`, which throws `Unknown signal` off Windows.
70
+ * 3. It only terminates while the default disposition survives listener removal. Any
71
+ * other library still holding a listener on that signal turns the re-raise into a
72
+ * no-op and the process hangs — the exact failure class this file keeps regressing
73
+ * into, traded in for the one thing re-raise was supposed to buy.
74
+ *
75
+ * `128 + signum` is deterministic, needs no cooperation from anything else in the
76
+ * process, and is platform-correct as long as the number comes from the OS and never
77
+ * from a literal (SIGBUS is 10 on darwin, 7 on Linux): SIGINT 130, SIGQUIT 131,
78
+ * SIGABRT 134, SIGBUS 138, SIGPIPE 141, SIGTERM 143. Every one is distinguishable from
79
+ * another and from the 0 of a clean quit — a signal-terminated TUI never reports success.
80
+ * The trade-off accepted: a parent reading `WIFSIGNALED` sees an exit code instead. */
81
+ const signalExitCode = (sig: NodeJS.Signals): number => {
82
+ const signum = (
83
+ constants.signals as unknown as Record<string, number | undefined>
84
+ )[sig];
85
+ return typeof signum === "number" ? 128 + signum : 128; // unknown here: still never 0
86
+ };
87
+
88
+ const UNPRINTABLE = "<unprintable throwable>";
89
+
90
+ /**
91
+ * Format a throwable, and NEVER throw doing it. Not defensive padding — the catch block
92
+ * used to run `err.stack ?? err.message` / `String(err)` raw, so an `unmount` that threw
93
+ * an object with a throwing `toString` made the FORMATTER throw. That escaped the catch,
94
+ * skipped `renderer.destroy()` and skipped the exit, with the idempotency flag already
95
+ * latched so no retry could ever clean up: measured, that process hung until the test's
96
+ * guard timer fired. A `stack` getter, a `message` getter, `Symbol.toPrimitive` and a
97
+ * Proxy trap can each throw here, so the whole read sits in one `try` and the fallback is
98
+ * a constant that cannot involve the value itself. */
99
+ const describeError = (err: unknown): string => {
100
+ try {
101
+ if (err instanceof Error) {
102
+ const stack = err.stack;
103
+ if (typeof stack === "string" && stack !== "") return stack;
104
+ const message = err.message;
105
+ if (typeof message === "string" && message !== "") return message;
106
+ }
107
+ return String(err);
108
+ } catch {
109
+ return `${UNPRINTABLE} (typeof ${typeof err})`;
110
+ }
111
+ };
112
+
113
+ /**
114
+ * Structural minimums, not the full classes: `installShutdown` calls exactly two methods,
115
+ * and saying so lets a test pass two stubs with no `as unknown as` cast — a teardown path
116
+ * whose failure modes cannot be exercised is how this file grew them. A real
117
+ * `CliRenderer` / `Root` satisfies these. */
118
+ type Destroyable = Pick<CliRenderer, "destroy">;
119
+ type Unmountable = Pick<Root, "unmount">;
120
+
121
+ /**
122
+ * Wire every exit path to one teardown and return it, so the app can also end normally by
123
+ * awaiting it. The returned function is safe to call repeatedly and from inside a handler
124
+ * — a SIGINT arriving during teardown must not re-enter.
125
+ *
126
+ * The returned function takes an optional exit code. Omitted, it tears down and hands
127
+ * control back, and the program ends when the event loop drains. Given — `shutdown(0)` for
128
+ * a clean quit — it tears down, flushes, and exits with that code. An app whose loop may
129
+ * still hold work at quit time (a pending fetch, a child process) passes the code: a quit
130
+ * key that tears the UI down and then leaves the process running looks hung.
131
+ *
132
+ * GUARANTEED ORDER, on every path: `disposers` (stop the app's own work) → `root.unmount`
133
+ * (so no render is queued against a dead renderer) → `renderer.destroy` (restore the
134
+ * terminal) → report failures → flush → exit.
135
+ *
136
+ * `disposers` is where an app-owned `setInterval`, watcher or socket goes. It is not a
137
+ * convenience: this file forbids `process.exit()` everywhere else, so a timer with no
138
+ * teardown hook had nowhere to be cleared and kept the event loop alive after an
139
+ * `await shutdown()` that was supposed to end the program. Each runs at most once, before
140
+ * the tree comes down, so nothing can fire a `setState` into an unmounting root:
141
+ *
142
+ * const t = setInterval(tick, 1000)
143
+ * const shutdown = installShutdown(renderer, root, [() => clearInterval(t)])
144
+ *
145
+ * EVERY STEP IS GUARDED INDEPENDENTLY, AND `process.exit(code)` IS IN A `finally`. That is
146
+ * the whole shape of the fix. A quit that leaves the terminal wedged is the worst failure
147
+ * this file can have, so a broken disposer, an unprintable throwable or a double-destroy
148
+ * all degrade to a stderr line printed AFTER the terminal is restored, and the process
149
+ * still exits with the code it was given.
150
+ */
151
+ export function installShutdown(
152
+ renderer: Destroyable,
153
+ root: Unmountable,
154
+ disposers: ReadonlyArray<() => void> = [],
155
+ ): (exitCode?: number) => Promise<void> {
156
+ const handlers = new Map<NodeJS.Signals, () => void>();
157
+ const failures: string[] = [];
158
+ let torndown = false;
159
+
160
+ /** stderr is safe only AFTER `destroy()`: behind a live renderer it leaves cells the
161
+ * renderer cannot invalidate. It can also fail on its own (a closed pipe), and a failed
162
+ * report must never be the thing that stops the process exiting. */
163
+ const report = (line: string): void => {
164
+ try {
165
+ console.error(line);
166
+ } catch {
167
+ /* stderr is gone; there is nowhere left to report */
168
+ }
169
+ };
170
+
171
+ /** Run one teardown step; never let it end the teardown. Reported after `destroy()`. */
172
+ const attempt = (label: string, step: () => void): void => {
173
+ try {
174
+ step();
175
+ } catch (err) {
176
+ failures.push(`${label}: ${describeError(err)}`);
177
+ }
178
+ };
179
+
180
+ /**
181
+ * Every step, exactly once, in the documented order — and this function CANNOT throw.
182
+ * The teardown steps are guarded independently and at the same level, so nothing (not a
183
+ * throwing disposer, not a pathological `disposers` array, not a throwing `unmount`) can
184
+ * skip `renderer.destroy()`. Restoring the terminal is the step that must never be
185
+ * skipped. */
186
+ const teardownOnce = (): void => {
187
+ if (torndown) return;
188
+ torndown = true;
189
+ try {
190
+ // Listeners come off FIRST, so a second signal arriving mid-teardown hits the
191
+ // default disposition and kills the process outright instead of re-entering here —
192
+ // and so a later renderer in the same process starts from a clean slate.
193
+ attempt("removeListeners", () => {
194
+ for (const [sig, handler] of handlers)
195
+ process.removeListener(sig, handler);
196
+ handlers.clear();
197
+ process.removeListener("uncaughtException", onCrash);
198
+ process.removeListener("unhandledRejection", onCrash);
199
+ });
200
+ attempt("disposers", () => {
201
+ disposers.forEach((d, i) => attempt(`disposers[${i}]`, d));
202
+ });
203
+ attempt("root.unmount", () => {
204
+ root.unmount();
205
+ });
206
+ attempt("renderer.destroy", () => {
207
+ renderer.destroy();
208
+ });
209
+ for (const f of failures) report(`shutdown: ${f}`);
210
+ } catch (err) {
211
+ // Unreachable by construction — which is exactly what was believed of the catch
212
+ // block that threw. A file broken three times does not get to rely on that.
213
+ report(`shutdown: teardown: ${describeError(err)}`);
214
+ }
215
+ };
216
+
217
+ /**
218
+ * `exitCode === undefined` means "tear down and hand control back": the normal-quit
219
+ * path ends the program by letting the event loop drain, not by exiting.
220
+ *
221
+ * THE EXIT IS IN A `finally`. It used to sit in a `.then()` on the success path, so any
222
+ * throw upstream skipped it entirely — and since every caller is fire-and-forget
223
+ * (`void shutdown(...)`, because a signal handler cannot await), the rejection went
224
+ * nowhere and took the exit code with it. The once-latch guards only the STEPS: a
225
+ * repeat call re-runs nothing but still reaches this `finally`, so a caller can always
226
+ * still end the process and latching can never strand it. */
227
+ const shutdown = async (
228
+ exitCode?: number,
229
+ crash?: { readonly err: unknown },
230
+ ): Promise<void> => {
231
+ try {
232
+ teardownOnce();
233
+ // Deliberately NOT behind the latch: a crash can land after teardown has already
234
+ // run, and it still has to be reported — after `destroy()`, never before.
235
+ if (crash) report(describeError(crash.err));
236
+ } finally {
237
+ if (exitCode !== undefined) {
238
+ // Let the restored terminal flush its last frame before the process goes.
239
+ try {
240
+ await new Promise((r) => setTimeout(r, FLUSH_MS));
241
+ } catch {
242
+ /* a broken timer must not block the exit */
243
+ }
244
+ process.exit(exitCode);
245
+ }
246
+ }
247
+ };
248
+
249
+ const onCrash = (err: unknown): void => {
250
+ void shutdown(1, { err });
251
+ };
252
+
253
+ for (const sig of OWNED_SIGNALS) {
254
+ const handler = (): void => {
255
+ void shutdown(signalExitCode(sig));
256
+ };
257
+ try {
258
+ process.on(sig, handler);
259
+ handlers.set(sig, handler); // track only what registered, so removal stays symmetric
260
+ } catch {
261
+ /* signal unknown to this platform: nothing registered, nothing to remove */
262
+ }
263
+ }
264
+ process.on("uncaughtException", onCrash);
265
+ process.on("unhandledRejection", onCrash);
266
+
267
+ return (exitCode?: number) => shutdown(exitCode);
268
+ }
@@ -32,10 +32,15 @@ import {
32
32
  validateAliasName,
33
33
  withoutDerivedChannel,
34
34
  } from "../../services/alias-store.js";
35
- import { FlagDetailEditor } from "../components/FlagDetailEditor.js";
36
- import { type FooterHint, ScreenLayout } from "../components/layout/index.js";
37
- import { useKeyboardHandler } from "../hooks/useKeyboardHandler.js";
35
+ import {
36
+ FlagDetailEditor,
37
+ type FlagDetailEditorHandle,
38
+ } from "../components/FlagDetailEditor.js";
39
+ import { ScreenLayout } from "../components/layout/index.js";
40
+ import { useScreenKeys } from "../input/KeyRouter.js";
41
+ import { moveDirection } from "../keymap.js";
38
42
  import { useApp, useModal } from "../state/AppContext.js";
43
+ import { displayWidth, truncate } from "../text-width.js";
39
44
  import { theme } from "../theme.js";
40
45
 
41
46
  interface FlagRow {
@@ -627,39 +632,33 @@ export function AliasScreen() {
627
632
  setViewMode({ kind: "main" });
628
633
  }, []);
629
634
 
630
- // ── Keyboard handler ──────────────────────────────────────────────────
631
- // When the sub-screen is mounted, FlagDetailEditor installs its own
632
- // keyboard handler. Both handlers fire (OpenTUI's useKeyboard delivers
633
- // to every subscriber), so this handler must SKIP the keys the child
634
- // owns to avoid double-dispatch. We bail entirely while in detail mode.
635
- useKeyboardHandler((input, key) => {
636
- if (state.modal) return;
637
- if (viewMode.kind === "flag-detail") return;
638
-
639
- // Main mode.
640
- if (input === "j" || key.downArrow) {
641
- moveSelection(1);
642
- return;
643
- }
644
- if (input === "k" || key.upArrow) {
645
- moveSelection(-1);
646
- return;
647
- }
648
- if (key.return || input === " " || key.name === "space") {
649
- void toggleSelected();
650
- return;
651
- }
652
- if (input === "e") {
653
- void editSelected();
654
- return;
655
- }
656
- if (input === "w") {
657
- void handleWrite();
658
- return;
659
- }
660
- if (input === "r") {
661
- // Reset the selected row to its default.
662
- if (!selectedRow) return;
635
+ // ── Keys ──────────────────────────────────────────────────────────────
636
+ // ONE screen layer for both views. While the flag editor is open its keys
637
+ // go to the editor through `editorRef`; `h` / Esc leave it. Esc is consumed
638
+ // there, so it never also reaches the global "back to Plugins". On the flag
639
+ // list the editor-only keys decline, and Esc falls through to that global
640
+ // back as on every other tab.
641
+ const editorRef = useRef<FlagDetailEditorHandle | null>(null);
642
+ const editing = viewMode.kind === "flag-detail";
643
+
644
+ useScreenKeys("alias", {
645
+ move: (key) => {
646
+ if (editing) editorRef.current?.move(moveDirection(key));
647
+ else moveSelection(moveDirection(key));
648
+ },
649
+ toggle: () => {
650
+ if (editing) editorRef.current?.toggle();
651
+ else void toggleSelected();
652
+ },
653
+ edit: () => {
654
+ if (!editing) void editSelected();
655
+ },
656
+ write: () => {
657
+ if (!editing) void handleWrite();
658
+ },
659
+ // Reset the selected row to its default.
660
+ reset: () => {
661
+ if (editing || !selectedRow) return;
663
662
  if (selectedRow.kind === "alias-name") {
664
663
  setConfig((prev) =>
665
664
  prev ? { ...prev, aliasName: DEFAULT_ALIAS_NAME } : prev,
@@ -669,7 +668,18 @@ export function AliasScreen() {
669
668
  }
670
669
  // Flag reset stays in memory until `w`.
671
670
  updateFlag(selectedRow.flag.id, defaultValueFor(selectedRow.flag));
672
- }
671
+ },
672
+ add: () => {
673
+ if (editing) editorRef.current?.add();
674
+ },
675
+ delete: () => {
676
+ if (editing) editorRef.current?.remove();
677
+ },
678
+ back: () => {
679
+ if (!editing) return false;
680
+ exitDetail();
681
+ return true;
682
+ },
673
683
  });
674
684
 
675
685
  // ── Render ────────────────────────────────────────────────────────────
@@ -699,38 +709,49 @@ export function AliasScreen() {
699
709
  ? detailValue
700
710
  : null;
701
711
 
712
+ // A function of the panel width: every row is cut to it with "…", because a
713
+ // row that wraps turns one flag into two lines and the list stops reading as
714
+ // one flag per line (MEASURED at 80 columns: two flags folded).
702
715
  const mainListPanel = !config ? (
703
716
  <text fg={theme.colors.muted}>
704
717
  {error ? `Error: ${error}` : "Loading..."}
705
718
  </text>
706
719
  ) : (
707
- <box flexDirection="column" height="100%">
708
- <scrollbox
709
- ref={mainScrollRef}
710
- scrollY={true}
711
- scrollX={false}
712
- flexGrow={1}
713
- >
714
- <box flexDirection="column">
715
- {items.map((item, idx) => (
716
- // id is content-derived (not positional `${idx}`) so OpenTUI's
717
- // reconciler stays in lockstep with React's key-based reconcile.
718
- <box key={getItemKey(item)} id={`alias-row-${getItemKey(item)}`}>
719
- {renderItem(item, idx === selectedIdx, isGated)}
720
- </box>
721
- ))}
722
- </box>
723
- </scrollbox>
724
- </box>
720
+ (width: number) => (
721
+ <box flexDirection="column" height="100%">
722
+ <scrollbox
723
+ ref={mainScrollRef}
724
+ scrollY={true}
725
+ scrollX={false}
726
+ flexGrow={1}
727
+ >
728
+ <box flexDirection="column">
729
+ {items.map((item, idx) => (
730
+ // id is content-derived (not positional `${idx}`) so OpenTUI's
731
+ // reconciler stays in lockstep with React's key-based reconcile.
732
+ <box key={getItemKey(item)} id={`alias-row-${getItemKey(item)}`}>
733
+ {renderItem(
734
+ item,
735
+ idx === selectedIdx,
736
+ isGated,
737
+ // The scrollbox keeps one column for its scrollbar.
738
+ Math.max(8, width - 1),
739
+ )}
740
+ </box>
741
+ ))}
742
+ </box>
743
+ </scrollbox>
744
+ </box>
745
+ )
725
746
  );
726
747
 
727
748
  const listPanel =
728
749
  inDetail && editableDetailValue ? (
729
750
  <FlagDetailEditor
751
+ ref={editorRef}
730
752
  flag={detailFlag}
731
753
  value={editableDetailValue}
732
754
  onChange={(next) => updateFlag(detailFlag.id, next)}
733
- onBack={exitDetail}
734
755
  derivedValues={
735
756
  detailFlag.id === CHANNELS_FLAG_ID && config
736
757
  ? derivedChannelValues(config.flags)
@@ -785,17 +806,6 @@ export function AliasScreen() {
785
806
  : null;
786
807
  const detailPanel = inDetail ? detailDetailPanel : mainDetailPanel;
787
808
 
788
- const enabledCount = config
789
- ? Object.values(config.flags).filter(isFlagEnabled).length
790
- : 0;
791
- const subtitle = inDetail
792
- ? `editing ${detailFlag.flag}`
793
- : writeStatus
794
- ? writeStatus
795
- : config
796
- ? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled`
797
- : "";
798
-
799
809
  // Resolve the rc-file name we'd actually write to (basename of the chosen
800
810
  // shell's path). Falls back to "shell" if no shells were detected — UI
801
811
  // surface mirrors the runtime behavior of `handleWrite`.
@@ -812,31 +822,30 @@ export function AliasScreen() {
812
822
  ? rcSnapshot !== null && !shallowFlagsEqual(config.flags, rcSnapshot)
813
823
  : false;
814
824
 
815
- const footerHints: FooterHint[] = inDetail
816
- ? [
817
- { keys: ["↑", "↓"], label: "move" },
818
- { keys: ["space"], label: "toggle" },
819
- { keys: ["a"], label: "add" },
820
- { keys: ["d"], label: "delete" },
821
- { keys: ["h"], label: "back" },
822
- ]
823
- : [
824
- { keys: ["↑", "↓"], label: "move" },
825
- { keys: ["space"], label: "toggle" },
826
- { keys: ["e"], label: "edit" },
827
- { keys: ["r"], label: "reset" },
828
- {
829
- keys: ["w"],
830
- label: `${dirty ? "*" : ""}write to ${writeLabel}`,
831
- },
832
- ];
825
+ // The footer's `w write` is the keymap's fixed hint, so where it writes and
826
+ // whether anything is unwritten are said here, beside the title.
827
+ const enabledCount = config
828
+ ? Object.values(config.flags).filter(isFlagEnabled).length
829
+ : 0;
830
+ const subtitle = inDetail
831
+ ? `editing ${detailFlag.flag}`
832
+ : writeStatus
833
+ ? writeStatus
834
+ : config
835
+ ? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled${
836
+ dirty ? ` │ * unsaved, w writes ${writeLabel}` : ""
837
+ }`
838
+ : "";
833
839
 
834
840
  return (
835
841
  <ScreenLayout
836
842
  title="Claude Alias"
837
843
  subtitle={subtitle}
838
844
  currentScreen="alias"
839
- footerHints={footerHints}
845
+ footer={{
846
+ // Each view advertises its own keys; all of them stay in help.
847
+ hide: inDetail ? ["edit", "reset", "write"] : ["add", "delete", "back"],
848
+ }}
840
849
  listPanel={listPanel}
841
850
  detailPanel={detailPanel}
842
851
  />
@@ -1045,10 +1054,6 @@ function summarizeValue(value: FlagValue): string {
1045
1054
  }
1046
1055
  }
1047
1056
 
1048
- function truncate(s: string, n: number): string {
1049
- return s.length <= n ? s : `${s.slice(0, n - 1)}…`;
1050
- }
1051
-
1052
1057
  /**
1053
1058
  * Quick toggle (enter/space). For boolean → flip. For tri-state → cycle
1054
1059
  * unset → on → off → unset. For others, just flip the `enabled` field; the
@@ -1207,23 +1212,31 @@ async function editFlagValue(
1207
1212
  * "Alias:" label and the value styled as the editable field, plus an `[edit]`
1208
1213
  * hint, so it doesn't blend in with the flag rows below.
1209
1214
  */
1210
- function renderAliasNameRow(name: string, selected: boolean): React.ReactNode {
1215
+ function renderAliasNameRow(
1216
+ name: string,
1217
+ selected: boolean,
1218
+ width: number,
1219
+ ): React.ReactNode {
1211
1220
  if (selected) {
1212
1221
  return (
1213
1222
  <box key="alias-name-row" flexDirection="row">
1214
- <text bg={theme.colors.accent} fg={theme.hints.fg}>
1215
- {`▶ Alias name: ${name} [enter to edit]`}
1223
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
1224
+ {truncate(`▶ Alias name: ${name} [enter to edit]`, width)}
1216
1225
  </text>
1217
1226
  </box>
1218
1227
  );
1219
1228
  }
1229
+ const hint = " [enter to edit]";
1230
+ const head = " Alias name: ";
1220
1231
  return (
1221
- <text key="alias-name-row">
1222
- <span fg={theme.colors.muted}>{" Alias name: "}</span>
1232
+ <text key="alias-name-row" fg={theme.colors.text}>
1233
+ <span fg={theme.colors.muted}>{head}</span>
1223
1234
  <span fg={theme.colors.info}>
1224
1235
  <strong>{name}</strong>
1225
1236
  </span>
1226
- <span fg={theme.colors.muted}>{" [enter to edit]"}</span>
1237
+ <span fg={theme.colors.muted}>
1238
+ {truncate(hint, Math.max(0, width - displayWidth(head + name)))}
1239
+ </span>
1227
1240
  </text>
1228
1241
  );
1229
1242
  }
@@ -1245,12 +1258,13 @@ function renderItem(
1245
1258
  item: Item,
1246
1259
  isSelected: boolean,
1247
1260
  isGated: (f: AliasFlag) => boolean,
1261
+ width: number,
1248
1262
  ): React.ReactNode {
1249
1263
  switch (item.kind) {
1250
1264
  case "alias-name":
1251
- return renderAliasNameRow(item.row.value, isSelected);
1265
+ return renderAliasNameRow(item.row.value, isSelected, width);
1252
1266
  case "flag":
1253
- return renderRow(item.row, isSelected, isGated(item.row.flag));
1267
+ return renderRow(item.row, isSelected, isGated(item.row.flag), width);
1254
1268
  case "header":
1255
1269
  return <text fg={theme.colors.accent}>{item.label}</text>;
1256
1270
  }
@@ -1260,6 +1274,7 @@ function renderRow(
1260
1274
  row: FlagRow,
1261
1275
  selected: boolean,
1262
1276
  gated: boolean,
1277
+ width: number,
1263
1278
  ): React.ReactNode {
1264
1279
  // Prefer the precomputed effective state (e.g. --channels shows enabled when
1265
1280
  // a dev-loaded channel forces it to emit) over the flag's own enabled flag.
@@ -1276,10 +1291,13 @@ function renderRow(
1276
1291
  // effectiveDetail (e.g. --channels union count) wins over the own-value one.
1277
1292
  const detail = row.effectiveDetail ?? enabledDetail(row.value);
1278
1293
  const detailSuffix = detail ? ` ${detail}` : "";
1279
- const line = `${selected ? "▶ " : " "}${marker} ${row.flag.flag}${detailSuffix}${issueMark}`;
1294
+ const line = truncate(
1295
+ `${selected ? "▶ " : " "}${marker} ${row.flag.flag}${detailSuffix}${issueMark}`,
1296
+ width,
1297
+ );
1280
1298
  if (selected) {
1281
1299
  return (
1282
- <text key={row.flag.id} bg={theme.colors.accent} fg={theme.hints.fg}>
1300
+ <text key={row.flag.id} bg={theme.selection.bg} fg={theme.selection.fg}>
1283
1301
  {line}
1284
1302
  </text>
1285
1303
  );