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.
Files changed (74) hide show
  1. package/README.md +49 -3
  2. package/package.json +9 -4
  3. package/prompts/SYSTEM.md +16 -0
  4. package/src/app/shutdown.ts +1 -1
  5. package/src/app/startApp.ts +11 -8
  6. package/src/cli/args.ts +14 -11
  7. package/src/config/index.test.ts +26 -0
  8. package/src/config/index.ts +25 -7
  9. package/src/plugin.ts +96 -0
  10. package/src/runtime/codingTools/bash.ts +114 -0
  11. package/src/runtime/codingTools/edit-file.ts +60 -0
  12. package/src/runtime/codingTools/index.ts +39 -0
  13. package/src/runtime/codingTools/read-file.ts +83 -0
  14. package/src/runtime/codingTools/utils.ts +21 -0
  15. package/src/runtime/codingTools/write-file.ts +42 -0
  16. package/src/runtime/createRegistry.test.ts +146 -0
  17. package/src/runtime/createRegistry.ts +128 -23
  18. package/src/runtime/messageBus.test.ts +62 -0
  19. package/src/runtime/messageBus.ts +78 -0
  20. package/src/runtime/pluginLoader.ts +22 -9
  21. package/src/sessions/index.ts +2 -9
  22. package/src/tui/channel/tuiChannel.test.ts +107 -0
  23. package/src/tui/channel/tuiChannel.ts +49 -0
  24. package/src/tui/chat/MessageRendererContext.ts +44 -0
  25. package/src/tui/chat/ToolDisplayContext.ts +1 -1
  26. package/src/tui/chat/useAttachment.ts +1 -1
  27. package/src/tui/chat/useChat.ts +31 -3
  28. package/src/tui/chat/useChatPanel.ts +7 -5
  29. package/src/tui/chat/useChatSession.ts +222 -53
  30. package/src/tui/chat/useModels.ts +2 -1
  31. package/src/tui/chat/usePluginStatus.ts +1 -1
  32. package/src/tui/chat/useSessionPersistence.ts +25 -14
  33. package/src/tui/chat/useStatusSegments.ts +17 -4
  34. package/src/tui/components/chat/ChatPanel.tsx +10 -4
  35. package/src/tui/components/chat/ChatPanelBody.tsx +1 -1
  36. package/src/tui/components/messageView.tsx +4 -2
  37. package/src/tui/components/messages/EditOutput.tsx +6 -4
  38. package/src/tui/components/messages/ToolHeader.tsx +3 -1
  39. package/src/tui/components/messages/assistantMessage.tsx +17 -2
  40. package/src/tui/components/messages/messageItem.tsx +19 -1
  41. package/src/tui/components/messages/reasoningBlock.tsx +4 -2
  42. package/src/tui/components/messages/streamingOutput.tsx +5 -1
  43. package/src/tui/components/messages/toolCallBlock.tsx +6 -5
  44. package/src/tui/components/messages/userMessage.tsx +21 -6
  45. package/src/tui/components/primitives/dropdown.tsx +8 -4
  46. package/src/tui/components/primitives/modal.tsx +4 -2
  47. package/src/tui/components/primitives/pickerModal.tsx +3 -1
  48. package/src/tui/components/primitives/toast.tsx +5 -3
  49. package/src/tui/components/statusBar.tsx +8 -1
  50. package/src/tui/components/ui/dialogLayer.tsx +11 -6
  51. package/src/tui/context/ThemeContext.tsx +18 -0
  52. package/src/tui/input/InputBoxView.tsx +135 -26
  53. package/src/tui/input/commands.test.ts +3 -1
  54. package/src/tui/input/commands.ts +6 -1
  55. package/src/tui/input/cursor.test.ts +136 -0
  56. package/src/tui/input/cursor.ts +214 -0
  57. package/src/tui/input/dumpContext.ts +107 -0
  58. package/src/tui/input/sanitize.ts +1 -1
  59. package/src/tui/input/useCommandExecutor.ts +1 -1
  60. package/src/tui/input/useInputBox.ts +134 -15
  61. package/src/tui/input/useInputHandler.ts +316 -126
  62. package/src/tui/input/useMentionPicker.ts +121 -0
  63. package/src/tui/input/usePluginShortcuts.ts +29 -0
  64. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  65. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  66. package/src/tui/plugins/InkUIService.ts +1 -1
  67. package/src/tui/renderApp.tsx +26 -13
  68. package/src/tui/theme/index.ts +1 -0
  69. package/src/tui/theme/merge.test.ts +49 -0
  70. package/src/tui/theme/merge.ts +43 -0
  71. package/src/tui/theme/presets.ts +79 -0
  72. package/src/tui/theme/types.ts +116 -0
  73. package/src/utils/clipboard.ts +1 -1
  74. package/src/tui/chat/useStreamConsumer.ts +0 -118
@@ -1,11 +1,18 @@
1
- import type { PluginRegistry } from 'mu-agents';
2
- import type { ChatMessage, ProviderConfig } from 'mu-provider';
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 type { StreamState } from './useStreamConsumer';
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
- * Top-level chat-session hook. Composes:
32
- * - `useSessionPersistence` transcript, history, save path
33
- * - `useStreamConsumer` in-flight tokens, tps, error
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
- export function useChatSession(deps: SessionDeps): ChatSessionState {
38
- const { config, currentModel, attachment, controllerRef, initialMessages, registry } = deps;
39
- const persistence = useSessionPersistence(initialMessages);
40
- const consumer = useStreamConsumer();
41
- const { messages, setMessages, appendHistory, saveCurrent } = persistence;
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
- const onSend = useCallback(
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 (consumer.streaming) {
46
- return;
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: text,
167
+ content: finalText,
51
168
  ...(attachment.attachment ? { images: [attachment.attachment] } : {}),
52
169
  };
53
- setMessages((prev) => [...prev, userMsg]);
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
- const final = await consumer.runStream(
62
- [...messages, userMsg],
182
+ await session.runTurn({
183
+ userMessage: userMsg,
63
184
  config,
64
- currentModel,
65
- controller.signal,
185
+ model: currentModel,
66
186
  registry,
67
- setMessages,
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
- persistence.onNew();
93
- consumer.resetError();
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
- }, [persistence.onNew, consumer.resetError, attachment]);
254
+ }, [resetForNew, session, attachment, controllerRef]);
96
255
 
97
256
  const onLoadSession = useCallback(
98
257
  (path: string) => {
99
- persistence.onLoadSession(path);
100
- consumer.resetError();
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
- [persistence.onLoadSession, consumer.resetError],
271
+ [loadFromPath, session, controllerRef],
103
272
  );
104
273
 
105
274
  return {
106
- messages: persistence.messages,
107
- streaming: consumer.streaming,
108
- error: consumer.error,
109
- stream: consumer.stream,
275
+ messages,
276
+ streaming,
277
+ error,
278
+ stream,
110
279
  inputHistory: persistence.inputHistory,
111
280
  onSend,
112
281
  onNew,
@@ -1,4 +1,5 @@
1
- import { type ApiModel, listModels } from 'mu-provider';
1
+ import type { ApiModel } from 'mu-core';
2
+ import { listModels } from 'mu-openai-provider';
2
3
  import { useCallback, useEffect, useState } from 'react';
3
4
  import { saveConfig } from '../../config/index';
4
5
 
@@ -1,4 +1,4 @@
1
- import type { PluginRegistry, StatusSegment } from 'mu-agents';
1
+ import type { PluginRegistry, StatusSegment } from 'mu-core';
2
2
  import { useEffect, useMemo, useState } from 'react';
3
3
  import type { InkUIService } from '../plugins/InkUIService';
4
4
 
@@ -1,16 +1,22 @@
1
- import type { ChatMessage } from 'mu-provider';
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
- onNew: () => void;
13
- onLoadSession: (path: string) => void;
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
- * Owns the conversation transcript and its on-disk persistence. Keeps the
22
- * current session path, the transcript, and the user-input history in sync.
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 onNew = useCallback(() => {
43
- setMessages([]);
49
+ const resetForNew = useCallback(() => {
44
50
  sessionPathRef.current = generateSessionPath();
51
+ setInputHistory([]);
45
52
  }, []);
46
53
 
47
- const onLoadSession = useCallback((path: string) => {
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 { messages, setMessages, inputHistory, appendHistory, sessionPathRef, saveCurrent, onNew, onLoadSession };
67
+ return { inputHistory, appendHistory, sessionPathRef, saveCurrent, resetForNew, loadFromPath, setHistory };
57
68
  }
@@ -1,4 +1,4 @@
1
- import type { StatusSegment } from 'mu-agents';
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
- tokensPerSecond: number;
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.tokensPerSecond > 0) {
29
- segments.push({ text: `${options.tokensPerSecond} tok/s`, dim: true });
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-agents';
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
- <ChatPanelBody {...bodyProps} />
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-provider';
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-provider';
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="red">Error: {error}</Text>}
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-agents';
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="yellow" bold={true}>
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 = 'red';
92
- else if (line.startsWith('+')) color = 'green';
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 ? 'red' : 'green'} bold={true}>
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-provider';
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 && <Text wrap="wrap">{msg.content}</Text>}
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-provider';
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') {