run: defer startup work until first paint

Keep optional initialization off the critical rendering path so the prompt
appears before network and heavy module startup completes. Stop deferred
work when the footer closes to avoid needless requests during early exit.
This commit is contained in:
Simon Klee
2026-07-04 23:55:34 +02:00
parent ba65426f19
commit fa4050673d
12 changed files with 174 additions and 94 deletions
@@ -111,9 +111,10 @@ 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) {
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)
@@ -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"
@@ -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,
@@ -168,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 {
@@ -186,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,
@@ -204,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,
@@ -236,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,
@@ -252,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()
+72 -52
View File
@@ -16,7 +16,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Flag } from "@opencode-ai/core/flag/flag"
import { MessageID } from "@/session/schema"
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
import { createRunDemo } from "./demo"
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>
@@ -191,7 +192,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
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,
@@ -206,7 +207,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
sessionTitle: ctx.sessionTitle,
agent: ctx.agent,
}
const modelTask = (async () => {
const loadModel = async () => {
if (state.model) {
return {
model: state.model,
@@ -216,7 +217,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
}
}
const model = await waitForDefaultModel({ sdk: ctx.sdk, directory: ctx.directory })
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),
@@ -237,24 +243,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
boot: true,
info,
}
})()
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 shell = await (deps.createRuntimeLifecycle ?? createRuntimeLifecycle)({
directory: ctx.directory,
findFiles: (query) =>
@@ -272,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)) {
@@ -408,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)
}
@@ -495,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,
@@ -520,11 +527,20 @@ 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((result) => {
if (!result) return
const current = state.model
const boot =
result.boot &&
@@ -534,7 +550,12 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
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
@@ -548,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")
}
@@ -617,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()
@@ -662,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,
})
@@ -774,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.
@@ -784,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 {
@@ -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)
@@ -159,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) => {
+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 { 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
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)
}
+51 -2
View File
@@ -301,14 +301,17 @@ describe("run interactive runtime", () => {
expect(legacyCommands).not.toHaveBeenCalled()
})
test("paints before resolving the catalog-selected model", async () => {
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)
@@ -318,6 +321,7 @@ describe("run interactive runtime", () => {
}
spyOn(sdk.v2.model, "default").mockImplementation(async () => {
defaultRequested = true
defaultStarted.resolve()
await releaseDefault.promise
return ok({
@@ -382,8 +386,10 @@ describe("run interactive runtime", () => {
},
)
await defaultStarted.promise
await lifecycleStarted.promise
expect(defaultRequested).toBe(false)
painted.resolve()
await defaultStarted.promise
releaseDefault.resolve()
await modelShown.promise
await task
@@ -395,6 +401,49 @@ describe("run interactive runtime", () => {
})
})
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>()
+1
View File
@@ -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
View File
@@ -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
+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
}