@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.
- package/LICENSE +21 -0
- package/README.md +126 -0
- package/dist/cli.js +22 -0
- package/package.json +79 -0
- package/src/ai/agent-turn-runner.ts +130 -0
- package/src/ai/daemon-ai.ts +403 -0
- package/src/ai/exa-client.ts +21 -0
- package/src/ai/exa-fetch-cache.ts +104 -0
- package/src/ai/model-config.ts +99 -0
- package/src/ai/sanitize-messages.ts +83 -0
- package/src/ai/system-prompt.ts +363 -0
- package/src/ai/tools/fetch-urls.ts +187 -0
- package/src/ai/tools/grounding-manager.ts +94 -0
- package/src/ai/tools/index.ts +52 -0
- package/src/ai/tools/read-file.ts +100 -0
- package/src/ai/tools/render-url.ts +275 -0
- package/src/ai/tools/run-bash.ts +224 -0
- package/src/ai/tools/subagents.ts +195 -0
- package/src/ai/tools/todo-manager.ts +150 -0
- package/src/ai/tools/web-search.ts +91 -0
- package/src/app/App.tsx +711 -0
- package/src/app/components/AppOverlays.tsx +131 -0
- package/src/app/components/AvatarLayer.tsx +51 -0
- package/src/app/components/ConversationPane.tsx +476 -0
- package/src/avatar/DaemonAvatarRenderable.ts +343 -0
- package/src/avatar/daemon-avatar-rig.ts +1165 -0
- package/src/avatar-preview.ts +186 -0
- package/src/cli.ts +26 -0
- package/src/components/ApiKeyInput.tsx +99 -0
- package/src/components/ApiKeyStep.tsx +95 -0
- package/src/components/ApprovalPicker.tsx +109 -0
- package/src/components/ContentBlockView.tsx +141 -0
- package/src/components/DaemonText.tsx +34 -0
- package/src/components/DeviceMenu.tsx +166 -0
- package/src/components/GroundingBadge.tsx +21 -0
- package/src/components/GroundingMenu.tsx +310 -0
- package/src/components/HotkeysPane.tsx +115 -0
- package/src/components/InlineStatusIndicator.tsx +106 -0
- package/src/components/ModelMenu.tsx +411 -0
- package/src/components/OnboardingOverlay.tsx +446 -0
- package/src/components/ProviderMenu.tsx +177 -0
- package/src/components/SessionMenu.tsx +297 -0
- package/src/components/SettingsMenu.tsx +291 -0
- package/src/components/StatusBar.tsx +126 -0
- package/src/components/TokenUsageDisplay.tsx +92 -0
- package/src/components/ToolCallView.tsx +113 -0
- package/src/components/TypingInputBar.tsx +131 -0
- package/src/components/tool-layouts/components.tsx +120 -0
- package/src/components/tool-layouts/defaults.ts +9 -0
- package/src/components/tool-layouts/index.ts +22 -0
- package/src/components/tool-layouts/layouts/bash.ts +110 -0
- package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
- package/src/components/tool-layouts/layouts/index.ts +8 -0
- package/src/components/tool-layouts/layouts/read-file.ts +59 -0
- package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
- package/src/components/tool-layouts/layouts/system-info.ts +8 -0
- package/src/components/tool-layouts/layouts/todo.tsx +139 -0
- package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
- package/src/components/tool-layouts/layouts/web-search.ts +110 -0
- package/src/components/tool-layouts/registry.ts +17 -0
- package/src/components/tool-layouts/types.ts +94 -0
- package/src/hooks/daemon-event-handlers.ts +944 -0
- package/src/hooks/keyboard-handlers.ts +399 -0
- package/src/hooks/menu-navigation.ts +147 -0
- package/src/hooks/use-app-audio-devices-loader.ts +71 -0
- package/src/hooks/use-app-callbacks.ts +202 -0
- package/src/hooks/use-app-context-builder.ts +159 -0
- package/src/hooks/use-app-display-state.ts +162 -0
- package/src/hooks/use-app-menus.ts +51 -0
- package/src/hooks/use-app-model-pricing-loader.ts +45 -0
- package/src/hooks/use-app-model.ts +123 -0
- package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
- package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
- package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
- package/src/hooks/use-app-sessions.ts +105 -0
- package/src/hooks/use-app-settings.ts +62 -0
- package/src/hooks/use-conversation-manager.ts +163 -0
- package/src/hooks/use-copy-on-select.ts +50 -0
- package/src/hooks/use-daemon-events.ts +396 -0
- package/src/hooks/use-daemon-keyboard.ts +397 -0
- package/src/hooks/use-grounding.ts +46 -0
- package/src/hooks/use-input-history.ts +92 -0
- package/src/hooks/use-menu-keyboard.ts +93 -0
- package/src/hooks/use-playwright-notification.ts +23 -0
- package/src/hooks/use-reasoning-animation.ts +97 -0
- package/src/hooks/use-response-timer.ts +55 -0
- package/src/hooks/use-tool-approval.tsx +202 -0
- package/src/hooks/use-typing-mode.ts +137 -0
- package/src/hooks/use-voice-dependencies-notification.ts +37 -0
- package/src/index.tsx +48 -0
- package/src/scripts/setup-browsers.ts +42 -0
- package/src/state/app-context.tsx +160 -0
- package/src/state/daemon-events.ts +67 -0
- package/src/state/daemon-state.ts +493 -0
- package/src/state/migrations/001-init.ts +33 -0
- package/src/state/migrations/index.ts +8 -0
- package/src/state/model-history-store.ts +45 -0
- package/src/state/runtime-context.ts +21 -0
- package/src/state/session-store.ts +359 -0
- package/src/types/index.ts +405 -0
- package/src/types/theme.ts +52 -0
- package/src/ui/constants.ts +157 -0
- package/src/utils/clipboard.ts +89 -0
- package/src/utils/debug-logger.ts +69 -0
- package/src/utils/formatters.ts +242 -0
- package/src/utils/js-rendering.ts +77 -0
- package/src/utils/markdown-tables.ts +234 -0
- package/src/utils/model-metadata.ts +191 -0
- package/src/utils/openrouter-endpoints.ts +212 -0
- package/src/utils/openrouter-models.ts +205 -0
- package/src/utils/openrouter-pricing.ts +59 -0
- package/src/utils/openrouter-reported-cost.ts +16 -0
- package/src/utils/paste.ts +33 -0
- package/src/utils/preferences.ts +289 -0
- package/src/utils/text-fragment.ts +39 -0
- package/src/utils/tool-output-preview.ts +250 -0
- package/src/utils/voice-dependencies.ts +107 -0
- package/src/utils/workspace-manager.ts +85 -0
- package/src/voice/audio-recorder.ts +579 -0
- package/src/voice/mic-level.ts +35 -0
- package/src/voice/tts/openai-tts-stream.ts +222 -0
- package/src/voice/tts/speech-controller.ts +64 -0
- package/src/voice/tts/tts-player.ts +257 -0
- package/src/voice/voice-input-controller.ts +96 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import type { KeyEvent, TextareaRenderable } from "@opentui/core";
|
|
2
|
+
import { useKeyboard } from "@opentui/react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import type { RefObject } from "react";
|
|
5
|
+
import type { AppPreferences, AudioDevice, ModelOption, OnboardingStep } from "../types";
|
|
6
|
+
import { ApiKeyStep } from "./ApiKeyStep";
|
|
7
|
+
import { handleOnboardingKey } from "../hooks/keyboard-handlers";
|
|
8
|
+
import { COLORS } from "../ui/constants";
|
|
9
|
+
import { formatContextWindowK, formatPrice } from "../utils/formatters";
|
|
10
|
+
|
|
11
|
+
const MODEL_COL_WIDTH = {
|
|
12
|
+
CTX: 6,
|
|
13
|
+
IN: 10,
|
|
14
|
+
OUT: 10,
|
|
15
|
+
CACHE: 6,
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/** Configuration for each API key step */
|
|
19
|
+
const API_KEY_CONFIGS = {
|
|
20
|
+
openrouter_key: {
|
|
21
|
+
title: "OPENROUTER API KEY REQUIRED",
|
|
22
|
+
description: "OpenRouter provides access to the AI models needed for DAEMON responses.",
|
|
23
|
+
errorMessage: "OPENROUTER_API_KEY environment variable not found.",
|
|
24
|
+
envVarName: "OPENROUTER_API_KEY",
|
|
25
|
+
keyUrl: "https://openrouter.ai/keys",
|
|
26
|
+
optional: false,
|
|
27
|
+
},
|
|
28
|
+
openai_key: {
|
|
29
|
+
title: "OPENAI API KEY (OPTIONAL)",
|
|
30
|
+
description: "OpenAI enables voice features (speech-to-text and text-to-speech).",
|
|
31
|
+
envVarName: "OPENAI_API_KEY",
|
|
32
|
+
keyUrl: "https://platform.openai.com/api-keys",
|
|
33
|
+
optional: true,
|
|
34
|
+
skipConsequence: "voice input and voice output capabilities are disabled",
|
|
35
|
+
},
|
|
36
|
+
exa_key: {
|
|
37
|
+
title: "EXA API KEY (OPTIONAL)",
|
|
38
|
+
description:
|
|
39
|
+
"Exa provides web search capabilities for DAEMON. It is highly recommended to configure Exa for the best experience. Exa comes with generous free credits on sign-up.",
|
|
40
|
+
envVarName: "EXA_API_KEY",
|
|
41
|
+
keyUrl: "https://dashboard.exa.ai/api-keys",
|
|
42
|
+
optional: true,
|
|
43
|
+
skipConsequence: "Web search will be disabled",
|
|
44
|
+
},
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
interface OnboardingOverlayProps {
|
|
48
|
+
step: OnboardingStep;
|
|
49
|
+
preferences: AppPreferences | null;
|
|
50
|
+
devices: AudioDevice[];
|
|
51
|
+
currentDevice?: string;
|
|
52
|
+
currentOutputDevice?: string;
|
|
53
|
+
models: ModelOption[];
|
|
54
|
+
currentModelId: string;
|
|
55
|
+
deviceLoadTimedOut?: boolean;
|
|
56
|
+
setCurrentDevice: (deviceName: string | undefined) => void;
|
|
57
|
+
setCurrentOutputDevice: (deviceName: string | undefined) => void;
|
|
58
|
+
setCurrentModelId: (modelId: string) => void;
|
|
59
|
+
setOnboardingStep: (step: OnboardingStep) => void;
|
|
60
|
+
completeOnboarding: () => void;
|
|
61
|
+
persistPreferences: (updates: Partial<AppPreferences>) => void;
|
|
62
|
+
/** Callback when key is submitted */
|
|
63
|
+
onKeySubmit: () => void;
|
|
64
|
+
/** Ref to the textarea for focus management */
|
|
65
|
+
apiKeyTextareaRef?: RefObject<TextareaRenderable | null>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function OnboardingOverlay({
|
|
69
|
+
step,
|
|
70
|
+
preferences,
|
|
71
|
+
devices,
|
|
72
|
+
currentDevice,
|
|
73
|
+
currentOutputDevice,
|
|
74
|
+
models,
|
|
75
|
+
currentModelId,
|
|
76
|
+
deviceLoadTimedOut,
|
|
77
|
+
setCurrentDevice,
|
|
78
|
+
setCurrentOutputDevice,
|
|
79
|
+
setCurrentModelId,
|
|
80
|
+
setOnboardingStep,
|
|
81
|
+
completeOnboarding,
|
|
82
|
+
persistPreferences,
|
|
83
|
+
onKeySubmit,
|
|
84
|
+
apiKeyTextareaRef,
|
|
85
|
+
}: OnboardingOverlayProps) {
|
|
86
|
+
const [selectedDeviceIdx, setSelectedDeviceIdx] = useState(0);
|
|
87
|
+
const [selectedModelIdx, setSelectedModelIdx] = useState(0);
|
|
88
|
+
|
|
89
|
+
const initialDeviceIdx = useMemo(() => {
|
|
90
|
+
if (devices.length === 0) return 0;
|
|
91
|
+
const idx = currentDevice ? devices.findIndex((device) => device.name === currentDevice) : -1;
|
|
92
|
+
if (idx >= 0) return idx;
|
|
93
|
+
return devices.length > 1 ? 1 : 0;
|
|
94
|
+
}, [devices, currentDevice]);
|
|
95
|
+
|
|
96
|
+
const initialModelIdx = useMemo(() => {
|
|
97
|
+
if (models.length === 0) return 0;
|
|
98
|
+
const idx = models.findIndex((model) => model.id === currentModelId);
|
|
99
|
+
return idx >= 0 ? idx : 0;
|
|
100
|
+
}, [models, currentModelId]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (step !== "device") return;
|
|
104
|
+
setSelectedDeviceIdx(initialDeviceIdx);
|
|
105
|
+
}, [step, initialDeviceIdx]);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (step !== "model") return;
|
|
109
|
+
setSelectedModelIdx(initialModelIdx);
|
|
110
|
+
}, [step, initialModelIdx]);
|
|
111
|
+
|
|
112
|
+
const handleKeyPress = useCallback(
|
|
113
|
+
(key: KeyEvent) => {
|
|
114
|
+
const handled = handleOnboardingKey(key, {
|
|
115
|
+
step,
|
|
116
|
+
devices,
|
|
117
|
+
models,
|
|
118
|
+
selectedDeviceIdx,
|
|
119
|
+
selectedModelIdx,
|
|
120
|
+
preferences,
|
|
121
|
+
setSelectedDeviceIdx,
|
|
122
|
+
setSelectedModelIdx,
|
|
123
|
+
setCurrentDevice,
|
|
124
|
+
setCurrentOutputDevice,
|
|
125
|
+
setCurrentModelId,
|
|
126
|
+
setOnboardingStep,
|
|
127
|
+
completeOnboarding,
|
|
128
|
+
persistPreferences,
|
|
129
|
+
currentModelId,
|
|
130
|
+
manager: { outputDeviceName: currentOutputDevice },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (handled) {
|
|
134
|
+
key.preventDefault();
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
[
|
|
138
|
+
step,
|
|
139
|
+
devices,
|
|
140
|
+
models,
|
|
141
|
+
selectedDeviceIdx,
|
|
142
|
+
selectedModelIdx,
|
|
143
|
+
preferences,
|
|
144
|
+
setCurrentDevice,
|
|
145
|
+
setCurrentOutputDevice,
|
|
146
|
+
setCurrentModelId,
|
|
147
|
+
setOnboardingStep,
|
|
148
|
+
completeOnboarding,
|
|
149
|
+
persistPreferences,
|
|
150
|
+
currentModelId,
|
|
151
|
+
currentOutputDevice,
|
|
152
|
+
]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
useKeyboard(handleKeyPress);
|
|
156
|
+
return (
|
|
157
|
+
<box
|
|
158
|
+
position="absolute"
|
|
159
|
+
left={0}
|
|
160
|
+
top={0}
|
|
161
|
+
width="100%"
|
|
162
|
+
height="100%"
|
|
163
|
+
flexDirection="column"
|
|
164
|
+
alignItems="center"
|
|
165
|
+
justifyContent="center"
|
|
166
|
+
zIndex={200}
|
|
167
|
+
>
|
|
168
|
+
<box
|
|
169
|
+
flexDirection="column"
|
|
170
|
+
backgroundColor={COLORS.MENU_BG}
|
|
171
|
+
borderStyle="single"
|
|
172
|
+
borderColor={COLORS.MENU_BORDER}
|
|
173
|
+
paddingLeft={3}
|
|
174
|
+
paddingRight={3}
|
|
175
|
+
paddingTop={2}
|
|
176
|
+
paddingBottom={2}
|
|
177
|
+
width="75%"
|
|
178
|
+
minWidth={72}
|
|
179
|
+
maxWidth={160}
|
|
180
|
+
>
|
|
181
|
+
{step === "intro" && (
|
|
182
|
+
<>
|
|
183
|
+
<box marginBottom={1}>
|
|
184
|
+
<text>
|
|
185
|
+
<span fg={COLORS.DAEMON_LABEL}>[ FIRST LAUNCH DETECTED ]</span>
|
|
186
|
+
</text>
|
|
187
|
+
</box>
|
|
188
|
+
<box marginBottom={1}>
|
|
189
|
+
<text>
|
|
190
|
+
<span fg={COLORS.MENU_TEXT}>
|
|
191
|
+
DAEMON needs a few settings to ensure an optimal experience.
|
|
192
|
+
</span>
|
|
193
|
+
</text>
|
|
194
|
+
</box>
|
|
195
|
+
<box marginBottom={1}>
|
|
196
|
+
<text>
|
|
197
|
+
<span fg={COLORS.REASONING_DIM}>All settings can be changed later.</span>
|
|
198
|
+
</text>
|
|
199
|
+
</box>
|
|
200
|
+
<box justifyContent="center">
|
|
201
|
+
<text>
|
|
202
|
+
<span fg={COLORS.DAEMON_LABEL}>Press ENTER to begin</span>
|
|
203
|
+
<span fg={COLORS.REASONING_DIM}> · ESC to skip</span>
|
|
204
|
+
</text>
|
|
205
|
+
</box>
|
|
206
|
+
</>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{step === "openrouter_key" && (
|
|
210
|
+
<ApiKeyStep
|
|
211
|
+
{...API_KEY_CONFIGS.openrouter_key}
|
|
212
|
+
onSubmit={onKeySubmit}
|
|
213
|
+
textareaRef={apiKeyTextareaRef}
|
|
214
|
+
/>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
{step === "openai_key" && (
|
|
218
|
+
<ApiKeyStep
|
|
219
|
+
{...API_KEY_CONFIGS.openai_key}
|
|
220
|
+
onSubmit={onKeySubmit}
|
|
221
|
+
textareaRef={apiKeyTextareaRef}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
{step === "exa_key" && (
|
|
226
|
+
<ApiKeyStep {...API_KEY_CONFIGS.exa_key} onSubmit={onKeySubmit} textareaRef={apiKeyTextareaRef} />
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{step === "device" && (
|
|
230
|
+
<>
|
|
231
|
+
<box marginBottom={1}>
|
|
232
|
+
<text>
|
|
233
|
+
<span fg={COLORS.DAEMON_LABEL}>[ SELECT AUDIO DEVICES ]</span>
|
|
234
|
+
</text>
|
|
235
|
+
</box>
|
|
236
|
+
<box marginBottom={1}>
|
|
237
|
+
<text>
|
|
238
|
+
<span fg={COLORS.REASONING_DIM}>
|
|
239
|
+
Configure input device for voice commands and output device for TTS playback.
|
|
240
|
+
</span>
|
|
241
|
+
</text>
|
|
242
|
+
</box>
|
|
243
|
+
<box marginBottom={1}>
|
|
244
|
+
<text>
|
|
245
|
+
<span fg={COLORS.USER_LABEL}>↑/↓ navigate, ENTER select</span>
|
|
246
|
+
<span fg={COLORS.REASONING_DIM}> · TAB continue · ESC skip</span>
|
|
247
|
+
</text>
|
|
248
|
+
</box>
|
|
249
|
+
{devices.length === 0 ? (
|
|
250
|
+
<box flexDirection="column">
|
|
251
|
+
<box>
|
|
252
|
+
<text>
|
|
253
|
+
<span fg={COLORS.USER_LABEL}>
|
|
254
|
+
{deviceLoadTimedOut ? "No devices found. Press ESC to skip." : "Loading devices..."}
|
|
255
|
+
</span>
|
|
256
|
+
</text>
|
|
257
|
+
</box>
|
|
258
|
+
</box>
|
|
259
|
+
) : (
|
|
260
|
+
<>
|
|
261
|
+
<box marginBottom={1} marginTop={1}>
|
|
262
|
+
<text>
|
|
263
|
+
<span fg={COLORS.DAEMON_LABEL}>[ INPUT ]</span>
|
|
264
|
+
</text>
|
|
265
|
+
</box>
|
|
266
|
+
<box flexDirection="column">
|
|
267
|
+
{devices.map((device, idx) => {
|
|
268
|
+
const isInputSection = selectedDeviceIdx < devices.length;
|
|
269
|
+
const inputSelectedIdx = isInputSection ? selectedDeviceIdx : -1;
|
|
270
|
+
return (
|
|
271
|
+
<box
|
|
272
|
+
key={`input-${device.name}`}
|
|
273
|
+
backgroundColor={idx === inputSelectedIdx ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
|
|
274
|
+
paddingLeft={1}
|
|
275
|
+
paddingRight={1}
|
|
276
|
+
>
|
|
277
|
+
<text>
|
|
278
|
+
<span fg={idx === inputSelectedIdx ? COLORS.DAEMON_LABEL : COLORS.MENU_TEXT}>
|
|
279
|
+
{idx === inputSelectedIdx ? "▶ " : " "}
|
|
280
|
+
{device.name}
|
|
281
|
+
{device.name === currentDevice ? " ●" : ""}
|
|
282
|
+
</span>
|
|
283
|
+
</text>
|
|
284
|
+
</box>
|
|
285
|
+
);
|
|
286
|
+
})}
|
|
287
|
+
</box>
|
|
288
|
+
<box marginBottom={1} marginTop={1}>
|
|
289
|
+
<text>
|
|
290
|
+
<span fg={COLORS.DAEMON_LABEL}>[ OUTPUT ]</span>
|
|
291
|
+
</text>
|
|
292
|
+
</box>
|
|
293
|
+
<box flexDirection="column">
|
|
294
|
+
{devices.map((device, idx) => {
|
|
295
|
+
const isOutputSection = selectedDeviceIdx >= devices.length;
|
|
296
|
+
const outputSelectedIdx = isOutputSection ? selectedDeviceIdx - devices.length : -1;
|
|
297
|
+
return (
|
|
298
|
+
<box
|
|
299
|
+
key={`output-${device.name}`}
|
|
300
|
+
backgroundColor={idx === outputSelectedIdx ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
|
|
301
|
+
paddingLeft={1}
|
|
302
|
+
paddingRight={1}
|
|
303
|
+
>
|
|
304
|
+
<text>
|
|
305
|
+
<span fg={idx === outputSelectedIdx ? COLORS.DAEMON_LABEL : COLORS.MENU_TEXT}>
|
|
306
|
+
{idx === outputSelectedIdx ? "▶ " : " "}
|
|
307
|
+
{device.name}
|
|
308
|
+
{device.name === currentOutputDevice ? " ●" : ""}
|
|
309
|
+
</span>
|
|
310
|
+
</text>
|
|
311
|
+
</box>
|
|
312
|
+
);
|
|
313
|
+
})}
|
|
314
|
+
</box>
|
|
315
|
+
</>
|
|
316
|
+
)}
|
|
317
|
+
</>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
{step === "model" && (
|
|
321
|
+
<>
|
|
322
|
+
<box marginBottom={1}>
|
|
323
|
+
<text>
|
|
324
|
+
<span fg={COLORS.DAEMON_LABEL}>[ SELECT RESPONSE MODEL ]</span>
|
|
325
|
+
</text>
|
|
326
|
+
</box>
|
|
327
|
+
<box marginBottom={1}>
|
|
328
|
+
<text>
|
|
329
|
+
<span fg={COLORS.REASONING_DIM}>The model controls response quality, speed and cost.</span>
|
|
330
|
+
</text>
|
|
331
|
+
</box>
|
|
332
|
+
<box marginBottom={1}>
|
|
333
|
+
<text>
|
|
334
|
+
<span fg={COLORS.REASONING_DIM}>
|
|
335
|
+
Press Enter to use the default model or select another one from the list.
|
|
336
|
+
</span>
|
|
337
|
+
</text>
|
|
338
|
+
</box>
|
|
339
|
+
<box marginBottom={1}>
|
|
340
|
+
<text>
|
|
341
|
+
<span fg={COLORS.USER_LABEL}>↑/↓ navigate, ENTER confirm</span>
|
|
342
|
+
<span fg={COLORS.REASONING_DIM}> · ESC skip</span>
|
|
343
|
+
</text>
|
|
344
|
+
</box>
|
|
345
|
+
{models.length === 0 ? (
|
|
346
|
+
<box>
|
|
347
|
+
<text>
|
|
348
|
+
<span fg={COLORS.USER_LABEL}>No models available</span>
|
|
349
|
+
</text>
|
|
350
|
+
</box>
|
|
351
|
+
) : (
|
|
352
|
+
<>
|
|
353
|
+
<box marginBottom={1}>
|
|
354
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
355
|
+
<text>
|
|
356
|
+
<span fg={COLORS.REASONING_DIM}>MODEL</span>
|
|
357
|
+
</text>
|
|
358
|
+
<text>
|
|
359
|
+
<span fg={COLORS.REASONING_DIM}>
|
|
360
|
+
{"CTX".padStart(MODEL_COL_WIDTH.CTX)} {"IN".padStart(MODEL_COL_WIDTH.IN)}{" "}
|
|
361
|
+
{"OUT".padStart(MODEL_COL_WIDTH.OUT)} {"CACHE".padStart(MODEL_COL_WIDTH.CACHE)}
|
|
362
|
+
</span>
|
|
363
|
+
</text>
|
|
364
|
+
</box>
|
|
365
|
+
</box>
|
|
366
|
+
<box flexDirection="column">
|
|
367
|
+
{models.map((model, idx) => {
|
|
368
|
+
const isSelected = idx === selectedModelIdx;
|
|
369
|
+
const isCurrent = model.id === currentModelId;
|
|
370
|
+
const pricing = model.pricing;
|
|
371
|
+
const ctxText =
|
|
372
|
+
typeof model.contextLength === "number" && model.contextLength > 0
|
|
373
|
+
? formatContextWindowK(model.contextLength)
|
|
374
|
+
: "--";
|
|
375
|
+
|
|
376
|
+
const inText = pricing ? formatPrice(pricing.prompt) : "--";
|
|
377
|
+
const outText = pricing ? formatPrice(pricing.completion) : "--";
|
|
378
|
+
|
|
379
|
+
const supportsCaching = Boolean(model.supportsCaching);
|
|
380
|
+
const cacheText = supportsCaching ? "✓" : "x";
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<box
|
|
384
|
+
key={model.id}
|
|
385
|
+
backgroundColor={isSelected ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
|
|
386
|
+
paddingLeft={1}
|
|
387
|
+
paddingRight={1}
|
|
388
|
+
flexDirection="row"
|
|
389
|
+
justifyContent="space-between"
|
|
390
|
+
>
|
|
391
|
+
<text>
|
|
392
|
+
<span fg={isSelected ? COLORS.DAEMON_LABEL : COLORS.MENU_TEXT}>
|
|
393
|
+
{isSelected ? "▶ " : " "}
|
|
394
|
+
{model.name}
|
|
395
|
+
{isCurrent ? " ●" : ""}
|
|
396
|
+
</span>
|
|
397
|
+
</text>
|
|
398
|
+
<text>
|
|
399
|
+
<span fg={COLORS.MENU_TEXT}>{ctxText.padStart(MODEL_COL_WIDTH.CTX)} </span>
|
|
400
|
+
<span fg={COLORS.TYPING_PROMPT}>
|
|
401
|
+
{inText.padStart(MODEL_COL_WIDTH.IN)} {outText.padStart(MODEL_COL_WIDTH.OUT)}{" "}
|
|
402
|
+
</span>
|
|
403
|
+
<span fg={supportsCaching ? COLORS.DAEMON_TEXT : COLORS.REASONING_DIM}>
|
|
404
|
+
{cacheText.padStart(MODEL_COL_WIDTH.CACHE)}
|
|
405
|
+
</span>
|
|
406
|
+
</text>
|
|
407
|
+
</box>
|
|
408
|
+
);
|
|
409
|
+
})}
|
|
410
|
+
</box>
|
|
411
|
+
</>
|
|
412
|
+
)}
|
|
413
|
+
</>
|
|
414
|
+
)}
|
|
415
|
+
|
|
416
|
+
{step === "settings" && (
|
|
417
|
+
<>
|
|
418
|
+
<box marginBottom={1}>
|
|
419
|
+
<text>
|
|
420
|
+
<span fg={COLORS.DAEMON_LABEL}>[ ONBOARDING COMPLETE ]</span>
|
|
421
|
+
</text>
|
|
422
|
+
</box>
|
|
423
|
+
<box marginBottom={1}>
|
|
424
|
+
<text>
|
|
425
|
+
<span fg={COLORS.MENU_TEXT}>You can fine-tune behavior anytime in the Settings menu.</span>
|
|
426
|
+
</text>
|
|
427
|
+
</box>
|
|
428
|
+
<box marginBottom={1}>
|
|
429
|
+
<text>
|
|
430
|
+
<span fg={COLORS.REASONING_DIM}>
|
|
431
|
+
Press S to open Settings and ? to view hotkeys after closing this pane.
|
|
432
|
+
</span>
|
|
433
|
+
</text>
|
|
434
|
+
</box>
|
|
435
|
+
<box justifyContent="center">
|
|
436
|
+
<text>
|
|
437
|
+
<span fg={COLORS.DAEMON_LABEL}>Press ENTER to finish</span>
|
|
438
|
+
<span fg={COLORS.REASONING_DIM}> · ESC also works</span>
|
|
439
|
+
</text>
|
|
440
|
+
</box>
|
|
441
|
+
</>
|
|
442
|
+
)}
|
|
443
|
+
</box>
|
|
444
|
+
</box>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { useMenuKeyboard } from "../hooks/use-menu-keyboard";
|
|
3
|
+
import { COLORS } from "../ui/constants";
|
|
4
|
+
import { formatContextWindowK, formatPrice } from "../utils/formatters";
|
|
5
|
+
import type { ModelPricing } from "../types";
|
|
6
|
+
|
|
7
|
+
const COL_WIDTH = {
|
|
8
|
+
CTX: 6,
|
|
9
|
+
IN: 10,
|
|
10
|
+
OUT: 10,
|
|
11
|
+
CACHE: 6,
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export interface ProviderMenuItem {
|
|
15
|
+
/** Provider slug (OpenRouter routing tag). Use null for "Auto". */
|
|
16
|
+
tag: string | null;
|
|
17
|
+
label: string;
|
|
18
|
+
contextLength?: number;
|
|
19
|
+
pricing?: ModelPricing;
|
|
20
|
+
supportsCaching?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ProviderMenuProps {
|
|
24
|
+
items: ProviderMenuItem[];
|
|
25
|
+
currentProviderTag: string | undefined;
|
|
26
|
+
modelId: string;
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
onSelect: (tag: string | undefined) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ProviderMenu({ items, currentProviderTag, modelId, onClose, onSelect }: ProviderMenuProps) {
|
|
32
|
+
const sortedItems = useMemo(() => {
|
|
33
|
+
return [...items].sort((a, b) => {
|
|
34
|
+
if (a.tag === null) return -1;
|
|
35
|
+
if (b.tag === null) return 1;
|
|
36
|
+
|
|
37
|
+
const priceA = a.pricing ? a.pricing.prompt + a.pricing.completion : Number.MAX_SAFE_INTEGER;
|
|
38
|
+
const priceB = b.pricing ? b.pricing.prompt + b.pricing.completion : Number.MAX_SAFE_INTEGER;
|
|
39
|
+
if (priceA !== priceB) return priceA - priceB;
|
|
40
|
+
return a.label.localeCompare(b.label);
|
|
41
|
+
});
|
|
42
|
+
}, [items]);
|
|
43
|
+
|
|
44
|
+
const initialIndex = useMemo(() => {
|
|
45
|
+
if (sortedItems.length === 0) return 0;
|
|
46
|
+
const desiredTag = currentProviderTag ?? null;
|
|
47
|
+
const idx = sortedItems.findIndex((item) => item.tag === desiredTag);
|
|
48
|
+
return idx >= 0 ? idx : 0;
|
|
49
|
+
}, [sortedItems, currentProviderTag]);
|
|
50
|
+
|
|
51
|
+
const { selectedIndex } = useMenuKeyboard({
|
|
52
|
+
itemCount: sortedItems.length,
|
|
53
|
+
initialIndex,
|
|
54
|
+
onClose,
|
|
55
|
+
onSelect: (selectedIdx) => {
|
|
56
|
+
const selected = sortedItems[selectedIdx];
|
|
57
|
+
if (!selected) return;
|
|
58
|
+
if (selected.tag === null) {
|
|
59
|
+
onSelect(undefined);
|
|
60
|
+
} else {
|
|
61
|
+
onSelect(selected.tag);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<box
|
|
68
|
+
position="absolute"
|
|
69
|
+
left={0}
|
|
70
|
+
top={0}
|
|
71
|
+
width="100%"
|
|
72
|
+
height="100%"
|
|
73
|
+
flexDirection="column"
|
|
74
|
+
alignItems="center"
|
|
75
|
+
justifyContent="center"
|
|
76
|
+
zIndex={100}
|
|
77
|
+
>
|
|
78
|
+
<box
|
|
79
|
+
flexDirection="column"
|
|
80
|
+
backgroundColor={COLORS.MENU_BG}
|
|
81
|
+
borderStyle="single"
|
|
82
|
+
borderColor={COLORS.MENU_BORDER}
|
|
83
|
+
paddingLeft={2}
|
|
84
|
+
paddingRight={2}
|
|
85
|
+
paddingTop={1}
|
|
86
|
+
paddingBottom={1}
|
|
87
|
+
width="75%"
|
|
88
|
+
minWidth={68}
|
|
89
|
+
maxWidth={160}
|
|
90
|
+
>
|
|
91
|
+
<box marginBottom={1}>
|
|
92
|
+
<text>
|
|
93
|
+
<span fg={COLORS.DAEMON_LABEL}>[ INFERENCE PROVIDER ]</span>
|
|
94
|
+
</text>
|
|
95
|
+
</box>
|
|
96
|
+
<box marginBottom={1}>
|
|
97
|
+
<text>
|
|
98
|
+
<span fg={COLORS.REASONING_DIM}>Model: {modelId}</span>
|
|
99
|
+
</text>
|
|
100
|
+
</box>
|
|
101
|
+
<box marginBottom={1}>
|
|
102
|
+
<text>
|
|
103
|
+
<span fg={COLORS.USER_LABEL}>↑/↓ or j/k to navigate, ENTER to select, ESC to cancel</span>
|
|
104
|
+
</text>
|
|
105
|
+
</box>
|
|
106
|
+
|
|
107
|
+
{sortedItems.length === 0 ? (
|
|
108
|
+
<box>
|
|
109
|
+
<text>
|
|
110
|
+
<span fg={COLORS.USER_LABEL}>No providers available</span>
|
|
111
|
+
</text>
|
|
112
|
+
</box>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
<box marginBottom={1}>
|
|
116
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
117
|
+
<text>
|
|
118
|
+
<span fg={COLORS.REASONING_DIM}>PROVIDER</span>
|
|
119
|
+
</text>
|
|
120
|
+
<text>
|
|
121
|
+
<span fg={COLORS.REASONING_DIM}>
|
|
122
|
+
{"CTX".padStart(COL_WIDTH.CTX)} {"IN".padStart(COL_WIDTH.IN)}{" "}
|
|
123
|
+
{"OUT".padStart(COL_WIDTH.OUT)} {"CACHE".padStart(COL_WIDTH.CACHE)}
|
|
124
|
+
</span>
|
|
125
|
+
</text>
|
|
126
|
+
</box>
|
|
127
|
+
</box>
|
|
128
|
+
<box flexDirection="column">
|
|
129
|
+
{sortedItems.map((item, idx) => {
|
|
130
|
+
const isSelected = idx === selectedIndex;
|
|
131
|
+
const isCurrent = item.tag === null ? !currentProviderTag : item.tag === currentProviderTag;
|
|
132
|
+
const hasPrice = Boolean(item.pricing);
|
|
133
|
+
const supportsCaching = Boolean(item.supportsCaching);
|
|
134
|
+
|
|
135
|
+
const ctxText =
|
|
136
|
+
typeof item.contextLength === "number" && item.contextLength > 0
|
|
137
|
+
? formatContextWindowK(item.contextLength)
|
|
138
|
+
: "--";
|
|
139
|
+
const inText = hasPrice ? formatPrice(item.pricing!.prompt) : "--";
|
|
140
|
+
const outText = hasPrice ? formatPrice(item.pricing!.completion) : "--";
|
|
141
|
+
const cacheText = supportsCaching ? "✓" : "x";
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<box
|
|
145
|
+
key={item.tag ?? "auto"}
|
|
146
|
+
backgroundColor={isSelected ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
|
|
147
|
+
paddingLeft={1}
|
|
148
|
+
paddingRight={1}
|
|
149
|
+
flexDirection="row"
|
|
150
|
+
justifyContent="space-between"
|
|
151
|
+
>
|
|
152
|
+
<text>
|
|
153
|
+
<span fg={isSelected ? COLORS.DAEMON_LABEL : COLORS.MENU_TEXT}>
|
|
154
|
+
{isSelected ? "▶ " : " "}
|
|
155
|
+
{item.label}
|
|
156
|
+
{isCurrent ? " ●" : ""}
|
|
157
|
+
</span>
|
|
158
|
+
</text>
|
|
159
|
+
<text>
|
|
160
|
+
<span fg={COLORS.MENU_TEXT}>{ctxText.padStart(COL_WIDTH.CTX)} </span>
|
|
161
|
+
<span fg={COLORS.TYPING_PROMPT}>
|
|
162
|
+
{inText.padStart(COL_WIDTH.IN)} {outText.padStart(COL_WIDTH.OUT)}{" "}
|
|
163
|
+
</span>
|
|
164
|
+
<span fg={supportsCaching ? COLORS.DAEMON_TEXT : COLORS.REASONING_DIM}>
|
|
165
|
+
{cacheText.padStart(COL_WIDTH.CACHE)}
|
|
166
|
+
</span>
|
|
167
|
+
</text>
|
|
168
|
+
</box>
|
|
169
|
+
);
|
|
170
|
+
})}
|
|
171
|
+
</box>
|
|
172
|
+
</>
|
|
173
|
+
)}
|
|
174
|
+
</box>
|
|
175
|
+
</box>
|
|
176
|
+
);
|
|
177
|
+
}
|