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.
Files changed (104) hide show
  1. package/README.md +49 -5
  2. package/bin/mu.js +1 -1
  3. package/package.json +17 -4
  4. package/prompts/SYSTEM.md +16 -0
  5. package/src/app/shutdown.ts +94 -0
  6. package/src/app/startApp.ts +43 -0
  7. package/src/cli/args.ts +131 -0
  8. package/src/{install.ts → cli/install.ts} +19 -15
  9. package/src/config/index.test.ts +77 -0
  10. package/src/config/index.ts +199 -0
  11. package/src/main.ts +4 -0
  12. package/src/plugin.ts +96 -0
  13. package/src/runtime/codingTools/bash.ts +114 -0
  14. package/src/runtime/codingTools/edit-file.ts +60 -0
  15. package/src/runtime/codingTools/index.ts +39 -0
  16. package/src/runtime/codingTools/read-file.ts +83 -0
  17. package/src/runtime/codingTools/utils.ts +21 -0
  18. package/src/runtime/codingTools/write-file.ts +42 -0
  19. package/src/runtime/createRegistry.test.ts +146 -0
  20. package/src/runtime/createRegistry.ts +163 -0
  21. package/src/runtime/messageBus.test.ts +62 -0
  22. package/src/runtime/messageBus.ts +78 -0
  23. package/src/runtime/pluginLoader.ts +122 -0
  24. package/src/sessions/index.test.ts +66 -0
  25. package/src/sessions/index.ts +183 -0
  26. package/src/sessions/peek.test.ts +88 -0
  27. package/src/sessions/project.ts +51 -0
  28. package/src/tui/channel/tuiChannel.test.ts +107 -0
  29. package/src/tui/channel/tuiChannel.ts +49 -0
  30. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  31. package/src/tui/chat/MessageRendererContext.ts +44 -0
  32. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  33. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  34. package/src/tui/chat/useAttachment.ts +74 -0
  35. package/src/tui/chat/useChat.ts +106 -0
  36. package/src/tui/chat/useChatPanel.ts +98 -0
  37. package/src/tui/chat/useChatSession.ts +284 -0
  38. package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
  39. package/src/tui/chat/usePluginStatus.ts +44 -0
  40. package/src/tui/chat/useSessionPersistence.ts +68 -0
  41. package/src/tui/chat/useStatusSegments.ts +62 -0
  42. package/src/tui/components/chat/ChatPanel.tsx +20 -40
  43. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  44. package/src/tui/components/chat/Pickers.tsx +2 -2
  45. package/src/tui/components/messageView.tsx +72 -0
  46. package/src/tui/components/messages/EditOutput.tsx +47 -30
  47. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  48. package/src/tui/components/messages/ToolHeader.tsx +28 -0
  49. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  50. package/src/tui/components/messages/assistantMessage.tsx +17 -2
  51. package/src/tui/components/messages/messageItem.tsx +23 -16
  52. package/src/tui/components/messages/reasoningBlock.tsx +4 -2
  53. package/src/tui/components/messages/streamingOutput.tsx +5 -1
  54. package/src/tui/components/messages/toolCallBlock.tsx +61 -38
  55. package/src/tui/components/messages/userMessage.tsx +21 -6
  56. package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
  57. package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
  58. package/src/tui/components/primitives/pickerModal.tsx +47 -0
  59. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  60. package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
  61. package/src/tui/components/statusBar.tsx +32 -0
  62. package/src/tui/components/ui/dialogLayer.tsx +32 -13
  63. package/src/tui/context/ThemeContext.tsx +18 -0
  64. package/src/tui/hooks/useScroll.ts +11 -3
  65. package/src/tui/input/InputBox.tsx +6 -0
  66. package/src/tui/input/InputBoxView.tsx +237 -0
  67. package/src/tui/input/commands.test.ts +51 -0
  68. package/src/tui/input/commands.ts +44 -0
  69. package/src/tui/input/cursor.test.ts +136 -0
  70. package/src/tui/input/cursor.ts +214 -0
  71. package/src/tui/input/dumpContext.ts +107 -0
  72. package/src/tui/input/sanitize.ts +33 -0
  73. package/src/tui/input/useCommandExecutor.ts +32 -0
  74. package/src/tui/input/useInputBox.ts +207 -0
  75. package/src/tui/input/useInputHandler.ts +453 -0
  76. package/src/tui/input/useMentionPicker.ts +121 -0
  77. package/src/tui/input/usePluginShortcuts.ts +29 -0
  78. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  79. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  80. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  81. package/src/tui/renderApp.tsx +43 -0
  82. package/src/tui/theme/index.ts +1 -0
  83. package/src/tui/theme/merge.test.ts +49 -0
  84. package/src/tui/theme/merge.ts +43 -0
  85. package/src/tui/theme/presets.ts +79 -0
  86. package/src/tui/theme/types.ts +116 -0
  87. package/src/utils/clipboard.ts +97 -0
  88. package/src/utils/diff.test.ts +56 -0
  89. package/src/cli.ts +0 -96
  90. package/src/clipboard.ts +0 -62
  91. package/src/config.ts +0 -116
  92. package/src/main.tsx +0 -147
  93. package/src/project.ts +0 -32
  94. package/src/session.ts +0 -95
  95. package/src/tui/commands.ts +0 -33
  96. package/src/tui/components/chatLayout.tsx +0 -192
  97. package/src/tui/components/inputBox.tsx +0 -153
  98. package/src/tui/hooks/useInputHandler.ts +0 -268
  99. package/src/tui/useChat.ts +0 -52
  100. package/src/tui/useChatSession.ts +0 -155
  101. package/src/tui/useChatUI.ts +0 -51
  102. package/tsconfig.json +0 -10
  103. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  104. /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 { 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 type { InkUIService } from '../../services/uiService';
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 = useChat(config, registry, initialMessages);
24
- const { width, height } = useTerminalSize();
25
- const viewRef = useRef<InkDOMElement>(null);
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
- <ChatPanelBody
45
- width={width}
46
- height={height}
47
- viewRef={viewRef}
48
- contentRef={contentRef}
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 { StatusSegment } from 'mu-agents';
3
- import { useEffect, useState } from 'react';
4
- import { useChatContext } from '../../context/chat';
5
- import type { InkUIService, ToastRequest } from '../../services/uiService';
6
- import { MessageView, StatusBar } from '../chatLayout';
7
- import { InputBox } from '../inputBox';
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 LayoutProps {
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
- const TOAST_LEVEL_COLORS: Record<string, string> = {
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={session.messages}
57
- streaming={session.streaming}
58
- stream={session.stream}
59
- error={session.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={session.onSend}
52
+ onSubmit={props.onSubmit}
66
53
  onScrollUp={props.onScrollUp}
67
54
  onScrollDown={props.onScrollDown}
68
55
  isActive={props.isActive}
69
- model={models.currentModel}
70
- history={session.inputHistory}
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={dismiss} />
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 '../../context/chat';
3
- import { PickerModal } from '../chatLayout';
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 { computeDiff, renderDiff } from '../../../diff';
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
- export function EditOutput({ args, content, error }: EditOutputProps) {
11
- let path = '(unknown)';
12
- let oldString = '';
13
- let newString = '';
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
- path = parsed.path ?? '(unknown)';
18
- oldString = parsed.old_string ?? '';
19
- newString = parsed.new_string ?? '';
34
+ return {
35
+ path: parsed[pathField] ?? '(unknown)',
36
+ before: parsed[fromField] ?? '',
37
+ after: parsed[toField] ?? '',
38
+ };
20
39
  } catch {
21
- // ignore
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
- <Text color="red" bold={true}>
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(oldString, newString);
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="yellow" bold={true}>
44
- ! edit_file
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
- <Text color="green" bold={true}>
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, 30);
85
+ const { lines, truncated } = renderDiff(diff, MAX_DIFF_LINES);
67
86
 
68
87
  return (
69
88
  <Box flexDirection="column" flexShrink={0} marginBottom={1}>
70
- <Text color="green" bold={true}>
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 = 'red';
78
- else if (line.startsWith('+')) color = 'green';
93
+ if (line.startsWith('-')) color = theme.diff.removed;
94
+ else if (line.startsWith('+')) color = theme.diff.added;
79
95
  return (
80
- <Text key={line} color={color} dimColor={color === undefined} wrap="wrap">
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, 30 line limit)</Text>}
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
- export function ReadOutput({ args, error }: ReadOutputProps) {
9
- let paths: string[] = ['(unknown)'];
10
- let startLine: number | undefined;
11
- let endLine: number | undefined;
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
- paths = Array.isArray(p) ? p : [p];
17
- startLine = parsed.start;
18
- endLine = parsed.end;
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
- // ignore
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
- <Text dimColor={true} wrap="wrap">
28
- <Text color={error ? 'red' : 'green'} bold={true}>
29
- {error ? '✗' : '✓'} read_file
30
- </Text>{' '}
31
- {paths.length > 1 ? `(${paths.length} files)` : ''}
32
- {paths.length > 1 ? '\n' : ''}
33
- {paths.map((p) => (
34
- <Text key={p} dimColor={true} wrap="wrap">
35
- {paths.length > 1 ? ' • ' : ''}
36
- {p}
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
- export function WriteOutput({ args, content, error, expanded }: WriteOutputProps) {
13
- let path = '(unknown)';
12
+ function parsePath(args: string): string {
14
13
  try {
15
14
  const parsed = JSON.parse(args);
16
- path = parsed.path ?? '(unknown)';
15
+ return parsed.path ?? '(unknown)';
17
16
  } catch {
18
- // ignore
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
- <Text color="red" bold={true}>
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
- <Text color="green" bold={true}>
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
- {expanded ? content : preview}
49
+ {hasMore ? preview : content}
52
50
  </Text>
53
- {hasMore && !expanded && <Text dimColor={true}>… ({totalLines - PREVIEW_LINES} more lines)</Text>}
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>