@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.
- package/package.json +5 -4
- package/src/ai/daemon-ai.ts +30 -85
- package/src/ai/system-prompt.ts +134 -111
- package/src/ai/tool-approval-coordinator.ts +113 -0
- package/src/ai/tools/index.ts +12 -32
- package/src/ai/tools/subagents.ts +16 -30
- package/src/ai/tools/tool-registry.ts +203 -0
- package/src/app/App.tsx +23 -631
- package/src/app/components/AppOverlays.tsx +25 -1
- package/src/app/components/ConversationPane.tsx +5 -3
- package/src/components/HotkeysPane.tsx +3 -1
- package/src/components/TokenUsageDisplay.tsx +11 -11
- package/src/components/ToolsMenu.tsx +235 -0
- package/src/components/UrlMenu.tsx +182 -0
- package/src/hooks/daemon-event-handlers/interrupted-turn.ts +148 -0
- package/src/hooks/daemon-event-handlers.ts +11 -151
- package/src/hooks/use-app-context-builder.ts +4 -0
- package/src/hooks/use-app-controller.ts +546 -0
- package/src/hooks/use-app-menus.ts +12 -0
- package/src/hooks/use-app-preferences-bootstrap.ts +9 -0
- package/src/hooks/use-bootstrap-controller.ts +92 -0
- package/src/hooks/use-daemon-keyboard.ts +63 -57
- package/src/hooks/use-daemon-runtime-controller.ts +147 -0
- package/src/hooks/use-grounding-menu-controller.ts +51 -0
- package/src/hooks/use-overlay-controller.ts +84 -0
- package/src/hooks/use-session-controller.ts +79 -0
- package/src/hooks/use-url-menu-items.ts +19 -0
- package/src/state/app-context.tsx +4 -0
- package/src/state/daemon-state.ts +19 -8
- package/src/state/session-store.ts +4 -0
- package/src/types/index.ts +39 -0
- package/src/utils/derive-url-menu-items.ts +155 -0
- package/src/utils/formatters.ts +1 -7
- 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
|
-
|
|
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 &&
|
|
432
|
-
|
|
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: "
|
|
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 {
|
|
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
|
-
//
|
|
34
|
-
const
|
|
35
|
-
const contextUsage =
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
{
|
|
50
|
+
{contextUsage && (
|
|
51
51
|
<>
|
|
52
52
|
<span fg={COLORS.TOKEN_USAGE_LABEL}>Tokens: </span>
|
|
53
|
-
<span fg={COLORS.TOKEN_USAGE}>{formatTokenCount(
|
|
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
|
|
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
|
+
}
|