show current git branch in tui (#4765)

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Aiden Cline 2025-11-25 19:39:20 -08:00 committed by GitHub
parent d95f724303
commit 09bc8d9ca4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 193 additions and 2 deletions

View file

@ -478,7 +478,10 @@ function App() {
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
</box>
<box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
<text fg={theme.textMuted}>
{process.cwd().replace(Global.Path.home, "~")}
{sync.data.vcs?.vcs?.branch ? `:${sync.data.vcs.vcs.branch}` : ""}
</text>
</box>
</box>
<Show when={false}>

View file

@ -14,6 +14,7 @@ import type {
SessionStatus,
ProviderListResponse,
ProviderAuthMethod,
VcsInfo,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
@ -59,6 +60,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[key: string]: McpStatus
}
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
}>({
provider_next: {
all: [],
@ -82,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
lsp: [],
mcp: {},
formatter: [],
vcs: undefined,
})
const sdk = useSDK()
@ -238,6 +241,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
break
}
case "vcs.changed": {
setStore("vcs", "vcs", { branch: event.properties.branch })
break
}
}
})
@ -276,6 +284,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
sdk.client.vcs.get().then((x) => setStore("vcs", x.data)),
]).then(() => {
setStore("status", "complete")
})

View file

@ -4,11 +4,11 @@ import { Format } from "../format"
import { LSP } from "../lsp"
import { FileWatcher } from "../file/watcher"
import { File } from "../file"
import { Flag } from "../flag/flag"
import { Project } from "./project"
import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
import { Vcs } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
@ -21,6 +21,7 @@ export async function InstanceBootstrap() {
await LSP.init()
FileWatcher.init()
File.init()
Vcs.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {

View file

@ -0,0 +1,86 @@
import { $ } from "bun"
import { watch, type FSWatcher } from "fs"
import path from "path"
import z from "zod"
import { Log } from "@/util/log"
import { Bus } from "@/bus"
import { Instance } from "./instance"
const log = Log.create({ service: "vcs" })
export namespace Vcs {
export const Event = {
Changed: Bus.event(
"vcs.changed",
z.object({
branch: z.string().optional(),
}),
),
}
async function currentBranch() {
return $`git rev-parse --abbrev-ref HEAD`
.quiet()
.nothrow()
.cwd(Instance.worktree)
.text()
.then((x) => x.trim())
.catch(() => undefined)
}
const state = Instance.state(
async () => {
if (Instance.project.vcs !== "git") {
return { branch: async () => undefined, watcher: undefined }
}
let current = await currentBranch()
log.info("initialized", { branch: current })
const gitDir = await $`git rev-parse --git-dir`
.quiet()
.nothrow()
.cwd(Instance.worktree)
.text()
.then((x) => x.trim())
.catch(() => undefined)
if (!gitDir) {
log.warn("failed to resolve git directory")
return { branch: async () => current, watcher: undefined }
}
const gitHead = path.join(gitDir, "HEAD")
let watcher: FSWatcher | undefined
// we should probably centralize file watching (see watcher.ts)
// but parcel still marked experimental rn
try {
watcher = watch(gitHead, async () => {
const next = await currentBranch()
if (next !== current) {
log.info("branch changed", { from: current, to: next })
current = next
Bus.publish(Event.Changed, { branch: next })
}
})
log.info("watching", { path: gitHead })
} catch (e) {
log.warn("failed to watch git HEAD", { error: e })
}
return {
branch: async () => current,
watcher,
}
},
async (state) => {
state.watcher?.close()
},
)
export async function init() {
return state()
}
export async function branch() {
return await state().then((s) => s.branch())
}
}

View file

@ -20,6 +20,7 @@ import { MessageV2 } from "../session/message-v2"
import { TuiRoute } from "./tui"
import { Permission } from "../permission"
import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs"
import { Agent } from "../agent/agent"
import { Auth } from "../auth"
import { Command } from "../command"
@ -365,6 +366,47 @@ export namespace Server {
})
},
)
.get(
"/vcs",
describeRoute({
description: "Get VCS info for the current instance",
operationId: "vcs.get",
responses: {
200: {
description: "VCS info",
content: {
"application/json": {
schema: resolver(
z
.object({
worktree: z.string(),
directory: z.string(),
projectID: z.string(),
vcs: z
.object({
branch: z.string(),
})
.optional(),
})
.meta({
ref: "VcsInfo",
}),
),
},
},
},
},
}),
async (c) => {
const branch = await Vcs.branch()
return c.json({
worktree: Instance.worktree,
directory: Instance.directory,
projectID: Instance.project.id,
vcs: Instance.project.vcs ? { branch } : undefined,
})
},
)
.get(
"/session",
describeRoute({

View file

@ -23,6 +23,8 @@ import type {
InstanceDisposeResponses,
PathGetData,
PathGetResponses,
VcsGetData,
VcsGetResponses,
SessionListData,
SessionListResponses,
SessionCreateData,
@ -311,6 +313,18 @@ class Path extends _HeyApiClient {
}
}
class Vcs extends _HeyApiClient {
/**
* Get VCS info for the current instance
*/
public get<ThrowOnError extends boolean = false>(options?: Options<VcsGetData, ThrowOnError>) {
return (options?.client ?? this._client).get<VcsGetResponses, unknown, ThrowOnError>({
url: "/vcs",
...options,
})
}
}
class Session extends _HeyApiClient {
/**
* List all sessions
@ -995,6 +1009,7 @@ export class OpencodeClient extends _HeyApiClient {
tool = new Tool({ client: this._client })
instance = new Instance({ client: this._client })
path = new Path({ client: this._client })
vcs = new Vcs({ client: this._client })
session = new Session({ client: this._client })
command = new Command({ client: this._client })
provider = new Provider({ client: this._client })

View file

@ -589,6 +589,13 @@ export type EventSessionError = {
}
}
export type EventVcsChanged = {
type: "vcs.changed"
properties: {
branch?: string
}
}
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
@ -670,6 +677,7 @@ export type Event =
| EventSessionDeleted
| EventSessionDiff
| EventSessionError
| EventVcsChanged
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
@ -1251,6 +1259,15 @@ export type Path = {
directory: string
}
export type VcsInfo = {
worktree: string
directory: string
projectID: string
vcs?: {
branch: string
}
}
export type NotFoundError = {
name: "NotFoundError"
data: {
@ -1687,6 +1704,24 @@ export type PathGetResponses = {
export type PathGetResponse = PathGetResponses[keyof PathGetResponses]
export type VcsGetData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/vcs"
}
export type VcsGetResponses = {
/**
* VCS info
*/
200: VcsInfo
}
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
export type SessionListData = {
body?: never
path?: never