@makefinks/daemon 0.1.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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/cli.js +22 -0
  4. package/package.json +79 -0
  5. package/src/ai/agent-turn-runner.ts +130 -0
  6. package/src/ai/daemon-ai.ts +403 -0
  7. package/src/ai/exa-client.ts +21 -0
  8. package/src/ai/exa-fetch-cache.ts +104 -0
  9. package/src/ai/model-config.ts +99 -0
  10. package/src/ai/sanitize-messages.ts +83 -0
  11. package/src/ai/system-prompt.ts +363 -0
  12. package/src/ai/tools/fetch-urls.ts +187 -0
  13. package/src/ai/tools/grounding-manager.ts +94 -0
  14. package/src/ai/tools/index.ts +52 -0
  15. package/src/ai/tools/read-file.ts +100 -0
  16. package/src/ai/tools/render-url.ts +275 -0
  17. package/src/ai/tools/run-bash.ts +224 -0
  18. package/src/ai/tools/subagents.ts +195 -0
  19. package/src/ai/tools/todo-manager.ts +150 -0
  20. package/src/ai/tools/web-search.ts +91 -0
  21. package/src/app/App.tsx +711 -0
  22. package/src/app/components/AppOverlays.tsx +131 -0
  23. package/src/app/components/AvatarLayer.tsx +51 -0
  24. package/src/app/components/ConversationPane.tsx +476 -0
  25. package/src/avatar/DaemonAvatarRenderable.ts +343 -0
  26. package/src/avatar/daemon-avatar-rig.ts +1165 -0
  27. package/src/avatar-preview.ts +186 -0
  28. package/src/cli.ts +26 -0
  29. package/src/components/ApiKeyInput.tsx +99 -0
  30. package/src/components/ApiKeyStep.tsx +95 -0
  31. package/src/components/ApprovalPicker.tsx +109 -0
  32. package/src/components/ContentBlockView.tsx +141 -0
  33. package/src/components/DaemonText.tsx +34 -0
  34. package/src/components/DeviceMenu.tsx +166 -0
  35. package/src/components/GroundingBadge.tsx +21 -0
  36. package/src/components/GroundingMenu.tsx +310 -0
  37. package/src/components/HotkeysPane.tsx +115 -0
  38. package/src/components/InlineStatusIndicator.tsx +106 -0
  39. package/src/components/ModelMenu.tsx +411 -0
  40. package/src/components/OnboardingOverlay.tsx +446 -0
  41. package/src/components/ProviderMenu.tsx +177 -0
  42. package/src/components/SessionMenu.tsx +297 -0
  43. package/src/components/SettingsMenu.tsx +291 -0
  44. package/src/components/StatusBar.tsx +126 -0
  45. package/src/components/TokenUsageDisplay.tsx +92 -0
  46. package/src/components/ToolCallView.tsx +113 -0
  47. package/src/components/TypingInputBar.tsx +131 -0
  48. package/src/components/tool-layouts/components.tsx +120 -0
  49. package/src/components/tool-layouts/defaults.ts +9 -0
  50. package/src/components/tool-layouts/index.ts +22 -0
  51. package/src/components/tool-layouts/layouts/bash.ts +110 -0
  52. package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
  53. package/src/components/tool-layouts/layouts/index.ts +8 -0
  54. package/src/components/tool-layouts/layouts/read-file.ts +59 -0
  55. package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
  56. package/src/components/tool-layouts/layouts/system-info.ts +8 -0
  57. package/src/components/tool-layouts/layouts/todo.tsx +139 -0
  58. package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
  59. package/src/components/tool-layouts/layouts/web-search.ts +110 -0
  60. package/src/components/tool-layouts/registry.ts +17 -0
  61. package/src/components/tool-layouts/types.ts +94 -0
  62. package/src/hooks/daemon-event-handlers.ts +944 -0
  63. package/src/hooks/keyboard-handlers.ts +399 -0
  64. package/src/hooks/menu-navigation.ts +147 -0
  65. package/src/hooks/use-app-audio-devices-loader.ts +71 -0
  66. package/src/hooks/use-app-callbacks.ts +202 -0
  67. package/src/hooks/use-app-context-builder.ts +159 -0
  68. package/src/hooks/use-app-display-state.ts +162 -0
  69. package/src/hooks/use-app-menus.ts +51 -0
  70. package/src/hooks/use-app-model-pricing-loader.ts +45 -0
  71. package/src/hooks/use-app-model.ts +123 -0
  72. package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
  73. package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
  74. package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
  75. package/src/hooks/use-app-sessions.ts +105 -0
  76. package/src/hooks/use-app-settings.ts +62 -0
  77. package/src/hooks/use-conversation-manager.ts +163 -0
  78. package/src/hooks/use-copy-on-select.ts +50 -0
  79. package/src/hooks/use-daemon-events.ts +396 -0
  80. package/src/hooks/use-daemon-keyboard.ts +397 -0
  81. package/src/hooks/use-grounding.ts +46 -0
  82. package/src/hooks/use-input-history.ts +92 -0
  83. package/src/hooks/use-menu-keyboard.ts +93 -0
  84. package/src/hooks/use-playwright-notification.ts +23 -0
  85. package/src/hooks/use-reasoning-animation.ts +97 -0
  86. package/src/hooks/use-response-timer.ts +55 -0
  87. package/src/hooks/use-tool-approval.tsx +202 -0
  88. package/src/hooks/use-typing-mode.ts +137 -0
  89. package/src/hooks/use-voice-dependencies-notification.ts +37 -0
  90. package/src/index.tsx +48 -0
  91. package/src/scripts/setup-browsers.ts +42 -0
  92. package/src/state/app-context.tsx +160 -0
  93. package/src/state/daemon-events.ts +67 -0
  94. package/src/state/daemon-state.ts +493 -0
  95. package/src/state/migrations/001-init.ts +33 -0
  96. package/src/state/migrations/index.ts +8 -0
  97. package/src/state/model-history-store.ts +45 -0
  98. package/src/state/runtime-context.ts +21 -0
  99. package/src/state/session-store.ts +359 -0
  100. package/src/types/index.ts +405 -0
  101. package/src/types/theme.ts +52 -0
  102. package/src/ui/constants.ts +157 -0
  103. package/src/utils/clipboard.ts +89 -0
  104. package/src/utils/debug-logger.ts +69 -0
  105. package/src/utils/formatters.ts +242 -0
  106. package/src/utils/js-rendering.ts +77 -0
  107. package/src/utils/markdown-tables.ts +234 -0
  108. package/src/utils/model-metadata.ts +191 -0
  109. package/src/utils/openrouter-endpoints.ts +212 -0
  110. package/src/utils/openrouter-models.ts +205 -0
  111. package/src/utils/openrouter-pricing.ts +59 -0
  112. package/src/utils/openrouter-reported-cost.ts +16 -0
  113. package/src/utils/paste.ts +33 -0
  114. package/src/utils/preferences.ts +289 -0
  115. package/src/utils/text-fragment.ts +39 -0
  116. package/src/utils/tool-output-preview.ts +250 -0
  117. package/src/utils/voice-dependencies.ts +107 -0
  118. package/src/utils/workspace-manager.ts +85 -0
  119. package/src/voice/audio-recorder.ts +579 -0
  120. package/src/voice/mic-level.ts +35 -0
  121. package/src/voice/tts/openai-tts-stream.ts +222 -0
  122. package/src/voice/tts/speech-controller.ts +64 -0
  123. package/src/voice/tts/tts-player.ts +257 -0
  124. package/src/voice/voice-input-controller.ts +96 -0
@@ -0,0 +1,944 @@
1
+ /**
2
+ * Event handler factories for daemon state manager events.
3
+ * Extracted from use-daemon-events.ts for better maintainability.
4
+ */
5
+
6
+ import { toast } from "@opentui-ui/toast/react";
7
+ import { getCurrentTodos } from "../ai/tools/todo-manager";
8
+ import type { DaemonAvatarRenderable } from "../avatar/DaemonAvatarRenderable";
9
+ import type { ToolCategory } from "../avatar/DaemonAvatarRenderable";
10
+ import { clearRuntimeContext, setRuntimeContext } from "../state/runtime-context";
11
+ import { saveSessionSnapshot } from "../state/session-store";
12
+ import type {
13
+ ContentBlock,
14
+ ConversationMessage,
15
+ ModelMessage,
16
+ SubagentStep,
17
+ TokenUsage,
18
+ ToolApprovalRequest,
19
+ ToolCall,
20
+ ToolResultOutput,
21
+ } from "../types";
22
+ import { DaemonState } from "../types";
23
+ import { REASONING_COLORS, STATE_COLORS } from "../types/theme";
24
+ import { REASONING_ANIMATION } from "../ui/constants";
25
+ import { debug } from "../utils/debug-logger";
26
+ import { hasVisibleText } from "../utils/formatters";
27
+
28
+ function getToolCategory(toolName: string): ToolCategory | "fast" | undefined {
29
+ if (toolName === "subagent") return "subagent";
30
+ if (toolName === "webSearch" || toolName === "fetchUrls" || toolName === "renderUrl") return "web";
31
+ if (toolName === "runBash" || toolName === "getSystemInfo") return "bash";
32
+ if (
33
+ toolName === "readFile" ||
34
+ toolName === "getFragmentCandidates" ||
35
+ toolName === "todoManager" ||
36
+ toolName === "groundingManager"
37
+ )
38
+ return "fast";
39
+ return undefined;
40
+ }
41
+
42
+ const INTERRUPTED_TOOL_RESULT = "Tool execution interrupted by user";
43
+
44
+ function normalizeInterruptedToolBlockResult(result: unknown): unknown {
45
+ if (result !== undefined) return result;
46
+ return { success: false, error: INTERRUPTED_TOOL_RESULT };
47
+ }
48
+
49
+ function normalizeInterruptedToolResultOutput(result: unknown): ToolResultOutput {
50
+ if (result === undefined) {
51
+ return { type: "error-text", value: INTERRUPTED_TOOL_RESULT };
52
+ }
53
+
54
+ if (typeof result === "string") {
55
+ return { type: "text", value: result };
56
+ }
57
+
58
+ try {
59
+ JSON.stringify(result);
60
+ return { type: "json", value: result as ToolResultOutput["value"] };
61
+ } catch {
62
+ return { type: "text", value: String(result) };
63
+ }
64
+ }
65
+
66
+ function clearAvatarToolEffects(avatar: DaemonAvatarRenderable | null): void {
67
+ if (!avatar) return;
68
+ avatar.triggerToolComplete();
69
+ avatar.setToolActive(false);
70
+ avatar.setReasoningMode(false);
71
+ avatar.setTypingMode(false);
72
+ }
73
+
74
+ function buildInterruptedContentBlocks(contentBlocks: ContentBlock[]): ContentBlock[] {
75
+ return contentBlocks.map((block) => {
76
+ if (block.type !== "tool") return { ...block };
77
+
78
+ const call = { ...block.call };
79
+ if (call.status === "running") {
80
+ call.status = "failed";
81
+ call.error = INTERRUPTED_TOOL_RESULT;
82
+ }
83
+ if (call.subagentSteps) {
84
+ call.subagentSteps = call.subagentSteps.map((step) =>
85
+ step.status === "running" ? { ...step, status: "failed" } : step
86
+ );
87
+ }
88
+
89
+ return {
90
+ ...block,
91
+ call,
92
+ result: normalizeInterruptedToolBlockResult(block.result),
93
+ };
94
+ });
95
+ }
96
+
97
+ export function buildInterruptedModelMessages(contentBlocks: ContentBlock[]): ModelMessage[] {
98
+ const messages: ModelMessage[] = [];
99
+
100
+ type AssistantPart =
101
+ | { type: "text"; text: string }
102
+ | { type: "reasoning"; text: string }
103
+ | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown };
104
+
105
+ type ToolResultPart = {
106
+ type: "tool-result";
107
+ toolCallId: string;
108
+ toolName: string;
109
+ output: ToolResultOutput;
110
+ };
111
+
112
+ let assistantParts: AssistantPart[] = [];
113
+ let toolResults: ToolResultPart[] = [];
114
+
115
+ for (const block of contentBlocks) {
116
+ if (block.type === "reasoning" && block.content) {
117
+ // Tool results must be emitted before new assistant reasoning.
118
+ if (toolResults.length > 0) {
119
+ messages.push({
120
+ role: "tool",
121
+ content: [...toolResults],
122
+ } as unknown as ModelMessage);
123
+ toolResults = [];
124
+ }
125
+
126
+ assistantParts.push({ type: "reasoning", text: block.content });
127
+ continue;
128
+ }
129
+
130
+ if (block.type === "text" && block.content) {
131
+ // Tool results must be emitted before new assistant text.
132
+ if (toolResults.length > 0) {
133
+ messages.push({
134
+ role: "tool",
135
+ content: [...toolResults],
136
+ } as unknown as ModelMessage);
137
+ toolResults = [];
138
+ }
139
+
140
+ assistantParts.push({ type: "text", text: block.content });
141
+ continue;
142
+ }
143
+
144
+ if (block.type === "tool") {
145
+ // Tool results must be emitted before a new tool call.
146
+ if (toolResults.length > 0) {
147
+ messages.push({
148
+ role: "tool",
149
+ content: [...toolResults],
150
+ } as unknown as ModelMessage);
151
+ toolResults = [];
152
+ }
153
+
154
+ const toolCallId = block.call.toolCallId;
155
+ if (!toolCallId) {
156
+ continue;
157
+ }
158
+
159
+ assistantParts.push({
160
+ type: "tool-call",
161
+ toolCallId,
162
+ toolName: block.call.name,
163
+ input: block.call.input ?? {},
164
+ });
165
+
166
+ // Tool calls must be emitted before their tool results.
167
+ if (assistantParts.length > 0) {
168
+ messages.push({
169
+ role: "assistant",
170
+ content: [...assistantParts],
171
+ } as unknown as ModelMessage);
172
+ assistantParts = [];
173
+ }
174
+
175
+ toolResults.push({
176
+ type: "tool-result",
177
+ toolCallId,
178
+ toolName: block.call.name,
179
+ output: normalizeInterruptedToolResultOutput(block.result),
180
+ });
181
+ }
182
+ }
183
+
184
+ if (assistantParts.length > 0) {
185
+ messages.push({
186
+ role: "assistant",
187
+ content: [...assistantParts],
188
+ } as unknown as ModelMessage);
189
+ }
190
+
191
+ if (toolResults.length > 0) {
192
+ messages.push({
193
+ role: "tool",
194
+ content: [...toolResults],
195
+ } as unknown as ModelMessage);
196
+ }
197
+
198
+ return messages;
199
+ }
200
+
201
+ function finalizePendingUserMessage(
202
+ prev: ConversationMessage[],
203
+ userText: string,
204
+ daemonMessage: ConversationMessage | null,
205
+ nextMessageId: () => number
206
+ ): ConversationMessage[] {
207
+ let pendingIndex = -1;
208
+ for (let i = prev.length - 1; i >= 0; i--) {
209
+ const msg = prev[i];
210
+ if (msg?.type === "user" && msg.pending) {
211
+ pendingIndex = i;
212
+ break;
213
+ }
214
+ }
215
+
216
+ const next = [...prev];
217
+ if (pendingIndex >= 0) {
218
+ const pendingMessage = next[pendingIndex];
219
+ if (pendingMessage) {
220
+ next[pendingIndex] = { ...pendingMessage, pending: false };
221
+ } else {
222
+ next.push({
223
+ id: nextMessageId(),
224
+ type: "user",
225
+ content: userText,
226
+ messages: [{ role: "user", content: userText }],
227
+ });
228
+ }
229
+ } else {
230
+ next.push({
231
+ id: nextMessageId(),
232
+ type: "user",
233
+ content: userText,
234
+ messages: [{ role: "user", content: userText }],
235
+ });
236
+ }
237
+
238
+ if (daemonMessage) {
239
+ next.push(daemonMessage);
240
+ }
241
+
242
+ return next;
243
+ }
244
+
245
+ /**
246
+ * Shared refs and state for event handlers.
247
+ */
248
+ export interface EventHandlerRefs {
249
+ avatarRef: React.RefObject<DaemonAvatarRenderable | null>;
250
+ hasStartedSpeakingRef: React.MutableRefObject<boolean>;
251
+ streamPhaseRef: React.MutableRefObject<"reasoning" | "text" | null>;
252
+ messageIdRef: React.MutableRefObject<number>;
253
+ currentUserInputRef: React.MutableRefObject<string>;
254
+ toolCallsRef: React.MutableRefObject<ToolCall[]>;
255
+ toolCallsByIdRef: React.MutableRefObject<Map<string, ToolCall>>;
256
+ contentBlocksRef: React.MutableRefObject<ContentBlock[]>;
257
+ reasoningStartAtRef: React.MutableRefObject<number | null>;
258
+ reasoningDurationMsRef: React.MutableRefObject<number | null>;
259
+ currentReasoningBlockRef: React.MutableRefObject<ContentBlock | null>;
260
+ sessionUsageRef: React.MutableRefObject<TokenUsage>;
261
+ fullReasoningRef: React.RefObject<string>;
262
+ }
263
+
264
+ /**
265
+ * State setters for event handlers.
266
+ */
267
+ export interface EventHandlerSetters {
268
+ setDaemonState: (state: DaemonState) => void;
269
+ setCurrentTranscription: (text: string) => void;
270
+ setCurrentResponse: React.Dispatch<React.SetStateAction<string>>;
271
+ setCurrentContentBlocks: React.Dispatch<React.SetStateAction<ContentBlock[]>>;
272
+ setConversationHistory: React.Dispatch<React.SetStateAction<ConversationMessage[]>>;
273
+ setSessionUsage: React.Dispatch<React.SetStateAction<TokenUsage>>;
274
+ setError: (error: string) => void;
275
+ setReasoningQueue: (queue: string | ((prev: string) => string)) => void;
276
+ setFullReasoning: (full: string | ((prev: string) => string)) => void;
277
+ }
278
+
279
+ /**
280
+ * Callbacks and dependencies for event handlers.
281
+ */
282
+ export interface EventHandlerDeps {
283
+ applyAvatarForState: (state: DaemonState) => void;
284
+ clearReasoningState: () => void;
285
+ clearReasoningTicker: () => void;
286
+ finalizeReasoningDuration: (endAt: number) => void;
287
+ sessionId: string | null;
288
+ sessionIdRef: React.RefObject<string | null>;
289
+ ensureSessionId: () => Promise<string>;
290
+ onFirstMessage?: (sessionId: string, message: string) => void;
291
+ addToHistory: (input: string) => void;
292
+ syncModelHistory: (history: ConversationMessage[]) => void;
293
+ }
294
+
295
+ /**
296
+ * Create handler for state change events.
297
+ */
298
+ export function createStateChangeHandler(
299
+ refs: EventHandlerRefs,
300
+ setters: EventHandlerSetters,
301
+ deps: EventHandlerDeps
302
+ ) {
303
+ return (state: DaemonState) => {
304
+ setters.setDaemonState(state);
305
+
306
+ if (state === DaemonState.IDLE) {
307
+ refs.hasStartedSpeakingRef.current = false;
308
+ refs.streamPhaseRef.current = null;
309
+ refs.reasoningStartAtRef.current = null;
310
+ refs.reasoningDurationMsRef.current = null;
311
+ refs.currentReasoningBlockRef.current = null;
312
+ clearRuntimeContext();
313
+ } else if (state === DaemonState.RESPONDING) {
314
+ refs.hasStartedSpeakingRef.current = false;
315
+ refs.streamPhaseRef.current = "reasoning";
316
+ refs.reasoningStartAtRef.current = null;
317
+ refs.reasoningDurationMsRef.current = null;
318
+ refs.currentReasoningBlockRef.current = null;
319
+ deps.clearReasoningState();
320
+ setters.setCurrentResponse("");
321
+ setters.setCurrentContentBlocks([]);
322
+ refs.toolCallsRef.current = [];
323
+ refs.toolCallsByIdRef.current.clear();
324
+ refs.contentBlocksRef.current = [];
325
+ setRuntimeContext(deps.sessionIdRef.current, refs.messageIdRef.current);
326
+ }
327
+
328
+ deps.applyAvatarForState(state);
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Create handler for mic level events.
334
+ */
335
+ export function createMicLevelHandler(refs: EventHandlerRefs, managerState: () => DaemonState) {
336
+ return (level: number) => {
337
+ const avatar = refs.avatarRef.current;
338
+ if (!avatar) return;
339
+ if (managerState() !== DaemonState.LISTENING) return;
340
+
341
+ const boosted = Math.min(1, level * 1.2);
342
+ avatar.setAudioLevel(boosted);
343
+ };
344
+ }
345
+
346
+ /**
347
+ * Create handler for TTS level events.
348
+ */
349
+ export function createTtsLevelHandler(refs: EventHandlerRefs, managerState: () => DaemonState) {
350
+ return (level: number) => {
351
+ const avatar = refs.avatarRef.current;
352
+ if (!avatar) return;
353
+ if (managerState() !== DaemonState.SPEAKING) return;
354
+ avatar.setAudioLevel(level);
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Create handler for transcription events.
360
+ */
361
+ export function createTranscriptionHandler(refs: EventHandlerRefs, setters: EventHandlerSetters) {
362
+ return (text: string) => {
363
+ setters.setCurrentTranscription(text);
364
+ refs.currentUserInputRef.current = text;
365
+ };
366
+ }
367
+
368
+ /**
369
+ * Create handler for user message events.
370
+ */
371
+ export function createUserMessageHandler(
372
+ refs: EventHandlerRefs,
373
+ setters: EventHandlerSetters,
374
+ deps: EventHandlerDeps
375
+ ) {
376
+ return (text: string) => {
377
+ if (!text.trim()) return;
378
+
379
+ deps.addToHistory(text);
380
+
381
+ if (!deps.sessionId) {
382
+ void deps.ensureSessionId();
383
+ }
384
+
385
+ const userMessage: ConversationMessage = {
386
+ id: refs.messageIdRef.current++,
387
+ type: "user",
388
+ content: text,
389
+ messages: [{ role: "user", content: text }],
390
+ pending: true,
391
+ };
392
+ refs.currentUserInputRef.current = text;
393
+ setters.setCurrentTranscription("");
394
+
395
+ setters.setConversationHistory((prev: ConversationMessage[]) => {
396
+ const isFirstMessage = prev.length === 0;
397
+ const next = [...prev, userMessage];
398
+ void (async () => {
399
+ const targetSessionId = deps.sessionId ?? (await deps.ensureSessionId());
400
+ await saveSessionSnapshot(
401
+ {
402
+ conversationHistory: next,
403
+ sessionUsage: refs.sessionUsageRef.current,
404
+ },
405
+ targetSessionId
406
+ );
407
+ if (isFirstMessage && deps.onFirstMessage) {
408
+ deps.onFirstMessage(targetSessionId, text);
409
+ }
410
+ })();
411
+ return next;
412
+ });
413
+ };
414
+ }
415
+
416
+ /**
417
+ * Create handler for reasoning token events.
418
+ */
419
+ export function createReasoningTokenHandler(refs: EventHandlerRefs, setters: EventHandlerSetters) {
420
+ return (token: string) => {
421
+ refs.streamPhaseRef.current = "reasoning";
422
+ const cleanToken = token.replace(/\n/g, " ");
423
+ if (refs.reasoningStartAtRef.current === null) {
424
+ refs.reasoningStartAtRef.current = Date.now();
425
+ }
426
+ setters.setReasoningQueue((prev: string) => prev + cleanToken);
427
+ setters.setFullReasoning((prev: string) => prev + token);
428
+ refs.fullReasoningRef.current = refs.fullReasoningRef.current + token;
429
+
430
+ const blocks = refs.contentBlocksRef.current;
431
+ const lastBlock = blocks[blocks.length - 1];
432
+ if (lastBlock && lastBlock.type === "reasoning") {
433
+ lastBlock.content += token;
434
+ refs.currentReasoningBlockRef.current = lastBlock;
435
+ } else {
436
+ const newBlock: ContentBlock = { type: "reasoning", content: token };
437
+ blocks.push(newBlock);
438
+ refs.currentReasoningBlockRef.current = newBlock;
439
+ }
440
+ setters.setCurrentContentBlocks([...blocks]);
441
+
442
+ // If the model emits additional reasoning mid-stream (after some text),
443
+ // shift the avatar back into the low-intensity reasoning phase.
444
+ if (refs.avatarRef.current) {
445
+ refs.avatarRef.current.setColors(REASONING_COLORS);
446
+ refs.avatarRef.current.setIntensity(REASONING_ANIMATION.INTENSITY);
447
+ refs.avatarRef.current.setReasoningMode(true);
448
+ }
449
+ };
450
+ }
451
+
452
+ /**
453
+ * Create handler for response token events.
454
+ */
455
+ export function createTokenHandler(
456
+ refs: EventHandlerRefs,
457
+ setters: EventHandlerSetters,
458
+ deps: EventHandlerDeps
459
+ ) {
460
+ return (token: string) => {
461
+ if (!token) return;
462
+
463
+ // Some models/providers emit standalone whitespace/newline "text-delta" tokens
464
+ // between reasoning/tool events. If we turn those into their own text blocks,
465
+ // the UI renders them as empty vertical gaps. Only keep whitespace tokens once
466
+ // we have visible text in the current text block.
467
+ const isWhitespaceOnly = token.trim().length === 0;
468
+ const blocks = refs.contentBlocksRef.current;
469
+ const lastBlock = blocks[blocks.length - 1];
470
+ if (isWhitespaceOnly) {
471
+ if (lastBlock?.type !== "text") return;
472
+ if (!hasVisibleText(lastBlock.content)) return;
473
+ } else {
474
+ refs.streamPhaseRef.current = "text";
475
+ }
476
+
477
+ // Any time we transition from reasoning back to text output, finalize the
478
+ // reasoning duration and clear the ticker (even if this isn't the first token).
479
+ // Some providers interleave whitespace-only text tokens (e.g. "\n")
480
+ // while still emitting reasoning deltas. Those tokens should not be treated as
481
+ // a "back to speaking" transition for avatar state or reasoning timing.
482
+ if (!isWhitespaceOnly && refs.reasoningStartAtRef.current !== null) {
483
+ deps.finalizeReasoningDuration(Date.now());
484
+ deps.clearReasoningTicker();
485
+ }
486
+
487
+ // Only treat "started speaking" as true once visible text arrives. This keeps
488
+ // the avatar in the reasoning phase if the model emits leading newlines.
489
+ if (!refs.hasStartedSpeakingRef.current && !isWhitespaceOnly) {
490
+ refs.hasStartedSpeakingRef.current = true;
491
+ }
492
+
493
+ setters.setCurrentResponse((prev: string) => prev + token);
494
+
495
+ if (lastBlock && lastBlock.type === "text") {
496
+ lastBlock.content += token;
497
+ } else {
498
+ blocks.push({ type: "text", content: token });
499
+ }
500
+ setters.setCurrentContentBlocks([...blocks]);
501
+
502
+ // Only switch the avatar into high-intensity speaking mode when we receive
503
+ // visible text. Whitespace-only tokens should not override a reasoning phase.
504
+ if (refs.avatarRef.current && !isWhitespaceOnly) {
505
+ refs.avatarRef.current.setColors(STATE_COLORS[DaemonState.RESPONDING]);
506
+ refs.avatarRef.current.setIntensity(0.7);
507
+ refs.avatarRef.current.setReasoningMode(false);
508
+ }
509
+ };
510
+ }
511
+
512
+ /**
513
+ * Create handler for tool input start events (streaming tool call detected).
514
+ */
515
+ export function createToolInputStartHandler(
516
+ refs: EventHandlerRefs,
517
+ setters: EventHandlerSetters,
518
+ deps: EventHandlerDeps
519
+ ) {
520
+ return (toolName: string, toolCallId: string) => {
521
+ refs.streamPhaseRef.current = "reasoning";
522
+ if (refs.avatarRef.current) {
523
+ refs.avatarRef.current.setColors(REASONING_COLORS);
524
+ refs.avatarRef.current.setIntensity(REASONING_ANIMATION.INTENSITY);
525
+ refs.avatarRef.current.setReasoningMode(true);
526
+ }
527
+
528
+ deps.finalizeReasoningDuration(Date.now());
529
+ deps.clearReasoningTicker();
530
+
531
+ const blocks = refs.contentBlocksRef.current;
532
+ const lastBlock = blocks[blocks.length - 1];
533
+ if (lastBlock?.type === "text" && !hasVisibleText(lastBlock.content)) {
534
+ blocks.pop();
535
+ }
536
+
537
+ const toolCall: ToolCall = {
538
+ name: toolName,
539
+ input: undefined,
540
+ toolCallId,
541
+ status: "streaming",
542
+ subagentSteps: toolName === "subagent" ? [] : undefined,
543
+ };
544
+ refs.toolCallsRef.current.push(toolCall);
545
+ refs.toolCallsByIdRef.current.set(toolCallId, toolCall);
546
+
547
+ blocks.push({ type: "tool", call: toolCall });
548
+ setters.setCurrentContentBlocks([...blocks]);
549
+ };
550
+ }
551
+
552
+ /**
553
+ * Create handler for tool invocation events.
554
+ */
555
+ export function createToolInvocationHandler(
556
+ refs: EventHandlerRefs,
557
+ setters: EventHandlerSetters,
558
+ deps: EventHandlerDeps
559
+ ) {
560
+ return (toolName: string, input: unknown, toolCallId?: string) => {
561
+ refs.streamPhaseRef.current = "reasoning";
562
+ if (refs.avatarRef.current) {
563
+ refs.avatarRef.current.setColors(REASONING_COLORS);
564
+ refs.avatarRef.current.setIntensity(REASONING_ANIMATION.INTENSITY);
565
+ refs.avatarRef.current.setReasoningMode(true);
566
+ }
567
+
568
+ deps.finalizeReasoningDuration(Date.now());
569
+ deps.clearReasoningTicker();
570
+
571
+ const blocks = refs.contentBlocksRef.current;
572
+
573
+ const existingToolCall = toolCallId ? refs.toolCallsByIdRef.current.get(toolCallId) : undefined;
574
+ if (existingToolCall && existingToolCall.status === "streaming") {
575
+ existingToolCall.input = input;
576
+ existingToolCall.status = "running";
577
+ setters.setCurrentContentBlocks([...blocks]);
578
+
579
+ const avatar = refs.avatarRef.current;
580
+ if (avatar) {
581
+ const category = getToolCategory(toolName);
582
+ avatar.triggerToolFlash(category as ToolCategory | undefined);
583
+ if (category !== "fast") {
584
+ avatar.setToolActive(true, category as ToolCategory | undefined);
585
+ }
586
+ }
587
+ return;
588
+ }
589
+
590
+ const lastBlock = blocks[blocks.length - 1];
591
+ if (lastBlock?.type === "text" && !hasVisibleText(lastBlock.content)) {
592
+ blocks.pop();
593
+ }
594
+
595
+ const toolCall: ToolCall = {
596
+ name: toolName,
597
+ input,
598
+ toolCallId,
599
+ status: "running",
600
+ subagentSteps: toolName === "subagent" ? [] : undefined,
601
+ };
602
+ refs.toolCallsRef.current.push(toolCall);
603
+
604
+ if (toolCallId) {
605
+ refs.toolCallsByIdRef.current.set(toolCallId, toolCall);
606
+ }
607
+
608
+ blocks.push({ type: "tool", call: toolCall });
609
+ setters.setCurrentContentBlocks([...blocks]);
610
+
611
+ const avatar = refs.avatarRef.current;
612
+ if (avatar) {
613
+ const category = getToolCategory(toolName);
614
+ avatar.triggerToolFlash(category as ToolCategory | undefined);
615
+ if (category !== "fast") {
616
+ avatar.setToolActive(true, category as ToolCategory | undefined);
617
+ }
618
+ }
619
+ };
620
+ }
621
+
622
+ /**
623
+ * Create handler for tool approval request events.
624
+ * Updates the tool call status to "awaiting_approval" when approval is needed.
625
+ */
626
+ export function createToolApprovalRequestHandler(refs: EventHandlerRefs, setters: EventHandlerSetters) {
627
+ return (request: ToolApprovalRequest) => {
628
+ const toolCall = refs.toolCallsByIdRef.current.get(request.toolCallId);
629
+ if (toolCall) {
630
+ toolCall.status = "awaiting_approval";
631
+ setters.setCurrentContentBlocks([...refs.contentBlocksRef.current]);
632
+ }
633
+ };
634
+ }
635
+
636
+ /**
637
+ * Create handler for tool approval resolved events.
638
+ * Updates the tool call's approvalResult when user approves/denies.
639
+ */
640
+ export function createToolApprovalResolvedHandler(refs: EventHandlerRefs, setters: EventHandlerSetters) {
641
+ return (toolCallId: string, approved: boolean) => {
642
+ const toolCall = refs.toolCallsByIdRef.current.get(toolCallId);
643
+ if (toolCall) {
644
+ toolCall.approvalResult = approved ? "approved" : "denied";
645
+ setters.setCurrentContentBlocks([...refs.contentBlocksRef.current]);
646
+ }
647
+ };
648
+ }
649
+
650
+ /**
651
+ * Create handler for subagent tool call events.
652
+ */
653
+ export function createSubagentToolCallHandler(refs: EventHandlerRefs, setters: EventHandlerSetters) {
654
+ return (toolCallId: string, toolName: string, input?: unknown) => {
655
+ const toolCall = refs.toolCallsByIdRef.current.get(toolCallId);
656
+ if (!toolCall || !toolCall.subagentSteps) return;
657
+
658
+ const step: SubagentStep = {
659
+ toolName,
660
+ status: "running",
661
+ input,
662
+ };
663
+ toolCall.subagentSteps = [...toolCall.subagentSteps, step];
664
+ setters.setCurrentContentBlocks([...refs.contentBlocksRef.current]);
665
+ };
666
+ }
667
+
668
+ /**
669
+ * Create handler for subagent tool result events.
670
+ */
671
+ export function createSubagentToolResultHandler(refs: EventHandlerRefs, setters: EventHandlerSetters) {
672
+ return (toolCallId: string, toolName: string, success: boolean) => {
673
+ const toolCall = refs.toolCallsByIdRef.current.get(toolCallId);
674
+ if (!toolCall || !toolCall.subagentSteps) return;
675
+
676
+ let updated = false;
677
+ const nextSteps: SubagentStep[] = toolCall.subagentSteps.map((step) => {
678
+ if (!updated && step.toolName === toolName && step.status === "running") {
679
+ updated = true;
680
+ return {
681
+ ...step,
682
+ status: (success ? "completed" : "failed") as SubagentStep["status"],
683
+ };
684
+ }
685
+ return step;
686
+ });
687
+ toolCall.subagentSteps = nextSteps;
688
+ setters.setCurrentContentBlocks([...refs.contentBlocksRef.current]);
689
+ };
690
+ }
691
+
692
+ /**
693
+ * Create handler for subagent complete events.
694
+ */
695
+ export function createSubagentCompleteHandler(refs: EventHandlerRefs, setters: EventHandlerSetters) {
696
+ return (toolCallId: string, success: boolean) => {
697
+ const toolCall = refs.toolCallsByIdRef.current.get(toolCallId);
698
+ if (!toolCall) return;
699
+ toolCall.status = success ? "completed" : "failed";
700
+ setters.setCurrentContentBlocks([...refs.contentBlocksRef.current]);
701
+ };
702
+ }
703
+
704
+ function mergeTokenUsage(prev: TokenUsage, usage: TokenUsage, isSubagent: boolean): TokenUsage {
705
+ const currentCost =
706
+ prev.cost !== undefined || usage.cost !== undefined ? (prev.cost ?? 0) + (usage.cost ?? 0) : undefined;
707
+
708
+ if (isSubagent) {
709
+ return {
710
+ ...prev,
711
+ cost: currentCost,
712
+ subagentTotalTokens: (prev.subagentTotalTokens ?? 0) + usage.totalTokens,
713
+ subagentPromptTokens: (prev.subagentPromptTokens ?? 0) + usage.promptTokens,
714
+ subagentCompletionTokens: (prev.subagentCompletionTokens ?? 0) + usage.completionTokens,
715
+ };
716
+ }
717
+
718
+ return {
719
+ cost: currentCost,
720
+ promptTokens: prev.promptTokens + usage.promptTokens,
721
+ completionTokens: prev.completionTokens + usage.completionTokens,
722
+ totalTokens: prev.totalTokens + usage.totalTokens,
723
+ reasoningTokens: (prev.reasoningTokens ?? 0) + (usage.reasoningTokens ?? 0) || undefined,
724
+ cachedInputTokens: (prev.cachedInputTokens ?? 0) + (usage.cachedInputTokens ?? 0) || undefined,
725
+ subagentTotalTokens: prev.subagentTotalTokens,
726
+ subagentPromptTokens: prev.subagentPromptTokens,
727
+ subagentCompletionTokens: prev.subagentCompletionTokens,
728
+ };
729
+ }
730
+
731
+ /**
732
+ * Create handler for step usage events.
733
+ */
734
+ export function createStepUsageHandler(setters: EventHandlerSetters) {
735
+ return (usage: TokenUsage) => {
736
+ setters.setSessionUsage((prev: TokenUsage) => mergeTokenUsage(prev, usage, false));
737
+ };
738
+ }
739
+
740
+ /**
741
+ * Create handler for subagent usage events.
742
+ */
743
+ export function createSubagentUsageHandler(setters: EventHandlerSetters) {
744
+ return (usage: TokenUsage) => {
745
+ setters.setSessionUsage((prev: TokenUsage) => mergeTokenUsage(prev, usage, true));
746
+ };
747
+ }
748
+
749
+ /**
750
+ * Create handler for tool result events.
751
+ */
752
+ export function createToolResultHandler(refs: EventHandlerRefs, setters: EventHandlerSetters) {
753
+ return (toolName: string, result: unknown, toolCallId?: string) => {
754
+ const errorMessage =
755
+ typeof result === "object" &&
756
+ result !== null &&
757
+ "error" in result &&
758
+ typeof (result as { error?: unknown }).error === "string"
759
+ ? ((result as { error?: string }).error ?? "").trim()
760
+ : undefined;
761
+ const isErrorResult = errorMessage !== undefined && errorMessage.length > 0;
762
+
763
+ // Mark the tool as completed or failed based on the payload.
764
+ const toolCall =
765
+ (toolCallId ? refs.toolCallsByIdRef.current.get(toolCallId) : undefined) ??
766
+ [...refs.toolCallsRef.current].reverse().find((t) => t.name === toolName && t.status === "running");
767
+ if (toolCall) {
768
+ toolCall.status = isErrorResult ? "failed" : "completed";
769
+ if (isErrorResult) {
770
+ toolCall.error = errorMessage;
771
+ }
772
+ const blocks = refs.contentBlocksRef.current;
773
+ const toolBlock = [...blocks].reverse().find((b) => b.type === "tool" && b.call === toolCall);
774
+ if (toolBlock && toolBlock.type === "tool") {
775
+ toolBlock.result = result;
776
+ }
777
+ }
778
+
779
+ if (toolName === "todoManager" && toolCallId) {
780
+ const todoToolCall = refs.toolCallsByIdRef.current.get(toolCallId);
781
+ if (todoToolCall) {
782
+ const currentTodos = getCurrentTodos();
783
+ todoToolCall.todoSnapshot = currentTodos.map((t) => ({
784
+ content: t.content,
785
+ status: t.status,
786
+ }));
787
+ }
788
+ }
789
+
790
+ setters.setCurrentContentBlocks([...refs.contentBlocksRef.current]);
791
+
792
+ const avatar = refs.avatarRef.current;
793
+ if (avatar) {
794
+ const category = getToolCategory(toolName);
795
+ if (category !== "fast") {
796
+ avatar.triggerToolComplete();
797
+ avatar.setToolActive(false);
798
+ }
799
+ }
800
+ };
801
+ }
802
+
803
+ /**
804
+ * Create handler for response complete events.
805
+ */
806
+ export function createCompleteHandler(
807
+ refs: EventHandlerRefs,
808
+ setters: EventHandlerSetters,
809
+ deps: EventHandlerDeps
810
+ ) {
811
+ return (fullText: string, responseMessages: ModelMessage[], _usage: TokenUsage | undefined) => {
812
+ refs.hasStartedSpeakingRef.current = false;
813
+ deps.finalizeReasoningDuration(Date.now());
814
+ clearAvatarToolEffects(refs.avatarRef.current);
815
+
816
+ const userText = refs.currentUserInputRef.current;
817
+ const contentBlocks =
818
+ refs.contentBlocksRef.current.length > 0 ? [...refs.contentBlocksRef.current] : undefined;
819
+ if (userText) {
820
+ const daemonMessage: ConversationMessage = {
821
+ id: refs.messageIdRef.current++,
822
+ type: "daemon",
823
+ content: fullText,
824
+ messages: responseMessages,
825
+ contentBlocks,
826
+ };
827
+
828
+ setters.setConversationHistory((prev: ConversationMessage[]) => {
829
+ const next = finalizePendingUserMessage(
830
+ prev,
831
+ userText,
832
+ daemonMessage,
833
+ () => refs.messageIdRef.current++
834
+ );
835
+
836
+ void (async () => {
837
+ const targetSessionId = deps.sessionId ?? (await deps.ensureSessionId());
838
+ await saveSessionSnapshot(
839
+ {
840
+ conversationHistory: next,
841
+ sessionUsage: refs.sessionUsageRef.current,
842
+ },
843
+ targetSessionId
844
+ );
845
+ })();
846
+
847
+ return next;
848
+ });
849
+ }
850
+
851
+ setters.setCurrentTranscription("");
852
+ deps.clearReasoningState();
853
+ setters.setCurrentResponse("");
854
+ setters.setCurrentContentBlocks([]);
855
+ refs.toolCallsRef.current = [];
856
+ refs.toolCallsByIdRef.current.clear();
857
+ refs.contentBlocksRef.current = [];
858
+ refs.currentUserInputRef.current = "";
859
+ };
860
+ }
861
+
862
+ /**
863
+ * Create handler for cancelled events.
864
+ */
865
+ export function createCancelledHandler(
866
+ refs: EventHandlerRefs,
867
+ setters: EventHandlerSetters,
868
+ deps: EventHandlerDeps
869
+ ) {
870
+ return () => {
871
+ refs.hasStartedSpeakingRef.current = false;
872
+ deps.finalizeReasoningDuration(Date.now());
873
+ clearAvatarToolEffects(refs.avatarRef.current);
874
+
875
+ const userText = refs.currentUserInputRef.current;
876
+ const hasBlocks = refs.contentBlocksRef.current.length > 0;
877
+ const contentBlocks = hasBlocks ? buildInterruptedContentBlocks(refs.contentBlocksRef.current) : [];
878
+
879
+ debug.info("agent-turn-incomplete", {
880
+ userText,
881
+ contentBlocks,
882
+ });
883
+
884
+ if (userText) {
885
+ const responseMessages = hasBlocks ? buildInterruptedModelMessages(contentBlocks) : [];
886
+ const daemonMessage: ConversationMessage | null = hasBlocks
887
+ ? {
888
+ id: refs.messageIdRef.current++,
889
+ type: "daemon",
890
+ content: "",
891
+ messages: responseMessages,
892
+ contentBlocks,
893
+ }
894
+ : null;
895
+
896
+ debug.info("agent-turn-incomplete-messages", {
897
+ responseMessages,
898
+ });
899
+
900
+ setters.setConversationHistory((prev: ConversationMessage[]) => {
901
+ const next = finalizePendingUserMessage(
902
+ prev,
903
+ userText,
904
+ daemonMessage,
905
+ () => refs.messageIdRef.current++
906
+ );
907
+
908
+ void (async () => {
909
+ const targetSessionId = deps.sessionId ?? (await deps.ensureSessionId());
910
+ await saveSessionSnapshot(
911
+ {
912
+ conversationHistory: next,
913
+ sessionUsage: refs.sessionUsageRef.current,
914
+ },
915
+ targetSessionId
916
+ );
917
+ })();
918
+
919
+ deps.syncModelHistory(next);
920
+
921
+ return next;
922
+ });
923
+ }
924
+
925
+ setters.setCurrentTranscription("");
926
+ deps.clearReasoningState();
927
+ setters.setCurrentResponse("");
928
+ setters.setCurrentContentBlocks([]);
929
+ refs.toolCallsRef.current = [];
930
+ refs.toolCallsByIdRef.current.clear();
931
+ refs.contentBlocksRef.current = [];
932
+ refs.currentUserInputRef.current = "";
933
+ };
934
+ }
935
+
936
+ /**
937
+ * Create handler for error events.
938
+ */
939
+ export function createErrorHandler(setters: EventHandlerSetters) {
940
+ return (err: Error) => {
941
+ setters.setError(err.message);
942
+ setTimeout(() => setters.setError(""), 5000);
943
+ };
944
+ }