feat(tui): overhauled UI with welcome screen and powerline footer

- Implemented a new welcome screen component for startup.
- Introduced a powerline-style footer with integrated git status, model info, and token usage.
- Consolidated extension loading messages into a new `/status` command.
- Added `?` hotkey to display shortcuts when the editor is empty.
This commit is contained in:
can1357
2026-01-02 15:05:36 +01:00
parent b85d3f6aa4
commit cf5c4286f1
7 changed files with 498 additions and 198 deletions
@@ -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);
}
@@ -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;
@@ -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);
}
}
@@ -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<void> {
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<void> {
// 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<void> {
// Stop loading animation
if (this.loadingAnimation) {
@@ -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",
@@ -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",
@@ -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"