mu-coding 0.4.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -5
- package/bin/mu.js +1 -1
- package/package.json +17 -4
- package/prompts/SYSTEM.md +16 -0
- package/src/app/shutdown.ts +94 -0
- package/src/app/startApp.ts +43 -0
- package/src/cli/args.ts +131 -0
- package/src/{install.ts → cli/install.ts} +19 -15
- package/src/config/index.test.ts +77 -0
- package/src/config/index.ts +199 -0
- package/src/main.ts +4 -0
- package/src/plugin.ts +96 -0
- package/src/runtime/codingTools/bash.ts +114 -0
- package/src/runtime/codingTools/edit-file.ts +60 -0
- package/src/runtime/codingTools/index.ts +39 -0
- package/src/runtime/codingTools/read-file.ts +83 -0
- package/src/runtime/codingTools/utils.ts +21 -0
- package/src/runtime/codingTools/write-file.ts +42 -0
- package/src/runtime/createRegistry.test.ts +146 -0
- package/src/runtime/createRegistry.ts +163 -0
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +122 -0
- package/src/sessions/index.test.ts +66 -0
- package/src/sessions/index.ts +183 -0
- package/src/sessions/peek.test.ts +88 -0
- package/src/sessions/project.ts +51 -0
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +49 -0
- package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- 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/chat/useChat.ts +106 -0
- package/src/tui/chat/useChatPanel.ts +98 -0
- package/src/tui/chat/useChatSession.ts +284 -0
- package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
- package/src/tui/chat/usePluginStatus.ts +44 -0
- package/src/tui/chat/useSessionPersistence.ts +68 -0
- package/src/tui/chat/useStatusSegments.ts +62 -0
- package/src/tui/components/chat/ChatPanel.tsx +20 -40
- package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
- package/src/tui/components/chat/Pickers.tsx +2 -2
- package/src/tui/components/messageView.tsx +72 -0
- package/src/tui/components/messages/EditOutput.tsx +47 -30
- package/src/tui/components/messages/ReadOutput.tsx +27 -22
- package/src/tui/components/messages/ToolHeader.tsx +28 -0
- package/src/tui/components/messages/WriteOutput.tsx +12 -24
- package/src/tui/components/messages/assistantMessage.tsx +17 -2
- package/src/tui/components/messages/messageItem.tsx +23 -16
- package/src/tui/components/messages/reasoningBlock.tsx +4 -2
- package/src/tui/components/messages/streamingOutput.tsx +5 -1
- package/src/tui/components/messages/toolCallBlock.tsx +61 -38
- package/src/tui/components/messages/userMessage.tsx +21 -6
- package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
- package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
- package/src/tui/components/primitives/pickerModal.tsx +47 -0
- package/src/tui/components/primitives/scrollbar.tsx +27 -0
- package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
- package/src/tui/components/statusBar.tsx +32 -0
- package/src/tui/components/ui/dialogLayer.tsx +32 -13
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/hooks/useScroll.ts +11 -3
- package/src/tui/input/InputBox.tsx +6 -0
- package/src/tui/input/InputBoxView.tsx +237 -0
- package/src/tui/input/commands.test.ts +51 -0
- package/src/tui/input/commands.ts +44 -0
- package/src/tui/input/cursor.test.ts +136 -0
- package/src/tui/input/cursor.ts +214 -0
- package/src/tui/input/dumpContext.ts +107 -0
- package/src/tui/input/sanitize.ts +33 -0
- package/src/tui/input/useCommandExecutor.ts +32 -0
- package/src/tui/input/useInputBox.ts +207 -0
- package/src/tui/input/useInputHandler.ts +453 -0
- package/src/tui/input/useMentionPicker.ts +121 -0
- package/src/tui/input/usePluginShortcuts.ts +29 -0
- package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
- package/src/tui/plugins/InkApprovalChannel.ts +30 -0
- package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
- package/src/tui/renderApp.tsx +43 -0
- package/src/tui/theme/index.ts +1 -0
- package/src/tui/theme/merge.test.ts +49 -0
- package/src/tui/theme/merge.ts +43 -0
- package/src/tui/theme/presets.ts +79 -0
- package/src/tui/theme/types.ts +116 -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/components/inputBox.tsx +0 -153
- package/src/tui/hooks/useInputHandler.ts +0 -268
- package/src/tui/useChat.ts +0 -52
- 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/{diff.ts → utils/diff.ts} +0 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { Box, Text, useInput } from 'ink';
|
|
2
2
|
import { useCallback, useEffect, useState } from 'react';
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
4
|
+
import { sanitizeTerminalInput } from '../../input/sanitize';
|
|
5
|
+
import type { DialogRequest, InkUIService } from '../../plugins/InkUIService';
|
|
6
|
+
import { Dropdown } from '../primitives/dropdown';
|
|
7
|
+
import { Modal } from '../primitives/modal';
|
|
6
8
|
|
|
7
9
|
// ─── Confirm Dialog ───────────────────────────────────────────────────────────
|
|
8
10
|
|
|
@@ -15,6 +17,7 @@ function ConfirmDialog({
|
|
|
15
17
|
onResolve: (value: unknown) => void;
|
|
16
18
|
onCancel: () => void;
|
|
17
19
|
}) {
|
|
20
|
+
const theme = useTheme();
|
|
18
21
|
const [selected, setSelected] = useState(0);
|
|
19
22
|
|
|
20
23
|
useInput((input, key) => {
|
|
@@ -41,15 +44,15 @@ function ConfirmDialog({
|
|
|
41
44
|
</Box>
|
|
42
45
|
)}
|
|
43
46
|
<Box gap={2}>
|
|
44
|
-
<Text color={selected === 0 ?
|
|
47
|
+
<Text color={selected === 0 ? theme.dialog.confirmYes : undefined} bold={selected === 0}>
|
|
45
48
|
{selected === 0 ? '▸ ' : ' '}Yes
|
|
46
49
|
</Text>
|
|
47
|
-
<Text color={selected === 1 ?
|
|
50
|
+
<Text color={selected === 1 ? theme.dialog.confirmNo : undefined} bold={selected === 1}>
|
|
48
51
|
{selected === 1 ? '▸ ' : ' '}No
|
|
49
52
|
</Text>
|
|
50
53
|
</Box>
|
|
51
54
|
<Box marginTop={1}>
|
|
52
|
-
<Text
|
|
55
|
+
<Text color={theme.dialog.hint}>y/n · Enter to confirm · Esc to cancel</Text>
|
|
53
56
|
</Box>
|
|
54
57
|
</Modal>
|
|
55
58
|
);
|
|
@@ -80,6 +83,12 @@ function SelectDialog({
|
|
|
80
83
|
|
|
81
84
|
// ─── Input Dialog ─────────────────────────────────────────────────────────────
|
|
82
85
|
|
|
86
|
+
function sanitizeDialogInput(text: string): string {
|
|
87
|
+
// Strip mouse sequences + control bytes via the shared helper, then drop
|
|
88
|
+
// \t/\n that the shared helper preserves — this dialog is single-line.
|
|
89
|
+
return sanitizeTerminalInput(text).replace(/[\t\n]/g, '');
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
function InputDialog({
|
|
84
93
|
dialog,
|
|
85
94
|
onResolve,
|
|
@@ -89,17 +98,25 @@ function InputDialog({
|
|
|
89
98
|
onResolve: (value: unknown) => void;
|
|
90
99
|
onCancel: () => void;
|
|
91
100
|
}) {
|
|
101
|
+
const theme = useTheme();
|
|
92
102
|
const [value, setValue] = useState('');
|
|
93
103
|
|
|
94
104
|
useInput((input, key) => {
|
|
95
105
|
if (key.escape) {
|
|
96
106
|
onCancel();
|
|
97
|
-
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (key.return) {
|
|
98
110
|
onResolve(value || null);
|
|
99
|
-
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (key.backspace || key.delete) {
|
|
100
114
|
setValue((v) => v.slice(0, -1));
|
|
101
|
-
|
|
102
|
-
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const insert = sanitizeDialogInput(input);
|
|
118
|
+
if (insert) {
|
|
119
|
+
setValue((v) => v + insert);
|
|
103
120
|
}
|
|
104
121
|
});
|
|
105
122
|
|
|
@@ -107,12 +124,14 @@ function InputDialog({
|
|
|
107
124
|
<Modal visible={true} title={dialog.title}>
|
|
108
125
|
<Box flexDirection="column">
|
|
109
126
|
<Box paddingX={1} marginBottom={1}>
|
|
110
|
-
{!value && dialog.placeholder && <Text
|
|
127
|
+
{!value && dialog.placeholder && <Text color={theme.dialog.placeholder}>{dialog.placeholder}</Text>}
|
|
111
128
|
{value && <Text>{value}</Text>}
|
|
112
|
-
<Text inverse={true}
|
|
129
|
+
<Text color={theme.dialog.cursor} inverse={true}>
|
|
130
|
+
▎
|
|
131
|
+
</Text>
|
|
113
132
|
</Box>
|
|
114
133
|
<Box>
|
|
115
|
-
<Text
|
|
134
|
+
<Text color={theme.dialog.hint}>Enter to submit · Esc to cancel</Text>
|
|
116
135
|
</Box>
|
|
117
136
|
</Box>
|
|
118
137
|
</Modal>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext } from 'react';
|
|
2
|
+
import { DEFAULT_THEME } from '../theme/presets';
|
|
3
|
+
import type { Theme } from '../theme/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Theme is read in many small components, so a dedicated context keeps
|
|
7
|
+
* `ChatContext` focused on session/runtime state and avoids re-renders
|
|
8
|
+
* cascading through unrelated subtrees when chat state changes.
|
|
9
|
+
*/
|
|
10
|
+
const ThemeContext = createContext<Theme>(DEFAULT_THEME);
|
|
11
|
+
|
|
12
|
+
export function ThemeProvider({ theme, children }: { theme: Theme; children: ReactNode }) {
|
|
13
|
+
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function useTheme(): Theme {
|
|
17
|
+
return useContext(ThemeContext);
|
|
18
|
+
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import type { MentionCompletion } from 'mu-core';
|
|
3
|
+
import { useTheme } from '../context/ThemeContext';
|
|
4
|
+
import type { Theme } from '../theme/types';
|
|
5
|
+
import type { SlashCommand } from './commands';
|
|
6
|
+
|
|
7
|
+
interface MentionPickerView {
|
|
8
|
+
completions: MentionCompletion[];
|
|
9
|
+
selectedIndex: number;
|
|
10
|
+
partial: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface InputBoxViewProps {
|
|
14
|
+
value: string;
|
|
15
|
+
cursor: number;
|
|
16
|
+
commands: SlashCommand[];
|
|
17
|
+
cmdIndex: number;
|
|
18
|
+
isCommandMode: boolean;
|
|
19
|
+
streaming: boolean;
|
|
20
|
+
isActive: boolean;
|
|
21
|
+
model: string;
|
|
22
|
+
attachmentName: string | null;
|
|
23
|
+
attachmentError: string | null;
|
|
24
|
+
mentions: MentionPickerView | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function CommandHints({
|
|
28
|
+
commands,
|
|
29
|
+
selectedIndex,
|
|
30
|
+
theme,
|
|
31
|
+
}: {
|
|
32
|
+
commands: SlashCommand[];
|
|
33
|
+
selectedIndex: number;
|
|
34
|
+
theme: Theme;
|
|
35
|
+
}) {
|
|
36
|
+
if (!commands.length) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return (
|
|
40
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
41
|
+
{commands.map((cmd, i) => (
|
|
42
|
+
<Box key={cmd.name} paddingX={1}>
|
|
43
|
+
<Text color={i === selectedIndex ? theme.input.commandHighlight : undefined} bold={i === selectedIndex}>
|
|
44
|
+
{i === selectedIndex ? '▸ ' : ' '}
|
|
45
|
+
{cmd.name}
|
|
46
|
+
</Text>
|
|
47
|
+
<Text dimColor={true}> {cmd.description}</Text>
|
|
48
|
+
</Box>
|
|
49
|
+
))}
|
|
50
|
+
</Box>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function MentionHints({ mentions, theme }: { mentions: MentionPickerView; theme: Theme }) {
|
|
55
|
+
if (!mentions.completions.length) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return (
|
|
59
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
60
|
+
{mentions.completions.map((c, i) => (
|
|
61
|
+
<Box key={c.value} paddingX={1}>
|
|
62
|
+
<Text
|
|
63
|
+
color={i === mentions.selectedIndex ? theme.input.commandHighlight : undefined}
|
|
64
|
+
bold={i === mentions.selectedIndex}
|
|
65
|
+
>
|
|
66
|
+
{i === mentions.selectedIndex ? '▸ @' : ' @'}
|
|
67
|
+
{c.label ?? c.value}
|
|
68
|
+
</Text>
|
|
69
|
+
{c.description && <Text dimColor={true}> {c.description}</Text>}
|
|
70
|
+
</Box>
|
|
71
|
+
))}
|
|
72
|
+
</Box>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function InputFooter({
|
|
77
|
+
model,
|
|
78
|
+
attachmentName,
|
|
79
|
+
attachmentError,
|
|
80
|
+
hasContent,
|
|
81
|
+
isCommandMode,
|
|
82
|
+
hasMentions,
|
|
83
|
+
theme,
|
|
84
|
+
}: {
|
|
85
|
+
model: string;
|
|
86
|
+
attachmentName: string | null;
|
|
87
|
+
attachmentError: string | null;
|
|
88
|
+
hasContent: boolean;
|
|
89
|
+
isCommandMode: boolean;
|
|
90
|
+
hasMentions: boolean;
|
|
91
|
+
theme: Theme;
|
|
92
|
+
}) {
|
|
93
|
+
const hint = hasMentions
|
|
94
|
+
? '↑↓ select · Tab/Enter accept'
|
|
95
|
+
: hasContent
|
|
96
|
+
? isCommandMode
|
|
97
|
+
? '↑↓ select · Enter execute'
|
|
98
|
+
: 'Enter to send · Shift+Enter for newline · ←→ move'
|
|
99
|
+
: 'Type / for commands · @ for mentions';
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Box justifyContent="space-between">
|
|
103
|
+
<Box gap={1}>
|
|
104
|
+
{model && (
|
|
105
|
+
<Text color={theme.input.modelLabel} bold={true}>
|
|
106
|
+
{model}
|
|
107
|
+
</Text>
|
|
108
|
+
)}
|
|
109
|
+
{attachmentName && <Text color={theme.input.attachmentName}>📷 {attachmentName}</Text>}
|
|
110
|
+
{attachmentError && <Text color={theme.input.attachmentError}>{attachmentError}</Text>}
|
|
111
|
+
</Box>
|
|
112
|
+
<Text color={theme.input.footerHint}>{hint}</Text>
|
|
113
|
+
</Box>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface RowProps {
|
|
118
|
+
line: string;
|
|
119
|
+
cursorCol: number | null;
|
|
120
|
+
isCommandLine: boolean;
|
|
121
|
+
theme: Theme;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render one buffer row, splicing the cursor glyph at `cursorCol` if the
|
|
126
|
+
* cursor lives on this row. Splitting around the cursor (rather than rendering
|
|
127
|
+
* the whole line and overlaying) keeps the layout flowing inside Ink's text
|
|
128
|
+
* wrapping engine.
|
|
129
|
+
*/
|
|
130
|
+
function InputRow({ line, cursorCol, isCommandLine, theme }: RowProps) {
|
|
131
|
+
const colorize = (text: string) => (isCommandLine ? <Text color={theme.input.commandHighlight}>{text}</Text> : text);
|
|
132
|
+
|
|
133
|
+
if (cursorCol === null) {
|
|
134
|
+
return <Text wrap="wrap">{colorize(line)}</Text>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const before = line.slice(0, cursorCol);
|
|
138
|
+
const after = line.slice(cursorCol);
|
|
139
|
+
return (
|
|
140
|
+
<Text wrap="wrap">
|
|
141
|
+
{colorize(before)}
|
|
142
|
+
<Text color={theme.input.cursor} inverse={true}>
|
|
143
|
+
▎
|
|
144
|
+
</Text>
|
|
145
|
+
{colorize(after)}
|
|
146
|
+
</Text>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface DisplayProps {
|
|
151
|
+
value: string;
|
|
152
|
+
cursor: number;
|
|
153
|
+
isCommandMode: boolean;
|
|
154
|
+
streaming: boolean;
|
|
155
|
+
isActive: boolean;
|
|
156
|
+
theme: Theme;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function InputDisplay({ value, cursor, isCommandMode, streaming, isActive, theme }: DisplayProps) {
|
|
160
|
+
const showCursor = !streaming && isActive;
|
|
161
|
+
if (!value.length) {
|
|
162
|
+
return (
|
|
163
|
+
<Text>
|
|
164
|
+
{showCursor && (
|
|
165
|
+
<Text color={theme.input.cursor} inverse={true}>
|
|
166
|
+
▎
|
|
167
|
+
</Text>
|
|
168
|
+
)}
|
|
169
|
+
</Text>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const lines = value.split('\n');
|
|
173
|
+
// Locate cursor row/col by walking newline offsets.
|
|
174
|
+
let row = 0;
|
|
175
|
+
let consumed = 0;
|
|
176
|
+
for (let i = 0; i < lines.length; i++) {
|
|
177
|
+
const lineEnd = consumed + lines[i].length;
|
|
178
|
+
if (cursor <= lineEnd) {
|
|
179
|
+
row = i;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
consumed = lineEnd + 1; // +1 for the newline
|
|
183
|
+
row = i + 1;
|
|
184
|
+
}
|
|
185
|
+
const col = cursor - consumed;
|
|
186
|
+
return (
|
|
187
|
+
<>
|
|
188
|
+
{lines.map((line, i) => (
|
|
189
|
+
<InputRow
|
|
190
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: static input display lines
|
|
191
|
+
key={`${i}-${line}`}
|
|
192
|
+
line={line}
|
|
193
|
+
cursorCol={showCursor && i === row ? col : null}
|
|
194
|
+
isCommandLine={i === 0 && isCommandMode}
|
|
195
|
+
theme={theme}
|
|
196
|
+
/>
|
|
197
|
+
))}
|
|
198
|
+
</>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function InputBoxView(props: InputBoxViewProps) {
|
|
203
|
+
const theme = useTheme();
|
|
204
|
+
return (
|
|
205
|
+
<Box
|
|
206
|
+
flexDirection="column"
|
|
207
|
+
flexShrink={0}
|
|
208
|
+
backgroundColor={theme.input.background}
|
|
209
|
+
paddingX={1}
|
|
210
|
+
paddingY={1}
|
|
211
|
+
marginX={1}
|
|
212
|
+
marginTop={1}
|
|
213
|
+
>
|
|
214
|
+
{props.isCommandMode && <CommandHints commands={props.commands} selectedIndex={props.cmdIndex} theme={theme} />}
|
|
215
|
+
{props.mentions && <MentionHints mentions={props.mentions} theme={theme} />}
|
|
216
|
+
<Box flexDirection="column" minHeight={2}>
|
|
217
|
+
<InputDisplay
|
|
218
|
+
value={props.value}
|
|
219
|
+
cursor={props.cursor}
|
|
220
|
+
isCommandMode={props.isCommandMode}
|
|
221
|
+
streaming={props.streaming}
|
|
222
|
+
isActive={props.isActive}
|
|
223
|
+
theme={theme}
|
|
224
|
+
/>
|
|
225
|
+
</Box>
|
|
226
|
+
<InputFooter
|
|
227
|
+
model={props.model}
|
|
228
|
+
attachmentName={props.attachmentName}
|
|
229
|
+
attachmentError={props.attachmentError}
|
|
230
|
+
hasContent={props.value.length > 0}
|
|
231
|
+
isCommandMode={props.isCommandMode}
|
|
232
|
+
hasMentions={Boolean(props.mentions)}
|
|
233
|
+
theme={theme}
|
|
234
|
+
/>
|
|
235
|
+
</Box>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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 onShowContext = mock(() => undefined);
|
|
27
|
+
const actions: InputActions = { onTogglePicker, onToggleSessionPicker, onNew, onShowContext };
|
|
28
|
+
|
|
29
|
+
for (const cmd of BUILTIN_COMMANDS) {
|
|
30
|
+
cmd.invoke?.(actions);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
expect(onTogglePicker).toHaveBeenCalledTimes(1);
|
|
34
|
+
expect(onToggleSessionPicker).toHaveBeenCalledTimes(1);
|
|
35
|
+
expect(onNew).toHaveBeenCalledTimes(1);
|
|
36
|
+
expect(onShowContext).toHaveBeenCalledTimes(1);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('fromPluginCommand', () => {
|
|
41
|
+
it('prepends a slash and forwards args/context to execute', async () => {
|
|
42
|
+
const execute = mock(async () => 'ok');
|
|
43
|
+
const wrapped = fromPluginCommand(
|
|
44
|
+
{ name: 'foo', description: 'plugin', execute },
|
|
45
|
+
{ messages: [], cwd: '/tmp', config: { baseUrl: '', maxTokens: 0, temperature: 0, streamTimeoutMs: 0 } },
|
|
46
|
+
);
|
|
47
|
+
expect(wrapped.name).toBe('/foo');
|
|
48
|
+
await wrapped.execute?.('hello');
|
|
49
|
+
expect(execute).toHaveBeenCalledWith('hello', expect.any(Object));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CommandContext, SlashCommand as PluginSlashCommand } from 'mu-core';
|
|
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
|
+
name: '/context',
|
|
25
|
+
description: 'Show the LLM context (system prompt, messages, tools) as plain text',
|
|
26
|
+
invoke: (a) => a.onShowContext?.(),
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function fromPluginCommand(command: PluginSlashCommand, context: CommandContext): SlashCommand {
|
|
31
|
+
return {
|
|
32
|
+
name: `/${command.name}`,
|
|
33
|
+
description: command.description,
|
|
34
|
+
execute: (args: string) => command.execute(args, context),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function matchCommands(input: string, commands: SlashCommand[]): SlashCommand[] {
|
|
39
|
+
if (!input.startsWith('/')) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
const q = input.toLowerCase();
|
|
43
|
+
return commands.filter((cmd) => cmd.name.startsWith(q));
|
|
44
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
cursorRowCol,
|
|
4
|
+
deleteBackward,
|
|
5
|
+
deleteForward,
|
|
6
|
+
deleteWordBackward,
|
|
7
|
+
insertAt,
|
|
8
|
+
killToLineEnd,
|
|
9
|
+
killToLineStart,
|
|
10
|
+
moveLeft,
|
|
11
|
+
moveLineDown,
|
|
12
|
+
moveLineEnd,
|
|
13
|
+
moveLineHome,
|
|
14
|
+
moveLineUp,
|
|
15
|
+
moveRight,
|
|
16
|
+
moveWordLeft,
|
|
17
|
+
moveWordRight,
|
|
18
|
+
positionAt,
|
|
19
|
+
} from './cursor';
|
|
20
|
+
|
|
21
|
+
const s = (value: string, cursor: number) => ({ value, cursor });
|
|
22
|
+
|
|
23
|
+
describe('insertAt', () => {
|
|
24
|
+
it('inserts at the cursor and advances it', () => {
|
|
25
|
+
expect(insertAt(s('hello', 5), '!')).toEqual({ value: 'hello!', cursor: 6 });
|
|
26
|
+
expect(insertAt(s('helo', 2), 'l')).toEqual({ value: 'hello', cursor: 3 });
|
|
27
|
+
});
|
|
28
|
+
it('is a no-op for empty text', () => {
|
|
29
|
+
expect(insertAt(s('a', 1), '')).toEqual({ value: 'a', cursor: 1 });
|
|
30
|
+
});
|
|
31
|
+
it('clamps an out-of-range cursor', () => {
|
|
32
|
+
expect(insertAt(s('abc', 99), 'x')).toEqual({ value: 'abcx', cursor: 4 });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('deleteBackward', () => {
|
|
37
|
+
it('removes the char left of the cursor', () => {
|
|
38
|
+
expect(deleteBackward(s('hello', 5))).toEqual({ value: 'hell', cursor: 4 });
|
|
39
|
+
expect(deleteBackward(s('hello', 1))).toEqual({ value: 'ello', cursor: 0 });
|
|
40
|
+
});
|
|
41
|
+
it('is a no-op at position 0', () => {
|
|
42
|
+
expect(deleteBackward(s('hello', 0))).toEqual({ value: 'hello', cursor: 0 });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('deleteForward', () => {
|
|
47
|
+
it('removes the char at the cursor', () => {
|
|
48
|
+
expect(deleteForward(s('hello', 0))).toEqual({ value: 'ello', cursor: 0 });
|
|
49
|
+
expect(deleteForward(s('hello', 4))).toEqual({ value: 'hell', cursor: 4 });
|
|
50
|
+
});
|
|
51
|
+
it('is a no-op at end of buffer', () => {
|
|
52
|
+
expect(deleteForward(s('hello', 5))).toEqual({ value: 'hello', cursor: 5 });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('deleteWordBackward', () => {
|
|
57
|
+
it('eats the previous word', () => {
|
|
58
|
+
expect(deleteWordBackward(s('hello world', 11))).toEqual({ value: 'hello ', cursor: 6 });
|
|
59
|
+
expect(deleteWordBackward(s('hello world', 13))).toEqual({ value: 'hello ', cursor: 8 });
|
|
60
|
+
});
|
|
61
|
+
it('eats trailing whitespace before the word', () => {
|
|
62
|
+
expect(deleteWordBackward(s('foo bar ', 9))).toEqual({ value: 'foo ', cursor: 4 });
|
|
63
|
+
});
|
|
64
|
+
it('is a no-op at start', () => {
|
|
65
|
+
expect(deleteWordBackward(s('foo', 0))).toEqual({ value: 'foo', cursor: 0 });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('killToLineEnd / killToLineStart', () => {
|
|
70
|
+
it('kills from cursor to end of line', () => {
|
|
71
|
+
expect(killToLineEnd(s('hello\nworld', 3))).toEqual({ value: 'hel\nworld', cursor: 3 });
|
|
72
|
+
});
|
|
73
|
+
it('eats the newline when cursor sits at line end', () => {
|
|
74
|
+
expect(killToLineEnd(s('hello\nworld', 5))).toEqual({ value: 'helloworld', cursor: 5 });
|
|
75
|
+
});
|
|
76
|
+
it('kills from start of line to cursor', () => {
|
|
77
|
+
expect(killToLineStart(s('hello\nworld', 3))).toEqual({ value: 'lo\nworld', cursor: 0 });
|
|
78
|
+
expect(killToLineStart(s('hello\nworld', 8))).toEqual({ value: 'hello\nrld', cursor: 6 });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('horizontal movement', () => {
|
|
83
|
+
it('moveLeft / moveRight respect bounds', () => {
|
|
84
|
+
expect(moveLeft(s('abc', 0))).toEqual({ value: 'abc', cursor: 0 });
|
|
85
|
+
expect(moveLeft(s('abc', 2))).toEqual({ value: 'abc', cursor: 1 });
|
|
86
|
+
expect(moveRight(s('abc', 3))).toEqual({ value: 'abc', cursor: 3 });
|
|
87
|
+
expect(moveRight(s('abc', 1))).toEqual({ value: 'abc', cursor: 2 });
|
|
88
|
+
});
|
|
89
|
+
it('moveWordLeft / moveWordRight jump across word boundaries', () => {
|
|
90
|
+
expect(moveWordLeft(s('foo bar baz', 11))).toEqual({ value: 'foo bar baz', cursor: 8 });
|
|
91
|
+
expect(moveWordLeft(s('foo bar baz', 8))).toEqual({ value: 'foo bar baz', cursor: 4 });
|
|
92
|
+
expect(moveWordRight(s('foo bar baz', 0))).toEqual({ value: 'foo bar baz', cursor: 3 });
|
|
93
|
+
expect(moveWordRight(s('foo bar baz', 3))).toEqual({ value: 'foo bar baz', cursor: 7 });
|
|
94
|
+
});
|
|
95
|
+
it('Home/End operate on the current line', () => {
|
|
96
|
+
expect(moveLineHome(s('hello\nworld', 8))).toEqual({ value: 'hello\nworld', cursor: 6 });
|
|
97
|
+
expect(moveLineEnd(s('hello\nworld', 6))).toEqual({ value: 'hello\nworld', cursor: 11 });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('vertical movement', () => {
|
|
102
|
+
it('moveLineUp returns null on first line', () => {
|
|
103
|
+
expect(moveLineUp(s('one\ntwo', 1), null)).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
it('moveLineDown returns null on last line', () => {
|
|
106
|
+
expect(moveLineDown(s('one\ntwo', 5), null)).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
it('preserves desired column across short lines', () => {
|
|
109
|
+
// Cursor on line 0 col 5 → down to line 1 (3 chars) clamps to 3 → down again to line 2 (10 chars) restores 5.
|
|
110
|
+
const start = { value: 'hello\nfoo\nlonglineee', cursor: 5 };
|
|
111
|
+
const down1 = moveLineDown(start, 5);
|
|
112
|
+
expect(down1).toEqual({ value: start.value, cursor: 9 }); // end of "foo"
|
|
113
|
+
const down2 = moveLineDown(down1 as { value: string; cursor: number }, 5);
|
|
114
|
+
expect(down2).toEqual({ value: start.value, cursor: 15 }); // 10 + 5
|
|
115
|
+
});
|
|
116
|
+
it('round-trips up then down to the same offset when desired column is preserved', () => {
|
|
117
|
+
const state = { value: 'aaaa\nbbbb\ncccc', cursor: 12 }; // line 2, col 2
|
|
118
|
+
const up = moveLineUp(state, 2);
|
|
119
|
+
expect(up).toEqual({ value: state.value, cursor: 7 });
|
|
120
|
+
const down = moveLineDown(up as { value: string; cursor: number }, 2);
|
|
121
|
+
expect(down).toEqual({ value: state.value, cursor: 12 });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('cursorRowCol / positionAt', () => {
|
|
126
|
+
it('reports row and col correctly', () => {
|
|
127
|
+
expect(cursorRowCol('hello\nworld', 0)).toEqual({ row: 0, col: 0 });
|
|
128
|
+
expect(cursorRowCol('hello\nworld', 5)).toEqual({ row: 0, col: 5 });
|
|
129
|
+
expect(cursorRowCol('hello\nworld', 6)).toEqual({ row: 1, col: 0 });
|
|
130
|
+
expect(cursorRowCol('hello\nworld', 11)).toEqual({ row: 1, col: 5 });
|
|
131
|
+
});
|
|
132
|
+
it('positionAt clamps to line length', () => {
|
|
133
|
+
expect(positionAt('hello\nfoo', 1, 99)).toBe(9);
|
|
134
|
+
expect(positionAt('hello\nfoo', 0, 2)).toBe(2);
|
|
135
|
+
});
|
|
136
|
+
});
|