mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-20 16:05:11 +00:00
Merge pull request #600 from nicobailon/feature/footer-data-provider
feat(coding-agent): add FooterDataProvider for git branch and extension statuses
This commit is contained in:
commit
22b2a18952
7 changed files with 220 additions and 223 deletions
|
|
@ -624,23 +624,24 @@ ctx.ui.setWidget("my-widget", undefined);
|
||||||
|
|
||||||
### Pattern 6: Custom Footer
|
### Pattern 6: Custom Footer
|
||||||
|
|
||||||
Replace the entire footer with custom content.
|
Replace the footer. `footerData` exposes data not otherwise accessible to extensions.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
ctx.ui.setFooter((_tui, theme) => ({
|
ctx.ui.setFooter((tui, theme, footerData) => ({
|
||||||
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)];
|
|
||||||
},
|
|
||||||
invalidate() {},
|
invalidate() {},
|
||||||
|
render(width: number): string[] {
|
||||||
|
// footerData.getGitBranch(): string | null
|
||||||
|
// footerData.getExtensionStatuses(): ReadonlyMap<string, string>
|
||||||
|
return [`${ctx.model?.id} (${footerData.getGitBranch() || "no git"})`];
|
||||||
|
},
|
||||||
|
dispose: footerData.onBranchChange(() => tui.requestRender()), // reactive
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Restore default
|
ctx.ui.setFooter(undefined); // restore default
|
||||||
ctx.ui.setFooter(undefined);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Token stats available via `ctx.sessionManager.getBranch()` and `ctx.model`.
|
||||||
|
|
||||||
**Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts)
|
**Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts)
|
||||||
|
|
||||||
### Pattern 7: Custom Editor (vim mode, etc.)
|
### Pattern 7: Custom Editor (vim mode, etc.)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* Custom Footer Extension
|
* Custom Footer Extension - demonstrates ctx.ui.setFooter()
|
||||||
*
|
*
|
||||||
* Demonstrates ctx.ui.setFooter() for replacing the built-in footer
|
* footerData exposes data not otherwise accessible:
|
||||||
* with a custom component showing session context usage.
|
* - 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";
|
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";
|
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
let isCustomFooter = false;
|
let enabled = false;
|
||||||
|
|
||||||
// Toggle custom footer with /footer command
|
|
||||||
pi.registerCommand("footer", {
|
pi.registerCommand("footer", {
|
||||||
description: "Toggle custom footer showing context usage",
|
description: "Toggle custom footer",
|
||||||
handler: async (_args, ctx) => {
|
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 {
|
return {
|
||||||
|
dispose: unsub,
|
||||||
|
invalidate() {},
|
||||||
render(width: number): string[] {
|
render(width: number): string[] {
|
||||||
// Calculate usage from branch entries
|
// Compute tokens from ctx (already accessible to extensions)
|
||||||
let totalInput = 0;
|
let input = 0,
|
||||||
let totalOutput = 0;
|
output = 0,
|
||||||
let totalCost = 0;
|
cost = 0;
|
||||||
let lastAssistant: AssistantMessage | undefined;
|
for (const e of ctx.sessionManager.getBranch()) {
|
||||||
|
if (e.type === "message" && e.message.role === "assistant") {
|
||||||
for (const entry of ctx.sessionManager.getBranch()) {
|
const m = e.message as AssistantMessage;
|
||||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
input += m.usage.input;
|
||||||
const msg = entry.message as AssistantMessage;
|
output += m.usage.output;
|
||||||
totalInput += msg.usage.input;
|
cost += m.usage.cost.total;
|
||||||
totalOutput += msg.usage.output;
|
|
||||||
totalCost += msg.usage.cost.total;
|
|
||||||
lastAssistant = msg;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context percentage from last assistant message
|
// Get git branch (not otherwise accessible)
|
||||||
const contextTokens = lastAssistant
|
const branch = footerData.getGitBranch();
|
||||||
? 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
|
|
||||||
const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);
|
const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);
|
||||||
|
|
||||||
// Build footer line
|
const left = theme.fg("dim", `↑${fmt(input)} ↓${fmt(output)} $${cost.toFixed(3)}`);
|
||||||
const left = [
|
const branchStr = branch ? ` (${branch})` : "";
|
||||||
theme.fg("dim", `↑${fmt(totalInput)}`),
|
const right = theme.fg("dim", `${ctx.model?.id || "no-model"}${branchStr}`);
|
||||||
theme.fg("dim", `↓${fmt(totalOutput)}`),
|
|
||||||
theme.fg("dim", `$${totalCost.toFixed(3)}`),
|
|
||||||
].join(" ");
|
|
||||||
|
|
||||||
// Color context percentage based on usage
|
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
|
||||||
let contextStr = `${contextPercent.toFixed(1)}%`;
|
return [truncateToWidth(left + pad + right, width)];
|
||||||
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)];
|
|
||||||
},
|
},
|
||||||
invalidate() {},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
ctx.ui.notify("Custom footer enabled", "info");
|
ctx.ui.notify("Custom footer enabled", "info");
|
||||||
} else {
|
} else {
|
||||||
ctx.ui.setFooter(undefined);
|
ctx.ui.setFooter(undefined);
|
||||||
ctx.ui.notify("Built-in footer restored", "info");
|
ctx.ui.notify("Default footer restored", "info");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import type { BashResult } from "../bash-executor.js";
|
||||||
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
|
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
|
||||||
import type { EventBus } from "../event-bus.js";
|
import type { EventBus } from "../event-bus.js";
|
||||||
import type { ExecOptions, ExecResult } from "../exec.js";
|
import type { ExecOptions, ExecResult } from "../exec.js";
|
||||||
|
import type { ReadonlyFooterDataProvider } from "../footer-data-provider.js";
|
||||||
import type { KeybindingsManager } from "../keybindings.js";
|
import type { KeybindingsManager } from "../keybindings.js";
|
||||||
import type { CustomMessage } from "../messages.js";
|
import type { CustomMessage } from "../messages.js";
|
||||||
import type { ModelRegistry } from "../model-registry.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: string[] | undefined): void;
|
||||||
setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | 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. */
|
/** Set a custom footer component, or undefined to restore the built-in footer.
|
||||||
setFooter(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
|
*
|
||||||
|
* 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. */
|
/** 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;
|
setHeader(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void;
|
||||||
|
|
|
||||||
121
packages/coding-agent/src/core/footer-data-provider.ts
Normal file
121
packages/coding-agent/src/core/footer-data-provider.ts
Normal 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"
|
||||||
|
>;
|
||||||
|
|
@ -103,6 +103,8 @@ export {
|
||||||
wrapToolsWithExtensions,
|
wrapToolsWithExtensions,
|
||||||
wrapToolWithExtensions,
|
wrapToolWithExtensions,
|
||||||
} from "./core/extensions/index.js";
|
} 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 { convertToLlm } from "./core/messages.js";
|
||||||
export { ModelRegistry } from "./core/model-registry.js";
|
export { ModelRegistry } from "./core/model-registry.js";
|
||||||
// SDK for programmatic usage
|
// SDK for programmatic usage
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
import { type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
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 { AgentSession } from "../../../core/agent-session.js";
|
||||||
|
import type { ReadonlyFooterDataProvider } from "../../../core/footer-data-provider.js";
|
||||||
import { theme } from "../theme/theme.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.
|
* Format token counts (similar to web-ui)
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
function findGitHeadPath(): string | null {
|
function formatTokens(count: number): string {
|
||||||
let dir = process.cwd();
|
if (count < 1000) return count.toString();
|
||||||
while (true) {
|
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
||||||
const gitPath = join(dir, ".git");
|
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
||||||
if (existsSync(gitPath)) {
|
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||||
try {
|
return `${Math.round(count / 1000000)}M`;
|
||||||
const stat = statSync(gitPath);
|
|
||||||
if (stat.isFile()) {
|
|
||||||
// Worktree: .git is a file containing "gitdir: <path>"
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 {
|
export class FooterComponent implements Component {
|
||||||
private session: AgentSession;
|
private autoCompactEnabled = true;
|
||||||
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<string, string> = new Map();
|
|
||||||
|
|
||||||
constructor(session: AgentSession) {
|
constructor(
|
||||||
this.session = session;
|
private session: AgentSession,
|
||||||
}
|
private footerData: ReadonlyFooterDataProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
setAutoCompactEnabled(enabled: boolean): void {
|
setAutoCompactEnabled(enabled: boolean): void {
|
||||||
this.autoCompactEnabled = enabled;
|
this.autoCompactEnabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set extension status text to display in the footer.
|
* No-op: git branch caching now handled by provider.
|
||||||
* Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width.
|
* Kept for compatibility with existing call sites in interactive-mode.
|
||||||
* ANSI escape codes for styling are preserved.
|
|
||||||
* @param key - Unique key to identify this status
|
|
||||||
* @param text - Status text, or undefined to clear
|
|
||||||
*/
|
*/
|
||||||
setExtensionStatus(key: string, text: string | undefined): void {
|
invalidate(): void {
|
||||||
if (text === undefined) {
|
// No-op: git branch is cached/invalidated by provider
|
||||||
this.extensionStatuses.delete(key);
|
|
||||||
} else {
|
|
||||||
this.extensionStatuses.set(key, text);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up a file watcher on .git/HEAD to detect branch changes.
|
* Clean up resources.
|
||||||
* Call the provided callback when branch changes.
|
* Git watcher cleanup now handled by provider.
|
||||||
*/
|
|
||||||
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
|
|
||||||
*/
|
*/
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this.gitWatcher) {
|
// Git watcher cleanup handled by provider
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render(width: number): string[] {
|
render(width: number): string[] {
|
||||||
|
|
@ -211,15 +96,6 @@ export class FooterComponent implements Component {
|
||||||
const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
|
const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
|
||||||
const contextPercent = contextPercentValue.toFixed(1);
|
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 ~
|
// Replace home directory with ~
|
||||||
let pwd = process.cwd();
|
let pwd = process.cwd();
|
||||||
const home = process.env.HOME || process.env.USERPROFILE;
|
const home = process.env.HOME || process.env.USERPROFILE;
|
||||||
|
|
@ -228,7 +104,7 @@ export class FooterComponent implements Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add git branch if available
|
// Add git branch if available
|
||||||
const branch = this.getCurrentBranch();
|
const branch = this.footerData.getGitBranch();
|
||||||
if (branch) {
|
if (branch) {
|
||||||
pwd = `${pwd} (${branch})`;
|
pwd = `${pwd} (${branch})`;
|
||||||
}
|
}
|
||||||
|
|
@ -332,8 +208,9 @@ export class FooterComponent implements Component {
|
||||||
const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
|
const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
|
||||||
|
|
||||||
// Add extension statuses on a single line, sorted by key alphabetically
|
// Add extension statuses on a single line, sorted by key alphabetically
|
||||||
if (this.extensionStatuses.size > 0) {
|
const extensionStatuses = this.footerData.getExtensionStatuses();
|
||||||
const sortedStatuses = Array.from(this.extensionStatuses.entries())
|
if (extensionStatuses.size > 0) {
|
||||||
|
const sortedStatuses = Array.from(extensionStatuses.entries())
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
.map(([, text]) => sanitizeStatusText(text));
|
.map(([, text]) => sanitizeStatusText(text));
|
||||||
const statusLine = sortedStatuses.join(" ");
|
const statusLine = sortedStatuses.join(" ");
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import type {
|
||||||
ExtensionUIContext,
|
ExtensionUIContext,
|
||||||
ExtensionUIDialogOptions,
|
ExtensionUIDialogOptions,
|
||||||
} from "../../core/extensions/index.js";
|
} from "../../core/extensions/index.js";
|
||||||
|
import { FooterDataProvider, type ReadonlyFooterDataProvider } from "../../core/footer-data-provider.js";
|
||||||
import { KeybindingsManager } from "../../core/keybindings.js";
|
import { KeybindingsManager } from "../../core/keybindings.js";
|
||||||
import { createCompactionSummaryMessage } from "../../core/messages.js";
|
import { createCompactionSummaryMessage } from "../../core/messages.js";
|
||||||
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
|
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
|
||||||
|
|
@ -128,6 +129,7 @@ export class InteractiveMode {
|
||||||
private autocompleteProvider: CombinedAutocompleteProvider | undefined;
|
private autocompleteProvider: CombinedAutocompleteProvider | undefined;
|
||||||
private editorContainer: Container;
|
private editorContainer: Container;
|
||||||
private footer: FooterComponent;
|
private footer: FooterComponent;
|
||||||
|
private footerDataProvider: FooterDataProvider;
|
||||||
private keybindings: KeybindingsManager;
|
private keybindings: KeybindingsManager;
|
||||||
private version: string;
|
private version: string;
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
|
|
@ -226,7 +228,8 @@ export class InteractiveMode {
|
||||||
this.editor = this.defaultEditor;
|
this.editor = this.defaultEditor;
|
||||||
this.editorContainer = new Container();
|
this.editorContainer = new Container();
|
||||||
this.editorContainer.addChild(this.editor as Component);
|
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);
|
this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
|
||||||
|
|
||||||
// Load hide thinking block setting
|
// Load hide thinking block setting
|
||||||
|
|
@ -427,8 +430,8 @@ export class InteractiveMode {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up git branch watcher
|
// Set up git branch watcher (uses provider instead of footer)
|
||||||
this.footer.watchBranch(() => {
|
this.footerDataProvider.onBranchChange(() => {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -797,7 +800,7 @@ export class InteractiveMode {
|
||||||
* Set extension status text in the footer.
|
* Set extension status text in the footer.
|
||||||
*/
|
*/
|
||||||
private setExtensionStatus(key: string, text: string | undefined): void {
|
private setExtensionStatus(key: string, text: string | undefined): void {
|
||||||
this.footer.setExtensionStatus(key, text);
|
this.footerDataProvider.setExtensionStatus(key, text);
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -859,7 +862,11 @@ export class InteractiveMode {
|
||||||
/**
|
/**
|
||||||
* Set a custom footer component, or restore the built-in footer.
|
* 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
|
// Dispose existing custom footer
|
||||||
if (this.customFooter?.dispose) {
|
if (this.customFooter?.dispose) {
|
||||||
this.customFooter.dispose();
|
this.customFooter.dispose();
|
||||||
|
|
@ -873,8 +880,8 @@ export class InteractiveMode {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (factory) {
|
if (factory) {
|
||||||
// Create and add custom footer
|
// Create and add custom footer, passing the data provider
|
||||||
this.customFooter = factory(this.ui, theme);
|
this.customFooter = factory(this.ui, theme, this.footerDataProvider);
|
||||||
this.ui.addChild(this.customFooter);
|
this.ui.addChild(this.customFooter);
|
||||||
} else {
|
} else {
|
||||||
// Restore built-in footer
|
// Restore built-in footer
|
||||||
|
|
@ -3430,6 +3437,7 @@ export class InteractiveMode {
|
||||||
this.loadingAnimation = undefined;
|
this.loadingAnimation = undefined;
|
||||||
}
|
}
|
||||||
this.footer.dispose();
|
this.footer.dispose();
|
||||||
|
this.footerDataProvider.dispose();
|
||||||
if (this.unsubscribe) {
|
if (this.unsubscribe) {
|
||||||
this.unsubscribe();
|
this.unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue