feat: add Codex CLI detection and model management APIs

- Implemented IPC handlers for checking Codex CLI installation status, retrieving available models, and checking provider statuses.
- Enhanced the SettingsView to include OpenAI API key management and connection testing.
- Updated the feature executor to support multiple model providers (Claude and Codex), allowing for improved flexibility in feature execution.
- Introduced utility functions to determine model types and support for thinking controls.

This update enhances the application's capabilities by integrating Codex CLI support and improving model management, providing users with a more robust experience.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Kacper
2025-12-10 03:00:15 +01:00
parent e260eafcb9
commit 6d130ca2b5
19 changed files with 3128 additions and 121 deletions

View File

@@ -1362,6 +1362,39 @@
box-shadow: 0 0 8px #f97e72;
}
/* Line clamp utilities for text overflow prevention */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* Kanban card improvements to prevent text overflow */
.kanban-card-content {
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
/* Ensure proper column layout in double-width kanban columns */
.kanban-columns-layout > * {
page-break-inside: avoid;
break-inside: avoid;
display: block;
width: 100%;
box-sizing: border-box;
}
/* Electron title bar drag region */
.titlebar-drag-region {
-webkit-app-region: drag;

View File

@@ -246,13 +246,13 @@ export function AgentOutputModal({
<Loader2 className="w-5 h-5 text-purple-500 animate-spin" />
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-zinc-900/50 rounded-lg p-1">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode("parsed")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "parsed"
? "bg-purple-500/20 text-purple-300 shadow-sm"
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-parsed"
>
@@ -264,7 +264,7 @@ export function AgentOutputModal({
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "raw"
? "bg-purple-500/20 text-purple-300 shadow-sm"
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-raw"
>

View File

@@ -25,7 +25,7 @@ import {
ThinkingLevel,
} from "@/store/app-store";
import { getElectronAPI } from "@/lib/electron";
import { cn } from "@/lib/utils";
import { cn, modelSupportsThinking } from "@/lib/utils";
import {
Card,
CardDescription,
@@ -92,6 +92,76 @@ const COLUMNS: { id: ColumnId; title: string; color: string }[] = [
{ id: "verified", title: "Verified", color: "bg-green-500" },
];
type ModelOption = {
id: AgentModel;
label: string;
description: string;
badge?: string;
provider: "claude" | "codex";
};
const CLAUDE_MODELS: ModelOption[] = [
{
id: "haiku",
label: "Claude Haiku",
description: "Fast and efficient for simple tasks.",
badge: "Speed",
provider: "claude",
},
{
id: "sonnet",
label: "Claude Sonnet",
description: "Balanced performance with strong reasoning.",
badge: "Balanced",
provider: "claude",
},
{
id: "opus",
label: "Claude Opus",
description: "Most capable model for complex work.",
badge: "Premium",
provider: "claude",
},
];
const CODEX_MODELS: ModelOption[] = [
{
id: "gpt-5.1-codex-max",
label: "GPT-5.1 Codex Max",
description: "Flagship Codex model tuned for deep coding tasks.",
badge: "Flagship",
provider: "codex",
},
{
id: "gpt-5.1-codex",
label: "GPT-5.1 Codex",
description: "Strong coding performance with lower cost.",
badge: "Standard",
provider: "codex",
},
{
id: "gpt-5.1-codex-mini",
label: "GPT-5.1 Codex Mini",
description: "Fastest Codex option for lightweight edits.",
badge: "Fast",
provider: "codex",
},
{
id: "gpt-5.1",
label: "GPT-5.1",
description: "General-purpose reasoning with solid coding ability.",
badge: "General",
provider: "codex",
},
{
id: "o3",
label: "OpenAI O3",
description: "Reasoning-focused model for tricky problems.",
badge: "Reasoning",
provider: "codex",
},
];
export function BoardView() {
const {
currentProject,
@@ -597,6 +667,10 @@ export function BoardView() {
const handleAddFeature = () => {
const category = newFeature.category || "Uncategorized";
const selectedModel = newFeature.model;
const normalizedThinking = modelSupportsThinking(selectedModel)
? newFeature.thinkingLevel
: "none";
addFeature({
category,
description: newFeature.description,
@@ -605,8 +679,8 @@ export function BoardView() {
images: newFeature.images,
imagePaths: newFeature.imagePaths,
skipTests: newFeature.skipTests,
model: newFeature.model,
thinkingLevel: newFeature.thinkingLevel,
model: selectedModel,
thinkingLevel: normalizedThinking,
});
// Persist the category
saveCategory(category);
@@ -626,13 +700,18 @@ export function BoardView() {
const handleUpdateFeature = () => {
if (!editingFeature) return;
const selectedModel = (editingFeature.model ?? "opus") as AgentModel;
const normalizedThinking = modelSupportsThinking(selectedModel)
? editingFeature.thinkingLevel
: "none";
updateFeature(editingFeature.id, {
category: editingFeature.category,
description: editingFeature.description,
steps: editingFeature.steps,
skipTests: editingFeature.skipTests,
model: editingFeature.model,
thinkingLevel: editingFeature.thinkingLevel,
model: selectedModel,
thinkingLevel: normalizedThinking,
});
// Persist the category if it's new
if (editingFeature.category) {
@@ -1096,6 +1175,64 @@ export function BoardView() {
startNextFeaturesRef.current = handleStartNextFeatures;
}, [handleStartNextFeatures]);
const renderModelOptions = (
options: ModelOption[],
selectedModel: AgentModel,
onSelect: (model: AgentModel) => void,
testIdPrefix = "model-select"
) => (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{options.map((option) => {
const isSelected = selectedModel === option.id;
const isCodex = option.provider === "codex";
return (
<button
key={option.id}
type="button"
onClick={() => onSelect(option.id)}
className={cn(
"w-full rounded-lg border p-3 text-left transition-all",
"hover:-translate-y-[1px] hover:shadow-sm",
isSelected
? isCodex
? "border-emerald-500 bg-emerald-600 text-white shadow-sm"
: "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-input bg-background hover:border-primary/40"
)}
data-testid={`${testIdPrefix}-${option.id}`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-sm">{option.label}</span>
{option.badge && (
<span
className={cn(
"text-[11px] uppercase tracking-wide px-2 py-0.5 rounded-full border",
isSelected
? "border-primary-foreground/60 bg-primary-foreground/15 text-primary-foreground"
: isCodex
? "border-emerald-500/60 text-emerald-700 dark:text-emerald-200"
: "border-primary/50 text-primary"
)}
>
{option.badge}
</span>
)}
</div>
<p className={cn(
"text-xs leading-snug mt-1",
isSelected ? "text-primary-foreground/90" : "text-muted-foreground"
)}>
{option.description}
</p>
</button>
);
})}
</div>
);
const newModelAllowsThinking = modelSupportsThinking(newFeature.model);
const editModelAllowsThinking = modelSupportsThinking(editingFeature?.model);
if (!currentProject) {
return (
<div
@@ -1207,7 +1344,7 @@ export function BoardView() {
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-white/10 border border-white/20"
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-accent border border-border-glass"
data-testid="shortcut-add-feature"
>
{ACTION_SHORTCUTS.addFeature}
@@ -1265,7 +1402,7 @@ export function BoardView() {
>
<FastForward className="w-3 h-3 mr-1" />
Start Next
<span className="ml-1 px-1 py-0.5 text-[9px] font-mono rounded bg-white/10 border border-white/20">
<span className="ml-1 px-1 py-0.5 text-[9px] font-mono rounded bg-accent border border-border-glass">
{ACTION_SHORTCUTS.startNext}
</span>
</Button>
@@ -1438,37 +1575,66 @@ export function BoardView() {
</p>
{/* Model Selection */}
<div className="space-y-2">
<div className="space-y-3">
<Label className="flex items-center gap-2">
<Zap className="w-4 h-4 text-muted-foreground" />
Model
</Label>
<div className="flex gap-2">
{(["haiku", "sonnet", "opus"] as AgentModel[]).map((model) => (
<button
key={model}
type="button"
onClick={() => setNewFeature({ ...newFeature, model })}
className={cn(
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors",
newFeature.model === model
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`model-select-${model}`}
>
{model === "haiku" && "Haiku"}
{model === "sonnet" && "Sonnet"}
{model === "opus" && "Opus"}
</button>
))}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground font-medium">Claude (SDK)</p>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
Native
</span>
</div>
{renderModelOptions(
CLAUDE_MODELS,
newFeature.model,
(model) =>
setNewFeature({
...newFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? newFeature.thinkingLevel
: "none",
})
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground font-medium">
OpenAI via Codex CLI
</p>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/50 text-emerald-600 dark:text-emerald-300">
CLI
</span>
</div>
{renderModelOptions(
CODEX_MODELS,
newFeature.model,
(model) =>
setNewFeature({
...newFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? newFeature.thinkingLevel
: "none",
})
)}
</div>
<p className="text-xs text-muted-foreground">
Haiku for simple tasks, Sonnet for balanced, Opus for complex tasks.
Claude models use the Claude SDK. OpenAI models run through the Codex CLI.
{!newModelAllowsThinking && (
<span className="block mt-1 text-amber-600 dark:text-amber-400">
Thinking controls are hidden for Codex CLI models.
</span>
)}
</p>
</div>
{/* Thinking Level */}
{/* Thinking Level - Hidden for Codex models */}
{newModelAllowsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-muted-foreground" />
@@ -1508,6 +1674,7 @@ export function BoardView() {
Higher thinking levels give the model more time to reason through complex problems.
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setShowAddDialog(false)}>
@@ -1520,7 +1687,7 @@ export function BoardView() {
>
Add Feature
<span
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-white/10 border border-white/20"
className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20"
data-testid="shortcut-confirm-add-feature"
>
@@ -1628,37 +1795,68 @@ export function BoardView() {
</p>
{/* Model Selection */}
<div className="space-y-2">
<div className="space-y-3">
<Label className="flex items-center gap-2">
<Zap className="w-4 h-4 text-muted-foreground" />
Model
</Label>
<div className="flex gap-2">
{(["haiku", "sonnet", "opus"] as AgentModel[]).map((model) => (
<button
key={model}
type="button"
onClick={() => setEditingFeature({ ...editingFeature, model })}
className={cn(
"flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors",
(editingFeature.model ?? "opus") === model
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`edit-model-select-${model}`}
>
{model === "haiku" && "Haiku"}
{model === "sonnet" && "Sonnet"}
{model === "opus" && "Opus"}
</button>
))}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground font-medium">Claude (SDK)</p>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-primary/40 text-primary">
Native
</span>
</div>
{renderModelOptions(
CLAUDE_MODELS,
(editingFeature.model ?? "opus") as AgentModel,
(model) =>
setEditingFeature({
...editingFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? editingFeature.thinkingLevel
: "none",
}),
"edit-model-select"
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground font-medium">
OpenAI via Codex CLI
</p>
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/50 text-emerald-600 dark:text-emerald-300">
CLI
</span>
</div>
{renderModelOptions(
CODEX_MODELS,
(editingFeature.model ?? "opus") as AgentModel,
(model) =>
setEditingFeature({
...editingFeature,
model,
thinkingLevel: modelSupportsThinking(model)
? editingFeature.thinkingLevel
: "none",
}),
"edit-model-select"
)}
</div>
<p className="text-xs text-muted-foreground">
Haiku for simple tasks, Sonnet for balanced, Opus for complex tasks.
Claude models use the Claude SDK. OpenAI models run through the Codex CLI.
{!editModelAllowsThinking && (
<span className="block mt-1 text-amber-600 dark:text-amber-400">
Thinking controls are hidden for Codex CLI models.
</span>
)}
</p>
</div>
{/* Thinking Level */}
{/* Thinking Level - Hidden for Codex models */}
{editModelAllowsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-muted-foreground" />
@@ -1698,6 +1896,7 @@ export function BoardView() {
Higher thinking levels give the model more time to reason through complex problems.
</p>
</div>
)}
</div>
)}
<DialogFooter>
@@ -1860,7 +2059,7 @@ export function BoardView() {
>
<MessageSquare className="w-4 h-4 mr-2" />
Send Follow-Up
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-white/10 border border-white/20">
<span className="ml-2 px-1.5 py-0.5 text-[10px] font-mono rounded bg-primary-foreground/10 border border-primary-foreground/20">
</span>
</Button>

View File

@@ -191,7 +191,7 @@ export function KanbanCard({
ref={setNodeRef}
style={style}
className={cn(
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative",
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content",
isDragging && "opacity-50 scale-105 shadow-lg",
isCurrentAutoTask &&
"border-purple-500 border-2 shadow-purple-500/50 shadow-lg animate-pulse"
@@ -260,10 +260,10 @@ export function KanbanCard({
</div>
)}
<div className="flex-1 min-w-0">
<CardTitle className="text-sm leading-tight">
<CardTitle className="text-sm leading-tight break-words hyphens-auto line-clamp-3">
{feature.description}
</CardTitle>
<CardDescription className="text-xs mt-1">
<CardDescription className="text-xs mt-1 truncate">
{feature.category}
</CardDescription>
</div>
@@ -283,7 +283,7 @@ export function KanbanCard({
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
)}
<span className="truncate">{step}</span>
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
</div>
))}
{feature.steps.length > 3 && (
@@ -302,7 +302,7 @@ export function KanbanCard({
agentInfo &&
(isCurrentAutoTask || feature.status === "in_progress") && (
<div className="mb-3 space-y-1">
<div className="w-full h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="w-full h-full bg-primary transition-transform duration-500 ease-out origin-left"
style={{
@@ -349,7 +349,7 @@ export function KanbanCard({
{/* Progress Indicator */}
{(isCurrentAutoTask || feature.status === "in_progress") && (
<div className="space-y-1">
<div className="w-full h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="w-full h-full bg-primary transition-transform duration-500 ease-out origin-left"
style={{
@@ -367,7 +367,7 @@ export function KanbanCard({
</span>
{agentInfo.lastToolUsed && (
<span
className="text-zinc-500 truncate max-w-[80px]"
className="text-muted-foreground truncate max-w-[80px]"
title={agentInfo.lastToolUsed}
>
{agentInfo.lastToolUsed}
@@ -403,15 +403,15 @@ export function KanbanCard({
) : todo.status === "in_progress" ? (
<Loader2 className="w-2.5 h-2.5 text-amber-400 animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-zinc-500 shrink-0" />
<Circle className="w-2.5 h-2.5 text-muted-foreground shrink-0" />
)}
<span
className={cn(
"truncate",
"break-words hyphens-auto line-clamp-2 leading-relaxed",
todo.status === "completed" &&
"text-zinc-500 line-through",
"text-muted-foreground line-through",
todo.status === "in_progress" && "text-amber-400",
todo.status === "pending" && "text-zinc-400"
todo.status === "pending" && "text-foreground-secondary"
)}
>
{todo.content}
@@ -432,7 +432,7 @@ export function KanbanCard({
feature.status === "verified") && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1 pt-1 border-t border-white/5">
<div className="space-y-1 pt-1 border-t border-border-glass">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-[10px] text-green-400">
<Sparkles className="w-3 h-3" />
@@ -443,14 +443,14 @@ export function KanbanCard({
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
className="p-0.5 rounded hover:bg-white/10 transition-colors text-zinc-500 hover:text-zinc-300"
className="p-0.5 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
>
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-zinc-400 line-clamp-3">
<p className="text-[10px] text-foreground-secondary line-clamp-3 break-words hyphens-auto leading-relaxed">
{feature.summary || summary || agentInfo.summary}
</p>
</div>
@@ -460,7 +460,7 @@ export function KanbanCard({
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-white/5">
<div className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-border-glass">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
@@ -753,7 +753,7 @@ export function KanbanCard({
: feature.description}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4 bg-zinc-900/50 rounded-lg border border-white/10">
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border">
<Markdown>
{feature.summary ||
summary ||

View File

@@ -50,7 +50,7 @@ export function KanbanColumn({
className={cn(
"flex-1 overflow-y-auto p-2",
isDoubleWidth
? "columns-2 gap-2 [&>*]:break-inside-avoid [&>*]:mb-2"
? "columns-2 gap-3 [&>*]:break-inside-avoid [&>*]:mb-3 [&>*]:overflow-hidden kanban-columns-layout"
: "space-y-2"
)}
>

View File

@@ -46,8 +46,10 @@ export function SettingsView() {
} = useAppStore();
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
const [googleKey, setGoogleKey] = useState(apiKeys.google);
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
const [showGoogleKey, setShowGoogleKey] = useState(false);
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
const [saved, setSaved] = useState(false);
const [testingConnection, setTestingConnection] = useState(false);
const [testResult, setTestResult] = useState<{
@@ -74,10 +76,32 @@ export function SettingsView() {
};
error?: string;
} | null>(null);
const [codexCliStatus, setCodexCliStatus] = useState<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
hasApiKey?: boolean;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
} | null>(null);
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
const [openaiTestResult, setOpenaiTestResult] = useState<{
success: boolean;
message: string;
} | null>(null);
useEffect(() => {
setAnthropicKey(apiKeys.anthropic);
setGoogleKey(apiKeys.google);
setOpenaiKey(apiKeys.openai);
}, [apiKeys]);
useEffect(() => {
@@ -91,6 +115,14 @@ export function SettingsView() {
console.error("Failed to check Claude CLI status:", error);
}
}
if (api?.checkCodexCli) {
try {
const status = await api.checkCodexCli();
setCodexCliStatus(status);
} catch (error) {
console.error("Failed to check Codex CLI status:", error);
}
}
};
checkCliStatus();
}, []);
@@ -167,10 +199,64 @@ export function SettingsView() {
}
};
const handleTestOpenaiConnection = async () => {
setTestingOpenaiConnection(true);
setOpenaiTestResult(null);
try {
const api = getElectronAPI();
if (api?.testOpenAIConnection) {
const result = await api.testOpenAIConnection(openaiKey);
if (result.success) {
setOpenaiTestResult({
success: true,
message: result.message || "Connection successful! OpenAI API responded.",
});
} else {
setOpenaiTestResult({
success: false,
message: result.error || "Failed to connect to OpenAI API.",
});
}
} else {
// Fallback to web API test
const response = await fetch("/api/openai/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ apiKey: openaiKey }),
});
const data = await response.json();
if (response.ok && data.success) {
setOpenaiTestResult({
success: true,
message: data.message || "Connection successful! OpenAI API responded.",
});
} else {
setOpenaiTestResult({
success: false,
message: data.error || "Failed to connect to OpenAI API.",
});
}
}
} catch (error) {
setOpenaiTestResult({
success: false,
message: "Network error. Please check your connection.",
});
} finally {
setTestingOpenaiConnection(false);
}
};
const handleSave = () => {
setApiKeys({
anthropic: anthropicKey,
google: googleKey,
openai: openaiKey,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
@@ -273,7 +359,7 @@ export function SettingsView() {
)}
</Button>
</div>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted-foreground">
Used for Claude AI features. Get your key at{" "}
<a
href="https://console.anthropic.com/account/keys"
@@ -367,7 +453,7 @@ export function SettingsView() {
)}
</Button>
</div>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted-foreground">
Used for Gemini AI features (including image/design prompts).
Get your key at{" "}
<a
@@ -403,6 +489,99 @@ export function SettingsView() {
)}
</div>
{/* OpenAI API Key */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label htmlFor="openai-key" className="text-foreground">
OpenAI API Key (Codex/GPT)
</Label>
{apiKeys.openai && (
<CheckCircle2 className="w-4 h-4 text-brand-500" />
)}
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id="openai-key"
type={showOpenaiKey ? "text" : "password"}
value={openaiKey}
onChange={(e) => setOpenaiKey(e.target.value)}
placeholder="sk-..."
className="pr-10 bg-input border-border text-foreground placeholder:text-muted-foreground"
data-testid="openai-api-key-input"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground hover:bg-transparent"
onClick={() => setShowOpenaiKey(!showOpenaiKey)}
data-testid="toggle-openai-visibility"
>
{showOpenaiKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
</div>
<Button
type="button"
variant="secondary"
onClick={handleTestOpenaiConnection}
disabled={!openaiKey || testingOpenaiConnection}
className="bg-secondary hover:bg-accent text-secondary-foreground border border-border"
data-testid="test-openai-connection"
>
{testingOpenaiConnection ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Testing...
</>
) : (
<>
<Zap className="w-4 h-4 mr-2" />
Test
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Used for OpenAI Codex CLI and GPT models.
Get your key at{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:text-brand-400 hover:underline"
>
platform.openai.com
</a>
</p>
{openaiTestResult && (
<div
className={`flex items-center gap-2 p-3 rounded-lg ${
openaiTestResult.success
? "bg-green-500/10 border border-green-500/20 text-green-400"
: "bg-red-500/10 border border-red-500/20 text-red-400"
}`}
data-testid="openai-test-connection-result"
>
{openaiTestResult.success ? (
<CheckCircle2 className="w-4 h-4" />
) : (
<AlertCircle className="w-4 h-4" />
)}
<span
className="text-sm"
data-testid="openai-test-connection-message"
>
{openaiTestResult.message}
</span>
</div>
)}
</div>
{/* Security Notice */}
<div className="flex items-start gap-3 p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
@@ -419,13 +598,13 @@ export function SettingsView() {
{/* Claude CLI Status Section */}
{claudeCliStatus && (
<div className="rounded-xl border border-white/10 bg-zinc-900/50 backdrop-blur-md overflow-hidden">
<div className="p-6 border-b border-white/10">
<div className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden">
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Terminal className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-white">Claude Code CLI</h2>
<h2 className="text-lg font-semibold text-foreground">Claude Code CLI</h2>
</div>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
Claude Code CLI provides better performance for long-running tasks, especially with ultrathink.
</p>
</div>
@@ -452,7 +631,7 @@ export function SettingsView() {
</div>
</div>
{claudeCliStatus.recommendation && (
<p className="text-xs text-zinc-400">{claudeCliStatus.recommendation}</p>
<p className="text-xs text-muted-foreground">{claudeCliStatus.recommendation}</p>
)}
</div>
) : (
@@ -468,24 +647,123 @@ export function SettingsView() {
</div>
{claudeCliStatus.installCommands && (
<div className="space-y-2">
<p className="text-xs font-medium text-zinc-300">Installation Commands:</p>
<p className="text-xs font-medium text-foreground-secondary">Installation Commands:</p>
<div className="space-y-1">
{claudeCliStatus.installCommands.npm && (
<div className="p-2 rounded bg-zinc-950/50 border border-white/5">
<p className="text-xs text-zinc-400 mb-1">npm:</p>
<code className="text-xs text-zinc-300 font-mono break-all">{claudeCliStatus.installCommands.npm}</code>
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">npm:</p>
<code className="text-xs text-foreground-secondary font-mono break-all">{claudeCliStatus.installCommands.npm}</code>
</div>
)}
{claudeCliStatus.installCommands.macos && (
<div className="p-2 rounded bg-zinc-950/50 border border-white/5">
<p className="text-xs text-zinc-400 mb-1">macOS/Linux:</p>
<code className="text-xs text-zinc-300 font-mono break-all">{claudeCliStatus.installCommands.macos}</code>
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">macOS/Linux:</p>
<code className="text-xs text-foreground-secondary font-mono break-all">{claudeCliStatus.installCommands.macos}</code>
</div>
)}
{claudeCliStatus.installCommands.windows && (
<div className="p-2 rounded bg-zinc-950/50 border border-white/5">
<p className="text-xs text-zinc-400 mb-1">Windows (PowerShell):</p>
<code className="text-xs text-zinc-300 font-mono break-all">{claudeCliStatus.installCommands.windows}</code>
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">Windows (PowerShell):</p>
<code className="text-xs text-foreground-secondary font-mono break-all">{claudeCliStatus.installCommands.windows}</code>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Codex CLI Status Section */}
{codexCliStatus && (
<div className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden">
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<Terminal className="w-5 h-5 text-green-500" />
<h2 className="text-lg font-semibold text-foreground">OpenAI Codex CLI</h2>
</div>
<p className="text-sm text-muted-foreground">
Codex CLI enables GPT-5.1 Codex models for autonomous coding tasks.
</p>
</div>
<div className="p-6 space-y-4">
{codexCliStatus.success && codexCliStatus.status === 'installed' ? (
<div className="space-y-3">
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-green-400">Codex CLI Installed</p>
<div className="text-xs text-green-400/80 mt-1 space-y-1">
{codexCliStatus.method && (
<p>Method: <span className="font-mono">{codexCliStatus.method}</span></p>
)}
{codexCliStatus.version && (
<p>Version: <span className="font-mono">{codexCliStatus.version}</span></p>
)}
{codexCliStatus.path && (
<p className="truncate" title={codexCliStatus.path}>
Path: <span className="font-mono text-[10px]">{codexCliStatus.path}</span>
</p>
)}
</div>
</div>
</div>
{codexCliStatus.recommendation && (
<p className="text-xs text-muted-foreground">{codexCliStatus.recommendation}</p>
)}
</div>
) : codexCliStatus.status === 'api_key_only' ? (
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-blue-400">API Key Detected - CLI Not Installed</p>
<p className="text-xs text-blue-400/80 mt-1">
{codexCliStatus.recommendation || 'OPENAI_API_KEY found but Codex CLI not installed. Install the CLI for full agentic capabilities.'}
</p>
</div>
</div>
{codexCliStatus.installCommands && (
<div className="space-y-2">
<p className="text-xs font-medium text-foreground-secondary">Installation Commands:</p>
<div className="space-y-1">
{codexCliStatus.installCommands.npm && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">npm:</p>
<code className="text-xs text-foreground-secondary font-mono break-all">{codexCliStatus.installCommands.npm}</code>
</div>
)}
</div>
</div>
)}
</div>
) : (
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-yellow-400">Codex CLI Not Detected</p>
<p className="text-xs text-yellow-400/80 mt-1">
{codexCliStatus.recommendation || 'Install OpenAI Codex CLI to use GPT-5.1 Codex models for autonomous coding.'}
</p>
</div>
</div>
{codexCliStatus.installCommands && (
<div className="space-y-2">
<p className="text-xs font-medium text-foreground-secondary">Installation Commands:</p>
<div className="space-y-1">
{codexCliStatus.installCommands.npm && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">npm:</p>
<code className="text-xs text-foreground-secondary font-mono break-all">{codexCliStatus.installCommands.npm}</code>
</div>
)}
{codexCliStatus.installCommands.macos && (
<div className="p-2 rounded bg-background border border-border-glass">
<p className="text-xs text-muted-foreground mb-1">macOS (Homebrew):</p>
<code className="text-xs text-foreground-secondary font-mono break-all">{codexCliStatus.installCommands.macos}</code>
</div>
)}
</div>
@@ -664,34 +942,34 @@ export function SettingsView() {
</div>
{/* Kanban Card Display Section */}
<div className="rounded-xl border border-white/10 bg-zinc-900/50 backdrop-blur-md overflow-hidden">
<div className="p-6 border-b border-white/10">
<div className="rounded-xl border border-border bg-card backdrop-blur-md overflow-hidden">
<div className="p-6 border-b border-border">
<div className="flex items-center gap-2 mb-2">
<LayoutGrid className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-semibold text-white">
<h2 className="text-lg font-semibold text-foreground">
Kanban Card Display
</h2>
</div>
<p className="text-sm text-zinc-400">
<p className="text-sm text-muted-foreground">
Control how much information is displayed on Kanban cards.
</p>
</div>
<div className="p-6 space-y-4">
<div className="space-y-3">
<Label className="text-zinc-300">Detail Level</Label>
<Label className="text-foreground-secondary">Detail Level</Label>
<div className="grid grid-cols-3 gap-3">
<button
onClick={() => setKanbanCardDetailLevel("minimal")}
className={`flex flex-col items-center justify-center gap-2 px-4 py-4 rounded-lg border transition-all ${
kanbanCardDetailLevel === "minimal"
? "bg-white/5 border-brand-500 text-white"
: "bg-zinc-950/50 border-white/10 text-zinc-400 hover:text-white hover:bg-white/5"
? "bg-accent border-brand-500 text-foreground"
: "bg-input border-border text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="kanban-detail-minimal"
>
<Minimize2 className="w-5 h-5" />
<span className="font-medium text-sm">Minimal</span>
<span className="text-xs text-zinc-500 text-center">
<span className="text-xs text-muted-foreground text-center">
Title & category only
</span>
</button>
@@ -699,14 +977,14 @@ export function SettingsView() {
onClick={() => setKanbanCardDetailLevel("standard")}
className={`flex flex-col items-center justify-center gap-2 px-4 py-4 rounded-lg border transition-all ${
kanbanCardDetailLevel === "standard"
? "bg-white/5 border-brand-500 text-white"
: "bg-zinc-950/50 border-white/10 text-zinc-400 hover:text-white hover:bg-white/5"
? "bg-accent border-brand-500 text-foreground"
: "bg-input border-border text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="kanban-detail-standard"
>
<Square className="w-5 h-5" />
<span className="font-medium text-sm">Standard</span>
<span className="text-xs text-zinc-500 text-center">
<span className="text-xs text-muted-foreground text-center">
Steps & progress
</span>
</button>
@@ -714,19 +992,19 @@ export function SettingsView() {
onClick={() => setKanbanCardDetailLevel("detailed")}
className={`flex flex-col items-center justify-center gap-2 px-4 py-4 rounded-lg border transition-all ${
kanbanCardDetailLevel === "detailed"
? "bg-white/5 border-brand-500 text-white"
: "bg-zinc-950/50 border-white/10 text-zinc-400 hover:text-white hover:bg-white/5"
? "bg-accent border-brand-500 text-foreground"
: "bg-input border-border text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="kanban-detail-detailed"
>
<Maximize2 className="w-5 h-5" />
<span className="font-medium text-sm">Detailed</span>
<span className="text-xs text-zinc-500 text-center">
<span className="text-xs text-muted-foreground text-center">
Model, tools & tasks
</span>
</button>
</div>
<p className="text-xs text-zinc-500">
<p className="text-xs text-muted-foreground">
<strong>Minimal:</strong> Shows only title and category
<br />
<strong>Standard:</strong> Adds steps preview and progress bar
@@ -757,7 +1035,7 @@ export function SettingsView() {
<Button
variant="secondary"
onClick={() => setCurrentView("welcome")}
className="bg-white/5 hover:bg-white/10 text-white border border-white/10"
className="bg-secondary hover:bg-accent text-secondary-foreground border border-border"
data-testid="back-to-home"
>
Back to Home

View File

@@ -42,7 +42,7 @@ export interface StatResult {
}
// Auto Mode types - Import from electron.d.ts to avoid duplication
import type { AutoModeEvent } from "@/types/electron";
import type { AutoModeEvent, ModelDefinition, ProviderStatus } from "@/types/electron";
export interface AutoModeAPI {
start: (projectPath: string) => Promise<{ success: boolean; error?: string }>;
@@ -94,6 +94,39 @@ export interface ElectronAPI {
};
error?: string;
}>;
checkCodexCli?: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
hasApiKey?: boolean;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}>;
model?: {
getAvailable: () => Promise<{
success: boolean;
models?: ModelDefinition[];
error?: string;
}>;
checkProviders: () => Promise<{
success: boolean;
providers?: Record<string, ProviderStatus>;
error?: string;
}>;
};
testOpenAIConnection?: (apiKey?: string) => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
}
declare global {
@@ -359,6 +392,28 @@ export const getElectronAPI = (): ElectronAPI => {
return { success: true, path: tempFilePath };
},
checkClaudeCli: async () => ({
success: false,
status: "not_installed",
recommendation: "Claude CLI checks are unavailable in the web preview.",
}),
checkCodexCli: async () => ({
success: false,
status: "not_installed",
recommendation: "Codex CLI checks are unavailable in the web preview.",
}),
model: {
getAvailable: async () => ({ success: true, models: [] }),
checkProviders: async () => ({ success: true, providers: {} }),
},
testOpenAIConnection: async () => ({
success: false,
error: "OpenAI connection test is only available in the Electron app.",
}),
// Mock Auto Mode API
autoMode: createMockAutoModeAPI(),
};

View File

@@ -29,7 +29,27 @@ export interface LogEntry {
};
}
const generateId = () => Math.random().toString(36).substring(2, 9);
/**
* Generates a deterministic ID based on content and position
* This ensures the same log entry always gets the same ID,
* preserving expanded/collapsed state when new logs stream in
*
* Uses only the first 200 characters of content to ensure stability
* even when entries are merged (which appends content at the end)
*/
const generateDeterministicId = (content: string, lineIndex: number): string => {
// Use first 200 chars to ensure stability when entries are merged
const stableContent = content.slice(0, 200);
// Simple hash function for the content
let hash = 0;
const str = stableContent + '|' + lineIndex.toString();
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return 'log_' + Math.abs(hash).toString(36);
};
/**
* Detects the type of log entry based on content patterns
@@ -165,24 +185,32 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
const entries: LogEntry[] = [];
const lines = rawOutput.split("\n");
let currentEntry: LogEntry | null = null;
let currentEntry: Omit<LogEntry, 'id'> & { id?: string } | null = null;
let currentContent: string[] = [];
let entryStartLine = 0; // Track the starting line for deterministic ID generation
const finalizeEntry = () => {
if (currentEntry && currentContent.length > 0) {
currentEntry.content = currentContent.join("\n").trim();
if (currentEntry.content) {
entries.push(currentEntry);
// Generate deterministic ID based on content and position
const entryWithId: LogEntry = {
...currentEntry as Omit<LogEntry, 'id'>,
id: generateDeterministicId(currentEntry.content, entryStartLine),
};
entries.push(entryWithId);
}
}
currentContent = [];
};
let lineIndex = 0;
for (const line of lines) {
const trimmedLine = line.trim();
// Skip empty lines at the beginning
if (!trimmedLine && !currentEntry) {
lineIndex++;
continue;
}
@@ -204,9 +232,11 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
// Finalize previous entry
finalizeEntry();
// Start new entry
// Track starting line for deterministic ID
entryStartLine = lineIndex;
// Start new entry (ID will be generated when finalizing)
currentEntry = {
id: generateId(),
type: lineType,
title: generateTitle(lineType, trimmedLine),
content: "",
@@ -220,15 +250,18 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
// Continue current entry
currentContent.push(line);
} else {
// Track starting line for deterministic ID
entryStartLine = lineIndex;
// No current entry, create a default info entry
currentEntry = {
id: generateId(),
type: "info",
title: "Info",
content: "",
};
currentContent.push(line);
}
lineIndex++;
}
// Finalize last entry
@@ -248,6 +281,7 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
const merged: LogEntry[] = [];
let current: LogEntry | null = null;
let mergeIndex = 0;
for (const entry of entries) {
if (
@@ -255,13 +289,15 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
(current.type === "debug" || current.type === "info") &&
current.type === entry.type
) {
// Merge into current
// Merge into current - regenerate ID based on merged content
current.content += "\n\n" + entry.content;
current.id = generateDeterministicId(current.content, mergeIndex);
} else {
if (current) {
merged.push(current);
}
current = { ...entry };
mergeIndex = merged.length;
}
}

View File

@@ -1,6 +1,55 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { AgentModel } from "@/store/app-store"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Check if a model is a Codex/OpenAI model (doesn't support thinking)
*/
export function isCodexModel(model?: AgentModel | string): boolean {
if (!model) return false;
const codexModels: string[] = [
"gpt-5.1-codex-max",
"gpt-5.1-codex",
"gpt-5.1-codex-mini",
"gpt-5.1",
"o3",
"o3-mini",
"o4-mini",
"gpt-4o",
"gpt-4o-mini",
];
return codexModels.includes(model);
}
/**
* Determine if the current model supports extended thinking controls
*/
export function modelSupportsThinking(model?: AgentModel | string): boolean {
if (!model) return true;
return !isCodexModel(model);
}
/**
* Get display name for a model
*/
export function getModelDisplayName(model: AgentModel | string): string {
const displayNames: Record<string, string> = {
haiku: "Claude Haiku",
sonnet: "Claude Sonnet",
opus: "Claude Opus",
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
"gpt-5.1-codex": "GPT-5.1 Codex",
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
"gpt-5.1": "GPT-5.1",
o3: "OpenAI O3",
"o3-mini": "OpenAI O3 Mini",
"o4-mini": "OpenAI O4 Mini",
"gpt-4o": "GPT-4o",
"gpt-4o-mini": "GPT-4o Mini",
};
return displayNames[model] || model;
}

View File

@@ -32,6 +32,7 @@ export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
export interface ApiKeys {
anthropic: string;
google: string;
openai: string;
}
export interface ImageAttachment {
@@ -76,7 +77,22 @@ export interface FeatureImagePath {
}
// Available models for feature execution
export type AgentModel = "opus" | "sonnet" | "haiku";
// Claude models
export type ClaudeModel = "opus" | "sonnet" | "haiku";
// OpenAI/Codex models
export type OpenAIModel =
| "gpt-5.1-codex-max"
| "gpt-5.1-codex"
| "gpt-5.1-codex-mini"
| "gpt-5.1"
| "o3"
| "o3-mini"
| "o4-mini";
// Combined model type
export type AgentModel = ClaudeModel | OpenAIModel;
// Model provider type
export type ModelProvider = "claude" | "codex";
// Thinking level (budget_tokens) options
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
@@ -226,6 +242,7 @@ const initialState: AppState = {
apiKeys: {
anthropic: "",
google: "",
openai: "",
},
chatSessions: [],
currentChatSession: null,

View File

@@ -340,6 +340,74 @@ export interface ElectronAPI {
};
error?: string;
}>;
// Codex CLI Detection API
checkCodexCli: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
hasApiKey?: boolean;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}>;
// Model Management APIs
model: {
// Get all available models from all providers
getAvailable: () => Promise<{
success: boolean;
models?: ModelDefinition[];
error?: string;
}>;
// Check all provider installation status
checkProviders: () => Promise<{
success: boolean;
providers?: Record<string, ProviderStatus>;
error?: string;
}>;
};
// OpenAI API
testOpenAIConnection: (apiKey?: string) => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
}
// Model definition type
export interface ModelDefinition {
id: string;
name: string;
modelString: string;
provider: "claude" | "codex";
description?: string;
tier?: "basic" | "standard" | "premium";
default?: boolean;
}
// Provider status type
export interface ProviderStatus {
status: "installed" | "not_installed" | "api_key_only";
method?: string;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
}
declare global {