mu-coding 0.8.0 → 0.9.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 (41) hide show
  1. package/package.json +4 -4
  2. package/src/cli/install.ts +18 -3
  3. package/src/plugin.ts +33 -5
  4. package/src/runtime/createRegistry.test.ts +4 -3
  5. package/src/runtime/createRegistry.ts +34 -2
  6. package/src/runtime/fileMentionProvider.ts +116 -0
  7. package/src/runtime/pluginLoader.ts +37 -6
  8. package/src/tui/channel/tuiChannel.ts +14 -1
  9. package/src/tui/chat/useAbort.ts +5 -0
  10. package/src/tui/chat/useChat.ts +7 -0
  11. package/src/tui/chat/useChatPanel.ts +24 -3
  12. package/src/tui/chat/useChatSession.ts +105 -7
  13. package/src/tui/chat/useModels.ts +25 -1
  14. package/src/tui/chat/useSessionPersistence.ts +27 -11
  15. package/src/tui/chat/useStatusSegments.ts +26 -6
  16. package/src/tui/chat/useSubagentBrowser.ts +133 -0
  17. package/src/tui/components/chat/ChatPanel.tsx +16 -1
  18. package/src/tui/components/chat/ChatPanelBody.tsx +21 -0
  19. package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
  20. package/src/tui/components/messages/EditOutput.tsx +11 -5
  21. package/src/tui/components/messages/ReadOutput.tsx +1 -1
  22. package/src/tui/components/messages/ToolHeader.tsx +6 -4
  23. package/src/tui/components/messages/WriteOutput.tsx +12 -4
  24. package/src/tui/components/messages/assistantMessage.tsx +43 -10
  25. package/src/tui/components/messages/markdown.tsx +402 -0
  26. package/src/tui/components/messages/reasoningBlock.tsx +8 -6
  27. package/src/tui/components/messages/streamingOutput.tsx +1 -1
  28. package/src/tui/components/messages/toolCallBlock.tsx +2 -2
  29. package/src/tui/components/messages/userMessage.tsx +3 -3
  30. package/src/tui/components/primitives/toast.tsx +38 -7
  31. package/src/tui/components/statusBar.tsx +24 -15
  32. package/src/tui/hooks/useChordKeyboard.ts +87 -0
  33. package/src/tui/hooks/useInputInfoSegments.ts +22 -0
  34. package/src/tui/input/InputBoxView.tsx +71 -15
  35. package/src/tui/input/commands.ts +5 -0
  36. package/src/tui/input/useInputBox.ts +29 -3
  37. package/src/tui/input/useInputHandler.ts +1 -0
  38. package/src/tui/input/useMentionPicker.ts +26 -14
  39. package/src/tui/renderApp.tsx +29 -8
  40. package/src/tui/theme/presets.ts +12 -1
  41. package/src/tui/theme/types.ts +22 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * SubagentBrowserPanel — read-only view of a single subagent run.
3
+ *
4
+ * Replaces the chat body when `viewMode.kind === 'subagent'`:
5
+ * - Top banner: session title · agent name · status (agent colour).
6
+ * - Body: full-fidelity `MessageView` over the run's transcript so every
7
+ * nested tool call / reasoning block / output renders identically to
8
+ * the parent chat.
9
+ * - Status bar: subagent-specific segments (i/N, tool calls, elapsed).
10
+ * - No input box — the panel is read-only; the user navigates via
11
+ * `Ctrl+X →/←` or returns to chat with `Esc` / `Ctrl+X ↑`.
12
+ */
13
+
14
+ import { Box, type DOMElement as InkDOMElement, Text } from 'ink';
15
+ import type { SubagentRun } from 'mu-agents';
16
+ import type { ChatMessage } from 'mu-core';
17
+ import { type RefObject, useEffect, useMemo, useRef, useState } from 'react';
18
+ import { useTheme } from '../../context/ThemeContext';
19
+ import { useScroll } from '../../hooks/useScroll';
20
+ import { useMeasure, useTerminalSize } from '../../hooks/useTerminal';
21
+ import { MessageView } from '../messageView';
22
+ import { StatusBar, type StatusBarSegment } from '../statusBar';
23
+
24
+ interface SubagentBrowserPanelProps {
25
+ run: SubagentRun;
26
+ position: { index: number; total: number } | null;
27
+ /** Display title for the parent session (e.g. session file stem). */
28
+ sessionTitle?: string;
29
+ }
30
+
31
+ const STATUS_LABEL: Record<SubagentRun['status'], string> = {
32
+ running: 'running…',
33
+ done: 'done',
34
+ error: 'error',
35
+ aborted: 'aborted',
36
+ };
37
+
38
+ function statusColor(status: SubagentRun['status']): string | undefined {
39
+ switch (status) {
40
+ case 'running':
41
+ return 'cyan';
42
+ case 'done':
43
+ return 'green';
44
+ case 'error':
45
+ return 'red';
46
+ case 'aborted':
47
+ return 'yellow';
48
+ default:
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ function formatElapsed(run: SubagentRun): string {
54
+ const end = run.finishedAt ?? Date.now();
55
+ const ms = Math.max(0, end - run.startedAt);
56
+ if (ms < 1000) return `${ms}ms`;
57
+ const s = Math.round(ms / 1000);
58
+ if (s < 60) return `${s}s`;
59
+ const m = Math.floor(s / 60);
60
+ const rs = s % 60;
61
+ return `${m}m${rs.toString().padStart(2, '0')}s`;
62
+ }
63
+
64
+ function countToolCalls(messages: ChatMessage[]): number {
65
+ let n = 0;
66
+ for (const m of messages) {
67
+ if (m.toolCalls?.length) n += m.toolCalls.length;
68
+ }
69
+ return n;
70
+ }
71
+
72
+ /**
73
+ * Subscribe to elapsed time so the live "running…" status keeps updating
74
+ * even when no new tokens arrive. Refreshes every second; the timer
75
+ * shuts off as soon as the run has a `finishedAt`.
76
+ */
77
+ function useTickWhileRunning(run: SubagentRun): void {
78
+ const [, force] = useState(0);
79
+ useEffect(() => {
80
+ if (run.finishedAt) return;
81
+ const id = setInterval(() => force((n) => n + 1), 1000);
82
+ return () => clearInterval(id);
83
+ }, [run.finishedAt]);
84
+ }
85
+
86
+ export function SubagentBrowserPanel({ run, position, sessionTitle }: SubagentBrowserPanelProps) {
87
+ const theme = useTheme();
88
+ const { width, height } = useTerminalSize();
89
+ const viewRef = useRef<InkDOMElement>(null);
90
+ const contentRef = useRef<InkDOMElement>(null);
91
+ const measureKey = useMemo(
92
+ () => `${run.id}|${run.messages.length}|${run.status}`,
93
+ [run.id, run.messages.length, run.status],
94
+ );
95
+ const { viewHeight, contentHeight } = useMeasure(viewRef, contentRef, measureKey);
96
+ const { scrollOffset } = useScroll(contentHeight, viewHeight);
97
+
98
+ useTickWhileRunning(run);
99
+
100
+ const segments: StatusBarSegment[] = [
101
+ ...(position ? [{ text: `subagent ${position.index}/${position.total}`, align: 'left' as const, dim: true }] : []),
102
+ { text: `tool calls: ${countToolCalls(run.messages)}`, dim: true },
103
+ { text: formatElapsed(run), dim: true },
104
+ { text: 'Esc · chat | Ctrl+X →/← cycle', dim: true },
105
+ ];
106
+
107
+ const banner = (
108
+ <Box flexShrink={0} paddingX={1} borderStyle="single" borderColor={run.agentColor ?? theme.status.separator}>
109
+ <Box flexGrow={1}>
110
+ <Text color={run.agentColor} bold={true}>
111
+ ↳ {run.agentName}
112
+ </Text>
113
+ <Text dimColor={true}>{sessionTitle ? ` · ${sessionTitle}` : ''}</Text>
114
+ <Text dimColor={true}>{` · ${run.id}`}</Text>
115
+ </Box>
116
+ <Text color={statusColor(run.status)} bold={true}>
117
+ {STATUS_LABEL[run.status]}
118
+ </Text>
119
+ </Box>
120
+ );
121
+
122
+ return (
123
+ <Box flexDirection="column" height={height} width={width}>
124
+ {banner}
125
+ <MessageView
126
+ viewRef={viewRef}
127
+ contentRef={contentRef}
128
+ messages={run.messages}
129
+ streaming={run.status === 'running'}
130
+ stream={{ text: '', reasoning: '', totalTokens: 0, cachedTokens: 0 }}
131
+ error={run.error ?? null}
132
+ scrollOffset={scrollOffset}
133
+ viewHeight={viewHeight}
134
+ contentHeight={contentHeight}
135
+ />
136
+ <StatusBar segments={segments} />
137
+ </Box>
138
+ );
139
+ }
140
+
141
+ /** Helper type used by `ChatPanelBody` when constructing the panel. */
142
+ export type SubagentBrowserPanelComponent = typeof SubagentBrowserPanel;
143
+
144
+ /** Wrap a `RefObject<DOMElement>` cast for callers that need it. */
145
+ export type _SubagentBrowserRef = RefObject<InkDOMElement | null>;
@@ -48,7 +48,7 @@ export function EditOutput({ args, content, error, hint }: EditOutputProps) {
48
48
 
49
49
  if (error) {
50
50
  return (
51
- <Box flexDirection="column" flexShrink={0} marginBottom={1}>
51
+ <Box flexDirection="column" flexShrink={0} marginBottom={0}>
52
52
  <ToolHeader name={verb} subtitle={path} error={true} />
53
53
  <Text dimColor={true} wrap="wrap">
54
54
  {content}
@@ -61,7 +61,7 @@ export function EditOutput({ args, content, error, hint }: EditOutputProps) {
61
61
 
62
62
  if (diff.lines.length === 0 && diff.totalOldLines > 0 && diff.totalNewLines > 0) {
63
63
  return (
64
- <Box flexDirection="column" flexShrink={0} marginBottom={1}>
64
+ <Box flexDirection="column" flexShrink={0} marginBottom={0}>
65
65
  <Text color={theme.diff.warning} bold={true}>
66
66
  ! {verb}
67
67
  </Text>
@@ -75,7 +75,7 @@ export function EditOutput({ args, content, error, hint }: EditOutputProps) {
75
75
 
76
76
  if (diff.lines.length === 0) {
77
77
  return (
78
- <Box flexDirection="column" flexShrink={0} marginBottom={1}>
78
+ <Box flexDirection="column" flexShrink={0} marginBottom={0}>
79
79
  <ToolHeader name={verb} subtitle={path} />
80
80
  <Text dimColor={true}>No changes (content identical)</Text>
81
81
  </Box>
@@ -85,9 +85,15 @@ export function EditOutput({ args, content, error, hint }: EditOutputProps) {
85
85
  const { lines, truncated } = renderDiff(diff, MAX_DIFF_LINES);
86
86
 
87
87
  return (
88
- <Box flexDirection="column" flexShrink={0} marginBottom={1}>
88
+ <Box flexDirection="column" flexShrink={0} marginBottom={0}>
89
89
  <ToolHeader name={verb} subtitle={path} />
90
- <Box flexDirection="column" flexShrink={0}>
90
+ <Box
91
+ flexDirection="column"
92
+ flexShrink={0}
93
+ backgroundColor={theme.tool.previewBackground}
94
+ paddingX={1}
95
+ paddingY={0}
96
+ >
91
97
  {lines.map((line, i) => {
92
98
  let color: string | undefined;
93
99
  if (line.startsWith('-')) color = theme.diff.removed;
@@ -32,7 +32,7 @@ export function ReadOutput({ args, error }: ReadOutputProps) {
32
32
  const subtitle = paths.length === 1 ? `${paths[0]}${rangeLabel}` : `${paths.length} files${rangeLabel}`;
33
33
 
34
34
  return (
35
- <Box flexDirection="column" flexShrink={0} marginBottom={1}>
35
+ <Box flexDirection="column" flexShrink={0} marginBottom={0}>
36
36
  <ToolHeader name="read_file" subtitle={subtitle} error={error} />
37
37
  {paths.length > 1 && (
38
38
  <Box flexDirection="column" flexShrink={0}>
@@ -18,11 +18,13 @@ interface ToolHeaderProps {
18
18
  export function ToolHeader({ name, subtitle, error = false }: ToolHeaderProps) {
19
19
  const theme = useTheme();
20
20
  return (
21
- <Box flexDirection="column" flexShrink={0}>
22
- <Text color={error ? theme.tool.error : theme.tool.success} bold={true}>
23
- {error ? '✗' : '✓'} {name}
21
+ <Box flexShrink={0}>
22
+ <Text wrap="truncate-end">
23
+ <Text color={error ? theme.tool.error : theme.tool.success} bold={true}>
24
+ {error ? '✗' : '✓'} {name}
25
+ </Text>
26
+ {subtitle && <Text dimColor={true}> {subtitle}</Text>}
24
27
  </Text>
25
- {subtitle && <Text dimColor={true}> {subtitle}</Text>}
26
28
  </Box>
27
29
  );
28
30
  }
@@ -1,4 +1,5 @@
1
1
  import { Box, Text } from 'ink';
2
+ import { useTheme } from '../../context/ThemeContext';
2
3
  import { ToolHeader } from './ToolHeader';
3
4
 
4
5
  const PREVIEW_LINES = 30;
@@ -19,11 +20,12 @@ function parsePath(args: string): string {
19
20
  }
20
21
 
21
22
  export function WriteOutput({ args, content, error }: WriteOutputProps) {
23
+ const theme = useTheme();
22
24
  const path = parsePath(args);
23
25
 
24
26
  if (error) {
25
27
  return (
26
- <Box flexDirection="column" flexShrink={0} marginBottom={1}>
28
+ <Box flexDirection="column" flexShrink={0} marginBottom={0}>
27
29
  <ToolHeader name="write_file" error={true} />
28
30
  <Text dimColor={true} wrap="wrap">
29
31
  {content}
@@ -38,14 +40,20 @@ export function WriteOutput({ args, content, error }: WriteOutputProps) {
38
40
  const hasMore = totalLines > PREVIEW_LINES;
39
41
 
40
42
  return (
41
- <Box flexDirection="column" flexShrink={0} marginBottom={1}>
43
+ <Box flexDirection="column" flexShrink={0} marginBottom={0}>
42
44
  <ToolHeader name="write_file" subtitle={path} />
43
45
  <Box flexDirection="column" flexShrink={0}>
44
46
  <Text dimColor={true}>
45
47
  {totalLines} line{totalLines !== 1 ? 's' : ''}
46
48
  </Text>
47
- <Box flexDirection="column" flexShrink={0}>
48
- <Text dimColor={true} wrap="wrap">
49
+ <Box
50
+ flexDirection="column"
51
+ flexShrink={0}
52
+ backgroundColor={theme.tool.previewBackground}
53
+ paddingX={1}
54
+ paddingY={0}
55
+ >
56
+ <Text color={theme.tool.previewText} wrap="wrap">
49
57
  {hasMore ? preview : content}
50
58
  </Text>
51
59
  {hasMore && <Text dimColor={true}>… ({totalLines - PREVIEW_LINES} more lines)</Text>}
@@ -1,9 +1,26 @@
1
1
  import { Box, Text } from 'ink';
2
2
  import type { ChatMessage } from 'mu-core';
3
3
  import React from 'react';
4
+ import { MarkdownContent } from './markdown';
4
5
  import { ReasoningBlock } from './reasoningBlock';
5
6
  import { ToolCallBlock } from './toolCallBlock';
6
7
 
8
+ /**
9
+ * Tool names whose calls are already represented in the transcript by the
10
+ * `mu-agents.subagent` custom message renderer (`SubagentMessage`).
11
+ * Filtering them out here prevents a redundant `✓ subagent` block from
12
+ * rendering the same body the SubagentMessage already shows.
13
+ *
14
+ * Reload caveat: the `SubagentRunRegistry` is in-memory only, so after a
15
+ * session reload the SubagentMessage block has no live run and shows
16
+ * just the `↳ <name>` glyph without a body. The wrapped tool result
17
+ * still lives in the persisted transcript (and the LLM payload), so the
18
+ * parent agent's relay paragraph is intact; the user just can't see the
19
+ * raw subagent output inline after reopening the session. Accepted
20
+ * trade-off; revisit if/when run hydration is wired into reload.
21
+ */
22
+ const SUBAGENT_TOOL_NAMES = new Set(['subagent', 'subagent_parallel']);
23
+
7
24
  export const AssistantMessage: React.FC<{
8
25
  msg: ChatMessage;
9
26
  toolMessages?: ChatMessage[];
@@ -11,28 +28,44 @@ export const AssistantMessage: React.FC<{
11
28
  const badge = msg.display?.badge;
12
29
  const prefix = msg.display?.prefix;
13
30
  const color = msg.display?.color;
31
+
32
+ // Filter subagent tool calls out of the visible list, dropping their
33
+ // matching `toolMessages` entries in lock-step so positional indexing
34
+ // stays correct for the surviving calls.
35
+ const visibleEntries = (msg.toolCalls ?? []).flatMap((tc, i) =>
36
+ SUBAGENT_TOOL_NAMES.has(tc.function.name) ? [] : [{ tc, toolMsg: toolMessages?.[i] }],
37
+ );
38
+
39
+ // If every renderable surface on this assistant message is empty after
40
+ // filtering, suppress the entire block — otherwise we'd render a
41
+ // dangling badge bubble for assistant turns that were nothing but a
42
+ // subagent dispatch.
43
+ const hasAnything = visibleEntries.length > 0 || !!msg.content || !!msg.reasoning;
44
+ if (!hasAnything) return null;
45
+
46
+ const hasVisibleToolCalls = visibleEntries.length > 0;
14
47
  return (
15
- <Box flexDirection="column" flexShrink={0} marginBottom={1}>
48
+ <Box flexDirection="column" flexShrink={0} marginBottom={hasVisibleToolCalls ? 0 : 1}>
16
49
  {badge && (
17
- <Box marginBottom={1}>
50
+ <Box>
18
51
  <Text color={color} bold={true}>
19
- [{badge}]
52
+ {badge.charAt(0).toUpperCase() + badge.slice(1)}
20
53
  </Text>
21
54
  </Box>
22
55
  )}
23
56
  {msg.reasoning && <ReasoningBlock reasoning={msg.reasoning} />}
24
- {msg.toolCalls?.length ? (
25
- <Box flexDirection="column" marginBottom={1}>
26
- {msg.toolCalls.map((tc, i) => (
27
- <ToolCallBlock key={tc.id} toolCall={tc} toolMsg={toolMessages?.[i]} />
57
+ {hasVisibleToolCalls ? (
58
+ <Box flexDirection="column">
59
+ {visibleEntries.map(({ tc, toolMsg }) => (
60
+ <ToolCallBlock key={tc.id} toolCall={tc} toolMsg={toolMsg} />
28
61
  ))}
29
62
  </Box>
30
63
  ) : null}
31
64
  {msg.content && (
32
- <Text wrap="wrap" color={color}>
65
+ <Box flexDirection="column">
33
66
  {prefix && <Text color={color}>{prefix}</Text>}
34
- {msg.content}
35
- </Text>
67
+ <MarkdownContent content={msg.content} color={color} />
68
+ </Box>
36
69
  )}
37
70
  </Box>
38
71
  );