Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/client/app/settings/SkillsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const PROVIDER_LABELS: Record<AgentProvider, string> = {
codex: "Codex",
cursor: "Cursor",
pi: "Pi",
opencode: "opencode",
}

function formatInstallCount(count: number) {
Expand Down
43 changes: 40 additions & 3 deletions src/client/components/auth/AuthCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AUTH_SERVICE_ICONS } from "../provider-icons"
import { Button } from "../ui/button"
import { CopyButton } from "../ui/copy-button"
import { Input } from "../ui/input"
import { OpenCodeSignInDialog } from "./OpenCodeSignInDialog"

/** "2.1.218" → "v2.1.218"; calendar/otherwise-shaped versions pass through. */
function displayVersion(version: string | null): string | null {
Expand Down Expand Up @@ -223,21 +224,31 @@ export function AuthCard({
service,
socket,
className,
badge,
}: {
service: AuthServiceSnapshot
socket: KannaSocket
className?: string
/** Small pill beside the name (onboarding uses it to mark a recommendation). */
badge?: string
}) {
const Icon = AUTH_SERVICE_ICONS[service.service]
const version = displayVersion(service.version)
const installing = service.installState === "installing"
const loginActive = service.login.phase !== "idle"
const [openCodeDialogOpen, setOpenCodeDialogOpen] = useState(false)

const startLogin = () => {
if (service.service === "openrouter") {
void startOpenRouterOauth(socket).catch(() => undefined)
return
}
if (service.service === "opencode") {
// opencode's picker runs in a terminal inside a dialog — see
// OpenCodeSignInDialog for why there is no server-driven flow.
setOpenCodeDialogOpen(true)
return
}
void socket.command({ type: "auth.login.start", service: service.service }).catch(() => undefined)
}
const install = () => {
Expand Down Expand Up @@ -274,9 +285,22 @@ export function AuthCard({
action = <ActionButton onClick={install}>Update to {displayVersion(service.latestVersion)}</ActionButton>
} else if (service.authStatus === "signed_in") {
action = (
<span className="flex shrink-0 items-center pr-2" title={service.account ?? "Connected"}>
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
</span>
<div className="flex shrink-0 items-center gap-2">
{/* opencode holds one credential per provider, so connecting another
stays useful after the first — every other service is one account. */}
{service.service === "opencode" ? (
<button
type="button"
onClick={startLogin}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Add
</button>
) : null}
<span className="flex items-center pr-2" title={service.account ?? "Connected"}>
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
</span>
</div>
)
} else if (service.authStatus === "outdated") {
// The installed CLI can't run the commands Kanna drives — updating is
Expand All @@ -300,6 +324,11 @@ export function AuthCard({
<div className="flex min-w-0 items-center gap-2.5">
<Icon className="h-4 w-4 shrink-0 text-foreground" />
<span className="truncate text-sm font-semibold text-foreground">{service.label}</span>
{badge ? (
<span className="shrink-0 rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{badge}
</span>
) : null}
{version ? (
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{version}</span>
) : null}
Expand All @@ -313,6 +342,14 @@ export function AuthCard({
<div className="mt-2 text-xs text-muted-foreground">{service.statusDetail}</div>
) : null}
<LoginFlowPanel service={service} socket={socket} />
{service.service === "opencode" ? (
<OpenCodeSignInDialog
service={service}
socket={socket}
open={openCodeDialogOpen}
onOpenChange={setOpenCodeDialogOpen}
/>
) : null}
</div>
)
}
151 changes: 151 additions & 0 deletions src/client/components/auth/OpenCodeSignInDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { Check, Loader2 } from "lucide-react"
import type { AuthServiceSnapshot } from "../../../shared/types"
import type { KannaSocket, SocketStatus } from "../../app/socket"
import { TerminalPane } from "../chat-ui/TerminalPane"
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogGhostButton,
DialogHeader,
DialogTitle,
} from "../ui/dialog"

/**
* opencode's sign-in dialog.
*
* Unlike the other harnesses, opencode has no single account and no device
* flow: `opencode auth login` is an interactive picker over ~100 providers,
* and several of them (GitHub Copilot, ChatGPT, Claude Pro/Max) finish in a
* browser rather than with a pasted key. Scraping that would only ever cover
* the API-key subset, so instead the real CLI runs inside this dialog and
* Kanna watches for the credential to land — the same shape as the OAuth
* cards, with the CLI standing in for the provider's web page.
*
* Completion is detected by polling the auth snapshot rather than by watching
* the terminal: the credential file is the source of truth, and it means
* adding a *second* provider (when already signed in) is detected too.
*/

/** How often to re-probe while the dialog is open. */
const POLL_INTERVAL_MS = 2_000
/** Let the ✓ land before the dialog closes itself. */
const SUCCESS_LINGER_MS = 900

export function OpenCodeSignInDialog({
service,
socket,
open,
onOpenChange,
}: {
service: AuthServiceSnapshot
socket: KannaSocket
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [connectionStatus, setConnectionStatus] = useState<SocketStatus>("connecting")
const [connected, setConnected] = useState(false)

// A fresh terminal per opening, so reopening never replays a finished login.
const terminalId = useMemo(
() => (open ? `opencode-login-${Math.random().toString(36).slice(2, 10)}` : null),
[open]
)

// The credentials already present when the dialog opened. Anything beyond
// this baseline is what the user just added — which is how "add another
// provider" is detected even though the card already reads as signed in.
const baselineRef = useRef<string | null>(null)
useEffect(() => {
if (open) {
baselineRef.current = service.authStatus === "signed_in" ? service.account ?? "" : null
setConnected(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])

useEffect(() => socket.onStatus(setConnectionStatus), [socket])

// Poll the probe while the dialog is open; the CLI writes auth.json when the
// user finishes, and the next refresh turns that into a signed_in snapshot.
useEffect(() => {
if (!open || connected) return
const timer = setInterval(() => {
void socket.command({ type: "auth.refresh", service: service.service }).catch(() => undefined)
}, POLL_INTERVAL_MS)
return () => clearInterval(timer)
}, [open, connected, socket, service.service])

useEffect(() => {
if (!open || connected) return
if (service.authStatus !== "signed_in") return
// Signed in and the credential list changed (or there was none before).
if (baselineRef.current !== null && (service.account ?? "") === baselineRef.current) return
setConnected(true)
}, [open, connected, service.authStatus, service.account])

useEffect(() => {
if (!connected) return
const timer = setTimeout(() => onOpenChange(false), SUCCESS_LINGER_MS)
return () => clearTimeout(timer)
}, [connected, onOpenChange])

// Tear the shell down on close — the login process should not outlive the dialog.
useEffect(() => {
if (open || !terminalId) return
void socket.command({ type: "terminal.close", terminalId }).catch(() => undefined)
}, [open, terminalId, socket])
Comment on lines +97 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Terminal session survives dialog close

When the sign-in dialog closes, terminalId has already recomputed to null, so this effect returns without sending terminal.close. Unmounting TerminalPane only disposes the client terminal, leaving the server-side shell and opencode auth login process running after every completed or cancelled attempt.

Fix in Codex


return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Sign in to opencode</DialogTitle>
<DialogDescription>
Pick a provider and follow the prompts. opencode keeps credentials on this machine, and
you can connect as many providers as you like.
</DialogDescription>
</DialogHeader>

<DialogBody>
<div className="h-[340px] overflow-hidden rounded-xl border border-border bg-card/40 p-2">
{open && terminalId ? (
<TerminalPane
projectId={null}
terminalId={terminalId}
socket={socket}
scrollback={1_000}
connectionStatus={connectionStatus}
initialCommand="opencode auth login"
/>
) : null}
</div>
</DialogBody>

<DialogFooter>
<div className="flex flex-1 items-center gap-2 text-sm">
{connected ? (
<>
<Check className="h-4 w-4 shrink-0 text-emerald-500" />
<span className="text-foreground">
Connected{service.account ? ` — ${service.account}` : ""}
</span>
</>
) : (
<>
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Waiting for a credential…</span>
</>
)}
</div>
<DialogGhostButton onClick={() => onOpenChange(false)}>
{connected ? "Close" : "Cancel"}
</DialogGhostButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
15 changes: 13 additions & 2 deletions src/client/components/auth/SetupWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ type SetupStep = (typeof STEP_ORDER)[number]
/** Auto-advance delay after a skippable step connects — long enough to see the ✓ land. */
const AUTO_ADVANCE_MS = 900

const AGENT_SERVICES: AuthServiceId[] = ["claude", "codex", "cursor"]
const AGENT_SERVICES: AuthServiceId[] = ["claude", "codex", "cursor", "opencode"]

/** Cards that carry a pill in the agents step. */
const AGENT_BADGES: Partial<Record<AuthServiceId, string>> = {
// opencode ships free models, so it's the fastest card to get running.
opencode: "Recommended",
}

function StepHeading({ title, description }: { title: string; description: string }) {
return (
Expand Down Expand Up @@ -210,7 +216,12 @@ export function SetupWizard() {
/>
<div className="mt-8 space-y-3">
{services.agents.map((service) => (
<AuthCard key={service.service} service={service} socket={socket} />
<AuthCard
key={service.service}
service={service}
socket={socket}
badge={AGENT_BADGES[service.service]}
/>
))}
</div>
<StepFooter
Expand Down
3 changes: 2 additions & 1 deletion src/client/components/chat-ui/ChatPreferenceControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type CodexModelOptions,
type CodexReasoningEffort,
type CursorModelOptions,
type OpenCodeModelOptions,
type PiModelOptions,
type PiReasoningEffort,
type ProviderCatalogEntry,
Expand Down Expand Up @@ -195,7 +196,7 @@ interface ChatPreferenceControlsProps {
/** A harness switch is staged for this chat and applies on the next send. */
providerSwitchPending?: boolean
model: string
modelOptions: ClaudeModelOptions | CodexModelOptions | CursorModelOptions | PiModelOptions
modelOptions: ClaudeModelOptions | CodexModelOptions | CursorModelOptions | PiModelOptions | OpenCodeModelOptions
onProviderChange?: (provider: AgentProvider) => void
onModelChange: (provider: AgentProvider, model: string) => void
onModelOptionChange: (change: ModelOptionChange) => void
Expand Down
23 changes: 23 additions & 0 deletions src/client/components/provider-icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,40 @@ export function OpenRouterIcon({ className, ...props }: SVGProps<SVGSVGElement>)
)
}

// Placeholder mark (terminal prompt) — swap for opencode's real logo.
export function OpenCodeIcon({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={cn("shrink-0", className)}
{...props}
>
<rect x="2" y="3" width="20" height="18" rx="3" />
<path d="m7 9 3 3-3 3" />
<path d="M13 15h4" />
</svg>
)
}

export const PROVIDER_ICONS: Record<AgentProvider, IconComponent> = {
claude: AnthropicIcon,
codex: OpenAIIcon,
cursor: CursorIcon,
pi: PiIcon,
opencode: OpenCodeIcon,
}

export const AUTH_SERVICE_ICONS: Record<AuthServiceId, IconComponent> = {
claude: AnthropicIcon,
codex: OpenAIIcon,
cursor: CursorIcon,
opencode: OpenCodeIcon,
gh: GitHubIcon,
openrouter: OpenRouterIcon,
}
8 changes: 8 additions & 0 deletions src/client/lib/composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ export function getEffectiveComposerState(
planMode: composerState.planMode,
autoPlan: composerState.autoPlan,
}
case "opencode":
return {
provider: "opencode",
model: providerDefaults.opencode.model,
modelOptions: { ...providerDefaults.opencode.modelOptions },
planMode: composerState.planMode,
autoPlan: composerState.autoPlan,
}
default:
return assertNever(activeProvider)
}
Expand Down
6 changes: 6 additions & 0 deletions src/client/stores/chatPreferencesStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ describe("migrateChatPreferencesState", () => {
planMode: false,
autoPlan: false,
},
opencode: {
model: "opencode/north-mini-code-free",
modelOptions: {},
planMode: false,
autoPlan: false,
},
},
chatStates: {},
legacyComposerState: {
Expand Down
2 changes: 1 addition & 1 deletion src/client/stores/providerAuthStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function service(

function snapshotWith(status: AuthServiceSnapshot["authStatus"]): ProviderAuthSnapshot {
return {
services: (["claude", "codex", "cursor", "gh", "openrouter"] as const).map((id) =>
services: (["claude", "codex", "cursor", "opencode", "gh", "openrouter"] as const).map((id) =>
service(id, status),
),
}
Expand Down
5 changes: 3 additions & 2 deletions src/client/stores/providerAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,14 @@ export function getSetupStatus(snapshot: ProviderAuthSnapshot | null): SetupStat
const services = snapshot?.services ?? []
const byId = new Map(services.map((service) => [service.service, service]))
const isConnected = (id: AuthServiceId) => byId.get(id)?.authStatus === "signed_in"
const relevant: AuthServiceId[] = ["claude", "codex", "cursor", "gh", "openrouter"]
const relevant: AuthServiceId[] = ["claude", "codex", "cursor", "opencode", "gh", "openrouter"]
const resolved = services.length > 0 && relevant.every((id) => {
const status = byId.get(id)?.authStatus
return status !== undefined && status !== "unknown"
})
const githubConnected = isConnected("gh")
const anyAgentConnected = isConnected("claude") || isConnected("codex") || isConnected("cursor")
const anyAgentConnected =
isConnected("claude") || isConnected("codex") || isConnected("cursor") || isConnected("opencode")
const openRouterConnected = isConnected("openrouter")
return {
resolved,
Expand Down
Loading