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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { StatusSegment } from 'mu-core';
|
|
2
|
+
import type { StatusBarSegment } from '../components/statusBar';
|
|
3
|
+
import { useSpinner } from '../hooks/useUI';
|
|
4
|
+
|
|
5
|
+
const ERROR_PREVIEW_LEN = 40;
|
|
6
|
+
|
|
7
|
+
interface StatusSegmentOptions {
|
|
8
|
+
streaming: boolean;
|
|
9
|
+
abortWarning: boolean;
|
|
10
|
+
quitWarning: boolean;
|
|
11
|
+
error: string | null;
|
|
12
|
+
modelError: string | null;
|
|
13
|
+
totalTokens: number;
|
|
14
|
+
/** Tokens served from server-side prompt cache. Rendered as `(N cached)`
|
|
15
|
+
* next to the total when > 0. Omit (or pass 0) to hide the suffix. */
|
|
16
|
+
cachedTokens?: number;
|
|
17
|
+
pluginStatus?: StatusSegment[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function truncate(text: string, max: number): string {
|
|
21
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const tokenFormatter = new Intl.NumberFormat('en-US');
|
|
25
|
+
function formatTokens(n: number): string {
|
|
26
|
+
return tokenFormatter.format(n);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useStatusSegments(options: StatusSegmentOptions): StatusBarSegment[] {
|
|
30
|
+
const spinner = useSpinner(options.streaming);
|
|
31
|
+
const segments: StatusBarSegment[] = [];
|
|
32
|
+
|
|
33
|
+
if (options.streaming) {
|
|
34
|
+
segments.push({ text: `${spinner} generating`, color: 'yellow' });
|
|
35
|
+
}
|
|
36
|
+
if (options.totalTokens > 0) {
|
|
37
|
+
const cached = options.cachedTokens ?? 0;
|
|
38
|
+
const label =
|
|
39
|
+
cached > 0
|
|
40
|
+
? `${formatTokens(options.totalTokens)} tokens (${formatTokens(cached)} cached)`
|
|
41
|
+
: `${formatTokens(options.totalTokens)} tokens`;
|
|
42
|
+
segments.push({ text: label, dim: true });
|
|
43
|
+
}
|
|
44
|
+
if (options.abortWarning) {
|
|
45
|
+
segments.push({ text: 'Esc again to stop', color: 'yellow' });
|
|
46
|
+
} else if (options.quitWarning) {
|
|
47
|
+
segments.push({ text: 'Ctrl+C again to quit', color: 'yellow' });
|
|
48
|
+
} else if (options.streaming) {
|
|
49
|
+
segments.push({ text: 'Esc to stop', dim: true });
|
|
50
|
+
}
|
|
51
|
+
if (options.error) {
|
|
52
|
+
segments.push({ text: `⚠ ${truncate(options.error, ERROR_PREVIEW_LEN)}`, color: 'red' });
|
|
53
|
+
}
|
|
54
|
+
if (options.modelError) {
|
|
55
|
+
segments.push({ text: `⚠ ${truncate(options.modelError, ERROR_PREVIEW_LEN)}`, color: 'red' });
|
|
56
|
+
}
|
|
57
|
+
if (options.pluginStatus) {
|
|
58
|
+
segments.push(...options.pluginStatus);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return segments;
|
|
62
|
+
}
|
|
@@ -1,59 +1,39 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
3
|
-
import type {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import type { InkUIService } from '../../
|
|
9
|
-
import { useChat } from '../../useChat';
|
|
1
|
+
import type { ChatMessage, PluginRegistry, ProviderConfig } from 'mu-core';
|
|
2
|
+
import type { ShutdownFn } from '../../../app/shutdown';
|
|
3
|
+
import type { HostMessageBus } from '../../../runtime/messageBus';
|
|
4
|
+
import { ChatContext } from '../../chat/ChatContext';
|
|
5
|
+
import { MessageRendererProvider, useRegistryRenderers } from '../../chat/MessageRendererContext';
|
|
6
|
+
import { ToolDisplayProvider, useToolDisplayMap } from '../../chat/ToolDisplayContext';
|
|
7
|
+
import { useChatPanel } from '../../chat/useChatPanel';
|
|
8
|
+
import type { InkUIService } from '../../plugins/InkUIService';
|
|
10
9
|
import { ChatPanelBody } from './ChatPanelBody';
|
|
11
10
|
|
|
12
11
|
export function ChatPanel({
|
|
13
12
|
config,
|
|
14
13
|
initialMessages,
|
|
15
14
|
registry,
|
|
15
|
+
messageBus,
|
|
16
16
|
uiService,
|
|
17
|
+
shutdown,
|
|
17
18
|
}: {
|
|
18
19
|
config: ProviderConfig;
|
|
19
20
|
initialMessages?: ChatMessage[];
|
|
20
21
|
registry: PluginRegistry;
|
|
22
|
+
messageBus?: HostMessageBus;
|
|
21
23
|
uiService?: InkUIService;
|
|
24
|
+
shutdown?: ShutdownFn;
|
|
22
25
|
}) {
|
|
23
|
-
const ctx =
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const contentRef = useRef<InkDOMElement>(null);
|
|
27
|
-
const { viewHeight, contentHeight } = useMeasure(
|
|
28
|
-
viewRef,
|
|
29
|
-
contentRef,
|
|
30
|
-
[
|
|
31
|
-
ctx.session.messages.length,
|
|
32
|
-
...ctx.session.messages.map((m) => m.content.length),
|
|
33
|
-
ctx.session.stream.text.length,
|
|
34
|
-
ctx.session.stream.reasoning?.length ?? 0,
|
|
35
|
-
].join('|'),
|
|
36
|
-
);
|
|
37
|
-
const { scrollOffset, onScrollUp, onScrollDown } = useScroll(contentHeight, viewHeight);
|
|
38
|
-
|
|
39
|
-
const anyModalOpen = ctx.toggles.showModelPicker || ctx.toggles.showSessionPicker;
|
|
40
|
-
useInput((input, key) => key.ctrl && input === 'c' && ctx.abort.onCtrlC(), { isActive: anyModalOpen });
|
|
26
|
+
const { ctx, bodyProps } = useChatPanel({ config, initialMessages, registry, messageBus, uiService, shutdown });
|
|
27
|
+
const toolDisplays = useToolDisplayMap(registry);
|
|
28
|
+
const renderers = useRegistryRenderers(registry);
|
|
41
29
|
|
|
42
30
|
return (
|
|
43
31
|
<ChatContext.Provider value={ctx}>
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
scrollOffset={scrollOffset}
|
|
50
|
-
viewHeight={viewHeight}
|
|
51
|
-
contentHeight={contentHeight}
|
|
52
|
-
isActive={!anyModalOpen}
|
|
53
|
-
onScrollUp={onScrollUp}
|
|
54
|
-
onScrollDown={onScrollDown}
|
|
55
|
-
uiService={uiService}
|
|
56
|
-
/>
|
|
32
|
+
<ToolDisplayProvider value={toolDisplays}>
|
|
33
|
+
<MessageRendererProvider value={renderers}>
|
|
34
|
+
<ChatPanelBody {...bodyProps} />
|
|
35
|
+
</MessageRendererProvider>
|
|
36
|
+
</ToolDisplayProvider>
|
|
57
37
|
</ChatContext.Provider>
|
|
58
38
|
);
|
|
59
39
|
}
|
|
@@ -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-core';
|
|
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,72 @@
|
|
|
1
|
+
import type { DOMElement } from 'ink';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import type { ChatMessage } from 'mu-core';
|
|
4
|
+
import { type RefObject, useMemo } from 'react';
|
|
5
|
+
import type { StreamState } from '../chat/useChatSession';
|
|
6
|
+
import { useTheme } from '../context/ThemeContext';
|
|
7
|
+
import { MessageItem } from './messages/messageItem';
|
|
8
|
+
import { StreamingOutput } from './messages/streamingOutput';
|
|
9
|
+
import { Scrollbar } from './primitives/scrollbar';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Walk `messages` once and group every assistant-with-tool-calls index to
|
|
13
|
+
* its trailing `tool` messages. Avoids the previous O(n²) scan where each
|
|
14
|
+
* `MessageItem` re-walked the array forward to find its tool replies.
|
|
15
|
+
*/
|
|
16
|
+
function indexToolMessages(messages: ChatMessage[]): Map<number, ChatMessage[]> {
|
|
17
|
+
const map = new Map<number, ChatMessage[]>();
|
|
18
|
+
let activeAssistant = -1;
|
|
19
|
+
for (let i = 0; i < messages.length; i++) {
|
|
20
|
+
const msg = messages[i];
|
|
21
|
+
if (msg.role === 'assistant' && msg.toolCalls?.length) {
|
|
22
|
+
activeAssistant = i;
|
|
23
|
+
map.set(i, []);
|
|
24
|
+
} else if (msg.role === 'tool' && activeAssistant !== -1) {
|
|
25
|
+
map.get(activeAssistant)?.push(msg);
|
|
26
|
+
} else {
|
|
27
|
+
activeAssistant = -1;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return map;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function MessageView({
|
|
34
|
+
viewRef,
|
|
35
|
+
contentRef,
|
|
36
|
+
messages,
|
|
37
|
+
streaming,
|
|
38
|
+
stream,
|
|
39
|
+
error,
|
|
40
|
+
scrollOffset,
|
|
41
|
+
viewHeight,
|
|
42
|
+
contentHeight,
|
|
43
|
+
}: {
|
|
44
|
+
viewRef: RefObject<DOMElement | null>;
|
|
45
|
+
contentRef: RefObject<DOMElement | null>;
|
|
46
|
+
messages: ChatMessage[];
|
|
47
|
+
streaming: boolean;
|
|
48
|
+
stream: StreamState;
|
|
49
|
+
error: string | null;
|
|
50
|
+
scrollOffset: number;
|
|
51
|
+
viewHeight: number;
|
|
52
|
+
contentHeight: number;
|
|
53
|
+
}) {
|
|
54
|
+
const theme = useTheme();
|
|
55
|
+
const toolMessageIndex = useMemo(() => indexToolMessages(messages), [messages]);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Box flexGrow={1} overflow="hidden">
|
|
59
|
+
<Box ref={viewRef} flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
|
|
60
|
+
<Box ref={contentRef} flexDirection="column" flexShrink={0} marginTop={-scrollOffset}>
|
|
61
|
+
{messages.map((msg, i) => (
|
|
62
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: messages have no stable id
|
|
63
|
+
<MessageItem key={i} msg={msg} toolMessages={toolMessageIndex.get(i)} />
|
|
64
|
+
))}
|
|
65
|
+
{streaming && <StreamingOutput currentText={stream.text} currentReasoning={stream.reasoning} />}
|
|
66
|
+
{error && <Text color={theme.common.error}>Error: {error}</Text>}
|
|
67
|
+
</Box>
|
|
68
|
+
</Box>
|
|
69
|
+
<Scrollbar viewHeight={viewHeight} contentHeight={contentHeight} scrollOffset={scrollOffset} />
|
|
70
|
+
</Box>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -1,33 +1,55 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
-
import {
|
|
2
|
+
import type { ToolDisplayHint } from 'mu-core';
|
|
3
|
+
import { computeDiff, renderDiff } from '../../../utils/diff';
|
|
4
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
5
|
+
import { ToolHeader } from './ToolHeader';
|
|
3
6
|
|
|
4
7
|
interface EditOutputProps {
|
|
5
8
|
args: string;
|
|
6
9
|
content: string;
|
|
7
10
|
error: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Display hint from the tool's plugin. Used to resolve which JSON arg field
|
|
13
|
+
* holds the path / from-string / to-string, so a plugin can register a
|
|
14
|
+
* diff-kind tool with arbitrary field names.
|
|
15
|
+
*/
|
|
16
|
+
hint?: ToolDisplayHint;
|
|
8
17
|
}
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
interface ParsedEditArgs {
|
|
20
|
+
path: string;
|
|
21
|
+
before: string;
|
|
22
|
+
after: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MAX_DIFF_LINES = 30;
|
|
14
26
|
|
|
27
|
+
function parseEditArgs(args: string, hint: ToolDisplayHint | undefined): ParsedEditArgs {
|
|
28
|
+
const fields = hint?.fields ?? {};
|
|
29
|
+
const pathField = fields.path ?? 'path';
|
|
30
|
+
const fromField = fields.from ?? 'old_string';
|
|
31
|
+
const toField = fields.to ?? 'new_string';
|
|
15
32
|
try {
|
|
16
33
|
const parsed = JSON.parse(args);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
34
|
+
return {
|
|
35
|
+
path: parsed[pathField] ?? '(unknown)',
|
|
36
|
+
before: parsed[fromField] ?? '',
|
|
37
|
+
after: parsed[toField] ?? '',
|
|
38
|
+
};
|
|
20
39
|
} catch {
|
|
21
|
-
|
|
40
|
+
return { path: '(unknown)', before: '', after: '' };
|
|
22
41
|
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function EditOutput({ args, content, error, hint }: EditOutputProps) {
|
|
45
|
+
const theme = useTheme();
|
|
46
|
+
const { path, before, after } = parseEditArgs(args, hint);
|
|
47
|
+
const verb = hint?.verb ?? 'edit_file';
|
|
23
48
|
|
|
24
49
|
if (error) {
|
|
25
50
|
return (
|
|
26
51
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
27
|
-
<
|
|
28
|
-
✗ edit_file
|
|
29
|
-
</Text>
|
|
30
|
-
<Text dimColor={true}> {path}</Text>
|
|
52
|
+
<ToolHeader name={verb} subtitle={path} error={true} />
|
|
31
53
|
<Text dimColor={true} wrap="wrap">
|
|
32
54
|
{content}
|
|
33
55
|
</Text>
|
|
@@ -35,13 +57,13 @@ export function EditOutput({ args, content, error }: EditOutputProps) {
|
|
|
35
57
|
);
|
|
36
58
|
}
|
|
37
59
|
|
|
38
|
-
const diff = computeDiff(
|
|
60
|
+
const diff = computeDiff(before, after);
|
|
39
61
|
|
|
40
62
|
if (diff.lines.length === 0 && diff.totalOldLines > 0 && diff.totalNewLines > 0) {
|
|
41
63
|
return (
|
|
42
64
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
43
|
-
<Text color=
|
|
44
|
-
!
|
|
65
|
+
<Text color={theme.diff.warning} bold={true}>
|
|
66
|
+
! {verb}
|
|
45
67
|
</Text>
|
|
46
68
|
<Text dimColor={true}> {path}</Text>
|
|
47
69
|
<Text dimColor={true}>
|
|
@@ -54,35 +76,30 @@ export function EditOutput({ args, content, error }: EditOutputProps) {
|
|
|
54
76
|
if (diff.lines.length === 0) {
|
|
55
77
|
return (
|
|
56
78
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
57
|
-
<
|
|
58
|
-
✓ edit_file
|
|
59
|
-
</Text>
|
|
60
|
-
<Text dimColor={true}> {path}</Text>
|
|
79
|
+
<ToolHeader name={verb} subtitle={path} />
|
|
61
80
|
<Text dimColor={true}>No changes (content identical)</Text>
|
|
62
81
|
</Box>
|
|
63
82
|
);
|
|
64
83
|
}
|
|
65
84
|
|
|
66
|
-
const { lines, truncated } = renderDiff(diff,
|
|
85
|
+
const { lines, truncated } = renderDiff(diff, MAX_DIFF_LINES);
|
|
67
86
|
|
|
68
87
|
return (
|
|
69
88
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
70
|
-
<
|
|
71
|
-
✓ edit_file
|
|
72
|
-
</Text>
|
|
73
|
-
<Text dimColor={true}> {path}</Text>
|
|
89
|
+
<ToolHeader name={verb} subtitle={path} />
|
|
74
90
|
<Box flexDirection="column" flexShrink={0}>
|
|
75
|
-
{lines.map((line) => {
|
|
91
|
+
{lines.map((line, i) => {
|
|
76
92
|
let color: string | undefined;
|
|
77
|
-
if (line.startsWith('-')) color =
|
|
78
|
-
else if (line.startsWith('+')) color =
|
|
93
|
+
if (line.startsWith('-')) color = theme.diff.removed;
|
|
94
|
+
else if (line.startsWith('+')) color = theme.diff.added;
|
|
79
95
|
return (
|
|
80
|
-
|
|
96
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: diff lines may repeat (blank lines, braces); index disambiguates
|
|
97
|
+
<Text key={`${i}-${line}`} color={color} dimColor={color === undefined} wrap="wrap">
|
|
81
98
|
{line}
|
|
82
99
|
</Text>
|
|
83
100
|
);
|
|
84
101
|
})}
|
|
85
|
-
{truncated && <Text dimColor={true}>… (truncated,
|
|
102
|
+
{truncated && <Text dimColor={true}>… (truncated, {MAX_DIFF_LINES} line limit)</Text>}
|
|
86
103
|
</Box>
|
|
87
104
|
</Box>
|
|
88
105
|
);
|
|
@@ -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,28 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
3
|
+
|
|
4
|
+
interface ToolHeaderProps {
|
|
5
|
+
/** The tool name shown after the status icon. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Optional subtitle (typically the file path or command). */
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
/** When true, render with the failure styling. */
|
|
10
|
+
error?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Shared header used by every tool-output renderer (read/write/edit/bash).
|
|
15
|
+
* Centralizes the ✓/✗ glyphs, color choice, and subtitle formatting so each
|
|
16
|
+
* specific component doesn't have to re-implement the same layout.
|
|
17
|
+
*/
|
|
18
|
+
export function ToolHeader({ name, subtitle, error = false }: ToolHeaderProps) {
|
|
19
|
+
const theme = useTheme();
|
|
20
|
+
return (
|
|
21
|
+
<Box flexDirection="column" flexShrink={0}>
|
|
22
|
+
<Text color={error ? theme.tool.error : theme.tool.success} bold={true}>
|
|
23
|
+
{error ? '✗' : '✓'} {name}
|
|
24
|
+
</Text>
|
|
25
|
+
{subtitle && <Text dimColor={true}> {subtitle}</Text>}
|
|
26
|
+
</Box>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -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>
|