@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,186 @@
|
|
|
1
|
+
import { OptimizedBuffer, createCliRenderer, RGBA } from "@opentui/core";
|
|
2
|
+
import { SuperSampleType, ThreeCliRenderer } from "@opentui/core/3d";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdir } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createDaemonRig } from "./avatar/daemon-avatar-rig";
|
|
8
|
+
|
|
9
|
+
/** Resolve ffmpeg path - tries ffmpeg-static if available, otherwise falls back to system PATH. */
|
|
10
|
+
function getFfmpegPath(): string {
|
|
11
|
+
try {
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
13
|
+
const ffmpegStatic = require("ffmpeg-static") as string;
|
|
14
|
+
if (ffmpegStatic && existsSync(ffmpegStatic)) {
|
|
15
|
+
return ffmpegStatic;
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
// ffmpeg-static not installed, use system ffmpeg
|
|
19
|
+
}
|
|
20
|
+
return "ffmpeg";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type PreviewOptions = {
|
|
24
|
+
outDir: string;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
frames: number;
|
|
28
|
+
fps: number;
|
|
29
|
+
mp4?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function parseArgs(argv: string[]): PreviewOptions {
|
|
33
|
+
const out: PreviewOptions = {
|
|
34
|
+
outDir: "tmp/avatar-preview",
|
|
35
|
+
width: 320,
|
|
36
|
+
height: 180,
|
|
37
|
+
frames: 180,
|
|
38
|
+
fps: 30,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const arg = argv[i];
|
|
43
|
+
const next = argv[i + 1];
|
|
44
|
+
if (!arg) continue;
|
|
45
|
+
|
|
46
|
+
if (arg === "--out" && next) {
|
|
47
|
+
out.outDir = next;
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (arg === "--width" && next) {
|
|
52
|
+
out.width = Number(next);
|
|
53
|
+
i++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg === "--height" && next) {
|
|
57
|
+
out.height = Number(next);
|
|
58
|
+
i++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (arg === "--frames" && next) {
|
|
62
|
+
out.frames = Number(next);
|
|
63
|
+
i++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (arg === "--fps" && next) {
|
|
67
|
+
out.fps = Number(next);
|
|
68
|
+
i++;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (arg === "--mp4" && next) {
|
|
72
|
+
out.mp4 = next;
|
|
73
|
+
i++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!Number.isFinite(out.width) || out.width <= 0) throw new Error("Invalid --width");
|
|
79
|
+
if (!Number.isFinite(out.height) || out.height <= 0) throw new Error("Invalid --height");
|
|
80
|
+
if (!Number.isFinite(out.frames) || out.frames <= 0) throw new Error("Invalid --frames");
|
|
81
|
+
if (!Number.isFinite(out.fps) || out.fps <= 0) throw new Error("Invalid --fps");
|
|
82
|
+
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function stitchToMp4(opts: { outDir: string; fps: number; mp4Path: string }): Promise<void> {
|
|
87
|
+
const inputPattern = join(opts.outDir, "frame_%04d.png");
|
|
88
|
+
|
|
89
|
+
const args = [
|
|
90
|
+
"-y",
|
|
91
|
+
"-hide_banner",
|
|
92
|
+
"-loglevel",
|
|
93
|
+
"error",
|
|
94
|
+
"-framerate",
|
|
95
|
+
String(opts.fps),
|
|
96
|
+
"-i",
|
|
97
|
+
inputPattern,
|
|
98
|
+
"-c:v",
|
|
99
|
+
"libx264",
|
|
100
|
+
"-pix_fmt",
|
|
101
|
+
"yuv420p",
|
|
102
|
+
"-crf",
|
|
103
|
+
"18",
|
|
104
|
+
"-preset",
|
|
105
|
+
"medium",
|
|
106
|
+
// Avoid odd-size issues with yuv420p encodes.
|
|
107
|
+
"-vf",
|
|
108
|
+
"pad=ceil(iw/2)*2:ceil(ih/2)*2",
|
|
109
|
+
opts.mp4Path,
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
await new Promise<void>((resolve, reject) => {
|
|
113
|
+
const ffmpegPath = getFfmpegPath();
|
|
114
|
+
const child = spawn(ffmpegPath, args, { stdio: "inherit" });
|
|
115
|
+
child.on("error", (err) => reject(err));
|
|
116
|
+
child.on("exit", (code) => {
|
|
117
|
+
if (code === 0) resolve();
|
|
118
|
+
else reject(new Error(`ffmpeg exited with code ${code}`));
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function main() {
|
|
124
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
125
|
+
await mkdir(opts.outDir, { recursive: true });
|
|
126
|
+
|
|
127
|
+
// We still need CliRenderer because ThreeCliRenderer plugs into OpenTUI's WebGPU + lifecycle.
|
|
128
|
+
// But this script doesn't mount a UI; it just renders frames to PNG.
|
|
129
|
+
const cli = await createCliRenderer({
|
|
130
|
+
useConsole: false,
|
|
131
|
+
useMouse: false,
|
|
132
|
+
enableMouseMovement: false,
|
|
133
|
+
useAlternateScreen: false,
|
|
134
|
+
exitOnCtrlC: true,
|
|
135
|
+
targetFps: 60,
|
|
136
|
+
maxFps: 60,
|
|
137
|
+
backgroundColor: "#000000",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const three = new ThreeCliRenderer(cli, {
|
|
141
|
+
width: opts.width,
|
|
142
|
+
height: opts.height,
|
|
143
|
+
alpha: true,
|
|
144
|
+
backgroundColor: RGBA.fromValues(0, 0, 0, 0),
|
|
145
|
+
superSample: SuperSampleType.GPU,
|
|
146
|
+
autoResize: false,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const buffer = OptimizedBuffer.create(opts.width, opts.height, cli.widthMethod, {
|
|
150
|
+
respectAlpha: true,
|
|
151
|
+
id: "avatar-preview",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
let rig: ReturnType<typeof createDaemonRig> | null = null;
|
|
155
|
+
try {
|
|
156
|
+
await three.init();
|
|
157
|
+
rig = createDaemonRig({ aspectRatio: three.aspectRatio });
|
|
158
|
+
three.setActiveCamera(rig.camera);
|
|
159
|
+
|
|
160
|
+
const deltaS = 1 / opts.fps;
|
|
161
|
+
|
|
162
|
+
for (let frame = 0; frame < opts.frames; frame++) {
|
|
163
|
+
rig.update(deltaS);
|
|
164
|
+
await three.drawScene(rig.scene, buffer, deltaS);
|
|
165
|
+
|
|
166
|
+
const filename = `frame_${String(frame).padStart(4, "0")}.png`;
|
|
167
|
+
await three.saveToFile(join(opts.outDir, filename));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (opts.mp4) {
|
|
171
|
+
try {
|
|
172
|
+
await stitchToMp4({ outDir: opts.outDir, fps: opts.fps, mp4Path: opts.mp4 });
|
|
173
|
+
} catch (err: unknown) {
|
|
174
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
175
|
+
console.error(`Failed to stitch MP4. Ensure ffmpeg is installed and on PATH. (${e.message})`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} finally {
|
|
179
|
+
rig?.dispose();
|
|
180
|
+
buffer.destroy();
|
|
181
|
+
three.destroy();
|
|
182
|
+
cli.destroy();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await main();
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
const bunCmd = process.platform === "win32" ? "bun.exe" : "bun";
|
|
9
|
+
const bunCheck = spawnSync(bunCmd, ["--version"], { stdio: "ignore" });
|
|
10
|
+
|
|
11
|
+
if (bunCheck.error || bunCheck.status !== 0) {
|
|
12
|
+
console.error("DAEMON requires Bun. Install it from https://bun.sh and try again.");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const entry = path.join(packageRoot, "src", "index.tsx");
|
|
18
|
+
const result = spawnSync(bunCmd, [entry, ...args], { stdio: "inherit" });
|
|
19
|
+
|
|
20
|
+
if (result.error) {
|
|
21
|
+
const error = result.error instanceof Error ? result.error : new Error(String(result.error));
|
|
22
|
+
console.error(error.message);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
process.exit(result.status ?? 0);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API key input component for onboarding.
|
|
3
|
+
* Simple single-line input using OpenTUI textarea.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { TextareaRenderable, PasteEvent, KeyEvent } from "@opentui/core";
|
|
7
|
+
import { useRef, type RefObject } from "react";
|
|
8
|
+
import { COLORS } from "../ui/constants";
|
|
9
|
+
import { debug } from "../utils/debug-logger";
|
|
10
|
+
import { pasteClipboardIntoTextarea } from "../utils/paste";
|
|
11
|
+
|
|
12
|
+
export interface ApiKeyInputProps {
|
|
13
|
+
onSubmit: () => void;
|
|
14
|
+
placeholder?: string;
|
|
15
|
+
textareaRef?: RefObject<TextareaRenderable | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ApiKeyInput({
|
|
19
|
+
onSubmit,
|
|
20
|
+
placeholder = "Paste your API key here...",
|
|
21
|
+
textareaRef,
|
|
22
|
+
}: ApiKeyInputProps) {
|
|
23
|
+
const localRef = useRef<TextareaRenderable | null>(null);
|
|
24
|
+
const activeRef = textareaRef ?? localRef;
|
|
25
|
+
|
|
26
|
+
const handleContentChange = () => {
|
|
27
|
+
const text = activeRef.current?.plainText ?? "";
|
|
28
|
+
// Strip newlines - API keys should be single line
|
|
29
|
+
const cleanedText = text.replace(/[\r\n]/g, "");
|
|
30
|
+
if (cleanedText !== text) {
|
|
31
|
+
activeRef.current?.setText(cleanedText);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const handlePaste = (event: PasteEvent) => {
|
|
36
|
+
debug.log("[ApiKeyInput] onPaste received", {
|
|
37
|
+
textLength: event.text.length,
|
|
38
|
+
textPreview: event.text.slice(0, 50),
|
|
39
|
+
defaultPrevented: event.defaultPrevented,
|
|
40
|
+
});
|
|
41
|
+
if (!event.text.trim()) {
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
void pasteClipboardIntoTextarea(activeRef.current, {
|
|
44
|
+
singleLine: true,
|
|
45
|
+
source: "apikey-onPaste",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// Otherwise, let the textarea handle it
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handleKeyDown = (key: KeyEvent) => {
|
|
52
|
+
if (key.eventType !== "press") return;
|
|
53
|
+
if (key.name !== "v") return;
|
|
54
|
+
if (!(key.ctrl || key.meta || key.super)) return;
|
|
55
|
+
key.preventDefault();
|
|
56
|
+
void pasteClipboardIntoTextarea(activeRef.current, {
|
|
57
|
+
singleLine: true,
|
|
58
|
+
source: "apikey-shortcut",
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<box
|
|
64
|
+
border={true}
|
|
65
|
+
borderStyle="single"
|
|
66
|
+
borderColor={COLORS.MENU_BORDER}
|
|
67
|
+
backgroundColor={COLORS.MENU_SELECTED_BG}
|
|
68
|
+
flexDirection="row"
|
|
69
|
+
alignItems="stretch"
|
|
70
|
+
width="100%"
|
|
71
|
+
maxWidth={100}
|
|
72
|
+
height={3}
|
|
73
|
+
paddingLeft={1}
|
|
74
|
+
paddingRight={1}
|
|
75
|
+
>
|
|
76
|
+
<textarea
|
|
77
|
+
ref={activeRef}
|
|
78
|
+
placeholder={placeholder}
|
|
79
|
+
focused={true}
|
|
80
|
+
wrapMode="none"
|
|
81
|
+
keyBindings={[{ name: "return", action: "submit" }]}
|
|
82
|
+
onContentChange={handleContentChange}
|
|
83
|
+
onPaste={handlePaste}
|
|
84
|
+
onKeyDown={handleKeyDown}
|
|
85
|
+
onSubmit={() => onSubmit()}
|
|
86
|
+
width="100%"
|
|
87
|
+
height="100%"
|
|
88
|
+
style={{
|
|
89
|
+
backgroundColor: "transparent",
|
|
90
|
+
focusedBackgroundColor: "transparent",
|
|
91
|
+
textColor: COLORS.DAEMON_LABEL,
|
|
92
|
+
focusedTextColor: COLORS.DAEMON_LABEL,
|
|
93
|
+
cursorColor: COLORS.DAEMON_LABEL,
|
|
94
|
+
cursorStyle: { style: "block", blinking: true },
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
</box>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable API key input step for onboarding.
|
|
3
|
+
* Reduces repetition across openrouter, openai, and exa key steps.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { TextareaRenderable } from "@opentui/core";
|
|
7
|
+
import type { RefObject } from "react";
|
|
8
|
+
import { ApiKeyInput } from "./ApiKeyInput";
|
|
9
|
+
import { COLORS } from "../ui/constants";
|
|
10
|
+
|
|
11
|
+
interface ApiKeyStepProps {
|
|
12
|
+
/** Title displayed in the header */
|
|
13
|
+
title: string;
|
|
14
|
+
/** Main description of what the service does */
|
|
15
|
+
description: string;
|
|
16
|
+
/** Optional error message (e.g., "env variable not found") */
|
|
17
|
+
errorMessage?: string;
|
|
18
|
+
/** Environment variable name for Option 2 */
|
|
19
|
+
envVarName: string;
|
|
20
|
+
/** URL where the user can get their API key */
|
|
21
|
+
keyUrl: string;
|
|
22
|
+
/** Whether this key is optional (shows ESC to skip option) */
|
|
23
|
+
optional?: boolean;
|
|
24
|
+
/** What happens if skipped (e.g., "text-only mode", "web search disabled") */
|
|
25
|
+
skipConsequence?: string;
|
|
26
|
+
/** Callback when key is submitted */
|
|
27
|
+
onSubmit: () => void;
|
|
28
|
+
/** Ref to the textarea for focus management */
|
|
29
|
+
textareaRef?: RefObject<TextareaRenderable | null>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ApiKeyStep({
|
|
33
|
+
title,
|
|
34
|
+
description,
|
|
35
|
+
errorMessage,
|
|
36
|
+
envVarName,
|
|
37
|
+
keyUrl,
|
|
38
|
+
optional = false,
|
|
39
|
+
skipConsequence,
|
|
40
|
+
onSubmit,
|
|
41
|
+
textareaRef,
|
|
42
|
+
}: ApiKeyStepProps) {
|
|
43
|
+
return (
|
|
44
|
+
<>
|
|
45
|
+
<box marginBottom={1}>
|
|
46
|
+
<text>
|
|
47
|
+
<span fg={COLORS.DAEMON_LABEL}>[ {title} ]</span>
|
|
48
|
+
</text>
|
|
49
|
+
</box>
|
|
50
|
+
{errorMessage && (
|
|
51
|
+
<box marginBottom={1}>
|
|
52
|
+
<text>
|
|
53
|
+
<span fg={COLORS.DAEMON_ERROR}>{errorMessage}</span>
|
|
54
|
+
</text>
|
|
55
|
+
</box>
|
|
56
|
+
)}
|
|
57
|
+
<box marginBottom={1}>
|
|
58
|
+
<text>
|
|
59
|
+
<span fg={COLORS.MENU_TEXT}>{description}</span>
|
|
60
|
+
</text>
|
|
61
|
+
</box>
|
|
62
|
+
{optional && skipConsequence && (
|
|
63
|
+
<box marginBottom={1}>
|
|
64
|
+
<text>
|
|
65
|
+
<span fg={COLORS.REASONING_DIM}>Without this key, {skipConsequence}.</span>
|
|
66
|
+
</text>
|
|
67
|
+
</box>
|
|
68
|
+
)}
|
|
69
|
+
<box marginBottom={1}>
|
|
70
|
+
<text>
|
|
71
|
+
<span fg={COLORS.REASONING_DIM}>Option 1: Paste your key (Ctrl+v) below and press ENTER</span>
|
|
72
|
+
</text>
|
|
73
|
+
</box>
|
|
74
|
+
<box marginBottom={1}>
|
|
75
|
+
<text>
|
|
76
|
+
<span fg={COLORS.REASONING_DIM}>Option 2: Quit, set {envVarName} env variable and restart</span>
|
|
77
|
+
</text>
|
|
78
|
+
</box>
|
|
79
|
+
{optional && (
|
|
80
|
+
<box marginBottom={1}>
|
|
81
|
+
<text>
|
|
82
|
+
<span fg={COLORS.REASONING_DIM}>Option 3: Press ESC to skip ({skipConsequence})</span>
|
|
83
|
+
</text>
|
|
84
|
+
</box>
|
|
85
|
+
)}
|
|
86
|
+
<box marginBottom={1}>
|
|
87
|
+
<text>
|
|
88
|
+
<span fg={COLORS.REASONING_DIM}>Get your key: {keyUrl}</span>
|
|
89
|
+
<span fg={COLORS.USER_LABEL}> · Shift+O to open</span>
|
|
90
|
+
</text>
|
|
91
|
+
</box>
|
|
92
|
+
<ApiKeyInput onSubmit={onSubmit} textareaRef={textareaRef} />
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from "react";
|
|
2
|
+
import { useKeyboard } from "@opentui/react";
|
|
3
|
+
import { TextAttributes, type KeyEvent } from "@opentui/core";
|
|
4
|
+
import { COLORS } from "../ui/constants";
|
|
5
|
+
|
|
6
|
+
interface ApprovalPickerProps {
|
|
7
|
+
onApprove: () => void;
|
|
8
|
+
onDeny: () => void;
|
|
9
|
+
onApproveAll?: () => void;
|
|
10
|
+
onDenyAll?: () => void;
|
|
11
|
+
focused?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ApprovalPicker({
|
|
15
|
+
onApprove,
|
|
16
|
+
onDeny,
|
|
17
|
+
onApproveAll,
|
|
18
|
+
onDenyAll,
|
|
19
|
+
focused = true,
|
|
20
|
+
}: ApprovalPickerProps) {
|
|
21
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
22
|
+
const respondedRef = useRef(false);
|
|
23
|
+
|
|
24
|
+
const handleSelect = useCallback(() => {
|
|
25
|
+
if (respondedRef.current) return;
|
|
26
|
+
respondedRef.current = true;
|
|
27
|
+
if (selectedIndex === 0) {
|
|
28
|
+
onApprove();
|
|
29
|
+
} else {
|
|
30
|
+
onDeny();
|
|
31
|
+
}
|
|
32
|
+
}, [selectedIndex, onApprove, onDeny]);
|
|
33
|
+
|
|
34
|
+
useKeyboard((key: KeyEvent) => {
|
|
35
|
+
if (!focused) return;
|
|
36
|
+
if (respondedRef.current) return;
|
|
37
|
+
if (key.eventType !== "press") return;
|
|
38
|
+
|
|
39
|
+
if (key.name === "left" || key.name === "right" || key.name === "tab") {
|
|
40
|
+
setSelectedIndex((prev) => (prev === 0 ? 1 : 0));
|
|
41
|
+
key.preventDefault();
|
|
42
|
+
} else if (key.name === "return") {
|
|
43
|
+
handleSelect();
|
|
44
|
+
key.preventDefault();
|
|
45
|
+
} else if (key.sequence === "y") {
|
|
46
|
+
respondedRef.current = true;
|
|
47
|
+
onApprove();
|
|
48
|
+
key.preventDefault();
|
|
49
|
+
} else if (key.sequence === "Y" && onApproveAll) {
|
|
50
|
+
respondedRef.current = true;
|
|
51
|
+
onApproveAll();
|
|
52
|
+
key.preventDefault();
|
|
53
|
+
} else if (key.sequence === "n") {
|
|
54
|
+
respondedRef.current = true;
|
|
55
|
+
onDeny();
|
|
56
|
+
key.preventDefault();
|
|
57
|
+
} else if (key.sequence === "N" && onDenyAll) {
|
|
58
|
+
respondedRef.current = true;
|
|
59
|
+
onDenyAll();
|
|
60
|
+
key.preventDefault();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const approveSelected = selectedIndex === 0;
|
|
65
|
+
const denySelected = selectedIndex === 1;
|
|
66
|
+
|
|
67
|
+
if (!focused) {
|
|
68
|
+
return (
|
|
69
|
+
<box flexDirection="row" alignItems="center" gap={1} marginTop={1} paddingLeft={2}>
|
|
70
|
+
<text>
|
|
71
|
+
<span fg={COLORS.REASONING_DIM}>{" awaiting review..."}</span>
|
|
72
|
+
</text>
|
|
73
|
+
</box>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<box flexDirection="row" alignItems="center" gap={1} marginTop={1} paddingLeft={2}>
|
|
79
|
+
<text>
|
|
80
|
+
<span fg={COLORS.STATUS_APPROVAL}>{">> "}</span>
|
|
81
|
+
<span fg={COLORS.TOOL_INPUT_TEXT}>{"approve? "}</span>
|
|
82
|
+
</text>
|
|
83
|
+
<box
|
|
84
|
+
paddingLeft={1}
|
|
85
|
+
paddingRight={1}
|
|
86
|
+
backgroundColor={approveSelected ? COLORS.STATUS_COMPLETED : undefined}
|
|
87
|
+
>
|
|
88
|
+
<text>
|
|
89
|
+
<span
|
|
90
|
+
fg={approveSelected ? COLORS.MENU_BG : COLORS.STATUS_COMPLETED}
|
|
91
|
+
attributes={approveSelected ? TextAttributes.BOLD : TextAttributes.NONE}
|
|
92
|
+
>
|
|
93
|
+
{"[Y]es"}
|
|
94
|
+
</span>
|
|
95
|
+
</text>
|
|
96
|
+
</box>
|
|
97
|
+
<box paddingLeft={1} paddingRight={1} backgroundColor={denySelected ? COLORS.STATUS_FAILED : undefined}>
|
|
98
|
+
<text>
|
|
99
|
+
<span
|
|
100
|
+
fg={denySelected ? COLORS.MENU_BG : COLORS.STATUS_FAILED}
|
|
101
|
+
attributes={denySelected ? TextAttributes.BOLD : TextAttributes.NONE}
|
|
102
|
+
>
|
|
103
|
+
{"[N]o"}
|
|
104
|
+
</span>
|
|
105
|
+
</text>
|
|
106
|
+
</box>
|
|
107
|
+
</box>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component for rendering a single content block (reasoning, tool, or text).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { DaemonText } from "./DaemonText";
|
|
6
|
+
import { ToolCallView } from "./ToolCallView";
|
|
7
|
+
import { COLORS, REASONING_MARKDOWN_STYLE } from "../ui/constants";
|
|
8
|
+
import type { ContentBlock } from "../types";
|
|
9
|
+
import { formatElapsedTime, hasVisibleText } from "../utils/formatters";
|
|
10
|
+
|
|
11
|
+
interface ContentBlockViewProps {
|
|
12
|
+
block: ContentBlock;
|
|
13
|
+
isLastReasoningBlock: boolean;
|
|
14
|
+
isLastTextBlock: boolean;
|
|
15
|
+
isLastBlock?: boolean;
|
|
16
|
+
isStreaming: boolean;
|
|
17
|
+
showFullReasoning: boolean;
|
|
18
|
+
showToolOutput?: boolean;
|
|
19
|
+
reasoningDisplay?: string;
|
|
20
|
+
showReasoningTicker?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ContentBlockView({
|
|
24
|
+
block,
|
|
25
|
+
isLastReasoningBlock,
|
|
26
|
+
isLastTextBlock,
|
|
27
|
+
isLastBlock = false,
|
|
28
|
+
isStreaming,
|
|
29
|
+
showFullReasoning,
|
|
30
|
+
showToolOutput = true,
|
|
31
|
+
reasoningDisplay,
|
|
32
|
+
showReasoningTicker,
|
|
33
|
+
}: ContentBlockViewProps) {
|
|
34
|
+
if (block.type === "reasoning") {
|
|
35
|
+
if (shouldHideContentBlock(block)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cleanedContent = block.content.replace(/\[REDACTED\]/g, "");
|
|
40
|
+
|
|
41
|
+
// Show full reasoning if enabled
|
|
42
|
+
if (showFullReasoning) {
|
|
43
|
+
return (
|
|
44
|
+
<box
|
|
45
|
+
flexDirection="column"
|
|
46
|
+
border={["left"]}
|
|
47
|
+
borderStyle="heavy"
|
|
48
|
+
borderColor={COLORS.REASONING_DIM}
|
|
49
|
+
paddingLeft={1}
|
|
50
|
+
>
|
|
51
|
+
<text>
|
|
52
|
+
<span fg={COLORS.REASONING}>{"REASONING"}</span>
|
|
53
|
+
</text>
|
|
54
|
+
<code
|
|
55
|
+
content={cleanedContent}
|
|
56
|
+
filetype="markdown"
|
|
57
|
+
syntaxStyle={REASONING_MARKDOWN_STYLE}
|
|
58
|
+
streaming={isStreaming && isLastBlock}
|
|
59
|
+
drawUnstyledText={false}
|
|
60
|
+
/>
|
|
61
|
+
</box>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// For non-full-reasoning mode, show animated display only for the latest reasoning block
|
|
66
|
+
if (showReasoningTicker && isLastReasoningBlock && reasoningDisplay) {
|
|
67
|
+
return (
|
|
68
|
+
<text>
|
|
69
|
+
<span fg={COLORS.REASONING_DIM}>
|
|
70
|
+
{"// "}
|
|
71
|
+
{reasoningDisplay}
|
|
72
|
+
</span>
|
|
73
|
+
</text>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const durationLabel =
|
|
77
|
+
block.durationMs !== undefined
|
|
78
|
+
? ` · ${formatElapsedTime(block.durationMs, { style: "detailed" })}`
|
|
79
|
+
: "";
|
|
80
|
+
return (
|
|
81
|
+
<text>
|
|
82
|
+
<span fg={COLORS.REASONING_DIM}>
|
|
83
|
+
{"// REASONING"}
|
|
84
|
+
{durationLabel}
|
|
85
|
+
</span>
|
|
86
|
+
</text>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (block.type === "tool") {
|
|
91
|
+
return (
|
|
92
|
+
<box flexDirection="column">
|
|
93
|
+
<ToolCallView call={block.call} result={block.result} showOutput={showToolOutput} />
|
|
94
|
+
</box>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (block.type === "text") {
|
|
99
|
+
return (
|
|
100
|
+
<box flexDirection="column">
|
|
101
|
+
<DaemonText
|
|
102
|
+
content={block.content}
|
|
103
|
+
showLabel={isLastTextBlock && hasVisibleText(block.content)}
|
|
104
|
+
streaming={isStreaming}
|
|
105
|
+
/>
|
|
106
|
+
</box>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Helper to find if a block is the last text block in a list
|
|
115
|
+
*/
|
|
116
|
+
export function isLastTextBlockInList(blocks: ContentBlock[], block: ContentBlock): boolean {
|
|
117
|
+
const lastTextBlock = [...blocks].reverse().find((b) => b.type === "text");
|
|
118
|
+
return lastTextBlock === block;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Helper to find if a block is the last reasoning block in a list
|
|
123
|
+
*/
|
|
124
|
+
export function isLastReasoningBlockInList(blocks: ContentBlock[], block: ContentBlock): boolean {
|
|
125
|
+
const lastReasoningBlock = [...blocks].reverse().find((b) => b.type === "reasoning");
|
|
126
|
+
return lastReasoningBlock === block;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Helper to determine if a content block should be hidden entirely
|
|
131
|
+
* (e.g. reasoning blocks that only contain redacted content)
|
|
132
|
+
*/
|
|
133
|
+
export function shouldHideContentBlock(block: ContentBlock): boolean {
|
|
134
|
+
if (block.type === "reasoning") {
|
|
135
|
+
const cleanedContent = block.content.replace(/\[REDACTED\]/g, "");
|
|
136
|
+
if (cleanedContent.trim().length === 0 && block.content.includes("[REDACTED]")) {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { formatMarkdownTables } from "../utils/markdown-tables";
|
|
2
|
+
import { COLORS, DAEMON_MARKDOWN_STYLE } from "../ui/constants";
|
|
3
|
+
|
|
4
|
+
export interface DaemonTextProps {
|
|
5
|
+
content: string;
|
|
6
|
+
showLabel?: boolean;
|
|
7
|
+
streaming?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function DaemonText({ content, showLabel = false, streaming = false }: DaemonTextProps) {
|
|
11
|
+
const maxWidth =
|
|
12
|
+
typeof process !== "undefined" && process.stdout?.columns ? process.stdout.columns : undefined;
|
|
13
|
+
// Trim trailing whitespace when not streaming to avoid gaps before subsequent blocks
|
|
14
|
+
const trimmedContent = streaming ? content : content.trimEnd();
|
|
15
|
+
const renderedContent = streaming ? trimmedContent : formatMarkdownTables(trimmedContent, { maxWidth });
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<box flexDirection="column">
|
|
19
|
+
{showLabel && (
|
|
20
|
+
<text>
|
|
21
|
+
<span fg={COLORS.DAEMON_LABEL}>DAEMON: </span>
|
|
22
|
+
</text>
|
|
23
|
+
)}
|
|
24
|
+
<code
|
|
25
|
+
content={renderedContent}
|
|
26
|
+
filetype="markdown"
|
|
27
|
+
syntaxStyle={DAEMON_MARKDOWN_STYLE}
|
|
28
|
+
conceal={true}
|
|
29
|
+
streaming={streaming}
|
|
30
|
+
drawUnstyledText={false}
|
|
31
|
+
/>
|
|
32
|
+
</box>
|
|
33
|
+
);
|
|
34
|
+
}
|