@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
@@ -1,29 +1,33 @@
1
1
  /** @jsxImportSource react */
2
2
  import React, { useState, useEffect, useRef } from 'react';
3
- import { Message } from '../types';
4
- import { MessageItem } from './MessageItem';
5
- import { Sidebar, SidebarProps } from './Sidebar';
6
- import { QuestionRequest } from '../../utils/questionBridge';
7
- import { ApprovalRequest } from '../../utils/approvalBridge';
8
- import { QuestionPanel } from './QuestionPanel';
9
- import { ApprovalPanel } from './ApprovalPanel';
10
- import { ThinkingIndicator } from './ThinkingIndicator';
11
- import { findModelsDevModelById, modelAcceptsImages } from '../../utils/models';
12
- import '../assets/css/global.css'
13
-
14
- interface ChatPageProps {
15
- messages: Message[];
16
- isProcessing: boolean;
17
- processingStartTime?: number;
18
- currentTokens?: number;
19
- onSendMessage: (message: string) => void;
20
- onStopAgent?: () => void;
21
- sidebarProps: SidebarProps;
22
- currentTitle?: string | null;
23
- workspace?: string | null;
24
- questionRequest?: QuestionRequest | null;
25
- approvalRequest?: ApprovalRequest | null;
26
- }
3
+ import { Message } from '../types';
4
+ import { MessageItem } from './MessageItem';
5
+ import { Sidebar, SidebarProps } from './Sidebar';
6
+ import { QuestionRequest } from '../../utils/questionBridge';
7
+ import { ApprovalRequest } from '../../utils/approvalBridge';
8
+ import { QuestionPanel } from './QuestionPanel';
9
+ import { ApprovalPanel } from './ApprovalPanel';
10
+ import { ThinkingIndicator } from './ThinkingIndicator';
11
+ import { findModelsDevModelById, modelAcceptsImages } from '../../utils/models';
12
+ import type { ImageAttachment } from '../../utils/images';
13
+ import { guessImageMimeType, toDataUrl } from '../../utils/images';
14
+ import '../assets/css/global.css'
15
+
16
+ interface ChatPageProps {
17
+ messages: Message[];
18
+ isProcessing: boolean;
19
+ processingStartTime?: number;
20
+ currentTokens?: number;
21
+ onSendMessage: (message: string, images?: ImageAttachment[]) => void;
22
+ onStopAgent?: () => void;
23
+ sidebarProps: SidebarProps;
24
+ currentTitle?: string | null;
25
+ workspace?: string | null;
26
+ questionRequest?: QuestionRequest | null;
27
+ approvalRequest?: ApprovalRequest | null;
28
+ requireApprovals: boolean;
29
+ onToggleApprovals: () => void;
30
+ }
27
31
 
28
32
  function formatWorkspace(path: string | null | undefined): string {
29
33
  if (!path) return '';
@@ -55,11 +59,54 @@ function formatWorkspace(path: string | null | undefined): string {
55
59
  return normalized;
56
60
  }
57
61
 
58
- export function ChatPage({ messages, isProcessing, processingStartTime, currentTokens, onSendMessage, onStopAgent, sidebarProps, currentTitle, workspace, questionRequest, approvalRequest }: ChatPageProps) {
59
- const [inputValue, setInputValue] = useState('');
60
- const [showAttachButton, setShowAttachButton] = useState(false);
61
- const messagesEndRef = useRef<HTMLDivElement>(null);
62
- const inputRef = useRef<HTMLTextAreaElement>(null);
62
+ function getPlanProgress(messages: Message[]): { inProgressStep?: string; nextStep?: string } {
63
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
64
+ const message = messages[i];
65
+ if (!message || message.role !== 'tool' || message.toolName !== 'plan') continue;
66
+ const result = message.toolResult;
67
+ if (!result || typeof result !== 'object') continue;
68
+ const obj = result as Record<string, unknown>;
69
+ const planItems = Array.isArray(obj.plan) ? obj.plan : [];
70
+ const normalized = planItems
71
+ .map((item) => {
72
+ if (!item || typeof item !== 'object') return null;
73
+ const entry = item as Record<string, unknown>;
74
+ const step = typeof entry.step === 'string' ? entry.step.trim() : '';
75
+ const status = typeof entry.status === 'string' ? entry.status : 'pending';
76
+ if (!step) return null;
77
+ return { step, status };
78
+ })
79
+ .filter((item): item is { step: string; status: string } => !!item);
80
+
81
+ if (normalized.length === 0) return {};
82
+
83
+ const inProgressIndex = normalized.findIndex(item => item.status === 'in_progress');
84
+ const inProgressStep = inProgressIndex >= 0 ? normalized[inProgressIndex]?.step : undefined;
85
+ let nextStep: string | undefined;
86
+
87
+ if (inProgressIndex >= 0) {
88
+ const after = normalized.slice(inProgressIndex + 1).find(item => item.status === 'pending');
89
+ nextStep = after?.step;
90
+ }
91
+
92
+ if (!nextStep) {
93
+ nextStep = normalized.find(item => item.status === 'pending')?.step;
94
+ }
95
+
96
+ return { inProgressStep, nextStep };
97
+ }
98
+
99
+ return {};
100
+ }
101
+
102
+ export function ChatPage({ messages, isProcessing, processingStartTime, currentTokens, onSendMessage, onStopAgent, sidebarProps, currentTitle, workspace, questionRequest, approvalRequest, requireApprovals, onToggleApprovals }: ChatPageProps) {
103
+ const [inputValue, setInputValue] = useState('');
104
+ const [showAttachButton, setShowAttachButton] = useState(false);
105
+ const [pendingImages, setPendingImages] = useState<ImageAttachment[]>([]);
106
+ const messagesEndRef = useRef<HTMLDivElement>(null);
107
+ const inputRef = useRef<HTMLTextAreaElement>(null);
108
+ const fileInputRef = useRef<HTMLInputElement>(null);
109
+ const planProgress = getPlanProgress(messages);
63
110
 
64
111
  useEffect(() => {
65
112
  if (messagesEndRef.current) {
@@ -77,17 +124,23 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
77
124
  if (inputRef.current) inputRef.current.focus();
78
125
  }, []);
79
126
 
80
- useEffect(() => {
81
- const handleKeyDown = (e: KeyboardEvent) => {
82
- if (e.key === 'Escape' && isProcessing && onStopAgent) {
83
- e.preventDefault();
84
- onStopAgent();
85
- }
86
- };
127
+ useEffect(() => {
128
+ const handleKeyDown = (e: KeyboardEvent) => {
129
+ if (e.key === 'Escape' && isProcessing && onStopAgent) {
130
+ e.preventDefault();
131
+ onStopAgent();
132
+ }
133
+ };
87
134
 
88
135
  document.addEventListener('keydown', handleKeyDown);
89
- return () => document.removeEventListener('keydown', handleKeyDown);
90
- }, [isProcessing, onStopAgent]);
136
+ return () => document.removeEventListener('keydown', handleKeyDown);
137
+ }, [isProcessing, onStopAgent]);
138
+
139
+ useEffect(() => {
140
+ if (!showAttachButton) {
141
+ setPendingImages([]);
142
+ }
143
+ }, [showAttachButton]);
91
144
 
92
145
  useEffect(() => {
93
146
  const checkModelSupport = async () => {
@@ -98,44 +151,82 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
98
151
  const { model } = await configRes.json();
99
152
 
100
153
  if (model) {
101
- // Try to find the model using the enhanced fuzzy search
102
154
  const result = await findModelsDevModelById(model);
103
155
 
104
- if (result && result.model) {
105
- setShowAttachButton(modelAcceptsImages(result.model));
106
- } else {
107
- // Very basic fallback if even fuzzy search fails
108
- const lowerId = model.toLowerCase();
109
- const likelySupportsImages =
110
- lowerId.includes('gpt-4') ||
111
- lowerId.includes('gpt-5') ||
112
- lowerId.includes('claude-3') ||
113
- lowerId.includes('gemini') ||
114
- lowerId.includes('vision');
115
- setShowAttachButton(likelySupportsImages);
116
- }
117
- }
118
- } catch (err) {
119
- console.error('Failed to check model support:', err);
120
- }
121
- };
156
+ if (result && result.model) {
157
+ setShowAttachButton(modelAcceptsImages(result.model));
158
+ } else {
159
+ setShowAttachButton(false);
160
+ }
161
+ }
162
+ } catch (err) {
163
+ console.error('Failed to check model support:', err);
164
+ }
165
+ };
122
166
 
123
167
  checkModelSupport();
124
168
  }, []);
125
169
 
126
- const handleSubmit = (e?: React.FormEvent) => {
127
- if (e) e.preventDefault();
128
- if (!inputValue.trim() || isProcessing) return;
129
- onSendMessage(inputValue);
130
- setInputValue('');
131
- };
170
+ const handleSubmit = (e?: React.FormEvent) => {
171
+ if (e) e.preventDefault();
172
+ if ((!inputValue.trim() && pendingImages.length === 0) || isProcessing) return;
173
+ onSendMessage(inputValue, showAttachButton ? pendingImages : []);
174
+ setInputValue('');
175
+ setPendingImages([]);
176
+ };
132
177
 
133
- const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
134
- if (e.key === 'Enter' && !e.shiftKey) {
135
- e.preventDefault();
136
- handleSubmit();
137
- }
138
- };
178
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
179
+ if (e.key === 'Enter' && !e.shiftKey) {
180
+ e.preventDefault();
181
+ handleSubmit();
182
+ }
183
+ };
184
+
185
+ const toBase64 = async (file: File): Promise<string> => {
186
+ const buffer = await file.arrayBuffer();
187
+ const bytes = new Uint8Array(buffer);
188
+ let binary = '';
189
+ const chunkSize = 0x8000;
190
+ for (let i = 0; i < bytes.length; i += chunkSize) {
191
+ const chunk = bytes.subarray(i, i + chunkSize);
192
+ binary += String.fromCharCode(...Array.from(chunk));
193
+ }
194
+ return btoa(binary);
195
+ };
196
+
197
+ const handleAttachClick = () => {
198
+ fileInputRef.current?.click();
199
+ };
200
+
201
+ const handleFilesSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
202
+ if (!showAttachButton) {
203
+ e.target.value = '';
204
+ return;
205
+ }
206
+ const files = Array.from(e.target.files || []);
207
+ if (files.length === 0) return;
208
+ const attachments: ImageAttachment[] = [];
209
+ for (const file of files) {
210
+ const mimeType = file.type || guessImageMimeType(file.name);
211
+ if (!mimeType.startsWith('image/')) continue;
212
+ const data = await toBase64(file);
213
+ attachments.push({
214
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
215
+ name: file.name,
216
+ mimeType,
217
+ data,
218
+ size: file.size
219
+ });
220
+ }
221
+ if (attachments.length > 0) {
222
+ setPendingImages((prev) => [...prev, ...attachments]);
223
+ }
224
+ e.target.value = '';
225
+ };
226
+
227
+ const handleRemovePendingImage = (id: string) => {
228
+ setPendingImages((prev) => prev.filter((img) => img.id !== id));
229
+ };
139
230
 
140
231
  const handleQuestionAnswer = async (index: number, customText?: string) => {
141
232
  try {
@@ -169,16 +260,24 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
169
260
 
170
261
  <div className="main-content" style={{ padding: 0 }}>
171
262
  <div className="chat-page">
172
- {(currentTitle || workspace) && (
173
- <div className="chat-title-bar">
174
- <span className="chat-title">{currentTitle || ''}</span>
175
- {formattedWorkspace && (
176
- <span className="chat-workspace" title={workspace || ''}>
177
- {formattedWorkspace}
178
- </span>
179
- )}
180
- </div>
181
- )}
263
+ <div className="chat-title-bar">
264
+ <span className="chat-title">{currentTitle || ''}</span>
265
+ <div className="chat-title-actions">
266
+ {formattedWorkspace && (
267
+ <span className="chat-workspace" title={workspace || ''}>
268
+ {formattedWorkspace}
269
+ </span>
270
+ )}
271
+ <button
272
+ type="button"
273
+ className={`approval-toggle ${requireApprovals ? '' : 'active'}`}
274
+ onClick={onToggleApprovals}
275
+ title={requireApprovals ? 'Enable auto-approve' : 'Disable auto-approve'}
276
+ >
277
+ {requireApprovals ? 'Approvals on' : 'Auto-approve'}
278
+ </button>
279
+ </div>
280
+ </div>
182
281
  <div className="chat-container">
183
282
  <div className="messages">
184
283
  {messages.map((msg) => (
@@ -187,7 +286,12 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
187
286
  {isProcessing && !questionRequest && !approvalRequest && (
188
287
  <div className="message assistant">
189
288
  <div className="message-content">
190
- <ThinkingIndicator startTime={processingStartTime} tokens={currentTokens} />
289
+ <ThinkingIndicator
290
+ startTime={processingStartTime}
291
+ tokens={currentTokens}
292
+ inProgressStep={planProgress.inProgressStep}
293
+ nextStep={planProgress.nextStep}
294
+ />
191
295
  </div>
192
296
  </div>
193
297
  )}
@@ -217,25 +321,44 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
217
321
  <div ref={messagesEndRef} />
218
322
  </div>
219
323
 
220
- <form onSubmit={handleSubmit} className="input-area">
221
- <textarea
222
- ref={inputRef}
223
- value={inputValue}
224
- onChange={(e) => setInputValue(e.target.value)}
225
- onKeyDown={handleKeyDown}
226
- placeholder="Type your message..."
227
- rows={2}
228
- disabled={isProcessing || !!questionRequest || !!approvalRequest}
229
- />
230
- <div className="input-actions">
231
- <div className="input-actions-left">
232
- {showAttachButton && (
233
- <button type="button" className="send-btn" disabled={isProcessing} title="Attach file">
234
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ transform: 'rotate(-45deg)' }}>
235
- <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
236
- </svg>
237
- </button>
238
- )}
324
+ <form onSubmit={handleSubmit} className="input-area">
325
+ <input
326
+ ref={fileInputRef}
327
+ type="file"
328
+ accept="image/*"
329
+ multiple
330
+ style={{ display: 'none' }}
331
+ onChange={handleFilesSelected}
332
+ />
333
+ {pendingImages.length > 0 && (
334
+ <div className="attachment-strip">
335
+ {pendingImages.map((img) => (
336
+ <div key={img.id} className="attachment-item">
337
+ <img src={toDataUrl(img)} alt={img.name} />
338
+ <button type="button" onClick={() => handleRemovePendingImage(img.id)} title="Remove">
339
+ x
340
+ </button>
341
+ </div>
342
+ ))}
343
+ </div>
344
+ )}
345
+ <textarea
346
+ ref={inputRef}
347
+ value={inputValue}
348
+ onChange={(e) => setInputValue(e.target.value)}
349
+ onKeyDown={handleKeyDown}
350
+ placeholder="Type your message..."
351
+ rows={2}
352
+ />
353
+ <div className="input-actions">
354
+ <div className="input-actions-left">
355
+ {showAttachButton && (
356
+ <button type="button" className="send-btn" title="Attach image" onClick={handleAttachClick}>
357
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ transform: 'rotate(-45deg)' }}>
358
+ <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
359
+ </svg>
360
+ </button>
361
+ )}
239
362
  </div>
240
363
  <div className="input-actions-right">
241
364
  {isProcessing ? (
@@ -250,12 +373,12 @@ export function ChatPage({ messages, isProcessing, processingStartTime, currentT
250
373
  </svg>
251
374
  </button>
252
375
  ) : (
253
- <button
254
- type="submit"
255
- className="send-btn"
256
- disabled={!inputValue.trim() || !!questionRequest || !!approvalRequest}
257
- title="Send"
258
- >
376
+ <button
377
+ type="submit"
378
+ className="send-btn"
379
+ disabled={(!inputValue.trim() && pendingImages.length === 0) || !!questionRequest || !!approvalRequest}
380
+ title="Send"
381
+ >
259
382
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
260
383
  <line x1="12" y1="19" x2="12" y2="5"></line>
261
384
  <polyline points="5 12 12 5 19 12"></polyline>
@@ -12,7 +12,7 @@ interface RecentProject {
12
12
  }
13
13
 
14
14
  interface HomePageProps {
15
- onStartChat: (message: string) => void;
15
+ onStartChat: (message: string, images?: import("../../utils/images").ImageAttachment[]) => void;
16
16
  onOpenProject: (path: string) => void;
17
17
  sidebarProps: SidebarProps;
18
18
  }
@@ -31,7 +31,7 @@ function formatRelativeTime(timestamp: number): string {
31
31
  return `${days} day${days !== 1 ? 's' : ''} ago`;
32
32
  }
33
33
 
34
- export function HomePage({ onStartChat, onOpenProject, sidebarProps }: HomePageProps) {
34
+ export function HomePage({ onStartChat: _onStartChat, onOpenProject, sidebarProps }: HomePageProps) {
35
35
  const [recentProjects, setRecentProjects] = useState<RecentProject[]>([]);
36
36
  const [isLoading, setIsLoading] = useState(true);
37
37
  const [showFileExplorer, setShowFileExplorer] = useState(false);
@@ -118,4 +118,4 @@ export function HomePage({ onStartChat, onOpenProject, sidebarProps }: HomePageP
118
118
  </Modal>
119
119
  </div>
120
120
  );
121
- }
121
+ }
@@ -5,38 +5,10 @@ import remarkGfm from 'remark-gfm';
5
5
  import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
6
6
  import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
7
7
  import { Message } from '../types';
8
+ import { toDataUrl } from '../../utils/images';
8
9
  import { parseDiffLine, getDiffLineColors } from '../utils';
9
10
  import '../assets/css/global.css'
10
11
 
11
- function BlendIcon() {
12
- return (
13
- <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="blend-icon">
14
- <circle cx="12" cy="6.5" r="1.4" />
15
- <circle cx="17.5" cy="12" r="1.4" />
16
- <circle cx="12" cy="17.5" r="1.4" />
17
- <circle cx="6.5" cy="12" r="1.4" />
18
- </svg>
19
- );
20
- }
21
-
22
- function formatBlendTime(ms: number): string {
23
- const totalSeconds = Math.floor(ms / 1000);
24
- const minutes = Math.floor(totalSeconds / 60);
25
- const seconds = totalSeconds % 60;
26
-
27
- if (minutes >= 60) {
28
- const hours = Math.floor(minutes / 60);
29
- const remainingMinutes = minutes % 60;
30
- return `${hours}h ${remainingMinutes}m`;
31
- }
32
-
33
- if (minutes > 0) {
34
- return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
35
- }
36
-
37
- return `${seconds}s`;
38
- }
39
-
40
12
  interface MessageItemProps {
41
13
  message: Message;
42
14
  }
@@ -72,6 +44,35 @@ function renderDiffLine(line: string, index: number): React.ReactElement {
72
44
  );
73
45
  }
74
46
 
47
+ function renderToolLine(line: string, index: number): React.ReactElement {
48
+ const parsed = parseDiffLine(line);
49
+ if (parsed.isDiffLine) {
50
+ return renderDiffLine(line, index);
51
+ }
52
+
53
+ const planMatch = line.match(/^(\s*)>\s*(\[[~x ]\])?\s*(.*)$/);
54
+ if (planMatch) {
55
+ const [, leading, bracket, rest] = planMatch;
56
+ const isActive = bracket === '[~]';
57
+ return (
58
+ <div key={index} className="tool-line plan-line">
59
+ <span className="plan-indent">{leading || ''}</span>
60
+ <span className="plan-prefix">{'>'}</span>
61
+ <span> </span>
62
+ {bracket && <span className={`plan-bracket${isActive ? ' active' : ''}`}>{bracket}</span>}
63
+ {bracket && <span> </span>}
64
+ <span className="plan-step">{rest}</span>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <div key={index} className="tool-line">
71
+ {line}
72
+ </div>
73
+ );
74
+ }
75
+
75
76
  function parseToolHeader(content: string): { name: string; info: string | null; bodyLines: string[] } {
76
77
  const lines = content.split('\n');
77
78
  const firstLine = lines[0] || '';
@@ -88,11 +89,12 @@ function parseToolHeader(content: string): { name: string; info: string | null;
88
89
  export function MessageItem({ message }: MessageItemProps) {
89
90
  if (message.role === 'tool') {
90
91
  const statusClass = message.success === false ? 'error' : message.isRunning ? 'running' : 'success';
92
+ const planClass = message.toolName === 'plan' ? 'plan-tool' : '';
91
93
 
92
94
  const { name, info, bodyLines } = parseToolHeader(message.content);
93
95
 
94
96
  return (
95
- <div className={`message tool ${statusClass}`}>
97
+ <div className={`message tool ${statusClass} ${planClass}`}>
96
98
  <div className="message-content">
97
99
  <div className="tool-header">
98
100
  <span className={`tool-name ${message.toolName === 'stop' ? 'no-bold' : ''}`}>{name}</span>
@@ -105,7 +107,7 @@ export function MessageItem({ message }: MessageItemProps) {
105
107
  </div>
106
108
  {bodyLines.length > 0 && (
107
109
  <div className="tool-output">
108
- {bodyLines.map((line, index) => renderDiffLine(line, index))}
110
+ {bodyLines.map((line, index) => renderToolLine(line, index))}
109
111
  </div>
110
112
  )}
111
113
  </div>
@@ -114,65 +116,62 @@ export function MessageItem({ message }: MessageItemProps) {
114
116
  }
115
117
 
116
118
  if (message.role === 'assistant') {
117
- const showBlend = message.responseDuration && message.responseDuration > 60000;
118
-
119
119
  return (
120
- <>
121
- <div className="message assistant">
122
- <div className="message-content">
123
- {message.thinkingContent && (
124
- <details className="thinking-section">
125
- <summary>Thinking...</summary>
126
- <pre className="thinking-content">{message.thinkingContent}</pre>
127
- </details>
128
- )}
129
- <div className="markdown-content">
130
- <ReactMarkdown
131
- remarkPlugins={[remarkGfm]}
132
- components={{
133
- code({ node, className, children, ...props }) {
134
- const match = /language-(\w+)/.exec(className || '');
135
- const { ref, ...rest } = props as any;
136
- return match ? (
137
- <SyntaxHighlighter
138
- style={vscDarkPlus as any}
139
- language={match[1]}
140
- PreTag="div"
141
- {...rest}
142
- >
143
- {String(children).replace(/\n$/, '')}
144
- </SyntaxHighlighter>
145
- ) : (
146
- <code className={className} {...props}>
147
- {children}
148
- </code>
149
- );
150
- }
151
- }}
152
- >
153
- {message.content}
154
- </ReactMarkdown>
155
- </div>
120
+ <div className="message assistant">
121
+ <div className="message-content">
122
+ {message.thinkingContent && (
123
+ <details className="thinking-section">
124
+ <summary>Thinking...</summary>
125
+ <pre className="thinking-content">{message.thinkingContent}</pre>
126
+ </details>
127
+ )}
128
+ <div className="markdown-content">
129
+ <ReactMarkdown
130
+ remarkPlugins={[remarkGfm]}
131
+ components={{
132
+ code({ node, className, children, ...props }) {
133
+ const match = /language-(\w+)/.exec(className || '');
134
+ const { ref, ...rest } = props as any;
135
+ return match ? (
136
+ <SyntaxHighlighter
137
+ style={vscDarkPlus as any}
138
+ language={match[1]}
139
+ PreTag="div"
140
+ {...rest}
141
+ >
142
+ {String(children).replace(/\n$/, '')}
143
+ </SyntaxHighlighter>
144
+ ) : (
145
+ <code className={className} {...props}>
146
+ {children}
147
+ </code>
148
+ );
149
+ }
150
+ }}
151
+ >
152
+ {message.content}
153
+ </ReactMarkdown>
156
154
  </div>
157
155
  </div>
158
- {showBlend && (
159
- <div className="message assistant blend-message">
160
- <div className="message-content blend-indicator">
161
- <BlendIcon />
162
- <span className="blend-text">
163
- {message.blendWord || 'Blended'} for {formatBlendTime(message.responseDuration!)}
164
- </span>
165
- </div>
166
- </div>
167
- )}
168
- </>
156
+ </div>
169
157
  );
170
158
  }
171
159
 
160
+ const hasImages = Array.isArray(message.images) && message.images.length > 0;
161
+
172
162
  return (
173
163
  <div className={`message ${message.role} ${message.isError ? 'error' : ''}`}>
174
164
  <div className="message-content">
175
- {message.displayContent || message.content}
165
+ {hasImages && (
166
+ <div className="message-images">
167
+ {message.images!.map((img) => (
168
+ <img key={img.id} src={toDataUrl(img)} alt={img.name} />
169
+ ))}
170
+ </div>
171
+ )}
172
+ <div className="message-text">
173
+ {message.displayContent || message.content}
174
+ </div>
176
175
  </div>
177
176
  </div>
178
177
  );