@makefinks/daemon 0.9.1 → 0.11.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 (40) hide show
  1. package/README.md +60 -14
  2. package/package.json +4 -2
  3. package/src/ai/copilot-client.ts +775 -0
  4. package/src/ai/daemon-ai.ts +32 -234
  5. package/src/ai/model-config.ts +55 -14
  6. package/src/ai/providers/capabilities.ts +16 -0
  7. package/src/ai/providers/copilot-provider.ts +632 -0
  8. package/src/ai/providers/openrouter-provider.ts +217 -0
  9. package/src/ai/providers/registry.ts +14 -0
  10. package/src/ai/providers/types.ts +31 -0
  11. package/src/ai/system-prompt.ts +16 -0
  12. package/src/ai/tools/subagents.ts +1 -1
  13. package/src/ai/tools/tool-registry.ts +22 -1
  14. package/src/ai/tools/write-file.ts +51 -0
  15. package/src/app/components/AppOverlays.tsx +9 -1
  16. package/src/app/components/ConversationPane.tsx +8 -2
  17. package/src/components/ModelMenu.tsx +202 -140
  18. package/src/components/OnboardingOverlay.tsx +147 -1
  19. package/src/components/SettingsMenu.tsx +27 -1
  20. package/src/components/TokenUsageDisplay.tsx +5 -3
  21. package/src/components/tool-layouts/layouts/index.ts +1 -0
  22. package/src/components/tool-layouts/layouts/write-file.tsx +117 -0
  23. package/src/hooks/daemon-event-handlers.ts +61 -14
  24. package/src/hooks/keyboard-handlers.ts +109 -28
  25. package/src/hooks/use-app-callbacks.ts +141 -43
  26. package/src/hooks/use-app-context-builder.ts +5 -0
  27. package/src/hooks/use-app-controller.ts +31 -2
  28. package/src/hooks/use-app-copilot-models-loader.ts +45 -0
  29. package/src/hooks/use-app-display-state.ts +24 -2
  30. package/src/hooks/use-app-model.ts +103 -17
  31. package/src/hooks/use-app-preferences-bootstrap.ts +54 -10
  32. package/src/hooks/use-bootstrap-controller.ts +5 -0
  33. package/src/hooks/use-daemon-events.ts +8 -2
  34. package/src/hooks/use-daemon-keyboard.ts +19 -6
  35. package/src/hooks/use-daemon-runtime-controller.ts +4 -0
  36. package/src/hooks/use-menu-keyboard.ts +6 -1
  37. package/src/state/app-context.tsx +6 -0
  38. package/src/types/index.ts +24 -1
  39. package/src/utils/copilot-models.ts +77 -0
  40. package/src/utils/preferences.ts +3 -0
@@ -3,9 +3,10 @@ import { useKeyboard } from "@opentui/react";
3
3
  import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import type { RefObject } from "react";
5
5
  import { handleOnboardingKey } from "../hooks/keyboard-handlers";
6
- import type { AppPreferences, AudioDevice, ModelOption, OnboardingStep } from "../types";
6
+ import type { AppPreferences, AudioDevice, LlmProvider, ModelOption, OnboardingStep } from "../types";
7
7
  import { COLORS } from "../ui/constants";
8
8
  import { formatContextWindowK, formatPrice } from "../utils/formatters";
9
+ import { ApiKeyInput } from "./ApiKeyInput";
9
10
  import { ApiKeyStep } from "./ApiKeyStep";
10
11
 
11
12
  const MODEL_COL_WIDTH = {
@@ -25,6 +26,13 @@ const API_KEY_CONFIGS = {
25
26
  keyUrl: "https://openrouter.ai/keys",
26
27
  optional: false,
27
28
  },
29
+ copilot_auth: {
30
+ title: "GITHUB COPILOT LOGIN REQUIRED",
31
+ description: "Authenticate with GitHub/Copilot CLI, then validate from here.",
32
+ envVarName: "COPILOT_CLI_LOGIN",
33
+ keyUrl: "https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli",
34
+ optional: false,
35
+ },
28
36
  openai_key: {
29
37
  title: "OPENAI API KEY (OPTIONAL)",
30
38
  description: "OpenAI enables voice features (speech-to-text and text-to-speech).",
@@ -51,10 +59,13 @@ interface OnboardingOverlayProps {
51
59
  currentDevice?: string;
52
60
  currentOutputDevice?: string;
53
61
  models: ModelOption[];
62
+ currentModelProvider: LlmProvider;
63
+ copilotAuthenticated: boolean;
54
64
  currentModelId: string;
55
65
  deviceLoadTimedOut?: boolean;
56
66
  soxAvailable: boolean;
57
67
  soxInstallHint: string;
68
+ setCurrentModelProvider: (provider: LlmProvider) => void;
58
69
  setCurrentDevice: (deviceName: string | undefined) => void;
59
70
  setCurrentOutputDevice: (deviceName: string | undefined) => void;
60
71
  setCurrentModelId: (modelId: string) => void;
@@ -74,10 +85,13 @@ export function OnboardingOverlay({
74
85
  currentDevice,
75
86
  currentOutputDevice,
76
87
  models,
88
+ currentModelProvider,
89
+ copilotAuthenticated,
77
90
  currentModelId,
78
91
  deviceLoadTimedOut,
79
92
  soxAvailable,
80
93
  soxInstallHint,
94
+ setCurrentModelProvider,
81
95
  setCurrentDevice,
82
96
  setCurrentOutputDevice,
83
97
  setCurrentModelId,
@@ -87,9 +101,35 @@ export function OnboardingOverlay({
87
101
  onKeySubmit,
88
102
  apiKeyTextareaRef,
89
103
  }: OnboardingOverlayProps) {
104
+ const [selectedProviderIdx, setSelectedProviderIdx] = useState(0);
90
105
  const [selectedDeviceIdx, setSelectedDeviceIdx] = useState(0);
91
106
  const [selectedModelIdx, setSelectedModelIdx] = useState(0);
92
107
 
108
+ const providerOptions = useMemo(
109
+ () => [
110
+ {
111
+ id: "openrouter" as const,
112
+ label: "OpenRouter",
113
+ description: "API key based provider routing",
114
+ },
115
+ ...(copilotAuthenticated
116
+ ? ([
117
+ {
118
+ id: "copilot" as const,
119
+ label: "GitHub Copilot",
120
+ description: "GitHub auth + Copilot subscription",
121
+ },
122
+ ] as const)
123
+ : []),
124
+ ],
125
+ [copilotAuthenticated]
126
+ );
127
+
128
+ const initialProviderIdx = useMemo(() => {
129
+ const idx = providerOptions.findIndex((provider) => provider.id === currentModelProvider);
130
+ return idx >= 0 ? idx : 0;
131
+ }, [currentModelProvider, providerOptions]);
132
+
93
133
  const initialDeviceIdx = useMemo(() => {
94
134
  if (devices.length === 0) return 0;
95
135
  const idx = currentDevice ? devices.findIndex((device) => device.name === currentDevice) : -1;
@@ -103,6 +143,11 @@ export function OnboardingOverlay({
103
143
  return idx >= 0 ? idx : 0;
104
144
  }, [models, currentModelId]);
105
145
 
146
+ useEffect(() => {
147
+ if (step !== "provider") return;
148
+ setSelectedProviderIdx(initialProviderIdx);
149
+ }, [step, initialProviderIdx]);
150
+
106
151
  useEffect(() => {
107
152
  if (step !== "device") return;
108
153
  setSelectedDeviceIdx(initialDeviceIdx);
@@ -117,13 +162,18 @@ export function OnboardingOverlay({
117
162
  (key: KeyEvent) => {
118
163
  const handled = handleOnboardingKey(key, {
119
164
  step,
165
+ selectedProviderIdx,
120
166
  devices,
121
167
  models,
122
168
  selectedDeviceIdx,
123
169
  selectedModelIdx,
170
+ currentModelProvider,
171
+ copilotAuthenticated,
124
172
  preferences,
173
+ setSelectedProviderIdx,
125
174
  setSelectedDeviceIdx,
126
175
  setSelectedModelIdx,
176
+ setCurrentModelProvider,
127
177
  setCurrentDevice,
128
178
  setCurrentOutputDevice,
129
179
  setCurrentModelId,
@@ -140,11 +190,15 @@ export function OnboardingOverlay({
140
190
  },
141
191
  [
142
192
  step,
193
+ selectedProviderIdx,
143
194
  devices,
144
195
  models,
145
196
  selectedDeviceIdx,
146
197
  selectedModelIdx,
198
+ currentModelProvider,
199
+ copilotAuthenticated,
147
200
  preferences,
201
+ setCurrentModelProvider,
148
202
  setCurrentDevice,
149
203
  setCurrentOutputDevice,
150
204
  setCurrentModelId,
@@ -209,6 +263,61 @@ export function OnboardingOverlay({
209
263
  </>
210
264
  )}
211
265
 
266
+ {step === "provider" && (
267
+ <>
268
+ <box marginBottom={1}>
269
+ <text>
270
+ <span fg={COLORS.DAEMON_LABEL}>[ SELECT MODEL PROVIDER ]</span>
271
+ </text>
272
+ </box>
273
+ <box marginBottom={1}>
274
+ <text>
275
+ <span fg={COLORS.REASONING_DIM}>Choose where DAEMON should run agent responses.</span>
276
+ </text>
277
+ </box>
278
+ <box marginBottom={1}>
279
+ <text>
280
+ <span fg={COLORS.USER_LABEL}>↑/↓ navigate, ENTER select</span>
281
+ </text>
282
+ </box>
283
+ <box flexDirection="column">
284
+ {providerOptions.map((provider, idx) => {
285
+ const isSelected = idx === selectedProviderIdx;
286
+ const isCurrent = currentModelProvider === provider.id;
287
+ return (
288
+ <box
289
+ key={provider.id}
290
+ backgroundColor={isSelected ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
291
+ paddingLeft={1}
292
+ paddingRight={1}
293
+ flexDirection="column"
294
+ >
295
+ <text>
296
+ <span fg={isSelected ? COLORS.DAEMON_LABEL : COLORS.MENU_TEXT}>
297
+ {isSelected ? "▶ " : " "}
298
+ {provider.label}
299
+ {isCurrent ? " ●" : ""}
300
+ </span>
301
+ </text>
302
+ <text>
303
+ <span fg={COLORS.REASONING_DIM}> {provider.description}</span>
304
+ </text>
305
+ </box>
306
+ );
307
+ })}
308
+ {!copilotAuthenticated && (
309
+ <box marginTop={1}>
310
+ <text>
311
+ <span fg={COLORS.REASONING_DIM}>
312
+ Copilot appears after `gh auth login` and `copilot login`.
313
+ </span>
314
+ </text>
315
+ </box>
316
+ )}
317
+ </box>
318
+ </>
319
+ )}
320
+
212
321
  {step === "openrouter_key" && (
213
322
  <ApiKeyStep
214
323
  {...API_KEY_CONFIGS.openrouter_key}
@@ -217,6 +326,43 @@ export function OnboardingOverlay({
217
326
  />
218
327
  )}
219
328
 
329
+ {step === "copilot_auth" && (
330
+ <>
331
+ <box marginBottom={1}>
332
+ <text>
333
+ <span fg={COLORS.DAEMON_LABEL}>[ {API_KEY_CONFIGS.copilot_auth.title} ]</span>
334
+ </text>
335
+ </box>
336
+ <box marginBottom={1}>
337
+ <text>
338
+ <span fg={COLORS.MENU_TEXT}>
339
+ Run `gh auth login` and `copilot login`, then press ENTER to validate.
340
+ </span>
341
+ </text>
342
+ </box>
343
+ <box marginBottom={1}>
344
+ <text>
345
+ <span fg={COLORS.REASONING_DIM}>
346
+ Copilot token auth is disabled in DAEMON; logged-in CLI session is required.
347
+ </span>
348
+ </text>
349
+ </box>
350
+ <box marginBottom={1}>
351
+ <text>
352
+ <span fg={COLORS.REASONING_DIM}>
353
+ Install/login guide: {API_KEY_CONFIGS.copilot_auth.keyUrl}
354
+ </span>
355
+ <span fg={COLORS.USER_LABEL}> · Shift+O to open</span>
356
+ </text>
357
+ </box>
358
+ <ApiKeyInput
359
+ onSubmit={onKeySubmit}
360
+ placeholder="Press ENTER to validate Copilot login..."
361
+ textareaRef={apiKeyTextareaRef}
362
+ />
363
+ </>
364
+ )}
365
+
220
366
  {step === "openai_key" && (
221
367
  <ApiKeyStep
222
368
  {...API_KEY_CONFIGS.openai_key}
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
4
4
  import type {
5
5
  AppPreferences,
6
6
  InteractionMode,
7
+ LlmProvider,
7
8
  VoiceInteractionType,
8
9
  SpeechSpeed,
9
10
  ReasoningEffort,
@@ -31,13 +32,17 @@ interface SettingsMenuProps {
31
32
  speechSpeed: SpeechSpeed;
32
33
  reasoningEffort: ReasoningEffort;
33
34
  bashApprovalLevel: BashApprovalLevel;
35
+ modelProvider: LlmProvider;
34
36
  supportsReasoning: boolean;
37
+ supportsReasoningXHigh: boolean;
38
+ copilotAvailable: boolean;
35
39
  canEnableVoiceOutput: boolean;
36
40
  showFullReasoning: boolean;
37
41
  showToolOutput: boolean;
38
42
  memoryEnabled: boolean;
39
43
  onClose: () => void;
40
44
  toggleInteractionMode: () => void;
45
+ cycleModelProvider: () => void;
41
46
  setVoiceInteractionType: (type: VoiceInteractionType) => void;
42
47
  setSpeechSpeed: (speed: SpeechSpeed) => void;
43
48
  setReasoningEffort: (effort: ReasoningEffort) => void;
@@ -54,13 +59,17 @@ export function SettingsMenu({
54
59
  speechSpeed,
55
60
  reasoningEffort,
56
61
  bashApprovalLevel,
62
+ modelProvider,
57
63
  supportsReasoning,
64
+ supportsReasoningXHigh,
65
+ copilotAvailable,
58
66
  canEnableVoiceOutput,
59
67
  showFullReasoning,
60
68
  showToolOutput,
61
69
  memoryEnabled,
62
70
  onClose,
63
71
  toggleInteractionMode,
72
+ cycleModelProvider,
64
73
  setVoiceInteractionType,
65
74
  setSpeechSpeed,
66
75
  setReasoningEffort,
@@ -93,6 +102,18 @@ export function SettingsMenu({
93
102
  isToggle: true,
94
103
  disabled: interactionModeLocked,
95
104
  },
105
+ {
106
+ id: "model-provider",
107
+ label: "Model Provider",
108
+ value: modelProvider === "copilot" ? "COPILOT" : "OPENROUTER",
109
+ description:
110
+ !copilotAvailable && modelProvider === "openrouter"
111
+ ? "OpenRouter API with provider routing (Copilot: run `gh auth login` + `copilot login`)"
112
+ : modelProvider === "copilot"
113
+ ? "GitHub Copilot session runtime"
114
+ : "OpenRouter API with provider routing",
115
+ isToggle: true,
116
+ },
96
117
  {
97
118
  id: "voice-interaction-type",
98
119
  label: "Voice Flow",
@@ -108,7 +129,9 @@ export function SettingsMenu({
108
129
  label: "Reasoning Effort",
109
130
  value: supportsReasoning ? REASONING_EFFORT_LABELS[reasoningEffort] : "N/A",
110
131
  description: supportsReasoning
111
- ? "Depth of reasoning (LOW / MEDIUM / HIGH)"
132
+ ? supportsReasoningXHigh
133
+ ? "Depth of reasoning (LOW / MEDIUM / HIGH / XHIGH)"
134
+ : "Depth of reasoning (LOW / MEDIUM / HIGH)"
112
135
  : "Not supported by current model",
113
136
  isCyclic: supportsReasoning,
114
137
  },
@@ -187,17 +210,20 @@ export function SettingsMenu({
187
210
  selectedIdx,
188
211
  menuItemCount: selectableCount,
189
212
  interactionMode,
213
+ modelProvider,
190
214
  voiceInteractionType,
191
215
  speechSpeed,
192
216
  reasoningEffort,
193
217
  bashApprovalLevel,
194
218
  supportsReasoning,
219
+ supportsReasoningXHigh,
195
220
  canEnableVoiceOutput,
196
221
  showFullReasoning,
197
222
  showToolOutput,
198
223
  memoryEnabled,
199
224
  setSelectedIdx,
200
225
  toggleInteractionMode,
226
+ cycleModelProvider,
201
227
  setVoiceInteractionType,
202
228
  setSpeechSpeed,
203
229
  setReasoningEffort,
@@ -10,15 +10,17 @@ import { type ModelMetadata, calculateCost, formatContextUsage, formatCost } fro
10
10
  interface TokenUsageDisplayProps {
11
11
  usage: TokenUsage;
12
12
  modelMetadata?: ModelMetadata | null;
13
+ hideCost?: boolean;
13
14
  }
14
15
 
15
- export function TokenUsageDisplay({ usage, modelMetadata }: TokenUsageDisplayProps) {
16
+ export function TokenUsageDisplay({ usage, modelMetadata, hideCost = false }: TokenUsageDisplayProps) {
16
17
  const mainPromptTokens = usage.promptTokens;
17
18
  const mainCompletionTokens = usage.completionTokens;
18
19
 
19
20
  // Calculate cost if we have pricing info
20
- const cost =
21
- typeof usage.cost === "number"
21
+ const cost = hideCost
22
+ ? null
23
+ : typeof usage.cost === "number"
22
24
  ? usage.cost
23
25
  : modelMetadata?.pricing
24
26
  ? calculateCost(
@@ -2,6 +2,7 @@ import "./bash";
2
2
  import "./web-search";
3
3
  import "./url-tools";
4
4
  import "./read-file";
5
+ import "./write-file.tsx";
5
6
  import "./subagent";
6
7
  import "./todo";
7
8
  import "./system-info";
@@ -0,0 +1,117 @@
1
+ import { pathToFiletype } from "@opentui/core";
2
+ import React from "react";
3
+ import { COLORS, REASONING_MARKDOWN_STYLE } from "../../../ui/constants";
4
+ import { registerToolLayout } from "../registry";
5
+ import type { ToolHeader, ToolLayoutConfig, ToolLayoutRenderProps } from "../types";
6
+
7
+ type UnknownRecord = Record<string, unknown>;
8
+
9
+ function isRecord(value: unknown): value is UnknownRecord {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
+ }
12
+
13
+ function extractPath(input: unknown): string | null {
14
+ if (!isRecord(input)) return null;
15
+ if ("path" in input && typeof input.path === "string") {
16
+ return input.path;
17
+ }
18
+ return null;
19
+ }
20
+
21
+ function extractContent(input: unknown): string | null {
22
+ if (!isRecord(input)) return null;
23
+ if ("content" in input && typeof input.content === "string") {
24
+ return input.content;
25
+ }
26
+ return null;
27
+ }
28
+
29
+ function extractAppend(input: unknown): boolean {
30
+ if (!isRecord(input)) return false;
31
+ if ("append" in input && typeof input.append === "boolean") {
32
+ return input.append;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ function WriteFileBody({ call, result }: ToolLayoutRenderProps) {
38
+ if (!isRecord(result)) return null;
39
+ if (result.success === false && typeof result.error === "string") {
40
+ return (
41
+ <box paddingLeft={2}>
42
+ <text>
43
+ <span fg={COLORS.STATUS_FAILED}>{`error: ${result.error}`}</span>
44
+ </text>
45
+ </box>
46
+ );
47
+ }
48
+ if (result.success !== true) return null;
49
+
50
+ const content = extractContent(call.input) ?? "";
51
+ const path = extractPath(call.input) ?? "";
52
+
53
+ // Detect filetype from path for syntax highlighting
54
+ const filetype = pathToFiletype(path);
55
+
56
+ // Format content preview
57
+ let previewContent = "";
58
+ if (content.trim()) {
59
+ const MAX_LINES = 4;
60
+ const MAX_CHARS = 160;
61
+ const contentLines = content
62
+ .split("\n")
63
+ .slice(0, MAX_LINES)
64
+ .map((line) => (line.length > MAX_CHARS ? `${line.slice(0, MAX_CHARS - 1)}…` : line));
65
+
66
+ const totalLines = content.split("\n").length;
67
+ if (totalLines > MAX_LINES) {
68
+ contentLines.push(`... (${totalLines - MAX_LINES} more lines)`);
69
+ }
70
+ previewContent = contentLines.join("\n");
71
+ } else {
72
+ previewContent = "(empty file)";
73
+ }
74
+
75
+ return (
76
+ <box flexDirection="column" paddingLeft={2} marginTop={0}>
77
+ <box
78
+ borderStyle="single"
79
+ borderColor={COLORS.TOOL_INPUT_BORDER}
80
+ paddingLeft={1}
81
+ paddingRight={1}
82
+ paddingTop={0}
83
+ paddingBottom={0}
84
+ >
85
+ <code
86
+ content={previewContent}
87
+ filetype={filetype}
88
+ syntaxStyle={REASONING_MARKDOWN_STYLE}
89
+ conceal={true}
90
+ drawUnstyledText={false}
91
+ />
92
+ </box>
93
+ </box>
94
+ );
95
+ }
96
+
97
+ export const writeFileLayout: ToolLayoutConfig = {
98
+ abbreviation: "write",
99
+
100
+ getHeader: (input): ToolHeader | null => {
101
+ const path = extractPath(input);
102
+ if (!path) return null;
103
+ const append = extractAppend(input);
104
+ const filetype = pathToFiletype(path);
105
+
106
+ const parts: string[] = [];
107
+ if (filetype) parts.push(filetype);
108
+ if (append) parts.push("append");
109
+
110
+ const secondary = parts.length > 0 ? parts.join(" · ") : undefined;
111
+ return { primary: path, secondary, secondaryStyle: "dim" };
112
+ },
113
+
114
+ renderBody: WriteFileBody,
115
+ };
116
+
117
+ registerToolLayout("writeFile", writeFileLayout);
@@ -57,6 +57,31 @@ function clearAvatarToolEffects(avatar: DaemonAvatarRenderable | null): void {
57
57
  avatar.setTypingMode(false);
58
58
  }
59
59
 
60
+ function normalizeToolCallId(toolCallId: string | undefined): string | undefined {
61
+ if (typeof toolCallId !== "string") return undefined;
62
+ const trimmed = toolCallId.trim();
63
+ return trimmed.length > 0 ? trimmed : undefined;
64
+ }
65
+
66
+ function isInProgressToolCall(toolCall: ToolCall | undefined): boolean {
67
+ const status = toolCall?.status;
68
+ return status === "streaming" || status === "running" || status === "awaiting_approval";
69
+ }
70
+
71
+ function findExistingToolCall(
72
+ refs: EventHandlerRefs,
73
+ toolName: string,
74
+ toolCallId: string | undefined
75
+ ): ToolCall | undefined {
76
+ if (toolCallId) {
77
+ return refs.toolCallsByIdRef.current.get(toolCallId);
78
+ }
79
+
80
+ return [...refs.toolCallsRef.current]
81
+ .reverse()
82
+ .find((call) => call.name === toolName && isInProgressToolCall(call));
83
+ }
84
+
60
85
  export function createMemorySavedHandler() {
61
86
  return (preview: MemoryToastPreview) => {
62
87
  const description = preview.description?.trim();
@@ -401,15 +426,27 @@ export function createToolInputStartHandler(
401
426
  blocks.pop();
402
427
  }
403
428
 
429
+ const normalizedToolCallId = normalizeToolCallId(toolCallId);
430
+ const existingToolCall = findExistingToolCall(refs, toolName, normalizedToolCallId);
431
+ if (existingToolCall) {
432
+ if (existingToolCall.status === undefined || existingToolCall.status === "streaming") {
433
+ existingToolCall.status = "streaming";
434
+ }
435
+ setters.setCurrentContentBlocks([...blocks]);
436
+ return;
437
+ }
438
+
404
439
  const toolCall: ToolCall = {
405
440
  name: toolName,
406
441
  input: undefined,
407
- toolCallId,
442
+ toolCallId: normalizedToolCallId,
408
443
  status: "streaming",
409
444
  subagentSteps: toolName === "subagent" ? [] : undefined,
410
445
  };
411
446
  refs.toolCallsRef.current.push(toolCall);
412
- refs.toolCallsByIdRef.current.set(toolCallId, toolCall);
447
+ if (normalizedToolCallId) {
448
+ refs.toolCallsByIdRef.current.set(normalizedToolCallId, toolCall);
449
+ }
413
450
 
414
451
  blocks.push({ type: "tool", call: toolCall });
415
452
  setters.setCurrentContentBlocks([...blocks]);
@@ -437,18 +474,28 @@ export function createToolInvocationHandler(
437
474
 
438
475
  const blocks = refs.contentBlocksRef.current;
439
476
 
440
- const existingToolCall = toolCallId ? refs.toolCallsByIdRef.current.get(toolCallId) : undefined;
441
- if (existingToolCall && existingToolCall.status === "streaming") {
477
+ const normalizedToolCallId = normalizeToolCallId(toolCallId);
478
+ const existingToolCall = findExistingToolCall(refs, toolName, normalizedToolCallId);
479
+ if (existingToolCall) {
480
+ const shouldActivateToolAnimation =
481
+ existingToolCall.status === undefined ||
482
+ existingToolCall.status === "streaming" ||
483
+ existingToolCall.status === "awaiting_approval";
484
+
442
485
  existingToolCall.input = input;
443
- existingToolCall.status = "running";
486
+ if (shouldActivateToolAnimation) {
487
+ existingToolCall.status = "running";
488
+ }
444
489
  setters.setCurrentContentBlocks([...blocks]);
445
490
 
446
- const avatar = refs.avatarRef.current;
447
- if (avatar) {
448
- const category = getToolCategory(toolName);
449
- avatar.triggerToolFlash(category as ToolCategory | undefined);
450
- if (category !== "fast") {
451
- avatar.setToolActive(true, category as ToolCategory | undefined);
491
+ if (shouldActivateToolAnimation) {
492
+ const avatar = refs.avatarRef.current;
493
+ if (avatar) {
494
+ const category = getToolCategory(toolName);
495
+ avatar.triggerToolFlash(category as ToolCategory | undefined);
496
+ if (category !== "fast") {
497
+ avatar.setToolActive(true, category as ToolCategory | undefined);
498
+ }
452
499
  }
453
500
  }
454
501
  return;
@@ -462,14 +509,14 @@ export function createToolInvocationHandler(
462
509
  const toolCall: ToolCall = {
463
510
  name: toolName,
464
511
  input,
465
- toolCallId,
512
+ toolCallId: normalizedToolCallId,
466
513
  status: "running",
467
514
  subagentSteps: toolName === "subagent" ? [] : undefined,
468
515
  };
469
516
  refs.toolCallsRef.current.push(toolCall);
470
517
 
471
- if (toolCallId) {
472
- refs.toolCallsByIdRef.current.set(toolCallId, toolCall);
518
+ if (normalizedToolCallId) {
519
+ refs.toolCallsByIdRef.current.set(normalizedToolCallId, toolCall);
473
520
  }
474
521
 
475
522
  blocks.push({ type: "tool", call: toolCall });