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