Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/plugin/src/tui/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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>
Expand Down
13 changes: 9 additions & 4 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
32 changes: 32 additions & 0 deletions packages/tui/src/context/data.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
SessionPendingInfo,
ShellInfo,
SkillInfo,
VcsInfo,
OpenCodeEvent,
WebSearchProvider,
} from "@opencode-ai/client"
Expand All @@ -49,6 +50,7 @@ type ShellWithLocation = ShellInfo & { readonly location: LocationRef }

type LocationData = {
info?: LocationGetOutput
vcs?: VcsInfo
agent?: AgentInfo[]
command?: CommandInfo[]
integration?: IntegrationInfo[]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
9 changes: 6 additions & 3 deletions packages/tui/src/feature-plugins/sidebar/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} />}
Expand Down
43 changes: 43 additions & 0 deletions packages/tui/test/cli/tui/data.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/tui/test/fixture/tui-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
Loading