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,15 +1,16 @@
|
|
|
1
1
|
import { Box, type DOMElement as InkDOMElement } from 'ink';
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import type { InkUIService
|
|
6
|
-
import { MessageView
|
|
7
|
-
import {
|
|
2
|
+
import type { ChatMessage } from 'mu-provider';
|
|
3
|
+
import type { StreamState } from '../../chat/useChatSession';
|
|
4
|
+
import { InputBox } from '../../input/InputBox';
|
|
5
|
+
import type { InkUIService } from '../../plugins/InkUIService';
|
|
6
|
+
import { MessageView } from '../messageView';
|
|
7
|
+
import { type Toast, ToastContainer } from '../primitives/toast';
|
|
8
|
+
import type { StatusBarSegment } from '../statusBar';
|
|
9
|
+
import { StatusBar } from '../statusBar';
|
|
8
10
|
import { DialogLayer } from '../ui/dialogLayer';
|
|
9
|
-
import { ToastContainer, useToast } from '../ui/toast';
|
|
10
11
|
import { Pickers } from './Pickers';
|
|
11
12
|
|
|
12
|
-
interface
|
|
13
|
+
export interface ChatPanelBodyProps {
|
|
13
14
|
width: number;
|
|
14
15
|
height: number;
|
|
15
16
|
viewRef: React.RefObject<InkDOMElement | null>;
|
|
@@ -20,68 +21,45 @@ interface LayoutProps {
|
|
|
20
21
|
isActive: boolean;
|
|
21
22
|
onScrollUp: () => void;
|
|
22
23
|
onScrollDown: () => void;
|
|
24
|
+
uiService?: InkUIService;
|
|
25
|
+
messages: ChatMessage[];
|
|
26
|
+
streaming: boolean;
|
|
27
|
+
stream: StreamState;
|
|
28
|
+
error: string | null;
|
|
29
|
+
onSubmit: (text: string) => void;
|
|
30
|
+
model: string;
|
|
31
|
+
history: string[];
|
|
32
|
+
statusSegments: StatusBarSegment[];
|
|
33
|
+
toasts: Toast[];
|
|
34
|
+
onDismissToast: (id: number) => void;
|
|
23
35
|
}
|
|
24
36
|
|
|
25
|
-
|
|
26
|
-
info: 'cyan',
|
|
27
|
-
success: 'green',
|
|
28
|
-
warning: 'yellow',
|
|
29
|
-
error: 'red',
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export function ChatPanelBody(props: LayoutProps & { uiService?: InkUIService }) {
|
|
33
|
-
const { session, models, abort, registry } = useChatContext();
|
|
34
|
-
const [pluginStatus, setPluginStatus] = useState<StatusSegment[]>([]);
|
|
35
|
-
const { toasts, show, dismiss } = useToast();
|
|
36
|
-
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
if (!props.uiService) return;
|
|
39
|
-
props.uiService.onToast((toast: ToastRequest) => {
|
|
40
|
-
show(toast.message, TOAST_LEVEL_COLORS[toast.level] ?? 'white');
|
|
41
|
-
});
|
|
42
|
-
}, [props.uiService, show]);
|
|
43
|
-
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
const refresh = () => setPluginStatus(registry.getStatusSegments());
|
|
46
|
-
refresh();
|
|
47
|
-
const interval = setInterval(refresh, 2000);
|
|
48
|
-
return () => clearInterval(interval);
|
|
49
|
-
}, [registry]);
|
|
50
|
-
|
|
37
|
+
export function ChatPanelBody(props: ChatPanelBodyProps) {
|
|
51
38
|
return (
|
|
52
39
|
<Box flexDirection="column" height={props.height} width={props.width}>
|
|
53
40
|
<MessageView
|
|
54
41
|
viewRef={props.viewRef}
|
|
55
42
|
contentRef={props.contentRef}
|
|
56
|
-
messages={
|
|
57
|
-
streaming={
|
|
58
|
-
stream={
|
|
59
|
-
error={
|
|
43
|
+
messages={props.messages}
|
|
44
|
+
streaming={props.streaming}
|
|
45
|
+
stream={props.stream}
|
|
46
|
+
error={props.error}
|
|
60
47
|
scrollOffset={props.scrollOffset}
|
|
61
48
|
viewHeight={props.viewHeight}
|
|
62
49
|
contentHeight={props.contentHeight}
|
|
63
50
|
/>
|
|
64
51
|
<InputBox
|
|
65
|
-
onSubmit={
|
|
52
|
+
onSubmit={props.onSubmit}
|
|
66
53
|
onScrollUp={props.onScrollUp}
|
|
67
54
|
onScrollDown={props.onScrollDown}
|
|
68
55
|
isActive={props.isActive}
|
|
69
|
-
model={
|
|
70
|
-
history={
|
|
71
|
-
/>
|
|
72
|
-
<StatusBar
|
|
73
|
-
streaming={session.streaming}
|
|
74
|
-
abortWarning={abort.abortWarning}
|
|
75
|
-
quitWarning={abort.quitWarning}
|
|
76
|
-
error={session.error}
|
|
77
|
-
modelError={models.modelError}
|
|
78
|
-
totalTokens={session.stream.totalTokens}
|
|
79
|
-
tokensPerSecond={session.stream.tps}
|
|
80
|
-
pluginStatus={pluginStatus}
|
|
56
|
+
model={props.model}
|
|
57
|
+
history={props.history}
|
|
81
58
|
/>
|
|
59
|
+
<StatusBar segments={props.statusSegments} />
|
|
82
60
|
<Pickers />
|
|
83
61
|
{props.uiService && <DialogLayer service={props.uiService} />}
|
|
84
|
-
<ToastContainer toasts={toasts} onDismiss={
|
|
62
|
+
<ToastContainer toasts={props.toasts} onDismiss={props.onDismissToast} />
|
|
85
63
|
</Box>
|
|
86
64
|
);
|
|
87
65
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
|
-
import { useChatContext } from '../../
|
|
3
|
-
import { PickerModal } from '../
|
|
2
|
+
import { useChatContext } from '../../chat/ChatContext';
|
|
3
|
+
import { PickerModal } from '../primitives/pickerModal';
|
|
4
4
|
|
|
5
5
|
export function Pickers() {
|
|
6
6
|
const { toggles, models, sessions, session } = useChatContext();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { DOMElement } from 'ink';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import type { ChatMessage } from 'mu-provider';
|
|
4
|
+
import { type RefObject, useMemo } from 'react';
|
|
5
|
+
import type { StreamState } from '../chat/useChatSession';
|
|
6
|
+
import { MessageItem } from './messages/messageItem';
|
|
7
|
+
import { StreamingOutput } from './messages/streamingOutput';
|
|
8
|
+
import { Scrollbar } from './primitives/scrollbar';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Walk `messages` once and group every assistant-with-tool-calls index to
|
|
12
|
+
* its trailing `tool` messages. Avoids the previous O(n²) scan where each
|
|
13
|
+
* `MessageItem` re-walked the array forward to find its tool replies.
|
|
14
|
+
*/
|
|
15
|
+
function indexToolMessages(messages: ChatMessage[]): Map<number, ChatMessage[]> {
|
|
16
|
+
const map = new Map<number, ChatMessage[]>();
|
|
17
|
+
let activeAssistant = -1;
|
|
18
|
+
for (let i = 0; i < messages.length; i++) {
|
|
19
|
+
const msg = messages[i];
|
|
20
|
+
if (msg.role === 'assistant' && msg.toolCalls?.length) {
|
|
21
|
+
activeAssistant = i;
|
|
22
|
+
map.set(i, []);
|
|
23
|
+
} else if (msg.role === 'tool' && activeAssistant !== -1) {
|
|
24
|
+
map.get(activeAssistant)?.push(msg);
|
|
25
|
+
} else {
|
|
26
|
+
activeAssistant = -1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return map;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function MessageView({
|
|
33
|
+
viewRef,
|
|
34
|
+
contentRef,
|
|
35
|
+
messages,
|
|
36
|
+
streaming,
|
|
37
|
+
stream,
|
|
38
|
+
error,
|
|
39
|
+
scrollOffset,
|
|
40
|
+
viewHeight,
|
|
41
|
+
contentHeight,
|
|
42
|
+
}: {
|
|
43
|
+
viewRef: RefObject<DOMElement | null>;
|
|
44
|
+
contentRef: RefObject<DOMElement | null>;
|
|
45
|
+
messages: ChatMessage[];
|
|
46
|
+
streaming: boolean;
|
|
47
|
+
stream: StreamState;
|
|
48
|
+
error: string | null;
|
|
49
|
+
scrollOffset: number;
|
|
50
|
+
viewHeight: number;
|
|
51
|
+
contentHeight: number;
|
|
52
|
+
}) {
|
|
53
|
+
const toolMessageIndex = useMemo(() => indexToolMessages(messages), [messages]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Box flexGrow={1} overflow="hidden">
|
|
57
|
+
<Box ref={viewRef} flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
|
58
|
+
<Box ref={contentRef} flexDirection="column" flexShrink={0} marginTop={-scrollOffset}>
|
|
59
|
+
{messages.map((msg, i) => (
|
|
60
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: messages have no stable id
|
|
61
|
+
<MessageItem key={i} msg={msg} toolMessages={toolMessageIndex.get(i)} />
|
|
62
|
+
))}
|
|
63
|
+
{streaming && <StreamingOutput currentText={stream.text} currentReasoning={stream.reasoning} />}
|
|
64
|
+
{error && <Text color="red">Error: {error}</Text>}
|
|
65
|
+
</Box>
|
|
66
|
+
</Box>
|
|
67
|
+
<Scrollbar viewHeight={viewHeight} contentHeight={contentHeight} scrollOffset={scrollOffset} />
|
|
68
|
+
</Box>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -1,33 +1,53 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
-
import {
|
|
2
|
+
import type { ToolDisplayHint } from 'mu-agents';
|
|
3
|
+
import { computeDiff, renderDiff } from '../../../utils/diff';
|
|
4
|
+
import { ToolHeader } from './ToolHeader';
|
|
3
5
|
|
|
4
6
|
interface EditOutputProps {
|
|
5
7
|
args: string;
|
|
6
8
|
content: string;
|
|
7
9
|
error: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Display hint from the tool's plugin. Used to resolve which JSON arg field
|
|
12
|
+
* holds the path / from-string / to-string, so a plugin can register a
|
|
13
|
+
* diff-kind tool with arbitrary field names.
|
|
14
|
+
*/
|
|
15
|
+
hint?: ToolDisplayHint;
|
|
8
16
|
}
|
|
9
17
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
18
|
+
interface ParsedEditArgs {
|
|
19
|
+
path: string;
|
|
20
|
+
before: string;
|
|
21
|
+
after: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_DIFF_LINES = 30;
|
|
14
25
|
|
|
26
|
+
function parseEditArgs(args: string, hint: ToolDisplayHint | undefined): ParsedEditArgs {
|
|
27
|
+
const fields = hint?.fields ?? {};
|
|
28
|
+
const pathField = fields.path ?? 'path';
|
|
29
|
+
const fromField = fields.from ?? 'old_string';
|
|
30
|
+
const toField = fields.to ?? 'new_string';
|
|
15
31
|
try {
|
|
16
32
|
const parsed = JSON.parse(args);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
33
|
+
return {
|
|
34
|
+
path: parsed[pathField] ?? '(unknown)',
|
|
35
|
+
before: parsed[fromField] ?? '',
|
|
36
|
+
after: parsed[toField] ?? '',
|
|
37
|
+
};
|
|
20
38
|
} catch {
|
|
21
|
-
|
|
39
|
+
return { path: '(unknown)', before: '', after: '' };
|
|
22
40
|
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function EditOutput({ args, content, error, hint }: EditOutputProps) {
|
|
44
|
+
const { path, before, after } = parseEditArgs(args, hint);
|
|
45
|
+
const verb = hint?.verb ?? 'edit_file';
|
|
23
46
|
|
|
24
47
|
if (error) {
|
|
25
48
|
return (
|
|
26
49
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
27
|
-
<
|
|
28
|
-
✗ edit_file
|
|
29
|
-
</Text>
|
|
30
|
-
<Text dimColor={true}> {path}</Text>
|
|
50
|
+
<ToolHeader name={verb} subtitle={path} error={true} />
|
|
31
51
|
<Text dimColor={true} wrap="wrap">
|
|
32
52
|
{content}
|
|
33
53
|
</Text>
|
|
@@ -35,13 +55,13 @@ export function EditOutput({ args, content, error }: EditOutputProps) {
|
|
|
35
55
|
);
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
const diff = computeDiff(
|
|
58
|
+
const diff = computeDiff(before, after);
|
|
39
59
|
|
|
40
60
|
if (diff.lines.length === 0 && diff.totalOldLines > 0 && diff.totalNewLines > 0) {
|
|
41
61
|
return (
|
|
42
62
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
43
63
|
<Text color="yellow" bold={true}>
|
|
44
|
-
!
|
|
64
|
+
! {verb}
|
|
45
65
|
</Text>
|
|
46
66
|
<Text dimColor={true}> {path}</Text>
|
|
47
67
|
<Text dimColor={true}>
|
|
@@ -54,35 +74,30 @@ export function EditOutput({ args, content, error }: EditOutputProps) {
|
|
|
54
74
|
if (diff.lines.length === 0) {
|
|
55
75
|
return (
|
|
56
76
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
57
|
-
<
|
|
58
|
-
✓ edit_file
|
|
59
|
-
</Text>
|
|
60
|
-
<Text dimColor={true}> {path}</Text>
|
|
77
|
+
<ToolHeader name={verb} subtitle={path} />
|
|
61
78
|
<Text dimColor={true}>No changes (content identical)</Text>
|
|
62
79
|
</Box>
|
|
63
80
|
);
|
|
64
81
|
}
|
|
65
82
|
|
|
66
|
-
const { lines, truncated } = renderDiff(diff,
|
|
83
|
+
const { lines, truncated } = renderDiff(diff, MAX_DIFF_LINES);
|
|
67
84
|
|
|
68
85
|
return (
|
|
69
86
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
70
|
-
<
|
|
71
|
-
✓ edit_file
|
|
72
|
-
</Text>
|
|
73
|
-
<Text dimColor={true}> {path}</Text>
|
|
87
|
+
<ToolHeader name={verb} subtitle={path} />
|
|
74
88
|
<Box flexDirection="column" flexShrink={0}>
|
|
75
|
-
{lines.map((line) => {
|
|
89
|
+
{lines.map((line, i) => {
|
|
76
90
|
let color: string | undefined;
|
|
77
91
|
if (line.startsWith('-')) color = 'red';
|
|
78
92
|
else if (line.startsWith('+')) color = 'green';
|
|
79
93
|
return (
|
|
80
|
-
|
|
94
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: diff lines may repeat (blank lines, braces); index disambiguates
|
|
95
|
+
<Text key={`${i}-${line}`} color={color} dimColor={color === undefined} wrap="wrap">
|
|
81
96
|
{line}
|
|
82
97
|
</Text>
|
|
83
98
|
);
|
|
84
99
|
})}
|
|
85
|
-
{truncated && <Text dimColor={true}>… (truncated,
|
|
100
|
+
{truncated && <Text dimColor={true}>… (truncated, {MAX_DIFF_LINES} line limit)</Text>}
|
|
86
101
|
</Box>
|
|
87
102
|
</Box>
|
|
88
103
|
);
|
|
@@ -1,43 +1,48 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
+
import { ToolHeader } from './ToolHeader';
|
|
2
3
|
|
|
3
4
|
interface ReadOutputProps {
|
|
4
5
|
args: string;
|
|
5
6
|
error: boolean;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
interface ReadArgs {
|
|
10
|
+
paths: string[];
|
|
11
|
+
startLine?: number;
|
|
12
|
+
endLine?: number;
|
|
13
|
+
}
|
|
12
14
|
|
|
15
|
+
function parseReadArgs(args: string): ReadArgs {
|
|
13
16
|
try {
|
|
14
17
|
const parsed = JSON.parse(args);
|
|
15
18
|
const p = parsed.path;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
return {
|
|
20
|
+
paths: Array.isArray(p) ? p : [p ?? '(unknown)'],
|
|
21
|
+
startLine: typeof parsed.start === 'number' ? parsed.start : undefined,
|
|
22
|
+
endLine: typeof parsed.end === 'number' ? parsed.end : undefined,
|
|
23
|
+
};
|
|
19
24
|
} catch {
|
|
20
|
-
|
|
25
|
+
return { paths: ['(unknown)'] };
|
|
21
26
|
}
|
|
27
|
+
}
|
|
22
28
|
|
|
29
|
+
export function ReadOutput({ args, error }: ReadOutputProps) {
|
|
30
|
+
const { paths, startLine, endLine } = parseReadArgs(args);
|
|
23
31
|
const rangeLabel = startLine != null && endLine != null ? ` (lines ${startLine}-${endLine})` : '';
|
|
32
|
+
const subtitle = paths.length === 1 ? `${paths[0]}${rangeLabel}` : `${paths.length} files${rangeLabel}`;
|
|
24
33
|
|
|
25
34
|
return (
|
|
26
35
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
</Text>
|
|
38
|
-
))}
|
|
39
|
-
{rangeLabel}
|
|
40
|
-
</Text>
|
|
36
|
+
<ToolHeader name="read_file" subtitle={subtitle} error={error} />
|
|
37
|
+
{paths.length > 1 && (
|
|
38
|
+
<Box flexDirection="column" flexShrink={0}>
|
|
39
|
+
{paths.map((p) => (
|
|
40
|
+
<Text key={p} dimColor={true} wrap="wrap">
|
|
41
|
+
{` • ${p}`}
|
|
42
|
+
</Text>
|
|
43
|
+
))}
|
|
44
|
+
</Box>
|
|
45
|
+
)}
|
|
41
46
|
</Box>
|
|
42
47
|
);
|
|
43
48
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
|
|
3
|
+
interface ToolHeaderProps {
|
|
4
|
+
/** The tool name shown after the status icon. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Optional subtitle (typically the file path or command). */
|
|
7
|
+
subtitle?: string;
|
|
8
|
+
/** When true, render with the failure styling. */
|
|
9
|
+
error?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shared header used by every tool-output renderer (read/write/edit/bash).
|
|
14
|
+
* Centralizes the ✓/✗ glyphs, color choice, and subtitle formatting so each
|
|
15
|
+
* specific component doesn't have to re-implement the same layout.
|
|
16
|
+
*/
|
|
17
|
+
export function ToolHeader({ name, subtitle, error = false }: ToolHeaderProps) {
|
|
18
|
+
return (
|
|
19
|
+
<Box flexDirection="column" flexShrink={0}>
|
|
20
|
+
<Text color={error ? 'red' : 'green'} bold={true}>
|
|
21
|
+
{error ? '✗' : '✓'} {name}
|
|
22
|
+
</Text>
|
|
23
|
+
{subtitle && <Text dimColor={true}> {subtitle}</Text>}
|
|
24
|
+
</Box>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
+
import { ToolHeader } from './ToolHeader';
|
|
2
3
|
|
|
3
4
|
const PREVIEW_LINES = 30;
|
|
4
5
|
|
|
@@ -6,24 +7,24 @@ interface WriteOutputProps {
|
|
|
6
7
|
args: string;
|
|
7
8
|
content: string;
|
|
8
9
|
error: boolean;
|
|
9
|
-
expanded: boolean;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
let path = '(unknown)';
|
|
12
|
+
function parsePath(args: string): string {
|
|
14
13
|
try {
|
|
15
14
|
const parsed = JSON.parse(args);
|
|
16
|
-
|
|
15
|
+
return parsed.path ?? '(unknown)';
|
|
17
16
|
} catch {
|
|
18
|
-
|
|
17
|
+
return '(unknown)';
|
|
19
18
|
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function WriteOutput({ args, content, error }: WriteOutputProps) {
|
|
22
|
+
const path = parsePath(args);
|
|
20
23
|
|
|
21
24
|
if (error) {
|
|
22
25
|
return (
|
|
23
26
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
24
|
-
<
|
|
25
|
-
✗ write_file
|
|
26
|
-
</Text>
|
|
27
|
+
<ToolHeader name="write_file" error={true} />
|
|
27
28
|
<Text dimColor={true} wrap="wrap">
|
|
28
29
|
{content}
|
|
29
30
|
</Text>
|
|
@@ -38,29 +39,16 @@ export function WriteOutput({ args, content, error, expanded }: WriteOutputProps
|
|
|
38
39
|
|
|
39
40
|
return (
|
|
40
41
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
41
|
-
<
|
|
42
|
-
✓ write_file
|
|
43
|
-
</Text>
|
|
44
|
-
<Text dimColor={true}> {path}</Text>
|
|
42
|
+
<ToolHeader name="write_file" subtitle={path} />
|
|
45
43
|
<Box flexDirection="column" flexShrink={0}>
|
|
46
44
|
<Text dimColor={true}>
|
|
47
45
|
{totalLines} line{totalLines !== 1 ? 's' : ''}
|
|
48
46
|
</Text>
|
|
49
47
|
<Box flexDirection="column" flexShrink={0}>
|
|
50
48
|
<Text dimColor={true} wrap="wrap">
|
|
51
|
-
{
|
|
49
|
+
{hasMore ? preview : content}
|
|
52
50
|
</Text>
|
|
53
|
-
{hasMore &&
|
|
54
|
-
{!expanded && (
|
|
55
|
-
<Box>
|
|
56
|
-
<Text color="cyan"> [Enter] show more </Text>
|
|
57
|
-
</Box>
|
|
58
|
-
)}
|
|
59
|
-
{expanded && (
|
|
60
|
-
<Box>
|
|
61
|
-
<Text color="cyan"> [Enter] show less </Text>
|
|
62
|
-
</Box>
|
|
63
|
-
)}
|
|
51
|
+
{hasMore && <Text dimColor={true}>… ({totalLines - PREVIEW_LINES} more lines)</Text>}
|
|
64
52
|
</Box>
|
|
65
53
|
</Box>
|
|
66
54
|
</Box>
|
|
@@ -5,26 +5,15 @@ import { UserMessage } from './userMessage';
|
|
|
5
5
|
|
|
6
6
|
export const MessageItem: React.FC<{
|
|
7
7
|
msg: ChatMessage;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
8
|
+
toolMessages?: ChatMessage[];
|
|
9
|
+
}> = React.memo(function MessageItem({ msg, toolMessages }) {
|
|
10
|
+
// Tool result messages are rendered inline within ToolCallBlock via the
|
|
11
|
+
// pre-built index passed from MessageView; suppress them at the top level.
|
|
12
12
|
if (msg.role === 'tool') {
|
|
13
13
|
return null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// Check if this assistant message has tool calls
|
|
17
16
|
if (msg.role === 'assistant' && msg.toolCalls?.length) {
|
|
18
|
-
// Collect following tool messages
|
|
19
|
-
const toolMessages: ChatMessage[] = [];
|
|
20
|
-
for (let i = index + 1; i < messages.length; i++) {
|
|
21
|
-
if (messages[i].role === 'tool') {
|
|
22
|
-
toolMessages.push(messages[i]);
|
|
23
|
-
} else {
|
|
24
|
-
break;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
17
|
return <AssistantMessage msg={msg} toolMessages={toolMessages} />;
|
|
29
18
|
}
|
|
30
19
|
|
|
@@ -1,27 +1,32 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
+
import type { ToolDisplayHint } from 'mu-agents';
|
|
2
3
|
import type { ChatMessage } from 'mu-provider';
|
|
4
|
+
import { useToolDisplay } from '../../chat/ToolDisplayContext';
|
|
3
5
|
import { useSpinner } from '../../hooks/useUI';
|
|
4
6
|
import { EditOutput } from './EditOutput';
|
|
5
7
|
import { ReadOutput } from './ReadOutput';
|
|
6
8
|
import { WriteOutput } from './WriteOutput';
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Render a tool call. Display behaviour is driven by the optional
|
|
12
|
+
* `ToolDisplayHint` the plugin attached to its tool — `kind` selects the
|
|
13
|
+
* dedicated renderer (file-read / file-write / diff / shell), and `verb`
|
|
14
|
+
* shows in the spinner line. Tools without a hint fall back to a generic
|
|
15
|
+
* preview block, so plugin-registered tools "just work" without UI changes.
|
|
16
|
+
*/
|
|
14
17
|
|
|
15
|
-
function
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
function getArgSummary(args: string, hint: ToolDisplayHint | undefined): string {
|
|
19
|
+
if (!hint?.fields) return args;
|
|
20
|
+
// For shell-like tools the most useful preview is the command itself;
|
|
21
|
+
// generic tools show the raw JSON.
|
|
22
|
+
const commandField = hint.fields.command;
|
|
23
|
+
if (!commandField) return args;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(args);
|
|
26
|
+
return parsed[commandField] ?? args;
|
|
27
|
+
} catch {
|
|
28
|
+
return args;
|
|
23
29
|
}
|
|
24
|
-
return args;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export function ToolCallBlock({
|
|
@@ -33,13 +38,13 @@ export function ToolCallBlock({
|
|
|
33
38
|
}) {
|
|
34
39
|
const name = toolCall.function.name;
|
|
35
40
|
const args = toolCall.function.arguments;
|
|
41
|
+
const hint = useToolDisplay(name);
|
|
36
42
|
|
|
37
|
-
// Find the matching tool result message
|
|
38
43
|
const result = toolMsg?.toolResult;
|
|
39
44
|
const hasResult = result !== undefined;
|
|
40
45
|
const spinner = useSpinner(!hasResult);
|
|
41
|
-
const verb =
|
|
42
|
-
const argSummary =
|
|
46
|
+
const verb = hint?.verb ?? 'executing';
|
|
47
|
+
const argSummary = getArgSummary(args, hint);
|
|
43
48
|
|
|
44
49
|
return (
|
|
45
50
|
<Box flexDirection="column" flexShrink={0}>
|
|
@@ -51,29 +56,46 @@ export function ToolCallBlock({
|
|
|
51
56
|
</Text>
|
|
52
57
|
</Box>
|
|
53
58
|
) : (
|
|
54
|
-
renderToolOutput(name, args, result.content, result.error ?? false,
|
|
59
|
+
renderToolOutput(name, args, result.content, result.error ?? false, hint)
|
|
55
60
|
)}
|
|
56
61
|
</Box>
|
|
57
62
|
);
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
function renderToolOutput(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
function renderToolOutput(
|
|
66
|
+
name: string,
|
|
67
|
+
args: string,
|
|
68
|
+
content: string,
|
|
69
|
+
error: boolean,
|
|
70
|
+
hint: ToolDisplayHint | undefined,
|
|
71
|
+
) {
|
|
72
|
+
switch (hint?.kind) {
|
|
73
|
+
case 'file-read':
|
|
74
|
+
return <ReadOutput args={args} error={error} />;
|
|
75
|
+
case 'file-write':
|
|
76
|
+
return <WriteOutput args={args} content={content} error={error} />;
|
|
77
|
+
case 'diff':
|
|
78
|
+
return <EditOutput args={args} content={content} error={error} hint={hint} />;
|
|
79
|
+
default:
|
|
80
|
+
return <GenericToolOutput name={name} args={args} content={content} error={error} hint={hint} />;
|
|
69
81
|
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface GenericProps {
|
|
85
|
+
name: string;
|
|
86
|
+
args: string;
|
|
87
|
+
content: string;
|
|
88
|
+
error: boolean;
|
|
89
|
+
hint: ToolDisplayHint | undefined;
|
|
90
|
+
}
|
|
70
91
|
|
|
71
|
-
|
|
72
|
-
let
|
|
73
|
-
|
|
92
|
+
function GenericToolOutput({ name, args, content, error, hint }: GenericProps) {
|
|
93
|
+
let summary = '';
|
|
94
|
+
const commandField = hint?.fields?.command;
|
|
95
|
+
if (commandField) {
|
|
74
96
|
try {
|
|
75
97
|
const parsed = JSON.parse(args);
|
|
76
|
-
|
|
98
|
+
summary = parsed[commandField] ?? '';
|
|
77
99
|
} catch {
|
|
78
100
|
// ignore
|
|
79
101
|
}
|
|
@@ -84,10 +106,10 @@ function renderToolOutput(name: string, args: string, content: string, error: bo
|
|
|
84
106
|
<Box flexDirection="column" flexShrink={0}>
|
|
85
107
|
<Text color={error ? 'red' : 'green'} bold={true}>
|
|
86
108
|
{error ? '✗' : '✓'} {name}
|
|
87
|
-
{
|
|
109
|
+
{summary && (
|
|
88
110
|
<>
|
|
89
111
|
{' '}
|
|
90
|
-
<Text dimColor={true}>{
|
|
112
|
+
<Text dimColor={true}>{summary}</Text>
|
|
91
113
|
</>
|
|
92
114
|
)}
|
|
93
115
|
</Text>
|