fix(tui): show branch beside directory (#40500)

This commit is contained in:
Aiden Cline 2026-08-04 12:26:18 -05:00 committed by GitHub
parent 9bd99b4c91
commit 0b1ec457dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 101 additions and 7 deletions

View file

@ -19,6 +19,7 @@ import type {
SessionPendingInfo,
ShellInfo,
SkillInfo,
VcsInfo,
} from "@opencode-ai/client"
import type { ResolvedTheme } from "@opencode-ai/theme/tui"
import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core"
@ -113,6 +114,11 @@ export interface Data {
default(): LocationRef
sync(location?: LocationRef): Promise<void>
invalidate(location?: LocationRef): void
readonly vcs: {
info(location?: LocationRef): VcsInfo | undefined
sync(location?: LocationRef): Promise<void>
invalidate(location?: LocationRef): void
}
readonly agent: LocationCollection<AgentInfo>
readonly command: LocationCollection<CommandInfo>
readonly integration: LocationCollection<IntegrationInfo>

View file

@ -1329,12 +1329,17 @@ export function Prompt(props: PromptProps) {
const locationLabel = createMemo(() => {
if (!props.sessionID) {
// No session yet: show where the next session will be created.
const directory = currentLocation.ref?.directory ?? data.location.default().directory
return abbreviateHome(directory, paths.home)
const location = currentLocation.ref ?? data.location.default()
const directory = abbreviateHome(location.directory, paths.home)
const branch = data.location.vcs.info(location)?.branch.current
return branch ? `${directory}:${branch}` : directory
}
if (status() !== "idle") return
const directory = data.session.get(props.sessionID)?.location.directory
return directory ? abbreviateHome(directory, paths.home) : undefined
const location = data.session.get(props.sessionID)?.location
if (!location) return
const directory = abbreviateHome(location.directory, paths.home)
const branch = data.location.vcs.info(location)?.branch.current
return branch ? `${directory}:${branch}` : directory
})
const spinnerDef = createMemo(() => {

View file

@ -27,6 +27,7 @@ import type {
SessionPendingInfo,
ShellInfo,
SkillInfo,
VcsInfo,
OpenCodeEvent,
WebSearchProvider,
} from "@opencode-ai/client"
@ -49,6 +50,7 @@ type ShellWithLocation = ShellInfo & { readonly location: LocationRef }
type LocationData = {
info?: LocationGetOutput
vcs?: VcsInfo
agent?: AgentInfo[]
command?: CommandInfo[]
integration?: IntegrationInfo[]
@ -339,6 +341,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.skill.invalidate(event.location)
void result.location.skill.sync(event.location)
break
case "vcs.branch.updated":
setStore("location", locationKey(event.location ?? defaultLocation()), (data) => ({
...data,
vcs: {
branch: {
...data?.vcs?.branch,
current: event.data.branch,
},
},
}))
break
case "session.agent.selected":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
@ -1152,6 +1165,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
const location = ref ?? defaultLocation()
await Promise.all([
result.location.vcs.sync(location),
result.location.agent.sync(location),
result.location.command.sync(location),
result.location.integration.sync(location),
@ -1168,6 +1182,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
invalidate(ref?: LocationRef) {
const location = ref ?? defaultLocation()
sync.invalidate(`location:${locationKey(location)}`)
result.location.vcs.invalidate(location)
result.location.agent.invalidate(location)
result.location.command.invalidate(location)
result.location.integration.invalidate(location)
@ -1180,6 +1195,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.shell.invalidate(location)
result.session.form.invalidate("global", location)
},
vcs: {
info(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.vcs
},
sync(ref?: LocationRef) {
const location = ref ?? defaultLocation()
return sync.run(`location.vcs:${locationKey(location)}`, async () => {
const response = await client.api.vcs.get({ location: locationQuery(location) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], vcs: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
},
},
agent: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.agent
@ -1377,6 +1408,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
setStore("location", key, { ...store.location[key], info: location })
})
.catch((error) => console.error("Failed to preload location", error))
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
}

View file

@ -3,9 +3,12 @@ import { createMemo, Show } from "solid-js"
import { FilePath } from "../../ui/file-path"
function View(props: { context: Plugin.Context }) {
const directory = createMemo(() =>
props.context.location ? props.context.ui.format.path(props.context.location.directory) : undefined,
)
const directory = createMemo(() => {
if (!props.context.location) return undefined
const value = props.context.ui.format.path(props.context.location.directory)
const branch = props.context.data.location.vcs.info(props.context.location)?.branch.current
return branch ? `${value}:${branch}` : value
})
return (
<Show when={directory()}>
{(value) => <FilePath value={value()} maxWidth={38} fg={props.context.theme.text.subdued} />}

View file

@ -106,6 +106,49 @@ test("does not preload session summaries into the data context", async () => {
}
})
test("syncs VCS info and applies branch updates", async () => {
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== "/api/vcs") return undefined
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
data: { branch: { current: "main", default: "main" } },
})
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => data.location.vcs.info()?.branch.current === "main")
emitEvent(events, {
id: "evt_vcs_branch",
created: Date.now(),
type: "vcs.branch.updated",
data: { branch: "feature" },
})
await wait(() => data.location.vcs.info()?.branch.current === "feature")
expect(data.location.vcs.info()?.branch).toEqual({ current: "feature", default: "main" })
} finally {
app.renderer.destroy()
}
})
test("proactively syncs project metadata newest first", async () => {
const events = createEventStream()
const calls = createFetch((url) => {

View file

@ -95,6 +95,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
if (url.pathname === "/api/location")
return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
if (url.pathname === "/api/vcs")
return json({
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
data: { branch: { current: "main", default: "main" } },
})
if (url.pathname === "/api/fs/list")
return json({ location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } }, data: [] })
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })