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