diff --git a/desktop/index.html b/desktop/index.html index a324904..374c56c 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -27,6 +27,10 @@ Sessions + + + Model settings + Secrets diff --git a/desktop/src/main.ts b/desktop/src/main.ts index c5f55d3..20a5a4d 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -17,6 +17,17 @@ import { setSecret, startRun, } from "./api.js"; +import { + MODEL_CATALOG, + buildRunEnv, + editsFiles, + findModel, + loadSettings, + modelLabel, + saveSettings, + usesAdvancedRunners, + type ModelChoice, +} from "./settings.js"; import type { RunMessage, SessionRecord, TraceResponse } from "./types.js"; const view = document.getElementById("view") as HTMLElement; @@ -62,6 +73,9 @@ const ICONS = { caret: '', folder: '', github: '', + write: '', + review: '', + sliders: '', }; // --- recent repos (persisted locally) -------------------------------------- @@ -129,7 +143,7 @@ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; // --- navigation ------------------------------------------------------------- -type Nav = "start" | "sessions" | "secrets"; +type Nav = "start" | "sessions" | "models" | "secrets"; let teardown: (() => void) | null = null; function navigate(nav: Nav, arg?: string): void { @@ -144,6 +158,7 @@ function navigate(nav: Nav, arg?: string): void { view.scrollTop = 0; if (nav === "start") renderStart(); else if (nav === "sessions") renderSessions(); + else if (nav === "models") renderModels(); else if (nav === "secrets") renderSecrets(); void arg; } @@ -158,129 +173,6 @@ function clearNavActive(): void { // --- Start view ------------------------------------------------------------- -interface Preset { - label: string; - json: string; -} - -const PRESETS: Record = { - openai: { - label: "OpenAI (gpt-4o-mini)", - json: JSON.stringify( - [{ id: "primary", kind: "http", model: "gpt-4o-mini", options: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY" } }], - null, - 2, - ), - }, - anthropic: { - label: "Anthropic (claude-3-5-sonnet)", - json: JSON.stringify( - [{ id: "primary", kind: "http", model: "claude-3-5-sonnet-latest", options: { baseUrl: "https://api.anthropic.com/v1", apiKeyEnv: "ANTHROPIC_API_KEY" } }], - null, - 2, - ), - }, - local: { - label: "Local (Ollama / OpenAI-compatible)", - json: JSON.stringify( - [{ id: "primary", kind: "http", model: "llama3.1", options: { baseUrl: "http://localhost:11434/v1", apiKeyEnv: "OLLAMA_API_KEY" } }], - null, - 2, - ), - }, - split: { - label: "Split actor + critic", - json: JSON.stringify( - [ - { id: "builder", kind: "http", model: "gpt-4o-mini", options: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY" } }, - { id: "reviewer", kind: "http", model: "gpt-4o", options: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY" } }, - ], - null, - 2, - ), - }, - codex: { - label: "Codex CLI (edits files locally)", - json: JSON.stringify( - [ - { - id: "codex", - kind: "cli", - model: "", - options: { - command: "codex", - args: ["exec", "--json", "{{prompt}}"], - promptVia: "arg", - output: { mode: "json-stream", textPath: "msg.text", typeField: "type", type: "item.completed" }, - }, - }, - ], - null, - 2, - ), - }, - kiro: { - label: "Kiro CLI (edits files locally)", - json: JSON.stringify( - [ - { - id: "kiro", - kind: "cli", - model: "", - options: { - command: "kiro", - args: ["--headless", "--prompt", "{{prompt}}"], - promptVia: "arg", - output: { mode: "last-line" }, - }, - }, - ], - null, - 2, - ), - }, - codexCritic: { - label: "Codex actor + OpenAI critic", - json: JSON.stringify( - [ - { - id: "codex", - kind: "cli", - model: "", - options: { - command: "codex", - args: ["exec", "--json", "{{prompt}}"], - promptVia: "arg", - output: { mode: "json-stream", textPath: "msg.text", typeField: "type", type: "item.completed" }, - }, - }, - { id: "reviewer", kind: "http", model: "gpt-4o", options: { baseUrl: "https://api.openai.com/v1", apiKeyEnv: "OPENAI_API_KEY" } }, - ], - null, - 2, - ), - }, -}; - -/** - * Presets whose actor runner edits files directly on disk (CLI agents). Only - * these can actually CHANGE code in the selected repo — HTTP runners return a - * diff string the engine does not apply. The UI nudges toward a CLI actor when - * a repo is selected so a run can produce real, committable changes (item 5). - */ -const FILE_EDITING_PRESETS = new Set(["codex", "kiro", "codexCritic"]); - -/** Default actor/critic ids per preset, so roles match the chosen profiles. */ -const PRESET_ROLES: Record = { - openai: { actor: "primary", critic: "primary" }, - anthropic: { actor: "primary", critic: "primary" }, - local: { actor: "primary", critic: "primary" }, - split: { actor: "builder", critic: "reviewer" }, - codex: { actor: "codex", critic: "codex" }, - kiro: { actor: "kiro", critic: "kiro" }, - codexCritic: { actor: "codex", critic: "reviewer" }, -}; - const EXAMPLE_GOALS = [ "Add a /healthz endpoint with a test", "Fix the failing auth middleware tests", @@ -288,47 +180,69 @@ const EXAMPLE_GOALS = [ "Refactor the config loader to use zod", ]; +/** Last path segment of a repo dir, for compact display in the run box. */ +function repoName(p: string): string { + const parts = p.replace(/[/\\]+$/, "").split(/[/\\]/); + return parts[parts.length - 1] || p; +} + function renderStart(): void { - const page = h("section", { class: "page" }); + const settings = loadSettings(); + const page = h("section", { class: "page page-run" }); - // Header page.append( h("div", { class: "page-head" }, [ h("div", { class: "eyebrow" }, ["Actor – Critic loop"]), h("h1", {}, ["Start a new run"]), - h("p", { class: "page-sub" }, [ - "Describe a goal in plain language. Loopwright plans the work, an actor agent implements it, a critic agent reviews each change, and the loop repeats until the work is verified.", - ]), ]), ); - // How it works - const steps = h("div", { class: "steps" }); - const stepData: Array<[string, string, string, string]> = [ - ["1", ICONS.plan, "Plan", "The goal is broken into small, independently verifiable tasks."], - ["2", ICONS.loop, "Build & critique", "An actor writes code; a critic reviews it. They iterate until it holds up."], - ["3", ICONS.verify, "Verify & integrate", "Mechanical checks run, branches merge, and results are traced end to end."], - ]; - for (const [n, p, title, desc] of stepData) { - steps.append( - h("div", { class: "step", "data-n": n }, [ - icon(p, "step-ico"), - h("h4", {}, [title]), - h("p", {}, [desc]), - ]), - ); + const form = h("form", { class: "card run-box" }); + + // -- Top row: "Goal" label (left) + the selected repository (top-right). + const repoText = h("span", {}); + const repoPill = h("button", { type: "button", class: "repo-pill" }, [icon(ICONS.folder), repoText]); + const repoStatus = h("span", { class: "hint", id: "repo-status" }); + function paintRepo(): void { + const r = settings.repo.trim(); + repoText.textContent = r ? repoName(r) : "No repository selected"; + repoPill.title = r || "Choose a repository"; + repoPill.classList.toggle("unset", !r); } - page.append(steps); + paintRepo(); + repoPill.addEventListener("click", async () => { + // Desktop: pick + validate a folder right here. Browser: send the user to + // Model settings, where a path can be typed (no native picker in a browser). + if (!isTauri()) { + navigate("models"); + return; + } + const dir = await pickDirectory(); + if (!dir) return; + const ok = await checkGitRepo(dir); + if (ok === false) { + repoStatus.textContent = "Not a git repository — pick a folder containing a .git directory."; + repoStatus.className = "hint error"; + return; + } + settings.repo = dir; + saveSettings(settings); + rememberRepo(dir); + repoStatus.textContent = ""; + repoStatus.className = "hint"; + paintRepo(); + }); - // Form - const form = h("form", { class: "card form" }); + const topRow = h("div", { class: "run-box-top" }, [ + h("label", { class: "label", for: "start-goal" }, ["Goal"]), + repoPill, + ]); - // -- Goal field - const goalField = h("div", { class: "field" }); + // -- Goal input + example chips const goalArea = h("textarea", { id: "start-goal", name: "goal", - rows: "3", + rows: "4", placeholder: "e.g. Add a /healthz endpoint that returns 200 and write a test for it", required: "true", }) as HTMLTextAreaElement; @@ -341,70 +255,304 @@ function renderStart(): void { }); chips.append(chip); } - goalField.append( - h("label", { class: "label", for: "start-goal" }, ["Goal"]), - h("div", { class: "desc" }, ["What should the agents accomplish? Be specific about the outcome you expect."]), - goalArea, - chips, + + // -- Bottom row: Start button (left) + the two models in use (bottom-right). + const submit = h("button", { type: "submit", class: "primary" }, [icon(ICONS.rocket), "Start run"]); + const modelsBtn = h("button", { type: "button", class: "run-models", title: "Change in Model settings" }); + if (usesAdvancedRunners(settings)) { + modelsBtn.append(h("span", { class: "run-model" }, [icon(ICONS.sliders, "rm-ico"), h("span", { class: "rm-name" }, ["Custom runners"])])); + } else { + modelsBtn.append( + h("span", { class: "run-model" }, [icon(ICONS.write, "rm-ico"), h("span", { class: "rm-role" }, ["Writes"]), h("span", { class: "rm-name" }, [modelLabel(settings.writer)])]), + h("span", { class: "run-model" }, [icon(ICONS.review, "rm-ico"), h("span", { class: "rm-role" }, ["Reviews"]), h("span", { class: "rm-name" }, [modelLabel(settings.reviewer)])]), + ); + } + modelsBtn.addEventListener("click", () => navigate("models")); + const bottomRow = h("div", { class: "run-box-bottom" }, [submit, modelsBtn]); + + const hint = h("span", { class: "hint", id: "start-hint" }); + + form.append(topRow, goalArea, chips, repoStatus, bottomRow, hint); + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + const goal = goalArea.value.trim(); + if (!goal) { + goalArea.focus(); + return; + } + + const repoDir = settings.repo.trim(); + + // Guard: pushing requires a repo. Fail early with a clear, inline message. + if (settings.pushToRemote && !repoDir) { + hint.textContent = "Select a repository before enabling “Push to GitHub” (Model settings)."; + hint.className = "hint error"; + return; + } + // Desktop: re-verify the path is a git tree before hitting the engine. + if (repoDir && isTauri()) { + const ok = await checkGitRepo(repoDir); + if (ok === false) { + hint.textContent = "The selected repository is not a git repository (Model settings)."; + hint.className = "hint error"; + return; + } + } + + const env = buildRunEnv(settings); + try { + // Validate JSON early so the user gets a clear message, not a 400. + if (env.LOOPWRIGHT_RUNNERS) JSON.parse(env.LOOPWRIGHT_RUNNERS); + } catch (err) { + hint.textContent = `Runner profiles must be valid JSON (Model settings → Advanced): ${(err as Error).message}`; + hint.className = "hint error"; + return; + } + + hint.textContent = "Starting…"; + hint.className = "hint"; + submit.setAttribute("disabled", "true"); + try { + const sessionId = await startRun({ goal, env, ...(repoDir ? { repoDir } : {}) }); + if (repoDir) rememberRepo(repoDir); + saveRunConfig(sessionId, { goal, env, ...(repoDir ? { repoDir } : {}) }); + renderMonitor(sessionId, goal); + } catch (err) { + submit.removeAttribute("disabled"); + hint.textContent = `Failed to start: ${(err as Error).message}`; + hint.className = "hint error"; + } + }); + + page.append(form); + view.append(page); +} + +// --- Model settings view ---------------------------------------------------- + +async function renderModels(): Promise { + const settings = loadSettings(); + const persist = (): void => saveSettings(settings); + + const page = h("section", { class: "page" }); + page.append( + h("div", { class: "page-head" }, [ + h("h1", {}, ["Model settings"]), + h("p", { class: "page-sub" }, [ + "Choose which model writes the code and which reviews it, pick the repository to work in, and control run + publishing options. These apply to every new run.", + ]), + ]), + ); + view.append(page); + + // Availability: HTTP providers are unlocked by a stored API key (Tauri + // keychain); CLI agents by an installed command. In a browser we can't see + // either, so nothing is gated there. + let storedKeys = new Set(); + let knowKeys = false; + let installed: Record = {}; + if (isTauri()) { + try { + storedKeys = new Set(await listSecretKeys()); + knowKeys = true; + } catch { + /* unknown — show everything as selectable */ + } + try { + installed = await detectCommands(["codex", "kiro", "gh"]); + } catch { + /* unknown — don't flag CLI agents as missing */ + } + } + + function providerMissing(p: (typeof MODEL_CATALOG)[number]): boolean { + if (p.kind === "cli") return isTauri() && installed[p.command ?? ""] !== true; + return knowKeys && !storedKeys.has(p.apiKeyEnv ?? ""); + } + function providerMissingLabel(p: (typeof MODEL_CATALOG)[number]): string { + return p.kind === "cli" ? `install ${p.command}` : `add ${p.apiKeyEnv}`; + } + + function modelSelect(role: "writer" | "reviewer", onChange: () => void): HTMLSelectElement { + const current = settings[role]; + const select = h("select", { "aria-label": `${role} model` }) as HTMLSelectElement; + for (const provider of MODEL_CATALOG) { + const missing = providerMissing(provider); + const group = h("optgroup", { label: missing ? `${provider.label} (${providerMissingLabel(provider)})` : provider.label }) as HTMLOptGroupElement; + for (const m of provider.models) { + const opt = h("option", { value: `${provider.id}::${m.id}` }, [m.label]) as HTMLOptionElement; + if (current.provider === provider.id && current.model === m.id) opt.selected = true; + group.append(opt); + } + select.append(group); + } + select.addEventListener("change", () => { + const [p, m] = select.value.split("::"); + settings[role] = { provider: p!, model: m! } as ModelChoice; + persist(); + onChange(); + }); + return select; + } + + function noteFor(role: "writer" | "reviewer", choice: ModelChoice): HTMLElement { + const note = h("div", { class: "model-note" }); + const found = findModel(choice); + if (!found) return note; + const { provider } = found; + if (provider.kind === "http" && knowKeys && !storedKeys.has(provider.apiKeyEnv ?? "")) { + note.className = "model-note warn"; + const link = h("button", { type: "button", class: "linklike" }, [`Add ${provider.apiKeyEnv}`]); + link.addEventListener("click", () => navigate("secrets")); + note.append(h("span", {}, [`No key stored for ${provider.label}. `]), link); + return note; + } + if (provider.kind === "cli" && isTauri() && installed[provider.command ?? ""] !== true) { + note.className = "model-note warn"; + note.append(h("span", {}, [`${provider.command} is not installed — install it to use ${provider.label}.`])); + return note; + } + if (role === "writer") { + if (editsFiles(choice)) { + note.className = "model-note ok"; + note.append(h("span", {}, ["Edits files directly — runs can produce real, committable changes."])); + } else { + note.className = "model-note warn"; + note.append(h("span", {}, ["HTTP models return a diff but don't edit files. Pick a CLI writer (Codex / Kiro) to change a repo."])); + } + return note; + } + note.append(h("span", {}, [provider.kind === "http" ? `Uses ${provider.apiKeyEnv}` : `Local command: ${provider.command}`])); + return note; + } + + const writerNote = h("div", { class: "model-note" }); + const reviewerNote = h("div", { class: "model-note" }); + function setNote(target: HTMLElement, role: "writer" | "reviewer"): void { + const fresh = noteFor(role, settings[role]); + target.className = fresh.className; + target.replaceChildren(...Array.from(fresh.childNodes)); + } + const writerSelect = modelSelect("writer", () => setNote(writerNote, "writer")); + const reviewerSelect = modelSelect("reviewer", () => setNote(reviewerNote, "reviewer")); + setNote(writerNote, "writer"); + setNote(reviewerNote, "reviewer"); + + // -- Advanced: raw runner-profile override (escape hatch for power users) + const advRunners = h("textarea", { rows: "8", spellcheck: "false", class: "mono", "aria-label": "Runner profiles (JSON)" }) as HTMLTextAreaElement; + advRunners.value = settings.advancedRunners; + const advActor = h("input", { type: "text", placeholder: "actor runner id" }) as HTMLInputElement; + advActor.value = settings.advancedActor; + const advCritic = h("input", { type: "text", placeholder: "critic runner id" }) as HTMLInputElement; + advCritic.value = settings.advancedCritic; + const advHint = h("div", { class: "hint" }); + function commitAdvanced(): void { + settings.advancedRunners = advRunners.value; + settings.advancedActor = advActor.value.trim(); + settings.advancedCritic = advCritic.value.trim(); + persist(); + if (settings.advancedRunners.trim()) { + try { + JSON.parse(settings.advancedRunners); + advHint.textContent = "Active — overrides the Writer / Reviewer pickers above."; + advHint.className = "hint ok"; + } catch (err) { + advHint.textContent = `Invalid JSON: ${(err as Error).message}`; + advHint.className = "hint error"; + } + } else { + advHint.textContent = "Leave empty to use the model pickers above."; + advHint.className = "hint"; + } + } + advRunners.addEventListener("input", commitAdvanced); + advActor.addEventListener("input", commitAdvanced); + advCritic.addEventListener("input", commitAdvanced); + commitAdvanced(); + const advancedDetails = h("details", { class: "advanced" }); + if (usesAdvancedRunners(settings)) advancedDetails.setAttribute("open", "true"); + advancedDetails.append( + h("summary", {}, [icon(ICONS.caret, "caret"), "Advanced — custom runner profiles (JSON)"]), + h("div", { class: "advanced-body" }, [ + advRunners, + h("div", { class: "field-grid" }, [ + h("label", { class: "inline-label" }, ["Actor runner id", advActor]), + h("label", { class: "inline-label" }, ["Critic runner id", advCritic]), + ]), + h("small", { class: "desc" }, ["Maps to LOOPWRIGHT_RUNNERS. When set, this overrides the pickers. API keys are referenced by env var name (apiKeyEnv) and resolved from secure storage — never pasted here."]), + advHint, + ]), ); - // -- Repository field (folder picker + recent repos + validation) - const repoField = h("div", { class: "field" }); + const modelsCard = h("div", { class: "card" }, [ + h("h3", {}, ["Models"]), + h("div", { class: "model-grid" }, [ + h("div", { class: "model-pick" }, [ + h("label", { class: "model-pick-head" }, [icon(ICONS.write, "mp-ico"), "Writer", h("span", { class: "desc" }, ["Writes the code"])]), + writerSelect, + writerNote, + ]), + h("div", { class: "model-pick" }, [ + h("label", { class: "model-pick-head" }, [icon(ICONS.review, "mp-ico"), "Reviewer", h("span", { class: "desc" }, ["Reviews the code"])]), + reviewerSelect, + reviewerNote, + ]), + ]), + h("div", { class: "desc keys-hint" }, [ + isTauri() + ? "HTTP models are unlocked by the API keys you store under Secrets; CLI agents must be installed locally." + : "HTTP models are unlocked by the API keys provided in the engine's environment.", + ]), + advancedDetails, + ]); + + // -- Repository const repoInput = h("input", { type: "text", - id: "start-repo", - name: "repoDir", + value: settings.repo, placeholder: isTauri() ? "Select a local git repository…" : "/absolute/path/to/your/repo", autocomplete: "off", spellcheck: "false", }) as HTMLInputElement; const browseBtn = h("button", { type: "button", class: "ghost" }, [icon(ICONS.folder), "Browse…"]); - const repoStatus = h("span", { class: "hint", id: "repo-status" }); - const repoRow = h("div", { class: "repo-row" }, [repoInput, browseBtn]); - - // Validate the path: in the desktop app we can check the filesystem directly; - // in a browser the engine validates on submit, so we just note that. - let repoValid = false; + if (!isTauri()) browseBtn.setAttribute("disabled", "true"); + const repoStatus = h("span", { class: "hint" }); async function validateRepo(): Promise { const dir = repoInput.value.trim(); + settings.repo = dir; + persist(); if (!dir) { - repoValid = false; repoStatus.textContent = "Optional — leave empty to build in the engine's working directory (no worktrees)."; repoStatus.className = "hint"; return; } const ok = await checkGitRepo(dir); if (ok === null) { - // Browser: can't check locally; the engine validates on start. - repoValid = true; repoStatus.textContent = "Will be validated as a git repository when the run starts."; repoStatus.className = "hint"; return; } - repoValid = ok; repoStatus.textContent = ok ? "✓ Git repository detected." : "Not a git repository — pick a folder that contains a .git directory."; repoStatus.className = ok ? "hint ok" : "hint error"; } repoInput.addEventListener("change", () => void validateRepo()); repoInput.addEventListener("blur", () => void validateRepo()); - browseBtn.addEventListener("click", async () => { const dir = await pickDirectory(); if (dir) { repoInput.value = dir; + rememberRepo(dir); await validateRepo(); } }); - if (!isTauri()) browseBtn.setAttribute("disabled", "true"); - // Recent repos (item 11): quick re-selection of previously used folders. const recents = recentRepos(); const recentChips = h("div", { class: "chips" }); if (recents.length) { recentChips.append(h("span", { class: "desc" }, ["Recent:"])); for (const dir of recents) { - const short = dir.length > 40 ? `…${dir.slice(-40)}` : dir; - const chip = h("button", { type: "button", class: "chip", title: dir }, [short]); + const chip = h("button", { type: "button", class: "chip", title: dir }, [repoName(dir)]); chip.addEventListener("click", () => { repoInput.value = dir; void validateRepo(); @@ -413,310 +561,132 @@ function renderStart(): void { } } - repoField.append( - h("label", { class: "label", for: "start-repo" }, ["Repository"]), - h("div", { class: "desc" }, ["The local git repo the agents will edit. Each task builds in an isolated worktree off this repo."]), - repoRow, - repoStatus, - recentChips, - ); - void validateRepo(); - - // -- Model / runner field - const runnerField = h("div", { class: "field" }); - const presetSelect = h("select", { id: "start-preset", name: "preset" }) as HTMLSelectElement; - for (const [key, p] of Object.entries(PRESETS)) { - presetSelect.append(h("option", { value: key }, [p.label])); - } - const runnersArea = h("textarea", { name: "runners", rows: "10", spellcheck: "false", class: "mono", "aria-label": "Runner profiles (JSON)" }) as HTMLTextAreaElement; - runnersArea.value = PRESETS.openai!.json; - - const advanced = h("details", { class: "advanced" }); - const summary = h("summary", {}, [icon(ICONS.caret, "caret"), "Advanced — edit runner profiles (JSON)"]); - advanced.append( - summary, - h("div", { class: "advanced-body" }, [ - runnersArea, - h("small", { class: "desc" }, [ - "Maps to LOOPWRIGHT_RUNNERS. API keys are referenced by env var name (apiKeyEnv) and resolved from secure storage — never pasted here.", - ]), + const repoCard = h("div", { class: "card" }, [ + h("h3", {}, ["Repository"]), + h("div", { class: "field" }, [ + h("div", { class: "desc" }, ["The local git repo the agents edit. Each task builds in an isolated worktree off this repo. Shown on the New run box."]), + h("div", { class: "repo-row" }, [repoInput, browseBtn]), + repoStatus, + recentChips, ]), - ); - - // Nudge: only CLI (file-editing) actors actually change code on disk. HTTP - // runners return a diff the engine does not apply, so they can't update a - // selected repo (item 5). - const runnerHint = h("div", { class: "hint", id: "runner-hint" }); - function updateRunnerHint(): void { - const isFileEditing = FILE_EDITING_PRESETS.has(presetSelect.value); - if (isFileEditing) { - runnerHint.textContent = "✓ This actor edits files directly in the repo, so a run can produce real, committable changes."; - runnerHint.className = "hint ok"; - } else { - runnerHint.textContent = - "Note: HTTP model runners return a diff but do NOT edit files. To actually change a selected repo, pick a CLI actor (Codex or Kiro)."; - runnerHint.className = "hint warn"; - } - } - - presetSelect.addEventListener("change", () => { - const p = PRESETS[presetSelect.value]; - if (p) { - runnersArea.value = p.json; - syncRunnerIds(); - const roles = PRESET_ROLES[presetSelect.value]; - if (roles) { - actorInput.value = roles.actor; - criticInput.value = roles.critic; - } - updateRunnerHint(); - } - }); - - runnerField.append( - h("label", { class: "label", for: "start-preset" }, ["Model provider"]), - h("div", { class: "desc" }, ["Pick a preset to get started, or open Advanced to define your own runner profiles."]), - presetSelect, - runnerHint, - advanced, - ); - - // -- Roles - const rolesGrid = h("div", { class: "field-grid" }); - const actorInput = h("input", { type: "text", name: "actor", value: "primary", list: "runner-ids" }) as HTMLInputElement; - const criticInput = h("input", { type: "text", name: "critic", value: "primary", list: "runner-ids" }) as HTMLInputElement; - const idDatalist = h("datalist", { id: "runner-ids" }); - rolesGrid.append( - h("label", { class: "inline-label" }, ["Actor runner", h("span", { class: "desc" }, ["Writes the code"]), actorInput]), - h("label", { class: "inline-label" }, ["Critic runner", h("span", { class: "desc" }, ["Reviews the code"]), criticInput]), - idDatalist, - ); - const rolesField = h("div", { class: "field" }, [ - h("div", { class: "label" }, ["Roles"]), - h("div", { class: "desc" }, ["Which runner profile plays each role. They can be the same."]), - rolesGrid, ]); + void validateRepo(); - function syncRunnerIds(): void { - idDatalist.innerHTML = ""; - try { - const arr = JSON.parse(runnersArea.value) as Array<{ id?: string }>; - const ids = arr.map((r) => r.id).filter((x): x is string => typeof x === "string"); - for (const id of ids) idDatalist.append(h("option", { value: id })); - // If current actor/critic aren't valid ids, point them at the first one. - if (ids[0] && !ids.includes(actorInput.value)) actorInput.value = ids[0]; - if (ids[0] && !ids.includes(criticInput.value)) criticInput.value = ids[0]; - } catch { - /* invalid JSON — leave inputs as-is, validated on submit */ - } - } - runnersArea.addEventListener("input", syncRunnerIds); - syncRunnerIds(); - updateRunnerHint(); - - // -- Options - const optionsField = h("div", { class: "field" }); - const options = h("div", { class: "options" }); - - function optionRow(name: string, title: string, desc: string, checked: boolean): HTMLElement { - const input = h("input", { type: "checkbox", name, "aria-label": title }) as HTMLInputElement; - if (checked) input.checked = true; + // -- Run options + function switchRow(title: string, desc: string, key: "worktrees" | "mechanicalGate" | "dryRun"): HTMLElement { + const input = h("input", { type: "checkbox", "aria-label": title }) as HTMLInputElement; + if (settings[key]) input.checked = true; + input.addEventListener("change", () => { + settings[key] = input.checked; + persist(); + }); return h("div", { class: "option" }, [ h("div", { class: "option-text" }, [h("strong", {}, [title]), h("span", {}, [desc])]), h("label", { class: "switch" }, [input, h("span", { class: "track" })]), ]); } - // Max parallel stepper - const parallelInput = h("input", { type: "number", name: "maxParallel", min: "1", value: "2", "aria-label": "Max parallel tasks" }) as HTMLInputElement; + const parallelInput = h("input", { type: "number", min: "1", value: String(settings.maxParallel), "aria-label": "Max parallel tasks" }) as HTMLInputElement; const dec = h("button", { type: "button", "aria-label": "decrease" }, [icon("")]); const inc = h("button", { type: "button", "aria-label": "increase" }, [icon("")]); - dec.addEventListener("click", () => { parallelInput.value = String(Math.max(1, Number(parallelInput.value || "1") - 1)); }); - inc.addEventListener("click", () => { parallelInput.value = String(Number(parallelInput.value || "1") + 1); }); + const commitParallel = (): void => { + const parsed = Number(parallelInput.value); + settings.maxParallel = Number.isFinite(parsed) ? Math.max(1, Math.floor(parsed)) : 1; + parallelInput.value = String(settings.maxParallel); + persist(); + }; + dec.addEventListener("click", () => { parallelInput.value = String(Math.max(1, Number(parallelInput.value || "1") - 1)); commitParallel(); }); + inc.addEventListener("click", () => { parallelInput.value = String(Number(parallelInput.value || "1") + 1); commitParallel(); }); + parallelInput.addEventListener("change", commitParallel); const stepper = h("div", { class: "stepper" }, [parallelInput, h("div", { class: "steps-btns" }, [inc, dec])]); - options.append( - h("div", { class: "option" }, [ - h("div", { class: "option-text" }, [h("strong", {}, ["Max parallel tasks"]), h("span", {}, ["How many tasks run at the same time."])]), - stepper, + const optionsCard = h("div", { class: "card" }, [ + h("h3", {}, ["Run options"]), + h("div", { class: "options" }, [ + h("div", { class: "option" }, [ + h("div", { class: "option-text" }, [h("strong", {}, ["Max parallel tasks"]), h("span", {}, ["How many tasks run at the same time."])]), + stepper, + ]), + switchRow("Use git worktrees", "Isolate each task in its own worktree so parallel work never collides.", "worktrees"), + switchRow("Mechanical gate", "Run build / test / lint checks before the critic reviews each change.", "mechanicalGate"), ]), - optionRow("worktrees", "Use git worktrees", "Isolate each task in its own worktree so parallel work never collides.", true), - optionRow("gate", "Mechanical gate", "Run build / test / lint checks before the critic reviews each change.", true), - ); - optionsField.append(h("div", { class: "label" }, ["Options"]), options); - - // -- Publish (GitHub) field -------------------------------------------- - // All push/PR controls live here; everything is opt-in and OFF by default so - // a run never touches a remote unless the user asks (items 7, 8, 9, 12, 13). - const publishField = h("div", { class: "field" }); + ]); - /** Reads the checkbox inside a row built by optionRow. */ - function checkboxOf(row: HTMLElement, name: string): HTMLInputElement { - return row.querySelector(`input[name="${name}"]`) as HTMLInputElement; - } - function labeledInput(label: string, name: string, value: string, placeholder = ""): HTMLElement { - const input = h("input", { type: "text", name, value, placeholder, autocomplete: "off", spellcheck: "false" }) as HTMLInputElement; + // -- Branch & publishing + function textField(label: string, value: string, placeholder: string, on: (v: string) => void): HTMLElement { + const input = h("input", { type: "text", value, placeholder, autocomplete: "off", spellcheck: "false" }) as HTMLInputElement; + input.addEventListener("input", () => on(input.value)); return h("label", { class: "inline-label" }, [label, input]); } + function switchWith(input: HTMLInputElement, title: string, desc: string): HTMLElement { + return h("div", { class: "option" }, [ + h("div", { class: "option-text" }, [h("strong", {}, [title]), h("span", {}, [desc])]), + h("label", { class: "switch" }, [input, h("span", { class: "track" })]), + ]); + } - // Branch naming (item 12) + dry run (item 13). - const branchPrefixField = labeledInput("Branch prefix", "branchPrefix", "loopwright", "loopwright"); - const dryRunRow = optionRow("dryRun", "Dry run", "Build a local integration branch but never push, even if pushing is enabled.", false); - const pushRow = optionRow("pushToRemote", "Push to GitHub", "After a clean, verified integration, push the integration branch to a remote.", false); + const branchPrefixField = textField("Branch prefix", settings.branchPrefix, "loopwright", (v) => { settings.branchPrefix = v; persist(); }); + const dryRunRow = switchRow("Dry run", "Build a local integration branch but never push, even if pushing is enabled.", "dryRun"); + + const pushInput = h("input", { type: "checkbox", "aria-label": "Push to GitHub" }) as HTMLInputElement; + if (settings.pushToRemote) pushInput.checked = true; + const pushRow = switchWith(pushInput, "Push to GitHub", "After a clean, verified integration, push the integration branch to a remote."); - // Push sub-panel (revealed when "Push to GitHub" is on). const pushPanel = h("div", { class: "subpanel", hidden: "true" }); - const remoteGrid = h("div", { class: "field-grid" }, [ - labeledInput("Remote", "remote", "origin", "origin"), - labeledInput("Target branch", "pushBranch", "", "(default: generated integration branch)"), - ]); - const openPrRow = optionRow("openPr", "Open a pull request", "After pushing, open a PR with the GitHub CLI (gh).", false); + const openPrInput = h("input", { type: "checkbox", "aria-label": "Open a pull request" }) as HTMLInputElement; + if (settings.openPr) openPrInput.checked = true; + const openPrRow = switchWith(openPrInput, "Open a pull request", "After pushing, open a PR with the GitHub CLI (gh)."); const prPanel = h("div", { class: "subpanel", hidden: "true" }); + const prDraftInput = h("input", { type: "checkbox", "aria-label": "Open as draft" }) as HTMLInputElement; + if (settings.prDraft) prDraftInput.checked = true; + prDraftInput.addEventListener("change", () => { settings.prDraft = prDraftInput.checked; persist(); }); prPanel.append( h("div", { class: "field-grid" }, [ - labeledInput("PR base branch", "prBase", "", "(repo default branch)"), - labeledInput("PR title", "prTitle", "", "(generated from the goal)"), + textField("PR base branch", settings.prBase, "(repo default branch)", (v) => { settings.prBase = v; persist(); }), + textField("PR title", settings.prTitle, "(generated from the goal)", (v) => { settings.prTitle = v; persist(); }), ]), - optionRow("prDraft", "Open as draft", "Recommended — open the PR as a draft for review.", true), - ); - const overrideRow = optionRow( - "pushOverride", - "Override safety checks (unsafe)", - "Push even if integration failed, there were merge conflicts, or verification did not pass.", - false, + switchWith(prDraftInput, "Open as draft", "Recommended — open the PR as a draft for review."), ); + prPanel.hidden = !settings.openPr; + openPrInput.addEventListener("change", () => { settings.openPr = openPrInput.checked; persist(); prPanel.hidden = !openPrInput.checked; }); - checkboxOf(pushRow, "pushToRemote").addEventListener("change", (e) => { - pushPanel.hidden = !(e.target as HTMLInputElement).checked; - }); - checkboxOf(openPrRow, "openPr").addEventListener("change", (e) => { - prPanel.hidden = !(e.target as HTMLInputElement).checked; - }); + const overrideInput = h("input", { type: "checkbox", "aria-label": "Override safety checks" }) as HTMLInputElement; + if (settings.pushOverride) overrideInput.checked = true; + overrideInput.addEventListener("change", () => { settings.pushOverride = overrideInput.checked; persist(); }); - pushPanel.append(remoteGrid, openPrRow, prPanel, overrideRow); + pushPanel.append( + h("div", { class: "field-grid" }, [ + textField("Remote", settings.remote, "origin", (v) => { settings.remote = v; persist(); }), + textField("Target branch", settings.pushBranch, "(default: generated integration branch)", (v) => { settings.pushBranch = v; persist(); }), + ]), + openPrRow, + prPanel, + switchWith(overrideInput, "Override safety checks (unsafe)", "Push even if integration failed, there were merge conflicts, or verification did not pass."), + ); + pushPanel.hidden = !settings.pushToRemote; + pushInput.addEventListener("change", () => { settings.pushToRemote = pushInput.checked; persist(); pushPanel.hidden = !pushInput.checked; }); - // Environment readiness (items 9, 15): show whether the CLI tools the run may - // need are installed. Desktop only; a browser can't probe local commands. const envPanel = h("div", { class: "env-checks" }); if (isTauri()) { - void detectCommands(["gh", "codex", "kiro"]).then((found) => { - envPanel.innerHTML = ""; - envPanel.append(h("div", { class: "desc" }, ["Detected tools:"])); - for (const name of ["codex", "kiro", "gh"]) { - const ok = found[name] === true; - envPanel.append( - h("span", { class: `tool ${ok ? "ok" : "missing"}` }, [`${name}: ${ok ? "installed" : "missing"}`]), - ); - } - envPanel.append( - h("div", { class: "desc" }, [ - "Pushing uses your local git credentials. Opening a PR needs gh installed and authenticated (gh auth status), or a GITHUB_TOKEN secret.", - ]), - ); - }); + envPanel.append(h("div", { class: "desc" }, ["Detected tools:"])); + for (const name of ["codex", "kiro", "gh"]) { + const ok = installed[name] === true; + envPanel.append(h("span", { class: `tool ${ok ? "ok" : "missing"}` }, [`${name}: ${ok ? "installed" : "missing"}`])); + } + envPanel.append(h("div", { class: "desc" }, ["Pushing uses your local git credentials. Opening a PR needs gh installed and authenticated (gh auth status), or a GITHUB_TOKEN secret."])); } else { - envPanel.append( - h("div", { class: "desc" }, [ - "Pushing uses the engine host's git credentials; opening a PR needs the gh CLI authenticated or a GITHUB_TOKEN in the environment.", - ]), - ); + envPanel.append(h("div", { class: "desc" }, ["Pushing uses the engine host's git credentials; opening a PR needs the gh CLI authenticated or a GITHUB_TOKEN in the environment."])); } - publishField.append( - h("div", { class: "label" }, ["Branch & publishing"]), - h("div", { class: "desc" }, ["Control branch naming and whether a successful run is pushed to GitHub."]), + const publishCard = h("div", { class: "card" }, [ + h("h3", {}, ["Branch & publishing"]), + h("div", { class: "desc" }, ["Control branch naming and whether a successful run is pushed to GitHub. Everything here is opt-in and off by default."]), h("div", { class: "options" }, [branchPrefixField, dryRunRow, pushRow]), pushPanel, envPanel, - ); - - // -- Submit - const hint = h("span", { class: "hint", id: "start-hint" }); - const submit = h("button", { type: "submit", class: "primary" }, [icon(ICONS.rocket), "Start run"]); - const actions = h("div", { class: "actions" }, [submit, hint]); - - form.append(goalField, repoField, runnerField, rolesField, optionsField, publishField, actions); - - form.addEventListener("submit", async (e) => { - e.preventDefault(); - const data = new FormData(form); - const goal = String(data.get("goal") ?? "").trim(); - if (!goal) { - goalArea.focus(); - return; - } - - const repoDir = String(data.get("repoDir") ?? "").trim(); - const pushToRemote = Boolean(data.get("pushToRemote")); - - // Revalidate now: `repoValid` reflects the last change/blur, but a user can - // edit or pick a path and submit before those handlers fire (stale state). - if (repoDir) await validateRepo(); - - // Guard: pushing (or worktrees) requires a repo. Fail early with a clear, - // inline message rather than a server 400. - if (pushToRemote && !repoDir) { - hint.textContent = "Select a repository before enabling “Push to GitHub”."; - hint.className = "hint error"; - repoInput.focus(); - return; - } - - // In the desktop app we know locally whether the path is a git repo; block - // a clearly-invalid selection before hitting the engine. - if (repoDir && !repoValid) { - hint.textContent = "The selected repository path is not a git repository."; - hint.className = "hint error"; - repoInput.focus(); - return; - } - - const env: Record = { - LOOPWRIGHT_RUNNERS: String(data.get("runners") ?? "").trim(), - LOOPWRIGHT_ACTOR_RUNNER: String(data.get("actor") ?? "").trim(), - LOOPWRIGHT_CRITIC_RUNNER: String(data.get("critic") ?? "").trim(), - LOOPWRIGHT_MAX_PARALLEL: String(data.get("maxParallel") ?? "2"), - LOOPWRIGHT_USE_WORKTREES: data.get("worktrees") ? "true" : "false", - LOOPWRIGHT_MECHANICAL_GATE: data.get("gate") ? "true" : "false", - LOOPWRIGHT_BRANCH_PREFIX: String(data.get("branchPrefix") ?? "loopwright").trim() || "loopwright", - LOOPWRIGHT_DRY_RUN: data.get("dryRun") ? "true" : "false", - LOOPWRIGHT_PUSH_TO_REMOTE: pushToRemote ? "true" : "false", - LOOPWRIGHT_REMOTE: String(data.get("remote") ?? "origin").trim() || "origin", - LOOPWRIGHT_PUSH_BRANCH: String(data.get("pushBranch") ?? "").trim(), - LOOPWRIGHT_OPEN_PR: data.get("openPr") ? "true" : "false", - LOOPWRIGHT_PR_BASE: String(data.get("prBase") ?? "").trim(), - LOOPWRIGHT_PR_TITLE: String(data.get("prTitle") ?? "").trim(), - LOOPWRIGHT_PR_DRAFT: data.get("prDraft") ? "true" : "false", - LOOPWRIGHT_PUSH_OVERRIDE_SAFETY: data.get("pushOverride") ? "true" : "false", - }; - - try { - // Validate JSON early so the user gets a clear message, not a 400. - if (env.LOOPWRIGHT_RUNNERS) JSON.parse(env.LOOPWRIGHT_RUNNERS); - } catch (err) { - advanced.setAttribute("open", "true"); - hint.textContent = `Runner profiles must be valid JSON: ${(err as Error).message}`; - hint.className = "hint error"; - return; - } - - hint.textContent = "Starting…"; - hint.className = "hint"; - submit.setAttribute("disabled", "true"); - try { - const sessionId = await startRun({ goal, env, ...(repoDir ? { repoDir } : {}) }); - if (repoDir) rememberRepo(repoDir); - saveRunConfig(sessionId, { goal, env, ...(repoDir ? { repoDir } : {}) }); - renderMonitor(sessionId, goal); - } catch (err) { - submit.removeAttribute("disabled"); - hint.textContent = `Failed to start: ${(err as Error).message}`; - hint.className = "hint error"; - } - }); + ]); - page.append(form); - view.append(page); + page.append(repoCard, modelsCard, optionsCard, publishCard); } // --- Monitor view ----------------------------------------------------------- @@ -1196,6 +1166,30 @@ async function renderSecrets(): Promise { const listEl = h("ul", { class: "secret-list" }); card.append(h("h3", {}, ["Stored keys"]), listEl); + // Models-by-key: makes it explicit which models each environment key unlocks, + // so the user can see exactly what a key buys them before (or after) adding it. + const catalogCard = h("div", { class: "card" }, [h("h3", {}, ["Models by environment key"])]); + const catalogList = h("div", { class: "key-catalog" }); + catalogCard.append(catalogList); + + function renderCatalog(stored: Set): void { + catalogList.innerHTML = ""; + for (const provider of MODEL_CATALOG) { + if (provider.kind !== "http" || !provider.apiKeyEnv) continue; // CLI agents have no key + const has = stored.has(provider.apiKeyEnv); + const head = h("div", { class: "key-cat-head" }, [ + icon(ICONS.key, "key-cat-ico"), + h("code", {}, [provider.apiKeyEnv]), + h("span", { class: "key-cat-prov" }, [provider.label]), + h("span", { class: `key-cat-status ${has ? "on" : "off"}` }, [has ? "stored" : "not stored"]), + ]); + const models = h("div", { class: "key-cat-models" }); + for (const m of provider.models) models.append(h("span", { class: "model-tag" }, [m.label])); + catalogList.append(h("div", { class: `key-cat${has ? " on" : ""}` }, [head, models])); + } + } + page.append(catalogCard); + // Stored secrets are injected into the engine only at (re)start, so changing // them requires a restart to take effect. Make that gate explicit rather than // silently leaving the running engine on stale values. @@ -1219,6 +1213,7 @@ async function renderSecrets(): Promise { return; } formError.hidden = true; + renderCatalog(new Set(keys)); if (!keys.length) listEl.append(h("li", { class: "hint" }, ["No secrets stored yet."])); for (const k of keys) { const del = h("button", { class: "danger small" }, ["Delete"]); diff --git a/desktop/src/settings.ts b/desktop/src/settings.ts new file mode 100644 index 0000000..7f1c2d6 --- /dev/null +++ b/desktop/src/settings.ts @@ -0,0 +1,348 @@ +/** + * Run settings + the model catalog. + * + * The engine itself is vendor-neutral: a run is configured purely by env vars + * (LOOPWRIGHT_RUNNERS + role bindings + publishing flags) plus an optional + * repo path. This module is the UI-side source of truth that turns a friendly + * "which model writes / which model reviews" choice into those env vars, and + * remembers every run-shaping preference between runs so the New run screen can + * stay a minimal goal box while nothing is lost. + * + * HTTP models are grouped by the *environment key* (API key env var) that + * unlocks them; CLI agents (Codex, Kiro) are grouped by the local command that + * must be installed. CLI actors are the ones that actually edit files on disk — + * HTTP runners only return a diff the engine does not apply. + */ + +export type ProviderKind = "http" | "cli"; + +export interface CatalogModel { + /** model id sent to the provider (the runner profile's `model`) */ + id: string; + /** human label shown in the UI */ + label: string; +} + +export interface Provider { + /** stable provider id, e.g. "openai" or "codex" */ + id: string; + /** display name, e.g. "OpenAI" */ + label: string; + kind: ProviderKind; + /** http: OpenAI-compatible base URL the HttpRunner targets */ + baseUrl?: string; + /** http: env var name that holds this provider's API key */ + apiKeyEnv?: string; + /** cli: the command that must be on PATH (used for install detection) */ + command?: string; + /** true for CLI actors that edit files directly (can produce real changes) */ + editsFiles?: boolean; + /** models this provider offers */ + models: CatalogModel[]; +} + +/** + * Real providers and models. HTTP providers are keyed by the environment + * variable that holds the API key; CLI providers are local file-editing agents. + * No placeholder/demo entries: every option here is something a user can + * actually run once the matching key is stored or the command is installed. + */ +export const MODEL_CATALOG: Provider[] = [ + { + id: "openai", + label: "OpenAI", + kind: "http", + baseUrl: "https://api.openai.com/v1", + apiKeyEnv: "OPENAI_API_KEY", + models: [ + { id: "gpt-4o", label: "GPT-4o" }, + { id: "gpt-4o-mini", label: "GPT-4o mini" }, + { id: "gpt-4.1", label: "GPT-4.1" }, + { id: "gpt-4.1-mini", label: "GPT-4.1 mini" }, + { id: "o3", label: "o3" }, + { id: "o4-mini", label: "o4-mini" }, + ], + }, + { + id: "anthropic", + label: "Anthropic", + kind: "http", + baseUrl: "https://api.anthropic.com/v1", + apiKeyEnv: "ANTHROPIC_API_KEY", + models: [ + { id: "claude-3-7-sonnet-latest", label: "Claude 3.7 Sonnet" }, + { id: "claude-3-5-sonnet-latest", label: "Claude 3.5 Sonnet" }, + { id: "claude-3-5-haiku-latest", label: "Claude 3.5 Haiku" }, + ], + }, + { + id: "google", + label: "Google", + kind: "http", + baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", + apiKeyEnv: "GEMINI_API_KEY", + models: [ + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, + { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, + ], + }, + { + id: "codex", + label: "Codex CLI", + kind: "cli", + command: "codex", + editsFiles: true, + models: [{ id: "codex", label: "Codex (edits files)" }], + }, + { + id: "kiro", + label: "Kiro CLI", + kind: "cli", + command: "kiro", + editsFiles: true, + models: [{ id: "kiro", label: "Kiro (edits files)" }], + }, +]; + +/** A concrete model choice: which provider + which model id. */ +export interface ModelChoice { + provider: string; + model: string; +} + +export interface RunSettings { + /** absolute path of the local git repo the run builds against ("" = engine cwd) */ + repo: string; + /** model that writes the code (actor role) */ + writer: ModelChoice; + /** model that reviews the code (critic role) */ + reviewer: ModelChoice; + + /** how many tasks run at once */ + maxParallel: number; + /** isolate each task in its own git worktree */ + worktrees: boolean; + /** run build/test/lint before the critic reviews */ + mechanicalGate: boolean; + + /** prefix for generated branch names */ + branchPrefix: string; + /** build a local integration branch but never push */ + dryRun: boolean; + /** push the integration branch after a clean, verified run */ + pushToRemote: boolean; + /** git remote to push to */ + remote: string; + /** target branch to push to ("" = generated integration branch) */ + pushBranch: string; + /** open a pull request after pushing (needs gh) */ + openPr: boolean; + /** PR base branch ("" = repo default) */ + prBase: string; + /** PR title ("" = generated from goal) */ + prTitle: string; + /** open the PR as a draft */ + prDraft: boolean; + /** push even if integration/verification failed (unsafe) */ + pushOverride: boolean; + + /** + * Power-user escape hatch: raw runner-profile JSON. When non-empty it + * overrides the catalog-derived writer/reviewer runners, and the role ids + * below pick which profile backs each role. + */ + advancedRunners: string; + advancedActor: string; + advancedCritic: string; +} + +const STORAGE_KEY = "loopwright.runSettings.v1"; + +/** First provider that has at least one model — the baseline for defaults. */ +const firstProvider = MODEL_CATALOG[0]!; + +export const DEFAULT_SETTINGS: RunSettings = { + repo: "", + writer: { provider: firstProvider.id, model: "gpt-4o-mini" }, + reviewer: { provider: firstProvider.id, model: "gpt-4o" }, + maxParallel: 2, + worktrees: true, + mechanicalGate: true, + branchPrefix: "loopwright", + dryRun: false, + pushToRemote: false, + remote: "origin", + pushBranch: "", + openPr: false, + prBase: "", + prTitle: "", + prDraft: true, + pushOverride: false, + advancedRunners: "", + advancedActor: "", + advancedCritic: "", +}; + +export function getProvider(id: string): Provider | undefined { + return MODEL_CATALOG.find((p) => p.id === id); +} + +export function findModel(choice: ModelChoice): { provider: Provider; model: CatalogModel } | undefined { + const provider = getProvider(choice.provider); + if (!provider) return undefined; + const model = provider.models.find((m) => m.id === choice.model); + if (!model) return undefined; + return { provider, model }; +} + +/** True when the choice resolves to a CLI agent that edits files on disk. */ +export function editsFiles(choice: ModelChoice): boolean { + return getProvider(choice.provider)?.editsFiles === true; +} + +/** Just the model label, e.g. "GPT-4o mini" (falls back to the raw id). */ +export function modelLabel(choice: ModelChoice): string { + return findModel(choice)?.model.label ?? choice.model ?? "—"; +} + +/** True when an advanced runner-profile override is active. */ +export function usesAdvancedRunners(settings: RunSettings): boolean { + return settings.advancedRunners.trim() !== ""; +} + +/** Loads saved settings, falling back to defaults and tolerating bad/legacy data. */ +export function loadSettings(): RunSettings { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULT_SETTINGS }; + const parsed = JSON.parse(raw) as Partial; + const merged: RunSettings = { + ...DEFAULT_SETTINGS, + ...parsed, + writer: { ...DEFAULT_SETTINGS.writer, ...(parsed.writer ?? {}) }, + reviewer: { ...DEFAULT_SETTINGS.reviewer, ...(parsed.reviewer ?? {}) }, + }; + // Coerce persisted scalars: tampered or legacy localStorage could hold the + // wrong type (e.g. maxParallel "abc", worktrees null), which would otherwise + // flow into buildRunEnv() and fail run startup with invalid config. + const str = (v: unknown, d: string): string => (typeof v === "string" ? v : d); + const bool = (v: unknown, d: boolean): boolean => (typeof v === "boolean" ? v : d); + merged.repo = str(merged.repo, DEFAULT_SETTINGS.repo); + merged.maxParallel = + Number.isFinite(merged.maxParallel) && merged.maxParallel >= 1 + ? Math.floor(merged.maxParallel) + : DEFAULT_SETTINGS.maxParallel; + merged.worktrees = bool(merged.worktrees, DEFAULT_SETTINGS.worktrees); + merged.mechanicalGate = bool(merged.mechanicalGate, DEFAULT_SETTINGS.mechanicalGate); + merged.branchPrefix = str(merged.branchPrefix, DEFAULT_SETTINGS.branchPrefix); + merged.dryRun = bool(merged.dryRun, DEFAULT_SETTINGS.dryRun); + merged.pushToRemote = bool(merged.pushToRemote, DEFAULT_SETTINGS.pushToRemote); + merged.remote = str(merged.remote, DEFAULT_SETTINGS.remote); + merged.pushBranch = str(merged.pushBranch, DEFAULT_SETTINGS.pushBranch); + merged.openPr = bool(merged.openPr, DEFAULT_SETTINGS.openPr); + merged.prBase = str(merged.prBase, DEFAULT_SETTINGS.prBase); + merged.prTitle = str(merged.prTitle, DEFAULT_SETTINGS.prTitle); + merged.prDraft = bool(merged.prDraft, DEFAULT_SETTINGS.prDraft); + merged.pushOverride = bool(merged.pushOverride, DEFAULT_SETTINGS.pushOverride); + merged.advancedRunners = str(merged.advancedRunners, DEFAULT_SETTINGS.advancedRunners); + merged.advancedActor = str(merged.advancedActor, DEFAULT_SETTINGS.advancedActor); + merged.advancedCritic = str(merged.advancedCritic, DEFAULT_SETTINGS.advancedCritic); + // Drop selections that no longer exist in the catalog so the UI never shows + // a stale/removed model as active. + if (!findModel(merged.writer)) merged.writer = { ...DEFAULT_SETTINGS.writer }; + if (!findModel(merged.reviewer)) merged.reviewer = { ...DEFAULT_SETTINGS.reviewer }; + return merged; + } catch { + return { ...DEFAULT_SETTINGS }; + } +} + +export function saveSettings(settings: RunSettings): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + } catch { + /* storage unavailable (private mode / quota) — settings stay in-memory */ + } +} + +/** A runner profile as accepted by LOOPWRIGHT_RUNNERS. */ +interface RunnerProfile { + id: string; + kind: ProviderKind; + model: string; + options: Record; +} + +function profileFor(id: string, choice: ModelChoice): RunnerProfile { + const provider = getProvider(choice.provider) ?? firstProvider; + if (provider.kind === "cli") { + if (provider.id === "kiro") { + return { + id, + kind: "cli", + model: "", + options: { command: "kiro", args: ["--headless", "--prompt", "{{prompt}}"], promptVia: "arg", output: { mode: "last-line" } }, + }; + } + // Codex (default CLI shape) + return { + id, + kind: "cli", + model: "", + options: { + command: "codex", + args: ["exec", "--json", "{{prompt}}"], + promptVia: "arg", + output: { mode: "json-stream", textPath: "msg.text", typeField: "type", type: "item.completed" }, + }, + }; + } + return { + id, + kind: "http", + model: choice.model, + options: { baseUrl: provider.baseUrl, apiKeyEnv: provider.apiKeyEnv }, + }; +} + +/** + * Translates the saved settings into the LOOPWRIGHT_* env the engine expects. + * The writer backs the actor role, the reviewer backs the critic role. An + * advanced runner-profile override, when present, wins over the catalog. + * + * Note: LOOPWRIGHT_RUNNERS is returned verbatim from `advancedRunners` when set; + * callers should validate it is JSON before starting a run. + */ +export function buildRunEnv(settings: RunSettings): Record { + let runnersJson: string; + let actor: string; + let critic: string; + if (usesAdvancedRunners(settings)) { + runnersJson = settings.advancedRunners.trim(); + actor = settings.advancedActor.trim(); + critic = settings.advancedCritic.trim(); + } else { + runnersJson = JSON.stringify([profileFor("writer", settings.writer), profileFor("reviewer", settings.reviewer)]); + actor = "writer"; + critic = "reviewer"; + } + return { + LOOPWRIGHT_RUNNERS: runnersJson, + LOOPWRIGHT_ACTOR_RUNNER: actor, + LOOPWRIGHT_CRITIC_RUNNER: critic, + LOOPWRIGHT_MAX_PARALLEL: String(settings.maxParallel), + LOOPWRIGHT_USE_WORKTREES: settings.worktrees ? "true" : "false", + LOOPWRIGHT_MECHANICAL_GATE: settings.mechanicalGate ? "true" : "false", + LOOPWRIGHT_BRANCH_PREFIX: settings.branchPrefix.trim() || "loopwright", + LOOPWRIGHT_DRY_RUN: settings.dryRun ? "true" : "false", + LOOPWRIGHT_PUSH_TO_REMOTE: settings.pushToRemote ? "true" : "false", + LOOPWRIGHT_REMOTE: settings.remote.trim() || "origin", + LOOPWRIGHT_PUSH_BRANCH: settings.pushBranch.trim(), + LOOPWRIGHT_OPEN_PR: settings.openPr ? "true" : "false", + LOOPWRIGHT_PR_BASE: settings.prBase.trim(), + LOOPWRIGHT_PR_TITLE: settings.prTitle.trim(), + LOOPWRIGHT_PR_DRAFT: settings.prDraft ? "true" : "false", + LOOPWRIGHT_PUSH_OVERRIDE_SAFETY: settings.pushOverride ? "true" : "false", + }; +} diff --git a/desktop/src/styles.css b/desktop/src/styles.css index e3c1db4..ce3cf67 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -608,6 +608,105 @@ button.linkish:hover .s-arrow { color: var(--accent); } } @keyframes spin { to { transform: rotate(360deg); } } +/* =========================================================================== + New run — minimal box + =========================================================================== */ +.page-run { max-width: 680px; } +.run-box { display: flex; flex-direction: column; gap: 14px; } + +.run-box-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.run-box-top .label { font-size: 13px; font-weight: 600; color: var(--text); } + +.repo-pill { + display: inline-flex; align-items: center; gap: 7px; + max-width: 60%; + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: var(--r-pill); + background: var(--surface); + color: var(--text); + font: inherit; font-size: 12.5px; font-weight: 600; + cursor: pointer; + transition: border-color 0.14s ease, color 0.14s ease, background 0.14s ease; +} +.repo-pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.repo-pill svg { width: 15px; height: 15px; flex: none; color: var(--accent); } +.repo-pill:hover { border-color: var(--accent); background: var(--accent-soft); } +.repo-pill.unset { color: var(--muted); border-style: dashed; } +.repo-pill.unset svg { color: var(--faint); } + +.run-box textarea { min-height: 96px; } + +.run-box-bottom { + display: flex; align-items: center; justify-content: space-between; + gap: 14px; flex-wrap: wrap; +} + +.run-models { + display: inline-flex; align-items: center; gap: 6px; + background: transparent; border: none; cursor: pointer; + padding: 4px 6px; border-radius: var(--r-sm); + font: inherit; + transition: background 0.14s ease; +} +.run-models:hover { background: var(--surface); } +.run-model { + display: inline-flex; align-items: center; gap: 7px; + padding: 4px 8px; + border: 1px solid var(--border-soft); + border-radius: var(--r-pill); + background: var(--panel-2); +} +.run-model + .run-model { margin-left: 2px; } +.run-model svg { width: 14px; height: 14px; flex: none; color: var(--accent); } +.run-model .rm-role { font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--faint); } +.run-model .rm-name { font-size: 12px; font-weight: 600; color: var(--text); } + +/* =========================================================================== + Model settings — pickers + =========================================================================== */ +.model-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +.model-pick { display: flex; flex-direction: column; gap: 8px; } +.model-pick-head { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--text); } +.model-pick-head svg { width: 16px; height: 16px; flex: none; color: var(--accent); } +.model-pick-head .desc { margin-left: auto; font-size: 12px; font-weight: 500; color: var(--muted); } + +.model-note { font-size: 12px; color: var(--faint); display: flex; align-items: center; gap: 4px; flex-wrap: wrap; } +.model-note.warn { color: var(--yellow); } +.model-note.ok { color: var(--green); } +.linklike { background: none; border: none; padding: 0; font: inherit; font-size: 12px; color: var(--accent); cursor: pointer; text-decoration: underline; } +.linklike:hover { color: var(--accent-strong); } +.keys-hint { margin-top: 14px; } + +/* =========================================================================== + Secrets — models by environment key + =========================================================================== */ +.key-catalog { display: flex; flex-direction: column; gap: 10px; } +.key-cat { + border: 1px solid var(--border-soft); + border-radius: var(--r-sm); + background: var(--surface); + padding: 12px 14px; + display: flex; flex-direction: column; gap: 9px; +} +.key-cat.on { border-color: rgba(70, 196, 106, 0.3); } +.key-cat-head { display: flex; align-items: center; gap: 9px; } +.key-cat-head .key-cat-ico svg { width: 15px; height: 15px; flex: none; color: var(--faint); } +.key-cat-head code { font-family: var(--mono); font-size: 13px; color: var(--text); } +.key-cat-prov { font-size: 12.5px; color: var(--muted); } +.key-cat-status { + margin-left: auto; font-size: 11px; font-weight: 600; + padding: 3px 10px; border-radius: var(--r-pill); border: 1px solid var(--border); +} +.key-cat-status.on { color: var(--green); border-color: rgba(70, 196, 106, 0.4); background: var(--green-soft); } +.key-cat-status.off { color: var(--faint); background: var(--panel-2); } +.key-cat-models { display: flex; flex-wrap: wrap; gap: 6px; } +.model-tag { + font-family: var(--mono); font-size: 11.5px; color: var(--muted); + background: var(--panel-2); border: 1px solid var(--border-soft); + border-radius: var(--r-pill); padding: 3px 9px; +} + /* =========================================================================== Responsive =========================================================================== */ @@ -626,6 +725,8 @@ button.linkish:hover .s-arrow { color: var(--accent); } .sidebar-footer { margin: 0 0 0 auto; padding: 0; } .steps { grid-template-columns: 1fr; } .field-grid, .secret-form { grid-template-columns: 1fr; } + .model-grid { grid-template-columns: 1fr; } + .repo-pill { max-width: 50%; } .page { padding: 24px 18px 60px; } }