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.
Files changed (74) hide show
  1. package/README.md +49 -3
  2. package/package.json +9 -4
  3. package/prompts/SYSTEM.md +16 -0
  4. package/src/app/shutdown.ts +1 -1
  5. package/src/app/startApp.ts +11 -8
  6. package/src/cli/args.ts +14 -11
  7. package/src/config/index.test.ts +26 -0
  8. package/src/config/index.ts +25 -7
  9. package/src/plugin.ts +96 -0
  10. package/src/runtime/codingTools/bash.ts +114 -0
  11. package/src/runtime/codingTools/edit-file.ts +60 -0
  12. package/src/runtime/codingTools/index.ts +39 -0
  13. package/src/runtime/codingTools/read-file.ts +83 -0
  14. package/src/runtime/codingTools/utils.ts +21 -0
  15. package/src/runtime/codingTools/write-file.ts +42 -0
  16. package/src/runtime/createRegistry.test.ts +146 -0
  17. package/src/runtime/createRegistry.ts +128 -23
  18. package/src/runtime/messageBus.test.ts +62 -0
  19. package/src/runtime/messageBus.ts +78 -0
  20. package/src/runtime/pluginLoader.ts +22 -9
  21. package/src/sessions/index.ts +2 -9
  22. package/src/tui/channel/tuiChannel.test.ts +107 -0
  23. package/src/tui/channel/tuiChannel.ts +49 -0
  24. package/src/tui/chat/MessageRendererContext.ts +44 -0
  25. package/src/tui/chat/ToolDisplayContext.ts +1 -1
  26. package/src/tui/chat/useAttachment.ts +1 -1
  27. package/src/tui/chat/useChat.ts +31 -3
  28. package/src/tui/chat/useChatPanel.ts +7 -5
  29. package/src/tui/chat/useChatSession.ts +222 -53
  30. package/src/tui/chat/useModels.ts +2 -1
  31. package/src/tui/chat/usePluginStatus.ts +1 -1
  32. package/src/tui/chat/useSessionPersistence.ts +25 -14
  33. package/src/tui/chat/useStatusSegments.ts +17 -4
  34. package/src/tui/components/chat/ChatPanel.tsx +10 -4
  35. package/src/tui/components/chat/ChatPanelBody.tsx +1 -1
  36. package/src/tui/components/messageView.tsx +4 -2
  37. package/src/tui/components/messages/EditOutput.tsx +6 -4
  38. package/src/tui/components/messages/ToolHeader.tsx +3 -1
  39. package/src/tui/components/messages/assistantMessage.tsx +17 -2
  40. package/src/tui/components/messages/messageItem.tsx +19 -1
  41. package/src/tui/components/messages/reasoningBlock.tsx +4 -2
  42. package/src/tui/components/messages/streamingOutput.tsx +5 -1
  43. package/src/tui/components/messages/toolCallBlock.tsx +6 -5
  44. package/src/tui/components/messages/userMessage.tsx +21 -6
  45. package/src/tui/components/primitives/dropdown.tsx +8 -4
  46. package/src/tui/components/primitives/modal.tsx +4 -2
  47. package/src/tui/components/primitives/pickerModal.tsx +3 -1
  48. package/src/tui/components/primitives/toast.tsx +5 -3
  49. package/src/tui/components/statusBar.tsx +8 -1
  50. package/src/tui/components/ui/dialogLayer.tsx +11 -6
  51. package/src/tui/context/ThemeContext.tsx +18 -0
  52. package/src/tui/input/InputBoxView.tsx +135 -26
  53. package/src/tui/input/commands.test.ts +3 -1
  54. package/src/tui/input/commands.ts +6 -1
  55. package/src/tui/input/cursor.test.ts +136 -0
  56. package/src/tui/input/cursor.ts +214 -0
  57. package/src/tui/input/dumpContext.ts +107 -0
  58. package/src/tui/input/sanitize.ts +1 -1
  59. package/src/tui/input/useCommandExecutor.ts +1 -1
  60. package/src/tui/input/useInputBox.ts +134 -15
  61. package/src/tui/input/useInputHandler.ts +316 -126
  62. package/src/tui/input/useMentionPicker.ts +121 -0
  63. package/src/tui/input/usePluginShortcuts.ts +29 -0
  64. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  65. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  66. package/src/tui/plugins/InkUIService.ts +1 -1
  67. package/src/tui/renderApp.tsx +26 -13
  68. package/src/tui/theme/index.ts +1 -0
  69. package/src/tui/theme/merge.test.ts +49 -0
  70. package/src/tui/theme/merge.ts +43 -0
  71. package/src/tui/theme/presets.ts +79 -0
  72. package/src/tui/theme/types.ts +116 -0
  73. package/src/utils/clipboard.ts +1 -1
  74. 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
- // Build a stable key identifier from an Ink key event
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
- if (key.ctrl && key.shift && input) {
41
- return `ctrl+shift+${input}`;
42
- }
43
- if (key.ctrl && input) {
44
- return `ctrl+${input}`;
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(value: string, history: string[]) {
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
- function useRawBackspace(isActive: boolean, setValue: (fn: (p: string) => string) => void) {
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
- setValue((p) => p.slice(0, -1));
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, setValue]);
178
+ }, [stdin, isActive]);
124
179
 
125
180
  return handledRef;
126
181
  }
127
182
 
128
183
  interface BindingCtx {
129
- value: string;
130
- setValue: React.Dispatch<React.SetStateAction<string>>;
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.setValue((p) => `${p}\n`),
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.setValue('');
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.setValue((p) => `${p}\n`),
161
- return: (c) => c.submit(),
162
- tab: (c) => c.setValue((p) => `${p} `),
163
- up: (c) => {
164
- if (c.isCommandMode) {
165
- c.setCmdIndex((i) => (i > 0 ? i - 1 : c.commands.length - 1));
166
- return;
167
- }
168
- const r = c.nav.up();
169
- if (r !== null) {
170
- c.setValue(r);
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
- function handleBackspace(c: BindingCtx, alreadyHandled: boolean) {
186
- if (!alreadyHandled) {
187
- c.setValue((p) => p.slice(0, -1));
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
- return;
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(options: UseInputHandlerOptions): InputState {
205
- const { isActive, streaming, history, actions, onSubmit, availableCommands, onCommand } = options;
206
- const [value, setValue] = useState('');
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 nav = useHistoryNavigation(value, history);
209
- const backspaceHandledRef = useRawBackspace(isActive, setValue);
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(() => matchCommands(value.trim(), availableCommands), [value, availableCommands]);
212
- const isCommandMode = commands.length > 0 && value.trim().startsWith('/');
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
- setValue('');
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
- return;
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
- (input, key) => {
237
- // Discard SGR mouse events outright — useScroll has already routed wheel
238
- // events; clicks/releases must not leak into the input box.
239
- if (isMouseSequence(input)) {
240
- return;
241
- }
242
-
243
- const alreadyHandled = backspaceHandledRef.current;
244
- backspaceHandledRef.current = false;
245
-
246
- const ctx: BindingCtx = { value, setValue, setCmdIndex, nav, submit, isCommandMode, commands, actions };
247
- const id = keyId(input, key);
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
- if (id === 'backspace') {
250
- handleBackspace(ctx, alreadyHandled);
251
- return;
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
- { isActive },
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
+ }