import { CustomEditor } from "@mariozechner/pi-coding-agent"; import { CURSOR_MARKER, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui"; import * as path from "node:path"; import { execSync } from "node:child_process"; // Strip ANSI escape codes from a string function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); } export interface MinimalEditorConfig { prefix: string; cwd: string; } export interface ContextStats { tokens: number; contextWindow: number; percent: number; } export interface GitInfo { branch: string; modified: boolean; staged: boolean; untracked: boolean; } export let editorConfig: MinimalEditorConfig = { prefix: "%d > ", cwd: "." }; export let contextStats: ContextStats | null = null; export let gitInfo: GitInfo | null = null; let editorRef: MinimalEditor | null = null; export function getEditorRef(): MinimalEditor | null { return editorRef; } export function setContextStats(stats: ContextStats | null): void { contextStats = stats; } export function getContextStats(): ContextStats | null { return contextStats; } export function setGitInfo(info: GitInfo | null): void { gitInfo = info; } export function getGitInfo(): GitInfo | null { return gitInfo; } export function updateGitInfo(): void { try { // Get branch name const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: editorConfig.cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }).trim(); // Get status (porcelain format) const status = execSync("git status --porcelain", { cwd: editorConfig.cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }); const lines = status.split("\n").filter(Boolean); let modified = false; let staged = false; let untracked = false; for (const line of lines) { const indexStatus = line[0]; const workTreeStatus = line[1]; if (indexStatus === "?" || workTreeStatus === "?") { untracked = true; } if (indexStatus === "M" || indexStatus === "A" || indexStatus === "D" || indexStatus === "R") { staged = true; } if (workTreeStatus === "M" || workTreeStatus === "D") { modified = true; } } gitInfo = { branch, modified, staged, untracked }; } catch { // Not a git repo or git not installed gitInfo = null; } } /** * Get text width respecting grapheme clusters (e.g., emojis). */ function graphemeWidth(str: string): number { // Simple: each grapheme cluster counts as 1 // This is a simplified version - full implementation uses Intl.Segmenter let width = 0; let i = 0; while (i < str.length) { const char = str[i]; const code = str.charCodeAt(i); // Check for emoji/extended characters (rough heuristic) if (code >= 0x1F300 && code <= 0x1F9FF) { width += 2; i += 2; } else if (code >= 0xAC00 && code <= 0xD7AF) { // Korean Hangul syllables width += 2; i += 3; } else if (code >= 0x4E00 && code <= 0x9FFF) { // CJK Unified Ideographs width += 2; i += 3; } else if (code >= 0x2000 && code <= 0x2BFF) { // General symbols, technical symbols, etc. width += 1; i++; } else if (char === '\x1b') { // Skip ANSI escape sequence let j = i; while (j < str.length && str[j] !== 'm') j++; i = j + 1; } else { width += 1; i++; } } return width; } export class MinimalEditor extends CustomEditor { render(width: number): string[] { // Check if autocomplete is showing const isAutocompleteShowing = (this as any).autocompleteState !== null; const autocompleteList = (this as any).autocompleteList as any; // Get bold prefix and calculate content width const [prefix, prefixLen] = this.getBoldPrefix(); const maxContentWidth = Math.max(1, width - prefixLen); // Get raw text and cursor position const rawText = this.getText(); const cursor = this.getCursor(); const emitCursorMarker = (this as any).focused && !isAutocompleteShowing; // Split into logical lines and process const inputLines = rawText.split("\n"); const result: string[] = []; // Track position within the logical content let contentPos = 0; for (let lineIdx = 0; lineIdx < inputLines.length; lineIdx++) { const line = inputLines[lineIdx]; const isFirstLogicalLine = lineIdx === 0; const lineWrapWidth = isFirstLogicalLine ? maxContentWidth : width; // Wrap the line const wrapped = wrapTextWithAnsi(line, lineWrapWidth); // Calculate character offsets for each wrapped segment let segmentStart = contentPos; let lineEndPos = segmentStart; for (let wIdx = 0; wIdx < wrapped.length; wIdx++) { const wrappedLine = wrapped[wIdx]; const wrappedWidth = graphemeWidth(wrappedLine); lineEndPos = segmentStart + wrappedWidth; // Determine if cursor is in this segment const isCursorLine = !isAutocompleteShowing && lineIdx === cursor.line; const isCursorInSegment = isCursorLine && cursor.col >= segmentStart && cursor.col <= lineEndPos; // Calculate cursor offset within this segment (visual, after prefix if first line) const cursorOffset = isCursorInSegment ? cursor.col - segmentStart : -1; // Build the line content with cursor highlighting let lineContent: string; if (isCursorInSegment && cursorOffset >= 0) { // Split at cursor position for highlighting const before = wrappedLine.slice(0, cursorOffset); const after = wrappedLine.slice(cursorOffset); if (after.length > 0) { // Cursor on a character - highlight it const firstChar = after[0]; const rest = after.slice(1); const cursorHighlight = `\x1b[7m${firstChar}\x1b[0m`; const cursorMark = emitCursorMarker ? CURSOR_MARKER : ""; lineContent = before + cursorMark + cursorHighlight + rest; } else { // Cursor at end - highlight space const cursorHighlight = "\x1b[7m \x1b[0m"; const cursorMark = emitCursorMarker ? CURSOR_MARKER : ""; lineContent = before + cursorMark + cursorHighlight; } } else { lineContent = wrappedLine; } // Add prefix to first result line if (result.length === 0) { result.push(prefix + lineContent); } else { result.push(lineContent); } segmentStart = lineEndPos; } // Account for newline character between logical lines if (lineIdx < inputLines.length - 1) { contentPos = lineEndPos + 1; // +1 for newline } } // If empty, show prompt with empty cursor if (result.length === 0) { const cursorMark = emitCursorMarker ? CURSOR_MARKER : ""; result.push(prefix + cursorMark + "\x1b[7m \x1b[0m"); } // If autocomplete is showing, append completion lines if (isAutocompleteShowing && autocompleteList) { const autocompleteLines = autocompleteList.render(width); result.push(...autocompleteLines); } // Ensure no line exceeds terminal width (safety truncation) for (let i = 0; i < result.length; i++) { if (this.visibleWidth(result[i]) > width) { result[i] = truncateToWidth(result[i], width); } } return result; } /** * Format: %d (basename), %D (full path), %cf% (context fill %), * %gb% (git branch), %gs% (git status), %td% (time HH:MM:SS), %% (literal) */ private getBoldPrefix(): [string, number] { let formatted = editorConfig.prefix; // Replace format specifiers const cwd = editorConfig.cwd || "."; const dirName = path.basename(cwd) || "."; formatted = formatted.replace(/%%/g, "%"); formatted = formatted.replace(/%d/g, dirName); formatted = formatted.replace(/%D/g, cwd); // Context fill percentage (%cf%) if (contextStats) { formatted = formatted.replace(/%cf%/g, `${contextStats.percent}%`); } else { formatted = formatted.replace(/%cf%/g, "0%"); } // Git branch (%gb%) if (gitInfo) { formatted = formatted.replace(/%gb%/g, gitInfo.branch); } else { formatted = formatted.replace(/%gb%/g, ""); } // Git status (%gs%) if (gitInfo) { let gs = ""; if (gitInfo.modified) gs += "*"; if (gitInfo.staged) gs += "+"; if (gitInfo.untracked) gs += "~"; formatted = formatted.replace(/%gs%/g, gs); } else { formatted = formatted.replace(/%gs%/g, ""); } // Time (%td%) const now = new Date(); const timeStr = now.toTimeString().slice(0, 8); // HH:MM:SS formatted = formatted.replace(/%td%/g, timeStr); // Calculate visible width (without ANSI codes) const visibleLen = this.visibleWidth(formatted); // Apply ANSI bold escape sequence const styled = `\x1b[1m${formatted}\x1b[22m`; return [styled, visibleLen]; } visibleWidth(str: string): number { return stripAnsi(str).length; } }