Bläddra i källkod

mini: fix default model (#35515)

Simon Klee 1 månad sedan
förälder
incheckning
fb75ea2cf6

+ 20 - 1
packages/opencode/src/cli/cmd/run/catalog.shared.ts

@@ -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 CurrentCommand = NonNullable<Awaited<ReturnType<OpencodeClient["v2"]["command"]["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]
 
 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[]> {
   const result = await sdk.v2.agent.list(location(directory), { throwOnError: true })
   return (result.data?.data ?? []).map(runAgent)

+ 1 - 1
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"

+ 4 - 0
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()

+ 3 - 1
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<RunInput["model"]>
   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),
       }
     })
 

+ 10 - 18
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<RunTuiConfig>
   backgroundSubagents: boolean
   onPermissionReply: (input: PermissionReply) => 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[]) {
-  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<RunInput, "agent" | "model" | "variant">): 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<Lifecycle> {
   const source = resolveInteractiveStdin()
+  const footerTask = import("./footer")
   let unregisterKeymap: (() => void) | undefined
 
   try {
@@ -194,10 +186,10 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
       consoleMode: "disabled",
       clearOnShutdown: false,
     })
-    const theme = await resolveRunTheme(renderer)
+    const [theme, tuiConfig] = await Promise.all([resolveRunTheme(renderer), input.tuiConfig])
     renderer.setBackgroundColor(theme.background)
     const keymap = createDefaultOpenTuiKeymap(renderer)
-    unregisterKeymap = registerOpencodeKeymap(keymap, renderer, input.tuiConfig)
+    unregisterKeymap = registerOpencodeKeymap(keymap, renderer, tuiConfig)
     const state: SplashState = {
       entry: false,
       exit: false,
@@ -212,7 +204,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
       model: input.model,
       variant: input.variant,
     })
-    const footerTask = import("./footer")
     const wrote = queueSplash(
       renderer,
       state,
@@ -244,9 +235,9 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
       theme,
       wrote,
       keymap,
-      tuiConfig: input.tuiConfig,
+      tuiConfig,
       backgroundSubagents: input.backgroundSubagents,
-      diffStyle: input.tuiConfig.diff_style ?? "auto",
+      diffStyle: tuiConfig.diff_style ?? "auto",
       onPermissionReply: input.onPermissionReply,
       onQuestionReply: input.onQuestionReply,
       onQuestionReject: input.onQuestionReject,
@@ -260,6 +251,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
           return
         }
 
+        const { openEditor } = await import("@opencode-ai/tui/editor")
         await renderer.idle().catch(() => {})
         const ignore = () => {}
         detachSigint()

+ 121 - 54
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<ReturnType<StreamTransportModule["createSessionTransport"]>>
 }
 
+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<void>
-  demo?: ReturnType<typeof createRunDemo>
+  demo?: RunDemo
   selectSubagent?: (sessionID: string | undefined) => void
   session?: Promise<void>
   stream?: Promise<StreamState>
@@ -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
+    const model = await waitForDefaultModel({
+      sdk: ctx.sdk,
+      directory: ctx.directory,
+      active: () => !footer.isClosed,
+    })
+    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.session = input.resolveSession(ctx).then((next) => {
-      state.sessionID = next.sessionID
-      state.sessionTitle = next.sessionTitle ?? state.sessionTitle
-      state.agent = next.agent
-    })
-    return state.session
+    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<ReturnType<typeof resolveModelInfo>>,
     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<void> | 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.demo) {
+    await firstPaint
+    if (!footer.isClosed) {
+      await ensureSession()
+      state.demo = await createDemo()
+    }
+  }
+
   if (input.afterPaint) {
-    void Promise.resolve(input.afterPaint(ctx)).catch(() => {})
+    void firstPaint.then(() => (footer.isClosed ? undefined : input.afterPaint?.(ctx))).catch(() => {})
   }
 
-  void modelTask.then((info) => applyModelInfo(info, session.variant, true))
+  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)
+  })
 
-  const streamTask = deps.streamTransport ?? import("./stream-v2.transport")
+  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 {

+ 4 - 3
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)

+ 17 - 2
packages/opencode/src/cli/cmd/run/session.shared.ts

@@ -20,6 +20,8 @@ type Turn = {
 export type RunSession = {
   first: boolean
   turns: Turn[]
+  model?: NonNullable<RunInput["model"]>
+  variant?: string
 }
 
 function fileName(url: string, filename?: string) {
@@ -157,9 +159,11 @@ export async function resolveCurrentSession(
   sessionID: string,
   limit = LIMIT,
 ): 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 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) {

+ 1 - 0
packages/opencode/src/cli/cmd/run/types.ts

@@ -312,6 +312,7 @@ export type FooterEvent =
   | {
       type: "model"
       model: string
+      selection: NonNullable<RunInput["model"]>
     }
   | {
       type: "turn.send"

+ 4 - 10
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,

+ 11 - 0
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)
+}

+ 26 - 1
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(

+ 156 - 11
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<void>()
     const providers = defer<void>()
+    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<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 () => {
     const sdk = new OpencodeClient()
     const refreshGate = defer<void>()

+ 2 - 0
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: [

+ 1 - 0
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",

+ 2 - 14
packages/tui/src/editor.ts

@@ -7,21 +7,9 @@ import { spawn } from "node:child_process"
 import type { Stream } from "node:stream"
 import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
 
-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
-  }
+export { normalizePromptContent } from "./prompt/content"
 
-  if (content.endsWith("\n")) {
-    const body = content.slice(0, -1)
-    return !body.includes("\n") && !body.includes("\r") ? body : content
-  }
-
-  return content
-}
+type EditorStdio = "inherit" | "pipe" | "ignore" | number | Stream
 
 export async function openEditor(input: { value: string; renderer: CliRenderer; cwd?: string; stdin?: EditorStdio }) {
   const editor = process.env.VISUAL || process.env.EDITOR

+ 13 - 0
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
+}