diff --git a/src/main/apps.ts b/src/main/apps.ts index fdf06b15..f3eba225 100644 --- a/src/main/apps.ts +++ b/src/main/apps.ts @@ -39,3 +39,8 @@ export function detectApps(): { editors: string[]; browsers: string[] } { * Used as the default handoff target before falling back to a hardcoded app. */ export const firstInstalledEditor = (): string => EDITOR_APPS.find(appInstalled) || '' export const firstInstalledBrowser = (): string => BROWSER_APPS.find(appInstalled) || '' + +/** Every installed browser, in preference order. The handoff walks this as a + * chain so a configured browser that is missing on THIS machine falls through + * to the next real one instead of straight to the OS default. */ +export const installedBrowsers = (): string[] => BROWSER_APPS.filter(appInstalled) diff --git a/src/main/ipc/system.ts b/src/main/ipc/system.ts index 9a40d173..9a506249 100644 --- a/src/main/ipc/system.ts +++ b/src/main/ipc/system.ts @@ -23,7 +23,7 @@ import { repoRootOf } from '../repo' import { resolveWithinAny } from '../path-guard' import { isExternallyOpenableUrl } from '../../shared/url-safety' import { - resolvedBrowserApp, + resolvedBrowserApps, resolvedEditorApp, resolvedProjectsDir, resolvedWorktreesDir, @@ -116,15 +116,28 @@ export function registerSystemIpc(deps: SystemIpcDeps): void { }) // Hand a target to a configured external app via `open -a ` (robust, no // PATH/CLI dependency), falling back to the OS default if the app isn't there. - function openInApp(appName: string, target: string, fallback: () => void) { + // Walks a candidate chain, best app first, and only reaches `fallback` once + // every candidate has failed. `error` and `exit` can BOTH fire for a single + // failed spawn (a failed spawn exits with a null code, which is != 0), so the + // settled latch is what stops one failure opening the target twice. + function openInApp(candidates: string | string[], target: string, fallback: () => void) { + const [appName, ...rest] = typeof candidates === 'string' ? [candidates] : candidates + if (!appName) return fallback() + let settled = false + const advance = () => { + if (settled) return + settled = true + openInApp(rest, target, fallback) + } try { const p = cpSpawn('open', ['-a', appName, target], { stdio: 'ignore' }) - p.on('error', fallback) + p.on('error', advance) p.on('exit', (code) => { - if (code !== 0) fallback() + if (code === 0) settled = true + else advance() }) } catch { - fallback() + advance() } } // "Open in browser" — the configured browser (default Brave) with its extensions/wallet. @@ -133,7 +146,7 @@ export function registerSystemIpc(deps: SystemIpcDeps): void { // simply a second, unguarded way to reach an OS protocol handler. handle('open:in-browser', (_e, url: string) => { if (!isExternallyOpenableUrl(url)) return openExternalSafe(url) - openInApp(resolvedBrowserApp(), url, () => openExternalSafe(url)) + openInApp(resolvedBrowserApps(), url, () => openExternalSafe(url)) }) // "Open in editor" — the configured editor (default Cursor). Opens a path; defaults // to the active session's repo root. Renderer-supplied paths are constrained to diff --git a/src/main/loops-experiment.test.ts b/src/main/loops-experiment.test.ts index 38d4498d..083f0c64 100644 --- a/src/main/loops-experiment.test.ts +++ b/src/main/loops-experiment.test.ts @@ -54,8 +54,12 @@ describe('the gate is wired where it matters', () => { test('the EntryScreen loop affordance is behind useExperiment', () => { const src = read('src/renderer/src/components/EntryScreen.tsx') expect(src).toContain("useExperiment('loops')") - // The mode toggle must not offer 'loop' when the flag is off. - expect(src).toContain("loopsOn ? (['single', 'loop'] as const) : (['single'] as const)") + // The action select must not offer 'loop' when the flag is off: the option + // list filters on loopsOn… + expect(src).toContain("a.id !== 'loop' || loopsOn") + // …and flipping the flag off mid-session must not strand the screen in a + // loop form whose launch button main would refuse. + expect(src).toMatch(/if \(!loopsOn\) setAction\(/) }) test('the renderer consumes stop and state — every startable loop is stoppable', () => { diff --git a/src/main/settings.ts b/src/main/settings.ts index 19a51ae4..fd69ce8e 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -4,7 +4,7 @@ import { join, dirname } from 'node:path' import { homedir } from 'node:os' import { quarantineCorruptFile, readJsonState, withFileLock, writeFileAtomic } from './atomic-write' import { configPath } from './config-dir' -import { firstInstalledEditor, firstInstalledBrowser } from './apps' +import { firstInstalledEditor, firstInstalledBrowser, installedBrowsers } from './apps' import { DEFAULT_BRIDGE_PORT } from './bridge/identity' import { NOTIFY_CATEGORIES, @@ -935,3 +935,12 @@ export const resolvedEditorApp = () => readSettings().apps.editor || firstInstalledEditor() || DEFAULT_EDITOR export const resolvedBrowserApp = () => readSettings().apps.browser || firstInstalledBrowser() || DEFAULT_BROWSER +/** The browser handoff's full candidate chain, best first: the configured app, + * then every other installed browser. The configured app leads even when we + * can't see it in /Applications — it may live somewhere we don't scan — but a + * machine that simply doesn't have it now falls through to a browser it DOES + * have instead of bailing to the OS default. */ +export const resolvedBrowserApps = (): string[] => { + const chain = [...new Set([readSettings().apps.browser, ...installedBrowsers()].filter(Boolean))] + return chain.length ? chain : [DEFAULT_BROWSER] +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 31abfb7e..9849f42d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1348,7 +1348,7 @@ export default function App() { {/* session tab bar (top-level, also the window drag region) */}
{/* brand mark — the logo asset is already tightly cropped, so it fills the box at scale-1 (no extra zoom) */} diff --git a/src/renderer/src/components/EntryScreen.tsx b/src/renderer/src/components/EntryScreen.tsx index d1cf066f..169bf8d6 100644 --- a/src/renderer/src/components/EntryScreen.tsx +++ b/src/renderer/src/components/EntryScreen.tsx @@ -7,6 +7,8 @@ import { SquareTerminal, RefreshCw, Server, + ArrowLeft, + ArrowRight, ArrowUp, Home, Check, @@ -29,13 +31,18 @@ import type { import { engineLabel, sessionEngineLabel, ENGINE_MODELS, ENGINE_IDS } from '../lib/engines' import { useExperiment } from '../lib/useExperiment' import { EngineLogo } from './EngineLogo' -import { EffortSelect, ModelSelect } from './ModelSelect' +import { EffortDropdown, ModelDropdown } from './ModelSelect' import logo from '../assets/logo.png' import { filterSessionMetas } from '../lib/sessionSearch' import { repoOrientationPendingKey } from '../lib/orientation' import { relativeTime } from '../lib/time' import { SpawnOptions, type SpawnOptionsValue } from './SpawnOptions' import { clampSpawnCount, DEFAULT_SPAWN } from '../lib/spawnOptions' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' export type Choice = { mode: 'new' | 'resume' @@ -186,6 +193,53 @@ const readLastEngine = (): SessionEngine | null => { } } +/** WHERE the work happens. Answered first, because every other question — + * including whether "resume" is even on the table — depends on it. */ +type Target = 'existing' | 'repo' | 'scratch' +/** WHAT to do once the workspace is known. Only an existing repo offers all + * three; a fresh scaffold and a scratch dir have nothing to resume into. */ +type Action = 'new' | 'resume' | 'loop' +/** The two axes collapsed back into the single value the rest of the screen + * (steps, summary, launch) is written against. */ +type Intent = 'new' | 'resume' | 'loop' | 'scratch' | 'repo' + +const TARGETS: { id: Target; label: string; hint: string; icon: typeof SquareTerminal }[] = [ + { + id: 'existing', + label: 'Existing repo', + hint: 'Work in a repo you already have.', + icon: FolderOpen, + }, + { + id: 'repo', + label: 'New repo', + hint: 'Scaffold a project from your template.', + icon: FolderPlus, + }, + { id: 'scratch', label: 'Scratch', hint: 'Throwaway — no repo attached.', icon: Zap }, +] +const ACTIONS: { id: Action; label: string; hint: string; icon: typeof SquareTerminal }[] = [ + { id: 'new', label: 'New session', hint: 'Start an agent in this repo.', icon: SquareTerminal }, + { id: 'resume', label: 'Resume a session', hint: 'Pick up a prior session.', icon: RefreshCw }, + { + id: 'loop', + label: 'Paired loop', + hint: 'Two agents grade each other until the goal is met.', + icon: Repeat, + }, +] + +type StepId = 'workspace' | 'action' | 'model' | 'goal' | 'roles' | 'sessions' | 'review' +const STEP_TITLE: Record = { + workspace: 'Workspace', + action: 'Action', + model: 'Model', + goal: 'Goal', + roles: 'Agents', + sessions: 'Session', + review: 'Review', +} + export function EntryScreen({ onChoose, onCancel, @@ -210,13 +264,26 @@ export function EntryScreen({ // Loops are experimental: with the flag off there is no loop mode at all, and // `loops:create` refuses in main regardless of what this screen renders. const loopsOn = useExperiment('loops') - // 'single' → one session (default). 'loop' → two linked role agents. - const [mode, setMode] = useState<'single' | 'loop'>(initialMode) - useEffect(() => setMode(initialMode), [initialMode]) + // Top-level intent: which of the launch actions the user is composing. It is + // the first field of the wizard (a select), not a tab bar — every intent runs + // through the same one-question-per-step form. + // Split across the two axes the old single list conflated: `target` is WHERE + // (asked first — it decides which actions even exist), `action` is WHAT. + const [target, setTarget] = useState('existing') + const [action, setAction] = useState(initialMode === 'loop' ? 'loop' : 'new') + // Everything downstream still reads one value. A non-repo target has no + // action of its own — there is nothing to resume or loop over. + const intent: Intent = target === 'existing' ? action : target + // Index into `steps` (derived from the intent below). + const [stepIndex, setStepIndex] = useState(0) + useEffect(() => { + setTarget('existing') + setAction(initialMode === 'loop' ? 'loop' : 'new') + }, [initialMode]) // Flipping the flag off while the screen sits in loop mode must not leave a // loop form on screen with a launch button main would reject. useEffect(() => { - if (!loopsOn) setMode('single') + if (!loopsOn) setAction((a) => (a === 'loop' ? 'new' : a)) }, [loopsOn]) // Live-paired loop fields (mode === 'loop'). const [goal, setGoal] = useState('') @@ -237,12 +304,16 @@ export function EntryScreen({ const [visibleSessionCount, setVisibleSessionCount] = useState(SESSION_PAGE_SIZE) const [sessionSearch, setSessionSearch] = useState('') const [cwd, setCwd] = useState(lockedRemote?.cwd || lockedCwd || '') // new-session target - const [filterDir, setFilterDir] = useState(lockedCwd || '') // resume filter ('' = all) // Captured once at mount — the tag stays on the restored engine even as the // user clicks around; clicks persist for the NEXT open via selectEngine. const [recentEngine] = useState(readLastEngine) const [engine, setEngine] = useState(recentEngine ?? 'local') - const [scratchEngine, setScratchEngine] = useState('claude') + // Resume is self-contained: its own engine, independent of the New-session picker. + const [resumeEngine, setResumeEngine] = useState( + recentEngine && isAiEngine(recentEngine) && recentEngine !== 'openrouter' + ? recentEngine + : 'claude', + ) const [model, setModel] = useState(undefined) // '' semantics: undefined = engine default const [effort, setEffort] = useState(undefined) // undefined = engine default const [openrouterHarness, setOpenrouterHarness] = useState<'codex' | 'hermes'>('codex') @@ -263,6 +334,8 @@ export function EntryScreen({ const [pinnedWorkspaces, setPinnedWorkspaces] = useState(() => readWorkspaceList('gt.pinnedWorkspaces'), ) + // Read once: the list must not reshuffle under the user mid-wizard. + const [recentWorkspaces] = useState(() => readWorkspaceList('gt.recentWorkspaces')) // Spawn options apply to every NEW session this screen can start — workspace, // scratch, a freshly scaffolded repo, and the recent-workspace chips. // Deliberately NOT persisted: every visit (and every spawn within a visit) @@ -377,7 +450,7 @@ export function EntryScreen({ } // A restored recent engine should behave like a click — load its resume list. useEffect(() => { - if (isAiEngine(engine)) loadEngineSessions(engine) + if (isAiEngine(resumeEngine) && resumeEngine !== 'openrouter') loadEngineSessions(resumeEngine) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -390,18 +463,30 @@ export function EntryScreen({ } setModel(undefined) // model is engine-specific — reset to the new engine's default setEffort(undefined) // effort levels are engine-specific too + } + const selectResumeEngine = (next: Engine) => { + setResumeEngine(next) setVisibleSessionCount(SESSION_PAGE_SIZE) - if (isAiEngine(next)) loadEngineSessions(next) + if (next !== 'openrouter') loadEngineSessions(next) } - const switchMode = (next: 'single' | 'loop') => { - setMode(next) + const selectTarget = (next: Target) => { + setTarget(next) + setLoopErr('') + setScaffoldErr('') + // Only an existing repo can be resumed into or looped over. + if (next !== 'existing') setAction('new') + } + const selectAction = (next: Action) => { + setAction(next) setLoopErr('') // Loops run in a local git worktree — remote daemons aren't supported. if (next === 'loop' && location === 'remote') switchLocation('local') + if (next === 'resume' && isAiEngine(resumeEngine) && resumeEngine !== 'openrouter') { + void loadEngineSessions(resumeEngine) + } } const switchLocation = (next: 'local' | 'remote') => { setLocation(next) - setFilterDir('') if (lockedCwd) return if (next === 'local') { setCwd('') @@ -421,15 +506,21 @@ export function EntryScreen({ } } - useEffect(() => { - setVisibleSessionCount(SESSION_PAGE_SIZE) - }, [engine, filterDir, sessionSearch]) - - // One-click throwaway session: spin up an engine in the app-owned scratch dir + // Throwaway session: spin up the picked engine in the app-owned scratch dir // (no repo, no folder-picking). For a quick chat you don't want to file away. - const startScratch = async (e: SessionEngine) => { + const startScratch = async () => { const dir = await window.gt.scratchDir() - onChoose(withSpawn({ mode: 'new', engine: e, cwd: dir, name: 'scratch' })) + onChoose( + withSpawn({ + mode: 'new', + engine, + model: isAiEngine(engine) ? model : undefined, + effort: isAiEngine(engine) ? effort : undefined, + cwd: dir, + name: 'scratch', + openrouterHarness: engine === 'openrouter' ? openrouterHarness : undefined, + }), + ) } const loopRepoRoot = (lockedCwd || cwd).trim() const launchLoop = async () => { @@ -450,34 +541,57 @@ export function EntryScreen({ setLoopBusy(false) } } - // selecting a folder targets the new session there AND filters resume to it - const selectDir = (path: string) => { - setCwd(path) - setFilterDir(path) + /** Point the form at a workspace. Pinned/recent chips SELECT — they never + * launch — so the user can pick a repo and then keep customizing the model. + * A remote (`ssh://host/path`) chip also flips the form to that host. */ + const selectWorkspace = (path: string) => { + if (!isRemotePath(path)) { + setLocation('local') + setCwd(path) + setProjParent('') + return + } + const rest = path.replace(/^ssh:\/\//, '') + const slash = rest.indexOf('/') + const target = slash >= 0 ? rest.slice(0, slash) : rest + const remotePath = slash >= 0 ? '/' + rest.slice(slash + 1).replace(/^\/+/, '') : '~' + const host = remoteHosts.find( + (h) => h.label === target || h.sshTarget === target || h.id === target, + ) + if (!host) return setCwd(path) + setLocation('remote') + setRemoteHostId(host.id) + setCwd(remotePath) + setProjParent(remotePath) + if (engine === 'local') setEngine(host.daemon.defaultEngine || 'claude') } const browse = async () => { const dir = await window.gt.pickDir() - if (dir) selectDir(dir) + if (!dir) return + selectWorkspace(dir) } // openrouter is one-shot with no resumable local store — hide its resume list. // Hermes resumes from its ~/.hermes SQLite store, so it's allowed. - const canResume = - isAiEngine(engine) && engine !== 'openrouter' && location === 'local' && !lockedRemote - const sessions = canResume ? sessionsByEngine[engine] : undefined + const canResume = isAiEngine(resumeEngine) && resumeEngine !== 'openrouter' && !lockedRemote + const sessions = canResume ? sessionsByEngine[resumeEngine] : undefined + /** The workspace the resume list is scoped to. Empty = every workspace — + * `lockedCwd` pins it, otherwise it's whatever the workspace step selected. */ + const resumeDir = (lockedCwd || cwd).trim() // Memoized — these scans (6 fields per session) used to re-run on every // keystroke AND every unrelated re-render of this large component. - const scopedSessionCount = useMemo( - () => (sessions ? filterSessionMetas(sessions, { filterDir }).length : 0), - [sessions, filterDir], - ) const shown = useMemo( - () => (sessions ? filterSessionMetas(sessions, { filterDir, query: sessionSearch }) : []), - [sessions, filterDir, sessionSearch], + () => + sessions ? filterSessionMetas(sessions, { query: sessionSearch, filterDir: resumeDir }) : [], + [sessions, sessionSearch, resumeDir], ) + // Any change to what the list is scoped by starts the paging over. + useEffect(() => { + setVisibleSessionCount(SESSION_PAGE_SIZE) + }, [resumeEngine, sessionSearch, resumeDir]) const visibleShown = shown.slice(0, visibleSessionCount) const hiddenShown = Math.max(0, shown.length - visibleShown.length) - const isLoadingThisEngine = canResume ? !!loadingSessions[engine] : false + const isLoadingThisEngine = canResume ? !!loadingSessions[resumeEngine] : false const remoteHost = lockedRemote ? { id: lockedRemote.hostId, @@ -489,6 +603,12 @@ export function EntryScreen({ } : remoteHosts.find((h) => h.id === remoteHostId) || null const remoteCwd = cwd.trim() || remoteHost?.defaultCwd || remoteHost?.daemon?.projectsDir || '' + /** The path currently targeted, in the same spelling the chips use — so a + * chip can render as selected. */ + const selectedWorkspacePath = + location === 'remote' && remoteHost + ? `ssh://${remoteHost.label || remoteHost.sshTarget}${remoteCwd.startsWith('/') ? '' : '/'}${remoteCwd}` + : cwd.trim() const loadRemoteDir = async (path?: string, opts?: { select?: boolean }) => { if (!remoteHost) return @@ -542,36 +662,7 @@ export function EntryScreen({ }, } } - const choiceFromRecent = (path: string): Choice => { - if (!isRemotePath(path)) return { mode: 'new', engine, cwd: path } - const rest = path.replace(/^ssh:\/\//, '') - const slash = rest.indexOf('/') - const target = slash >= 0 ? rest.slice(0, slash) : rest - const remotePath = slash >= 0 ? '/' + rest.slice(slash + 1).replace(/^\/+/, '') : '~' - const host = remoteHosts.find( - (h) => h.label === target || h.sshTarget === target || h.id === target, - ) - if (!host) return { mode: 'new', engine, cwd: path } - const resolvedEngine = engine === 'local' ? host.daemon.defaultEngine || 'claude' : engine - return { - mode: 'new', - engine: resolvedEngine, - cwd: remotePath, - remote: { - hostId: host.id, - label: host.label || host.sshTarget, - sshTarget: host.sshTarget, - cwd: remotePath, - platform: host.platform, - daemon: host.daemon, - }, - } - } - const resumeCountLabel = sessions - ? ` · showing ${visibleShown.length} of ${shown.length}${ - scopedSessionCount !== shown.length ? ` (${scopedSessionCount} in scope)` : '' - }` - : '' + const resumeCountLabel = sessions ? ` · ${shown.length}` : '' const projectParentLabel = location === 'remote' ? remoteDisplayPath(projParent || cwd || remoteCwd || '~') @@ -591,8 +682,49 @@ export function EntryScreen({ ? tilde(cwd.trim()) : '~' - const sel = - 'rounded-lg border border-[var(--gt-border)] bg-black/30 px-3 py-2 text-[12px] text-zinc-200 outline-none focus:border-[var(--gt-accent)]/60' + // ── Wizard ─────────────────────────────────────────────────────────────── + // Workspace FIRST, then what to do in it. The old single "start" list mixed + // the two, so you had to commit to "new session" before knowing the repo — + // and it offered Resume for a scratch dir that can never have one. + // A locked repo answers the workspace question already, so the target picker + // (and the scaffold/scratch options it carries) has nothing left to ask. + const targetOptions = lockedCwd ? [] : TARGETS + const actionOptions = ACTIONS.filter((a) => a.id !== 'loop' || loopsOn) + const steps: StepId[] = useMemo(() => { + const tail: StepId[] = + intent === 'resume' + ? ['sessions'] + : intent === 'loop' + ? ['goal', 'roles'] + : ['model', 'review'] + // A locked repo skips the workspace step; every other path opens on it. + // The action step only earns its place when more than one action applies. + const head: StepId[] = lockedCwd + ? ['action'] + : target === 'existing' + ? ['workspace', 'action'] + : ['workspace'] + return [...head, ...tail] + }, [intent, target, lockedCwd]) + const step = steps[Math.min(stepIndex, steps.length - 1)] + const stepNumber = steps.indexOf(step) + 1 + const back = () => setStepIndex((i) => Math.max(0, i - 1)) + const next = () => setStepIndex((i) => Math.min(steps.length - 1, i + 1)) + // Changing the intent re-derives the steps; never strand the user past the end. + useEffect(() => { + setStepIndex((i) => Math.min(i, steps.length - 1)) + }, [steps.length]) + // A step that can't be left until its one question is answered. Everything + // up to (and including) the FIRST such step is reachable, so the rail can be + // clicked forward as well as back — you don't have to walk through Next. + const stepBlocked = (s: StepId) => + (s === 'workspace' && target === 'repo' && !projName.trim()) || + (s === 'goal' && !goal.trim()) || + (s === 'workspace' && intent === 'loop' && !loopRepoRoot) + const firstBlocked = steps.findIndex(stepBlocked) + const maxReachable = firstBlocked === -1 ? steps.length - 1 : firstBlocked + const nextBlocked = stepBlocked(step) + const sectionTitle = 'text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-500' const pickButton = (active: boolean, disabled = false) => `group flex min-h-[44px] items-center gap-3 rounded-xl border px-3 text-left transition-colors ${ @@ -602,316 +734,527 @@ export function EntryScreen({ ? 'cursor-not-allowed border-[var(--gt-border)] bg-black/10 text-zinc-700' : 'border-[var(--gt-border)] bg-black/20 text-zinc-400 hover:border-[var(--gt-accent)]/50 hover:text-zinc-200' }` + /** A big pick-one card: icon, label, hint. Shared by the two axes so the + * workspace targets and the actions read as the same kind of choice. */ + const optionCard = (active: boolean) => + `flex items-start gap-2.5 rounded-xl border p-3 text-left transition-colors ${ + active + ? 'border-primary/70 bg-primary/10' + : 'border-border bg-muted/20 hover:border-primary/50' + }` + /** Compact inline option — the small-scale sibling of pickButton. Used where + * the whole option set fits on one row (hosts, resume engines). */ + const chip = (active: boolean) => + `inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-[12px] font-semibold transition-colors ${ + active + ? 'border-primary/70 bg-primary/15 text-foreground' + : 'border-border bg-muted/30 text-muted-foreground hover:border-primary/50 hover:text-foreground' + }` + + const STEP_COPY: Record = { + workspace: { + title: 'Which workspace?', + hint: 'Where the work happens — nothing launches until the last step.', + }, + action: { title: 'What do you want to do here?', hint: 'Scoped to the workspace above.' }, + model: { title: 'Which model?', hint: 'Engine first, then the model it runs.' }, + goal: { + title: 'What should the loop converge on?', + hint: 'The driver turns this into a gradable contract.', + }, + roles: { title: 'Who runs the loop?', hint: 'Each role gets its own engine and model.' }, + sessions: { + title: 'Pick a session to resume', + hint: 'Sessions are listed per engine, newest first.', + }, + review: { title: 'Ready to launch', hint: 'Last look before anything starts.' }, + } + const { title: stepTitle, hint: stepHint } = STEP_COPY[step] + const samePath = (a: string, b: string) => a.replace(/\/+$/, '') === b.replace(/\/+$/, '') + + // Pinned + recent workspaces, as SELECTABLE chips (they set the target repo; + // the wizard's last step is the only thing that launches). + const pins = pinnedWorkspaces + const recents = recentWorkspaces.filter((r) => !pins.includes(r)).slice(0, 8) + const workspaceChip = (r: string) => { + const isPinned = pins.includes(r) + const isSelected = !!selectedWorkspacePath && samePath(selectedWorkspacePath, r) + return ( +
+ + +
+ ) + } + + const summaryRow = (label: string, value: string, mono = false) => ( +
+ {label} + + {value} + +
+ ) return ( -
-
-
+
+
+ {/* Header */} +
-

TerMinal

-
+
+

+ TerMinal +

+

+ {lockedCwd ? ( + <> + New session in{' '} + {tilde(lockedCwd)} + + ) : ( + 'A local-first cockpit for your coding agents.' + )} +

+
{onCancel && ( - - )} -
-

- {lockedCwd ? ( - <> - New session in {tilde(lockedCwd)} — - pick "Start" for a fresh session - {lockedRemote ? '.' : ' or attach to a prior one below.'} - - ) : ( - <> - Pick an engine, attach it to a local or SSH daemon profile, then choose the workspace - that Tickets, MRs, Agents, Runs, Files, CI, and Search should read from. - + )} -

+
- {mode === 'single' && ( - persistPrompts([...savedPrompts, p])} - onDeleteCustom={(id) => persistPrompts(savedPrompts.filter((p) => p.id !== id))} + {/* Step rail — any reachable step is clickable, forward or back. A step + is unreachable only when an earlier one is still unanswered. */} +
+ {steps.map((s, i) => { + const done = i < stepNumber - 1 + const active = i === stepNumber - 1 + const reachable = i <= maxReachable + return ( + + ) + })} + + {stepNumber}/{steps.length} + +
+
+
- )} +
- {!lockedCwd && ( -
-
- -
-
Scratch session
-
- Throwaway — spins up in{' '} - ~/.config/TerMinal/scratch, no repo attached -
-
-
- {/* The engine mark sits inside the select's box rather than - beside it — a native setScratchEngine(e.target.value as SessionEngine)} - aria-label="Scratch session engine" - className={`${sel} pl-8`} + + + {stepTitle} +

{stepHint}

+
+ + {/* ── Step · Action ────────────────────────────────────────── */} + {step === 'action' && ( +
+ {actionOptions.map(({ id, label, hint, icon: Icon }) => ( +
- -
-
- {mode === 'single' && ( -
-
-
- -
-
Brand-new repo
-
From template
-
-
-
- { - setProjName(e.target.value) - setScaffoldErr('') - }} - onKeyDown={(e) => e.key === 'Enter' && createProject()} - placeholder="project-name" - spellCheck={false} - className={`${sel} min-w-0 flex-1 font-mono`} + - - -
-
- {/* Ticket-provider choice (local / GitHub / Linear / …) is - deliberately NOT here: the per-repo orientation modal that - auto-opens on first launch of a fresh repo carries the full - picker, so the scaffold row stays a single input + button. */} - {scaffoldErr && ( -
- {scaffoldErr} -
- Check your network/VPN and that the template repo (Settings → Projects → - templateRepo) is reachable — a private repo needs your git auth. -
-
- )} + + + {label} + + + {hint} + + + + ))}
)} -
- )} -
-
-
- -
-
New workspace session
-
- {daemonLabel} daemon · {selectedWorkspaceLabel} -
-
-
- - {location === 'remote' ? 'SSH daemon' : 'Local daemon'} - -
- -
-
- {(loopsOn ? (['single', 'loop'] as const) : (['single'] as const)).map((m) => ( - - ))} -
- {loopsOn && mode === 'loop' && ( -
- Two linked agents in one worktree — a worker{' '} - writes code, a driver negotiates the contract - and grades it. Opened side by side, contract-first. -
- )} - {!lockedCwd && - (() => { - const pins = pinnedWorkspaces - const recents = readWorkspaceList('gt.recentWorkspaces') - .filter((r) => !pins.includes(r)) - .slice(0, 8) - if (!pins.length && !recents.length) return null - const chip = (r: string) => { - const isPinned = pins.includes(r) - return ( -
- - + + + {label} + + + {hint} + + + + ))}
- ) - } - return ( -
- {pins.length > 0 && ( +
+ )} + + {target === 'scratch' && ( +

+ A throwaway session in the app-owned scratch dir. No repo, nothing to file away. +

+ )} + + {target === 'repo' && ( + <> + {remoteHosts.length > 0 && (
-
Pinned
-
{pins.map(chip)}
+
Location
+
+ + +
)} - {recents.length > 0 && ( +
+
Name
+
+ { + setProjName(e.target.value) + setScaffoldErr('') + }} + placeholder="project-name" + className="flex-1 font-mono" + /> + +
+
+ + )} + + {target === 'existing' && ( + <> +
+
Location
+
+ + +
+ {remoteHosts.length === 0 && ( +
+ SSH profiles are configured in Settings → SSH Hosts. +
+ )} +
+ + {location === 'remote' && remoteHosts.length > 0 && (
-
Recent workspaces
-
{recents.map(chip)}
+
Host
+
+ {remoteHosts.map((h) => ( + + ))} +
)} -
- ) - })()} - {mode === 'single' && ( -
-
1 · Engine
-
- {engineOptions.map((e) => ( - + + +
+
+ {remoteListingLoading && !remoteListing ? ( +
+ {' '} + Loading folders… +
+ ) : remoteListingErr ? ( +
+ {remoteListingErr} +
+ ) : remoteListing && remoteListing.entries.length === 0 ? ( +
+ No child folders. +
+ ) : ( + remoteListing?.entries.map((d) => ( + + )) + )} +
+
+ ) : ( + <> + {(pins.length > 0 || recents.length > 0) && ( +
+ {pins.length > 0 && ( +
+
Pinned
+
+ {pins.map(workspaceChip)} +
+
+ )} + {recents.length > 0 && ( +
+
Recent
+
+ {recents.map(workspaceChip)} +
+
+ )} +
+ )} +
+
Folder
+
+ + setCwd(e.target.value)} + placeholder={intent === 'resume' ? 'Every workspace' : '~ (home)'} + className="flex-1 font-mono" + /> + {/* Resume can legitimately span every repo — a new + session cannot, so the escape hatch is resume-only. */} + {intent === 'resume' && cwd.trim() && ( + + )} +
+
+ + )} + + {/* Remote only — the local path input above already IS the + confirmation, so repeating it there is noise. */} + {location === 'remote' && ( +
+ + {daemonLabel} + + {selectedWorkspaceLabel} - )} - - ))} +
+ )} + + )} + + )} + + {/* ── Step · Model ─────────────────────────────────────────── */} + {step === 'model' && ( + <> +
+
Engine
+
+ {engineOptions.map((e) => ( + + ))} +
+ {engine === 'openrouter' && ( -
-
- Harness -
+
+
Harness
{(['codex', 'hermes'] as const).map((h) => (
)} - {isAiEngine(engine) && ( -
-
- Model -
-
- - {!(engine === 'openrouter' && openrouterHarness === 'hermes') && ( - - )} + + {isAiEngine(engine) ? ( +
+
+
Model
+
+ {!(engine === 'openrouter' && openrouterHarness === 'hermes') && ( +
+
Reasoning effort
+ +
+ )}
+ ) : ( +

+ A plain shell — no model, no agent. +

)} -
+ )} - {loopsOn && mode === 'loop' && ( + {/* ── Step · Loop goal ─────────────────────────────────────── */} + {step === 'goal' && ( <>
-
1 · Goal
-