@kirosnn/mosaic 0.71.0 → 0.74.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 (79) 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 +136 -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 +552 -339
  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 +156 -0
  32. package/src/mcp/cli/add.ts +185 -0
  33. package/src/mcp/cli/doctor.ts +74 -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 +234 -0
  41. package/src/mcp/index.ts +80 -0
  42. package/src/mcp/processManager.ts +304 -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/browser.ts +151 -0
  47. package/src/mcp/servers/navigation/index.ts +23 -0
  48. package/src/mcp/servers/navigation/tools.ts +263 -0
  49. package/src/mcp/servers/navigation/types.ts +17 -0
  50. package/src/mcp/servers/navigation/utils.ts +20 -0
  51. package/src/mcp/toolCatalog.ts +182 -0
  52. package/src/mcp/types.ts +116 -0
  53. package/src/utils/approvalBridge.ts +17 -5
  54. package/src/utils/commands/compact.ts +30 -0
  55. package/src/utils/commands/echo.ts +1 -1
  56. package/src/utils/commands/index.ts +4 -6
  57. package/src/utils/commands/new.ts +15 -0
  58. package/src/utils/commands/types.ts +3 -0
  59. package/src/utils/config.ts +3 -1
  60. package/src/utils/diffRendering.tsx +1 -3
  61. package/src/utils/exploreBridge.ts +10 -0
  62. package/src/utils/markdown.tsx +220 -122
  63. package/src/utils/models.ts +31 -9
  64. package/src/utils/questionBridge.ts +36 -1
  65. package/src/utils/tokenEstimator.ts +32 -0
  66. package/src/utils/toolFormatting.ts +317 -7
  67. package/src/web/app.tsx +72 -72
  68. package/src/web/components/HomePage.tsx +7 -7
  69. package/src/web/components/MessageItem.tsx +66 -35
  70. package/src/web/components/QuestionPanel.tsx +72 -12
  71. package/src/web/components/Sidebar.tsx +0 -2
  72. package/src/web/components/ThinkingIndicator.tsx +1 -0
  73. package/src/web/server.tsx +767 -683
  74. package/src/utils/commands/redo.ts +0 -74
  75. package/src/utils/commands/sessions.ts +0 -129
  76. package/src/utils/commands/undo.ts +0 -75
  77. package/src/utils/undoRedo.ts +0 -429
  78. package/src/utils/undoRedoBridge.ts +0 -45
  79. package/src/utils/undoRedoDb.ts +0 -338
@@ -1,5 +1,5 @@
1
- import { useState, useEffect, useRef } from "react";
2
- import type { ImagePart, TextPart, UserContent } from "ai";
1
+ import { useState, useEffect, useRef } from "react";
2
+ import type { ImagePart, TextPart, UserContent } from "ai";
3
3
  import { useKeyboard } from "@opentui/react";
4
4
  import { Agent } from "../agent";
5
5
  import { saveConversation, addInputToHistory, type ConversationHistory, type ConversationStep } from "../utils/history";
@@ -9,19 +9,20 @@ import { initializeCommands, isCommand, executeCommand } from '../utils/commands
9
9
  import type { InputSubmitMeta } from './CustomInput';
10
10
 
11
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";
17
- import { getCurrentQuestion, cancelQuestion } from "../utils/questionBridge";
18
- import { getCurrentApproval, cancelApproval } from "../utils/approvalBridge";
19
- import { BLEND_WORDS, type MainProps, type Message } from "./main/types";
20
- import { HomePage } from './main/HomePage';
21
- import { ChatPage } from './main/ChatPage';
22
- import type { ImageAttachment } from "../utils/images";
23
- import { subscribeImageCommand, setImageSupport } from "../utils/imageBridge";
24
- import { findModelsDevModelById, modelAcceptsImages } from "../utils/models";
12
+ import { subscribeApprovalAccepted } from "../utils/approvalBridge";
13
+ import { setExploreAbortController, setExploreToolCallback } from "../utils/exploreBridge";
14
+ import { getCurrentQuestion, cancelQuestion } from "../utils/questionBridge";
15
+ import { getCurrentApproval, cancelApproval } from "../utils/approvalBridge";
16
+ import { BLEND_WORDS, type MainProps, type Message } from "./main/types";
17
+ import { HomePage } from './main/HomePage';
18
+ import { ChatPage } from './main/ChatPage';
19
+ import type { ImageAttachment } from "../utils/images";
20
+ import { subscribeImageCommand, setImageSupport } from "../utils/imageBridge";
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">;
25
26
 
26
27
  function extractTitle(content: string, alreadyResolved: boolean): { title: string | null; cleanContent: string; isPending: boolean; noTitle: boolean } {
27
28
  const trimmed = content.trimStart();
@@ -49,6 +50,147 @@ function setTerminalTitle(title: string) {
49
50
  process.title = `⁘ ${title}`;
50
51
  }
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
+
52
194
  export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsOpen = false, commandsOpen = false, initialMessage }: MainProps) {
53
195
  const [currentPage, setCurrentPage] = useState<"home" | "chat">(initialMessage ? "chat" : "home");
54
196
  const [messages, setMessages] = useState<Message[]>([]);
@@ -58,10 +200,10 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
58
200
  const [scrollOffset, setScrollOffset] = useState(0);
59
201
  const [terminalHeight, setTerminalHeight] = useState(process.stdout.rows || 24);
60
202
  const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
61
- const [questionRequest, setQuestionRequest] = useState<QuestionRequest | null>(null);
62
- const [currentTitle, setCurrentTitle] = useState<string | null>(null);
63
- const [pendingImages, setPendingImages] = useState<ImageAttachment[]>([]);
64
- const [imagesSupported, setImagesSupported] = useState(false);
203
+ const [questionRequest, setQuestionRequest] = useState<QuestionRequest | null>(null);
204
+ const [currentTitle, setCurrentTitle] = useState<string | null>(null);
205
+ const [pendingImages, setPendingImages] = useState<ImageAttachment[]>([]);
206
+ const [imagesSupported, setImagesSupported] = useState(false);
65
207
  const currentTitleRef = useRef<string | null>(null);
66
208
  const titleExtractedRef = useRef(false);
67
209
  const shouldAutoScroll = useRef(true);
@@ -71,37 +213,37 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
71
213
  const commandsOpenRef = useRef(commandsOpen);
72
214
  const questionRequestRef = useRef<QuestionRequest | null>(questionRequest);
73
215
  const initialMessageProcessed = useRef(false);
216
+ const lastPromptTokensRef = useRef<number>(0);
74
217
  const exploreMessageIdRef = useRef<string | null>(null);
75
218
  const exploreToolsRef = useRef<Array<{ tool: string; info: string; success: boolean }>>([]);
76
219
  const explorePurposeRef = useRef<string>('');
77
220
 
78
221
  const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
79
222
 
80
- useEffect(() => {
81
- initializeCommands();
82
- initializeSession();
83
- }, []);
84
-
85
- useEffect(() => {
86
- const loadSupport = async () => {
87
- const config = readConfig();
88
- if (!config.model) {
89
- setImagesSupported(false);
90
- setImageSupport(false);
91
- return;
92
- }
93
- try {
94
- const result = await findModelsDevModelById(config.model);
95
- const supported = Boolean(result && result.model && modelAcceptsImages(result.model));
96
- setImagesSupported(supported);
97
- setImageSupport(supported);
98
- } catch {
99
- setImagesSupported(false);
100
- setImageSupport(false);
101
- }
102
- };
103
- loadSupport();
104
- }, []);
223
+ useEffect(() => {
224
+ initializeCommands();
225
+ }, []);
226
+
227
+ useEffect(() => {
228
+ const loadSupport = async () => {
229
+ const config = readConfig();
230
+ if (!config.model) {
231
+ setImagesSupported(false);
232
+ setImageSupport(false);
233
+ return;
234
+ }
235
+ try {
236
+ const result = await findModelsDevModelById(config.model);
237
+ const supported = Boolean(result && result.model && modelAcceptsImages(result.model));
238
+ setImagesSupported(supported);
239
+ setImageSupport(supported);
240
+ } catch {
241
+ setImagesSupported(false);
242
+ setImageSupport(false);
243
+ }
244
+ };
245
+ loadSupport();
246
+ }, []);
105
247
 
106
248
  useEffect(() => {
107
249
  let lastExploreTokens = 0;
@@ -139,41 +281,31 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
139
281
  };
140
282
  }, []);
141
283
 
142
- useEffect(() => {
143
- return subscribeUndoRedo((state, action) => {
144
- if (state) {
145
- setMessages(state.messages);
146
- resetFileChanges();
147
- }
148
- });
149
- }, []);
150
-
151
- useEffect(() => {
152
- return subscribeImageCommand((event) => {
153
- if (event.type === "clear") {
154
- setPendingImages([]);
155
- return;
156
- }
157
- if (event.type === "remove") {
158
- setPendingImages((prev) => prev.filter((img) => img.id !== event.id));
159
- return;
160
- }
161
- if (!imagesSupported) return;
162
- setPendingImages((prev) => [...prev, event.image]);
163
- });
164
- }, [imagesSupported]);
165
-
166
- useEffect(() => {
167
- if (!imagesSupported) {
168
- setPendingImages([]);
169
- }
170
- }, [imagesSupported]);
284
+ useEffect(() => {
285
+ return subscribeImageCommand((event) => {
286
+ if (event.type === "clear") {
287
+ setPendingImages([]);
288
+ return;
289
+ }
290
+ if (event.type === "remove") {
291
+ setPendingImages((prev) => prev.filter((img) => img.id !== event.id));
292
+ return;
293
+ }
294
+ if (!imagesSupported) return;
295
+ setPendingImages((prev) => [...prev, event.image]);
296
+ });
297
+ }, [imagesSupported]);
298
+
299
+ useEffect(() => {
300
+ if (!imagesSupported) {
301
+ setPendingImages([]);
302
+ }
303
+ }, [imagesSupported]);
171
304
 
172
305
  useEffect(() => {
173
306
  const handleResize = () => {
174
307
  const newWidth = process.stdout.columns || 80;
175
308
  const newHeight = process.stdout.rows || 24;
176
- const oldWidth = terminalWidth;
177
309
  const oldHeight = terminalHeight;
178
310
 
179
311
  setTerminalWidth(newWidth);
@@ -199,13 +331,27 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
199
331
  useEffect(() => {
200
332
  return subscribeApprovalAccepted((accepted) => {
201
333
  const isBashTool = accepted.toolName === 'bash';
334
+ const isMcpTool = accepted.toolName.startsWith('mcp__');
202
335
 
203
- if (isBashTool) {
336
+ if (isBashTool || isMcpTool) {
204
337
  const { name: toolDisplayName, info: toolInfo } = parseToolHeader(accepted.toolName, accepted.args);
205
338
  const runningContent = toolInfo ? `${toolDisplayName} (${toolInfo})` : toolDisplayName;
206
339
 
207
340
  setMessages((prev: Message[]) => {
208
341
  const newMessages = [...prev];
342
+
343
+ if (isMcpTool) {
344
+ const existingIdx = newMessages.findIndex(m => m.isRunning && m.toolName === accepted.toolName);
345
+ if (existingIdx !== -1) {
346
+ newMessages[existingIdx] = {
347
+ ...newMessages[existingIdx]!,
348
+ content: runningContent,
349
+ runningStartTime: Date.now()
350
+ };
351
+ return newMessages;
352
+ }
353
+ }
354
+
209
355
  newMessages.push({
210
356
  id: createId(),
211
357
  role: "tool",
@@ -308,67 +454,98 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
308
454
  }
309
455
  }, [copyRequestId, onCopy, messages]);
310
456
 
311
- useKeyboard((key) => {
312
- if ((key.name === 'c' && key.ctrl) || key.sequence === '\x03') {
313
- if (getCurrentQuestion()) {
314
- cancelQuestion();
315
- }
316
- if (getCurrentApproval()) {
317
- cancelApproval();
318
- }
319
- abortControllerRef.current?.abort();
320
- return;
321
- }
322
-
323
- if (key.name === 'escape') {
324
- if (getCurrentQuestion()) {
325
- cancelQuestion();
326
- }
457
+ useKeyboard((key) => {
458
+ if ((key.name === 'c' && key.ctrl) || key.sequence === '\x03') {
459
+ if (getCurrentQuestion()) {
460
+ cancelQuestion();
461
+ }
462
+ if (getCurrentApproval()) {
463
+ cancelApproval();
464
+ }
465
+ abortControllerRef.current?.abort();
466
+ return;
467
+ }
468
+
469
+ if (key.name === 'escape') {
470
+ if (getCurrentQuestion()) {
471
+ cancelQuestion();
472
+ }
327
473
  if (getCurrentApproval()) {
328
474
  cancelApproval();
329
475
  }
330
476
  abortControllerRef.current?.abort();
331
477
  return;
332
- }
333
- });
334
-
335
- const buildUserContent = (text: string, images?: ImageAttachment[]): UserContent => {
336
- if (!images || images.length === 0) return text;
337
- const parts: Array<TextPart | ImagePart> = [];
338
- parts.push({ type: "text", text });
339
- for (const img of images) {
340
- parts.push({ type: "image", image: img.data, mimeType: img.mimeType });
341
- }
342
- return parts;
343
- };
344
-
345
- const buildConversationHistory = (base: Message[], includeImages: boolean) => {
346
- return base
347
- .filter((m): m is Message & { role: "user" | "assistant" } => m.role === "user" || m.role === "assistant")
348
- .map((m) => {
349
- if (m.role === "user") {
350
- const content = includeImages ? buildUserContent(m.content, m.images) : m.content;
351
- return { role: "user" as const, content };
352
- }
353
- return { role: "assistant" as const, content: m.content };
354
- });
355
- };
356
-
357
- const handleSubmit = async (value: string, meta?: InputSubmitMeta) => {
358
- if (isProcessing) return;
359
-
360
- const hasPastedContent = Boolean(meta?.isPaste && meta.pastedContent);
361
- const hasImages = imagesSupported && pendingImages.length > 0;
362
- if (!value.trim() && !hasPastedContent && !hasImages) return;
478
+ }
479
+ });
480
+
481
+ const buildUserContent = (text: string, images?: ImageAttachment[]): UserContent => {
482
+ if (!images || images.length === 0) return text;
483
+ const parts: Array<TextPart | ImagePart> = [];
484
+ parts.push({ type: "text", text });
485
+ for (const img of images) {
486
+ parts.push({ type: "image", image: img.data, mimeType: img.mimeType });
487
+ }
488
+ return parts;
489
+ };
490
+
491
+ const buildConversationHistory = (base: Message[], includeImages: boolean) => {
492
+ return base
493
+ .filter((m): m is Message & { role: "user" | "assistant" } => m.role === "user" || m.role === "assistant")
494
+ .map((m) => {
495
+ if (m.role === "user") {
496
+ const content = includeImages ? buildUserContent(m.content, m.images) : m.content;
497
+ return { role: "user" as const, content };
498
+ }
499
+ return { role: "assistant" as const, content: m.content };
500
+ });
501
+ };
502
+
503
+ const handleSubmit = async (value: string, meta?: InputSubmitMeta) => {
504
+ if (isProcessing) return;
505
+
506
+ const hasPastedContent = Boolean(meta?.isPaste && meta.pastedContent);
507
+ const hasImages = imagesSupported && pendingImages.length > 0;
508
+ if (!value.trim() && !hasPastedContent && !hasImages) return;
363
509
 
364
510
  if (isCommand(value)) {
365
511
  const result = await executeCommand(value);
366
512
  if (result) {
513
+ if (result.shouldClearMessages === true) {
514
+ const commandMessage: Message = {
515
+ id: createId(),
516
+ role: "slash",
517
+ content: result.content,
518
+ isError: !result.success
519
+ };
520
+ setMessages([commandMessage]);
521
+ return;
522
+ }
523
+
524
+ if (result.shouldCompactMessages === true) {
525
+ const config = readConfig();
526
+ const rawSystemPrompt = config.systemPrompt || DEFAULT_SYSTEM_PROMPT;
527
+ const systemPrompt = processSystemPrompt(rawSystemPrompt, true);
528
+ let maxContextTokens = result.compactMaxTokens ?? config.maxContextTokens;
529
+ if (!maxContextTokens && config.provider && config.model) {
530
+ const resolved = await getModelsDevContextLimit(config.provider, config.model);
531
+ if (typeof resolved === "number") {
532
+ maxContextTokens = resolved;
533
+ }
534
+ }
535
+ const targetTokens = maxContextTokens ?? getDefaultContextBudget(config.provider);
536
+ let nextTokens = currentTokens;
537
+ setMessages(prev => {
538
+ const compacted = compactMessagesForUi(prev, systemPrompt, targetTokens, createId, true);
539
+ nextTokens = compacted.estimatedTokens;
540
+ return compacted.messages;
541
+ });
542
+ setCurrentTokens(nextTokens);
543
+ return;
544
+ }
545
+
367
546
  if (result.shouldAddToHistory === true) {
368
547
  addInputToHistory(value.trim());
369
548
 
370
- saveState(messages);
371
-
372
549
  const userMessage: Message = {
373
550
  id: createId(),
374
551
  role: "user",
@@ -381,6 +558,7 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
381
558
  const localStartTime = Date.now();
382
559
  setProcessingStartTime(localStartTime);
383
560
  setCurrentTokens(0);
561
+ lastPromptTokensRef.current = 0;
384
562
  shouldAutoScroll.current = true;
385
563
 
386
564
  const conversationId = createId();
@@ -424,12 +602,12 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
424
602
  timestamp: Date.now()
425
603
  });
426
604
 
427
- let responseDuration: number | null = null;
428
- let responseBlendWord: string | null = null;
429
-
430
- try {
431
- const providerStatus = await Agent.ensureProviderReady();
432
- if (!providerStatus.ready) {
605
+ let responseDuration: number | null = null;
606
+ let responseBlendWord: string | undefined = undefined;
607
+
608
+ try {
609
+ const providerStatus = await Agent.ensureProviderReady();
610
+ if (!providerStatus.ready) {
433
611
  setMessages((prev: Message[]) => {
434
612
  const newMessages = [...prev];
435
613
  newMessages.push({
@@ -445,7 +623,7 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
445
623
  }
446
624
 
447
625
  const agent = new Agent();
448
- const conversationHistory = buildConversationHistory([...messages, userMessage], imagesSupported);
626
+ const conversationHistory = buildConversationHistory([...messages, userMessage], imagesSupported);
449
627
  let assistantChunk = '';
450
628
  let thinkingChunk = '';
451
629
  const pendingToolCalls = new Map<string, { toolName: string; args: Record<string, unknown>; messageId?: string }>();
@@ -524,7 +702,8 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
524
702
 
525
703
  const needsApproval = event.toolName === 'write' || event.toolName === 'edit' || event.toolName === 'bash';
526
704
  const isExploreTool = event.toolName === 'explore';
527
- const showRunning = event.toolName === 'bash';
705
+ const isMcpTool = event.toolName.startsWith('mcp__');
706
+ const showRunning = event.toolName === 'bash' || isMcpTool;
528
707
  let runningMessageId: string | undefined;
529
708
 
530
709
  if (isExploreTool) {
@@ -611,8 +790,8 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
611
790
  let runningIndex = -1;
612
791
  if (runningMessageId) {
613
792
  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);
793
+ } else if (toolName === 'bash' || toolName === 'explore' || toolName.startsWith('mcp__')) {
794
+ runningIndex = newMessages.findIndex(m => m.isRunning && m.toolName === toolName);
616
795
  }
617
796
 
618
797
  if (runningIndex !== -1) {
@@ -687,6 +866,7 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
687
866
  completion: event.usage.completionTokens,
688
867
  total: event.usage.totalTokens
689
868
  };
869
+ lastPromptTokensRef.current = event.usage.promptTokens;
690
870
  setCurrentTokens(event.usage.totalTokens);
691
871
  }
692
872
  }
@@ -697,44 +877,44 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
697
877
  return;
698
878
  }
699
879
 
700
- if (!streamHadError && assistantChunk.trim()) {
701
- conversationSteps.push({
702
- type: 'assistant',
703
- content: assistantChunk,
704
- timestamp: Date.now()
705
- });
706
- }
707
-
708
- responseDuration = Date.now() - localStartTime;
709
- if (responseDuration >= 60000) {
710
- responseBlendWord = BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
711
- for (let i = conversationSteps.length - 1; i >= 0; i--) {
712
- if (conversationSteps[i]?.type === 'assistant') {
713
- conversationSteps[i] = {
714
- ...conversationSteps[i]!,
715
- responseDuration,
716
- blendWord: responseBlendWord
717
- };
718
- break;
719
- }
720
- }
721
- }
722
-
723
- const conversationData: ConversationHistory = {
724
- id: conversationId,
725
- timestamp: Date.now(),
726
- steps: conversationSteps,
727
- totalSteps: stepCount,
728
- title: currentTitleRef.current ?? currentTitle ?? null,
729
- workspace: process.cwd(),
730
- totalTokens: totalTokens.total > 0 ? totalTokens : undefined,
731
- model: config.model,
732
- provider: config.provider
733
- };
734
-
735
- saveConversation(conversationData);
736
-
737
- } catch (error) {
880
+ if (!streamHadError && assistantChunk.trim()) {
881
+ conversationSteps.push({
882
+ type: 'assistant',
883
+ content: assistantChunk,
884
+ timestamp: Date.now()
885
+ });
886
+ }
887
+
888
+ responseDuration = Date.now() - localStartTime;
889
+ if (responseDuration >= 60000) {
890
+ responseBlendWord = BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
891
+ for (let i = conversationSteps.length - 1; i >= 0; i--) {
892
+ if (conversationSteps[i]?.type === 'assistant') {
893
+ conversationSteps[i] = {
894
+ ...conversationSteps[i]!,
895
+ responseDuration,
896
+ blendWord: responseBlendWord
897
+ };
898
+ break;
899
+ }
900
+ }
901
+ }
902
+
903
+ const conversationData: ConversationHistory = {
904
+ id: conversationId,
905
+ timestamp: Date.now(),
906
+ steps: conversationSteps,
907
+ totalSteps: stepCount,
908
+ title: currentTitleRef.current ?? currentTitle ?? null,
909
+ workspace: process.cwd(),
910
+ totalTokens: totalTokens.total > 0 ? totalTokens : undefined,
911
+ model: config.model,
912
+ provider: config.provider
913
+ };
914
+
915
+ saveConversation(conversationData);
916
+
917
+ } catch (error) {
738
918
  if (abortController.signal.aborted) {
739
919
  notifyAbort();
740
920
  return;
@@ -764,14 +944,14 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
764
944
  if (abortControllerRef.current === abortController) {
765
945
  abortControllerRef.current = null;
766
946
  }
767
- const duration = responseDuration ?? (Date.now() - localStartTime);
768
- if (duration >= 60000) {
769
- const blendWord = responseBlendWord ?? BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
770
- setMessages((prev: Message[]) => {
771
- const newMessages = [...prev];
772
- for (let i = newMessages.length - 1; i >= 0; i--) {
773
- if (newMessages[i]?.role === 'assistant') {
774
- newMessages[i] = { ...newMessages[i]!, responseDuration: duration, blendWord };
947
+ const duration = responseDuration ?? (Date.now() - localStartTime);
948
+ if (duration >= 60000) {
949
+ const blendWord = responseBlendWord ?? BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
950
+ setMessages((prev: Message[]) => {
951
+ const newMessages = [...prev];
952
+ for (let i = newMessages.length - 1; i >= 0; i--) {
953
+ if (newMessages[i]?.role === 'assistant') {
954
+ newMessages[i] = { ...newMessages[i]!, responseDuration: duration, blendWord };
775
955
  break;
776
956
  }
777
957
  }
@@ -806,29 +986,28 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
806
986
  ? `${meta!.pastedContent!}${value.trim() ? `\n\n${value}` : ''}`
807
987
  : value;
808
988
 
809
- addInputToHistory(value.trim() || (hasPastedContent ? '[Pasted text]' : (hasImages ? '[Image]' : value)));
810
-
811
- saveState(messages);
812
-
813
- const imagesForMessage = imagesSupported ? pendingImages : [];
814
-
815
- const userMessage: Message = {
816
- id: createId(),
817
- role: "user",
818
- content: composedContent,
819
- displayContent: meta?.isPaste ? '[Pasted text]' : undefined,
820
- images: imagesForMessage.length > 0 ? imagesForMessage : undefined,
821
- };
822
-
823
- if (imagesForMessage.length > 0) {
824
- setPendingImages([]);
825
- }
826
-
989
+ addInputToHistory(value.trim() || (hasPastedContent ? '[Pasted text]' : (hasImages ? '[Image]' : value)));
990
+
991
+ const imagesForMessage = imagesSupported ? pendingImages : [];
992
+
993
+ const userMessage: Message = {
994
+ id: createId(),
995
+ role: "user",
996
+ content: composedContent,
997
+ displayContent: meta?.isPaste ? '[Pasted text]' : undefined,
998
+ images: imagesForMessage.length > 0 ? imagesForMessage : undefined,
999
+ };
1000
+
1001
+ if (imagesForMessage.length > 0) {
1002
+ setPendingImages([]);
1003
+ }
1004
+
827
1005
  setMessages((prev: Message[]) => [...prev, userMessage]);
828
1006
  setIsProcessing(true);
829
1007
  const localStartTime = Date.now();
830
1008
  setProcessingStartTime(localStartTime);
831
1009
  setCurrentTokens(0);
1010
+ lastPromptTokensRef.current = 0;
832
1011
  shouldAutoScroll.current = true;
833
1012
 
834
1013
  const conversationId = createId();
@@ -848,6 +1027,18 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
848
1027
  const estimateTokens = () => Math.ceil(totalChars / 4);
849
1028
  setCurrentTokens(estimateTokens());
850
1029
  const config = readConfig();
1030
+ const resolveMaxContextTokens = async () => {
1031
+ if (config.maxContextTokens) return config.maxContextTokens;
1032
+ if (config.provider && config.model) {
1033
+ const resolved = await getModelsDevContextLimit(config.provider, config.model);
1034
+ if (typeof resolved === "number") return resolved;
1035
+ }
1036
+ return undefined;
1037
+ };
1038
+ const buildSystemPrompt = () => {
1039
+ const rawSystemPrompt = config.systemPrompt || DEFAULT_SYSTEM_PROMPT;
1040
+ return processSystemPrompt(rawSystemPrompt, true);
1041
+ };
851
1042
  const abortController = new AbortController();
852
1043
  abortControllerRef.current = abortController;
853
1044
  let abortNotified = false;
@@ -866,19 +1057,19 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
866
1057
  });
867
1058
  };
868
1059
 
869
- conversationSteps.push({
870
- type: 'user',
871
- content: composedContent,
872
- timestamp: Date.now(),
873
- images: imagesForMessage.length > 0 ? imagesForMessage : undefined
874
- });
875
-
876
- let responseDuration: number | null = null;
877
- let responseBlendWord: string | null = null;
878
-
879
- try {
880
- const providerStatus = await Agent.ensureProviderReady();
881
- if (!providerStatus.ready) {
1060
+ conversationSteps.push({
1061
+ type: 'user',
1062
+ content: composedContent,
1063
+ timestamp: Date.now(),
1064
+ images: imagesForMessage.length > 0 ? imagesForMessage : undefined
1065
+ });
1066
+
1067
+ let responseDuration: number | null = null;
1068
+ let responseBlendWord: string | undefined = undefined;
1069
+
1070
+ try {
1071
+ const providerStatus = await Agent.ensureProviderReady();
1072
+ if (!providerStatus.ready) {
882
1073
  setMessages((prev: Message[]) => {
883
1074
  const newMessages = [...prev];
884
1075
  newMessages.push({
@@ -894,43 +1085,43 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
894
1085
  }
895
1086
 
896
1087
  const agent = new Agent();
897
- const conversationHistory = buildConversationHistory([...messages, userMessage], imagesSupported);
898
- let assistantChunk = '';
899
- let thinkingChunk = '';
900
- const pendingToolCalls = new Map<string, { toolName: string; args: Record<string, unknown>; messageId?: string }>();
901
- let assistantMessageId: string | null = null;
902
- let streamHadError = false;
903
- titleExtractedRef.current = false;
904
-
905
- for await (const event of agent.streamMessages(conversationHistory, { abortSignal: abortController.signal })) {
906
- if (event.type === 'reasoning-delta') {
907
- thinkingChunk += event.content;
908
- totalChars += event.content.length;
909
- setCurrentTokens(estimateTokens());
910
-
911
- if (assistantMessageId === null) {
912
- assistantMessageId = createId();
913
- }
914
-
915
- const currentMessageId = assistantMessageId;
916
- setMessages((prev: Message[]) => {
917
- const newMessages = [...prev];
918
- const messageIndex = newMessages.findIndex(m => m.id === currentMessageId);
919
-
920
- if (messageIndex === -1) {
921
- newMessages.push({ id: currentMessageId, role: "assistant", content: '', thinkingContent: thinkingChunk });
922
- } else {
923
- newMessages[messageIndex] = {
924
- ...newMessages[messageIndex]!,
925
- thinkingContent: thinkingChunk
926
- };
927
- }
928
- return newMessages;
929
- });
930
- } else if (event.type === 'text-delta') {
931
- assistantChunk += event.content;
932
- totalChars += event.content.length;
933
- setCurrentTokens(estimateTokens());
1088
+ const conversationHistory = buildConversationHistory([...messages, userMessage], imagesSupported);
1089
+ let assistantChunk = '';
1090
+ let thinkingChunk = '';
1091
+ const pendingToolCalls = new Map<string, { toolName: string; args: Record<string, unknown>; messageId?: string }>();
1092
+ let assistantMessageId: string | null = null;
1093
+ let streamHadError = false;
1094
+ titleExtractedRef.current = false;
1095
+
1096
+ for await (const event of agent.streamMessages(conversationHistory, { abortSignal: abortController.signal })) {
1097
+ if (event.type === 'reasoning-delta') {
1098
+ thinkingChunk += event.content;
1099
+ totalChars += event.content.length;
1100
+ setCurrentTokens(estimateTokens());
1101
+
1102
+ if (assistantMessageId === null) {
1103
+ assistantMessageId = createId();
1104
+ }
1105
+
1106
+ const currentMessageId = assistantMessageId;
1107
+ setMessages((prev: Message[]) => {
1108
+ const newMessages = [...prev];
1109
+ const messageIndex = newMessages.findIndex(m => m.id === currentMessageId);
1110
+
1111
+ if (messageIndex === -1) {
1112
+ newMessages.push({ id: currentMessageId, role: "assistant", content: '', thinkingContent: thinkingChunk });
1113
+ } else {
1114
+ newMessages[messageIndex] = {
1115
+ ...newMessages[messageIndex]!,
1116
+ thinkingContent: thinkingChunk
1117
+ };
1118
+ }
1119
+ return newMessages;
1120
+ });
1121
+ } else if (event.type === 'text-delta') {
1122
+ assistantChunk += event.content;
1123
+ totalChars += event.content.length;
1124
+ setCurrentTokens(estimateTokens());
934
1125
 
935
1126
  const { title, cleanContent, isPending, noTitle } = extractTitle(assistantChunk, titleExtractedRef.current);
936
1127
 
@@ -950,22 +1141,22 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
950
1141
  }
951
1142
 
952
1143
  const displayContent = cleanContent;
953
- const currentMessageId = assistantMessageId;
954
- setMessages((prev: Message[]) => {
955
- const newMessages = [...prev];
956
- const messageIndex = newMessages.findIndex(m => m.id === currentMessageId);
957
-
958
- if (messageIndex === -1) {
959
- newMessages.push({ id: currentMessageId, role: "assistant", content: displayContent, thinkingContent: thinkingChunk });
960
- } else {
961
- newMessages[messageIndex] = {
962
- ...newMessages[messageIndex]!,
963
- content: displayContent,
964
- thinkingContent: thinkingChunk
965
- };
966
- }
967
- return newMessages;
968
- });
1144
+ const currentMessageId = assistantMessageId;
1145
+ setMessages((prev: Message[]) => {
1146
+ const newMessages = [...prev];
1147
+ const messageIndex = newMessages.findIndex(m => m.id === currentMessageId);
1148
+
1149
+ if (messageIndex === -1) {
1150
+ newMessages.push({ id: currentMessageId, role: "assistant", content: displayContent, thinkingContent: thinkingChunk });
1151
+ } else {
1152
+ newMessages[messageIndex] = {
1153
+ ...newMessages[messageIndex]!,
1154
+ content: displayContent,
1155
+ thinkingContent: thinkingChunk
1156
+ };
1157
+ }
1158
+ return newMessages;
1159
+ });
969
1160
  } else if (event.type === 'step-start') {
970
1161
  stepCount++;
971
1162
  } else if (event.type === 'tool-call-end') {
@@ -973,6 +1164,7 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
973
1164
  setCurrentTokens(estimateTokens());
974
1165
 
975
1166
  const isExploreTool = event.toolName === 'explore';
1167
+ const isMcpTool = event.toolName.startsWith('mcp__');
976
1168
  let runningMessageId: string | undefined;
977
1169
 
978
1170
  if (isExploreTool) {
@@ -980,8 +1172,13 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
980
1172
  exploreToolsRef.current = [];
981
1173
  const purpose = (event.args.purpose as string) || 'exploring...';
982
1174
  explorePurposeRef.current = purpose;
1175
+ }
1176
+
1177
+ if (isExploreTool || isMcpTool) {
983
1178
  runningMessageId = createId();
984
- exploreMessageIdRef.current = runningMessageId;
1179
+ if (isExploreTool) {
1180
+ exploreMessageIdRef.current = runningMessageId;
1181
+ }
985
1182
  const { name: toolDisplayName, info: toolInfo } = parseToolHeader(event.toolName, event.args);
986
1183
  const runningContent = toolInfo ? `${toolDisplayName} (${toolInfo})` : toolDisplayName;
987
1184
 
@@ -1053,7 +1250,7 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1053
1250
  let runningIndex = -1;
1054
1251
  if (runningMessageId) {
1055
1252
  runningIndex = newMessages.findIndex(m => m.id === runningMessageId);
1056
- } else if (toolName === 'bash' || toolName === 'explore') {
1253
+ } else if (toolName === 'bash' || toolName === 'explore' || toolName.startsWith('mcp__')) {
1057
1254
  runningIndex = newMessages.findIndex(m => m.isRunning && m.toolName === toolName);
1058
1255
  }
1059
1256
 
@@ -1082,15 +1279,15 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1082
1279
  return newMessages;
1083
1280
  });
1084
1281
 
1085
- assistantChunk = '';
1086
- thinkingChunk = '';
1087
- assistantMessageId = null;
1088
- } else if (event.type === 'error') {
1089
- if (abortController.signal.aborted) {
1090
- notifyAbort();
1091
- streamHadError = true;
1092
- break;
1093
- }
1282
+ assistantChunk = '';
1283
+ thinkingChunk = '';
1284
+ assistantMessageId = null;
1285
+ } else if (event.type === 'error') {
1286
+ if (abortController.signal.aborted) {
1287
+ notifyAbort();
1288
+ streamHadError = true;
1289
+ break;
1290
+ }
1094
1291
  if (assistantChunk.trim()) {
1095
1292
  conversationSteps.push({
1096
1293
  type: 'assistant',
@@ -1117,11 +1314,11 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1117
1314
  return newMessages;
1118
1315
  });
1119
1316
 
1120
- assistantChunk = '';
1121
- thinkingChunk = '';
1122
- assistantMessageId = null;
1123
- streamHadError = true;
1124
- break;
1317
+ assistantChunk = '';
1318
+ thinkingChunk = '';
1319
+ assistantMessageId = null;
1320
+ streamHadError = true;
1321
+ break;
1125
1322
  } else if (event.type === 'finish') {
1126
1323
  if (event.usage && event.usage.totalTokens > 0) {
1127
1324
  totalTokens = {
@@ -1129,6 +1326,7 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1129
1326
  completion: event.usage.completionTokens,
1130
1327
  total: event.usage.totalTokens
1131
1328
  };
1329
+ lastPromptTokensRef.current = event.usage.promptTokens;
1132
1330
  setCurrentTokens(event.usage.totalTokens);
1133
1331
  }
1134
1332
  }
@@ -1139,42 +1337,57 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1139
1337
  return;
1140
1338
  }
1141
1339
 
1142
- if (!streamHadError && assistantChunk.trim()) {
1143
- conversationSteps.push({
1144
- type: 'assistant',
1145
- content: assistantChunk,
1146
- timestamp: Date.now()
1147
- });
1148
- }
1149
-
1150
- responseDuration = Date.now() - localStartTime;
1151
- if (responseDuration >= 60000) {
1152
- responseBlendWord = BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
1153
- for (let i = conversationSteps.length - 1; i >= 0; i--) {
1154
- if (conversationSteps[i]?.type === 'assistant') {
1155
- conversationSteps[i] = {
1156
- ...conversationSteps[i]!,
1157
- responseDuration,
1158
- blendWord: responseBlendWord
1159
- };
1160
- break;
1161
- }
1162
- }
1163
- }
1164
-
1165
- const conversationData: ConversationHistory = {
1166
- id: conversationId,
1167
- timestamp: Date.now(),
1168
- steps: conversationSteps,
1169
- totalSteps: stepCount,
1170
- title: currentTitleRef.current ?? currentTitle ?? null,
1171
- workspace: process.cwd(),
1172
- totalTokens: totalTokens.total > 0 ? totalTokens : undefined,
1173
- model: config.model,
1174
- provider: config.provider
1175
- };
1176
-
1177
- saveConversation(conversationData);
1340
+ if (!streamHadError && assistantChunk.trim()) {
1341
+ conversationSteps.push({
1342
+ type: 'assistant',
1343
+ content: assistantChunk,
1344
+ timestamp: Date.now()
1345
+ });
1346
+ }
1347
+
1348
+ responseDuration = Date.now() - localStartTime;
1349
+ if (responseDuration >= 60000) {
1350
+ responseBlendWord = BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
1351
+ for (let i = conversationSteps.length - 1; i >= 0; i--) {
1352
+ if (conversationSteps[i]?.type === 'assistant') {
1353
+ conversationSteps[i] = {
1354
+ ...conversationSteps[i]!,
1355
+ responseDuration,
1356
+ blendWord: responseBlendWord
1357
+ };
1358
+ break;
1359
+ }
1360
+ }
1361
+ }
1362
+
1363
+ const conversationData: ConversationHistory = {
1364
+ id: conversationId,
1365
+ timestamp: Date.now(),
1366
+ steps: conversationSteps,
1367
+ totalSteps: stepCount,
1368
+ title: currentTitleRef.current ?? currentTitle ?? null,
1369
+ workspace: process.cwd(),
1370
+ totalTokens: totalTokens.total > 0 ? totalTokens : undefined,
1371
+ model: config.model,
1372
+ provider: config.provider
1373
+ };
1374
+
1375
+ saveConversation(conversationData);
1376
+ const resolvedMax = await resolveMaxContextTokens();
1377
+ const maxContextTokens = resolvedMax ?? getDefaultContextBudget(config.provider);
1378
+ if (!abortController.signal.aborted) {
1379
+ const realPromptTokens = lastPromptTokensRef.current;
1380
+ const systemPrompt = buildSystemPrompt();
1381
+ setMessages(prev => {
1382
+ const usedTokens = realPromptTokens > 0
1383
+ ? realPromptTokens
1384
+ : estimateTotalTokens(prev, systemPrompt);
1385
+ if (!shouldAutoCompact(usedTokens, maxContextTokens)) return prev;
1386
+ const compacted = compactMessagesForUi(prev, systemPrompt, maxContextTokens, createId, true);
1387
+ setCurrentTokens(compacted.estimatedTokens);
1388
+ return compacted.messages;
1389
+ });
1390
+ }
1178
1391
 
1179
1392
  } catch (error) {
1180
1393
  if (abortController.signal.aborted) {
@@ -1206,14 +1419,14 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1206
1419
  if (abortControllerRef.current === abortController) {
1207
1420
  abortControllerRef.current = null;
1208
1421
  }
1209
- const duration = responseDuration ?? (Date.now() - localStartTime);
1210
- if (duration >= 60000) {
1211
- const blendWord = responseBlendWord ?? BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
1212
- setMessages((prev: Message[]) => {
1213
- const newMessages = [...prev];
1214
- for (let i = newMessages.length - 1; i >= 0; i--) {
1215
- if (newMessages[i]?.role === 'assistant') {
1216
- newMessages[i] = { ...newMessages[i]!, responseDuration: duration, blendWord };
1422
+ const duration = responseDuration ?? (Date.now() - localStartTime);
1423
+ if (duration >= 60000) {
1424
+ const blendWord = responseBlendWord ?? BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
1425
+ setMessages((prev: Message[]) => {
1426
+ const newMessages = [...prev];
1427
+ for (let i = newMessages.length - 1; i >= 0; i--) {
1428
+ if (newMessages[i]?.role === 'assistant') {
1429
+ newMessages[i] = { ...newMessages[i]!, responseDuration: duration, blendWord };
1217
1430
  break;
1218
1431
  }
1219
1432
  }
@@ -1250,18 +1463,18 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1250
1463
  }
1251
1464
 
1252
1465
  return (
1253
- <ChatPage
1254
- messages={messages}
1255
- isProcessing={isProcessing}
1256
- processingStartTime={processingStartTime}
1257
- currentTokens={currentTokens}
1258
- scrollOffset={scrollOffset}
1259
- terminalHeight={terminalHeight}
1260
- terminalWidth={terminalWidth}
1261
- pasteRequestId={pasteRequestId}
1262
- shortcutsOpen={shortcutsOpen}
1263
- onSubmit={handleSubmit}
1264
- pendingImages={pendingImages}
1265
- />
1266
- );
1267
- }
1466
+ <ChatPage
1467
+ messages={messages}
1468
+ isProcessing={isProcessing}
1469
+ processingStartTime={processingStartTime}
1470
+ currentTokens={currentTokens}
1471
+ scrollOffset={scrollOffset}
1472
+ terminalHeight={terminalHeight}
1473
+ terminalWidth={terminalWidth}
1474
+ pasteRequestId={pasteRequestId}
1475
+ shortcutsOpen={shortcutsOpen}
1476
+ onSubmit={handleSubmit}
1477
+ pendingImages={pendingImages}
1478
+ />
1479
+ );
1480
+ }