diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index c2a82a1ec..4f22d3eb6 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -5,682 +5,682 @@ import { getAgentDir } from "../config"; import { loadSync } from "../discovery"; export interface CompactionSettings { - enabled?: boolean; // default: true - reserveTokens?: number; // default: 16384 - keepRecentTokens?: number; // default: 20000 + enabled?: boolean; // default: true + reserveTokens?: number; // default: 16384 + keepRecentTokens?: number; // default: 20000 } export interface BranchSummarySettings { - reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response) + reserveTokens?: number; // default: 16384 (tokens reserved for prompt + LLM response) } export interface RetrySettings { - enabled?: boolean; // default: true - maxRetries?: number; // default: 3 - baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s) + enabled?: boolean; // default: true + maxRetries?: number; // default: 3 + baseDelayMs?: number; // default: 2000 (exponential backoff: 2s, 4s, 8s) } export interface SkillsSettings { - enabled?: boolean; // default: true - enableCodexUser?: boolean; // default: true - enableClaudeUser?: boolean; // default: true - enableClaudeProject?: boolean; // default: true - enablePiUser?: boolean; // default: true - enablePiProject?: boolean; // default: true - customDirectories?: string[]; // default: [] - ignoredSkills?: string[]; // default: [] (glob patterns to exclude; takes precedence over includeSkills) - includeSkills?: string[]; // default: [] (empty = include all; glob patterns to filter) + enabled?: boolean; // default: true + enableCodexUser?: boolean; // default: true + enableClaudeUser?: boolean; // default: true + enableClaudeProject?: boolean; // default: true + enablePiUser?: boolean; // default: true + enablePiProject?: boolean; // default: true + customDirectories?: string[]; // default: [] + ignoredSkills?: string[]; // default: [] (glob patterns to exclude; takes precedence over includeSkills) + includeSkills?: string[]; // default: [] (empty = include all; glob patterns to filter) } export interface CommandsSettings { - enableClaudeUser?: boolean; // default: true (load from ~/.claude/commands/) - enableClaudeProject?: boolean; // default: true (load from .claude/commands/) + enableClaudeUser?: boolean; // default: true (load from ~/.claude/commands/) + enableClaudeProject?: boolean; // default: true (load from .claude/commands/) } export interface TerminalSettings { - showImages?: boolean; // default: true (only relevant if terminal supports images) + showImages?: boolean; // default: true (only relevant if terminal supports images) } export interface ExaSettings { - enabled?: boolean; // default: true (master toggle for all Exa tools) - enableSearch?: boolean; // default: true (search, deep, code, crawl) - enableLinkedin?: boolean; // default: false - enableCompany?: boolean; // default: false - enableResearcher?: boolean; // default: false - enableWebsets?: boolean; // default: false + enabled?: boolean; // default: true (master toggle for all Exa tools) + enableSearch?: boolean; // default: true (search, deep, code, crawl) + enableLinkedin?: boolean; // default: false + enableCompany?: boolean; // default: false + enableResearcher?: boolean; // default: false + enableWebsets?: boolean; // default: false } export interface BashInterceptorSettings { - enabled?: boolean; // default: false (blocks shell commands that have dedicated tools) + enabled?: boolean; // default: false (blocks shell commands that have dedicated tools) } export interface MCPSettings { - enableProjectConfig?: boolean; // default: true (load .mcp.json from project root) + enableProjectConfig?: boolean; // default: true (load .mcp.json from project root) } export interface LspSettings { - formatOnWrite?: boolean; // default: false (format files using LSP after write tool writes code files) - diagnosticsOnWrite?: boolean; // default: true (return LSP diagnostics after write tool writes code files) - diagnosticsOnEdit?: boolean; // default: false (return LSP diagnostics after edit tool edits code files) + formatOnWrite?: boolean; // default: false (format files using LSP after write tool writes code files) + diagnosticsOnWrite?: boolean; // default: true (return LSP diagnostics after write tool writes code files) + diagnosticsOnEdit?: boolean; // default: false (return LSP diagnostics after edit tool edits code files) } export interface EditSettings { - fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation) + fuzzyMatch?: boolean; // default: true (accept high-confidence fuzzy matches for whitespace/indentation) } export interface TtsrSettings { - enabled?: boolean; // default: true - /** What to do with partial output when TTSR triggers: "keep" shows interrupted attempt, "discard" removes it */ - contextMode?: "keep" | "discard"; // default: "discard" - /** How TTSR rules repeat: "once" = only trigger once per session, "after-gap" = can repeat after N messages */ - repeatMode?: "once" | "after-gap"; // default: "once" - /** Number of messages before a rule can trigger again (only used when repeatMode is "after-gap") */ - repeatGap?: number; // default: 10 + enabled?: boolean; // default: true + /** What to do with partial output when TTSR triggers: "keep" shows interrupted attempt, "discard" removes it */ + contextMode?: "keep" | "discard"; // default: "discard" + /** How TTSR rules repeat: "once" = only trigger once per session, "after-gap" = can repeat after N messages */ + repeatMode?: "once" | "after-gap"; // default: "once" + /** Number of messages before a rule can trigger again (only used when repeatMode is "after-gap") */ + repeatGap?: number; // default: 10 } export interface Settings { - lastChangelogVersion?: string; - /** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */ - modelRoles?: Record; - defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; - queueMode?: "all" | "one-at-a-time"; - interruptMode?: "immediate" | "wait"; - theme?: string; - compaction?: CompactionSettings; - branchSummary?: BranchSummarySettings; - retry?: RetrySettings; - hideThinkingBlock?: boolean; - shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) - collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) - hooks?: string[]; // Array of hook file paths - customTools?: string[]; // Array of custom tool file paths - skills?: SkillsSettings; - commands?: CommandsSettings; - terminal?: TerminalSettings; - enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) - exa?: ExaSettings; - bashInterceptor?: BashInterceptorSettings; - mcp?: MCPSettings; - lsp?: LspSettings; - edit?: EditSettings; - ttsr?: TtsrSettings; - disabledProviders?: string[]; // Discovery provider IDs that are disabled - disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit") + lastChangelogVersion?: string; + /** Model roles map: { default: "provider/modelId", small: "provider/modelId", ... } */ + modelRoles?: Record; + defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + queueMode?: "all" | "one-at-a-time"; + interruptMode?: "immediate" | "wait"; + theme?: string; + compaction?: CompactionSettings; + branchSummary?: BranchSummarySettings; + retry?: RetrySettings; + hideThinkingBlock?: boolean; + shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) + collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) + hooks?: string[]; // Array of hook file paths + customTools?: string[]; // Array of custom tool file paths + skills?: SkillsSettings; + commands?: CommandsSettings; + terminal?: TerminalSettings; + enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) + exa?: ExaSettings; + bashInterceptor?: BashInterceptorSettings; + mcp?: MCPSettings; + lsp?: LspSettings; + edit?: EditSettings; + ttsr?: TtsrSettings; + disabledProviders?: string[]; // Discovery provider IDs that are disabled + disabledExtensions?: string[]; // Individual extension IDs that are disabled (e.g., "skill:commit") } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ function deepMergeSettings(base: Settings, overrides: Settings): Settings { - const result: Settings = { ...base }; + const result: Settings = { ...base }; - for (const key of Object.keys(overrides) as (keyof Settings)[]) { - const overrideValue = overrides[key]; - const baseValue = base[key]; + for (const key of Object.keys(overrides) as (keyof Settings)[]) { + const overrideValue = overrides[key]; + const baseValue = base[key]; - if (overrideValue === undefined) { - continue; - } + if (overrideValue === undefined) { + continue; + } - // For nested objects, merge recursively - if ( - typeof overrideValue === "object" && - overrideValue !== null && - !Array.isArray(overrideValue) && - typeof baseValue === "object" && - baseValue !== null && - !Array.isArray(baseValue) - ) { - (result as Record)[key] = { ...baseValue, ...overrideValue }; - } else { - // For primitives and arrays, override value wins - (result as Record)[key] = overrideValue; - } - } + // For nested objects, merge recursively + if ( + typeof overrideValue === "object" && + overrideValue !== null && + !Array.isArray(overrideValue) && + typeof baseValue === "object" && + baseValue !== null && + !Array.isArray(baseValue) + ) { + (result as Record)[key] = { ...baseValue, ...overrideValue }; + } else { + // For primitives and arrays, override value wins + (result as Record)[key] = overrideValue; + } + } - return result; + return result; } export class SettingsManager { - private settingsPath: string | null; - private cwd: string | null; - private globalSettings: Settings; - private settings: Settings; - private persist: boolean; - - private constructor(settingsPath: string | null, cwd: string | null, initialSettings: Settings, persist: boolean) { - this.settingsPath = settingsPath; - this.cwd = cwd; - this.persist = persist; - this.globalSettings = initialSettings; - const projectSettings = this.loadProjectSettings(); - this.settings = deepMergeSettings(this.globalSettings, projectSettings); - } - - /** Create a SettingsManager that loads from files */ - static create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): SettingsManager { - const settingsPath = join(agentDir, "settings.json"); - - // Use capability API to load user-level settings from all providers - const result = loadSync(settingsCapability.id, { cwd }); - - // Merge all user-level settings - let globalSettings: Settings = {}; - for (const item of result.items as SettingsItem[]) { - if (item.level === "user") { - globalSettings = deepMergeSettings(globalSettings, item.data as Settings); - } - } - - // Also load from agentDir for backward compatibility (if not covered by providers) - const legacySettings = SettingsManager.loadFromFile(settingsPath); - globalSettings = deepMergeSettings(globalSettings, legacySettings); - - return new SettingsManager(settingsPath, cwd, globalSettings, true); - } - - /** Create an in-memory SettingsManager (no file I/O) */ - static inMemory(settings: Partial = {}): SettingsManager { - return new SettingsManager(null, null, settings, false); - } - - private static loadFromFile(path: string): Settings { - if (!existsSync(path)) { - return {}; - } - try { - const content = readFileSync(path, "utf-8"); - return JSON.parse(content); - } catch (error) { - console.error(`Warning: Could not read settings file ${path}: ${error}`); - return {}; - } - } - - private loadProjectSettings(): Settings { - if (!this.cwd) return {}; - - // Use capability API to discover settings from all providers - const result = loadSync(settingsCapability.id, { cwd: this.cwd }); - - // Merge only project-level settings (user-level settings are handled separately via globalSettings) - let merged: Settings = {}; - for (const item of result.items as SettingsItem[]) { - if (item.level === "project") { - merged = deepMergeSettings(merged, item.data as Settings); - } - } - - return merged; - } - - /** Apply additional overrides on top of current settings */ - applyOverrides(overrides: Partial): void { - this.settings = deepMergeSettings(this.settings, overrides); - } - - private save(): void { - if (!this.persist || !this.settingsPath) return; - - try { - const dir = dirname(this.settingsPath); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - // Save only global settings (project settings are read-only) - writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8"); - - // Re-merge project settings into active settings - const projectSettings = this.loadProjectSettings(); - this.settings = deepMergeSettings(this.globalSettings, projectSettings); - } catch (error) { - console.error(`Warning: Could not save settings file: ${error}`); - } - } - - getLastChangelogVersion(): string | undefined { - return this.settings.lastChangelogVersion; - } - - setLastChangelogVersion(version: string): void { - this.globalSettings.lastChangelogVersion = version; - this.save(); - } - - /** - * Get model for a role. Returns "provider/modelId" string or undefined. - */ - getModelRole(role: string): string | undefined { - return this.settings.modelRoles?.[role]; - } - - /** - * Set model for a role. Model should be "provider/modelId" format. - */ - setModelRole(role: string, model: string): void { - if (!this.globalSettings.modelRoles) { - this.globalSettings.modelRoles = {}; - } - this.globalSettings.modelRoles[role] = model; - this.save(); - } - - /** - * Get all model roles. - */ - getModelRoles(): Record { - return { ...this.settings.modelRoles }; - } - - getQueueMode(): "all" | "one-at-a-time" { - return this.settings.queueMode || "one-at-a-time"; - } - - setQueueMode(mode: "all" | "one-at-a-time"): void { - this.globalSettings.queueMode = mode; - this.save(); - } - - getInterruptMode(): "immediate" | "wait" { - return this.settings.interruptMode || "immediate"; - } - - setInterruptMode(mode: "immediate" | "wait"): void { - this.globalSettings.interruptMode = mode; - this.save(); - } - - getTheme(): string | undefined { - return this.settings.theme; - } - - setTheme(theme: string): void { - this.globalSettings.theme = theme; - this.save(); - } - - getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | undefined { - return this.settings.defaultThinkingLevel; - } - - setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void { - this.globalSettings.defaultThinkingLevel = level; - this.save(); - } - - getCompactionEnabled(): boolean { - return this.settings.compaction?.enabled ?? true; - } - - setCompactionEnabled(enabled: boolean): void { - if (!this.globalSettings.compaction) { - this.globalSettings.compaction = {}; - } - this.globalSettings.compaction.enabled = enabled; - this.save(); - } - - getCompactionReserveTokens(): number { - return this.settings.compaction?.reserveTokens ?? 16384; - } - - getCompactionKeepRecentTokens(): number { - return this.settings.compaction?.keepRecentTokens ?? 20000; - } - - getCompactionSettings(): { enabled: boolean; reserveTokens: number; keepRecentTokens: number } { - return { - enabled: this.getCompactionEnabled(), - reserveTokens: this.getCompactionReserveTokens(), - keepRecentTokens: this.getCompactionKeepRecentTokens(), - }; - } - - getBranchSummarySettings(): { reserveTokens: number } { - return { - reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384, - }; - } - - getRetryEnabled(): boolean { - return this.settings.retry?.enabled ?? true; - } - - setRetryEnabled(enabled: boolean): void { - if (!this.globalSettings.retry) { - this.globalSettings.retry = {}; - } - this.globalSettings.retry.enabled = enabled; - this.save(); - } - - getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number } { - return { - enabled: this.getRetryEnabled(), - maxRetries: this.settings.retry?.maxRetries ?? 3, - baseDelayMs: this.settings.retry?.baseDelayMs ?? 2000, - }; - } - - getHideThinkingBlock(): boolean { - return this.settings.hideThinkingBlock ?? false; - } - - setHideThinkingBlock(hide: boolean): void { - this.globalSettings.hideThinkingBlock = hide; - this.save(); - } - - getShellPath(): string | undefined { - return this.settings.shellPath; - } - - setShellPath(path: string | undefined): void { - this.globalSettings.shellPath = path; - this.save(); - } - - getCollapseChangelog(): boolean { - return this.settings.collapseChangelog ?? false; - } - - setCollapseChangelog(collapse: boolean): void { - this.globalSettings.collapseChangelog = collapse; - this.save(); - } - - getHookPaths(): string[] { - return [...(this.settings.hooks ?? [])]; - } - - setHookPaths(paths: string[]): void { - this.globalSettings.hooks = paths; - this.save(); - } - - getCustomToolPaths(): string[] { - return [...(this.settings.customTools ?? [])]; - } - - setCustomToolPaths(paths: string[]): void { - this.globalSettings.customTools = paths; - this.save(); - } - - getSkillsEnabled(): boolean { - return this.settings.skills?.enabled ?? true; - } - - setSkillsEnabled(enabled: boolean): void { - if (!this.globalSettings.skills) { - this.globalSettings.skills = {}; - } - this.globalSettings.skills.enabled = enabled; - this.save(); - } - - getSkillsSettings(): Required { - return { - enabled: this.settings.skills?.enabled ?? true, - enableCodexUser: this.settings.skills?.enableCodexUser ?? true, - enableClaudeUser: this.settings.skills?.enableClaudeUser ?? true, - enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true, - enablePiUser: this.settings.skills?.enablePiUser ?? true, - enablePiProject: this.settings.skills?.enablePiProject ?? true, - customDirectories: [...(this.settings.skills?.customDirectories ?? [])], - ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])], - includeSkills: [...(this.settings.skills?.includeSkills ?? [])], - }; - } - - getCommandsSettings(): Required { - return { - enableClaudeUser: this.settings.commands?.enableClaudeUser ?? true, - enableClaudeProject: this.settings.commands?.enableClaudeProject ?? true, - }; - } - - getShowImages(): boolean { - return this.settings.terminal?.showImages ?? true; - } - - setShowImages(show: boolean): void { - if (!this.globalSettings.terminal) { - this.globalSettings.terminal = {}; - } - this.globalSettings.terminal.showImages = show; - this.save(); - } - - getEnabledModels(): string[] | undefined { - return this.settings.enabledModels; - } - - getExaSettings(): Required { - return { - enabled: this.settings.exa?.enabled ?? true, - enableSearch: this.settings.exa?.enableSearch ?? true, - enableLinkedin: this.settings.exa?.enableLinkedin ?? false, - enableCompany: this.settings.exa?.enableCompany ?? false, - enableResearcher: this.settings.exa?.enableResearcher ?? false, - enableWebsets: this.settings.exa?.enableWebsets ?? false, - }; - } - - setExaEnabled(enabled: boolean): void { - if (!this.globalSettings.exa) { - this.globalSettings.exa = {}; - } - this.globalSettings.exa.enabled = enabled; - this.save(); - } - - setExaSearchEnabled(enabled: boolean): void { - if (!this.globalSettings.exa) { - this.globalSettings.exa = {}; - } - this.globalSettings.exa.enableSearch = enabled; - this.save(); - } - - setExaLinkedinEnabled(enabled: boolean): void { - if (!this.globalSettings.exa) { - this.globalSettings.exa = {}; - } - this.globalSettings.exa.enableLinkedin = enabled; - this.save(); - } - - setExaCompanyEnabled(enabled: boolean): void { - if (!this.globalSettings.exa) { - this.globalSettings.exa = {}; - } - this.globalSettings.exa.enableCompany = enabled; - this.save(); - } - - setExaResearcherEnabled(enabled: boolean): void { - if (!this.globalSettings.exa) { - this.globalSettings.exa = {}; - } - this.globalSettings.exa.enableResearcher = enabled; - this.save(); - } - - setExaWebsetsEnabled(enabled: boolean): void { - if (!this.globalSettings.exa) { - this.globalSettings.exa = {}; - } - this.globalSettings.exa.enableWebsets = enabled; - this.save(); - } - - getBashInterceptorEnabled(): boolean { - return this.settings.bashInterceptor?.enabled ?? false; - } - - setBashInterceptorEnabled(enabled: boolean): void { - if (!this.globalSettings.bashInterceptor) { - this.globalSettings.bashInterceptor = {}; - } - this.globalSettings.bashInterceptor.enabled = enabled; - this.save(); - } - - getMCPProjectConfigEnabled(): boolean { - return this.settings.mcp?.enableProjectConfig ?? true; - } - - setMCPProjectConfigEnabled(enabled: boolean): void { - if (!this.globalSettings.mcp) { - this.globalSettings.mcp = {}; - } - this.globalSettings.mcp.enableProjectConfig = enabled; - this.save(); - } - - getLspFormatOnWrite(): boolean { - return this.settings.lsp?.formatOnWrite ?? false; - } - - setLspFormatOnWrite(enabled: boolean): void { - if (!this.globalSettings.lsp) { - this.globalSettings.lsp = {}; - } - this.globalSettings.lsp.formatOnWrite = enabled; - this.save(); - } - - getLspDiagnosticsOnWrite(): boolean { - return this.settings.lsp?.diagnosticsOnWrite ?? true; - } - - setLspDiagnosticsOnWrite(enabled: boolean): void { - if (!this.globalSettings.lsp) { - this.globalSettings.lsp = {}; - } - this.globalSettings.lsp.diagnosticsOnWrite = enabled; - this.save(); - } - - getLspDiagnosticsOnEdit(): boolean { - return this.settings.lsp?.diagnosticsOnEdit ?? false; - } - - setLspDiagnosticsOnEdit(enabled: boolean): void { - if (!this.globalSettings.lsp) { - this.globalSettings.lsp = {}; - } - this.globalSettings.lsp.diagnosticsOnEdit = enabled; - this.save(); - } - - getEditFuzzyMatch(): boolean { - return this.settings.edit?.fuzzyMatch ?? true; - } - - setEditFuzzyMatch(enabled: boolean): void { - if (!this.globalSettings.edit) { - this.globalSettings.edit = {}; - } - this.globalSettings.edit.fuzzyMatch = enabled; - this.save(); - } - - getDisabledProviders(): string[] { - return [...(this.settings.disabledProviders ?? [])]; - } - - setDisabledProviders(providerIds: string[]): void { - this.globalSettings.disabledProviders = providerIds; - this.save(); - } - - getDisabledExtensions(): string[] { - return [...(this.settings.disabledExtensions ?? [])]; - } - - setDisabledExtensions(extensionIds: string[]): void { - this.globalSettings.disabledExtensions = extensionIds; - this.save(); - } - - isExtensionEnabled(extensionId: string): boolean { - return !(this.settings.disabledExtensions ?? []).includes(extensionId); - } - - enableExtension(extensionId: string): void { - const disabled = this.globalSettings.disabledExtensions ?? []; - const index = disabled.indexOf(extensionId); - if (index !== -1) { - disabled.splice(index, 1); - this.globalSettings.disabledExtensions = disabled; - this.save(); - } - } - - disableExtension(extensionId: string): void { - const disabled = this.globalSettings.disabledExtensions ?? []; - if (!disabled.includes(extensionId)) { - disabled.push(extensionId); - this.globalSettings.disabledExtensions = disabled; - this.save(); - } - } - - getTtsrSettings(): TtsrSettings { - return this.settings.ttsr ?? {}; - } - - setTtsrSettings(settings: TtsrSettings): void { - this.globalSettings.ttsr = { ...this.globalSettings.ttsr, ...settings }; - this.save(); - } - - getTtsrEnabled(): boolean { - return this.settings.ttsr?.enabled ?? true; - } - - setTtsrEnabled(enabled: boolean): void { - if (!this.globalSettings.ttsr) { - this.globalSettings.ttsr = {}; - } - this.globalSettings.ttsr.enabled = enabled; - this.save(); - } - - getTtsrContextMode(): "keep" | "discard" { - return this.settings.ttsr?.contextMode ?? "discard"; - } - - setTtsrContextMode(mode: "keep" | "discard"): void { - if (!this.globalSettings.ttsr) { - this.globalSettings.ttsr = {}; - } - this.globalSettings.ttsr.contextMode = mode; - this.save(); - } - - getTtsrRepeatMode(): "once" | "after-gap" { - return this.settings.ttsr?.repeatMode ?? "once"; - } - - setTtsrRepeatMode(mode: "once" | "after-gap"): void { - if (!this.globalSettings.ttsr) { - this.globalSettings.ttsr = {}; - } - this.globalSettings.ttsr.repeatMode = mode; - this.save(); - } - - getTtsrRepeatGap(): number { - return this.settings.ttsr?.repeatGap ?? 10; - } - - setTtsrRepeatGap(gap: number): void { - if (!this.globalSettings.ttsr) { - this.globalSettings.ttsr = {}; - } - this.globalSettings.ttsr.repeatGap = gap; - this.save(); - } + private settingsPath: string | null; + private cwd: string | null; + private globalSettings: Settings; + private settings: Settings; + private persist: boolean; + + private constructor(settingsPath: string | null, cwd: string | null, initialSettings: Settings, persist: boolean) { + this.settingsPath = settingsPath; + this.cwd = cwd; + this.persist = persist; + this.globalSettings = initialSettings; + const projectSettings = this.loadProjectSettings(); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); + } + + /** Create a SettingsManager that loads from files */ + static create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): SettingsManager { + const settingsPath = join(agentDir, "settings.json"); + + // Use capability API to load user-level settings from all providers + const result = loadSync(settingsCapability.id, { cwd }); + + // Merge all user-level settings + let globalSettings: Settings = {}; + for (const item of result.items as SettingsItem[]) { + if (item.level === "user") { + globalSettings = deepMergeSettings(globalSettings, item.data as Settings); + } + } + + // Also load from agentDir for backward compatibility (if not covered by providers) + const legacySettings = SettingsManager.loadFromFile(settingsPath); + globalSettings = deepMergeSettings(globalSettings, legacySettings); + + return new SettingsManager(settingsPath, cwd, globalSettings, true); + } + + /** Create an in-memory SettingsManager (no file I/O) */ + static inMemory(settings: Partial = {}): SettingsManager { + return new SettingsManager(null, null, settings, false); + } + + private static loadFromFile(path: string): Settings { + if (!existsSync(path)) { + return {}; + } + try { + const content = readFileSync(path, "utf-8"); + return JSON.parse(content); + } catch (error) { + console.error(`Warning: Could not read settings file ${path}: ${error}`); + return {}; + } + } + + private loadProjectSettings(): Settings { + if (!this.cwd) return {}; + + // Use capability API to discover settings from all providers + const result = loadSync(settingsCapability.id, { cwd: this.cwd }); + + // Merge only project-level settings (user-level settings are handled separately via globalSettings) + let merged: Settings = {}; + for (const item of result.items as SettingsItem[]) { + if (item.level === "project") { + merged = deepMergeSettings(merged, item.data as Settings); + } + } + + return merged; + } + + /** Apply additional overrides on top of current settings */ + applyOverrides(overrides: Partial): void { + this.settings = deepMergeSettings(this.settings, overrides); + } + + private save(): void { + if (!this.persist || !this.settingsPath) return; + + try { + const dir = dirname(this.settingsPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Save only global settings (project settings are read-only) + writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8"); + + // Re-merge project settings into active settings + const projectSettings = this.loadProjectSettings(); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); + } catch (error) { + console.error(`Warning: Could not save settings file: ${error}`); + } + } + + getLastChangelogVersion(): string | undefined { + return this.settings.lastChangelogVersion; + } + + setLastChangelogVersion(version: string): void { + this.globalSettings.lastChangelogVersion = version; + this.save(); + } + + /** + * Get model for a role. Returns "provider/modelId" string or undefined. + */ + getModelRole(role: string): string | undefined { + return this.settings.modelRoles?.[role]; + } + + /** + * Set model for a role. Model should be "provider/modelId" format. + */ + setModelRole(role: string, model: string): void { + if (!this.globalSettings.modelRoles) { + this.globalSettings.modelRoles = {}; + } + this.globalSettings.modelRoles[role] = model; + this.save(); + } + + /** + * Get all model roles. + */ + getModelRoles(): Record { + return { ...this.settings.modelRoles }; + } + + getQueueMode(): "all" | "one-at-a-time" { + return this.settings.queueMode || "one-at-a-time"; + } + + setQueueMode(mode: "all" | "one-at-a-time"): void { + this.globalSettings.queueMode = mode; + this.save(); + } + + getInterruptMode(): "immediate" | "wait" { + return this.settings.interruptMode || "immediate"; + } + + setInterruptMode(mode: "immediate" | "wait"): void { + this.globalSettings.interruptMode = mode; + this.save(); + } + + getTheme(): string | undefined { + return this.settings.theme; + } + + setTheme(theme: string): void { + this.globalSettings.theme = theme; + this.save(); + } + + getDefaultThinkingLevel(): "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | undefined { + return this.settings.defaultThinkingLevel; + } + + setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void { + this.globalSettings.defaultThinkingLevel = level; + this.save(); + } + + getCompactionEnabled(): boolean { + return this.settings.compaction?.enabled ?? true; + } + + setCompactionEnabled(enabled: boolean): void { + if (!this.globalSettings.compaction) { + this.globalSettings.compaction = {}; + } + this.globalSettings.compaction.enabled = enabled; + this.save(); + } + + getCompactionReserveTokens(): number { + return this.settings.compaction?.reserveTokens ?? 16384; + } + + getCompactionKeepRecentTokens(): number { + return this.settings.compaction?.keepRecentTokens ?? 20000; + } + + getCompactionSettings(): { enabled: boolean; reserveTokens: number; keepRecentTokens: number } { + return { + enabled: this.getCompactionEnabled(), + reserveTokens: this.getCompactionReserveTokens(), + keepRecentTokens: this.getCompactionKeepRecentTokens(), + }; + } + + getBranchSummarySettings(): { reserveTokens: number } { + return { + reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384, + }; + } + + getRetryEnabled(): boolean { + return this.settings.retry?.enabled ?? true; + } + + setRetryEnabled(enabled: boolean): void { + if (!this.globalSettings.retry) { + this.globalSettings.retry = {}; + } + this.globalSettings.retry.enabled = enabled; + this.save(); + } + + getRetrySettings(): { enabled: boolean; maxRetries: number; baseDelayMs: number } { + return { + enabled: this.getRetryEnabled(), + maxRetries: this.settings.retry?.maxRetries ?? 3, + baseDelayMs: this.settings.retry?.baseDelayMs ?? 2000, + }; + } + + getHideThinkingBlock(): boolean { + return this.settings.hideThinkingBlock ?? false; + } + + setHideThinkingBlock(hide: boolean): void { + this.globalSettings.hideThinkingBlock = hide; + this.save(); + } + + getShellPath(): string | undefined { + return this.settings.shellPath; + } + + setShellPath(path: string | undefined): void { + this.globalSettings.shellPath = path; + this.save(); + } + + getCollapseChangelog(): boolean { + return this.settings.collapseChangelog ?? false; + } + + setCollapseChangelog(collapse: boolean): void { + this.globalSettings.collapseChangelog = collapse; + this.save(); + } + + getHookPaths(): string[] { + return [...(this.settings.hooks ?? [])]; + } + + setHookPaths(paths: string[]): void { + this.globalSettings.hooks = paths; + this.save(); + } + + getCustomToolPaths(): string[] { + return [...(this.settings.customTools ?? [])]; + } + + setCustomToolPaths(paths: string[]): void { + this.globalSettings.customTools = paths; + this.save(); + } + + getSkillsEnabled(): boolean { + return this.settings.skills?.enabled ?? true; + } + + setSkillsEnabled(enabled: boolean): void { + if (!this.globalSettings.skills) { + this.globalSettings.skills = {}; + } + this.globalSettings.skills.enabled = enabled; + this.save(); + } + + getSkillsSettings(): Required { + return { + enabled: this.settings.skills?.enabled ?? true, + enableCodexUser: this.settings.skills?.enableCodexUser ?? true, + enableClaudeUser: this.settings.skills?.enableClaudeUser ?? true, + enableClaudeProject: this.settings.skills?.enableClaudeProject ?? true, + enablePiUser: this.settings.skills?.enablePiUser ?? true, + enablePiProject: this.settings.skills?.enablePiProject ?? true, + customDirectories: [...(this.settings.skills?.customDirectories ?? [])], + ignoredSkills: [...(this.settings.skills?.ignoredSkills ?? [])], + includeSkills: [...(this.settings.skills?.includeSkills ?? [])], + }; + } + + getCommandsSettings(): Required { + return { + enableClaudeUser: this.settings.commands?.enableClaudeUser ?? true, + enableClaudeProject: this.settings.commands?.enableClaudeProject ?? true, + }; + } + + getShowImages(): boolean { + return this.settings.terminal?.showImages ?? true; + } + + setShowImages(show: boolean): void { + if (!this.globalSettings.terminal) { + this.globalSettings.terminal = {}; + } + this.globalSettings.terminal.showImages = show; + this.save(); + } + + getEnabledModels(): string[] | undefined { + return this.settings.enabledModels; + } + + getExaSettings(): Required { + return { + enabled: this.settings.exa?.enabled ?? true, + enableSearch: this.settings.exa?.enableSearch ?? true, + enableLinkedin: this.settings.exa?.enableLinkedin ?? false, + enableCompany: this.settings.exa?.enableCompany ?? false, + enableResearcher: this.settings.exa?.enableResearcher ?? false, + enableWebsets: this.settings.exa?.enableWebsets ?? false, + }; + } + + setExaEnabled(enabled: boolean): void { + if (!this.globalSettings.exa) { + this.globalSettings.exa = {}; + } + this.globalSettings.exa.enabled = enabled; + this.save(); + } + + setExaSearchEnabled(enabled: boolean): void { + if (!this.globalSettings.exa) { + this.globalSettings.exa = {}; + } + this.globalSettings.exa.enableSearch = enabled; + this.save(); + } + + setExaLinkedinEnabled(enabled: boolean): void { + if (!this.globalSettings.exa) { + this.globalSettings.exa = {}; + } + this.globalSettings.exa.enableLinkedin = enabled; + this.save(); + } + + setExaCompanyEnabled(enabled: boolean): void { + if (!this.globalSettings.exa) { + this.globalSettings.exa = {}; + } + this.globalSettings.exa.enableCompany = enabled; + this.save(); + } + + setExaResearcherEnabled(enabled: boolean): void { + if (!this.globalSettings.exa) { + this.globalSettings.exa = {}; + } + this.globalSettings.exa.enableResearcher = enabled; + this.save(); + } + + setExaWebsetsEnabled(enabled: boolean): void { + if (!this.globalSettings.exa) { + this.globalSettings.exa = {}; + } + this.globalSettings.exa.enableWebsets = enabled; + this.save(); + } + + getBashInterceptorEnabled(): boolean { + return this.settings.bashInterceptor?.enabled ?? false; + } + + setBashInterceptorEnabled(enabled: boolean): void { + if (!this.globalSettings.bashInterceptor) { + this.globalSettings.bashInterceptor = {}; + } + this.globalSettings.bashInterceptor.enabled = enabled; + this.save(); + } + + getMCPProjectConfigEnabled(): boolean { + return this.settings.mcp?.enableProjectConfig ?? true; + } + + setMCPProjectConfigEnabled(enabled: boolean): void { + if (!this.globalSettings.mcp) { + this.globalSettings.mcp = {}; + } + this.globalSettings.mcp.enableProjectConfig = enabled; + this.save(); + } + + getLspFormatOnWrite(): boolean { + return this.settings.lsp?.formatOnWrite ?? false; + } + + setLspFormatOnWrite(enabled: boolean): void { + if (!this.globalSettings.lsp) { + this.globalSettings.lsp = {}; + } + this.globalSettings.lsp.formatOnWrite = enabled; + this.save(); + } + + getLspDiagnosticsOnWrite(): boolean { + return this.settings.lsp?.diagnosticsOnWrite ?? true; + } + + setLspDiagnosticsOnWrite(enabled: boolean): void { + if (!this.globalSettings.lsp) { + this.globalSettings.lsp = {}; + } + this.globalSettings.lsp.diagnosticsOnWrite = enabled; + this.save(); + } + + getLspDiagnosticsOnEdit(): boolean { + return this.settings.lsp?.diagnosticsOnEdit ?? false; + } + + setLspDiagnosticsOnEdit(enabled: boolean): void { + if (!this.globalSettings.lsp) { + this.globalSettings.lsp = {}; + } + this.globalSettings.lsp.diagnosticsOnEdit = enabled; + this.save(); + } + + getEditFuzzyMatch(): boolean { + return this.settings.edit?.fuzzyMatch ?? true; + } + + setEditFuzzyMatch(enabled: boolean): void { + if (!this.globalSettings.edit) { + this.globalSettings.edit = {}; + } + this.globalSettings.edit.fuzzyMatch = enabled; + this.save(); + } + + getDisabledProviders(): string[] { + return [...(this.settings.disabledProviders ?? [])]; + } + + setDisabledProviders(providerIds: string[]): void { + this.globalSettings.disabledProviders = providerIds; + this.save(); + } + + getDisabledExtensions(): string[] { + return [...(this.settings.disabledExtensions ?? [])]; + } + + setDisabledExtensions(extensionIds: string[]): void { + this.globalSettings.disabledExtensions = extensionIds; + this.save(); + } + + isExtensionEnabled(extensionId: string): boolean { + return !(this.settings.disabledExtensions ?? []).includes(extensionId); + } + + enableExtension(extensionId: string): void { + const disabled = this.globalSettings.disabledExtensions ?? []; + const index = disabled.indexOf(extensionId); + if (index !== -1) { + disabled.splice(index, 1); + this.globalSettings.disabledExtensions = disabled; + this.save(); + } + } + + disableExtension(extensionId: string): void { + const disabled = this.globalSettings.disabledExtensions ?? []; + if (!disabled.includes(extensionId)) { + disabled.push(extensionId); + this.globalSettings.disabledExtensions = disabled; + this.save(); + } + } + + getTtsrSettings(): TtsrSettings { + return this.settings.ttsr ?? {}; + } + + setTtsrSettings(settings: TtsrSettings): void { + this.globalSettings.ttsr = { ...this.globalSettings.ttsr, ...settings }; + this.save(); + } + + getTtsrEnabled(): boolean { + return this.settings.ttsr?.enabled ?? true; + } + + setTtsrEnabled(enabled: boolean): void { + if (!this.globalSettings.ttsr) { + this.globalSettings.ttsr = {}; + } + this.globalSettings.ttsr.enabled = enabled; + this.save(); + } + + getTtsrContextMode(): "keep" | "discard" { + return this.settings.ttsr?.contextMode ?? "discard"; + } + + setTtsrContextMode(mode: "keep" | "discard"): void { + if (!this.globalSettings.ttsr) { + this.globalSettings.ttsr = {}; + } + this.globalSettings.ttsr.contextMode = mode; + this.save(); + } + + getTtsrRepeatMode(): "once" | "after-gap" { + return this.settings.ttsr?.repeatMode ?? "once"; + } + + setTtsrRepeatMode(mode: "once" | "after-gap"): void { + if (!this.globalSettings.ttsr) { + this.globalSettings.ttsr = {}; + } + this.globalSettings.ttsr.repeatMode = mode; + this.save(); + } + + getTtsrRepeatGap(): number { + return this.settings.ttsr?.repeatGap ?? 10; + } + + setTtsrRepeatGap(gap: number): void { + if (!this.globalSettings.ttsr) { + this.globalSettings.ttsr = {}; + } + this.globalSettings.ttsr.repeatGap = gap; + this.save(); + } } diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts deleted file mode 100644 index 784125f84..000000000 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ /dev/null @@ -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 = 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; - } -} diff --git a/packages/coding-agent/src/modes/interactive/components/status-line.ts b/packages/coding-agent/src/modes/interactive/components/status-line.ts new file mode 100644 index 000000000..8479d6064 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/status-line.ts @@ -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 = 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", "…"))]; + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 2a2883c92..8b97bb58d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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(); } diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index 01a1c5835..10051b7cc 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -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", diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index 393009dec..8a31d1ee3 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -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", diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index d047493d7..8a558ddd7 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -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)) { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c3ed2e88f..593a02297 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog ## [Unreleased] +### Added + +- Added `setTopBorder()` method to Editor component for displaying custom status content in the top border +- Added `getWidth()` method to TUI class for retrieving terminal width +- Added rounded corner box-drawing characters to Editor component borders + +### Changed + +- Changed Editor component to use proper box borders with vertical side borders instead of horizontal-only borders ## [3.8.1337] - 2026-01-04 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 2fe82fc30..2fa9b7855 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -26,7 +26,7 @@ import { isTab, } from "../keys"; import type { Component } from "../tui"; -import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils"; +import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils"; import { SelectList, type SelectListTheme } from "./select-list"; const segmenter = getSegmenter(); @@ -48,6 +48,13 @@ export interface EditorTheme { selectList: SelectListTheme; } +export interface EditorTopBorder { + /** The status content (already styled) */ + content: string; + /** Visible width of the content */ + width: number; +} + export class Editor implements Component { private state: EditorState = { lines: [""], @@ -85,6 +92,9 @@ export class Editor implements Component { public onChange?: (text: string) => void; public disableSubmit: boolean = false; + // Custom top border (for status line integration) + private topBorderContent?: EditorTopBorder; + constructor(theme: EditorTheme) { this.theme = theme; this.borderColor = theme.borderColor; @@ -94,6 +104,14 @@ export class Editor implements Component { this.autocompleteProvider = provider; } + /** + * Set custom content for the top border (e.g., status line). + * Pass undefined to use the default plain border. + */ + setTopBorder(content: EditorTopBorder | undefined): void { + this.topBorderContent = content; + } + /** * Add a prompt to history for up/down arrow navigation. * Called after successful submission. @@ -162,17 +180,42 @@ export class Editor implements Component { // Store width for cursor navigation this.lastWidth = width; + // Box-drawing characters for rounded corners + const topLeft = this.borderColor("╭─"); + const topRight = this.borderColor("─╮"); + const bottomLeft = this.borderColor("╰─"); + const bottomRight = this.borderColor("─╯"); const horizontal = this.borderColor("─"); - // Layout the text - use full width - const layoutLines = this.layoutText(width); + // Layout the text - content area is width minus 4 for borders (2 left + 2 right) + const contentAreaWidth = width - 4; + const layoutLines = this.layoutText(contentAreaWidth); const result: string[] = []; - // Render top border - result.push(horizontal.repeat(width)); + // Render top border: ╭─ [status content] ────────────────╮ + // Reserve: 2 for "╭─", 2 for "─╮" = 4 total for corners + const topFillWidth = width - 4; + if (this.topBorderContent) { + const { content, width: statusWidth } = this.topBorderContent; + if (statusWidth <= topFillWidth) { + // Status fits - add fill after it + const fillWidth = topFillWidth - statusWidth; + result.push(topLeft + content + this.borderColor("─".repeat(fillWidth)) + topRight); + } else { + // Status too long - truncate it + const truncated = truncateToWidth(content, topFillWidth - 1, this.borderColor("…")); + const truncatedWidth = visibleWidth(truncated); + const fillWidth = Math.max(0, topFillWidth - truncatedWidth); + result.push(topLeft + truncated + this.borderColor("─".repeat(fillWidth)) + topRight); + } + } else { + result.push(topLeft + horizontal.repeat(topFillWidth) + topRight); + } // Render each layout line + // Content area is width - 4 (for prefix and suffix borders) + const lineContentWidth = width - 4; for (const layoutLine of layoutLines) { let displayText = layoutLine.text; let lineVisibleWidth = visibleWidth(layoutLine.text); @@ -193,7 +236,7 @@ export class Editor implements Component { // lineVisibleWidth stays the same - we're replacing, not adding } else { // Cursor is at the end - check if we have room for the space - if (lineVisibleWidth < width) { + if (lineVisibleWidth < lineContentWidth) { // We have room - add highlighted space const cursor = "\x1b[7m \x1b[0m"; displayText = before + cursor; @@ -218,16 +261,21 @@ export class Editor implements Component { } } - // Calculate padding based on actual visible width - const padding = " ".repeat(Math.max(0, width - lineVisibleWidth)); + // Each line gets: "│ " prefix (2 chars) and " │" suffix (2 chars) for middle lines + // Last line gets: "╰─" prefix (2 chars) and "─╯" suffix (2 chars) + const isLastLine = layoutLine === layoutLines[layoutLines.length - 1]; + const padding = " ".repeat(Math.max(0, lineContentWidth - lineVisibleWidth)); - // Render the line (no side borders, just horizontal lines above and below) - result.push(displayText + padding); + if (isLastLine) { + result.push(bottomLeft + displayText + padding + bottomRight); + } else { + // Middle lines: use vertical bar borders + const leftBorder = this.borderColor("│ "); + const rightBorder = this.borderColor(" │"); + result.push(leftBorder + displayText + padding + rightBorder); + } } - // Render bottom border - result.push(horizontal.repeat(width)); - // Add autocomplete list if active if (this.isAutocompleting && this.autocompleteList) { const autocompleteResult = this.autocompleteList.render(width); @@ -1284,7 +1332,7 @@ export class Editor implements Component { https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883 17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19 536643416/job/55932288317 havea look at .gi - */ + */ private forceFileAutocomplete(): void { if (!this.autocompleteProvider) return; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 141144b9d..309df0eb1 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -10,7 +10,7 @@ export { // Components export { Box } from "./components/box"; export { CancellableLoader } from "./components/cancellable-loader"; -export { Editor, type EditorTheme } from "./components/editor"; +export { Editor, type EditorTheme, type EditorTopBorder } from "./components/editor"; export { Image, type ImageOptions, type ImageTheme } from "./components/image"; export { Input } from "./components/input"; export { Loader } from "./components/loader"; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 9497512c9..c50fcc69a 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -123,6 +123,10 @@ export class TUI extends Container { this.terminal.stop(); } + getWidth(): number { + return this.terminal.columns; + } + requestRender(force = false): void { if (force) { this.previousLines = [];