mu-coding 0.15.0 → 0.16.1

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 (118) hide show
  1. package/README.md +9 -123
  2. package/bin/coding-agent.ts +95 -0
  3. package/package.json +10 -21
  4. package/src/config.ts +122 -0
  5. package/src/harness.test.ts +159 -0
  6. package/src/main.ts +53 -3
  7. package/src/plugins.ts +49 -0
  8. package/src/systemPrompt.ts +22 -0
  9. package/src/ui/ChatApp.ts +959 -0
  10. package/src/ui/commands.ts +35 -0
  11. package/src/ui/editor.ts +166 -0
  12. package/src/ui/markdown.ts +363 -0
  13. package/src/ui/picker.ts +126 -0
  14. package/src/ui/status.ts +61 -0
  15. package/src/ui/theme.ts +241 -0
  16. package/src/ui/transcript.test.ts +121 -0
  17. package/src/ui/transcript.ts +399 -0
  18. package/tsconfig.json +8 -0
  19. package/bin/mu.js +0 -2
  20. package/prompts/SYSTEM.md +0 -16
  21. package/src/app/shutdown.ts +0 -94
  22. package/src/app/startApp.ts +0 -49
  23. package/src/cli/args.ts +0 -133
  24. package/src/cli/install.ts +0 -107
  25. package/src/cli/subcommands.ts +0 -29
  26. package/src/cli/update.ts +0 -205
  27. package/src/config/index.test.ts +0 -77
  28. package/src/config/index.ts +0 -199
  29. package/src/plugin.ts +0 -124
  30. package/src/runtime/codingTools/bash.ts +0 -114
  31. package/src/runtime/codingTools/edit-file.ts +0 -60
  32. package/src/runtime/codingTools/index.ts +0 -39
  33. package/src/runtime/codingTools/read-file.ts +0 -83
  34. package/src/runtime/codingTools/utils.ts +0 -21
  35. package/src/runtime/codingTools/write-file.ts +0 -42
  36. package/src/runtime/createRegistry.test.ts +0 -147
  37. package/src/runtime/createRegistry.ts +0 -195
  38. package/src/runtime/fileMentionProvider.ts +0 -117
  39. package/src/runtime/messageBus.test.ts +0 -62
  40. package/src/runtime/messageBus.ts +0 -78
  41. package/src/runtime/pluginLoader.ts +0 -153
  42. package/src/runtime/startupUpdateCheck.ts +0 -163
  43. package/src/runtime/updateCheck.ts +0 -136
  44. package/src/sessions/index.test.ts +0 -66
  45. package/src/sessions/index.ts +0 -183
  46. package/src/sessions/peek.test.ts +0 -88
  47. package/src/sessions/project.ts +0 -51
  48. package/src/tui/channel/tuiChannel.test.ts +0 -107
  49. package/src/tui/channel/tuiChannel.ts +0 -62
  50. package/src/tui/chat/ChatContext.ts +0 -10
  51. package/src/tui/chat/MessageRendererContext.ts +0 -44
  52. package/src/tui/chat/ToolDisplayContext.ts +0 -33
  53. package/src/tui/chat/useAbort.ts +0 -85
  54. package/src/tui/chat/useAttachment.ts +0 -74
  55. package/src/tui/chat/useChat.ts +0 -113
  56. package/src/tui/chat/useChatPanel.ts +0 -120
  57. package/src/tui/chat/useChatSession.ts +0 -384
  58. package/src/tui/chat/useModels.ts +0 -83
  59. package/src/tui/chat/usePluginStatus.ts +0 -44
  60. package/src/tui/chat/useSessionPersistence.ts +0 -84
  61. package/src/tui/chat/useStatusSegments.ts +0 -85
  62. package/src/tui/chat/useSubagentBrowser.ts +0 -133
  63. package/src/tui/components/chat/ChatPanel.tsx +0 -54
  64. package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
  65. package/src/tui/components/chat/Pickers.tsx +0 -44
  66. package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
  67. package/src/tui/components/messageView.tsx +0 -72
  68. package/src/tui/components/messages/EditOutput.tsx +0 -112
  69. package/src/tui/components/messages/ReadOutput.tsx +0 -48
  70. package/src/tui/components/messages/ToolHeader.tsx +0 -30
  71. package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
  72. package/src/tui/components/messages/WriteOutput.tsx +0 -64
  73. package/src/tui/components/messages/assistantMessage.tsx +0 -72
  74. package/src/tui/components/messages/markdown.tsx +0 -407
  75. package/src/tui/components/messages/messageItem.tsx +0 -43
  76. package/src/tui/components/messages/reasoningBlock.tsx +0 -18
  77. package/src/tui/components/messages/streamingOutput.tsx +0 -18
  78. package/src/tui/components/messages/toolCallBlock.tsx +0 -125
  79. package/src/tui/components/messages/userMessage.tsx +0 -44
  80. package/src/tui/components/primitives/dropdown.tsx +0 -125
  81. package/src/tui/components/primitives/modal.tsx +0 -47
  82. package/src/tui/components/primitives/pickerModal.tsx +0 -47
  83. package/src/tui/components/primitives/scrollbar.tsx +0 -27
  84. package/src/tui/components/primitives/toast.tsx +0 -100
  85. package/src/tui/components/statusBar.tsx +0 -41
  86. package/src/tui/components/ui/dialogLayer.tsx +0 -175
  87. package/src/tui/context/ThemeContext.tsx +0 -18
  88. package/src/tui/hooks/useChordKeyboard.ts +0 -87
  89. package/src/tui/hooks/useInputInfoSegments.ts +0 -22
  90. package/src/tui/hooks/useScroll.ts +0 -64
  91. package/src/tui/hooks/useTerminal.ts +0 -40
  92. package/src/tui/hooks/useUI.ts +0 -15
  93. package/src/tui/input/InputBox.tsx +0 -6
  94. package/src/tui/input/InputBoxView.tsx +0 -293
  95. package/src/tui/input/commands.test.ts +0 -71
  96. package/src/tui/input/commands.ts +0 -55
  97. package/src/tui/input/cursor.test.ts +0 -136
  98. package/src/tui/input/cursor.ts +0 -214
  99. package/src/tui/input/dumpContext.ts +0 -107
  100. package/src/tui/input/sanitize.ts +0 -33
  101. package/src/tui/input/useCommandExecutor.ts +0 -32
  102. package/src/tui/input/useInputBox.ts +0 -265
  103. package/src/tui/input/useInputHandler.ts +0 -455
  104. package/src/tui/input/useMentionPicker.ts +0 -133
  105. package/src/tui/input/usePluginShortcuts.ts +0 -29
  106. package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
  107. package/src/tui/plugins/InkApprovalChannel.ts +0 -30
  108. package/src/tui/plugins/InkUIService.ts +0 -188
  109. package/src/tui/renderApp.tsx +0 -66
  110. package/src/tui/theme/index.ts +0 -1
  111. package/src/tui/theme/merge.test.ts +0 -49
  112. package/src/tui/theme/merge.ts +0 -43
  113. package/src/tui/theme/presets.ts +0 -90
  114. package/src/tui/theme/types.ts +0 -138
  115. package/src/tui/update/runUpdateInTui.ts +0 -127
  116. package/src/utils/clipboard.ts +0 -97
  117. package/src/utils/diff.test.ts +0 -56
  118. package/src/utils/diff.ts +0 -81
@@ -1,84 +0,0 @@
1
- import type { ChatMessage } from 'mu-core';
2
- import { useCallback, useEffect, useRef, useState } from 'react';
3
- import type { SessionPathHolder } from '../../runtime/createRegistry';
4
- import { generateSessionPath, loadSession, saveSession } from '../../sessions/index';
5
-
6
- export interface SessionPersistenceState {
7
- inputHistory: string[];
8
- appendHistory: (text: string) => void;
9
- sessionPathRef: React.RefObject<string>;
10
- /** Persist the given transcript to the current session file. */
11
- saveCurrent: (messages: ChatMessage[]) => void;
12
- /** Reset to a brand-new session: rotates the file path. */
13
- resetForNew: () => void;
14
- /**
15
- * Load a transcript from disk and adopt its path. Returns the loaded
16
- * messages so the caller can hand them to the session.
17
- */
18
- loadFromPath: (path: string) => ChatMessage[];
19
- /** Replace history (used after resume / load). */
20
- setHistory: (history: string[]) => void;
21
- }
22
-
23
- function userPromptsFrom(messages: ChatMessage[]): string[] {
24
- return messages.filter((m) => m.role === 'user').map((m) => m.content);
25
- }
26
-
27
- /**
28
- * Side-channel persistence: history bookkeeping, on-disk save, and session
29
- * file path management. Does NOT own the transcript — `Session` is the
30
- * single source of truth in the new architecture, this hook only writes to
31
- * disk and tracks the current target path.
32
- *
33
- * Save errors are logged to stderr and do not surface to the chat error
34
- * channel — they're considered non-fatal (next save attempt may succeed).
35
- */
36
- export function useSessionPersistence(
37
- initialMessages?: ChatMessage[],
38
- sessionPathHolder?: SessionPathHolder,
39
- ): SessionPersistenceState {
40
- const [inputHistory, setInputHistory] = useState<string[]>(userPromptsFrom(initialMessages ?? []));
41
- const sessionPathRef = useRef(generateSessionPath());
42
-
43
- // Mirror the live path into the cross-plugin holder so mu-agents can read
44
- // it lazily when dispatching a subagent. Using a useEffect keeps the
45
- // holder write off the React render path.
46
- useEffect(() => {
47
- if (sessionPathHolder) sessionPathHolder.current = sessionPathRef.current;
48
- }, [sessionPathHolder]);
49
-
50
- const appendHistory = useCallback((text: string) => {
51
- setInputHistory((prev) => [...prev, text]);
52
- }, []);
53
-
54
- const saveCurrent = useCallback((finalMessages: ChatMessage[]) => {
55
- saveSession(sessionPathRef.current, finalMessages).catch((err) => {
56
- console.error('Failed to save session:', err);
57
- });
58
- }, []);
59
-
60
- const resetForNew = useCallback(() => {
61
- sessionPathRef.current = generateSessionPath();
62
- if (sessionPathHolder) sessionPathHolder.current = sessionPathRef.current;
63
- setInputHistory([]);
64
- }, [sessionPathHolder]);
65
-
66
- const loadFromPath = useCallback(
67
- (path: string): ChatMessage[] => {
68
- const msgs = loadSession(path);
69
- if (msgs.length > 0) {
70
- sessionPathRef.current = path;
71
- if (sessionPathHolder) sessionPathHolder.current = sessionPathRef.current;
72
- setInputHistory(userPromptsFrom(msgs));
73
- }
74
- return msgs;
75
- },
76
- [sessionPathHolder],
77
- );
78
-
79
- const setHistory = useCallback((history: string[]) => {
80
- setInputHistory(history);
81
- }, []);
82
-
83
- return { inputHistory, appendHistory, sessionPathRef, saveCurrent, resetForNew, loadFromPath, setHistory };
84
- }
@@ -1,85 +0,0 @@
1
- import type { StatusSegment } from 'mu-core';
2
- import type { StatusBarSegment } from '../components/statusBar';
3
- import { useSpinner } from '../hooks/useUI';
4
-
5
- const ERROR_PREVIEW_LEN = 40;
6
-
7
- interface StatusSegmentOptions {
8
- streaming: boolean;
9
- abortWarning: boolean;
10
- quitWarning: boolean;
11
- error: string | null;
12
- modelError: string | null;
13
- totalTokens: number;
14
- /** Input tokens sent to the model for the current turn (prompt size). Used
15
- * for the context window percentage calculation. */
16
- promptTokens: number;
17
- /** Tokens served from server-side prompt cache. Rendered as `(N cached)`
18
- * next to the total when > 0. Omit (or pass 0) to hide the suffix. */
19
- cachedTokens?: number;
20
- /** Model context window (input + output) reported by the provider; when
21
- * set, the tokens segment is rendered as `used/limit tokens`. */
22
- contextLimit?: number;
23
- pluginStatus?: StatusSegment[];
24
- }
25
-
26
- function truncate(text: string, max: number): string {
27
- return text.length > max ? `${text.slice(0, max - 1)}…` : text;
28
- }
29
-
30
- const tokenFormatter = new Intl.NumberFormat('en-US');
31
- function formatTokens(n: number): string {
32
- if (n >= 1_000_000) {
33
- const v = n / 1_000_000;
34
- return `${v >= 10 ? v.toFixed(0) : v.toFixed(1)}M`;
35
- }
36
- if (n >= 1000) {
37
- const v = n / 1000;
38
- return `${v >= 10 ? v.toFixed(0) : v.toFixed(1)}k`;
39
- }
40
- return tokenFormatter.format(n);
41
- }
42
-
43
- export function useStatusSegments(options: StatusSegmentOptions): StatusBarSegment[] {
44
- const spinner = useSpinner(options.streaming);
45
- const segments: StatusBarSegment[] = [];
46
-
47
- if (options.streaming) {
48
- segments.push({ text: spinner, color: 'yellow', align: 'left' });
49
- }
50
- if (options.totalTokens > 0) {
51
- const cached = options.cachedTokens ?? 0;
52
- const used = formatTokens(options.promptTokens);
53
- let head: string;
54
- if (options.contextLimit) {
55
- const pct = (options.promptTokens / options.contextLimit) * 100;
56
- const pctStr = pct >= 10 ? pct.toFixed(0) : pct.toFixed(1);
57
- head = `${used} (${pctStr}%)`;
58
- } else {
59
- head = used;
60
- }
61
- if (cached > 0) {
62
- segments.push({ text: `${head} · ${formatTokens(cached)} cached`, dim: true });
63
- } else {
64
- segments.push({ text: head, dim: true });
65
- }
66
- }
67
- if (options.abortWarning) {
68
- segments.push({ text: 'Esc again to stop', color: 'yellow' });
69
- } else if (options.quitWarning) {
70
- segments.push({ text: 'Ctrl+C again to quit', color: 'yellow' });
71
- } else if (options.streaming) {
72
- segments.push({ text: 'Esc to stop', dim: true });
73
- }
74
- if (options.error) {
75
- segments.push({ text: `⚠ ${truncate(options.error, ERROR_PREVIEW_LEN)}`, color: 'red' });
76
- }
77
- if (options.modelError) {
78
- segments.push({ text: `⚠ ${truncate(options.modelError, ERROR_PREVIEW_LEN)}`, color: 'red' });
79
- }
80
- if (options.pluginStatus) {
81
- segments.push(...options.pluginStatus);
82
- }
83
-
84
- return segments;
85
- }
@@ -1,133 +0,0 @@
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,54 +0,0 @@
1
- import type { SubagentRunRegistry } from 'mu-agents';
2
- import type { ChatMessage, PluginRegistry, ProviderConfig } from 'mu-core';
3
- import type { ShutdownFn } from '../../../app/shutdown';
4
- import type { SessionPathHolder } from '../../../runtime/createRegistry';
5
- import type { HostMessageBus } from '../../../runtime/messageBus';
6
- import { ChatContext } from '../../chat/ChatContext';
7
- import { MessageRendererProvider, useRegistryRenderers } from '../../chat/MessageRendererContext';
8
- import { ToolDisplayProvider, useToolDisplayMap } from '../../chat/ToolDisplayContext';
9
- import { useChatPanel } from '../../chat/useChatPanel';
10
- import type { InkUIService } from '../../plugins/InkUIService';
11
- import { ChatPanelBody } from './ChatPanelBody';
12
-
13
- export function ChatPanel({
14
- config,
15
- initialMessages,
16
- registry,
17
- messageBus,
18
- uiService,
19
- shutdown,
20
- sessionPathHolder,
21
- subagentRuns,
22
- }: {
23
- config: ProviderConfig;
24
- initialMessages?: ChatMessage[];
25
- registry: PluginRegistry;
26
- messageBus?: HostMessageBus;
27
- uiService?: InkUIService;
28
- shutdown?: ShutdownFn;
29
- sessionPathHolder?: SessionPathHolder;
30
- subagentRuns?: SubagentRunRegistry;
31
- }) {
32
- const { ctx, bodyProps } = useChatPanel({
33
- config,
34
- initialMessages,
35
- registry,
36
- messageBus,
37
- uiService,
38
- shutdown,
39
- sessionPathHolder,
40
- subagentRuns,
41
- });
42
- const toolDisplays = useToolDisplayMap(registry);
43
- const renderers = useRegistryRenderers(registry);
44
-
45
- return (
46
- <ChatContext.Provider value={ctx}>
47
- <ToolDisplayProvider value={toolDisplays}>
48
- <MessageRendererProvider value={renderers}>
49
- <ChatPanelBody {...bodyProps} />
50
- </MessageRendererProvider>
51
- </ToolDisplayProvider>
52
- </ChatContext.Provider>
53
- );
54
- }
@@ -1,86 +0,0 @@
1
- import { Box, type DOMElement as InkDOMElement } from 'ink';
2
- import type { ChatMessage } from 'mu-core';
3
- import type { StreamState } from '../../chat/useChatSession';
4
- import type { SubagentBrowserState } from '../../chat/useSubagentBrowser';
5
- import { useInputInfoSegments } from '../../hooks/useInputInfoSegments';
6
- import { InputBox } from '../../input/InputBox';
7
- import type { InkUIService } from '../../plugins/InkUIService';
8
- import { MessageView } from '../messageView';
9
- import { type Toast, ToastContainer } from '../primitives/toast';
10
- import type { StatusBarSegment } from '../statusBar';
11
- import { StatusBar } from '../statusBar';
12
- import { DialogLayer } from '../ui/dialogLayer';
13
- import { Pickers } from './Pickers';
14
- import { SubagentBrowserPanel } from './SubagentBrowserPanel';
15
-
16
- export interface ChatPanelBodyProps {
17
- width: number;
18
- height: number;
19
- viewRef: React.RefObject<InkDOMElement | null>;
20
- contentRef: React.RefObject<InkDOMElement | null>;
21
- scrollOffset: number;
22
- viewHeight: number;
23
- contentHeight: number;
24
- isActive: boolean;
25
- onScrollUp: () => void;
26
- onScrollDown: () => void;
27
- uiService?: InkUIService;
28
- messages: ChatMessage[];
29
- streaming: boolean;
30
- stream: StreamState;
31
- error: string | null;
32
- onSubmit: (text: string) => void;
33
- model: string;
34
- history: string[];
35
- statusSegments: StatusBarSegment[];
36
- toasts: Toast[];
37
- onDismissToast: (id: number) => void;
38
- /** Subagent browser state — when in subagent mode the chat body is replaced. */
39
- browser?: SubagentBrowserState;
40
- }
41
-
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
-
58
- return (
59
- <Box flexDirection="column" height={props.height} width={props.width}>
60
- <MessageView
61
- viewRef={props.viewRef}
62
- contentRef={props.contentRef}
63
- messages={props.messages}
64
- streaming={props.streaming}
65
- stream={props.stream}
66
- error={props.error}
67
- scrollOffset={props.scrollOffset}
68
- viewHeight={props.viewHeight}
69
- contentHeight={props.contentHeight}
70
- />
71
- <InputBox
72
- onSubmit={props.onSubmit}
73
- onScrollUp={props.onScrollUp}
74
- onScrollDown={props.onScrollDown}
75
- isActive={props.isActive}
76
- model={props.model}
77
- history={props.history}
78
- infoSegments={infoSegments}
79
- />
80
- <StatusBar segments={props.statusSegments} />
81
- <Pickers />
82
- {props.uiService && <DialogLayer service={props.uiService} />}
83
- <ToastContainer toasts={props.toasts} onDismiss={props.onDismissToast} />
84
- </Box>
85
- );
86
- }
@@ -1,44 +0,0 @@
1
- import { useMemo } from 'react';
2
- import { useChatContext } from '../../chat/ChatContext';
3
- import { PickerModal } from '../primitives/pickerModal';
4
-
5
- export function Pickers() {
6
- const { toggles, models, sessions, session } = useChatContext();
7
- const sessionItems = useMemo(
8
- () =>
9
- sessions.map((s) => ({
10
- label: s.preview,
11
- value: s.path,
12
- description: `${s.messageCount} msgs`,
13
- })),
14
- [sessions],
15
- );
16
-
17
- return (
18
- <>
19
- <PickerModal
20
- visible={toggles.showModelPicker}
21
- title="Select model"
22
- items={models.models.map((m) => ({ label: m.id, value: m.id }))}
23
- placeholder="Search models..."
24
- onSelect={(id) => {
25
- models.selectModel(id);
26
- toggles.onTogglePicker();
27
- }}
28
- onCancel={toggles.onTogglePicker}
29
- />
30
- <PickerModal
31
- visible={toggles.showSessionPicker}
32
- title={`Sessions · ${sessions[0]?.project ?? 'project'}`}
33
- items={sessionItems}
34
- placeholder="Search sessions..."
35
- emptyMessage="No sessions found for this project"
36
- onSelect={(p) => {
37
- session.onLoadSession(p);
38
- toggles.onToggleSessionPicker();
39
- }}
40
- onCancel={toggles.onToggleSessionPicker}
41
- />
42
- </>
43
- );
44
- }
@@ -1,145 +0,0 @@
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>;