feat(coding-agent): introduced powerline status bar with git integration

- Replaced FooterComponent with new StatusLineComponent featuring powerline-style segments and Nerd Font icons.
- Added git branch detection with file watching and status tracking with 1-second TTL cache.
- Added setTopBorder() method to Editor component for custom status content in top border.
- Changed Editor component to use rounded box-drawing characters for borders.
- Renamed footer theme properties to statusLine prefix across dark and light themes.
This commit is contained in:
can1357
2026-01-04 15:24:39 +01:00
parent ee187c3ef8
commit eba38cfa6e
11 changed files with 1237 additions and 1114 deletions
@@ -1,395 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, type FSWatcher, readFileSync, watch } from "node:fs";
import { dirname, join } from "node:path";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import type { AgentSession } from "../../../core/agent-session";
import { theme } from "../theme/theme";
// Nerd Font icons (matching Claude/statusline-nerd.sh)
const ICONS = {
model: "\uf4bc", // robot/model
folder: "\uf115", // folder
branch: "\uf126", // git branch
sep: "\ue0b1", // powerline thin chevron
tokens: "\uf0ce", // table/tokens
} as const;
/**
* Sanitize text for display in a single-line status.
* Removes newlines, tabs, carriage returns, and other control characters.
*/
function sanitizeStatusText(text: string): string {
// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
return text
.replace(/[\r\n\t]/g, " ")
.replace(/ +/g, " ")
.trim();
}
/**
* Find the git root directory by walking up from cwd.
* Returns the path to .git/HEAD if found, null otherwise.
*/
function findGitHeadPath(): string | null {
let dir = process.cwd();
while (true) {
const gitHeadPath = join(dir, ".git", "HEAD");
if (existsSync(gitHeadPath)) {
return gitHeadPath;
}
const parent = dirname(dir);
if (parent === dir) {
// Reached filesystem root
return null;
}
dir = parent;
}
}
/**
* Footer component that shows pwd, token stats, and context usage
*/
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 hookStatuses: Map<string, string> = new Map();
// Git status caching (1s TTL to avoid excessive subprocess spawns)
private cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
private gitStatusLastFetch = 0;
constructor(session: AgentSession) {
this.session = session;
}
setAutoCompactEnabled(enabled: boolean): void {
this.autoCompactEnabled = enabled;
}
/**
* Set hook 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
*/
setHookStatus(key: string, text: string | undefined): void {
if (text === undefined) {
this.hookStatuses.delete(key);
} else {
this.hookStatuses.set(key, text);
}
}
/**
* 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
*/
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;
}
/**
* Get git status indicators (staged, unstaged, untracked counts).
* Returns null if not in a git repo.
* Cached for 1s to avoid excessive subprocess spawns.
*/
private getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
const now = Date.now();
if (now - this.gitStatusLastFetch < 1000) {
return this.cachedGitStatus;
}
try {
const output = execSync("git status --porcelain 2>/dev/null", {
encoding: "utf8",
timeout: 1000,
stdio: ["pipe", "pipe", "pipe"],
});
let staged = 0;
let unstaged = 0;
let untracked = 0;
for (const line of output.split("\n")) {
if (!line) continue;
const x = line[0]; // Index (staged) status
const y = line[1]; // Working tree status
// Untracked files
if (x === "?" && y === "?") {
untracked++;
continue;
}
// Staged changes (first column is not space or ?)
if (x && x !== " " && x !== "?") {
staged++;
}
// Unstaged changes (second column is not space)
if (y && y !== " ") {
unstaged++;
}
}
this.cachedGitStatus = { staged, unstaged, untracked };
this.gitStatusLastFetch = now;
return this.cachedGitStatus;
} catch {
this.cachedGitStatus = null;
this.gitStatusLastFetch = now;
return null;
}
}
render(width: number): string[] {
const state = this.session.state;
// Calculate cumulative usage from ALL session entries
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheWrite = 0;
let totalCost = 0;
for (const entry of this.session.sessionManager.getEntries()) {
if (entry.type === "message" && entry.message.role === "assistant") {
totalInput += entry.message.usage.input;
totalOutput += entry.message.usage.output;
totalCacheRead += entry.message.usage.cacheRead;
totalCacheWrite += entry.message.usage.cacheWrite;
totalCost += entry.message.usage.cost.total;
}
}
// Get context percentage from last assistant message
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 contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
// Format helpers
const formatTokens = (n: number): string => {
if (n < 1000) return n.toString();
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
if (n < 1000000) return `${Math.round(n / 1000)}k`;
if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
return `${Math.round(n / 1000000)}M`;
};
// Powerline separator (very dim)
const sep = theme.fg("footerSep", ` ${ICONS.sep} `);
// ═══════════════════════════════════════════════════════════════════════
// SEGMENT 1: Model (Gold/White)
// ═══════════════════════════════════════════════════════════════════════
const modelName = state.model?.id || "no-model";
let modelSegment = theme.fg("footerModel", `${ICONS.model} ${modelName}`);
if (state.model?.reasoning) {
const level = state.thinkingLevel || "off";
if (level !== "off") {
modelSegment += theme.fg("footerSep", " · ") + theme.fg("footerModel", level);
}
}
// ═══════════════════════════════════════════════════════════════════════
// SEGMENT 2: Path (Cyan with dim separators)
// Replace home with ~, strip /work/, color separators
// ═══════════════════════════════════════════════════════════════════════
let pwd = process.cwd();
const home = process.env.HOME || process.env.USERPROFILE;
if (home && pwd.startsWith(home)) {
pwd = `~${pwd.slice(home.length)}`;
}
// Strip /work/ prefix
if (pwd.startsWith("/work/")) {
pwd = pwd.slice(6);
}
// Color path with dim separators: ~/foo/bar -> ~/foo/bar (separators dim)
const pathColored = pwd
.split("/")
.map((part) => theme.fg("footerPath", part))
.join(theme.fg("footerSep", "/"));
const pathSegment = theme.fg("footerIcon", `${ICONS.folder} `) + pathColored;
// ═══════════════════════════════════════════════════════════════════════
// SEGMENT 3: Git Branch + Status (Green/Yellow)
// ═══════════════════════════════════════════════════════════════════════
const branch = this.getCurrentBranch();
let gitSegment = "";
if (branch) {
const gitStatus = this.getGitStatus();
const isDirty = gitStatus && (gitStatus.staged > 0 || gitStatus.unstaged > 0 || gitStatus.untracked > 0);
// Branch name - green if clean, yellow if dirty
const branchColor = isDirty ? "footerDirty" : "footerBranch";
gitSegment = theme.fg("footerIcon", `${ICONS.branch} `) + theme.fg(branchColor, branch);
// Add status indicators
if (gitStatus) {
const indicators: string[] = [];
if (gitStatus.unstaged > 0) {
indicators.push(theme.fg("footerDirty", `*${gitStatus.unstaged}`));
}
if (gitStatus.staged > 0) {
indicators.push(theme.fg("footerStaged", `+${gitStatus.staged}`));
}
if (gitStatus.untracked > 0) {
indicators.push(theme.fg("footerUntracked", `!${gitStatus.untracked}`));
}
if (indicators.length > 0) {
gitSegment += ` ${indicators.join(" ")}`;
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// SEGMENT 4: Stats (Pink/Magenta tones)
// Concise: total tokens, cost, context%
// ═══════════════════════════════════════════════════════════════════════
const statParts: string[] = [];
// Total tokens (input + output + cache)
const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
if (totalTokens) {
statParts.push(theme.fg("footerOutput", `${ICONS.tokens} ${formatTokens(totalTokens)}`));
}
// Cost (pink)
const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
if (totalCost || usingSubscription) {
const costDisplay = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
statParts.push(theme.fg("footerCost", costDisplay));
}
// Context percentage with severity coloring
const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
const contextDisplay = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)}${autoIndicator}`;
let contextColored: string;
if (contextPercentValue > 90) {
contextColored = theme.fg("error", contextDisplay);
} else if (contextPercentValue > 70) {
contextColored = theme.fg("warning", contextDisplay);
} else {
contextColored = theme.fg("footerSep", contextDisplay);
}
statParts.push(contextColored);
const statsSegment = statParts.join(" ");
// ═══════════════════════════════════════════════════════════════════════
// Assemble single powerline-style line
// [Model] > [Path] > [Git] > [Stats]
// ═══════════════════════════════════════════════════════════════════════
const segments = [modelSegment, pathSegment];
if (gitSegment) segments.push(gitSegment);
segments.push(statsSegment);
let statusLine = segments.join(sep);
// Truncate if needed
if (visibleWidth(statusLine) > width) {
statusLine = truncateToWidth(statusLine, width, theme.fg("footerSep", "…"));
}
const lines = [statusLine];
// Hook statuses (optional second line)
if (this.hookStatuses.size > 0) {
const sortedStatuses = Array.from(this.hookStatuses.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => sanitizeStatusText(text));
const hookLine = sortedStatuses.join(" ");
lines.push(truncateToWidth(hookLine, width, theme.fg("footerSep", "…")));
}
return lines;
}
}
@@ -0,0 +1,441 @@
import { execSync } from "node:child_process";
import { existsSync, type FSWatcher, readFileSync, watch } from "node:fs";
import { dirname, join } from "node:path";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import type { AgentSession } from "../../../core/agent-session";
import { theme } from "../theme/theme";
// Nerd Font icons
const ICONS = {
model: "\uf4bc", // robot/model
folder: "\uf115", // folder
branch: "\ue725", // git branch
sep: "\ue0b1", // powerline thin chevron
tokens: "\ue26b", // coins
context: "", // window
} as const;
/** Create a colored text segment with background */
function plSegment(content: string, fgAnsi: string, bgAnsi: string): string {
return `${bgAnsi}${fgAnsi} ${content} \x1b[0m`;
}
/** Create separator with background */
function plSep(sepAnsi: string, bgAnsi: string): string {
return `${bgAnsi}${sepAnsi}${ICONS.sep}\x1b[0m`;
}
/** Create end cap - solid arrow transitioning bg to terminal default */
function plEnd(bgAnsi: string): string {
// Use the bg color as fg for the arrow (creates the triangle effect)
const fgFromBg = bgAnsi.replace("\x1b[48;", "\x1b[38;");
return `${fgFromBg}\ue0b0\x1b[0m`;
}
/**
* Sanitize text for display in a single-line status.
* Removes newlines, tabs, carriage returns, and other control characters.
*/
function sanitizeStatusText(text: string): string {
// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
return text
.replace(/[\r\n\t]/g, " ")
.replace(/ +/g, " ")
.trim();
}
/**
* Find the git root directory by walking up from cwd.
* Returns the path to .git/HEAD if found, null otherwise.
*/
function findGitHeadPath(): string | null {
let dir = process.cwd();
while (true) {
const gitHeadPath = join(dir, ".git", "HEAD");
if (existsSync(gitHeadPath)) {
return gitHeadPath;
}
const parent = dirname(dir);
if (parent === dir) {
// Reached filesystem root
return null;
}
dir = parent;
}
}
/**
* Footer component that shows pwd, token stats, and context usage
*/
export class StatusLineComponent 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 hookStatuses: Map<string, string> = new Map();
// Git status caching (1s TTL to avoid excessive subprocess spawns)
private cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
private gitStatusLastFetch = 0;
constructor(session: AgentSession) {
this.session = session;
}
setAutoCompactEnabled(enabled: boolean): void {
this.autoCompactEnabled = enabled;
}
/**
* Set hook 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
*/
setHookStatus(key: string, text: string | undefined): void {
if (text === undefined) {
this.hookStatuses.delete(key);
} else {
this.hookStatuses.set(key, text);
}
}
/**
* 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
*/
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;
}
/**
* Get git status indicators (staged, unstaged, untracked counts).
* Returns null if not in a git repo.
* Cached for 1s to avoid excessive subprocess spawns.
*/
private getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
const now = Date.now();
if (now - this.gitStatusLastFetch < 1000) {
return this.cachedGitStatus;
}
try {
const output = execSync("git status --porcelain 2>/dev/null", {
encoding: "utf8",
timeout: 1000,
stdio: ["pipe", "pipe", "pipe"],
});
let staged = 0;
let unstaged = 0;
let untracked = 0;
for (const line of output.split("\n")) {
if (!line) continue;
const x = line[0]; // Index (staged) status
const y = line[1]; // Working tree status
// Untracked files
if (x === "?" && y === "?") {
untracked++;
continue;
}
// Staged changes (first column is not space or ?)
if (x && x !== " " && x !== "?") {
staged++;
}
// Unstaged changes (second column is not space)
if (y && y !== " ") {
unstaged++;
}
}
this.cachedGitStatus = { staged, unstaged, untracked };
this.gitStatusLastFetch = now;
return this.cachedGitStatus;
} catch {
this.cachedGitStatus = null;
this.gitStatusLastFetch = now;
return null;
}
}
private buildStatusLine(): string {
const state = this.session.state;
// Calculate cumulative usage from ALL session entries
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheWrite = 0;
let totalCost = 0;
for (const entry of this.session.sessionManager.getEntries()) {
if (entry.type === "message" && entry.message.role === "assistant") {
totalInput += entry.message.usage.input;
totalOutput += entry.message.usage.output;
totalCacheRead += entry.message.usage.cacheRead;
totalCacheWrite += entry.message.usage.cacheWrite;
totalCost += entry.message.usage.cost.total;
}
}
// Get context percentage from last assistant message
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 contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
// Format helpers
const formatTokens = (n: number): string => {
if (n < 1000) return n.toString();
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
if (n < 1000000) return `${Math.round(n / 1000)}k`;
if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
return `${Math.round(n / 1000000)}M`;
};
// ═══════════════════════════════════════════════════════════════════════
// SEGMENT 1: Model
// ═══════════════════════════════════════════════════════════════════════
let modelName = state.model?.name || state.model?.id || "no-model";
// Strip "Claude " prefix for brevity
if (modelName.startsWith("Claude ")) {
modelName = modelName.slice(7);
}
let modelContent = `${ICONS.model} ${modelName}`;
if (state.model?.reasoning) {
const level = state.thinkingLevel || "off";
if (level !== "off") {
// Shorten thinking level labels
const shortLevel =
level === "minimal" ? "min" : level === "medium" ? "mid" : level === "high" ? "hi" : level === "xhigh" ? "max" : level;
modelContent += ` · ${shortLevel}`;
}
}
// ═══════════════════════════════════════════════════════════════════════
// SEGMENT 2: Path
// ═══════════════════════════════════════════════════════════════════════
let pwd = process.cwd();
const home = process.env.HOME || process.env.USERPROFILE;
if (home && pwd.startsWith(home)) {
pwd = `~${pwd.slice(home.length)}`;
}
if (pwd.startsWith("/work/")) {
pwd = pwd.slice(6);
}
const pathContent = `${ICONS.folder} ${pwd}`;
// ═══════════════════════════════════════════════════════════════════════
// SEGMENT 3: Git Branch + Status
// ═══════════════════════════════════════════════════════════════════════
const branch = this.getCurrentBranch();
let gitContent = "";
let gitColorName: "statusLineGitClean" | "statusLineGitDirty" = "statusLineGitClean";
if (branch) {
const gitStatus = this.getGitStatus();
const isDirty = gitStatus && (gitStatus.staged > 0 || gitStatus.unstaged > 0 || gitStatus.untracked > 0);
gitColorName = isDirty ? "statusLineGitDirty" : "statusLineGitClean";
gitContent = `${ICONS.branch} ${branch}`;
if (gitStatus) {
const indicators: string[] = [];
if (gitStatus.unstaged > 0) {
indicators.push(theme.fg("statusLineDirty", `*${gitStatus.unstaged}`));
}
if (gitStatus.staged > 0) {
indicators.push(theme.fg("statusLineStaged", `+${gitStatus.staged}`));
}
if (gitStatus.untracked > 0) {
indicators.push(theme.fg("statusLineUntracked", `?${gitStatus.untracked}`));
}
if (indicators.length > 0) {
gitContent += ` ${indicators.join(" ")}`;
}
}
}
// ═══════════════════════════════════════════════════════════════════════
// SEGMENT 4: Context (window usage)
// ═══════════════════════════════════════════════════════════════════════
const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
const contextText = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)}${autoIndicator}`;
let contextContent: string;
if (contextPercentValue > 90) {
contextContent = `${ICONS.context} ${theme.fg("error", contextText)}`;
} else if (contextPercentValue > 70) {
contextContent = `${ICONS.context} ${theme.fg("warning", contextText)}`;
} else {
contextContent = `${ICONS.context} ${contextText}`;
}
// ═══════════════════════════════════════════════════════════════════════
// SEGMENT 5: Spend (tokens + cost)
// ═══════════════════════════════════════════════════════════════════════
const spendParts: string[] = [];
const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
if (totalTokens) {
spendParts.push(theme.fg("statusLineOutput", `${ICONS.tokens} ${formatTokens(totalTokens)}`));
}
const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
if (totalCost || usingSubscription) {
const costDisplay = `$${totalCost.toFixed(2)}${usingSubscription ? " (sub)" : ""}`;
spendParts.push(theme.fg("statusLineCost", costDisplay));
}
const spendContent = spendParts.join(" · ");
// ═══════════════════════════════════════════════════════════════════════
// Assemble: [Model] > [Path] > [Git?] > [Context] > [Spend] >
// ═══════════════════════════════════════════════════════════════════════
const bgAnsi = theme.getBgAnsi("statusLineBg");
const sepAnsi = theme.getFgAnsi("statusLineSep");
let statusLine = "";
// Model segment
statusLine += plSegment(modelContent, theme.getFgAnsi("statusLineModel"), bgAnsi);
statusLine += plSep(sepAnsi, bgAnsi);
// Path segment
statusLine += plSegment(pathContent, theme.getFgAnsi("statusLinePath"), bgAnsi);
if (gitContent) {
statusLine += plSep(sepAnsi, bgAnsi);
statusLine += plSegment(gitContent, theme.getFgAnsi(gitColorName), bgAnsi);
}
// Context segment
statusLine += plSep(sepAnsi, bgAnsi);
statusLine += plSegment(contextContent, theme.getFgAnsi("statusLineContext"), bgAnsi);
// Spend segment
statusLine += plSep(sepAnsi, bgAnsi);
statusLine += plSegment(spendContent, theme.getFgAnsi("statusLineSpend"), bgAnsi);
// End cap (solid arrow to terminal bg)
statusLine += plEnd(bgAnsi);
return statusLine;
}
/**
* Get the status line content for use as editor top border.
* Returns the content string and its visible width.
*/
getTopBorder(_width: number): { content: string; width: number } {
const content = this.buildStatusLine();
return {
content,
width: visibleWidth(content),
};
}
/**
* Render only hook statuses (if any).
* Used when footer is integrated into editor border.
*/
render(width: number): string[] {
// Only render hook statuses - main status is in editor's top border
if (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, theme.fg("statusLineSep", "…"))];
}
}
@@ -43,7 +43,6 @@ import { CompactionSummaryMessageComponent } from "./components/compaction-summa
import { CustomEditor } from "./components/custom-editor";
import { DynamicBorder } from "./components/dynamic-border";
import { ExtensionDashboard } from "./components/extensions";
import { FooterComponent } from "./components/footer";
import { HookEditorComponent } from "./components/hook-editor";
import { HookInputComponent } from "./components/hook-input";
import { HookMessageComponent } from "./components/hook-message";
@@ -52,6 +51,7 @@ import { ModelSelectorComponent } from "./components/model-selector";
import { OAuthSelectorComponent } from "./components/oauth-selector";
import { SessionSelectorComponent } from "./components/session-selector";
import { SettingsSelectorComponent } from "./components/settings-selector";
import { StatusLineComponent } from "./components/status-line";
import { ToolExecutionComponent } from "./components/tool-execution";
import { TreeSelectorComponent } from "./components/tree-selector";
import { TtsrNotificationComponent } from "./components/ttsr-notification";
@@ -85,7 +85,7 @@ export class InteractiveMode {
private statusContainer: Container;
private editor: CustomEditor;
private editorContainer: Container;
private footer: FooterComponent;
private statusLine: StatusLineComponent;
private version: string;
private isInitialized = false;
private onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void;
@@ -176,8 +176,8 @@ export class InteractiveMode {
this.editor = new CustomEditor(getEditorTheme());
this.editorContainer = new Container();
this.editorContainer.addChild(this.editor);
this.footer = new FooterComponent(session);
this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
this.statusLine = new StatusLineComponent(session);
this.statusLine.setAutoCompactEnabled(session.autoCompactionEnabled);
// Define slash commands for autocomplete
const slashCommands: SlashCommand[] = [
@@ -287,7 +287,7 @@ export class InteractiveMode {
this.ui.addChild(this.statusContainer);
this.ui.addChild(new Spacer(1));
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.footer);
this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
this.ui.setFocus(this.editor);
this.setupKeyHandlers();
@@ -311,9 +311,13 @@ export class InteractiveMode {
});
// Set up git branch watcher
this.footer.watchBranch(() => {
this.statusLine.watchBranch(() => {
this.updateEditorTopBorder();
this.ui.requestRender();
});
// Initial top border update
this.updateEditorTopBorder();
}
// =========================================================================
@@ -492,7 +496,7 @@ export class InteractiveMode {
* Set hook status text in the footer.
*/
private setHookStatus(key: string, text: string | undefined): void {
this.footer.setHookStatus(key, text);
this.statusLine.setHookStatus(key, text);
this.ui.requestRender();
}
@@ -934,7 +938,8 @@ export class InteractiveMode {
await this.init();
}
this.footer.invalidate();
this.statusLine.invalidate();
this.updateEditorTopBorder();
switch (event.type) {
case "agent_start":
@@ -1039,7 +1044,8 @@ export class InteractiveMode {
}
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.footer.invalidate();
this.statusLine.invalidate();
this.updateEditorTopBorder();
}
this.ui.requestRender();
break;
@@ -1147,7 +1153,8 @@ export class InteractiveMode {
summary: event.result.summary,
timestamp: Date.now(),
});
this.footer.invalidate();
this.statusLine.invalidate();
this.updateEditorTopBorder();
}
this.ui.requestRender();
break;
@@ -1324,7 +1331,7 @@ export class InteractiveMode {
this.pendingTools.clear();
if (options.updateFooter) {
this.footer.invalidate();
this.statusLine.invalidate();
this.updateEditorBorderColor();
}
@@ -1492,17 +1499,24 @@ export class InteractiveMode {
const level = this.session.thinkingLevel || "off";
this.editor.borderColor = theme.getThinkingBorderColor(level);
}
// Update footer content in editor's top border
this.updateEditorTopBorder();
this.ui.requestRender();
}
private updateEditorTopBorder(): void {
const width = this.ui.getWidth();
const topBorder = this.statusLine.getTopBorder(width);
this.editor.setTopBorder(topBorder);
}
private cycleThinkingLevel(): void {
const newLevel = this.session.cycleThinkingLevel();
if (newLevel === undefined) {
this.showStatus("Current model does not support thinking");
} else {
this.footer.invalidate();
this.statusLine.invalidate();
this.updateEditorBorderColor();
this.showStatus(`Thinking level: ${newLevel}`);
}
}
@@ -1513,7 +1527,7 @@ export class InteractiveMode {
const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
this.showStatus(msg);
} else {
this.footer.invalidate();
this.statusLine.invalidate();
this.updateEditorBorderColor();
const thinkingStr =
result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : "";
@@ -1749,7 +1763,7 @@ export class InteractiveMode {
// Session-managed settings (not in SettingsManager)
case "autoCompact":
this.session.setAutoCompactionEnabled(value as boolean);
this.footer.setAutoCompactEnabled(value as boolean);
this.statusLine.setAutoCompactEnabled(value as boolean);
break;
case "queueMode":
this.session.setQueueMode(value as "all" | "one-at-a-time");
@@ -1759,7 +1773,7 @@ export class InteractiveMode {
break;
case "thinkingLevel":
this.session.setThinkingLevel(value as ThinkingLevel);
this.footer.invalidate();
this.statusLine.invalidate();
this.updateEditorBorderColor();
break;
@@ -1808,7 +1822,7 @@ export class InteractiveMode {
// Only update agent state for default role
if (role === "default") {
await this.session.setModel(model, role);
this.footer.invalidate();
this.statusLine.invalidate();
this.updateEditorBorderColor();
}
// For other roles (small), just show status - settings already updated by selector
@@ -2533,7 +2547,8 @@ export class InteractiveMode {
const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString());
this.addMessageToChat(msg);
this.footer.invalidate();
this.statusLine.invalidate();
this.updateEditorTopBorder();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) {
@@ -2553,7 +2568,7 @@ export class InteractiveMode {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.footer.dispose();
this.statusLine.dispose();
if (this.unsubscribe) {
this.unsubscribe();
}
@@ -79,19 +79,19 @@
"bashMode": "cyan",
"footerIcon": 39,
"footerSep": 244,
"footerModel": 255,
"footerPath": 39,
"footerBranch": 76,
"footerStaged": 70,
"footerDirty": 178,
"footerUntracked": 39,
"footerInput": 76,
"footerOutput": 205,
"footerCacheRead": 76,
"footerCacheWrite": 76,
"footerCost": 205
"statusLineBg": "#121212",
"statusLineSep": 244,
"statusLineModel": "#d787af",
"statusLinePath": "#00afaf",
"statusLineGitClean": "#5faf5f",
"statusLineGitDirty": "#d7af5f",
"statusLineContext": "#8787af",
"statusLineSpend": "#5fafaf",
"statusLineStaged": 70,
"statusLineDirty": 178,
"statusLineUntracked": 39,
"statusLineOutput": 205,
"statusLineCost": 205
},
"export": {
"pageBg": "#18181e",
@@ -76,19 +76,19 @@
"bashMode": "green",
"footerIcon": 31,
"footerSep": 246,
"footerModel": 236,
"footerPath": 31,
"footerBranch": 28,
"footerStaged": 28,
"footerDirty": 136,
"footerUntracked": 31,
"footerInput": 28,
"footerOutput": 133,
"footerCacheRead": 28,
"footerCacheWrite": 28,
"footerCost": 133
"statusLineBg": "#e0e0e0",
"statusLineSep": "#808080",
"statusLineModel": "#875f87",
"statusLinePath": "#005f87",
"statusLineGitClean": "#005f00",
"statusLineGitDirty": "#af5f00",
"statusLineContext": "#5f5f87",
"statusLineSpend": "#005f5f",
"statusLineStaged": 28,
"statusLineDirty": 136,
"statusLineUntracked": 31,
"statusLineOutput": 133,
"statusLineCost": 133
},
"export": {
"pageBg": "#f8f8f8",
@@ -85,20 +85,20 @@ const ThemeJsonSchema = Type.Object({
thinkingXhigh: ColorValueSchema,
// Bash Mode (1 color)
bashMode: ColorValueSchema,
// Footer Status Line (10 colors)
footerIcon: ColorValueSchema,
footerSep: ColorValueSchema,
footerModel: ColorValueSchema,
footerPath: ColorValueSchema,
footerBranch: ColorValueSchema,
footerStaged: ColorValueSchema,
footerDirty: ColorValueSchema,
footerUntracked: ColorValueSchema,
footerInput: ColorValueSchema,
footerOutput: ColorValueSchema,
footerCacheRead: ColorValueSchema,
footerCacheWrite: ColorValueSchema,
footerCost: ColorValueSchema,
// Footer Status Line
statusLineBg: ColorValueSchema,
statusLineSep: ColorValueSchema,
statusLineModel: ColorValueSchema,
statusLinePath: ColorValueSchema,
statusLineGitClean: ColorValueSchema,
statusLineGitDirty: ColorValueSchema,
statusLineContext: ColorValueSchema,
statusLineSpend: ColorValueSchema,
statusLineStaged: ColorValueSchema,
statusLineDirty: ColorValueSchema,
statusLineUntracked: ColorValueSchema,
statusLineOutput: ColorValueSchema,
statusLineCost: ColorValueSchema,
}),
export: Type.Optional(
Type.Object({
@@ -160,19 +160,18 @@ export type ThemeColor =
| "thinkingHigh"
| "thinkingXhigh"
| "bashMode"
| "footerIcon"
| "footerSep"
| "footerModel"
| "footerPath"
| "footerBranch"
| "footerStaged"
| "footerDirty"
| "footerUntracked"
| "footerInput"
| "footerOutput"
| "footerCacheRead"
| "footerCacheWrite"
| "footerCost";
| "statusLineSep"
| "statusLineModel"
| "statusLinePath"
| "statusLineGitClean"
| "statusLineGitDirty"
| "statusLineContext"
| "statusLineSpend"
| "statusLineStaged"
| "statusLineDirty"
| "statusLineUntracked"
| "statusLineOutput"
| "statusLineCost";
export type ThemeBg =
| "selectedBg"
@@ -180,7 +179,8 @@ export type ThemeBg =
| "customMessageBg"
| "toolPendingBg"
| "toolSuccessBg"
| "toolErrorBg";
| "toolErrorBg"
| "statusLineBg";
type ColorMode = "truecolor" | "256color";
@@ -536,6 +536,7 @@ function createTheme(themeJson: ThemeJson, mode?: ColorMode): Theme {
"toolPendingBg",
"toolSuccessBg",
"toolErrorBg",
"statusLineBg",
]);
for (const [key, value] of Object.entries(resolvedColors)) {
if (bgColorKeys.has(key)) {