mu-coding 0.15.0 → 0.16.1

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