@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,10 +1,22 @@
1
1
  import { TextAttributes } from "@opentui/core";
2
2
 
3
- export interface MarkdownSegment {
4
- type: 'text' | 'bold' | 'italic' | 'code' | 'heading' | 'listitem';
5
- content: string;
6
- level?: number;
7
- }
3
+ export interface MarkdownSegment {
4
+ type: 'text' | 'bold' | 'italic' | 'code' | 'heading' | 'listitem' | 'link';
5
+ content: string;
6
+ level?: number;
7
+ href?: string;
8
+ }
9
+
10
+ const linkSchemePattern = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
11
+
12
+ function normalizeLinkUri(href: string) {
13
+ const trimmed = href.trim();
14
+ if (!trimmed) return trimmed;
15
+ if (linkSchemePattern.test(trimmed)) return trimmed;
16
+ if (trimmed.startsWith('//')) return `https:${trimmed}`;
17
+ if (trimmed.startsWith('/') || trimmed.startsWith('#') || trimmed.startsWith('.') || trimmed.startsWith('?')) return trimmed;
18
+ return `https://${trimmed}`;
19
+ }
8
20
 
9
21
  function parseInline(text: string): MarkdownSegment[] {
10
22
  const segments: MarkdownSegment[] = [];
@@ -30,21 +42,36 @@ function parseInline(text: string): MarkdownSegment[] {
30
42
  }
31
43
  }
32
44
 
33
- if (text.substring(i, i + 2) === '**') {
34
- const j = text.indexOf('**', i + 2);
35
- if (j !== -1) {
36
- flushText();
37
- segments.push({ type: 'bold', content: text.substring(i + 2, j) });
38
- i = j + 2;
39
- continue;
40
- }
41
- }
42
-
43
- if (text[i] === '*' && text.substring(i, i + 2) !== '**') {
44
- const j = text.indexOf('*', i + 1);
45
- if (j !== -1) {
46
- flushText();
47
- segments.push({ type: 'italic', content: text.substring(i + 1, j) });
45
+ if (text.substring(i, i + 2) === '**') {
46
+ const j = text.indexOf('**', i + 2);
47
+ if (j !== -1) {
48
+ flushText();
49
+ segments.push({ type: 'bold', content: text.substring(i + 2, j) });
50
+ i = j + 2;
51
+ continue;
52
+ }
53
+ }
54
+
55
+ if (text[i] === '[') {
56
+ const labelEnd = text.indexOf(']', i + 1);
57
+ if (labelEnd !== -1 && text[labelEnd + 1] === '(') {
58
+ const urlEnd = text.indexOf(')', labelEnd + 2);
59
+ if (urlEnd !== -1) {
60
+ const label = text.substring(i + 1, labelEnd);
61
+ const href = text.substring(labelEnd + 2, urlEnd).trim();
62
+ flushText();
63
+ segments.push({ type: 'link', content: label, href });
64
+ i = urlEnd + 1;
65
+ continue;
66
+ }
67
+ }
68
+ }
69
+
70
+ if (text[i] === '*' && text.substring(i, i + 2) !== '**') {
71
+ const j = text.indexOf('*', i + 1);
72
+ if (j !== -1) {
73
+ flushText();
74
+ segments.push({ type: 'italic', content: text.substring(i + 1, j) });
48
75
  i = j + 1;
49
76
  continue;
50
77
  }
@@ -82,12 +109,19 @@ export function renderMarkdownSegment(segment: MarkdownSegment, key: number) {
82
109
  case 'italic':
83
110
  return <text key={key} fg="white" attributes={TextAttributes.DIM}>{segment.content}</text>;
84
111
 
85
- case 'code':
86
- return <text key={key} fg="#ffdd80">{`${segment.content}`}</text>;
87
-
88
- case 'heading':
89
- return <text key={key} fg="#ffca38" attributes={TextAttributes.BOLD}>{segment.content}</text>;
90
-
112
+ case 'code':
113
+ return <text key={key} fg="#ffdd80">{`${segment.content}`}</text>;
114
+
115
+ case 'heading':
116
+ return <text key={key} fg="#ffca38" attributes={TextAttributes.BOLD}>{segment.content}</text>;
117
+
118
+ case 'link':
119
+ return (
120
+ <text key={key} fg="#7fbfff" attributes={TextAttributes.UNDERLINE}>
121
+ <a href={normalizeLinkUri(segment.href || '')}>{segment.content}</a>
122
+ </text>
123
+ );
124
+
91
125
  case 'listitem':
92
126
  return (
93
127
  <box key={key} flexDirection="row">
@@ -121,112 +155,176 @@ export function parseMarkdownContent(content: string): ParsedMarkdownLine[] {
121
155
  return result;
122
156
  }
123
157
 
124
- export function wrapMarkdownText(text: string, maxWidth: number): { text: string; segments: MarkdownSegment[] }[] {
125
- if (!text || maxWidth <= 0) return [{ text: '', segments: [] }];
158
+ export function wrapMarkdownText(text: string, maxWidth: number): { text: string; segments: MarkdownSegment[] }[] {
159
+ if (!text || maxWidth <= 0) return [{ text: '', segments: [] }];
160
+
161
+ const segments = parseMarkdownLine(text);
162
+ const lines: { text: string; segments: MarkdownSegment[] }[] = [];
163
+ let currentLine = '';
164
+ let currentSegments: MarkdownSegment[] = [];
165
+
166
+ const splitSegment = (segment: MarkdownSegment): MarkdownSegment[] => {
167
+ if (segment.type === 'code') return [segment];
168
+ const parts = segment.content.match(/\s+|[^\s]+/g);
169
+ if (!parts) return [segment];
170
+ return parts.map(part => ({ ...segment, content: part }));
171
+ };
172
+
173
+ const pushLine = () => {
174
+ if (!currentLine) return;
175
+ lines.push({ text: currentLine, segments: currentSegments });
176
+ currentLine = '';
177
+ currentSegments = [];
178
+ };
179
+
180
+ const addPiece = (piece: MarkdownSegment) => {
181
+ let remaining = piece.content;
182
+ while (remaining.length > 0) {
183
+ if (!currentLine) {
184
+ if (remaining.trim() === '') {
185
+ return;
186
+ }
187
+ if (remaining.length <= maxWidth) {
188
+ currentLine = remaining;
189
+ currentSegments = [{ ...piece, content: remaining }];
190
+ return;
191
+ }
192
+ const chunk = remaining.slice(0, maxWidth);
193
+ lines.push({ text: chunk, segments: [{ ...piece, content: chunk }] });
194
+ remaining = remaining.slice(maxWidth);
195
+ continue;
196
+ }
197
+
198
+ if ((currentLine + remaining).length <= maxWidth) {
199
+ currentLine += remaining;
200
+ currentSegments.push({ ...piece, content: remaining });
201
+ return;
202
+ }
203
+
204
+ pushLine();
205
+ }
206
+ };
207
+
208
+ for (const segment of segments) {
209
+ const pieces = splitSegment(segment);
210
+ for (const piece of pieces) {
211
+ addPiece(piece);
212
+ }
213
+ }
214
+
215
+ if (currentLine) {
216
+ lines.push({ text: currentLine, segments: currentSegments });
217
+ }
218
+
219
+ return lines.length > 0 ? lines : [{ text: '', segments: [] }];
220
+ }
221
+
222
+ export interface WrappedMarkdownBlock {
223
+ type: 'line' | 'code';
224
+ wrappedLines?: { text: string; segments: MarkdownSegment[] }[];
225
+ codeLines?: string[];
226
+ language?: string;
227
+ }
126
228
 
127
- const segments = parseMarkdownLine(text);
128
- const lines: { text: string; segments: MarkdownSegment[] }[] = [];
129
- let currentLine = '';
130
- let currentSegments: MarkdownSegment[] = [];
229
+ function wrapCodeLine(line: string, maxWidth: number): string[] {
230
+ if (!line) return [''];
231
+ if (maxWidth <= 0) return [line];
232
+ if (line.length <= maxWidth) return [line];
131
233
 
132
- for (const segment of segments) {
133
- const content = segment.content;
134
- const fullText = content;
234
+ const chunks: string[] = [];
235
+ let i = 0;
236
+ while (i < line.length) {
237
+ chunks.push(line.slice(i, i + maxWidth));
238
+ i += maxWidth;
239
+ }
240
+ return chunks;
241
+ }
135
242
 
136
- if (!currentLine) {
137
- if (fullText.length <= maxWidth) {
138
- currentLine = fullText;
139
- currentSegments.push(segment);
140
- } else {
141
- let remaining = content;
142
- while (remaining) {
143
- if (remaining.length <= maxWidth) {
144
- currentLine = remaining;
145
- currentSegments.push({ ...segment, content: remaining });
146
- remaining = '';
147
- } else {
148
- const breakPoint = remaining.lastIndexOf(' ', maxWidth);
149
- if (breakPoint > 0) {
150
- const chunk = remaining.slice(0, breakPoint);
151
- currentLine = chunk;
152
- currentSegments.push({ ...segment, content: chunk });
153
- lines.push({ text: currentLine, segments: currentSegments });
154
- currentLine = '';
155
- currentSegments = [];
156
- remaining = remaining.slice(breakPoint + 1);
157
- } else {
158
- const chunk = remaining.slice(0, maxWidth);
159
- currentLine = chunk;
160
- currentSegments.push({ ...segment, content: chunk });
161
- lines.push({ text: currentLine, segments: currentSegments });
162
- currentLine = '';
163
- currentSegments = [];
164
- remaining = remaining.slice(maxWidth);
165
- }
166
- }
167
- }
168
- }
243
+ function reflowParagraphs(text: string): string {
244
+ const rawLines = text.split('\n');
245
+ const result: string[] = [];
246
+ let inCodeBlock = false;
247
+
248
+ for (let i = 0; i < rawLines.length; i++) {
249
+ const line = rawLines[i]!;
250
+
251
+ if (/^```/.test(line)) {
252
+ inCodeBlock = !inCodeBlock;
253
+ result.push(line);
254
+ continue;
255
+ }
256
+
257
+ if (inCodeBlock) {
258
+ result.push(line);
259
+ continue;
260
+ }
261
+
262
+ if (line.trim() === '') {
263
+ result.push(line);
264
+ continue;
265
+ }
266
+
267
+ if (/^#{1,6}\s/.test(line) || /^[-*+]\s/.test(line) || /^\d+\.\s/.test(line)) {
268
+ result.push(line);
269
+ continue;
270
+ }
271
+
272
+ const prev = result.length > 0 ? result[result.length - 1]! : '';
273
+ const prevIsText = prev.trim() !== '' &&
274
+ !/^```/.test(prev) &&
275
+ !/^#{1,6}\s/.test(prev) &&
276
+ !/^[-*+]\s/.test(prev) &&
277
+ !/^\d+\.\s/.test(prev);
278
+
279
+ if (prevIsText) {
280
+ result[result.length - 1] = prev + ' ' + line;
169
281
  } else {
170
- const needsSpace = !currentLine.endsWith(' ') && !fullText.startsWith(' ');
171
- const separator = needsSpace ? ' ' : '';
282
+ result.push(line);
283
+ }
284
+ }
285
+
286
+ return result.join('\n');
287
+ }
288
+
289
+ export function parseAndWrapMarkdown(text: string, maxWidth: number): WrappedMarkdownBlock[] {
290
+ const lines = reflowParagraphs(text).split('\n');
291
+ const blocks: WrappedMarkdownBlock[] = [];
292
+ let inCodeBlock = false;
293
+ let codeLines: string[] = [];
294
+ let language: string | undefined;
172
295
 
173
- if ((currentLine + separator + fullText).length <= maxWidth) {
174
- currentLine += separator + fullText;
175
- currentSegments.push(segment);
296
+ for (const line of lines) {
297
+ const fenceMatch = line.match(/^```([A-Za-z0-9_-]+)?\s*$/);
298
+ if (fenceMatch) {
299
+ if (!inCodeBlock) {
300
+ inCodeBlock = true;
301
+ language = fenceMatch[1];
302
+ codeLines = [];
176
303
  } else {
177
- lines.push({ text: currentLine, segments: currentSegments });
178
- currentLine = fullText;
179
- currentSegments = [segment];
180
-
181
- if (fullText.length > maxWidth) {
182
- let remaining = content;
183
- while (remaining) {
184
- if (remaining.length <= maxWidth) {
185
- currentLine = remaining;
186
- currentSegments = [{ ...segment, content: remaining }];
187
- remaining = '';
188
- } else {
189
- const breakPoint = remaining.lastIndexOf(' ', maxWidth);
190
- if (breakPoint > 0) {
191
- const chunk = remaining.slice(0, breakPoint);
192
- currentLine = chunk;
193
- currentSegments = [{ ...segment, content: chunk }];
194
- lines.push({ text: currentLine, segments: currentSegments });
195
- currentLine = '';
196
- currentSegments = [];
197
- remaining = remaining.slice(breakPoint + 1);
198
- } else {
199
- const chunk = remaining.slice(0, maxWidth);
200
- currentLine = chunk;
201
- currentSegments = [{ ...segment, content: chunk }];
202
- lines.push({ text: currentLine, segments: currentSegments });
203
- currentLine = '';
204
- currentSegments = [];
205
- remaining = remaining.slice(maxWidth);
206
- }
207
- }
208
- }
209
- }
304
+ const wrapped = codeLines.flatMap(codeLine => wrapCodeLine(codeLine, maxWidth));
305
+ blocks.push({ type: 'code', codeLines: wrapped, language });
306
+ inCodeBlock = false;
307
+ codeLines = [];
308
+ language = undefined;
210
309
  }
310
+ continue;
211
311
  }
212
- }
213
312
 
214
- if (currentLine) {
215
- lines.push({ text: currentLine, segments: currentSegments });
313
+ if (inCodeBlock) {
314
+ codeLines.push(line);
315
+ continue;
316
+ }
317
+
318
+ blocks.push({
319
+ type: 'line',
320
+ wrappedLines: wrapMarkdownText(line, maxWidth)
321
+ });
216
322
  }
217
323
 
218
- return lines.length > 0 ? lines : [{ text: '', segments: [] }];
219
- }
324
+ if (inCodeBlock) {
325
+ const wrapped = codeLines.flatMap(codeLine => wrapCodeLine(codeLine, maxWidth));
326
+ blocks.push({ type: 'code', codeLines: wrapped, language });
327
+ }
220
328
 
221
- export interface WrappedMarkdownBlock {
222
- type: 'line';
223
- wrappedLines?: { text: string; segments: MarkdownSegment[] }[];
329
+ return blocks;
224
330
  }
225
-
226
- export function parseAndWrapMarkdown(text: string, maxWidth: number): WrappedMarkdownBlock[] {
227
- const lines = text.split('\n');
228
- return lines.map((line) => ({
229
- type: 'line',
230
- wrappedLines: wrapMarkdownText(line, maxWidth)
231
- }));
232
- }
@@ -286,12 +286,34 @@ export async function findModelsDevModelById(
286
286
  return modelsDev.getModelById(modelId, options);
287
287
  }
288
288
 
289
- export async function searchModelsDev(query: ModelsDevSearchQuery, options: { refresh?: boolean } = {}): Promise<ModelsDevSearchResult[]> {
290
- return modelsDev.search(query, options);
291
- }
292
-
293
- export function modelAcceptsImages(model: ModelsDevModel): boolean {
294
- if (!model.modalities) return false;
295
- const { input } = model.modalities;
296
- return Array.isArray(input) && input.includes("image");
297
- }
289
+ export async function searchModelsDev(query: ModelsDevSearchQuery, options: { refresh?: boolean } = {}): Promise<ModelsDevSearchResult[]> {
290
+ return modelsDev.search(query, options);
291
+ }
292
+
293
+ export async function getModelsDevContextLimit(
294
+ providerId: string,
295
+ modelId: string,
296
+ options: { refresh?: boolean } = {}
297
+ ): Promise<number | null> {
298
+ try {
299
+ const direct = await getModelsDevModel(providerId, modelId, options);
300
+ const limit = direct?.limit?.context;
301
+ if (typeof limit === "number" && Number.isFinite(limit) && limit > 0) return limit;
302
+ } catch {
303
+ }
304
+
305
+ try {
306
+ const byId = await findModelsDevModelById(modelId, options);
307
+ const limit = byId?.model?.limit?.context;
308
+ if (typeof limit === "number" && Number.isFinite(limit) && limit > 0) return limit;
309
+ } catch {
310
+ }
311
+
312
+ return null;
313
+ }
314
+
315
+ export function modelAcceptsImages(model: ModelsDevModel): boolean {
316
+ if (!model.modalities) return false;
317
+ const { input } = model.modalities;
318
+ return Array.isArray(input) && input.includes("image");
319
+ }
@@ -1,12 +1,15 @@
1
1
  export interface QuestionOption {
2
2
  label: string;
3
3
  value?: string | null;
4
+ group?: string;
4
5
  }
5
6
 
6
7
  export interface QuestionRequest {
7
8
  id: string;
8
9
  prompt: string;
9
10
  options: QuestionOption[];
11
+ timeout?: number;
12
+ validation?: { pattern: string; message?: string };
10
13
  }
11
14
 
12
15
  export interface QuestionAnswer {
@@ -23,6 +26,7 @@ let currentRequest: QuestionRequest | null = null;
23
26
  let listeners = new Set<QuestionListener>();
24
27
  let pendingResolve: ((answer: QuestionAnswer) => void) | null = null;
25
28
  let pendingReject: ((reason?: any) => void) | null = null;
29
+ let pendingTimeoutId: ReturnType<typeof setTimeout> | null = null;
26
30
 
27
31
  function notify(): void {
28
32
  for (const listener of listeners) {
@@ -46,7 +50,12 @@ export function getCurrentQuestion(): QuestionRequest | null {
46
50
  return currentRequest;
47
51
  }
48
52
 
49
- export async function askQuestion(prompt: string, options: QuestionOption[]): Promise<QuestionAnswer> {
53
+ export async function askQuestion(
54
+ prompt: string,
55
+ options: QuestionOption[],
56
+ timeout?: number,
57
+ validation?: { pattern: string; message?: string },
58
+ ): Promise<QuestionAnswer> {
50
59
  if (pendingResolve) {
51
60
  throw new Error('A question is already pending');
52
61
  }
@@ -63,6 +72,8 @@ export async function askQuestion(prompt: string, options: QuestionOption[]): Pr
63
72
  id: createId(),
64
73
  prompt,
65
74
  options,
75
+ ...(timeout !== undefined && { timeout }),
76
+ ...(validation !== undefined && { validation }),
66
77
  };
67
78
 
68
79
  currentRequest = request;
@@ -71,6 +82,20 @@ export async function askQuestion(prompt: string, options: QuestionOption[]): Pr
71
82
  const answer = await new Promise<QuestionAnswer>((resolve, reject) => {
72
83
  pendingResolve = resolve;
73
84
  pendingReject = reject;
85
+
86
+ if (timeout !== undefined && timeout > 0) {
87
+ pendingTimeoutId = setTimeout(() => {
88
+ pendingTimeoutId = null;
89
+ if (pendingReject) {
90
+ const rej = pendingReject;
91
+ pendingResolve = null;
92
+ pendingReject = null;
93
+ currentRequest = null;
94
+ notify();
95
+ rej(new Error(`Question timed out after ${timeout}s`));
96
+ }
97
+ }, timeout * 1000);
98
+ }
74
99
  });
75
100
 
76
101
  return answer;
@@ -101,6 +126,11 @@ export function answerQuestion(index: number, customText?: string): void {
101
126
 
102
127
  if (!answer) return;
103
128
 
129
+ if (pendingTimeoutId !== null) {
130
+ clearTimeout(pendingTimeoutId);
131
+ pendingTimeoutId = null;
132
+ }
133
+
104
134
  const resolve = pendingResolve;
105
135
  pendingResolve = null;
106
136
  pendingReject = null;
@@ -112,6 +142,11 @@ export function answerQuestion(index: number, customText?: string): void {
112
142
  export function cancelQuestion(): void {
113
143
  if (!currentRequest || !pendingReject) return;
114
144
 
145
+ if (pendingTimeoutId !== null) {
146
+ clearTimeout(pendingTimeoutId);
147
+ pendingTimeoutId = null;
148
+ }
149
+
115
150
  const reject = pendingReject;
116
151
  pendingResolve = null;
117
152
  pendingReject = null;
@@ -0,0 +1,32 @@
1
+ const CODE_CHARS = /[{}()\[\]=<>:;]/g;
2
+ const CODE_DENSITY_THRESHOLD = 0.04;
3
+
4
+ export function estimateTokensFromText(text: string): number {
5
+ if (!text) return 0;
6
+ const matches = text.match(CODE_CHARS);
7
+ const density = matches ? matches.length / text.length : 0;
8
+ const ratio = density >= CODE_DENSITY_THRESHOLD ? 2.8 : 3.3;
9
+ return Math.ceil(text.length / ratio);
10
+ }
11
+
12
+ export function estimateTokensForContent(content: string, thinkingContent?: string): number {
13
+ const contentTokens = estimateTokensFromText(content);
14
+ const thinkingTokens = thinkingContent ? estimateTokensFromText(thinkingContent) : 0;
15
+ return contentTokens + thinkingTokens + 4;
16
+ }
17
+
18
+ const CONTEXT_BUDGETS: Record<string, number> = {
19
+ anthropic: 180000,
20
+ openai: 115000,
21
+ google: 900000,
22
+ mistral: 28000,
23
+ xai: 117000,
24
+ ollama: 7000,
25
+ };
26
+
27
+ const DEFAULT_BUDGET = 12000;
28
+
29
+ export function getDefaultContextBudget(provider?: string): number {
30
+ if (!provider) return DEFAULT_BUDGET;
31
+ return CONTEXT_BUDGETS[provider] ?? DEFAULT_BUDGET;
32
+ }