UNPKG

@owloops/claude-powerline

Version:

Beautiful vim-style powerline statusline for Claude Code with real-time usage tracking, git integration, and custom themes

713 lines (619 loc) 20.6 kB
import type { GridCell, AlignValue, TuiGridBreakpoint, TuiGridConfig, BoxChars, } from "./types"; import { visibleLength } from "../utils/terminal"; import { truncateAnsi, padRight, padLeft, padCenter } from "./primitives"; export const DIVIDER = "---"; export const EMPTY_CELL = "."; // Segments whose content is resolved after column widths are known (lateResolve). // Auto-width measurement must skip these to avoid locking columns to placeholder widths. export const LATE_RESOLVE_SEGMENTS = new Set([ "context", "context.bar", "block.bar", "weekly.bar", ]); function isDividerRow(row: GridCell[]): boolean { return row.length === 1 && row[0]!.segment === DIVIDER; } function parseFr(colDef: string): number { if (!colDef.endsWith("fr")) return 0; const fr = parseInt(colDef.replace("fr", ""), 10); return !isNaN(fr) && fr > 0 ? fr : 0; } function distributeExact( total: number, targets: number[], widths: number[], ): void { const base = Math.floor(total / targets.length); let extra = total - base * targets.length; for (const idx of targets) { widths[idx] = widths[idx]! + base + (extra > 0 ? 1 : 0); if (extra > 0) extra--; } } function spanCellWidth( colWidths: number[], startIdx: number, spanSize: number, sepWidth: number, ): number { let width = 0; for (let j = 0; j < spanSize; j++) { width += colWidths[startIdx + j] ?? 0; } if (spanSize > 1) { width += (spanSize - 1) * sepWidth; } return width; } export interface GridResult { lines: string[]; panelWidth: number; } // --- Breakpoint Selection --- export function selectBreakpoint( breakpoints: TuiGridBreakpoint[], panelWidth: number, ): TuiGridBreakpoint { let best: TuiGridBreakpoint | undefined; for (const bp of breakpoints) { if (panelWidth >= bp.minWidth) { if (!best || bp.minWidth > best.minWidth) { best = bp; } } } if (best) return best; // Fallback to smallest minWidth let smallest = breakpoints[0]!; for (let i = 1; i < breakpoints.length; i++) { if (breakpoints[i]!.minWidth < smallest.minWidth) { smallest = breakpoints[i]!; } } return smallest; } // --- Area Parsing --- export function parseAreas(areas: string[]): GridCell[][] { const matrix: GridCell[][] = []; for (const row of areas) { const trimmed = row.trim(); // Divider row if (trimmed === DIVIDER) { matrix.push([{ segment: DIVIDER, spanStart: true, spanSize: 1 }]); continue; } const cells = trimmed.split(/\s+/); const rowCells: GridCell[] = []; let i = 0; while (i < cells.length) { const name = cells[i]!; let spanSize = 1; // Count adjacent cells with the same name while (i + spanSize < cells.length && cells[i + spanSize] === name) { spanSize++; } // First cell of the span rowCells.push({ segment: name, spanStart: true, spanSize }); // Continuation cells for (let j = 1; j < spanSize; j++) { rowCells.push({ segment: name, spanStart: false, spanSize: 0 }); } i += spanSize; } matrix.push(rowCells); } return matrix; } // --- Matrix Culling --- export function cullMatrix( matrix: GridCell[][], resolvedData: Record<string, string>, ): GridCell[][] { // Phase 1: Replace cells whose segment has no data with "." const processed = matrix.map((row) => { if (isDividerRow(row)) return row; return row.map((cell) => { if (cell.segment === EMPTY_CELL || cell.segment === DIVIDER) return cell; const data = resolvedData[cell.segment]; if (!data) { return { segment: EMPTY_CELL, spanStart: true, spanSize: 1 }; } return cell; }); }); // Phase 2: Re-calculate spans after emptying cells // When a span-start cell was emptied, all its continuation cells are already individual "." cells. // But when continuation cells were emptied, the span-start needs fixing. const respanned = processed.map((row) => { if (isDividerRow(row)) return row; // Rebuild spans from scratch const cells = row.map((c) => c.segment); const rebuilt: GridCell[] = []; let i = 0; while (i < cells.length) { const name = cells[i]!; let spanSize = 1; while (i + spanSize < cells.length && cells[i + spanSize] === name) { spanSize++; } rebuilt.push({ segment: name, spanStart: true, spanSize }); for (let j = 1; j < spanSize; j++) { rebuilt.push({ segment: name, spanStart: false, spanSize: 0 }); } i += spanSize; } return rebuilt; }); // Phase 3: Remove rows that are entirely "." const nonEmpty = respanned.filter((row) => { if (isDividerRow(row)) return true; return row.some((cell) => cell.segment !== EMPTY_CELL); }); // Phase 4: Collapse adjacent dividers into one, remove leading/trailing dividers const cleaned: GridCell[][] = []; for (let i = 0; i < nonEmpty.length; i++) { const row = nonEmpty[i]!; if (!isDividerRow(row)) { cleaned.push(row); continue; } // Skip dividers at top if (cleaned.length === 0) continue; // Collapse adjacent dividers: skip if last pushed row is already a divider if (isDividerRow(cleaned[cleaned.length - 1]!)) continue; cleaned.push(row); } // Remove trailing divider if (cleaned.length > 0 && isDividerRow(cleaned[cleaned.length - 1]!)) { cleaned.pop(); } return cleaned; } // --- Column Width Distribution --- function measureAutoWidths( colCount: number, matrix: GridCell[][], resolvedData: Record<string, string>, lateResolveNames?: ReadonlySet<string>, ): number[] { const widths = Array.from<number>({ length: colCount }).fill(0); for (const row of matrix) { if (isDividerRow(row)) continue; for (let colIdx = 0; colIdx < row.length; colIdx++) { const cell = row[colIdx]!; if (!cell.spanStart || cell.spanSize !== 1) continue; if (cell.segment === EMPTY_CELL) continue; if (colIdx >= colCount) continue; if (lateResolveNames?.has(cell.segment)) continue; const content = resolvedData[cell.segment] || ""; const len = visibleLength(content); if (len > widths[colIdx]!) { widths[colIdx] = len; } } } return widths; } export function calculateColumnWidths( columns: string[], matrix: GridCell[][], resolvedData: Record<string, string>, contentWidth: number, separatorWidth: number, lateResolveNames?: ReadonlySet<string>, ): number[] { const colCount = columns.length; const autoWidths = measureAutoWidths( colCount, matrix, resolvedData, lateResolveNames, ); const widths = Array.from<number>({ length: colCount }).fill(0); // Phase 1: Apply auto widths for (let i = 0; i < colCount; i++) { if (columns[i] === "auto") { widths[i] = autoWidths[i]!; } } // Phase 2: Apply fixed widths for (let i = 0; i < colCount; i++) { const colDef = columns[i]!; if (colDef === "auto") continue; if (colDef.endsWith("fr")) continue; const fixed = parseInt(colDef, 10); if (!isNaN(fixed) && fixed > 0) { widths[i] = fixed; } } // Clamp auto/fixed widths to >= 1 BEFORE computing fr remaining, // so fr columns account for the clamped minimums in their budget. for (let i = 0; i < colCount; i++) { if (widths[i]! < 1 && !columns[i]!.endsWith("fr")) { widths[i] = 1; } } const totalSepWidth = Math.max(0, colCount - 1) * separatorWidth; const usedWidth = widths.reduce((sum, w) => sum + w, 0); const remaining = Math.max(0, contentWidth - usedWidth - totalSepWidth); let totalFr = 0; for (const colDef of columns) totalFr += parseFr(colDef); if (totalFr > 0) { const perFr = remaining / totalFr; const frCols: number[] = []; let allocatedFr = 0; for (let i = 0; i < colCount; i++) { const fr = parseFr(columns[i]!); if (fr > 0) { const w = Math.floor(perFr * fr); widths[i] = w; allocatedFr += w; frCols.push(i); } } let leftover = remaining - allocatedFr; for (let k = 0; leftover > 0 && k < frCols.length; k++) { widths[frCols[k]!]! += 1; leftover--; } } return widths; } export function solveFitContentLayout( columns: string[], matrix: GridCell[][], resolvedData: Record<string, string>, separatorWidth: number, horizontalPadding: number, lateResolveNames?: ReadonlySet<string>, ): { panelWidth: number; colWidths: number[] } { const colCount = columns.length; const autoWidths = measureAutoWidths( colCount, matrix, resolvedData, lateResolveNames, ); // Seed from intrinsic non-spanning content and fixed widths const widths = Array.from<number>({ length: colCount }); for (let i = 0; i < colCount; i++) { const colDef = columns[i]!; if (colDef !== "auto" && !colDef.endsWith("fr")) { const fixed = parseInt(colDef, 10); widths[i] = !isNaN(fixed) && fixed > 0 ? fixed : autoWidths[i]!; } else { widths[i] = autoWidths[i]!; } } // Expand columns to fit spanning cells for (const row of matrix) { if (isDividerRow(row)) continue; for (let i = 0; i < row.length; i++) { const cell = row[i]!; if (!cell.spanStart || cell.spanSize <= 1 || cell.segment === EMPTY_CELL) continue; const content = resolvedData[cell.segment] || ""; const contentLen = visibleLength(content); const sw = spanCellWidth(widths, i, cell.spanSize, separatorWidth); if (contentLen > sw) { const deficit = contentLen - sw; const frCols: number[] = []; for (let j = 0; j < cell.spanSize; j++) { if (parseFr(columns[i + j]!) > 0) frCols.push(i + j); } if (frCols.length > 0) { distributeExact(deficit, frCols, widths); } else { const allCols: number[] = []; for (let j = 0; j < cell.spanSize; j++) allCols.push(i + j); distributeExact(deficit, allCols, widths); } } } } // Clamp all widths to >= 1 for (let i = 0; i < colCount; i++) { if (widths[i]! < 1) widths[i] = 1; } let naturalWidth = 0; for (let i = 0; i < colCount; i++) { naturalWidth += widths[i]!; } const totalSepWidth = Math.max(0, colCount - 1) * separatorWidth; const extraWallPad = Math.max(0, 1 - horizontalPadding); const borders = 2 + extraWallPad * 2; // 2 box chars + extra wall padding const cellPadding = colCount * horizontalPadding * 2; return { panelWidth: naturalWidth + totalSepWidth + borders + cellPadding, colWidths: widths, }; } // --- Cell Rendering --- function alignContent(text: string, width: number, align: AlignValue): string { switch (align) { case "right": return padLeft(text, width); case "center": return padCenter(text, width); case "left": default: return padRight(text, width); } } export function renderGridRow( row: GridCell[], colWidths: number[], align: AlignValue[], resolvedData: Record<string, string>, separator: string, horizontalPadding = 0, padShrink?: number[], ): string { const parts: string[] = []; const sepWidth = visibleLength(separator); const hPad = horizontalPadding; for (let i = 0; i < row.length; i++) { const cell = row[i]!; if (!cell.spanStart) continue; const cellWidth = spanCellWidth(colWidths, i, cell.spanSize, sepWidth); // Compute per-cell padding from column shrink values const lastCol = i + cell.spanSize - 1; const leftShrink = align[i] === "right" ? (padShrink?.[i] ?? 0) : 0; const rightShrink = align[lastCol] === "left" ? (padShrink?.[lastCol] ?? 0) : 0; const leftPad = hPad - leftShrink; const rightPad = hPad - rightShrink; // Inner padding for spanning cells (accounts for shrink of internal columns) let innerPad = 0; for (let j = i; j < lastCol; j++) { const rShrink = align[j] === "left" ? (padShrink?.[j] ?? 0) : 0; const lShrink = align[j + 1] === "right" ? (padShrink?.[j + 1] ?? 0) : 0; innerPad += hPad - rShrink + (hPad - lShrink); } const contentWidth = cellWidth + innerPad; if (cell.segment === EMPTY_CELL) { parts.push(" ".repeat(contentWidth + leftPad + rightPad)); } else { const content = resolvedData[cell.segment] || ""; const truncated = truncateAnsi(content, contentWidth); const cellAlign = align[i] || "left"; const aligned = alignContent(truncated, contentWidth, cellAlign); const lp = leftPad > 0 ? " ".repeat(leftPad) : ""; const rp = rightPad > 0 ? " ".repeat(rightPad) : ""; parts.push(lp + aligned + rp); } } return parts.join(separator); } // --- Divider Rendering --- export function renderGridDivider( box: BoxChars, innerWidth: number, dividerChar?: string, ): string { const ch = dividerChar || box.horizontal; return box.teeLeft + ch.repeat(innerWidth) + box.teeRight; } // --- Main Grid Render --- export function renderGrid( gridConfig: TuiGridConfig, resolvedData: Record<string, string>, box: BoxChars, rawTerminalWidth: number, lateResolve?: (segment: string, cellWidth: number) => string | undefined, ): GridResult { const minWidth = gridConfig.minWidth ?? 32; const maxWidth = gridConfig.maxWidth ?? Infinity; const colSep = gridConfig.separator?.column ?? " "; const dividerChar = gridConfig.separator?.divider; const sepWidth = visibleLength(colSep); const fitContent = gridConfig.fitContent ?? false; const hPad = gridConfig.padding?.horizontal ?? 0; // Breakpoint selection always uses available width (terminal - reserve) const widthReserve = gridConfig.widthReserve ?? 45; const availableWidth = Math.min( maxWidth, Math.max(minWidth, rawTerminalWidth - widthReserve), ); const bp = selectBreakpoint(gridConfig.breakpoints, availableWidth); // Panel width for rendering let panelWidth: number; if (fitContent) { panelWidth = maxWidth !== Infinity ? Math.min(rawTerminalWidth, maxWidth) : rawTerminalWidth; } else { panelWidth = availableWidth; } // Parse areas const rawMatrix = parseAreas(bp.areas); // Cull empty cells/rows const matrix = cullMatrix(rawMatrix, resolvedData); if (matrix.length === 0) { return { lines: [], panelWidth }; } let colWidths: number[]; // Collect late-resolve segment names (including user-defined templates) const lateNames = new Set(LATE_RESOLVE_SEGMENTS); if (gridConfig.segments) { for (const key of Object.keys(gridConfig.segments)) { lateNames.add(key); } } if (fitContent) { const solved = solveFitContentLayout( bp.columns, matrix, resolvedData, sepWidth, hPad, lateNames, ); panelWidth = Math.min(maxWidth, Math.max(minWidth, solved.panelWidth)); colWidths = solved.colWidths; // Redistribute surplus (from minWidth or maxWidth clamping) into fr columns const surplus = panelWidth - solved.panelWidth; if (surplus > 0) { let totalFr = 0; for (const colDef of bp.columns) totalFr += parseFr(colDef); if (totalFr > 0) { const frCols: number[] = []; let allocated = 0; for (let i = 0; i < colWidths.length; i++) { const fr = parseFr(bp.columns[i]!); if (fr > 0) { const add = Math.floor((surplus * fr) / totalFr); colWidths[i]! += add; allocated += add; frCols.push(i); } } let leftover = surplus - allocated; for (let k = 0; leftover > 0 && k < frCols.length; k++) { colWidths[frCols[k]!]! += 1; leftover--; } } } } else { const innerW = panelWidth - 2; const ewp = Math.max(0, 1 - hPad); const contentW = innerW - ewp * 2 - bp.columns.length * hPad * 2; colWidths = calculateColumnWidths( bp.columns, matrix, resolvedData, contentW, sepWidth, lateNames, ); } const innerWidth = panelWidth - 2; // When hPad >= 1, cell padding replaces the base 1-space wall padding const wallPad = Math.max(1, hPad); const extraWallPad = wallPad - hPad; // 1 when hPad=0, 0 when hPad>=1 const wallPadStr = extraWallPad > 0 ? " ".repeat(extraWallPad) : ""; const contentWidth = innerWidth - extraWallPad * 2; // Alignment defaults const align: AlignValue[] = bp.align || bp.columns.map(() => "left" as AlignValue); // Adaptive padding: absorb alignment gaps into padding, redistribute savings to fr columns. // padShrink[col] = how much of hPad is absorbed by existing alignment gap on the aligned side. const padShrink = new Array<number>(bp.columns.length).fill(0); if (hPad > 0) { const maxContent = new Array<number>(bp.columns.length).fill(0); for (const row of matrix) { if (isDividerRow(row)) continue; for (let ci = 0; ci < row.length; ci++) { const cell = row[ci]!; if (!cell.spanStart || cell.spanSize !== 1) continue; if (cell.segment === EMPTY_CELL) continue; if (lateNames.has(cell.segment)) continue; const len = visibleLength(resolvedData[cell.segment] || ""); if (len > maxContent[ci]!) maxContent[ci] = len; } } let totalSavings = 0; for (let ci = 0; ci < bp.columns.length; ci++) { if (parseFr(bp.columns[ci]!) > 0) continue; if (maxContent[ci]! <= 0) continue; const gap = colWidths[ci]! - maxContent[ci]!; if (gap <= 0) continue; padShrink[ci] = Math.min(hPad, gap); totalSavings += padShrink[ci]!; } if (totalSavings > 0) { let totalFr = 0; for (const colDef of bp.columns) totalFr += parseFr(colDef); if (totalFr > 0) { const frCols: number[] = []; let allocated = 0; for (let ci = 0; ci < colWidths.length; ci++) { const fr = parseFr(bp.columns[ci]!); if (fr > 0) { const add = Math.floor((totalSavings * fr) / totalFr); colWidths[ci]! += add; allocated += add; frCols.push(ci); } } let leftover = totalSavings - allocated; for (let k = 0; leftover > 0 && k < frCols.length; k++) { colWidths[frCols[k]!]! += 1; leftover--; } } } } // Compute span inner padding accounting for per-column shrink function spanInnerPad(colIdx: number, spanSize: number): number { let pad = 0; for (let j = colIdx; j < colIdx + spanSize - 1; j++) { const rShrink = align[j] === "left" ? (padShrink[j] ?? 0) : 0; const lShrink = align[j + 1] === "right" ? (padShrink[j + 1] ?? 0) : 0; pad += hPad - rShrink + (hPad - lShrink); } return pad; } // Late resolve: re-resolve width-dependent segments now that cell widths are known if (lateResolve) { const seen = new Set<string>(); for (const row of matrix) { if (isDividerRow(row)) continue; for (let i = 0; i < row.length; i++) { const cell = row[i]!; if ( !cell.spanStart || cell.segment === EMPTY_CELL || cell.segment === DIVIDER ) continue; if (seen.has(cell.segment)) continue; seen.add(cell.segment); const cellWidth = spanCellWidth(colWidths, i, cell.spanSize, sepWidth); const innerPad = spanInnerPad(i, cell.spanSize); const content = lateResolve(cell.segment, cellWidth + innerPad); if (content !== undefined) { resolvedData[cell.segment] = content; } } } } // Post-lateResolve culling: segments that resolved to empty after lateResolve // can leave orphaned rows and dividers. Re-cull the matrix. const finalMatrix = cullMatrix(matrix, resolvedData); if (finalMatrix.length === 0) { return { lines: [], panelWidth }; } // Render rows const lines: string[] = []; for (const row of finalMatrix) { if (isDividerRow(row)) { lines.push(renderGridDivider(box, innerWidth, dividerChar)); } else { const rowStr = renderGridRow( row, colWidths, align, resolvedData, colSep, hPad, padShrink, ); const truncated = truncateAnsi(rowStr, contentWidth); const padded = padRight(truncated, contentWidth); lines.push( box.vertical + wallPadStr + padded + wallPadStr + box.vertical, ); } } return { lines, panelWidth }; }