@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,297 @@
1
+ import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core";
2
+ import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import type { SessionInfo } from "../types";
5
+ import { useMenuKeyboard } from "../hooks/use-menu-keyboard";
6
+ import { COLORS } from "../ui/constants";
7
+
8
+ export interface SessionMenuItem extends SessionInfo {
9
+ isNew?: boolean;
10
+ }
11
+
12
+ interface SessionMenuProps {
13
+ items: SessionMenuItem[];
14
+ currentSessionId: string | null;
15
+ onClose: () => void;
16
+ onSelect: (index: number) => void;
17
+ onDelete: (index: number) => void;
18
+ }
19
+
20
+ const SESSION_ITEM_HEIGHT = 2;
21
+ const MAX_SCROLLBOX_HEIGHT = 20;
22
+
23
+ function formatTimestamp(value: string): string {
24
+ if (!value) return "";
25
+ return value.replace("T", " ").slice(0, 16);
26
+ }
27
+
28
+ export function SessionMenu({ items, currentSessionId, onClose, onSelect, onDelete }: SessionMenuProps) {
29
+ const [searchQuery, setSearchQuery] = useState("");
30
+ const [isSearchFocused, setIsSearchFocused] = useState(false);
31
+ const searchInputRef = useRef<TextareaRenderable | null>(null);
32
+
33
+ const filteredItems = useMemo(() => {
34
+ if (!searchQuery) return items;
35
+ const lowerQuery = searchQuery.toLowerCase();
36
+ return items.filter(
37
+ (item) => item.title.toLowerCase().includes(lowerQuery) || item.id.toLowerCase().includes(lowerQuery)
38
+ );
39
+ }, [items, searchQuery]);
40
+
41
+ const handleSelect = (filteredIndex: number) => {
42
+ const item = filteredItems[filteredIndex];
43
+ if (!item) return;
44
+ const originalIndex = items.indexOf(item);
45
+ if (originalIndex >= 0) {
46
+ onSelect(originalIndex);
47
+ }
48
+ };
49
+
50
+ const handleDelete = (filteredIndex: number) => {
51
+ const item = filteredItems[filteredIndex];
52
+ if (!item || item.isNew) return;
53
+ const originalIndex = items.indexOf(item);
54
+ if (originalIndex >= 0) {
55
+ onDelete(originalIndex);
56
+ }
57
+ };
58
+
59
+ const initialIndex = useMemo(() => {
60
+ if (filteredItems.length === 0) return 0;
61
+ if (!currentSessionId) return 0;
62
+ const idx = filteredItems.findIndex((item) => item.id === currentSessionId);
63
+ return idx >= 0 ? idx : 0;
64
+ }, [filteredItems, currentSessionId]);
65
+
66
+ const { selectedIndex } = useMenuKeyboard({
67
+ itemCount: filteredItems.length,
68
+ initialIndex,
69
+ onClose,
70
+ onSelect: handleSelect,
71
+ enableViKeys: !isSearchFocused,
72
+ ignoreEscape: isSearchFocused,
73
+ });
74
+
75
+ useKeyboard((key) => {
76
+ if (key.eventType !== "press") return;
77
+
78
+ if (!isSearchFocused && (key.name === "x" || key.sequence?.toLowerCase() === "x")) {
79
+ handleDelete(selectedIndex);
80
+ key.preventDefault();
81
+ return;
82
+ }
83
+
84
+ if ((key.name === "tab" && key.shift) || (!isSearchFocused && key.name === "/")) {
85
+ setIsSearchFocused(true);
86
+ searchInputRef.current?.focus();
87
+ key.preventDefault();
88
+ }
89
+ });
90
+
91
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
92
+ const scrollboxHeight = Math.min(
93
+ MAX_SCROLLBOX_HEIGHT,
94
+ Math.max(SESSION_ITEM_HEIGHT, filteredItems.length * SESSION_ITEM_HEIGHT)
95
+ );
96
+
97
+ useEffect(() => {
98
+ const scrollbox = scrollRef.current;
99
+ if (!scrollbox) return;
100
+ const viewportHeight = scrollbox.viewport?.height ?? 0;
101
+ if (viewportHeight <= 0) return;
102
+
103
+ const maxScrollTop = Math.max(0, scrollbox.scrollHeight - viewportHeight);
104
+ const itemTop = selectedIndex * SESSION_ITEM_HEIGHT;
105
+ const itemBottom = itemTop + SESSION_ITEM_HEIGHT;
106
+ const currentTop = scrollbox.scrollTop;
107
+ const currentBottom = currentTop + viewportHeight;
108
+ let nextTop = currentTop;
109
+
110
+ if (itemTop < currentTop) {
111
+ nextTop = itemTop;
112
+ } else if (itemBottom > currentBottom) {
113
+ nextTop = itemBottom - viewportHeight;
114
+ }
115
+
116
+ nextTop = Math.max(0, Math.min(nextTop, maxScrollTop));
117
+ if (nextTop !== currentTop) {
118
+ scrollbox.scrollTop = nextTop;
119
+ }
120
+ }, [filteredItems.length, selectedIndex]);
121
+
122
+ return (
123
+ <box
124
+ position="absolute"
125
+ left={0}
126
+ top={0}
127
+ width="100%"
128
+ height="100%"
129
+ flexDirection="column"
130
+ alignItems="center"
131
+ justifyContent="center"
132
+ zIndex={100}
133
+ >
134
+ <box
135
+ flexDirection="column"
136
+ alignItems="flex-start"
137
+ backgroundColor={COLORS.MENU_BG}
138
+ borderStyle="single"
139
+ borderColor={COLORS.MENU_BORDER}
140
+ paddingLeft={2}
141
+ paddingRight={2}
142
+ paddingTop={1}
143
+ paddingBottom={1}
144
+ width="70%"
145
+ minWidth={56}
146
+ maxWidth={130}
147
+ >
148
+ <box marginBottom={1}>
149
+ <text>
150
+ <span fg={COLORS.DAEMON_LABEL}>[ SESSIONS ]</span>
151
+ </text>
152
+ </box>
153
+
154
+ <box marginBottom={1}>
155
+ <text>
156
+ <span fg={COLORS.REASONING_DIM}>
157
+ ↑/↓ j/k navigate · ENTER load · X delete · / search · ESC close
158
+ </span>
159
+ </text>
160
+ </box>
161
+
162
+ <box marginBottom={0}>
163
+ <text>
164
+ <span fg={COLORS.USER_LABEL}>— SEARCH —</span>
165
+ </text>
166
+ </box>
167
+
168
+ <box
169
+ marginBottom={1}
170
+ marginTop={0}
171
+ width="100%"
172
+ height={1}
173
+ flexDirection="row"
174
+ alignItems="center"
175
+ paddingLeft={1}
176
+ backgroundColor={isSearchFocused ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
177
+ >
178
+ <box width={2}>
179
+ <text>
180
+ <span fg={isSearchFocused ? COLORS.TYPING_PROMPT : COLORS.REASONING_DIM}>/ </span>
181
+ </text>
182
+ </box>
183
+ <box flexGrow={1} height={1}>
184
+ <textarea
185
+ ref={searchInputRef}
186
+ focused={isSearchFocused}
187
+ width="100%"
188
+ height={1}
189
+ placeholder="Type to filter... (/ or Shift+Tab)"
190
+ style={{
191
+ backgroundColor: "transparent",
192
+ focusedBackgroundColor: "transparent",
193
+ textColor: COLORS.MENU_TEXT,
194
+ focusedTextColor: COLORS.TYPING_PROMPT,
195
+ cursorColor: COLORS.TYPING_PROMPT,
196
+ }}
197
+ onContentChange={() => {
198
+ const text = searchInputRef.current?.plainText ?? "";
199
+ const cleaned = text.replace(/[\r\n]/g, "");
200
+ if (cleaned !== text) {
201
+ searchInputRef.current?.setText(cleaned);
202
+ }
203
+ setSearchQuery(cleaned);
204
+ }}
205
+ onKeyDown={(key) => {
206
+ if (key.eventType === "press") {
207
+ if (key.name === "escape") {
208
+ setIsSearchFocused(false);
209
+ key.preventDefault();
210
+ }
211
+ if (key.name === "return") {
212
+ key.preventDefault();
213
+ }
214
+ }
215
+ }}
216
+ />
217
+ </box>
218
+ </box>
219
+
220
+ <box marginBottom={0}>
221
+ <text>
222
+ <span fg={COLORS.USER_LABEL}>— SESSIONS —</span>
223
+ </text>
224
+ </box>
225
+
226
+ {filteredItems.length === 0 ? (
227
+ <box marginTop={1} paddingLeft={1}>
228
+ <text>
229
+ <span fg={COLORS.REASONING_DIM}>No sessions found</span>
230
+ </text>
231
+ </box>
232
+ ) : (
233
+ <scrollbox
234
+ ref={scrollRef}
235
+ height={scrollboxHeight}
236
+ alignSelf="flex-start"
237
+ focused={false}
238
+ scrollY={true}
239
+ scrollX={false}
240
+ style={{
241
+ rootOptions: { backgroundColor: COLORS.MENU_BG },
242
+ wrapperOptions: { backgroundColor: COLORS.MENU_BG },
243
+ viewportOptions: { backgroundColor: COLORS.MENU_BG },
244
+ contentOptions: { backgroundColor: COLORS.MENU_BG },
245
+ }}
246
+ >
247
+ <box flexDirection="column">
248
+ {filteredItems.map((item, idx) => {
249
+ const isSelected = idx === selectedIndex;
250
+ const isCurrent = !item.isNew && item.id === currentSessionId;
251
+
252
+ const labelColor = item.isNew
253
+ ? isSelected
254
+ ? COLORS.DAEMON_TEXT
255
+ : COLORS.DAEMON_TEXT
256
+ : isSelected
257
+ ? COLORS.DAEMON_LABEL
258
+ : COLORS.MENU_TEXT;
259
+
260
+ const currentIndicatorColor = COLORS.TYPING_PROMPT;
261
+ const detailColor = COLORS.REASONING_DIM;
262
+ const detail = item.isNew
263
+ ? "Start a fresh conversation"
264
+ : `Updated ${formatTimestamp(item.updatedAt)}`;
265
+
266
+ return (
267
+ <box
268
+ key={item.id}
269
+ backgroundColor={isSelected ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
270
+ paddingLeft={1}
271
+ paddingRight={1}
272
+ flexDirection="column"
273
+ >
274
+ <box>
275
+ <text>
276
+ <span fg={labelColor}>
277
+ {isSelected ? "▶ " : " "}
278
+ {item.isNew ? "+ NEW SESSION" : item.title}
279
+ </span>
280
+ {isCurrent && <span fg={currentIndicatorColor}> ●</span>}
281
+ </text>
282
+ </box>
283
+ <box marginLeft={4}>
284
+ <text>
285
+ <span fg={detailColor}>{detail}</span>
286
+ </text>
287
+ </box>
288
+ </box>
289
+ );
290
+ })}
291
+ </box>
292
+ </scrollbox>
293
+ )}
294
+ </box>
295
+ </box>
296
+ );
297
+ }
@@ -0,0 +1,291 @@
1
+ import type { KeyEvent } from "@opentui/core";
2
+ import { useKeyboard } from "@opentui/react";
3
+ import { useEffect, useState } from "react";
4
+ import type {
5
+ AppPreferences,
6
+ InteractionMode,
7
+ VoiceInteractionType,
8
+ SpeechSpeed,
9
+ ReasoningEffort,
10
+ BashApprovalLevel,
11
+ } from "../types";
12
+ import { REASONING_EFFORT_LABELS, BASH_APPROVAL_LABELS } from "../types";
13
+ import { getDaemonManager } from "../state/daemon-state";
14
+ import { handleSettingsMenuKey } from "../hooks/keyboard-handlers";
15
+ import { COLORS } from "../ui/constants";
16
+
17
+ interface SettingsMenuItem {
18
+ id: string;
19
+ label: string;
20
+ value?: string;
21
+ description?: string;
22
+ isToggle?: boolean;
23
+ isCyclic?: boolean;
24
+ isHeader?: boolean;
25
+ disabled?: boolean;
26
+ }
27
+
28
+ interface SettingsMenuProps {
29
+ interactionMode: InteractionMode;
30
+ voiceInteractionType: VoiceInteractionType;
31
+ speechSpeed: SpeechSpeed;
32
+ reasoningEffort: ReasoningEffort;
33
+ bashApprovalLevel: BashApprovalLevel;
34
+ supportsReasoning: boolean;
35
+ canEnableVoiceOutput: boolean;
36
+ showFullReasoning: boolean;
37
+ showToolOutput: boolean;
38
+ onClose: () => void;
39
+ toggleInteractionMode: () => void;
40
+ setVoiceInteractionType: (type: VoiceInteractionType) => void;
41
+ setSpeechSpeed: (speed: SpeechSpeed) => void;
42
+ setReasoningEffort: (effort: ReasoningEffort) => void;
43
+ setBashApprovalLevel: (level: BashApprovalLevel) => void;
44
+ setShowFullReasoning: (show: boolean) => void;
45
+ setShowToolOutput: (show: boolean) => void;
46
+ persistPreferences: (updates: Partial<AppPreferences>) => void;
47
+ }
48
+
49
+ export function SettingsMenu({
50
+ interactionMode,
51
+ voiceInteractionType,
52
+ speechSpeed,
53
+ reasoningEffort,
54
+ bashApprovalLevel,
55
+ supportsReasoning,
56
+ canEnableVoiceOutput,
57
+ showFullReasoning,
58
+ showToolOutput,
59
+ onClose,
60
+ toggleInteractionMode,
61
+ setVoiceInteractionType,
62
+ setSpeechSpeed,
63
+ setReasoningEffort,
64
+ setBashApprovalLevel,
65
+ setShowFullReasoning,
66
+ setShowToolOutput,
67
+ persistPreferences,
68
+ }: SettingsMenuProps) {
69
+ const [selectedIdx, setSelectedIdx] = useState(0);
70
+ const manager = getDaemonManager();
71
+ const interactionModeLocked = !canEnableVoiceOutput && interactionMode === "text";
72
+ const interactionModeDescription = interactionModeLocked
73
+ ? "[LOCKED] OpenAI key required for voice output"
74
+ : interactionMode === "voice"
75
+ ? "Conversational responses and speech output"
76
+ : "Markdown responses for terminal";
77
+
78
+ const items: SettingsMenuItem[] = [
79
+ {
80
+ id: "header-core",
81
+ label: "CORE SYSTEMS",
82
+ isHeader: true,
83
+ },
84
+ {
85
+ id: "interaction-mode",
86
+ label: "Interaction Mode",
87
+ value: interactionMode === "voice" ? "VOICE" : "TEXT",
88
+ description: interactionModeDescription,
89
+ isToggle: true,
90
+ disabled: interactionModeLocked,
91
+ },
92
+ {
93
+ id: "voice-interaction-type",
94
+ label: "Voice Flow",
95
+ value: voiceInteractionType === "direct" ? "DIRECT" : "REVIEW",
96
+ description:
97
+ voiceInteractionType === "direct"
98
+ ? "Send transcript immediately"
99
+ : "Review/Edit trasncript before sending",
100
+ isToggle: true,
101
+ },
102
+ {
103
+ id: "reasoning-effort",
104
+ label: "Reasoning Effort",
105
+ value: supportsReasoning ? REASONING_EFFORT_LABELS[reasoningEffort] : "N/A",
106
+ description: supportsReasoning
107
+ ? "Depth of reasoning (LOW / MEDIUM / HIGH)"
108
+ : "Not supported by current model",
109
+ isCyclic: supportsReasoning,
110
+ },
111
+ {
112
+ id: "bash-approvals",
113
+ label: "Bash Approvals",
114
+ value: BASH_APPROVAL_LABELS[bashApprovalLevel],
115
+ description: "Require approval for bash commands (NONE / DANGEROUS / ALL)",
116
+ isCyclic: true,
117
+ },
118
+ ];
119
+
120
+ if (interactionMode === "voice") {
121
+ items.push(
122
+ {
123
+ id: "header-audio",
124
+ label: "AUDIO PARAMETERS",
125
+ isHeader: true,
126
+ },
127
+ {
128
+ id: "speech-speed",
129
+ label: "Speech Speed",
130
+ value: `${speechSpeed.toFixed(2)}x`,
131
+ description: "Adjust speech rate (1.0x - 2.0x)",
132
+ isCyclic: true,
133
+ }
134
+ );
135
+ }
136
+
137
+ items.push(
138
+ {
139
+ id: "header-display",
140
+ label: "DISPLAY",
141
+ isHeader: true,
142
+ },
143
+ {
144
+ id: "show-full-reasoning",
145
+ label: "Full Reasoning",
146
+ value: showFullReasoning ? "ON" : "OFF",
147
+ description: "Show full reasoning blocks (hotkey: T)",
148
+ isToggle: true,
149
+ },
150
+ {
151
+ id: "show-tool-output",
152
+ label: "Tool Output",
153
+ value: showToolOutput ? "ON" : "OFF",
154
+ description: "Show tool output previews (hotkey: O)",
155
+ isToggle: true,
156
+ }
157
+ );
158
+
159
+ // Filter out headers for selection logic
160
+ const selectableItems = items.filter((item) => !item.isHeader);
161
+ const selectableCount = selectableItems.length;
162
+
163
+ useEffect(() => {
164
+ if (selectableCount === 0) {
165
+ setSelectedIdx(0);
166
+ return;
167
+ }
168
+ setSelectedIdx((prev) => (prev >= selectableCount ? selectableCount - 1 : prev));
169
+ }, [selectableCount]);
170
+
171
+ useKeyboard((key: KeyEvent) => {
172
+ handleSettingsMenuKey(key, {
173
+ selectedIdx,
174
+ menuItemCount: selectableCount,
175
+ interactionMode,
176
+ voiceInteractionType,
177
+ speechSpeed,
178
+ reasoningEffort,
179
+ bashApprovalLevel,
180
+ supportsReasoning,
181
+ canEnableVoiceOutput,
182
+ showFullReasoning,
183
+ showToolOutput,
184
+ setSelectedIdx,
185
+ toggleInteractionMode,
186
+ setVoiceInteractionType,
187
+ setSpeechSpeed,
188
+ setReasoningEffort,
189
+ setBashApprovalLevel,
190
+ setShowFullReasoning,
191
+ setShowToolOutput,
192
+ persistPreferences,
193
+ onClose,
194
+ manager,
195
+ });
196
+ });
197
+
198
+ return (
199
+ <box
200
+ position="absolute"
201
+ left={0}
202
+ top={0}
203
+ width="100%"
204
+ height="100%"
205
+ flexDirection="column"
206
+ alignItems="center"
207
+ justifyContent="center"
208
+ zIndex={100}
209
+ >
210
+ <box
211
+ flexDirection="column"
212
+ backgroundColor={COLORS.MENU_BG}
213
+ borderStyle="single"
214
+ borderColor={COLORS.MENU_BORDER}
215
+ paddingLeft={2}
216
+ paddingRight={2}
217
+ paddingTop={1}
218
+ paddingBottom={1}
219
+ width="60%"
220
+ minWidth={50}
221
+ maxWidth={130}
222
+ >
223
+ <box marginBottom={1}>
224
+ <text>
225
+ <span fg={COLORS.DAEMON_LABEL}>[ SETTINGS ]</span>
226
+ </text>
227
+ </box>
228
+ <box marginBottom={1}>
229
+ <text>
230
+ <span fg={COLORS.USER_LABEL}>↑/↓ or j/k to navigate, ENTER to cycle, ESC to close</span>
231
+ </text>
232
+ </box>
233
+ <box flexDirection="column">
234
+ {items.map((item) => {
235
+ if (item.isHeader) {
236
+ return (
237
+ <box key={item.id} marginTop={1} marginBottom={0}>
238
+ <text>
239
+ <span fg={COLORS.USER_LABEL}>— {item.label} —</span>
240
+ </text>
241
+ </box>
242
+ );
243
+ }
244
+
245
+ const selectableIdx = selectableItems.indexOf(item);
246
+ const isSelected = selectableIdx === selectedIdx;
247
+ const labelColor = item.disabled
248
+ ? COLORS.REASONING_DIM
249
+ : isSelected
250
+ ? COLORS.DAEMON_LABEL
251
+ : COLORS.MENU_TEXT;
252
+ const valueColor = item.disabled
253
+ ? COLORS.REASONING_DIM
254
+ : item.value === "VOICE"
255
+ ? COLORS.DAEMON_LABEL
256
+ : COLORS.DAEMON_TEXT;
257
+
258
+ return (
259
+ <box
260
+ key={item.id}
261
+ backgroundColor={isSelected ? COLORS.MENU_SELECTED_BG : COLORS.MENU_BG}
262
+ paddingLeft={1}
263
+ paddingRight={1}
264
+ flexDirection="column"
265
+ >
266
+ <box>
267
+ <text>
268
+ <span fg={labelColor}>
269
+ {isSelected ? "▶ " : " "}
270
+ {item.label}:{" "}
271
+ </span>
272
+ <span fg={valueColor}>{item.value}</span>
273
+ {item.isToggle && !item.disabled && <span fg={COLORS.USER_LABEL}></span>}
274
+ {item.isCyclic && !item.disabled && <span fg={COLORS.USER_LABEL}></span>}
275
+ </text>
276
+ </box>
277
+ {item.description && (
278
+ <box marginLeft={4}>
279
+ <text>
280
+ <span fg={COLORS.REASONING_DIM}>{item.description}</span>
281
+ </text>
282
+ </box>
283
+ )}
284
+ </box>
285
+ );
286
+ })}
287
+ </box>
288
+ </box>
289
+ </box>
290
+ );
291
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Status bar component that displays the current daemon state.
3
+ */
4
+
5
+ import { COLORS } from "../ui/constants";
6
+
7
+ const SESSION_TITLE_MAX_LENGTH = 40;
8
+
9
+ // Matches default timestamp titles: "Session 2025-12-30 20:53"
10
+ const DEFAULT_TITLE_PATTERN = /^Session \d{4}-\d{2}-\d{2} \d{2}:\d{2}$/;
11
+
12
+ function isDefaultSessionTitle(title: string): boolean {
13
+ return DEFAULT_TITLE_PATTERN.test(title);
14
+ }
15
+
16
+ function truncateWithEllipsis(text: string, maxLength: number): string {
17
+ if (text.length <= maxLength) return text;
18
+ return text.slice(0, maxLength - 1) + "…";
19
+ }
20
+
21
+ interface StatusBarProps {
22
+ statusText: string;
23
+ statusColor: string;
24
+ errorText?: string;
25
+ modelName?: string;
26
+ sessionTitle?: string;
27
+ hasInteracted?: boolean;
28
+ }
29
+
30
+ export function StatusBar({
31
+ statusText,
32
+ statusColor,
33
+ errorText,
34
+ modelName,
35
+ sessionTitle,
36
+ hasInteracted,
37
+ }: StatusBarProps) {
38
+ if (hasInteracted) {
39
+ const showTitleSpinner = sessionTitle && isDefaultSessionTitle(sessionTitle);
40
+ const displayTitle =
41
+ sessionTitle && !showTitleSpinner ? truncateWithEllipsis(sessionTitle, SESSION_TITLE_MAX_LENGTH) : null;
42
+
43
+ return (
44
+ <box
45
+ width="100%"
46
+ flexShrink={0}
47
+ flexDirection="column"
48
+ borderStyle="single"
49
+ borderColor={COLORS.STATUS_BORDER}
50
+ paddingTop={0}
51
+ paddingLeft={1}
52
+ paddingRight={1}
53
+ >
54
+ <box width="100%" flexDirection="row" justifyContent="center" alignItems="center">
55
+ <box position="absolute" left={0} top={0}>
56
+ {modelName && (
57
+ <text>
58
+ <span fg={COLORS.STATUS_BORDER}>{modelName}</span>
59
+ </text>
60
+ )}
61
+ </box>
62
+
63
+ <text>
64
+ <span fg={statusColor}>{statusText}</span>
65
+ </text>
66
+
67
+ <box position="absolute" right={0} top={0} flexDirection="row">
68
+ {showTitleSpinner && (
69
+ <>
70
+ <spinner name="dots" color={COLORS.STATUS_BORDER} />
71
+ <text marginLeft={1}>
72
+ <span fg={COLORS.STATUS_BORDER}>title generating...</span>
73
+ </text>
74
+ </>
75
+ )}
76
+ {displayTitle && (
77
+ <text>
78
+ <span fg={COLORS.STATUS_BORDER}>{displayTitle}</span>
79
+ </text>
80
+ )}
81
+ </box>
82
+ </box>
83
+
84
+ {errorText && (
85
+ <box width="100%" flexDirection="row" justifyContent="center" alignItems="center" marginTop={1}>
86
+ <text>
87
+ <span fg={COLORS.DAEMON_ERROR}>{errorText}</span>
88
+ </text>
89
+ </box>
90
+ )}
91
+ </box>
92
+ );
93
+ }
94
+
95
+ return (
96
+ <box
97
+ width="100%"
98
+ flexShrink={0}
99
+ flexDirection="column"
100
+ alignItems="center"
101
+ borderStyle="single"
102
+ borderColor={COLORS.STATUS_BORDER}
103
+ title={modelName}
104
+ titleAlignment="center"
105
+ paddingTop={0}
106
+ paddingLeft={1}
107
+ paddingRight={1}
108
+ >
109
+ {/* Main status text */}
110
+ <box width="100%" flexDirection="row" justifyContent="center" alignItems="center">
111
+ <text>
112
+ <span fg={statusColor}>{statusText}</span>
113
+ </text>
114
+ </box>
115
+
116
+ {/* Error text if present */}
117
+ {errorText && (
118
+ <box width="100%" flexDirection="row" justifyContent="center" alignItems="center" marginTop={1}>
119
+ <text>
120
+ <span fg={COLORS.DAEMON_ERROR}>{errorText}</span>
121
+ </text>
122
+ </box>
123
+ )}
124
+ </box>
125
+ );
126
+ }