From 1864bc41617c3cc158c51203746b81f2e31f58de Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 21 Aug 2026 17:06:43 -0400 Subject: [PATCH] fix(tui): preserve model label during location boot (#43974) --- packages/tui/src/component/prompt/index.tsx | 86 ++++++++----- packages/tui/test/app-lifecycle.test.tsx | 129 ++++++++++++++++++++ 2 files changed, 186 insertions(+), 29 deletions(-) diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index a5d43a30192..b172344db35 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -58,7 +58,7 @@ import { MAX_LOCAL_ATTACHMENT_BYTES, type LocalAttachment, } from "./local-attachment" -import { useData } from "../../context/data" +import { locationKey, useData } from "../../context/data" import { useLocation } from "../../context/location" import { Keymap, type KeymapCommand } from "../../context/keymap" import { abbreviateHome } from "../../runtime" @@ -262,6 +262,7 @@ export function Prompt(props: PromptProps) { (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? data.location.info()?.project.id, sessionID: () => props.sessionID, }) + const [pendingDirectory, setPendingDirectory] = createSignal() Keymap.createLayer(() => ({ mode: "global", commands: [ @@ -285,13 +286,18 @@ export function Prompt(props: PromptProps) { expanded, ) if (!sessionID) { + setPendingDirectory(directory) const location = await client.api.location.get({ location: { directory } }).catch((error) => { toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" }) return undefined }) - if (!location) return + if (!location) { + setPendingDirectory(undefined) + return + } if (sourceProjectID) directoryRecents.touch(sourceProjectID, location.directory) currentLocation.set(location) + setPendingDirectory(undefined) return } const error = await client.api.session.move({ sessionID, directory: input }).then( @@ -308,7 +314,6 @@ export function Prompt(props: PromptProps) { ], })) const [cursorVersion, setCursorVersion] = createSignal(0) - const currentProviderLabel = createMemo(() => local.model.parsed().provider) const connected = useConnected() const hasRightContent = createMemo(() => Boolean(props.right)) @@ -1565,30 +1570,53 @@ export function Prompt(props: PromptProps) { resetComposer() } + // Keep the last resolved prompt display visible while destination catalogs load; + // availability and submission still use the live location-scoped catalog. + const promptDisplay = createMemo<{ + agentLabel: string | undefined + agentColor: RGBA | undefined + modelLabel: string + providerLabel: string + variant: string | undefined + }>( + (previous) => { + const location = currentLocation.ref ?? data.location.default() + const sessionLocation = props.sessionID ? data.session.get(props.sessionID)?.location : location + if (!sessionLocation || locationKey(sessionLocation) !== locationKey(location)) return previous + + const loading = data.location.agent.list(location) === undefined || !local.model.catalogReady + const error = currentLocation.error + const failed = error && locationKey(error.location) === locationKey(location) + if (loading && !failed) return previous + + const agent = local.agent.current() + const model = local.model.parsed() + return { + agentLabel: agent ? Locale.titlecase(agent.id) : undefined, + agentColor: agent ? local.agent.color(agent.id) : undefined, + modelLabel: model.model, + providerLabel: model.provider, + variant: local.model.variant.current(), + } + }, + { + agentLabel: undefined, + agentColor: undefined, + modelLabel: local.model.parsed().model, + providerLabel: local.model.parsed().provider, + variant: undefined, + }, + ) const highlight = createMemo(() => { if (leader()) return theme.border.default if (store.mode === "shell") return theme.text.action.primary.selected - const agent = local.agent.current() - if (!agent) return theme.border.default - return local.agent.color(agent.id) + return promptDisplay().agentColor ?? theme.border.default }) - const agentLabel = createMemo(() => { - if (store.mode === "shell") return "Shell" - const agent = local.agent.current() - return agent ? Locale.titlecase(agent.id) : undefined - }) - - const showVariant = createMemo(() => { - const variants = local.model.variant.list() - if (variants.length === 0) return false - const current = local.model.variant.current() - return !!current - }) - - const agentMetaAlpha = createFadeIn(() => store.mode === "shell" || !!local.agent.current(), animationsEnabled) - const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled) + const agentLabel = createMemo(() => (store.mode === "shell" ? "Shell" : promptDisplay().agentLabel)) + const agentMetaAlpha = createFadeIn(() => !!agentLabel(), animationsEnabled) + const modelMetaAlpha = createFadeIn(() => !!promptDisplay().agentLabel && store.mode === "normal", animationsEnabled) const variantMetaAlpha = createFadeIn( - () => !!local.agent.current() && store.mode === "normal" && showVariant(), + () => !!promptDisplay().agentLabel && store.mode === "normal" && !!promptDisplay().variant, animationsEnabled, ) const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha())) @@ -1617,7 +1645,8 @@ export function Prompt(props: PromptProps) { return data.session.get(props.sessionID)?.location }) const locationLabel = createMemo(() => { - const location = footerLocation() + const pending = pendingDirectory() + const location = pending ? { directory: pending } : footerLocation() if (!location) return const directory = abbreviateHome(location.directory, paths.home) const branch = data.location.vcs.info(location)?.branch.current @@ -1635,8 +1664,7 @@ export function Prompt(props: PromptProps) { }) const spinnerDef = createMemo(() => { - const agent = status() === "running" ? local.agent.current() : local.agent.current() - const color = agent ? local.agent.color(agent.id) : theme.border.default + const color = promptDisplay().agentColor ?? theme.border.default return { frames: createFrames({ color, @@ -1851,14 +1879,14 @@ export function Prompt(props: PromptProps) { truncate fg={fadeColor(leader() ? theme.text.subdued : theme.text.default, modelMetaAlpha())} > - {local.model.parsed().model} + {promptDisplay().modelLabel} = 50}> - {currentProviderLabel()} + {promptDisplay().providerLabel} - = 70}> + = 70}> · - {local.model.variant.current()} + {promptDisplay().variant} diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index ca0f9a6a34b..d1dae7cb78c 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -3,6 +3,7 @@ import { createTestRenderer } from "@opentui/core/testing" import { Effect, FileSystem } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/util/global" +import path from "node:path" import { createEventStream, createFetch, directory, json } from "./fixture/tui-client" test("SIGHUP clears title and disposes scoped resources once", async () => { @@ -295,6 +296,134 @@ test("session startup prompt is submitted exactly once", async () => { } }) +test("keeps the prompt display stable while a new location catalog loads", async () => { + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true }) + setup.renderer.start() + const events = createEventStream() + const source = process.cwd() + const target = path.join(path.parse(source).root, "opencode-target") + const locationCatalog = Promise.withResolvers() + const catalog = Promise.withResolvers() + const providerCatalog = Promise.withResolvers() + const locationRequested = Promise.withResolvers() + const modelRequested = Promise.withResolvers() + const ready = Promise.withResolvers() + const calls = createFetch(async (url) => { + const requestedDirectory = url.searchParams.get("location[directory]") ?? source + const location = { + directory: requestedDirectory, + project: { + id: requestedDirectory === target ? "target" : "source", + directory: requestedDirectory, + canonical: requestedDirectory, + }, + } + if (url.pathname === "/api/location") { + if (requestedDirectory === target) { + locationRequested.resolve() + await locationCatalog.promise + } + return json(location) + } + if (url.pathname === "/api/agent") { + if (requestedDirectory === target) await catalog.promise + return json({ + location, + data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }], + }) + } + if (url.pathname === "/api/provider") { + if (requestedDirectory === target) await providerCatalog.promise + return json({ location, data: [{ id: "provider", name: "Provider" }] }) + } + if (url.pathname === "/api/model") { + if (requestedDirectory === target) { + modelRequested.resolve() + await catalog.promise + } + return json({ + location, + data: [ + { + id: requestedDirectory === target ? "target-model" : "source-model", + providerID: "provider", + name: requestedDirectory === target ? "Target Model" : "Source Model", + variants: [], + }, + ], + }) + } + return undefined + }, events) + const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) }) + + try { + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + app: { name: "test", version: "test", channel: "test" }, + server: { endpoint: { url: server.url.toString() } }, + config: { get: async () => ({ animations: false }), update: async () => ({}) }, + packages: { resolve: async () => undefined }, + terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }), + args: {}, + log: () => {}, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))), + ) + + await ready.promise + await setup.waitForFrame((frame) => frame.includes("Build · Source Model Provider")) + const agentSpan = () => + setup + .captureSpans() + .lines.flatMap((line) => line.spans) + .find((span) => span.text.trim() === "Build") + const sourceAgentColor = agentSpan()?.fg.toInts() + expect(sourceAgentColor).toBeDefined() + await setup.mockInput.typeText(`/cd ${target}`) + await setup.renderOnce() + expect(setup.captureCharFrame()).toContain(`/cd ${target}`) + setup.mockInput.pressEscape() + setup.mockInput.pressEnter() + await Promise.race([ + locationRequested.promise, + Bun.sleep(2_000).then(() => { + throw new Error("target location was not requested") + }), + ]) + await setup.renderOnce() + expect(setup.captureCharFrame()).toContain(target) + + locationCatalog.resolve() + await Promise.race([ + modelRequested.promise, + Bun.sleep(2_000).then(() => { + throw new Error("target model catalog was not requested") + }), + ]) + await setup.renderOnce() + + expect(setup.captureCharFrame()).toContain("Build · Source Model Provider") + expect(agentSpan()?.fg.toInts()).toEqual(sourceAgentColor) + + catalog.resolve() + const resolved = await setup.waitForFrame((frame) => frame.includes("Build · Target Model provider")) + expect(resolved).not.toContain("Source Model") + + providerCatalog.resolve() + await setup.waitForFrame((frame) => frame.includes("Build · Target Model Provider")) + + setup.renderer.destroy() + await task + } finally { + locationCatalog.resolve() + catalog.resolve() + providerCatalog.resolve() + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + await server.stop() + } +}) + test("configured app bindings execute settings and permission commands", async () => { const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true }) setup.renderer.start()