@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.
@@ -24,6 +24,15 @@ import { REASONING_COLORS, STATE_COLORS } from "../types/theme";
24
24
  import { REASONING_ANIMATION } from "../ui/constants";
25
25
  import { debug } from "../utils/debug-logger";
26
26
  import { hasVisibleText } from "../utils/formatters";
27
+ import {
28
+ INTERRUPTED_TOOL_RESULT,
29
+ buildInterruptedContentBlocks,
30
+ buildInterruptedModelMessages,
31
+ normalizeInterruptedToolBlockResult,
32
+ normalizeInterruptedToolResultOutput,
33
+ } from "./daemon-event-handlers/interrupted-turn";
34
+
35
+ export { buildInterruptedModelMessages };
27
36
 
28
37
  function getToolCategory(toolName: string): ToolCategory | "fast" | undefined {
29
38
  if (toolName === "subagent") return "subagent";
@@ -39,30 +48,6 @@ function getToolCategory(toolName: string): ToolCategory | "fast" | undefined {
39
48
  return undefined;
40
49
  }
41
50
 
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
51
  function clearAvatarToolEffects(avatar: DaemonAvatarRenderable | null): void {
67
52
  if (!avatar) return;
68
53
  avatar.triggerToolComplete();
@@ -71,133 +56,6 @@ function clearAvatarToolEffects(avatar: DaemonAvatarRenderable | null): void {
71
56
  avatar.setTypingMode(false);
72
57
  }
73
58
 
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
59
  function finalizePendingUserMessage(
202
60
  prev: ConversationMessage[],
203
61
  userText: string,
@@ -725,6 +583,8 @@ function mergeTokenUsage(prev: TokenUsage, usage: TokenUsage, isSubagent: boolea
725
583
  subagentTotalTokens: prev.subagentTotalTokens,
726
584
  subagentPromptTokens: prev.subagentPromptTokens,
727
585
  subagentCompletionTokens: prev.subagentCompletionTokens,
586
+ latestTurnPromptTokens: usage.promptTokens,
587
+ latestTurnCompletionTokens: usage.completionTokens,
728
588
  };
729
589
  }
730
590
 
@@ -39,6 +39,8 @@ export interface UseAppContextBuilderParams {
39
39
  setShowHotkeysPane: React.Dispatch<React.SetStateAction<boolean>>;
40
40
  showGroundingMenu: boolean;
41
41
  setShowGroundingMenu: React.Dispatch<React.SetStateAction<boolean>>;
42
+ showUrlMenu: boolean;
43
+ setShowUrlMenu: React.Dispatch<React.SetStateAction<boolean>>;
42
44
  };
43
45
 
44
46
  device: {
@@ -21,6 +21,9 @@ export interface UseAppMenusReturn {
21
21
 
22
22
  showGroundingMenu: boolean;
23
23
  setShowGroundingMenu: React.Dispatch<React.SetStateAction<boolean>>;
24
+
25
+ showUrlMenu: boolean;
26
+ setShowUrlMenu: React.Dispatch<React.SetStateAction<boolean>>;
24
27
  }
25
28
 
26
29
  export function useAppMenus(): UseAppMenusReturn {
@@ -31,6 +34,7 @@ export function useAppMenus(): UseAppMenusReturn {
31
34
  const [showSessionMenu, setShowSessionMenu] = useState(false);
32
35
  const [showHotkeysPane, setShowHotkeysPane] = useState(false);
33
36
  const [showGroundingMenu, setShowGroundingMenu] = useState(false);
37
+ const [showUrlMenu, setShowUrlMenu] = useState(false);
34
38
 
35
39
  return {
36
40
  showDeviceMenu,
@@ -47,5 +51,7 @@ export function useAppMenus(): UseAppMenusReturn {
47
51
  setShowHotkeysPane,
48
52
  showGroundingMenu,
49
53
  setShowGroundingMenu,
54
+ showUrlMenu,
55
+ setShowUrlMenu,
50
56
  };
51
57
  }
@@ -1,15 +1,11 @@
1
- /**
2
- * Hook for handling keyboard input across all application states.
3
- */
4
-
5
- import { useCallback } from "react";
1
+ import { toast } from "@opentui-ui/toast/react";
6
2
  import type { KeyEvent } from "@opentui/core";
3
+ import type { ScrollBoxRenderable } from "@opentui/core";
7
4
  import { useKeyboard } from "@opentui/react";
8
- import { toast } from "@opentui-ui/toast/react";
9
- import { COLORS } from "../ui/constants";
5
+ import { useCallback } from "react";
10
6
  import { getDaemonManager } from "../state/daemon-state";
11
- import { DaemonState, type AppPreferences } from "../types";
12
- import type { ScrollBoxRenderable } from "@opentui/core";
7
+ import { type AppPreferences, DaemonState } from "../types";
8
+ import { COLORS } from "../ui/constants";
13
9
  export interface KeyboardHandlerState {
14
10
  isOverlayOpen: boolean;
15
11
  escPendingCancel: boolean;
@@ -27,6 +23,7 @@ export interface KeyboardHandlerActions {
27
23
  setShowSessionMenu: (show: boolean) => void;
28
24
  setShowHotkeysPane: (show: boolean) => void;
29
25
  setShowGroundingMenu: (show: boolean) => void;
26
+ setShowUrlMenu: (show: boolean) => void;
30
27
  setTypingInput: (input: string | ((prev: string) => string)) => void;
31
28
  setCurrentTranscription: (text: string) => void;
32
29
  setCurrentResponse: (text: string) => void;
@@ -46,6 +43,17 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
46
43
  const manager = getDaemonManager();
47
44
  const { isOverlayOpen, escPendingCancel, hasInteracted, hasGrounding } = state;
48
45
 
46
+ const closeAllMenus = useCallback(() => {
47
+ actions.setShowDeviceMenu(false);
48
+ actions.setShowSettingsMenu(false);
49
+ actions.setShowModelMenu(false);
50
+ actions.setShowProviderMenu(false);
51
+ actions.setShowSessionMenu(false);
52
+ actions.setShowHotkeysPane(false);
53
+ actions.setShowGroundingMenu(false);
54
+ actions.setShowUrlMenu(false);
55
+ }, [actions]);
56
+
49
57
  const handleKeyPress = useCallback(
50
58
  (key: KeyEvent) => {
51
59
  const currentState = manager.state;
@@ -97,12 +105,7 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
97
105
  currentState === DaemonState.IDLE &&
98
106
  !hasInteracted
99
107
  ) {
100
- actions.setShowModelMenu(false);
101
- actions.setShowProviderMenu(false);
102
- actions.setShowSessionMenu(false);
103
- actions.setShowSettingsMenu(false);
104
- actions.setShowGroundingMenu(false);
105
- actions.setShowHotkeysPane(false);
108
+ closeAllMenus();
106
109
  actions.setShowDeviceMenu(true);
107
110
  key.preventDefault();
108
111
  return;
@@ -114,12 +117,7 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
114
117
  key.eventType === "press" &&
115
118
  (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING)
116
119
  ) {
117
- actions.setShowDeviceMenu(false);
118
- actions.setShowSettingsMenu(false);
119
- actions.setShowModelMenu(false);
120
- actions.setShowProviderMenu(false);
121
- actions.setShowGroundingMenu(false);
122
- actions.setShowHotkeysPane(false);
120
+ closeAllMenus();
123
121
  actions.setShowSessionMenu(true);
124
122
  key.preventDefault();
125
123
  return;
@@ -133,12 +131,7 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
133
131
  currentState === DaemonState.SPEAKING ||
134
132
  currentState === DaemonState.RESPONDING)
135
133
  ) {
136
- actions.setShowDeviceMenu(false);
137
- actions.setShowModelMenu(false);
138
- actions.setShowProviderMenu(false);
139
- actions.setShowSessionMenu(false);
140
- actions.setShowGroundingMenu(false);
141
- actions.setShowHotkeysPane(false);
134
+ closeAllMenus();
142
135
  actions.setShowSettingsMenu(true);
143
136
  key.preventDefault();
144
137
  return;
@@ -152,12 +145,7 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
152
145
  currentState === DaemonState.SPEAKING ||
153
146
  currentState === DaemonState.RESPONDING)
154
147
  ) {
155
- actions.setShowDeviceMenu(false);
156
- actions.setShowSettingsMenu(false);
157
- actions.setShowProviderMenu(false);
158
- actions.setShowSessionMenu(false);
159
- actions.setShowGroundingMenu(false);
160
- actions.setShowHotkeysPane(false);
148
+ closeAllMenus();
161
149
  actions.setShowModelMenu(true);
162
150
  key.preventDefault();
163
151
  return;
@@ -169,12 +157,7 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
169
157
  key.eventType === "press" &&
170
158
  (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING)
171
159
  ) {
172
- actions.setShowDeviceMenu(false);
173
- actions.setShowSettingsMenu(false);
174
- actions.setShowModelMenu(false);
175
- actions.setShowSessionMenu(false);
176
- actions.setShowGroundingMenu(false);
177
- actions.setShowHotkeysPane(false);
160
+ closeAllMenus();
178
161
  actions.setShowProviderMenu(true);
179
162
  key.preventDefault();
180
163
  return;
@@ -187,17 +170,25 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
187
170
  (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING) &&
188
171
  hasGrounding
189
172
  ) {
190
- actions.setShowDeviceMenu(false);
191
- actions.setShowSettingsMenu(false);
192
- actions.setShowModelMenu(false);
193
- actions.setShowProviderMenu(false);
194
- actions.setShowSessionMenu(false);
195
- actions.setShowHotkeysPane(false);
173
+ closeAllMenus();
196
174
  actions.setShowGroundingMenu(true);
197
175
  key.preventDefault();
198
176
  return;
199
177
  }
200
178
 
179
+ // 'U' key to open URL menu (in IDLE or SPEAKING state when hasInteracted)
180
+ if (
181
+ (key.sequence === "u" || key.sequence === "U") &&
182
+ key.eventType === "press" &&
183
+ (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING) &&
184
+ hasInteracted
185
+ ) {
186
+ closeAllMenus();
187
+ actions.setShowUrlMenu(true);
188
+ key.preventDefault();
189
+ return;
190
+ }
191
+
201
192
  // 'N' key to start a new session (in IDLE or SPEAKING state)
202
193
  if (
203
194
  (key.sequence === "n" || key.sequence === "N") &&
@@ -267,12 +258,7 @@ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: Keyboard
267
258
 
268
259
  // '?' key to show hotkeys pane
269
260
  if (key.sequence === "?" && key.eventType === "press" && currentState !== DaemonState.TYPING) {
270
- actions.setShowDeviceMenu(false);
271
- actions.setShowSettingsMenu(false);
272
- actions.setShowModelMenu(false);
273
- actions.setShowProviderMenu(false);
274
- actions.setShowSessionMenu(false);
275
- actions.setShowGroundingMenu(false);
261
+ closeAllMenus();
276
262
  actions.setShowHotkeysPane(true);
277
263
  key.preventDefault();
278
264
  return;
@@ -0,0 +1,51 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import type { GroundingMap } from "../types";
3
+ import { openUrlInBrowser } from "../utils/preferences";
4
+ import { buildTextFragmentUrl } from "../utils/text-fragment";
5
+
6
+ export function useGroundingMenuController(params: {
7
+ sessionId: string | null;
8
+ latestGroundingMap: GroundingMap | null;
9
+ }) {
10
+ const { sessionId, latestGroundingMap } = params;
11
+ const [selectedIndex, setSelectedIndex] = useState(0);
12
+
13
+ useEffect(() => {
14
+ setSelectedIndex(0);
15
+ }, [sessionId]);
16
+
17
+ const initialIndex = useMemo(() => {
18
+ if (!latestGroundingMap) return 0;
19
+ return Math.min(selectedIndex, Math.max(0, latestGroundingMap.items.length - 1));
20
+ }, [latestGroundingMap, selectedIndex]);
21
+
22
+ const openGroundingSource = useCallback(
23
+ (idx: number) => {
24
+ if (!latestGroundingMap) return;
25
+ const item = latestGroundingMap.items[idx];
26
+ if (!item) return;
27
+ const { source } = item;
28
+ const url = source.textFragment
29
+ ? buildTextFragmentUrl(source.url, { fragmentText: source.textFragment })
30
+ : source.url;
31
+ openUrlInBrowser(url);
32
+ },
33
+ [latestGroundingMap]
34
+ );
35
+
36
+ const handleSelect = useCallback(
37
+ (index: number) => {
38
+ setSelectedIndex(index);
39
+ openGroundingSource(index);
40
+ },
41
+ [openGroundingSource]
42
+ );
43
+
44
+ return {
45
+ groundingInitialIndex: initialIndex,
46
+ groundingSelectedIndex: selectedIndex,
47
+ setGroundingSelectedIndex: setSelectedIndex,
48
+ onGroundingSelect: handleSelect,
49
+ onGroundingIndexChange: setSelectedIndex,
50
+ };
51
+ }
@@ -0,0 +1,78 @@
1
+ import { useCallback, useMemo } from "react";
2
+
3
+ export interface OverlayControllerState {
4
+ showDeviceMenu: boolean;
5
+ showSettingsMenu: boolean;
6
+ showModelMenu: boolean;
7
+ showProviderMenu: boolean;
8
+ showSessionMenu: boolean;
9
+ showHotkeysPane: boolean;
10
+ showGroundingMenu: boolean;
11
+ showUrlMenu: boolean;
12
+ onboardingActive: boolean;
13
+ }
14
+
15
+ export interface OverlayControllerActions {
16
+ setShowDeviceMenu: (show: boolean) => void;
17
+ setShowSettingsMenu: (show: boolean) => void;
18
+ setShowModelMenu: (show: boolean) => void;
19
+ setShowProviderMenu: (show: boolean) => void;
20
+ setShowSessionMenu: (show: boolean) => void;
21
+ setShowHotkeysPane: (show: boolean) => void;
22
+ setShowGroundingMenu: (show: boolean) => void;
23
+ setShowUrlMenu: (show: boolean) => void;
24
+ }
25
+
26
+ export function useOverlayController(state: OverlayControllerState, actions: OverlayControllerActions) {
27
+ const {
28
+ showDeviceMenu,
29
+ showSettingsMenu,
30
+ showModelMenu,
31
+ showProviderMenu,
32
+ showSessionMenu,
33
+ showHotkeysPane,
34
+ showGroundingMenu,
35
+ showUrlMenu,
36
+ onboardingActive,
37
+ } = state;
38
+
39
+ const isOverlayOpen = useMemo(() => {
40
+ return (
41
+ showDeviceMenu ||
42
+ showSettingsMenu ||
43
+ showModelMenu ||
44
+ showProviderMenu ||
45
+ showSessionMenu ||
46
+ showHotkeysPane ||
47
+ showGroundingMenu ||
48
+ showUrlMenu ||
49
+ onboardingActive
50
+ );
51
+ }, [
52
+ showDeviceMenu,
53
+ showSettingsMenu,
54
+ showModelMenu,
55
+ showProviderMenu,
56
+ showSessionMenu,
57
+ showHotkeysPane,
58
+ showGroundingMenu,
59
+ showUrlMenu,
60
+ onboardingActive,
61
+ ]);
62
+
63
+ const closeAllOverlays = useCallback(() => {
64
+ actions.setShowDeviceMenu(false);
65
+ actions.setShowSettingsMenu(false);
66
+ actions.setShowModelMenu(false);
67
+ actions.setShowProviderMenu(false);
68
+ actions.setShowSessionMenu(false);
69
+ actions.setShowHotkeysPane(false);
70
+ actions.setShowGroundingMenu(false);
71
+ actions.setShowUrlMenu(false);
72
+ }, [actions]);
73
+
74
+ return {
75
+ isOverlayOpen,
76
+ closeAllOverlays,
77
+ };
78
+ }
@@ -0,0 +1,19 @@
1
+ import { useMemo } from "react";
2
+ import type { ContentBlock, ConversationMessage, GroundingMap, UrlMenuItem } from "../types";
3
+ import { deriveUrlMenuItems } from "../utils/derive-url-menu-items";
4
+
5
+ export function useUrlMenuItems(params: {
6
+ conversationHistory: ConversationMessage[];
7
+ currentContentBlocks: ContentBlock[];
8
+ latestGroundingMap: GroundingMap | null;
9
+ }): UrlMenuItem[] {
10
+ const { conversationHistory, currentContentBlocks, latestGroundingMap } = params;
11
+
12
+ return useMemo(() => {
13
+ return deriveUrlMenuItems({
14
+ conversationHistory,
15
+ currentContentBlocks,
16
+ latestGroundingMap,
17
+ });
18
+ }, [conversationHistory, currentContentBlocks, latestGroundingMap]);
19
+ }
@@ -29,6 +29,8 @@ export interface MenuState {
29
29
  setShowHotkeysPane: React.Dispatch<React.SetStateAction<boolean>>;
30
30
  showGroundingMenu: boolean;
31
31
  setShowGroundingMenu: React.Dispatch<React.SetStateAction<boolean>>;
32
+ showUrlMenu: boolean;
33
+ setShowUrlMenu: React.Dispatch<React.SetStateAction<boolean>>;
32
34
  }
33
35
 
34
36
  export interface DeviceState {
@@ -131,6 +131,10 @@ function parseSessionUsage(raw: string): TokenUsage {
131
131
  subagentPromptTokens: typeof parsed.subagentPromptTokens === "number" ? parsed.subagentPromptTokens : 0,
132
132
  subagentCompletionTokens:
133
133
  typeof parsed.subagentCompletionTokens === "number" ? parsed.subagentCompletionTokens : 0,
134
+ latestTurnPromptTokens:
135
+ typeof parsed.latestTurnPromptTokens === "number" ? parsed.latestTurnPromptTokens : undefined,
136
+ latestTurnCompletionTokens:
137
+ typeof parsed.latestTurnCompletionTokens === "number" ? parsed.latestTurnCompletionTokens : undefined,
134
138
  };
135
139
  } catch {
136
140
  return { ...DEFAULT_SESSION_USAGE };
@@ -42,6 +42,10 @@ export interface TokenUsage {
42
42
  subagentTotalTokens?: number;
43
43
  subagentPromptTokens?: number;
44
44
  subagentCompletionTokens?: number;
45
+ /** Latest turn's prompt tokens (for context window % calculation) */
46
+ latestTurnPromptTokens?: number;
47
+ /** Latest turn's completion tokens (for context window % calculation) */
48
+ latestTurnCompletionTokens?: number;
45
49
  }
46
50
 
47
51
  /**
@@ -403,3 +407,13 @@ export interface GroundingMap {
403
407
  createdAt: string;
404
408
  items: GroundedStatement[];
405
409
  }
410
+
411
+ export interface UrlMenuItem {
412
+ url: string;
413
+ groundedCount: number;
414
+ readPercent?: number;
415
+ highlightsCount?: number;
416
+ status: "ok" | "error";
417
+ error?: string;
418
+ lastSeenIndex: number;
419
+ }