mu-coding 0.2.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.
Files changed (72) hide show
  1. package/README.md +0 -2
  2. package/bin/mu.js +1 -1
  3. package/package.json +12 -4
  4. package/src/app/shutdown.ts +94 -0
  5. package/src/app/startApp.ts +40 -0
  6. package/src/cli/args.ts +128 -0
  7. package/src/{install.ts → cli/install.ts} +19 -15
  8. package/src/config/index.test.ts +51 -0
  9. package/src/config/index.ts +181 -0
  10. package/src/main.ts +4 -0
  11. package/src/runtime/createRegistry.ts +58 -0
  12. package/src/runtime/pluginLoader.ts +109 -0
  13. package/src/sessions/index.test.ts +66 -0
  14. package/src/sessions/index.ts +190 -0
  15. package/src/sessions/peek.test.ts +88 -0
  16. package/src/sessions/project.ts +51 -0
  17. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  18. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  19. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  20. package/src/tui/chat/useAttachment.ts +74 -0
  21. package/src/tui/{useChat.ts → chat/useChat.ts} +32 -6
  22. package/src/tui/chat/useChatPanel.ts +96 -0
  23. package/src/tui/chat/useChatSession.ts +115 -0
  24. package/src/tui/{useModelList.ts → chat/useModels.ts} +10 -1
  25. package/src/tui/chat/usePluginStatus.ts +44 -0
  26. package/src/tui/chat/useSessionPersistence.ts +57 -0
  27. package/src/tui/chat/useStatusSegments.ts +49 -0
  28. package/src/tui/chat/useStreamConsumer.ts +118 -0
  29. package/src/tui/components/chat/ChatPanel.tsx +12 -38
  30. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  31. package/src/tui/components/chat/Pickers.tsx +2 -2
  32. package/src/tui/components/messageView.tsx +70 -0
  33. package/src/tui/components/messages/EditOutput.tsx +42 -27
  34. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  35. package/src/tui/components/messages/ToolHeader.tsx +26 -0
  36. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  37. package/src/tui/components/messages/messageItem.tsx +4 -15
  38. package/src/tui/components/messages/toolCallBlock.tsx +56 -34
  39. package/src/tui/components/{ui → primitives}/dropdown.tsx +32 -7
  40. package/src/tui/components/primitives/pickerModal.tsx +45 -0
  41. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  42. package/src/tui/components/statusBar.tsx +25 -0
  43. package/src/tui/components/ui/dialogLayer.tsx +21 -7
  44. package/src/tui/hooks/useScroll.ts +11 -3
  45. package/src/tui/input/InputBox.tsx +6 -0
  46. package/src/tui/{components/inputBox.tsx → input/InputBoxView.tsx} +24 -49
  47. package/src/tui/input/commands.test.ts +49 -0
  48. package/src/tui/input/commands.ts +39 -0
  49. package/src/tui/input/sanitize.ts +33 -0
  50. package/src/tui/input/useCommandExecutor.ts +32 -0
  51. package/src/tui/input/useInputBox.ts +88 -0
  52. package/src/tui/{hooks → input}/useInputHandler.ts +21 -26
  53. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  54. package/src/tui/renderApp.tsx +30 -0
  55. package/src/utils/clipboard.ts +97 -0
  56. package/src/utils/diff.test.ts +56 -0
  57. package/src/cli.ts +0 -92
  58. package/src/clipboard.ts +0 -62
  59. package/src/config.ts +0 -116
  60. package/src/main.tsx +0 -161
  61. package/src/project.ts +0 -32
  62. package/src/session.ts +0 -95
  63. package/src/singleShot.ts +0 -42
  64. package/src/tui/commands.ts +0 -33
  65. package/src/tui/components/chatLayout.tsx +0 -192
  66. package/src/tui/useChatSession.ts +0 -155
  67. package/src/tui/useChatUI.ts +0 -51
  68. package/tsconfig.json +0 -10
  69. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  70. /package/src/tui/components/{ui → primitives}/modal.tsx +0 -0
  71. /package/src/tui/components/{ui → primitives}/toast.tsx +0 -0
  72. /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 { 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-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 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,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 { computeDiff, renderDiff } from '../../../diff';
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
- export function EditOutput({ args, content, error }: EditOutputProps) {
11
- let path = '(unknown)';
12
- let oldString = '';
13
- let newString = '';
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
- path = parsed.path ?? '(unknown)';
18
- oldString = parsed.old_string ?? '';
19
- newString = parsed.new_string ?? '';
33
+ return {
34
+ path: parsed[pathField] ?? '(unknown)',
35
+ before: parsed[fromField] ?? '',
36
+ after: parsed[toField] ?? '',
37
+ };
20
38
  } catch {
21
- // ignore
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
- <Text color="red" bold={true}>
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(oldString, newString);
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
- ! edit_file
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
- <Text color="green" bold={true}>
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, 30);
83
+ const { lines, truncated } = renderDiff(diff, MAX_DIFF_LINES);
67
84
 
68
85
  return (
69
86
  <Box flexDirection="column" flexShrink={0} marginBottom={1}>
70
- <Text color="green" bold={true}>
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
- <Text key={line} color={color} dimColor={color === undefined} wrap="wrap">
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, 30 line limit)</Text>}
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
- 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,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
- 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>
@@ -5,26 +5,15 @@ import { UserMessage } from './userMessage';
5
5
 
6
6
  export const MessageItem: React.FC<{
7
7
  msg: ChatMessage;
8
- messages: ChatMessage[];
9
- index: number;
10
- }> = React.memo(function MessageItem({ msg, messages, index }) {
11
- // Tool result messages are rendered inline within ToolCallBlock
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
- const TOOL_VERBS: Record<string, string> = {
9
- bash: 'running',
10
- read_file: 'reading',
11
- write_file: 'writing',
12
- edit_file: 'editing',
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 getToolArgSummary(name: string, args: string): string {
16
- if (name === 'bash') {
17
- try {
18
- const parsed = JSON.parse(args);
19
- return parsed.command ?? args;
20
- } catch {
21
- return args;
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 = TOOL_VERBS[name] ?? 'executing';
42
- const argSummary = getToolArgSummary(name, args);
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, result.expanded)
59
+ renderToolOutput(name, args, result.content, result.error ?? false, hint)
55
60
  )}
56
61
  </Box>
57
62
  );
58
63
  }
59
64
 
60
- function renderToolOutput(name: string, args: string, content: string, error: boolean, expanded?: boolean) {
61
- if (name === 'read_file') {
62
- return <ReadOutput args={args} error={error} />;
63
- }
64
- if (name === 'write_file') {
65
- return <WriteOutput args={args} content={content} error={error} expanded={expanded ?? false} />;
66
- }
67
- if (name === 'edit_file') {
68
- return <EditOutput args={args} content={content} error={error} />;
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
- // Fallback for bash and unknown tools
72
- let command = '';
73
- if (name === 'bash') {
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
- command = parsed.command ?? '';
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
- {command && (
109
+ {summary && (
88
110
  <>
89
111
  {' '}
90
- <Text dimColor={true}>{command}</Text>
112
+ <Text dimColor={true}>{summary}</Text>
91
113
  </>
92
114
  )}
93
115
  </Text>