mu-coding 0.4.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.
- package/README.md +0 -2
- package/bin/mu.js +1 -1
- package/package.json +12 -4
- package/src/app/shutdown.ts +94 -0
- package/src/app/startApp.ts +40 -0
- package/src/cli/args.ts +128 -0
- package/src/{install.ts → cli/install.ts} +19 -15
- package/src/config/index.test.ts +51 -0
- package/src/config/index.ts +181 -0
- package/src/main.ts +4 -0
- package/src/runtime/createRegistry.ts +58 -0
- package/src/runtime/pluginLoader.ts +109 -0
- package/src/sessions/index.test.ts +66 -0
- package/src/sessions/index.ts +190 -0
- package/src/sessions/peek.test.ts +88 -0
- package/src/sessions/project.ts +51 -0
- package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
- package/src/tui/chat/ToolDisplayContext.ts +33 -0
- package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
- package/src/tui/chat/useAttachment.ts +74 -0
- package/src/tui/{useChat.ts → chat/useChat.ts} +32 -6
- package/src/tui/chat/useChatPanel.ts +96 -0
- package/src/tui/chat/useChatSession.ts +115 -0
- package/src/tui/{useModelList.ts → chat/useModels.ts} +10 -1
- package/src/tui/chat/usePluginStatus.ts +44 -0
- package/src/tui/chat/useSessionPersistence.ts +57 -0
- package/src/tui/chat/useStatusSegments.ts +49 -0
- package/src/tui/chat/useStreamConsumer.ts +118 -0
- package/src/tui/components/chat/ChatPanel.tsx +12 -38
- package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
- package/src/tui/components/chat/Pickers.tsx +2 -2
- package/src/tui/components/messageView.tsx +70 -0
- package/src/tui/components/messages/EditOutput.tsx +42 -27
- package/src/tui/components/messages/ReadOutput.tsx +27 -22
- package/src/tui/components/messages/ToolHeader.tsx +26 -0
- package/src/tui/components/messages/WriteOutput.tsx +12 -24
- package/src/tui/components/messages/messageItem.tsx +4 -15
- package/src/tui/components/messages/toolCallBlock.tsx +56 -34
- package/src/tui/components/{ui → primitives}/dropdown.tsx +32 -7
- package/src/tui/components/primitives/pickerModal.tsx +45 -0
- package/src/tui/components/primitives/scrollbar.tsx +27 -0
- package/src/tui/components/statusBar.tsx +25 -0
- package/src/tui/components/ui/dialogLayer.tsx +21 -7
- package/src/tui/hooks/useScroll.ts +11 -3
- package/src/tui/input/InputBox.tsx +6 -0
- package/src/tui/{components/inputBox.tsx → input/InputBoxView.tsx} +24 -49
- package/src/tui/input/commands.test.ts +49 -0
- package/src/tui/input/commands.ts +39 -0
- package/src/tui/input/sanitize.ts +33 -0
- package/src/tui/input/useCommandExecutor.ts +32 -0
- package/src/tui/input/useInputBox.ts +88 -0
- package/src/tui/{hooks → input}/useInputHandler.ts +21 -26
- package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
- package/src/tui/renderApp.tsx +30 -0
- package/src/utils/clipboard.ts +97 -0
- package/src/utils/diff.test.ts +56 -0
- package/src/cli.ts +0 -96
- package/src/clipboard.ts +0 -62
- package/src/config.ts +0 -116
- package/src/main.tsx +0 -147
- package/src/project.ts +0 -32
- package/src/session.ts +0 -95
- package/src/tui/commands.ts +0 -33
- package/src/tui/components/chatLayout.tsx +0 -192
- package/src/tui/useChatSession.ts +0 -155
- package/src/tui/useChatUI.ts +0 -51
- package/tsconfig.json +0 -10
- /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
- /package/src/tui/components/{ui → primitives}/modal.tsx +0 -0
- /package/src/tui/components/{ui → primitives}/toast.tsx +0 -0
- /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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
-
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (key.return) {
|
|
98
107
|
onResolve(value || null);
|
|
99
|
-
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (key.backspace || key.delete) {
|
|
100
111
|
setValue((v) => v.slice(0, -1));
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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[?
|
|
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
|
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
-
import type { SlashCommand } from '
|
|
3
|
-
import { useChatContext } from '../context/chat';
|
|
4
|
-
import { type InputActions, useInputHandler } from '../hooks/useInputHandler';
|
|
2
|
+
import type { SlashCommand } from './commands';
|
|
5
3
|
|
|
6
|
-
interface
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
|
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={
|
|
147
|
-
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 '
|
|
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
|
-
|
|
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
|
|
199
|
-
|
|
200
|
-
c.nav.reset();
|
|
193
|
+
if (!input) {
|
|
194
|
+
return;
|
|
201
195
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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,
|
|
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(),
|
|
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
|
-
|
|
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,
|
|
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
|
|