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:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user