mu-coding 0.4.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 (104) hide show
  1. package/README.md +49 -5
  2. package/bin/mu.js +1 -1
  3. package/package.json +17 -4
  4. package/prompts/SYSTEM.md +16 -0
  5. package/src/app/shutdown.ts +94 -0
  6. package/src/app/startApp.ts +43 -0
  7. package/src/cli/args.ts +131 -0
  8. package/src/{install.ts → cli/install.ts} +19 -15
  9. package/src/config/index.test.ts +77 -0
  10. package/src/config/index.ts +199 -0
  11. package/src/main.ts +4 -0
  12. package/src/plugin.ts +96 -0
  13. package/src/runtime/codingTools/bash.ts +114 -0
  14. package/src/runtime/codingTools/edit-file.ts +60 -0
  15. package/src/runtime/codingTools/index.ts +39 -0
  16. package/src/runtime/codingTools/read-file.ts +83 -0
  17. package/src/runtime/codingTools/utils.ts +21 -0
  18. package/src/runtime/codingTools/write-file.ts +42 -0
  19. package/src/runtime/createRegistry.test.ts +146 -0
  20. package/src/runtime/createRegistry.ts +163 -0
  21. package/src/runtime/messageBus.test.ts +62 -0
  22. package/src/runtime/messageBus.ts +78 -0
  23. package/src/runtime/pluginLoader.ts +122 -0
  24. package/src/sessions/index.test.ts +66 -0
  25. package/src/sessions/index.ts +183 -0
  26. package/src/sessions/peek.test.ts +88 -0
  27. package/src/sessions/project.ts +51 -0
  28. package/src/tui/channel/tuiChannel.test.ts +107 -0
  29. package/src/tui/channel/tuiChannel.ts +49 -0
  30. package/src/tui/{context/chat.ts → chat/ChatContext.ts} +1 -1
  31. package/src/tui/chat/MessageRendererContext.ts +44 -0
  32. package/src/tui/chat/ToolDisplayContext.ts +33 -0
  33. package/src/tui/{useAbort.ts → chat/useAbort.ts} +16 -7
  34. package/src/tui/chat/useAttachment.ts +74 -0
  35. package/src/tui/chat/useChat.ts +106 -0
  36. package/src/tui/chat/useChatPanel.ts +98 -0
  37. package/src/tui/chat/useChatSession.ts +284 -0
  38. package/src/tui/{useModelList.ts → chat/useModels.ts} +12 -2
  39. package/src/tui/chat/usePluginStatus.ts +44 -0
  40. package/src/tui/chat/useSessionPersistence.ts +68 -0
  41. package/src/tui/chat/useStatusSegments.ts +62 -0
  42. package/src/tui/components/chat/ChatPanel.tsx +20 -40
  43. package/src/tui/components/chat/ChatPanelBody.tsx +30 -52
  44. package/src/tui/components/chat/Pickers.tsx +2 -2
  45. package/src/tui/components/messageView.tsx +72 -0
  46. package/src/tui/components/messages/EditOutput.tsx +47 -30
  47. package/src/tui/components/messages/ReadOutput.tsx +27 -22
  48. package/src/tui/components/messages/ToolHeader.tsx +28 -0
  49. package/src/tui/components/messages/WriteOutput.tsx +12 -24
  50. package/src/tui/components/messages/assistantMessage.tsx +17 -2
  51. package/src/tui/components/messages/messageItem.tsx +23 -16
  52. package/src/tui/components/messages/reasoningBlock.tsx +4 -2
  53. package/src/tui/components/messages/streamingOutput.tsx +5 -1
  54. package/src/tui/components/messages/toolCallBlock.tsx +61 -38
  55. package/src/tui/components/messages/userMessage.tsx +21 -6
  56. package/src/tui/components/{ui → primitives}/dropdown.tsx +40 -11
  57. package/src/tui/components/{ui → primitives}/modal.tsx +4 -2
  58. package/src/tui/components/primitives/pickerModal.tsx +47 -0
  59. package/src/tui/components/primitives/scrollbar.tsx +27 -0
  60. package/src/tui/components/{ui → primitives}/toast.tsx +5 -3
  61. package/src/tui/components/statusBar.tsx +32 -0
  62. package/src/tui/components/ui/dialogLayer.tsx +32 -13
  63. package/src/tui/context/ThemeContext.tsx +18 -0
  64. package/src/tui/hooks/useScroll.ts +11 -3
  65. package/src/tui/input/InputBox.tsx +6 -0
  66. package/src/tui/input/InputBoxView.tsx +237 -0
  67. package/src/tui/input/commands.test.ts +51 -0
  68. package/src/tui/input/commands.ts +44 -0
  69. package/src/tui/input/cursor.test.ts +136 -0
  70. package/src/tui/input/cursor.ts +214 -0
  71. package/src/tui/input/dumpContext.ts +107 -0
  72. package/src/tui/input/sanitize.ts +33 -0
  73. package/src/tui/input/useCommandExecutor.ts +32 -0
  74. package/src/tui/input/useInputBox.ts +207 -0
  75. package/src/tui/input/useInputHandler.ts +453 -0
  76. package/src/tui/input/useMentionPicker.ts +121 -0
  77. package/src/tui/input/usePluginShortcuts.ts +29 -0
  78. package/src/tui/plugins/InkApprovalChannel.test.ts +51 -0
  79. package/src/tui/plugins/InkApprovalChannel.ts +30 -0
  80. package/src/tui/{services/uiService.ts → plugins/InkUIService.ts} +68 -35
  81. package/src/tui/renderApp.tsx +43 -0
  82. package/src/tui/theme/index.ts +1 -0
  83. package/src/tui/theme/merge.test.ts +49 -0
  84. package/src/tui/theme/merge.ts +43 -0
  85. package/src/tui/theme/presets.ts +79 -0
  86. package/src/tui/theme/types.ts +116 -0
  87. package/src/utils/clipboard.ts +97 -0
  88. package/src/utils/diff.test.ts +56 -0
  89. package/src/cli.ts +0 -96
  90. package/src/clipboard.ts +0 -62
  91. package/src/config.ts +0 -116
  92. package/src/main.tsx +0 -147
  93. package/src/project.ts +0 -32
  94. package/src/session.ts +0 -95
  95. package/src/tui/commands.ts +0 -33
  96. package/src/tui/components/chatLayout.tsx +0 -192
  97. package/src/tui/components/inputBox.tsx +0 -153
  98. package/src/tui/hooks/useInputHandler.ts +0 -268
  99. package/src/tui/useChat.ts +0 -52
  100. package/src/tui/useChatSession.ts +0 -155
  101. package/src/tui/useChatUI.ts +0 -51
  102. package/tsconfig.json +0 -10
  103. /package/src/{subcommands.ts → cli/subcommands.ts} +0 -0
  104. /package/src/{diff.ts → utils/diff.ts} +0 -0
@@ -0,0 +1,106 @@
1
+ import { useApp } from 'ink';
2
+ import {
3
+ type ChatMessage,
4
+ createSessionManager,
5
+ type PluginRegistry,
6
+ type ProviderConfig,
7
+ type SessionManager,
8
+ } from 'mu-core';
9
+ import { useEffect, useMemo, useRef, useState } from 'react';
10
+ import type { ShutdownFn } from '../../app/shutdown';
11
+ import type { HostMessageBus } from '../../runtime/messageBus';
12
+ import { listSessionsAsync, type SessionInfo } from '../../sessions/index';
13
+ import type { InkUIService } from '../plugins/InkUIService';
14
+ import { type AbortState, useAbort } from './useAbort';
15
+ import { type AttachmentState, type TogglesState, useAttachment, useToggles } from './useAttachment';
16
+ import { type ChatSessionState, useChatSession } from './useChatSession';
17
+ import { type ModelListState, useModelList } from './useModels';
18
+
19
+ const ABORT_TIMEOUT_MS = 2000;
20
+
21
+ export interface ChatContextValue {
22
+ config: ProviderConfig;
23
+ session: ChatSessionState;
24
+ sessionManager: SessionManager;
25
+ toggles: TogglesState;
26
+ attachment: AttachmentState;
27
+ models: ModelListState;
28
+ abort: AbortState;
29
+ sessions: SessionInfo[];
30
+ registry: PluginRegistry;
31
+ uiService?: InkUIService;
32
+ messageBus?: HostMessageBus;
33
+ }
34
+
35
+ export function useChat(
36
+ config: ProviderConfig,
37
+ registry: PluginRegistry,
38
+ initialMessages?: ChatMessage[],
39
+ shutdown?: ShutdownFn,
40
+ uiService?: InkUIService,
41
+ messageBus?: HostMessageBus,
42
+ ): ChatContextValue {
43
+ const { exit } = useApp();
44
+ const controllerRef = useRef<AbortController | null>(null);
45
+ const attachment = useAttachment();
46
+ const toggles = useToggles();
47
+ const models = useModelList(config.baseUrl, config.model);
48
+ // Stable SessionManager + Session for the lifetime of the chat hook. Model
49
+ // updates flow through `runTurn(options)` per call, so we don't need to
50
+ // re-instantiate on every change.
51
+ const sessionManager = useMemo(
52
+ () => createSessionManager({ registry, config, model: models.currentModel || config.model || 'unknown' }),
53
+ [registry, config, models.currentModel],
54
+ );
55
+ const muSession = useMemo(
56
+ () => sessionManager.getOrCreate('tui', { initialMessages }),
57
+ [sessionManager, initialMessages],
58
+ );
59
+ const session = useChatSession({
60
+ session: muSession,
61
+ config,
62
+ currentModel: models.currentModel,
63
+ attachment,
64
+ controllerRef,
65
+ initialMessages,
66
+ registry,
67
+ messageBus,
68
+ });
69
+ const abort = useAbort(session.streaming, controllerRef, exit, ABORT_TIMEOUT_MS, shutdown);
70
+
71
+ // Stream the session list asynchronously when the picker opens. Empty until
72
+ // the first listing settles; subsequent opens hit the in-memory peek cache
73
+ // so they're effectively instant.
74
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
75
+ useEffect(() => {
76
+ if (!toggles.showSessionPicker) {
77
+ setSessions([]);
78
+ return;
79
+ }
80
+ let cancelled = false;
81
+ listSessionsAsync()
82
+ .then((list) => {
83
+ if (!cancelled) setSessions(list);
84
+ })
85
+ .catch(() => {
86
+ if (!cancelled) setSessions([]);
87
+ });
88
+ return () => {
89
+ cancelled = true;
90
+ };
91
+ }, [toggles.showSessionPicker]);
92
+
93
+ return {
94
+ config,
95
+ session,
96
+ sessionManager,
97
+ toggles,
98
+ attachment,
99
+ models,
100
+ abort,
101
+ sessions,
102
+ registry,
103
+ uiService,
104
+ messageBus,
105
+ };
106
+ }
@@ -0,0 +1,98 @@
1
+ import { type DOMElement as InkDOMElement, useInput } from 'ink';
2
+ import type { ChatMessage, PluginRegistry, ProviderConfig } from 'mu-core';
3
+ import { useEffect, useMemo, useRef } from 'react';
4
+ import type { ShutdownFn } from '../../app/shutdown';
5
+ import type { HostMessageBus } from '../../runtime/messageBus';
6
+ import type { ChatPanelBodyProps } from '../components/chat/ChatPanelBody';
7
+ import { useToast } from '../components/primitives/toast';
8
+ import { useScroll } from '../hooks/useScroll';
9
+ import { useMeasure, useTerminalSize } from '../hooks/useTerminal';
10
+ import type { InkUIService, ToastRequest } from '../plugins/InkUIService';
11
+ import { useChat } from './useChat';
12
+ import { usePluginStatus } from './usePluginStatus';
13
+ import { useStatusSegments } from './useStatusSegments';
14
+
15
+ const TOAST_LEVEL_COLORS: Record<string, string> = {
16
+ info: 'cyan',
17
+ success: 'green',
18
+ warning: 'yellow',
19
+ error: 'red',
20
+ };
21
+
22
+ interface UseChatPanelOptions {
23
+ config: ProviderConfig;
24
+ initialMessages?: ChatMessage[];
25
+ registry: PluginRegistry;
26
+ messageBus?: HostMessageBus;
27
+ uiService?: InkUIService;
28
+ shutdown?: ShutdownFn;
29
+ }
30
+
31
+ export function useChatPanel(options: UseChatPanelOptions) {
32
+ const { config, initialMessages, registry, messageBus, uiService, shutdown } = options;
33
+ const ctx = useChat(config, registry, initialMessages, shutdown, uiService, messageBus);
34
+ const { width, height } = useTerminalSize();
35
+ const viewRef = useRef<InkDOMElement>(null);
36
+ const contentRef = useRef<InkDOMElement>(null);
37
+ // The composite key only needs to change when content visible to the layout
38
+ // shifts: number of messages or active stream length. Mapping over every
39
+ // message's content per render was O(n) wasted work.
40
+ const measureKey = useMemo(
41
+ () =>
42
+ [ctx.session.messages.length, ctx.session.stream.text.length, ctx.session.stream.reasoning?.length ?? 0].join(
43
+ '|',
44
+ ),
45
+ [ctx.session.messages.length, ctx.session.stream.text.length, ctx.session.stream.reasoning?.length],
46
+ );
47
+ const { viewHeight, contentHeight } = useMeasure(viewRef, contentRef, measureKey);
48
+ const { scrollOffset, onScrollUp, onScrollDown } = useScroll(contentHeight, viewHeight);
49
+ const anyModalOpen = ctx.toggles.showModelPicker || ctx.toggles.showSessionPicker;
50
+ const pluginStatus = usePluginStatus(registry, uiService);
51
+ const { toasts, show, dismiss } = useToast();
52
+
53
+ useInput((input, key) => key.ctrl && input === 'c' && ctx.abort.onCtrlC(), { isActive: anyModalOpen });
54
+
55
+ useEffect(() => {
56
+ if (!uiService) return;
57
+ return uiService.onToast((toast: ToastRequest) => {
58
+ show(toast.message, TOAST_LEVEL_COLORS[toast.level] ?? 'white');
59
+ });
60
+ }, [uiService, show]);
61
+
62
+ const statusSegments = useStatusSegments({
63
+ streaming: ctx.session.streaming,
64
+ abortWarning: ctx.abort.abortWarning,
65
+ quitWarning: ctx.abort.quitWarning,
66
+ error: ctx.session.error,
67
+ modelError: ctx.models.modelError,
68
+ totalTokens: ctx.session.stream.totalTokens,
69
+ cachedTokens: ctx.session.stream.cachedTokens,
70
+ pluginStatus,
71
+ });
72
+
73
+ const bodyProps: ChatPanelBodyProps = {
74
+ width,
75
+ height,
76
+ viewRef,
77
+ contentRef,
78
+ scrollOffset,
79
+ viewHeight,
80
+ contentHeight,
81
+ isActive: !anyModalOpen,
82
+ onScrollUp,
83
+ onScrollDown,
84
+ uiService,
85
+ messages: ctx.session.messages,
86
+ streaming: ctx.session.streaming,
87
+ stream: ctx.session.stream,
88
+ error: ctx.session.error,
89
+ onSubmit: ctx.session.onSend,
90
+ model: ctx.models.currentModel,
91
+ history: ctx.session.inputHistory,
92
+ statusSegments,
93
+ toasts,
94
+ onDismissToast: dismiss,
95
+ };
96
+
97
+ return { ctx, bodyProps };
98
+ }
@@ -0,0 +1,284 @@
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';
5
+ import type { AttachmentState } from './useAttachment';
6
+ import { useSessionPersistence } from './useSessionPersistence';
7
+
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 };
16
+
17
+ export interface ChatSessionState {
18
+ messages: ChatMessage[];
19
+ streaming: boolean;
20
+ error: string | null;
21
+ stream: StreamState;
22
+ inputHistory: string[];
23
+ onSend: (text: string) => Promise<void>;
24
+ onNew: () => void;
25
+ onLoadSession: (path: string) => void;
26
+ }
27
+
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). */
36
+ config: ProviderConfig;
37
+ /** Currently selected model id (may shift across sends). */
38
+ currentModel: string;
39
+ attachment: AttachmentState;
40
+ controllerRef: React.RefObject<AbortController | null>;
41
+ initialMessages?: ChatMessage[];
42
+ registry: PluginRegistry;
43
+ messageBus?: HostMessageBus;
44
+ }
45
+
46
+ /**
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.
50
+ */
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
+ }
153
+
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(
158
+ async (text: string) => {
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
+
165
+ const userMsg: ChatMessage = {
166
+ role: 'user',
167
+ content: finalText,
168
+ ...(attachment.attachment ? { images: [attachment.attachment] } : {}),
169
+ };
170
+
171
+ const injections = messageBus?.drainNext() ?? [];
172
+ for (const inj of injections) session.queueForNextTurn(inj);
173
+
174
+ appendHistory(text);
175
+ attachment.clear();
176
+
177
+ const controller = new AbortController();
178
+ controllerRef.current = controller;
179
+ controller.signal.addEventListener('abort', () => session.abort(), { once: true });
180
+
181
+ try {
182
+ await session.runTurn({
183
+ userMessage: userMsg,
184
+ config,
185
+ model: currentModel,
186
+ registry,
187
+ });
188
+ } finally {
189
+ controllerRef.current = null;
190
+ }
191
+ },
192
+ [streaming, session, config, currentModel, attachment, controllerRef, registry, messageBus, appendHistory],
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
+ });
237
+
238
+ const onNew = useCallback(() => {
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);
253
+ attachment.clear();
254
+ }, [resetForNew, session, attachment, controllerRef]);
255
+
256
+ const onLoadSession = useCallback(
257
+ (path: string) => {
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);
270
+ },
271
+ [loadFromPath, session, controllerRef],
272
+ );
273
+
274
+ return {
275
+ messages,
276
+ streaming,
277
+ error,
278
+ stream,
279
+ inputHistory: persistence.inputHistory,
280
+ onSend,
281
+ onNew,
282
+ onLoadSession,
283
+ };
284
+ }
@@ -1,6 +1,7 @@
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
- import { saveConfig } from '../config';
4
+ import { saveConfig } from '../../config/index';
4
5
 
5
6
  export interface ModelListState {
6
7
  models: ApiModel[];
@@ -16,8 +17,13 @@ export function useModelList(baseUrl: string, preferredModel?: string): ModelLis
16
17
  const [error, setError] = useState<string | null>(null);
17
18
 
18
19
  useEffect(() => {
20
+ // Guard against late resolution: if the user quits or `baseUrl` changes
21
+ // before the request settles, swallow the response so we don't call
22
+ // setState on an unmounted hook.
23
+ let cancelled = false;
19
24
  listModels(baseUrl)
20
25
  .then((list) => {
26
+ if (cancelled) return;
21
27
  if (list.length === 0) {
22
28
  setError(`No models found at ${baseUrl}`);
23
29
  return;
@@ -28,8 +34,12 @@ export function useModelList(baseUrl: string, preferredModel?: string): ModelLis
28
34
  setCurrentModel(target);
29
35
  })
30
36
  .catch((err) => {
37
+ if (cancelled) return;
31
38
  setError(err instanceof Error ? err.message : 'Failed to fetch models');
32
39
  });
40
+ return () => {
41
+ cancelled = true;
42
+ };
33
43
  }, [baseUrl, preferredModel]);
34
44
 
35
45
  const cycleModel = useCallback(() => {
@@ -0,0 +1,44 @@
1
+ import type { PluginRegistry, StatusSegment } from 'mu-core';
2
+ import { useEffect, useMemo, useState } from 'react';
3
+ import type { InkUIService } from '../plugins/InkUIService';
4
+
5
+ /**
6
+ * Aggregate plugin status from the two complementary channels into one
7
+ * flat segment list:
8
+ *
9
+ * 1. `registry.onStatusChange` — push-based, structured `StatusSegment[]` per
10
+ * plugin (color/dim metadata). Producers use `PluginContext.setStatusLine`.
11
+ * 2. `uiService.onStatusChange` — free-form `key → text` map. Producers use
12
+ * `UIService.setStatus` (e.g. `mu-repomap` progress, Pi `pi.ui.setStatus`,
13
+ * Pi `pi.ui.setWidget`). Rendered as dim text since the API carries no
14
+ * color metadata.
15
+ *
16
+ * The split lets producers pick the right granularity; callers see a single
17
+ * pre-merged list ready for the status bar.
18
+ */
19
+ export function usePluginStatus(registry: PluginRegistry, uiService?: InkUIService): StatusSegment[] {
20
+ const [pluginStatus, setPluginStatus] = useState<StatusSegment[]>([]);
21
+ const [uiStatus, setUiStatus] = useState<StatusSegment[]>([]);
22
+
23
+ useEffect(() => {
24
+ setPluginStatus(registry.getStatusSegments());
25
+ return registry.onStatusChange(() => {
26
+ setPluginStatus(registry.getStatusSegments());
27
+ });
28
+ }, [registry]);
29
+
30
+ useEffect(() => {
31
+ if (!uiService) return;
32
+ const apply = (entries: Map<string, string>) => {
33
+ const segments: StatusSegment[] = [];
34
+ for (const [, text] of entries) {
35
+ segments.push({ text, dim: true });
36
+ }
37
+ setUiStatus(segments);
38
+ };
39
+ apply(uiService.getStatusEntries());
40
+ return uiService.onStatusChange(apply);
41
+ }, [uiService]);
42
+
43
+ return useMemo(() => [...pluginStatus, ...uiStatus], [pluginStatus, uiStatus]);
44
+ }
@@ -0,0 +1,68 @@
1
+ import type { ChatMessage } from 'mu-core';
2
+ import { useCallback, useRef, useState } from 'react';
3
+ import { generateSessionPath, loadSession, saveSession } from '../../sessions/index';
4
+
5
+ export interface SessionPersistenceState {
6
+ inputHistory: string[];
7
+ appendHistory: (text: string) => void;
8
+ sessionPathRef: React.RefObject<string>;
9
+ /** Persist the given transcript to the current session file. */
10
+ saveCurrent: (messages: ChatMessage[]) => 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;
20
+ }
21
+
22
+ function userPromptsFrom(messages: ChatMessage[]): string[] {
23
+ return messages.filter((m) => m.role === 'user').map((m) => m.content);
24
+ }
25
+
26
+ /**
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.
31
+ *
32
+ * Save errors are logged to stderr and do not surface to the chat error
33
+ * channel — they're considered non-fatal (next save attempt may succeed).
34
+ */
35
+ export function useSessionPersistence(initialMessages?: ChatMessage[]): SessionPersistenceState {
36
+ const [inputHistory, setInputHistory] = useState<string[]>(userPromptsFrom(initialMessages ?? []));
37
+ const sessionPathRef = useRef(generateSessionPath());
38
+
39
+ const appendHistory = useCallback((text: string) => {
40
+ setInputHistory((prev) => [...prev, text]);
41
+ }, []);
42
+
43
+ const saveCurrent = useCallback((finalMessages: ChatMessage[]) => {
44
+ saveSession(sessionPathRef.current, finalMessages).catch((err) => {
45
+ console.error('Failed to save session:', err);
46
+ });
47
+ }, []);
48
+
49
+ const resetForNew = useCallback(() => {
50
+ sessionPathRef.current = generateSessionPath();
51
+ setInputHistory([]);
52
+ }, []);
53
+
54
+ const loadFromPath = useCallback((path: string): ChatMessage[] => {
55
+ const msgs = loadSession(path);
56
+ if (msgs.length > 0) {
57
+ sessionPathRef.current = path;
58
+ setInputHistory(userPromptsFrom(msgs));
59
+ }
60
+ return msgs;
61
+ }, []);
62
+
63
+ const setHistory = useCallback((history: string[]) => {
64
+ setInputHistory(history);
65
+ }, []);
66
+
67
+ return { inputHistory, appendHistory, sessionPathRef, saveCurrent, resetForNew, loadFromPath, setHistory };
68
+ }