diff --git a/packages/opencode/src/cli/cmd/run/catalog.shared.ts b/packages/opencode/src/cli/cmd/run/catalog.shared.ts index e4c8277fd9..826b87656b 100644 --- a/packages/opencode/src/cli/cmd/run/catalog.shared.ts +++ b/packages/opencode/src/cli/cmd/run/catalog.shared.ts @@ -4,7 +4,9 @@ import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types" type CurrentAgent = NonNullable>["data"]>["data"][number] type CurrentCommand = NonNullable>["data"]>["data"][number] type CurrentSkill = NonNullable>["data"]>["data"][number] -type CurrentProvider = NonNullable>["data"]>["data"][number] +type CurrentProvider = NonNullable< + Awaited>["data"] +>["data"][number] type CurrentModel = NonNullable>["data"]>["data"][number] 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 { const result = await sdk.v2.agent.list(location(directory), { throwOnError: true }) return (result.data?.data ?? []).map(runAgent) diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 9d10a5266a..224a707dd2 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -8,7 +8,7 @@ import { pathToFileURL } from "bun" import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core" 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 path from "path" import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js" diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 6a4c03e797..2432c29689 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -390,6 +390,10 @@ export class RunFooter implements FooterApi { } public event(next: FooterEvent): void { + if (next.type === "model") { + this.setCurrentModel(next.selection) + } + if (next.type === "turn.duration") { const current = this.currentModel() this.flush() diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index 2933ce5fdd..eae1db77e8 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -26,6 +26,7 @@ export type ModelInfo = { export type SessionInfo = { first: boolean history: RunPrompt[] + model?: NonNullable variant: string | undefined } @@ -141,7 +142,8 @@ const layer = Layer.effect( return { first: session.first, history: sessionHistory(session), - variant: pickVariant(model, session), + model: session.model, + variant: pickVariant(model ?? session.model, session), } }) diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index a25b293883..e06523c7c6 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -12,9 +12,8 @@ import path from "path" import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core" import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" import { Global } from "@opencode-ai/core/global" -import { openEditor } from "@opencode-ai/tui/editor" 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 { resolveInteractiveStdin } from "./runtime.stdin" import { entrySplash, exitSplash, splashMeta } from "./splash" @@ -64,7 +63,7 @@ export type LifecycleInput = { agent: string | undefined model: RunInput["model"] variant: string | undefined - tuiConfig: RunTuiConfig + tuiConfig: RunTuiConfig | Promise backgroundSubagents: boolean onPermissionReply: (input: PermissionReply) => void | Promise onQuestionReply: (input: QuestionReply) => void | Promise @@ -108,7 +107,7 @@ function shutdown(renderer: CliRenderer): void { } function splashInfo(title: string | undefined, history: RunPrompt[]) { - if (title && !SessionApi.isDefaultTitle(title)) { + if (title && !isDefaultTitle(title)) { return { title, showSession: true, @@ -124,17 +123,9 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) { function footerLabels(input: Pick): FooterLabels { const agentLabel = Locale.titlecase(input.agent ?? "build") - - if (!input.model) { - return { - agentLabel, - modelLabel: "Model default", - } - } - return { 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. export async function createRuntimeLifecycle(input: LifecycleInput): Promise { const source = resolveInteractiveStdin() + const footerTask = import("./footer") let unregisterKeymap: (() => void) | undefined try { @@ -194,10 +186,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise {}) const ignore = () => {} detachSigint() diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 5c6ede1582..601cdc567f 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -15,8 +15,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" import { MessageID } from "@/session/schema" -import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared" -import { createRunDemo } from "./demo" +import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared" import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" import { trace } from "./trace" @@ -91,6 +90,8 @@ type StreamState = { handle: Awaited> } +type RunDemo = ReturnType<(typeof import("./demo"))["createRunDemo"]> + type ResolvedSession = { sessionID: string sessionTitle?: string @@ -130,7 +131,7 @@ type RuntimeState = { sessionTitle?: string agent: string | undefined switching?: Promise - demo?: ReturnType + demo?: RunDemo selectSubagent?: (sessionID: string | undefined) => void session?: Promise stream?: Promise @@ -181,21 +182,21 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep const log = trace() const tuiConfigTask = resolveRunTuiConfig() const ctx = await input.boot() - const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model) const sessionTask = ctx.resume === true ? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model) : Promise.resolve({ first: true, history: [], + model: undefined, variant: undefined, }) 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 = { shown: !session.first, aborting: false, - model: ctx.model, + model: ctx.model ?? session.model, providers: [], variants: [], limits: {}, @@ -206,23 +207,43 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep sessionTitle: ctx.sessionTitle, agent: ctx.agent, } - const ensureSession = () => { - if (!input.resolveSession || state.sessionID) { - return Promise.resolve() + const loadModel = async () => { + if (state.model) { + return { + model: state.model, + savedVariant, + boot: true, + info: await resolveModelInfo(ctx.sdk, ctx.directory, state.model), + } } - 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 + const model = await waitForDefaultModel({ + sdk: ctx.sdk, + directory: ctx.directory, + active: () => !footer.isClosed, }) - 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)({ directory: ctx.directory, findFiles: (query) => @@ -240,7 +261,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep agent: state.agent, model: state.model, variant: state.activeVariant, - tuiConfig, + tuiConfig: tuiConfigTask, backgroundSubagents: input.backgroundSubagents, onPermissionReply: async (next) => { if (state.demo?.permission(next)) { @@ -345,9 +366,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } state.aborting = true - void (state.stream - ? state.stream.then((item) => item.handle.interruptActiveTurn()) - : ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID })) + void ( + state.stream + ? state.stream.then((item) => item.handle.interruptActiveTurn()) + : ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }) + ) .catch(() => {}) .finally(() => { state.aborting = false @@ -374,6 +397,24 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }, }) 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) => { state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT) } @@ -417,12 +458,13 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep info: Awaited>, current: string | undefined, boot = false, + saved = savedVariant, ) => { state.providers = info.providers state.variants = variantsFor(state.providers, state.model) state.limits = info.limits state.activeVariant = boot - ? resolveVariant(ctx.variant, current, savedVariant, state.variants) + ? resolveVariant(ctx.variant, current, saved, state.variants) : current && !state.variants.includes(current) ? undefined : current @@ -430,7 +472,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep footer.event({ type: "models", providers: info.providers }) footer.event({ type: "variants", variants: state.variants, current: state.activeVariant }) 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 | undefined @@ -456,24 +502,24 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep void catalogRefresh.catch(() => {}) } - const initialCatalog = footer - .idle() - .then(loadCatalog) - .catch(() => {}) + const initialCatalog = firstPaint.then(() => (footer.isClosed ? undefined : loadCatalog())).catch(() => {}) void initialCatalog if (Flag.OPENCODE_SHOW_TTFD) { - footer.append({ - kind: "system", - text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`, - phase: "final", - source: "system", + void firstPaint.then(() => { + if (footer.isClosed) return + footer.append({ + kind: "system", + text: `startup ${Math.max(0, Math.round(performance.now() - start))}ms`, + phase: "final", + source: "system", + }) }) } - if (input.demo) { - await ensureSession() - state.demo = createRunDemo({ + const createDemo = async () => { + const { createRunDemo } = await import("./demo") + return createRunDemo({ footer, sessionID: state.sessionID, thinking: input.thinking, @@ -481,13 +527,35 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }) } - if (input.afterPaint) { - void Promise.resolve(input.afterPaint(ctx)).catch(() => {}) + if (input.demo) { + 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 = () => { if (state.stream) { return state.stream @@ -501,7 +569,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep throw new Error("runtime closed") } - const mod = await streamTask + const mod = await loadStreamTransport() if (footer.isClosed) { throw new Error("runtime closed") } @@ -570,6 +638,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }) const runQueue = async () => { + await firstPaint + if (footer.isClosed) return let includeFiles = true if (state.demo) { await state.demo.start() @@ -615,14 +685,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep state.history = [] state.localRows = [] includeFiles = true - state.demo = input.demo - ? createRunDemo({ - footer, - sessionID: state.sessionID, - thinking: input.thinking, - limits: () => state.limits, - }) - : undefined + state.demo = input.demo ? await createDemo() : undefined log?.write("session.new", { sessionID: state.sessionID, }) @@ -727,6 +790,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep try { const eager = eagerStream(input, ctx) if (eager) { + await firstPaint + if (footer.isClosed) return if (input.replay && state.shown) { // Replay commits immutable scrollback rows, so wait for provider names // before bootstrapping existing session history. @@ -737,13 +802,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } if (!eager && input.resolveSession) { - queueMicrotask(() => { - if (footer.isClosed) { - return - } + void firstPaint + .then(() => { + if (footer.isClosed) { + return + } - void ensureStream().catch(() => {}) - }) + return ensureStream() + }) + .catch(() => {}) } try { diff --git a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts index f8516a054c..c5e5c6dd7d 100644 --- a/packages/opencode/src/cli/cmd/run/scrollback.surface.ts +++ b/packages/opencode/src/cli/cmd/run/scrollback.surface.ts @@ -105,7 +105,7 @@ export class RunScrollbackStream { ) { this.diffStyle = options.diffStyle this.sessionID = options.sessionID - this.treeSitterClient = options.treeSitterClient ?? getTreeSitterClient() + this.treeSitterClient = options.treeSitterClient this.wrote = options.wrote ?? false this.onThemeRelease = options.onThemeRelease } @@ -151,6 +151,7 @@ export class RunScrollbackStream { startOnNewLine: entryFlags(commit).startOnNewLine, }) const style = entryLook(commit, this.theme.entry) + const treeSitterClient = body.type === "text" ? undefined : (this.treeSitterClient ??= getTreeSitterClient()) const renderable = body.type === "text" ? new TextRenderable(surface.renderContext, { @@ -170,7 +171,7 @@ export class RunScrollbackStream { drawUnstyledText: false, streaming: true, fg: entryColor(commit, this.theme), - treeSitterClient: this.treeSitterClient, + treeSitterClient, }) : new MarkdownRenderable(surface.renderContext, { content: "", @@ -180,7 +181,7 @@ export class RunScrollbackStream { internalBlockMode: "top-level", tableOptions: { widthMode: "content" }, fg: entryColor(commit, this.theme), - treeSitterClient: this.treeSitterClient, + treeSitterClient, }) surface.root.add(renderable) diff --git a/packages/opencode/src/cli/cmd/run/session.shared.ts b/packages/opencode/src/cli/cmd/run/session.shared.ts index 49dece5a89..b39624662c 100644 --- a/packages/opencode/src/cli/cmd/run/session.shared.ts +++ b/packages/opencode/src/cli/cmd/run/session.shared.ts @@ -20,6 +20,8 @@ type Turn = { export type RunSession = { first: boolean turns: Turn[] + model?: NonNullable + variant?: string } function fileName(url: string, filename?: string) { @@ -157,9 +159,11 @@ export async function resolveCurrentSession( sessionID: string, limit = LIMIT, ): Promise { - 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 session = await sdk.v2.session.get({ sessionID }, { throwOnError: true }) return { first: messages.length === 0, 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 } + 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) { const turn = session.turns[idx] if (turn.provider !== model.providerID || turn.model !== model.modelID) { diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index f311febb26..2c489e2626 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -312,6 +312,7 @@ export type FooterEvent = | { type: "model" model: string + selection: NonNullable } | { type: "turn.send" diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 143c04a12a..641ae2ddf8 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1,3 +1,6 @@ +export { isDefaultTitle } from "./title" +import { createDefaultTitle } from "./title" + import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionV1 } from "@opencode-ai/core/v1/permission" 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 { 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 export function fromRow(row: SessionRow): Info { @@ -518,7 +512,7 @@ const layer: Layer.Layer< path: input.path, workspaceID: input.workspaceID, parentID: input.parentID, - title: input.title ?? (input.parentID ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString(), + title: input.title ?? createDefaultTitle(!!input.parentID), agent: input.agent, model: input.model, metadata: input.metadata, diff --git a/packages/opencode/src/session/title.ts b/packages/opencode/src/session/title.ts new file mode 100644 index 0000000000..f90012eb72 --- /dev/null +++ b/packages/opencode/src/session/title.ts @@ -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) +} diff --git a/packages/opencode/test/cli/run/catalog.shared.test.ts b/packages/opencode/test/cli/run/catalog.shared.test.ts index bdf57ef7d1..847eceebec 100644 --- a/packages/opencode/test/cli/run/catalog.shared.test.ts +++ b/packages/opencode/test/cli/run/catalog.shared.test.ts @@ -1,12 +1,37 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" 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(() => { mock.restore() }) 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 () => { const client = new OpencodeClient() const list = spyOn(client.v2.reference, "list").mockImplementation( diff --git a/packages/opencode/test/cli/run/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts index 702d82d769..c98a61b69b 100644 --- a/packages/opencode/test/cli/run/runtime.test.ts +++ b/packages/opencode/test/cli/run/runtime.test.ts @@ -113,6 +113,7 @@ describe("run interactive runtime", () => { test("waits for provider metadata before eager replay transport bootstrap", async () => { const providersStarted = defer() const providers = defer() + const lifecycleModels: unknown[] = [] const sdk = new OpencodeClient() const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused")) @@ -249,23 +250,23 @@ describe("run interactive runtime", () => { replay: true, replayLimit: 100, agent: "build", - model: { - providerID: "openai", - modelID: "gpt-5", - }, + model: undefined, variant: undefined, files: [], thinking: true, backgroundSubagents: false, }, { - createRuntimeLifecycle: async () => ({ - footer: footer(), - onResize: () => () => {}, - refreshTheme: () => {}, - resetForReplay: () => Promise.resolve(), - close: () => Promise.resolve(), - }), + createRuntimeLifecycle: async (input) => { + lifecycleModels.push(input.model) + return { + footer: footer(), + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, streamTransport: Promise.resolve({ createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => { transportProviders.push(input.providers?.() ?? []) @@ -293,12 +294,156 @@ describe("run interactive runtime", () => { await task + expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }]) expect(transportProviders).toEqual([[provider]]) expect(legacyProviders).not.toHaveBeenCalled() expect(legacyAgents).not.toHaveBeenCalled() expect(legacyCommands).not.toHaveBeenCalled() }) + test("defers catalog-selected model resolution until after first paint", async () => { + const sdk = new OpencodeClient() + const defaultStarted = defer() + const releaseDefault = defer() + const lifecycleStarted = defer() + const painted = defer() + const modelShown = defer() + 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() + const painted = defer() + 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 () => { const sdk = new OpencodeClient() const refreshGate = defer() diff --git a/packages/opencode/test/cli/run/session.shared.test.ts b/packages/opencode/test/cli/run/session.shared.test.ts index a470f51014..102a53ba50 100644 --- a/packages/opencode/test/cli/run/session.shared.test.ts +++ b/packages/opencode/test/cli/run/session.shared.test.ts @@ -303,6 +303,8 @@ describe("run session shared", () => { 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({ text: "Review @note.ts", parts: [ diff --git a/packages/tui/package.json b/packages/tui/package.json index 4dade71898..12279e7115 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -31,6 +31,7 @@ "./terminal-win32": "./src/terminal-win32.ts", "./config/keybind": "./src/config/keybind.ts", "./keymap": "./src/keymap.tsx", + "./prompt/content": "./src/prompt/content.ts", "./prompt/display": "./src/prompt/display.ts", "./plugin/runtime": "./src/plugin/runtime.tsx", "./plugin/slots": "./src/plugin/slots.tsx", diff --git a/packages/tui/src/editor.ts b/packages/tui/src/editor.ts index 68afba6751..f5bbbbc3bb 100644 --- a/packages/tui/src/editor.ts +++ b/packages/tui/src/editor.ts @@ -7,22 +7,10 @@ import { spawn } from "node:child_process" import type { Stream } from "node:stream" import { resolveZedDbPath, resolveZedSelection } from "./editor-zed" +export { normalizePromptContent } from "./prompt/content" + 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 }) { const editor = process.env.VISUAL || process.env.EDITOR if (!editor) return diff --git a/packages/tui/src/prompt/content.ts b/packages/tui/src/prompt/content.ts new file mode 100644 index 0000000000..99ed030868 --- /dev/null +++ b/packages/tui/src/prompt/content.ts @@ -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 +}