Files
oh-my-pi/packages/coding-agent/src/modes/components/status-line.ts
T
2026-02-10 07:39:39 +01:00

427 lines
13 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { $ } from "bun";
import { settings } from "../../config/settings";
import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema";
import { theme } from "../../modes/theme/theme";
import type { AgentSession } from "../../session/agent-session";
import { getPreset } from "./status-line/presets";
import { renderSegment, type SegmentContext } from "./status-line/segments";
import { getSeparator } from "./status-line/separators";
export interface StatusLineSegmentOptions {
model?: { showThinkingLevel?: boolean };
path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean };
git?: { showBranch?: boolean; showStaged?: boolean; showUnstaged?: boolean; showUntracked?: boolean };
time?: { format?: "12h" | "24h"; showSeconds?: boolean };
}
export interface StatusLineSettings {
preset?: StatusLinePreset;
leftSegments?: StatusLineSegmentId[];
rightSegments?: StatusLineSegmentId[];
separator?: StatusLineSeparatorStyle;
segmentOptions?: StatusLineSegmentOptions;
showHookStatus?: boolean;
}
// ═══════════════════════════════════════════════════════════════════════════
// Rendering Helpers
// ═══════════════════════════════════════════════════════════════════════════
/** Sanitize text for display in a single-line status */
function sanitizeStatusText(text: string): string {
return text
.replace(/[\r\n\t]/g, " ")
.replace(/ +/g, " ")
.trim();
}
/** Find the git root directory by walking up from cwd */
function findGitHeadPath(): string | null {
let dir = process.cwd();
while (true) {
const gitHeadPath = path.join(dir, ".git", "HEAD");
if (fs.existsSync(gitHeadPath)) {
return gitHeadPath;
}
const parent = path.dirname(dir);
if (parent === dir) {
return null;
}
dir = parent;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// StatusLineComponent
// ═══════════════════════════════════════════════════════════════════════════
export class StatusLineComponent implements Component {
#settings: StatusLineSettings = {};
#cachedBranch: string | null | undefined = undefined;
#gitWatcher: fs.FSWatcher | null = null;
#onBranchChange: (() => void) | null = null;
#autoCompactEnabled: boolean = true;
#hookStatuses: Map<string, string> = new Map();
#subagentCount: number = 0;
#sessionStartTime: number = Date.now();
#planModeStatus: { enabled: boolean; paused: boolean } | null = null;
// Git status caching (1s TTL)
#cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
#gitStatusLastFetch = 0;
constructor(private readonly session: AgentSession) {
this.#settings = {
preset: settings.get("statusLine.preset"),
leftSegments: settings.get("statusLine.leftSegments"),
rightSegments: settings.get("statusLine.rightSegments"),
separator: settings.get("statusLine.separator"),
showHookStatus: settings.get("statusLine.showHookStatus"),
segmentOptions: settings.getGroup("statusLine").segmentOptions,
};
}
updateSettings(settings: StatusLineSettings): void {
this.#settings = settings;
}
setAutoCompactEnabled(enabled: boolean): void {
this.#autoCompactEnabled = enabled;
}
setSubagentCount(count: number): void {
this.#subagentCount = count;
}
setSessionStartTime(time: number): void {
this.#sessionStartTime = time;
}
setPlanModeStatus(status: { enabled: boolean; paused: boolean } | undefined): void {
this.#planModeStatus = status ?? null;
}
setHookStatus(key: string, text: string | undefined): void {
if (text === undefined) {
this.#hookStatuses.delete(key);
} else {
this.#hookStatuses.set(key, text);
}
}
watchBranch(onBranchChange: () => void): void {
this.#onBranchChange = onBranchChange;
this.#setupGitWatcher();
}
#setupGitWatcher(): void {
if (this.#gitWatcher) {
this.#gitWatcher.close();
this.#gitWatcher = null;
}
const gitHeadPath = findGitHeadPath();
if (!gitHeadPath) return;
try {
this.#gitWatcher = fs.watch(gitHeadPath, () => {
this.#cachedBranch = undefined;
if (this.#onBranchChange) {
this.#onBranchChange();
}
});
} catch {
// Silently fail
}
}
dispose(): void {
if (this.#gitWatcher) {
this.#gitWatcher.close();
this.#gitWatcher = null;
}
}
invalidate(): void {
this.#cachedBranch = undefined;
}
#getCurrentBranch(): string | null {
if (this.#cachedBranch !== undefined) {
return this.#cachedBranch;
}
const gitHeadPath = findGitHeadPath();
if (!gitHeadPath) {
this.#cachedBranch = null;
return null;
}
try {
const content = fs.readFileSync(gitHeadPath, "utf8").trim();
if (content.startsWith("ref: refs/heads/")) {
this.#cachedBranch = content.slice(16);
} else {
this.#cachedBranch = "detached";
}
} catch {
this.#cachedBranch = null;
}
return this.#cachedBranch ?? null;
}
#getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
const now = Date.now();
if (now - this.#gitStatusLastFetch < 1000) {
return this.#cachedGitStatus;
}
// Fire async fetch, return cached value
(async () => {
try {
const result = await $`git status --porcelain`.quiet().nothrow();
if (result.exitCode !== 0) {
this.#cachedGitStatus = null;
this.#gitStatusLastFetch = now;
return;
}
const output = result.stdout.toString();
let staged = 0;
let unstaged = 0;
let untracked = 0;
for (const line of output.split("\n")) {
if (!line) continue;
const x = line[0];
const y = line[1];
if (x === "?" && y === "?") {
untracked++;
continue;
}
if (x && x !== " " && x !== "?") {
staged++;
}
if (y && y !== " ") {
unstaged++;
}
}
this.#cachedGitStatus = { staged, unstaged, untracked };
this.#gitStatusLastFetch = now;
} catch {
this.#cachedGitStatus = null;
this.#gitStatusLastFetch = now;
}
})();
return this.#cachedGitStatus;
}
#buildSegmentContext(width: number): SegmentContext {
const state = this.session.state;
// Get usage statistics
const usageStats = this.session.sessionManager?.getUsageStatistics() ?? {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
cost: 0,
};
// Get context percentage
const lastAssistantMessage = state.messages
.slice()
.reverse()
.find(m => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined;
const contextTokens = lastAssistantMessage
? lastAssistantMessage.usage.input +
lastAssistantMessage.usage.output +
lastAssistantMessage.usage.cacheRead +
lastAssistantMessage.usage.cacheWrite
: 0;
const contextWindow = state.model?.contextWindow || 0;
const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
return {
session: this.session,
width,
options: this.#resolveSettings().segmentOptions ?? {},
planMode: this.#planModeStatus,
usageStats,
contextPercent,
contextWindow,
autoCompactEnabled: this.#autoCompactEnabled,
subagentCount: this.#subagentCount,
sessionStartTime: this.#sessionStartTime,
git: {
branch: this.#getCurrentBranch(),
status: this.#getGitStatus(),
},
};
}
#resolveSettings(): Required<
Pick<StatusLineSettings, "leftSegments" | "rightSegments" | "separator" | "segmentOptions">
> &
StatusLineSettings {
const preset = this.#settings.preset ?? "default";
const presetDef = getPreset(preset);
const useCustomSegments = preset === "custom";
const mergedSegmentOptions: StatusLineSettings["segmentOptions"] = {};
for (const [segment, options] of Object.entries(presetDef.segmentOptions ?? {})) {
mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = { ...(options as Record<string, unknown>) };
}
for (const [segment, options] of Object.entries(this.#settings.segmentOptions ?? {})) {
const current = mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] ?? {};
mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = {
...(current as Record<string, unknown>),
...(options as Record<string, unknown>),
};
}
const leftSegments = useCustomSegments
? (this.#settings.leftSegments ?? presetDef.leftSegments)
: presetDef.leftSegments;
const rightSegments = useCustomSegments
? (this.#settings.rightSegments ?? presetDef.rightSegments)
: presetDef.rightSegments;
return {
...this.#settings,
leftSegments,
rightSegments,
separator: this.#settings.separator ?? presetDef.separator,
segmentOptions: mergedSegmentOptions,
};
}
#buildStatusLine(width: number): string {
const ctx = this.#buildSegmentContext(width);
const effectiveSettings = this.#resolveSettings();
const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
const bgAnsi = theme.getBgAnsi("statusLineBg");
const fgAnsi = theme.getFgAnsi("text");
const sepAnsi = theme.getFgAnsi("statusLineSep");
// Collect visible segment contents
const leftParts: string[] = [];
for (const segId of effectiveSettings.leftSegments) {
const rendered = renderSegment(segId, ctx);
if (rendered.visible && rendered.content) {
leftParts.push(rendered.content);
}
}
const rightParts: string[] = [];
for (const segId of effectiveSettings.rightSegments) {
const rendered = renderSegment(segId, ctx);
if (rendered.visible && rendered.content) {
rightParts.push(rendered.content);
}
}
const topFillWidth = width > 0 ? Math.max(0, width - 4) : 0;
const left = [...leftParts];
const right = [...rightParts];
const leftSepWidth = visibleWidth(separatorDef.left);
const rightSepWidth = visibleWidth(separatorDef.right);
const leftCapWidth = separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.right) : 0;
const rightCapWidth = separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.left) : 0;
const groupWidth = (parts: string[], capWidth: number, sepWidth: number): number => {
if (parts.length === 0) return 0;
const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
const sepTotal = Math.max(0, parts.length - 1) * (sepWidth + 2);
return partsWidth + sepTotal + 2 + capWidth;
};
let leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
let rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0);
if (topFillWidth > 0) {
while (totalWidth() > topFillWidth && right.length > 0) {
right.pop();
rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
}
while (totalWidth() > topFillWidth && left.length > 0) {
left.pop();
leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
}
}
const renderGroup = (parts: string[], direction: "left" | "right"): string => {
if (parts.length === 0) return "";
const sep = direction === "left" ? separatorDef.left : separatorDef.right;
const cap = separatorDef.endCaps
? direction === "left"
? separatorDef.endCaps.right
: separatorDef.endCaps.left
: "";
const capPrefix = separatorDef.endCaps?.useBgAsFg ? bgAnsi.replace("\x1b[48;", "\x1b[38;") : bgAnsi + sepAnsi;
const capText = cap ? `${capPrefix}${cap}\x1b[0m` : "";
let content = bgAnsi + fgAnsi;
content += ` ${parts.join(` ${sepAnsi}${sep}${fgAnsi} `)} `;
content += "\x1b[0m";
if (capText) {
return direction === "right" ? capText + content : content + capText;
}
return content;
};
const leftGroup = renderGroup(left, "left");
const rightGroup = renderGroup(right, "right");
if (!leftGroup && !rightGroup) return "";
if (topFillWidth === 0 || left.length === 0 || right.length === 0) {
return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
}
leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
return leftGroup + padding(gapWidth) + rightGroup;
}
getTopBorder(width: number): { content: string; width: number } {
const content = this.#buildStatusLine(width);
return {
content,
width: visibleWidth(content),
};
}
render(width: number): string[] {
// Only render hook statuses - main status is in editor's top border
const showHooks = this.#settings.showHookStatus ?? true;
if (!showHooks || this.#hookStatuses.size === 0) {
return [];
}
const sortedStatuses = Array.from(this.#hookStatuses.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => sanitizeStatusText(text));
const hookLine = sortedStatuses.join(" ");
return [truncateToWidth(hookLine, width)];
}
}