mu-coding 0.5.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/README.md +49 -3
- package/package.json +9 -4
- package/prompts/SYSTEM.md +16 -0
- package/src/app/shutdown.ts +1 -1
- package/src/app/startApp.ts +11 -8
- package/src/cli/args.ts +14 -11
- package/src/cli/install.ts +18 -3
- package/src/config/index.test.ts +26 -0
- package/src/config/index.ts +25 -7
- package/src/plugin.ts +124 -0
- package/src/runtime/codingTools/bash.ts +114 -0
- package/src/runtime/codingTools/edit-file.ts +60 -0
- package/src/runtime/codingTools/index.ts +39 -0
- package/src/runtime/codingTools/read-file.ts +83 -0
- package/src/runtime/codingTools/utils.ts +21 -0
- package/src/runtime/codingTools/write-file.ts +42 -0
- package/src/runtime/createRegistry.test.ts +147 -0
- package/src/runtime/createRegistry.ts +160 -23
- package/src/runtime/fileMentionProvider.ts +116 -0
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +59 -15
- package/src/sessions/index.ts +2 -9
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +62 -0
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +1 -1
- package/src/tui/chat/useAbort.ts +5 -0
- package/src/tui/chat/useAttachment.ts +1 -1
- package/src/tui/chat/useChat.ts +38 -3
- package/src/tui/chat/useChatPanel.ts +29 -6
- package/src/tui/chat/useChatSession.ts +324 -57
- package/src/tui/chat/useModels.ts +26 -1
- package/src/tui/chat/usePluginStatus.ts +1 -1
- package/src/tui/chat/useSessionPersistence.ts +48 -21
- package/src/tui/chat/useStatusSegments.ts +38 -5
- package/src/tui/chat/useSubagentBrowser.ts +133 -0
- package/src/tui/components/chat/ChatPanel.tsx +25 -4
- package/src/tui/components/chat/ChatPanelBody.tsx +22 -1
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +145 -0
- package/src/tui/components/messageView.tsx +4 -2
- package/src/tui/components/messages/EditOutput.tsx +17 -9
- package/src/tui/components/messages/ReadOutput.tsx +1 -1
- package/src/tui/components/messages/ToolHeader.tsx +8 -4
- package/src/tui/components/messages/WriteOutput.tsx +12 -4
- package/src/tui/components/messages/assistantMessage.tsx +55 -7
- package/src/tui/components/messages/markdown.tsx +402 -0
- package/src/tui/components/messages/messageItem.tsx +19 -1
- package/src/tui/components/messages/reasoningBlock.tsx +10 -6
- package/src/tui/components/messages/streamingOutput.tsx +6 -2
- package/src/tui/components/messages/toolCallBlock.tsx +7 -6
- package/src/tui/components/messages/userMessage.tsx +22 -7
- package/src/tui/components/primitives/dropdown.tsx +8 -4
- package/src/tui/components/primitives/modal.tsx +4 -2
- package/src/tui/components/primitives/pickerModal.tsx +3 -1
- package/src/tui/components/primitives/toast.tsx +43 -10
- package/src/tui/components/statusBar.tsx +26 -10
- package/src/tui/components/ui/dialogLayer.tsx +11 -6
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/hooks/useChordKeyboard.ts +87 -0
- package/src/tui/hooks/useInputInfoSegments.ts +22 -0
- package/src/tui/input/InputBoxView.tsx +191 -26
- package/src/tui/input/commands.test.ts +3 -1
- package/src/tui/input/commands.ts +11 -1
- package/src/tui/input/cursor.test.ts +136 -0
- package/src/tui/input/cursor.ts +214 -0
- package/src/tui/input/dumpContext.ts +107 -0
- package/src/tui/input/sanitize.ts +1 -1
- package/src/tui/input/useCommandExecutor.ts +1 -1
- package/src/tui/input/useInputBox.ts +160 -15
- package/src/tui/input/useInputHandler.ts +317 -126
- package/src/tui/input/useMentionPicker.ts +133 -0
- package/src/tui/input/usePluginShortcuts.ts +29 -0
- package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
- package/src/tui/plugins/InkApprovalChannel.ts +30 -0
- package/src/tui/plugins/InkUIService.ts +1 -1
- package/src/tui/renderApp.tsx +47 -13
- package/src/tui/theme/index.ts +1 -0
- package/src/tui/theme/merge.test.ts +49 -0
- package/src/tui/theme/merge.ts +43 -0
- package/src/tui/theme/presets.ts +90 -0
- package/src/tui/theme/types.ts +138 -0
- package/src/utils/clipboard.ts +1 -1
- package/src/tui/chat/useStreamConsumer.ts +0 -118
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { StatusSegment } from 'mu-
|
|
1
|
+
import type { StatusSegment } from 'mu-core';
|
|
2
2
|
import type { StatusBarSegment } from '../components/statusBar';
|
|
3
3
|
import { useSpinner } from '../hooks/useUI';
|
|
4
4
|
|
|
@@ -10,7 +10,13 @@ interface StatusSegmentOptions {
|
|
|
10
10
|
quitWarning: boolean;
|
|
11
11
|
error: string | null;
|
|
12
12
|
modelError: string | null;
|
|
13
|
-
|
|
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
|
+
/** Model context window (input + output) reported by the provider; when
|
|
18
|
+
* set, the tokens segment is rendered as `used/limit tokens`. */
|
|
19
|
+
contextLimit?: number;
|
|
14
20
|
pluginStatus?: StatusSegment[];
|
|
15
21
|
}
|
|
16
22
|
|
|
@@ -18,15 +24,42 @@ function truncate(text: string, max: number): string {
|
|
|
18
24
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
19
25
|
}
|
|
20
26
|
|
|
27
|
+
const tokenFormatter = new Intl.NumberFormat('en-US');
|
|
28
|
+
function formatTokens(n: number): string {
|
|
29
|
+
if (n >= 1_000_000) {
|
|
30
|
+
const v = n / 1_000_000;
|
|
31
|
+
return `${v >= 10 ? v.toFixed(0) : v.toFixed(1)}M`;
|
|
32
|
+
}
|
|
33
|
+
if (n >= 1000) {
|
|
34
|
+
const v = n / 1000;
|
|
35
|
+
return `${v >= 10 ? v.toFixed(0) : v.toFixed(1)}k`;
|
|
36
|
+
}
|
|
37
|
+
return tokenFormatter.format(n);
|
|
38
|
+
}
|
|
39
|
+
|
|
21
40
|
export function useStatusSegments(options: StatusSegmentOptions): StatusBarSegment[] {
|
|
22
41
|
const spinner = useSpinner(options.streaming);
|
|
23
42
|
const segments: StatusBarSegment[] = [];
|
|
24
43
|
|
|
25
44
|
if (options.streaming) {
|
|
26
|
-
segments.push({ text:
|
|
45
|
+
segments.push({ text: spinner, color: 'yellow', align: 'left' });
|
|
27
46
|
}
|
|
28
|
-
if (options.
|
|
29
|
-
|
|
47
|
+
if (options.totalTokens > 0) {
|
|
48
|
+
const cached = options.cachedTokens ?? 0;
|
|
49
|
+
const used = formatTokens(options.totalTokens);
|
|
50
|
+
let head: string;
|
|
51
|
+
if (options.contextLimit) {
|
|
52
|
+
const pct = (options.totalTokens / options.contextLimit) * 100;
|
|
53
|
+
const pctStr = pct >= 10 ? pct.toFixed(0) : pct.toFixed(1);
|
|
54
|
+
head = `${used} (${pctStr}%)`;
|
|
55
|
+
} else {
|
|
56
|
+
head = used;
|
|
57
|
+
}
|
|
58
|
+
if (cached > 0) {
|
|
59
|
+
segments.push({ text: `${head} · ${formatTokens(cached)} cached`, dim: true });
|
|
60
|
+
} else {
|
|
61
|
+
segments.push({ text: head, dim: true });
|
|
62
|
+
}
|
|
30
63
|
}
|
|
31
64
|
if (options.abortWarning) {
|
|
32
65
|
segments.push({ text: 'Esc again to stop', color: 'yellow' });
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent browser state machine.
|
|
3
|
+
*
|
|
4
|
+
* Exposes:
|
|
5
|
+
* - the live list of `SubagentRun`s (subscribed to the registry)
|
|
6
|
+
* - the current `viewMode` (chat vs browsing a specific run)
|
|
7
|
+
* - keyboard handlers wired through `useChordKeyboard`:
|
|
8
|
+
* Ctrl+X ↓ — enter browser at the most recent run
|
|
9
|
+
* Ctrl+X → — next run (loops)
|
|
10
|
+
* Ctrl+X ← — previous run (loops)
|
|
11
|
+
* Ctrl+X ↑ or Esc — back to chat
|
|
12
|
+
*
|
|
13
|
+
* Returning to chat happens via the `Esc` handler the panel registers
|
|
14
|
+
* directly (kept outside the chord so it's reachable without the prefix).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useInput } from 'ink';
|
|
18
|
+
import type { SubagentRun, SubagentRunRegistry } from 'mu-agents';
|
|
19
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
20
|
+
import { useChordKeyboard } from '../hooks/useChordKeyboard';
|
|
21
|
+
|
|
22
|
+
export type SubagentViewMode = { kind: 'chat' } | { kind: 'subagent'; runId: string };
|
|
23
|
+
|
|
24
|
+
export interface SubagentBrowserState {
|
|
25
|
+
mode: SubagentViewMode;
|
|
26
|
+
runs: SubagentRun[];
|
|
27
|
+
currentRun: SubagentRun | undefined;
|
|
28
|
+
/** 1-based position of the current run (for UI labels like "i / N"). */
|
|
29
|
+
position: { index: number; total: number } | null;
|
|
30
|
+
enterLatest: () => void;
|
|
31
|
+
next: () => void;
|
|
32
|
+
prev: () => void;
|
|
33
|
+
exit: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const NOOP = (): void => {
|
|
37
|
+
// Intentional: returned by the empty-registry shape so call sites can
|
|
38
|
+
// wire handlers up without null-checking each invocation.
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const EMPTY_BROWSER: SubagentBrowserState = {
|
|
42
|
+
mode: { kind: 'chat' },
|
|
43
|
+
runs: [],
|
|
44
|
+
currentRun: undefined,
|
|
45
|
+
position: null,
|
|
46
|
+
enterLatest: NOOP,
|
|
47
|
+
next: NOOP,
|
|
48
|
+
prev: NOOP,
|
|
49
|
+
exit: NOOP,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function useSubagentBrowser(registry: SubagentRunRegistry | undefined): SubagentBrowserState {
|
|
53
|
+
const [runs, setRuns] = useState<SubagentRun[]>(() => registry?.list() ?? []);
|
|
54
|
+
const [mode, setMode] = useState<SubagentViewMode>({ kind: 'chat' });
|
|
55
|
+
|
|
56
|
+
// Subscribe to registry events; the listener fires immediately on
|
|
57
|
+
// subscribe with the current snapshot, so the initial state is correct
|
|
58
|
+
// even when the registry already has runs from a resumed session.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!registry) return;
|
|
61
|
+
return registry.subscribe((next) => setRuns(next));
|
|
62
|
+
}, [registry]);
|
|
63
|
+
|
|
64
|
+
// If the run we're showing disappears (registry cleared on /new), bounce
|
|
65
|
+
// back to chat so the user isn't stuck on a phantom run.
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (mode.kind !== 'subagent') return;
|
|
68
|
+
if (!runs.some((r) => r.id === mode.runId)) setMode({ kind: 'chat' });
|
|
69
|
+
}, [runs, mode]);
|
|
70
|
+
|
|
71
|
+
const exit = useCallback(() => setMode({ kind: 'chat' }), []);
|
|
72
|
+
|
|
73
|
+
const enterLatest = useCallback(() => {
|
|
74
|
+
if (runs.length === 0) return;
|
|
75
|
+
const last = runs[runs.length - 1];
|
|
76
|
+
setMode({ kind: 'subagent', runId: last.id });
|
|
77
|
+
}, [runs]);
|
|
78
|
+
|
|
79
|
+
const cycle = useCallback(
|
|
80
|
+
(direction: 1 | -1) => {
|
|
81
|
+
if (runs.length === 0) return;
|
|
82
|
+
const currentId = mode.kind === 'subagent' ? mode.runId : runs[runs.length - 1].id;
|
|
83
|
+
const idx = runs.findIndex((r) => r.id === currentId);
|
|
84
|
+
const start = idx === -1 ? runs.length - 1 : idx;
|
|
85
|
+
const len = runs.length;
|
|
86
|
+
// Wrap-around — `(start + direction + len) % len` is the canonical
|
|
87
|
+
// modulo trick that handles negative results in JS (`%` keeps sign).
|
|
88
|
+
const nextIdx = (start + direction + len) % len;
|
|
89
|
+
setMode({ kind: 'subagent', runId: runs[nextIdx].id });
|
|
90
|
+
},
|
|
91
|
+
[mode, runs],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const next = useCallback(() => cycle(1), [cycle]);
|
|
95
|
+
const prev = useCallback(() => cycle(-1), [cycle]);
|
|
96
|
+
|
|
97
|
+
// Ctrl+X chord is always armed, but the follow-ups are no-ops while no
|
|
98
|
+
// subagent run exists (so the user gets terminal-bell silence rather
|
|
99
|
+
// than an unexpected mode switch).
|
|
100
|
+
useChordKeyboard({
|
|
101
|
+
prefix: ({ key, input }) => key.ctrl === true && input === 'x',
|
|
102
|
+
followUps: [
|
|
103
|
+
{ match: ({ key }) => key.downArrow === true, handler: enterLatest },
|
|
104
|
+
{ match: ({ key }) => key.rightArrow === true, handler: next },
|
|
105
|
+
{ match: ({ key }) => key.leftArrow === true, handler: prev },
|
|
106
|
+
{ match: ({ key }) => key.upArrow === true, handler: exit },
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Esc returns to chat from the browser. Active only while we're actually
|
|
111
|
+
// showing a run; otherwise we'd intercept Esc clearing modal/picker UIs.
|
|
112
|
+
useInput(
|
|
113
|
+
(_input, key) => {
|
|
114
|
+
if (key.escape) exit();
|
|
115
|
+
},
|
|
116
|
+
{ isActive: mode.kind === 'subagent' },
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const currentRun = useMemo(
|
|
120
|
+
() => (mode.kind === 'subagent' ? runs.find((r) => r.id === mode.runId) : undefined),
|
|
121
|
+
[mode, runs],
|
|
122
|
+
);
|
|
123
|
+
const position = useMemo(() => {
|
|
124
|
+
if (mode.kind !== 'subagent') return null;
|
|
125
|
+
const i = runs.findIndex((r) => r.id === mode.runId);
|
|
126
|
+
if (i === -1) return null;
|
|
127
|
+
return { index: i + 1, total: runs.length };
|
|
128
|
+
}, [mode, runs]);
|
|
129
|
+
|
|
130
|
+
if (!registry) return EMPTY_BROWSER;
|
|
131
|
+
|
|
132
|
+
return { mode, runs, currentRun, position, enterLatest, next, prev, exit };
|
|
133
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { ChatMessage, ProviderConfig } from 'mu-
|
|
1
|
+
import type { SubagentRunRegistry } from 'mu-agents';
|
|
2
|
+
import type { ChatMessage, PluginRegistry, ProviderConfig } from 'mu-core';
|
|
3
3
|
import type { ShutdownFn } from '../../../app/shutdown';
|
|
4
|
+
import type { SessionPathHolder } from '../../../runtime/createRegistry';
|
|
5
|
+
import type { HostMessageBus } from '../../../runtime/messageBus';
|
|
4
6
|
import { ChatContext } from '../../chat/ChatContext';
|
|
7
|
+
import { MessageRendererProvider, useRegistryRenderers } from '../../chat/MessageRendererContext';
|
|
5
8
|
import { ToolDisplayProvider, useToolDisplayMap } from '../../chat/ToolDisplayContext';
|
|
6
9
|
import { useChatPanel } from '../../chat/useChatPanel';
|
|
7
10
|
import type { InkUIService } from '../../plugins/InkUIService';
|
|
@@ -11,22 +14,40 @@ export function ChatPanel({
|
|
|
11
14
|
config,
|
|
12
15
|
initialMessages,
|
|
13
16
|
registry,
|
|
17
|
+
messageBus,
|
|
14
18
|
uiService,
|
|
15
19
|
shutdown,
|
|
20
|
+
sessionPathHolder,
|
|
21
|
+
subagentRuns,
|
|
16
22
|
}: {
|
|
17
23
|
config: ProviderConfig;
|
|
18
24
|
initialMessages?: ChatMessage[];
|
|
19
25
|
registry: PluginRegistry;
|
|
26
|
+
messageBus?: HostMessageBus;
|
|
20
27
|
uiService?: InkUIService;
|
|
21
28
|
shutdown?: ShutdownFn;
|
|
29
|
+
sessionPathHolder?: SessionPathHolder;
|
|
30
|
+
subagentRuns?: SubagentRunRegistry;
|
|
22
31
|
}) {
|
|
23
|
-
const { ctx, bodyProps } = useChatPanel({
|
|
32
|
+
const { ctx, bodyProps } = useChatPanel({
|
|
33
|
+
config,
|
|
34
|
+
initialMessages,
|
|
35
|
+
registry,
|
|
36
|
+
messageBus,
|
|
37
|
+
uiService,
|
|
38
|
+
shutdown,
|
|
39
|
+
sessionPathHolder,
|
|
40
|
+
subagentRuns,
|
|
41
|
+
});
|
|
24
42
|
const toolDisplays = useToolDisplayMap(registry);
|
|
43
|
+
const renderers = useRegistryRenderers(registry);
|
|
25
44
|
|
|
26
45
|
return (
|
|
27
46
|
<ChatContext.Provider value={ctx}>
|
|
28
47
|
<ToolDisplayProvider value={toolDisplays}>
|
|
29
|
-
<
|
|
48
|
+
<MessageRendererProvider value={renderers}>
|
|
49
|
+
<ChatPanelBody {...bodyProps} />
|
|
50
|
+
</MessageRendererProvider>
|
|
30
51
|
</ToolDisplayProvider>
|
|
31
52
|
</ChatContext.Provider>
|
|
32
53
|
);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Box, type DOMElement as InkDOMElement } from 'ink';
|
|
2
|
-
import type { ChatMessage } from 'mu-
|
|
2
|
+
import type { ChatMessage } from 'mu-core';
|
|
3
3
|
import type { StreamState } from '../../chat/useChatSession';
|
|
4
|
+
import type { SubagentBrowserState } from '../../chat/useSubagentBrowser';
|
|
5
|
+
import { useInputInfoSegments } from '../../hooks/useInputInfoSegments';
|
|
4
6
|
import { InputBox } from '../../input/InputBox';
|
|
5
7
|
import type { InkUIService } from '../../plugins/InkUIService';
|
|
6
8
|
import { MessageView } from '../messageView';
|
|
@@ -9,6 +11,7 @@ import type { StatusBarSegment } from '../statusBar';
|
|
|
9
11
|
import { StatusBar } from '../statusBar';
|
|
10
12
|
import { DialogLayer } from '../ui/dialogLayer';
|
|
11
13
|
import { Pickers } from './Pickers';
|
|
14
|
+
import { SubagentBrowserPanel } from './SubagentBrowserPanel';
|
|
12
15
|
|
|
13
16
|
export interface ChatPanelBodyProps {
|
|
14
17
|
width: number;
|
|
@@ -32,9 +35,26 @@ export interface ChatPanelBodyProps {
|
|
|
32
35
|
statusSegments: StatusBarSegment[];
|
|
33
36
|
toasts: Toast[];
|
|
34
37
|
onDismissToast: (id: number) => void;
|
|
38
|
+
/** Subagent browser state — when in subagent mode the chat body is replaced. */
|
|
39
|
+
browser?: SubagentBrowserState;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
export function ChatPanelBody(props: ChatPanelBodyProps) {
|
|
43
|
+
const infoSegments = useInputInfoSegments();
|
|
44
|
+
|
|
45
|
+
// Browser mode: replace the entire chat body with the subagent panel.
|
|
46
|
+
// Modals (dialog layer, toasts) still render so an `ask` triggered by a
|
|
47
|
+
// subagent dispatch can still surface to the user.
|
|
48
|
+
if (props.browser?.mode.kind === 'subagent' && props.browser.currentRun) {
|
|
49
|
+
return (
|
|
50
|
+
<Box flexDirection="column" height={props.height} width={props.width}>
|
|
51
|
+
<SubagentBrowserPanel run={props.browser.currentRun} position={props.browser.position} />
|
|
52
|
+
{props.uiService && <DialogLayer service={props.uiService} />}
|
|
53
|
+
<ToastContainer toasts={props.toasts} onDismiss={props.onDismissToast} />
|
|
54
|
+
</Box>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
return (
|
|
39
59
|
<Box flexDirection="column" height={props.height} width={props.width}>
|
|
40
60
|
<MessageView
|
|
@@ -55,6 +75,7 @@ export function ChatPanelBody(props: ChatPanelBodyProps) {
|
|
|
55
75
|
isActive={props.isActive}
|
|
56
76
|
model={props.model}
|
|
57
77
|
history={props.history}
|
|
78
|
+
infoSegments={infoSegments}
|
|
58
79
|
/>
|
|
59
80
|
<StatusBar segments={props.statusSegments} />
|
|
60
81
|
<Pickers />
|
|
@@ -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>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { DOMElement } from 'ink';
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
|
-
import type { ChatMessage } from 'mu-
|
|
3
|
+
import type { ChatMessage } from 'mu-core';
|
|
4
4
|
import { type RefObject, useMemo } from 'react';
|
|
5
5
|
import type { StreamState } from '../chat/useChatSession';
|
|
6
|
+
import { useTheme } from '../context/ThemeContext';
|
|
6
7
|
import { MessageItem } from './messages/messageItem';
|
|
7
8
|
import { StreamingOutput } from './messages/streamingOutput';
|
|
8
9
|
import { Scrollbar } from './primitives/scrollbar';
|
|
@@ -50,6 +51,7 @@ export function MessageView({
|
|
|
50
51
|
viewHeight: number;
|
|
51
52
|
contentHeight: number;
|
|
52
53
|
}) {
|
|
54
|
+
const theme = useTheme();
|
|
53
55
|
const toolMessageIndex = useMemo(() => indexToolMessages(messages), [messages]);
|
|
54
56
|
|
|
55
57
|
return (
|
|
@@ -61,7 +63,7 @@ export function MessageView({
|
|
|
61
63
|
<MessageItem key={i} msg={msg} toolMessages={toolMessageIndex.get(i)} />
|
|
62
64
|
))}
|
|
63
65
|
{streaming && <StreamingOutput currentText={stream.text} currentReasoning={stream.reasoning} />}
|
|
64
|
-
{error && <Text color=
|
|
66
|
+
{error && <Text color={theme.common.error}>Error: {error}</Text>}
|
|
65
67
|
</Box>
|
|
66
68
|
</Box>
|
|
67
69
|
<Scrollbar viewHeight={viewHeight} contentHeight={contentHeight} scrollOffset={scrollOffset} />
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
-
import type { ToolDisplayHint } from 'mu-
|
|
2
|
+
import type { ToolDisplayHint } from 'mu-core';
|
|
3
3
|
import { computeDiff, renderDiff } from '../../../utils/diff';
|
|
4
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
4
5
|
import { ToolHeader } from './ToolHeader';
|
|
5
6
|
|
|
6
7
|
interface EditOutputProps {
|
|
@@ -41,12 +42,13 @@ function parseEditArgs(args: string, hint: ToolDisplayHint | undefined): ParsedE
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export function EditOutput({ args, content, error, hint }: EditOutputProps) {
|
|
45
|
+
const theme = useTheme();
|
|
44
46
|
const { path, before, after } = parseEditArgs(args, hint);
|
|
45
47
|
const verb = hint?.verb ?? 'edit_file';
|
|
46
48
|
|
|
47
49
|
if (error) {
|
|
48
50
|
return (
|
|
49
|
-
<Box flexDirection="column" flexShrink={0} marginBottom={
|
|
51
|
+
<Box flexDirection="column" flexShrink={0} marginBottom={0}>
|
|
50
52
|
<ToolHeader name={verb} subtitle={path} error={true} />
|
|
51
53
|
<Text dimColor={true} wrap="wrap">
|
|
52
54
|
{content}
|
|
@@ -59,8 +61,8 @@ export function EditOutput({ args, content, error, hint }: EditOutputProps) {
|
|
|
59
61
|
|
|
60
62
|
if (diff.lines.length === 0 && diff.totalOldLines > 0 && diff.totalNewLines > 0) {
|
|
61
63
|
return (
|
|
62
|
-
<Box flexDirection="column" flexShrink={0} marginBottom={
|
|
63
|
-
<Text color=
|
|
64
|
+
<Box flexDirection="column" flexShrink={0} marginBottom={0}>
|
|
65
|
+
<Text color={theme.diff.warning} bold={true}>
|
|
64
66
|
! {verb}
|
|
65
67
|
</Text>
|
|
66
68
|
<Text dimColor={true}> {path}</Text>
|
|
@@ -73,7 +75,7 @@ export function EditOutput({ args, content, error, hint }: EditOutputProps) {
|
|
|
73
75
|
|
|
74
76
|
if (diff.lines.length === 0) {
|
|
75
77
|
return (
|
|
76
|
-
<Box flexDirection="column" flexShrink={0} marginBottom={
|
|
78
|
+
<Box flexDirection="column" flexShrink={0} marginBottom={0}>
|
|
77
79
|
<ToolHeader name={verb} subtitle={path} />
|
|
78
80
|
<Text dimColor={true}>No changes (content identical)</Text>
|
|
79
81
|
</Box>
|
|
@@ -83,13 +85,19 @@ export function EditOutput({ args, content, error, hint }: EditOutputProps) {
|
|
|
83
85
|
const { lines, truncated } = renderDiff(diff, MAX_DIFF_LINES);
|
|
84
86
|
|
|
85
87
|
return (
|
|
86
|
-
<Box flexDirection="column" flexShrink={0} marginBottom={
|
|
88
|
+
<Box flexDirection="column" flexShrink={0} marginBottom={0}>
|
|
87
89
|
<ToolHeader name={verb} subtitle={path} />
|
|
88
|
-
<Box
|
|
90
|
+
<Box
|
|
91
|
+
flexDirection="column"
|
|
92
|
+
flexShrink={0}
|
|
93
|
+
backgroundColor={theme.tool.previewBackground}
|
|
94
|
+
paddingX={1}
|
|
95
|
+
paddingY={0}
|
|
96
|
+
>
|
|
89
97
|
{lines.map((line, i) => {
|
|
90
98
|
let color: string | undefined;
|
|
91
|
-
if (line.startsWith('-')) color =
|
|
92
|
-
else if (line.startsWith('+')) color =
|
|
99
|
+
if (line.startsWith('-')) color = theme.diff.removed;
|
|
100
|
+
else if (line.startsWith('+')) color = theme.diff.added;
|
|
93
101
|
return (
|
|
94
102
|
// biome-ignore lint/suspicious/noArrayIndexKey: diff lines may repeat (blank lines, braces); index disambiguates
|
|
95
103
|
<Text key={`${i}-${line}`} color={color} dimColor={color === undefined} wrap="wrap">
|
|
@@ -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}>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
+
import { useTheme } from '../../context/ThemeContext';
|
|
2
3
|
|
|
3
4
|
interface ToolHeaderProps {
|
|
4
5
|
/** The tool name shown after the status icon. */
|
|
@@ -15,12 +16,15 @@ interface ToolHeaderProps {
|
|
|
15
16
|
* specific component doesn't have to re-implement the same layout.
|
|
16
17
|
*/
|
|
17
18
|
export function ToolHeader({ name, subtitle, error = false }: ToolHeaderProps) {
|
|
19
|
+
const theme = useTheme();
|
|
18
20
|
return (
|
|
19
|
-
<Box
|
|
20
|
-
<Text
|
|
21
|
-
{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>}
|
|
22
27
|
</Text>
|
|
23
|
-
{subtitle && <Text dimColor={true}> {subtitle}</Text>}
|
|
24
28
|
</Box>
|
|
25
29
|
);
|
|
26
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>}
|