@kirosnn/mosaic 0.71.0 → 0.73.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 (75) hide show
  1. package/README.md +1 -5
  2. package/package.json +4 -2
  3. package/src/agent/Agent.ts +353 -131
  4. package/src/agent/context.ts +4 -4
  5. package/src/agent/prompts/systemPrompt.ts +15 -6
  6. package/src/agent/prompts/toolsPrompt.ts +75 -10
  7. package/src/agent/provider/anthropic.ts +100 -100
  8. package/src/agent/provider/google.ts +102 -102
  9. package/src/agent/provider/mistral.ts +95 -95
  10. package/src/agent/provider/ollama.ts +77 -60
  11. package/src/agent/provider/openai.ts +42 -38
  12. package/src/agent/provider/rateLimit.ts +178 -0
  13. package/src/agent/provider/xai.ts +99 -99
  14. package/src/agent/tools/definitions.ts +19 -9
  15. package/src/agent/tools/executor.ts +95 -85
  16. package/src/agent/tools/exploreExecutor.ts +8 -10
  17. package/src/agent/tools/grep.ts +30 -29
  18. package/src/agent/tools/question.ts +7 -1
  19. package/src/agent/types.ts +9 -8
  20. package/src/components/App.tsx +45 -45
  21. package/src/components/CustomInput.tsx +214 -36
  22. package/src/components/Main.tsx +1146 -954
  23. package/src/components/Setup.tsx +1 -1
  24. package/src/components/Welcome.tsx +1 -1
  25. package/src/components/main/ApprovalPanel.tsx +4 -3
  26. package/src/components/main/ChatPage.tsx +858 -675
  27. package/src/components/main/HomePage.tsx +53 -38
  28. package/src/components/main/QuestionPanel.tsx +52 -7
  29. package/src/components/main/ThinkingIndicator.tsx +2 -1
  30. package/src/index.tsx +50 -20
  31. package/src/mcp/approvalPolicy.ts +148 -0
  32. package/src/mcp/cli/add.ts +185 -0
  33. package/src/mcp/cli/doctor.ts +77 -0
  34. package/src/mcp/cli/index.ts +85 -0
  35. package/src/mcp/cli/list.ts +50 -0
  36. package/src/mcp/cli/logs.ts +24 -0
  37. package/src/mcp/cli/manage.ts +99 -0
  38. package/src/mcp/cli/show.ts +53 -0
  39. package/src/mcp/cli/tools.ts +77 -0
  40. package/src/mcp/config.ts +223 -0
  41. package/src/mcp/index.ts +80 -0
  42. package/src/mcp/processManager.ts +299 -0
  43. package/src/mcp/rateLimiter.ts +50 -0
  44. package/src/mcp/registry.ts +151 -0
  45. package/src/mcp/schemaConverter.ts +100 -0
  46. package/src/mcp/servers/navigation.ts +854 -0
  47. package/src/mcp/toolCatalog.ts +169 -0
  48. package/src/mcp/types.ts +95 -0
  49. package/src/utils/approvalBridge.ts +17 -5
  50. package/src/utils/commands/compact.ts +30 -0
  51. package/src/utils/commands/echo.ts +1 -1
  52. package/src/utils/commands/index.ts +4 -6
  53. package/src/utils/commands/new.ts +15 -0
  54. package/src/utils/commands/types.ts +3 -0
  55. package/src/utils/config.ts +3 -1
  56. package/src/utils/diffRendering.tsx +1 -3
  57. package/src/utils/exploreBridge.ts +10 -0
  58. package/src/utils/markdown.tsx +163 -99
  59. package/src/utils/models.ts +31 -9
  60. package/src/utils/questionBridge.ts +36 -1
  61. package/src/utils/tokenEstimator.ts +32 -0
  62. package/src/utils/toolFormatting.ts +268 -7
  63. package/src/web/app.tsx +72 -72
  64. package/src/web/components/HomePage.tsx +7 -7
  65. package/src/web/components/MessageItem.tsx +22 -22
  66. package/src/web/components/QuestionPanel.tsx +72 -12
  67. package/src/web/components/Sidebar.tsx +0 -2
  68. package/src/web/components/ThinkingIndicator.tsx +1 -0
  69. package/src/web/server.tsx +767 -683
  70. package/src/utils/commands/redo.ts +0 -74
  71. package/src/utils/commands/sessions.ts +0 -129
  72. package/src/utils/commands/undo.ts +0 -75
  73. package/src/utils/undoRedo.ts +0 -429
  74. package/src/utils/undoRedoBridge.ts +0 -45
  75. package/src/utils/undoRedoDb.ts +0 -338
@@ -1,19 +1,16 @@
1
1
  import { useState, useEffect, useRef } from "react";
2
2
  import type { ImagePart, TextPart, UserContent } from "ai";
3
- import { useKeyboard } from "@opentui/react";
4
- import { Agent } from "../agent";
5
- import { saveConversation, addInputToHistory, type ConversationHistory, type ConversationStep } from "../utils/history";
6
- import { readConfig } from "../utils/config";
7
- import { DEFAULT_MAX_TOOL_LINES, formatToolMessage, formatErrorMessage, parseToolHeader } from '../utils/toolFormatting';
8
- import { initializeCommands, isCommand, executeCommand } from '../utils/commands';
9
- import type { InputSubmitMeta } from './CustomInput';
10
-
11
- import { subscribeQuestion, type QuestionRequest } from "../utils/questionBridge";
12
- import { subscribeApprovalAccepted, type ApprovalAccepted } from "../utils/approvalBridge";
13
- import { subscribeUndoRedo } from "../utils/undoRedoBridge";
14
- import { setExploreAbortController, setExploreToolCallback, abortExplore } from "../utils/exploreBridge";
15
- import { initializeSession, saveState } from "../utils/undoRedo";
16
- import { resetFileChanges } from "../utils/fileChangeTracker";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import { Agent } from "../agent";
5
+ import { saveConversation, addInputToHistory, type ConversationHistory, type ConversationStep } from "../utils/history";
6
+ import { readConfig } from "../utils/config";
7
+ import { DEFAULT_MAX_TOOL_LINES, formatToolMessage, formatErrorMessage, parseToolHeader } from '../utils/toolFormatting';
8
+ import { initializeCommands, isCommand, executeCommand } from '../utils/commands';
9
+ import type { InputSubmitMeta } from './CustomInput';
10
+
11
+ import { subscribeQuestion, type QuestionRequest } from "../utils/questionBridge";
12
+ import { subscribeApprovalAccepted } from "../utils/approvalBridge";
13
+ import { setExploreAbortController, setExploreToolCallback } from "../utils/exploreBridge";
17
14
  import { getCurrentQuestion, cancelQuestion } from "../utils/questionBridge";
18
15
  import { getCurrentApproval, cancelApproval } from "../utils/approvalBridge";
19
16
  import { BLEND_WORDS, type MainProps, type Message } from "./main/types";
@@ -21,65 +18,210 @@ import { HomePage } from './main/HomePage';
21
18
  import { ChatPage } from './main/ChatPage';
22
19
  import type { ImageAttachment } from "../utils/images";
23
20
  import { subscribeImageCommand, setImageSupport } from "../utils/imageBridge";
24
- import { findModelsDevModelById, modelAcceptsImages } from "../utils/models";
25
-
26
- function extractTitle(content: string, alreadyResolved: boolean): { title: string | null; cleanContent: string; isPending: boolean; noTitle: boolean } {
27
- const trimmed = content.trimStart();
28
-
29
- const titleMatch = trimmed.match(/^<title>(.*?)<\/title>\s*/s);
30
- if (titleMatch) {
31
- const title = alreadyResolved ? null : (titleMatch[1]?.trim() || null);
32
- const cleanContent = trimmed.replace(/^<title>.*?<\/title>\s*/s, '');
33
- return { title, cleanContent, isPending: false, noTitle: false };
34
- }
35
-
36
- if (alreadyResolved) {
37
- return { title: null, cleanContent: content, isPending: false, noTitle: false };
38
- }
39
-
40
- const partialTitlePattern = /^<(t(i(t(l(e(>.*)?)?)?)?)?)?$/i;
41
- if (partialTitlePattern.test(trimmed) || (trimmed.startsWith('<title>') && !trimmed.includes('</title>'))) {
42
- return { title: null, cleanContent: '', isPending: true, noTitle: false };
43
- }
44
-
45
- return { title: null, cleanContent: content, isPending: false, noTitle: true };
46
- }
47
-
48
- function setTerminalTitle(title: string) {
49
- process.title = `⁘ ${title}`;
50
- }
51
-
52
- export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsOpen = false, commandsOpen = false, initialMessage }: MainProps) {
53
- const [currentPage, setCurrentPage] = useState<"home" | "chat">(initialMessage ? "chat" : "home");
54
- const [messages, setMessages] = useState<Message[]>([]);
55
- const [isProcessing, setIsProcessing] = useState(false);
56
- const [processingStartTime, setProcessingStartTime] = useState<number | null>(null);
57
- const [currentTokens, setCurrentTokens] = useState(0);
58
- const [scrollOffset, setScrollOffset] = useState(0);
59
- const [terminalHeight, setTerminalHeight] = useState(process.stdout.rows || 24);
60
- const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
21
+ import { findModelsDevModelById, modelAcceptsImages, getModelsDevContextLimit } from "../utils/models";
22
+ import { DEFAULT_SYSTEM_PROMPT, processSystemPrompt } from "../agent/prompts/systemPrompt";
23
+ import { estimateTokensFromText, estimateTokensForContent, getDefaultContextBudget } from "../utils/tokenEstimator";
24
+
25
+ type CompactableMessage = Pick<Message, "role" | "content" | "thinkingContent" | "toolName">;
26
+
27
+ function extractTitle(content: string, alreadyResolved: boolean): { title: string | null; cleanContent: string; isPending: boolean; noTitle: boolean } {
28
+ const trimmed = content.trimStart();
29
+
30
+ const titleMatch = trimmed.match(/^<title>(.*?)<\/title>\s*/s);
31
+ if (titleMatch) {
32
+ const title = alreadyResolved ? null : (titleMatch[1]?.trim() || null);
33
+ const cleanContent = trimmed.replace(/^<title>.*?<\/title>\s*/s, '');
34
+ return { title, cleanContent, isPending: false, noTitle: false };
35
+ }
36
+
37
+ if (alreadyResolved) {
38
+ return { title: null, cleanContent: content, isPending: false, noTitle: false };
39
+ }
40
+
41
+ const partialTitlePattern = /^<(t(i(t(l(e(>.*)?)?)?)?)?)?$/i;
42
+ if (partialTitlePattern.test(trimmed) || (trimmed.startsWith('<title>') && !trimmed.includes('</title>'))) {
43
+ return { title: null, cleanContent: '', isPending: true, noTitle: false };
44
+ }
45
+
46
+ return { title: null, cleanContent: content, isPending: false, noTitle: true };
47
+ }
48
+
49
+ function setTerminalTitle(title: string) {
50
+ process.title = `⁘ ${title}`;
51
+ }
52
+
53
+ export function normalizeWhitespace(text: string): string {
54
+ return text.replace(/\s+/g, " ").trim();
55
+ }
56
+
57
+ export function truncateText(text: string, maxChars: number): string {
58
+ if (text.length <= maxChars) return text;
59
+ return text.slice(0, Math.max(0, maxChars - 3)) + "...";
60
+ }
61
+
62
+ export function estimateTokensForMessage(message: CompactableMessage): number {
63
+ return estimateTokensForContent(message.content || "", message.thinkingContent || undefined);
64
+ }
65
+
66
+ export function estimateTokensForMessages(messages: CompactableMessage[]): number {
67
+ return messages.reduce((sum, message) => sum + estimateTokensForMessage(message), 0);
68
+ }
69
+
70
+ export function estimateTotalTokens(messages: CompactableMessage[], systemPrompt: string): number {
71
+ const systemTokens = estimateTokensFromText(systemPrompt) + 8;
72
+ return systemTokens + estimateTokensForMessages(messages);
73
+ }
74
+
75
+ export function shouldAutoCompact(totalTokens: number, maxContextTokens: number): boolean {
76
+ if (!Number.isFinite(maxContextTokens) || maxContextTokens <= 0) return false;
77
+ const threshold = Math.floor(maxContextTokens * 0.95);
78
+ return totalTokens >= threshold;
79
+ }
80
+
81
+ export function summarizeMessage(message: CompactableMessage, isLastUser: boolean): string {
82
+ if (message.role === "tool") {
83
+ const name = message.toolName || "tool";
84
+ const text = message.content || "";
85
+ const isError = text.toLowerCase().includes('error') || text.toLowerCase().includes('failed');
86
+ const status = isError ? 'FAILED' : 'OK';
87
+ const cleaned = normalizeWhitespace(text);
88
+ return `[tool:${name} ${status}] ${truncateText(cleaned, 120)}`;
89
+ }
90
+
91
+ if (message.role === "assistant") {
92
+ const cleaned = normalizeWhitespace(message.content || "");
93
+ const sentenceMatch = cleaned.match(/^[^.!?\n]{10,}[.!?]/);
94
+ const summary = sentenceMatch ? sentenceMatch[0] : cleaned;
95
+ return `assistant: ${truncateText(summary, 200)}`;
96
+ }
97
+
98
+ const cleaned = normalizeWhitespace(message.content || "");
99
+ const limit = isLastUser ? cleaned.length : 400;
100
+ return `user: ${truncateText(cleaned, limit)}`;
101
+ }
102
+
103
+ export function buildSummary(messages: CompactableMessage[], maxTokens: number): string {
104
+ const maxChars = Math.max(0, maxTokens * 3);
105
+ const header = "Résumé de conversation (compact):";
106
+ let charCount = header.length + 1;
107
+ const lines: string[] = [];
108
+
109
+ let lastUserIndex = -1;
110
+ for (let i = messages.length - 1; i >= 0; i--) {
111
+ if (messages[i]!.role === 'user') { lastUserIndex = i; break; }
112
+ }
113
+
114
+ for (let i = 0; i < messages.length; i++) {
115
+ if (charCount >= maxChars) break;
116
+ const line = `- ${summarizeMessage(messages[i]!, i === lastUserIndex)}`;
117
+ charCount += line.length + 1;
118
+ lines.push(line);
119
+ }
120
+ const body = lines.join("\n");
121
+ const full = `${header}\n${body}`.trim();
122
+ return truncateText(full, maxChars);
123
+ }
124
+
125
+ export function collectContextFiles(messages: Message[]): string[] {
126
+ const files = new Set<string>();
127
+ for (const message of messages) {
128
+ if (message.role !== "tool") continue;
129
+ if (!message.toolArgs) continue;
130
+ const toolName = message.toolName || "";
131
+ if (!["read", "write", "edit", "list", "grep"].includes(toolName)) continue;
132
+ const path = message.toolArgs.path;
133
+ if (typeof path === "string" && path.trim()) {
134
+ files.add(path.trim());
135
+ }
136
+ const pattern = message.toolArgs.pattern;
137
+ if (toolName === "grep" && typeof pattern === "string" && pattern.trim()) {
138
+ files.add(pattern.trim());
139
+ }
140
+ }
141
+ return Array.from(files.values()).sort((a, b) => a.localeCompare(b));
142
+ }
143
+
144
+ export function appendContextFiles(summary: string, files: string[], maxTokens: number): string {
145
+ if (files.length === 0) return summary;
146
+ const maxChars = Math.max(0, maxTokens * 4);
147
+ const list = files.map(f => `- ${f}`).join("\n");
148
+ const block = `\n\nFichiers conservés après compaction:\n${list}`;
149
+ return truncateText(`${summary}${block}`, maxChars);
150
+ }
151
+
152
+ export function compactMessagesForUi(
153
+ messages: Message[],
154
+ systemPrompt: string,
155
+ maxContextTokens: number,
156
+ createId: () => string,
157
+ summaryOnly: boolean
158
+ ): { messages: Message[]; estimatedTokens: number; didCompact: boolean } {
159
+ const systemTokens = estimateTokensFromText(systemPrompt) + 8;
160
+ const totalTokens = systemTokens + estimateTokensForMessages(messages);
161
+ if (totalTokens <= maxContextTokens && !summaryOnly) {
162
+ return { messages, estimatedTokens: totalTokens - systemTokens, didCompact: false };
163
+ }
164
+
165
+ const summaryTokens = Math.min(2000, Math.max(400, Math.floor(maxContextTokens * 0.2)));
166
+ const recentBudget = Math.max(500, maxContextTokens - summaryTokens);
167
+
168
+ let recentTokens = 0;
169
+ const recent: Message[] = [];
170
+ for (let i = messages.length - 1; i >= 0; i--) {
171
+ const message = messages[i]!;
172
+ const msgTokens = estimateTokensForMessage(message);
173
+ if (recentTokens + msgTokens > recentBudget && recent.length > 0) break;
174
+ recent.unshift(message);
175
+ recentTokens += msgTokens;
176
+ }
177
+
178
+ const cutoff = messages.length - recent.length;
179
+ const older = cutoff > 0 ? messages.slice(0, cutoff) : [];
180
+ const files = collectContextFiles(messages);
181
+ const summaryBase = buildSummary(summaryOnly ? messages : (older.length > 0 ? older : messages), summaryTokens);
182
+ const summary = appendContextFiles(summaryBase, files, summaryTokens);
183
+ const summaryMessage: Message = {
184
+ id: createId(),
185
+ role: "assistant",
186
+ content: summary
187
+ };
188
+
189
+ const nextMessages = summaryOnly ? [summaryMessage] : [summaryMessage, ...recent];
190
+ const estimatedTokens = estimateTokensForMessages(nextMessages);
191
+ return { messages: nextMessages, estimatedTokens, didCompact: true };
192
+ }
193
+
194
+ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsOpen = false, commandsOpen = false, initialMessage }: MainProps) {
195
+ const [currentPage, setCurrentPage] = useState<"home" | "chat">(initialMessage ? "chat" : "home");
196
+ const [messages, setMessages] = useState<Message[]>([]);
197
+ const [isProcessing, setIsProcessing] = useState(false);
198
+ const [processingStartTime, setProcessingStartTime] = useState<number | null>(null);
199
+ const [currentTokens, setCurrentTokens] = useState(0);
200
+ const [scrollOffset, setScrollOffset] = useState(0);
201
+ const [terminalHeight, setTerminalHeight] = useState(process.stdout.rows || 24);
202
+ const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
61
203
  const [questionRequest, setQuestionRequest] = useState<QuestionRequest | null>(null);
62
204
  const [currentTitle, setCurrentTitle] = useState<string | null>(null);
63
205
  const [pendingImages, setPendingImages] = useState<ImageAttachment[]>([]);
64
206
  const [imagesSupported, setImagesSupported] = useState(false);
65
- const currentTitleRef = useRef<string | null>(null);
66
- const titleExtractedRef = useRef(false);
67
- const shouldAutoScroll = useRef(true);
68
- const abortControllerRef = useRef<AbortController | null>(null);
69
- const currentPageRef = useRef(currentPage);
70
- const shortcutsOpenRef = useRef(shortcutsOpen);
71
- const commandsOpenRef = useRef(commandsOpen);
72
- const questionRequestRef = useRef<QuestionRequest | null>(questionRequest);
73
- const initialMessageProcessed = useRef(false);
74
- const exploreMessageIdRef = useRef<string | null>(null);
75
- const exploreToolsRef = useRef<Array<{ tool: string; info: string; success: boolean }>>([]);
76
- const explorePurposeRef = useRef<string>('');
77
-
78
- const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
79
-
207
+ const currentTitleRef = useRef<string | null>(null);
208
+ const titleExtractedRef = useRef(false);
209
+ const shouldAutoScroll = useRef(true);
210
+ const abortControllerRef = useRef<AbortController | null>(null);
211
+ const currentPageRef = useRef(currentPage);
212
+ const shortcutsOpenRef = useRef(shortcutsOpen);
213
+ const commandsOpenRef = useRef(commandsOpen);
214
+ const questionRequestRef = useRef<QuestionRequest | null>(questionRequest);
215
+ const initialMessageProcessed = useRef(false);
216
+ const lastPromptTokensRef = useRef<number>(0);
217
+ const exploreMessageIdRef = useRef<string | null>(null);
218
+ const exploreToolsRef = useRef<Array<{ tool: string; info: string; success: boolean }>>([]);
219
+ const explorePurposeRef = useRef<string>('');
220
+
221
+ const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
222
+
80
223
  useEffect(() => {
81
224
  initializeCommands();
82
- initializeSession();
83
225
  }, []);
84
226
 
85
227
  useEffect(() => {
@@ -102,50 +244,41 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
102
244
  };
103
245
  loadSupport();
104
246
  }, []);
105
-
106
- useEffect(() => {
107
- let lastExploreTokens = 0;
108
- setExploreToolCallback((toolName, args, result, totalTokens) => {
109
- const info = (args.path || args.pattern || args.query || '') as string;
110
- const shortInfo = info.length > 40 ? info.substring(0, 37) + '...' : info;
111
- exploreToolsRef.current.push({ tool: toolName, info: shortInfo, success: result.success });
112
-
113
- const tokenDelta = totalTokens - lastExploreTokens;
114
- lastExploreTokens = totalTokens;
115
- if (tokenDelta > 0) {
116
- setCurrentTokens(prev => prev + tokenDelta);
117
- }
118
-
119
- if (exploreMessageIdRef.current) {
120
- setMessages((prev: Message[]) => {
121
- const newMessages = [...prev];
122
- const idx = newMessages.findIndex(m => m.id === exploreMessageIdRef.current);
123
- if (idx !== -1) {
124
- const toolLines = exploreToolsRef.current.map(t => {
125
- const icon = t.success ? '→' : '-';
126
- return ` ${icon} ${t.tool}(${t.info})`;
127
- });
128
- const purpose = explorePurposeRef.current;
129
- const newContent = `Explore (${purpose})\n${toolLines.join('\n')}`;
130
- newMessages[idx] = { ...newMessages[idx]!, content: newContent };
131
- }
132
- return newMessages;
133
- });
134
- }
135
- });
136
-
137
- return () => {
138
- setExploreToolCallback(null);
139
- };
140
- }, []);
141
-
247
+
142
248
  useEffect(() => {
143
- return subscribeUndoRedo((state, action) => {
144
- if (state) {
145
- setMessages(state.messages);
146
- resetFileChanges();
249
+ let lastExploreTokens = 0;
250
+ setExploreToolCallback((toolName, args, result, totalTokens) => {
251
+ const info = (args.path || args.pattern || args.query || '') as string;
252
+ const shortInfo = info.length > 40 ? info.substring(0, 37) + '...' : info;
253
+ exploreToolsRef.current.push({ tool: toolName, info: shortInfo, success: result.success });
254
+
255
+ const tokenDelta = totalTokens - lastExploreTokens;
256
+ lastExploreTokens = totalTokens;
257
+ if (tokenDelta > 0) {
258
+ setCurrentTokens(prev => prev + tokenDelta);
259
+ }
260
+
261
+ if (exploreMessageIdRef.current) {
262
+ setMessages((prev: Message[]) => {
263
+ const newMessages = [...prev];
264
+ const idx = newMessages.findIndex(m => m.id === exploreMessageIdRef.current);
265
+ if (idx !== -1) {
266
+ const toolLines = exploreToolsRef.current.map(t => {
267
+ const icon = t.success ? '→' : '-';
268
+ return ` ${icon} ${t.tool}(${t.info})`;
269
+ });
270
+ const purpose = explorePurposeRef.current;
271
+ const newContent = `Explore (${purpose})\n${toolLines.join('\n')}`;
272
+ newMessages[idx] = { ...newMessages[idx]!, content: newContent };
273
+ }
274
+ return newMessages;
275
+ });
147
276
  }
148
277
  });
278
+
279
+ return () => {
280
+ setExploreToolCallback(null);
281
+ };
149
282
  }, []);
150
283
 
151
284
  useEffect(() => {
@@ -168,146 +301,145 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
168
301
  setPendingImages([]);
169
302
  }
170
303
  }, [imagesSupported]);
171
-
172
- useEffect(() => {
173
- const handleResize = () => {
174
- const newWidth = process.stdout.columns || 80;
175
- const newHeight = process.stdout.rows || 24;
176
- const oldWidth = terminalWidth;
177
- const oldHeight = terminalHeight;
178
-
179
- setTerminalWidth(newWidth);
180
- setTerminalHeight(newHeight);
181
-
182
- if (shouldAutoScroll.current) {
183
- setScrollOffset(0);
184
- } else if (oldHeight !== newHeight) {
185
- const heightDiff = newHeight - oldHeight;
186
- setScrollOffset(prev => Math.max(0, prev - heightDiff));
187
- }
188
- };
189
- process.stdout.on('resize', handleResize);
190
- return () => {
191
- process.stdout.off('resize', handleResize);
192
- };
193
- }, [terminalWidth, terminalHeight]);
194
-
195
- useEffect(() => {
196
- return subscribeQuestion(setQuestionRequest);
197
- }, []);
198
-
199
- useEffect(() => {
200
- return subscribeApprovalAccepted((accepted) => {
201
- const isBashTool = accepted.toolName === 'bash';
202
-
203
- if (isBashTool) {
204
- const { name: toolDisplayName, info: toolInfo } = parseToolHeader(accepted.toolName, accepted.args);
205
- const runningContent = toolInfo ? `${toolDisplayName} (${toolInfo})` : toolDisplayName;
206
-
207
- setMessages((prev: Message[]) => {
208
- const newMessages = [...prev];
209
- newMessages.push({
210
- id: createId(),
211
- role: "tool",
212
- content: runningContent,
213
- toolName: accepted.toolName,
214
- toolArgs: accepted.args,
215
- success: true,
216
- isRunning: true,
217
- runningStartTime: Date.now()
218
- });
219
- return newMessages;
220
- });
221
- }
222
- });
223
- }, []);
224
-
225
- useEffect(() => {
226
- currentPageRef.current = currentPage;
227
- }, [currentPage]);
228
-
229
- useEffect(() => {
230
- shortcutsOpenRef.current = shortcutsOpen;
231
- }, [shortcutsOpen]);
232
-
233
- useEffect(() => {
234
- commandsOpenRef.current = commandsOpen;
235
- }, [commandsOpen]);
236
-
237
- useEffect(() => {
238
- questionRequestRef.current = questionRequest;
239
- }, [questionRequest]);
240
-
241
- useEffect(() => {
242
- if (questionRequest) {
243
- shouldAutoScroll.current = true;
244
- setScrollOffset(0);
245
- }
246
- }, [questionRequest]);
247
-
248
- useEffect(() => {
249
- if (currentPage !== "chat") return;
250
-
251
- process.stdin.setRawMode(true);
252
- process.stdout.write('\x1b[?1000h');
253
- process.stdout.write('\x1b[?1003h');
254
- process.stdout.write('\x1b[?1006h');
255
-
256
- const handleData = (data: Buffer) => {
257
- const str = data.toString();
258
-
259
- if (str.match(/\x1b\[<(\d+);(\d+);(\d+)([mM])/)) {
260
- const match = str.match(/\x1b\[<(\d+);(\d+);(\d+)([mM])/);
261
- if (match) {
262
- const button = parseInt(match[1] || '0');
263
-
264
- if (button === 64) {
265
- shouldAutoScroll.current = false;
266
- setScrollOffset((prev) => prev + 1);
267
- } else if (button === 65) {
268
- setScrollOffset((prev) => {
269
- const newOffset = Math.max(0, prev - 1);
270
- if (newOffset === 0) {
271
- shouldAutoScroll.current = true;
272
- }
273
- return newOffset;
274
- });
275
- }
276
- }
277
- }
278
- };
279
-
280
- process.stdin.on('data', handleData);
281
-
282
- return () => {
283
- process.stdin.off('data', handleData);
284
- process.stdout.write('\x1b[?1000l');
285
- process.stdout.write('\x1b[?1003l');
286
- process.stdout.write('\x1b[?1006l');
287
- };
288
- }, [currentPage]);
289
-
290
- useEffect(() => {
291
- if (currentPage === "chat") {
292
- setScrollOffset((prevOffset) => {
293
- if (shouldAutoScroll.current || prevOffset < 5) {
294
- shouldAutoScroll.current = true;
295
- return 0;
296
- }
297
- return prevOffset;
298
- });
299
- }
300
- }, [messages, currentPage]);
301
-
302
- useEffect(() => {
303
- if (copyRequestId > 0 && onCopy && messages.length > 0) {
304
- const lastAssistantMessage = messages.slice().reverse().find(m => m.role === 'assistant');
305
- if (lastAssistantMessage) {
306
- onCopy(lastAssistantMessage.content);
307
- }
308
- }
309
- }, [copyRequestId, onCopy, messages]);
310
-
304
+
305
+ useEffect(() => {
306
+ const handleResize = () => {
307
+ const newWidth = process.stdout.columns || 80;
308
+ const newHeight = process.stdout.rows || 24;
309
+ const oldHeight = terminalHeight;
310
+
311
+ setTerminalWidth(newWidth);
312
+ setTerminalHeight(newHeight);
313
+
314
+ if (shouldAutoScroll.current) {
315
+ setScrollOffset(0);
316
+ } else if (oldHeight !== newHeight) {
317
+ const heightDiff = newHeight - oldHeight;
318
+ setScrollOffset(prev => Math.max(0, prev - heightDiff));
319
+ }
320
+ };
321
+ process.stdout.on('resize', handleResize);
322
+ return () => {
323
+ process.stdout.off('resize', handleResize);
324
+ };
325
+ }, [terminalWidth, terminalHeight]);
326
+
327
+ useEffect(() => {
328
+ return subscribeQuestion(setQuestionRequest);
329
+ }, []);
330
+
331
+ useEffect(() => {
332
+ return subscribeApprovalAccepted((accepted) => {
333
+ const isBashTool = accepted.toolName === 'bash';
334
+
335
+ if (isBashTool) {
336
+ const { name: toolDisplayName, info: toolInfo } = parseToolHeader(accepted.toolName, accepted.args);
337
+ const runningContent = toolInfo ? `${toolDisplayName} (${toolInfo})` : toolDisplayName;
338
+
339
+ setMessages((prev: Message[]) => {
340
+ const newMessages = [...prev];
341
+ newMessages.push({
342
+ id: createId(),
343
+ role: "tool",
344
+ content: runningContent,
345
+ toolName: accepted.toolName,
346
+ toolArgs: accepted.args,
347
+ success: true,
348
+ isRunning: true,
349
+ runningStartTime: Date.now()
350
+ });
351
+ return newMessages;
352
+ });
353
+ }
354
+ });
355
+ }, []);
356
+
357
+ useEffect(() => {
358
+ currentPageRef.current = currentPage;
359
+ }, [currentPage]);
360
+
361
+ useEffect(() => {
362
+ shortcutsOpenRef.current = shortcutsOpen;
363
+ }, [shortcutsOpen]);
364
+
365
+ useEffect(() => {
366
+ commandsOpenRef.current = commandsOpen;
367
+ }, [commandsOpen]);
368
+
369
+ useEffect(() => {
370
+ questionRequestRef.current = questionRequest;
371
+ }, [questionRequest]);
372
+
373
+ useEffect(() => {
374
+ if (questionRequest) {
375
+ shouldAutoScroll.current = true;
376
+ setScrollOffset(0);
377
+ }
378
+ }, [questionRequest]);
379
+
380
+ useEffect(() => {
381
+ if (currentPage !== "chat") return;
382
+
383
+ process.stdin.setRawMode(true);
384
+ process.stdout.write('\x1b[?1000h');
385
+ process.stdout.write('\x1b[?1003h');
386
+ process.stdout.write('\x1b[?1006h');
387
+
388
+ const handleData = (data: Buffer) => {
389
+ const str = data.toString();
390
+
391
+ if (str.match(/\x1b\[<(\d+);(\d+);(\d+)([mM])/)) {
392
+ const match = str.match(/\x1b\[<(\d+);(\d+);(\d+)([mM])/);
393
+ if (match) {
394
+ const button = parseInt(match[1] || '0');
395
+
396
+ if (button === 64) {
397
+ shouldAutoScroll.current = false;
398
+ setScrollOffset((prev) => prev + 1);
399
+ } else if (button === 65) {
400
+ setScrollOffset((prev) => {
401
+ const newOffset = Math.max(0, prev - 1);
402
+ if (newOffset === 0) {
403
+ shouldAutoScroll.current = true;
404
+ }
405
+ return newOffset;
406
+ });
407
+ }
408
+ }
409
+ }
410
+ };
411
+
412
+ process.stdin.on('data', handleData);
413
+
414
+ return () => {
415
+ process.stdin.off('data', handleData);
416
+ process.stdout.write('\x1b[?1000l');
417
+ process.stdout.write('\x1b[?1003l');
418
+ process.stdout.write('\x1b[?1006l');
419
+ };
420
+ }, [currentPage]);
421
+
422
+ useEffect(() => {
423
+ if (currentPage === "chat") {
424
+ setScrollOffset((prevOffset) => {
425
+ if (shouldAutoScroll.current || prevOffset < 5) {
426
+ shouldAutoScroll.current = true;
427
+ return 0;
428
+ }
429
+ return prevOffset;
430
+ });
431
+ }
432
+ }, [messages, currentPage]);
433
+
434
+ useEffect(() => {
435
+ if (copyRequestId > 0 && onCopy && messages.length > 0) {
436
+ const lastAssistantMessage = messages.slice().reverse().find(m => m.role === 'assistant');
437
+ if (lastAssistantMessage) {
438
+ onCopy(lastAssistantMessage.content);
439
+ }
440
+ }
441
+ }, [copyRequestId, onCopy, messages]);
442
+
311
443
  useKeyboard((key) => {
312
444
  if ((key.name === 'c' && key.ctrl) || key.sequence === '\x03') {
313
445
  if (getCurrentQuestion()) {
@@ -324,11 +456,11 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
324
456
  if (getCurrentQuestion()) {
325
457
  cancelQuestion();
326
458
  }
327
- if (getCurrentApproval()) {
328
- cancelApproval();
329
- }
330
- abortControllerRef.current?.abort();
331
- return;
459
+ if (getCurrentApproval()) {
460
+ cancelApproval();
461
+ }
462
+ abortControllerRef.current?.abort();
463
+ return;
332
464
  }
333
465
  });
334
466
 
@@ -360,343 +492,376 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
360
492
  const hasPastedContent = Boolean(meta?.isPaste && meta.pastedContent);
361
493
  const hasImages = imagesSupported && pendingImages.length > 0;
362
494
  if (!value.trim() && !hasPastedContent && !hasImages) return;
363
-
364
- if (isCommand(value)) {
365
- const result = await executeCommand(value);
366
- if (result) {
367
- if (result.shouldAddToHistory === true) {
368
- addInputToHistory(value.trim());
369
-
370
- saveState(messages);
371
-
372
- const userMessage: Message = {
373
- id: createId(),
374
- role: "user",
375
- content: result.content,
376
- displayContent: value,
377
- };
378
-
379
- setMessages((prev: Message[]) => [...prev, userMessage]);
380
- setIsProcessing(true);
381
- const localStartTime = Date.now();
382
- setProcessingStartTime(localStartTime);
383
- setCurrentTokens(0);
384
- shouldAutoScroll.current = true;
385
-
386
- const conversationId = createId();
387
- const conversationSteps: ConversationStep[] = [];
388
- let totalTokens = { prompt: 0, completion: 0, total: 0 };
389
- let stepCount = 0;
390
- let totalChars = 0;
391
- for (const m of messages) {
392
- if (m.role === 'assistant') {
393
- totalChars += m.content.length;
394
- if (m.thinkingContent) totalChars += m.thinkingContent.length;
395
- } else if (m.role === 'tool') {
396
- totalChars += m.content.length;
397
- }
398
- }
399
-
400
- const estimateTokens = () => Math.ceil(totalChars / 4);
401
- setCurrentTokens(estimateTokens());
402
- const config = readConfig();
403
- const abortController = new AbortController();
404
- abortControllerRef.current = abortController;
405
- let abortNotified = false;
406
- const notifyAbort = () => {
407
- if (abortNotified) return;
408
- abortNotified = true;
409
- setMessages((prev: Message[]) => {
410
- const newMessages = [...prev];
411
- newMessages.push({
412
- id: createId(),
413
- role: "tool",
414
- success: false,
415
- content: "Request interrupted by user. \n↪ What should Mosaic do instead?"
416
- });
417
- return newMessages;
418
- });
419
- };
420
-
421
- conversationSteps.push({
422
- type: 'user',
423
- content: result.content,
424
- timestamp: Date.now()
425
- });
426
-
495
+
496
+ if (isCommand(value)) {
497
+ const result = await executeCommand(value);
498
+ if (result) {
499
+ if (result.shouldClearMessages === true) {
500
+ const commandMessage: Message = {
501
+ id: createId(),
502
+ role: "slash",
503
+ content: result.content,
504
+ isError: !result.success
505
+ };
506
+ setMessages([commandMessage]);
507
+ return;
508
+ }
509
+
510
+ if (result.shouldCompactMessages === true) {
511
+ const config = readConfig();
512
+ const rawSystemPrompt = config.systemPrompt || DEFAULT_SYSTEM_PROMPT;
513
+ const systemPrompt = processSystemPrompt(rawSystemPrompt, true);
514
+ let maxContextTokens = result.compactMaxTokens ?? config.maxContextTokens;
515
+ if (!maxContextTokens && config.provider && config.model) {
516
+ const resolved = await getModelsDevContextLimit(config.provider, config.model);
517
+ if (typeof resolved === "number") {
518
+ maxContextTokens = resolved;
519
+ }
520
+ }
521
+ const targetTokens = maxContextTokens ?? getDefaultContextBudget(config.provider);
522
+ let nextTokens = currentTokens;
523
+ setMessages(prev => {
524
+ const compacted = compactMessagesForUi(prev, systemPrompt, targetTokens, createId, true);
525
+ nextTokens = compacted.estimatedTokens;
526
+ return compacted.messages;
527
+ });
528
+ setCurrentTokens(nextTokens);
529
+ return;
530
+ }
531
+
532
+ if (result.shouldAddToHistory === true) {
533
+ addInputToHistory(value.trim());
534
+
535
+ const userMessage: Message = {
536
+ id: createId(),
537
+ role: "user",
538
+ content: result.content,
539
+ displayContent: value,
540
+ };
541
+
542
+ setMessages((prev: Message[]) => [...prev, userMessage]);
543
+ setIsProcessing(true);
544
+ const localStartTime = Date.now();
545
+ setProcessingStartTime(localStartTime);
546
+ setCurrentTokens(0);
547
+ lastPromptTokensRef.current = 0;
548
+ shouldAutoScroll.current = true;
549
+
550
+ const conversationId = createId();
551
+ const conversationSteps: ConversationStep[] = [];
552
+ let totalTokens = { prompt: 0, completion: 0, total: 0 };
553
+ let stepCount = 0;
554
+ let totalChars = 0;
555
+ for (const m of messages) {
556
+ if (m.role === 'assistant') {
557
+ totalChars += m.content.length;
558
+ if (m.thinkingContent) totalChars += m.thinkingContent.length;
559
+ } else if (m.role === 'tool') {
560
+ totalChars += m.content.length;
561
+ }
562
+ }
563
+
564
+ const estimateTokens = () => Math.ceil(totalChars / 4);
565
+ setCurrentTokens(estimateTokens());
566
+ const config = readConfig();
567
+ const abortController = new AbortController();
568
+ abortControllerRef.current = abortController;
569
+ let abortNotified = false;
570
+ const notifyAbort = () => {
571
+ if (abortNotified) return;
572
+ abortNotified = true;
573
+ setMessages((prev: Message[]) => {
574
+ const newMessages = [...prev];
575
+ newMessages.push({
576
+ id: createId(),
577
+ role: "tool",
578
+ success: false,
579
+ content: "Request interrupted by user. \n↪ What should Mosaic do instead?"
580
+ });
581
+ return newMessages;
582
+ });
583
+ };
584
+
585
+ conversationSteps.push({
586
+ type: 'user',
587
+ content: result.content,
588
+ timestamp: Date.now()
589
+ });
590
+
427
591
  let responseDuration: number | null = null;
428
- let responseBlendWord: string | null = null;
592
+ let responseBlendWord: string | undefined = undefined;
429
593
 
430
594
  try {
431
595
  const providerStatus = await Agent.ensureProviderReady();
432
596
  if (!providerStatus.ready) {
433
- setMessages((prev: Message[]) => {
434
- const newMessages = [...prev];
435
- newMessages.push({
436
- id: createId(),
437
- role: "assistant",
438
- content: `Ollama error: ${providerStatus.error || 'Could not start Ollama. Make sure Ollama is installed.'}`,
439
- isError: true
440
- });
441
- return newMessages;
442
- });
443
- setIsProcessing(false);
444
- return;
445
- }
446
-
447
- const agent = new Agent();
597
+ setMessages((prev: Message[]) => {
598
+ const newMessages = [...prev];
599
+ newMessages.push({
600
+ id: createId(),
601
+ role: "assistant",
602
+ content: `Ollama error: ${providerStatus.error || 'Could not start Ollama. Make sure Ollama is installed.'}`,
603
+ isError: true
604
+ });
605
+ return newMessages;
606
+ });
607
+ setIsProcessing(false);
608
+ return;
609
+ }
610
+
611
+ const agent = new Agent();
448
612
  const conversationHistory = buildConversationHistory([...messages, userMessage], imagesSupported);
449
- let assistantChunk = '';
450
- let thinkingChunk = '';
451
- const pendingToolCalls = new Map<string, { toolName: string; args: Record<string, unknown>; messageId?: string }>();
452
- let assistantMessageId: string | null = null;
453
- let streamHadError = false;
454
- titleExtractedRef.current = false;
455
-
456
- for await (const event of agent.streamMessages(conversationHistory, { abortSignal: abortController.signal })) {
457
- if (event.type === 'reasoning-delta') {
458
- thinkingChunk += event.content;
459
- totalChars += event.content.length;
460
- setCurrentTokens(estimateTokens());
461
-
462
- if (assistantMessageId === null) {
463
- assistantMessageId = createId();
464
- }
465
-
466
- const currentMessageId = assistantMessageId;
467
- setMessages((prev: Message[]) => {
468
- const newMessages = [...prev];
469
- const messageIndex = newMessages.findIndex(m => m.id === currentMessageId);
470
-
471
- if (messageIndex === -1) {
472
- newMessages.push({ id: currentMessageId, role: "assistant", content: '', thinkingContent: thinkingChunk });
473
- } else {
474
- newMessages[messageIndex] = {
475
- ...newMessages[messageIndex]!,
476
- thinkingContent: thinkingChunk
477
- };
478
- }
479
- return newMessages;
480
- });
481
- } else if (event.type === 'text-delta') {
482
- assistantChunk += event.content;
483
- totalChars += event.content.length;
484
- setCurrentTokens(estimateTokens());
485
-
486
- const { title, cleanContent, isPending, noTitle } = extractTitle(assistantChunk, titleExtractedRef.current);
487
-
488
- if (title) {
489
- titleExtractedRef.current = true;
490
- currentTitleRef.current = title;
491
- setCurrentTitle(title);
492
- setTerminalTitle(title);
493
- } else if (noTitle) {
494
- titleExtractedRef.current = true;
495
- }
496
-
497
- if (isPending) continue;
498
-
499
- if (assistantMessageId === null) {
500
- assistantMessageId = createId();
501
- }
502
-
503
- const displayContent = cleanContent;
504
- const currentMessageId = assistantMessageId;
505
- setMessages((prev: Message[]) => {
506
- const newMessages = [...prev];
507
- const messageIndex = newMessages.findIndex(m => m.id === currentMessageId);
508
-
509
- if (messageIndex === -1) {
510
- newMessages.push({ id: currentMessageId, role: "assistant", content: displayContent, thinkingContent: thinkingChunk });
511
- } else {
512
- newMessages[messageIndex] = {
513
- ...newMessages[messageIndex]!,
514
- content: displayContent
515
- };
516
- }
517
- return newMessages;
518
- });
519
- } else if (event.type === 'step-start') {
520
- stepCount++;
521
- } else if (event.type === 'tool-call-end') {
522
- totalChars += JSON.stringify(event.args).length;
523
- setCurrentTokens(estimateTokens());
524
-
525
- const needsApproval = event.toolName === 'write' || event.toolName === 'edit' || event.toolName === 'bash';
526
- const isExploreTool = event.toolName === 'explore';
527
- const showRunning = event.toolName === 'bash';
528
- let runningMessageId: string | undefined;
529
-
530
- if (isExploreTool) {
531
- setExploreAbortController(abortController);
532
- exploreToolsRef.current = [];
533
- const purpose = (event.args.purpose as string) || 'exploring...';
534
- explorePurposeRef.current = purpose;
535
- }
536
-
537
- if (!needsApproval) {
538
- runningMessageId = createId();
539
- const { name: toolDisplayName, info: toolInfo } = parseToolHeader(event.toolName, event.args);
540
- const runningContent = toolInfo ? `${toolDisplayName} (${toolInfo})` : toolDisplayName;
541
-
542
- if (isExploreTool) {
543
- exploreMessageIdRef.current = runningMessageId;
544
- }
545
-
546
- setMessages((prev: Message[]) => {
547
- const newMessages = [...prev];
548
- newMessages.push({
549
- id: runningMessageId!,
550
- role: "tool",
551
- content: runningContent,
552
- toolName: event.toolName,
553
- toolArgs: event.args,
554
- success: true,
555
- isRunning: showRunning || isExploreTool,
556
- runningStartTime: (showRunning || isExploreTool) ? Date.now() : undefined
557
- });
558
- return newMessages;
559
- });
560
- }
561
-
562
- pendingToolCalls.set(event.toolCallId, {
563
- toolName: event.toolName,
564
- args: event.args,
565
- messageId: runningMessageId
566
- });
567
-
568
- } else if (event.type === 'tool-result') {
569
- const pending = pendingToolCalls.get(event.toolCallId);
570
- const toolName = pending?.toolName ?? event.toolName;
571
- const toolArgs = pending?.args ?? {};
572
- const runningMessageId = pending?.messageId;
573
- pendingToolCalls.delete(event.toolCallId);
574
-
575
- if (toolName === 'explore') {
576
- exploreMessageIdRef.current = null;
577
- setExploreAbortController(null);
578
- }
579
-
580
- const { content: toolContent, success } = formatToolMessage(
581
- toolName,
582
- toolArgs,
583
- event.result,
584
- { maxLines: DEFAULT_MAX_TOOL_LINES }
585
- );
586
-
587
- const toolResultStr = typeof event.result === 'string' ? event.result : JSON.stringify(event.result);
588
- totalChars += toolResultStr.length;
589
- setCurrentTokens(estimateTokens());
590
-
591
- if (assistantChunk.trim()) {
592
- conversationSteps.push({
593
- type: 'assistant',
594
- content: assistantChunk,
595
- timestamp: Date.now()
596
- });
597
- }
598
-
599
- conversationSteps.push({
600
- type: 'tool',
601
- content: toolContent,
602
- toolName,
603
- toolArgs,
604
- toolResult: event.result,
605
- timestamp: Date.now()
606
- });
607
-
608
- setMessages((prev: Message[]) => {
609
- const newMessages = [...prev];
610
-
611
- let runningIndex = -1;
612
- if (runningMessageId) {
613
- runningIndex = newMessages.findIndex(m => m.id === runningMessageId);
614
- } else if (toolName === 'bash' || toolName === 'explore') {
615
- runningIndex = newMessages.findIndex(m => m.toolName === toolName && m.isRunning === true);
616
- }
617
-
618
- if (runningIndex !== -1) {
619
- newMessages[runningIndex] = {
620
- ...newMessages[runningIndex]!,
621
- content: toolContent,
622
- toolArgs: toolArgs,
623
- toolResult: event.result,
624
- success,
625
- isRunning: false,
626
- runningStartTime: undefined,
627
- timestamp: Date.now()
628
- };
629
- return newMessages;
630
- }
631
-
632
- newMessages.push({
633
- id: createId(),
634
- role: "tool",
635
- content: toolContent,
636
- toolName,
637
- toolArgs: toolArgs,
638
- toolResult: event.result,
639
- success: success,
640
- timestamp: Date.now()
641
- });
642
- return newMessages;
643
- });
644
-
645
- assistantChunk = '';
646
- assistantMessageId = null;
647
- } else if (event.type === 'error') {
648
- if (abortController.signal.aborted) {
649
- notifyAbort();
650
- streamHadError = true;
651
- break;
652
- }
653
- if (assistantChunk.trim()) {
654
- conversationSteps.push({
655
- type: 'assistant',
656
- content: assistantChunk,
657
- timestamp: Date.now()
658
- });
659
- }
660
-
661
- const errorContent = formatErrorMessage('API', event.error);
662
- conversationSteps.push({
663
- type: 'assistant',
664
- content: errorContent,
665
- timestamp: Date.now()
666
- });
667
-
668
- setMessages((prev: Message[]) => {
669
- const newMessages = [...prev];
670
- newMessages.push({
671
- id: createId(),
672
- role: 'assistant',
673
- content: errorContent,
674
- isError: true,
675
- });
676
- return newMessages;
677
- });
678
-
679
- assistantChunk = '';
680
- assistantMessageId = null;
681
- streamHadError = true;
682
- break;
683
- } else if (event.type === 'finish') {
684
- if (event.usage && event.usage.totalTokens > 0) {
685
- totalTokens = {
686
- prompt: event.usage.promptTokens,
687
- completion: event.usage.completionTokens,
688
- total: event.usage.totalTokens
689
- };
690
- setCurrentTokens(event.usage.totalTokens);
691
- }
692
- }
693
- }
694
-
695
- if (abortController.signal.aborted) {
696
- notifyAbort();
697
- return;
698
- }
699
-
613
+ let assistantChunk = '';
614
+ let thinkingChunk = '';
615
+ const pendingToolCalls = new Map<string, { toolName: string; args: Record<string, unknown>; messageId?: string }>();
616
+ let assistantMessageId: string | null = null;
617
+ let streamHadError = false;
618
+ titleExtractedRef.current = false;
619
+
620
+ for await (const event of agent.streamMessages(conversationHistory, { abortSignal: abortController.signal })) {
621
+ if (event.type === 'reasoning-delta') {
622
+ thinkingChunk += event.content;
623
+ totalChars += event.content.length;
624
+ setCurrentTokens(estimateTokens());
625
+
626
+ if (assistantMessageId === null) {
627
+ assistantMessageId = createId();
628
+ }
629
+
630
+ const currentMessageId = assistantMessageId;
631
+ setMessages((prev: Message[]) => {
632
+ const newMessages = [...prev];
633
+ const messageIndex = newMessages.findIndex(m => m.id === currentMessageId);
634
+
635
+ if (messageIndex === -1) {
636
+ newMessages.push({ id: currentMessageId, role: "assistant", content: '', thinkingContent: thinkingChunk });
637
+ } else {
638
+ newMessages[messageIndex] = {
639
+ ...newMessages[messageIndex]!,
640
+ thinkingContent: thinkingChunk
641
+ };
642
+ }
643
+ return newMessages;
644
+ });
645
+ } else if (event.type === 'text-delta') {
646
+ assistantChunk += event.content;
647
+ totalChars += event.content.length;
648
+ setCurrentTokens(estimateTokens());
649
+
650
+ const { title, cleanContent, isPending, noTitle } = extractTitle(assistantChunk, titleExtractedRef.current);
651
+
652
+ if (title) {
653
+ titleExtractedRef.current = true;
654
+ currentTitleRef.current = title;
655
+ setCurrentTitle(title);
656
+ setTerminalTitle(title);
657
+ } else if (noTitle) {
658
+ titleExtractedRef.current = true;
659
+ }
660
+
661
+ if (isPending) continue;
662
+
663
+ if (assistantMessageId === null) {
664
+ assistantMessageId = createId();
665
+ }
666
+
667
+ const displayContent = cleanContent;
668
+ const currentMessageId = assistantMessageId;
669
+ setMessages((prev: Message[]) => {
670
+ const newMessages = [...prev];
671
+ const messageIndex = newMessages.findIndex(m => m.id === currentMessageId);
672
+
673
+ if (messageIndex === -1) {
674
+ newMessages.push({ id: currentMessageId, role: "assistant", content: displayContent, thinkingContent: thinkingChunk });
675
+ } else {
676
+ newMessages[messageIndex] = {
677
+ ...newMessages[messageIndex]!,
678
+ content: displayContent
679
+ };
680
+ }
681
+ return newMessages;
682
+ });
683
+ } else if (event.type === 'step-start') {
684
+ stepCount++;
685
+ } else if (event.type === 'tool-call-end') {
686
+ totalChars += JSON.stringify(event.args).length;
687
+ setCurrentTokens(estimateTokens());
688
+
689
+ const needsApproval = event.toolName === 'write' || event.toolName === 'edit' || event.toolName === 'bash';
690
+ const isExploreTool = event.toolName === 'explore';
691
+ const showRunning = event.toolName === 'bash';
692
+ let runningMessageId: string | undefined;
693
+
694
+ if (isExploreTool) {
695
+ setExploreAbortController(abortController);
696
+ exploreToolsRef.current = [];
697
+ const purpose = (event.args.purpose as string) || 'exploring...';
698
+ explorePurposeRef.current = purpose;
699
+ }
700
+
701
+ if (!needsApproval) {
702
+ runningMessageId = createId();
703
+ const { name: toolDisplayName, info: toolInfo } = parseToolHeader(event.toolName, event.args);
704
+ const runningContent = toolInfo ? `${toolDisplayName} (${toolInfo})` : toolDisplayName;
705
+
706
+ if (isExploreTool) {
707
+ exploreMessageIdRef.current = runningMessageId;
708
+ }
709
+
710
+ setMessages((prev: Message[]) => {
711
+ const newMessages = [...prev];
712
+ newMessages.push({
713
+ id: runningMessageId!,
714
+ role: "tool",
715
+ content: runningContent,
716
+ toolName: event.toolName,
717
+ toolArgs: event.args,
718
+ success: true,
719
+ isRunning: showRunning || isExploreTool,
720
+ runningStartTime: (showRunning || isExploreTool) ? Date.now() : undefined
721
+ });
722
+ return newMessages;
723
+ });
724
+ }
725
+
726
+ pendingToolCalls.set(event.toolCallId, {
727
+ toolName: event.toolName,
728
+ args: event.args,
729
+ messageId: runningMessageId
730
+ });
731
+
732
+ } else if (event.type === 'tool-result') {
733
+ const pending = pendingToolCalls.get(event.toolCallId);
734
+ const toolName = pending?.toolName ?? event.toolName;
735
+ const toolArgs = pending?.args ?? {};
736
+ const runningMessageId = pending?.messageId;
737
+ pendingToolCalls.delete(event.toolCallId);
738
+
739
+ if (toolName === 'explore') {
740
+ exploreMessageIdRef.current = null;
741
+ setExploreAbortController(null);
742
+ }
743
+
744
+ const { content: toolContent, success } = formatToolMessage(
745
+ toolName,
746
+ toolArgs,
747
+ event.result,
748
+ { maxLines: DEFAULT_MAX_TOOL_LINES }
749
+ );
750
+
751
+ const toolResultStr = typeof event.result === 'string' ? event.result : JSON.stringify(event.result);
752
+ totalChars += toolResultStr.length;
753
+ setCurrentTokens(estimateTokens());
754
+
755
+ if (assistantChunk.trim()) {
756
+ conversationSteps.push({
757
+ type: 'assistant',
758
+ content: assistantChunk,
759
+ timestamp: Date.now()
760
+ });
761
+ }
762
+
763
+ conversationSteps.push({
764
+ type: 'tool',
765
+ content: toolContent,
766
+ toolName,
767
+ toolArgs,
768
+ toolResult: event.result,
769
+ timestamp: Date.now()
770
+ });
771
+
772
+ setMessages((prev: Message[]) => {
773
+ const newMessages = [...prev];
774
+
775
+ let runningIndex = -1;
776
+ if (runningMessageId) {
777
+ runningIndex = newMessages.findIndex(m => m.id === runningMessageId);
778
+ } else if (toolName === 'bash' || toolName === 'explore') {
779
+ runningIndex = newMessages.findIndex(m => m.toolName === toolName && m.isRunning === true);
780
+ }
781
+
782
+ if (runningIndex !== -1) {
783
+ newMessages[runningIndex] = {
784
+ ...newMessages[runningIndex]!,
785
+ content: toolContent,
786
+ toolArgs: toolArgs,
787
+ toolResult: event.result,
788
+ success,
789
+ isRunning: false,
790
+ runningStartTime: undefined,
791
+ timestamp: Date.now()
792
+ };
793
+ return newMessages;
794
+ }
795
+
796
+ newMessages.push({
797
+ id: createId(),
798
+ role: "tool",
799
+ content: toolContent,
800
+ toolName,
801
+ toolArgs: toolArgs,
802
+ toolResult: event.result,
803
+ success: success,
804
+ timestamp: Date.now()
805
+ });
806
+ return newMessages;
807
+ });
808
+
809
+ assistantChunk = '';
810
+ assistantMessageId = null;
811
+ } else if (event.type === 'error') {
812
+ if (abortController.signal.aborted) {
813
+ notifyAbort();
814
+ streamHadError = true;
815
+ break;
816
+ }
817
+ if (assistantChunk.trim()) {
818
+ conversationSteps.push({
819
+ type: 'assistant',
820
+ content: assistantChunk,
821
+ timestamp: Date.now()
822
+ });
823
+ }
824
+
825
+ const errorContent = formatErrorMessage('API', event.error);
826
+ conversationSteps.push({
827
+ type: 'assistant',
828
+ content: errorContent,
829
+ timestamp: Date.now()
830
+ });
831
+
832
+ setMessages((prev: Message[]) => {
833
+ const newMessages = [...prev];
834
+ newMessages.push({
835
+ id: createId(),
836
+ role: 'assistant',
837
+ content: errorContent,
838
+ isError: true,
839
+ });
840
+ return newMessages;
841
+ });
842
+
843
+ assistantChunk = '';
844
+ assistantMessageId = null;
845
+ streamHadError = true;
846
+ break;
847
+ } else if (event.type === 'finish') {
848
+ if (event.usage && event.usage.totalTokens > 0) {
849
+ totalTokens = {
850
+ prompt: event.usage.promptTokens,
851
+ completion: event.usage.completionTokens,
852
+ total: event.usage.totalTokens
853
+ };
854
+ lastPromptTokensRef.current = event.usage.promptTokens;
855
+ setCurrentTokens(event.usage.totalTokens);
856
+ }
857
+ }
858
+ }
859
+
860
+ if (abortController.signal.aborted) {
861
+ notifyAbort();
862
+ return;
863
+ }
864
+
700
865
  if (!streamHadError && assistantChunk.trim()) {
701
866
  conversationSteps.push({
702
867
  type: 'assistant',
@@ -735,35 +900,35 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
735
900
  saveConversation(conversationData);
736
901
 
737
902
  } catch (error) {
738
- if (abortController.signal.aborted) {
739
- notifyAbort();
740
- return;
741
- }
742
- const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
743
- const errorContent = formatErrorMessage('Mosaic', errorMessage);
744
- setMessages((prev: Message[]) => {
745
- const newMessages = [...prev];
746
- if (newMessages[newMessages.length - 1]?.role === 'assistant' && newMessages[newMessages.length - 1]?.content === '') {
747
- newMessages[newMessages.length - 1] = {
748
- id: newMessages[newMessages.length - 1]!.id,
749
- role: "assistant",
750
- content: errorContent,
751
- isError: true
752
- };
753
- } else {
754
- newMessages.push({
755
- id: createId(),
756
- role: "assistant",
757
- content: errorContent,
758
- isError: true
759
- });
760
- }
761
- return newMessages;
762
- });
763
- } finally {
764
- if (abortControllerRef.current === abortController) {
765
- abortControllerRef.current = null;
766
- }
903
+ if (abortController.signal.aborted) {
904
+ notifyAbort();
905
+ return;
906
+ }
907
+ const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
908
+ const errorContent = formatErrorMessage('Mosaic', errorMessage);
909
+ setMessages((prev: Message[]) => {
910
+ const newMessages = [...prev];
911
+ if (newMessages[newMessages.length - 1]?.role === 'assistant' && newMessages[newMessages.length - 1]?.content === '') {
912
+ newMessages[newMessages.length - 1] = {
913
+ id: newMessages[newMessages.length - 1]!.id,
914
+ role: "assistant",
915
+ content: errorContent,
916
+ isError: true
917
+ };
918
+ } else {
919
+ newMessages.push({
920
+ id: createId(),
921
+ role: "assistant",
922
+ content: errorContent,
923
+ isError: true
924
+ });
925
+ }
926
+ return newMessages;
927
+ });
928
+ } finally {
929
+ if (abortControllerRef.current === abortController) {
930
+ abortControllerRef.current = null;
931
+ }
767
932
  const duration = responseDuration ?? (Date.now() - localStartTime);
768
933
  if (duration >= 60000) {
769
934
  const blendWord = responseBlendWord ?? BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
@@ -772,44 +937,42 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
772
937
  for (let i = newMessages.length - 1; i >= 0; i--) {
773
938
  if (newMessages[i]?.role === 'assistant') {
774
939
  newMessages[i] = { ...newMessages[i]!, responseDuration: duration, blendWord };
775
- break;
776
- }
777
- }
778
- return newMessages;
779
- });
780
- }
781
- setIsProcessing(false);
782
- setProcessingStartTime(null);
783
- }
784
-
785
- return;
786
- }
787
-
788
- const commandMessage: Message = {
789
- id: createId(),
790
- role: "slash",
791
- content: result.content,
792
- isError: !result.success
793
- };
794
-
795
- setMessages((prev: Message[]) => [...prev, commandMessage]);
796
-
797
- if (result.shouldAddToHistory !== false) {
798
- addInputToHistory(value.trim());
799
- }
800
-
801
- return;
802
- }
803
- }
804
-
805
- const composedContent = hasPastedContent
806
- ? `${meta!.pastedContent!}${value.trim() ? `\n\n${value}` : ''}`
807
- : value;
808
-
940
+ break;
941
+ }
942
+ }
943
+ return newMessages;
944
+ });
945
+ }
946
+ setIsProcessing(false);
947
+ setProcessingStartTime(null);
948
+ }
949
+
950
+ return;
951
+ }
952
+
953
+ const commandMessage: Message = {
954
+ id: createId(),
955
+ role: "slash",
956
+ content: result.content,
957
+ isError: !result.success
958
+ };
959
+
960
+ setMessages((prev: Message[]) => [...prev, commandMessage]);
961
+
962
+ if (result.shouldAddToHistory !== false) {
963
+ addInputToHistory(value.trim());
964
+ }
965
+
966
+ return;
967
+ }
968
+ }
969
+
970
+ const composedContent = hasPastedContent
971
+ ? `${meta!.pastedContent!}${value.trim() ? `\n\n${value}` : ''}`
972
+ : value;
973
+
809
974
  addInputToHistory(value.trim() || (hasPastedContent ? '[Pasted text]' : (hasImages ? '[Image]' : value)));
810
-
811
- saveState(messages);
812
-
975
+
813
976
  const imagesForMessage = imagesSupported ? pendingImages : [];
814
977
 
815
978
  const userMessage: Message = {
@@ -824,76 +987,89 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
824
987
  setPendingImages([]);
825
988
  }
826
989
 
827
- setMessages((prev: Message[]) => [...prev, userMessage]);
828
- setIsProcessing(true);
829
- const localStartTime = Date.now();
830
- setProcessingStartTime(localStartTime);
831
- setCurrentTokens(0);
832
- shouldAutoScroll.current = true;
833
-
834
- const conversationId = createId();
835
- const conversationSteps: ConversationStep[] = [];
836
- let totalTokens = { prompt: 0, completion: 0, total: 0 };
837
- let stepCount = 0;
838
- let totalChars = 0;
839
- for (const m of messages) {
840
- if (m.role === 'assistant') {
841
- totalChars += m.content.length;
842
- if (m.thinkingContent) totalChars += m.thinkingContent.length;
843
- } else if (m.role === 'tool') {
844
- totalChars += m.content.length;
845
- }
846
- }
847
-
848
- const estimateTokens = () => Math.ceil(totalChars / 4);
849
- setCurrentTokens(estimateTokens());
850
- const config = readConfig();
851
- const abortController = new AbortController();
852
- abortControllerRef.current = abortController;
853
- let abortNotified = false;
854
- const notifyAbort = () => {
855
- if (abortNotified) return;
856
- abortNotified = true;
857
- setMessages((prev: Message[]) => {
858
- const newMessages = [...prev];
859
- newMessages.push({
860
- id: createId(),
861
- role: "tool",
862
- success: false,
863
- content: "Generation aborted. \n↪ What should Mosaic do instead?"
864
- });
865
- return newMessages;
866
- });
867
- };
868
-
990
+ setMessages((prev: Message[]) => [...prev, userMessage]);
991
+ setIsProcessing(true);
992
+ const localStartTime = Date.now();
993
+ setProcessingStartTime(localStartTime);
994
+ setCurrentTokens(0);
995
+ lastPromptTokensRef.current = 0;
996
+ shouldAutoScroll.current = true;
997
+
998
+ const conversationId = createId();
999
+ const conversationSteps: ConversationStep[] = [];
1000
+ let totalTokens = { prompt: 0, completion: 0, total: 0 };
1001
+ let stepCount = 0;
1002
+ let totalChars = 0;
1003
+ for (const m of messages) {
1004
+ if (m.role === 'assistant') {
1005
+ totalChars += m.content.length;
1006
+ if (m.thinkingContent) totalChars += m.thinkingContent.length;
1007
+ } else if (m.role === 'tool') {
1008
+ totalChars += m.content.length;
1009
+ }
1010
+ }
1011
+
1012
+ const estimateTokens = () => Math.ceil(totalChars / 4);
1013
+ setCurrentTokens(estimateTokens());
1014
+ const config = readConfig();
1015
+ const resolveMaxContextTokens = async () => {
1016
+ if (config.maxContextTokens) return config.maxContextTokens;
1017
+ if (config.provider && config.model) {
1018
+ const resolved = await getModelsDevContextLimit(config.provider, config.model);
1019
+ if (typeof resolved === "number") return resolved;
1020
+ }
1021
+ return undefined;
1022
+ };
1023
+ const buildSystemPrompt = () => {
1024
+ const rawSystemPrompt = config.systemPrompt || DEFAULT_SYSTEM_PROMPT;
1025
+ return processSystemPrompt(rawSystemPrompt, true);
1026
+ };
1027
+ const abortController = new AbortController();
1028
+ abortControllerRef.current = abortController;
1029
+ let abortNotified = false;
1030
+ const notifyAbort = () => {
1031
+ if (abortNotified) return;
1032
+ abortNotified = true;
1033
+ setMessages((prev: Message[]) => {
1034
+ const newMessages = [...prev];
1035
+ newMessages.push({
1036
+ id: createId(),
1037
+ role: "tool",
1038
+ success: false,
1039
+ content: "Generation aborted. \n↪ What should Mosaic do instead?"
1040
+ });
1041
+ return newMessages;
1042
+ });
1043
+ };
1044
+
869
1045
  conversationSteps.push({
870
1046
  type: 'user',
871
1047
  content: composedContent,
872
1048
  timestamp: Date.now(),
873
1049
  images: imagesForMessage.length > 0 ? imagesForMessage : undefined
874
1050
  });
875
-
1051
+
876
1052
  let responseDuration: number | null = null;
877
- let responseBlendWord: string | null = null;
1053
+ let responseBlendWord: string | undefined = undefined;
878
1054
 
879
1055
  try {
880
1056
  const providerStatus = await Agent.ensureProviderReady();
881
1057
  if (!providerStatus.ready) {
882
- setMessages((prev: Message[]) => {
883
- const newMessages = [...prev];
884
- newMessages.push({
885
- id: createId(),
886
- role: "assistant",
887
- content: `Ollama error: ${providerStatus.error || 'Could not start Ollama. Make sure Ollama is installed.'}`,
888
- isError: true
889
- });
890
- return newMessages;
891
- });
892
- setIsProcessing(false);
893
- return;
894
- }
895
-
896
- const agent = new Agent();
1058
+ setMessages((prev: Message[]) => {
1059
+ const newMessages = [...prev];
1060
+ newMessages.push({
1061
+ id: createId(),
1062
+ role: "assistant",
1063
+ content: `Ollama error: ${providerStatus.error || 'Could not start Ollama. Make sure Ollama is installed.'}`,
1064
+ isError: true
1065
+ });
1066
+ return newMessages;
1067
+ });
1068
+ setIsProcessing(false);
1069
+ return;
1070
+ }
1071
+
1072
+ const agent = new Agent();
897
1073
  const conversationHistory = buildConversationHistory([...messages, userMessage], imagesSupported);
898
1074
  let assistantChunk = '';
899
1075
  let thinkingChunk = '';
@@ -931,25 +1107,25 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
931
1107
  assistantChunk += event.content;
932
1108
  totalChars += event.content.length;
933
1109
  setCurrentTokens(estimateTokens());
934
-
935
- const { title, cleanContent, isPending, noTitle } = extractTitle(assistantChunk, titleExtractedRef.current);
936
-
937
- if (title) {
938
- titleExtractedRef.current = true;
939
- currentTitleRef.current = title;
940
- setCurrentTitle(title);
941
- setTerminalTitle(title);
942
- } else if (noTitle) {
943
- titleExtractedRef.current = true;
944
- }
945
-
946
- if (isPending) continue;
947
-
948
- if (assistantMessageId === null) {
949
- assistantMessageId = createId();
950
- }
951
-
952
- const displayContent = cleanContent;
1110
+
1111
+ const { title, cleanContent, isPending, noTitle } = extractTitle(assistantChunk, titleExtractedRef.current);
1112
+
1113
+ if (title) {
1114
+ titleExtractedRef.current = true;
1115
+ currentTitleRef.current = title;
1116
+ setCurrentTitle(title);
1117
+ setTerminalTitle(title);
1118
+ } else if (noTitle) {
1119
+ titleExtractedRef.current = true;
1120
+ }
1121
+
1122
+ if (isPending) continue;
1123
+
1124
+ if (assistantMessageId === null) {
1125
+ assistantMessageId = createId();
1126
+ }
1127
+
1128
+ const displayContent = cleanContent;
953
1129
  const currentMessageId = assistantMessageId;
954
1130
  setMessages((prev: Message[]) => {
955
1131
  const newMessages = [...prev];
@@ -966,122 +1142,122 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
966
1142
  }
967
1143
  return newMessages;
968
1144
  });
969
- } else if (event.type === 'step-start') {
970
- stepCount++;
971
- } else if (event.type === 'tool-call-end') {
972
- totalChars += JSON.stringify(event.args).length;
973
- setCurrentTokens(estimateTokens());
974
-
975
- const isExploreTool = event.toolName === 'explore';
976
- let runningMessageId: string | undefined;
977
-
978
- if (isExploreTool) {
979
- setExploreAbortController(abortController);
980
- exploreToolsRef.current = [];
981
- const purpose = (event.args.purpose as string) || 'exploring...';
982
- explorePurposeRef.current = purpose;
983
- runningMessageId = createId();
984
- exploreMessageIdRef.current = runningMessageId;
985
- const { name: toolDisplayName, info: toolInfo } = parseToolHeader(event.toolName, event.args);
986
- const runningContent = toolInfo ? `${toolDisplayName} (${toolInfo})` : toolDisplayName;
987
-
988
- setMessages((prev: Message[]) => {
989
- const newMessages = [...prev];
990
- newMessages.push({
991
- id: runningMessageId!,
992
- role: "tool",
993
- content: runningContent,
994
- toolName: event.toolName,
995
- toolArgs: event.args,
996
- success: true,
997
- isRunning: true,
998
- runningStartTime: Date.now()
999
- });
1000
- return newMessages;
1001
- });
1002
- }
1003
-
1004
- pendingToolCalls.set(event.toolCallId, {
1005
- toolName: event.toolName,
1006
- args: event.args,
1007
- messageId: runningMessageId
1008
- });
1009
-
1010
- } else if (event.type === 'tool-result') {
1011
- const pending = pendingToolCalls.get(event.toolCallId);
1012
- const toolName = pending?.toolName ?? event.toolName;
1013
- const toolArgs = pending?.args ?? {};
1014
- const runningMessageId = pending?.messageId;
1015
- pendingToolCalls.delete(event.toolCallId);
1016
-
1017
- if (toolName === 'explore') {
1018
- exploreMessageIdRef.current = null;
1019
- setExploreAbortController(null);
1020
- }
1021
-
1022
- const { content: toolContent, success } = formatToolMessage(
1023
- toolName,
1024
- toolArgs,
1025
- event.result,
1026
- { maxLines: DEFAULT_MAX_TOOL_LINES }
1027
- );
1028
-
1029
- const toolResultStr = typeof event.result === 'string' ? event.result : JSON.stringify(event.result);
1030
- totalChars += toolResultStr.length;
1031
- setCurrentTokens(estimateTokens());
1032
-
1033
- if (assistantChunk.trim()) {
1034
- conversationSteps.push({
1035
- type: 'assistant',
1036
- content: assistantChunk,
1037
- timestamp: Date.now()
1038
- });
1039
- }
1040
-
1041
- conversationSteps.push({
1042
- type: 'tool',
1043
- content: toolContent,
1044
- toolName,
1045
- toolArgs,
1046
- toolResult: event.result,
1047
- timestamp: Date.now()
1048
- });
1049
-
1050
- setMessages((prev: Message[]) => {
1051
- const newMessages = [...prev];
1052
-
1053
- let runningIndex = -1;
1054
- if (runningMessageId) {
1055
- runningIndex = newMessages.findIndex(m => m.id === runningMessageId);
1056
- } else if (toolName === 'bash' || toolName === 'explore') {
1057
- runningIndex = newMessages.findIndex(m => m.isRunning && m.toolName === toolName);
1058
- }
1059
-
1060
- if (runningIndex !== -1) {
1061
- newMessages[runningIndex] = {
1062
- ...newMessages[runningIndex]!,
1063
- content: toolContent,
1064
- toolArgs: toolArgs,
1065
- toolResult: event.result,
1066
- success,
1067
- isRunning: false,
1068
- runningStartTime: undefined
1069
- };
1070
- return newMessages;
1071
- }
1072
-
1073
- newMessages.push({
1074
- id: createId(),
1075
- role: "tool",
1076
- content: toolContent,
1077
- toolName,
1078
- toolArgs: toolArgs,
1079
- toolResult: event.result,
1080
- success: success
1081
- });
1082
- return newMessages;
1083
- });
1084
-
1145
+ } else if (event.type === 'step-start') {
1146
+ stepCount++;
1147
+ } else if (event.type === 'tool-call-end') {
1148
+ totalChars += JSON.stringify(event.args).length;
1149
+ setCurrentTokens(estimateTokens());
1150
+
1151
+ const isExploreTool = event.toolName === 'explore';
1152
+ let runningMessageId: string | undefined;
1153
+
1154
+ if (isExploreTool) {
1155
+ setExploreAbortController(abortController);
1156
+ exploreToolsRef.current = [];
1157
+ const purpose = (event.args.purpose as string) || 'exploring...';
1158
+ explorePurposeRef.current = purpose;
1159
+ runningMessageId = createId();
1160
+ exploreMessageIdRef.current = runningMessageId;
1161
+ const { name: toolDisplayName, info: toolInfo } = parseToolHeader(event.toolName, event.args);
1162
+ const runningContent = toolInfo ? `${toolDisplayName} (${toolInfo})` : toolDisplayName;
1163
+
1164
+ setMessages((prev: Message[]) => {
1165
+ const newMessages = [...prev];
1166
+ newMessages.push({
1167
+ id: runningMessageId!,
1168
+ role: "tool",
1169
+ content: runningContent,
1170
+ toolName: event.toolName,
1171
+ toolArgs: event.args,
1172
+ success: true,
1173
+ isRunning: true,
1174
+ runningStartTime: Date.now()
1175
+ });
1176
+ return newMessages;
1177
+ });
1178
+ }
1179
+
1180
+ pendingToolCalls.set(event.toolCallId, {
1181
+ toolName: event.toolName,
1182
+ args: event.args,
1183
+ messageId: runningMessageId
1184
+ });
1185
+
1186
+ } else if (event.type === 'tool-result') {
1187
+ const pending = pendingToolCalls.get(event.toolCallId);
1188
+ const toolName = pending?.toolName ?? event.toolName;
1189
+ const toolArgs = pending?.args ?? {};
1190
+ const runningMessageId = pending?.messageId;
1191
+ pendingToolCalls.delete(event.toolCallId);
1192
+
1193
+ if (toolName === 'explore') {
1194
+ exploreMessageIdRef.current = null;
1195
+ setExploreAbortController(null);
1196
+ }
1197
+
1198
+ const { content: toolContent, success } = formatToolMessage(
1199
+ toolName,
1200
+ toolArgs,
1201
+ event.result,
1202
+ { maxLines: DEFAULT_MAX_TOOL_LINES }
1203
+ );
1204
+
1205
+ const toolResultStr = typeof event.result === 'string' ? event.result : JSON.stringify(event.result);
1206
+ totalChars += toolResultStr.length;
1207
+ setCurrentTokens(estimateTokens());
1208
+
1209
+ if (assistantChunk.trim()) {
1210
+ conversationSteps.push({
1211
+ type: 'assistant',
1212
+ content: assistantChunk,
1213
+ timestamp: Date.now()
1214
+ });
1215
+ }
1216
+
1217
+ conversationSteps.push({
1218
+ type: 'tool',
1219
+ content: toolContent,
1220
+ toolName,
1221
+ toolArgs,
1222
+ toolResult: event.result,
1223
+ timestamp: Date.now()
1224
+ });
1225
+
1226
+ setMessages((prev: Message[]) => {
1227
+ const newMessages = [...prev];
1228
+
1229
+ let runningIndex = -1;
1230
+ if (runningMessageId) {
1231
+ runningIndex = newMessages.findIndex(m => m.id === runningMessageId);
1232
+ } else if (toolName === 'bash' || toolName === 'explore') {
1233
+ runningIndex = newMessages.findIndex(m => m.isRunning && m.toolName === toolName);
1234
+ }
1235
+
1236
+ if (runningIndex !== -1) {
1237
+ newMessages[runningIndex] = {
1238
+ ...newMessages[runningIndex]!,
1239
+ content: toolContent,
1240
+ toolArgs: toolArgs,
1241
+ toolResult: event.result,
1242
+ success,
1243
+ isRunning: false,
1244
+ runningStartTime: undefined
1245
+ };
1246
+ return newMessages;
1247
+ }
1248
+
1249
+ newMessages.push({
1250
+ id: createId(),
1251
+ role: "tool",
1252
+ content: toolContent,
1253
+ toolName,
1254
+ toolArgs: toolArgs,
1255
+ toolResult: event.result,
1256
+ success: success
1257
+ });
1258
+ return newMessages;
1259
+ });
1260
+
1085
1261
  assistantChunk = '';
1086
1262
  thinkingChunk = '';
1087
1263
  assistantMessageId = null;
@@ -1091,54 +1267,55 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1091
1267
  streamHadError = true;
1092
1268
  break;
1093
1269
  }
1094
- if (assistantChunk.trim()) {
1095
- conversationSteps.push({
1096
- type: 'assistant',
1097
- content: assistantChunk,
1098
- timestamp: Date.now()
1099
- });
1100
- }
1101
-
1102
- const errorContent = formatErrorMessage('API', event.error);
1103
- conversationSteps.push({
1104
- type: 'assistant',
1105
- content: errorContent,
1106
- timestamp: Date.now()
1107
- });
1108
-
1109
- setMessages((prev: Message[]) => {
1110
- const newMessages = [...prev];
1111
- newMessages.push({
1112
- id: createId(),
1113
- role: 'assistant',
1114
- content: errorContent,
1115
- isError: true,
1116
- });
1117
- return newMessages;
1118
- });
1119
-
1270
+ if (assistantChunk.trim()) {
1271
+ conversationSteps.push({
1272
+ type: 'assistant',
1273
+ content: assistantChunk,
1274
+ timestamp: Date.now()
1275
+ });
1276
+ }
1277
+
1278
+ const errorContent = formatErrorMessage('API', event.error);
1279
+ conversationSteps.push({
1280
+ type: 'assistant',
1281
+ content: errorContent,
1282
+ timestamp: Date.now()
1283
+ });
1284
+
1285
+ setMessages((prev: Message[]) => {
1286
+ const newMessages = [...prev];
1287
+ newMessages.push({
1288
+ id: createId(),
1289
+ role: 'assistant',
1290
+ content: errorContent,
1291
+ isError: true,
1292
+ });
1293
+ return newMessages;
1294
+ });
1295
+
1120
1296
  assistantChunk = '';
1121
1297
  thinkingChunk = '';
1122
1298
  assistantMessageId = null;
1123
1299
  streamHadError = true;
1124
1300
  break;
1125
- } else if (event.type === 'finish') {
1126
- if (event.usage && event.usage.totalTokens > 0) {
1127
- totalTokens = {
1128
- prompt: event.usage.promptTokens,
1129
- completion: event.usage.completionTokens,
1130
- total: event.usage.totalTokens
1131
- };
1132
- setCurrentTokens(event.usage.totalTokens);
1133
- }
1134
- }
1135
- }
1136
-
1137
- if (abortController.signal.aborted) {
1138
- notifyAbort();
1139
- return;
1140
- }
1141
-
1301
+ } else if (event.type === 'finish') {
1302
+ if (event.usage && event.usage.totalTokens > 0) {
1303
+ totalTokens = {
1304
+ prompt: event.usage.promptTokens,
1305
+ completion: event.usage.completionTokens,
1306
+ total: event.usage.totalTokens
1307
+ };
1308
+ lastPromptTokensRef.current = event.usage.promptTokens;
1309
+ setCurrentTokens(event.usage.totalTokens);
1310
+ }
1311
+ }
1312
+ }
1313
+
1314
+ if (abortController.signal.aborted) {
1315
+ notifyAbort();
1316
+ return;
1317
+ }
1318
+
1142
1319
  if (!streamHadError && assistantChunk.trim()) {
1143
1320
  conversationSteps.push({
1144
1321
  type: 'assistant',
@@ -1175,37 +1352,52 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1175
1352
  };
1176
1353
 
1177
1354
  saveConversation(conversationData);
1178
-
1179
- } catch (error) {
1180
- if (abortController.signal.aborted) {
1181
- notifyAbort();
1182
- return;
1183
- }
1184
- const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
1185
- const errorContent = formatErrorMessage('Mosaic', errorMessage);
1186
- setMessages((prev: Message[]) => {
1187
- const newMessages = [...prev];
1188
- if (newMessages[newMessages.length - 1]?.role === 'assistant' && newMessages[newMessages.length - 1]?.content === '') {
1189
- newMessages[newMessages.length - 1] = {
1190
- id: newMessages[newMessages.length - 1]!.id,
1191
- role: "assistant",
1192
- content: errorContent,
1193
- isError: true
1194
- };
1195
- } else {
1196
- newMessages.push({
1197
- id: createId(),
1198
- role: "assistant",
1199
- content: errorContent,
1200
- isError: true
1201
- });
1202
- }
1203
- return newMessages;
1204
- });
1205
- } finally {
1206
- if (abortControllerRef.current === abortController) {
1207
- abortControllerRef.current = null;
1208
- }
1355
+ const resolvedMax = await resolveMaxContextTokens();
1356
+ const maxContextTokens = resolvedMax ?? getDefaultContextBudget(config.provider);
1357
+ if (!abortController.signal.aborted) {
1358
+ const realPromptTokens = lastPromptTokensRef.current;
1359
+ const systemPrompt = buildSystemPrompt();
1360
+ setMessages(prev => {
1361
+ const usedTokens = realPromptTokens > 0
1362
+ ? realPromptTokens
1363
+ : estimateTotalTokens(prev, systemPrompt);
1364
+ if (!shouldAutoCompact(usedTokens, maxContextTokens)) return prev;
1365
+ const compacted = compactMessagesForUi(prev, systemPrompt, maxContextTokens, createId, true);
1366
+ setCurrentTokens(compacted.estimatedTokens);
1367
+ return compacted.messages;
1368
+ });
1369
+ }
1370
+
1371
+ } catch (error) {
1372
+ if (abortController.signal.aborted) {
1373
+ notifyAbort();
1374
+ return;
1375
+ }
1376
+ const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
1377
+ const errorContent = formatErrorMessage('Mosaic', errorMessage);
1378
+ setMessages((prev: Message[]) => {
1379
+ const newMessages = [...prev];
1380
+ if (newMessages[newMessages.length - 1]?.role === 'assistant' && newMessages[newMessages.length - 1]?.content === '') {
1381
+ newMessages[newMessages.length - 1] = {
1382
+ id: newMessages[newMessages.length - 1]!.id,
1383
+ role: "assistant",
1384
+ content: errorContent,
1385
+ isError: true
1386
+ };
1387
+ } else {
1388
+ newMessages.push({
1389
+ id: createId(),
1390
+ role: "assistant",
1391
+ content: errorContent,
1392
+ isError: true
1393
+ });
1394
+ }
1395
+ return newMessages;
1396
+ });
1397
+ } finally {
1398
+ if (abortControllerRef.current === abortController) {
1399
+ abortControllerRef.current = null;
1400
+ }
1209
1401
  const duration = responseDuration ?? (Date.now() - localStartTime);
1210
1402
  if (duration >= 60000) {
1211
1403
  const blendWord = responseBlendWord ?? BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
@@ -1214,42 +1406,42 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1214
1406
  for (let i = newMessages.length - 1; i >= 0; i--) {
1215
1407
  if (newMessages[i]?.role === 'assistant') {
1216
1408
  newMessages[i] = { ...newMessages[i]!, responseDuration: duration, blendWord };
1217
- break;
1218
- }
1219
- }
1220
- return newMessages;
1221
- });
1222
- }
1223
- setIsProcessing(false);
1224
- setProcessingStartTime(null);
1225
- }
1226
- };
1227
-
1228
- useEffect(() => {
1229
- if (initialMessage && !initialMessageProcessed.current && currentPage === "chat") {
1230
- initialMessageProcessed.current = true;
1231
- handleSubmit(initialMessage);
1232
- }
1233
- }, [initialMessage, currentPage, handleSubmit]);
1234
-
1235
- if (currentPage === "home") {
1236
- const handleHomeSubmit = (value: string, meta?: InputSubmitMeta) => {
1237
- const hasPastedContent = Boolean(meta?.isPaste && meta.pastedContent);
1238
- if (!value.trim() && !hasPastedContent) return;
1239
- setCurrentPage("chat");
1240
- handleSubmit(value, meta);
1241
- };
1242
-
1243
- return (
1244
- <HomePage
1245
- onSubmit={handleHomeSubmit}
1246
- pasteRequestId={pasteRequestId}
1247
- shortcutsOpen={shortcutsOpen}
1248
- />
1249
- );
1250
- }
1251
-
1252
- return (
1409
+ break;
1410
+ }
1411
+ }
1412
+ return newMessages;
1413
+ });
1414
+ }
1415
+ setIsProcessing(false);
1416
+ setProcessingStartTime(null);
1417
+ }
1418
+ };
1419
+
1420
+ useEffect(() => {
1421
+ if (initialMessage && !initialMessageProcessed.current && currentPage === "chat") {
1422
+ initialMessageProcessed.current = true;
1423
+ handleSubmit(initialMessage);
1424
+ }
1425
+ }, [initialMessage, currentPage, handleSubmit]);
1426
+
1427
+ if (currentPage === "home") {
1428
+ const handleHomeSubmit = (value: string, meta?: InputSubmitMeta) => {
1429
+ const hasPastedContent = Boolean(meta?.isPaste && meta.pastedContent);
1430
+ if (!value.trim() && !hasPastedContent) return;
1431
+ setCurrentPage("chat");
1432
+ handleSubmit(value, meta);
1433
+ };
1434
+
1435
+ return (
1436
+ <HomePage
1437
+ onSubmit={handleHomeSubmit}
1438
+ pasteRequestId={pasteRequestId}
1439
+ shortcutsOpen={shortcutsOpen}
1440
+ />
1441
+ );
1442
+ }
1443
+
1444
+ return (
1253
1445
  <ChatPage
1254
1446
  messages={messages}
1255
1447
  isProcessing={isProcessing}