@makefinks/daemon 0.1.4 → 0.2.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.
package/package.json CHANGED
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "module": "src/index.tsx",
30
30
  "type": "module",
31
- "version": "0.1.4",
31
+ "version": "0.2.0",
32
32
  "bin": {
33
33
  "daemon": "dist/cli.js"
34
34
  },
@@ -54,12 +54,13 @@
54
54
  "preview:avatar": "bun run src/avatar-preview.ts",
55
55
  "preview:avatar:mp4": "bun run src/avatar-preview.ts --mp4 tmp/avatar-preview.mp4",
56
56
  "setup:browsers": "bun run src/scripts/setup-browsers.ts",
57
+ "setup:hooks": "bash .githooks/setup.sh",
57
58
  "test": "bun test",
58
59
  "test:watch": "bun test --watch",
59
60
  "prepublishOnly": "bun run build:cli",
60
- "release:patch": "npm version patch && git push && git push --tags && npm publish",
61
- "release:minor": "npm version minor && git push && git push --tags && npm publish",
62
- "release:major": "npm version major && git push && git push --tags && npm publish"
61
+ "release:patch": "npm version patch && git push && git push --tags && gh release create v$(node -p \"require('./package.json').version\") --generate-notes && npm publish",
62
+ "release:minor": "npm version minor && git push && git push --tags && gh release create v$(node -p \"require('./package.json').version\") --generate-notes && npm publish",
63
+ "release:major": "npm version major && git push && git push --tags && gh release create v$(node -p \"require('./package.json').version\") --generate-notes && npm publish"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@biomejs/biome": "^1.9.4",
package/src/app/App.tsx CHANGED
@@ -19,7 +19,9 @@ import { useCopyOnSelect } from "../hooks/use-copy-on-select";
19
19
  import { useDaemonEvents } from "../hooks/use-daemon-events";
20
20
  import { useDaemonKeyboard } from "../hooks/use-daemon-keyboard";
21
21
  import { useGrounding } from "../hooks/use-grounding";
22
+ import { useGroundingMenuController } from "../hooks/use-grounding-menu-controller";
22
23
  import { useInputHistory } from "../hooks/use-input-history";
24
+ import { useOverlayController } from "../hooks/use-overlay-controller";
23
25
  import { usePlaywrightNotification } from "../hooks/use-playwright-notification";
24
26
  import { useReasoningAnimation } from "../hooks/use-reasoning-animation";
25
27
  import { useResponseTimer } from "../hooks/use-response-timer";
@@ -30,11 +32,9 @@ import { AppProvider } from "../state/app-context";
30
32
  import { daemonEvents } from "../state/daemon-events";
31
33
  import { getDaemonManager } from "../state/daemon-state";
32
34
  import { deleteSession } from "../state/session-store";
33
- import { DaemonState } from "../types";
34
35
  import type { AppPreferences, AudioDevice, OnboardingStep } from "../types";
36
+ import { DaemonState } from "../types";
35
37
  import { COLORS } from "../ui/constants";
36
- import { openUrlInBrowser } from "../utils/preferences";
37
- import { buildTextFragmentUrl } from "../utils/text-fragment";
38
38
  import { getSoxInstallHint, isSoxAvailable, setAudioDevice } from "../voice/audio-recorder";
39
39
  import { AppOverlays } from "./components/AppOverlays";
40
40
  import { AvatarLayer } from "./components/AvatarLayer";
@@ -115,6 +115,8 @@ export function App() {
115
115
  setShowHotkeysPane,
116
116
  showGroundingMenu,
117
117
  setShowGroundingMenu,
118
+ showUrlMenu,
119
+ setShowUrlMenu,
118
120
  } = menus;
119
121
 
120
122
  const {
@@ -128,7 +130,13 @@ export function App() {
128
130
  } = useAppSessions({ showSessionMenu });
129
131
 
130
132
  const { latestGroundingMap, hasGrounding } = useGrounding(currentSessionId);
131
- const [groundingSelectedIndex, setGroundingSelectedIndex] = useState(0);
133
+ const {
134
+ groundingInitialIndex,
135
+ groundingSelectedIndex,
136
+ setGroundingSelectedIndex,
137
+ onGroundingSelect,
138
+ onGroundingIndexChange,
139
+ } = useGroundingMenuController({ sessionId: currentSessionId, latestGroundingMap });
132
140
 
133
141
  const appSettings = useAppSettings();
134
142
  const {
@@ -379,6 +387,7 @@ export function App() {
379
387
  setShowSessionMenu,
380
388
  setShowHotkeysPane,
381
389
  setShowGroundingMenu,
390
+ setShowUrlMenu,
382
391
  setTypingInput,
383
392
  setCurrentTranscription,
384
393
  setCurrentResponse,
@@ -401,6 +410,7 @@ export function App() {
401
410
  setShowSessionMenu,
402
411
  setShowHotkeysPane,
403
412
  setShowGroundingMenu,
413
+ setShowUrlMenu,
404
414
  setTypingInput,
405
415
  setCurrentTranscription,
406
416
  setCurrentResponse,
@@ -420,17 +430,33 @@ export function App() {
420
430
  const hasInteracted =
421
431
  conversationHistory.length > 0 || currentTranscription.length > 0 || currentContentBlocks.length > 0;
422
432
 
433
+ const { isOverlayOpen } = useOverlayController(
434
+ {
435
+ showDeviceMenu,
436
+ showSettingsMenu,
437
+ showModelMenu,
438
+ showProviderMenu,
439
+ showSessionMenu,
440
+ showHotkeysPane,
441
+ showGroundingMenu,
442
+ showUrlMenu,
443
+ onboardingActive,
444
+ },
445
+ {
446
+ setShowDeviceMenu,
447
+ setShowSettingsMenu,
448
+ setShowModelMenu,
449
+ setShowProviderMenu,
450
+ setShowSessionMenu,
451
+ setShowHotkeysPane,
452
+ setShowGroundingMenu,
453
+ setShowUrlMenu,
454
+ }
455
+ );
456
+
423
457
  useDaemonKeyboard(
424
458
  {
425
- isOverlayOpen:
426
- showDeviceMenu ||
427
- showSettingsMenu ||
428
- showModelMenu ||
429
- showProviderMenu ||
430
- showSessionMenu ||
431
- showHotkeysPane ||
432
- showGroundingMenu ||
433
- onboardingActive,
459
+ isOverlayOpen,
434
460
  escPendingCancel,
435
461
  hasInteracted,
436
462
  hasGrounding,
@@ -481,24 +507,6 @@ export function App() {
481
507
  }
482
508
  }, [daemonState]);
483
509
 
484
- const openGroundingSource = useCallback(
485
- (idx: number) => {
486
- if (!latestGroundingMap) return;
487
- const item = latestGroundingMap.items[idx];
488
- if (!item) return;
489
- const { source } = item;
490
- const url = source.textFragment
491
- ? buildTextFragmentUrl(source.url, { fragmentText: source.textFragment })
492
- : source.url;
493
- openUrlInBrowser(url);
494
- },
495
- [latestGroundingMap]
496
- );
497
-
498
- const groundingInitialIndex = latestGroundingMap
499
- ? Math.min(groundingSelectedIndex, Math.max(0, latestGroundingMap.items.length - 1))
500
- : 0;
501
-
502
510
  const conversationDisplayState: ConversationDisplayState = {
503
511
  conversationHistory,
504
512
  currentTranscription,
@@ -556,6 +564,8 @@ export function App() {
556
564
  setShowHotkeysPane,
557
565
  showGroundingMenu,
558
566
  setShowGroundingMenu,
567
+ showUrlMenu,
568
+ setShowUrlMenu,
559
569
  },
560
570
  device: {
561
571
  devices,
@@ -630,11 +640,8 @@ export function App() {
630
640
  onSessionDelete: handleSessionDelete,
631
641
  },
632
642
  groundingCallbacks: {
633
- onGroundingSelect: (index: number) => {
634
- setGroundingSelectedIndex(index);
635
- openGroundingSource(index);
636
- },
637
- onGroundingIndexChange: setGroundingSelectedIndex,
643
+ onGroundingSelect,
644
+ onGroundingIndexChange,
638
645
  },
639
646
  onboardingCallbacks: {
640
647
  onKeySubmit: handleApiKeySubmit,
@@ -702,7 +709,10 @@ export function App() {
702
709
  </box>
703
710
 
704
711
  <AppProvider value={appContextValue}>
705
- <AppOverlays />
712
+ <AppOverlays
713
+ conversationHistory={conversationHistory}
714
+ currentContentBlocks={currentContentBlocks}
715
+ />
706
716
  </AppProvider>
707
717
  </>
708
718
  </box>
@@ -7,11 +7,25 @@ import { OnboardingOverlay } from "../../components/OnboardingOverlay";
7
7
  import { ProviderMenu } from "../../components/ProviderMenu";
8
8
  import { SessionMenu } from "../../components/SessionMenu";
9
9
  import { SettingsMenu } from "../../components/SettingsMenu";
10
+ import { UrlMenu } from "../../components/UrlMenu";
11
+ import { useUrlMenuItems } from "../../hooks/use-url-menu-items";
10
12
  import { useAppContext } from "../../state/app-context";
13
+ import type { ContentBlock, ConversationMessage } from "../../types";
11
14
 
12
- function AppOverlaysImpl() {
15
+ interface AppOverlaysProps {
16
+ conversationHistory: ConversationMessage[];
17
+ currentContentBlocks: ContentBlock[];
18
+ }
19
+
20
+ function AppOverlaysImpl({ conversationHistory, currentContentBlocks }: AppOverlaysProps) {
13
21
  const ctx = useAppContext();
14
22
  const { menus, device, settings, model, session, grounding, onboarding } = ctx;
23
+
24
+ const urlMenuItems = useUrlMenuItems({
25
+ conversationHistory,
26
+ currentContentBlocks,
27
+ latestGroundingMap: grounding.latestGroundingMap,
28
+ });
15
29
  const {
16
30
  deviceCallbacks,
17
31
  settingsCallbacks,
@@ -104,6 +118,8 @@ function AppOverlaysImpl() {
104
118
  />
105
119
  )}
106
120
 
121
+ {menus.showUrlMenu && <UrlMenu items={urlMenuItems} onClose={() => menus.setShowUrlMenu(false)} />}
122
+
107
123
  {onboarding.onboardingActive && (
108
124
  <OnboardingOverlay
109
125
  step={onboarding.onboardingStep}
@@ -428,9 +428,11 @@ function ConversationPaneImpl(props: ConversationPaneProps) {
428
428
  </box>
429
429
  )}
430
430
 
431
- {hasGrounding && groundingCount && daemonState === DaemonState.IDLE && (
432
- <GroundingBadge count={groundingCount} />
433
- )}
431
+ {hasGrounding &&
432
+ groundingCount &&
433
+ (daemonState === DaemonState.IDLE || daemonState === DaemonState.SPEAKING) && (
434
+ <GroundingBadge count={groundingCount} />
435
+ )}
434
436
 
435
437
  {showWorkingSpinner && (
436
438
  <InlineStatusIndicator
@@ -40,6 +40,7 @@ export function HotkeysPane({ onClose }: HotkeysPaneProps) {
40
40
  { key: "O", label: "Toggle tool output previews" },
41
41
  { key: "N", label: "New session" },
42
42
  { key: "G", label: "Open Grounding Menu" },
43
+ { key: "U", label: "Open URL Menu" },
43
44
  { key: "CTRL+X", label: "Undo last message" },
44
45
  ],
45
46
  },
@@ -2,10 +2,10 @@
2
2
  * Component for displaying token usage statistics.
3
3
  */
4
4
 
5
+ import type { TokenUsage } from "../types";
5
6
  import { COLORS } from "../ui/constants";
6
7
  import { formatTokenCount } from "../utils/formatters";
7
- import { calculateCost, formatCost, formatContextUsage, type ModelMetadata } from "../utils/model-metadata";
8
- import type { TokenUsage } from "../types";
8
+ import { type ModelMetadata, calculateCost, formatContextUsage, formatCost } from "../utils/model-metadata";
9
9
 
10
10
  interface TokenUsageDisplayProps {
11
11
  usage: TokenUsage;
@@ -15,7 +15,6 @@ interface TokenUsageDisplayProps {
15
15
  export function TokenUsageDisplay({ usage, modelMetadata }: TokenUsageDisplayProps) {
16
16
  const mainPromptTokens = usage.promptTokens;
17
17
  const mainCompletionTokens = usage.completionTokens;
18
- const mainTotalTokens = mainPromptTokens + mainCompletionTokens;
19
18
 
20
19
  // Calculate cost if we have pricing info
21
20
  const cost =
@@ -30,11 +29,12 @@ export function TokenUsageDisplay({ usage, modelMetadata }: TokenUsageDisplayPro
30
29
  )
31
30
  : null;
32
31
 
33
- // Calculate context usage percentage
34
- const contextTotalTokens = mainTotalTokens;
35
- const contextUsage = modelMetadata?.contextLength
36
- ? formatContextUsage(contextTotalTokens, modelMetadata.contextLength)
37
- : null;
32
+ // Context % uses latest turn only (prompt already includes full history, completion is that turn's output)
33
+ const latestTurnTotal = (usage.latestTurnPromptTokens ?? 0) + (usage.latestTurnCompletionTokens ?? 0);
34
+ const contextUsage =
35
+ modelMetadata?.contextLength && latestTurnTotal > 0
36
+ ? formatContextUsage(latestTurnTotal, modelMetadata.contextLength)
37
+ : null;
38
38
 
39
39
  return (
40
40
  <box
@@ -47,12 +47,12 @@ export function TokenUsageDisplay({ usage, modelMetadata }: TokenUsageDisplayPro
47
47
  >
48
48
  <text>
49
49
  {/* Context usage: X/Y (Z%) */}
50
- {modelMetadata?.contextLength && (
50
+ {contextUsage && (
51
51
  <>
52
52
  <span fg={COLORS.TOKEN_USAGE_LABEL}>Tokens: </span>
53
- <span fg={COLORS.TOKEN_USAGE}>{formatTokenCount(contextTotalTokens)}</span>
53
+ <span fg={COLORS.TOKEN_USAGE}>{formatTokenCount(latestTurnTotal)}</span>
54
54
  <span fg={COLORS.TOKEN_USAGE_LABEL}>/</span>
55
- <span fg={COLORS.TOKEN_USAGE}>{formatTokenCount(modelMetadata.contextLength)}</span>
55
+ <span fg={COLORS.TOKEN_USAGE}>{formatTokenCount(modelMetadata!.contextLength)}</span>
56
56
  <span fg={COLORS.REASONING_DIM}> ({contextUsage})</span>
57
57
  <span fg={COLORS.TOKEN_USAGE_LABEL}> · </span>
58
58
  </>
@@ -0,0 +1,182 @@
1
+ import type { KeyEvent, ScrollBoxRenderable } from "@opentui/core";
2
+ import { useKeyboard, useRenderer } from "@opentui/react";
3
+ import { useCallback, useMemo, useRef } from "react";
4
+ import { COLORS } from "../ui/constants";
5
+
6
+ import type { UrlMenuItem } from "../types";
7
+
8
+ interface UrlMenuProps {
9
+ items: UrlMenuItem[];
10
+ onClose: () => void;
11
+ }
12
+
13
+ const SCROLL_AMOUNT = 1;
14
+
15
+ function splitUrl(url: string): { origin: string; path: string } {
16
+ try {
17
+ const parsed = new URL(url);
18
+ return { origin: parsed.origin, path: parsed.pathname + parsed.search + parsed.hash };
19
+ } catch {
20
+ const match = url.match(/^(https?:\/\/[^/]+)(\/.*)?$/);
21
+ if (match) {
22
+ return { origin: match[1] ?? url, path: match[2] ?? "" };
23
+ }
24
+ return { origin: url, path: "" };
25
+ }
26
+ }
27
+
28
+ export function UrlMenu({ items, onClose }: UrlMenuProps) {
29
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
30
+ const renderer = useRenderer();
31
+
32
+ const sortedItems = useMemo(() => {
33
+ const next = [...items];
34
+ next.sort((a, b) => {
35
+ const groundedDelta = b.groundedCount - a.groundedCount;
36
+ if (groundedDelta !== 0) return groundedDelta;
37
+
38
+ const aPercent = a.readPercent ?? -1;
39
+ const bPercent = b.readPercent ?? -1;
40
+ if (aPercent !== bPercent) return bPercent - aPercent;
41
+
42
+ return b.lastSeenIndex - a.lastSeenIndex;
43
+ });
44
+ return next;
45
+ }, [items]);
46
+
47
+ const menuWidth = useMemo(() => {
48
+ return Math.max(80, Math.min(220, Math.floor(renderer.terminalWidth * 0.8)));
49
+ }, [renderer.terminalWidth]);
50
+
51
+ const menuHeight = useMemo(() => {
52
+ const headerHeight = 4;
53
+ const rowCount = sortedItems.length;
54
+ const contentHeight = rowCount > 0 ? rowCount : 1;
55
+ const minHeight = Math.floor(renderer.terminalHeight * 0.5);
56
+ const maxHeight = Math.floor(renderer.terminalHeight * 0.8);
57
+ return Math.max(minHeight, Math.min(headerHeight + contentHeight + 2, maxHeight));
58
+ }, [sortedItems.length, renderer.terminalHeight]);
59
+
60
+ const scrollBy = useCallback((delta: number) => {
61
+ const scrollbox = scrollRef.current;
62
+ if (!scrollbox) return;
63
+ const viewportHeight = scrollbox.viewport?.height ?? 0;
64
+ const maxScrollTop = Math.max(0, scrollbox.scrollHeight - viewportHeight);
65
+ const nextScrollTop = Math.max(0, Math.min(scrollbox.scrollTop + delta, maxScrollTop));
66
+ scrollbox.scrollTop = nextScrollTop;
67
+ }, []);
68
+
69
+ const handleKeyPress = useCallback(
70
+ (key: KeyEvent) => {
71
+ if (key.eventType !== "press") return;
72
+
73
+ if (key.name === "escape" || key.sequence === "u" || key.sequence === "U") {
74
+ onClose();
75
+ key.preventDefault();
76
+ return;
77
+ }
78
+
79
+ if (key.sequence === "j" || key.sequence === "J" || key.name === "down") {
80
+ scrollBy(SCROLL_AMOUNT);
81
+ key.preventDefault();
82
+ return;
83
+ }
84
+
85
+ if (key.sequence === "k" || key.sequence === "K" || key.name === "up") {
86
+ scrollBy(-SCROLL_AMOUNT);
87
+ key.preventDefault();
88
+ return;
89
+ }
90
+ },
91
+ [onClose, scrollBy]
92
+ );
93
+
94
+ useKeyboard(handleKeyPress);
95
+
96
+ return (
97
+ <box
98
+ position="absolute"
99
+ left={0}
100
+ top={0}
101
+ width="100%"
102
+ height="100%"
103
+ flexDirection="column"
104
+ alignItems="center"
105
+ justifyContent="center"
106
+ zIndex={100}
107
+ >
108
+ <box
109
+ flexDirection="column"
110
+ backgroundColor={COLORS.MENU_BG}
111
+ borderStyle="single"
112
+ borderColor={COLORS.MENU_BORDER}
113
+ paddingLeft={2}
114
+ paddingRight={2}
115
+ paddingTop={1}
116
+ paddingBottom={1}
117
+ width={menuWidth}
118
+ height={menuHeight}
119
+ >
120
+ <box marginBottom={1} flexDirection="row" width="100%">
121
+ <text>
122
+ <span fg={COLORS.DAEMON_LABEL}>[ URLS ]</span>
123
+ <span fg={COLORS.REASONING_DIM}> — {sortedItems.length} fetched</span>
124
+ </text>
125
+ <box flexGrow={1} />
126
+ <text>
127
+ <span fg={COLORS.USER_LABEL}>
128
+ <span fg={COLORS.DAEMON_LABEL}>j/k</span> scroll · <span fg={COLORS.DAEMON_LABEL}>ESC</span>{" "}
129
+ close
130
+ </span>
131
+ </text>
132
+ </box>
133
+
134
+ <box marginBottom={1} flexDirection="row" width="100%" justifyContent="space-between">
135
+ <text>
136
+ <span fg={COLORS.REASONING_DIM}>
137
+ {"G".padEnd(2)}
138
+ {"READ".padEnd(6)}URL
139
+ </span>
140
+ </text>
141
+ <text>
142
+ <span fg={COLORS.REASONING_DIM}>(G=grounded, READ=% or HL=highlights)</span>
143
+ </text>
144
+ </box>
145
+
146
+ <scrollbox ref={scrollRef} flexGrow={1} width="100%" overflow="scroll">
147
+ {sortedItems.length === 0 ? (
148
+ <text>
149
+ <span fg={COLORS.REASONING_DIM}>No URLs fetched yet</span>
150
+ </text>
151
+ ) : (
152
+ sortedItems.map((item, idx) => {
153
+ const { origin, path } = splitUrl(item.url);
154
+ const grounded = item.groundedCount > 0;
155
+ const readLabel =
156
+ item.readPercent !== undefined
157
+ ? `${item.readPercent}%`
158
+ : item.highlightsCount !== undefined
159
+ ? `HL:${item.highlightsCount}`
160
+ : "—";
161
+
162
+ return (
163
+ <box key={idx} flexDirection="row" marginBottom={0}>
164
+ <text>
165
+ <span fg={grounded ? COLORS.DAEMON_TEXT : COLORS.REASONING_DIM}>
166
+ {grounded ? "G" : "·"}
167
+ </span>
168
+ <span fg={COLORS.REASONING_DIM}> </span>
169
+ <span fg={COLORS.REASONING_DIM}>{readLabel.padStart(4, " ")}</span>
170
+ <span fg={COLORS.REASONING_DIM}> </span>
171
+ <span fg={item.status === "error" ? COLORS.ERROR : COLORS.DAEMON_LABEL}>{origin}</span>
172
+ <span fg={COLORS.REASONING_DIM}>{path}</span>
173
+ </text>
174
+ </box>
175
+ );
176
+ })
177
+ )}
178
+ </scrollbox>
179
+ </box>
180
+ </box>
181
+ );
182
+ }
@@ -0,0 +1,148 @@
1
+ import type { ContentBlock, ModelMessage, ToolResultOutput } from "../../types";
2
+
3
+ export const INTERRUPTED_TOOL_RESULT = "Tool execution interrupted by user";
4
+
5
+ export function normalizeInterruptedToolBlockResult(result: unknown): unknown {
6
+ if (result !== undefined) return result;
7
+ return { success: false, error: INTERRUPTED_TOOL_RESULT };
8
+ }
9
+
10
+ export function normalizeInterruptedToolResultOutput(result: unknown): ToolResultOutput {
11
+ if (result === undefined) {
12
+ return { type: "error-text", value: INTERRUPTED_TOOL_RESULT };
13
+ }
14
+
15
+ if (typeof result === "string") {
16
+ return { type: "text", value: result };
17
+ }
18
+
19
+ try {
20
+ JSON.stringify(result);
21
+ return { type: "json", value: result as ToolResultOutput["value"] };
22
+ } catch {
23
+ return { type: "text", value: String(result) };
24
+ }
25
+ }
26
+
27
+ export function buildInterruptedContentBlocks(contentBlocks: ContentBlock[]): ContentBlock[] {
28
+ return contentBlocks.map((block) => {
29
+ if (block.type !== "tool") return { ...block };
30
+
31
+ const call = { ...block.call };
32
+ if (call.status === "running") {
33
+ call.status = "failed";
34
+ call.error = INTERRUPTED_TOOL_RESULT;
35
+ }
36
+ if (call.subagentSteps) {
37
+ call.subagentSteps = call.subagentSteps.map((step) =>
38
+ step.status === "running" ? { ...step, status: "failed" } : step
39
+ );
40
+ }
41
+
42
+ return {
43
+ ...block,
44
+ call,
45
+ result: normalizeInterruptedToolBlockResult(block.result),
46
+ };
47
+ });
48
+ }
49
+
50
+ export function buildInterruptedModelMessages(contentBlocks: ContentBlock[]): ModelMessage[] {
51
+ const messages: ModelMessage[] = [];
52
+
53
+ type AssistantPart =
54
+ | { type: "text"; text: string }
55
+ | { type: "reasoning"; text: string }
56
+ | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown };
57
+
58
+ type ToolResultPart = {
59
+ type: "tool-result";
60
+ toolCallId: string;
61
+ toolName: string;
62
+ output: ToolResultOutput;
63
+ };
64
+
65
+ let assistantParts: AssistantPart[] = [];
66
+ let toolResults: ToolResultPart[] = [];
67
+
68
+ for (const block of contentBlocks) {
69
+ if (block.type === "reasoning" && block.content) {
70
+ if (toolResults.length > 0) {
71
+ messages.push({
72
+ role: "tool",
73
+ content: [...toolResults],
74
+ } as unknown as ModelMessage);
75
+ toolResults = [];
76
+ }
77
+
78
+ assistantParts.push({ type: "reasoning", text: block.content });
79
+ continue;
80
+ }
81
+
82
+ if (block.type === "text" && block.content) {
83
+ if (toolResults.length > 0) {
84
+ messages.push({
85
+ role: "tool",
86
+ content: [...toolResults],
87
+ } as unknown as ModelMessage);
88
+ toolResults = [];
89
+ }
90
+
91
+ assistantParts.push({ type: "text", text: block.content });
92
+ continue;
93
+ }
94
+
95
+ if (block.type === "tool") {
96
+ if (toolResults.length > 0) {
97
+ messages.push({
98
+ role: "tool",
99
+ content: [...toolResults],
100
+ } as unknown as ModelMessage);
101
+ toolResults = [];
102
+ }
103
+
104
+ const toolCallId = block.call.toolCallId;
105
+ if (!toolCallId) {
106
+ continue;
107
+ }
108
+
109
+ assistantParts.push({
110
+ type: "tool-call",
111
+ toolCallId,
112
+ toolName: block.call.name,
113
+ input: block.call.input ?? {},
114
+ });
115
+
116
+ if (assistantParts.length > 0) {
117
+ messages.push({
118
+ role: "assistant",
119
+ content: [...assistantParts],
120
+ } as unknown as ModelMessage);
121
+ assistantParts = [];
122
+ }
123
+
124
+ toolResults.push({
125
+ type: "tool-result",
126
+ toolCallId,
127
+ toolName: block.call.name,
128
+ output: normalizeInterruptedToolResultOutput(block.result),
129
+ });
130
+ }
131
+ }
132
+
133
+ if (assistantParts.length > 0) {
134
+ messages.push({
135
+ role: "assistant",
136
+ content: [...assistantParts],
137
+ } as unknown as ModelMessage);
138
+ }
139
+
140
+ if (toolResults.length > 0) {
141
+ messages.push({
142
+ role: "tool",
143
+ content: [...toolResults],
144
+ } as unknown as ModelMessage);
145
+ }
146
+
147
+ return messages;
148
+ }