@makefinks/daemon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/cli.js +22 -0
  4. package/package.json +79 -0
  5. package/src/ai/agent-turn-runner.ts +130 -0
  6. package/src/ai/daemon-ai.ts +403 -0
  7. package/src/ai/exa-client.ts +21 -0
  8. package/src/ai/exa-fetch-cache.ts +104 -0
  9. package/src/ai/model-config.ts +99 -0
  10. package/src/ai/sanitize-messages.ts +83 -0
  11. package/src/ai/system-prompt.ts +363 -0
  12. package/src/ai/tools/fetch-urls.ts +187 -0
  13. package/src/ai/tools/grounding-manager.ts +94 -0
  14. package/src/ai/tools/index.ts +52 -0
  15. package/src/ai/tools/read-file.ts +100 -0
  16. package/src/ai/tools/render-url.ts +275 -0
  17. package/src/ai/tools/run-bash.ts +224 -0
  18. package/src/ai/tools/subagents.ts +195 -0
  19. package/src/ai/tools/todo-manager.ts +150 -0
  20. package/src/ai/tools/web-search.ts +91 -0
  21. package/src/app/App.tsx +711 -0
  22. package/src/app/components/AppOverlays.tsx +131 -0
  23. package/src/app/components/AvatarLayer.tsx +51 -0
  24. package/src/app/components/ConversationPane.tsx +476 -0
  25. package/src/avatar/DaemonAvatarRenderable.ts +343 -0
  26. package/src/avatar/daemon-avatar-rig.ts +1165 -0
  27. package/src/avatar-preview.ts +186 -0
  28. package/src/cli.ts +26 -0
  29. package/src/components/ApiKeyInput.tsx +99 -0
  30. package/src/components/ApiKeyStep.tsx +95 -0
  31. package/src/components/ApprovalPicker.tsx +109 -0
  32. package/src/components/ContentBlockView.tsx +141 -0
  33. package/src/components/DaemonText.tsx +34 -0
  34. package/src/components/DeviceMenu.tsx +166 -0
  35. package/src/components/GroundingBadge.tsx +21 -0
  36. package/src/components/GroundingMenu.tsx +310 -0
  37. package/src/components/HotkeysPane.tsx +115 -0
  38. package/src/components/InlineStatusIndicator.tsx +106 -0
  39. package/src/components/ModelMenu.tsx +411 -0
  40. package/src/components/OnboardingOverlay.tsx +446 -0
  41. package/src/components/ProviderMenu.tsx +177 -0
  42. package/src/components/SessionMenu.tsx +297 -0
  43. package/src/components/SettingsMenu.tsx +291 -0
  44. package/src/components/StatusBar.tsx +126 -0
  45. package/src/components/TokenUsageDisplay.tsx +92 -0
  46. package/src/components/ToolCallView.tsx +113 -0
  47. package/src/components/TypingInputBar.tsx +131 -0
  48. package/src/components/tool-layouts/components.tsx +120 -0
  49. package/src/components/tool-layouts/defaults.ts +9 -0
  50. package/src/components/tool-layouts/index.ts +22 -0
  51. package/src/components/tool-layouts/layouts/bash.ts +110 -0
  52. package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
  53. package/src/components/tool-layouts/layouts/index.ts +8 -0
  54. package/src/components/tool-layouts/layouts/read-file.ts +59 -0
  55. package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
  56. package/src/components/tool-layouts/layouts/system-info.ts +8 -0
  57. package/src/components/tool-layouts/layouts/todo.tsx +139 -0
  58. package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
  59. package/src/components/tool-layouts/layouts/web-search.ts +110 -0
  60. package/src/components/tool-layouts/registry.ts +17 -0
  61. package/src/components/tool-layouts/types.ts +94 -0
  62. package/src/hooks/daemon-event-handlers.ts +944 -0
  63. package/src/hooks/keyboard-handlers.ts +399 -0
  64. package/src/hooks/menu-navigation.ts +147 -0
  65. package/src/hooks/use-app-audio-devices-loader.ts +71 -0
  66. package/src/hooks/use-app-callbacks.ts +202 -0
  67. package/src/hooks/use-app-context-builder.ts +159 -0
  68. package/src/hooks/use-app-display-state.ts +162 -0
  69. package/src/hooks/use-app-menus.ts +51 -0
  70. package/src/hooks/use-app-model-pricing-loader.ts +45 -0
  71. package/src/hooks/use-app-model.ts +123 -0
  72. package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
  73. package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
  74. package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
  75. package/src/hooks/use-app-sessions.ts +105 -0
  76. package/src/hooks/use-app-settings.ts +62 -0
  77. package/src/hooks/use-conversation-manager.ts +163 -0
  78. package/src/hooks/use-copy-on-select.ts +50 -0
  79. package/src/hooks/use-daemon-events.ts +396 -0
  80. package/src/hooks/use-daemon-keyboard.ts +397 -0
  81. package/src/hooks/use-grounding.ts +46 -0
  82. package/src/hooks/use-input-history.ts +92 -0
  83. package/src/hooks/use-menu-keyboard.ts +93 -0
  84. package/src/hooks/use-playwright-notification.ts +23 -0
  85. package/src/hooks/use-reasoning-animation.ts +97 -0
  86. package/src/hooks/use-response-timer.ts +55 -0
  87. package/src/hooks/use-tool-approval.tsx +202 -0
  88. package/src/hooks/use-typing-mode.ts +137 -0
  89. package/src/hooks/use-voice-dependencies-notification.ts +37 -0
  90. package/src/index.tsx +48 -0
  91. package/src/scripts/setup-browsers.ts +42 -0
  92. package/src/state/app-context.tsx +160 -0
  93. package/src/state/daemon-events.ts +67 -0
  94. package/src/state/daemon-state.ts +493 -0
  95. package/src/state/migrations/001-init.ts +33 -0
  96. package/src/state/migrations/index.ts +8 -0
  97. package/src/state/model-history-store.ts +45 -0
  98. package/src/state/runtime-context.ts +21 -0
  99. package/src/state/session-store.ts +359 -0
  100. package/src/types/index.ts +405 -0
  101. package/src/types/theme.ts +52 -0
  102. package/src/ui/constants.ts +157 -0
  103. package/src/utils/clipboard.ts +89 -0
  104. package/src/utils/debug-logger.ts +69 -0
  105. package/src/utils/formatters.ts +242 -0
  106. package/src/utils/js-rendering.ts +77 -0
  107. package/src/utils/markdown-tables.ts +234 -0
  108. package/src/utils/model-metadata.ts +191 -0
  109. package/src/utils/openrouter-endpoints.ts +212 -0
  110. package/src/utils/openrouter-models.ts +205 -0
  111. package/src/utils/openrouter-pricing.ts +59 -0
  112. package/src/utils/openrouter-reported-cost.ts +16 -0
  113. package/src/utils/paste.ts +33 -0
  114. package/src/utils/preferences.ts +289 -0
  115. package/src/utils/text-fragment.ts +39 -0
  116. package/src/utils/tool-output-preview.ts +250 -0
  117. package/src/utils/voice-dependencies.ts +107 -0
  118. package/src/utils/workspace-manager.ts +85 -0
  119. package/src/voice/audio-recorder.ts +579 -0
  120. package/src/voice/mic-level.ts +35 -0
  121. package/src/voice/tts/openai-tts-stream.ts +222 -0
  122. package/src/voice/tts/speech-controller.ts +64 -0
  123. package/src/voice/tts/tts-player.ts +257 -0
  124. package/src/voice/voice-input-controller.ts +96 -0
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Hook for handling keyboard input across all application states.
3
+ */
4
+
5
+ import { useCallback } from "react";
6
+ import type { KeyEvent } from "@opentui/core";
7
+ import { useKeyboard } from "@opentui/react";
8
+ import { toast } from "@opentui-ui/toast/react";
9
+ import { COLORS } from "../ui/constants";
10
+ import { getDaemonManager } from "../state/daemon-state";
11
+ import { DaemonState, type AppPreferences } from "../types";
12
+ import type { ScrollBoxRenderable } from "@opentui/core";
13
+ export interface KeyboardHandlerState {
14
+ isOverlayOpen: boolean;
15
+ escPendingCancel: boolean;
16
+ hasInteracted: boolean;
17
+ hasGrounding: boolean;
18
+ showFullReasoning: boolean;
19
+ showToolOutput: boolean;
20
+ }
21
+
22
+ export interface KeyboardHandlerActions {
23
+ setShowDeviceMenu: (show: boolean) => void;
24
+ setShowSettingsMenu: (show: boolean) => void;
25
+ setShowModelMenu: (show: boolean) => void;
26
+ setShowProviderMenu: (show: boolean) => void;
27
+ setShowSessionMenu: (show: boolean) => void;
28
+ setShowHotkeysPane: (show: boolean) => void;
29
+ setShowGroundingMenu: (show: boolean) => void;
30
+ setTypingInput: (input: string | ((prev: string) => string)) => void;
31
+ setCurrentTranscription: (text: string) => void;
32
+ setCurrentResponse: (text: string) => void;
33
+ setApiKeyMissingError: (msg: string) => void;
34
+ setEscPendingCancel: (pending: boolean) => void;
35
+ setShowFullReasoning: (show: boolean | ((prev: boolean) => boolean)) => void;
36
+ setShowToolOutput: (show: boolean | ((prev: boolean) => boolean)) => void;
37
+ persistPreferences: (updates: Partial<AppPreferences>) => void;
38
+ clearReasoningState: () => void;
39
+ currentUserInputRef: React.RefObject<string>;
40
+ conversationScrollRef: React.RefObject<ScrollBoxRenderable | null>;
41
+ startNewSession: () => void;
42
+ undoLastTurn: () => void;
43
+ }
44
+
45
+ export function useDaemonKeyboard(state: KeyboardHandlerState, actions: KeyboardHandlerActions) {
46
+ const manager = getDaemonManager();
47
+ const { isOverlayOpen, escPendingCancel, hasInteracted, hasGrounding } = state;
48
+
49
+ const handleKeyPress = useCallback(
50
+ (key: KeyEvent) => {
51
+ const currentState = manager.state;
52
+
53
+ if (isOverlayOpen) return;
54
+
55
+ if (
56
+ key.eventType === "press" &&
57
+ !key.ctrl &&
58
+ !key.meta &&
59
+ !key.shift &&
60
+ currentState !== DaemonState.TYPING &&
61
+ (key.name === "up" || key.name === "down" || key.sequence === "k" || key.sequence === "j")
62
+ ) {
63
+ const scrollbox = actions.conversationScrollRef.current;
64
+ const viewportHeight = scrollbox?.viewport?.height ?? 0;
65
+ if (!scrollbox || viewportHeight <= 0) return;
66
+
67
+ const maxScrollTop = Math.max(0, scrollbox.scrollHeight - viewportHeight);
68
+ const step = Math.max(1, Math.floor(viewportHeight * 0.1));
69
+ const delta = key.name === "up" || key.sequence === "k" ? -step : step;
70
+ const nextScrollTop = Math.max(0, Math.min(scrollbox.scrollTop + delta, maxScrollTop));
71
+ if (nextScrollTop !== scrollbox.scrollTop) {
72
+ scrollbox.scrollTop = nextScrollTop;
73
+ }
74
+ key.preventDefault();
75
+ return;
76
+ }
77
+
78
+ if (key.eventType === "press" && key.ctrl && (key.name === "u" || key.name === "d")) {
79
+ const scrollbox = actions.conversationScrollRef.current;
80
+ const viewportHeight = scrollbox?.viewport?.height ?? 0;
81
+ if (!scrollbox || viewportHeight <= 0) return;
82
+
83
+ const maxScrollTop = Math.max(0, scrollbox.scrollHeight - viewportHeight);
84
+ const delta = key.name === "u" ? -viewportHeight : viewportHeight;
85
+ const nextScrollTop = Math.max(0, Math.min(scrollbox.scrollTop + delta, maxScrollTop));
86
+ if (nextScrollTop !== scrollbox.scrollTop) {
87
+ scrollbox.scrollTop = nextScrollTop;
88
+ }
89
+ key.preventDefault();
90
+ return;
91
+ }
92
+
93
+ // 'D' key to open device menu (only in IDLE state before conversation starts)
94
+ if (
95
+ (key.sequence === "d" || key.sequence === "D") &&
96
+ key.eventType === "press" &&
97
+ currentState === DaemonState.IDLE &&
98
+ !hasInteracted
99
+ ) {
100
+ actions.setShowModelMenu(false);
101
+ actions.setShowProviderMenu(false);
102
+ actions.setShowSessionMenu(false);
103
+ actions.setShowSettingsMenu(false);
104
+ actions.setShowGroundingMenu(false);
105
+ actions.setShowHotkeysPane(false);
106
+ actions.setShowDeviceMenu(true);
107
+ key.preventDefault();
108
+ return;
109
+ }
110
+
111
+ // 'L' key to open session menu (in IDLE or SPEAKING state)
112
+ if (
113
+ (key.sequence === "l" || key.sequence === "L") &&
114
+ key.eventType === "press" &&
115
+ (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING)
116
+ ) {
117
+ actions.setShowDeviceMenu(false);
118
+ actions.setShowSettingsMenu(false);
119
+ actions.setShowModelMenu(false);
120
+ actions.setShowProviderMenu(false);
121
+ actions.setShowGroundingMenu(false);
122
+ actions.setShowHotkeysPane(false);
123
+ actions.setShowSessionMenu(true);
124
+ key.preventDefault();
125
+ return;
126
+ }
127
+
128
+ // 'S' key to open settings menu (in IDLE, SPEAKING, or RESPONDING state)
129
+ if (
130
+ (key.sequence === "s" || key.sequence === "S") &&
131
+ key.eventType === "press" &&
132
+ (currentState === DaemonState.IDLE ||
133
+ currentState === DaemonState.SPEAKING ||
134
+ currentState === DaemonState.RESPONDING)
135
+ ) {
136
+ actions.setShowDeviceMenu(false);
137
+ actions.setShowModelMenu(false);
138
+ actions.setShowProviderMenu(false);
139
+ actions.setShowSessionMenu(false);
140
+ actions.setShowGroundingMenu(false);
141
+ actions.setShowHotkeysPane(false);
142
+ actions.setShowSettingsMenu(true);
143
+ key.preventDefault();
144
+ return;
145
+ }
146
+
147
+ // 'M' key to open model menu (in IDLE, SPEAKING, or RESPONDING state)
148
+ if (
149
+ (key.sequence === "m" || key.sequence === "M") &&
150
+ key.eventType === "press" &&
151
+ (currentState === DaemonState.IDLE ||
152
+ currentState === DaemonState.SPEAKING ||
153
+ currentState === DaemonState.RESPONDING)
154
+ ) {
155
+ actions.setShowDeviceMenu(false);
156
+ actions.setShowSettingsMenu(false);
157
+ actions.setShowProviderMenu(false);
158
+ actions.setShowSessionMenu(false);
159
+ actions.setShowGroundingMenu(false);
160
+ actions.setShowHotkeysPane(false);
161
+ actions.setShowModelMenu(true);
162
+ key.preventDefault();
163
+ return;
164
+ }
165
+
166
+ // 'P' key to open provider menu (in IDLE or SPEAKING state)
167
+ if (
168
+ (key.sequence === "p" || key.sequence === "P") &&
169
+ key.eventType === "press" &&
170
+ (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING)
171
+ ) {
172
+ actions.setShowDeviceMenu(false);
173
+ actions.setShowSettingsMenu(false);
174
+ actions.setShowModelMenu(false);
175
+ actions.setShowSessionMenu(false);
176
+ actions.setShowGroundingMenu(false);
177
+ actions.setShowHotkeysPane(false);
178
+ actions.setShowProviderMenu(true);
179
+ key.preventDefault();
180
+ return;
181
+ }
182
+
183
+ // 'G' key to open grounding menu (in IDLE or SPEAKING state when grounding exists)
184
+ if (
185
+ (key.sequence === "g" || key.sequence === "G") &&
186
+ key.eventType === "press" &&
187
+ (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING) &&
188
+ hasGrounding
189
+ ) {
190
+ actions.setShowDeviceMenu(false);
191
+ actions.setShowSettingsMenu(false);
192
+ actions.setShowModelMenu(false);
193
+ actions.setShowProviderMenu(false);
194
+ actions.setShowSessionMenu(false);
195
+ actions.setShowHotkeysPane(false);
196
+ actions.setShowGroundingMenu(true);
197
+ key.preventDefault();
198
+ return;
199
+ }
200
+
201
+ // 'N' key to start a new session (in IDLE or SPEAKING state)
202
+ if (
203
+ (key.sequence === "n" || key.sequence === "N") &&
204
+ key.eventType === "press" &&
205
+ (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING)
206
+ ) {
207
+ actions.startNewSession();
208
+ key.preventDefault();
209
+ return;
210
+ }
211
+
212
+ // Ctrl+X to undo last turn (in IDLE or SPEAKING state)
213
+ if (
214
+ key.ctrl &&
215
+ (key.name === "x" || key.sequence === "\u0018") &&
216
+ key.eventType === "press" &&
217
+ (currentState === DaemonState.IDLE || currentState === DaemonState.SPEAKING)
218
+ ) {
219
+ actions.undoLastTurn();
220
+ key.preventDefault();
221
+ return;
222
+ }
223
+
224
+ // 'T' key to toggle full reasoning display (in IDLE, SPEAKING, or RESPONDING state)
225
+ if (
226
+ (key.sequence === "t" || key.sequence === "T") &&
227
+ key.eventType === "press" &&
228
+ (currentState === DaemonState.IDLE ||
229
+ currentState === DaemonState.SPEAKING ||
230
+ currentState === DaemonState.RESPONDING)
231
+ ) {
232
+ const next = !state.showFullReasoning;
233
+ actions.setShowFullReasoning(next);
234
+ actions.persistPreferences({ showFullReasoning: next });
235
+ toast.info(`FULL PREVIEWS: ${next ? "ON" : "OFF"}`, {
236
+ description: next
237
+ ? "Reasoning blocks will display in full."
238
+ : "Reasoning blocks will show as a compact ticker.",
239
+ style: {
240
+ foregroundColor: next ? COLORS.DAEMON_TEXT : COLORS.ERROR,
241
+ },
242
+ });
243
+ key.preventDefault();
244
+ return;
245
+ }
246
+
247
+ // 'O' key to toggle tool output preview (in IDLE, SPEAKING, or RESPONDING state)
248
+ if (
249
+ (key.sequence === "o" || key.sequence === "O") &&
250
+ key.eventType === "press" &&
251
+ (currentState === DaemonState.IDLE ||
252
+ currentState === DaemonState.SPEAKING ||
253
+ currentState === DaemonState.RESPONDING)
254
+ ) {
255
+ const next = !state.showToolOutput;
256
+ actions.setShowToolOutput(next);
257
+ actions.persistPreferences({ showToolOutput: next });
258
+ toast.info(`TOOL OUTPUT: ${next ? "ON" : "OFF"}`, {
259
+ description: next ? "Tool outputs will be displayed." : "Tool outputs will be hidden.",
260
+ style: {
261
+ foregroundColor: next ? COLORS.DAEMON_TEXT : COLORS.ERROR,
262
+ },
263
+ });
264
+ key.preventDefault();
265
+ return;
266
+ }
267
+
268
+ // '?' key to show hotkeys pane
269
+ 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);
276
+ actions.setShowHotkeysPane(true);
277
+ key.preventDefault();
278
+ return;
279
+ }
280
+
281
+ // Space key for voice activation (toggle)
282
+ if (
283
+ key.name === "space" &&
284
+ key.eventType === "press" &&
285
+ !key.shift &&
286
+ !key.ctrl &&
287
+ !key.meta &&
288
+ currentState !== DaemonState.TYPING
289
+ ) {
290
+ if (currentState === DaemonState.IDLE) {
291
+ // Check for OpenRouter API key first (needed for any AI response)
292
+ if (!process.env.OPENROUTER_API_KEY) {
293
+ actions.setApiKeyMissingError(
294
+ "OPENROUTER_API_KEY not found · Set via environment variable or enter in onboarding"
295
+ );
296
+ key.preventDefault();
297
+ return;
298
+ }
299
+ // Check for OpenAI API key (needed for voice transcription)
300
+ if (!process.env.OPENAI_API_KEY) {
301
+ actions.setApiKeyMissingError("Voice input is disabled because OpenAI API key is not set.");
302
+ key.preventDefault();
303
+ return;
304
+ }
305
+
306
+ // Clear any previous error
307
+ actions.setApiKeyMissingError("");
308
+ actions.setCurrentTranscription("");
309
+ actions.setCurrentResponse("");
310
+ manager.startListening();
311
+ } else if (currentState === DaemonState.LISTENING) {
312
+ manager.stopListening();
313
+ }
314
+ key.preventDefault();
315
+ return;
316
+ }
317
+
318
+ // Shift+Tab for typing mode
319
+ if (key.name === "tab" && key.shift && key.eventType === "press") {
320
+ if (currentState === DaemonState.IDLE) {
321
+ // Check for OpenRouter API key (needed for any AI response)
322
+ if (!process.env.OPENROUTER_API_KEY) {
323
+ actions.setApiKeyMissingError(
324
+ "OPENROUTER_API_KEY not found · Set via environment variable or enter in onboarding"
325
+ );
326
+ key.preventDefault();
327
+ return;
328
+ }
329
+
330
+ // Clear any previous error
331
+ actions.setApiKeyMissingError("");
332
+ actions.setCurrentTranscription("");
333
+ actions.setCurrentResponse("");
334
+ actions.setTypingInput("");
335
+ manager.enterTypingMode();
336
+ }
337
+ key.preventDefault();
338
+ return;
339
+ }
340
+
341
+ // Escape to cancel current action or exit typing mode
342
+ if (key.name === "escape" && key.eventType === "press") {
343
+ if (currentState === DaemonState.TYPING) {
344
+ manager.exitTypingMode();
345
+ actions.setTypingInput("");
346
+ } else if (currentState === DaemonState.LISTENING) {
347
+ manager.cancelCurrentAction();
348
+ actions.setCurrentTranscription("");
349
+ actions.setCurrentResponse("");
350
+ actions.clearReasoningState();
351
+ actions.currentUserInputRef.current = "";
352
+ } else if (currentState === DaemonState.SPEAKING) {
353
+ // Cancel TTS playback immediately with single ESC
354
+ manager.cancelCurrentAction();
355
+ } else if (currentState === DaemonState.TRANSCRIBING || currentState === DaemonState.RESPONDING) {
356
+ if (escPendingCancel) {
357
+ manager.cancelCurrentAction();
358
+ actions.setCurrentTranscription("");
359
+ actions.setCurrentResponse("");
360
+ actions.clearReasoningState();
361
+ actions.currentUserInputRef.current = "";
362
+ actions.setEscPendingCancel(false);
363
+ } else {
364
+ actions.setEscPendingCancel(true);
365
+ }
366
+ }
367
+ key.preventDefault();
368
+ return;
369
+ }
370
+
371
+ // Enter to submit in typing mode
372
+ if (key.name === "return" && key.eventType === "press" && currentState === DaemonState.TYPING) {
373
+ // Let the focused <input> handle submit (global handlers run first in OpenTUI).
374
+ return;
375
+ }
376
+
377
+ // Regular character input in typing mode (handled by <input> component)
378
+ if (currentState === DaemonState.TYPING && key.eventType === "press" && !key.ctrl && !key.meta) {
379
+ // Don't prevent default for regular keys so <input> can receive them
380
+ // except for those that might trigger global actions
381
+ return;
382
+ }
383
+ },
384
+ [
385
+ manager,
386
+ isOverlayOpen,
387
+ escPendingCancel,
388
+ hasInteracted,
389
+ hasGrounding,
390
+ state.showFullReasoning,
391
+ state.showToolOutput,
392
+ actions,
393
+ ]
394
+ );
395
+
396
+ useKeyboard(handleKeyPress);
397
+ }
@@ -0,0 +1,46 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import type { GroundingMap } from "../types";
3
+ import { loadLatestGroundingMap } from "../state/session-store";
4
+ import { daemonEvents } from "../state/daemon-events";
5
+
6
+ export interface UseGroundingReturn {
7
+ latestGroundingMap: GroundingMap | null;
8
+ hasGrounding: boolean;
9
+ refreshGrounding: () => Promise<void>;
10
+ }
11
+
12
+ export function useGrounding(sessionId: string | null): UseGroundingReturn {
13
+ const [latestGroundingMap, setLatestGroundingMap] = useState<GroundingMap | null>(null);
14
+
15
+ const refreshGrounding = useCallback(async () => {
16
+ if (!sessionId) {
17
+ setLatestGroundingMap(null);
18
+ return;
19
+ }
20
+ const map = await loadLatestGroundingMap(sessionId);
21
+ setLatestGroundingMap(map);
22
+ }, [sessionId]);
23
+
24
+ useEffect(() => {
25
+ void refreshGrounding();
26
+ }, [refreshGrounding]);
27
+
28
+ useEffect(() => {
29
+ const handleGroundingSaved = (savedSessionId: string) => {
30
+ if (savedSessionId === sessionId) {
31
+ void refreshGrounding();
32
+ }
33
+ };
34
+
35
+ daemonEvents.on("groundingSaved", handleGroundingSaved);
36
+ return () => {
37
+ daemonEvents.off("groundingSaved", handleGroundingSaved);
38
+ };
39
+ }, [sessionId, refreshGrounding]);
40
+
41
+ return {
42
+ latestGroundingMap,
43
+ hasGrounding: latestGroundingMap !== null && latestGroundingMap.items.length > 0,
44
+ refreshGrounding,
45
+ };
46
+ }
@@ -0,0 +1,92 @@
1
+ import { useCallback, useRef, useState, useEffect } from "react";
2
+ import { loadPreferences, updatePreferences } from "../utils/preferences";
3
+
4
+ const MAX_HISTORY_SIZE = 20;
5
+
6
+ export interface UseInputHistoryReturn {
7
+ addToHistory: (input: string) => void;
8
+ navigateUp: (currentInput: string) => string | null;
9
+ navigateDown: () => string | null;
10
+ resetNavigation: () => void;
11
+ isNavigating: boolean;
12
+ }
13
+
14
+ export function useInputHistory(): UseInputHistoryReturn {
15
+ const [history, setHistory] = useState<string[]>([]);
16
+ const navigationIndexRef = useRef<number>(-1);
17
+ const savedCurrentInputRef = useRef<string>("");
18
+ const isNavigatingRef = useRef<boolean>(false);
19
+
20
+ useEffect(() => {
21
+ loadPreferences().then((prefs) => {
22
+ if (prefs?.inputHistory) {
23
+ setHistory(prefs.inputHistory);
24
+ }
25
+ });
26
+ }, []);
27
+
28
+ const addToHistory = useCallback((input: string) => {
29
+ const trimmed = input.trim();
30
+ if (!trimmed) return;
31
+
32
+ setHistory((prev) => {
33
+ const filtered = prev.filter((item) => item !== trimmed);
34
+ const next = [trimmed, ...filtered].slice(0, MAX_HISTORY_SIZE);
35
+ void updatePreferences({ inputHistory: next });
36
+ return next;
37
+ });
38
+
39
+ navigationIndexRef.current = -1;
40
+ savedCurrentInputRef.current = "";
41
+ isNavigatingRef.current = false;
42
+ }, []);
43
+
44
+ const navigateUp = useCallback(
45
+ (currentInput: string): string | null => {
46
+ if (history.length === 0) return null;
47
+
48
+ if (!isNavigatingRef.current) {
49
+ savedCurrentInputRef.current = currentInput;
50
+ isNavigatingRef.current = true;
51
+ navigationIndexRef.current = 0;
52
+ return history[0] ?? null;
53
+ }
54
+
55
+ const nextIndex = navigationIndexRef.current + 1;
56
+ if (nextIndex >= history.length) return null;
57
+
58
+ navigationIndexRef.current = nextIndex;
59
+ return history[nextIndex] ?? null;
60
+ },
61
+ [history]
62
+ );
63
+
64
+ const navigateDown = useCallback((): string | null => {
65
+ if (!isNavigatingRef.current) return null;
66
+
67
+ const nextIndex = navigationIndexRef.current - 1;
68
+
69
+ if (nextIndex < 0) {
70
+ isNavigatingRef.current = false;
71
+ navigationIndexRef.current = -1;
72
+ return savedCurrentInputRef.current;
73
+ }
74
+
75
+ navigationIndexRef.current = nextIndex;
76
+ return history[nextIndex] ?? null;
77
+ }, [history]);
78
+
79
+ const resetNavigation = useCallback(() => {
80
+ navigationIndexRef.current = -1;
81
+ savedCurrentInputRef.current = "";
82
+ isNavigatingRef.current = false;
83
+ }, []);
84
+
85
+ return {
86
+ addToHistory,
87
+ navigateUp,
88
+ navigateDown,
89
+ resetNavigation,
90
+ isNavigating: isNavigatingRef.current,
91
+ };
92
+ }
@@ -0,0 +1,93 @@
1
+ import type { KeyEvent } from "@opentui/core";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import type { Dispatch, SetStateAction } from "react";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import { handleMenuKey, isNavigateDownKey, isNavigateUpKey } from "./menu-navigation";
6
+
7
+ interface UseMenuKeyboardParams {
8
+ itemCount: number;
9
+ initialIndex?: number;
10
+ onClose: () => void;
11
+ onSelect: (selectedIndex: number) => void;
12
+ enableViKeys?: boolean;
13
+ closeOnSelect?: boolean;
14
+ ignoreEscape?: boolean;
15
+ }
16
+
17
+ interface UseMenuKeyboardReturn {
18
+ selectedIndex: number;
19
+ setSelectedIndex: Dispatch<SetStateAction<number>>;
20
+ }
21
+
22
+ export function useMenuKeyboard({
23
+ itemCount,
24
+ initialIndex = 0,
25
+ onClose,
26
+ onSelect,
27
+ enableViKeys = true,
28
+ closeOnSelect = true,
29
+ ignoreEscape = false,
30
+ }: UseMenuKeyboardParams): UseMenuKeyboardReturn {
31
+ const [selectedIndex, setSelectedIndex] = useState(initialIndex);
32
+
33
+ useEffect(() => {
34
+ setSelectedIndex(initialIndex);
35
+ }, [initialIndex]);
36
+
37
+ useEffect(() => {
38
+ if (itemCount <= 0) {
39
+ setSelectedIndex(0);
40
+ return;
41
+ }
42
+ setSelectedIndex((prev) => {
43
+ if (prev < 0) return 0;
44
+ if (prev >= itemCount) return itemCount - 1;
45
+ return prev;
46
+ });
47
+ }, [itemCount]);
48
+
49
+ const handleKeyPress = useCallback(
50
+ (key: KeyEvent) => {
51
+ if (key.eventType !== "press") return;
52
+ if (ignoreEscape && key.name === "escape") {
53
+ return;
54
+ }
55
+
56
+ if (itemCount === 0) {
57
+ if (key.name === "escape") {
58
+ onClose();
59
+ key.preventDefault();
60
+ return;
61
+ }
62
+ if (
63
+ key.name === "return" ||
64
+ isNavigateUpKey(key, enableViKeys) ||
65
+ isNavigateDownKey(key, enableViKeys)
66
+ ) {
67
+ key.preventDefault();
68
+ }
69
+ return;
70
+ }
71
+
72
+ const result = handleMenuKey(key, selectedIndex, itemCount, enableViKeys, closeOnSelect);
73
+ if (!result.handled) return;
74
+
75
+ setSelectedIndex(result.selectedIndex);
76
+
77
+ if (result.itemSelected) {
78
+ onSelect(result.selectedIndex);
79
+ }
80
+
81
+ if (result.shouldClose) {
82
+ onClose();
83
+ }
84
+
85
+ key.preventDefault();
86
+ },
87
+ [itemCount, selectedIndex, onClose, onSelect, enableViKeys, closeOnSelect]
88
+ );
89
+
90
+ useKeyboard(handleKeyPress);
91
+
92
+ return { selectedIndex, setSelectedIndex };
93
+ }
@@ -0,0 +1,23 @@
1
+ import { useEffect } from "react";
2
+ import { toast } from "@opentui-ui/toast/react";
3
+ import { detectLocalPlaywrightChromium } from "../utils/js-rendering";
4
+
5
+ export function usePlaywrightNotification(): void {
6
+ useEffect(() => {
7
+ let cancelled = false;
8
+
9
+ void (async () => {
10
+ const capability = await detectLocalPlaywrightChromium();
11
+ if (cancelled) return;
12
+
13
+ if (capability.available) return;
14
+
15
+ const description = capability.hint ? `${capability.reason}\n\n${capability.hint}` : capability.reason;
16
+ toast.warning("JS-rendered pages unavailable", { description });
17
+ })();
18
+
19
+ return () => {
20
+ cancelled = true;
21
+ };
22
+ }, []);
23
+ }