From fa4050673d0df9bf7a91b512d012f683ca46cdc5 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sat, 4 Jul 2026 23:55:34 +0200 Subject: [PATCH] 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. --- .../src/cli/cmd/run/catalog.shared.ts | 3 +- .../src/cli/cmd/run/footer.prompt.tsx | 2 +- .../src/cli/cmd/run/runtime.lifecycle.ts | 18 +-- packages/opencode/src/cli/cmd/run/runtime.ts | 124 ++++++++++-------- .../src/cli/cmd/run/scrollback.surface.ts | 7 +- .../src/cli/cmd/run/session.shared.ts | 6 +- packages/opencode/src/session/session.ts | 14 +- packages/opencode/src/session/title.ts | 11 ++ .../opencode/test/cli/run/runtime.test.ts | 53 +++++++- packages/tui/package.json | 1 + packages/tui/src/editor.ts | 16 +-- packages/tui/src/prompt/content.ts | 13 ++ 12 files changed, 174 insertions(+), 94 deletions(-) create mode 100644 packages/opencode/src/session/title.ts create mode 100644 packages/tui/src/prompt/content.ts diff --git a/packages/opencode/src/cli/cmd/run/catalog.shared.ts b/packages/opencode/src/cli/cmd/run/catalog.shared.ts index e873071f69..826b87656b 100644 --- a/packages/opencode/src/cli/cmd/run/catalog.shared.ts +++ b/packages/opencode/src/cli/cmd/run/catalog.shared.ts @@ -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) 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/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index d9dab9f309..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, @@ -168,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 { @@ -186,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 092bda3096..601cdc567f 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -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> } +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 @@ -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 { 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 62d27ab37d..b39624662c 100644 --- a/packages/opencode/src/cli/cmd/run/session.shared.ts +++ b/packages/opencode/src/cli/cmd/run/session.shared.ts @@ -159,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) => { 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/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts index 0a933c0a5f..c98a61b69b 100644 --- a/packages/opencode/test/cli/run/runtime.test.ts +++ b/packages/opencode/test/cli/run/runtime.test.ts @@ -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() 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) @@ -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() + 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/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 +}