ubume 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 (182) hide show
  1. package/README.md +88 -0
  2. package/bin/codexa-local-harness-bridge.js +3 -0
  3. package/bin/codexa.js +28 -0
  4. package/bin/ubume-local-harness-bridge.js +325 -0
  5. package/bin/ubume.js +398 -0
  6. package/package.json +66 -0
  7. package/src/app.tsx +5759 -0
  8. package/src/commands/handler.ts +889 -0
  9. package/src/config/appVersion.ts +69 -0
  10. package/src/config/buildInfo.ts +3 -0
  11. package/src/config/launchArgs.ts +196 -0
  12. package/src/config/layeredConfig.ts +853 -0
  13. package/src/config/legacyEnv.ts +16 -0
  14. package/src/config/persistence.ts +377 -0
  15. package/src/config/runtimeConfig.ts +558 -0
  16. package/src/config/settings.ts +405 -0
  17. package/src/config/toml-serialize.ts +109 -0
  18. package/src/config/trustStore.ts +84 -0
  19. package/src/config/updateCheckCache.ts +85 -0
  20. package/src/core/README.md +55 -0
  21. package/src/core/agent/loop.ts +464 -0
  22. package/src/core/agent/protocol.ts +345 -0
  23. package/src/core/agent/tools.ts +423 -0
  24. package/src/core/auth/codexAuth.ts +359 -0
  25. package/src/core/codex/codexExecArgs.ts +166 -0
  26. package/src/core/codex/codexLaunch.ts +163 -0
  27. package/src/core/codex/codexPrompt.ts +429 -0
  28. package/src/core/debug/inputDebug.ts +51 -0
  29. package/src/core/debug/localStreamDebug.ts +50 -0
  30. package/src/core/debug/modelStateDebug.ts +35 -0
  31. package/src/core/executables/antigravityExecutable.ts +48 -0
  32. package/src/core/executables/claudeExecutable.ts +63 -0
  33. package/src/core/executables/codexExecutable.ts +160 -0
  34. package/src/core/executables/executableResolver.ts +164 -0
  35. package/src/core/executables/geminiExecutable.ts +78 -0
  36. package/src/core/models/codexCapabilities.ts +97 -0
  37. package/src/core/models/codexModelCapabilities.ts +624 -0
  38. package/src/core/models/codexModelsCacheSeed.ts +153 -0
  39. package/src/core/models/modelSpecs.ts +303 -0
  40. package/src/core/models/providerModelCache.ts +94 -0
  41. package/src/core/perf/profiler.ts +125 -0
  42. package/src/core/perf/renderDebug.ts +398 -0
  43. package/src/core/process/CommandRunner.ts +280 -0
  44. package/src/core/process/processValidation.ts +111 -0
  45. package/src/core/providerLauncher/launcher.ts +220 -0
  46. package/src/core/providerLauncher/registry.ts +354 -0
  47. package/src/core/providerLauncher/types.ts +95 -0
  48. package/src/core/providerLauncher/workspaceConfig.ts +487 -0
  49. package/src/core/providerRuntime/anthropic.ts +580 -0
  50. package/src/core/providerRuntime/antigravity.ts +500 -0
  51. package/src/core/providerRuntime/capabilityProfile.ts +383 -0
  52. package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
  53. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  54. package/src/core/providerRuntime/codexaCupy.ts +97 -0
  55. package/src/core/providerRuntime/codexaNative.ts +425 -0
  56. package/src/core/providerRuntime/contextMetadata.ts +397 -0
  57. package/src/core/providerRuntime/gemini.ts +789 -0
  58. package/src/core/providerRuntime/lmstudio.ts +118 -0
  59. package/src/core/providerRuntime/local.ts +770 -0
  60. package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
  61. package/src/core/providerRuntime/localOutputBudget.ts +17 -0
  62. package/src/core/providerRuntime/mistralVibe.ts +667 -0
  63. package/src/core/providerRuntime/models.ts +175 -0
  64. package/src/core/providerRuntime/reasoning.ts +20 -0
  65. package/src/core/providerRuntime/registry.ts +286 -0
  66. package/src/core/providerRuntime/types.ts +142 -0
  67. package/src/core/providerRuntime/unsloth.ts +216 -0
  68. package/src/core/providers/codexJsonStream.ts +305 -0
  69. package/src/core/providers/codexSubprocess.ts +378 -0
  70. package/src/core/providers/codexTranscript.ts +695 -0
  71. package/src/core/providers/openaiNative.ts +13 -0
  72. package/src/core/providers/registry.ts +21 -0
  73. package/src/core/providers/types.ts +91 -0
  74. package/src/core/shared/attachments.ts +101 -0
  75. package/src/core/shared/cleanupFastFail.ts +67 -0
  76. package/src/core/shared/clipboard.ts +24 -0
  77. package/src/core/shared/clipboardImage.ts +111 -0
  78. package/src/core/shared/githubDiagnostics.ts +222 -0
  79. package/src/core/shared/hollowResponseFormat.ts +39 -0
  80. package/src/core/terminal/clearFrameBoundary.ts +852 -0
  81. package/src/core/terminal/frameLock.ts +110 -0
  82. package/src/core/terminal/inkRenderReset.ts +123 -0
  83. package/src/core/terminal/startupClear.ts +20 -0
  84. package/src/core/terminal/terminalCapabilities.ts +100 -0
  85. package/src/core/terminal/terminalControl.ts +169 -0
  86. package/src/core/terminal/terminalSanitize.ts +147 -0
  87. package/src/core/terminal/terminalTitle.ts +400 -0
  88. package/src/core/version/channel.ts +27 -0
  89. package/src/core/version/packageManager.ts +119 -0
  90. package/src/core/version/updateCheck.ts +203 -0
  91. package/src/core/workspace/appData.ts +107 -0
  92. package/src/core/workspace/conversationStore.ts +335 -0
  93. package/src/core/workspace/launchContext.ts +259 -0
  94. package/src/core/workspace/planStorage.ts +135 -0
  95. package/src/core/workspace/projectInstructions.ts +54 -0
  96. package/src/core/workspace/scratchDir.ts +64 -0
  97. package/src/core/workspace/workspaceActivity.ts +384 -0
  98. package/src/core/workspace/workspaceGuard.ts +377 -0
  99. package/src/core/workspace/workspaceRoot.ts +47 -0
  100. package/src/exec.ts +73 -0
  101. package/src/headless/execArgs.ts +296 -0
  102. package/src/headless/execRunner.ts +304 -0
  103. package/src/index.tsx +270 -0
  104. package/src/legacyEnvBootstrap.ts +5 -0
  105. package/src/session/appSession.ts +771 -0
  106. package/src/session/chatLifecycle.ts +994 -0
  107. package/src/session/conversation.ts +107 -0
  108. package/src/session/liveRenderScheduler.ts +214 -0
  109. package/src/session/persistedResponse.ts +93 -0
  110. package/src/session/planFlow.ts +159 -0
  111. package/src/session/planTranscript.ts +19 -0
  112. package/src/session/promptRunSchedule.ts +26 -0
  113. package/src/session/types.ts +234 -0
  114. package/src/test/runtimeTestUtils.ts +14 -0
  115. package/src/types/react-dom.d.ts +3 -0
  116. package/src/ui/chrome/ActivityBars.tsx +68 -0
  117. package/src/ui/chrome/ActivityIndicator.tsx +58 -0
  118. package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
  119. package/src/ui/chrome/AppShell.tsx +474 -0
  120. package/src/ui/chrome/BottomComposer.tsx +1135 -0
  121. package/src/ui/chrome/DashCard.tsx +82 -0
  122. package/src/ui/chrome/RunFooter.tsx +65 -0
  123. package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
  124. package/src/ui/chrome/Spinner.tsx +25 -0
  125. package/src/ui/chrome/TopHeader.tsx +439 -0
  126. package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
  127. package/src/ui/chrome/busyStatusAnimation.ts +11 -0
  128. package/src/ui/input/commandNormalize.ts +66 -0
  129. package/src/ui/input/focus.ts +73 -0
  130. package/src/ui/input/imageAttachments.ts +18 -0
  131. package/src/ui/input/inputBuffer.ts +203 -0
  132. package/src/ui/input/pastedContent.ts +75 -0
  133. package/src/ui/input/rawArrowKeys.ts +28 -0
  134. package/src/ui/input/slashCommands.ts +43 -0
  135. package/src/ui/input/useStdinRawModeLease.ts +23 -0
  136. package/src/ui/layout.ts +560 -0
  137. package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
  138. package/src/ui/panels/AuthPanel.tsx +149 -0
  139. package/src/ui/panels/BackendPicker.tsx +28 -0
  140. package/src/ui/panels/ModePicker.tsx +31 -0
  141. package/src/ui/panels/ModelPicker.tsx +31 -0
  142. package/src/ui/panels/ModelPickerScreen.tsx +761 -0
  143. package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
  144. package/src/ui/panels/Panel.tsx +51 -0
  145. package/src/ui/panels/PermissionsPanel.tsx +78 -0
  146. package/src/ui/panels/PlanActionPicker.tsx +187 -0
  147. package/src/ui/panels/ProviderPicker.tsx +753 -0
  148. package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
  149. package/src/ui/panels/ReasoningPicker.tsx +46 -0
  150. package/src/ui/panels/ResumePicker.tsx +90 -0
  151. package/src/ui/panels/SelectionPanel.tsx +138 -0
  152. package/src/ui/panels/SettingsPanel.tsx +156 -0
  153. package/src/ui/panels/TextEntryPanel.tsx +139 -0
  154. package/src/ui/panels/ThemePicker.tsx +32 -0
  155. package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
  156. package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
  157. package/src/ui/panels/responsivePickerViewport.ts +64 -0
  158. package/src/ui/render/Markdown.tsx +331 -0
  159. package/src/ui/render/diffRenderer.ts +116 -0
  160. package/src/ui/render/logoVariants.ts +113 -0
  161. package/src/ui/render/modeDisplay.ts +52 -0
  162. package/src/ui/render/outputPipeline.ts +64 -0
  163. package/src/ui/render/runtimeDisplay.ts +128 -0
  164. package/src/ui/render/terminalAnswerFormat.ts +128 -0
  165. package/src/ui/render/textLayout.ts +392 -0
  166. package/src/ui/theme.tsx +274 -0
  167. package/src/ui/themeFlow.ts +41 -0
  168. package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
  169. package/src/ui/timeline/AgentBlock.tsx +130 -0
  170. package/src/ui/timeline/StaticIntroItem.tsx +54 -0
  171. package/src/ui/timeline/ThinkingBlock.tsx +100 -0
  172. package/src/ui/timeline/Timeline.tsx +1410 -0
  173. package/src/ui/timeline/TranscriptShell.tsx +302 -0
  174. package/src/ui/timeline/TurnGroup.tsx +673 -0
  175. package/src/ui/timeline/layoutListWindow.ts +145 -0
  176. package/src/ui/timeline/liveViewportWindow.ts +68 -0
  177. package/src/ui/timeline/progressEntries.ts +156 -0
  178. package/src/ui/timeline/runActivityView.ts +37 -0
  179. package/src/ui/timeline/staticTranscriptCache.ts +174 -0
  180. package/src/ui/timeline/streamCoalesce.ts +53 -0
  181. package/src/ui/timeline/timelineMeasure.ts +3273 -0
  182. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,52 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { FOCUS_IDS } from "../input/focus.js";
4
+ import { SelectionPanel } from "./SelectionPanel.js";
5
+
6
+ interface ProviderSetupPromptProps {
7
+ providerLabel: string;
8
+ executable: string;
9
+ installCommand: string | null;
10
+ setupCommand: string;
11
+ onInstall: () => void;
12
+ onCancel: () => void;
13
+ }
14
+
15
+ export function ProviderSetupPrompt({
16
+ providerLabel,
17
+ executable,
18
+ installCommand,
19
+ setupCommand,
20
+ onInstall,
21
+ onCancel,
22
+ }: ProviderSetupPromptProps) {
23
+ return (
24
+ <Box flexDirection="column" width="100%">
25
+ <Box flexDirection="column" paddingX={1} marginBottom={1}>
26
+ <Text color="yellow" bold>{providerLabel} is not ready</Text>
27
+ <Text color="gray">Ubume could not find `{executable}` on this computer.</Text>
28
+ <Text color="gray">
29
+ {installCommand
30
+ ? `Install it and open ${providerLabel}'s sign-in/setup flow?`
31
+ : `Install ${providerLabel} manually, then retry the launch?`}
32
+ </Text>
33
+ <Text color="gray">Credentials stay in the provider’s own CLI and are not stored by Ubume.</Text>
34
+ {installCommand && <Text color="gray">Setup command: {setupCommand}</Text>}
35
+ </Box>
36
+ <SelectionPanel
37
+ focusId={FOCUS_IDS.providerSetup}
38
+ title={`Set up ${providerLabel}`}
39
+ subtitle="Enter starts installation and setup · Esc cancels"
40
+ items={installCommand ? [
41
+ { label: `Install ${providerLabel} and sign in`, value: "install" },
42
+ { label: "Cancel", value: "cancel" },
43
+ ] : [
44
+ { label: "I installed it — retry launch", value: "retry" },
45
+ { label: "Cancel", value: "cancel" },
46
+ ]}
47
+ onSelect={(value) => value === "install" || value === "retry" ? onInstall() : onCancel()}
48
+ onCancel={onCancel}
49
+ />
50
+ </Box>
51
+ );
52
+ }
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import { type ReasoningEffortCapability } from "../../core/models/codexModelCapabilities.js";
3
+ import { formatReasoningLabel } from "../../config/settings.js";
4
+ import { FOCUS_IDS } from "../input/focus.js";
5
+ import { SelectionPanel } from "./SelectionPanel.js";
6
+
7
+ interface ReasoningPickerProps {
8
+ currentReasoning: string;
9
+ currentModel: string;
10
+ reasoningLevels: readonly ReasoningEffortCapability[];
11
+ defaultReasoning: string | null;
12
+ sourceLabel?: string | null;
13
+ onSelect: (reasoning: string) => void;
14
+ onCancel: () => void;
15
+ }
16
+
17
+ export function ReasoningPicker({
18
+ currentReasoning,
19
+ currentModel,
20
+ reasoningLevels,
21
+ defaultReasoning,
22
+ sourceLabel,
23
+ onSelect,
24
+ onCancel,
25
+ }: ReasoningPickerProps) {
26
+ const items = reasoningLevels.map((reasoning) => ({
27
+ label: reasoning.id === currentReasoning ? `${reasoning.label} ✓` : reasoning.label,
28
+ value: reasoning.id,
29
+ }));
30
+
31
+ const baseSubtitle = defaultReasoning
32
+ ? `Suggested for ${currentModel}: ${formatReasoningLabel(defaultReasoning)}`
33
+ : `Detected levels for ${currentModel}`;
34
+ const subtitle = sourceLabel ? `${baseSubtitle} · ${sourceLabel}` : baseSubtitle;
35
+
36
+ return (
37
+ <SelectionPanel
38
+ focusId={FOCUS_IDS.reasoningPicker}
39
+ title="Select reasoning level"
40
+ subtitle={subtitle}
41
+ items={items}
42
+ onSelect={onSelect}
43
+ onCancel={onCancel}
44
+ />
45
+ );
46
+ }
@@ -0,0 +1,90 @@
1
+ import React, { useEffect, useMemo, useState } from "react";
2
+ import { Box, Text, useFocus, useInput } from "ink";
3
+ import type { ConversationListEntry } from "../../core/workspace/conversationStore.js";
4
+ import { clampVisualText, usePanelLayout } from "../layout.js";
5
+ import { useTheme } from "../theme.js";
6
+ import { calculateResponsivePickerViewport } from "./responsivePickerViewport.js";
7
+
8
+ interface ResumePickerProps {
9
+ conversations: readonly ConversationListEntry[];
10
+ onSelect: (id: string) => void;
11
+ onCancel: () => void;
12
+ }
13
+
14
+ function activityLabel(value: string): string {
15
+ const date = new Date(value);
16
+ if (Number.isNaN(date.getTime())) return "Unknown time";
17
+ const now = new Date();
18
+ const sameDay = date.toDateString() === now.toDateString();
19
+ if (sameDay) return `Today, ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
20
+ const yesterday = new Date(now);
21
+ yesterday.setDate(now.getDate() - 1);
22
+ if (date.toDateString() === yesterday.toDateString()) return `Yesterday, ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
23
+ return date.toLocaleDateString([], { month: "short", day: "numeric" });
24
+ }
25
+
26
+ function providerLabel(providerId: string | null): string {
27
+ switch (providerId) {
28
+ case "local": return "Local";
29
+ case "anthropic": return "Anthropic";
30
+ case "google": return "Google";
31
+ case "mistral": return "Mistral";
32
+ case "codexa-native": return "ubume-PyTorch";
33
+ case "codexa-cupy": return "CuPy";
34
+ case "antigravity": return "Antigravity";
35
+ case "openai": return "OpenAI";
36
+ default: return "Unavailable";
37
+ }
38
+ }
39
+
40
+ export function ResumePicker({ conversations, onSelect, onCancel }: ResumePickerProps) {
41
+ const theme = useTheme();
42
+ const panelLayout = usePanelLayout();
43
+ const { isFocused } = useFocus({ id: "resume-picker", autoFocus: true });
44
+ const [selectedIndex, setSelectedIndex] = useState(0);
45
+ const [scrollOffset, setScrollOffset] = useState(0);
46
+ const availableRows = Math.max(1, panelLayout?.availableRows ?? 12);
47
+ const width = Math.max(1, (panelLayout?.availableCols ?? 80) - 2);
48
+ const viewport = useMemo(() => calculateResponsivePickerViewport({
49
+ itemCount: conversations.length,
50
+ selectedIndex,
51
+ availableRows,
52
+ chromeRows: availableRows >= 3 ? 2 : 0,
53
+ scrollOffset,
54
+ }), [availableRows, conversations.length, scrollOffset, selectedIndex]);
55
+
56
+ useEffect(() => setSelectedIndex((index) => Math.max(0, Math.min(index, conversations.length - 1))), [conversations.length]);
57
+ useEffect(() => setScrollOffset(viewport.start), [viewport.start]);
58
+
59
+ const move = (index: number) => setSelectedIndex(Math.max(0, Math.min(index, conversations.length - 1)));
60
+ useInput((input, key) => {
61
+ if (key.escape) return onCancel();
62
+ if (key.return) {
63
+ const selected = conversations[selectedIndex];
64
+ if (selected) onSelect(selected.id);
65
+ return;
66
+ }
67
+ if (key.upArrow || input === "k") return move(selectedIndex - 1);
68
+ if (key.downArrow || input === "j") return move(selectedIndex + 1);
69
+ if (key.home) return move(0);
70
+ if (key.end) return move(conversations.length - 1);
71
+ if (key.pageUp) return move(selectedIndex - Math.max(1, viewport.capacity));
72
+ if (key.pageDown) return move(selectedIndex + Math.max(1, viewport.capacity));
73
+ }, { isActive: isFocused });
74
+
75
+ return (
76
+ <Box borderStyle={availableRows >= 3 ? "round" : undefined} borderColor={theme.borderFocused} paddingX={availableRows >= 3 ? 1 : 0} width="100%" flexDirection="column" overflow="hidden">
77
+ {availableRows >= 3 && <Text color={theme.accent} bold>Resume Conversation{conversations.length > 0 ? ` · ${selectedIndex + 1}/${conversations.length}` : ""}</Text>}
78
+ {conversations.length === 0 && <Text color={theme.textMuted}>No previous conversations found.</Text>}
79
+ {conversations.slice(viewport.start, viewport.end).map((conversation, offset) => {
80
+ const index = viewport.start + offset;
81
+ const selected = index === selectedIndex;
82
+ const metadata = `${activityLabel(conversation.updatedAt)} · ${conversation.modelId} · ${providerLabel(conversation.providerId)} · ${conversation.messageCount} messages`;
83
+ return <Text key={conversation.id} color={selected ? theme.accent : theme.textMuted} bold={selected} wrap="truncate">
84
+ {selected ? "> " : " "}{clampVisualText(`${conversation.title} — ${metadata}`, Math.max(1, width - 2))}
85
+ </Text>;
86
+ })}
87
+ {availableRows >= 3 && <Text color={theme.textDim}>↑↓ navigate · Enter resume · Esc cancel</Text>}
88
+ </Box>
89
+ );
90
+ }
@@ -0,0 +1,138 @@
1
+ import React, { useEffect, useMemo, useState } from "react";
2
+ import { Box, Text, useFocus, useInput } from "ink";
3
+ import { clampVisualText, usePanelLayout } from "../layout.js";
4
+ import { useTheme } from "../theme.js";
5
+ import { calculateResponsivePickerViewport } from "./responsivePickerViewport.js";
6
+
7
+ interface SelectionPanelProps {
8
+ focusId: string;
9
+ title: string;
10
+ subtitle: string;
11
+ items: Array<{ label: string; value: string }>;
12
+ limit?: number;
13
+ initialValue?: string;
14
+ onSelect: (value: string) => void;
15
+ onHighlight?: (value: string) => void;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ function clampIndex(index: number, count: number): number {
20
+ return count <= 0 ? 0 : Math.max(0, Math.min(count - 1, index));
21
+ }
22
+
23
+ export function SelectionPanel({
24
+ focusId,
25
+ title,
26
+ subtitle,
27
+ items,
28
+ initialValue,
29
+ onSelect,
30
+ onHighlight,
31
+ onCancel,
32
+ }: SelectionPanelProps) {
33
+ const theme = useTheme();
34
+ const panelLayout = usePanelLayout();
35
+ const { isFocused } = useFocus({ id: focusId, autoFocus: true });
36
+ const [selectedIndex, setSelectedIndex] = useState(() => {
37
+ const initialIndex = initialValue ? items.findIndex((item) => item.value === initialValue) : -1;
38
+ return initialIndex >= 0 ? initialIndex : 0;
39
+ });
40
+ const [scrollOffset, setScrollOffset] = useState(0);
41
+ const availableRows = Math.max(1, panelLayout?.availableRows ?? 12);
42
+ const innerWidth = Math.max(1, (panelLayout?.availableCols ?? 80) - 2);
43
+
44
+ const viewport = useMemo(() => calculateResponsivePickerViewport({
45
+ itemCount: items.length,
46
+ selectedIndex,
47
+ availableRows,
48
+ chromeRows: availableRows >= 3 ? 2 : 0,
49
+ scrollOffset,
50
+ }), [availableRows, items.length, scrollOffset, selectedIndex]);
51
+
52
+ useEffect(() => {
53
+ setSelectedIndex((current) => clampIndex(current, items.length));
54
+ }, [items.length]);
55
+
56
+ useEffect(() => {
57
+ setScrollOffset(viewport.start);
58
+ }, [viewport.start]);
59
+
60
+ const highlightIndex = (index: number) => {
61
+ const nextIndex = clampIndex(index, items.length);
62
+ setSelectedIndex(nextIndex);
63
+ const item = items[nextIndex];
64
+ if (item) onHighlight?.(item.value);
65
+ };
66
+
67
+ useInput((input, key) => {
68
+ if (key.escape) {
69
+ onCancel();
70
+ return;
71
+ }
72
+ if (key.return) {
73
+ const item = items[viewport.selectedIndex];
74
+ if (item) onSelect(item.value);
75
+ return;
76
+ }
77
+ if (key.home) {
78
+ highlightIndex(0);
79
+ return;
80
+ }
81
+ if (key.end) {
82
+ highlightIndex(items.length - 1);
83
+ return;
84
+ }
85
+ if (key.pageUp) {
86
+ highlightIndex(selectedIndex - Math.max(1, viewport.capacity));
87
+ return;
88
+ }
89
+ if (key.pageDown) {
90
+ highlightIndex(selectedIndex + Math.max(1, viewport.capacity));
91
+ return;
92
+ }
93
+ if (key.upArrow || input === "k") {
94
+ highlightIndex(selectedIndex - 1);
95
+ return;
96
+ }
97
+ if (key.downArrow || input === "j") {
98
+ highlightIndex(selectedIndex + 1);
99
+ }
100
+ }, { isActive: isFocused });
101
+
102
+ const visibleItems = items.slice(viewport.start, viewport.end);
103
+ const titleText = viewport.hasOverflow
104
+ ? `${title} · ${viewport.selectedIndex + 1}/${items.length}`
105
+ : title;
106
+ const heading = innerWidth >= 70 ? `${titleText} ${subtitle}` : titleText;
107
+
108
+ return (
109
+ <Box
110
+ borderStyle={availableRows >= 3 ? "round" : undefined}
111
+ borderColor={theme.borderFocused}
112
+ paddingX={availableRows >= 3 ? 1 : 0}
113
+ width="100%"
114
+ flexDirection="column"
115
+ overflow="hidden"
116
+ >
117
+ {availableRows >= 3 && (
118
+ <Text color={theme.accent} bold wrap="truncate">
119
+ {clampVisualText(heading, innerWidth)}
120
+ </Text>
121
+ )}
122
+ {visibleItems.map((item, index) => {
123
+ const actualIndex = viewport.start + index;
124
+ const selected = actualIndex === viewport.selectedIndex;
125
+ return (
126
+ <Box key={item.value} width="100%" overflow="hidden">
127
+ <Text color={selected ? theme.accent : theme.textMuted} bold={selected} wrap="truncate">
128
+ {selected ? "> " : " "}{clampVisualText(item.label, Math.max(1, innerWidth - 2))}
129
+ </Text>
130
+ </Box>
131
+ );
132
+ })}
133
+ {availableRows >= 3 && (
134
+ <Text color={theme.textDim}>↑↓ move · Enter confirm · Esc close</Text>
135
+ )}
136
+ </Box>
137
+ );
138
+ }
@@ -0,0 +1,156 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { Box, Text, useFocus, useInput } from "ink";
3
+ import type { SettingDefinition } from "../../config/settings.js";
4
+ import type { FocusTargetId } from "../input/focus.js";
5
+ import { useTheme } from "../theme.js";
6
+
7
+ export interface SettingsPanelProps<TKey extends string> {
8
+ focusId: FocusTargetId;
9
+ title?: string;
10
+ settings: readonly SettingDefinition<TKey, string>[];
11
+ values: Record<TKey, string>;
12
+ onSave: (values: Record<TKey, string>) => void;
13
+ onCancel: () => void;
14
+ }
15
+
16
+ function cycleOption<TKey extends string>(
17
+ definition: SettingDefinition<TKey, string>,
18
+ currentValue: string,
19
+ direction: -1 | 1,
20
+ ): string {
21
+ const currentIndex = definition.options.findIndex((option) => option.value === currentValue);
22
+ const safeIndex = currentIndex >= 0 ? currentIndex : 0;
23
+ const nextIndex = (safeIndex + direction + definition.options.length) % definition.options.length;
24
+ return definition.options[nextIndex]?.value ?? currentValue;
25
+ }
26
+
27
+ export function SettingsPanel<TKey extends string>({
28
+ focusId,
29
+ title = "Settings",
30
+ settings,
31
+ values,
32
+ onSave,
33
+ onCancel,
34
+ }: SettingsPanelProps<TKey>) {
35
+ const theme = useTheme();
36
+ const { isFocused } = useFocus({ id: focusId, autoFocus: true });
37
+ const [selectedIndex, setSelectedIndex] = useState(0);
38
+ const [draftValues, setDraftValues] = useState<Record<TKey, string>>(() => ({ ...values }));
39
+
40
+ const activeSetting = settings[selectedIndex] ?? settings[0];
41
+ const subtitle = useMemo(() => {
42
+ const instructions = "↑↓ setting ←→ option Esc to close · Enter to confirm";
43
+ if (!activeSetting?.description) {
44
+ return instructions;
45
+ }
46
+
47
+ return `${instructions}\n${activeSetting.description}`;
48
+ }, [activeSetting]);
49
+
50
+ useInput((input, key) => {
51
+ if (key.escape) {
52
+ onCancel();
53
+ return;
54
+ }
55
+
56
+ if (key.return) {
57
+ onSave(draftValues);
58
+ return;
59
+ }
60
+
61
+ if (key.upArrow) {
62
+ setSelectedIndex((current) => Math.max(0, current - 1));
63
+ return;
64
+ }
65
+
66
+ if (key.downArrow) {
67
+ setSelectedIndex((current) => Math.min(settings.length - 1, current + 1));
68
+ return;
69
+ }
70
+
71
+ if (key.leftArrow || key.rightArrow) {
72
+ const direction: -1 | 1 = key.leftArrow ? -1 : 1;
73
+ const definition = settings[selectedIndex];
74
+ if (!definition) {
75
+ return;
76
+ }
77
+
78
+ setDraftValues((current) => ({
79
+ ...current,
80
+ [definition.key]: cycleOption(definition, current[definition.key], direction),
81
+ }));
82
+ return;
83
+ }
84
+
85
+ if (!input || key.ctrl || key.meta) {
86
+ return;
87
+ }
88
+ }, { isActive: isFocused });
89
+
90
+ return (
91
+ <Box flexDirection="column" width="100%" marginTop={1}>
92
+ <Box
93
+ borderStyle="round"
94
+ borderColor={theme.border}
95
+ paddingX={2}
96
+ paddingY={1}
97
+ width="100%"
98
+ flexDirection="column"
99
+ >
100
+ <Box>
101
+ <Text color={theme.accent} bold>{title} </Text>
102
+ <Text color={theme.textMuted}>{subtitle.split("\n")[0]}</Text>
103
+ </Box>
104
+ {activeSetting?.description && (
105
+ <Box marginTop={1}>
106
+ <Text color={theme.textDim}>{activeSetting.description}</Text>
107
+ </Box>
108
+ )}
109
+ </Box>
110
+
111
+ <Box
112
+ borderStyle="round"
113
+ borderColor={theme.borderFocused}
114
+ paddingX={2}
115
+ paddingY={1}
116
+ marginTop={1}
117
+ width="100%"
118
+ flexDirection="column"
119
+ >
120
+ {settings.map((setting, index) => {
121
+ const isSelectedRow = index === selectedIndex;
122
+ const currentValue = draftValues[setting.key];
123
+
124
+ return (
125
+ <Box key={setting.key} flexDirection="row" overflow="hidden">
126
+ <Box width={3} flexShrink={0}>
127
+ <Text color={isSelectedRow ? theme.accent : theme.textDim}>{isSelectedRow ? "▸ " : " "}</Text>
128
+ </Box>
129
+ <Box width={20} flexShrink={0} overflow="hidden">
130
+ <Text color={isSelectedRow ? theme.text : theme.textMuted} bold={isSelectedRow}>
131
+ {setting.label}
132
+ </Text>
133
+ </Box>
134
+ <Box flexDirection="row" flexWrap="nowrap" flexGrow={1} flexShrink={1} overflow="hidden">
135
+ {setting.options.map((option, optionIndex) => {
136
+ const isActiveOption = option.value === currentValue;
137
+ const optionText = isActiveOption ? `[${option.label}]` : option.label;
138
+ return (
139
+ <Text
140
+ key={option.value}
141
+ color={isActiveOption ? (isSelectedRow ? theme.accent : theme.text) : theme.textDim}
142
+ bold={isActiveOption}
143
+ >
144
+ {optionIndex > 0 ? " " : ""}
145
+ {optionText}
146
+ </Text>
147
+ );
148
+ })}
149
+ </Box>
150
+ </Box>
151
+ );
152
+ })}
153
+ </Box>
154
+ </Box>
155
+ );
156
+ }
@@ -0,0 +1,139 @@
1
+ import React, { useMemo, useState } from "react";
2
+ import { Box, Text, useFocus, useInput } from "ink";
3
+ import type { FocusTargetId } from "../input/focus.js";
4
+ import { useTheme } from "../theme.js";
5
+ import { stripMouseEscapes } from "../input/inputBuffer.js";
6
+
7
+ interface TextEntryPanelProps {
8
+ focusId: FocusTargetId;
9
+ title: string;
10
+ subtitle: string;
11
+ placeholder?: string;
12
+ initialValue?: string;
13
+ inputLabel?: string;
14
+ footerHint?: string;
15
+ onSubmit: (value: string) => void;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ function insertAt(value: string, index: number, text: string): string {
20
+ return value.slice(0, index) + text + value.slice(index);
21
+ }
22
+
23
+ export function TextEntryPanel({
24
+ focusId,
25
+ title,
26
+ subtitle,
27
+ placeholder = "",
28
+ initialValue = "",
29
+ inputLabel = "Input",
30
+ footerHint = "Esc to close · Enter to confirm",
31
+ onSubmit,
32
+ onCancel,
33
+ }: TextEntryPanelProps) {
34
+ const theme = useTheme();
35
+ const { isFocused } = useFocus({ id: focusId, autoFocus: true });
36
+ const [value, setValue] = useState(initialValue);
37
+ const [cursor, setCursor] = useState(initialValue.length);
38
+
39
+ useInput((input, key) => {
40
+ if (key.escape) {
41
+ onCancel();
42
+ return;
43
+ }
44
+
45
+ if (key.return) {
46
+ onSubmit(value.trim());
47
+ return;
48
+ }
49
+
50
+ if (key.leftArrow) {
51
+ setCursor((current) => Math.max(0, current - 1));
52
+ return;
53
+ }
54
+
55
+ if (key.rightArrow) {
56
+ setCursor((current) => Math.min(value.length, current + 1));
57
+ return;
58
+ }
59
+
60
+ if (key.backspace || key.delete) {
61
+ if (cursor === 0) return;
62
+ setValue((current) => current.slice(0, cursor - 1) + current.slice(cursor));
63
+ setCursor((current) => Math.max(0, current - 1));
64
+ return;
65
+ }
66
+
67
+ if (!input || key.ctrl || key.meta) {
68
+ return;
69
+ }
70
+
71
+ const filtered = stripMouseEscapes(input);
72
+ if (!filtered) return;
73
+ setValue((current) => insertAt(current, cursor, filtered));
74
+ setCursor((current) => current + filtered.length);
75
+ }, { isActive: isFocused });
76
+
77
+ const display = useMemo(() => {
78
+ if (!value) {
79
+ return {
80
+ before: "",
81
+ current: placeholder || " ",
82
+ after: "",
83
+ isPlaceholder: true,
84
+ };
85
+ }
86
+
87
+ const safeCursor = Math.max(0, Math.min(cursor, value.length));
88
+ return {
89
+ before: value.slice(0, safeCursor),
90
+ current: value[safeCursor] ?? " ",
91
+ after: value.slice(safeCursor + (safeCursor < value.length ? 1 : 0)),
92
+ isPlaceholder: false,
93
+ };
94
+ }, [cursor, placeholder, value]);
95
+
96
+ return (
97
+ <Box flexDirection="column" width="100%" marginTop={1}>
98
+ <Box
99
+ borderStyle="round"
100
+ borderColor={theme.border}
101
+ paddingX={2}
102
+ paddingY={1}
103
+ width="100%"
104
+ >
105
+ <Text color={theme.accent} bold>{title} </Text>
106
+ <Text color={theme.textMuted}>{subtitle}</Text>
107
+ </Box>
108
+
109
+ <Box
110
+ borderStyle="round"
111
+ borderColor={theme.borderFocused}
112
+ paddingX={2}
113
+ paddingY={1}
114
+ marginTop={1}
115
+ width="100%"
116
+ flexDirection="column"
117
+ >
118
+ <Box>
119
+ <Text color={theme.text}>{inputLabel}: </Text>
120
+ <Text color={display.isPlaceholder ? theme.textDim : theme.text}>{display.before}</Text>
121
+ <Text
122
+ backgroundColor={theme.text}
123
+ color={theme.surface}
124
+ >
125
+ {display.current}
126
+ </Text>
127
+ <Text color={display.isPlaceholder ? theme.textDim : theme.text}>{display.after}</Text>
128
+ </Box>
129
+ <Box marginTop={1}>
130
+ <Text color={theme.textDim}>{footerHint}</Text>
131
+ </Box>
132
+ </Box>
133
+ </Box>
134
+ );
135
+ }
136
+
137
+ export function measureTextEntryPanelRows(): number {
138
+ return 13;
139
+ }
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+ import { AVAILABLE_THEMES, formatThemeLabel } from "../../config/settings.js";
3
+ import { FOCUS_IDS } from "../input/focus.js";
4
+ import { SelectionPanel } from "./SelectionPanel.js";
5
+
6
+ interface ThemePickerProps {
7
+ currentTheme: string;
8
+ onSelect: (themeId: string) => void;
9
+ onHighlight?: (themeId: string) => void;
10
+ onCancel: () => void;
11
+ }
12
+
13
+ export function ThemePicker({ currentTheme, onSelect, onHighlight, onCancel }: ThemePickerProps) {
14
+ const items = AVAILABLE_THEMES.map((theme) => ({
15
+ label: theme.id === currentTheme ? `${formatThemeLabel(theme.id)} ✓` : formatThemeLabel(theme.id),
16
+ value: theme.id,
17
+ }));
18
+
19
+ return (
20
+ <SelectionPanel
21
+ focusId={FOCUS_IDS.themePicker}
22
+ title="Select visual theme"
23
+ subtitle="Use arrow keys and Enter to switch. Esc closes the panel."
24
+ items={items}
25
+ limit={8}
26
+ initialValue={currentTheme}
27
+ onSelect={onSelect}
28
+ onHighlight={onHighlight}
29
+ onCancel={onCancel}
30
+ />
31
+ );
32
+ }