mu-coding 0.1.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.
Files changed (40) hide show
  1. package/README.md +81 -0
  2. package/bin/mu.js +2 -0
  3. package/package.json +19 -0
  4. package/src/cli.ts +90 -0
  5. package/src/clipboard.ts +62 -0
  6. package/src/config.ts +116 -0
  7. package/src/diff.ts +81 -0
  8. package/src/main.tsx +80 -0
  9. package/src/project.ts +32 -0
  10. package/src/session.ts +95 -0
  11. package/src/singleShot.ts +42 -0
  12. package/src/tui/commands.ts +19 -0
  13. package/src/tui/components/chat/ChatPanel.tsx +55 -0
  14. package/src/tui/components/chat/ChatPanelBody.tsx +67 -0
  15. package/src/tui/components/chat/Pickers.tsx +44 -0
  16. package/src/tui/components/chatLayout.tsx +192 -0
  17. package/src/tui/components/inputBox.tsx +152 -0
  18. package/src/tui/components/messages/EditOutput.tsx +89 -0
  19. package/src/tui/components/messages/ReadOutput.tsx +43 -0
  20. package/src/tui/components/messages/WriteOutput.tsx +68 -0
  21. package/src/tui/components/messages/assistantMessage.tsx +24 -0
  22. package/src/tui/components/messages/messageItem.tsx +36 -0
  23. package/src/tui/components/messages/reasoningBlock.tsx +14 -0
  24. package/src/tui/components/messages/streamingOutput.tsx +14 -0
  25. package/src/tui/components/messages/toolCallBlock.tsx +99 -0
  26. package/src/tui/components/messages/userMessage.tsx +29 -0
  27. package/src/tui/components/ui/dropdown.tsx +96 -0
  28. package/src/tui/components/ui/modal.tsx +45 -0
  29. package/src/tui/components/ui/toast.tsx +45 -0
  30. package/src/tui/context/chat.ts +10 -0
  31. package/src/tui/hooks/useInputHandler.ts +257 -0
  32. package/src/tui/hooks/useScroll.ts +56 -0
  33. package/src/tui/hooks/useTerminal.ts +40 -0
  34. package/src/tui/hooks/useUI.ts +15 -0
  35. package/src/tui/useAbort.ts +68 -0
  36. package/src/tui/useChat.ts +52 -0
  37. package/src/tui/useChatSession.ts +155 -0
  38. package/src/tui/useChatUI.ts +51 -0
  39. package/src/tui/useModelList.ts +49 -0
  40. package/tsconfig.json +10 -0
@@ -0,0 +1,42 @@
1
+ import type { PluginRegistry } from 'mu-agents';
2
+ import type { ChatMessage, ProviderConfig } from 'mu-provider';
3
+ import { listModels, streamChat } from 'mu-provider';
4
+
5
+ export async function runSingleShot(prompt: string, config: ProviderConfig, registry: PluginRegistry): Promise<void> {
6
+ const messages: ChatMessage[] = [{ role: 'user', content: prompt }];
7
+
8
+ let resolvedModel = config.model;
9
+ if (!resolvedModel) {
10
+ const models = await listModels(config.baseUrl);
11
+ if (models.length === 0) {
12
+ console.error('Error: no models available at', config.baseUrl);
13
+ process.exit(1);
14
+ }
15
+ resolvedModel = models[0].id;
16
+ }
17
+
18
+ const toolDefinitions = registry.getToolDefinitions();
19
+
20
+ let tokens = 0;
21
+ let hasToolCalls = false;
22
+ process.stdout.write('mu: ');
23
+ for await (const chunk of streamChat(messages, config, resolvedModel, {
24
+ onUsage: (usage) => {
25
+ tokens = usage.totalTokens;
26
+ },
27
+ tools: toolDefinitions,
28
+ })) {
29
+ if (chunk.type === 'content') {
30
+ process.stdout.write(chunk.text);
31
+ } else if (chunk.type === 'tool_call') {
32
+ hasToolCalls = true;
33
+ }
34
+ }
35
+ if (hasToolCalls) {
36
+ process.stderr.write('\n[tool calls made — use interactive mode for tool execution]\n');
37
+ }
38
+ process.stdout.write('\n');
39
+ if (tokens > 0) {
40
+ process.stderr.write(`(${tokens} tokens)\n`);
41
+ }
42
+ }
@@ -0,0 +1,19 @@
1
+ export interface SlashCommand {
2
+ name: string;
3
+ description: string;
4
+ action: string;
5
+ }
6
+
7
+ const COMMANDS: SlashCommand[] = [
8
+ { name: '/model', description: 'Select a model', action: 'model' },
9
+ { name: '/sessions', description: 'List project sessions', action: 'sessions' },
10
+ { name: '/new', description: 'New conversation', action: 'new' },
11
+ ];
12
+
13
+ export function matchCommands(input: string): SlashCommand[] {
14
+ if (!input.startsWith('/')) {
15
+ return [];
16
+ }
17
+ const q = input.toLowerCase();
18
+ return COMMANDS.filter((cmd) => cmd.name.startsWith(q));
19
+ }
@@ -0,0 +1,55 @@
1
+ import { type DOMElement as InkDOMElement, useInput } from 'ink';
2
+ import type { PluginRegistry } from 'mu-agents';
3
+ import type { ChatMessage, ProviderConfig } from 'mu-provider';
4
+ import { useRef } from 'react';
5
+ import { ChatContext } from '../../context/chat';
6
+ import { useScroll } from '../../hooks/useScroll';
7
+ import { useMeasure, useTerminalSize } from '../../hooks/useTerminal';
8
+ import { useChat } from '../../useChat';
9
+ import { ChatPanelBody } from './ChatPanelBody';
10
+
11
+ export function ChatPanel({
12
+ config,
13
+ initialMessages,
14
+ registry,
15
+ }: {
16
+ config: ProviderConfig;
17
+ initialMessages?: ChatMessage[];
18
+ registry: PluginRegistry;
19
+ }) {
20
+ const ctx = useChat(config, registry, initialMessages);
21
+ const { width, height } = useTerminalSize();
22
+ const viewRef = useRef<InkDOMElement>(null);
23
+ const contentRef = useRef<InkDOMElement>(null);
24
+ const { viewHeight, contentHeight } = useMeasure(
25
+ viewRef,
26
+ contentRef,
27
+ [
28
+ ctx.session.messages.length,
29
+ ...ctx.session.messages.map((m) => m.content.length),
30
+ ctx.session.stream.text.length,
31
+ ctx.session.stream.reasoning?.length ?? 0,
32
+ ].join('|'),
33
+ );
34
+ const { scrollOffset, onScrollUp, onScrollDown } = useScroll(contentHeight, viewHeight);
35
+
36
+ const anyModalOpen = ctx.toggles.showModelPicker || ctx.toggles.showSessionPicker;
37
+ useInput((input, key) => key.ctrl && input === 'c' && ctx.abort.onCtrlC(), { isActive: anyModalOpen });
38
+
39
+ return (
40
+ <ChatContext.Provider value={ctx}>
41
+ <ChatPanelBody
42
+ width={width}
43
+ height={height}
44
+ viewRef={viewRef}
45
+ contentRef={contentRef}
46
+ scrollOffset={scrollOffset}
47
+ viewHeight={viewHeight}
48
+ contentHeight={contentHeight}
49
+ isActive={!anyModalOpen}
50
+ onScrollUp={onScrollUp}
51
+ onScrollDown={onScrollDown}
52
+ />
53
+ </ChatContext.Provider>
54
+ );
55
+ }
@@ -0,0 +1,67 @@
1
+ import { Box, type DOMElement as InkDOMElement } from 'ink';
2
+ import type { StatusSegment } from 'mu-agents';
3
+ import { useEffect, useState } from 'react';
4
+ import { useChatContext } from '../../context/chat';
5
+ import { MessageView, StatusBar } from '../chatLayout';
6
+ import { InputBox } from '../inputBox';
7
+ import { Pickers } from './Pickers';
8
+
9
+ interface LayoutProps {
10
+ width: number;
11
+ height: number;
12
+ viewRef: React.RefObject<InkDOMElement | null>;
13
+ contentRef: React.RefObject<InkDOMElement | null>;
14
+ scrollOffset: number;
15
+ viewHeight: number;
16
+ contentHeight: number;
17
+ isActive: boolean;
18
+ onScrollUp: () => void;
19
+ onScrollDown: () => void;
20
+ }
21
+
22
+ export function ChatPanelBody(props: LayoutProps) {
23
+ const { session, models, abort, registry } = useChatContext();
24
+ const [pluginStatus, setPluginStatus] = useState<StatusSegment[]>([]);
25
+
26
+ useEffect(() => {
27
+ const refresh = () => setPluginStatus(registry.getStatusSegments());
28
+ refresh();
29
+ const interval = setInterval(refresh, 2000);
30
+ return () => clearInterval(interval);
31
+ }, [registry]);
32
+
33
+ return (
34
+ <Box flexDirection="column" height={props.height} width={props.width}>
35
+ <MessageView
36
+ viewRef={props.viewRef}
37
+ contentRef={props.contentRef}
38
+ messages={session.messages}
39
+ streaming={session.streaming}
40
+ stream={session.stream}
41
+ error={session.error}
42
+ scrollOffset={props.scrollOffset}
43
+ viewHeight={props.viewHeight}
44
+ contentHeight={props.contentHeight}
45
+ />
46
+ <InputBox
47
+ onSubmit={session.onSend}
48
+ onScrollUp={props.onScrollUp}
49
+ onScrollDown={props.onScrollDown}
50
+ isActive={props.isActive}
51
+ model={models.currentModel}
52
+ history={session.inputHistory}
53
+ />
54
+ <StatusBar
55
+ streaming={session.streaming}
56
+ abortWarning={abort.abortWarning}
57
+ quitWarning={abort.quitWarning}
58
+ error={session.error}
59
+ modelError={models.modelError}
60
+ totalTokens={session.stream.totalTokens}
61
+ tokensPerSecond={session.stream.tps}
62
+ pluginStatus={pluginStatus}
63
+ />
64
+ <Pickers />
65
+ </Box>
66
+ );
67
+ }
@@ -0,0 +1,44 @@
1
+ import { useMemo } from 'react';
2
+ import { useChatContext } from '../../context/chat';
3
+ import { PickerModal } from '../chatLayout';
4
+
5
+ export function Pickers() {
6
+ const { toggles, models, sessions, session } = useChatContext();
7
+ const sessionItems = useMemo(
8
+ () =>
9
+ sessions.map((s) => ({
10
+ label: s.preview,
11
+ value: s.path,
12
+ description: `${s.messageCount} msgs`,
13
+ })),
14
+ [sessions],
15
+ );
16
+
17
+ return (
18
+ <>
19
+ <PickerModal
20
+ visible={toggles.showModelPicker}
21
+ title="Select model"
22
+ items={models.models.map((m) => ({ label: m.id, value: m.id }))}
23
+ placeholder="Search models..."
24
+ onSelect={(id) => {
25
+ models.selectModel(id);
26
+ toggles.onTogglePicker();
27
+ }}
28
+ onCancel={toggles.onTogglePicker}
29
+ />
30
+ <PickerModal
31
+ visible={toggles.showSessionPicker}
32
+ title={`Sessions · ${sessions[0]?.project ?? 'project'}`}
33
+ items={sessionItems}
34
+ placeholder="Search sessions..."
35
+ emptyMessage="No sessions found for this project"
36
+ onSelect={(p) => {
37
+ session.onLoadSession(p);
38
+ toggles.onToggleSessionPicker();
39
+ }}
40
+ onCancel={toggles.onToggleSessionPicker}
41
+ />
42
+ </>
43
+ );
44
+ }
@@ -0,0 +1,192 @@
1
+ import type { DOMElement } from 'ink';
2
+ import { Box, Text } from 'ink';
3
+ import type { StatusSegment } from 'mu-agents';
4
+ import type { ChatMessage } from 'mu-provider';
5
+ import type React from 'react';
6
+ import { useSpinner } from '../hooks/useUI';
7
+ import type { StreamState } from '../useChatSession';
8
+ import { MessageItem } from './messages/messageItem';
9
+ import { StreamingOutput } from './messages/streamingOutput';
10
+ import { Dropdown } from './ui/dropdown';
11
+ import { Modal } from './ui/modal';
12
+
13
+ function Scrollbar({
14
+ viewHeight,
15
+ contentHeight,
16
+ scrollOffset,
17
+ }: {
18
+ viewHeight: number;
19
+ contentHeight: number;
20
+ scrollOffset: number;
21
+ }) {
22
+ if (contentHeight <= viewHeight || viewHeight < 1) {
23
+ return null;
24
+ }
25
+ const maxScroll = contentHeight - viewHeight;
26
+ const ratio = scrollOffset / maxScroll;
27
+ const thumbSize = Math.max(1, Math.round((viewHeight / contentHeight) * viewHeight));
28
+ const thumbPos = Math.round(ratio * (viewHeight - thumbSize));
29
+
30
+ const track = Array.from({ length: viewHeight }, (_, i) => (i >= thumbPos && i < thumbPos + thumbSize ? '┃' : '│'));
31
+
32
+ return (
33
+ <Box flexDirection="column" flexShrink={0} width={1}>
34
+ <Text>{track.join('')}</Text>
35
+ </Box>
36
+ );
37
+ }
38
+
39
+ export function StatusBar({
40
+ streaming,
41
+ abortWarning,
42
+ quitWarning,
43
+ error,
44
+ modelError,
45
+ totalTokens,
46
+ tokensPerSecond,
47
+ pluginStatus,
48
+ }: {
49
+ streaming: boolean;
50
+ abortWarning: boolean;
51
+ quitWarning: boolean;
52
+ error: string | null;
53
+ modelError: string | null;
54
+ totalTokens: number;
55
+ tokensPerSecond: number;
56
+ pluginStatus?: StatusSegment[];
57
+ }) {
58
+ const spinner = useSpinner(streaming);
59
+ const segments: Array<{ text: string; color?: string; dim?: boolean }> = [];
60
+ if (streaming) {
61
+ segments.push({ text: `${spinner} generating`, color: 'yellow' });
62
+ }
63
+ if (tokensPerSecond > 0) {
64
+ segments.push({ text: `${tokensPerSecond} tok/s`, dim: true });
65
+ }
66
+ if (abortWarning) {
67
+ segments.push({ text: 'Esc again to stop', color: 'yellow' });
68
+ } else if (quitWarning) {
69
+ segments.push({ text: 'Ctrl+C again to quit', color: 'yellow' });
70
+ } else if (streaming) {
71
+ segments.push({ text: 'Esc to stop', dim: true });
72
+ }
73
+ if (error) {
74
+ segments.push({ text: '⚠ error', color: 'red' });
75
+ }
76
+ if (modelError) {
77
+ segments.push({ text: `⚠ ${modelError}`, color: 'red' });
78
+ }
79
+
80
+ if (totalTokens > 0) {
81
+ segments.push({ text: `${formatTokens(totalTokens)} tokens`, dim: true });
82
+ }
83
+ if (pluginStatus) {
84
+ segments.push(...pluginStatus);
85
+ }
86
+
87
+ return (
88
+ <Box flexShrink={0} paddingX={1} marginY={1}>
89
+ <Box justifyContent="flex-end" flexGrow={1}>
90
+ {segments.map((seg, i) => (
91
+ // biome-ignore lint/suspicious/noArrayIndexKey: positional static list
92
+ <Box key={i}>
93
+ {i > 0 && <Text dimColor={true}> · </Text>}
94
+ <Text color={seg.color} dimColor={seg.dim}>
95
+ {seg.text}
96
+ </Text>
97
+ </Box>
98
+ ))}
99
+ </Box>
100
+ </Box>
101
+ );
102
+ }
103
+
104
+ function formatTokens(tokens: number): string {
105
+ if (tokens >= 1_000_000) {
106
+ return `${(tokens / 1_000_000).toFixed(1)}M`;
107
+ }
108
+ if (tokens >= 1_000) {
109
+ return `${(tokens / 1_000).toFixed(1)}k`;
110
+ }
111
+ return String(tokens);
112
+ }
113
+
114
+ interface PickerItem {
115
+ label: string;
116
+ value: string;
117
+ description?: string;
118
+ }
119
+
120
+ export function PickerModal({
121
+ visible,
122
+ title,
123
+ items,
124
+ placeholder,
125
+ emptyMessage,
126
+ onSelect,
127
+ onCancel,
128
+ }: {
129
+ visible: boolean;
130
+ title: string;
131
+ items: PickerItem[];
132
+ placeholder: string;
133
+ emptyMessage?: string;
134
+ onSelect: (value: string) => void;
135
+ onCancel?: () => void;
136
+ }) {
137
+ return (
138
+ <Modal visible={visible} title={title}>
139
+ {items.length === 0 && emptyMessage ? (
140
+ <Text dimColor={true} italic={true}>
141
+ {emptyMessage}
142
+ </Text>
143
+ ) : (
144
+ <Dropdown
145
+ items={items}
146
+ placeholder={placeholder}
147
+ isActive={visible}
148
+ onSelect={(item) => onSelect(item.value)}
149
+ onCancel={onCancel}
150
+ />
151
+ )}
152
+ </Modal>
153
+ );
154
+ }
155
+
156
+ export function MessageView({
157
+ viewRef,
158
+ contentRef,
159
+ messages,
160
+ streaming,
161
+ stream,
162
+ error,
163
+ scrollOffset,
164
+ viewHeight,
165
+ contentHeight,
166
+ }: {
167
+ viewRef: React.RefObject<DOMElement | null>;
168
+ contentRef: React.RefObject<DOMElement | null>;
169
+ messages: ChatMessage[];
170
+ streaming: boolean;
171
+ stream: StreamState;
172
+ error: string | null;
173
+ scrollOffset: number;
174
+ viewHeight: number;
175
+ contentHeight: number;
176
+ }) {
177
+ return (
178
+ <Box flexGrow={1} overflow="hidden">
179
+ <Box ref={viewRef} flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
180
+ <Box ref={contentRef} flexDirection="column" flexShrink={0} marginTop={-scrollOffset}>
181
+ {messages.map((msg, i) => (
182
+ // biome-ignore lint/suspicious/noArrayIndexKey: messages have no stable id
183
+ <MessageItem key={i} msg={msg} messages={messages} index={i} />
184
+ ))}
185
+ {streaming && <StreamingOutput currentText={stream.text} currentReasoning={stream.reasoning} />}
186
+ {error && <Text color="red">Error: {error}</Text>}
187
+ </Box>
188
+ </Box>
189
+ <Scrollbar viewHeight={viewHeight} contentHeight={contentHeight} scrollOffset={scrollOffset} />
190
+ </Box>
191
+ );
192
+ }
@@ -0,0 +1,152 @@
1
+ import { Box, Text } from 'ink';
2
+ import type { SlashCommand } from '../commands';
3
+ import { useChatContext } from '../context/chat';
4
+ import { type InputActions, useInputHandler } from '../hooks/useInputHandler';
5
+
6
+ interface InputBoxProps {
7
+ onSubmit: (text: string) => void;
8
+ onScrollUp?: () => void;
9
+ onScrollDown?: () => void;
10
+ isActive?: boolean;
11
+ model?: string;
12
+ history?: string[];
13
+ }
14
+
15
+ function CommandHints({ commands, selectedIndex }: { commands: SlashCommand[]; selectedIndex: number }) {
16
+ if (!commands.length) {
17
+ return null;
18
+ }
19
+ return (
20
+ <Box flexDirection="column" marginBottom={1}>
21
+ {commands.map((cmd, i) => (
22
+ <Box key={cmd.name} paddingX={1}>
23
+ <Text color={i === selectedIndex ? 'green' : undefined} bold={i === selectedIndex}>
24
+ {i === selectedIndex ? '▸ ' : ' '}
25
+ {cmd.name}
26
+ </Text>
27
+ <Text dimColor={true}> {cmd.description}</Text>
28
+ </Box>
29
+ ))}
30
+ </Box>
31
+ );
32
+ }
33
+
34
+ function InputFooter({
35
+ model,
36
+ attachmentName,
37
+ attachmentError,
38
+ hasContent,
39
+ isCommandMode,
40
+ }: {
41
+ model: string;
42
+ attachmentName: string | null;
43
+ attachmentError: string | null;
44
+ hasContent: boolean;
45
+ isCommandMode: boolean;
46
+ }) {
47
+ const hint = hasContent
48
+ ? isCommandMode
49
+ ? '↑↓ select · Enter execute'
50
+ : 'Enter to send · Shift+Enter for newline'
51
+ : 'Type / for commands';
52
+
53
+ return (
54
+ <Box justifyContent="space-between">
55
+ <Box gap={1}>
56
+ {model && (
57
+ <Text color="white" bold={true}>
58
+ {model}
59
+ </Text>
60
+ )}
61
+ {attachmentName && <Text color="cyan">📷 {attachmentName}</Text>}
62
+ {attachmentError && <Text color="red">{attachmentError}</Text>}
63
+ </Box>
64
+ <Text dimColor={true}>{hint}</Text>
65
+ </Box>
66
+ );
67
+ }
68
+
69
+ function InputDisplay({
70
+ value,
71
+ isCommandMode,
72
+ streaming,
73
+ isActive,
74
+ }: {
75
+ value: string;
76
+ isCommandMode: boolean;
77
+ streaming: boolean;
78
+ isActive: boolean;
79
+ }) {
80
+ const showCursor = !streaming && isActive;
81
+ if (!value.length) {
82
+ return <Text>{showCursor && <Text inverse={true}>▎</Text>}</Text>;
83
+ }
84
+ const lines = value.split('\n');
85
+ return (
86
+ <>
87
+ {lines.map((line, i) => (
88
+ // biome-ignore lint/suspicious/noArrayIndexKey: static input display lines
89
+ <Text key={`${i}-${line}`} wrap="wrap">
90
+ {i === 0 && isCommandMode ? <Text color="green">{line}</Text> : line}
91
+ {i === lines.length - 1 && showCursor && <Text inverse={true}>▎</Text>}
92
+ </Text>
93
+ ))}
94
+ </>
95
+ );
96
+ }
97
+
98
+ export function InputBox({
99
+ onSubmit,
100
+ onScrollUp,
101
+ onScrollDown,
102
+ isActive = true,
103
+ model = '',
104
+ history = [],
105
+ }: InputBoxProps) {
106
+ const { session, toggles, attachment, models, abort } = useChatContext();
107
+
108
+ const actions: InputActions = {
109
+ onCtrlC: abort.onCtrlC,
110
+ onEsc: abort.onEsc,
111
+ onPaste: attachment.onPaste,
112
+ onNew: session.onNew,
113
+ onCycleModel: models.cycleModel,
114
+ onTogglePicker: toggles.onTogglePicker,
115
+ onToggleSessionPicker: toggles.onToggleSessionPicker,
116
+ onScrollUp,
117
+ onScrollDown,
118
+ modelCount: models.models.length,
119
+ };
120
+
121
+ const { value, commands, cmdIndex, isCommandMode } = useInputHandler({
122
+ isActive,
123
+ streaming: session.streaming,
124
+ history,
125
+ actions,
126
+ onSubmit,
127
+ });
128
+
129
+ return (
130
+ <Box
131
+ flexDirection="column"
132
+ flexShrink={0}
133
+ backgroundColor="#222222"
134
+ paddingX={1}
135
+ paddingY={1}
136
+ marginX={1}
137
+ marginTop={1}
138
+ >
139
+ {isCommandMode && <CommandHints commands={commands} selectedIndex={cmdIndex} />}
140
+ <Box flexDirection="column" minHeight={2}>
141
+ <InputDisplay value={value} isCommandMode={isCommandMode} streaming={session.streaming} isActive={isActive} />
142
+ </Box>
143
+ <InputFooter
144
+ model={model}
145
+ attachmentName={attachment.attachment?.name ?? null}
146
+ attachmentError={attachment.attachmentError}
147
+ hasContent={value.length > 0}
148
+ isCommandMode={isCommandMode}
149
+ />
150
+ </Box>
151
+ );
152
+ }
@@ -0,0 +1,89 @@
1
+ import { Box, Text } from 'ink';
2
+ import { computeDiff, renderDiff } from '../../../diff';
3
+
4
+ interface EditOutputProps {
5
+ args: string;
6
+ content: string;
7
+ error: boolean;
8
+ }
9
+
10
+ export function EditOutput({ args, content, error }: EditOutputProps) {
11
+ let path = '(unknown)';
12
+ let oldString = '';
13
+ let newString = '';
14
+
15
+ try {
16
+ const parsed = JSON.parse(args);
17
+ path = parsed.path ?? '(unknown)';
18
+ oldString = parsed.old_string ?? '';
19
+ newString = parsed.new_string ?? '';
20
+ } catch {
21
+ // ignore
22
+ }
23
+
24
+ if (error) {
25
+ return (
26
+ <Box flexDirection="column" flexShrink={0} marginBottom={1}>
27
+ <Text color="red" bold={true}>
28
+ ✗ edit_file
29
+ </Text>
30
+ <Text dimColor={true}> {path}</Text>
31
+ <Text dimColor={true} wrap="wrap">
32
+ {content}
33
+ </Text>
34
+ </Box>
35
+ );
36
+ }
37
+
38
+ const diff = computeDiff(oldString, newString);
39
+
40
+ if (diff.lines.length === 0 && diff.totalOldLines > 0 && diff.totalNewLines > 0) {
41
+ return (
42
+ <Box flexDirection="column" flexShrink={0} marginBottom={1}>
43
+ <Text color="yellow" bold={true}>
44
+ ! edit_file
45
+ </Text>
46
+ <Text dimColor={true}> {path}</Text>
47
+ <Text dimColor={true}>
48
+ Diff too large to display ({diff.totalOldLines} → {diff.totalNewLines} lines)
49
+ </Text>
50
+ </Box>
51
+ );
52
+ }
53
+
54
+ if (diff.lines.length === 0) {
55
+ return (
56
+ <Box flexDirection="column" flexShrink={0} marginBottom={1}>
57
+ <Text color="green" bold={true}>
58
+ ✓ edit_file
59
+ </Text>
60
+ <Text dimColor={true}> {path}</Text>
61
+ <Text dimColor={true}>No changes (content identical)</Text>
62
+ </Box>
63
+ );
64
+ }
65
+
66
+ const { lines, truncated } = renderDiff(diff, 30);
67
+
68
+ return (
69
+ <Box flexDirection="column" flexShrink={0} marginBottom={1}>
70
+ <Text color="green" bold={true}>
71
+ ✓ edit_file
72
+ </Text>
73
+ <Text dimColor={true}> {path}</Text>
74
+ <Box flexDirection="column" flexShrink={0}>
75
+ {lines.map((line) => {
76
+ let color: string | undefined;
77
+ if (line.startsWith('-')) color = 'red';
78
+ else if (line.startsWith('+')) color = 'green';
79
+ return (
80
+ <Text key={line} color={color} dimColor={color === undefined} wrap="wrap">
81
+ {line}
82
+ </Text>
83
+ );
84
+ })}
85
+ {truncated && <Text dimColor={true}>… (truncated, 30 line limit)</Text>}
86
+ </Box>
87
+ </Box>
88
+ );
89
+ }