mu-coding 0.13.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.
- package/README.md +9 -123
- package/bin/coding-agent.ts +95 -0
- package/package.json +10 -21
- package/src/config.ts +122 -0
- package/src/harness.test.ts +159 -0
- package/src/main.ts +53 -3
- package/src/plugins.ts +49 -0
- package/src/systemPrompt.ts +22 -0
- package/src/ui/ChatApp.ts +959 -0
- package/src/ui/commands.ts +35 -0
- package/src/ui/editor.ts +166 -0
- package/src/ui/markdown.ts +363 -0
- package/src/ui/picker.ts +126 -0
- package/src/ui/status.ts +61 -0
- package/src/ui/theme.ts +241 -0
- package/src/ui/transcript.test.ts +121 -0
- package/src/ui/transcript.ts +399 -0
- package/tsconfig.json +8 -0
- package/bin/mu.js +0 -2
- package/prompts/SYSTEM.md +0 -16
- package/src/app/shutdown.ts +0 -94
- package/src/app/startApp.ts +0 -49
- package/src/cli/args.ts +0 -133
- package/src/cli/install.ts +0 -107
- package/src/cli/subcommands.ts +0 -29
- package/src/cli/update.ts +0 -205
- package/src/config/index.test.ts +0 -77
- package/src/config/index.ts +0 -199
- package/src/plugin.ts +0 -124
- package/src/runtime/codingTools/bash.ts +0 -114
- package/src/runtime/codingTools/edit-file.ts +0 -60
- package/src/runtime/codingTools/index.ts +0 -39
- package/src/runtime/codingTools/read-file.ts +0 -83
- package/src/runtime/codingTools/utils.ts +0 -21
- package/src/runtime/codingTools/write-file.ts +0 -42
- package/src/runtime/createRegistry.test.ts +0 -147
- package/src/runtime/createRegistry.ts +0 -195
- package/src/runtime/fileMentionProvider.ts +0 -117
- package/src/runtime/messageBus.test.ts +0 -62
- package/src/runtime/messageBus.ts +0 -78
- package/src/runtime/pluginLoader.ts +0 -153
- package/src/runtime/startupUpdateCheck.ts +0 -163
- package/src/runtime/updateCheck.ts +0 -136
- package/src/sessions/index.test.ts +0 -66
- package/src/sessions/index.ts +0 -183
- package/src/sessions/peek.test.ts +0 -88
- package/src/sessions/project.ts +0 -51
- package/src/tui/channel/tuiChannel.test.ts +0 -107
- package/src/tui/channel/tuiChannel.ts +0 -62
- package/src/tui/chat/ChatContext.ts +0 -10
- package/src/tui/chat/MessageRendererContext.ts +0 -44
- package/src/tui/chat/ToolDisplayContext.ts +0 -33
- package/src/tui/chat/useAbort.ts +0 -85
- package/src/tui/chat/useAttachment.ts +0 -74
- package/src/tui/chat/useChat.ts +0 -113
- package/src/tui/chat/useChatPanel.ts +0 -119
- package/src/tui/chat/useChatSession.ts +0 -382
- package/src/tui/chat/useModels.ts +0 -83
- package/src/tui/chat/usePluginStatus.ts +0 -44
- package/src/tui/chat/useSessionPersistence.ts +0 -84
- package/src/tui/chat/useStatusSegments.ts +0 -82
- package/src/tui/chat/useSubagentBrowser.ts +0 -133
- package/src/tui/components/chat/ChatPanel.tsx +0 -54
- package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
- package/src/tui/components/chat/Pickers.tsx +0 -44
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
- package/src/tui/components/messageView.tsx +0 -72
- package/src/tui/components/messages/EditOutput.tsx +0 -112
- package/src/tui/components/messages/ReadOutput.tsx +0 -48
- package/src/tui/components/messages/ToolHeader.tsx +0 -30
- package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
- package/src/tui/components/messages/WriteOutput.tsx +0 -64
- package/src/tui/components/messages/assistantMessage.tsx +0 -72
- package/src/tui/components/messages/markdown.tsx +0 -407
- package/src/tui/components/messages/messageItem.tsx +0 -43
- package/src/tui/components/messages/reasoningBlock.tsx +0 -18
- package/src/tui/components/messages/streamingOutput.tsx +0 -18
- package/src/tui/components/messages/toolCallBlock.tsx +0 -125
- package/src/tui/components/messages/userMessage.tsx +0 -44
- package/src/tui/components/primitives/dropdown.tsx +0 -125
- package/src/tui/components/primitives/modal.tsx +0 -47
- package/src/tui/components/primitives/pickerModal.tsx +0 -47
- package/src/tui/components/primitives/scrollbar.tsx +0 -27
- package/src/tui/components/primitives/toast.tsx +0 -100
- package/src/tui/components/statusBar.tsx +0 -41
- package/src/tui/components/ui/dialogLayer.tsx +0 -175
- package/src/tui/context/ThemeContext.tsx +0 -18
- package/src/tui/hooks/useChordKeyboard.ts +0 -87
- package/src/tui/hooks/useInputInfoSegments.ts +0 -22
- package/src/tui/hooks/useScroll.ts +0 -64
- package/src/tui/hooks/useTerminal.ts +0 -40
- package/src/tui/hooks/useUI.ts +0 -15
- package/src/tui/input/InputBox.tsx +0 -6
- package/src/tui/input/InputBoxView.tsx +0 -293
- package/src/tui/input/commands.test.ts +0 -71
- package/src/tui/input/commands.ts +0 -55
- package/src/tui/input/cursor.test.ts +0 -136
- package/src/tui/input/cursor.ts +0 -214
- package/src/tui/input/dumpContext.ts +0 -107
- package/src/tui/input/sanitize.ts +0 -33
- package/src/tui/input/useCommandExecutor.ts +0 -32
- package/src/tui/input/useInputBox.ts +0 -265
- package/src/tui/input/useInputHandler.ts +0 -455
- package/src/tui/input/useMentionPicker.ts +0 -133
- package/src/tui/input/usePluginShortcuts.ts +0 -29
- package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
- package/src/tui/plugins/InkApprovalChannel.ts +0 -30
- package/src/tui/plugins/InkUIService.ts +0 -188
- package/src/tui/renderApp.tsx +0 -64
- package/src/tui/theme/index.ts +0 -1
- package/src/tui/theme/merge.test.ts +0 -49
- package/src/tui/theme/merge.ts +0 -43
- package/src/tui/theme/presets.ts +0 -90
- package/src/tui/theme/types.ts +0 -138
- package/src/tui/update/runUpdateInTui.ts +0 -127
- package/src/utils/clipboard.ts +0 -97
- package/src/utils/diff.test.ts +0 -56
- package/src/utils/diff.ts +0 -81
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { useInput, useStdout } from 'ink';
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
-
|
|
4
|
-
const SCROLL_STEP = 3;
|
|
5
|
-
|
|
6
|
-
export function useScroll(contentHeight: number, viewHeight: number) {
|
|
7
|
-
const [scrollOffset, setScrollOffset] = useState(0);
|
|
8
|
-
const autoScrollRef = useRef(true);
|
|
9
|
-
const maxScroll = Math.max(0, contentHeight - viewHeight);
|
|
10
|
-
|
|
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.
|
|
20
|
-
const { stdout } = useStdout();
|
|
21
|
-
useEffect(() => {
|
|
22
|
-
stdout.write('\x1b[?1000h\x1b[?1006h');
|
|
23
|
-
return () => {
|
|
24
|
-
stdout.write('\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l');
|
|
25
|
-
};
|
|
26
|
-
}, [stdout]);
|
|
27
|
-
|
|
28
|
-
useEffect(() => {
|
|
29
|
-
if (autoScrollRef.current && contentHeight > viewHeight) {
|
|
30
|
-
setScrollOffset(contentHeight - viewHeight);
|
|
31
|
-
}
|
|
32
|
-
}, [contentHeight, viewHeight]);
|
|
33
|
-
|
|
34
|
-
const scrollUp = useCallback(() => {
|
|
35
|
-
autoScrollRef.current = false;
|
|
36
|
-
setScrollOffset((o) => Math.max(0, o - SCROLL_STEP));
|
|
37
|
-
}, []);
|
|
38
|
-
|
|
39
|
-
const scrollDown = useCallback(() => {
|
|
40
|
-
setScrollOffset((o) => {
|
|
41
|
-
const next = Math.min(maxScroll, o + SCROLL_STEP);
|
|
42
|
-
if (next >= maxScroll) {
|
|
43
|
-
autoScrollRef.current = true;
|
|
44
|
-
}
|
|
45
|
-
return next;
|
|
46
|
-
});
|
|
47
|
-
}, [maxScroll]);
|
|
48
|
-
|
|
49
|
-
// Detect SGR mouse wheel sequences via Ink's useInput hook.
|
|
50
|
-
// Ink's parseKeypress doesn't recognize SGR mouse, so raw sequences
|
|
51
|
-
// pass through with \x1b stripped: [<64;... (up), [<65;... (down)
|
|
52
|
-
useInput(
|
|
53
|
-
(input) => {
|
|
54
|
-
if (input.startsWith('[<64')) {
|
|
55
|
-
scrollUp();
|
|
56
|
-
} else if (input.startsWith('[<65')) {
|
|
57
|
-
scrollDown();
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
{ isActive: true },
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
return { scrollOffset, onScrollUp: scrollUp, onScrollDown: scrollDown };
|
|
64
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import type { DOMElement } from 'ink';
|
|
2
|
-
import { measureElement, useStdout } from 'ink';
|
|
3
|
-
import { useEffect, useLayoutEffect, useState } from 'react';
|
|
4
|
-
|
|
5
|
-
export function useTerminalSize() {
|
|
6
|
-
const { stdout } = useStdout();
|
|
7
|
-
const [size, setSize] = useState({ width: stdout.columns, height: stdout.rows });
|
|
8
|
-
useEffect(() => {
|
|
9
|
-
const onResize = () => setSize({ width: stdout.columns, height: stdout.rows });
|
|
10
|
-
stdout.on('resize', onResize);
|
|
11
|
-
return () => {
|
|
12
|
-
stdout.off('resize', onResize);
|
|
13
|
-
};
|
|
14
|
-
}, [stdout]);
|
|
15
|
-
return size;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function useMeasure(
|
|
19
|
-
viewRef: React.RefObject<DOMElement | null>,
|
|
20
|
-
contentRef: React.RefObject<DOMElement | null>,
|
|
21
|
-
contentKey?: unknown,
|
|
22
|
-
) {
|
|
23
|
-
const [viewHeight, setViewHeight] = useState(0);
|
|
24
|
-
const [contentHeight, setContentHeight] = useState(0);
|
|
25
|
-
|
|
26
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: contentKey triggers re-measure on content changes
|
|
27
|
-
useLayoutEffect(() => {
|
|
28
|
-
const timer = setTimeout(() => {
|
|
29
|
-
if (viewRef.current) {
|
|
30
|
-
setViewHeight(measureElement(viewRef.current).height);
|
|
31
|
-
}
|
|
32
|
-
if (contentRef.current) {
|
|
33
|
-
setContentHeight(measureElement(contentRef.current).height);
|
|
34
|
-
}
|
|
35
|
-
}, 100);
|
|
36
|
-
return () => clearTimeout(timer);
|
|
37
|
-
}, [viewRef, contentRef, contentKey]);
|
|
38
|
-
|
|
39
|
-
return { viewHeight, contentHeight };
|
|
40
|
-
}
|
package/src/tui/hooks/useUI.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
4
|
-
|
|
5
|
-
export function useSpinner(active: boolean): string {
|
|
6
|
-
const [frame, setFrame] = useState(0);
|
|
7
|
-
useEffect(() => {
|
|
8
|
-
if (!active) {
|
|
9
|
-
return;
|
|
10
|
-
}
|
|
11
|
-
const timer = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
|
|
12
|
-
return () => clearInterval(timer);
|
|
13
|
-
}, [active]);
|
|
14
|
-
return active ? SPINNER_FRAMES[frame] : '';
|
|
15
|
-
}
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
import { Box, Text } from 'ink';
|
|
2
|
-
import type { InputInfoSegment, 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
|
-
infoSegments: InputInfoSegment[];
|
|
23
|
-
attachmentName: string | null;
|
|
24
|
-
attachmentError: string | null;
|
|
25
|
-
mentions: MentionPickerView | null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function CommandHints({
|
|
29
|
-
commands,
|
|
30
|
-
selectedIndex,
|
|
31
|
-
theme,
|
|
32
|
-
}: {
|
|
33
|
-
commands: SlashCommand[];
|
|
34
|
-
selectedIndex: number;
|
|
35
|
-
theme: Theme;
|
|
36
|
-
}) {
|
|
37
|
-
if (!commands.length) {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
return (
|
|
41
|
-
<Box flexDirection="column" marginBottom={1}>
|
|
42
|
-
{commands.map((cmd, i) => (
|
|
43
|
-
<Box key={cmd.name} paddingX={1}>
|
|
44
|
-
<Text color={i === selectedIndex ? theme.input.commandHighlight : undefined} bold={i === selectedIndex}>
|
|
45
|
-
{i === selectedIndex ? '▸ ' : ' '}
|
|
46
|
-
{cmd.name}
|
|
47
|
-
</Text>
|
|
48
|
-
<Text dimColor={true}> {cmd.description}</Text>
|
|
49
|
-
</Box>
|
|
50
|
-
))}
|
|
51
|
-
</Box>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Render a label with the substring matching `partial` (case-insensitive)
|
|
57
|
-
* highlighted using the same accent color the picker uses for the selected
|
|
58
|
-
* row. Preserves the original casing of the label since we only slice it.
|
|
59
|
-
*/
|
|
60
|
-
function renderHighlightedLabel(label: string, partial: string, theme: Theme) {
|
|
61
|
-
if (!partial) return <>{label}</>;
|
|
62
|
-
const idx = label.toLowerCase().indexOf(partial.toLowerCase());
|
|
63
|
-
if (idx < 0) return <>{label}</>;
|
|
64
|
-
const head = label.slice(0, idx);
|
|
65
|
-
const match = label.slice(idx, idx + partial.length);
|
|
66
|
-
const tail = label.slice(idx + partial.length);
|
|
67
|
-
return (
|
|
68
|
-
<>
|
|
69
|
-
{head}
|
|
70
|
-
<Text color={theme.input.commandHighlight} bold={true}>
|
|
71
|
-
{match}
|
|
72
|
-
</Text>
|
|
73
|
-
{tail}
|
|
74
|
-
</>
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function MentionHints({ mentions, theme }: { mentions: MentionPickerView; theme: Theme }) {
|
|
79
|
-
if (!mentions.completions.length) {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
// Group completions by category while preserving the global index so
|
|
83
|
-
// ↑/↓ navigation still maps to the correct entry. When only one
|
|
84
|
-
// category is present we hide the header to keep the dropdown compact.
|
|
85
|
-
const grouped = new Map<string, { c: MentionCompletion; i: number }[]>();
|
|
86
|
-
mentions.completions.forEach((c, i) => {
|
|
87
|
-
const key = c.category ?? '';
|
|
88
|
-
const arr = grouped.get(key);
|
|
89
|
-
if (arr) arr.push({ c, i });
|
|
90
|
-
else grouped.set(key, [{ c, i }]);
|
|
91
|
-
});
|
|
92
|
-
const showHeaders = grouped.size > 1;
|
|
93
|
-
const sections = Array.from(grouped.entries());
|
|
94
|
-
return (
|
|
95
|
-
<Box flexDirection="column" marginBottom={1}>
|
|
96
|
-
{sections.map(([category, items]) => (
|
|
97
|
-
<Box key={category || 'default'} flexDirection="column">
|
|
98
|
-
{showHeaders && category && (
|
|
99
|
-
<Box paddingX={1}>
|
|
100
|
-
<Text dimColor={true} bold={true}>
|
|
101
|
-
{category}
|
|
102
|
-
</Text>
|
|
103
|
-
</Box>
|
|
104
|
-
)}
|
|
105
|
-
{items.map(({ c, i }) => {
|
|
106
|
-
const selected = i === mentions.selectedIndex;
|
|
107
|
-
const labelText = c.label ?? c.value;
|
|
108
|
-
return (
|
|
109
|
-
<Box key={`${category}:${c.value}`} paddingX={1}>
|
|
110
|
-
<Text wrap="truncate-start" color={selected ? theme.input.commandHighlight : undefined} bold={selected}>
|
|
111
|
-
{selected ? '▸ @' : ' @'}
|
|
112
|
-
{renderHighlightedLabel(labelText, mentions.partial, theme)}
|
|
113
|
-
</Text>
|
|
114
|
-
{c.description && <Text dimColor={true}> {c.description}</Text>}
|
|
115
|
-
</Box>
|
|
116
|
-
);
|
|
117
|
-
})}
|
|
118
|
-
</Box>
|
|
119
|
-
))}
|
|
120
|
-
</Box>
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function InputFooter({
|
|
125
|
-
model,
|
|
126
|
-
infoSegments,
|
|
127
|
-
attachmentName,
|
|
128
|
-
attachmentError,
|
|
129
|
-
hasContent,
|
|
130
|
-
isCommandMode,
|
|
131
|
-
hasMentions,
|
|
132
|
-
theme,
|
|
133
|
-
}: {
|
|
134
|
-
model: string;
|
|
135
|
-
infoSegments: InputInfoSegment[];
|
|
136
|
-
attachmentName: string | null;
|
|
137
|
-
attachmentError: string | null;
|
|
138
|
-
hasContent: boolean;
|
|
139
|
-
isCommandMode: boolean;
|
|
140
|
-
hasMentions: boolean;
|
|
141
|
-
theme: Theme;
|
|
142
|
-
}) {
|
|
143
|
-
const hint = hasMentions
|
|
144
|
-
? '↑↓ · Tab accept'
|
|
145
|
-
: hasContent
|
|
146
|
-
? isCommandMode
|
|
147
|
-
? '↑↓ · Enter run'
|
|
148
|
-
: ''
|
|
149
|
-
: '/ commands · @ mentions';
|
|
150
|
-
|
|
151
|
-
return (
|
|
152
|
-
<Box justifyContent="space-between">
|
|
153
|
-
<Box gap={1}>
|
|
154
|
-
{infoSegments.map((seg) => (
|
|
155
|
-
<Text key={seg.key} color={seg.color} bold={seg.bold}>
|
|
156
|
-
{seg.text}
|
|
157
|
-
</Text>
|
|
158
|
-
))}
|
|
159
|
-
{model && (
|
|
160
|
-
<Text color={theme.input.modelLabel} bold={true}>
|
|
161
|
-
{model}
|
|
162
|
-
</Text>
|
|
163
|
-
)}
|
|
164
|
-
{attachmentName && <Text color={theme.input.attachmentName}>📷 {attachmentName}</Text>}
|
|
165
|
-
{attachmentError && <Text color={theme.input.attachmentError}>{attachmentError}</Text>}
|
|
166
|
-
</Box>
|
|
167
|
-
<Text color={theme.input.footerHint}>{hint}</Text>
|
|
168
|
-
</Box>
|
|
169
|
-
);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
interface RowProps {
|
|
173
|
-
line: string;
|
|
174
|
-
cursorCol: number | null;
|
|
175
|
-
isCommandLine: boolean;
|
|
176
|
-
theme: Theme;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Render one buffer row, splicing the cursor glyph at `cursorCol` if the
|
|
181
|
-
* cursor lives on this row. Splitting around the cursor (rather than rendering
|
|
182
|
-
* the whole line and overlaying) keeps the layout flowing inside Ink's text
|
|
183
|
-
* wrapping engine.
|
|
184
|
-
*/
|
|
185
|
-
function InputRow({ line, cursorCol, isCommandLine, theme }: RowProps) {
|
|
186
|
-
const colorize = (text: string) => (isCommandLine ? <Text color={theme.input.commandHighlight}>{text}</Text> : text);
|
|
187
|
-
|
|
188
|
-
if (cursorCol === null) {
|
|
189
|
-
return <Text wrap="wrap">{colorize(line)}</Text>;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const before = line.slice(0, cursorCol);
|
|
193
|
-
const after = line.slice(cursorCol);
|
|
194
|
-
return (
|
|
195
|
-
<Text wrap="wrap">
|
|
196
|
-
{colorize(before)}
|
|
197
|
-
<Text color={theme.input.cursor} inverse={true}>
|
|
198
|
-
▎
|
|
199
|
-
</Text>
|
|
200
|
-
{colorize(after)}
|
|
201
|
-
</Text>
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
interface DisplayProps {
|
|
206
|
-
value: string;
|
|
207
|
-
cursor: number;
|
|
208
|
-
isCommandMode: boolean;
|
|
209
|
-
streaming: boolean;
|
|
210
|
-
isActive: boolean;
|
|
211
|
-
theme: Theme;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function InputDisplay({ value, cursor, isCommandMode, streaming, isActive, theme }: DisplayProps) {
|
|
215
|
-
const showCursor = !streaming && isActive;
|
|
216
|
-
if (!value.length) {
|
|
217
|
-
return (
|
|
218
|
-
<Text>
|
|
219
|
-
{showCursor && (
|
|
220
|
-
<Text color={theme.input.cursor} inverse={true}>
|
|
221
|
-
▎
|
|
222
|
-
</Text>
|
|
223
|
-
)}
|
|
224
|
-
</Text>
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
const lines = value.split('\n');
|
|
228
|
-
// Locate cursor row/col by walking newline offsets.
|
|
229
|
-
let row = 0;
|
|
230
|
-
let consumed = 0;
|
|
231
|
-
for (let i = 0; i < lines.length; i++) {
|
|
232
|
-
const lineEnd = consumed + lines[i].length;
|
|
233
|
-
if (cursor <= lineEnd) {
|
|
234
|
-
row = i;
|
|
235
|
-
break;
|
|
236
|
-
}
|
|
237
|
-
consumed = lineEnd + 1; // +1 for the newline
|
|
238
|
-
row = i + 1;
|
|
239
|
-
}
|
|
240
|
-
const col = cursor - consumed;
|
|
241
|
-
return (
|
|
242
|
-
<>
|
|
243
|
-
{lines.map((line, i) => (
|
|
244
|
-
<InputRow
|
|
245
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: static input display lines
|
|
246
|
-
key={`${i}-${line}`}
|
|
247
|
-
line={line}
|
|
248
|
-
cursorCol={showCursor && i === row ? col : null}
|
|
249
|
-
isCommandLine={i === 0 && isCommandMode}
|
|
250
|
-
theme={theme}
|
|
251
|
-
/>
|
|
252
|
-
))}
|
|
253
|
-
</>
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export function InputBoxView(props: InputBoxViewProps) {
|
|
258
|
-
const theme = useTheme();
|
|
259
|
-
return (
|
|
260
|
-
<Box
|
|
261
|
-
flexDirection="column"
|
|
262
|
-
flexShrink={0}
|
|
263
|
-
backgroundColor={theme.input.background}
|
|
264
|
-
paddingX={1}
|
|
265
|
-
paddingY={1}
|
|
266
|
-
marginX={1}
|
|
267
|
-
marginTop={1}
|
|
268
|
-
>
|
|
269
|
-
{props.isCommandMode && <CommandHints commands={props.commands} selectedIndex={props.cmdIndex} theme={theme} />}
|
|
270
|
-
{props.mentions && <MentionHints mentions={props.mentions} theme={theme} />}
|
|
271
|
-
<Box flexDirection="column" minHeight={2}>
|
|
272
|
-
<InputDisplay
|
|
273
|
-
value={props.value}
|
|
274
|
-
cursor={props.cursor}
|
|
275
|
-
isCommandMode={props.isCommandMode}
|
|
276
|
-
streaming={props.streaming}
|
|
277
|
-
isActive={props.isActive}
|
|
278
|
-
theme={theme}
|
|
279
|
-
/>
|
|
280
|
-
</Box>
|
|
281
|
-
<InputFooter
|
|
282
|
-
model={props.model}
|
|
283
|
-
infoSegments={props.infoSegments}
|
|
284
|
-
attachmentName={props.attachmentName}
|
|
285
|
-
attachmentError={props.attachmentError}
|
|
286
|
-
hasContent={props.value.length > 0}
|
|
287
|
-
isCommandMode={props.isCommandMode}
|
|
288
|
-
hasMentions={Boolean(props.mentions)}
|
|
289
|
-
theme={theme}
|
|
290
|
-
/>
|
|
291
|
-
</Box>
|
|
292
|
-
);
|
|
293
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
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 onCompact = mock(() => undefined);
|
|
27
|
-
const onShowContext = mock(() => undefined);
|
|
28
|
-
const onUpdate = mock(() => undefined);
|
|
29
|
-
const actions: InputActions = {
|
|
30
|
-
onTogglePicker,
|
|
31
|
-
onToggleSessionPicker,
|
|
32
|
-
onNew,
|
|
33
|
-
onCompact,
|
|
34
|
-
onShowContext,
|
|
35
|
-
onUpdate,
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
for (const cmd of BUILTIN_COMMANDS) {
|
|
39
|
-
cmd.invoke?.(actions, '');
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
expect(onTogglePicker).toHaveBeenCalledTimes(1);
|
|
43
|
-
expect(onToggleSessionPicker).toHaveBeenCalledTimes(1);
|
|
44
|
-
expect(onNew).toHaveBeenCalledTimes(1);
|
|
45
|
-
expect(onCompact).toHaveBeenCalledTimes(1);
|
|
46
|
-
expect(onShowContext).toHaveBeenCalledTimes(1);
|
|
47
|
-
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('/update forwards args to onUpdate', () => {
|
|
51
|
-
const onUpdate = mock(() => undefined);
|
|
52
|
-
const actions: InputActions = { onUpdate };
|
|
53
|
-
const cmd = BUILTIN_COMMANDS.find((c) => c.name === '/update');
|
|
54
|
-
expect(cmd).toBeDefined();
|
|
55
|
-
cmd?.invoke?.(actions, 'plugins');
|
|
56
|
-
expect(onUpdate).toHaveBeenCalledWith('plugins');
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
describe('fromPluginCommand', () => {
|
|
61
|
-
it('prepends a slash and forwards args/context to execute', async () => {
|
|
62
|
-
const execute = mock(async () => 'ok');
|
|
63
|
-
const wrapped = fromPluginCommand(
|
|
64
|
-
{ name: 'foo', description: 'plugin', execute },
|
|
65
|
-
{ messages: [], cwd: '/tmp', config: { baseUrl: '', maxTokens: 0, temperature: 0, streamTimeoutMs: 0 } },
|
|
66
|
-
);
|
|
67
|
-
expect(wrapped.name).toBe('/foo');
|
|
68
|
-
await wrapped.execute?.('hello');
|
|
69
|
-
expect(execute).toHaveBeenCalledWith('hello', expect.any(Object));
|
|
70
|
-
});
|
|
71
|
-
});
|
|
@@ -1,55 +0,0 @@
|
|
|
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, args)` — for builtins that just toggle UI state
|
|
7
|
-
* (most ignore `args`), or
|
|
8
|
-
* - run via `execute(args)` — for plugin-supplied commands that produce
|
|
9
|
-
* side-effects through the agent runtime.
|
|
10
|
-
*
|
|
11
|
-
* Exactly one of `invoke` / `execute` should be set per command.
|
|
12
|
-
*/
|
|
13
|
-
export interface SlashCommand {
|
|
14
|
-
name: string;
|
|
15
|
-
description: string;
|
|
16
|
-
invoke?: (actions: InputActions, args: string) => void;
|
|
17
|
-
execute?: (args: string) => Promise<string | undefined>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const BUILTIN_COMMANDS: SlashCommand[] = [
|
|
21
|
-
{ name: '/model', description: 'Select a model', invoke: (a) => a.onTogglePicker?.() },
|
|
22
|
-
{ name: '/sessions', description: 'List project sessions', invoke: (a) => a.onToggleSessionPicker?.() },
|
|
23
|
-
{ name: '/new', description: 'New conversation', invoke: (a) => a.onNew?.() },
|
|
24
|
-
{
|
|
25
|
-
name: '/compact',
|
|
26
|
-
description: 'Summarize the conversation and replace history with the summary (frees context)',
|
|
27
|
-
invoke: (a) => a.onCompact?.(),
|
|
28
|
-
},
|
|
29
|
-
{
|
|
30
|
-
name: '/context',
|
|
31
|
-
description: 'Show the LLM context (system prompt, messages, tools) as plain text',
|
|
32
|
-
invoke: (a) => a.onShowContext?.(),
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
name: '/update',
|
|
36
|
-
description: 'Update mu and installed npm plugins. Args: "plugins" | "self" (default: all)',
|
|
37
|
-
invoke: (a, args) => a.onUpdate?.(args),
|
|
38
|
-
},
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
export function fromPluginCommand(command: PluginSlashCommand, context: CommandContext): SlashCommand {
|
|
42
|
-
return {
|
|
43
|
-
name: `/${command.name}`,
|
|
44
|
-
description: command.description,
|
|
45
|
-
execute: (args: string) => command.execute(args, context),
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function matchCommands(input: string, commands: SlashCommand[]): SlashCommand[] {
|
|
50
|
-
if (!input.startsWith('/')) {
|
|
51
|
-
return [];
|
|
52
|
-
}
|
|
53
|
-
const q = input.toLowerCase();
|
|
54
|
-
return commands.filter((cmd) => cmd.name.startsWith(q));
|
|
55
|
-
}
|
|
@@ -1,136 +0,0 @@
|
|
|
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
|
-
});
|