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.
Files changed (84) 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/cli/install.ts +18 -3
  8. package/src/config/index.test.ts +26 -0
  9. package/src/config/index.ts +25 -7
  10. package/src/plugin.ts +124 -0
  11. package/src/runtime/codingTools/bash.ts +114 -0
  12. package/src/runtime/codingTools/edit-file.ts +60 -0
  13. package/src/runtime/codingTools/index.ts +39 -0
  14. package/src/runtime/codingTools/read-file.ts +83 -0
  15. package/src/runtime/codingTools/utils.ts +21 -0
  16. package/src/runtime/codingTools/write-file.ts +42 -0
  17. package/src/runtime/createRegistry.test.ts +147 -0
  18. package/src/runtime/createRegistry.ts +160 -23
  19. package/src/runtime/fileMentionProvider.ts +116 -0
  20. package/src/runtime/messageBus.test.ts +62 -0
  21. package/src/runtime/messageBus.ts +78 -0
  22. package/src/runtime/pluginLoader.ts +59 -15
  23. package/src/sessions/index.ts +2 -9
  24. package/src/tui/channel/tuiChannel.test.ts +107 -0
  25. package/src/tui/channel/tuiChannel.ts +62 -0
  26. package/src/tui/chat/MessageRendererContext.ts +44 -0
  27. package/src/tui/chat/ToolDisplayContext.ts +1 -1
  28. package/src/tui/chat/useAbort.ts +5 -0
  29. package/src/tui/chat/useAttachment.ts +1 -1
  30. package/src/tui/chat/useChat.ts +38 -3
  31. package/src/tui/chat/useChatPanel.ts +29 -6
  32. package/src/tui/chat/useChatSession.ts +324 -57
  33. package/src/tui/chat/useModels.ts +26 -1
  34. package/src/tui/chat/usePluginStatus.ts +1 -1
  35. package/src/tui/chat/useSessionPersistence.ts +48 -21
  36. package/src/tui/chat/useStatusSegments.ts +38 -5
  37. package/src/tui/chat/useSubagentBrowser.ts +133 -0
  38. package/src/tui/components/chat/ChatPanel.tsx +25 -4
  39. package/src/tui/components/chat/ChatPanelBody.tsx +22 -1
  40. package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
  41. package/src/tui/components/messageView.tsx +4 -2
  42. package/src/tui/components/messages/EditOutput.tsx +17 -9
  43. package/src/tui/components/messages/ReadOutput.tsx +1 -1
  44. package/src/tui/components/messages/ToolHeader.tsx +8 -4
  45. package/src/tui/components/messages/WriteOutput.tsx +12 -4
  46. package/src/tui/components/messages/assistantMessage.tsx +55 -7
  47. package/src/tui/components/messages/markdown.tsx +402 -0
  48. package/src/tui/components/messages/messageItem.tsx +19 -1
  49. package/src/tui/components/messages/reasoningBlock.tsx +10 -6
  50. package/src/tui/components/messages/streamingOutput.tsx +6 -2
  51. package/src/tui/components/messages/toolCallBlock.tsx +7 -6
  52. package/src/tui/components/messages/userMessage.tsx +22 -7
  53. package/src/tui/components/primitives/dropdown.tsx +8 -4
  54. package/src/tui/components/primitives/modal.tsx +4 -2
  55. package/src/tui/components/primitives/pickerModal.tsx +3 -1
  56. package/src/tui/components/primitives/toast.tsx +43 -10
  57. package/src/tui/components/statusBar.tsx +26 -10
  58. package/src/tui/components/ui/dialogLayer.tsx +11 -6
  59. package/src/tui/context/ThemeContext.tsx +18 -0
  60. package/src/tui/hooks/useChordKeyboard.ts +87 -0
  61. package/src/tui/hooks/useInputInfoSegments.ts +22 -0
  62. package/src/tui/input/InputBoxView.tsx +191 -26
  63. package/src/tui/input/commands.test.ts +3 -1
  64. package/src/tui/input/commands.ts +11 -1
  65. package/src/tui/input/cursor.test.ts +136 -0
  66. package/src/tui/input/cursor.ts +214 -0
  67. package/src/tui/input/dumpContext.ts +107 -0
  68. package/src/tui/input/sanitize.ts +1 -1
  69. package/src/tui/input/useCommandExecutor.ts +1 -1
  70. package/src/tui/input/useInputBox.ts +160 -15
  71. package/src/tui/input/useInputHandler.ts +317 -126
  72. package/src/tui/input/useMentionPicker.ts +133 -0
  73. package/src/tui/input/usePluginShortcuts.ts +29 -0
  74. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  75. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  76. package/src/tui/plugins/InkUIService.ts +1 -1
  77. package/src/tui/renderApp.tsx +47 -13
  78. package/src/tui/theme/index.ts +1 -0
  79. package/src/tui/theme/merge.test.ts +49 -0
  80. package/src/tui/theme/merge.ts +43 -0
  81. package/src/tui/theme/presets.ts +90 -0
  82. package/src/tui/theme/types.ts +138 -0
  83. package/src/utils/clipboard.ts +1 -1
  84. 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
- // Build a stable key identifier from an Ink key event
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
- 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';
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(value: string, history: string[]) {
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
- function useRawBackspace(isActive: boolean, setValue: (fn: (p: string) => string) => void) {
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
- setValue((p) => p.slice(0, -1));
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, setValue]);
179
+ }, [stdin, isActive]);
124
180
 
125
181
  return handledRef;
126
182
  }
127
183
 
128
184
  interface BindingCtx {
129
- value: string;
130
- setValue: React.Dispatch<React.SetStateAction<string>>;
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.setValue((p) => `${p}\n`),
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.setValue('');
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.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
- }
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
- function handleBackspace(c: BindingCtx, alreadyHandled: boolean) {
186
- if (!alreadyHandled) {
187
- c.setValue((p) => p.slice(0, -1));
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
- return;
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(options: UseInputHandlerOptions): InputState {
205
- const { isActive, streaming, history, actions, onSubmit, availableCommands, onCommand } = options;
206
- const [value, setValue] = useState('');
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 nav = useHistoryNavigation(value, history);
209
- const backspaceHandledRef = useRawBackspace(isActive, setValue);
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(() => matchCommands(value.trim(), availableCommands), [value, availableCommands]);
212
- const isCommandMode = commands.length > 0 && value.trim().startsWith('/');
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
- setValue('');
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
- return;
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
- (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);
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
- 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);
394
+ const setBuffer = useCallback(
395
+ (value: string, cursor: number) => {
396
+ setState({ value, cursor });
397
+ nav.reset();
258
398
  },
259
- { isActive },
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
  }