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,8 +1,10 @@
|
|
|
1
1
|
import { type DOMElement as InkDOMElement, useInput } from 'ink';
|
|
2
|
-
import type {
|
|
3
|
-
import type { ChatMessage, ProviderConfig } from 'mu-
|
|
2
|
+
import type { SubagentRunRegistry } from 'mu-agents';
|
|
3
|
+
import type { ChatMessage, PluginRegistry, ProviderConfig } from 'mu-core';
|
|
4
4
|
import { useEffect, useMemo, useRef } from 'react';
|
|
5
5
|
import type { ShutdownFn } from '../../app/shutdown';
|
|
6
|
+
import type { SessionPathHolder } from '../../runtime/createRegistry';
|
|
7
|
+
import type { HostMessageBus } from '../../runtime/messageBus';
|
|
6
8
|
import type { ChatPanelBodyProps } from '../components/chat/ChatPanelBody';
|
|
7
9
|
import { useToast } from '../components/primitives/toast';
|
|
8
10
|
import { useScroll } from '../hooks/useScroll';
|
|
@@ -11,6 +13,7 @@ import type { InkUIService, ToastRequest } from '../plugins/InkUIService';
|
|
|
11
13
|
import { useChat } from './useChat';
|
|
12
14
|
import { usePluginStatus } from './usePluginStatus';
|
|
13
15
|
import { useStatusSegments } from './useStatusSegments';
|
|
16
|
+
import { type SubagentBrowserState, useSubagentBrowser } from './useSubagentBrowser';
|
|
14
17
|
|
|
15
18
|
const TOAST_LEVEL_COLORS: Record<string, string> = {
|
|
16
19
|
info: 'cyan',
|
|
@@ -23,13 +26,27 @@ interface UseChatPanelOptions {
|
|
|
23
26
|
config: ProviderConfig;
|
|
24
27
|
initialMessages?: ChatMessage[];
|
|
25
28
|
registry: PluginRegistry;
|
|
29
|
+
messageBus?: HostMessageBus;
|
|
26
30
|
uiService?: InkUIService;
|
|
27
31
|
shutdown?: ShutdownFn;
|
|
32
|
+
sessionPathHolder?: SessionPathHolder;
|
|
33
|
+
subagentRuns?: SubagentRunRegistry;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export function useChatPanel(options: UseChatPanelOptions) {
|
|
31
|
-
const { config, initialMessages, registry, uiService, shutdown } =
|
|
32
|
-
|
|
37
|
+
const { config, initialMessages, registry, messageBus, uiService, shutdown, sessionPathHolder, subagentRuns } =
|
|
38
|
+
options;
|
|
39
|
+
const ctx = useChat(
|
|
40
|
+
config,
|
|
41
|
+
registry,
|
|
42
|
+
initialMessages,
|
|
43
|
+
shutdown,
|
|
44
|
+
uiService,
|
|
45
|
+
messageBus,
|
|
46
|
+
sessionPathHolder,
|
|
47
|
+
subagentRuns,
|
|
48
|
+
);
|
|
49
|
+
const browser = useSubagentBrowser(subagentRuns);
|
|
33
50
|
const { width, height } = useTerminalSize();
|
|
34
51
|
const viewRef = useRef<InkDOMElement>(null);
|
|
35
52
|
const contentRef = useRef<InkDOMElement>(null);
|
|
@@ -58,13 +75,16 @@ export function useChatPanel(options: UseChatPanelOptions) {
|
|
|
58
75
|
});
|
|
59
76
|
}, [uiService, show]);
|
|
60
77
|
|
|
78
|
+
const contextLimit = ctx.models.models.find((m) => m.id === ctx.models.currentModel)?.contextLimit;
|
|
61
79
|
const statusSegments = useStatusSegments({
|
|
62
80
|
streaming: ctx.session.streaming,
|
|
63
81
|
abortWarning: ctx.abort.abortWarning,
|
|
64
82
|
quitWarning: ctx.abort.quitWarning,
|
|
65
83
|
error: ctx.session.error,
|
|
66
84
|
modelError: ctx.models.modelError,
|
|
67
|
-
|
|
85
|
+
totalTokens: ctx.session.stream.totalTokens,
|
|
86
|
+
cachedTokens: ctx.session.stream.cachedTokens,
|
|
87
|
+
contextLimit,
|
|
68
88
|
pluginStatus,
|
|
69
89
|
});
|
|
70
90
|
|
|
@@ -76,7 +96,7 @@ export function useChatPanel(options: UseChatPanelOptions) {
|
|
|
76
96
|
scrollOffset,
|
|
77
97
|
viewHeight,
|
|
78
98
|
contentHeight,
|
|
79
|
-
isActive: !anyModalOpen,
|
|
99
|
+
isActive: !anyModalOpen && browser.mode.kind === 'chat',
|
|
80
100
|
onScrollUp,
|
|
81
101
|
onScrollDown,
|
|
82
102
|
uiService,
|
|
@@ -90,7 +110,10 @@ export function useChatPanel(options: UseChatPanelOptions) {
|
|
|
90
110
|
statusSegments,
|
|
91
111
|
toasts,
|
|
92
112
|
onDismissToast: dismiss,
|
|
113
|
+
browser,
|
|
93
114
|
};
|
|
94
115
|
|
|
95
116
|
return { ctx, bodyProps };
|
|
96
117
|
}
|
|
118
|
+
|
|
119
|
+
export type { SubagentBrowserState };
|
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type
|
|
3
|
-
import { useCallback } from 'react';
|
|
1
|
+
import type { ChatMessage, ProviderConfig, Session } from 'mu-core';
|
|
2
|
+
import { type PluginRegistry, runDecorateMessageHooks, runTransformUserInputHooks } from 'mu-core';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { SessionPathHolder } from '../../runtime/createRegistry';
|
|
5
|
+
import type { HostMessageBus } from '../../runtime/messageBus';
|
|
4
6
|
import type { AttachmentState } from './useAttachment';
|
|
5
7
|
import { useSessionPersistence } from './useSessionPersistence';
|
|
6
|
-
import { type StreamState, useStreamConsumer } from './useStreamConsumer';
|
|
7
8
|
|
|
8
|
-
export
|
|
9
|
+
export interface StreamState {
|
|
10
|
+
text: string;
|
|
11
|
+
reasoning: string;
|
|
12
|
+
totalTokens: number;
|
|
13
|
+
cachedTokens: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EMPTY_STREAM: StreamState = { text: '', reasoning: '', totalTokens: 0, cachedTokens: 0 };
|
|
9
17
|
|
|
10
18
|
export interface ChatSessionState {
|
|
11
19
|
messages: ChatMessage[];
|
|
@@ -16,100 +24,359 @@ export interface ChatSessionState {
|
|
|
16
24
|
onSend: (text: string) => Promise<void>;
|
|
17
25
|
onNew: () => void;
|
|
18
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>;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
interface SessionDeps {
|
|
36
|
+
/**
|
|
37
|
+
* mu-core Session instance owned by the host. Authoritative for the
|
|
38
|
+
* transcript — this hook only mirrors it into React state and writes
|
|
39
|
+
* persistence on each message_changed event.
|
|
40
|
+
*/
|
|
41
|
+
session: Session;
|
|
42
|
+
/** Provider config used as `runTurn` override (model lookup happens here). */
|
|
22
43
|
config: ProviderConfig;
|
|
44
|
+
/** Currently selected model id (may shift across sends). */
|
|
23
45
|
currentModel: string;
|
|
24
46
|
attachment: AttachmentState;
|
|
25
47
|
controllerRef: React.RefObject<AbortController | null>;
|
|
26
48
|
initialMessages?: ChatMessage[];
|
|
27
49
|
registry: PluginRegistry;
|
|
50
|
+
messageBus?: HostMessageBus;
|
|
51
|
+
sessionPathHolder?: SessionPathHolder;
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* Provides the `onSend` glue that wires user input through the agent.
|
|
55
|
+
* Wire the host MessageBus to the Session: bus.append flows through
|
|
56
|
+
* `session.appendSynthetic` so every subscriber (TUI, broadcaster) sees the
|
|
57
|
+
* same change. `bus.get()` mirrors the live transcript.
|
|
36
58
|
*/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
59
|
+
function useMessageBusWiring(messageBus: HostMessageBus | undefined, messages: ChatMessage[], session: Session): void {
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
messageBus?.setMessages(messages);
|
|
62
|
+
}, [messageBus, messages]);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!messageBus) return;
|
|
66
|
+
messageBus.setAppender((message) => {
|
|
67
|
+
session.appendSynthetic(message);
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
messageBus.setAppender(null);
|
|
71
|
+
};
|
|
72
|
+
}, [messageBus, session]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface SubscriptionDeps {
|
|
76
|
+
session: Session;
|
|
77
|
+
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
|
78
|
+
setStream: React.Dispatch<React.SetStateAction<StreamState>>;
|
|
79
|
+
setStreaming: React.Dispatch<React.SetStateAction<boolean>>;
|
|
80
|
+
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
|
81
|
+
saveCurrent: (messages: ChatMessage[]) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Subscribe React state to mu-core Session events. Session is authoritative;
|
|
86
|
+
* this hook only mirrors. Persistence is driven from the same stream so disk
|
|
87
|
+
* writes are guaranteed to match what the user sees, but writes are
|
|
88
|
+
* coalesced to once per `stream_ended` to keep tool-heavy turns light on I/O.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* Build the event handler. Extracted so the cognitive complexity of the
|
|
92
|
+
* dispatch lives outside the React effect closure (the effect itself is
|
|
93
|
+
* just `session.subscribe(handler)`).
|
|
94
|
+
*/
|
|
95
|
+
function makeSessionEventHandler(
|
|
96
|
+
deps: SubscriptionDeps,
|
|
97
|
+
lastMessagesRef: React.MutableRefObject<ChatMessage[]>,
|
|
98
|
+
): (event: import('mu-core').SessionEvent) => void {
|
|
99
|
+
const { setMessages, setStream, setStreaming, setError, saveCurrent } = deps;
|
|
100
|
+
return (event) => {
|
|
101
|
+
if (event.type === 'messages_changed') {
|
|
102
|
+
lastMessagesRef.current = event.messages;
|
|
103
|
+
setMessages(event.messages);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (event.type === 'stream_partial') {
|
|
107
|
+
setStream((s) => ({ ...s, text: event.text, reasoning: event.reasoning ?? '' }));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (event.type === 'stream_started') {
|
|
111
|
+
setStreaming(true);
|
|
112
|
+
setError(null);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (event.type === 'stream_ended') {
|
|
116
|
+
setStreaming(false);
|
|
117
|
+
setStream((s) => ({ ...s, text: '', reasoning: '' }));
|
|
118
|
+
if (lastMessagesRef.current.length > 0) saveCurrent(lastMessagesRef.current);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (event.type === 'usage') {
|
|
122
|
+
setStream((s) => ({
|
|
123
|
+
...s,
|
|
124
|
+
totalTokens: s.totalTokens + event.totalTokens,
|
|
125
|
+
cachedTokens: s.cachedTokens + event.cachedTokens,
|
|
126
|
+
}));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (event.type === 'error') {
|
|
130
|
+
setError(event.message);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
42
134
|
|
|
43
|
-
|
|
135
|
+
function useSessionSubscription(deps: SubscriptionDeps): void {
|
|
136
|
+
const { session, setMessages, setStream, setStreaming, setError, saveCurrent } = deps;
|
|
137
|
+
// The "last completed transcript" buffer survives effect re-subscriptions
|
|
138
|
+
// (e.g. if `saveCurrent` identity ever changes mid-stream). Without a ref
|
|
139
|
+
// we'd lose the in-flight save target on the next deps change.
|
|
140
|
+
const lastMessagesRef = useRef<ChatMessage[]>([]);
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const handler = makeSessionEventHandler(
|
|
143
|
+
{ session, setMessages, setStream, setStreaming, setError, saveCurrent },
|
|
144
|
+
lastMessagesRef,
|
|
145
|
+
);
|
|
146
|
+
return session.subscribe(handler);
|
|
147
|
+
}, [session, setMessages, setStream, setStreaming, setError, saveCurrent]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface OnSendDeps {
|
|
151
|
+
session: Session;
|
|
152
|
+
config: ProviderConfig;
|
|
153
|
+
currentModel: string;
|
|
154
|
+
attachment: AttachmentState;
|
|
155
|
+
controllerRef: React.RefObject<AbortController | null>;
|
|
156
|
+
registry: PluginRegistry;
|
|
157
|
+
messageBus?: HostMessageBus;
|
|
158
|
+
appendHistory: (text: string) => void;
|
|
159
|
+
streaming: boolean;
|
|
160
|
+
}
|
|
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
|
+
|
|
232
|
+
function useOnSend(deps: OnSendDeps): (text: string) => Promise<void> {
|
|
233
|
+
const { session, config, currentModel, attachment, controllerRef, registry, messageBus, appendHistory, streaming } =
|
|
234
|
+
deps;
|
|
235
|
+
return useCallback(
|
|
44
236
|
async (text: string) => {
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
237
|
+
if (streaming) return;
|
|
238
|
+
|
|
239
|
+
const transform = await runTransformUserInputHooks(registry.getHooks(), text);
|
|
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';
|
|
248
|
+
const finalText = transform.kind === 'transform' ? transform.text : text;
|
|
249
|
+
|
|
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
|
+
});
|
|
257
|
+
|
|
258
|
+
const injections = messageBus?.drainNext() ?? [];
|
|
259
|
+
for (const inj of injections) session.queueForNextTurn(inj);
|
|
260
|
+
|
|
54
261
|
appendHistory(text);
|
|
55
262
|
attachment.clear();
|
|
56
263
|
|
|
57
264
|
const controller = new AbortController();
|
|
58
265
|
controllerRef.current = controller;
|
|
266
|
+
controller.signal.addEventListener('abort', () => session.abort(), { once: true });
|
|
59
267
|
|
|
60
268
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
269
|
+
await session.runTurn({
|
|
270
|
+
userMessage: userMsg,
|
|
63
271
|
config,
|
|
64
|
-
currentModel,
|
|
65
|
-
controller.signal,
|
|
272
|
+
model: currentModel,
|
|
66
273
|
registry,
|
|
67
|
-
|
|
68
|
-
);
|
|
69
|
-
if (final) {
|
|
70
|
-
saveCurrent(final);
|
|
71
|
-
}
|
|
274
|
+
});
|
|
72
275
|
} finally {
|
|
73
276
|
controllerRef.current = null;
|
|
74
277
|
}
|
|
75
278
|
},
|
|
76
|
-
[
|
|
77
|
-
consumer.streaming,
|
|
78
|
-
consumer.runStream,
|
|
79
|
-
messages,
|
|
80
|
-
config,
|
|
81
|
-
currentModel,
|
|
82
|
-
attachment,
|
|
83
|
-
controllerRef,
|
|
84
|
-
registry,
|
|
85
|
-
setMessages,
|
|
86
|
-
appendHistory,
|
|
87
|
-
saveCurrent,
|
|
88
|
-
],
|
|
279
|
+
[streaming, session, config, currentModel, attachment, controllerRef, registry, messageBus, appendHistory],
|
|
89
280
|
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Top-level chat-session hook. Composes:
|
|
285
|
+
* - mu-core `Session` — single source of truth for the transcript
|
|
286
|
+
* - `useSessionPersistence` — disk write + history + session paths
|
|
287
|
+
*
|
|
288
|
+
* The hook is purely reactive: it subscribes to session events and exposes
|
|
289
|
+
* the resulting state, plus thin wrappers around `session.runTurn` /
|
|
290
|
+
* `session.setMessages` for user actions.
|
|
291
|
+
*/
|
|
292
|
+
export function useChatSession(deps: SessionDeps): ChatSessionState {
|
|
293
|
+
const { session, config, currentModel, attachment, controllerRef, initialMessages, registry, messageBus } = deps;
|
|
294
|
+
const persistence = useSessionPersistence(initialMessages, deps.sessionPathHolder);
|
|
295
|
+
const { appendHistory, saveCurrent, resetForNew, loadFromPath } = persistence;
|
|
296
|
+
|
|
297
|
+
// Initial seed: feed any persisted messages into the session once.
|
|
298
|
+
// The session subscription below will then mirror them into React state.
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
if (initialMessages?.length) session.setMessages(initialMessages);
|
|
301
|
+
// Run once per session instance.
|
|
302
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
303
|
+
}, [session, initialMessages?.length, initialMessages]);
|
|
304
|
+
|
|
305
|
+
const [messages, setMessages] = useState<ChatMessage[]>(() => initialMessages ?? []);
|
|
306
|
+
const [streaming, setStreaming] = useState(false);
|
|
307
|
+
const [error, setError] = useState<string | null>(null);
|
|
308
|
+
const [stream, setStream] = useState<StreamState>(EMPTY_STREAM);
|
|
309
|
+
|
|
310
|
+
useMessageBusWiring(messageBus, messages, session);
|
|
311
|
+
useSessionSubscription({ session, setMessages, setStream, setStreaming, setError, saveCurrent });
|
|
312
|
+
|
|
313
|
+
const onSend = useOnSend({
|
|
314
|
+
session,
|
|
315
|
+
config,
|
|
316
|
+
currentModel,
|
|
317
|
+
attachment,
|
|
318
|
+
controllerRef,
|
|
319
|
+
registry,
|
|
320
|
+
messageBus,
|
|
321
|
+
appendHistory,
|
|
322
|
+
streaming,
|
|
323
|
+
});
|
|
90
324
|
|
|
91
325
|
const onNew = useCallback(() => {
|
|
92
|
-
|
|
93
|
-
|
|
326
|
+
// Abort any in-flight turn *before* rotating the session path.
|
|
327
|
+
// Without this, the streaming `runTurn` keeps emitting `messages_changed`
|
|
328
|
+
// events that are saved to the newly-rotated path via `stream_ended`,
|
|
329
|
+
// mixing the old transcript into the brand-new file.
|
|
330
|
+
if (controllerRef.current) {
|
|
331
|
+
controllerRef.current.abort();
|
|
332
|
+
controllerRef.current = null;
|
|
333
|
+
}
|
|
334
|
+
resetForNew();
|
|
335
|
+
// `session.setMessages([])` emits `messages_changed` which the
|
|
336
|
+
// subscription mirrors into React state, so we don't double-write here.
|
|
337
|
+
session.setMessages([]);
|
|
338
|
+
setStream(EMPTY_STREAM);
|
|
339
|
+
setError(null);
|
|
94
340
|
attachment.clear();
|
|
95
|
-
}, [
|
|
341
|
+
}, [resetForNew, session, attachment, controllerRef]);
|
|
342
|
+
|
|
343
|
+
const onCompact = useOnCompact({
|
|
344
|
+
streaming,
|
|
345
|
+
session,
|
|
346
|
+
config,
|
|
347
|
+
currentModel,
|
|
348
|
+
registry,
|
|
349
|
+
controllerRef,
|
|
350
|
+
saveCurrent,
|
|
351
|
+
});
|
|
96
352
|
|
|
97
353
|
const onLoadSession = useCallback(
|
|
98
354
|
(path: string) => {
|
|
99
|
-
|
|
100
|
-
|
|
355
|
+
const loaded = loadFromPath(path);
|
|
356
|
+
if (loaded.length === 0) return;
|
|
357
|
+
// Abort any in-flight turn before replacing the transcript, for the
|
|
358
|
+
// same reason as onNew above.
|
|
359
|
+
if (controllerRef.current) {
|
|
360
|
+
controllerRef.current.abort();
|
|
361
|
+
controllerRef.current = null;
|
|
362
|
+
}
|
|
363
|
+
// setMessages emits messages_changed → React state mirrors it.
|
|
364
|
+
session.setMessages(loaded);
|
|
365
|
+
setStream(EMPTY_STREAM);
|
|
366
|
+
setError(null);
|
|
101
367
|
},
|
|
102
|
-
[
|
|
368
|
+
[loadFromPath, session, controllerRef],
|
|
103
369
|
);
|
|
104
370
|
|
|
105
371
|
return {
|
|
106
|
-
messages
|
|
107
|
-
streaming
|
|
108
|
-
error
|
|
109
|
-
stream
|
|
372
|
+
messages,
|
|
373
|
+
streaming,
|
|
374
|
+
error,
|
|
375
|
+
stream,
|
|
110
376
|
inputHistory: persistence.inputHistory,
|
|
111
377
|
onSend,
|
|
112
378
|
onNew,
|
|
113
379
|
onLoadSession,
|
|
380
|
+
onCompact,
|
|
114
381
|
};
|
|
115
382
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ApiModel } from 'mu-core';
|
|
2
|
+
import { fetchModelContextLimit, listModels } from 'mu-openai-provider';
|
|
2
3
|
import { useCallback, useEffect, useState } from 'react';
|
|
3
4
|
import { saveConfig } from '../../config/index';
|
|
4
5
|
|
|
@@ -41,6 +42,30 @@ export function useModelList(baseUrl: string, preferredModel?: string): ModelLis
|
|
|
41
42
|
};
|
|
42
43
|
}, [baseUrl, preferredModel]);
|
|
43
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
|
+
|
|
44
69
|
const cycleModel = useCallback(() => {
|
|
45
70
|
if (models.length === 0) {
|
|
46
71
|
return;
|
|
@@ -1,16 +1,23 @@
|
|
|
1
|
-
import type { ChatMessage } from 'mu-
|
|
2
|
-
import { useCallback, useRef, useState } from 'react';
|
|
1
|
+
import type { ChatMessage } from 'mu-core';
|
|
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 {
|
|
6
|
-
messages: ChatMessage[];
|
|
7
|
-
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
|
8
7
|
inputHistory: string[];
|
|
9
8
|
appendHistory: (text: string) => void;
|
|
10
9
|
sessionPathRef: React.RefObject<string>;
|
|
10
|
+
/** Persist the given transcript to the current session file. */
|
|
11
11
|
saveCurrent: (messages: ChatMessage[]) => void;
|
|
12
|
-
|
|
13
|
-
|
|
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;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
function userPromptsFrom(messages: ChatMessage[]): string[] {
|
|
@@ -18,17 +25,28 @@ function userPromptsFrom(messages: ChatMessage[]): string[] {
|
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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.
|
|
23
32
|
*
|
|
24
33
|
* Save errors are logged to stderr and do not surface to the chat error
|
|
25
34
|
* channel — they're considered non-fatal (next save attempt may succeed).
|
|
26
35
|
*/
|
|
27
|
-
export function useSessionPersistence(
|
|
28
|
-
|
|
36
|
+
export function useSessionPersistence(
|
|
37
|
+
initialMessages?: ChatMessage[],
|
|
38
|
+
sessionPathHolder?: SessionPathHolder,
|
|
39
|
+
): SessionPersistenceState {
|
|
29
40
|
const [inputHistory, setInputHistory] = useState<string[]>(userPromptsFrom(initialMessages ?? []));
|
|
30
41
|
const sessionPathRef = useRef(generateSessionPath());
|
|
31
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
|
+
|
|
32
50
|
const appendHistory = useCallback((text: string) => {
|
|
33
51
|
setInputHistory((prev) => [...prev, text]);
|
|
34
52
|
}, []);
|
|
@@ -39,19 +57,28 @@ export function useSessionPersistence(initialMessages?: ChatMessage[]): SessionP
|
|
|
39
57
|
});
|
|
40
58
|
}, []);
|
|
41
59
|
|
|
42
|
-
const
|
|
43
|
-
setMessages([]);
|
|
60
|
+
const resetForNew = useCallback(() => {
|
|
44
61
|
sessionPathRef.current = generateSessionPath();
|
|
45
|
-
|
|
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
|
+
);
|
|
46
78
|
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
if (msgs.length > 0) {
|
|
50
|
-
setMessages(msgs);
|
|
51
|
-
setInputHistory(userPromptsFrom(msgs));
|
|
52
|
-
sessionPathRef.current = path;
|
|
53
|
-
}
|
|
79
|
+
const setHistory = useCallback((history: string[]) => {
|
|
80
|
+
setInputHistory(history);
|
|
54
81
|
}, []);
|
|
55
82
|
|
|
56
|
-
return {
|
|
83
|
+
return { inputHistory, appendHistory, sessionPathRef, saveCurrent, resetForNew, loadFromPath, setHistory };
|
|
57
84
|
}
|