mu-coding 0.2.0 → 0.5.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 (72) hide show
  1. package/README.md +0 -2
  2. package/bin/mu.js +1 -1
  3. package/package.json +12 -4
  4. package/src/app/shutdown.ts +94 -0
  5. package/src/app/startApp.ts +40 -0
  6. package/src/cli/args.ts +128 -0
  7. package/src/{install.ts → cli/install.ts} +19 -15
  8. package/src/config/index.test.ts +51 -0
  9. package/src/config/index.ts +181 -0
  10. package/src/main.ts +4 -0
  11. package/src/runtime/createRegistry.ts +58 -0
  12. package/src/runtime/pluginLoader.ts +109 -0
  13. package/src/sessions/index.test.ts +66 -0
  14. package/src/sessions/index.ts +190 -0
  15. package/src/sessions/peek.test.ts +88 -0
  16. package/src/sessions/project.ts +51 -0
  17. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  18. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  19. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  20. package/src/tui/chat/useAttachment.ts +74 -0
  21. package/src/tui/{useChat.ts → chat/useChat.ts} +32 -6
  22. package/src/tui/chat/useChatPanel.ts +96 -0
  23. package/src/tui/chat/useChatSession.ts +115 -0
  24. package/src/tui/{useModelList.ts → chat/useModels.ts} +10 -1
  25. package/src/tui/chat/usePluginStatus.ts +44 -0
  26. package/src/tui/chat/useSessionPersistence.ts +57 -0
  27. package/src/tui/chat/useStatusSegments.ts +49 -0
  28. package/src/tui/chat/useStreamConsumer.ts +118 -0
  29. package/src/tui/components/chat/ChatPanel.tsx +12 -38
  30. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  31. package/src/tui/components/chat/Pickers.tsx +2 -2
  32. package/src/tui/components/messageView.tsx +70 -0
  33. package/src/tui/components/messages/EditOutput.tsx +42 -27
  34. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  35. package/src/tui/components/messages/ToolHeader.tsx +26 -0
  36. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  37. package/src/tui/components/messages/messageItem.tsx +4 -15
  38. package/src/tui/components/messages/toolCallBlock.tsx +56 -34
  39. package/src/tui/components/{ui → primitives}/dropdown.tsx +32 -7
  40. package/src/tui/components/primitives/pickerModal.tsx +45 -0
  41. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  42. package/src/tui/components/statusBar.tsx +25 -0
  43. package/src/tui/components/ui/dialogLayer.tsx +21 -7
  44. package/src/tui/hooks/useScroll.ts +11 -3
  45. package/src/tui/input/InputBox.tsx +6 -0
  46. package/src/tui/{components/inputBox.tsx → input/InputBoxView.tsx} +24 -49
  47. package/src/tui/input/commands.test.ts +49 -0
  48. package/src/tui/input/commands.ts +39 -0
  49. package/src/tui/input/sanitize.ts +33 -0
  50. package/src/tui/input/useCommandExecutor.ts +32 -0
  51. package/src/tui/input/useInputBox.ts +88 -0
  52. package/src/tui/{hooks → input}/useInputHandler.ts +21 -26
  53. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  54. package/src/tui/renderApp.tsx +30 -0
  55. package/src/utils/clipboard.ts +97 -0
  56. package/src/utils/diff.test.ts +56 -0
  57. package/src/cli.ts +0 -92
  58. package/src/clipboard.ts +0 -62
  59. package/src/config.ts +0 -116
  60. package/src/main.tsx +0 -161
  61. package/src/project.ts +0 -32
  62. package/src/session.ts +0 -95
  63. package/src/singleShot.ts +0 -42
  64. package/src/tui/commands.ts +0 -33
  65. package/src/tui/components/chatLayout.tsx +0 -192
  66. package/src/tui/useChatSession.ts +0 -155
  67. package/src/tui/useChatUI.ts +0 -51
  68. package/tsconfig.json +0 -10
  69. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  70. /package/src/tui/components/{ui → primitives}/modal.tsx +0 -0
  71. /package/src/tui/components/{ui → primitives}/toast.tsx +0 -0
  72. /package/src/{diff.ts → utils/diff.ts} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { Box, Text, useInput } from 'ink';
2
2
  import { useMemo, useState } from 'react';
3
+ import { sanitizeTerminalInput } from '../../input/sanitize';
3
4
 
4
5
  interface DropdownItem {
5
6
  label: string;
@@ -42,13 +43,37 @@ export function Dropdown({
42
43
 
43
44
  useInput(
44
45
  (input, key) => {
45
- if (!isActive || key.tab) return;
46
- if (key.escape) onCancel?.();
47
- else if (key.return && filtered[index]) onSelect(filtered[index]);
48
- else if (key.upArrow) setIndex((i) => Math.max(0, i - 1));
49
- else if (key.downArrow) setIndex((i) => Math.min(filtered.length - 1, i + 1));
50
- else if (key.backspace) setQuery((q) => q.slice(0, -1));
51
- else if (input?.length === 1) setQuery((q) => q + input);
46
+ if (!isActive) return;
47
+ // Tab is reserved for the input box's "insert two spaces" binding when
48
+ // dropdowns are not focused; inside a focused dropdown we ignore it
49
+ // rather than risk inserting whitespace into the query.
50
+ if (key.tab) return;
51
+ if (key.escape) {
52
+ onCancel?.();
53
+ return;
54
+ }
55
+ if (key.return && filtered[index]) {
56
+ onSelect(filtered[index]);
57
+ return;
58
+ }
59
+ if (key.upArrow) {
60
+ setIndex((i) => Math.max(0, i - 1));
61
+ return;
62
+ }
63
+ if (key.downArrow) {
64
+ setIndex((i) => Math.min(filtered.length - 1, i + 1));
65
+ return;
66
+ }
67
+ if (key.backspace) {
68
+ setQuery((q) => q.slice(0, -1));
69
+ return;
70
+ }
71
+ // Accept multi-char input (paste) into the filter; strip control bytes
72
+ // and any SGR mouse sequences that may leak through. Single-line: drop \t/\n.
73
+ if (input) {
74
+ const clean = sanitizeTerminalInput(input).replace(/[\t\n]/g, '');
75
+ if (clean) setQuery((q) => q + clean);
76
+ }
52
77
  },
53
78
  { isActive },
54
79
  );
@@ -0,0 +1,45 @@
1
+ import { Text } from 'ink';
2
+ import { Dropdown } from './dropdown';
3
+ import { Modal } from './modal';
4
+
5
+ interface PickerItem {
6
+ label: string;
7
+ value: string;
8
+ description?: string;
9
+ }
10
+
11
+ export function PickerModal({
12
+ visible,
13
+ title,
14
+ items,
15
+ placeholder,
16
+ emptyMessage,
17
+ onSelect,
18
+ onCancel,
19
+ }: {
20
+ visible: boolean;
21
+ title: string;
22
+ items: PickerItem[];
23
+ placeholder: string;
24
+ emptyMessage?: string;
25
+ onSelect: (value: string) => void;
26
+ onCancel?: () => void;
27
+ }) {
28
+ return (
29
+ <Modal visible={visible} title={title}>
30
+ {items.length === 0 && emptyMessage ? (
31
+ <Text dimColor={true} italic={true}>
32
+ {emptyMessage}
33
+ </Text>
34
+ ) : (
35
+ <Dropdown
36
+ items={items}
37
+ placeholder={placeholder}
38
+ isActive={visible}
39
+ onSelect={(item) => onSelect(item.value)}
40
+ onCancel={onCancel}
41
+ />
42
+ )}
43
+ </Modal>
44
+ );
45
+ }
@@ -0,0 +1,27 @@
1
+ import { Box, Text } from 'ink';
2
+
3
+ export function Scrollbar({
4
+ viewHeight,
5
+ contentHeight,
6
+ scrollOffset,
7
+ }: {
8
+ viewHeight: number;
9
+ contentHeight: number;
10
+ scrollOffset: number;
11
+ }) {
12
+ if (contentHeight <= viewHeight || viewHeight < 1) {
13
+ return null;
14
+ }
15
+ const maxScroll = contentHeight - viewHeight;
16
+ const ratio = scrollOffset / maxScroll;
17
+ const thumbSize = Math.max(1, Math.round((viewHeight / contentHeight) * viewHeight));
18
+ const thumbPos = Math.round(ratio * (viewHeight - thumbSize));
19
+
20
+ const track = Array.from({ length: viewHeight }, (_, i) => (i >= thumbPos && i < thumbPos + thumbSize ? '┃' : '│'));
21
+
22
+ return (
23
+ <Box flexDirection="column" flexShrink={0} width={1}>
24
+ <Text>{track.join('')}</Text>
25
+ </Box>
26
+ );
27
+ }
@@ -0,0 +1,25 @@
1
+ import { Box, Text } from 'ink';
2
+
3
+ export interface StatusBarSegment {
4
+ text: string;
5
+ color?: string;
6
+ dim?: boolean;
7
+ }
8
+
9
+ export function StatusBar({ segments }: { segments: StatusBarSegment[] }) {
10
+ return (
11
+ <Box flexShrink={0} paddingX={1} marginY={1}>
12
+ <Box justifyContent="flex-end" flexGrow={1}>
13
+ {segments.map((seg, i) => (
14
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional static list
15
+ <Box key={i}>
16
+ {i > 0 && <Text dimColor={true}> · </Text>}
17
+ <Text color={seg.color} dimColor={seg.dim}>
18
+ {seg.text}
19
+ </Text>
20
+ </Box>
21
+ ))}
22
+ </Box>
23
+ </Box>
24
+ );
25
+ }
@@ -1,8 +1,9 @@
1
1
  import { Box, Text, useInput } from 'ink';
2
2
  import { useCallback, useEffect, useState } from 'react';
3
- import type { DialogRequest, InkUIService } from '../../services/uiService';
4
- import { Dropdown } from './dropdown';
5
- import { Modal } from './modal';
3
+ import { sanitizeTerminalInput } from '../../input/sanitize';
4
+ import type { DialogRequest, InkUIService } from '../../plugins/InkUIService';
5
+ import { Dropdown } from '../primitives/dropdown';
6
+ import { Modal } from '../primitives/modal';
6
7
 
7
8
  // ─── Confirm Dialog ───────────────────────────────────────────────────────────
8
9
 
@@ -80,6 +81,12 @@ function SelectDialog({
80
81
 
81
82
  // ─── Input Dialog ─────────────────────────────────────────────────────────────
82
83
 
84
+ function sanitizeDialogInput(text: string): string {
85
+ // Strip mouse sequences + control bytes via the shared helper, then drop
86
+ // \t/\n that the shared helper preserves — this dialog is single-line.
87
+ return sanitizeTerminalInput(text).replace(/[\t\n]/g, '');
88
+ }
89
+
83
90
  function InputDialog({
84
91
  dialog,
85
92
  onResolve,
@@ -94,12 +101,19 @@ function InputDialog({
94
101
  useInput((input, key) => {
95
102
  if (key.escape) {
96
103
  onCancel();
97
- } else if (key.return) {
104
+ return;
105
+ }
106
+ if (key.return) {
98
107
  onResolve(value || null);
99
- } else if (key.backspace || key.delete) {
108
+ return;
109
+ }
110
+ if (key.backspace || key.delete) {
100
111
  setValue((v) => v.slice(0, -1));
101
- } else if (input && input.length === 1) {
102
- setValue((v) => v + input);
112
+ return;
113
+ }
114
+ const insert = sanitizeDialogInput(input);
115
+ if (insert) {
116
+ setValue((v) => v + insert);
103
117
  }
104
118
  });
105
119
 
@@ -8,12 +8,20 @@ export function useScroll(contentHeight: number, viewHeight: number) {
8
8
  const autoScrollRef = useRef(true);
9
9
  const maxScroll = Math.max(0, contentHeight - viewHeight);
10
10
 
11
- // Enable SGR mouse mode so wheel sequences arrive through Ink's input pipeline
11
+ // Enable SGR mouse mode (1000 = press/release+wheel only, no drag motion;
12
+ // 1006 = SGR-encoded coordinates) so wheel sequences arrive through Ink's
13
+ // input pipeline. Mode 1002 (button-event w/ drag) was previously used but
14
+ // produced spurious "[<32;...M" drag events that leaked into text inputs.
15
+ //
16
+ // On cleanup we defensively disable 1000/1002/1003 — any of them might be
17
+ // active from a prior session/binary/extension and disabling already-off
18
+ // modes is a no-op. Without this, mouse tracking can leak into the parent
19
+ // shell after abort.
12
20
  const { stdout } = useStdout();
13
21
  useEffect(() => {
14
- stdout.write('\x1b[?1002h\x1b[?1006h');
22
+ stdout.write('\x1b[?1000h\x1b[?1006h');
15
23
  return () => {
16
- stdout.write('\x1b[?1002l\x1b[?1006l');
24
+ stdout.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l');
17
25
  };
18
26
  }, [stdout]);
19
27
 
@@ -0,0 +1,6 @@
1
+ import { InputBoxView } from './InputBoxView';
2
+ import { type InputBoxProps, useInputBox } from './useInputBox';
3
+
4
+ export function InputBox(props: InputBoxProps) {
5
+ return <InputBoxView {...useInputBox(props)} />;
6
+ }
@@ -1,15 +1,16 @@
1
1
  import { Box, Text } from 'ink';
2
- import type { SlashCommand } from '../commands';
3
- import { useChatContext } from '../context/chat';
4
- import { type InputActions, useInputHandler } from '../hooks/useInputHandler';
2
+ import type { SlashCommand } from './commands';
5
3
 
6
- interface InputBoxProps {
7
- onSubmit: (text: string) => void;
8
- onScrollUp?: () => void;
9
- onScrollDown?: () => void;
10
- isActive?: boolean;
11
- model?: string;
12
- history?: string[];
4
+ export interface InputBoxViewProps {
5
+ value: string;
6
+ commands: SlashCommand[];
7
+ cmdIndex: number;
8
+ isCommandMode: boolean;
9
+ streaming: boolean;
10
+ isActive: boolean;
11
+ model: string;
12
+ attachmentName: string | null;
13
+ attachmentError: string | null;
13
14
  }
14
15
 
15
16
  function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; selectedIndex: number }) {
@@ -95,38 +96,7 @@ function InputDisplay({
95
96
  );
96
97
  }
97
98
 
98
- export function InputBox({
99
- onSubmit,
100
- onScrollUp,
101
- onScrollDown,
102
- isActive = true,
103
- model = '',
104
- history = [],
105
- }: InputBoxProps) {
106
- const { session, toggles, attachment, models, abort, registry } = useChatContext();
107
-
108
- const actions: InputActions = {
109
- onCtrlC: abort.onCtrlC,
110
- onEsc: abort.onEsc,
111
- onPaste: attachment.onPaste,
112
- onNew: session.onNew,
113
- onCycleModel: models.cycleModel,
114
- onTogglePicker: toggles.onTogglePicker,
115
- onToggleSessionPicker: toggles.onToggleSessionPicker,
116
- onScrollUp,
117
- onScrollDown,
118
- modelCount: models.models.length,
119
- };
120
-
121
- const { value, commands, cmdIndex, isCommandMode } = useInputHandler({
122
- isActive,
123
- streaming: session.streaming,
124
- history,
125
- actions,
126
- onSubmit,
127
- pluginCommands: registry.getCommands(),
128
- });
129
-
99
+ export function InputBoxView(props: InputBoxViewProps) {
130
100
  return (
131
101
  <Box
132
102
  flexDirection="column"
@@ -137,16 +107,21 @@ export function InputBox({
137
107
  marginX={1}
138
108
  marginTop={1}
139
109
  >
140
- {isCommandMode && <CommandHints commands={commands} selectedIndex={cmdIndex} />}
110
+ {props.isCommandMode && <CommandHints commands={props.commands} selectedIndex={props.cmdIndex} />}
141
111
  <Box flexDirection="column" minHeight={2}>
142
- <InputDisplay value={value} isCommandMode={isCommandMode} streaming={session.streaming} isActive={isActive} />
112
+ <InputDisplay
113
+ value={props.value}
114
+ isCommandMode={props.isCommandMode}
115
+ streaming={props.streaming}
116
+ isActive={props.isActive}
117
+ />
143
118
  </Box>
144
119
  <InputFooter
145
- model={model}
146
- attachmentName={attachment.attachment?.name ?? null}
147
- attachmentError={attachment.attachmentError}
148
- hasContent={value.length > 0}
149
- isCommandMode={isCommandMode}
120
+ model={props.model}
121
+ attachmentName={props.attachmentName}
122
+ attachmentError={props.attachmentError}
123
+ hasContent={props.value.length > 0}
124
+ isCommandMode={props.isCommandMode}
150
125
  />
151
126
  </Box>
152
127
  );
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it, mock } from 'bun:test';
2
+ import { BUILTIN_COMMANDS, fromPluginCommand, matchCommands } from './commands';
3
+ import type { InputActions } from './useInputHandler';
4
+
5
+ describe('matchCommands', () => {
6
+ it('returns no matches for input that does not start with /', () => {
7
+ expect(matchCommands('model', BUILTIN_COMMANDS)).toEqual([]);
8
+ });
9
+
10
+ it('filters by prefix case-insensitively', () => {
11
+ const result = matchCommands('/MOD', BUILTIN_COMMANDS);
12
+ expect(result.map((c) => c.name)).toEqual(['/model']);
13
+ });
14
+
15
+ it('returns all commands when input is just /', () => {
16
+ const result = matchCommands('/', BUILTIN_COMMANDS);
17
+ expect(result.length).toBe(BUILTIN_COMMANDS.length);
18
+ });
19
+ });
20
+
21
+ describe('BUILTIN_COMMANDS', () => {
22
+ it('each builtin invokes the expected action', () => {
23
+ const onTogglePicker = mock(() => undefined);
24
+ const onToggleSessionPicker = mock(() => undefined);
25
+ const onNew = mock(() => undefined);
26
+ const actions: InputActions = { onTogglePicker, onToggleSessionPicker, onNew };
27
+
28
+ for (const cmd of BUILTIN_COMMANDS) {
29
+ cmd.invoke?.(actions);
30
+ }
31
+
32
+ expect(onTogglePicker).toHaveBeenCalledTimes(1);
33
+ expect(onToggleSessionPicker).toHaveBeenCalledTimes(1);
34
+ expect(onNew).toHaveBeenCalledTimes(1);
35
+ });
36
+ });
37
+
38
+ describe('fromPluginCommand', () => {
39
+ it('prepends a slash and forwards args/context to execute', async () => {
40
+ const execute = mock(async () => 'ok');
41
+ const wrapped = fromPluginCommand(
42
+ { name: 'foo', description: 'plugin', execute },
43
+ { messages: [], cwd: '/tmp', config: { baseUrl: '', maxTokens: 0, temperature: 0, streamTimeoutMs: 0 } },
44
+ );
45
+ expect(wrapped.name).toBe('/foo');
46
+ await wrapped.execute?.('hello');
47
+ expect(execute).toHaveBeenCalledWith('hello', expect.any(Object));
48
+ });
49
+ });
@@ -0,0 +1,39 @@
1
+ import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-agents';
2
+ import type { InputActions } from './useInputHandler';
3
+
4
+ /**
5
+ * A slash command can either:
6
+ * - run via `invoke(actions)` — for builtins that just toggle UI state, or
7
+ * - run via `execute(args)` — for plugin-supplied commands that produce
8
+ * side-effects through the agent runtime.
9
+ *
10
+ * Exactly one of `invoke` / `execute` should be set per command.
11
+ */
12
+ export interface SlashCommand {
13
+ name: string;
14
+ description: string;
15
+ invoke?: (actions: InputActions) => void;
16
+ execute?: (args: string) => Promise<string | undefined>;
17
+ }
18
+
19
+ export const BUILTIN_COMMANDS: SlashCommand[] = [
20
+ { name: '/model', description: 'Select a model', invoke: (a) => a.onTogglePicker?.() },
21
+ { name: '/sessions', description: 'List project sessions', invoke: (a) => a.onToggleSessionPicker?.() },
22
+ { name: '/new', description: 'New conversation', invoke: (a) => a.onNew?.() },
23
+ ];
24
+
25
+ export function fromPluginCommand(command: PluginSlashCommand, context: CommandContext): SlashCommand {
26
+ return {
27
+ name: `/${command.name}`,
28
+ description: command.description,
29
+ execute: (args: string) => command.execute(args, context),
30
+ };
31
+ }
32
+
33
+ export function matchCommands(input: string, commands: SlashCommand[]): SlashCommand[] {
34
+ if (!input.startsWith('/')) {
35
+ return [];
36
+ }
37
+ const q = input.toLowerCase();
38
+ return commands.filter((cmd) => cmd.name.startsWith(q));
39
+ }
@@ -0,0 +1,33 @@
1
+ // Matches xterm SGR (1006) mouse-event sequences after Ink has stripped the
2
+ // leading \x1b. Format: \x1b[<button;x;y[Mm]
3
+ // - M = press / motion
4
+ // - m = release
5
+ // Examples: "[<0;126;31M", "[<32;36;51M", "[<0;31;51m"
6
+ export const SGR_MOUSE_RE = /\[<\d+;\d+;\d+[Mm]/g;
7
+
8
+ const SGR_MOUSE_EXACT_RE = /^\[<\d+;\d+;\d+[Mm]$/;
9
+
10
+ /** Single-event chunk (Ink usually delivers one event per input call). */
11
+ export function isMouseSequence(input: string): boolean {
12
+ return SGR_MOUSE_EXACT_RE.test(input);
13
+ }
14
+
15
+ /**
16
+ * Strip terminal-input bytes that should never become text:
17
+ * 1. Any embedded SGR mouse-event sequences (clicks/drags/release/wheel).
18
+ * 2. ASCII control bytes < 0x20 *except* \t and \n which paste should keep.
19
+ *
20
+ * Multi-event chunks (e.g. fast clicks batched into one data frame) are
21
+ * handled because the regex is global.
22
+ */
23
+ export function sanitizeTerminalInput(text: string): string {
24
+ const stripped = text.replace(SGR_MOUSE_RE, '');
25
+ let out = '';
26
+ for (const ch of stripped) {
27
+ const code = ch.charCodeAt(0);
28
+ if (ch === '\t' || ch === '\n' || code >= 0x20) {
29
+ out += ch;
30
+ }
31
+ }
32
+ return out;
33
+ }
@@ -0,0 +1,32 @@
1
+ import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-agents';
2
+ import { useCallback, useMemo } from 'react';
3
+ import { BUILTIN_COMMANDS, fromPluginCommand, type SlashCommand } from './commands';
4
+ import type { InputActions } from './useInputHandler';
5
+
6
+ interface CommandExecutorOptions {
7
+ actions: InputActions;
8
+ context: CommandContext;
9
+ pluginCommands: PluginSlashCommand[];
10
+ }
11
+
12
+ export function useCommandExecutor(options: CommandExecutorOptions) {
13
+ const { actions, context, pluginCommands } = options;
14
+
15
+ const commands = useMemo(
16
+ () => [...BUILTIN_COMMANDS, ...pluginCommands.map((command) => fromPluginCommand(command, context))],
17
+ [context, pluginCommands],
18
+ );
19
+
20
+ const execute = useCallback(
21
+ (command: SlashCommand, args: string) => {
22
+ if (command.execute) {
23
+ void command.execute(args);
24
+ return;
25
+ }
26
+ command.invoke?.(actions);
27
+ },
28
+ [actions],
29
+ );
30
+
31
+ return { commands, execute };
32
+ }
@@ -0,0 +1,88 @@
1
+ import { useMemo } from 'react';
2
+ import { useChatContext } from '../chat/ChatContext';
3
+ import type { InputBoxViewProps } from './InputBoxView';
4
+ import { useCommandExecutor } from './useCommandExecutor';
5
+ import { type InputActions, useInputHandler } from './useInputHandler';
6
+
7
+ export interface InputBoxProps {
8
+ onSubmit: (text: string) => void;
9
+ onScrollUp?: () => void;
10
+ onScrollDown?: () => void;
11
+ isActive?: boolean;
12
+ model?: string;
13
+ history?: string[];
14
+ }
15
+
16
+ export function useInputBox({
17
+ onSubmit,
18
+ onScrollUp,
19
+ onScrollDown,
20
+ isActive = true,
21
+ model = '',
22
+ history = [],
23
+ }: InputBoxProps): InputBoxViewProps {
24
+ const { config, session, toggles, attachment, models, abort, registry } = useChatContext();
25
+
26
+ // Stable references prevent downstream `useMemo`s (e.g. inside
27
+ // `useCommandExecutor`) from being invalidated on every render.
28
+ const actions: InputActions = useMemo(
29
+ () => ({
30
+ onCtrlC: abort.onCtrlC,
31
+ onEsc: abort.onEsc,
32
+ onPaste: attachment.onPaste,
33
+ onNew: session.onNew,
34
+ onCycleModel: models.cycleModel,
35
+ onTogglePicker: toggles.onTogglePicker,
36
+ onToggleSessionPicker: toggles.onToggleSessionPicker,
37
+ onScrollUp,
38
+ onScrollDown,
39
+ modelCount: models.models.length,
40
+ }),
41
+ [
42
+ abort.onCtrlC,
43
+ abort.onEsc,
44
+ attachment.onPaste,
45
+ session.onNew,
46
+ models.cycleModel,
47
+ models.models.length,
48
+ toggles.onTogglePicker,
49
+ toggles.onToggleSessionPicker,
50
+ onScrollUp,
51
+ onScrollDown,
52
+ ],
53
+ );
54
+
55
+ const commandContext = useMemo(
56
+ () => ({ messages: session.messages, cwd: process.cwd(), config }),
57
+ [session.messages, config],
58
+ );
59
+
60
+ // `registry.getCommands()` allocates a fresh array each call; cache by
61
+ // registry identity so `useCommandExecutor`'s memo can hit.
62
+ const pluginCommands = useMemo(() => registry.getCommands(), [registry]);
63
+
64
+ const commandExecutor = useCommandExecutor({
65
+ actions,
66
+ context: commandContext,
67
+ pluginCommands,
68
+ });
69
+
70
+ const input = useInputHandler({
71
+ isActive,
72
+ streaming: session.streaming,
73
+ history,
74
+ actions,
75
+ onSubmit,
76
+ availableCommands: commandExecutor.commands,
77
+ onCommand: commandExecutor.execute,
78
+ });
79
+
80
+ return {
81
+ ...input,
82
+ streaming: session.streaming,
83
+ isActive,
84
+ model,
85
+ attachmentName: attachment.attachment?.name ?? null,
86
+ attachmentError: attachment.attachmentError,
87
+ };
88
+ }
@@ -1,7 +1,7 @@
1
1
  import { type Key, useInput, useStdin } from 'ink';
2
- import type { SlashCommand as PluginSlashCommand } from 'mu-agents';
3
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
- import { matchCommands, type SlashCommand } from '../commands';
3
+ import { matchCommands, type SlashCommand } from './commands';
4
+ import { isMouseSequence, sanitizeTerminalInput } from './sanitize';
5
5
 
6
6
  const BACKSPACE_BYTES = new Set(['\x7f', '\x08']);
7
7
 
@@ -31,7 +31,8 @@ interface UseInputHandlerOptions {
31
31
  history: string[];
32
32
  actions: InputActions;
33
33
  onSubmit: (text: string) => void;
34
- pluginCommands?: PluginSlashCommand[];
34
+ availableCommands: SlashCommand[];
35
+ onCommand: (command: SlashCommand, args: string) => void;
35
36
  }
36
37
 
37
38
  // Build a stable key identifier from an Ink key event
@@ -124,12 +125,6 @@ function useRawBackspace(isActive: boolean, setValue: (fn: (p: string) => string
124
125
  return handledRef;
125
126
  }
126
127
 
127
- const COMMAND_ACTIONS: Record<string, keyof InputActions> = {
128
- model: 'onTogglePicker',
129
- sessions: 'onToggleSessionPicker',
130
- new: 'onNew',
131
- };
132
-
133
128
  interface BindingCtx {
134
129
  value: string;
135
130
  setValue: React.Dispatch<React.SetStateAction<string>>;
@@ -195,31 +190,25 @@ function handleBackspace(c: BindingCtx, alreadyHandled: boolean) {
195
190
  }
196
191
 
197
192
  function handleInsert(input: string, c: BindingCtx) {
198
- if (input && input.length === 1) {
199
- c.setValue((p) => p + input);
200
- c.nav.reset();
193
+ if (!input) {
194
+ return;
201
195
  }
202
- }
203
-
204
- function executeCommand(cmd: SlashCommand, args: string, actions: InputActions): void {
205
- if (cmd.execute) {
206
- cmd.execute(args);
207
- } else if (cmd.action) {
208
- const actionKey = COMMAND_ACTIONS[cmd.action];
209
- if (actionKey) {
210
- (actions[actionKey] as (() => void) | undefined)?.();
211
- }
196
+ const sanitized = sanitizeTerminalInput(input);
197
+ if (!sanitized) {
198
+ return;
212
199
  }
200
+ c.setValue((p) => p + sanitized);
201
+ c.nav.reset();
213
202
  }
214
203
 
215
204
  export function useInputHandler(options: UseInputHandlerOptions): InputState {
216
- const { isActive, streaming, history, actions, onSubmit, pluginCommands } = options;
205
+ const { isActive, streaming, history, actions, onSubmit, availableCommands, onCommand } = options;
217
206
  const [value, setValue] = useState('');
218
207
  const [cmdIndex, setCmdIndex] = useState(0);
219
208
  const nav = useHistoryNavigation(value, history);
220
209
  const backspaceHandledRef = useRawBackspace(isActive, setValue);
221
210
 
222
- const commands = useMemo(() => matchCommands(value.trim(), pluginCommands), [value, pluginCommands]);
211
+ const commands = useMemo(() => matchCommands(value.trim(), availableCommands), [value, availableCommands]);
223
212
  const isCommandMode = commands.length > 0 && value.trim().startsWith('/');
224
213
 
225
214
  const submit = useCallback(() => {
@@ -231,7 +220,7 @@ export function useInputHandler(options: UseInputHandlerOptions): InputState {
231
220
  if (cmd) {
232
221
  const args = value.trim().slice(cmd.name.length).trim();
233
222
  setValue('');
234
- executeCommand(cmd, args, actions);
223
+ onCommand(cmd, args);
235
224
  }
236
225
  return;
237
226
  }
@@ -241,10 +230,16 @@ export function useInputHandler(options: UseInputHandlerOptions): InputState {
241
230
  onSubmit(value);
242
231
  setValue('');
243
232
  nav.reset();
244
- }, [streaming, isCommandMode, commands, cmdIndex, value, actions, onSubmit, nav]);
233
+ }, [streaming, isCommandMode, commands, cmdIndex, value, onCommand, onSubmit, nav]);
245
234
 
246
235
  useInput(
247
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
+
248
243
  const alreadyHandled = backspaceHandledRef.current;
249
244
  backspaceHandledRef.current = false;
250
245