diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 8a75f0d95..3374a0539 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -28,6 +28,7 @@ export class CustomEditor extends Editor { public onCtrlT?: () => void; public onCtrlG?: () => void; public onCtrlZ?: () => void; + public onQuestionMark?: () => void; handleInput(data: string): void { // Intercept Ctrl+G for external editor @@ -100,6 +101,12 @@ export class CustomEditor extends Editor { return; } + // Intercept ? when editor is empty to show hotkeys + if (data === "?" && this.getText().length === 0 && this.onQuestionMark) { + this.onQuestionMark(); + return; + } + // Pass to parent for normal handling super.handleInput(data); } diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 78db717c4..4126c80ba 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,10 +1,20 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { execSync } from "child_process"; import { existsSync, type FSWatcher, readFileSync, watch } from "fs"; import { dirname, join } from "path"; import type { AgentSession } from "../../../core/agent-session.js"; import { theme } from "../theme/theme.js"; +// 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. @@ -152,10 +162,54 @@ export class FooterComponent implements Component { return this.cachedBranch; } + /** + * Get git status indicators (staged, unstaged, untracked counts). + * Returns null if not in a git repo. + */ + private getGitStatus(): { staged: number; unstaged: number; untracked: number } | null { + 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++; + } + } + + return { staged, unstaged, untracked }; + } catch { + return null; + } + } + render(width: number): string[] { const state = this.session.state; - // Calculate cumulative usage from ALL session entries (not just post-compaction messages) + // Calculate cumulative usage from ALL session entries let totalInput = 0; let totalOutput = 0; let totalCacheRead = 0; @@ -172,13 +226,12 @@ export class FooterComponent implements Component { } } - // Get last assistant message for context percentage calculation (skip aborted messages) + // Get context percentage from last assistant message const lastAssistantMessage = state.messages .slice() .reverse() .find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined; - // Calculate context percentage from last message (input + output + cacheRead + cacheWrite) const contextTokens = lastAssistantMessage ? lastAssistantMessage.usage.input + lastAssistantMessage.usage.output + @@ -187,136 +240,140 @@ export class FooterComponent implements Component { : 0; const contextWindow = state.model?.contextWindow || 0; const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; - const contextPercent = contextPercentValue.toFixed(1); - // Format token counts (similar to web-ui) - const formatTokens = (count: number): string => { - if (count < 1000) return count.toString(); - if (count < 10000) return `${(count / 1000).toFixed(1)}k`; - if (count < 1000000) return `${Math.round(count / 1000)}k`; - if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`; - return `${Math.round(count / 1000000)}M`; + // 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`; }; - // Replace home directory with ~ + // 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)}`; } - - // Add git branch if available - const branch = this.getCurrentBranch(); - if (branch) { - pwd = `${pwd} (${branch})`; + // 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; - // Truncate path if too long to fit width - if (pwd.length > width) { - const half = Math.floor(width / 2) - 2; - if (half > 0) { - const start = pwd.slice(0, half); - const end = pwd.slice(-(half - 1)); - pwd = `${start}...${end}`; - } else { - pwd = pwd.slice(0, Math.max(1, width)); + // ═══════════════════════════════════════════════════════════════════════ + // 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(" ")}`; + } } } - // Build stats line - const statsParts = []; - if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`); - if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`); - if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`); - if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); + // ═══════════════════════════════════════════════════════════════════════ + // SEGMENT 4: Stats (Pink/Magenta tones) + // Concise: total tokens, cost, context% + // ═══════════════════════════════════════════════════════════════════════ + const statParts: string[] = []; - // Show cost with "(sub)" indicator if using OAuth subscription + // 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 costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; - statsParts.push(costStr); + const costDisplay = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; + statParts.push(theme.fg("footerCost", costDisplay)); } - // Colorize context percentage based on usage - let contextPercentStr: string; + // Context percentage with severity coloring const autoIndicator = this.autoCompactEnabled ? " (auto)" : ""; - const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`; + const contextDisplay = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)}${autoIndicator}`; + let contextColored: string; if (contextPercentValue > 90) { - contextPercentStr = theme.fg("error", contextPercentDisplay); + contextColored = theme.fg("error", contextDisplay); } else if (contextPercentValue > 70) { - contextPercentStr = theme.fg("warning", contextPercentDisplay); + contextColored = theme.fg("warning", contextDisplay); } else { - contextPercentStr = contextPercentDisplay; + contextColored = theme.fg("footerSep", contextDisplay); } - statsParts.push(contextPercentStr); + statParts.push(contextColored); - let statsLeft = statsParts.join(" "); + const statsSegment = statParts.join(" "); - // Add model name on the right side, plus thinking level if model supports it - const modelName = state.model?.id || "no-model"; + // ═══════════════════════════════════════════════════════════════════════ + // Assemble single powerline-style line + // [Model] > [Path] > [Git] > [Stats] + // ═══════════════════════════════════════════════════════════════════════ + const segments = [modelSegment, pathSegment]; + if (gitSegment) segments.push(gitSegment); + segments.push(statsSegment); - // Add thinking level hint if model supports reasoning and thinking is enabled - let rightSide = modelName; - if (state.model?.reasoning) { - const thinkingLevel = state.thinkingLevel || "off"; - if (thinkingLevel !== "off") { - rightSide = `${modelName} • ${thinkingLevel}`; - } + let statusLine = segments.join(sep); + + // Truncate if needed + if (visibleWidth(statusLine) > width) { + statusLine = truncateToWidth(statusLine, width, theme.fg("footerSep", "…")); } - let statsLeftWidth = visibleWidth(statsLeft); - const rightSideWidth = visibleWidth(rightSide); + const lines = [statusLine]; - // If statsLeft is too wide, truncate it - if (statsLeftWidth > width) { - // Truncate statsLeft to fit width (no room for right side) - const plainStatsLeft = statsLeft.replace(/\x1b\[[0-9;]*m/g, ""); - statsLeft = `${plainStatsLeft.substring(0, width - 3)}...`; - statsLeftWidth = visibleWidth(statsLeft); - } - - // Calculate available space for padding (minimum 2 spaces between stats and model) - const minPadding = 2; - const totalNeeded = statsLeftWidth + minPadding + rightSideWidth; - - let statsLine: string; - if (totalNeeded <= width) { - // Both fit - add padding to right-align model - const padding = " ".repeat(width - statsLeftWidth - rightSideWidth); - statsLine = statsLeft + padding + rightSide; - } else { - // Need to truncate right side - const availableForRight = width - statsLeftWidth - minPadding; - if (availableForRight > 3) { - // Truncate to fit (strip ANSI codes for length calculation, then truncate raw string) - const plainRightSide = rightSide.replace(/\x1b\[[0-9;]*m/g, ""); - const truncatedPlain = plainRightSide.substring(0, availableForRight); - // For simplicity, just use plain truncated version (loses color, but fits) - const padding = " ".repeat(width - statsLeftWidth - truncatedPlain.length); - statsLine = statsLeft + padding + truncatedPlain; - } else { - // Not enough space for right side at all - statsLine = statsLeft; - } - } - - // Apply dim to each part separately. statsLeft may contain color codes (for context %) - // that end with a reset, which would clear an outer dim wrapper. So we dim the parts - // before and after the colored section independently. - const dimStatsLeft = theme.fg("dim", statsLeft); - const remainder = statsLine.slice(statsLeft.length); // padding + rightSide - const dimRemainder = theme.fg("dim", remainder); - - const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder]; - - // Add hook statuses on a single line, sorted by key alphabetically + // 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 statusLine = sortedStatuses.join(" "); - // Truncate to terminal width with dim ellipsis for consistency with footer style - lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "..."))); + const hookLine = sortedStatuses.join(" "); + lines.push(truncateToWidth(hookLine, width, theme.fg("footerSep", "…"))); } return lines; diff --git a/packages/coding-agent/src/modes/interactive/components/welcome.ts b/packages/coding-agent/src/modes/interactive/components/welcome.ts new file mode 100644 index 000000000..a2d7cf2e2 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/welcome.ts @@ -0,0 +1,186 @@ +import { type Component, visibleWidth } from "@mariozechner/pi-tui"; +import { APP_NAME } from "../../../config.js"; +import { theme } from "../theme/theme.js"; + +export interface RecentSession { + name: string; + timeAgo: string; +} + +/** + * Premium welcome screen with block-based Pi logo and two-column layout. + */ +export class WelcomeComponent implements Component { + private version: string; + private modelName: string; + private providerName: string; + private recentSessions: RecentSession[]; + + constructor(version: string, modelName: string, providerName: string, recentSessions: RecentSession[] = []) { + this.version = version; + this.modelName = modelName; + this.providerName = providerName; + this.recentSessions = recentSessions; + } + + invalidate(): void {} + + setModel(modelName: string, providerName: string): void { + this.modelName = modelName; + this.providerName = providerName; + } + + setRecentSessions(sessions: RecentSession[]): void { + this.recentSessions = sessions; + } + + render(termWidth: number): string[] { + // Box dimensions - responsive with min/max + const minWidth = 80; + const maxWidth = 100; + const boxWidth = Math.max(minWidth, Math.min(termWidth - 2, maxWidth)); + const leftCol = 26; + const rightCol = boxWidth - leftCol - 3; // 3 = │ + │ + │ + + // Block-based Pi logo (gradient: magenta → cyan) + const piLogo = [ + "▀████████████▀", + " ╘███ ███ ", + " ███ ███ ", + " ███ ███ ", + " ▄███▄ ▄███▄ ", + ]; + + // Apply gradient to logo + const logoColored = piLogo.map((line) => this.gradientLine(line)); + + // Left column - centered content + const leftLines = [ + "", + this.centerText(theme.bold("Welcome back!"), leftCol), + "", + ...logoColored.map((l) => this.centerText(l, leftCol)), + "", + this.centerText(theme.fg("muted", this.modelName), leftCol), + this.centerText(theme.fg("borderMuted", this.providerName), leftCol), + ]; + + // Right column separator + const separatorWidth = rightCol - 2; // padding on each side + const separator = " " + theme.fg("dim", "─".repeat(separatorWidth)); + + // Recent sessions content + const sessionLines: string[] = []; + if (this.recentSessions.length === 0) { + sessionLines.push(" " + theme.fg("dim", "No recent sessions")); + } else { + for (const session of this.recentSessions.slice(0, 3)) { + sessionLines.push(" " + theme.fg("dim", "▪ ") + theme.fg("muted", session.name) + theme.fg("dim", ` (${session.timeAgo})`)); + } + } + + // Right column + const rightLines = [ + " " + theme.bold(theme.fg("accent", "Tips")), + " " + theme.fg("dim", "?") + theme.fg("muted", " for keyboard shortcuts"), + " " + theme.fg("dim", "/") + theme.fg("muted", " for commands"), + " " + theme.fg("dim", "!") + theme.fg("muted", " to run bash"), + " " + theme.fg("dim", "/status") + theme.fg("muted", " for loaded extensions"), + separator, + " " + theme.bold(theme.fg("accent", "Recent sessions")), + ...sessionLines, + "", + ]; + + // Border characters (dim) + const h = theme.fg("dim", "─"); + const v = theme.fg("dim", "│"); + const tl = theme.fg("dim", "╭"); + const tr = theme.fg("dim", "╮"); + const bl = theme.fg("dim", "╰"); + const br = theme.fg("dim", "╯"); + + const lines: string[] = []; + + // Top border with embedded title + const title = ` ${APP_NAME} v${this.version} `; + const titleStyled = theme.fg("dim", "───") + theme.fg("muted", title); + const titleVisLen = 3 + title.length; + const afterTitle = boxWidth - 2 - titleVisLen; + lines.push(tl + titleStyled + h.repeat(Math.max(0, afterTitle)) + tr); + + // Content rows + const maxRows = Math.max(leftLines.length, rightLines.length); + for (let i = 0; i < maxRows; i++) { + const left = this.fitToWidth(leftLines[i] ?? "", leftCol); + const right = this.fitToWidth(rightLines[i] ?? "", rightCol); + lines.push(v + left + v + right + v); + } + + // Bottom border + lines.push(bl + h.repeat(leftCol) + theme.fg("dim", "┴") + h.repeat(rightCol) + br); + + return lines; + } + + /** Center text within a given width */ + private centerText(text: string, width: number): string { + const visLen = visibleWidth(text); + if (visLen >= width) return text; + const leftPad = Math.floor((width - visLen) / 2); + const rightPad = width - visLen - leftPad; + return " ".repeat(leftPad) + text + " ".repeat(rightPad); + } + + /** Apply magenta→cyan gradient to a string */ + private gradientLine(line: string): string { + const colors = [ + "\x1b[38;5;199m", // bright magenta + "\x1b[38;5;171m", // magenta-purple + "\x1b[38;5;135m", // purple + "\x1b[38;5;99m", // purple-blue + "\x1b[38;5;75m", // cyan-blue + "\x1b[38;5;51m", // bright cyan + ]; + const reset = "\x1b[0m"; + + let result = ""; + let colorIdx = 0; + const step = Math.max(1, Math.floor(line.length / colors.length)); + + for (let i = 0; i < line.length; i++) { + if (i > 0 && i % step === 0 && colorIdx < colors.length - 1) { + colorIdx++; + } + const char = line[i]; + if (char !== " ") { + result += colors[colorIdx] + char + reset; + } else { + result += char; + } + } + return result; + } + + /** Fit string to exact width with ANSI-aware truncation/padding */ + private fitToWidth(str: string, width: number): string { + const visLen = visibleWidth(str); + if (visLen > width) { + let truncated = ""; + let currentWidth = 0; + let inEscape = false; + for (const char of str) { + if (char === "\x1b") inEscape = true; + if (inEscape) { + truncated += char; + if (char === "m") inEscape = false; + } else if (currentWidth < width - 1) { + truncated += char; + currentWidth++; + } + } + return truncated + "…"; + } + return str + " ".repeat(width - visLen); + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 03e4fc50a..8b0708691 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -23,7 +23,7 @@ import { TUI, visibleWidth, } from "@mariozechner/pi-tui"; -import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js"; +import { getAuthPath, getDebugLogPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index.js"; import type { HookUIContext } from "../../core/hooks/index.js"; @@ -55,6 +55,7 @@ import { ToolExecutionComponent } from "./components/tool-execution.js"; import { TreeSelectorComponent } from "./components/tree-selector.js"; import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; +import { WelcomeComponent } from "./components/welcome.js"; import { getAvailableThemes, getEditorTheme, @@ -178,6 +179,7 @@ export class InteractiveMode { { name: "share", description: "Share session as a secret GitHub gist" }, { name: "copy", description: "Copy last agent message to clipboard" }, { name: "session", description: "Show session info and stats" }, + { name: "status", description: "Show loaded extensions (context, skills, tools, hooks)" }, { name: "changelog", description: "Show changelog entries" }, { name: "hotkeys", description: "Show all keyboard shortcuts" }, { name: "branch", description: "Create a new branch from a previous message" }, @@ -216,58 +218,16 @@ export class InteractiveMode { async init(): Promise { if (this.isInitialized) return; - // Add header - const logo = theme.bold(theme.fg("accent", APP_NAME)) + theme.fg("dim", ` v${this.version}`); - const instructions = - theme.fg("dim", "esc") + - theme.fg("muted", " to interrupt") + - "\n" + - theme.fg("dim", "ctrl+c") + - theme.fg("muted", " to clear") + - "\n" + - theme.fg("dim", "ctrl+c twice") + - theme.fg("muted", " to exit") + - "\n" + - theme.fg("dim", "ctrl+d") + - theme.fg("muted", " to exit (empty)") + - "\n" + - theme.fg("dim", "ctrl+z") + - theme.fg("muted", " to suspend") + - "\n" + - theme.fg("dim", "ctrl+k") + - theme.fg("muted", " to delete line") + - "\n" + - theme.fg("dim", "shift+tab") + - theme.fg("muted", " to cycle thinking") + - "\n" + - theme.fg("dim", "ctrl+p/shift+ctrl+p") + - theme.fg("muted", " to cycle models") + - "\n" + - theme.fg("dim", "ctrl+l") + - theme.fg("muted", " to select model") + - "\n" + - theme.fg("dim", "ctrl+o") + - theme.fg("muted", " to expand tools") + - "\n" + - theme.fg("dim", "ctrl+t") + - theme.fg("muted", " to toggle thinking") + - "\n" + - theme.fg("dim", "ctrl+g") + - theme.fg("muted", " for external editor") + - "\n" + - theme.fg("dim", "/") + - theme.fg("muted", " for commands") + - "\n" + - theme.fg("dim", "!") + - theme.fg("muted", " to run bash") + - "\n" + - theme.fg("dim", "drop files") + - theme.fg("muted", " to attach"); - const header = new Text(`${logo}\n${instructions}`, 1, 0); + // Get current model info for welcome screen + const modelName = this.session.model?.name ?? "Unknown"; + const providerName = this.session.model?.provider ?? "Unknown"; + + // Add welcome header + const welcome = new WelcomeComponent(this.version, modelName, providerName); // Setup UI layout this.ui.addChild(new Spacer(1)); - this.ui.addChild(header); + this.ui.addChild(welcome); this.ui.addChild(new Spacer(1)); // Add changelog if provided @@ -329,43 +289,6 @@ export class InteractiveMode { * Initialize the hook system with TUI-based UI context. */ private async initHooksAndCustomTools(): Promise { - // Show loaded project context files - const contextFiles = loadProjectContextFiles(); - if (contextFiles.length > 0) { - const contextList = contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n"); - this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded context:\n") + contextList, 0, 0)); - this.chatContainer.addChild(new Spacer(1)); - } - - // Show loaded skills - const skillsSettings = this.session.skillsSettings; - if (skillsSettings?.enabled !== false) { - const { skills, warnings: skillWarnings } = loadSkills(skillsSettings ?? {}); - if (skills.length > 0) { - const skillList = skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n"); - this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded skills:\n") + skillList, 0, 0)); - this.chatContainer.addChild(new Spacer(1)); - } - - // Show skill warnings if any - if (skillWarnings.length > 0) { - const warningList = skillWarnings - .map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)) - .join("\n"); - this.chatContainer.addChild(new Text(theme.fg("warning", "Skill warnings:\n") + warningList, 0, 0)); - this.chatContainer.addChild(new Spacer(1)); - } - } - - // Show loaded custom tools - if (this.customTools.size > 0) { - const toolList = Array.from(this.customTools.values()) - .map((ct) => theme.fg("dim", ` ${ct.tool.name} (${ct.path})`)) - .join("\n"); - this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded custom tools:\n") + toolList, 0, 0)); - this.chatContainer.addChild(new Spacer(1)); - } - // Create and set hook & tool UI context const uiContext: HookUIContext = { select: (title, options) => this.showHookSelector(title, options), @@ -491,14 +414,6 @@ export class InteractiveMode { this.showHookError(error.hookPath, error.error); }); - // Show loaded hooks - const hookPaths = hookRunner.getHookPaths(); - if (hookPaths.length > 0) { - const hookList = hookPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n"); - this.chatContainer.addChild(new Text(theme.fg("muted", "Loaded hooks:\n") + hookList, 0, 0)); - this.chatContainer.addChild(new Spacer(1)); - } - // Emit session_start event await hookRunner.emit({ type: "session_start", @@ -770,6 +685,7 @@ export class InteractiveMode { this.editor.onCtrlO = () => this.toggleToolOutputExpansion(); this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility(); this.editor.onCtrlG = () => this.openExternalEditor(); + this.editor.onQuestionMark = () => this.handleHotkeysCommand(); this.editor.onChange = (text: string) => { const wasBashMode = this.isBashMode; @@ -826,6 +742,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/status") { + this.handleStatusCommand(); + this.editor.setText(""); + return; + } if (text === "/branch") { this.showUserMessageSelector(); this.editor.setText(""); @@ -2276,6 +2197,80 @@ export class InteractiveMode { this.ui.requestRender(); } + private handleStatusCommand(): void { + const sections: string[] = []; + + // Loaded context files + const contextFiles = loadProjectContextFiles(); + if (contextFiles.length > 0) { + sections.push( + theme.bold(theme.fg("accent", "Context Files")) + + "\n" + + contextFiles.map((f) => theme.fg("dim", ` ${f.path}`)).join("\n"), + ); + } + + // Loaded skills + const skillsSettings = this.session.skillsSettings; + if (skillsSettings?.enabled !== false) { + const { skills, warnings: skillWarnings } = loadSkills(skillsSettings ?? {}); + if (skills.length > 0) { + sections.push( + theme.bold(theme.fg("accent", "Skills")) + + "\n" + + skills.map((s) => theme.fg("dim", ` ${s.filePath}`)).join("\n"), + ); + } + if (skillWarnings.length > 0) { + sections.push( + theme.bold(theme.fg("warning", "Skill Warnings")) + + "\n" + + skillWarnings.map((w) => theme.fg("warning", ` ${w.skillPath}: ${w.message}`)).join("\n"), + ); + } + } + + // Loaded custom tools + if (this.customTools.size > 0) { + sections.push( + theme.bold(theme.fg("accent", "Custom Tools")) + + "\n" + + Array.from(this.customTools.values()) + .map((ct) => theme.fg("dim", ` ${ct.tool.name} (${ct.path})`)) + .join("\n"), + ); + } + + // Loaded hooks + const hookRunner = this.session.hookRunner; + if (hookRunner) { + const hookPaths = hookRunner.getHookPaths(); + if (hookPaths.length > 0) { + sections.push( + theme.bold(theme.fg("accent", "Hooks")) + + "\n" + + hookPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n"), + ); + } + } + + if (sections.length === 0) { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(theme.fg("muted", "No extensions loaded."), 1, 0)); + } else { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new DynamicBorder()); + this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Loaded Extensions")), 1, 0)); + this.chatContainer.addChild(new Spacer(1)); + for (const section of sections) { + this.chatContainer.addChild(new Text(section, 1, 0)); + this.chatContainer.addChild(new Spacer(1)); + } + this.chatContainer.addChild(new DynamicBorder()); + } + this.ui.requestRender(); + } + private async handleClearCommand(): Promise { // Stop loading animation if (this.loadingAnimation) { diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index ba2bd8937..01a1c5835 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -77,7 +77,21 @@ "thinkingHigh": "#b281d6", "thinkingXhigh": "#e5c1ff", - "bashMode": "cyan" + "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 }, "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 9154b161f..393009dec 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -74,7 +74,21 @@ "thinkingHigh": "#875f87", "thinkingXhigh": "#8b008b", - "bashMode": "green" + "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 }, "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 524dc13d6..6c2124336 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -81,6 +81,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, }), export: Type.Optional( Type.Object({ @@ -141,7 +155,20 @@ export type ThemeColor = | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" - | "bashMode"; + | "bashMode" + | "footerIcon" + | "footerSep" + | "footerModel" + | "footerPath" + | "footerBranch" + | "footerStaged" + | "footerDirty" + | "footerUntracked" + | "footerInput" + | "footerOutput" + | "footerCacheRead" + | "footerCacheWrite" + | "footerCost"; export type ThemeBg = | "selectedBg"