@kirosnn/mosaic 0.0.9 → 0.71.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 (56) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +83 -19
  3. package/package.json +52 -47
  4. package/src/agent/prompts/systemPrompt.ts +198 -68
  5. package/src/agent/prompts/toolsPrompt.ts +217 -135
  6. package/src/agent/provider/anthropic.ts +19 -15
  7. package/src/agent/provider/google.ts +21 -17
  8. package/src/agent/provider/ollama.ts +80 -41
  9. package/src/agent/provider/openai.ts +107 -67
  10. package/src/agent/provider/reasoning.ts +29 -0
  11. package/src/agent/provider/xai.ts +19 -15
  12. package/src/agent/tools/definitions.ts +9 -5
  13. package/src/agent/tools/executor.ts +655 -46
  14. package/src/agent/tools/exploreExecutor.ts +12 -12
  15. package/src/agent/tools/fetch.ts +58 -0
  16. package/src/agent/tools/glob.ts +20 -4
  17. package/src/agent/tools/grep.ts +62 -8
  18. package/src/agent/tools/plan.ts +27 -0
  19. package/src/agent/tools/read.ts +2 -0
  20. package/src/agent/types.ts +6 -6
  21. package/src/components/App.tsx +67 -25
  22. package/src/components/CustomInput.tsx +274 -68
  23. package/src/components/Main.tsx +323 -168
  24. package/src/components/ShortcutsModal.tsx +11 -8
  25. package/src/components/main/ChatPage.tsx +217 -58
  26. package/src/components/main/HomePage.tsx +5 -1
  27. package/src/components/main/ThinkingIndicator.tsx +11 -1
  28. package/src/components/main/types.ts +11 -10
  29. package/src/index.tsx +3 -5
  30. package/src/utils/approvalBridge.ts +29 -8
  31. package/src/utils/approvalModeBridge.ts +17 -0
  32. package/src/utils/commands/approvals.ts +48 -0
  33. package/src/utils/commands/image.ts +109 -0
  34. package/src/utils/commands/index.ts +5 -1
  35. package/src/utils/diffRendering.tsx +13 -14
  36. package/src/utils/history.ts +82 -40
  37. package/src/utils/imageBridge.ts +28 -0
  38. package/src/utils/images.ts +31 -0
  39. package/src/utils/models.ts +0 -7
  40. package/src/utils/notificationBridge.ts +23 -0
  41. package/src/utils/toolFormatting.ts +162 -43
  42. package/src/web/app.tsx +94 -34
  43. package/src/web/assets/css/ChatPage.css +102 -30
  44. package/src/web/assets/css/MessageItem.css +26 -29
  45. package/src/web/assets/css/ThinkingIndicator.css +44 -6
  46. package/src/web/assets/css/ToolMessage.css +36 -14
  47. package/src/web/components/ChatPage.tsx +228 -105
  48. package/src/web/components/HomePage.tsx +6 -6
  49. package/src/web/components/MessageItem.tsx +88 -89
  50. package/src/web/components/Setup.tsx +1 -1
  51. package/src/web/components/Sidebar.tsx +1 -1
  52. package/src/web/components/ThinkingIndicator.tsx +40 -21
  53. package/src/web/router.ts +1 -1
  54. package/src/web/server.tsx +187 -39
  55. package/src/web/storage.ts +23 -1
  56. package/src/web/types.ts +7 -6
@@ -1,4 +1,5 @@
1
- import { useState, useEffect, useRef } from "react";
1
+ import { useState, useEffect, useRef } from "react";
2
+ import type { ImagePart, TextPart, UserContent } from "ai";
2
3
  import { useKeyboard } from "@opentui/react";
3
4
  import { Agent } from "../agent";
4
5
  import { saveConversation, addInputToHistory, type ConversationHistory, type ConversationStep } from "../utils/history";
@@ -13,11 +14,14 @@ import { subscribeUndoRedo } from "../utils/undoRedoBridge";
13
14
  import { setExploreAbortController, setExploreToolCallback, abortExplore } from "../utils/exploreBridge";
14
15
  import { initializeSession, saveState } from "../utils/undoRedo";
15
16
  import { resetFileChanges } from "../utils/fileChangeTracker";
16
- import { getCurrentQuestion, cancelQuestion } from "../utils/questionBridge";
17
- import { getCurrentApproval, cancelApproval } from "../utils/approvalBridge";
18
- import { BLEND_WORDS, type MainProps, type Message } from "./main/types";
19
- import { HomePage } from './main/HomePage';
20
- import { ChatPage } from './main/ChatPage';
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";
21
25
 
22
26
  function extractTitle(content: string, alreadyResolved: boolean): { title: string | null; cleanContent: string; isPending: boolean; noTitle: boolean } {
23
27
  const trimmed = content.trimStart();
@@ -54,8 +58,10 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
54
58
  const [scrollOffset, setScrollOffset] = useState(0);
55
59
  const [terminalHeight, setTerminalHeight] = useState(process.stdout.rows || 24);
56
60
  const [terminalWidth, setTerminalWidth] = useState(process.stdout.columns || 80);
57
- const [questionRequest, setQuestionRequest] = useState<QuestionRequest | null>(null);
58
- const [currentTitle, setCurrentTitle] = useState<string | null>(null);
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);
59
65
  const currentTitleRef = useRef<string | null>(null);
60
66
  const titleExtractedRef = useRef(false);
61
67
  const shouldAutoScroll = useRef(true);
@@ -71,10 +77,31 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
71
77
 
72
78
  const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
73
79
 
74
- useEffect(() => {
75
- initializeCommands();
76
- initializeSession();
77
- }, []);
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
+ }, []);
78
105
 
79
106
  useEffect(() => {
80
107
  let lastExploreTokens = 0;
@@ -95,7 +122,7 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
95
122
  const idx = newMessages.findIndex(m => m.id === exploreMessageIdRef.current);
96
123
  if (idx !== -1) {
97
124
  const toolLines = exploreToolsRef.current.map(t => {
98
- const icon = t.success ? '+' : '-';
125
+ const icon = t.success ? '' : '-';
99
126
  return ` ${icon} ${t.tool}(${t.info})`;
100
127
  });
101
128
  const purpose = explorePurposeRef.current;
@@ -112,14 +139,35 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
112
139
  };
113
140
  }, []);
114
141
 
115
- useEffect(() => {
116
- return subscribeUndoRedo((state, action) => {
117
- if (state) {
118
- setMessages(state.messages);
119
- resetFileChanges();
120
- }
121
- });
122
- }, []);
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]);
123
171
 
124
172
  useEffect(() => {
125
173
  const handleResize = () => {
@@ -260,24 +308,58 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
260
308
  }
261
309
  }, [copyRequestId, onCopy, messages]);
262
310
 
263
- useKeyboard((key) => {
264
- if (key.name === 'escape') {
265
- if (getCurrentQuestion()) {
266
- cancelQuestion();
267
- }
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
+ }
268
327
  if (getCurrentApproval()) {
269
328
  cancelApproval();
270
329
  }
271
330
  abortControllerRef.current?.abort();
272
331
  return;
273
- }
274
- });
275
-
276
- const handleSubmit = async (value: string, meta?: InputSubmitMeta) => {
277
- if (isProcessing) return;
278
-
279
- const hasPastedContent = Boolean(meta?.isPaste && meta.pastedContent);
280
- if (!value.trim() && !hasPastedContent) 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;
281
363
 
282
364
  if (isCommand(value)) {
283
365
  const result = await executeCommand(value);
@@ -342,9 +424,12 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
342
424
  timestamp: Date.now()
343
425
  });
344
426
 
345
- try {
346
- const providerStatus = await Agent.ensureProviderReady();
347
- if (!providerStatus.ready) {
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) {
348
433
  setMessages((prev: Message[]) => {
349
434
  const newMessages = [...prev];
350
435
  newMessages.push({
@@ -360,9 +445,7 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
360
445
  }
361
446
 
362
447
  const agent = new Agent();
363
- const conversationHistory = [...messages, userMessage]
364
- .filter((m): m is Message & { role: 'user' | 'assistant' } => m.role === 'user' || m.role === 'assistant')
365
- .map((m) => ({ role: m.role, content: m.content }));
448
+ const conversationHistory = buildConversationHistory([...messages, userMessage], imagesSupported);
366
449
  let assistantChunk = '';
367
450
  let thinkingChunk = '';
368
451
  const pendingToolCalls = new Map<string, { toolName: string; args: Record<string, unknown>; messageId?: string }>();
@@ -614,27 +697,44 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
614
697
  return;
615
698
  }
616
699
 
617
- if (!streamHadError && assistantChunk.trim()) {
618
- conversationSteps.push({
619
- type: 'assistant',
620
- content: assistantChunk,
621
- timestamp: Date.now()
622
- });
623
- }
624
-
625
- const conversationData: ConversationHistory = {
626
- id: conversationId,
627
- timestamp: Date.now(),
628
- steps: conversationSteps,
629
- totalSteps: stepCount,
630
- totalTokens: totalTokens.total > 0 ? totalTokens : undefined,
631
- model: config.model,
632
- provider: config.provider
633
- };
634
-
635
- saveConversation(conversationData);
636
-
637
- } catch (error) {
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) {
638
738
  if (abortController.signal.aborted) {
639
739
  notifyAbort();
640
740
  return;
@@ -664,14 +764,14 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
664
764
  if (abortControllerRef.current === abortController) {
665
765
  abortControllerRef.current = null;
666
766
  }
667
- const duration = Date.now() - localStartTime;
668
- if (duration >= 60000) {
669
- const blendWord = BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
670
- setMessages((prev: Message[]) => {
671
- const newMessages = [...prev];
672
- for (let i = newMessages.length - 1; i >= 0; i--) {
673
- if (newMessages[i]?.role === 'assistant') {
674
- newMessages[i] = { ...newMessages[i]!, responseDuration: duration, blendWord };
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 };
675
775
  break;
676
776
  }
677
777
  }
@@ -706,17 +806,24 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
706
806
  ? `${meta!.pastedContent!}${value.trim() ? `\n\n${value}` : ''}`
707
807
  : value;
708
808
 
709
- addInputToHistory(value.trim() || (hasPastedContent ? '[Pasted text]' : value));
809
+ addInputToHistory(value.trim() || (hasPastedContent ? '[Pasted text]' : (hasImages ? '[Image]' : value)));
710
810
 
711
811
  saveState(messages);
712
812
 
713
- const userMessage: Message = {
714
- id: createId(),
715
- role: "user",
716
- content: composedContent,
717
- displayContent: meta?.isPaste ? '[Pasted text]' : undefined,
718
- };
719
-
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
+
720
827
  setMessages((prev: Message[]) => [...prev, userMessage]);
721
828
  setIsProcessing(true);
722
829
  const localStartTime = Date.now();
@@ -759,15 +866,19 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
759
866
  });
760
867
  };
761
868
 
762
- conversationSteps.push({
763
- type: 'user',
764
- content: composedContent,
765
- timestamp: Date.now()
766
- });
767
-
768
- try {
769
- const providerStatus = await Agent.ensureProviderReady();
770
- if (!providerStatus.ready) {
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) {
771
882
  setMessages((prev: Message[]) => {
772
883
  const newMessages = [...prev];
773
884
  newMessages.push({
@@ -783,20 +894,43 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
783
894
  }
784
895
 
785
896
  const agent = new Agent();
786
- const conversationHistory = [...messages, userMessage]
787
- .filter((m): m is Message & { role: 'user' | 'assistant' } => m.role === 'user' || m.role === 'assistant')
788
- .map((m) => ({ role: m.role, content: m.content }));
789
- let assistantChunk = '';
790
- const pendingToolCalls = new Map<string, { toolName: string; args: Record<string, unknown>; messageId?: string }>();
791
- let assistantMessageId: string | null = null;
792
- let streamHadError = false;
793
- titleExtractedRef.current = false;
794
-
795
- for await (const event of agent.streamMessages(conversationHistory, { abortSignal: abortController.signal })) {
796
- if (event.type === 'text-delta') {
797
- assistantChunk += event.content;
798
- totalChars += event.content.length;
799
- setCurrentTokens(estimateTokens());
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());
800
934
 
801
935
  const { title, cleanContent, isPending, noTitle } = extractTitle(assistantChunk, titleExtractedRef.current);
802
936
 
@@ -816,21 +950,22 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
816
950
  }
817
951
 
818
952
  const displayContent = cleanContent;
819
- const currentMessageId = assistantMessageId;
820
- setMessages((prev: Message[]) => {
821
- const newMessages = [...prev];
822
- const messageIndex = newMessages.findIndex(m => m.id === currentMessageId);
823
-
824
- if (messageIndex === -1) {
825
- newMessages.push({ id: currentMessageId, role: "assistant", content: displayContent });
826
- } else {
827
- newMessages[messageIndex] = {
828
- ...newMessages[messageIndex]!,
829
- content: displayContent
830
- };
831
- }
832
- return newMessages;
833
- });
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
+ });
834
969
  } else if (event.type === 'step-start') {
835
970
  stepCount++;
836
971
  } else if (event.type === 'tool-call-end') {
@@ -947,14 +1082,15 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
947
1082
  return newMessages;
948
1083
  });
949
1084
 
950
- assistantChunk = '';
951
- assistantMessageId = null;
952
- } else if (event.type === 'error') {
953
- if (abortController.signal.aborted) {
954
- notifyAbort();
955
- streamHadError = true;
956
- break;
957
- }
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
+ }
958
1094
  if (assistantChunk.trim()) {
959
1095
  conversationSteps.push({
960
1096
  type: 'assistant',
@@ -981,10 +1117,11 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
981
1117
  return newMessages;
982
1118
  });
983
1119
 
984
- assistantChunk = '';
985
- assistantMessageId = null;
986
- streamHadError = true;
987
- break;
1120
+ assistantChunk = '';
1121
+ thinkingChunk = '';
1122
+ assistantMessageId = null;
1123
+ streamHadError = true;
1124
+ break;
988
1125
  } else if (event.type === 'finish') {
989
1126
  if (event.usage && event.usage.totalTokens > 0) {
990
1127
  totalTokens = {
@@ -1002,25 +1139,42 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1002
1139
  return;
1003
1140
  }
1004
1141
 
1005
- if (!streamHadError && assistantChunk.trim()) {
1006
- conversationSteps.push({
1007
- type: 'assistant',
1008
- content: assistantChunk,
1009
- timestamp: Date.now()
1010
- });
1011
- }
1012
-
1013
- const conversationData: ConversationHistory = {
1014
- id: conversationId,
1015
- timestamp: Date.now(),
1016
- steps: conversationSteps,
1017
- totalSteps: stepCount,
1018
- totalTokens: totalTokens.total > 0 ? totalTokens : undefined,
1019
- model: config.model,
1020
- provider: config.provider
1021
- };
1022
-
1023
- saveConversation(conversationData);
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);
1024
1178
 
1025
1179
  } catch (error) {
1026
1180
  if (abortController.signal.aborted) {
@@ -1052,14 +1206,14 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1052
1206
  if (abortControllerRef.current === abortController) {
1053
1207
  abortControllerRef.current = null;
1054
1208
  }
1055
- const duration = Date.now() - localStartTime;
1056
- if (duration >= 60000) {
1057
- const blendWord = BLEND_WORDS[Math.floor(Math.random() * BLEND_WORDS.length)];
1058
- setMessages((prev: Message[]) => {
1059
- const newMessages = [...prev];
1060
- for (let i = newMessages.length - 1; i >= 0; i--) {
1061
- if (newMessages[i]?.role === 'assistant') {
1062
- newMessages[i] = { ...newMessages[i]!, responseDuration: duration, blendWord };
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 };
1063
1217
  break;
1064
1218
  }
1065
1219
  }
@@ -1096,17 +1250,18 @@ export function Main({ pasteRequestId = 0, copyRequestId = 0, onCopy, shortcutsO
1096
1250
  }
1097
1251
 
1098
1252
  return (
1099
- <ChatPage
1100
- messages={messages}
1101
- isProcessing={isProcessing}
1102
- processingStartTime={processingStartTime}
1103
- currentTokens={currentTokens}
1104
- scrollOffset={scrollOffset}
1105
- terminalHeight={terminalHeight}
1106
- terminalWidth={terminalWidth}
1107
- pasteRequestId={pasteRequestId}
1108
- shortcutsOpen={shortcutsOpen}
1109
- onSubmit={handleSubmit}
1110
- />
1111
- );
1112
- }
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
+ }
@@ -10,13 +10,16 @@ interface ShortcutsModalProps {
10
10
  }
11
11
 
12
12
  export function ShortcutsModal({ activeTab }: ShortcutsModalProps) {
13
- const shortcutsGeneral: ShortcutItem[] = [
14
- { keys: "Ctrl+P / Alt+P", description: "Open/close this shortcuts panel" },
15
- { keys: "Alt+V (or Ctrl+V)", description: "Paste from clipboard into the focused input" },
16
- { keys: "Enter", description: "Confirm / submit" },
17
- { keys: "↑/↓ (or j/k)", description: "Navigate lists" },
18
- { keys: "PageUp/PageDown", description: "Scroll chat faster" },
19
- ];
13
+ const shortcutsGeneral: ShortcutItem[] = [
14
+ { keys: "Ctrl+P / Alt+P", description: "Open/close this shortcuts panel" },
15
+ { keys: "Alt+V (or Ctrl+V)", description: "Paste from clipboard into the focused input" },
16
+ { keys: "Ctrl+C", description: "Cancel the current request" },
17
+ { keys: "Alt+C (or Cmd+C)", description: "Copy the last assistant message" },
18
+ { keys: "Shift+Tab", description: "Toggle auto-approve for agent changes" },
19
+ { keys: "Enter", description: "Confirm / submit" },
20
+ { keys: "↑/↓ (or j/k)", description: "Navigate lists" },
21
+ { keys: "PageUp/PageDown", description: "Scroll chat faster" },
22
+ ];
20
23
 
21
24
  const shortcutsSetup: ShortcutItem[] = [
22
25
  { keys: "Esc", description: "Go back to the previous step" },
@@ -64,4 +67,4 @@ export function ShortcutsModal({ activeTab }: ShortcutsModalProps) {
64
67
  </box>
65
68
  </box>
66
69
  );
67
- }
70
+ }