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.
Files changed (127) 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/c19-paste-routing.test.ts +252 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  15. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  16. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  17. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  18. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  19. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  20. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  21. package/src/__tests__/blind/child-run.ts +114 -0
  22. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  23. package/src/__tests__/component-badge.test.ts +20 -36
  24. package/src/__tests__/footer-hints.test.ts +1 -1
  25. package/src/__tests__/manifest.test.ts +25 -17
  26. package/src/__tests__/marketplace-badge.test.ts +4 -4
  27. package/src/__tests__/models-screen-state.test.ts +36 -13
  28. package/src/__tests__/scope-squares.test.tsx +28 -114
  29. package/src/__tests__/shutdown.test.ts +578 -0
  30. package/src/__tests__/stale-load-repair.test.ts +5 -4
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  37. package/src/cli/ansi.ts +2 -2
  38. package/src/cli/bootstrap.ts +33 -2
  39. package/src/cli/doctor.ts +120 -15
  40. package/src/cli/install.ts +35 -4
  41. package/src/cli/profile.ts +18 -1
  42. package/src/cli/router.ts +31 -18
  43. package/src/cli/update-view.ts +1 -1
  44. package/src/data/cli-tools.ts +9 -1
  45. package/src/services/claude-settings.ts +434 -198
  46. package/src/services/cli-tool-commands.ts +45 -5
  47. package/src/services/cli-tool-version.ts +101 -0
  48. package/src/services/gitignore-service.ts +39 -22
  49. package/src/services/hook-registration.ts +23 -6
  50. package/src/services/manifest.ts +117 -52
  51. package/src/services/plugin-manager.ts +19 -5
  52. package/src/services/profile-adopt.ts +7 -2
  53. package/src/services/profile-startup.ts +87 -7
  54. package/src/services/profiles.ts +36 -23
  55. package/src/services/settings-file.ts +164 -4
  56. package/src/services/settings-manager.ts +19 -5
  57. package/src/services/skills-manager.ts +46 -0
  58. package/src/services/tool-probe.ts +316 -44
  59. package/src/services/toolchain.ts +39 -0
  60. package/src/services/update-engine.ts +5 -0
  61. package/src/services/update-plan.ts +9 -0
  62. package/src/tui.tsx +48 -21
  63. package/src/ui/App.tsx +58 -197
  64. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  65. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  66. package/src/ui/adapters/settingsAdapter.ts +0 -21
  67. package/src/ui/adapters/skillsAdapter.ts +27 -8
  68. package/src/ui/adapters/stylesAdapter.ts +15 -11
  69. package/src/ui/components/CategoryHeader.tsx +7 -1
  70. package/src/ui/components/EmptyFilterState.tsx +2 -2
  71. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  72. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  73. package/src/ui/components/TabBar.tsx +93 -21
  74. package/src/ui/components/layout/FooterHints.tsx +6 -27
  75. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  76. package/src/ui/components/layout/index.ts +1 -5
  77. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  78. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  79. package/src/ui/components/modals/MessageModal.tsx +3 -15
  80. package/src/ui/components/modals/ModalContainer.tsx +107 -60
  81. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  82. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  83. package/src/ui/components/primitives/MetaText.tsx +1 -1
  84. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  85. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  86. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  87. package/src/ui/components/primitives/index.ts +4 -3
  88. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  89. package/src/ui/input/KeyRouter.tsx +304 -0
  90. package/src/ui/input/key-dispatch.ts +197 -0
  91. package/src/ui/keymap.ts +905 -0
  92. package/src/ui/renderers/cliToolRenderers.tsx +192 -92
  93. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  94. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  95. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  96. package/src/ui/renderers/modelRenderers.tsx +12 -13
  97. package/src/ui/renderers/modelVisuals.tsx +0 -2
  98. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  99. package/src/ui/renderers/profileRenderers.tsx +32 -37
  100. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  101. package/src/ui/renderers/skillRenderers.tsx +237 -190
  102. package/src/ui/renderers/styleRenderers.tsx +13 -2
  103. package/src/ui/runtime/shutdown.ts +268 -0
  104. package/src/ui/screens/AliasScreen.tsx +118 -100
  105. package/src/ui/screens/CliToolsScreen.tsx +173 -101
  106. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  107. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  108. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  109. package/src/ui/screens/McpScreen.tsx +47 -33
  110. package/src/ui/screens/ModelsScreen.tsx +59 -104
  111. package/src/ui/screens/PluginsScreen.tsx +129 -129
  112. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  113. package/src/ui/screens/SkillsScreen.tsx +184 -204
  114. package/src/ui/screens/StylesScreen.tsx +104 -157
  115. package/src/ui/state/AppContext.tsx +6 -0
  116. package/src/ui/state/DimensionsContext.tsx +1 -10
  117. package/src/ui/state/reducer.ts +5 -8
  118. package/src/ui/state/types.ts +11 -9
  119. package/src/ui/text-width.ts +341 -0
  120. package/src/ui/theme-mode.ts +34 -71
  121. package/src/ui/theme.ts +50 -28
  122. package/src/utils/request-gate.ts +37 -0
  123. package/src/ui/components/ScopeIndicator.tsx +0 -63
  124. package/src/ui/components/SearchInput.tsx +0 -57
  125. package/src/ui/hooks/index.ts +0 -2
  126. package/src/ui/hooks/useKeyboard.ts +0 -24
  127. 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
+ }