feat(coding-agent): add FooterDataProvider for git branch and extension statuses

Expose data that extensions cannot otherwise access: git branch and
extension statuses from setStatus(). Token stats, model info, etc.
remain computable via ctx.sessionManager and ctx.model.
This commit is contained in:
Nico Bailon 2026-01-08 23:52:34 -08:00
parent b4351040a7
commit 7b902612e9
8 changed files with 227 additions and 230 deletions

View file

@ -22,6 +22,7 @@ import type { BashResult } from "../bash-executor.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { EventBus } from "../event-bus.js";
import type { ExecOptions, ExecResult } from "../exec.js";
import type { ReadonlyFooterDataProvider } from "../footer-data-provider.js";
import type { KeybindingsManager } from "../keybindings.js";
import type { CustomMessage } from "../messages.js";
import type { ModelRegistry } from "../model-registry.js";
@ -82,8 +83,17 @@ export interface ExtensionUIContext {
setWidget(key: string, content: string[] | undefined): void;
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
/** Set a custom footer component, or undefined to restore the built-in footer. */
setFooter(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
/** Set a custom footer component, or undefined to restore the built-in footer.
*
* The factory receives a FooterDataProvider for data not otherwise accessible:
* git branch and extension statuses from setStatus(). Token stats, model info,
* etc. are available via ctx.sessionManager and ctx.model.
*/
setFooter(
factory:
| ((tui: TUI, theme: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void })
| undefined,
): void;
/** Set a custom header component (shown at startup, above chat), or undefined to restore the built-in header. */
setHeader(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;

View file

@ -0,0 +1,121 @@
import { existsSync, type FSWatcher, readFileSync, statSync, watch } from "fs";
import { dirname, join, resolve } from "path";
/**
* Find the git HEAD path by walking up from cwd.
* Handles both regular git repos (.git is a directory) and worktrees (.git is a file).
*/
function findGitHeadPath(): string | null {
let dir = process.cwd();
while (true) {
const gitPath = join(dir, ".git");
if (existsSync(gitPath)) {
try {
const stat = statSync(gitPath);
if (stat.isFile()) {
const content = readFileSync(gitPath, "utf8").trim();
if (content.startsWith("gitdir: ")) {
const gitDir = content.slice(8);
const headPath = resolve(dir, gitDir, "HEAD");
if (existsSync(headPath)) return headPath;
}
} else if (stat.isDirectory()) {
const headPath = join(gitPath, "HEAD");
if (existsSync(headPath)) return headPath;
}
} catch {
return null;
}
}
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
/**
* Provides git branch and extension statuses - data not otherwise accessible to extensions.
* Token stats, model info available via ctx.sessionManager and ctx.model.
*/
export class FooterDataProvider {
private extensionStatuses = new Map<string, string>();
private cachedBranch: string | null | undefined = undefined;
private gitWatcher: FSWatcher | null = null;
private branchChangeCallbacks = new Set<() => void>();
constructor() {
this.setupGitWatcher();
}
/** Current git branch, null if not in repo, "detached" if detached HEAD */
getGitBranch(): string | null {
if (this.cachedBranch !== undefined) return this.cachedBranch;
try {
const gitHeadPath = findGitHeadPath();
if (!gitHeadPath) {
this.cachedBranch = null;
return null;
}
const content = readFileSync(gitHeadPath, "utf8").trim();
this.cachedBranch = content.startsWith("ref: refs/heads/") ? content.slice(16) : "detached";
} catch {
this.cachedBranch = null;
}
return this.cachedBranch;
}
/** Extension status texts set via ctx.ui.setStatus() */
getExtensionStatuses(): ReadonlyMap<string, string> {
return this.extensionStatuses;
}
/** Subscribe to git branch changes. Returns unsubscribe function. */
onBranchChange(callback: () => void): () => void {
this.branchChangeCallbacks.add(callback);
return () => this.branchChangeCallbacks.delete(callback);
}
/** Internal: set extension status */
setExtensionStatus(key: string, text: string | undefined): void {
if (text === undefined) {
this.extensionStatuses.delete(key);
} else {
this.extensionStatuses.set(key, text);
}
}
/** Internal: cleanup */
dispose(): void {
if (this.gitWatcher) {
this.gitWatcher.close();
this.gitWatcher = null;
}
this.branchChangeCallbacks.clear();
}
private setupGitWatcher(): void {
if (this.gitWatcher) {
this.gitWatcher.close();
this.gitWatcher = null;
}
const gitHeadPath = findGitHeadPath();
if (!gitHeadPath) return;
try {
this.gitWatcher = watch(gitHeadPath, () => {
this.cachedBranch = undefined;
for (const cb of this.branchChangeCallbacks) cb();
});
} catch {
// Silently fail if we can't watch
}
}
}
/** Read-only view for extensions - excludes setExtensionStatus and dispose */
export type ReadonlyFooterDataProvider = Pick<
FooterDataProvider,
"getGitBranch" | "getExtensionStatuses" | "onBranchChange"
>;