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.
- package/package.json +4 -4
- package/src/cli/install.ts +18 -3
- package/src/plugin.ts +33 -5
- package/src/runtime/createRegistry.test.ts +4 -3
- package/src/runtime/createRegistry.ts +34 -2
- package/src/runtime/fileMentionProvider.ts +116 -0
- package/src/runtime/pluginLoader.ts +37 -6
- package/src/tui/channel/tuiChannel.ts +14 -1
- package/src/tui/chat/useAbort.ts +5 -0
- package/src/tui/chat/useChat.ts +7 -0
- package/src/tui/chat/useChatPanel.ts +24 -3
- package/src/tui/chat/useChatSession.ts +105 -7
- package/src/tui/chat/useModels.ts +25 -1
- package/src/tui/chat/useSessionPersistence.ts +27 -11
- package/src/tui/chat/useStatusSegments.ts +26 -6
- package/src/tui/chat/useSubagentBrowser.ts +133 -0
- package/src/tui/components/chat/ChatPanel.tsx +16 -1
- package/src/tui/components/chat/ChatPanelBody.tsx +21 -0
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
- package/src/tui/components/messages/EditOutput.tsx +11 -5
- package/src/tui/components/messages/ReadOutput.tsx +1 -1
- package/src/tui/components/messages/ToolHeader.tsx +6 -4
- package/src/tui/components/messages/WriteOutput.tsx +12 -4
- package/src/tui/components/messages/assistantMessage.tsx +43 -10
- package/src/tui/components/messages/markdown.tsx +402 -0
- package/src/tui/components/messages/reasoningBlock.tsx +8 -6
- package/src/tui/components/messages/streamingOutput.tsx +1 -1
- package/src/tui/components/messages/toolCallBlock.tsx +2 -2
- package/src/tui/components/messages/userMessage.tsx +3 -3
- package/src/tui/components/primitives/toast.tsx +38 -7
- package/src/tui/components/statusBar.tsx +24 -15
- package/src/tui/hooks/useChordKeyboard.ts +87 -0
- package/src/tui/hooks/useInputInfoSegments.ts +22 -0
- package/src/tui/input/InputBoxView.tsx +71 -15
- package/src/tui/input/commands.ts +5 -0
- package/src/tui/input/useInputBox.ts +29 -3
- package/src/tui/input/useInputHandler.ts +1 -0
- package/src/tui/input/useMentionPicker.ts +26 -14
- package/src/tui/renderApp.tsx +29 -8
- package/src/tui/theme/presets.ts +12 -1
- 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={
|
|
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={
|
|
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={
|
|
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={
|
|
88
|
+
<Box flexDirection="column" flexShrink={0} marginBottom={0}>
|
|
89
89
|
<ToolHeader name={verb} subtitle={path} />
|
|
90
|
-
<Box
|
|
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={
|
|
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
|
|
22
|
-
<Text
|
|
23
|
-
{error ?
|
|
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={
|
|
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={
|
|
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
|
|
48
|
-
|
|
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
|
|
50
|
+
<Box>
|
|
18
51
|
<Text color={color} bold={true}>
|
|
19
|
-
|
|
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
|
-
{
|
|
25
|
-
<Box flexDirection="column"
|
|
26
|
-
{
|
|
27
|
-
<ToolCallBlock key={tc.id} toolCall={tc} toolMsg={
|
|
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
|
-
<
|
|
65
|
+
<Box flexDirection="column">
|
|
33
66
|
{prefix && <Text color={color}>{prefix}</Text>}
|
|
34
|
-
{msg.content}
|
|
35
|
-
</
|
|
67
|
+
<MarkdownContent content={msg.content} color={color} />
|
|
68
|
+
</Box>
|
|
36
69
|
)}
|
|
37
70
|
</Box>
|
|
38
71
|
);
|