@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,399 @@
1
+ import type { KeyEvent } from "@opentui/core";
2
+ import type {
3
+ AudioDevice,
4
+ ModelOption,
5
+ OnboardingStep,
6
+ SpeechSpeed,
7
+ ReasoningEffort,
8
+ BashApprovalLevel,
9
+ VoiceInteractionType,
10
+ AppPreferences,
11
+ } from "../types";
12
+ import { REASONING_EFFORT_LEVELS, BASH_APPROVAL_LEVELS } from "../types";
13
+ import { setAudioDevice } from "../voice/audio-recorder";
14
+ import { setResponseModel } from "../ai/model-config";
15
+ import { openUrlInBrowser } from "../utils/preferences";
16
+ import { isNavigateUpKey, isNavigateDownKey } from "./menu-navigation";
17
+
18
+ export type KeyHandler = (key: KeyEvent) => boolean;
19
+
20
+ const API_KEY_URLS: Record<string, string> = {
21
+ openrouter_key: "https://openrouter.ai/keys",
22
+ openai_key: "https://platform.openai.com/api-keys",
23
+ exa_key: "https://dashboard.exa.ai/api-keys",
24
+ };
25
+
26
+ interface OnboardingContext {
27
+ step: OnboardingStep;
28
+ devices: AudioDevice[];
29
+ models: ModelOption[];
30
+ selectedDeviceIdx: number;
31
+ selectedModelIdx: number;
32
+ preferences: AppPreferences | null;
33
+ setSelectedDeviceIdx: (fn: (prev: number) => number) => void;
34
+ setSelectedModelIdx: (fn: (prev: number) => number) => void;
35
+ setCurrentDevice: (device: string) => void;
36
+ setCurrentOutputDevice: (device: string) => void;
37
+ setCurrentModelId: (modelId: string) => void;
38
+ setOnboardingStep: (step: OnboardingStep) => void;
39
+ completeOnboarding: () => void;
40
+ persistPreferences: (updates: Partial<AppPreferences>) => void;
41
+ currentModelId: string;
42
+ manager: {
43
+ outputDeviceName?: string;
44
+ };
45
+ }
46
+
47
+ export function getApiKeyUrl(step: OnboardingStep): string | null {
48
+ return API_KEY_URLS[step] ?? null;
49
+ }
50
+
51
+ export function isApiKeyStep(step: OnboardingStep): boolean {
52
+ return step === "openrouter_key" || step === "openai_key" || step === "exa_key";
53
+ }
54
+
55
+ type StepCondition = {
56
+ step: OnboardingStep;
57
+ check: (prefs: AppPreferences | null) => boolean;
58
+ /** If true, this step is only shown during initial onboarding, not when re-prompting */
59
+ onboardingOnly?: boolean;
60
+ };
61
+
62
+ const STEP_CONDITIONS: StepCondition[] = [
63
+ { step: "openrouter_key", check: () => !process.env.OPENROUTER_API_KEY },
64
+ { step: "openai_key", check: () => !process.env.OPENAI_API_KEY },
65
+ { step: "exa_key", check: () => !process.env.EXA_API_KEY },
66
+ { step: "device", check: (prefs) => !prefs?.audioDeviceName, onboardingOnly: true },
67
+ { step: "model", check: (prefs) => !prefs?.modelId, onboardingOnly: true },
68
+ { step: "settings", check: () => true, onboardingOnly: true },
69
+ ];
70
+
71
+ const STEP_ORDER: OnboardingStep[] = [
72
+ "intro",
73
+ "openrouter_key",
74
+ "openai_key",
75
+ "exa_key",
76
+ "device",
77
+ "model",
78
+ "settings",
79
+ "complete",
80
+ ];
81
+
82
+ export function determineNextStep(
83
+ currentStep: OnboardingStep,
84
+ preferences: AppPreferences | null
85
+ ): OnboardingStep {
86
+ const currentIndex = STEP_ORDER.indexOf(currentStep);
87
+ if (currentIndex === -1 || currentStep === "complete") return "complete";
88
+
89
+ const isReprompt = preferences?.onboardingCompleted === true;
90
+
91
+ for (const condition of STEP_CONDITIONS) {
92
+ if (isReprompt && condition.onboardingOnly) continue;
93
+
94
+ const conditionIndex = STEP_ORDER.indexOf(condition.step);
95
+ if (conditionIndex > currentIndex && condition.check(preferences)) {
96
+ return condition.step;
97
+ }
98
+ }
99
+
100
+ return "complete";
101
+ }
102
+
103
+ type EscapeHandler = (ctx: OnboardingContext) => void;
104
+
105
+ const ESCAPE_HANDLERS: Partial<Record<OnboardingStep, EscapeHandler>> = {
106
+ intro: (ctx) => {
107
+ if (process.env.OPENROUTER_API_KEY) {
108
+ ctx.persistPreferences({ onboardingCompleted: true });
109
+ ctx.completeOnboarding();
110
+ }
111
+ },
112
+ openrouter_key: () => {},
113
+ openai_key: (ctx) => {
114
+ const nextStep = determineNextStep("openai_key", ctx.preferences);
115
+ if (nextStep === "complete") {
116
+ ctx.persistPreferences({ onboardingCompleted: true });
117
+ ctx.completeOnboarding();
118
+ } else {
119
+ ctx.setOnboardingStep(nextStep);
120
+ }
121
+ },
122
+ exa_key: (ctx) => {
123
+ const nextStep = determineNextStep("exa_key", ctx.preferences);
124
+ if (nextStep === "complete") {
125
+ ctx.persistPreferences({ onboardingCompleted: true });
126
+ ctx.completeOnboarding();
127
+ } else {
128
+ ctx.setOnboardingStep(nextStep);
129
+ }
130
+ },
131
+ device: (ctx) => ctx.setOnboardingStep("model"),
132
+ model: (ctx) => ctx.setOnboardingStep("settings"),
133
+ settings: (ctx) => {
134
+ ctx.persistPreferences({ onboardingCompleted: true });
135
+ ctx.completeOnboarding();
136
+ },
137
+ };
138
+
139
+ function handleEscapeKey(step: OnboardingStep, ctx: OnboardingContext): boolean {
140
+ const handler = ESCAPE_HANDLERS[step];
141
+ handler?.(ctx);
142
+ return true;
143
+ }
144
+
145
+ export function handleOnboardingKey(key: KeyEvent, ctx: OnboardingContext): boolean {
146
+ if (key.eventType !== "press") return true;
147
+
148
+ const { step, devices, models, selectedDeviceIdx, selectedModelIdx } = ctx;
149
+
150
+ if (key.sequence === "O" && key.shift && isApiKeyStep(step)) {
151
+ const url = getApiKeyUrl(step);
152
+ if (url) {
153
+ openUrlInBrowser(url);
154
+ }
155
+ return true;
156
+ }
157
+
158
+ if (key.name === "escape") {
159
+ return handleEscapeKey(step, ctx);
160
+ }
161
+
162
+ if (step === "intro") {
163
+ if (key.name === "return") {
164
+ ctx.setOnboardingStep(determineNextStep(step, ctx.preferences));
165
+ }
166
+ return true;
167
+ }
168
+
169
+ if (isApiKeyStep(step)) {
170
+ return false;
171
+ }
172
+
173
+ if (step === "device") {
174
+ if (devices.length === 0) return true;
175
+
176
+ const totalItems = devices.length * 2;
177
+
178
+ if (isNavigateUpKey(key)) {
179
+ ctx.setSelectedDeviceIdx((prev) => (prev > 0 ? prev - 1 : totalItems - 1));
180
+ return true;
181
+ }
182
+
183
+ if (isNavigateDownKey(key)) {
184
+ ctx.setSelectedDeviceIdx((prev) => (prev < totalItems - 1 ? prev + 1 : 0));
185
+ return true;
186
+ }
187
+
188
+ if (key.name === "return") {
189
+ const isOutputSection = selectedDeviceIdx >= devices.length;
190
+ const deviceIdx = isOutputSection ? selectedDeviceIdx - devices.length : selectedDeviceIdx;
191
+ const selectedDevice = devices[deviceIdx];
192
+ if (selectedDevice) {
193
+ if (isOutputSection) {
194
+ ctx.manager.outputDeviceName = selectedDevice.name;
195
+ ctx.setCurrentOutputDevice(selectedDevice.name);
196
+ ctx.persistPreferences({ audioOutputDeviceName: selectedDevice.name });
197
+ } else {
198
+ setAudioDevice(selectedDevice.name);
199
+ ctx.setCurrentDevice(selectedDevice.name);
200
+ ctx.persistPreferences({ audioDeviceName: selectedDevice.name });
201
+ }
202
+ }
203
+ }
204
+
205
+ if (key.name === "tab") {
206
+ ctx.setOnboardingStep("model");
207
+ return true;
208
+ }
209
+
210
+ return true;
211
+ }
212
+
213
+ if (step === "model") {
214
+ if (models.length === 0) return true;
215
+
216
+ if (isNavigateUpKey(key)) {
217
+ ctx.setSelectedModelIdx((prev) => (prev > 0 ? prev - 1 : models.length - 1));
218
+ return true;
219
+ }
220
+
221
+ if (isNavigateDownKey(key)) {
222
+ ctx.setSelectedModelIdx((prev) => (prev < models.length - 1 ? prev + 1 : 0));
223
+ return true;
224
+ }
225
+
226
+ if (key.name === "return") {
227
+ const selectedModel = models[selectedModelIdx];
228
+ if (selectedModel && selectedModel.id !== ctx.currentModelId) {
229
+ setResponseModel(selectedModel.id);
230
+ ctx.setCurrentModelId(selectedModel.id);
231
+ ctx.persistPreferences({
232
+ modelId: selectedModel.id,
233
+ openRouterProviderTag: undefined,
234
+ });
235
+ }
236
+ ctx.setOnboardingStep("settings");
237
+ }
238
+ return true;
239
+ }
240
+
241
+ if (step === "settings") {
242
+ if (key.name === "return") {
243
+ ctx.persistPreferences({ onboardingCompleted: true });
244
+ ctx.completeOnboarding();
245
+ }
246
+ return true;
247
+ }
248
+
249
+ return true;
250
+ }
251
+
252
+ interface SettingsMenuContext {
253
+ selectedIdx: number;
254
+ menuItemCount: number;
255
+ interactionMode: "text" | "voice";
256
+ voiceInteractionType: VoiceInteractionType;
257
+ speechSpeed: SpeechSpeed;
258
+ reasoningEffort: ReasoningEffort;
259
+ bashApprovalLevel: BashApprovalLevel;
260
+ supportsReasoning: boolean;
261
+ canEnableVoiceOutput: boolean;
262
+ showFullReasoning: boolean;
263
+ showToolOutput: boolean;
264
+ setSelectedIdx: (fn: (prev: number) => number) => void;
265
+ toggleInteractionMode: () => void;
266
+ setVoiceInteractionType: (type: VoiceInteractionType) => void;
267
+ setSpeechSpeed: (speed: SpeechSpeed) => void;
268
+ setReasoningEffort: (effort: ReasoningEffort) => void;
269
+ setBashApprovalLevel: (level: BashApprovalLevel) => void;
270
+ setShowFullReasoning: (show: boolean) => void;
271
+ setShowToolOutput: (show: boolean) => void;
272
+ persistPreferences: (updates: Partial<AppPreferences>) => void;
273
+ onClose: () => void;
274
+ manager: {
275
+ interactionMode: string;
276
+ voiceInteractionType: VoiceInteractionType;
277
+ speechSpeed: SpeechSpeed;
278
+ reasoningEffort: ReasoningEffort;
279
+ bashApprovalLevel: BashApprovalLevel;
280
+ };
281
+ }
282
+
283
+ export function handleSettingsMenuKey(key: KeyEvent, ctx: SettingsMenuContext): boolean {
284
+ if (key.eventType !== "press") return true;
285
+
286
+ if (key.name === "escape") {
287
+ ctx.onClose();
288
+ key.preventDefault();
289
+ return true;
290
+ }
291
+
292
+ if (isNavigateUpKey(key)) {
293
+ ctx.setSelectedIdx((prev) => (prev > 0 ? prev - 1 : ctx.menuItemCount - 1));
294
+ key.preventDefault();
295
+ return true;
296
+ }
297
+
298
+ if (isNavigateDownKey(key)) {
299
+ ctx.setSelectedIdx((prev) => (prev < ctx.menuItemCount - 1 ? prev + 1 : 0));
300
+ key.preventDefault();
301
+ return true;
302
+ }
303
+
304
+ if (key.name === "return") {
305
+ let settingIdx = 0;
306
+
307
+ if (ctx.selectedIdx === settingIdx) {
308
+ if (ctx.interactionMode === "text" && !ctx.canEnableVoiceOutput) {
309
+ key.preventDefault();
310
+ return true;
311
+ }
312
+ ctx.toggleInteractionMode();
313
+ ctx.persistPreferences({
314
+ interactionMode: ctx.manager.interactionMode as "text" | "voice",
315
+ });
316
+ key.preventDefault();
317
+ return true;
318
+ }
319
+ settingIdx++;
320
+
321
+ if (ctx.selectedIdx === settingIdx) {
322
+ const current = ctx.manager.voiceInteractionType;
323
+ const next = current === "direct" ? "review" : "direct";
324
+ ctx.manager.voiceInteractionType = next;
325
+ ctx.setVoiceInteractionType(next);
326
+ ctx.persistPreferences({ voiceInteractionType: next });
327
+ key.preventDefault();
328
+ return true;
329
+ }
330
+ settingIdx++;
331
+
332
+ if (ctx.selectedIdx === settingIdx) {
333
+ if (ctx.supportsReasoning) {
334
+ const currentEffort = ctx.manager.reasoningEffort;
335
+ const currentIndex = REASONING_EFFORT_LEVELS.indexOf(currentEffort);
336
+ const nextIndex = (currentIndex + 1) % REASONING_EFFORT_LEVELS.length;
337
+ const nextEffort = REASONING_EFFORT_LEVELS[nextIndex] ?? "medium";
338
+ ctx.manager.reasoningEffort = nextEffort;
339
+ ctx.setReasoningEffort(nextEffort);
340
+ ctx.persistPreferences({ reasoningEffort: nextEffort });
341
+ }
342
+ key.preventDefault();
343
+ return true;
344
+ }
345
+ settingIdx++;
346
+
347
+ if (ctx.selectedIdx === settingIdx) {
348
+ const currentLevel = ctx.manager.bashApprovalLevel;
349
+ const currentIndex = BASH_APPROVAL_LEVELS.indexOf(currentLevel);
350
+ const nextIndex = (currentIndex + 1) % BASH_APPROVAL_LEVELS.length;
351
+ const nextLevel = BASH_APPROVAL_LEVELS[nextIndex] ?? "dangerous";
352
+ ctx.manager.bashApprovalLevel = nextLevel;
353
+ ctx.setBashApprovalLevel(nextLevel);
354
+ ctx.persistPreferences({ bashApprovalLevel: nextLevel });
355
+ key.preventDefault();
356
+ return true;
357
+ }
358
+ settingIdx++;
359
+
360
+ if (ctx.interactionMode === "voice") {
361
+ if (ctx.selectedIdx === settingIdx) {
362
+ const speeds: SpeechSpeed[] = [1.0, 1.25, 1.5, 1.75, 2.0];
363
+ const currentSpeed = ctx.manager.speechSpeed;
364
+ const currentIndex = speeds.indexOf(currentSpeed);
365
+ const nextIndex = (currentIndex + 1) % speeds.length;
366
+ const nextSpeed = speeds[nextIndex] ?? 1.0;
367
+ ctx.manager.speechSpeed = nextSpeed;
368
+ ctx.setSpeechSpeed(nextSpeed);
369
+ ctx.persistPreferences({ speechSpeed: nextSpeed });
370
+ key.preventDefault();
371
+ return true;
372
+ }
373
+ settingIdx++;
374
+ }
375
+
376
+ if (ctx.selectedIdx === settingIdx) {
377
+ const next = !ctx.showFullReasoning;
378
+ ctx.setShowFullReasoning(next);
379
+ ctx.persistPreferences({ showFullReasoning: next });
380
+ key.preventDefault();
381
+ return true;
382
+ }
383
+ settingIdx++;
384
+
385
+ if (ctx.selectedIdx === settingIdx) {
386
+ const next = !ctx.showToolOutput;
387
+ ctx.setShowToolOutput(next);
388
+ ctx.persistPreferences({ showToolOutput: next });
389
+ key.preventDefault();
390
+ return true;
391
+ }
392
+ settingIdx++;
393
+
394
+ key.preventDefault();
395
+ return true;
396
+ }
397
+
398
+ return true;
399
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Generic menu navigation utilities.
3
+ * Reduces duplication across device, model, and session menus.
4
+ */
5
+
6
+ import type { KeyEvent } from "@opentui/core";
7
+
8
+ /**
9
+ * Navigate up in a list, wrapping around to the end.
10
+ */
11
+ export function navigateUp(currentIdx: number, itemCount: number): number {
12
+ if (itemCount === 0) return 0;
13
+ return currentIdx > 0 ? currentIdx - 1 : itemCount - 1;
14
+ }
15
+
16
+ /**
17
+ * Navigate down in a list, wrapping around to the beginning.
18
+ */
19
+ export function navigateDown(currentIdx: number, itemCount: number): number {
20
+ if (itemCount === 0) return 0;
21
+ return currentIdx < itemCount - 1 ? currentIdx + 1 : 0;
22
+ }
23
+
24
+ /**
25
+ * Check if a key is a navigation up key (up arrow or k).
26
+ */
27
+ export function isNavigateUpKey(key: KeyEvent, enableViKeys = true): boolean {
28
+ return key.name === "up" || (enableViKeys && key.sequence === "k");
29
+ }
30
+
31
+ /**
32
+ * Check if a key is a navigation down key (down arrow or j).
33
+ */
34
+ export function isNavigateDownKey(key: KeyEvent, enableViKeys = true): boolean {
35
+ return key.name === "down" || (enableViKeys && key.sequence === "j");
36
+ }
37
+
38
+ /**
39
+ * Check if a key is a confirm key (enter/return).
40
+ */
41
+ export function isConfirmKey(key: KeyEvent): boolean {
42
+ return key.name === "return";
43
+ }
44
+
45
+ /**
46
+ * Check if a key is a cancel key (escape).
47
+ */
48
+ export function isCancelKey(key: KeyEvent): boolean {
49
+ return key.name === "escape";
50
+ }
51
+
52
+ /**
53
+ * Handle generic menu navigation.
54
+ * Returns the new selected index, or null if the key wasn't a navigation key.
55
+ */
56
+ export function handleMenuNavigation(
57
+ key: KeyEvent,
58
+ currentIdx: number,
59
+ itemCount: number,
60
+ enableViKeys = true
61
+ ): { newIndex: number; handled: boolean } {
62
+ if (key.eventType !== "press") {
63
+ return { newIndex: currentIdx, handled: false };
64
+ }
65
+
66
+ if (isNavigateUpKey(key, enableViKeys)) {
67
+ return { newIndex: navigateUp(currentIdx, itemCount), handled: true };
68
+ }
69
+
70
+ if (isNavigateDownKey(key, enableViKeys)) {
71
+ return { newIndex: navigateDown(currentIdx, itemCount), handled: true };
72
+ }
73
+
74
+ return { newIndex: currentIdx, handled: false };
75
+ }
76
+
77
+ /**
78
+ * Result of a menu key handler.
79
+ */
80
+ export interface MenuKeyResult {
81
+ /** Whether the key was handled */
82
+ handled: boolean;
83
+ /** Whether the menu should close */
84
+ shouldClose: boolean;
85
+ /** The new selected index */
86
+ selectedIndex: number;
87
+ /** Whether an item was selected (enter pressed) */
88
+ itemSelected: boolean;
89
+ }
90
+
91
+ /**
92
+ * Handle all menu keyboard input with a standardized pattern.
93
+ */
94
+ export function handleMenuKey(
95
+ key: KeyEvent,
96
+ currentIdx: number,
97
+ itemCount: number,
98
+ enableViKeys = true,
99
+ closeOnSelect = true
100
+ ): MenuKeyResult {
101
+ if (key.eventType !== "press") {
102
+ return {
103
+ handled: false,
104
+ shouldClose: false,
105
+ selectedIndex: currentIdx,
106
+ itemSelected: false,
107
+ };
108
+ }
109
+
110
+ // Cancel
111
+ if (isCancelKey(key)) {
112
+ return {
113
+ handled: true,
114
+ shouldClose: true,
115
+ selectedIndex: currentIdx,
116
+ itemSelected: false,
117
+ };
118
+ }
119
+
120
+ // Navigation
121
+ const navResult = handleMenuNavigation(key, currentIdx, itemCount, enableViKeys);
122
+ if (navResult.handled) {
123
+ return {
124
+ handled: true,
125
+ shouldClose: false,
126
+ selectedIndex: navResult.newIndex,
127
+ itemSelected: false,
128
+ };
129
+ }
130
+
131
+ // Confirm selection
132
+ if (isConfirmKey(key)) {
133
+ return {
134
+ handled: true,
135
+ shouldClose: closeOnSelect,
136
+ selectedIndex: currentIdx,
137
+ itemSelected: true,
138
+ };
139
+ }
140
+
141
+ return {
142
+ handled: false,
143
+ shouldClose: false,
144
+ selectedIndex: currentIdx,
145
+ itemSelected: false,
146
+ };
147
+ }
@@ -0,0 +1,71 @@
1
+ import { useEffect } from "react";
2
+ import type { AudioDevice, OnboardingStep } from "../types";
3
+ import {
4
+ getCurrentDeviceName,
5
+ getSystemDefaultInputDeviceName,
6
+ listAudioDevices,
7
+ } from "../voice/audio-recorder";
8
+
9
+ export interface UseAppAudioDevicesLoaderParams {
10
+ preferencesLoaded: boolean;
11
+ showDeviceMenu: boolean;
12
+ onboardingStep: OnboardingStep;
13
+
14
+ setDevices: React.Dispatch<React.SetStateAction<AudioDevice[]>>;
15
+ setCurrentDevice: React.Dispatch<React.SetStateAction<string | undefined>>;
16
+ setDeviceLoadTimedOut: React.Dispatch<React.SetStateAction<boolean>>;
17
+ }
18
+
19
+ export function useAppAudioDevicesLoader(params: UseAppAudioDevicesLoaderParams): void {
20
+ const {
21
+ preferencesLoaded,
22
+ showDeviceMenu,
23
+ onboardingStep,
24
+ setDevices,
25
+ setCurrentDevice,
26
+ setDeviceLoadTimedOut,
27
+ } = params;
28
+
29
+ useEffect(() => {
30
+ if (!preferencesLoaded) return;
31
+ if (!showDeviceMenu && onboardingStep !== "device") return;
32
+
33
+ let cancelled = false;
34
+ setDeviceLoadTimedOut(false);
35
+
36
+ const timeoutId = setTimeout(() => {
37
+ if (!cancelled) setDeviceLoadTimedOut(true);
38
+ }, 3000);
39
+
40
+ (async () => {
41
+ try {
42
+ const devs = await listAudioDevices();
43
+ if (cancelled) return;
44
+
45
+ clearTimeout(timeoutId);
46
+ setDevices(devs);
47
+ if (devs.length === 0) setDeviceLoadTimedOut(true);
48
+
49
+ const explicitDeviceName = getCurrentDeviceName();
50
+ const systemDefaultDeviceName = explicitDeviceName ?? (await getSystemDefaultInputDeviceName());
51
+ if (cancelled) return;
52
+
53
+ setCurrentDevice(systemDefaultDeviceName);
54
+ } catch (_err: unknown) {
55
+ if (!cancelled) setDeviceLoadTimedOut(true);
56
+ }
57
+ })();
58
+
59
+ return () => {
60
+ cancelled = true;
61
+ clearTimeout(timeoutId);
62
+ };
63
+ }, [
64
+ preferencesLoaded,
65
+ showDeviceMenu,
66
+ onboardingStep,
67
+ setDevices,
68
+ setCurrentDevice,
69
+ setDeviceLoadTimedOut,
70
+ ]);
71
+ }