magus-cli 7.3.1 → 7.4.1
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/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -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__/stale-load-repair.test.ts +5 -4
- 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 +120 -15
- package/src/cli/install.ts +35 -4
- 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 +434 -198
- package/src/services/cli-tool-commands.ts +45 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/gitignore-service.ts +39 -22
- 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 +164 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +316 -44
- 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 +107 -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 +304 -0
- package/src/ui/input/key-dispatch.ts +197 -0
- package/src/ui/keymap.ts +905 -0
- package/src/ui/renderers/cliToolRenderers.tsx +192 -92
- 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 +173 -101
- 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 +129 -129
- 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,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one keyboard subscriber in the React tree — and the one paste subscriber.
|
|
3
|
+
*
|
|
4
|
+
* Components do not call `useKeyboard`, and no screen listens for a paste of
|
|
5
|
+
* its own. They register a LAYER — modal, input, screen, scroll or global — and
|
|
6
|
+
* this router offers each key to the live layers through `dispatchKey`, top
|
|
7
|
+
* first, so exactly one of them acts on it. The system layer (Ctrl+C) is not
|
|
8
|
+
* here: `src/tui.tsx` takes that key on the renderer before this subscriber
|
|
9
|
+
* runs and stops it there.
|
|
10
|
+
*
|
|
11
|
+
* Each key is dispatched inside `flushSync`, so everything a key changes is
|
|
12
|
+
* committed — and every layer's `active` flag and handler refreshed — before the
|
|
13
|
+
* next key is looked at. A fast typist delivers several keys in one stdin read;
|
|
14
|
+
* without this, `/abc` would open the search box and then send `a`, `b` and `c`
|
|
15
|
+
* to the screen underneath, whose handlers still believed the box was closed.
|
|
16
|
+
*
|
|
17
|
+
* A PASTE is NOT that burst of keys. Bracketed paste is on (OpenTUI's native
|
|
18
|
+
* core writes `ESC[?2004h`), so the terminal wraps the clipboard in
|
|
19
|
+
* `ESC[200~ … ESC[201~` and the renderer emits ONE `paste` event carrying the
|
|
20
|
+
* whole text — and no `keypress` at all. That is why a search term pasted into
|
|
21
|
+
* a filter box used to vanish without a trace: every layer here listened for
|
|
22
|
+
* keys that were never sent. `dispatchPaste` runs the same layer order over the
|
|
23
|
+
* layers that registered a paste handler.
|
|
24
|
+
*
|
|
25
|
+
* The paste event is deliberately NOT stopped: OpenTUI hands it to the focused
|
|
26
|
+
* renderable afterwards, which is how the input modal's `<input>` still takes a
|
|
27
|
+
* paste of its own — exactly the arrangement the modal layer relies on for keys.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { appendFileSync } from "node:fs";
|
|
31
|
+
import os from "node:os";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import type { KeyEvent, PasteEvent } from "@opentui/core";
|
|
34
|
+
import { decodePasteBytes, stripAnsiSequences } from "@opentui/core";
|
|
35
|
+
import { flushSync, useKeyboard, useRenderer } from "@opentui/react";
|
|
36
|
+
import {
|
|
37
|
+
type ReactNode,
|
|
38
|
+
createContext,
|
|
39
|
+
useContext,
|
|
40
|
+
useEffect,
|
|
41
|
+
useLayoutEffect,
|
|
42
|
+
useState,
|
|
43
|
+
} from "react";
|
|
44
|
+
import {
|
|
45
|
+
SCREEN_BINDINGS,
|
|
46
|
+
type ScreenHandlers,
|
|
47
|
+
runBindings,
|
|
48
|
+
} from "../keymap.js";
|
|
49
|
+
import type { Screen } from "../state/types.js";
|
|
50
|
+
import {
|
|
51
|
+
type KeyLayer,
|
|
52
|
+
type KeyLayerId,
|
|
53
|
+
type KeyPress,
|
|
54
|
+
type PasteLayer,
|
|
55
|
+
dispatchKey,
|
|
56
|
+
dispatchPaste,
|
|
57
|
+
inputLayerConsumes,
|
|
58
|
+
typedCharacter,
|
|
59
|
+
} from "./key-dispatch.js";
|
|
60
|
+
|
|
61
|
+
/** The two registries the router dispatches over. */
|
|
62
|
+
interface LayerRegistry {
|
|
63
|
+
keys: Set<KeyLayer>;
|
|
64
|
+
pastes: Set<PasteLayer>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const LayersContext = createContext<LayerRegistry | null>(null);
|
|
68
|
+
|
|
69
|
+
/** OpenTUI's key event, reduced to the fields the dispatcher reads. */
|
|
70
|
+
export function toKeyPress(event: KeyEvent): KeyPress {
|
|
71
|
+
return {
|
|
72
|
+
name: event.name ?? "",
|
|
73
|
+
sequence: event.sequence ?? "",
|
|
74
|
+
ctrl: event.ctrl === true,
|
|
75
|
+
meta: event.meta === true,
|
|
76
|
+
shift: event.shift === true,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The text a paste event carries, as a single-line search term.
|
|
82
|
+
*
|
|
83
|
+
* Three things are stripped, in this order. The bytes are decoded as UTF-8
|
|
84
|
+
* (`decodePasteBytes`). Any ANSI escape the clipboard carried is removed — a
|
|
85
|
+
* terminal forwards the clipboard verbatim inside the brackets, so copying
|
|
86
|
+
* coloured output pastes its escape sequences too, and they are not a search
|
|
87
|
+
* term. Then every remaining control character goes: a newline or a tab becomes
|
|
88
|
+
* one space, so a multi-line clipboard reads as words rather than running the
|
|
89
|
+
* lines together, and the rest are dropped outright.
|
|
90
|
+
*
|
|
91
|
+
* The ends are trimmed last, because a copied line almost always carries its
|
|
92
|
+
* trailing newline and a query ending in a space matches nothing.
|
|
93
|
+
*/
|
|
94
|
+
export function pastedText(event: PasteEvent): string {
|
|
95
|
+
const decoded = stripAnsiSequences(decodePasteBytes(event.bytes));
|
|
96
|
+
let out = "";
|
|
97
|
+
for (const ch of decoded) {
|
|
98
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
99
|
+
// Newline, carriage return, tab: one space — and a RUN of them one space
|
|
100
|
+
// too, so a CRLF clipboard does not double-space every line break.
|
|
101
|
+
if (cp === 0x0a || cp === 0x0d || cp === 0x09) {
|
|
102
|
+
if (!out.endsWith(" ")) out += " ";
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// The rest of C0 and C1, dropped. Written as codepoint bounds rather
|
|
106
|
+
// than a regex character class because the formatter rewrites a
|
|
107
|
+
// `\u0000` escape inside a regex literal into the raw byte it stands
|
|
108
|
+
// for — which puts literal control characters in this source file.
|
|
109
|
+
if (cp < 0x20 || (cp >= 0x7f && cp <= 0x9f)) continue;
|
|
110
|
+
out += ch;
|
|
111
|
+
}
|
|
112
|
+
return out.trim();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `MAGUS_DEBUG_KEYS=1` appends every key and the layer that took it to
|
|
117
|
+
* `magus-keys.log` in the temp directory — the first thing to look at when a
|
|
118
|
+
* key "does nothing". A paste is logged the same way, as `paste`.
|
|
119
|
+
*/
|
|
120
|
+
function logKey(what: string, consumer: KeyLayerId | null): void {
|
|
121
|
+
if (process.env.MAGUS_DEBUG_KEYS !== "1") return;
|
|
122
|
+
try {
|
|
123
|
+
appendFileSync(
|
|
124
|
+
path.join(os.tmpdir(), "magus-keys.log"),
|
|
125
|
+
`${new Date().toISOString()} ${what} -> ${consumer ?? "nobody"}\n`,
|
|
126
|
+
);
|
|
127
|
+
} catch {
|
|
128
|
+
// Best-effort logging: never let it break a keypress.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function describeKey(key: KeyPress): string {
|
|
133
|
+
return `name=${JSON.stringify(key.name)} seq=${JSON.stringify(key.sequence)} ctrl=${key.ctrl} meta=${key.meta} shift=${key.shift}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function KeyRouter({ children }: { children: ReactNode }) {
|
|
137
|
+
const [layers] = useState<LayerRegistry>(() => ({
|
|
138
|
+
keys: new Set<KeyLayer>(),
|
|
139
|
+
pastes: new Set<PasteLayer>(),
|
|
140
|
+
}));
|
|
141
|
+
const renderer = useRenderer();
|
|
142
|
+
|
|
143
|
+
useKeyboard((event) => {
|
|
144
|
+
const key = toKeyPress(event);
|
|
145
|
+
let consumer: KeyLayerId | null = null;
|
|
146
|
+
flushSync(() => {
|
|
147
|
+
consumer = dispatchKey([...layers.keys], key);
|
|
148
|
+
});
|
|
149
|
+
logKey(describeKey(key), consumer);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// The ONE paste subscriber. `useKeyboard` has no paste counterpart in
|
|
153
|
+
// @opentui/react, so this listens on the renderer's key input directly —
|
|
154
|
+
// once, here, rather than once per screen.
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const onPaste = (event: PasteEvent): void => {
|
|
157
|
+
const text = pastedText(event);
|
|
158
|
+
let consumer: KeyLayerId | null = null;
|
|
159
|
+
flushSync(() => {
|
|
160
|
+
consumer = dispatchPaste([...layers.pastes], text);
|
|
161
|
+
});
|
|
162
|
+
logKey(`paste=${JSON.stringify(text)}`, consumer);
|
|
163
|
+
};
|
|
164
|
+
renderer.keyInput.on("paste", onPaste);
|
|
165
|
+
return () => {
|
|
166
|
+
renderer.keyInput.off("paste", onPaste);
|
|
167
|
+
};
|
|
168
|
+
}, [renderer, layers]);
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<LayersContext.Provider value={layers}>{children}</LayersContext.Provider>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Register a key layer for as long as the calling component is mounted.
|
|
177
|
+
*
|
|
178
|
+
* `active` and `handle` are refreshed on every commit, so the router always
|
|
179
|
+
* calls the handler from the latest render — never a stale closure.
|
|
180
|
+
*/
|
|
181
|
+
export function useKeyLayer(
|
|
182
|
+
id: KeyLayerId,
|
|
183
|
+
active: boolean,
|
|
184
|
+
handle: (key: KeyPress) => boolean,
|
|
185
|
+
): void {
|
|
186
|
+
const layers = useContext(LayersContext);
|
|
187
|
+
if (!layers) throw new Error("useKeyLayer needs a <KeyRouter> above it");
|
|
188
|
+
const [entry] = useState<KeyLayer>(() => ({ id, active, handle }));
|
|
189
|
+
|
|
190
|
+
useLayoutEffect(() => {
|
|
191
|
+
entry.id = id;
|
|
192
|
+
entry.active = active;
|
|
193
|
+
entry.handle = handle;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
useLayoutEffect(() => {
|
|
197
|
+
layers.keys.add(entry);
|
|
198
|
+
return () => {
|
|
199
|
+
layers.keys.delete(entry);
|
|
200
|
+
};
|
|
201
|
+
}, [layers, entry]);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Register a PASTE layer for as long as the calling component is mounted.
|
|
206
|
+
*
|
|
207
|
+
* The counterpart of {@link useKeyLayer}, and refreshed on every commit the
|
|
208
|
+
* same way. A component registers this only when a paste means something to it:
|
|
209
|
+
* a layer that does not is simply absent from the order, and the paste falls
|
|
210
|
+
* through it.
|
|
211
|
+
*/
|
|
212
|
+
export function usePasteLayer(
|
|
213
|
+
id: KeyLayerId,
|
|
214
|
+
active: boolean,
|
|
215
|
+
handlePaste: (text: string) => boolean,
|
|
216
|
+
): void {
|
|
217
|
+
const layers = useContext(LayersContext);
|
|
218
|
+
if (!layers) throw new Error("usePasteLayer needs a <KeyRouter> above it");
|
|
219
|
+
const [entry] = useState<PasteLayer>(() => ({ id, active, handlePaste }));
|
|
220
|
+
|
|
221
|
+
useLayoutEffect(() => {
|
|
222
|
+
entry.id = id;
|
|
223
|
+
entry.active = active;
|
|
224
|
+
entry.handlePaste = handlePaste;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
useLayoutEffect(() => {
|
|
228
|
+
layers.pastes.add(entry);
|
|
229
|
+
return () => {
|
|
230
|
+
layers.pastes.delete(entry);
|
|
231
|
+
};
|
|
232
|
+
}, [layers, entry]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The screen layer: one handler per binding the keymap declares for `screen`.
|
|
237
|
+
* `active: false` stands the layer down without unmounting it.
|
|
238
|
+
*/
|
|
239
|
+
export function useScreenKeys<S extends Screen>(
|
|
240
|
+
screen: S,
|
|
241
|
+
handlers: ScreenHandlers<S>,
|
|
242
|
+
active = true,
|
|
243
|
+
): void {
|
|
244
|
+
useKeyLayer("screen", active, (key) =>
|
|
245
|
+
runBindings(SCREEN_BINDINGS[screen], handlers, key),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A screen's search box, as the input layer drives it. */
|
|
250
|
+
export interface SearchBox {
|
|
251
|
+
/** The box has focus: what the user types goes into it. */
|
|
252
|
+
focused: boolean;
|
|
253
|
+
query: string;
|
|
254
|
+
/** Add text: one typed character, or a whole pasted term. */
|
|
255
|
+
append(text: string): void;
|
|
256
|
+
/** Delete the last character. */
|
|
257
|
+
erase(): void;
|
|
258
|
+
/** Empty the box and stay in it. */
|
|
259
|
+
clear(): void;
|
|
260
|
+
/** Leave the box. The filter stays as typed. */
|
|
261
|
+
blur(): void;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The input layer for a search box.
|
|
266
|
+
*
|
|
267
|
+
* While focused it takes every printable key — digits, `j`, `k`, `q`, `?` and
|
|
268
|
+
* space are typed, not acted on — plus Backspace, Enter (keep the filter, leave
|
|
269
|
+
* the box) and Esc (clear the text; on an empty box, leave). Up/Down, Tab and the
|
|
270
|
+
* scroll keys pass through: the list still moves, tabs still cycle, the panel
|
|
271
|
+
* still scrolls.
|
|
272
|
+
*
|
|
273
|
+
* It is the input layer for a PASTE too, and the only layer that takes one:
|
|
274
|
+
* pasted text is appended to the query exactly as typing it would be. With no
|
|
275
|
+
* box focused nothing consumes a paste — it must never reach the screen or the
|
|
276
|
+
* global keys, where a clipboard holding `q` would quit the app.
|
|
277
|
+
*/
|
|
278
|
+
export function useSearchInput(box: SearchBox): void {
|
|
279
|
+
usePasteLayer("input", box.focused, (text) => {
|
|
280
|
+
box.append(text);
|
|
281
|
+
return true;
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
useKeyLayer("input", box.focused, (key) => {
|
|
285
|
+
if (!inputLayerConsumes(key)) return false;
|
|
286
|
+
switch (key.name) {
|
|
287
|
+
case "return":
|
|
288
|
+
case "enter":
|
|
289
|
+
box.blur();
|
|
290
|
+
return true;
|
|
291
|
+
case "escape":
|
|
292
|
+
if (box.query.length > 0) box.clear();
|
|
293
|
+
else box.blur();
|
|
294
|
+
return true;
|
|
295
|
+
case "backspace":
|
|
296
|
+
case "delete":
|
|
297
|
+
box.erase();
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
const char = typedCharacter(key);
|
|
301
|
+
if (char !== null) box.append(char);
|
|
302
|
+
return true;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One keypress, one handler.
|
|
3
|
+
*
|
|
4
|
+
* OpenTUI's `useKeyboard` is a broadcast: every subscriber receives every key,
|
|
5
|
+
* with no way for one to stop the next. magus used to have a dozen subscribers
|
|
6
|
+
* — the global keys, the panel scroll keys, each screen, each modal — and each
|
|
7
|
+
* one guessed from render-time flags (`modal`, `isSearching`, `detailActive`)
|
|
8
|
+
* whether the key was meant for it. The guesses disagreed: Esc that cleared a
|
|
9
|
+
* filter also ran the global Esc and quit, Enter in a confirm dialog also acted
|
|
10
|
+
* on the row underneath, and `j`/`k` moved the cursor before a search box could
|
|
11
|
+
* type them.
|
|
12
|
+
*
|
|
13
|
+
* This module replaces the guessing with an order. Every key is offered to the
|
|
14
|
+
* layers top first, and the first layer that consumes it is the only one that
|
|
15
|
+
* acts:
|
|
16
|
+
*
|
|
17
|
+
* system → modal → input → screen → scroll → global
|
|
18
|
+
*
|
|
19
|
+
* - system: Ctrl+C. Taken in `src/tui.tsx` on the renderer itself, before the
|
|
20
|
+
* React tree sees any key, so it quits even under a modal or a crashed screen.
|
|
21
|
+
* - modal: an open dialog (or the help overlay) takes every key.
|
|
22
|
+
* - input: a focused search box takes what it types — see `inputLayerConsumes`.
|
|
23
|
+
* - screen: the current screen's bindings, from `ui/keymap.ts`.
|
|
24
|
+
* - scroll: PgUp / PgDn / Ctrl+U / Ctrl+D scroll the detail panel.
|
|
25
|
+
* - global: tab digits, Tab, `q`, `?`, Esc.
|
|
26
|
+
*
|
|
27
|
+
* A PASTE follows the same order through `dispatchPaste`, over the layers that
|
|
28
|
+
* registered a paste handler. It is a separate entry point because a paste is a
|
|
29
|
+
* separate terminal event, not a burst of keys — see {@link PasteLayer}.
|
|
30
|
+
*
|
|
31
|
+
* Pure: no React, no OpenTUI. `KeyRouter.tsx` feeds it the live layers.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A keypress, named the way OpenTUI names it: "a", "1", "?", "space",
|
|
36
|
+
* "return", "escape", "backspace", "up", "down", "pageup", "pagedown", "tab".
|
|
37
|
+
* A shifted letter keeps its lowercase name with `shift` set (Shift+U is
|
|
38
|
+
* `{ name: "u", shift: true, sequence: "U" }`); Ctrl+U is `{ name: "u", ctrl: true }`.
|
|
39
|
+
*/
|
|
40
|
+
export interface KeyPress {
|
|
41
|
+
name: string;
|
|
42
|
+
sequence: string;
|
|
43
|
+
ctrl: boolean;
|
|
44
|
+
meta: boolean;
|
|
45
|
+
shift: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type KeyLayerId =
|
|
49
|
+
| "system"
|
|
50
|
+
| "modal"
|
|
51
|
+
| "input"
|
|
52
|
+
| "screen"
|
|
53
|
+
| "scroll"
|
|
54
|
+
| "global";
|
|
55
|
+
|
|
56
|
+
/** Top first. A layer only sees a key every layer above it declined. */
|
|
57
|
+
export const LAYER_ORDER: readonly KeyLayerId[] = [
|
|
58
|
+
"system",
|
|
59
|
+
"modal",
|
|
60
|
+
"input",
|
|
61
|
+
"screen",
|
|
62
|
+
"scroll",
|
|
63
|
+
"global",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export interface KeyLayer {
|
|
67
|
+
id: KeyLayerId;
|
|
68
|
+
/** An inactive layer is skipped without its handler being called. */
|
|
69
|
+
active: boolean;
|
|
70
|
+
/** True when the layer consumed the key; nothing below it will see it. */
|
|
71
|
+
handle(key: KeyPress): boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Offer `key` to the active layers in LAYER_ORDER — whatever order the array is
|
|
76
|
+
* in — and stop at the first that consumes it. Returns that layer's id, or null
|
|
77
|
+
* when no layer wanted the key. Layers below the consumer are never called.
|
|
78
|
+
*
|
|
79
|
+
* Several layers may share an id (two components can each register a screen
|
|
80
|
+
* layer); within one id they are asked in array order.
|
|
81
|
+
*/
|
|
82
|
+
export function dispatchKey(
|
|
83
|
+
layers: readonly KeyLayer[],
|
|
84
|
+
key: KeyPress,
|
|
85
|
+
): KeyLayerId | null {
|
|
86
|
+
for (const id of LAYER_ORDER) {
|
|
87
|
+
for (const layer of layers) {
|
|
88
|
+
if (layer.id !== id || !layer.active) continue;
|
|
89
|
+
if (layer.handle(key)) return id;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A layer that can take a PASTE.
|
|
97
|
+
*
|
|
98
|
+
* A terminal does not deliver a paste as keys. With bracketed paste on — and
|
|
99
|
+
* OpenTUI's native core turns it on — the whole clipboard arrives wrapped in
|
|
100
|
+
* `ESC[200~ … ESC[201~` and reaches the renderer as ONE `paste` event; no
|
|
101
|
+
* `keypress` is emitted for any of its characters. So a layer that only
|
|
102
|
+
* implements `handle()` never sees a pasted search term at all.
|
|
103
|
+
*
|
|
104
|
+
* Separate from {@link KeyLayer} because most layers have nothing to do with a
|
|
105
|
+
* paste: a tab digit, `q` or a scroll key is a keystroke, and pasting "q" must
|
|
106
|
+
* not quit.
|
|
107
|
+
*/
|
|
108
|
+
export interface PasteLayer {
|
|
109
|
+
id: KeyLayerId;
|
|
110
|
+
/** An inactive layer is skipped without its handler being called. */
|
|
111
|
+
active: boolean;
|
|
112
|
+
/** True when the layer consumed the text; nothing below it will see it. */
|
|
113
|
+
handlePaste(text: string): boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Offer pasted `text` to the active layers in LAYER_ORDER and stop at the first
|
|
118
|
+
* that consumes it — the same rule {@link dispatchKey} follows, so a paste
|
|
119
|
+
* lands where a keystroke would: a focused search box takes it, a modal above
|
|
120
|
+
* it takes it first, and with neither open nothing consumes it.
|
|
121
|
+
*
|
|
122
|
+
* Empty text is offered to nobody: there is nothing to insert, and a layer that
|
|
123
|
+
* consumed it would only report a phantom edit.
|
|
124
|
+
*/
|
|
125
|
+
export function dispatchPaste(
|
|
126
|
+
layers: readonly PasteLayer[],
|
|
127
|
+
text: string,
|
|
128
|
+
): KeyLayerId | null {
|
|
129
|
+
if (text.length === 0) return null;
|
|
130
|
+
for (const id of LAYER_ORDER) {
|
|
131
|
+
for (const layer of layers) {
|
|
132
|
+
if (layer.id !== id || !layer.active) continue;
|
|
133
|
+
if (layer.handlePaste(text)) return id;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** PgUp, PgDn, Ctrl+U, Ctrl+D — the keys that scroll the detail panel. */
|
|
140
|
+
export function isScrollKey(key: KeyPress): boolean {
|
|
141
|
+
if (key.meta) return false;
|
|
142
|
+
if (key.name === "pageup" || key.name === "pagedown") return true;
|
|
143
|
+
return key.ctrl && (key.name === "u" || key.name === "d");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** True for a single printable character: no control bytes, no escape sequences. */
|
|
147
|
+
function isPrintable(text: string): boolean {
|
|
148
|
+
const chars = [...text];
|
|
149
|
+
if (chars.length !== 1) return false;
|
|
150
|
+
const code = chars[0]?.codePointAt(0) ?? 0;
|
|
151
|
+
return code >= 0x20 && code !== 0x7f;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The character a keypress types, or null when it types nothing.
|
|
156
|
+
*
|
|
157
|
+
* `sequence` first: it is the literal text for a plain key, and under the kitty
|
|
158
|
+
* keyboard protocol OpenTUI puts the produced text there too. `name` is the
|
|
159
|
+
* fallback for a printable key whose sequence is an escape (kitty's Space).
|
|
160
|
+
*/
|
|
161
|
+
export function typedCharacter(key: KeyPress): string | null {
|
|
162
|
+
if (key.ctrl || key.meta) return null;
|
|
163
|
+
if (isPrintable(key.sequence)) return key.sequence;
|
|
164
|
+
if (key.name === "space") return " ";
|
|
165
|
+
if (isPrintable(key.name)) {
|
|
166
|
+
return key.shift && /^[a-z]$/.test(key.name)
|
|
167
|
+
? key.name.toUpperCase()
|
|
168
|
+
: key.name;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The editing keys a focused box owns besides the characters it types. */
|
|
174
|
+
const INPUT_EDIT_KEYS = new Set([
|
|
175
|
+
"backspace",
|
|
176
|
+
"delete",
|
|
177
|
+
"return",
|
|
178
|
+
"enter",
|
|
179
|
+
"escape",
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Does a focused search/edit box take this key?
|
|
184
|
+
*
|
|
185
|
+
* Yes: any printable character — letters, digits, punctuation including `?`,
|
|
186
|
+
* space — plus Backspace, Enter and Esc. They are typed or edit the box; they
|
|
187
|
+
* do not switch tab, move the selection, quit or open help.
|
|
188
|
+
*
|
|
189
|
+
* No: Up/Down (the screen moves the selection), Tab (the global layer cycles
|
|
190
|
+
* tabs), the scroll keys (the panel keeps scrolling while you type), Ctrl+C,
|
|
191
|
+
* and any other Ctrl or Meta chord.
|
|
192
|
+
*/
|
|
193
|
+
export function inputLayerConsumes(key: KeyPress): boolean {
|
|
194
|
+
if (key.ctrl || key.meta) return false;
|
|
195
|
+
if (INPUT_EDIT_KEYS.has(key.name)) return true;
|
|
196
|
+
return typedCharacter(key) !== null;
|
|
197
|
+
}
|