@kirosnn/mosaic 0.0.91 → 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 (99) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -6
  3. package/package.json +55 -48
  4. package/src/agent/Agent.ts +353 -131
  5. package/src/agent/context.ts +4 -4
  6. package/src/agent/prompts/systemPrompt.ts +209 -70
  7. package/src/agent/prompts/toolsPrompt.ts +285 -138
  8. package/src/agent/provider/anthropic.ts +109 -105
  9. package/src/agent/provider/google.ts +111 -107
  10. package/src/agent/provider/mistral.ts +95 -95
  11. package/src/agent/provider/ollama.ts +73 -17
  12. package/src/agent/provider/openai.ts +146 -102
  13. package/src/agent/provider/rateLimit.ts +178 -0
  14. package/src/agent/provider/reasoning.ts +29 -0
  15. package/src/agent/provider/xai.ts +108 -104
  16. package/src/agent/tools/definitions.ts +15 -1
  17. package/src/agent/tools/executor.ts +717 -98
  18. package/src/agent/tools/exploreExecutor.ts +20 -22
  19. package/src/agent/tools/fetch.ts +58 -0
  20. package/src/agent/tools/glob.ts +20 -4
  21. package/src/agent/tools/grep.ts +64 -9
  22. package/src/agent/tools/plan.ts +27 -0
  23. package/src/agent/tools/question.ts +7 -1
  24. package/src/agent/tools/read.ts +2 -0
  25. package/src/agent/types.ts +15 -14
  26. package/src/components/App.tsx +50 -8
  27. package/src/components/CustomInput.tsx +461 -77
  28. package/src/components/Main.tsx +1459 -1112
  29. package/src/components/Setup.tsx +1 -1
  30. package/src/components/ShortcutsModal.tsx +11 -8
  31. package/src/components/Welcome.tsx +1 -1
  32. package/src/components/main/ApprovalPanel.tsx +4 -3
  33. package/src/components/main/ChatPage.tsx +858 -516
  34. package/src/components/main/HomePage.tsx +58 -39
  35. package/src/components/main/QuestionPanel.tsx +52 -7
  36. package/src/components/main/ThinkingIndicator.tsx +13 -2
  37. package/src/components/main/types.ts +11 -10
  38. package/src/index.tsx +53 -25
  39. package/src/mcp/approvalPolicy.ts +148 -0
  40. package/src/mcp/cli/add.ts +185 -0
  41. package/src/mcp/cli/doctor.ts +77 -0
  42. package/src/mcp/cli/index.ts +85 -0
  43. package/src/mcp/cli/list.ts +50 -0
  44. package/src/mcp/cli/logs.ts +24 -0
  45. package/src/mcp/cli/manage.ts +99 -0
  46. package/src/mcp/cli/show.ts +53 -0
  47. package/src/mcp/cli/tools.ts +77 -0
  48. package/src/mcp/config.ts +223 -0
  49. package/src/mcp/index.ts +80 -0
  50. package/src/mcp/processManager.ts +299 -0
  51. package/src/mcp/rateLimiter.ts +50 -0
  52. package/src/mcp/registry.ts +151 -0
  53. package/src/mcp/schemaConverter.ts +100 -0
  54. package/src/mcp/servers/navigation.ts +854 -0
  55. package/src/mcp/toolCatalog.ts +169 -0
  56. package/src/mcp/types.ts +95 -0
  57. package/src/utils/approvalBridge.ts +45 -12
  58. package/src/utils/approvalModeBridge.ts +17 -0
  59. package/src/utils/commands/approvals.ts +48 -0
  60. package/src/utils/commands/compact.ts +30 -0
  61. package/src/utils/commands/echo.ts +1 -1
  62. package/src/utils/commands/image.ts +109 -0
  63. package/src/utils/commands/index.ts +9 -7
  64. package/src/utils/commands/new.ts +15 -0
  65. package/src/utils/commands/types.ts +3 -0
  66. package/src/utils/config.ts +3 -1
  67. package/src/utils/diffRendering.tsx +13 -16
  68. package/src/utils/exploreBridge.ts +10 -0
  69. package/src/utils/history.ts +82 -40
  70. package/src/utils/imageBridge.ts +28 -0
  71. package/src/utils/images.ts +31 -0
  72. package/src/utils/markdown.tsx +163 -99
  73. package/src/utils/models.ts +31 -16
  74. package/src/utils/notificationBridge.ts +23 -0
  75. package/src/utils/questionBridge.ts +36 -1
  76. package/src/utils/tokenEstimator.ts +32 -0
  77. package/src/utils/toolFormatting.ts +428 -48
  78. package/src/web/app.tsx +65 -5
  79. package/src/web/assets/css/ChatPage.css +102 -30
  80. package/src/web/assets/css/MessageItem.css +26 -29
  81. package/src/web/assets/css/ThinkingIndicator.css +44 -6
  82. package/src/web/assets/css/ToolMessage.css +36 -14
  83. package/src/web/components/ChatPage.tsx +228 -105
  84. package/src/web/components/HomePage.tsx +3 -3
  85. package/src/web/components/MessageItem.tsx +80 -81
  86. package/src/web/components/QuestionPanel.tsx +72 -12
  87. package/src/web/components/Setup.tsx +1 -1
  88. package/src/web/components/Sidebar.tsx +1 -3
  89. package/src/web/components/ThinkingIndicator.tsx +41 -21
  90. package/src/web/router.ts +1 -1
  91. package/src/web/server.tsx +894 -662
  92. package/src/web/storage.ts +23 -1
  93. package/src/web/types.ts +7 -6
  94. package/src/utils/commands/redo.ts +0 -74
  95. package/src/utils/commands/sessions.ts +0 -129
  96. package/src/utils/commands/undo.ts +0 -75
  97. package/src/utils/undoRedo.ts +0 -429
  98. package/src/utils/undoRedoBridge.ts +0 -45
  99. package/src/utils/undoRedoDb.ts +0 -338
@@ -8,13 +8,13 @@ export function renderDiffLine(line: string, key: string) {
8
8
  const colors = getDiffLineColors(parsed);
9
9
 
10
10
  return (
11
- <box key={key} flexDirection="row">
12
- <box backgroundColor={colors.labelBackground}>
11
+ <box key={key} flexDirection="row" width="100%" alignItems="stretch">
12
+ <box backgroundColor={colors.labelBackground} flexShrink={0}>
13
13
  <text fg="#ffffff">
14
14
  {" "}{parsed.prefix}{parsed.lineNumber?.padStart(5) || ''}{' '}
15
15
  </text>
16
16
  </box>
17
- <box flexGrow={1} backgroundColor={colors.contentBackground}>
17
+ <box flexGrow={1} backgroundColor={colors.contentBackground} minWidth={0}>
18
18
  <text fg="#ffffff">
19
19
  {" "}{parsed.content || ''}
20
20
  </text>
@@ -24,27 +24,26 @@ export function renderDiffLine(line: string, key: string) {
24
24
  }
25
25
 
26
26
  return (
27
- <text key={key} fg="#ffffff">
28
- {line || ' '}
29
- </text>
27
+ <box key={key} width="100%">
28
+ <text fg="#ffffff">
29
+ {line || ' '}
30
+ </text>
31
+ </box>
30
32
  );
31
33
  }
32
34
 
33
35
  export function renderInlineDiffLine(content: string) {
34
36
  const parsed = parseDiffLine(content);
35
-
36
37
  if (parsed.isDiffLine) {
37
- const colors = getDiffLineColors(parsed);
38
-
39
38
  return (
40
39
  <>
41
40
  <box>
42
- <text fg="#ffffff">
41
+ <text fg="white" attributes={TextAttributes.DIM}>
43
42
  {parsed.prefix}{parsed.lineNumber?.padStart(5) || ''}{' '}
44
43
  </text>
45
44
  </box>
46
- <box backgroundColor={colors.contentBackground}>
47
- <text fg="#ffffff">
45
+ <box>
46
+ <text fg="white">
48
47
  {" "}{parsed.content || ''}
49
48
  </text>
50
49
  </box>
@@ -55,8 +54,6 @@ export function renderInlineDiffLine(content: string) {
55
54
  return null;
56
55
  }
57
56
 
58
- export function getDiffLineBackground(content: string): string | null {
59
- const parsed = parseDiffLine(content);
60
- const colors = getDiffLineColors(parsed);
61
- return colors.contentBackground !== 'transparent' ? colors.contentBackground : null;
57
+ export function getDiffLineBackground(_content: string): string | null {
58
+ return null;
62
59
  }
@@ -15,6 +15,7 @@ interface ExploreBridgeGlobal {
15
15
  toolCallback: ExploreToolCallback | null;
16
16
  totalExploreTokens: number;
17
17
  subscribers: Set<ExploreToolSubscriber>;
18
+ parentContext: string;
18
19
  }
19
20
 
20
21
  const globalKey = '__mosaic_explore_bridge__';
@@ -26,6 +27,7 @@ if (!g[globalKey]) {
26
27
  toolCallback: null,
27
28
  totalExploreTokens: 0,
28
29
  subscribers: new Set<ExploreToolSubscriber>(),
30
+ parentContext: '',
29
31
  };
30
32
  }
31
33
 
@@ -85,3 +87,11 @@ export function subscribeExploreTool(callback: ExploreToolSubscriber): () => voi
85
87
  export function getExploreTokens(): number {
86
88
  return state.totalExploreTokens;
87
89
  }
90
+
91
+ export function setExploreContext(context: string): void {
92
+ state.parentContext = context;
93
+ }
94
+
95
+ export function getExploreContext(): string {
96
+ return state.parentContext;
97
+ }
@@ -1,26 +1,31 @@
1
- import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'fs';
1
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, unlinkSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { homedir } from 'os';
4
4
 
5
- export interface ConversationStep {
6
- type: 'user' | 'assistant' | 'tool';
7
- content: string;
8
- toolName?: string;
9
- toolArgs?: Record<string, unknown>;
10
- toolResult?: unknown;
11
- timestamp: number;
12
- }
13
-
14
- export interface ConversationHistory {
15
- id: string;
16
- timestamp: number;
17
- steps: ConversationStep[];
18
- totalSteps: number;
19
- totalTokens?: {
20
- prompt: number;
21
- completion: number;
22
- total: number;
23
- };
5
+ export interface ConversationStep {
6
+ type: 'user' | 'assistant' | 'tool';
7
+ content: string;
8
+ images?: import("./images").ImageAttachment[];
9
+ toolName?: string;
10
+ toolArgs?: Record<string, unknown>;
11
+ toolResult?: unknown;
12
+ timestamp: number;
13
+ responseDuration?: number;
14
+ blendWord?: string;
15
+ }
16
+
17
+ export interface ConversationHistory {
18
+ id: string;
19
+ timestamp: number;
20
+ steps: ConversationStep[];
21
+ totalSteps: number;
22
+ title?: string | null;
23
+ workspace?: string | null;
24
+ totalTokens?: {
25
+ prompt: number;
26
+ completion: number;
27
+ total: number;
28
+ };
24
29
  model?: string;
25
30
  provider?: string;
26
31
  }
@@ -36,14 +41,49 @@ export function getHistoryDir(): string {
36
41
  return historyDir;
37
42
  }
38
43
 
39
- export function saveConversation(conversation: ConversationHistory): void {
40
- const historyDir = getHistoryDir();
41
- const filename = `${conversation.id}.json`;
42
- const filepath = join(historyDir, filename);
43
-
44
- writeFileSync(filepath, JSON.stringify(conversation, null, 2), 'utf-8');
45
- }
46
-
44
+ export function saveConversation(conversation: ConversationHistory): void {
45
+ const historyDir = getHistoryDir();
46
+ const filename = `${conversation.id}.json`;
47
+ const filepath = join(historyDir, filename);
48
+
49
+ writeFileSync(filepath, JSON.stringify(conversation, null, 2), 'utf-8');
50
+ }
51
+
52
+ export function updateConversationTitle(id: string, title: string | null): boolean {
53
+ const historyDir = getHistoryDir();
54
+ const filepath = join(historyDir, `${id}.json`);
55
+
56
+ if (!existsSync(filepath)) {
57
+ return false;
58
+ }
59
+
60
+ try {
61
+ const content = readFileSync(filepath, 'utf-8');
62
+ const data = JSON.parse(content) as ConversationHistory;
63
+ data.title = title;
64
+ writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf-8');
65
+ return true;
66
+ } catch (error) {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ export function deleteConversation(id: string): boolean {
72
+ const historyDir = getHistoryDir();
73
+ const filepath = join(historyDir, `${id}.json`);
74
+
75
+ if (!existsSync(filepath)) {
76
+ return false;
77
+ }
78
+
79
+ try {
80
+ unlinkSync(filepath);
81
+ return true;
82
+ } catch (error) {
83
+ return false;
84
+ }
85
+ }
86
+
47
87
  export function loadConversations(): ConversationHistory[] {
48
88
  const historyDir = getHistoryDir();
49
89
 
@@ -51,17 +91,19 @@ export function loadConversations(): ConversationHistory[] {
51
91
  return [];
52
92
  }
53
93
 
54
- const files = readdirSync(historyDir).filter(f => f.endsWith('.json'));
55
- const conversations: ConversationHistory[] = [];
56
-
57
- for (const file of files) {
58
- try {
59
- const content = readFileSync(join(historyDir, file), 'utf-8');
60
- conversations.push(JSON.parse(content));
61
- } catch (error) {
62
- console.error(`Failed to load ${file}:`, error);
63
- }
64
- }
94
+ const files = readdirSync(historyDir).filter(f => f.endsWith('.json') && f !== 'inputs.json');
95
+ const conversations: ConversationHistory[] = [];
96
+
97
+ for (const file of files) {
98
+ try {
99
+ const content = readFileSync(join(historyDir, file), 'utf-8');
100
+ const parsed = JSON.parse(content) as ConversationHistory;
101
+ if (!parsed || !Array.isArray(parsed.steps)) continue;
102
+ conversations.push(parsed);
103
+ } catch (error) {
104
+ console.error(`Failed to load ${file}:`, error);
105
+ }
106
+ }
65
107
 
66
108
  return conversations.sort((a, b) => b.timestamp - a.timestamp);
67
109
  }
@@ -103,4 +145,4 @@ export function addInputToHistory(input: string): void {
103
145
 
104
146
  saveInputHistory(history);
105
147
  }
106
- }
148
+ }
@@ -0,0 +1,28 @@
1
+ import type { ImageAttachment } from "./images";
2
+
3
+ export type ImageCommandEvent =
4
+ | { type: "add"; image: ImageAttachment }
5
+ | { type: "clear" }
6
+ | { type: "remove"; id: string };
7
+
8
+ const listeners = new Set<(event: ImageCommandEvent) => void>();
9
+ let imageSupport = false;
10
+
11
+ export function subscribeImageCommand(listener: (event: ImageCommandEvent) => void): () => void {
12
+ listeners.add(listener);
13
+ return () => {
14
+ listeners.delete(listener);
15
+ };
16
+ }
17
+
18
+ export function emitImageCommand(event: ImageCommandEvent): void {
19
+ listeners.forEach((listener) => listener(event));
20
+ }
21
+
22
+ export function setImageSupport(enabled: boolean): void {
23
+ imageSupport = enabled;
24
+ }
25
+
26
+ export function canUseImages(): boolean {
27
+ return imageSupport;
28
+ }
@@ -0,0 +1,31 @@
1
+ export type ImageAttachment = {
2
+ id: string;
3
+ name: string;
4
+ mimeType: string;
5
+ data: string;
6
+ size: number;
7
+ };
8
+
9
+ const EXT_TO_MIME: Record<string, string> = {
10
+ png: "image/png",
11
+ jpg: "image/jpeg",
12
+ jpeg: "image/jpeg",
13
+ webp: "image/webp",
14
+ gif: "image/gif",
15
+ bmp: "image/bmp",
16
+ svg: "image/svg+xml",
17
+ tif: "image/tiff",
18
+ tiff: "image/tiff"
19
+ };
20
+
21
+ export function guessImageMimeType(filename: string): string {
22
+ const clean = filename.trim().toLowerCase();
23
+ const idx = clean.lastIndexOf(".");
24
+ if (idx === -1) return "application/octet-stream";
25
+ const ext = clean.slice(idx + 1);
26
+ return EXT_TO_MIME[ext] || "application/octet-stream";
27
+ }
28
+
29
+ export function toDataUrl(image: ImageAttachment): string {
30
+ return `data:${image.mimeType};base64,${image.data}`;
31
+ }
@@ -121,112 +121,176 @@ export function parseMarkdownContent(content: string): ParsedMarkdownLine[] {
121
121
  return result;
122
122
  }
123
123
 
124
- export function wrapMarkdownText(text: string, maxWidth: number): { text: string; segments: MarkdownSegment[] }[] {
125
- if (!text || maxWidth <= 0) return [{ text: '', segments: [] }];
126
-
127
- const segments = parseMarkdownLine(text);
128
- const lines: { text: string; segments: MarkdownSegment[] }[] = [];
129
- let currentLine = '';
130
- let currentSegments: MarkdownSegment[] = [];
131
-
132
- for (const segment of segments) {
133
- const content = segment.content;
134
- const fullText = content;
135
-
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
- }
124
+ export function wrapMarkdownText(text: string, maxWidth: number): { text: string; segments: MarkdownSegment[] }[] {
125
+ if (!text || maxWidth <= 0) return [{ text: '', segments: [] }];
126
+
127
+ const segments = parseMarkdownLine(text);
128
+ const lines: { text: string; segments: MarkdownSegment[] }[] = [];
129
+ let currentLine = '';
130
+ let currentSegments: MarkdownSegment[] = [];
131
+
132
+ const splitSegment = (segment: MarkdownSegment): MarkdownSegment[] => {
133
+ if (segment.type === 'code') return [segment];
134
+ const parts = segment.content.match(/\s+|[^\s]+/g);
135
+ if (!parts) return [segment];
136
+ return parts.map(part => ({ ...segment, content: part }));
137
+ };
138
+
139
+ const pushLine = () => {
140
+ if (!currentLine) return;
141
+ lines.push({ text: currentLine, segments: currentSegments });
142
+ currentLine = '';
143
+ currentSegments = [];
144
+ };
145
+
146
+ const addPiece = (piece: MarkdownSegment) => {
147
+ let remaining = piece.content;
148
+ while (remaining.length > 0) {
149
+ if (!currentLine) {
150
+ if (remaining.trim() === '') {
151
+ return;
152
+ }
153
+ if (remaining.length <= maxWidth) {
154
+ currentLine = remaining;
155
+ currentSegments = [{ ...piece, content: remaining }];
156
+ return;
157
+ }
158
+ const chunk = remaining.slice(0, maxWidth);
159
+ lines.push({ text: chunk, segments: [{ ...piece, content: chunk }] });
160
+ remaining = remaining.slice(maxWidth);
161
+ continue;
162
+ }
163
+
164
+ if ((currentLine + remaining).length <= maxWidth) {
165
+ currentLine += remaining;
166
+ currentSegments.push({ ...piece, content: remaining });
167
+ return;
168
+ }
169
+
170
+ pushLine();
171
+ }
172
+ };
173
+
174
+ for (const segment of segments) {
175
+ const pieces = splitSegment(segment);
176
+ for (const piece of pieces) {
177
+ addPiece(piece);
178
+ }
179
+ }
180
+
181
+ if (currentLine) {
182
+ lines.push({ text: currentLine, segments: currentSegments });
183
+ }
184
+
185
+ return lines.length > 0 ? lines : [{ text: '', segments: [] }];
186
+ }
187
+
188
+ export interface WrappedMarkdownBlock {
189
+ type: 'line' | 'code';
190
+ wrappedLines?: { text: string; segments: MarkdownSegment[] }[];
191
+ codeLines?: string[];
192
+ language?: string;
193
+ }
194
+
195
+ function wrapCodeLine(line: string, maxWidth: number): string[] {
196
+ if (!line) return [''];
197
+ if (maxWidth <= 0) return [line];
198
+ if (line.length <= maxWidth) return [line];
199
+
200
+ const chunks: string[] = [];
201
+ let i = 0;
202
+ while (i < line.length) {
203
+ chunks.push(line.slice(i, i + maxWidth));
204
+ i += maxWidth;
205
+ }
206
+ return chunks;
207
+ }
208
+
209
+ function reflowParagraphs(text: string): string {
210
+ const rawLines = text.split('\n');
211
+ const result: string[] = [];
212
+ let inCodeBlock = false;
213
+
214
+ for (let i = 0; i < rawLines.length; i++) {
215
+ const line = rawLines[i]!;
216
+
217
+ if (/^```/.test(line)) {
218
+ inCodeBlock = !inCodeBlock;
219
+ result.push(line);
220
+ continue;
221
+ }
222
+
223
+ if (inCodeBlock) {
224
+ result.push(line);
225
+ continue;
226
+ }
227
+
228
+ if (line.trim() === '') {
229
+ result.push(line);
230
+ continue;
231
+ }
232
+
233
+ if (/^#{1,6}\s/.test(line) || /^[-*+]\s/.test(line) || /^\d+\.\s/.test(line)) {
234
+ result.push(line);
235
+ continue;
236
+ }
237
+
238
+ const prev = result.length > 0 ? result[result.length - 1]! : '';
239
+ const prevIsText = prev.trim() !== '' &&
240
+ !/^```/.test(prev) &&
241
+ !/^#{1,6}\s/.test(prev) &&
242
+ !/^[-*+]\s/.test(prev) &&
243
+ !/^\d+\.\s/.test(prev);
244
+
245
+ if (prevIsText) {
246
+ result[result.length - 1] = prev + ' ' + line;
169
247
  } else {
170
- const needsSpace = !currentLine.endsWith(' ') && !fullText.startsWith(' ');
171
- const separator = needsSpace ? ' ' : '';
248
+ result.push(line);
249
+ }
250
+ }
172
251
 
173
- if ((currentLine + separator + fullText).length <= maxWidth) {
174
- currentLine += separator + fullText;
175
- currentSegments.push(segment);
252
+ return result.join('\n');
253
+ }
254
+
255
+ export function parseAndWrapMarkdown(text: string, maxWidth: number): WrappedMarkdownBlock[] {
256
+ const lines = reflowParagraphs(text).split('\n');
257
+ const blocks: WrappedMarkdownBlock[] = [];
258
+ let inCodeBlock = false;
259
+ let codeLines: string[] = [];
260
+ let language: string | undefined;
261
+
262
+ for (const line of lines) {
263
+ const fenceMatch = line.match(/^```([A-Za-z0-9_-]+)?\s*$/);
264
+ if (fenceMatch) {
265
+ if (!inCodeBlock) {
266
+ inCodeBlock = true;
267
+ language = fenceMatch[1];
268
+ codeLines = [];
176
269
  } 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
- }
270
+ const wrapped = codeLines.flatMap(codeLine => wrapCodeLine(codeLine, maxWidth));
271
+ blocks.push({ type: 'code', codeLines: wrapped, language });
272
+ inCodeBlock = false;
273
+ codeLines = [];
274
+ language = undefined;
210
275
  }
276
+ continue;
277
+ }
278
+
279
+ if (inCodeBlock) {
280
+ codeLines.push(line);
281
+ continue;
211
282
  }
212
- }
213
283
 
214
- if (currentLine) {
215
- lines.push({ text: currentLine, segments: currentSegments });
284
+ blocks.push({
285
+ type: 'line',
286
+ wrappedLines: wrapMarkdownText(line, maxWidth)
287
+ });
216
288
  }
217
289
 
218
- return lines.length > 0 ? lines : [{ text: '', segments: [] }];
219
- }
290
+ if (inCodeBlock) {
291
+ const wrapped = codeLines.flatMap(codeLine => wrapCodeLine(codeLine, maxWidth));
292
+ blocks.push({ type: 'code', codeLines: wrapped, language });
293
+ }
220
294
 
221
- export interface WrappedMarkdownBlock {
222
- type: 'line';
223
- wrappedLines?: { text: string; segments: MarkdownSegment[] }[];
295
+ return blocks;
224
296
  }
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
- }
@@ -152,23 +152,16 @@ export class ModelsDevClient {
152
152
 
153
153
  async getModelById(modelId: ModelsDevModelId, options: { refresh?: boolean } = {}): Promise<ModelsDevSearchResult | null> {
154
154
  const data = await this.getAll(options);
155
- // Try exact match first
156
155
  for (const provider of Object.values(data)) {
157
156
  const model = provider.models?.[modelId];
158
157
  if (model) return { provider, model };
159
158
  }
160
-
161
- // Try semantic/partial match
162
- // e.g. gpt-5.2-2025-12-11 should match gpt-5.2 or vice versa
163
159
  const lowerSearch = modelId.toLowerCase();
164
160
 
165
161
  for (const provider of Object.values(data)) {
166
162
  const models = provider.models ?? {};
167
163
  for (const [id, model] of Object.entries(models)) {
168
164
  const lowerId = id.toLowerCase();
169
- // If the known model ID is a prefix of our search (e.g. search gpt-5.2-v1 matches model gpt-5.2)
170
- // OR if our search is a prefix of the known model ID (e.g. search gpt-5.2 matches model gpt-5.2-preview)
171
- // OR if one contains the other
172
165
  if (lowerSearch.includes(lowerId) || lowerId.includes(lowerSearch)) {
173
166
  return { provider, model };
174
167
  }
@@ -293,12 +286,34 @@ export async function findModelsDevModelById(
293
286
  return modelsDev.getModelById(modelId, options);
294
287
  }
295
288
 
296
- export async function searchModelsDev(query: ModelsDevSearchQuery, options: { refresh?: boolean } = {}): Promise<ModelsDevSearchResult[]> {
297
- return modelsDev.search(query, options);
298
- }
299
-
300
- export function modelAcceptsImages(model: ModelsDevModel): boolean {
301
- if (!model.modalities) return false;
302
- const { input } = model.modalities;
303
- return Array.isArray(input) && input.includes("image");
304
- }
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
+ }
@@ -0,0 +1,23 @@
1
+ export type NotificationType = 'info' | 'success' | 'error' | 'warning'
2
+
3
+ export interface NotificationPayload {
4
+ message: string
5
+ type?: NotificationType
6
+ duration?: number
7
+ }
8
+
9
+ type NotificationListener = (payload: NotificationPayload) => void
10
+
11
+ const listeners = new Set<NotificationListener>()
12
+
13
+ export function subscribeNotifications(listener: NotificationListener): () => void {
14
+ listeners.add(listener)
15
+ return () => {
16
+ listeners.delete(listener)
17
+ }
18
+ }
19
+
20
+ export function notifyNotification(message: string, type: NotificationType = 'info', duration?: number): void {
21
+ const payload: NotificationPayload = { message, type, duration }
22
+ listeners.forEach((listener) => listener(payload))
23
+ }