mu-coding 0.5.0 → 0.9.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/README.md +49 -3
- package/package.json +9 -4
- package/prompts/SYSTEM.md +16 -0
- package/src/app/shutdown.ts +1 -1
- package/src/app/startApp.ts +11 -8
- package/src/cli/args.ts +14 -11
- package/src/cli/install.ts +18 -3
- package/src/config/index.test.ts +26 -0
- package/src/config/index.ts +25 -7
- package/src/plugin.ts +124 -0
- package/src/runtime/codingTools/bash.ts +114 -0
- package/src/runtime/codingTools/edit-file.ts +60 -0
- package/src/runtime/codingTools/index.ts +39 -0
- package/src/runtime/codingTools/read-file.ts +83 -0
- package/src/runtime/codingTools/utils.ts +21 -0
- package/src/runtime/codingTools/write-file.ts +42 -0
- package/src/runtime/createRegistry.test.ts +147 -0
- package/src/runtime/createRegistry.ts +160 -23
- package/src/runtime/fileMentionProvider.ts +116 -0
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +59 -15
- package/src/sessions/index.ts +2 -9
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +62 -0
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +1 -1
- package/src/tui/chat/useAbort.ts +5 -0
- package/src/tui/chat/useAttachment.ts +1 -1
- package/src/tui/chat/useChat.ts +38 -3
- package/src/tui/chat/useChatPanel.ts +29 -6
- package/src/tui/chat/useChatSession.ts +324 -57
- package/src/tui/chat/useModels.ts +26 -1
- package/src/tui/chat/usePluginStatus.ts +1 -1
- package/src/tui/chat/useSessionPersistence.ts +48 -21
- package/src/tui/chat/useStatusSegments.ts +38 -5
- package/src/tui/chat/useSubagentBrowser.ts +133 -0
- package/src/tui/components/chat/ChatPanel.tsx +25 -4
- package/src/tui/components/chat/ChatPanelBody.tsx +22 -1
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
- package/src/tui/components/messageView.tsx +4 -2
- package/src/tui/components/messages/EditOutput.tsx +17 -9
- package/src/tui/components/messages/ReadOutput.tsx +1 -1
- package/src/tui/components/messages/ToolHeader.tsx +8 -4
- package/src/tui/components/messages/WriteOutput.tsx +12 -4
- package/src/tui/components/messages/assistantMessage.tsx +55 -7
- package/src/tui/components/messages/markdown.tsx +402 -0
- package/src/tui/components/messages/messageItem.tsx +19 -1
- package/src/tui/components/messages/reasoningBlock.tsx +10 -6
- package/src/tui/components/messages/streamingOutput.tsx +6 -2
- package/src/tui/components/messages/toolCallBlock.tsx +7 -6
- package/src/tui/components/messages/userMessage.tsx +22 -7
- package/src/tui/components/primitives/dropdown.tsx +8 -4
- package/src/tui/components/primitives/modal.tsx +4 -2
- package/src/tui/components/primitives/pickerModal.tsx +3 -1
- package/src/tui/components/primitives/toast.tsx +43 -10
- package/src/tui/components/statusBar.tsx +26 -10
- package/src/tui/components/ui/dialogLayer.tsx +11 -6
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/hooks/useChordKeyboard.ts +87 -0
- package/src/tui/hooks/useInputInfoSegments.ts +22 -0
- package/src/tui/input/InputBoxView.tsx +191 -26
- package/src/tui/input/commands.test.ts +3 -1
- package/src/tui/input/commands.ts +11 -1
- package/src/tui/input/cursor.test.ts +136 -0
- package/src/tui/input/cursor.ts +214 -0
- package/src/tui/input/dumpContext.ts +107 -0
- package/src/tui/input/sanitize.ts +1 -1
- package/src/tui/input/useCommandExecutor.ts +1 -1
- package/src/tui/input/useInputBox.ts +160 -15
- package/src/tui/input/useInputHandler.ts +317 -126
- package/src/tui/input/useMentionPicker.ts +133 -0
- package/src/tui/input/usePluginShortcuts.ts +29 -0
- package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
- package/src/tui/plugins/InkApprovalChannel.ts +30 -0
- package/src/tui/plugins/InkUIService.ts +1 -1
- package/src/tui/renderApp.tsx +47 -13
- package/src/tui/theme/index.ts +1 -0
- package/src/tui/theme/merge.test.ts +49 -0
- package/src/tui/theme/merge.ts +43 -0
- package/src/tui/theme/presets.ts +90 -0
- package/src/tui/theme/types.ts +138 -0
- package/src/utils/clipboard.ts +1 -1
- package/src/tui/chat/useStreamConsumer.ts +0 -118
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { type Key, useInput, useStdin } from 'ink';
|
|
2
|
+
import type { ShortcutHandler } from 'mu-core';
|
|
2
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
3
4
|
import { matchCommands, type SlashCommand } from './commands';
|
|
5
|
+
import {
|
|
6
|
+
type BufferState,
|
|
7
|
+
cursorRowCol,
|
|
8
|
+
deleteBackward,
|
|
9
|
+
deleteForward,
|
|
10
|
+
deleteWordBackward,
|
|
11
|
+
insertAt,
|
|
12
|
+
killToLineEnd,
|
|
13
|
+
killToLineStart,
|
|
14
|
+
moveLeft,
|
|
15
|
+
moveLineDown,
|
|
16
|
+
moveLineEnd,
|
|
17
|
+
moveLineHome,
|
|
18
|
+
moveLineUp,
|
|
19
|
+
moveRight,
|
|
20
|
+
moveWordLeft,
|
|
21
|
+
moveWordRight,
|
|
22
|
+
} from './cursor';
|
|
4
23
|
import { isMouseSequence, sanitizeTerminalInput } from './sanitize';
|
|
5
24
|
|
|
6
25
|
const BACKSPACE_BYTES = new Set(['\x7f', '\x08']);
|
|
@@ -9,9 +28,11 @@ export interface InputActions {
|
|
|
9
28
|
onCtrlC?: () => void;
|
|
10
29
|
onPaste?: () => void;
|
|
11
30
|
onNew?: () => void;
|
|
31
|
+
onCompact?: () => void;
|
|
12
32
|
onCycleModel?: () => void;
|
|
13
33
|
onTogglePicker?: () => void;
|
|
14
34
|
onToggleSessionPicker?: () => void;
|
|
35
|
+
onShowContext?: () => void;
|
|
15
36
|
onEsc?: () => void;
|
|
16
37
|
onScrollUp?: () => void;
|
|
17
38
|
onScrollDown?: () => void;
|
|
@@ -20,11 +41,27 @@ export interface InputActions {
|
|
|
20
41
|
|
|
21
42
|
interface InputState {
|
|
22
43
|
value: string;
|
|
44
|
+
cursor: number;
|
|
23
45
|
commands: SlashCommand[];
|
|
24
46
|
cmdIndex: number;
|
|
25
47
|
isCommandMode: boolean;
|
|
26
48
|
}
|
|
27
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Optional mention-picker controls. When `active`, navigation keys (↑/↓) and
|
|
52
|
+
* accept keys (Tab / Enter) are routed through this handle instead of the
|
|
53
|
+
* default editing bindings. Read via a ref so the dispatch closure always
|
|
54
|
+
* sees the latest snapshot without forcing a hook re-call.
|
|
55
|
+
*/
|
|
56
|
+
export interface MentionMode {
|
|
57
|
+
active: boolean;
|
|
58
|
+
count: number;
|
|
59
|
+
selectedIndex: number;
|
|
60
|
+
next: () => void;
|
|
61
|
+
prev: () => void;
|
|
62
|
+
accept: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
28
65
|
interface UseInputHandlerOptions {
|
|
29
66
|
isActive: boolean;
|
|
30
67
|
streaming: boolean;
|
|
@@ -33,53 +70,69 @@ interface UseInputHandlerOptions {
|
|
|
33
70
|
onSubmit: (text: string) => void;
|
|
34
71
|
availableCommands: SlashCommand[];
|
|
35
72
|
onCommand: (command: SlashCommand, args: string) => void;
|
|
73
|
+
/**
|
|
74
|
+
* Plugin-registered keyboard shortcuts. Consulted before the built-in
|
|
75
|
+
* BINDINGS table; whenever a handler is registered for the pressed key id
|
|
76
|
+
* the default editor binding is skipped entirely. Handlers are fire-and-
|
|
77
|
+
* forget so the input loop never blocks on plugin work.
|
|
78
|
+
*/
|
|
79
|
+
pluginShortcuts?: Map<string, ShortcutHandler>;
|
|
80
|
+
/**
|
|
81
|
+
* Ref to the current mention-mode controls. The picker state is computed
|
|
82
|
+
* by the caller (which depends on `value`/`cursor` from this hook) and
|
|
83
|
+
* pushed into the ref every render; the dispatch closure reads it at key
|
|
84
|
+
* time. Avoids calling `useInputHandler` twice to break the dependency.
|
|
85
|
+
*/
|
|
86
|
+
mentionRef?: React.RefObject<MentionMode | null>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Build a stable key identifier from an Ink key event. The id is a flat
|
|
90
|
+
// string so the BINDINGS map can be a plain dictionary and adding a new
|
|
91
|
+
// shortcut means adding one line. Split into two helpers to keep cognitive
|
|
92
|
+
// complexity within Biome's threshold.
|
|
93
|
+
function modifierKeyId(input: string, key: Key): string | null {
|
|
94
|
+
if (key.ctrl && key.shift && input) return `ctrl+shift+${input}`;
|
|
95
|
+
if (key.ctrl && key.leftArrow) return 'ctrl+left';
|
|
96
|
+
if (key.ctrl && key.rightArrow) return 'ctrl+right';
|
|
97
|
+
if (key.ctrl && input) return `ctrl+${input}`;
|
|
98
|
+
if (key.meta && key.leftArrow) return 'alt+left';
|
|
99
|
+
if (key.meta && key.rightArrow) return 'alt+right';
|
|
100
|
+
return null;
|
|
36
101
|
}
|
|
37
102
|
|
|
38
|
-
|
|
103
|
+
const DIRECT_KEYS: ReadonlyArray<readonly [keyof Key, string]> = [
|
|
104
|
+
['escape', 'escape'],
|
|
105
|
+
['pageUp', 'pageup'],
|
|
106
|
+
['pageDown', 'pagedown'],
|
|
107
|
+
['tab', 'tab'],
|
|
108
|
+
['upArrow', 'up'],
|
|
109
|
+
['downArrow', 'down'],
|
|
110
|
+
['leftArrow', 'left'],
|
|
111
|
+
['rightArrow', 'right'],
|
|
112
|
+
['home', 'home'],
|
|
113
|
+
['end', 'end'],
|
|
114
|
+
['delete', 'delete'],
|
|
115
|
+
['backspace', 'backspace'],
|
|
116
|
+
];
|
|
117
|
+
|
|
39
118
|
function keyId(input: string, key: Key): string | null {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
if (key.escape) {
|
|
47
|
-
return 'escape';
|
|
48
|
-
}
|
|
49
|
-
if (key.pageUp) {
|
|
50
|
-
return 'pageup';
|
|
51
|
-
}
|
|
52
|
-
if (key.pageDown) {
|
|
53
|
-
return 'pagedown';
|
|
54
|
-
}
|
|
55
|
-
if (key.return) {
|
|
56
|
-
return key.shift ? 'shift+return' : 'return';
|
|
57
|
-
}
|
|
58
|
-
if (key.tab) {
|
|
59
|
-
return 'tab';
|
|
60
|
-
}
|
|
61
|
-
if (key.upArrow) {
|
|
62
|
-
return 'up';
|
|
63
|
-
}
|
|
64
|
-
if (key.downArrow) {
|
|
65
|
-
return 'down';
|
|
66
|
-
}
|
|
67
|
-
if (key.backspace || key.delete) {
|
|
68
|
-
return 'backspace';
|
|
119
|
+
const mod = modifierKeyId(input, key);
|
|
120
|
+
if (mod) return mod;
|
|
121
|
+
if (key.return) return key.shift ? 'shift+return' : 'return';
|
|
122
|
+
for (const [flag, id] of DIRECT_KEYS) {
|
|
123
|
+
if (key[flag]) return id;
|
|
69
124
|
}
|
|
70
125
|
return null;
|
|
71
126
|
}
|
|
72
127
|
|
|
73
|
-
function useHistoryNavigation(
|
|
128
|
+
function useHistoryNavigation(state: BufferState, history: string[]) {
|
|
74
129
|
const idx = useRef(-1);
|
|
75
130
|
const draft = useRef('');
|
|
76
131
|
|
|
77
132
|
const up = (): string | null => {
|
|
78
|
-
if (!history.length)
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
133
|
+
if (!history.length) return null;
|
|
81
134
|
if (idx.current === -1) {
|
|
82
|
-
draft.current = value;
|
|
135
|
+
draft.current = state.value;
|
|
83
136
|
idx.current = history.length - 1;
|
|
84
137
|
} else if (idx.current > 0) {
|
|
85
138
|
idx.current -= 1;
|
|
@@ -88,9 +141,7 @@ function useHistoryNavigation(value: string, history: string[]) {
|
|
|
88
141
|
};
|
|
89
142
|
|
|
90
143
|
const down = (): string | null => {
|
|
91
|
-
if (idx.current === -1)
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
144
|
+
if (idx.current === -1) return null;
|
|
94
145
|
if (idx.current < history.length - 1) {
|
|
95
146
|
idx.current += 1;
|
|
96
147
|
return history[idx.current] ?? null;
|
|
@@ -102,162 +153,302 @@ function useHistoryNavigation(value: string, history: string[]) {
|
|
|
102
153
|
return { up, down, reset: () => (idx.current = -1) };
|
|
103
154
|
}
|
|
104
155
|
|
|
105
|
-
|
|
156
|
+
/**
|
|
157
|
+
* Some terminals (and Ink versions) deliver `\x7f` / `\x08` as raw stdin
|
|
158
|
+
* bytes without firing `useInput`'s `key.backspace`. We listen at the raw
|
|
159
|
+
* level and let the React handler consume the synthetic flag.
|
|
160
|
+
*/
|
|
161
|
+
function useRawBackspace(isActive: boolean, onBackspace: () => void) {
|
|
106
162
|
const { stdin } = useStdin();
|
|
107
163
|
const handledRef = useRef(false);
|
|
164
|
+
const callbackRef = useRef(onBackspace);
|
|
165
|
+
callbackRef.current = onBackspace;
|
|
108
166
|
|
|
109
167
|
useEffect(() => {
|
|
110
|
-
if (!(stdin && isActive))
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
168
|
+
if (!(stdin && isActive)) return;
|
|
113
169
|
const onData = (data: Buffer) => {
|
|
114
170
|
if (BACKSPACE_BYTES.has(data.toString())) {
|
|
115
171
|
handledRef.current = true;
|
|
116
|
-
|
|
172
|
+
callbackRef.current();
|
|
117
173
|
}
|
|
118
174
|
};
|
|
119
175
|
stdin.on('data', onData);
|
|
120
176
|
return () => {
|
|
121
177
|
stdin.off('data', onData);
|
|
122
178
|
};
|
|
123
|
-
}, [stdin, isActive
|
|
179
|
+
}, [stdin, isActive]);
|
|
124
180
|
|
|
125
181
|
return handledRef;
|
|
126
182
|
}
|
|
127
183
|
|
|
128
184
|
interface BindingCtx {
|
|
129
|
-
|
|
130
|
-
|
|
185
|
+
state: BufferState;
|
|
186
|
+
setState: React.Dispatch<React.SetStateAction<BufferState>>;
|
|
131
187
|
setCmdIndex: React.Dispatch<React.SetStateAction<number>>;
|
|
188
|
+
desiredColumn: React.MutableRefObject<number | null>;
|
|
132
189
|
nav: ReturnType<typeof useHistoryNavigation>;
|
|
133
190
|
submit: () => void;
|
|
134
191
|
isCommandMode: boolean;
|
|
135
192
|
commands: SlashCommand[];
|
|
136
193
|
actions: InputActions;
|
|
194
|
+
mentionRef?: React.RefObject<MentionMode | null>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function getMention(c: BindingCtx): MentionMode | null {
|
|
198
|
+
return c.mentionRef?.current ?? null;
|
|
137
199
|
}
|
|
138
200
|
|
|
139
201
|
type Binding = (ctx: BindingCtx) => void;
|
|
140
202
|
|
|
203
|
+
// ─── Vertical movement: routed through history when at edges ─────────────────
|
|
204
|
+
//
|
|
205
|
+
// Pressing ↑ on the first row of a multi-line buffer (or single-line) goes to
|
|
206
|
+
// history. Pressing ↑ further up inside the buffer just moves the cursor.
|
|
207
|
+
// Mirroring fish/zsh's behaviour. Same for ↓ on the last row.
|
|
208
|
+
|
|
209
|
+
function handleUp(c: BindingCtx): void {
|
|
210
|
+
const m = getMention(c);
|
|
211
|
+
if (m?.active && m.count > 0) {
|
|
212
|
+
m.prev();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (c.isCommandMode) {
|
|
216
|
+
c.setCmdIndex((i) => (i > 0 ? i - 1 : c.commands.length - 1));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const { row, col } = cursorRowCol(c.state.value, c.state.cursor);
|
|
220
|
+
if (row > 0) {
|
|
221
|
+
if (c.desiredColumn.current === null) c.desiredColumn.current = col;
|
|
222
|
+
const next = moveLineUp(c.state, c.desiredColumn.current);
|
|
223
|
+
if (next) c.setState(next);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
c.desiredColumn.current = null;
|
|
227
|
+
const r = c.nav.up();
|
|
228
|
+
if (r !== null) c.setState({ value: r, cursor: r.length });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function handleDown(c: BindingCtx): void {
|
|
232
|
+
const m = getMention(c);
|
|
233
|
+
if (m?.active && m.count > 0) {
|
|
234
|
+
m.next();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (c.isCommandMode) {
|
|
238
|
+
c.setCmdIndex((i) => (i < c.commands.length - 1 ? i + 1 : 0));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const { row, col } = cursorRowCol(c.state.value, c.state.cursor);
|
|
242
|
+
const total = c.state.value.split('\n').length;
|
|
243
|
+
if (row < total - 1) {
|
|
244
|
+
if (c.desiredColumn.current === null) c.desiredColumn.current = col;
|
|
245
|
+
const next = moveLineDown(c.state, c.desiredColumn.current);
|
|
246
|
+
if (next) c.setState(next);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
c.desiredColumn.current = null;
|
|
250
|
+
const r = c.nav.down();
|
|
251
|
+
if (r !== null) c.setState({ value: r, cursor: r.length });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function handleTab(c: BindingCtx): void {
|
|
255
|
+
const m = getMention(c);
|
|
256
|
+
if (m?.active && m.count > 0) {
|
|
257
|
+
m.accept();
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
c.setState((s) => insertAt(s, ' '));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function handleReturn(c: BindingCtx): void {
|
|
264
|
+
const m = getMention(c);
|
|
265
|
+
if (m?.active && m.count > 0) {
|
|
266
|
+
m.accept();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
c.submit();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Bindings ────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
141
274
|
const BINDINGS: Record<string, Binding> = {
|
|
142
275
|
'ctrl+c': (c) => c.actions.onCtrlC?.(),
|
|
143
276
|
'ctrl+v': (c) => c.actions.onPaste?.(),
|
|
144
277
|
'ctrl+o': (c) => c.actions.onTogglePicker?.(),
|
|
145
278
|
'ctrl+s': (c) => c.submit(),
|
|
146
|
-
'ctrl+j': (c) => c.
|
|
279
|
+
'ctrl+j': (c) => c.setState((s) => insertAt(s, '\n')),
|
|
280
|
+
'ctrl+a': (c) => c.setState((s) => moveLineHome(s)),
|
|
281
|
+
'ctrl+e': (c) => c.setState((s) => moveLineEnd(s)),
|
|
282
|
+
'ctrl+w': (c) => c.setState((s) => deleteWordBackward(s)),
|
|
283
|
+
'ctrl+u': (c) => c.setState((s) => killToLineStart(s)),
|
|
284
|
+
'ctrl+k': (c) => c.setState((s) => killToLineEnd(s)),
|
|
285
|
+
'ctrl+left': (c) => c.setState((s) => moveWordLeft(s)),
|
|
286
|
+
'ctrl+right': (c) => c.setState((s) => moveWordRight(s)),
|
|
287
|
+
'alt+left': (c) => c.setState((s) => moveWordLeft(s)),
|
|
288
|
+
'alt+right': (c) => c.setState((s) => moveWordRight(s)),
|
|
147
289
|
'ctrl+m': (c) => {
|
|
148
|
-
if (c.actions.modelCount)
|
|
149
|
-
c.actions.onCycleModel?.();
|
|
150
|
-
}
|
|
290
|
+
if (c.actions.modelCount) c.actions.onCycleModel?.();
|
|
151
291
|
},
|
|
152
292
|
'ctrl+n': (c) => {
|
|
153
293
|
c.actions.onNew?.();
|
|
154
|
-
c.
|
|
294
|
+
c.setState({ value: '', cursor: 0 });
|
|
155
295
|
c.nav.reset();
|
|
156
296
|
},
|
|
157
297
|
escape: (c) => c.actions.onEsc?.(),
|
|
158
298
|
pageup: (c) => c.actions.onScrollUp?.(),
|
|
159
299
|
pagedown: (c) => c.actions.onScrollDown?.(),
|
|
160
|
-
'shift+return': (c) => c.
|
|
161
|
-
return:
|
|
162
|
-
tab:
|
|
163
|
-
up:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
},
|
|
173
|
-
down: (c) => {
|
|
174
|
-
if (c.isCommandMode) {
|
|
175
|
-
c.setCmdIndex((i) => (i < c.commands.length - 1 ? i + 1 : 0));
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
const r = c.nav.down();
|
|
179
|
-
if (r !== null) {
|
|
180
|
-
c.setValue(r);
|
|
181
|
-
}
|
|
300
|
+
'shift+return': (c) => c.setState((s) => insertAt(s, '\n')),
|
|
301
|
+
return: handleReturn,
|
|
302
|
+
tab: handleTab,
|
|
303
|
+
up: handleUp,
|
|
304
|
+
down: handleDown,
|
|
305
|
+
left: (c) => c.setState((s) => moveLeft(s)),
|
|
306
|
+
right: (c) => c.setState((s) => moveRight(s)),
|
|
307
|
+
home: (c) => c.setState((s) => moveLineHome(s)),
|
|
308
|
+
end: (c) => c.setState((s) => moveLineEnd(s)),
|
|
309
|
+
delete: (c) => {
|
|
310
|
+
c.setState((s) => deleteForward(s));
|
|
311
|
+
c.nav.reset();
|
|
182
312
|
},
|
|
183
313
|
};
|
|
184
314
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
315
|
+
// Keys that shouldn't reset the sticky vertical column. Anything else
|
|
316
|
+
// (typing, deleting, switching lines explicitly) drops the sticky column.
|
|
317
|
+
const COLUMN_PRESERVING = new Set(['up', 'down']);
|
|
318
|
+
|
|
319
|
+
function applyBackspace(c: BindingCtx): void {
|
|
320
|
+
c.setState((s) => deleteBackward(s));
|
|
189
321
|
c.nav.reset();
|
|
190
322
|
}
|
|
191
323
|
|
|
192
|
-
function handleInsert(input: string, c: BindingCtx) {
|
|
193
|
-
if (!input)
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
324
|
+
function handleInsert(input: string, c: BindingCtx): void {
|
|
325
|
+
if (!input) return;
|
|
196
326
|
const sanitized = sanitizeTerminalInput(input);
|
|
197
|
-
if (!sanitized)
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
c.setValue((p) => p + sanitized);
|
|
327
|
+
if (!sanitized) return;
|
|
328
|
+
c.setState((s) => insertAt(s, sanitized));
|
|
201
329
|
c.nav.reset();
|
|
202
330
|
}
|
|
203
331
|
|
|
204
|
-
export function useInputHandler(
|
|
205
|
-
|
|
206
|
-
|
|
332
|
+
export function useInputHandler(
|
|
333
|
+
options: UseInputHandlerOptions,
|
|
334
|
+
): InputState & { setBuffer: (value: string, cursor: number) => void } {
|
|
335
|
+
const { isActive, streaming, history, actions, onSubmit, availableCommands, onCommand, pluginShortcuts, mentionRef } =
|
|
336
|
+
options;
|
|
337
|
+
const [state, setState] = useState<BufferState>({ value: '', cursor: 0 });
|
|
207
338
|
const [cmdIndex, setCmdIndex] = useState(0);
|
|
208
|
-
const
|
|
209
|
-
const
|
|
339
|
+
const desiredColumn = useRef<number | null>(null);
|
|
340
|
+
const nav = useHistoryNavigation(state, history);
|
|
341
|
+
// Keep the latest map in a ref so the dispatchKey closure (created once
|
|
342
|
+
// per `useInput` call) always sees the freshest handlers without forcing
|
|
343
|
+
// a re-subscribe.
|
|
344
|
+
const pluginShortcutsRef = useRef(pluginShortcuts);
|
|
345
|
+
pluginShortcutsRef.current = pluginShortcuts;
|
|
346
|
+
|
|
347
|
+
const onRawBackspace = useCallback(() => {
|
|
348
|
+
setState((s) => deleteBackward(s));
|
|
349
|
+
nav.reset();
|
|
350
|
+
}, [nav]);
|
|
351
|
+
const backspaceHandledRef = useRawBackspace(isActive, onRawBackspace);
|
|
210
352
|
|
|
211
|
-
const commands = useMemo(
|
|
212
|
-
|
|
353
|
+
const commands = useMemo(
|
|
354
|
+
() => matchCommands(state.value.trim(), availableCommands),
|
|
355
|
+
[state.value, availableCommands],
|
|
356
|
+
);
|
|
357
|
+
const isCommandMode = commands.length > 0 && state.value.trim().startsWith('/');
|
|
213
358
|
|
|
214
359
|
const submit = useCallback(() => {
|
|
215
|
-
if (streaming)
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
360
|
+
if (streaming) return;
|
|
218
361
|
if (isCommandMode) {
|
|
219
362
|
const cmd = commands[cmdIndex];
|
|
220
363
|
if (cmd) {
|
|
221
|
-
const args = value.trim().slice(cmd.name.length).trim();
|
|
222
|
-
|
|
364
|
+
const args = state.value.trim().slice(cmd.name.length).trim();
|
|
365
|
+
setState({ value: '', cursor: 0 });
|
|
223
366
|
onCommand(cmd, args);
|
|
224
367
|
}
|
|
225
368
|
return;
|
|
226
369
|
}
|
|
227
|
-
if (!value.trim())
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
onSubmit(value);
|
|
231
|
-
setValue('');
|
|
370
|
+
if (!state.value.trim()) return;
|
|
371
|
+
onSubmit(state.value);
|
|
372
|
+
setState({ value: '', cursor: 0 });
|
|
232
373
|
nav.reset();
|
|
233
|
-
}, [streaming, isCommandMode, commands, cmdIndex, value, onCommand, onSubmit, nav]);
|
|
374
|
+
}, [streaming, isCommandMode, commands, cmdIndex, state.value, onCommand, onSubmit, nav]);
|
|
234
375
|
|
|
235
376
|
useInput(
|
|
236
|
-
(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
377
|
+
dispatchKey({
|
|
378
|
+
state,
|
|
379
|
+
setState,
|
|
380
|
+
setCmdIndex,
|
|
381
|
+
desiredColumn,
|
|
382
|
+
nav,
|
|
383
|
+
submit,
|
|
384
|
+
isCommandMode,
|
|
385
|
+
commands,
|
|
386
|
+
actions,
|
|
387
|
+
mentionRef,
|
|
388
|
+
backspaceHandledRef,
|
|
389
|
+
pluginShortcutsRef,
|
|
390
|
+
}),
|
|
391
|
+
{ isActive },
|
|
392
|
+
);
|
|
248
393
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (id && BINDINGS[id]) {
|
|
254
|
-
BINDINGS[id](ctx);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
handleInsert(input, ctx);
|
|
394
|
+
const setBuffer = useCallback(
|
|
395
|
+
(value: string, cursor: number) => {
|
|
396
|
+
setState({ value, cursor });
|
|
397
|
+
nav.reset();
|
|
258
398
|
},
|
|
259
|
-
|
|
399
|
+
[nav],
|
|
260
400
|
);
|
|
261
401
|
|
|
262
|
-
return { value, commands, cmdIndex, isCommandMode };
|
|
402
|
+
return { value: state.value, cursor: state.cursor, commands, cmdIndex, isCommandMode, setBuffer };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
interface DispatchOptions extends BindingCtx {
|
|
406
|
+
backspaceHandledRef: React.MutableRefObject<boolean>;
|
|
407
|
+
pluginShortcutsRef: React.MutableRefObject<Map<string, ShortcutHandler> | undefined>;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Try to dispatch a key id to a plugin-registered handler. Returns `true` when
|
|
412
|
+
* the key was claimed (so the default binding shouldn't fire). The async
|
|
413
|
+
* handler is fire-and-forget: plugins typically mutate their own state and
|
|
414
|
+
* surface results via `MessageBus.append`, so we don't need to await here.
|
|
415
|
+
*/
|
|
416
|
+
function tryPluginShortcut(id: string, opts: DispatchOptions): boolean {
|
|
417
|
+
const handlers = opts.pluginShortcutsRef.current;
|
|
418
|
+
const handler = handlers?.get(id);
|
|
419
|
+
if (!handler) return false;
|
|
420
|
+
void Promise.resolve(handler()).catch(() => {
|
|
421
|
+
/* swallow handler errors so a misbehaving plugin can't kill the input loop */
|
|
422
|
+
});
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Build the key handler for `useInput`. Extracted so the hook body stays
|
|
428
|
+
* under Biome's per-function line cap and so the binding lookup is testable
|
|
429
|
+
* in isolation if we ever need it.
|
|
430
|
+
*/
|
|
431
|
+
function dispatchKey(opts: DispatchOptions): (input: string, key: Key) => void {
|
|
432
|
+
return (input, key) => {
|
|
433
|
+
if (isMouseSequence(input)) return;
|
|
434
|
+
const alreadyHandled = opts.backspaceHandledRef.current;
|
|
435
|
+
opts.backspaceHandledRef.current = false;
|
|
436
|
+
|
|
437
|
+
const id = keyId(input, key);
|
|
438
|
+
if (id && !COLUMN_PRESERVING.has(id)) opts.desiredColumn.current = null;
|
|
439
|
+
|
|
440
|
+
if (id === 'backspace') {
|
|
441
|
+
if (!alreadyHandled) applyBackspace(opts);
|
|
442
|
+
else opts.nav.reset();
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (id && tryPluginShortcut(id, opts)) {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (id && BINDINGS[id]) {
|
|
449
|
+
BINDINGS[id](opts);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
handleInsert(input, opts);
|
|
453
|
+
};
|
|
263
454
|
}
|