mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 17:23:28 +00:00
fix(tui): preserve model label during location boot (#43974)
This commit is contained in:
parent
87ef814190
commit
1864bc4161
2 changed files with 186 additions and 29 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue