@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,166 @@
1
+ import { useMemo } from "react";
2
+ import { useMenuKeyboard } from "../hooks/use-menu-keyboard";
3
+ import type { AudioDevice } from "../types";
4
+ import { COLORS } from "../ui/constants";
5
+
6
+ interface DeviceMenuProps {
7
+ devices: AudioDevice[];
8
+ currentDevice: string | undefined;
9
+ currentOutputDevice: string | undefined;
10
+ soxAvailable: boolean;
11
+ soxInstallHint: string;
12
+ onClose: () => void;
13
+ onSelect: (device: AudioDevice) => void;
14
+ onOutputSelect: (device: AudioDevice) => void;
15
+ }
16
+
17
+ export function DeviceMenu({
18
+ devices,
19
+ currentDevice,
20
+ currentOutputDevice,
21
+ soxAvailable,
22
+ soxInstallHint,
23
+ onClose,
24
+ onSelect,
25
+ onOutputSelect,
26
+ }: DeviceMenuProps) {
27
+ const totalItems = devices.length * 2;
28
+
29
+ const initialIndex = useMemo(() => {
30
+ if (devices.length === 0) return 0;
31
+ const idx = currentDevice ? devices.findIndex((device) => device.name === currentDevice) : -1;
32
+ if (idx >= 0) return idx;
33
+ return devices.length > 1 ? 1 : 0;
34
+ }, [devices, currentDevice]);
35
+
36
+ const { selectedIndex } = useMenuKeyboard({
37
+ itemCount: totalItems,
38
+ initialIndex,
39
+ onClose,
40
+ closeOnSelect: false,
41
+ onSelect: (selectedIdx) => {
42
+ const isOutputSection = selectedIdx >= devices.length;
43
+ const deviceIdx = isOutputSection ? selectedIdx - devices.length : selectedIdx;
44
+ const selectedDevice = devices[deviceIdx];
45
+ if (selectedDevice) {
46
+ if (isOutputSection) {
47
+ onOutputSelect(selectedDevice);
48
+ } else {
49
+ onSelect(selectedDevice);
50
+ }
51
+ }
52
+ },
53
+ });
54
+
55
+ const isInputSection = selectedIndex < devices.length;
56
+ const inputSelectedIdx = isInputSection ? selectedIndex : -1;
57
+ const outputSelectedIdx = !isInputSection ? selectedIndex - devices.length : -1;
58
+
59
+ return (
60
+ <box
61
+ position="absolute"
62
+ left={0}
63
+ top={0}
64
+ width="100%"
65
+ height="100%"
66
+ flexDirection="column"
67
+ alignItems="center"
68
+ justifyContent="center"
69
+ zIndex={100}
70
+ >
71
+ <box
72
+ flexDirection="column"
73
+ backgroundColor={COLORS.MENU_BG}
74
+ borderStyle="single"
75
+ borderColor={COLORS.MENU_BORDER}
76
+ paddingLeft={2}
77
+ paddingRight={2}
78
+ paddingTop={1}
79
+ paddingBottom={1}
80
+ width="50%"
81
+ minWidth={50}
82
+ maxWidth={120}
83
+ >
84
+ <box marginBottom={1}>
85
+ <text>
86
+ <span fg={COLORS.USER_LABEL}>
87
+ {soxAvailable ? "↑/↓ or j/k to navigate, ENTER to select, ESC to cancel" : "ESC to close"}
88
+ </span>
89
+ </text>
90
+ </box>
91
+ {!soxAvailable ? (
92
+ <box flexDirection="column" paddingTop={1}>
93
+ <text>
94
+ <span fg={COLORS.ERROR}>sox is not installed</span>
95
+ </text>
96
+ <box marginTop={1}>
97
+ <text>
98
+ <span fg={COLORS.USER_LABEL}>Voice input requires sox for audio capture.</span>
99
+ </text>
100
+ </box>
101
+ <box marginTop={1}>
102
+ <text>
103
+ <span fg={COLORS.MENU_TEXT}>{soxInstallHint}</span>
104
+ </text>
105
+ </box>
106
+ </box>
107
+ ) : devices.length === 0 ? (
108
+ <box>
109
+ <text>
110
+ <span fg={COLORS.USER_LABEL}>Loading devices...</span>
111
+ </text>
112
+ </box>
113
+ ) : (
114
+ <>
115
+ <box marginBottom={1} marginTop={1}>
116
+ <text>
117
+ <span fg={COLORS.DAEMON_LABEL}>[ INPUT ]</span>
118
+ </text>
119
+ </box>
120
+ <box flexDirection="column">
121
+ {devices.map((device, idx) => (
122
+ <box
123
+ key={`input-${device.name}`}
124
+ backgroundColor={idx === inputSelectedIdx ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
125
+ paddingLeft={1}
126
+ paddingRight={1}
127
+ >
128
+ <text>
129
+ <span fg={idx === inputSelectedIdx ? COLORS.DAEMON_LABEL : COLORS.MENU_TEXT}>
130
+ {idx === inputSelectedIdx ? "▶ " : " "}
131
+ {device.name}
132
+ {device.name === currentDevice ? " ●" : ""}
133
+ </span>
134
+ </text>
135
+ </box>
136
+ ))}
137
+ </box>
138
+ <box marginBottom={1} marginTop={1}>
139
+ <text>
140
+ <span fg={COLORS.DAEMON_LABEL}>[ OUTPUT ]</span>
141
+ </text>
142
+ </box>
143
+ <box flexDirection="column">
144
+ {devices.map((device, idx) => (
145
+ <box
146
+ key={`output-${device.name}`}
147
+ backgroundColor={idx === outputSelectedIdx ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
148
+ paddingLeft={1}
149
+ paddingRight={1}
150
+ >
151
+ <text>
152
+ <span fg={idx === outputSelectedIdx ? COLORS.DAEMON_LABEL : COLORS.MENU_TEXT}>
153
+ {idx === outputSelectedIdx ? "▶ " : " "}
154
+ {device.name}
155
+ {device.name === currentOutputDevice ? " ●" : ""}
156
+ </span>
157
+ </text>
158
+ </box>
159
+ ))}
160
+ </box>
161
+ </>
162
+ )}
163
+ </box>
164
+ </box>
165
+ );
166
+ }
@@ -0,0 +1,21 @@
1
+ import { COLORS } from "../ui/constants";
2
+
3
+ interface GroundingBadgeProps {
4
+ count: number;
5
+ }
6
+
7
+ export function GroundingBadge({ count }: GroundingBadgeProps) {
8
+ return (
9
+ <box flexDirection="row" marginTop={1}>
10
+ <text>
11
+ <span fg={COLORS.REASONING_DIM}>[ </span>
12
+ <span fg={COLORS.DAEMON_LABEL}>
13
+ -&gt; {count} source{count !== 1 ? "s" : ""}
14
+ </span>
15
+ <span fg={COLORS.REASONING_DIM}> · press </span>
16
+ <span fg={COLORS.DAEMON_LABEL}>G</span>
17
+ <span fg={COLORS.REASONING_DIM}> to view ]</span>
18
+ </text>
19
+ </box>
20
+ );
21
+ }
@@ -0,0 +1,310 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core";
2
+ import { useRenderer } from "@opentui/react";
3
+ import { useEffect, useMemo, useRef } from "react";
4
+ import { useMenuKeyboard } from "../hooks/use-menu-keyboard";
5
+ import type { GroundedStatement, GroundingMap } from "../types";
6
+ import { COLORS } from "../ui/constants";
7
+
8
+ const QUOTE_INDENT = 2;
9
+
10
+ const ITEM_PADDING_TOP = 1;
11
+ const ITEM_PADDING_BOTTOM = 1;
12
+ const ITEM_MARGIN_BOTTOM = 1;
13
+ const MARGIN_QUOTE = 1;
14
+ const MARGIN_SOURCE = 1;
15
+ const SOURCE_LINE_HEIGHT = 1;
16
+ const DEBUG_FRAGMENTS =
17
+ process.env.DAEMON_DEBUG_FRAGMENTS === "true" || process.env.DAEMON_DEBUG_FRAGMENTS === "1";
18
+
19
+ function truncateText(text: string, maxLen: number): string {
20
+ if (text.length <= maxLen) return text;
21
+ return text.slice(0, maxLen - 1) + "…";
22
+ }
23
+
24
+ function wrapText(text: string, maxWidth: number): string[] {
25
+ if (!text || maxWidth <= 0) return [];
26
+ const words = text.split(/\s+/);
27
+ const lines: string[] = [];
28
+ let currentLine = "";
29
+
30
+ for (const word of words) {
31
+ if (currentLine.length === 0) {
32
+ currentLine = word;
33
+ } else if (currentLine.length + 1 + word.length <= maxWidth) {
34
+ currentLine += " " + word;
35
+ } else {
36
+ lines.push(currentLine);
37
+ currentLine = word;
38
+ }
39
+ }
40
+ if (currentLine.length > 0) {
41
+ lines.push(currentLine);
42
+ }
43
+ return lines;
44
+ }
45
+
46
+ interface LayoutItem {
47
+ item: GroundedStatement;
48
+ statementLines: string[];
49
+ quoteLines: string[];
50
+ fragmentLines: string[];
51
+ sourceDomain: string;
52
+ height: number;
53
+ }
54
+
55
+ interface GroundingMenuProps {
56
+ groundingMap: GroundingMap;
57
+ initialIndex?: number;
58
+ onClose: () => void;
59
+ onSelect: (index: number) => void;
60
+ onSelectedIndexChange?: (index: number) => void;
61
+ }
62
+
63
+ export function GroundingMenu({
64
+ groundingMap,
65
+ initialIndex = 0,
66
+ onClose,
67
+ onSelect,
68
+ onSelectedIndexChange,
69
+ }: GroundingMenuProps) {
70
+ const items = groundingMap.items;
71
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
72
+ const renderer = useRenderer();
73
+
74
+ const menuWidth = useMemo(() => {
75
+ return Math.max(80, Math.min(300, Math.floor(renderer.terminalWidth * 0.85)));
76
+ }, [renderer.terminalWidth]);
77
+
78
+ const { contentWidth, statementWidth, quoteWidth } = useMemo(() => {
79
+ const cw = menuWidth - 6;
80
+ return {
81
+ contentWidth: cw,
82
+ statementWidth: cw - 2,
83
+ quoteWidth: cw - QUOTE_INDENT - 4,
84
+ };
85
+ }, [menuWidth]);
86
+
87
+ const { selectedIndex } = useMenuKeyboard({
88
+ itemCount: items.length,
89
+ initialIndex,
90
+ onClose,
91
+ onSelect,
92
+ });
93
+
94
+ const layoutItems = useMemo<LayoutItem[]>(() => {
95
+ return items.map((item: GroundedStatement) => {
96
+ const statementLines = wrapText(item.statement, statementWidth);
97
+
98
+ const quoteText = (item.source.quote ?? "").trim();
99
+ const quoteLinesAll = wrapText(quoteText, quoteWidth);
100
+ const quoteLines = quoteLinesAll.slice(0, 4);
101
+ if (quoteLinesAll.length > 4) {
102
+ const lastLine = quoteLines[3] ?? "";
103
+ quoteLines[3] = truncateText(lastLine, quoteWidth - 3);
104
+ }
105
+
106
+ let fragmentLines: string[] = [];
107
+ if (DEBUG_FRAGMENTS && item.source.textFragment) {
108
+ fragmentLines = wrapText(`[Fragment] ${item.source.textFragment}`, quoteWidth);
109
+ }
110
+
111
+ let sourceDomain = "";
112
+ try {
113
+ sourceDomain = new URL(item.source.url).hostname;
114
+ } catch {
115
+ sourceDomain = truncateText(item.source.url, 40);
116
+ }
117
+
118
+ let h = ITEM_PADDING_TOP;
119
+ h += statementLines.length;
120
+
121
+ if (quoteLines.length > 0) {
122
+ h += MARGIN_QUOTE;
123
+ h += quoteLines.length;
124
+ }
125
+
126
+ if (fragmentLines.length > 0) {
127
+ h += MARGIN_QUOTE;
128
+ h += fragmentLines.length;
129
+ }
130
+
131
+ h += MARGIN_SOURCE + SOURCE_LINE_HEIGHT;
132
+ h += ITEM_PADDING_BOTTOM;
133
+
134
+ return {
135
+ item,
136
+ statementLines,
137
+ quoteLines,
138
+ fragmentLines,
139
+ sourceDomain,
140
+ height: h,
141
+ };
142
+ });
143
+ }, [items, statementWidth, quoteWidth]);
144
+
145
+ useEffect(() => {
146
+ const scrollbox = scrollRef.current;
147
+ if (!scrollbox || layoutItems.length === 0) return;
148
+
149
+ const viewportHeight = scrollbox.viewport?.height ?? 0;
150
+ if (viewportHeight <= 0) return;
151
+
152
+ const selectedLayout = layoutItems[selectedIndex];
153
+ if (!selectedLayout) return;
154
+
155
+ let itemTop = 0;
156
+ for (let i = 0; i < selectedIndex; i++) {
157
+ const layout = layoutItems[i];
158
+ if (layout) {
159
+ itemTop += layout.height + ITEM_MARGIN_BOTTOM;
160
+ }
161
+ }
162
+ const itemBottom = itemTop + selectedLayout.height;
163
+
164
+ const currentScrollTop = scrollbox.scrollTop;
165
+ const currentScrollBottom = currentScrollTop + viewportHeight;
166
+ const maxScrollTop = Math.max(0, scrollbox.scrollHeight - viewportHeight);
167
+
168
+ let nextScrollTop = currentScrollTop;
169
+
170
+ if (itemTop < currentScrollTop) {
171
+ nextScrollTop = itemTop;
172
+ } else if (itemBottom > currentScrollBottom) {
173
+ nextScrollTop = itemBottom - viewportHeight;
174
+ }
175
+
176
+ nextScrollTop = Math.max(0, Math.min(nextScrollTop, maxScrollTop));
177
+ if (nextScrollTop !== currentScrollTop) {
178
+ scrollbox.scrollTop = nextScrollTop;
179
+ }
180
+ }, [selectedIndex, layoutItems]);
181
+
182
+ useEffect(() => {
183
+ onSelectedIndexChange?.(selectedIndex);
184
+ }, [onSelectedIndexChange, selectedIndex]);
185
+
186
+ return (
187
+ <box
188
+ position="absolute"
189
+ left={0}
190
+ top={0}
191
+ width="100%"
192
+ height="100%"
193
+ flexDirection="column"
194
+ alignItems="center"
195
+ justifyContent="center"
196
+ zIndex={100}
197
+ >
198
+ <box
199
+ flexDirection="column"
200
+ backgroundColor={COLORS.MENU_BG}
201
+ borderStyle="single"
202
+ borderColor={COLORS.MENU_BORDER}
203
+ paddingLeft={3}
204
+ paddingRight={3}
205
+ paddingTop={1}
206
+ paddingBottom={1}
207
+ width={menuWidth}
208
+ maxHeight="85%"
209
+ >
210
+ <box marginBottom={1} flexDirection="row" width="100%">
211
+ <text>
212
+ <span fg={COLORS.DAEMON_LABEL}>[ GROUNDING ]</span>
213
+ <span fg={COLORS.REASONING_DIM}>
214
+ {" "}
215
+ — {items.length} source{items.length !== 1 ? "s" : ""}
216
+ </span>
217
+ </text>
218
+ <box flexGrow={1} />
219
+ <text>
220
+ <span fg={COLORS.USER_LABEL}>
221
+ <span fg={COLORS.DAEMON_LABEL}>ENTER</span> open · <span fg={COLORS.DAEMON_LABEL}>ESC</span>{" "}
222
+ close
223
+ </span>
224
+ </text>
225
+ </box>
226
+
227
+ {items.length === 0 ? (
228
+ <box height={3} justifyContent="center" alignItems="center">
229
+ <text>
230
+ <span fg={COLORS.USER_TEXT}>No grounded statements recorded.</span>
231
+ </text>
232
+ </box>
233
+ ) : (
234
+ <scrollbox
235
+ ref={scrollRef}
236
+ flexGrow={1}
237
+ flexShrink={1}
238
+ focused={false}
239
+ scrollY={true}
240
+ scrollX={false}
241
+ >
242
+ <box flexDirection="column" paddingBottom={1}>
243
+ {layoutItems.map((layout, idx) => {
244
+ const isSelected = idx === selectedIndex;
245
+ const { item, statementLines, quoteLines, fragmentLines, sourceDomain } = layout;
246
+
247
+ return (
248
+ <box
249
+ key={item.id}
250
+ flexDirection="column"
251
+ padding={1}
252
+ backgroundColor={isSelected ? COLORS.MENU_SELECTED_BG : undefined}
253
+ marginBottom={1}
254
+ >
255
+ {statementLines.map((line, i) => (
256
+ <text key={`s-${i}`}>
257
+ <span fg={isSelected ? COLORS.DAEMON_TEXT : COLORS.USER_TEXT}>
258
+ {i === 0 && isSelected ? "▶ " : " "}
259
+ {line}
260
+ </span>
261
+ </text>
262
+ ))}
263
+
264
+ {quoteLines.length > 0 && (
265
+ <box marginTop={1} marginLeft={QUOTE_INDENT} flexDirection="column">
266
+ {quoteLines.map((line, i) => (
267
+ <text key={`q-${i}`}>
268
+ <span fg={COLORS.REASONING_DIM}>
269
+ {i === 0 ? "❝ " : " "}
270
+ {line}
271
+ {i === quoteLines.length - 1 ? " ❞" : ""}
272
+ </span>
273
+ </text>
274
+ ))}
275
+ </box>
276
+ )}
277
+
278
+ {fragmentLines.length > 0 && (
279
+ <box marginTop={1} marginLeft={QUOTE_INDENT} flexDirection="column">
280
+ {fragmentLines.map((line, i) => (
281
+ <text key={`f-${i}`}>
282
+ <span fg={COLORS.DAEMON_ERROR}>{line}</span>
283
+ </text>
284
+ ))}
285
+ </box>
286
+ )}
287
+
288
+ <box marginTop={1} marginLeft={2}>
289
+ <text>
290
+ <span fg={isSelected ? COLORS.DAEMON_LABEL : COLORS.REASONING_DIM}>
291
+ [{idx + 1}] ↗ {sourceDomain}
292
+ </span>
293
+ {item.source.title && (
294
+ <span fg={COLORS.REASONING_DIM}>
295
+ {" · "}
296
+ {truncateText(item.source.title, 50)}
297
+ </span>
298
+ )}
299
+ </text>
300
+ </box>
301
+ </box>
302
+ );
303
+ })}
304
+ </box>
305
+ </scrollbox>
306
+ )}
307
+ </box>
308
+ </box>
309
+ );
310
+ }
@@ -0,0 +1,115 @@
1
+ import type { KeyEvent } from "@opentui/core";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { useCallback } from "react";
4
+ import { COLORS } from "../ui/constants";
5
+
6
+ interface HotkeysPaneProps {
7
+ onClose: () => void;
8
+ }
9
+
10
+ export function HotkeysPane({ onClose }: HotkeysPaneProps) {
11
+ const handleKeyPress = useCallback(
12
+ (key: KeyEvent) => {
13
+ if (key.eventType !== "press") return;
14
+ if (key.name === "escape" || key.sequence === "?") {
15
+ onClose();
16
+ key.preventDefault();
17
+ }
18
+ },
19
+ [onClose]
20
+ );
21
+
22
+ useKeyboard(handleKeyPress);
23
+
24
+ const sections = [
25
+ {
26
+ title: "PRIMARY",
27
+ items: [
28
+ { key: "SPACE", label: "Speak / stop listening" },
29
+ { key: "SHIFT+TAB", label: "Toggle type mode" },
30
+ { key: "↑/↓", label: "Scroll conversation" },
31
+ { key: "J/K", label: "Scroll conversation" },
32
+ { key: "CTRL+U", label: "Page up conversation" },
33
+ { key: "CTRL+D", label: "Page down conversation" },
34
+ ],
35
+ },
36
+ {
37
+ title: "SESSION",
38
+ items: [
39
+ { key: "T", label: "Toggle full reasoning previews" },
40
+ { key: "O", label: "Toggle tool output previews" },
41
+ { key: "N", label: "New session" },
42
+ { key: "G", label: "Open Grounding Menu" },
43
+ { key: "CTRL+X", label: "Undo last message" },
44
+ ],
45
+ },
46
+ {
47
+ title: "MENUS",
48
+ items: [
49
+ { key: "D", label: "Devices" },
50
+ { key: "M", label: "Models" },
51
+ { key: "P", label: "Providers" },
52
+ { key: "L", label: "Sessions" },
53
+ { key: "S", label: "Settings" },
54
+ ],
55
+ },
56
+ ];
57
+
58
+ return (
59
+ <box
60
+ position="absolute"
61
+ left={0}
62
+ top={0}
63
+ width="100%"
64
+ height="100%"
65
+ flexDirection="column"
66
+ alignItems="center"
67
+ justifyContent="center"
68
+ zIndex={100}
69
+ >
70
+ <box
71
+ flexDirection="column"
72
+ backgroundColor={COLORS.MENU_BG}
73
+ borderStyle="single"
74
+ borderColor={COLORS.MENU_BORDER}
75
+ paddingLeft={2}
76
+ paddingRight={2}
77
+ paddingTop={1}
78
+ paddingBottom={1}
79
+ width="60%"
80
+ minWidth={52}
81
+ maxWidth={140}
82
+ >
83
+ <box marginBottom={1}>
84
+ <text>
85
+ <span fg={COLORS.DAEMON_LABEL}>[ HOTKEYS ]</span>
86
+ </text>
87
+ </box>
88
+ <box marginBottom={1}>
89
+ <text>
90
+ <span fg={COLORS.USER_LABEL}>Press ? or ESC to close</span>
91
+ </text>
92
+ </box>
93
+ <box flexDirection="column">
94
+ {sections.map((section) => (
95
+ <box key={section.title} flexDirection="column" marginBottom={1}>
96
+ <box marginBottom={0}>
97
+ <text>
98
+ <span fg={COLORS.USER_LABEL}>— {section.title} —</span>
99
+ </text>
100
+ </box>
101
+ {section.items.map((item) => (
102
+ <box key={item.key} flexDirection="row">
103
+ <text>
104
+ <span fg={COLORS.DAEMON_LABEL}>{item.key}</span>
105
+ <span fg={COLORS.MENU_TEXT}> · {item.label}</span>
106
+ </text>
107
+ </box>
108
+ ))}
109
+ </box>
110
+ ))}
111
+ </box>
112
+ </box>
113
+ </box>
114
+ );
115
+ }
@@ -0,0 +1,106 @@
1
+ import { COLORS } from "../ui/constants";
2
+ import { DaemonState } from "../types";
3
+
4
+ export interface InlineStatusProps {
5
+ daemonState: DaemonState;
6
+ isToolCalling: boolean;
7
+ isReasoning: boolean;
8
+ responseElapsedMs: number;
9
+ }
10
+
11
+ type InlineStatusConfig = {
12
+ spinnerName: "line" | "dots3" | "dots2" | "dots" | "bouncingBall";
13
+ label: string;
14
+ color: string;
15
+ };
16
+
17
+ function buildElapsedSuffix(responseElapsedMs: number): string {
18
+ if (!Number.isFinite(responseElapsedMs) || responseElapsedMs < 1000) {
19
+ return "";
20
+ }
21
+
22
+ const seconds = Math.max(1, Math.floor(responseElapsedMs / 1000));
23
+ return ` · ${seconds}s`;
24
+ }
25
+
26
+ function getInlineStatusConfig(args: {
27
+ daemonState: DaemonState;
28
+ isToolCalling: boolean;
29
+ isReasoning: boolean;
30
+ elapsedSuffix: string;
31
+ }): InlineStatusConfig | null {
32
+ const { daemonState, isToolCalling, isReasoning, elapsedSuffix } = args;
33
+
34
+ if (daemonState === DaemonState.IDLE) {
35
+ return null;
36
+ }
37
+
38
+ if (isToolCalling) {
39
+ return {
40
+ spinnerName: "line",
41
+ label: `CALLING TOOL${elapsedSuffix}`,
42
+ color: COLORS.STATUS_RUNNING,
43
+ };
44
+ }
45
+
46
+ if (isReasoning) {
47
+ return {
48
+ spinnerName: "dots3",
49
+ label: `REASONING${elapsedSuffix}`,
50
+ color: COLORS.REASONING,
51
+ };
52
+ }
53
+
54
+ switch (daemonState) {
55
+ case DaemonState.RESPONDING:
56
+ return {
57
+ spinnerName: "dots2",
58
+ label: `RESPONDING${elapsedSuffix}`,
59
+ color: COLORS.STATUS_BORDER,
60
+ };
61
+ case DaemonState.TRANSCRIBING:
62
+ return {
63
+ spinnerName: "dots",
64
+ label: `TRANSCRIBING${elapsedSuffix}`,
65
+ color: COLORS.STATUS_BORDER,
66
+ };
67
+ case DaemonState.SPEAKING:
68
+ return {
69
+ spinnerName: "bouncingBall",
70
+ label: `SPEAKING${elapsedSuffix}`,
71
+ color: COLORS.STATUS_BORDER,
72
+ };
73
+ default:
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export function InlineStatusIndicator({
79
+ daemonState,
80
+ isToolCalling,
81
+ isReasoning,
82
+ responseElapsedMs,
83
+ }: InlineStatusProps) {
84
+ const elapsedSuffix = buildElapsedSuffix(responseElapsedMs);
85
+ const config = getInlineStatusConfig({
86
+ daemonState,
87
+ isToolCalling,
88
+ isReasoning,
89
+ elapsedSuffix,
90
+ });
91
+
92
+ if (!config) {
93
+ return null;
94
+ }
95
+
96
+ const { spinnerName, label, color } = config;
97
+
98
+ return (
99
+ <box flexDirection="row" alignItems="center" marginTop={1} marginBottom={1} paddingLeft={2}>
100
+ <spinner name={spinnerName} color={color} />
101
+ <text marginLeft={1}>
102
+ <span fg={color}>{label}</span>
103
+ </text>
104
+ </box>
105
+ );
106
+ }