@golba98/codexa 1.0.1
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/README.md +320 -0
- package/bin/codexa.js +445 -0
- package/package.json +45 -0
- package/scripts/audit-codexa-capabilities.mjs +466 -0
- package/scripts/smoke-terminal-bench.mjs +35 -0
- package/src/app.tsx +4561 -0
- package/src/appRenderStability.test.ts +131 -0
- package/src/commands/handler.test.ts +643 -0
- package/src/commands/handler.ts +875 -0
- package/src/config/launchArgs.test.ts +158 -0
- package/src/config/launchArgs.ts +186 -0
- package/src/config/layeredConfig.test.ts +143 -0
- package/src/config/layeredConfig.ts +836 -0
- package/src/config/persistence.test.ts +110 -0
- package/src/config/persistence.ts +311 -0
- package/src/config/runtimeConfig.test.ts +218 -0
- package/src/config/runtimeConfig.ts +554 -0
- package/src/config/settings.test.ts +155 -0
- package/src/config/settings.ts +401 -0
- package/src/config/toml-serialize.ts +98 -0
- package/src/config/trustStore.test.ts +29 -0
- package/src/config/trustStore.ts +68 -0
- package/src/core/attachments.test.ts +155 -0
- package/src/core/attachments.ts +71 -0
- package/src/core/auth/codexAuth.test.ts +68 -0
- package/src/core/auth/codexAuth.ts +359 -0
- package/src/core/cleanupFastFail.test.ts +76 -0
- package/src/core/cleanupFastFail.ts +67 -0
- package/src/core/clipboard.ts +24 -0
- package/src/core/codex.ts +124 -0
- package/src/core/codexExecArgs.test.ts +195 -0
- package/src/core/codexExecArgs.ts +152 -0
- package/src/core/codexLaunch.test.ts +205 -0
- package/src/core/codexLaunch.ts +162 -0
- package/src/core/codexPrompt.test.ts +252 -0
- package/src/core/codexPrompt.ts +428 -0
- package/src/core/executables/claudeExecutable.ts +63 -0
- package/src/core/executables/codexExecutable.test.ts +212 -0
- package/src/core/executables/codexExecutable.ts +159 -0
- package/src/core/executables/executableResolver.test.ts +129 -0
- package/src/core/executables/executableResolver.ts +138 -0
- package/src/core/executables/geminiExecutable.test.ts +116 -0
- package/src/core/executables/geminiExecutable.ts +78 -0
- package/src/core/executables/pathSanityScan.test.ts +47 -0
- package/src/core/githubDiagnostics.test.ts +92 -0
- package/src/core/githubDiagnostics.ts +222 -0
- package/src/core/hollowResponseFormat.test.ts +58 -0
- package/src/core/hollowResponseFormat.ts +39 -0
- package/src/core/inputDebug.ts +51 -0
- package/src/core/launchContext.test.ts +157 -0
- package/src/core/launchContext.ts +266 -0
- package/src/core/models/codexCapabilities.test.ts +45 -0
- package/src/core/models/codexCapabilities.ts +95 -0
- package/src/core/models/codexModelCapabilities.test.ts +246 -0
- package/src/core/models/codexModelCapabilities.ts +571 -0
- package/src/core/models/modelSpecs.test.ts +283 -0
- package/src/core/models/modelSpecs.ts +300 -0
- package/src/core/perf/profiler.ts +125 -0
- package/src/core/perf/renderDebug.test.ts +230 -0
- package/src/core/perf/renderDebug.ts +373 -0
- package/src/core/planStorage.test.ts +143 -0
- package/src/core/planStorage.ts +141 -0
- package/src/core/process/CommandRunner.test.ts +105 -0
- package/src/core/process/CommandRunner.ts +269 -0
- package/src/core/process/processValidation.ts +101 -0
- package/src/core/projectInstructions.test.ts +50 -0
- package/src/core/projectInstructions.ts +54 -0
- package/src/core/providerLauncher/launcher.test.ts +238 -0
- package/src/core/providerLauncher/launcher.ts +203 -0
- package/src/core/providerLauncher/registry.test.ts +324 -0
- package/src/core/providerLauncher/registry.ts +253 -0
- package/src/core/providerLauncher/types.ts +84 -0
- package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
- package/src/core/providerLauncher/workspaceConfig.ts +407 -0
- package/src/core/providerRuntime/anthropic.test.ts +1120 -0
- package/src/core/providerRuntime/anthropic.ts +576 -0
- package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
- package/src/core/providerRuntime/capabilityProfile.ts +288 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
- package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
- package/src/core/providerRuntime/contextMetadata.ts +409 -0
- package/src/core/providerRuntime/gemini.test.ts +437 -0
- package/src/core/providerRuntime/gemini.ts +784 -0
- package/src/core/providerRuntime/lmstudio.test.ts +168 -0
- package/src/core/providerRuntime/lmstudio.ts +118 -0
- package/src/core/providerRuntime/local.test.ts +787 -0
- package/src/core/providerRuntime/local.ts +754 -0
- package/src/core/providerRuntime/models.ts +150 -0
- package/src/core/providerRuntime/reasoning.ts +17 -0
- package/src/core/providerRuntime/registry.test.ts +233 -0
- package/src/core/providerRuntime/registry.ts +203 -0
- package/src/core/providerRuntime/types.ts +103 -0
- package/src/core/providers/codexJsonStream.test.ts +148 -0
- package/src/core/providers/codexJsonStream.ts +305 -0
- package/src/core/providers/codexSubprocess.test.ts +68 -0
- package/src/core/providers/codexSubprocess.ts +372 -0
- package/src/core/providers/codexTranscript.test.ts +284 -0
- package/src/core/providers/codexTranscript.ts +695 -0
- package/src/core/providers/openaiNative.ts +13 -0
- package/src/core/providers/registry.ts +21 -0
- package/src/core/providers/types.ts +59 -0
- package/src/core/terminal/terminalCapabilities.test.ts +93 -0
- package/src/core/terminal/terminalCapabilities.ts +100 -0
- package/src/core/terminal/terminalControl.test.ts +75 -0
- package/src/core/terminal/terminalControl.ts +147 -0
- package/src/core/terminal/terminalSanitize.test.ts +22 -0
- package/src/core/terminal/terminalSanitize.ts +147 -0
- package/src/core/terminal/terminalSelection.test.ts +42 -0
- package/src/core/terminal/terminalSelection.ts +66 -0
- package/src/core/terminal/terminalTitle.test.ts +328 -0
- package/src/core/terminal/terminalTitle.ts +483 -0
- package/src/core/workspaceActivity.test.ts +163 -0
- package/src/core/workspaceActivity.ts +380 -0
- package/src/core/workspaceGuard.test.ts +151 -0
- package/src/core/workspaceGuard.ts +288 -0
- package/src/core/workspaceRoot.test.ts +23 -0
- package/src/core/workspaceRoot.ts +47 -0
- package/src/exec.test.ts +13 -0
- package/src/exec.ts +72 -0
- package/src/headless/execArgs.test.ts +147 -0
- package/src/headless/execArgs.ts +294 -0
- package/src/headless/execRunner.test.ts +434 -0
- package/src/headless/execRunner.ts +304 -0
- package/src/index.test.tsx +618 -0
- package/src/index.tsx +296 -0
- package/src/session/appSession.test.ts +897 -0
- package/src/session/appSession.ts +761 -0
- package/src/session/chatLifecycle.test.ts +64 -0
- package/src/session/chatLifecycle.ts +951 -0
- package/src/session/liveRenderScheduler.test.ts +201 -0
- package/src/session/liveRenderScheduler.ts +214 -0
- package/src/session/planFlow.test.ts +103 -0
- package/src/session/planFlow.ts +149 -0
- package/src/session/planTranscript.test.ts +65 -0
- package/src/session/planTranscript.ts +15 -0
- package/src/session/promptRunSchedule.test.ts +36 -0
- package/src/session/promptRunSchedule.ts +26 -0
- package/src/session/types.ts +228 -0
- package/src/test/runtimeTestUtils.ts +14 -0
- package/src/types/react-dom.d.ts +3 -0
- package/src/ui/ActionRequiredBlock.tsx +38 -0
- package/src/ui/ActivityBars.tsx +68 -0
- package/src/ui/ActivityIndicator.test.tsx +58 -0
- package/src/ui/ActivityIndicator.tsx +58 -0
- package/src/ui/AgentBlock.test.ts +6 -0
- package/src/ui/AgentBlock.tsx +130 -0
- package/src/ui/AnimatedStatusText.test.ts +16 -0
- package/src/ui/AnimatedStatusText.tsx +69 -0
- package/src/ui/AppShell.test.tsx +1739 -0
- package/src/ui/AppShell.tsx +698 -0
- package/src/ui/AttachmentImportPanel.test.tsx +204 -0
- package/src/ui/AttachmentImportPanel.tsx +98 -0
- package/src/ui/AuthPanel.tsx +113 -0
- package/src/ui/BackendPicker.tsx +28 -0
- package/src/ui/BottomComposer.test.ts +674 -0
- package/src/ui/BottomComposer.tsx +1028 -0
- package/src/ui/CodexLogo.tsx +55 -0
- package/src/ui/DashCard.tsx +82 -0
- package/src/ui/Markdown.test.ts +157 -0
- package/src/ui/Markdown.tsx +310 -0
- package/src/ui/ModePicker.tsx +27 -0
- package/src/ui/ModelPicker.tsx +31 -0
- package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
- package/src/ui/ModelPickerScreen.test.tsx +99 -0
- package/src/ui/ModelPickerScreen.tsx +416 -0
- package/src/ui/ModelPickerState.test.tsx +151 -0
- package/src/ui/ModelReasoningPicker.test.tsx +447 -0
- package/src/ui/ModelReasoningPicker.tsx +458 -0
- package/src/ui/Panel.tsx +51 -0
- package/src/ui/PermissionsPanel.tsx +78 -0
- package/src/ui/PlanActionPicker.tsx +119 -0
- package/src/ui/PlanReviewPanel.test.tsx +267 -0
- package/src/ui/PlanReviewPanel.tsx +212 -0
- package/src/ui/PromptCardBorder.test.tsx +161 -0
- package/src/ui/ProviderPicker.test.tsx +289 -0
- package/src/ui/ProviderPicker.tsx +321 -0
- package/src/ui/ProviderShortcut.test.tsx +143 -0
- package/src/ui/ReasoningPicker.tsx +46 -0
- package/src/ui/RunFooter.tsx +65 -0
- package/src/ui/SelectionPanel.tsx +67 -0
- package/src/ui/SettingsPanel.test.tsx +233 -0
- package/src/ui/SettingsPanel.tsx +156 -0
- package/src/ui/Spinner.tsx +25 -0
- package/src/ui/StaticIntroItem.tsx +54 -0
- package/src/ui/StaticTranscriptItem.tsx +56 -0
- package/src/ui/TextEntryPanel.tsx +139 -0
- package/src/ui/ThemePicker.tsx +31 -0
- package/src/ui/ThinkingBlock.tsx +100 -0
- package/src/ui/Timeline.test.ts +2067 -0
- package/src/ui/Timeline.tsx +1472 -0
- package/src/ui/TimelineNavigation.test.tsx +201 -0
- package/src/ui/TopHeader.test.tsx +239 -0
- package/src/ui/TopHeader.tsx +257 -0
- package/src/ui/TurnGroup.test.tsx +365 -0
- package/src/ui/TurnGroup.tsx +657 -0
- package/src/ui/busyStatusAnimation.test.ts +30 -0
- package/src/ui/busyStatusAnimation.ts +11 -0
- package/src/ui/commandNormalize.test.ts +142 -0
- package/src/ui/commandNormalize.ts +66 -0
- package/src/ui/diffRenderer.test.ts +102 -0
- package/src/ui/diffRenderer.ts +116 -0
- package/src/ui/focus.ts +61 -0
- package/src/ui/focusFlow.test.tsx +1098 -0
- package/src/ui/inputBuffer.test.ts +151 -0
- package/src/ui/inputBuffer.ts +203 -0
- package/src/ui/layout.test.ts +145 -0
- package/src/ui/layout.ts +287 -0
- package/src/ui/modeDisplay.test.ts +42 -0
- package/src/ui/modeDisplay.ts +52 -0
- package/src/ui/outputPipeline.ts +64 -0
- package/src/ui/progressEntries.ts +156 -0
- package/src/ui/runActivityView.test.ts +89 -0
- package/src/ui/runActivityView.ts +37 -0
- package/src/ui/runLifecycleView.test.tsx +237 -0
- package/src/ui/slashCommands.ts +41 -0
- package/src/ui/statusRenderIsolation.test.tsx +654 -0
- package/src/ui/terminalAnswerFormat.test.ts +19 -0
- package/src/ui/terminalAnswerFormat.ts +128 -0
- package/src/ui/textLayout.test.ts +18 -0
- package/src/ui/textLayout.ts +338 -0
- package/src/ui/theme.tsx +395 -0
- package/src/ui/themeFlow.test.ts +53 -0
- package/src/ui/themeFlow.ts +41 -0
- package/src/ui/timelineMeasure.ts +3088 -0
- package/src/ui/timelineMeasureCache.test.ts +986 -0
- package/src/ui/useThrottledValue.ts +31 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { useTheme } from "./theme.js";
|
|
4
|
+
import type { LayoutMode } from "./layout.js";
|
|
5
|
+
|
|
6
|
+
// Full 4-line ASCII banner — rendered only in "full" mode.
|
|
7
|
+
const BANNER = [
|
|
8
|
+
" ____ ___ ____ _____ _ __ _ ",
|
|
9
|
+
" / ___| / _ \\| _ \\| ____| |/ / / \\ ",
|
|
10
|
+
"| | | | | | | | | _| | ' / / _ \\ ",
|
|
11
|
+
"|_| \\___/|_| |_|_____|_|\\_\\ /_/ \\_\\ "
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
interface CodexLogoProps {
|
|
16
|
+
/** Which layout mode we're in. Defaults to "full". */
|
|
17
|
+
layout?: LayoutMode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function CodexLogo({ layout = "full" }: CodexLogoProps) {
|
|
21
|
+
const theme = useTheme();
|
|
22
|
+
|
|
23
|
+
function colorForIndex(index: number): string {
|
|
24
|
+
return theme.LOGO[index % theme.LOGO.length] ?? theme.LOGO[0];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Micro mode — no logo rendered at all
|
|
28
|
+
if (layout === "micro") return null;
|
|
29
|
+
|
|
30
|
+
// Compact mode — single colourful line: ✦ CODEXA
|
|
31
|
+
if (layout === "compact") {
|
|
32
|
+
const letters = ["C", "O", "D", "E", "X", "A"];
|
|
33
|
+
return (
|
|
34
|
+
<Box>
|
|
35
|
+
<Text color={theme.LOGO[0]} bold>{"✦ "}</Text>
|
|
36
|
+
{letters.map((char, index) => (
|
|
37
|
+
<Text key={index} color={colorForIndex(index)} bold>
|
|
38
|
+
{char}
|
|
39
|
+
</Text>
|
|
40
|
+
))}
|
|
41
|
+
</Box>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Full mode — ASCII banner to stay stable across terminals/codepages.
|
|
46
|
+
return (
|
|
47
|
+
<Box flexDirection="column" overflow="hidden">
|
|
48
|
+
{BANNER.map((line, index) => (
|
|
49
|
+
<Text key={line} color={colorForIndex(index)} wrap="truncate">
|
|
50
|
+
{line}
|
|
51
|
+
</Text>
|
|
52
|
+
))}
|
|
53
|
+
</Box>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { useTheme } from "./theme.js";
|
|
5
|
+
import { getVisualWidth } from "./layout.js";
|
|
6
|
+
|
|
7
|
+
interface DashCardProps {
|
|
8
|
+
cols: number;
|
|
9
|
+
title: string;
|
|
10
|
+
rightBadge?: string;
|
|
11
|
+
borderColor?: string;
|
|
12
|
+
titleColor?: string;
|
|
13
|
+
badgeColor?: string;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function DashCard({
|
|
18
|
+
cols,
|
|
19
|
+
title,
|
|
20
|
+
rightBadge,
|
|
21
|
+
borderColor,
|
|
22
|
+
titleColor,
|
|
23
|
+
badgeColor,
|
|
24
|
+
children,
|
|
25
|
+
}: DashCardProps) {
|
|
26
|
+
const theme = useTheme();
|
|
27
|
+
const border = borderColor ?? theme.BORDER_SUBTLE;
|
|
28
|
+
const tColor = titleColor ?? theme.MUTED;
|
|
29
|
+
const bColor = badgeColor ?? theme.DIM;
|
|
30
|
+
|
|
31
|
+
// Account for AppShell gutter (-1) and Timeline paddingX={1} (-2)
|
|
32
|
+
const w = cols - 3;
|
|
33
|
+
|
|
34
|
+
const topLine = buildTopBorder(w, title, rightBadge);
|
|
35
|
+
const bottomFill = "─".repeat(Math.max(1, w - 2));
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<Box flexDirection="column" width="100%">
|
|
39
|
+
<Text wrap="truncate">
|
|
40
|
+
<Text color={border}>{"╭── "}</Text>
|
|
41
|
+
<Text color={tColor} bold>{topLine.title}</Text>
|
|
42
|
+
<Text color={border}>{topLine.badge ? " " + topLine.fill + " " : " " + topLine.fill}</Text>
|
|
43
|
+
{topLine.badge ? (
|
|
44
|
+
<>
|
|
45
|
+
<Text color={bColor}>{topLine.badge}</Text>
|
|
46
|
+
<Text color={border}>{" ──╮"}</Text>
|
|
47
|
+
</>
|
|
48
|
+
) : (
|
|
49
|
+
<Text color={border}>{"──╮"}</Text>
|
|
50
|
+
)}
|
|
51
|
+
</Text>
|
|
52
|
+
<Box flexDirection="row" width="100%">
|
|
53
|
+
<Text color={border}>{"│ "}</Text>
|
|
54
|
+
<Box flexDirection="column" flexGrow={1} flexShrink={1}>
|
|
55
|
+
{children}
|
|
56
|
+
</Box>
|
|
57
|
+
<Text color={border}>{" │"}</Text>
|
|
58
|
+
</Box>
|
|
59
|
+
<Text wrap="truncate" color={border}>{"╰" + bottomFill + "╯"}</Text>
|
|
60
|
+
</Box>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildTopBorder(
|
|
65
|
+
w: number,
|
|
66
|
+
title: string,
|
|
67
|
+
badge?: string,
|
|
68
|
+
): { title: string; fill: string; badge: string | null } {
|
|
69
|
+
// Layout: "╭── " + title + " " + fill + " " + badge + " ──╮"
|
|
70
|
+
// Or: "╭── " + title + " " + fill + "──╮"
|
|
71
|
+
const prefix = 4; // "╭── "
|
|
72
|
+
const suffix = badge ? 4 : 3; // " ──╮" with badge spacing, or "──╮"
|
|
73
|
+
const titleWidth = getVisualWidth(title);
|
|
74
|
+
const badgeWidth = badge ? getVisualWidth(badge) : 0;
|
|
75
|
+
const spacing = badge ? 2 : 1; // the title-only variant has no trailing gap before the corner
|
|
76
|
+
|
|
77
|
+
const available = w - prefix - titleWidth - spacing - badgeWidth - suffix;
|
|
78
|
+
const fillCount = Math.max(1, available);
|
|
79
|
+
const fill = "─".repeat(fillCount);
|
|
80
|
+
|
|
81
|
+
return { title, fill, badge: badge ?? null };
|
|
82
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseMarkdown } from "./Markdown.js";
|
|
4
|
+
import type { ParaSegment, CodeSegment, Segment } from "./Markdown.js";
|
|
5
|
+
|
|
6
|
+
const SAMPLE = [
|
|
7
|
+
"# Summary",
|
|
8
|
+
"",
|
|
9
|
+
"The current rendering path is flattening the assistant response into a dense block.",
|
|
10
|
+
"",
|
|
11
|
+
"What needs to improve:",
|
|
12
|
+
"",
|
|
13
|
+
"- Preserve paragraph spacing.",
|
|
14
|
+
"- Keep bullet lists readable.",
|
|
15
|
+
"- Keep code blocks separate.",
|
|
16
|
+
"- Avoid merging activity logs with final answers.",
|
|
17
|
+
"",
|
|
18
|
+
"Steps:",
|
|
19
|
+
"",
|
|
20
|
+
"1. Sanitize unsafe control characters.",
|
|
21
|
+
"2. Preserve markdown structure.",
|
|
22
|
+
"3. Render semantic blocks with spacing.",
|
|
23
|
+
"4. Verify streaming still works.",
|
|
24
|
+
"",
|
|
25
|
+
"Example command:",
|
|
26
|
+
"",
|
|
27
|
+
"```powershell",
|
|
28
|
+
"npm run typecheck",
|
|
29
|
+
"npm run build",
|
|
30
|
+
"```",
|
|
31
|
+
"",
|
|
32
|
+
"Assistant: Done. Formatting pipeline is now applied.",
|
|
33
|
+
].join("\n");
|
|
34
|
+
|
|
35
|
+
function segmentText(segments: Segment[]): string {
|
|
36
|
+
return segments.flatMap((segment) => {
|
|
37
|
+
if (segment.type === "code") return segment.lines;
|
|
38
|
+
if (segment.type === "header") return segment.parts.map((part) => part.text);
|
|
39
|
+
if (segment.type === "list") return segment.items.flatMap((item) => item.parts.map((part) => part.text));
|
|
40
|
+
return segment.lines.flatMap((line) => line.map((part) => part.text));
|
|
41
|
+
}).join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("sample response produces separate segments, not one dense para", () => {
|
|
45
|
+
const segments = parseMarkdown(SAMPLE);
|
|
46
|
+
const types = segments.map((s) => s.type);
|
|
47
|
+
|
|
48
|
+
assert.ok(
|
|
49
|
+
segments.length >= 7,
|
|
50
|
+
`expected ≥7 segments, got ${segments.length}: ${JSON.stringify(types)}`,
|
|
51
|
+
);
|
|
52
|
+
assert.equal(segments[0]?.type, "header", "first segment must be the header");
|
|
53
|
+
assert.ok(types.includes("code"), "expected a code segment");
|
|
54
|
+
assert.ok(types.includes("list"), "expected a list segment");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("blank lines between paragraphs produce separate ParaSegments", () => {
|
|
58
|
+
const segments = parseMarkdown(SAMPLE);
|
|
59
|
+
const paras = segments.filter((s): s is ParaSegment => s.type === "para");
|
|
60
|
+
|
|
61
|
+
for (const para of paras) {
|
|
62
|
+
const text = para.lines.flat().map((p) => p.text).join(" ");
|
|
63
|
+
assert.ok(
|
|
64
|
+
!(text.includes("rendering path") && text.includes("What needs to improve")),
|
|
65
|
+
"two distinct paragraphs must not share one ParaSegment",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("blank lines inside code blocks are preserved", () => {
|
|
71
|
+
const input = "```\nline1\n\nline3\n```";
|
|
72
|
+
const segments = parseMarkdown(input);
|
|
73
|
+
assert.equal(segments.length, 1);
|
|
74
|
+
assert.equal(segments[0]?.type, "code");
|
|
75
|
+
const code = segments[0] as CodeSegment;
|
|
76
|
+
assert.deepEqual(code.lines, ["line1", "", "line3"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("plain paragraphs with no blank lines stay in one segment", () => {
|
|
80
|
+
const input = "Line one.\nLine two.\nLine three.";
|
|
81
|
+
const segments = parseMarkdown(input);
|
|
82
|
+
assert.equal(segments.length, 1);
|
|
83
|
+
assert.equal(segments[0]?.type, "para");
|
|
84
|
+
const para = segments[0] as ParaSegment;
|
|
85
|
+
assert.equal(para.lines.length, 3);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("header immediately followed by text produces two segments", () => {
|
|
89
|
+
const input = "# Title\n\nSome text here.";
|
|
90
|
+
const segments = parseMarkdown(input);
|
|
91
|
+
assert.equal(segments[0]?.type, "header");
|
|
92
|
+
assert.equal(segments[1]?.type, "para");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("cleans local markdown file links into compact terminal paths", () => {
|
|
96
|
+
const input = "The app shell lives in [`src/App.tsx`](C:/Users/Example/Projects/Project/src/App.tsx#L22).";
|
|
97
|
+
const text = segmentText(parseMarkdown(input));
|
|
98
|
+
|
|
99
|
+
assert.match(text, /src\/App\.tsx:22/);
|
|
100
|
+
assert.doesNotMatch(text, /C:\/Users/);
|
|
101
|
+
assert.doesNotMatch(text, /\]\(/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("cleans Windows absolute paths in prose", () => {
|
|
105
|
+
const input = "Formal proof: C:\\Users\\Example\\Projects\\Project\\docs\\proof.md#L26";
|
|
106
|
+
const text = segmentText(parseMarkdown(input));
|
|
107
|
+
|
|
108
|
+
assert.match(text, /docs\/proof\.md:26/);
|
|
109
|
+
assert.doesNotMatch(text, /C:\\Users/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("cleans file paths with encoded spaces", () => {
|
|
113
|
+
const input = "Overview: [README.md](file:///C:/Users/Example/Projects/5-Date%20Verification/README.md)";
|
|
114
|
+
const text = segmentText(parseMarkdown(input));
|
|
115
|
+
|
|
116
|
+
assert.match(text, /README\.md/);
|
|
117
|
+
assert.doesNotMatch(text, /file:\/\//);
|
|
118
|
+
assert.doesNotMatch(text, /5-Date%20Verification/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("cleans local line ranges", () => {
|
|
122
|
+
const input = "See [proof](C:/Users/Example/Projects/Project/docs/proof.md#L26-L31).";
|
|
123
|
+
const text = segmentText(parseMarkdown(input));
|
|
124
|
+
|
|
125
|
+
assert.match(text, /proof \(docs\/proof\.md:26-31\)/);
|
|
126
|
+
assert.doesNotMatch(text, /C:\/Users/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("external web markdown links remain unchanged", () => {
|
|
130
|
+
const input = "Docs: [OpenAI](https://platform.openai.com/docs).";
|
|
131
|
+
const text = segmentText(parseMarkdown(input));
|
|
132
|
+
|
|
133
|
+
assert.match(text, /\[OpenAI\]\(https:\/\/platform\.openai\.com\/docs\)/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("inline-code web links remain unchanged", () => {
|
|
137
|
+
const input = "Use `https://platform.openai.com/docs` for reference.";
|
|
138
|
+
const text = segmentText(parseMarkdown(input));
|
|
139
|
+
|
|
140
|
+
assert.match(text, /https:\/\/platform\.openai\.com\/docs/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("code blocks are not rewritten by terminal answer cleanup", () => {
|
|
144
|
+
const input = [
|
|
145
|
+
"```text",
|
|
146
|
+
"C:/Users/Example/Project/src/App.tsx#L22",
|
|
147
|
+
"[README.md](file:///C:/Project/README.md)",
|
|
148
|
+
"```",
|
|
149
|
+
].join("\n");
|
|
150
|
+
const segments = parseMarkdown(input);
|
|
151
|
+
const code = segments[0] as CodeSegment;
|
|
152
|
+
|
|
153
|
+
assert.deepEqual(code.lines, [
|
|
154
|
+
"C:/Users/Example/Project/src/App.tsx#L22",
|
|
155
|
+
"[README.md](file:///C:/Project/README.md)",
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { useTheme } from "./theme.js";
|
|
4
|
+
import { Panel } from "./Panel.js";
|
|
5
|
+
import { maybeRenderDiff, type DiffRenderLineType } from "./diffRenderer.js";
|
|
6
|
+
import { formatLocalPathForTerminal, formatTerminalAnswerInline } from "./terminalAnswerFormat.js";
|
|
7
|
+
|
|
8
|
+
// ─── Markdown parser ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
type InlinePart =
|
|
11
|
+
| { kind: "text"; text: string }
|
|
12
|
+
| { kind: "code"; text: string }
|
|
13
|
+
| { kind: "bold"; text: string };
|
|
14
|
+
|
|
15
|
+
function parseInline(text: string): InlinePart[] {
|
|
16
|
+
const formattedText = formatTerminalAnswerInline(text);
|
|
17
|
+
const parts: InlinePart[] = [];
|
|
18
|
+
const pat = /`([^`\n]+)`|\*\*([^*\n]+)\*\*/g;
|
|
19
|
+
let last = 0;
|
|
20
|
+
let match: RegExpExecArray | null;
|
|
21
|
+
|
|
22
|
+
while ((match = pat.exec(formattedText)) !== null) {
|
|
23
|
+
if (match.index > last) parts.push({ kind: "text", text: formattedText.slice(last, match.index) });
|
|
24
|
+
if (match[1] !== undefined) parts.push({ kind: "code", text: formatLocalPathForTerminal(match[1]) });
|
|
25
|
+
if (match[2] !== undefined) parts.push({ kind: "bold", text: match[2] });
|
|
26
|
+
last = pat.lastIndex;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (last < formattedText.length) parts.push({ kind: "text", text: formattedText.slice(last) });
|
|
30
|
+
return parts.length > 0 ? parts : [{ kind: "text", text: formattedText }];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CodeSegment = { type: "code"; lang: string; lines: string[] };
|
|
34
|
+
export type HeaderSegment = { type: "header"; level: 1 | 2 | 3; parts: InlinePart[] };
|
|
35
|
+
export type ListItem = { num: number; parts: InlinePart[] };
|
|
36
|
+
export type ListSegment = { type: "list"; ordered: boolean; items: ListItem[] };
|
|
37
|
+
export type ParaSegment = { type: "para"; lines: InlinePart[][] };
|
|
38
|
+
export type Segment = CodeSegment | HeaderSegment | ListSegment | ParaSegment;
|
|
39
|
+
|
|
40
|
+
const FENCE_RE = /^```(.*)$/;
|
|
41
|
+
const HEADER_RE = /^(#{1,3})\s+(.+)/;
|
|
42
|
+
const BULLET_RE = /^\s*[-*]\s+(.+)/;
|
|
43
|
+
const ORDERED_RE = /^\s*(\d+)\.\s+(.+)/;
|
|
44
|
+
|
|
45
|
+
function isBlockStart(line: string): boolean {
|
|
46
|
+
return FENCE_RE.test(line) || HEADER_RE.test(line) || BULLET_RE.test(line) || ORDERED_RE.test(line);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseMarkdown(content: string): Segment[] {
|
|
50
|
+
const lines = content.split("\n");
|
|
51
|
+
const out: Segment[] = [];
|
|
52
|
+
let index = 0;
|
|
53
|
+
|
|
54
|
+
while (index < lines.length) {
|
|
55
|
+
const line = lines[index]!;
|
|
56
|
+
|
|
57
|
+
if (!line.trim()) {
|
|
58
|
+
index += 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const fenceMatch = FENCE_RE.exec(line);
|
|
63
|
+
if (fenceMatch) {
|
|
64
|
+
const codeLines: string[] = [];
|
|
65
|
+
const lang = (fenceMatch[1] ?? "").trim();
|
|
66
|
+
index += 1;
|
|
67
|
+
while (index < lines.length && !FENCE_RE.test(lines[index]!)) {
|
|
68
|
+
codeLines.push(lines[index]!);
|
|
69
|
+
index += 1;
|
|
70
|
+
}
|
|
71
|
+
index += 1;
|
|
72
|
+
out.push({ type: "code", lang, lines: codeLines });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const headerMatch = HEADER_RE.exec(line);
|
|
77
|
+
if (headerMatch) {
|
|
78
|
+
out.push({
|
|
79
|
+
type: "header",
|
|
80
|
+
level: Math.min(3, headerMatch[1]!.length) as 1 | 2 | 3,
|
|
81
|
+
parts: parseInline(headerMatch[2]!),
|
|
82
|
+
});
|
|
83
|
+
index += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (BULLET_RE.test(line)) {
|
|
88
|
+
const items: ListItem[] = [];
|
|
89
|
+
let counter = 1;
|
|
90
|
+
while (index < lines.length) {
|
|
91
|
+
const match = BULLET_RE.exec(lines[index]!);
|
|
92
|
+
if (!match) break;
|
|
93
|
+
items.push({ num: counter, parts: parseInline(match[1]!) });
|
|
94
|
+
counter += 1;
|
|
95
|
+
index += 1;
|
|
96
|
+
}
|
|
97
|
+
out.push({ type: "list", ordered: false, items });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (ORDERED_RE.test(line)) {
|
|
102
|
+
const items: ListItem[] = [];
|
|
103
|
+
while (index < lines.length) {
|
|
104
|
+
const match = ORDERED_RE.exec(lines[index]!);
|
|
105
|
+
if (!match) break;
|
|
106
|
+
items.push({ num: Number.parseInt(match[1]!, 10), parts: parseInline(match[2]!) });
|
|
107
|
+
index += 1;
|
|
108
|
+
}
|
|
109
|
+
out.push({ type: "list", ordered: true, items });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const paraLines: InlinePart[][] = [];
|
|
114
|
+
while (index < lines.length && !isBlockStart(lines[index]!) && lines[index]!.trim() !== '') {
|
|
115
|
+
paraLines.push(parseInline(lines[index]!));
|
|
116
|
+
index += 1;
|
|
117
|
+
}
|
|
118
|
+
if (paraLines.length > 0) {
|
|
119
|
+
out.push({ type: "para", lines: paraLines });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ─── Rendering helpers ────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function InlineText({ parts, color }: { parts: InlinePart[]; color: string }) {
|
|
129
|
+
const theme = useTheme();
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<Text color={color} wrap="wrap">
|
|
133
|
+
{parts.map((part, index) => {
|
|
134
|
+
if (part.kind === "code") return <Text key={index} color={theme.INFO}>{part.text}</Text>;
|
|
135
|
+
if (part.kind === "bold") return <Text key={index} bold>{part.text}</Text>;
|
|
136
|
+
return <Text key={index}>{part.text}</Text>;
|
|
137
|
+
})}
|
|
138
|
+
</Text>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function renderTreeLabel(label: string, theme: ReturnType<typeof useTheme>) {
|
|
143
|
+
const trimmed = label.trimEnd();
|
|
144
|
+
const slash = trimmed.endsWith("/") ? "/" : "";
|
|
145
|
+
const base = slash ? trimmed.slice(0, -1) : trimmed;
|
|
146
|
+
const lastDot = base.lastIndexOf(".");
|
|
147
|
+
const isDirectory = slash === "/" || (lastDot <= 0 && base.length > 0);
|
|
148
|
+
|
|
149
|
+
if (isDirectory) {
|
|
150
|
+
return (
|
|
151
|
+
<Text color={theme.TEXT} bold>
|
|
152
|
+
{base}
|
|
153
|
+
{slash}
|
|
154
|
+
</Text>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<Text color={theme.TEXT}>
|
|
160
|
+
{base.slice(0, lastDot)}
|
|
161
|
+
<Text color={theme.DIM}>{base.slice(lastDot)}</Text>
|
|
162
|
+
</Text>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function TreeLine({ line }: { line: string }) {
|
|
167
|
+
const theme = useTheme();
|
|
168
|
+
const match = /^([│├└─\s]*)(.*)$/.exec(line);
|
|
169
|
+
const prefix = match?.[1] ?? "";
|
|
170
|
+
const label = match?.[2] ?? line;
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<Box width="100%">
|
|
174
|
+
<Text color={theme.DIM}>{prefix}</Text>
|
|
175
|
+
{renderTreeLabel(label, theme)}
|
|
176
|
+
</Box>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isTreeLine(line: string): boolean {
|
|
181
|
+
return /^[│├└─\s]+/.test(line);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getDiffColor(kind: DiffRenderLineType, theme: ReturnType<typeof useTheme>): string {
|
|
185
|
+
switch (kind) {
|
|
186
|
+
case "add":
|
|
187
|
+
return theme.SUCCESS;
|
|
188
|
+
case "remove":
|
|
189
|
+
return theme.ERROR;
|
|
190
|
+
case "hunk":
|
|
191
|
+
return theme.ACCENT;
|
|
192
|
+
case "file":
|
|
193
|
+
case "meta":
|
|
194
|
+
return theme.INFO;
|
|
195
|
+
case "context":
|
|
196
|
+
default:
|
|
197
|
+
return theme.MUTED;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export function RenderMessage({ segments, width, brightHeadings = false }: { segments: Segment[]; width: number; brightHeadings?: boolean }) {
|
|
204
|
+
const theme = useTheme();
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<Box flexDirection="column" width="100%">
|
|
208
|
+
{segments.map((segment, index) => {
|
|
209
|
+
const marginTop = index > 0 ? 1 : 0;
|
|
210
|
+
|
|
211
|
+
if (segment.type === "code") {
|
|
212
|
+
const lang = (segment.lang || "").toLowerCase();
|
|
213
|
+
const looksLikeTree = segment.lines.some((line) => isTreeLine(line));
|
|
214
|
+
|
|
215
|
+
let title = segment.lang || "code";
|
|
216
|
+
let codeLines = segment.lines;
|
|
217
|
+
const firstLine = codeLines[0]?.trim() || "";
|
|
218
|
+
if (/^[a-zA-Z0-9_\-\.\/]+\.[a-zA-Z0-9]+$/.test(firstLine)) {
|
|
219
|
+
title = firstLine;
|
|
220
|
+
codeLines = codeLines.slice(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const rightTitle = segment.lang ? `${segment.lang.toUpperCase()} ⎘ Copy Code` : "⎘ Copy Code";
|
|
224
|
+
const panelWidth = Math.max(10, width);
|
|
225
|
+
const diffLines = maybeRenderDiff(codeLines.join("\n"), { force: lang === "diff" });
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<Box
|
|
229
|
+
key={index}
|
|
230
|
+
marginTop={marginTop}
|
|
231
|
+
flexDirection="column"
|
|
232
|
+
paddingLeft={2}
|
|
233
|
+
width="100%"
|
|
234
|
+
>
|
|
235
|
+
<Panel cols={panelWidth} title={title} rightTitle={rightTitle}>
|
|
236
|
+
{diffLines
|
|
237
|
+
? diffLines.map((line, lineIndex) => (
|
|
238
|
+
<Text key={lineIndex} color={getDiffColor(line.type, theme)} wrap="wrap">
|
|
239
|
+
{line.text || " "}
|
|
240
|
+
</Text>
|
|
241
|
+
))
|
|
242
|
+
: codeLines.map((line, lineIndex) => (
|
|
243
|
+
looksLikeTree ? (
|
|
244
|
+
<TreeLine key={lineIndex} line={line || " "} />
|
|
245
|
+
) : (
|
|
246
|
+
<Box key={lineIndex}>
|
|
247
|
+
<Box width={3} flexShrink={0} marginRight={1} justifyContent="flex-end">
|
|
248
|
+
<Text color={theme.DIM}>{lineIndex + 1}</Text>
|
|
249
|
+
</Box>
|
|
250
|
+
<Text color={theme.MUTED} wrap="wrap">{line || " "}</Text>
|
|
251
|
+
</Box>
|
|
252
|
+
)
|
|
253
|
+
))}
|
|
254
|
+
</Panel>
|
|
255
|
+
</Box>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (segment.type === "header") {
|
|
260
|
+
const color = segment.level === 1 ? theme.ACCENT : segment.level === 2 ? theme.TEXT : (brightHeadings ? theme.TEXT : theme.MUTED);
|
|
261
|
+
return (
|
|
262
|
+
<Box key={index} flexDirection="column" marginTop={marginTop}>
|
|
263
|
+
{segment.level <= 2 && <Text color={theme.BORDER_SUBTLE}>{"───"}</Text>}
|
|
264
|
+
<Box>
|
|
265
|
+
<Text color={color}>{segment.level <= 2 ? "✧ " : "• "}</Text>
|
|
266
|
+
<InlineText parts={segment.parts} color={color} />
|
|
267
|
+
</Box>
|
|
268
|
+
</Box>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (segment.type === "list") {
|
|
273
|
+
return (
|
|
274
|
+
<Box key={index} flexDirection="column" marginTop={marginTop}>
|
|
275
|
+
{segment.items.map((item, itemIndex) => (
|
|
276
|
+
<Box key={itemIndex}>
|
|
277
|
+
<Text color={theme.ACCENT}>{segment.ordered ? `${item.num}. ` : "• "}</Text>
|
|
278
|
+
<Box flexGrow={1} flexShrink={1}>
|
|
279
|
+
<InlineText parts={item.parts} color={theme.TEXT} />
|
|
280
|
+
</Box>
|
|
281
|
+
</Box>
|
|
282
|
+
))}
|
|
283
|
+
</Box>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const hasContent = segment.lines.some(
|
|
288
|
+
(parts) => !(parts.length === 1 && parts[0]!.kind === "text" && !parts[0]!.text.trim()),
|
|
289
|
+
);
|
|
290
|
+
if (!hasContent) return null;
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<Box key={index} flexDirection="column" marginTop={marginTop}>
|
|
294
|
+
{segment.lines.map((parts, lineIndex) => {
|
|
295
|
+
if (parts.length === 1 && parts[0]!.kind === "text" && !parts[0]!.text.trim()) {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return <InlineText key={lineIndex} parts={parts} color={theme.TEXT} />;
|
|
299
|
+
})}
|
|
300
|
+
</Box>
|
|
301
|
+
);
|
|
302
|
+
})}
|
|
303
|
+
</Box>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export const MemoizedRenderMessage = React.memo(RenderMessage, (prev, next) => (
|
|
308
|
+
prev.segments === next.segments && prev.width === next.width && prev.brightHeadings === next.brightHeadings
|
|
309
|
+
));
|
|
310
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { AVAILABLE_MODES } from "../config/settings.js";
|
|
2
|
+
import { FOCUS_IDS } from "./focus.js";
|
|
3
|
+
import { SelectionPanel } from "./SelectionPanel.js";
|
|
4
|
+
|
|
5
|
+
interface ModePickerProps {
|
|
6
|
+
currentMode: string;
|
|
7
|
+
onSelect: (mode: string) => void;
|
|
8
|
+
onCancel: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function ModePicker({ currentMode, onSelect, onCancel }: ModePickerProps) {
|
|
12
|
+
const items = AVAILABLE_MODES.map((mode) => ({
|
|
13
|
+
label: mode.key === currentMode ? `${mode.label} ✓` : mode.label,
|
|
14
|
+
value: mode.key,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<SelectionPanel
|
|
19
|
+
focusId={FOCUS_IDS.modePicker}
|
|
20
|
+
title="Select mode"
|
|
21
|
+
subtitle="Execution mode controls how aggressive the runner can be."
|
|
22
|
+
items={items}
|
|
23
|
+
onSelect={onSelect}
|
|
24
|
+
onCancel={onCancel}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type CodexModelCapability } from "../core/models/codexModelCapabilities.js";
|
|
2
|
+
import { FOCUS_IDS } from "./focus.js";
|
|
3
|
+
import { SelectionPanel } from "./SelectionPanel.js";
|
|
4
|
+
|
|
5
|
+
interface ModelPickerProps {
|
|
6
|
+
models: readonly CodexModelCapability[];
|
|
7
|
+
currentModel: string;
|
|
8
|
+
onSelect: (model: string) => void;
|
|
9
|
+
onCancel: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ModelPicker({ models, currentModel, onSelect, onCancel }: ModelPickerProps) {
|
|
13
|
+
const items = models.map((model) => {
|
|
14
|
+
const label = model.label === model.model ? model.model : `${model.label} (${model.model})`;
|
|
15
|
+
return {
|
|
16
|
+
label: model.model === currentModel || model.id === currentModel ? `${label} ✓` : label,
|
|
17
|
+
value: model.model,
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<SelectionPanel
|
|
23
|
+
focusId={FOCUS_IDS.modelPicker}
|
|
24
|
+
title="Select model"
|
|
25
|
+
subtitle="Use arrow keys and Enter. Esc closes the panel."
|
|
26
|
+
items={items}
|
|
27
|
+
onSelect={onSelect}
|
|
28
|
+
onCancel={onCancel}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|