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