mu-coding 0.5.0 → 0.8.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/config/index.test.ts +26 -0
- package/src/config/index.ts +25 -7
- package/src/plugin.ts +96 -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 +146 -0
- package/src/runtime/createRegistry.ts +128 -23
- package/src/runtime/messageBus.test.ts +62 -0
- package/src/runtime/messageBus.ts +78 -0
- package/src/runtime/pluginLoader.ts +22 -9
- package/src/sessions/index.ts +2 -9
- package/src/tui/channel/tuiChannel.test.ts +107 -0
- package/src/tui/channel/tuiChannel.ts +49 -0
- package/src/tui/chat/MessageRendererContext.ts +44 -0
- package/src/tui/chat/ToolDisplayContext.ts +1 -1
- package/src/tui/chat/useAttachment.ts +1 -1
- package/src/tui/chat/useChat.ts +31 -3
- package/src/tui/chat/useChatPanel.ts +7 -5
- package/src/tui/chat/useChatSession.ts +222 -53
- package/src/tui/chat/useModels.ts +2 -1
- package/src/tui/chat/usePluginStatus.ts +1 -1
- package/src/tui/chat/useSessionPersistence.ts +25 -14
- package/src/tui/chat/useStatusSegments.ts +17 -4
- package/src/tui/components/chat/ChatPanel.tsx +10 -4
- package/src/tui/components/chat/ChatPanelBody.tsx +1 -1
- package/src/tui/components/messageView.tsx +4 -2
- package/src/tui/components/messages/EditOutput.tsx +6 -4
- package/src/tui/components/messages/ToolHeader.tsx +3 -1
- package/src/tui/components/messages/assistantMessage.tsx +17 -2
- package/src/tui/components/messages/messageItem.tsx +19 -1
- package/src/tui/components/messages/reasoningBlock.tsx +4 -2
- package/src/tui/components/messages/streamingOutput.tsx +5 -1
- package/src/tui/components/messages/toolCallBlock.tsx +6 -5
- package/src/tui/components/messages/userMessage.tsx +21 -6
- 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 +5 -3
- package/src/tui/components/statusBar.tsx +8 -1
- package/src/tui/components/ui/dialogLayer.tsx +11 -6
- package/src/tui/context/ThemeContext.tsx +18 -0
- package/src/tui/input/InputBoxView.tsx +135 -26
- package/src/tui/input/commands.test.ts +3 -1
- package/src/tui/input/commands.ts +6 -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 +134 -15
- package/src/tui/input/useInputHandler.ts +316 -126
- package/src/tui/input/useMentionPicker.ts +121 -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 +26 -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 +79 -0
- package/src/tui/theme/types.ts +116 -0
- package/src/utils/clipboard.ts +1 -1
- package/src/tui/chat/useStreamConsumer.ts +0 -118
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type
|
|
3
|
-
import { useCallback } from 'react';
|
|
1
|
+
import type { ChatMessage, ProviderConfig, Session } from 'mu-core';
|
|
2
|
+
import { type PluginRegistry, runTransformUserInputHooks } from 'mu-core';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type { HostMessageBus } from '../../runtime/messageBus';
|
|
4
5
|
import type { AttachmentState } from './useAttachment';
|
|
5
6
|
import { useSessionPersistence } from './useSessionPersistence';
|
|
6
|
-
import { type StreamState, useStreamConsumer } from './useStreamConsumer';
|
|
7
7
|
|
|
8
|
-
export
|
|
8
|
+
export interface StreamState {
|
|
9
|
+
text: string;
|
|
10
|
+
reasoning: string;
|
|
11
|
+
totalTokens: number;
|
|
12
|
+
cachedTokens: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const EMPTY_STREAM: StreamState = { text: '', reasoning: '', totalTokens: 0, cachedTokens: 0 };
|
|
9
16
|
|
|
10
17
|
export interface ChatSessionState {
|
|
11
18
|
messages: ChatMessage[];
|
|
@@ -19,94 +26,256 @@ export interface ChatSessionState {
|
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
interface SessionDeps {
|
|
29
|
+
/**
|
|
30
|
+
* mu-core Session instance owned by the host. Authoritative for the
|
|
31
|
+
* transcript — this hook only mirrors it into React state and writes
|
|
32
|
+
* persistence on each message_changed event.
|
|
33
|
+
*/
|
|
34
|
+
session: Session;
|
|
35
|
+
/** Provider config used as `runTurn` override (model lookup happens here). */
|
|
22
36
|
config: ProviderConfig;
|
|
37
|
+
/** Currently selected model id (may shift across sends). */
|
|
23
38
|
currentModel: string;
|
|
24
39
|
attachment: AttachmentState;
|
|
25
40
|
controllerRef: React.RefObject<AbortController | null>;
|
|
26
41
|
initialMessages?: ChatMessage[];
|
|
27
42
|
registry: PluginRegistry;
|
|
43
|
+
messageBus?: HostMessageBus;
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* Provides the `onSend` glue that wires user input through the agent.
|
|
47
|
+
* Wire the host MessageBus to the Session: bus.append flows through
|
|
48
|
+
* `session.appendSynthetic` so every subscriber (TUI, broadcaster) sees the
|
|
49
|
+
* same change. `bus.get()` mirrors the live transcript.
|
|
36
50
|
*/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
function useMessageBusWiring(messageBus: HostMessageBus | undefined, messages: ChatMessage[], session: Session): void {
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
messageBus?.setMessages(messages);
|
|
54
|
+
}, [messageBus, messages]);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!messageBus) return;
|
|
58
|
+
messageBus.setAppender((message) => {
|
|
59
|
+
session.appendSynthetic(message);
|
|
60
|
+
});
|
|
61
|
+
return () => {
|
|
62
|
+
messageBus.setAppender(null);
|
|
63
|
+
};
|
|
64
|
+
}, [messageBus, session]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface SubscriptionDeps {
|
|
68
|
+
session: Session;
|
|
69
|
+
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
|
70
|
+
setStream: React.Dispatch<React.SetStateAction<StreamState>>;
|
|
71
|
+
setStreaming: React.Dispatch<React.SetStateAction<boolean>>;
|
|
72
|
+
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
|
73
|
+
saveCurrent: (messages: ChatMessage[]) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Subscribe React state to mu-core Session events. Session is authoritative;
|
|
78
|
+
* this hook only mirrors. Persistence is driven from the same stream so disk
|
|
79
|
+
* writes are guaranteed to match what the user sees, but writes are
|
|
80
|
+
* coalesced to once per `stream_ended` to keep tool-heavy turns light on I/O.
|
|
81
|
+
*/
|
|
82
|
+
/**
|
|
83
|
+
* Build the event handler. Extracted so the cognitive complexity of the
|
|
84
|
+
* dispatch lives outside the React effect closure (the effect itself is
|
|
85
|
+
* just `session.subscribe(handler)`).
|
|
86
|
+
*/
|
|
87
|
+
function makeSessionEventHandler(
|
|
88
|
+
deps: SubscriptionDeps,
|
|
89
|
+
lastMessagesRef: React.MutableRefObject<ChatMessage[]>,
|
|
90
|
+
): (event: import('mu-core').SessionEvent) => void {
|
|
91
|
+
const { setMessages, setStream, setStreaming, setError, saveCurrent } = deps;
|
|
92
|
+
return (event) => {
|
|
93
|
+
if (event.type === 'messages_changed') {
|
|
94
|
+
lastMessagesRef.current = event.messages;
|
|
95
|
+
setMessages(event.messages);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (event.type === 'stream_partial') {
|
|
99
|
+
setStream((s) => ({ ...s, text: event.text, reasoning: event.reasoning ?? '' }));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (event.type === 'stream_started') {
|
|
103
|
+
setStreaming(true);
|
|
104
|
+
setError(null);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (event.type === 'stream_ended') {
|
|
108
|
+
setStreaming(false);
|
|
109
|
+
setStream((s) => ({ ...s, text: '', reasoning: '' }));
|
|
110
|
+
if (lastMessagesRef.current.length > 0) saveCurrent(lastMessagesRef.current);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (event.type === 'usage') {
|
|
114
|
+
setStream((s) => ({
|
|
115
|
+
...s,
|
|
116
|
+
totalTokens: s.totalTokens + event.totalTokens,
|
|
117
|
+
cachedTokens: s.cachedTokens + event.cachedTokens,
|
|
118
|
+
}));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (event.type === 'error') {
|
|
122
|
+
setError(event.message);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function useSessionSubscription(deps: SubscriptionDeps): void {
|
|
128
|
+
const { session, setMessages, setStream, setStreaming, setError, saveCurrent } = deps;
|
|
129
|
+
// The "last completed transcript" buffer survives effect re-subscriptions
|
|
130
|
+
// (e.g. if `saveCurrent` identity ever changes mid-stream). Without a ref
|
|
131
|
+
// we'd lose the in-flight save target on the next deps change.
|
|
132
|
+
const lastMessagesRef = useRef<ChatMessage[]>([]);
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
const handler = makeSessionEventHandler(
|
|
135
|
+
{ session, setMessages, setStream, setStreaming, setError, saveCurrent },
|
|
136
|
+
lastMessagesRef,
|
|
137
|
+
);
|
|
138
|
+
return session.subscribe(handler);
|
|
139
|
+
}, [session, setMessages, setStream, setStreaming, setError, saveCurrent]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface OnSendDeps {
|
|
143
|
+
session: Session;
|
|
144
|
+
config: ProviderConfig;
|
|
145
|
+
currentModel: string;
|
|
146
|
+
attachment: AttachmentState;
|
|
147
|
+
controllerRef: React.RefObject<AbortController | null>;
|
|
148
|
+
registry: PluginRegistry;
|
|
149
|
+
messageBus?: HostMessageBus;
|
|
150
|
+
appendHistory: (text: string) => void;
|
|
151
|
+
streaming: boolean;
|
|
152
|
+
}
|
|
42
153
|
|
|
43
|
-
|
|
154
|
+
function useOnSend(deps: OnSendDeps): (text: string) => Promise<void> {
|
|
155
|
+
const { session, config, currentModel, attachment, controllerRef, registry, messageBus, appendHistory, streaming } =
|
|
156
|
+
deps;
|
|
157
|
+
return useCallback(
|
|
44
158
|
async (text: string) => {
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
159
|
+
if (streaming) return;
|
|
160
|
+
|
|
161
|
+
const transform = await runTransformUserInputHooks(registry.getHooks(), text);
|
|
162
|
+
if (transform.kind === 'intercept') return;
|
|
163
|
+
const finalText = transform.kind === 'transform' ? transform.text : text;
|
|
164
|
+
|
|
48
165
|
const userMsg: ChatMessage = {
|
|
49
166
|
role: 'user',
|
|
50
|
-
content:
|
|
167
|
+
content: finalText,
|
|
51
168
|
...(attachment.attachment ? { images: [attachment.attachment] } : {}),
|
|
52
169
|
};
|
|
53
|
-
|
|
170
|
+
|
|
171
|
+
const injections = messageBus?.drainNext() ?? [];
|
|
172
|
+
for (const inj of injections) session.queueForNextTurn(inj);
|
|
173
|
+
|
|
54
174
|
appendHistory(text);
|
|
55
175
|
attachment.clear();
|
|
56
176
|
|
|
57
177
|
const controller = new AbortController();
|
|
58
178
|
controllerRef.current = controller;
|
|
179
|
+
controller.signal.addEventListener('abort', () => session.abort(), { once: true });
|
|
59
180
|
|
|
60
181
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
182
|
+
await session.runTurn({
|
|
183
|
+
userMessage: userMsg,
|
|
63
184
|
config,
|
|
64
|
-
currentModel,
|
|
65
|
-
controller.signal,
|
|
185
|
+
model: currentModel,
|
|
66
186
|
registry,
|
|
67
|
-
|
|
68
|
-
);
|
|
69
|
-
if (final) {
|
|
70
|
-
saveCurrent(final);
|
|
71
|
-
}
|
|
187
|
+
});
|
|
72
188
|
} finally {
|
|
73
189
|
controllerRef.current = null;
|
|
74
190
|
}
|
|
75
191
|
},
|
|
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
|
-
],
|
|
192
|
+
[streaming, session, config, currentModel, attachment, controllerRef, registry, messageBus, appendHistory],
|
|
89
193
|
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Top-level chat-session hook. Composes:
|
|
198
|
+
* - mu-core `Session` — single source of truth for the transcript
|
|
199
|
+
* - `useSessionPersistence` — disk write + history + session paths
|
|
200
|
+
*
|
|
201
|
+
* The hook is purely reactive: it subscribes to session events and exposes
|
|
202
|
+
* the resulting state, plus thin wrappers around `session.runTurn` /
|
|
203
|
+
* `session.setMessages` for user actions.
|
|
204
|
+
*/
|
|
205
|
+
export function useChatSession(deps: SessionDeps): ChatSessionState {
|
|
206
|
+
const { session, config, currentModel, attachment, controllerRef, initialMessages, registry, messageBus } = deps;
|
|
207
|
+
const persistence = useSessionPersistence(initialMessages);
|
|
208
|
+
const { appendHistory, saveCurrent, resetForNew, loadFromPath } = persistence;
|
|
209
|
+
|
|
210
|
+
// Initial seed: feed any persisted messages into the session once.
|
|
211
|
+
// The session subscription below will then mirror them into React state.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (initialMessages?.length) session.setMessages(initialMessages);
|
|
214
|
+
// Run once per session instance.
|
|
215
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
216
|
+
}, [session, initialMessages?.length, initialMessages]);
|
|
217
|
+
|
|
218
|
+
const [messages, setMessages] = useState<ChatMessage[]>(() => initialMessages ?? []);
|
|
219
|
+
const [streaming, setStreaming] = useState(false);
|
|
220
|
+
const [error, setError] = useState<string | null>(null);
|
|
221
|
+
const [stream, setStream] = useState<StreamState>(EMPTY_STREAM);
|
|
222
|
+
|
|
223
|
+
useMessageBusWiring(messageBus, messages, session);
|
|
224
|
+
useSessionSubscription({ session, setMessages, setStream, setStreaming, setError, saveCurrent });
|
|
225
|
+
|
|
226
|
+
const onSend = useOnSend({
|
|
227
|
+
session,
|
|
228
|
+
config,
|
|
229
|
+
currentModel,
|
|
230
|
+
attachment,
|
|
231
|
+
controllerRef,
|
|
232
|
+
registry,
|
|
233
|
+
messageBus,
|
|
234
|
+
appendHistory,
|
|
235
|
+
streaming,
|
|
236
|
+
});
|
|
90
237
|
|
|
91
238
|
const onNew = useCallback(() => {
|
|
92
|
-
|
|
93
|
-
|
|
239
|
+
// Abort any in-flight turn *before* rotating the session path.
|
|
240
|
+
// Without this, the streaming `runTurn` keeps emitting `messages_changed`
|
|
241
|
+
// events that are saved to the newly-rotated path via `stream_ended`,
|
|
242
|
+
// mixing the old transcript into the brand-new file.
|
|
243
|
+
if (controllerRef.current) {
|
|
244
|
+
controllerRef.current.abort();
|
|
245
|
+
controllerRef.current = null;
|
|
246
|
+
}
|
|
247
|
+
resetForNew();
|
|
248
|
+
// `session.setMessages([])` emits `messages_changed` which the
|
|
249
|
+
// subscription mirrors into React state, so we don't double-write here.
|
|
250
|
+
session.setMessages([]);
|
|
251
|
+
setStream(EMPTY_STREAM);
|
|
252
|
+
setError(null);
|
|
94
253
|
attachment.clear();
|
|
95
|
-
}, [
|
|
254
|
+
}, [resetForNew, session, attachment, controllerRef]);
|
|
96
255
|
|
|
97
256
|
const onLoadSession = useCallback(
|
|
98
257
|
(path: string) => {
|
|
99
|
-
|
|
100
|
-
|
|
258
|
+
const loaded = loadFromPath(path);
|
|
259
|
+
if (loaded.length === 0) return;
|
|
260
|
+
// Abort any in-flight turn before replacing the transcript, for the
|
|
261
|
+
// same reason as onNew above.
|
|
262
|
+
if (controllerRef.current) {
|
|
263
|
+
controllerRef.current.abort();
|
|
264
|
+
controllerRef.current = null;
|
|
265
|
+
}
|
|
266
|
+
// setMessages emits messages_changed → React state mirrors it.
|
|
267
|
+
session.setMessages(loaded);
|
|
268
|
+
setStream(EMPTY_STREAM);
|
|
269
|
+
setError(null);
|
|
101
270
|
},
|
|
102
|
-
[
|
|
271
|
+
[loadFromPath, session, controllerRef],
|
|
103
272
|
);
|
|
104
273
|
|
|
105
274
|
return {
|
|
106
|
-
messages
|
|
107
|
-
streaming
|
|
108
|
-
error
|
|
109
|
-
stream
|
|
275
|
+
messages,
|
|
276
|
+
streaming,
|
|
277
|
+
error,
|
|
278
|
+
stream,
|
|
110
279
|
inputHistory: persistence.inputHistory,
|
|
111
280
|
onSend,
|
|
112
281
|
onNew,
|
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
import type { ChatMessage } from 'mu-
|
|
1
|
+
import type { ChatMessage } from 'mu-core';
|
|
2
2
|
import { useCallback, useRef, useState } from 'react';
|
|
3
3
|
import { generateSessionPath, loadSession, saveSession } from '../../sessions/index';
|
|
4
4
|
|
|
5
5
|
export interface SessionPersistenceState {
|
|
6
|
-
messages: ChatMessage[];
|
|
7
|
-
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
|
|
8
6
|
inputHistory: string[];
|
|
9
7
|
appendHistory: (text: string) => void;
|
|
10
8
|
sessionPathRef: React.RefObject<string>;
|
|
9
|
+
/** Persist the given transcript to the current session file. */
|
|
11
10
|
saveCurrent: (messages: ChatMessage[]) => void;
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
/** Reset to a brand-new session: rotates the file path. */
|
|
12
|
+
resetForNew: () => void;
|
|
13
|
+
/**
|
|
14
|
+
* Load a transcript from disk and adopt its path. Returns the loaded
|
|
15
|
+
* messages so the caller can hand them to the session.
|
|
16
|
+
*/
|
|
17
|
+
loadFromPath: (path: string) => ChatMessage[];
|
|
18
|
+
/** Replace history (used after resume / load). */
|
|
19
|
+
setHistory: (history: string[]) => void;
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
function userPromptsFrom(messages: ChatMessage[]): string[] {
|
|
@@ -18,14 +24,15 @@ function userPromptsFrom(messages: ChatMessage[]): string[] {
|
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
27
|
+
* Side-channel persistence: history bookkeeping, on-disk save, and session
|
|
28
|
+
* file path management. Does NOT own the transcript — `Session` is the
|
|
29
|
+
* single source of truth in the new architecture, this hook only writes to
|
|
30
|
+
* disk and tracks the current target path.
|
|
23
31
|
*
|
|
24
32
|
* Save errors are logged to stderr and do not surface to the chat error
|
|
25
33
|
* channel — they're considered non-fatal (next save attempt may succeed).
|
|
26
34
|
*/
|
|
27
35
|
export function useSessionPersistence(initialMessages?: ChatMessage[]): SessionPersistenceState {
|
|
28
|
-
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages ?? []);
|
|
29
36
|
const [inputHistory, setInputHistory] = useState<string[]>(userPromptsFrom(initialMessages ?? []));
|
|
30
37
|
const sessionPathRef = useRef(generateSessionPath());
|
|
31
38
|
|
|
@@ -39,19 +46,23 @@ export function useSessionPersistence(initialMessages?: ChatMessage[]): SessionP
|
|
|
39
46
|
});
|
|
40
47
|
}, []);
|
|
41
48
|
|
|
42
|
-
const
|
|
43
|
-
setMessages([]);
|
|
49
|
+
const resetForNew = useCallback(() => {
|
|
44
50
|
sessionPathRef.current = generateSessionPath();
|
|
51
|
+
setInputHistory([]);
|
|
45
52
|
}, []);
|
|
46
53
|
|
|
47
|
-
const
|
|
54
|
+
const loadFromPath = useCallback((path: string): ChatMessage[] => {
|
|
48
55
|
const msgs = loadSession(path);
|
|
49
56
|
if (msgs.length > 0) {
|
|
50
|
-
setMessages(msgs);
|
|
51
|
-
setInputHistory(userPromptsFrom(msgs));
|
|
52
57
|
sessionPathRef.current = path;
|
|
58
|
+
setInputHistory(userPromptsFrom(msgs));
|
|
53
59
|
}
|
|
60
|
+
return msgs;
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const setHistory = useCallback((history: string[]) => {
|
|
64
|
+
setInputHistory(history);
|
|
54
65
|
}, []);
|
|
55
66
|
|
|
56
|
-
return {
|
|
67
|
+
return { inputHistory, appendHistory, sessionPathRef, saveCurrent, resetForNew, loadFromPath, setHistory };
|
|
57
68
|
}
|
|
@@ -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,10 @@ 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;
|
|
14
17
|
pluginStatus?: StatusSegment[];
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -18,6 +21,11 @@ function truncate(text: string, max: number): string {
|
|
|
18
21
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
const tokenFormatter = new Intl.NumberFormat('en-US');
|
|
25
|
+
function formatTokens(n: number): string {
|
|
26
|
+
return tokenFormatter.format(n);
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
export function useStatusSegments(options: StatusSegmentOptions): StatusBarSegment[] {
|
|
22
30
|
const spinner = useSpinner(options.streaming);
|
|
23
31
|
const segments: StatusBarSegment[] = [];
|
|
@@ -25,8 +33,13 @@ export function useStatusSegments(options: StatusSegmentOptions): StatusBarSegme
|
|
|
25
33
|
if (options.streaming) {
|
|
26
34
|
segments.push({ text: `${spinner} generating`, color: 'yellow' });
|
|
27
35
|
}
|
|
28
|
-
if (options.
|
|
29
|
-
|
|
36
|
+
if (options.totalTokens > 0) {
|
|
37
|
+
const cached = options.cachedTokens ?? 0;
|
|
38
|
+
const label =
|
|
39
|
+
cached > 0
|
|
40
|
+
? `${formatTokens(options.totalTokens)} tokens (${formatTokens(cached)} cached)`
|
|
41
|
+
: `${formatTokens(options.totalTokens)} tokens`;
|
|
42
|
+
segments.push({ text: label, dim: true });
|
|
30
43
|
}
|
|
31
44
|
if (options.abortWarning) {
|
|
32
45
|
segments.push({ text: 'Esc again to stop', color: 'yellow' });
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { PluginRegistry } from 'mu-
|
|
2
|
-
import type { ChatMessage, ProviderConfig } from 'mu-provider';
|
|
1
|
+
import type { ChatMessage, PluginRegistry, ProviderConfig } from 'mu-core';
|
|
3
2
|
import type { ShutdownFn } from '../../../app/shutdown';
|
|
3
|
+
import type { HostMessageBus } from '../../../runtime/messageBus';
|
|
4
4
|
import { ChatContext } from '../../chat/ChatContext';
|
|
5
|
+
import { MessageRendererProvider, useRegistryRenderers } from '../../chat/MessageRendererContext';
|
|
5
6
|
import { ToolDisplayProvider, useToolDisplayMap } from '../../chat/ToolDisplayContext';
|
|
6
7
|
import { useChatPanel } from '../../chat/useChatPanel';
|
|
7
8
|
import type { InkUIService } from '../../plugins/InkUIService';
|
|
@@ -11,22 +12,27 @@ export function ChatPanel({
|
|
|
11
12
|
config,
|
|
12
13
|
initialMessages,
|
|
13
14
|
registry,
|
|
15
|
+
messageBus,
|
|
14
16
|
uiService,
|
|
15
17
|
shutdown,
|
|
16
18
|
}: {
|
|
17
19
|
config: ProviderConfig;
|
|
18
20
|
initialMessages?: ChatMessage[];
|
|
19
21
|
registry: PluginRegistry;
|
|
22
|
+
messageBus?: HostMessageBus;
|
|
20
23
|
uiService?: InkUIService;
|
|
21
24
|
shutdown?: ShutdownFn;
|
|
22
25
|
}) {
|
|
23
|
-
const { ctx, bodyProps } = useChatPanel({ config, initialMessages, registry, uiService, shutdown });
|
|
26
|
+
const { ctx, bodyProps } = useChatPanel({ config, initialMessages, registry, messageBus, uiService, shutdown });
|
|
24
27
|
const toolDisplays = useToolDisplayMap(registry);
|
|
28
|
+
const renderers = useRegistryRenderers(registry);
|
|
25
29
|
|
|
26
30
|
return (
|
|
27
31
|
<ChatContext.Provider value={ctx}>
|
|
28
32
|
<ToolDisplayProvider value={toolDisplays}>
|
|
29
|
-
<
|
|
33
|
+
<MessageRendererProvider value={renderers}>
|
|
34
|
+
<ChatPanelBody {...bodyProps} />
|
|
35
|
+
</MessageRendererProvider>
|
|
30
36
|
</ToolDisplayProvider>
|
|
31
37
|
</ChatContext.Provider>
|
|
32
38
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
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
4
|
import { InputBox } from '../../input/InputBox';
|
|
5
5
|
import type { InkUIService } from '../../plugins/InkUIService';
|
|
@@ -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,6 +42,7 @@ 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
|
|
|
@@ -60,7 +62,7 @@ export function EditOutput({ args, content, error, hint }: EditOutputProps) {
|
|
|
60
62
|
if (diff.lines.length === 0 && diff.totalOldLines > 0 && diff.totalNewLines > 0) {
|
|
61
63
|
return (
|
|
62
64
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
63
|
-
<Text color=
|
|
65
|
+
<Text color={theme.diff.warning} bold={true}>
|
|
64
66
|
! {verb}
|
|
65
67
|
</Text>
|
|
66
68
|
<Text dimColor={true}> {path}</Text>
|
|
@@ -88,8 +90,8 @@ export function EditOutput({ args, content, error, hint }: EditOutputProps) {
|
|
|
88
90
|
<Box flexDirection="column" flexShrink={0}>
|
|
89
91
|
{lines.map((line, i) => {
|
|
90
92
|
let color: string | undefined;
|
|
91
|
-
if (line.startsWith('-')) color =
|
|
92
|
-
else if (line.startsWith('+')) color =
|
|
93
|
+
if (line.startsWith('-')) color = theme.diff.removed;
|
|
94
|
+
else if (line.startsWith('+')) color = theme.diff.added;
|
|
93
95
|
return (
|
|
94
96
|
// biome-ignore lint/suspicious/noArrayIndexKey: diff lines may repeat (blank lines, braces); index disambiguates
|
|
95
97
|
<Text key={`${i}-${line}`} color={color} dimColor={color === undefined} wrap="wrap">
|
|
@@ -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,9 +16,10 @@ 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
21
|
<Box flexDirection="column" flexShrink={0}>
|
|
20
|
-
<Text color={error ?
|
|
22
|
+
<Text color={error ? theme.tool.error : theme.tool.success} bold={true}>
|
|
21
23
|
{error ? '✗' : '✓'} {name}
|
|
22
24
|
</Text>
|
|
23
25
|
{subtitle && <Text dimColor={true}> {subtitle}</Text>}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Box, Text } from 'ink';
|
|
2
|
-
import type { ChatMessage } from 'mu-
|
|
2
|
+
import type { ChatMessage } from 'mu-core';
|
|
3
3
|
import React from 'react';
|
|
4
4
|
import { ReasoningBlock } from './reasoningBlock';
|
|
5
5
|
import { ToolCallBlock } from './toolCallBlock';
|
|
@@ -8,8 +8,18 @@ export const AssistantMessage: React.FC<{
|
|
|
8
8
|
msg: ChatMessage;
|
|
9
9
|
toolMessages?: ChatMessage[];
|
|
10
10
|
}> = React.memo(function AssistantMessage({ msg, toolMessages }) {
|
|
11
|
+
const badge = msg.display?.badge;
|
|
12
|
+
const prefix = msg.display?.prefix;
|
|
13
|
+
const color = msg.display?.color;
|
|
11
14
|
return (
|
|
12
15
|
<Box flexDirection="column" flexShrink={0} marginBottom={1}>
|
|
16
|
+
{badge && (
|
|
17
|
+
<Box marginBottom={1}>
|
|
18
|
+
<Text color={color} bold={true}>
|
|
19
|
+
[{badge}]
|
|
20
|
+
</Text>
|
|
21
|
+
</Box>
|
|
22
|
+
)}
|
|
13
23
|
{msg.reasoning && <ReasoningBlock reasoning={msg.reasoning} />}
|
|
14
24
|
{msg.toolCalls?.length ? (
|
|
15
25
|
<Box flexDirection="column" marginBottom={1}>
|
|
@@ -18,7 +28,12 @@ export const AssistantMessage: React.FC<{
|
|
|
18
28
|
))}
|
|
19
29
|
</Box>
|
|
20
30
|
) : null}
|
|
21
|
-
{msg.content &&
|
|
31
|
+
{msg.content && (
|
|
32
|
+
<Text wrap="wrap" color={color}>
|
|
33
|
+
{prefix && <Text color={color}>{prefix}</Text>}
|
|
34
|
+
{msg.content}
|
|
35
|
+
</Text>
|
|
36
|
+
)}
|
|
22
37
|
</Box>
|
|
23
38
|
);
|
|
24
39
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { ChatMessage } from 'mu-
|
|
1
|
+
import type { ChatMessage } from 'mu-core';
|
|
2
2
|
import React from 'react';
|
|
3
|
+
import { useMessageRenderer } from '../../chat/MessageRendererContext';
|
|
3
4
|
import { AssistantMessage } from './assistantMessage';
|
|
4
5
|
import { UserMessage } from './userMessage';
|
|
5
6
|
|
|
@@ -7,6 +8,23 @@ export const MessageItem: React.FC<{
|
|
|
7
8
|
msg: ChatMessage;
|
|
8
9
|
toolMessages?: ChatMessage[];
|
|
9
10
|
}> = React.memo(function MessageItem({ msg, toolMessages }) {
|
|
11
|
+
const customRenderer = useMessageRenderer(msg.customType);
|
|
12
|
+
|
|
13
|
+
// Plugins may flag a message as `hidden` to keep it in the LLM transcript
|
|
14
|
+
// while suppressing on-screen rendering (e.g. system reminders carried with
|
|
15
|
+
// the user's next turn).
|
|
16
|
+
if (msg.display?.hidden) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Custom-typed messages always defer to a registered renderer when one is
|
|
21
|
+
// available; otherwise fall through to the role-default rendering so a
|
|
22
|
+
// plugin can ship messages whose renderer isn't loaded yet without losing
|
|
23
|
+
// their content.
|
|
24
|
+
if (customRenderer) {
|
|
25
|
+
return <>{customRenderer(msg)}</>;
|
|
26
|
+
}
|
|
27
|
+
|
|
10
28
|
// Tool result messages are rendered inline within ToolCallBlock via the
|
|
11
29
|
// pre-built index passed from MessageView; suppress them at the top level.
|
|
12
30
|
if (msg.role === 'tool') {
|