@makefinks/daemon 0.1.4 → 0.3.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 (34) hide show
  1. package/package.json +5 -4
  2. package/src/ai/daemon-ai.ts +30 -85
  3. package/src/ai/system-prompt.ts +134 -111
  4. package/src/ai/tool-approval-coordinator.ts +113 -0
  5. package/src/ai/tools/index.ts +12 -32
  6. package/src/ai/tools/subagents.ts +16 -30
  7. package/src/ai/tools/tool-registry.ts +203 -0
  8. package/src/app/App.tsx +23 -631
  9. package/src/app/components/AppOverlays.tsx +25 -1
  10. package/src/app/components/ConversationPane.tsx +5 -3
  11. package/src/components/HotkeysPane.tsx +3 -1
  12. package/src/components/TokenUsageDisplay.tsx +11 -11
  13. package/src/components/ToolsMenu.tsx +235 -0
  14. package/src/components/UrlMenu.tsx +182 -0
  15. package/src/hooks/daemon-event-handlers/interrupted-turn.ts +148 -0
  16. package/src/hooks/daemon-event-handlers.ts +11 -151
  17. package/src/hooks/use-app-context-builder.ts +4 -0
  18. package/src/hooks/use-app-controller.ts +546 -0
  19. package/src/hooks/use-app-menus.ts +12 -0
  20. package/src/hooks/use-app-preferences-bootstrap.ts +9 -0
  21. package/src/hooks/use-bootstrap-controller.ts +92 -0
  22. package/src/hooks/use-daemon-keyboard.ts +63 -57
  23. package/src/hooks/use-daemon-runtime-controller.ts +147 -0
  24. package/src/hooks/use-grounding-menu-controller.ts +51 -0
  25. package/src/hooks/use-overlay-controller.ts +84 -0
  26. package/src/hooks/use-session-controller.ts +79 -0
  27. package/src/hooks/use-url-menu-items.ts +19 -0
  28. package/src/state/app-context.tsx +4 -0
  29. package/src/state/daemon-state.ts +19 -8
  30. package/src/state/session-store.ts +4 -0
  31. package/src/types/index.ts +39 -0
  32. package/src/utils/derive-url-menu-items.ts +155 -0
  33. package/src/utils/formatters.ts +1 -7
  34. package/src/utils/preferences.ts +10 -0
@@ -7,11 +7,26 @@ 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 { ToolsMenu } from "../../components/ToolsMenu";
11
+ import { UrlMenu } from "../../components/UrlMenu";
12
+ import { useUrlMenuItems } from "../../hooks/use-url-menu-items";
10
13
  import { useAppContext } from "../../state/app-context";
14
+ import type { ContentBlock, ConversationMessage } from "../../types";
11
15
 
12
- function AppOverlaysImpl() {
16
+ interface AppOverlaysProps {
17
+ conversationHistory: ConversationMessage[];
18
+ currentContentBlocks: ContentBlock[];
19
+ }
20
+
21
+ function AppOverlaysImpl({ conversationHistory, currentContentBlocks }: AppOverlaysProps) {
13
22
  const ctx = useAppContext();
14
23
  const { menus, device, settings, model, session, grounding, onboarding } = ctx;
24
+
25
+ const urlMenuItems = useUrlMenuItems({
26
+ conversationHistory,
27
+ currentContentBlocks,
28
+ latestGroundingMap: grounding.latestGroundingMap,
29
+ });
15
30
  const {
16
31
  deviceCallbacks,
17
32
  settingsCallbacks,
@@ -104,6 +119,15 @@ function AppOverlaysImpl() {
104
119
  />
105
120
  )}
106
121
 
122
+ {menus.showUrlMenu && <UrlMenu items={urlMenuItems} onClose={() => menus.setShowUrlMenu(false)} />}
123
+
124
+ {menus.showToolsMenu && (
125
+ <ToolsMenu
126
+ onClose={() => menus.setShowToolsMenu(false)}
127
+ persistPreferences={(updates) => settings.persistPreferences(updates)}
128
+ />
129
+ )}
130
+
107
131
  {onboarding.onboardingActive && (
108
132
  <OnboardingOverlay
109
133
  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
@@ -36,10 +36,11 @@ export function HotkeysPane({ onClose }: HotkeysPaneProps) {
36
36
  {
37
37
  title: "SESSION",
38
38
  items: [
39
- { key: "T", label: "Toggle full reasoning previews" },
39
+ { key: "R", label: "Toggle full reasoning previews" },
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
  },
@@ -50,6 +51,7 @@ export function HotkeysPane({ onClose }: HotkeysPaneProps) {
50
51
  { key: "M", label: "Models" },
51
52
  { key: "P", label: "Providers" },
52
53
  { key: "L", label: "Sessions" },
54
+ { key: "T", label: "Tools" },
53
55
  { key: "S", label: "Settings" },
54
56
  ],
55
57
  },
@@ -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,235 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+
3
+ import { invalidateDaemonToolsCache } from "../ai/tools/index";
4
+ import { invalidateSubagentToolsCache } from "../ai/tools/subagents";
5
+ import {
6
+ buildMenuItems,
7
+ getDefaultToolOrder,
8
+ getToolLabels,
9
+ resolveToolAvailability,
10
+ } from "../ai/tools/tool-registry";
11
+ import { useMenuKeyboard } from "../hooks/use-menu-keyboard";
12
+ import { getDaemonManager } from "../state/daemon-state";
13
+ import type { ToolToggleId, ToolToggles } from "../types";
14
+ import { DEFAULT_TOOL_TOGGLES } from "../types";
15
+ import { COLORS } from "../ui/constants";
16
+
17
+ interface ToolsMenuProps {
18
+ persistPreferences: (updates: Partial<{ toolToggles: ToolToggles }>) => void;
19
+ onClose: () => void;
20
+ }
21
+
22
+ type MenuToolItem = {
23
+ id: ToolToggleId;
24
+ label: string;
25
+ envAvailable: boolean;
26
+ disabledReason?: string;
27
+ };
28
+
29
+ function getToolLabel(id: ToolToggleId): string {
30
+ switch (id) {
31
+ case "readFile":
32
+ return "readFile";
33
+ case "runBash":
34
+ return "runBash";
35
+ case "webSearch":
36
+ return "webSearch";
37
+ case "fetchUrls":
38
+ return "fetchUrls";
39
+ case "renderUrl":
40
+ return "renderUrl";
41
+ case "todoManager":
42
+ return "todoManager";
43
+ case "groundingManager":
44
+ return "groundingManager";
45
+ case "subagent":
46
+ return "subagent";
47
+ default:
48
+ return id;
49
+ }
50
+ }
51
+
52
+ export function ToolsMenu({ persistPreferences, onClose }: ToolsMenuProps) {
53
+ const manager = getDaemonManager();
54
+ const [toggles, setToggles] = useState<ToolToggles>(manager.toolToggles ?? { ...DEFAULT_TOOL_TOGGLES });
55
+
56
+ const [toolAvailability, setToolAvailability] = useState<Record<ToolToggleId, MenuToolItem> | null>(null);
57
+
58
+ useEffect(() => {
59
+ let cancelled = false;
60
+ const loadAvailability = async () => {
61
+ const toggles = manager.toolToggles ?? { ...DEFAULT_TOOL_TOGGLES };
62
+ const availability = await resolveToolAvailability(toggles);
63
+ const map = buildMenuItems(availability) as Record<ToolToggleId, MenuToolItem>;
64
+
65
+ if (cancelled) return;
66
+ setToolAvailability(map);
67
+ };
68
+
69
+ void loadAvailability();
70
+ return () => {
71
+ cancelled = true;
72
+ };
73
+ }, [manager, toggles]);
74
+
75
+ const items = useMemo((): MenuToolItem[] => {
76
+ const labels = getToolLabels();
77
+ const order = getDefaultToolOrder();
78
+ if (!toolAvailability) {
79
+ return order.map((id) => ({
80
+ id,
81
+ label: labels[id],
82
+ envAvailable: true,
83
+ }));
84
+ }
85
+
86
+ return order.map((id) => toolAvailability[id]).filter((item): item is MenuToolItem => Boolean(item));
87
+ }, [toolAvailability]);
88
+
89
+ const { selectedIndex } = useMenuKeyboard({
90
+ itemCount: items.length,
91
+ onClose,
92
+ closeOnSelect: false,
93
+ onSelect: (idx) => {
94
+ const item = items[idx];
95
+ if (!item) return;
96
+
97
+ const current = manager.toolToggles ?? { ...DEFAULT_TOOL_TOGGLES };
98
+
99
+ // If env-unavailable, block enabling, but allow disabling.
100
+ if (!item.envAvailable && current[item.id]) {
101
+ return;
102
+ }
103
+
104
+ const next: ToolToggles = {
105
+ ...DEFAULT_TOOL_TOGGLES,
106
+ ...current,
107
+ [item.id]: !current[item.id],
108
+ };
109
+ manager.toolToggles = next;
110
+ setToggles(next);
111
+ persistPreferences({ toolToggles: next });
112
+
113
+ invalidateDaemonToolsCache();
114
+ invalidateSubagentToolsCache();
115
+ resolveToolAvailability(next)
116
+ .then((availability) => {
117
+ const map = buildMenuItems(availability) as Record<ToolToggleId, MenuToolItem>;
118
+ setToolAvailability(map);
119
+ })
120
+ .catch(() => {
121
+ setToolAvailability(null);
122
+ });
123
+ },
124
+ });
125
+
126
+ const showReasonColumn = useMemo(() => {
127
+ return items.some((item) => !item.envAvailable);
128
+ }, [items]);
129
+
130
+ const labelWidth = useMemo(() => {
131
+ const raw = items.reduce((max, item) => Math.max(max, item.label.length), 0);
132
+ // When no env-disabled tools exist, keep the menu compact.
133
+ return showReasonColumn ? raw : Math.min(raw, 16);
134
+ }, [items, showReasonColumn]);
135
+
136
+ const statusWidth = 8;
137
+
138
+ function truncateText(text: string, maxLen: number): string {
139
+ if (text.length <= maxLen) return text;
140
+ return text.slice(0, Math.max(0, maxLen - 1)) + "…";
141
+ }
142
+
143
+ return (
144
+ <box
145
+ position="absolute"
146
+ left={0}
147
+ top={0}
148
+ width="100%"
149
+ height="100%"
150
+ flexDirection="column"
151
+ alignItems="center"
152
+ justifyContent="center"
153
+ zIndex={100}
154
+ >
155
+ <box
156
+ flexDirection="column"
157
+ backgroundColor={COLORS.MENU_BG}
158
+ borderStyle="single"
159
+ borderColor={COLORS.MENU_BORDER}
160
+ paddingLeft={2}
161
+ paddingRight={2}
162
+ paddingTop={1}
163
+ paddingBottom={1}
164
+ width={showReasonColumn ? "70%" : "52%"}
165
+ minWidth={showReasonColumn ? 70 : 48}
166
+ maxWidth={showReasonColumn ? 150 : 90}
167
+ >
168
+ <box marginBottom={1}>
169
+ <text>
170
+ <span fg={COLORS.DAEMON_LABEL}>[ TOOLS ]</span>
171
+ </text>
172
+ </box>
173
+ <box marginBottom={1}>
174
+ <text>
175
+ <span fg={COLORS.USER_LABEL}>↑/↓ or j/k to navigate, ENTER to toggle, ESC to close</span>
176
+ </text>
177
+ </box>
178
+
179
+ {showReasonColumn && (
180
+ <box marginBottom={1}>
181
+ <text>
182
+ <span fg={COLORS.REASONING_DIM}>
183
+ {"TOOL".padEnd(labelWidth)} {"STATUS".padEnd(statusWidth)} REASON
184
+ </span>
185
+ </text>
186
+ </box>
187
+ )}
188
+
189
+ <box flexDirection="column">
190
+ {items.map((item, idx) => {
191
+ const isSelected = idx === selectedIndex;
192
+ const isEnabled = Boolean(toggles[item.id]);
193
+ const canEnable = item.envAvailable;
194
+ const statusLabel = !canEnable ? "DISABLED" : isEnabled ? "ON" : "OFF";
195
+ const reason = !canEnable && item.disabledReason ? item.disabledReason : "";
196
+
197
+ const labelColor = isSelected ? COLORS.DAEMON_LABEL : COLORS.MENU_TEXT;
198
+ const statusColor = !canEnable
199
+ ? COLORS.REASONING_DIM
200
+ : isEnabled
201
+ ? COLORS.DAEMON_TEXT
202
+ : COLORS.REASONING_DIM;
203
+ const reasonColor = COLORS.REASONING_DIM;
204
+
205
+ const labelText = truncateText(item.label, labelWidth).padEnd(labelWidth);
206
+ const statusText = statusLabel.padEnd(statusWidth);
207
+ const reasonText = reason ? truncateText(reason, 60) : "";
208
+
209
+ return (
210
+ <box
211
+ key={item.id}
212
+ backgroundColor={isSelected ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
213
+ paddingLeft={1}
214
+ paddingRight={1}
215
+ >
216
+ <text>
217
+ <span fg={labelColor}>{isSelected ? "▶ " : " "}</span>
218
+ <span fg={labelColor}>{labelText}</span>
219
+ <span fg={COLORS.REASONING_DIM}> </span>
220
+ <span fg={statusColor}>{statusText}</span>
221
+ {showReasonColumn && reasonText ? (
222
+ <>
223
+ <span fg={COLORS.REASONING_DIM}> </span>
224
+ <span fg={reasonColor}>{reasonText}</span>
225
+ </>
226
+ ) : null}
227
+ </text>
228
+ </box>
229
+ );
230
+ })}
231
+ </box>
232
+ </box>
233
+ </box>
234
+ );
235
+ }
@@ -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
+ }