diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md index dcec011f..27f218da 100644 --- a/packages/coding-agent/docs/tui.md +++ b/packages/coding-agent/docs/tui.md @@ -624,23 +624,24 @@ ctx.ui.setWidget("my-widget", undefined); ### Pattern 6: Custom Footer -Replace the entire footer with custom content. +Replace the footer. `footerData` exposes data not otherwise accessible to extensions. ```typescript -ctx.ui.setFooter((_tui, theme) => ({ - render(width: number): string[] { - const left = theme.fg("dim", "custom footer"); - const right = theme.fg("accent", "status"); - const padding = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); - return [truncateToWidth(left + padding + right, width)]; - }, +ctx.ui.setFooter((tui, theme, footerData) => ({ invalidate() {}, + render(width: number): string[] { + // footerData.getGitBranch(): string | null + // footerData.getExtensionStatuses(): ReadonlyMap + return [`${ctx.model?.id} (${footerData.getGitBranch() || "no git"})`]; + }, + dispose: footerData.onBranchChange(() => tui.requestRender()), // reactive })); -// Restore default -ctx.ui.setFooter(undefined); +ctx.ui.setFooter(undefined); // restore default ``` +Token stats available via `ctx.sessionManager.getBranch()` and `ctx.model`. + **Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts) ### Pattern 7: Custom Editor (vim mode, etc.) diff --git a/packages/coding-agent/examples/extensions/custom-footer.ts b/packages/coding-agent/examples/extensions/custom-footer.ts index 857cfba8..f35853df 100644 --- a/packages/coding-agent/examples/extensions/custom-footer.ts +++ b/packages/coding-agent/examples/extensions/custom-footer.ts @@ -1,8 +1,11 @@ /** - * Custom Footer Extension + * Custom Footer Extension - demonstrates ctx.ui.setFooter() * - * Demonstrates ctx.ui.setFooter() for replacing the built-in footer - * with a custom component showing session context usage. + * footerData exposes data not otherwise accessible: + * - getGitBranch(): current git branch + * - getExtensionStatuses(): texts from ctx.ui.setStatus() + * + * Token stats come from ctx.sessionManager/ctx.model (already accessible). */ import type { AssistantMessage } from "@mariozechner/pi-ai"; @@ -10,76 +13,51 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; export default function (pi: ExtensionAPI) { - let isCustomFooter = false; + let enabled = false; - // Toggle custom footer with /footer command pi.registerCommand("footer", { - description: "Toggle custom footer showing context usage", + description: "Toggle custom footer", handler: async (_args, ctx) => { - isCustomFooter = !isCustomFooter; + enabled = !enabled; + + if (enabled) { + ctx.ui.setFooter((tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => tui.requestRender()); - if (isCustomFooter) { - ctx.ui.setFooter((_tui, theme) => { return { + dispose: unsub, + invalidate() {}, render(width: number): string[] { - // Calculate usage from branch entries - let totalInput = 0; - let totalOutput = 0; - let totalCost = 0; - let lastAssistant: AssistantMessage | undefined; - - for (const entry of ctx.sessionManager.getBranch()) { - if (entry.type === "message" && entry.message.role === "assistant") { - const msg = entry.message as AssistantMessage; - totalInput += msg.usage.input; - totalOutput += msg.usage.output; - totalCost += msg.usage.cost.total; - lastAssistant = msg; + // Compute tokens from ctx (already accessible to extensions) + let input = 0, + output = 0, + cost = 0; + for (const e of ctx.sessionManager.getBranch()) { + if (e.type === "message" && e.message.role === "assistant") { + const m = e.message as AssistantMessage; + input += m.usage.input; + output += m.usage.output; + cost += m.usage.cost.total; } } - // Context percentage from last assistant message - const contextTokens = lastAssistant - ? lastAssistant.usage.input + - lastAssistant.usage.output + - lastAssistant.usage.cacheRead + - lastAssistant.usage.cacheWrite - : 0; - const contextWindow = ctx.model?.contextWindow || 0; - const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; - - // Format tokens + // Get git branch (not otherwise accessible) + const branch = footerData.getGitBranch(); const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`); - // Build footer line - const left = [ - theme.fg("dim", `↑${fmt(totalInput)}`), - theme.fg("dim", `↓${fmt(totalOutput)}`), - theme.fg("dim", `$${totalCost.toFixed(3)}`), - ].join(" "); + const left = theme.fg("dim", `↑${fmt(input)} ↓${fmt(output)} $${cost.toFixed(3)}`); + const branchStr = branch ? ` (${branch})` : ""; + const right = theme.fg("dim", `${ctx.model?.id || "no-model"}${branchStr}`); - // Color context percentage based on usage - let contextStr = `${contextPercent.toFixed(1)}%`; - if (contextPercent > 90) { - contextStr = theme.fg("error", contextStr); - } else if (contextPercent > 70) { - contextStr = theme.fg("warning", contextStr); - } else { - contextStr = theme.fg("success", contextStr); - } - - const right = `${contextStr} ${theme.fg("dim", ctx.model?.id || "no model")}`; - const padding = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); - - return [truncateToWidth(left + padding + right, width)]; + const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); + return [truncateToWidth(left + pad + right, width)]; }, - invalidate() {}, }; }); ctx.ui.notify("Custom footer enabled", "info"); } else { ctx.ui.setFooter(undefined); - ctx.ui.notify("Built-in footer restored", "info"); + ctx.ui.notify("Default footer restored", "info"); } }, }); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index cddafde0..035473b0 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -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; diff --git a/packages/coding-agent/src/core/footer-data-provider.ts b/packages/coding-agent/src/core/footer-data-provider.ts new file mode 100644 index 00000000..91317c38 --- /dev/null +++ b/packages/coding-agent/src/core/footer-data-provider.ts @@ -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(); + 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 { + 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" +>; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index d2408ae9..ac9c49db 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -103,6 +103,8 @@ export { wrapToolsWithExtensions, wrapToolWithExtensions, } from "./core/extensions/index.js"; +// Footer data provider (git branch + extension statuses - data not otherwise available to extensions) +export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js"; export { convertToLlm } from "./core/messages.js"; export { ModelRegistry } from "./core/model-registry.js"; // SDK for programmatic usage diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index cb31b62c..29d66ce4 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,8 +1,7 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import { existsSync, type FSWatcher, readFileSync, statSync, watch } from "fs"; -import { dirname, join, resolve } from "path"; import type { AgentSession } from "../../../core/agent-session.js"; +import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.js"; import { theme } from "../theme/theme.js"; /** @@ -18,160 +17,46 @@ function sanitizeStatusText(text: string): string { } /** - * 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). - * Returns the path to the HEAD file if found, null otherwise. + * Format token counts (similar to web-ui) */ -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()) { - // Worktree: .git is a file containing "gitdir: " - 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()) { - // Regular repo: .git is a directory - const headPath = join(gitPath, "HEAD"); - if (existsSync(headPath)) { - return headPath; - } - } - } catch { - return null; - } - } - const parent = dirname(dir); - if (parent === dir) { - // Reached filesystem root - return null; - } - dir = parent; - } +function formatTokens(count: number): string { + if (count < 1000) return count.toString(); + if (count < 10000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1000000) return `${Math.round(count / 1000)}k`; + if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; + return `${Math.round(count / 1000000)}M`; } /** - * Footer component that shows pwd, token stats, and context usage + * Footer component that shows pwd, token stats, and context usage. + * Computes token/context stats from session, gets git branch and extension statuses from provider. */ export class FooterComponent implements Component { - private session: AgentSession; - private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name - private gitWatcher: FSWatcher | null = null; - private onBranchChange: (() => void) | null = null; - private autoCompactEnabled: boolean = true; - private extensionStatuses: Map = new Map(); + private autoCompactEnabled = true; - constructor(session: AgentSession) { - this.session = session; - } + constructor( + private session: AgentSession, + private footerData: ReadonlyFooterDataProvider, + ) {} setAutoCompactEnabled(enabled: boolean): void { this.autoCompactEnabled = enabled; } /** - * Set extension status text to display in the footer. - * Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width. - * ANSI escape codes for styling are preserved. - * @param key - Unique key to identify this status - * @param text - Status text, or undefined to clear + * No-op: git branch caching now handled by provider. + * Kept for compatibility with existing call sites in interactive-mode. */ - setExtensionStatus(key: string, text: string | undefined): void { - if (text === undefined) { - this.extensionStatuses.delete(key); - } else { - this.extensionStatuses.set(key, text); - } + invalidate(): void { + // No-op: git branch is cached/invalidated by provider } /** - * Set up a file watcher on .git/HEAD to detect branch changes. - * Call the provided callback when branch changes. - */ - watchBranch(onBranchChange: () => void): void { - this.onBranchChange = onBranchChange; - this.setupGitWatcher(); - } - - private setupGitWatcher(): void { - // Clean up existing watcher - if (this.gitWatcher) { - this.gitWatcher.close(); - this.gitWatcher = null; - } - - const gitHeadPath = findGitHeadPath(); - if (!gitHeadPath) { - return; - } - - try { - this.gitWatcher = watch(gitHeadPath, () => { - this.cachedBranch = undefined; // Invalidate cache - if (this.onBranchChange) { - this.onBranchChange(); - } - }); - } catch { - // Silently fail if we can't watch - } - } - - /** - * Clean up the file watcher + * Clean up resources. + * Git watcher cleanup now handled by provider. */ dispose(): void { - if (this.gitWatcher) { - this.gitWatcher.close(); - this.gitWatcher = null; - } - } - - invalidate(): void { - // Invalidate cached branch so it gets re-read on next render - this.cachedBranch = undefined; - } - - /** - * Get current git branch by reading .git/HEAD directly. - * Returns null if not in a git repo, branch name otherwise. - */ - private getCurrentBranch(): string | null { - // Return cached value if available - if (this.cachedBranch !== undefined) { - return this.cachedBranch; - } - - try { - const gitHeadPath = findGitHeadPath(); - if (!gitHeadPath) { - this.cachedBranch = null; - return null; - } - const content = readFileSync(gitHeadPath, "utf8").trim(); - - if (content.startsWith("ref: refs/heads/")) { - // Normal branch: extract branch name - this.cachedBranch = content.slice(16); - } else { - // Detached HEAD state - this.cachedBranch = "detached"; - } - } catch { - // Not in a git repo or error reading file - this.cachedBranch = null; - } - - return this.cachedBranch; + // Git watcher cleanup handled by provider } render(width: number): string[] { @@ -211,15 +96,6 @@ export class FooterComponent implements Component { const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; const contextPercent = contextPercentValue.toFixed(1); - // Format token counts (similar to web-ui) - const formatTokens = (count: number): string => { - if (count < 1000) return count.toString(); - if (count < 10000) return `${(count / 1000).toFixed(1)}k`; - if (count < 1000000) return `${Math.round(count / 1000)}k`; - if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; - return `${Math.round(count / 1000000)}M`; - }; - // Replace home directory with ~ let pwd = process.cwd(); const home = process.env.HOME || process.env.USERPROFILE; @@ -228,7 +104,7 @@ export class FooterComponent implements Component { } // Add git branch if available - const branch = this.getCurrentBranch(); + const branch = this.footerData.getGitBranch(); if (branch) { pwd = `${pwd} (${branch})`; } @@ -332,8 +208,9 @@ export class FooterComponent implements Component { const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder]; // Add extension statuses on a single line, sorted by key alphabetically - if (this.extensionStatuses.size > 0) { - const sortedStatuses = Array.from(this.extensionStatuses.entries()) + const extensionStatuses = this.footerData.getExtensionStatuses(); + if (extensionStatuses.size > 0) { + const sortedStatuses = Array.from(extensionStatuses.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([, text]) => sanitizeStatusText(text)); const statusLine = sortedStatuses.join(" "); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 80ab2ed5..dec86853 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -41,6 +41,7 @@ import type { ExtensionUIContext, ExtensionUIDialogOptions, } from "../../core/extensions/index.js"; +import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js"; import { KeybindingsManager } from "../../core/keybindings.js"; import { createCompactionSummaryMessage } from "../../core/messages.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; @@ -128,6 +129,7 @@ export class InteractiveMode { private autocompleteProvider: CombinedAutocompleteProvider | undefined; private editorContainer: Container; private footer: FooterComponent; + private footerDataProvider: FooterDataProvider; private keybindings: KeybindingsManager; private version: string; private isInitialized = false; @@ -226,7 +228,8 @@ export class InteractiveMode { this.editor = this.defaultEditor; this.editorContainer = new Container(); this.editorContainer.addChild(this.editor as Component); - this.footer = new FooterComponent(session); + this.footerDataProvider = new FooterDataProvider(); + this.footer = new FooterComponent(session, this.footerDataProvider); this.footer.setAutoCompactEnabled(session.autoCompactionEnabled); // Load hide thinking block setting @@ -427,8 +430,8 @@ export class InteractiveMode { this.ui.requestRender(); }); - // Set up git branch watcher - this.footer.watchBranch(() => { + // Set up git branch watcher (uses provider instead of footer) + this.footerDataProvider.onBranchChange(() => { this.ui.requestRender(); }); } @@ -797,7 +800,7 @@ export class InteractiveMode { * Set extension status text in the footer. */ private setExtensionStatus(key: string, text: string | undefined): void { - this.footer.setExtensionStatus(key, text); + this.footerDataProvider.setExtensionStatus(key, text); this.ui.requestRender(); } @@ -859,7 +862,11 @@ export class InteractiveMode { /** * Set a custom footer component, or restore the built-in footer. */ - private setExtensionFooter(factory: ((tui: TUI, thm: Theme) => Component & { dispose?(): void }) | undefined): void { + private setExtensionFooter( + factory: + | ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void }) + | undefined, + ): void { // Dispose existing custom footer if (this.customFooter?.dispose) { this.customFooter.dispose(); @@ -873,8 +880,8 @@ export class InteractiveMode { } if (factory) { - // Create and add custom footer - this.customFooter = factory(this.ui, theme); + // Create and add custom footer, passing the data provider + this.customFooter = factory(this.ui, theme, this.footerDataProvider); this.ui.addChild(this.customFooter); } else { // Restore built-in footer @@ -3430,6 +3437,7 @@ export class InteractiveMode { this.loadingAnimation = undefined; } this.footer.dispose(); + this.footerDataProvider.dispose(); if (this.unsubscribe) { this.unsubscribe(); }