mini: fix default model (#35515)

This commit is contained in:
Simon Klee
2026-07-06 09:20:23 +02:00
committed by GitHub
parent 147da5d278
commit fb75ea2cf6
17 changed files with 398 additions and 118 deletions
@@ -4,7 +4,9 @@ import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number] type CurrentAgent = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["agent"]["list"]>>["data"]>["data"][number]
type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number] type CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["list"]>>["data"]>["data"][number]
type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number] type CurrentSkill = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["skill"]["list"]>>["data"]>["data"][number]
type CurrentProvider = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]>["data"][number] type CurrentProvider = NonNullable<
Awaited<ReturnType<OpencodeClient["v2"]["provider"]["list"]>>["data"]
>["data"][number]
type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number] type CurrentModel = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["model"]["list"]>>["data"]>["data"][number]
function location(directory: string) { function location(directory: string) {
@@ -105,6 +107,23 @@ export async function waitForCatalogReady(input: {
} }
} }
export async function waitForDefaultModel(input: {
sdk: OpencodeClient
directory: string
timeoutMs?: number
active?: () => boolean
}): Promise<{ providerID: string; modelID: string } | undefined> {
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
while (Date.now() < deadline && (input.active?.() ?? true)) {
const model = await input.sdk.v2.model
.default(location(input.directory), { throwOnError: true })
.then((result) => result.data?.data)
.catch(() => undefined)
if (model) return { providerID: model.providerID, modelID: model.id }
await new Promise((resolve) => setTimeout(resolve, 25))
}
}
export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> { export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise<RunAgent[]> {
const result = await sdk.v2.agent.list(location(directory), { throwOnError: true }) const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
return (result.data?.data ?? []).map(runAgent) return (result.data?.data ?? []).map(runAgent)
@@ -8,7 +8,7 @@
import { pathToFileURL } from "bun" import { pathToFileURL } from "bun"
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core" import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
import { useRenderer } from "@opentui/solid" import { useRenderer } from "@opentui/solid"
import { normalizePromptContent } from "@opencode-ai/tui/editor" import { normalizePromptContent } from "@opencode-ai/tui/prompt/content"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import path from "path" import path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
@@ -390,6 +390,10 @@ export class RunFooter implements FooterApi {
} }
public event(next: FooterEvent): void { public event(next: FooterEvent): void {
if (next.type === "model") {
this.setCurrentModel(next.selection)
}
if (next.type === "turn.duration") { if (next.type === "turn.duration") {
const current = this.currentModel() const current = this.currentModel()
this.flush() this.flush()
@@ -26,6 +26,7 @@ export type ModelInfo = {
export type SessionInfo = { export type SessionInfo = {
first: boolean first: boolean
history: RunPrompt[] history: RunPrompt[]
model?: NonNullable<RunInput["model"]>
variant: string | undefined variant: string | undefined
} }
@@ -141,7 +142,8 @@ const layer = Layer.effect(
return { return {
first: session.first, first: session.first,
history: sessionHistory(session), history: sessionHistory(session),
variant: pickVariant(model, session), model: session.model,
variant: pickVariant(model ?? session.model, session),
} }
}) })
@@ -12,9 +12,8 @@ import path from "path"
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { openEditor } from "@opencode-ai/tui/editor"
import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap" import { registerOpencodeKeymap } from "@opencode-ai/tui/keymap"
import { Session as SessionApi } from "@/session/session" import { isDefaultTitle } from "@/session/title"
import * as Locale from "@/util/locale" import * as Locale from "@/util/locale"
import { resolveInteractiveStdin } from "./runtime.stdin" import { resolveInteractiveStdin } from "./runtime.stdin"
import { entrySplash, exitSplash, splashMeta } from "./splash" import { entrySplash, exitSplash, splashMeta } from "./splash"
@@ -64,7 +63,7 @@ export type LifecycleInput = {
agent: string | undefined agent: string | undefined
model: RunInput["model"] model: RunInput["model"]
variant: string | undefined variant: string | undefined
tuiConfig: RunTuiConfig tuiConfig: RunTuiConfig | Promise<RunTuiConfig>
backgroundSubagents: boolean backgroundSubagents: boolean
onPermissionReply: (input: PermissionReply) => void | Promise<void> onPermissionReply: (input: PermissionReply) => void | Promise<void>
onQuestionReply: (input: QuestionReply) => void | Promise<void> onQuestionReply: (input: QuestionReply) => void | Promise<void>
@@ -108,7 +107,7 @@ function shutdown(renderer: CliRenderer): void {
} }
function splashInfo(title: string | undefined, history: RunPrompt[]) { function splashInfo(title: string | undefined, history: RunPrompt[]) {
if (title && !SessionApi.isDefaultTitle(title)) { if (title && !isDefaultTitle(title)) {
return { return {
title, title,
showSession: true, showSession: true,
@@ -124,17 +123,9 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) {
function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels { function footerLabels(input: Pick<RunInput, "agent" | "model" | "variant">): FooterLabels {
const agentLabel = Locale.titlecase(input.agent ?? "build") const agentLabel = Locale.titlecase(input.agent ?? "build")
if (!input.model) {
return {
agentLabel,
modelLabel: "Model default",
}
}
return { return {
agentLabel, agentLabel,
modelLabel: formatModelLabel(input.model, input.variant), modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "",
} }
} }
@@ -176,6 +167,7 @@ function queueSplash(
// the entry splash, RunFooter takes over the footer region. // the entry splash, RunFooter takes over the footer region.
export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> { export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lifecycle> {
const source = resolveInteractiveStdin() const source = resolveInteractiveStdin()
const footerTask = import("./footer")
let unregisterKeymap: (() => void) | undefined let unregisterKeymap: (() => void) | undefined
try { try {
@@ -194,10 +186,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
consoleMode: "disabled", consoleMode: "disabled",
clearOnShutdown: false, clearOnShutdown: false,
}) })
const theme = await resolveRunTheme(renderer) const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
renderer.setBackgroundColor(theme.background) renderer.setBackgroundColor(theme.background)
const keymap = createDefaultOpenTuiKeymap(renderer) const keymap = createDefaultOpenTuiKeymap(renderer)
unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.tuiConfig) unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
const state: SplashState = { const state: SplashState = {
entry: false, entry: false,
exit: false, exit: false,
@@ -212,7 +204,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
model: input.model, model: input.model,
variant: input.variant, variant: input.variant,
}) })
const footerTask = import("./footer")
const wrote = queueSplash( const wrote = queueSplash(
renderer, renderer,
state, state,
@@ -244,9 +235,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
theme, theme,
wrote, wrote,
keymap, keymap,
tuiConfig: input.tuiConfig, tuiConfig,
backgroundSubagents: input.backgroundSubagents, backgroundSubagents: input.backgroundSubagents,
diffStyle: input.tuiConfig.diff_style ?? "auto", diffStyle: tuiConfig.diff_style ?? "auto",
onPermissionReply: input.onPermissionReply, onPermissionReply: input.onPermissionReply,
onQuestionReply: input.onQuestionReply, onQuestionReply: input.onQuestionReply,
onQuestionReject: input.onQuestionReject, onQuestionReject: input.onQuestionReject,
@@ -260,6 +251,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
return return
} }
const { openEditor } = await import("@opencode-ai/tui/editor")
await renderer.idle().catch(() => {}) await renderer.idle().catch(() => {})
const ignore = () => {} const ignore = () => {}
detachSigint() detachSigint()
+123 -56
View File
@@ -15,8 +15,7 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { MessageID } from "@/session/schema" import { MessageID } from "@/session/schema"
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared" import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
import { createRunDemo } from "./demo"
import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
import { createRuntimeLifecycle } from "./runtime.lifecycle" import { createRuntimeLifecycle } from "./runtime.lifecycle"
import { trace } from "./trace" import { trace } from "./trace"
@@ -91,6 +90,8 @@ type StreamState = {
handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>> handle: Awaited<ReturnType<StreamTransportModule["createSessionTransport"]>>
} }
type RunDemo = ReturnType<(typeof import("./demo"))["createRunDemo"]>
type ResolvedSession = { type ResolvedSession = {
sessionID: string sessionID: string
sessionTitle?: string sessionTitle?: string
@@ -130,7 +131,7 @@ type RuntimeState = {
sessionTitle?: string sessionTitle?: string
agent: string | undefined agent: string | undefined
switching?: Promise<void> switching?: Promise<void>
demo?: ReturnType<typeof createRunDemo> demo?: RunDemo
selectSubagent?: (sessionID: string | undefined) => void selectSubagent?: (sessionID: string | undefined) => void
session?: Promise<void> session?: Promise<void>
stream?: Promise<StreamState> stream?: Promise<StreamState>
@@ -181,21 +182,21 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
const log = trace() const log = trace()
const tuiConfigTask = resolveRunTuiConfig() const tuiConfigTask = resolveRunTuiConfig()
const ctx = await input.boot() const ctx = await input.boot()
const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model)
const sessionTask = const sessionTask =
ctx.resume === true ctx.resume === true
? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model) ? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model)
: Promise.resolve({ : Promise.resolve({
first: true, first: true,
history: [], history: [],
model: undefined,
variant: undefined, variant: undefined,
}) })
const savedTask = resolveSavedVariant(ctx.model) const savedTask = resolveSavedVariant(ctx.model)
const [tuiConfig, session, savedVariant] = await Promise.all([tuiConfigTask, sessionTask, savedTask]) const [session, savedVariant] = await Promise.all([sessionTask, savedTask])
const state: RuntimeState = { const state: RuntimeState = {
shown: !session.first, shown: !session.first,
aborting: false, aborting: false,
model: ctx.model, model: ctx.model ?? session.model,
providers: [], providers: [],
variants: [], variants: [],
limits: {}, limits: {},
@@ -206,23 +207,43 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
sessionTitle: ctx.sessionTitle, sessionTitle: ctx.sessionTitle,
agent: ctx.agent, agent: ctx.agent,
} }
const ensureSession = () => { const loadModel = async () => {
if (!input.resolveSession || state.sessionID) { if (state.model) {
return Promise.resolve() return {
model: state.model,
savedVariant,
boot: true,
info: await resolveModelInfo(ctx.sdk, ctx.directory, state.model),
}
} }
if (state.session) { const model = await waitForDefaultModel({
return state.session sdk: ctx.sdk,
} directory: ctx.directory,
active: () => !footer.isClosed,
state.session = input.resolveSession(ctx).then((next) => {
state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
state.agent = next.agent
}) })
return state.session if (footer.isClosed) return
} const [fallbackSavedVariant, info] = await Promise.all([
resolveSavedVariant(model),
resolveModelInfo(ctx.sdk, ctx.directory, model),
])
if (!model || state.model) {
return {
model: state.model,
savedVariant: undefined,
boot: false,
info,
}
}
state.model = model
return {
model,
savedVariant: fallbackSavedVariant,
boot: true,
info,
}
}
const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({ const shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
directory: ctx.directory, directory: ctx.directory,
findFiles: (query) => findFiles: (query) =>
@@ -240,7 +261,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
agent: state.agent, agent: state.agent,
model: state.model, model: state.model,
variant: state.activeVariant, variant: state.activeVariant,
tuiConfig, tuiConfig: tuiConfigTask,
backgroundSubagents: input.backgroundSubagents, backgroundSubagents: input.backgroundSubagents,
onPermissionReply: async (next) => { onPermissionReply: async (next) => {
if (state.demo?.permission(next)) { if (state.demo?.permission(next)) {
@@ -345,9 +366,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
} }
state.aborting = true state.aborting = true
void (state.stream void (
? state.stream.then((item) => item.handle.interruptActiveTurn()) state.stream
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID })) ? state.stream.then((item) => item.handle.interruptActiveTurn())
: ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID })
)
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
state.aborting = false state.aborting = false
@@ -374,6 +397,24 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}, },
}) })
const footer = shell.footer const footer = shell.footer
const firstPaint = footer.idle().catch(() => {})
const modelTask = firstPaint.then(() => (footer.isClosed ? undefined : loadModel()))
const ensureSession = () => {
if (!input.resolveSession || state.sessionID) {
return Promise.resolve()
}
if (state.session) {
return state.session
}
state.session = input.resolveSession(ctx).then((next) => {
state.sessionID = next.sessionID
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
state.agent = next.agent
})
return state.session
}
const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => { const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT) state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT)
} }
@@ -417,12 +458,13 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
info: Awaited<ReturnType<typeof resolveModelInfo>>, info: Awaited<ReturnType<typeof resolveModelInfo>>,
current: string | undefined, current: string | undefined,
boot = false, boot = false,
saved = savedVariant,
) => { ) => {
state.providers = info.providers state.providers = info.providers
state.variants = variantsFor(state.providers, state.model) state.variants = variantsFor(state.providers, state.model)
state.limits = info.limits state.limits = info.limits
state.activeVariant = boot state.activeVariant = boot
? resolveVariant(ctx.variant, current, savedVariant, state.variants) ? resolveVariant(ctx.variant, current, saved, state.variants)
: current && !state.variants.includes(current) : current && !state.variants.includes(current)
? undefined ? undefined
: current : current
@@ -430,7 +472,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
footer.event({ type: "models", providers: info.providers }) footer.event({ type: "models", providers: info.providers })
footer.event({ type: "variants", variants: state.variants, current: state.activeVariant }) footer.event({ type: "variants", variants: state.variants, current: state.activeVariant })
if (state.model) if (state.model)
footer.event({ type: "model", model: formatModelLabel(state.model, state.activeVariant, state.providers) }) footer.event({
type: "model",
model: formatModelLabel(state.model, state.activeVariant, state.providers),
selection: state.model,
})
} }
let catalogRefresh: Promise<void> | undefined let catalogRefresh: Promise<void> | undefined
@@ -456,24 +502,24 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
void catalogRefresh.catch(() => {}) void catalogRefresh.catch(() => {})
} }
const initialCatalog = footer const initialCatalog = firstPaint.then(() => (footer.isClosed ? undefined : loadCatalog())).catch(() => {})
.idle()
.then(loadCatalog)
.catch(() => {})
void initialCatalog void initialCatalog
if (Flag.OPENCODE_SHOW_TTFD) { if (Flag.OPENCODE_SHOW_TTFD) {
footer.append({ void firstPaint.then(() => {
kind: "system", if (footer.isClosed) return
text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`, footer.append({
phase: "final", kind: "system",
source: "system", text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`,
phase: "final",
source: "system",
})
}) })
} }
if (input.demo) { const createDemo = async () => {
await ensureSession() const { createRunDemo } = await import("./demo")
state.demo = createRunDemo({ return createRunDemo({
footer, footer,
sessionID: state.sessionID, sessionID: state.sessionID,
thinking: input.thinking, thinking: input.thinking,
@@ -481,13 +527,35 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}) })
} }
if (input.afterPaint) { if (input.demo) {
void Promise.resolve(input.afterPaint(ctx)).catch(() => {}) await firstPaint
if (!footer.isClosed) {
await ensureSession()
state.demo = await createDemo()
}
} }
void modelTask.then((info) => applyModelInfo(info, session.variant, true)) if (input.afterPaint) {
void firstPaint.then(() => (footer.isClosed ? undefined : input.afterPaint?.(ctx))).catch(() => {})
}
const streamTask = deps.streamTransport ?? import("./stream-v2.transport") void modelTask.then((result) => {
if (!result) return
const current = state.model
const boot =
result.boot &&
!!current &&
current.providerID === result.model?.providerID &&
current.modelID === result.model.modelID
applyModelInfo(result.info, boot ? session.variant : state.activeVariant, boot, result.savedVariant)
})
let streamTask = deps.streamTransport
const loadStreamTransport = () => {
if (streamTask) return streamTask
streamTask = import("./stream-v2.transport")
return streamTask
}
const ensureStream = () => { const ensureStream = () => {
if (state.stream) { if (state.stream) {
return state.stream return state.stream
@@ -501,7 +569,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
throw new Error("runtime closed") throw new Error("runtime closed")
} }
const mod = await streamTask const mod = await loadStreamTransport()
if (footer.isClosed) { if (footer.isClosed) {
throw new Error("runtime closed") throw new Error("runtime closed")
} }
@@ -570,6 +638,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}) })
const runQueue = async () => { const runQueue = async () => {
await firstPaint
if (footer.isClosed) return
let includeFiles = true let includeFiles = true
if (state.demo) { if (state.demo) {
await state.demo.start() await state.demo.start()
@@ -615,14 +685,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
state.history = [] state.history = []
state.localRows = [] state.localRows = []
includeFiles = true includeFiles = true
state.demo = input.demo state.demo = input.demo ? await createDemo() : undefined
? createRunDemo({
footer,
sessionID: state.sessionID,
thinking: input.thinking,
limits: () => state.limits,
})
: undefined
log?.write("session.new", { log?.write("session.new", {
sessionID: state.sessionID, sessionID: state.sessionID,
}) })
@@ -727,6 +790,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
try { try {
const eager = eagerStream(input, ctx) const eager = eagerStream(input, ctx)
if (eager) { if (eager) {
await firstPaint
if (footer.isClosed) return
if (input.replay && state.shown) { if (input.replay && state.shown) {
// Replay commits immutable scrollback rows, so wait for provider names // Replay commits immutable scrollback rows, so wait for provider names
// before bootstrapping existing session history. // before bootstrapping existing session history.
@@ -737,13 +802,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
} }
if (!eager && input.resolveSession) { if (!eager && input.resolveSession) {
queueMicrotask(() => { void firstPaint
if (footer.isClosed) { .then(() => {
return if (footer.isClosed) {
} return
}
void ensureStream().catch(() => {}) return ensureStream()
}) })
.catch(() => {})
} }
try { try {
@@ -105,7 +105,7 @@ export class RunScrollbackStream {
) { ) {
this.diffStyle = options.diffStyle this.diffStyle = options.diffStyle
this.sessionID = options.sessionID this.sessionID = options.sessionID
this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient() this.treeSitterClient = options.treeSitterClient
this.wrote = options.wrote ?? false this.wrote = options.wrote ?? false
this.onThemeRelease = options.onThemeRelease this.onThemeRelease = options.onThemeRelease
} }
@@ -151,6 +151,7 @@ export class RunScrollbackStream {
startOnNewLine: entryFlags(commit).startOnNewLine, startOnNewLine: entryFlags(commit).startOnNewLine,
}) })
const style = entryLook(commit, this.theme.entry) const style = entryLook(commit, this.theme.entry)
const treeSitterClient = body.type === "text" ? undefined : (this.treeSitterClient ??= getTreeSitterClient())
const renderable = const renderable =
body.type === "text" body.type === "text"
? new TextRenderable(surface.renderContext, { ? new TextRenderable(surface.renderContext, {
@@ -170,7 +171,7 @@ export class RunScrollbackStream {
drawUnstyledText: false, drawUnstyledText: false,
streaming: true, streaming: true,
fg: entryColor(commit, this.theme), fg: entryColor(commit, this.theme),
treeSitterClient: this.treeSitterClient, treeSitterClient,
}) })
: new MarkdownRenderable(surface.renderContext, { : new MarkdownRenderable(surface.renderContext, {
content: "", content: "",
@@ -180,7 +181,7 @@ export class RunScrollbackStream {
internalBlockMode: "top-level", internalBlockMode: "top-level",
tableOptions: { widthMode: "content" }, tableOptions: { widthMode: "content" },
fg: entryColor(commit, this.theme), fg: entryColor(commit, this.theme),
treeSitterClient: this.treeSitterClient, treeSitterClient,
}) })
surface.root.add(renderable) surface.root.add(renderable)
@@ -20,6 +20,8 @@ type Turn = {
export type RunSession = { export type RunSession = {
first: boolean first: boolean
turns: Turn[] turns: Turn[]
model?: NonNullable<RunInput["model"]>
variant?: string
} }
function fileName(url: string, filename?: string) { function fileName(url: string, filename?: string) {
@@ -157,9 +159,11 @@ export async function resolveCurrentSession(
sessionID: string, sessionID: string,
limit = LIMIT, limit = LIMIT,
): Promise<RunSession> { ): Promise<RunSession> {
const response = await sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true }) const [response, session] = await Promise.all([
sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true }),
sdk.v2.session.get({ sessionID }, { throwOnError: true }),
])
const messages = response.data.data.toReversed() const messages = response.data.data.toReversed()
const session = await sdk.v2.session.get({ sessionID }, { throwOnError: true })
return { return {
first: messages.length === 0, first: messages.length === 0,
turns: messages.flatMap((message) => { turns: messages.flatMap((message) => {
@@ -197,6 +201,13 @@ export async function resolveCurrentSession(
}, },
] ]
}), }),
...(session.data.data.model && {
model: {
providerID: session.data.data.model.providerID,
modelID: session.data.data.model.id,
},
variant: session.data.data.model.variant,
}),
} }
} }
@@ -223,6 +234,10 @@ export function sessionVariant(session: RunSession, model: RunInput["model"]): s
return undefined return undefined
} }
if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) {
return session.variant
}
for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) { for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) {
const turn = session.turns[idx] const turn = session.turns[idx]
if (turn.provider !== model.providerID || turn.model !== model.modelID) { if (turn.provider !== model.providerID || turn.model !== model.modelID) {
@@ -312,6 +312,7 @@ export type FooterEvent =
| { | {
type: "model" type: "model"
model: string model: string
selection: NonNullable<RunInput["model"]>
} }
| { | {
type: "turn.send" type: "turn.send"
+4 -10
View File
@@ -1,3 +1,6 @@
export { isDefaultTitle } from "./title"
import { createDefaultTitle } from "./title"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { Slug } from "@opencode-ai/core/util/slug" import { Slug } from "@opencode-ai/core/util/slug"
@@ -43,15 +46,6 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
const parentTitlePrefix = "New session - "
const childTitlePrefix = "Child session - "
export function isDefaultTitle(title: string) {
return new RegExp(
`^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`,
).test(title)
}
type SessionRow = typeof SessionTable.$inferSelect type SessionRow = typeof SessionTable.$inferSelect
export function fromRow(row: SessionRow): Info { export function fromRow(row: SessionRow): Info {
@@ -518,7 +512,7 @@ const layer: Layer.Layer<
path: input.path, path: input.path,
workspaceID: input.workspaceID, workspaceID: input.workspaceID,
parentID: input.parentID, parentID: input.parentID,
title: input.title ?? (input.parentID ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString(), title: input.title ?? createDefaultTitle(!!input.parentID),
agent: input.agent, agent: input.agent,
model: input.model, model: input.model,
metadata: input.metadata, metadata: input.metadata,
+11
View File
@@ -0,0 +1,11 @@
const parentTitlePrefix = "New session - "
const childTitlePrefix = "Child session - "
const defaultTitle = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function createDefaultTitle(child: boolean) {
return (child ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString()
}
export function isDefaultTitle(title: string) {
return defaultTitle.test(title)
}
@@ -1,12 +1,37 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { OpencodeClient } from "@opencode-ai/sdk/v2" import { OpencodeClient } from "@opencode-ai/sdk/v2"
import { loadRunReferences, runProviders } from "@/cli/cmd/run/catalog.shared" import { loadRunReferences, runProviders, waitForDefaultModel } from "@/cli/cmd/run/catalog.shared"
afterEach(() => { afterEach(() => {
mock.restore() mock.restore()
}) })
describe("run catalog shared", () => { describe("run catalog shared", () => {
test("resolves the catalog-selected model for the footer", async () => {
const client = new OpencodeClient()
const selected = spyOn(client.v2.model, "default").mockImplementation(
() =>
Promise.resolve({
data: {
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
data: {
id: "gpt-5",
providerID: "openai",
},
},
error: undefined,
request: new Request("https://opencode.test"),
response: new Response(),
}) as never,
)
await expect(waitForDefaultModel({ sdk: client, directory: "/tmp" })).resolves.toEqual({
providerID: "openai",
modelID: "gpt-5",
})
expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true })
})
test("loads visible project references from the current reference catalog", async () => { test("loads visible project references from the current reference catalog", async () => {
const client = new OpencodeClient() const client = new OpencodeClient()
const list = spyOn(client.v2.reference, "list").mockImplementation( const list = spyOn(client.v2.reference, "list").mockImplementation(
+156 -11
View File
@@ -113,6 +113,7 @@ describe("run interactive runtime", () => {
test("waits for provider metadata before eager replay transport bootstrap", async () => { test("waits for provider metadata before eager replay transport bootstrap", async () => {
const providersStarted = defer<void>() const providersStarted = defer<void>()
const providers = defer<void>() const providers = defer<void>()
const lifecycleModels: unknown[] = []
const sdk = new OpencodeClient() const sdk = new OpencodeClient()
const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused")) const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused"))
@@ -249,23 +250,23 @@ describe("run interactive runtime", () => {
replay: true, replay: true,
replayLimit: 100, replayLimit: 100,
agent: "build", agent: "build",
model: { model: undefined,
providerID: "openai",
modelID: "gpt-5",
},
variant: undefined, variant: undefined,
files: [], files: [],
thinking: true, thinking: true,
backgroundSubagents: false, backgroundSubagents: false,
}, },
{ {
createRuntimeLifecycle: async () => ({ createRuntimeLifecycle: async (input) => {
footer: footer(), lifecycleModels.push(input.model)
onResize: () => () => {}, return {
refreshTheme: () => {}, footer: footer(),
resetForReplay: () => Promise.resolve(), onResize: () => () => {},
close: () => Promise.resolve(), refreshTheme: () => {},
}), resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
},
streamTransport: Promise.resolve({ streamTransport: Promise.resolve({
createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => { createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => {
transportProviders.push(input.providers?.() ?? []) transportProviders.push(input.providers?.() ?? [])
@@ -293,12 +294,156 @@ describe("run interactive runtime", () => {
await task await task
expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }])
expect(transportProviders).toEqual([[provider]]) expect(transportProviders).toEqual([[provider]])
expect(legacyProviders).not.toHaveBeenCalled() expect(legacyProviders).not.toHaveBeenCalled()
expect(legacyAgents).not.toHaveBeenCalled() expect(legacyAgents).not.toHaveBeenCalled()
expect(legacyCommands).not.toHaveBeenCalled() expect(legacyCommands).not.toHaveBeenCalled()
}) })
test("defers catalog-selected model resolution until after first paint", async () => {
const sdk = new OpencodeClient()
const defaultStarted = defer<void>()
const releaseDefault = defer<void>()
const lifecycleStarted = defer<void>()
const painted = defer<void>()
const modelShown = defer<void>()
let defaultRequested = false
const events: FooterEvent[] = []
const api = footer(events)
api.idle = () => painted.promise
const event = api.event
api.event = (value) => {
event(value)
if (value.type !== "model") return
modelShown.resolve()
api.close()
}
spyOn(sdk.v2.model, "default").mockImplementation(async () => {
defaultRequested = true
defaultStarted.resolve()
await releaseDefault.promise
return ok({
location: { directory: "/tmp" },
data: { id: "gpt-5", providerID: "openai" },
}) as never
})
spyOn(sdk.v2.provider, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.model, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.agent, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.reference, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.command, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
spyOn(sdk.v2.skill, "list").mockImplementation(() =>
ok({ location: { directory: "/tmp" }, data: [] }) as never,
)
const task = runInteractiveMode(
{
sdk,
directory: "/tmp",
sessionID: "ses-fresh",
resume: false,
agent: "build",
model: undefined,
variant: undefined,
files: [],
thinking: false,
backgroundSubagents: false,
},
{
createRuntimeLifecycle: async (input) => {
expect(input.model).toBeUndefined()
lifecycleStarted.resolve()
return {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
},
streamTransport: Promise.resolve({
createSessionTransport: async () => ({
runPromptTurn: async () => {},
interruptActiveTurn: async () => {},
selectSubagent: () => {},
replayOnResize: async () => false,
close: async () => {},
}),
formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
}),
},
)
await lifecycleStarted.promise
expect(defaultRequested).toBe(false)
painted.resolve()
await defaultStarted.promise
releaseDefault.resolve()
await modelShown.promise
await task
expect(events.find((event) => event.type === "model")).toEqual({
type: "model",
model: "gpt-5 · openai",
selection: { providerID: "openai", modelID: "gpt-5" },
})
})
test("does not start deferred work after the footer closes", async () => {
const sdk = new OpencodeClient()
const lifecycleStarted = defer<void>()
const painted = defer<void>()
const api = footer()
api.idle = () => painted.promise
const defaultModel = spyOn(sdk.v2.model, "default")
const task = runInteractiveMode(
{
sdk,
directory: "/tmp",
sessionID: "ses-closed",
resume: false,
agent: "build",
model: undefined,
variant: undefined,
files: [],
thinking: false,
backgroundSubagents: false,
},
{
createRuntimeLifecycle: async () => {
lifecycleStarted.resolve()
return {
footer: api,
onResize: () => () => {},
refreshTheme: () => {},
resetForReplay: () => Promise.resolve(),
close: () => Promise.resolve(),
}
},
},
)
await lifecycleStarted.promise
api.close()
painted.resolve()
await task
expect(defaultModel).not.toHaveBeenCalled()
})
test("retains last-known-good state across failed coalesced refreshes and retries later", async () => { test("retains last-known-good state across failed coalesced refreshes and retries later", async () => {
const sdk = new OpencodeClient() const sdk = new OpencodeClient()
const refreshGate = defer<void>() const refreshGate = defer<void>()
@@ -303,6 +303,8 @@ describe("run session shared", () => {
const out = await resolveCurrentSession(client, "ses_1") const out = await resolveCurrentSession(client, "ses_1")
expect(out.model).toEqual({ providerID: "openai", modelID: "gpt-5" })
expect(out.variant).toBe("high")
expect(out.turns[0]?.prompt).toEqual({ expect(out.turns[0]?.prompt).toEqual({
text: "Review @note.ts", text: "Review @note.ts",
parts: [ parts: [
+1
View File
@@ -31,6 +31,7 @@
"./terminal-win32": "./src/terminal-win32.ts", "./terminal-win32": "./src/terminal-win32.ts",
"./config/keybind": "./src/config/keybind.ts", "./config/keybind": "./src/config/keybind.ts",
"./keymap": "./src/keymap.tsx", "./keymap": "./src/keymap.tsx",
"./prompt/content": "./src/prompt/content.ts",
"./prompt/display": "./src/prompt/display.ts", "./prompt/display": "./src/prompt/display.ts",
"./plugin/runtime": "./src/plugin/runtime.tsx", "./plugin/runtime": "./src/plugin/runtime.tsx",
"./plugin/slots": "./src/plugin/slots.tsx", "./plugin/slots": "./src/plugin/slots.tsx",
+2 -14
View File
@@ -7,22 +7,10 @@ import { spawn } from "node:child_process"
import type { Stream } from "node:stream" import type { Stream } from "node:stream"
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed" import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
export { normalizePromptContent } from "./prompt/content"
type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream
export function normalizePromptContent(content: string) {
if (content.endsWith("\r\n")) {
const body = content.slice(0, -2)
return !body.includes("\n") && !body.includes("\r") ? body : content
}
if (content.endsWith("\n")) {
const body = content.slice(0, -1)
return !body.includes("\n") && !body.includes("\r") ? body : content
}
return content
}
export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) { export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) {
const editor = process.env.VISUAL || process.env.EDITOR const editor = process.env.VISUAL || process.env.EDITOR
if (!editor) return if (!editor) return
+13
View File
@@ -0,0 +1,13 @@
export function normalizePromptContent(content: string) {
if (content.endsWith("\r\n")) {
const body = content.slice(0, -2)
return !body.includes("\n") && !body.includes("\r") ? body : content
}
if (content.endsWith("\n")) {
const body = content.slice(0, -1)
return !body.includes("\n") && !body.includes("\r") ? body : content
}
return content
}