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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ChatMessage, ProviderConfig, Session } from 'mu-core';
|
|
2
|
-
import { type PluginRegistry, runTransformUserInputHooks } from 'mu-core';
|
|
2
|
+
import { type PluginRegistry, runDecorateMessageHooks, runTransformUserInputHooks } from 'mu-core';
|
|
3
3
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { SessionPathHolder } from '../../runtime/createRegistry';
|
|
4
5
|
import type { HostMessageBus } from '../../runtime/messageBus';
|
|
5
6
|
import type { AttachmentState } from './useAttachment';
|
|
6
7
|
import { useSessionPersistence } from './useSessionPersistence';
|
|
@@ -23,6 +24,12 @@ export interface ChatSessionState {
|
|
|
23
24
|
onSend: (text: string) => Promise<void>;
|
|
24
25
|
onNew: () => void;
|
|
25
26
|
onLoadSession: (path: string) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Compact the current transcript: ask the model to summarize the entire
|
|
29
|
+
* conversation, then replace the transcript with `[user marker, assistant
|
|
30
|
+
* summary]`. Frees context while keeping intent + key decisions in scope.
|
|
31
|
+
*/
|
|
32
|
+
onCompact: () => Promise<void>;
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
interface SessionDeps {
|
|
@@ -41,6 +48,7 @@ interface SessionDeps {
|
|
|
41
48
|
initialMessages?: ChatMessage[];
|
|
42
49
|
registry: PluginRegistry;
|
|
43
50
|
messageBus?: HostMessageBus;
|
|
51
|
+
sessionPathHolder?: SessionPathHolder;
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
/**
|
|
@@ -151,6 +159,76 @@ interface OnSendDeps {
|
|
|
151
159
|
streaming: boolean;
|
|
152
160
|
}
|
|
153
161
|
|
|
162
|
+
interface OnCompactDeps {
|
|
163
|
+
streaming: boolean;
|
|
164
|
+
session: Session;
|
|
165
|
+
config: ProviderConfig;
|
|
166
|
+
currentModel: string;
|
|
167
|
+
registry: PluginRegistry;
|
|
168
|
+
controllerRef: React.RefObject<AbortController | null>;
|
|
169
|
+
saveCurrent: (messages: ChatMessage[]) => void;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const COMPACT_INSTRUCTION =
|
|
173
|
+
'Compact this conversation. Produce ONE concise summary that captures: ' +
|
|
174
|
+
"1) the user's overall intent, 2) key decisions made, 3) files modified " +
|
|
175
|
+
'(paths with line refs where relevant), 4) open tasks / next steps, and ' +
|
|
176
|
+
'5) any important context the assistant should retain. Output ONLY the ' +
|
|
177
|
+
'summary text — no preface, no markdown headers.';
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Walk a transcript backwards starting at `fromIndex` and return the first
|
|
181
|
+
* assistant message with non-empty content. Used by the compact flow to
|
|
182
|
+
* locate the summary the LLM produced for the freshly-injected request.
|
|
183
|
+
*/
|
|
184
|
+
function findLatestAssistantContent(messages: ChatMessage[], fromIndex: number): string {
|
|
185
|
+
for (let i = messages.length - 1; i >= fromIndex; i--) {
|
|
186
|
+
const m = messages[i];
|
|
187
|
+
if (m.role === 'assistant' && m.content && m.content.trim().length > 0) {
|
|
188
|
+
return m.content;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return '';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function useOnCompact(deps: OnCompactDeps): () => Promise<void> {
|
|
195
|
+
const { streaming, session, config, currentModel, registry, controllerRef, saveCurrent } = deps;
|
|
196
|
+
return useCallback(async () => {
|
|
197
|
+
if (streaming) return;
|
|
198
|
+
const before = session.getMessages();
|
|
199
|
+
if (before.length === 0) return;
|
|
200
|
+
const beforeCount = before.length;
|
|
201
|
+
|
|
202
|
+
// Hide the summarization instruction from the on-screen transcript
|
|
203
|
+
// (`display.hidden`) but keep it in the LLM payload. Once the run
|
|
204
|
+
// completes we replace the entire transcript anyway.
|
|
205
|
+
const summaryInstruction: ChatMessage = {
|
|
206
|
+
role: 'user',
|
|
207
|
+
content: COMPACT_INSTRUCTION,
|
|
208
|
+
display: { hidden: true },
|
|
209
|
+
};
|
|
210
|
+
const controller = new AbortController();
|
|
211
|
+
controllerRef.current = controller;
|
|
212
|
+
controller.signal.addEventListener('abort', () => session.abort(), { once: true });
|
|
213
|
+
try {
|
|
214
|
+
await session.runTurn({ userMessage: summaryInstruction, config, model: currentModel, registry });
|
|
215
|
+
const summary = findLatestAssistantContent(session.getMessages(), beforeCount);
|
|
216
|
+
if (!summary) return;
|
|
217
|
+
// Replace the entire history with a single user marker + the assistant
|
|
218
|
+
// summary. The next turn runs with a tiny context, "resumed" from
|
|
219
|
+
// the compacted state.
|
|
220
|
+
const compacted: ChatMessage[] = [
|
|
221
|
+
{ role: 'user', content: '[Conversation compacted — context below preserves prior intent and decisions]' },
|
|
222
|
+
{ role: 'assistant', content: summary },
|
|
223
|
+
];
|
|
224
|
+
session.setMessages(compacted);
|
|
225
|
+
saveCurrent(compacted);
|
|
226
|
+
} finally {
|
|
227
|
+
controllerRef.current = null;
|
|
228
|
+
}
|
|
229
|
+
}, [streaming, session, config, currentModel, registry, controllerRef, saveCurrent]);
|
|
230
|
+
}
|
|
231
|
+
|
|
154
232
|
function useOnSend(deps: OnSendDeps): (text: string) => Promise<void> {
|
|
155
233
|
const { session, config, currentModel, attachment, controllerRef, registry, messageBus, appendHistory, streaming } =
|
|
156
234
|
deps;
|
|
@@ -160,13 +238,22 @@ function useOnSend(deps: OnSendDeps): (text: string) => Promise<void> {
|
|
|
160
238
|
|
|
161
239
|
const transform = await runTransformUserInputHooks(registry.getHooks(), text);
|
|
162
240
|
if (transform.kind === 'intercept') return;
|
|
241
|
+
|
|
242
|
+
// `continue` signals the hook handled the user message itself
|
|
243
|
+
// (e.g. mu-agents' @-mention dispatch path appends the user msg
|
|
244
|
+
// live, runs the subagent live, and queues the synthetic tool
|
|
245
|
+
// flow for the upcoming turn). We skip the userMessage push but
|
|
246
|
+
// still drain the queue and stream the LLM follow-up.
|
|
247
|
+
const isContinue = transform.kind === 'continue';
|
|
163
248
|
const finalText = transform.kind === 'transform' ? transform.text : text;
|
|
164
249
|
|
|
165
|
-
const userMsg: ChatMessage =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
250
|
+
const userMsg: ChatMessage | undefined = isContinue
|
|
251
|
+
? undefined
|
|
252
|
+
: await runDecorateMessageHooks(registry.getHooks(), {
|
|
253
|
+
role: 'user',
|
|
254
|
+
content: finalText,
|
|
255
|
+
...(attachment.attachment ? { images: [attachment.attachment] } : {}),
|
|
256
|
+
});
|
|
170
257
|
|
|
171
258
|
const injections = messageBus?.drainNext() ?? [];
|
|
172
259
|
for (const inj of injections) session.queueForNextTurn(inj);
|
|
@@ -204,7 +291,7 @@ function useOnSend(deps: OnSendDeps): (text: string) => Promise<void> {
|
|
|
204
291
|
*/
|
|
205
292
|
export function useChatSession(deps: SessionDeps): ChatSessionState {
|
|
206
293
|
const { session, config, currentModel, attachment, controllerRef, initialMessages, registry, messageBus } = deps;
|
|
207
|
-
const persistence = useSessionPersistence(initialMessages);
|
|
294
|
+
const persistence = useSessionPersistence(initialMessages, deps.sessionPathHolder);
|
|
208
295
|
const { appendHistory, saveCurrent, resetForNew, loadFromPath } = persistence;
|
|
209
296
|
|
|
210
297
|
// Initial seed: feed any persisted messages into the session once.
|
|
@@ -253,6 +340,16 @@ export function useChatSession(deps: SessionDeps): ChatSessionState {
|
|
|
253
340
|
attachment.clear();
|
|
254
341
|
}, [resetForNew, session, attachment, controllerRef]);
|
|
255
342
|
|
|
343
|
+
const onCompact = useOnCompact({
|
|
344
|
+
streaming,
|
|
345
|
+
session,
|
|
346
|
+
config,
|
|
347
|
+
currentModel,
|
|
348
|
+
registry,
|
|
349
|
+
controllerRef,
|
|
350
|
+
saveCurrent,
|
|
351
|
+
});
|
|
352
|
+
|
|
256
353
|
const onLoadSession = useCallback(
|
|
257
354
|
(path: string) => {
|
|
258
355
|
const loaded = loadFromPath(path);
|
|
@@ -280,5 +377,6 @@ export function useChatSession(deps: SessionDeps): ChatSessionState {
|
|
|
280
377
|
onSend,
|
|
281
378
|
onNew,
|
|
282
379
|
onLoadSession,
|
|
380
|
+
onCompact,
|
|
283
381
|
};
|
|
284
382
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ApiModel } from 'mu-core';
|
|
2
|
-
import { listModels } from 'mu-openai-provider';
|
|
2
|
+
import { fetchModelContextLimit, listModels } from 'mu-openai-provider';
|
|
3
3
|
import { useCallback, useEffect, useState } from 'react';
|
|
4
4
|
import { saveConfig } from '../../config/index';
|
|
5
5
|
|
|
@@ -42,6 +42,30 @@ export function useModelList(baseUrl: string, preferredModel?: string): ModelLis
|
|
|
42
42
|
};
|
|
43
43
|
}, [baseUrl, preferredModel]);
|
|
44
44
|
|
|
45
|
+
// Lazily probe the active model's context window. Fires whenever the
|
|
46
|
+
// selection changes; skips if we already know the limit for that id.
|
|
47
|
+
// Runs in the background so the UI never blocks waiting for `/props`.
|
|
48
|
+
// Triggers a model load on llama-swap-style proxies, but only for the
|
|
49
|
+
// model the user has actually picked — same model the next chat would
|
|
50
|
+
// load anyway.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!(baseUrl && currentModel)) return;
|
|
53
|
+
const known = models.find((m) => m.id === currentModel);
|
|
54
|
+
if (!known || known.contextLimit !== undefined) return;
|
|
55
|
+
let cancelled = false;
|
|
56
|
+
fetchModelContextLimit(baseUrl, currentModel)
|
|
57
|
+
.then((limit) => {
|
|
58
|
+
if (cancelled || !limit) return;
|
|
59
|
+
setModels((prev) => prev.map((m) => (m.id === currentModel ? { ...m, contextLimit: limit } : m)));
|
|
60
|
+
})
|
|
61
|
+
.catch(() => {
|
|
62
|
+
/* silently ignore — providers without `/props` just don't get a limit */
|
|
63
|
+
});
|
|
64
|
+
return () => {
|
|
65
|
+
cancelled = true;
|
|
66
|
+
};
|
|
67
|
+
}, [baseUrl, currentModel, models]);
|
|
68
|
+
|
|
45
69
|
const cycleModel = useCallback(() => {
|
|
46
70
|
if (models.length === 0) {
|
|
47
71
|
return;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ChatMessage } from 'mu-core';
|
|
2
|
-
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import type { SessionPathHolder } from '../../runtime/createRegistry';
|
|
3
4
|
import { generateSessionPath, loadSession, saveSession } from '../../sessions/index';
|
|
4
5
|
|
|
5
6
|
export interface SessionPersistenceState {
|
|
@@ -32,10 +33,20 @@ function userPromptsFrom(messages: ChatMessage[]): string[] {
|
|
|
32
33
|
* Save errors are logged to stderr and do not surface to the chat error
|
|
33
34
|
* channel — they're considered non-fatal (next save attempt may succeed).
|
|
34
35
|
*/
|
|
35
|
-
export function useSessionPersistence(
|
|
36
|
+
export function useSessionPersistence(
|
|
37
|
+
initialMessages?: ChatMessage[],
|
|
38
|
+
sessionPathHolder?: SessionPathHolder,
|
|
39
|
+
): SessionPersistenceState {
|
|
36
40
|
const [inputHistory, setInputHistory] = useState<string[]>(userPromptsFrom(initialMessages ?? []));
|
|
37
41
|
const sessionPathRef = useRef(generateSessionPath());
|
|
38
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
|
+
|
|
39
50
|
const appendHistory = useCallback((text: string) => {
|
|
40
51
|
setInputHistory((prev) => [...prev, text]);
|
|
41
52
|
}, []);
|
|
@@ -48,17 +59,22 @@ export function useSessionPersistence(initialMessages?: ChatMessage[]): SessionP
|
|
|
48
59
|
|
|
49
60
|
const resetForNew = useCallback(() => {
|
|
50
61
|
sessionPathRef.current = generateSessionPath();
|
|
62
|
+
if (sessionPathHolder) sessionPathHolder.current = sessionPathRef.current;
|
|
51
63
|
setInputHistory([]);
|
|
52
|
-
}, []);
|
|
64
|
+
}, [sessionPathHolder]);
|
|
53
65
|
|
|
54
|
-
const loadFromPath = useCallback(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
);
|
|
62
78
|
|
|
63
79
|
const setHistory = useCallback((history: string[]) => {
|
|
64
80
|
setInputHistory(history);
|
|
@@ -14,6 +14,9 @@ interface StatusSegmentOptions {
|
|
|
14
14
|
/** Tokens served from server-side prompt cache. Rendered as `(N cached)`
|
|
15
15
|
* next to the total when > 0. Omit (or pass 0) to hide the suffix. */
|
|
16
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;
|
|
17
20
|
pluginStatus?: StatusSegment[];
|
|
18
21
|
}
|
|
19
22
|
|
|
@@ -23,6 +26,14 @@ function truncate(text: string, max: number): string {
|
|
|
23
26
|
|
|
24
27
|
const tokenFormatter = new Intl.NumberFormat('en-US');
|
|
25
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
|
+
}
|
|
26
37
|
return tokenFormatter.format(n);
|
|
27
38
|
}
|
|
28
39
|
|
|
@@ -31,15 +42,24 @@ export function useStatusSegments(options: StatusSegmentOptions): StatusBarSegme
|
|
|
31
42
|
const segments: StatusBarSegment[] = [];
|
|
32
43
|
|
|
33
44
|
if (options.streaming) {
|
|
34
|
-
segments.push({ text:
|
|
45
|
+
segments.push({ text: spinner, color: 'yellow', align: 'left' });
|
|
35
46
|
}
|
|
36
47
|
if (options.totalTokens > 0) {
|
|
37
48
|
const cached = options.cachedTokens ?? 0;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
}
|
|
43
63
|
}
|
|
44
64
|
if (options.abortWarning) {
|
|
45
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,5 +1,7 @@
|
|
|
1
|
+
import type { SubagentRunRegistry } from 'mu-agents';
|
|
1
2
|
import type { ChatMessage, PluginRegistry, ProviderConfig } from 'mu-core';
|
|
2
3
|
import type { ShutdownFn } from '../../../app/shutdown';
|
|
4
|
+
import type { SessionPathHolder } from '../../../runtime/createRegistry';
|
|
3
5
|
import type { HostMessageBus } from '../../../runtime/messageBus';
|
|
4
6
|
import { ChatContext } from '../../chat/ChatContext';
|
|
5
7
|
import { MessageRendererProvider, useRegistryRenderers } from '../../chat/MessageRendererContext';
|
|
@@ -15,6 +17,8 @@ export function ChatPanel({
|
|
|
15
17
|
messageBus,
|
|
16
18
|
uiService,
|
|
17
19
|
shutdown,
|
|
20
|
+
sessionPathHolder,
|
|
21
|
+
subagentRuns,
|
|
18
22
|
}: {
|
|
19
23
|
config: ProviderConfig;
|
|
20
24
|
initialMessages?: ChatMessage[];
|
|
@@ -22,8 +26,19 @@ export function ChatPanel({
|
|
|
22
26
|
messageBus?: HostMessageBus;
|
|
23
27
|
uiService?: InkUIService;
|
|
24
28
|
shutdown?: ShutdownFn;
|
|
29
|
+
sessionPathHolder?: SessionPathHolder;
|
|
30
|
+
subagentRuns?: SubagentRunRegistry;
|
|
25
31
|
}) {
|
|
26
|
-
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
|
+
});
|
|
27
42
|
const toolDisplays = useToolDisplayMap(registry);
|
|
28
43
|
const renderers = useRegistryRenderers(registry);
|
|
29
44
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Box, type DOMElement as InkDOMElement } from 'ink';
|
|
2
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 />
|