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.
- package/package.json +4 -4
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- 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 {
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
// ──
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
void
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
708
|
-
<
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
{
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
{
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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}>{
|
|
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}>
|
|
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 =
|
|
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.
|
|
1300
|
+
<text key={row.flag.id} bg={theme.selection.bg} fg={theme.selection.fg}>
|
|
1283
1301
|
{line}
|
|
1284
1302
|
</text>
|
|
1285
1303
|
);
|