diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 6a77ab0e47f..3071c52b402 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -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 invalidate(location?: LocationRef): void + readonly vcs: { + info(location?: LocationRef): VcsInfo | undefined + sync(location?: LocationRef): Promise + invalidate(location?: LocationRef): void + } readonly agent: LocationCollection readonly command: LocationCollection readonly integration: LocationCollection diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 34df09fcc0f..5de7d94ca4e 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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(() => { diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index d588c42b4d8..b6fc7be6bbb 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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 } diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index c233a478487..0df32ff9b5d 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -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 ( {(value) => } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 46a6defbf7a..e8c6f4095c6 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -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 + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + 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) => { diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index eae2d142da4..4c09d17c7bb 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -95,6 +95,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType