fix(tui): preserve model label during location boot (#43974)

This commit is contained in:
Kit Langton 2026-08-21 17:06:43 -04:00 committed by GitHub
parent 87ef814190
commit 1864bc4161
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 186 additions and 29 deletions

View file

@ -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<string>()
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}
</text>
<Show when={dimensions().width >= 50}>
<text flexShrink={0} fg={fadeColor(theme.text.subdued, modelMetaAlpha())}>
{currentProviderLabel()}
{promptDisplay().providerLabel}
</text>
</Show>
<Show when={showVariant() && dimensions().width >= 70}>
<Show when={promptDisplay().variant && dimensions().width >= 70}>
<text fg={fadeColor(theme.text.subdued, variantMetaAlpha())}>·</text>
<text>
<span
@ -1867,7 +1895,7 @@ export function Prompt(props: PromptProps) {
bold: true,
}}
>
{local.model.variant.current()}
{promptDisplay().variant}
</span>
</text>
</Show>

View file

@ -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<void>()
const catalog = Promise.withResolvers<void>()
const providerCatalog = Promise.withResolvers<void>()
const locationRequested = Promise.withResolvers<void>()
const modelRequested = Promise.withResolvers<void>()
const ready = Promise.withResolvers<void>()
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()