mu-coding 0.15.0 → 0.16.1

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 (118) hide show
  1. package/README.md +9 -123
  2. package/bin/coding-agent.ts +95 -0
  3. package/package.json +10 -21
  4. package/src/config.ts +122 -0
  5. package/src/harness.test.ts +159 -0
  6. package/src/main.ts +53 -3
  7. package/src/plugins.ts +49 -0
  8. package/src/systemPrompt.ts +22 -0
  9. package/src/ui/ChatApp.ts +959 -0
  10. package/src/ui/commands.ts +35 -0
  11. package/src/ui/editor.ts +166 -0
  12. package/src/ui/markdown.ts +363 -0
  13. package/src/ui/picker.ts +126 -0
  14. package/src/ui/status.ts +61 -0
  15. package/src/ui/theme.ts +241 -0
  16. package/src/ui/transcript.test.ts +121 -0
  17. package/src/ui/transcript.ts +399 -0
  18. package/tsconfig.json +8 -0
  19. package/bin/mu.js +0 -2
  20. package/prompts/SYSTEM.md +0 -16
  21. package/src/app/shutdown.ts +0 -94
  22. package/src/app/startApp.ts +0 -49
  23. package/src/cli/args.ts +0 -133
  24. package/src/cli/install.ts +0 -107
  25. package/src/cli/subcommands.ts +0 -29
  26. package/src/cli/update.ts +0 -205
  27. package/src/config/index.test.ts +0 -77
  28. package/src/config/index.ts +0 -199
  29. package/src/plugin.ts +0 -124
  30. package/src/runtime/codingTools/bash.ts +0 -114
  31. package/src/runtime/codingTools/edit-file.ts +0 -60
  32. package/src/runtime/codingTools/index.ts +0 -39
  33. package/src/runtime/codingTools/read-file.ts +0 -83
  34. package/src/runtime/codingTools/utils.ts +0 -21
  35. package/src/runtime/codingTools/write-file.ts +0 -42
  36. package/src/runtime/createRegistry.test.ts +0 -147
  37. package/src/runtime/createRegistry.ts +0 -195
  38. package/src/runtime/fileMentionProvider.ts +0 -117
  39. package/src/runtime/messageBus.test.ts +0 -62
  40. package/src/runtime/messageBus.ts +0 -78
  41. package/src/runtime/pluginLoader.ts +0 -153
  42. package/src/runtime/startupUpdateCheck.ts +0 -163
  43. package/src/runtime/updateCheck.ts +0 -136
  44. package/src/sessions/index.test.ts +0 -66
  45. package/src/sessions/index.ts +0 -183
  46. package/src/sessions/peek.test.ts +0 -88
  47. package/src/sessions/project.ts +0 -51
  48. package/src/tui/channel/tuiChannel.test.ts +0 -107
  49. package/src/tui/channel/tuiChannel.ts +0 -62
  50. package/src/tui/chat/ChatContext.ts +0 -10
  51. package/src/tui/chat/MessageRendererContext.ts +0 -44
  52. package/src/tui/chat/ToolDisplayContext.ts +0 -33
  53. package/src/tui/chat/useAbort.ts +0 -85
  54. package/src/tui/chat/useAttachment.ts +0 -74
  55. package/src/tui/chat/useChat.ts +0 -113
  56. package/src/tui/chat/useChatPanel.ts +0 -120
  57. package/src/tui/chat/useChatSession.ts +0 -384
  58. package/src/tui/chat/useModels.ts +0 -83
  59. package/src/tui/chat/usePluginStatus.ts +0 -44
  60. package/src/tui/chat/useSessionPersistence.ts +0 -84
  61. package/src/tui/chat/useStatusSegments.ts +0 -85
  62. package/src/tui/chat/useSubagentBrowser.ts +0 -133
  63. package/src/tui/components/chat/ChatPanel.tsx +0 -54
  64. package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
  65. package/src/tui/components/chat/Pickers.tsx +0 -44
  66. package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
  67. package/src/tui/components/messageView.tsx +0 -72
  68. package/src/tui/components/messages/EditOutput.tsx +0 -112
  69. package/src/tui/components/messages/ReadOutput.tsx +0 -48
  70. package/src/tui/components/messages/ToolHeader.tsx +0 -30
  71. package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
  72. package/src/tui/components/messages/WriteOutput.tsx +0 -64
  73. package/src/tui/components/messages/assistantMessage.tsx +0 -72
  74. package/src/tui/components/messages/markdown.tsx +0 -407
  75. package/src/tui/components/messages/messageItem.tsx +0 -43
  76. package/src/tui/components/messages/reasoningBlock.tsx +0 -18
  77. package/src/tui/components/messages/streamingOutput.tsx +0 -18
  78. package/src/tui/components/messages/toolCallBlock.tsx +0 -125
  79. package/src/tui/components/messages/userMessage.tsx +0 -44
  80. package/src/tui/components/primitives/dropdown.tsx +0 -125
  81. package/src/tui/components/primitives/modal.tsx +0 -47
  82. package/src/tui/components/primitives/pickerModal.tsx +0 -47
  83. package/src/tui/components/primitives/scrollbar.tsx +0 -27
  84. package/src/tui/components/primitives/toast.tsx +0 -100
  85. package/src/tui/components/statusBar.tsx +0 -41
  86. package/src/tui/components/ui/dialogLayer.tsx +0 -175
  87. package/src/tui/context/ThemeContext.tsx +0 -18
  88. package/src/tui/hooks/useChordKeyboard.ts +0 -87
  89. package/src/tui/hooks/useInputInfoSegments.ts +0 -22
  90. package/src/tui/hooks/useScroll.ts +0 -64
  91. package/src/tui/hooks/useTerminal.ts +0 -40
  92. package/src/tui/hooks/useUI.ts +0 -15
  93. package/src/tui/input/InputBox.tsx +0 -6
  94. package/src/tui/input/InputBoxView.tsx +0 -293
  95. package/src/tui/input/commands.test.ts +0 -71
  96. package/src/tui/input/commands.ts +0 -55
  97. package/src/tui/input/cursor.test.ts +0 -136
  98. package/src/tui/input/cursor.ts +0 -214
  99. package/src/tui/input/dumpContext.ts +0 -107
  100. package/src/tui/input/sanitize.ts +0 -33
  101. package/src/tui/input/useCommandExecutor.ts +0 -32
  102. package/src/tui/input/useInputBox.ts +0 -265
  103. package/src/tui/input/useInputHandler.ts +0 -455
  104. package/src/tui/input/useMentionPicker.ts +0 -133
  105. package/src/tui/input/usePluginShortcuts.ts +0 -29
  106. package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
  107. package/src/tui/plugins/InkApprovalChannel.ts +0 -30
  108. package/src/tui/plugins/InkUIService.ts +0 -188
  109. package/src/tui/renderApp.tsx +0 -66
  110. package/src/tui/theme/index.ts +0 -1
  111. package/src/tui/theme/merge.test.ts +0 -49
  112. package/src/tui/theme/merge.ts +0 -43
  113. package/src/tui/theme/presets.ts +0 -90
  114. package/src/tui/theme/types.ts +0 -138
  115. package/src/tui/update/runUpdateInTui.ts +0 -127
  116. package/src/utils/clipboard.ts +0 -97
  117. package/src/utils/diff.test.ts +0 -56
  118. package/src/utils/diff.ts +0 -81
@@ -1,120 +0,0 @@
1
- import { type DOMElement as InkDOMElement, useInput } from 'ink';
2
- import type { SubagentRunRegistry } from 'mu-agents';
3
- import type { ChatMessage, PluginRegistry, ProviderConfig } from 'mu-core';
4
- import { useEffect, useMemo, useRef } from 'react';
5
- import type { ShutdownFn } from '../../app/shutdown';
6
- import type { SessionPathHolder } from '../../runtime/createRegistry';
7
- import type { HostMessageBus } from '../../runtime/messageBus';
8
- import type { ChatPanelBodyProps } from '../components/chat/ChatPanelBody';
9
- import { useToast } from '../components/primitives/toast';
10
- import { useScroll } from '../hooks/useScroll';
11
- import { useMeasure, useTerminalSize } from '../hooks/useTerminal';
12
- import type { InkUIService, ToastRequest } from '../plugins/InkUIService';
13
- import { useChat } from './useChat';
14
- import { usePluginStatus } from './usePluginStatus';
15
- import { useStatusSegments } from './useStatusSegments';
16
- import { type SubagentBrowserState, useSubagentBrowser } from './useSubagentBrowser';
17
-
18
- const TOAST_LEVEL_COLORS: Record<string, string> = {
19
- info: 'cyan',
20
- success: 'green',
21
- warning: 'yellow',
22
- error: 'red',
23
- };
24
-
25
- interface UseChatPanelOptions {
26
- config: ProviderConfig;
27
- initialMessages?: ChatMessage[];
28
- registry: PluginRegistry;
29
- messageBus?: HostMessageBus;
30
- uiService?: InkUIService;
31
- shutdown?: ShutdownFn;
32
- sessionPathHolder?: SessionPathHolder;
33
- subagentRuns?: SubagentRunRegistry;
34
- }
35
-
36
- export function useChatPanel(options: UseChatPanelOptions) {
37
- const { config, initialMessages, registry, messageBus, uiService, shutdown, sessionPathHolder, subagentRuns } =
38
- options;
39
- const ctx = useChat(
40
- config,
41
- registry,
42
- initialMessages,
43
- shutdown,
44
- uiService,
45
- messageBus,
46
- sessionPathHolder,
47
- subagentRuns,
48
- );
49
- const browser = useSubagentBrowser(subagentRuns);
50
- const { width, height } = useTerminalSize();
51
- const viewRef = useRef<InkDOMElement>(null);
52
- const contentRef = useRef<InkDOMElement>(null);
53
- // The composite key only needs to change when content visible to the layout
54
- // shifts: number of messages or active stream length. Mapping over every
55
- // message's content per render was O(n) wasted work.
56
- const measureKey = useMemo(
57
- () =>
58
- [ctx.session.messages.length, ctx.session.stream.text.length, ctx.session.stream.reasoning?.length ?? 0].join(
59
- '|',
60
- ),
61
- [ctx.session.messages.length, ctx.session.stream.text.length, ctx.session.stream.reasoning?.length],
62
- );
63
- const { viewHeight, contentHeight } = useMeasure(viewRef, contentRef, measureKey);
64
- const { scrollOffset, onScrollUp, onScrollDown } = useScroll(contentHeight, viewHeight);
65
- const anyModalOpen = ctx.toggles.showModelPicker || ctx.toggles.showSessionPicker;
66
- const pluginStatus = usePluginStatus(registry, uiService);
67
- const { toasts, show, dismiss } = useToast();
68
-
69
- useInput((input, key) => key.ctrl && input === 'c' && ctx.abort.onCtrlC(), { isActive: anyModalOpen });
70
-
71
- useEffect(() => {
72
- if (!uiService) return;
73
- return uiService.onToast((toast: ToastRequest) => {
74
- show(toast.message, TOAST_LEVEL_COLORS[toast.level] ?? 'white');
75
- });
76
- }, [uiService, show]);
77
-
78
- const contextLimit = ctx.models.models.find((m) => m.id === ctx.models.currentModel)?.contextLimit;
79
- const statusSegments = useStatusSegments({
80
- streaming: ctx.session.streaming,
81
- abortWarning: ctx.abort.abortWarning,
82
- quitWarning: ctx.abort.quitWarning,
83
- error: ctx.session.error,
84
- modelError: ctx.models.modelError,
85
- totalTokens: ctx.session.stream.totalTokens,
86
- promptTokens: ctx.session.stream.promptTokens,
87
- cachedTokens: ctx.session.stream.cachedTokens,
88
- contextLimit,
89
- pluginStatus,
90
- });
91
-
92
- const bodyProps: ChatPanelBodyProps = {
93
- width,
94
- height,
95
- viewRef,
96
- contentRef,
97
- scrollOffset,
98
- viewHeight,
99
- contentHeight,
100
- isActive: !anyModalOpen && browser.mode.kind === 'chat',
101
- onScrollUp,
102
- onScrollDown,
103
- uiService,
104
- messages: ctx.session.messages,
105
- streaming: ctx.session.streaming,
106
- stream: ctx.session.stream,
107
- error: ctx.session.error,
108
- onSubmit: ctx.session.onSend,
109
- model: ctx.models.currentModel,
110
- history: ctx.session.inputHistory,
111
- statusSegments,
112
- toasts,
113
- onDismissToast: dismiss,
114
- browser,
115
- };
116
-
117
- return { ctx, bodyProps };
118
- }
119
-
120
- export type { SubagentBrowserState };
@@ -1,384 +0,0 @@
1
- import type { ChatMessage, ProviderConfig, Session } from 'mu-core';
2
- import { type PluginRegistry, runDecorateMessageHooks, runTransformUserInputHooks } from 'mu-core';
3
- import { useCallback, useEffect, useRef, useState } from 'react';
4
- import type { SessionPathHolder } from '../../runtime/createRegistry';
5
- import type { HostMessageBus } from '../../runtime/messageBus';
6
- import type { AttachmentState } from './useAttachment';
7
- import { useSessionPersistence } from './useSessionPersistence';
8
-
9
- export interface StreamState {
10
- text: string;
11
- reasoning: string;
12
- totalTokens: number;
13
- promptTokens: number;
14
- cachedTokens: number;
15
- }
16
-
17
- const EMPTY_STREAM: StreamState = { text: '', reasoning: '', totalTokens: 0, promptTokens: 0, cachedTokens: 0 };
18
-
19
- export interface ChatSessionState {
20
- messages: ChatMessage[];
21
- streaming: boolean;
22
- error: string | null;
23
- stream: StreamState;
24
- inputHistory: string[];
25
- onSend: (text: string) => Promise<void>;
26
- onNew: () => void;
27
- onLoadSession: (path: string) => void;
28
- /**
29
- * Compact the current transcript: ask the model to summarize the entire
30
- * conversation, then replace the transcript with `[user marker, assistant
31
- * summary]`. Frees context while keeping intent + key decisions in scope.
32
- */
33
- onCompact: () => Promise<void>;
34
- }
35
-
36
- interface SessionDeps {
37
- /**
38
- * mu-core Session instance owned by the host. Authoritative for the
39
- * transcript — this hook only mirrors it into React state and writes
40
- * persistence on each message_changed event.
41
- */
42
- session: Session;
43
- /** Provider config used as `runTurn` override (model lookup happens here). */
44
- config: ProviderConfig;
45
- /** Currently selected model id (may shift across sends). */
46
- currentModel: string;
47
- attachment: AttachmentState;
48
- controllerRef: React.RefObject<AbortController | null>;
49
- initialMessages?: ChatMessage[];
50
- registry: PluginRegistry;
51
- messageBus?: HostMessageBus;
52
- sessionPathHolder?: SessionPathHolder;
53
- }
54
-
55
- /**
56
- * Wire the host MessageBus to the Session: bus.append flows through
57
- * `session.appendSynthetic` so every subscriber (TUI, broadcaster) sees the
58
- * same change. `bus.get()` mirrors the live transcript.
59
- */
60
- function useMessageBusWiring(messageBus: HostMessageBus | undefined, messages: ChatMessage[], session: Session): void {
61
- useEffect(() => {
62
- messageBus?.setMessages(messages);
63
- }, [messageBus, messages]);
64
-
65
- useEffect(() => {
66
- if (!messageBus) return;
67
- messageBus.setAppender((message) => {
68
- session.appendSynthetic(message);
69
- });
70
- return () => {
71
- messageBus.setAppender(null);
72
- };
73
- }, [messageBus, session]);
74
- }
75
-
76
- interface SubscriptionDeps {
77
- session: Session;
78
- setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
79
- setStream: React.Dispatch<React.SetStateAction<StreamState>>;
80
- setStreaming: React.Dispatch<React.SetStateAction<boolean>>;
81
- setError: React.Dispatch<React.SetStateAction<string | null>>;
82
- saveCurrent: (messages: ChatMessage[]) => void;
83
- }
84
-
85
- /**
86
- * Subscribe React state to mu-core Session events. Session is authoritative;
87
- * this hook only mirrors. Persistence is driven from the same stream so disk
88
- * writes are guaranteed to match what the user sees, but writes are
89
- * coalesced to once per `stream_ended` to keep tool-heavy turns light on I/O.
90
- */
91
- /**
92
- * Build the event handler. Extracted so the cognitive complexity of the
93
- * dispatch lives outside the React effect closure (the effect itself is
94
- * just `session.subscribe(handler)`).
95
- */
96
- function makeSessionEventHandler(
97
- deps: SubscriptionDeps,
98
- lastMessagesRef: React.MutableRefObject<ChatMessage[]>,
99
- ): (event: import('mu-core').SessionEvent) => void {
100
- const { setMessages, setStream, setStreaming, setError, saveCurrent } = deps;
101
- return (event) => {
102
- if (event.type === 'messages_changed') {
103
- lastMessagesRef.current = event.messages;
104
- setMessages(event.messages);
105
- return;
106
- }
107
- if (event.type === 'stream_partial') {
108
- setStream((s) => ({ ...s, text: event.text, reasoning: event.reasoning ?? '' }));
109
- return;
110
- }
111
- if (event.type === 'stream_started') {
112
- setStreaming(true);
113
- setError(null);
114
- return;
115
- }
116
- if (event.type === 'stream_ended') {
117
- setStreaming(false);
118
- setStream((s) => ({ ...s, text: '', reasoning: '' }));
119
- if (lastMessagesRef.current.length > 0) saveCurrent(lastMessagesRef.current);
120
- return;
121
- }
122
- if (event.type === 'usage') {
123
- setStream((s) => ({
124
- ...s,
125
- totalTokens: event.totalTokens,
126
- promptTokens: event.promptTokens,
127
- cachedTokens: event.cachedTokens,
128
- }));
129
- return;
130
- }
131
- if (event.type === 'error') {
132
- setError(event.message);
133
- }
134
- };
135
- }
136
-
137
- function useSessionSubscription(deps: SubscriptionDeps): void {
138
- const { session, setMessages, setStream, setStreaming, setError, saveCurrent } = deps;
139
- // The "last completed transcript" buffer survives effect re-subscriptions
140
- // (e.g. if `saveCurrent` identity ever changes mid-stream). Without a ref
141
- // we'd lose the in-flight save target on the next deps change.
142
- const lastMessagesRef = useRef<ChatMessage[]>([]);
143
- useEffect(() => {
144
- const handler = makeSessionEventHandler(
145
- { session, setMessages, setStream, setStreaming, setError, saveCurrent },
146
- lastMessagesRef,
147
- );
148
- return session.subscribe(handler);
149
- }, [session, setMessages, setStream, setStreaming, setError, saveCurrent]);
150
- }
151
-
152
- interface OnSendDeps {
153
- session: Session;
154
- config: ProviderConfig;
155
- currentModel: string;
156
- attachment: AttachmentState;
157
- controllerRef: React.RefObject<AbortController | null>;
158
- registry: PluginRegistry;
159
- messageBus?: HostMessageBus;
160
- appendHistory: (text: string) => void;
161
- streaming: boolean;
162
- }
163
-
164
- interface OnCompactDeps {
165
- streaming: boolean;
166
- session: Session;
167
- config: ProviderConfig;
168
- currentModel: string;
169
- registry: PluginRegistry;
170
- controllerRef: React.RefObject<AbortController | null>;
171
- saveCurrent: (messages: ChatMessage[]) => void;
172
- }
173
-
174
- const COMPACT_INSTRUCTION =
175
- 'Compact this conversation. Produce ONE concise summary that captures: ' +
176
- "1) the user's overall intent, 2) key decisions made, 3) files modified " +
177
- '(paths with line refs where relevant), 4) open tasks / next steps, and ' +
178
- '5) any important context the assistant should retain. Output ONLY the ' +
179
- 'summary text — no preface, no markdown headers.';
180
-
181
- /**
182
- * Walk a transcript backwards starting at `fromIndex` and return the first
183
- * assistant message with non-empty content. Used by the compact flow to
184
- * locate the summary the LLM produced for the freshly-injected request.
185
- */
186
- function findLatestAssistantContent(messages: ChatMessage[], fromIndex: number): string {
187
- for (let i = messages.length - 1; i >= fromIndex; i--) {
188
- const m = messages[i];
189
- if (m.role === 'assistant' && m.content && m.content.trim().length > 0) {
190
- return m.content;
191
- }
192
- }
193
- return '';
194
- }
195
-
196
- function useOnCompact(deps: OnCompactDeps): () => Promise<void> {
197
- const { streaming, session, config, currentModel, registry, controllerRef, saveCurrent } = deps;
198
- return useCallback(async () => {
199
- if (streaming) return;
200
- const before = session.getMessages();
201
- if (before.length === 0) return;
202
- const beforeCount = before.length;
203
-
204
- // Hide the summarization instruction from the on-screen transcript
205
- // (`display.hidden`) but keep it in the LLM payload. Once the run
206
- // completes we replace the entire transcript anyway.
207
- const summaryInstruction: ChatMessage = {
208
- role: 'user',
209
- content: COMPACT_INSTRUCTION,
210
- display: { hidden: true },
211
- };
212
- const controller = new AbortController();
213
- controllerRef.current = controller;
214
- controller.signal.addEventListener('abort', () => session.abort(), { once: true });
215
- try {
216
- await session.runTurn({ userMessage: summaryInstruction, config, model: currentModel, registry });
217
- const summary = findLatestAssistantContent(session.getMessages(), beforeCount);
218
- if (!summary) return;
219
- // Replace the entire history with a single user marker + the assistant
220
- // summary. The next turn runs with a tiny context, "resumed" from
221
- // the compacted state.
222
- const compacted: ChatMessage[] = [
223
- { role: 'user', content: '[Conversation compacted — context below preserves prior intent and decisions]' },
224
- { role: 'assistant', content: summary },
225
- ];
226
- session.setMessages(compacted);
227
- saveCurrent(compacted);
228
- } finally {
229
- controllerRef.current = null;
230
- }
231
- }, [streaming, session, config, currentModel, registry, controllerRef, saveCurrent]);
232
- }
233
-
234
- function useOnSend(deps: OnSendDeps): (text: string) => Promise<void> {
235
- const { session, config, currentModel, attachment, controllerRef, registry, messageBus, appendHistory, streaming } =
236
- deps;
237
- return useCallback(
238
- async (text: string) => {
239
- if (streaming) return;
240
-
241
- const transform = await runTransformUserInputHooks(registry.getHooks(), text);
242
- if (transform.kind === 'intercept') return;
243
-
244
- // `continue` signals the hook handled the user message itself
245
- // (e.g. mu-agents' @-mention dispatch path appends the user msg
246
- // live, runs the subagent live, and queues the synthetic tool
247
- // flow for the upcoming turn). We skip the userMessage push but
248
- // still drain the queue and stream the LLM follow-up.
249
- const isContinue = transform.kind === 'continue';
250
- const finalText = transform.kind === 'transform' ? transform.text : text;
251
-
252
- const userMsg: ChatMessage | undefined = isContinue
253
- ? undefined
254
- : await runDecorateMessageHooks(registry.getHooks(), {
255
- role: 'user',
256
- content: finalText,
257
- ...(attachment.attachment ? { images: [attachment.attachment] } : {}),
258
- });
259
-
260
- const injections = messageBus?.drainNext() ?? [];
261
- for (const inj of injections) session.queueForNextTurn(inj);
262
-
263
- appendHistory(text);
264
- attachment.clear();
265
-
266
- const controller = new AbortController();
267
- controllerRef.current = controller;
268
- controller.signal.addEventListener('abort', () => session.abort(), { once: true });
269
-
270
- try {
271
- await session.runTurn({
272
- userMessage: userMsg,
273
- config,
274
- model: currentModel,
275
- registry,
276
- });
277
- } finally {
278
- controllerRef.current = null;
279
- }
280
- },
281
- [streaming, session, config, currentModel, attachment, controllerRef, registry, messageBus, appendHistory],
282
- );
283
- }
284
-
285
- /**
286
- * Top-level chat-session hook. Composes:
287
- * - mu-core `Session` — single source of truth for the transcript
288
- * - `useSessionPersistence` — disk write + history + session paths
289
- *
290
- * The hook is purely reactive: it subscribes to session events and exposes
291
- * the resulting state, plus thin wrappers around `session.runTurn` /
292
- * `session.setMessages` for user actions.
293
- */
294
- export function useChatSession(deps: SessionDeps): ChatSessionState {
295
- const { session, config, currentModel, attachment, controllerRef, initialMessages, registry, messageBus } = deps;
296
- const persistence = useSessionPersistence(initialMessages, deps.sessionPathHolder);
297
- const { appendHistory, saveCurrent, resetForNew, loadFromPath } = persistence;
298
-
299
- // Initial seed: feed any persisted messages into the session once.
300
- // The session subscription below will then mirror them into React state.
301
- useEffect(() => {
302
- if (initialMessages?.length) session.setMessages(initialMessages);
303
- // Run once per session instance.
304
- // eslint-disable-next-line react-hooks/exhaustive-deps
305
- }, [session, initialMessages?.length, initialMessages]);
306
-
307
- const [messages, setMessages] = useState<ChatMessage[]>(() => initialMessages ?? []);
308
- const [streaming, setStreaming] = useState(false);
309
- const [error, setError] = useState<string | null>(null);
310
- const [stream, setStream] = useState<StreamState>(EMPTY_STREAM);
311
-
312
- useMessageBusWiring(messageBus, messages, session);
313
- useSessionSubscription({ session, setMessages, setStream, setStreaming, setError, saveCurrent });
314
-
315
- const onSend = useOnSend({
316
- session,
317
- config,
318
- currentModel,
319
- attachment,
320
- controllerRef,
321
- registry,
322
- messageBus,
323
- appendHistory,
324
- streaming,
325
- });
326
-
327
- const onNew = useCallback(() => {
328
- // Abort any in-flight turn *before* rotating the session path.
329
- // Without this, the streaming `runTurn` keeps emitting `messages_changed`
330
- // events that are saved to the newly-rotated path via `stream_ended`,
331
- // mixing the old transcript into the brand-new file.
332
- if (controllerRef.current) {
333
- controllerRef.current.abort();
334
- controllerRef.current = null;
335
- }
336
- resetForNew();
337
- // `session.setMessages([])` emits `messages_changed` which the
338
- // subscription mirrors into React state, so we don't double-write here.
339
- session.setMessages([]);
340
- setStream(EMPTY_STREAM);
341
- setError(null);
342
- attachment.clear();
343
- }, [resetForNew, session, attachment, controllerRef]);
344
-
345
- const onCompact = useOnCompact({
346
- streaming,
347
- session,
348
- config,
349
- currentModel,
350
- registry,
351
- controllerRef,
352
- saveCurrent,
353
- });
354
-
355
- const onLoadSession = useCallback(
356
- (path: string) => {
357
- const loaded = loadFromPath(path);
358
- if (loaded.length === 0) return;
359
- // Abort any in-flight turn before replacing the transcript, for the
360
- // same reason as onNew above.
361
- if (controllerRef.current) {
362
- controllerRef.current.abort();
363
- controllerRef.current = null;
364
- }
365
- // setMessages emits messages_changed → React state mirrors it.
366
- session.setMessages(loaded);
367
- setStream(EMPTY_STREAM);
368
- setError(null);
369
- },
370
- [loadFromPath, session, controllerRef],
371
- );
372
-
373
- return {
374
- messages,
375
- streaming,
376
- error,
377
- stream,
378
- inputHistory: persistence.inputHistory,
379
- onSend,
380
- onNew,
381
- onLoadSession,
382
- onCompact,
383
- };
384
- }
@@ -1,83 +0,0 @@
1
- import type { ApiModel } from 'mu-core';
2
- import { fetchModelContextLimit, listModels } from 'mu-openai-provider';
3
- import { useCallback, useEffect, useState } from 'react';
4
- import { saveConfig } from '../../config/index';
5
-
6
- export interface ModelListState {
7
- models: ApiModel[];
8
- currentModel: string;
9
- modelError: string | null;
10
- cycleModel: () => void;
11
- selectModel: (id: string) => void;
12
- }
13
-
14
- export function useModelList(baseUrl: string, preferredModel?: string): ModelListState {
15
- const [models, setModels] = useState<ApiModel[]>([]);
16
- const [currentModel, setCurrentModel] = useState(preferredModel ?? '');
17
- const [error, setError] = useState<string | null>(null);
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;
24
- listModels(baseUrl)
25
- .then((list) => {
26
- if (cancelled) return;
27
- if (list.length === 0) {
28
- setError(`No models found at ${baseUrl}`);
29
- return;
30
- }
31
- setError(null);
32
- setModels(list);
33
- const target = preferredModel && list.some((m) => m.id === preferredModel) ? preferredModel : list[0].id;
34
- setCurrentModel(target);
35
- })
36
- .catch((err) => {
37
- if (cancelled) return;
38
- setError(err instanceof Error ? err.message : 'Failed to fetch models');
39
- });
40
- return () => {
41
- cancelled = true;
42
- };
43
- }, [baseUrl, preferredModel]);
44
-
45
- // Lazily probe the active model's context window. Fires whenever the
46
- // selection changes; skips if we already know the limit for that id.
47
- // Runs in the background so the UI never blocks waiting for `/props`.
48
- // Triggers a model load on llama-swap-style proxies, but only for the
49
- // model the user has actually picked — same model the next chat would
50
- // load anyway.
51
- useEffect(() => {
52
- if (!(baseUrl && currentModel)) return;
53
- const known = models.find((m) => m.id === currentModel);
54
- if (!known || known.contextLimit !== undefined) return;
55
- let cancelled = false;
56
- fetchModelContextLimit(baseUrl, currentModel)
57
- .then((limit) => {
58
- if (cancelled || !limit) return;
59
- setModels((prev) => prev.map((m) => (m.id === currentModel ? { ...m, contextLimit: limit } : m)));
60
- })
61
- .catch(() => {
62
- /* silently ignore — providers without `/props` just don't get a limit */
63
- });
64
- return () => {
65
- cancelled = true;
66
- };
67
- }, [baseUrl, currentModel, models]);
68
-
69
- const cycleModel = useCallback(() => {
70
- if (models.length === 0) {
71
- return;
72
- }
73
- const idx = models.findIndex((m) => m.id === currentModel);
74
- setCurrentModel(models[(idx + 1) % models.length].id);
75
- }, [models, currentModel]);
76
-
77
- const selectModel = useCallback((id: string) => {
78
- setCurrentModel(id);
79
- saveConfig({ model: id });
80
- }, []);
81
-
82
- return { models, currentModel, cycleModel, selectModel, modelError: error };
83
- }
@@ -1,44 +0,0 @@
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
- }