From cc90ed37f89a306d41dc2dec70b32e5ed3f8b593 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 31 Jan 2026 13:36:54 +0100 Subject: [PATCH] refactor: extracted padding logic into reusable utility function with optimized buffer allocation - Extracted manual space padding logic into a reusable `padding()` utility function across all UI components and utilities. - Optimized padding operations by introducing a pre-allocated 512-space buffer in the `padding()` function to reduce repeated string allocations. - Updated all imports across 30 files to use the new `padding()` function from pi-tui instead of inline `' '.repeat()` calls. - Renamed local variables from `padding` to `pad`, `padSize`, `indent`, or `linePad` to avoid naming conflicts with the imported `padding()` function. - Exported the `padding()` utility function from the tui package index for public use. --- packages/coding-agent/CHANGELOG.md | 2 ++ packages/coding-agent/examples/hooks/snake.ts | 10 +++++----- .../coding-agent/src/modes/components/armin.ts | 14 +++++++------- .../components/extensions/extension-dashboard.ts | 13 +++++++++++-- .../components/extensions/extension-list.ts | 4 ++-- .../coding-agent/src/modes/components/footer.ts | 10 +++++----- .../src/modes/components/history-search.ts | 3 ++- .../src/modes/components/hook-selector.ts | 4 ++-- .../src/modes/components/session-selector.ts | 3 ++- .../components/status-line-segment-editor.ts | 6 +++--- .../src/modes/components/status-line.ts | 4 ++-- .../coding-agent/src/modes/components/welcome.ts | 6 +++--- .../src/modes/controllers/command-controller.ts | 4 ++-- packages/coding-agent/src/patch/normalize.ts | 4 +++- packages/coding-agent/src/tui/output-block.ts | 4 ++-- packages/coding-agent/src/tui/utils.ts | 4 ++-- packages/react-edit-benchmark/index.ts | 7 ++++--- packages/react-edit-benchmark/mutations.ts | 4 +++- packages/tui/CHANGELOG.md | 7 +++++++ packages/tui/src/components/box.ts | 6 +++--- packages/tui/src/components/editor.ts | 16 ++++++++-------- packages/tui/src/components/input.ts | 6 +++--- packages/tui/src/components/markdown.ts | 16 ++++++++-------- packages/tui/src/components/select-list.ts | 8 ++++---- packages/tui/src/components/settings-list.ts | 4 ++-- packages/tui/src/components/text.ts | 8 ++++---- packages/tui/src/components/truncated-text.ts | 8 ++++---- packages/tui/src/index.ts | 2 +- packages/tui/src/tui.ts | 11 ++--------- packages/tui/src/utils.ts | 15 +++++++++++++-- 30 files changed, 121 insertions(+), 92 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4e3b5b0c6..e82a3f9f5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,10 @@ # Changelog ## [Unreleased] + ### Changed +- Replaced manual space padding with `padding()` utility function from pi-tui across UI components for consistent whitespace handling - Improved rendering performance for Python cell output by implementing caching in the table and cell results renderers - Updated task tool documentation to clarify that subagents can access parent conversation context via a searchable file, reducing need to repeat information in context parameter - Updated plan mode prompt to guide model toward using `edit` tool for incremental plan updates instead of defaulting to `write` diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts index 282caa5c3..03b1bdcbb 100644 --- a/packages/coding-agent/examples/hooks/snake.ts +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -2,7 +2,7 @@ * Snake game hook - play snake with /snake command */ import type { HookAPI } from "@oh-my-pi/pi-coding-agent"; -import { matchesKey, visibleWidth } from "@oh-my-pi/pi-tui"; +import { matchesKey, padding, visibleWidth } from "@oh-my-pi/pi-tui"; const GAME_WIDTH = 40; const GAME_HEIGHT = 15; @@ -227,8 +227,8 @@ class SnakeComponent { // Helper to pad content inside box const boxLine = (content: string) => { const contentLen = visibleWidth(content); - const padding = Math.max(0, boxWidth - contentLen); - return dim(" │") + content + " ".repeat(padding) + dim("│"); + const pad = Math.max(0, boxWidth - contentLen); + return dim(" │") + content + padding(pad) + dim("│"); }; // Top border @@ -291,8 +291,8 @@ class SnakeComponent { private padLine(line: string, width: number): string { // Calculate visible length (strip ANSI codes) const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length; - const padding = Math.max(0, width - visibleLen); - return line + " ".repeat(padding); + const pad = Math.max(0, width - visibleLen); + return line + padding(pad); } dispose(): void { diff --git a/packages/coding-agent/src/modes/components/armin.ts b/packages/coding-agent/src/modes/components/armin.ts index b7852043a..bb2e412d8 100644 --- a/packages/coding-agent/src/modes/components/armin.ts +++ b/packages/coding-agent/src/modes/components/armin.ts @@ -1,7 +1,7 @@ /** * Armin says hi! A fun easter egg with animated XBM art. */ -import type { Component, TUI } from "@oh-my-pi/pi-tui"; +import { type Component, padding, type TUI } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; // XBM image: 31x36 pixels, LSB first, 1=background, 0=foreground @@ -87,20 +87,20 @@ export class ArminComponent implements Component { return this.cachedLines; } - const padding = 1; - const availableWidth = width - padding; + const indent = 1; + const availableWidth = width - indent; this.cachedLines = this.currentGrid.map(row => { // Clip row to available width before applying color const clipped = row.slice(0, availableWidth).join(""); - const padRight = Math.max(0, width - padding - clipped.length); - return ` ${theme.fg("accent", clipped)}${" ".repeat(padRight)}`; + const padRight = Math.max(0, width - indent - clipped.length); + return ` ${theme.fg("accent", clipped)}${padding(padRight)}`; }); // Add "ARMIN SAYS HI" at the end const message = "ARMIN SAYS HI"; - const msgPadRight = Math.max(0, width - padding - message.length); - this.cachedLines.push(` ${theme.fg("accent", message)}${" ".repeat(msgPadRight)}`); + const msgPadRight = Math.max(0, width - indent - message.length); + this.cachedLines.push(` ${theme.fg("accent", message)}${padding(msgPadRight)}`); this.cachedWidth = width; this.cachedVersion = this.gridVersion; diff --git a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts index c5df474a9..ca2bf6b32 100644 --- a/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts +++ b/packages/coding-agent/src/modes/components/extensions/extension-dashboard.ts @@ -11,7 +11,16 @@ * - Space: Toggle selected item (or master switch) * - Esc: Close dashboard (clears search first if active) */ -import { type Component, Container, matchesKey, Spacer, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { + type Component, + Container, + matchesKey, + padding, + Spacer, + Text, + truncateToWidth, + visibleWidth, +} from "@oh-my-pi/pi-tui"; import type { SettingsManager } from "../../../config/settings-manager"; import { DynamicBorder } from "../../../modes/components/dynamic-border"; import { theme } from "../../../modes/theme/theme"; @@ -296,7 +305,7 @@ class TwoColumnBody implements Component { for (let i = 0; i < numLines; i++) { const left = truncateToWidth(leftLines[i] ?? "", leftWidth); - const leftPadded = left + " ".repeat(Math.max(0, leftWidth - visibleWidth(left))); + const leftPadded = left + padding(Math.max(0, leftWidth - visibleWidth(left))); const right = truncateToWidth(rightLines[i] ?? "", rightWidth); combined.push(leftPadded + separator + right); } diff --git a/packages/coding-agent/src/modes/components/extensions/extension-list.ts b/packages/coding-agent/src/modes/components/extensions/extension-list.ts index cd4364977..8c87df068 100644 --- a/packages/coding-agent/src/modes/components/extensions/extension-list.ts +++ b/packages/coding-agent/src/modes/components/extensions/extension-list.ts @@ -5,7 +5,7 @@ * that toggles the entire provider. All items below are dimmed when the * master switch is off. */ -import { type Component, matchesKey, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { type Component, matchesKey, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { isProviderEnabled } from "../../../discovery"; import { theme } from "../../../modes/theme/theme"; import { applyFilter } from "./state-manager"; @@ -272,7 +272,7 @@ export class ExtensionList implements Component { if (width >= targetWidth) { return truncateToWidth(text, targetWidth); } - return text + " ".repeat(targetWidth - width); + return text + padding(targetWidth - width); } private rebuildList(): void { diff --git a/packages/coding-agent/src/modes/components/footer.ts b/packages/coding-agent/src/modes/components/footer.ts index d958c4ae3..eb78bd7a1 100644 --- a/packages/coding-agent/src/modes/components/footer.ts +++ b/packages/coding-agent/src/modes/components/footer.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { isEnoent } from "@oh-my-pi/pi-utils"; import { theme } from "../../modes/theme/theme"; import type { AgentSession } from "../../session/agent-session"; @@ -283,8 +283,8 @@ export class FooterComponent implements Component { let statsLine: string; if (totalNeeded <= width) { // Both fit - add padding to right-align model - const padding = " ".repeat(width - statsLeftWidth - rightSideWidth); - statsLine = statsLeft + padding + rightSide; + const pad = padding(width - statsLeftWidth - rightSideWidth); + statsLine = statsLeft + pad + rightSide; } else { // Need to truncate right side const availableForRight = width - statsLeftWidth - minPadding; @@ -293,8 +293,8 @@ export class FooterComponent implements Component { 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; + const pad = padding(width - statsLeftWidth - truncatedPlain.length); + statsLine = statsLeft + pad + truncatedPlain; } else { // Not enough space for right side at all statsLine = statsLeft; diff --git a/packages/coding-agent/src/modes/components/history-search.ts b/packages/coding-agent/src/modes/components/history-search.ts index 2c18778b5..97934dc7c 100644 --- a/packages/coding-agent/src/modes/components/history-search.ts +++ b/packages/coding-agent/src/modes/components/history-search.ts @@ -3,6 +3,7 @@ import { Container, Input, matchesKey, + padding, Spacer, Text, truncateToWidth, @@ -50,7 +51,7 @@ class HistoryResultsList implements Component { const cursorSymbol = `${theme.nav.cursor} `; const cursorWidth = visibleWidth(cursorSymbol); - const cursor = isSelected ? theme.fg("accent", cursorSymbol) : " ".repeat(cursorWidth); + const cursor = isSelected ? theme.fg("accent", cursorSymbol) : padding(cursorWidth); const maxWidth = width - cursorWidth; const normalized = entry.prompt.replace(/\s+/g, " ").trim(); diff --git a/packages/coding-agent/src/modes/components/hook-selector.ts b/packages/coding-agent/src/modes/components/hook-selector.ts index 8c040a02f..7c42fd71b 100644 --- a/packages/coding-agent/src/modes/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/components/hook-selector.ts @@ -2,7 +2,7 @@ * Generic selector component for hooks. * Displays a list of string options with keyboard navigation. */ -import { Container, matchesKey, Spacer, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui"; +import { Container, matchesKey, padding, Spacer, Text, type TUI, visibleWidth } from "@oh-my-pi/pi-tui"; import { theme } from "../../modes/theme/theme"; import { CountdownTimer } from "./countdown-timer"; import { DynamicBorder } from "./dynamic-border"; @@ -29,7 +29,7 @@ class OutlinedList extends Container { const innerWidth = Math.max(1, width - 2); const content = this.lines.map(line => { const pad = Math.max(0, innerWidth - visibleWidth(line)); - return `${borderColor(theme.boxSharp.vertical)}${line}${" ".repeat(pad)}${borderColor(theme.boxSharp.vertical)}`; + return `${borderColor(theme.boxSharp.vertical)}${line}${padding(pad)}${borderColor(theme.boxSharp.vertical)}`; }); return [horizontal, ...content, horizontal]; } diff --git a/packages/coding-agent/src/modes/components/session-selector.ts b/packages/coding-agent/src/modes/components/session-selector.ts index 527961a6f..09d988885 100644 --- a/packages/coding-agent/src/modes/components/session-selector.ts +++ b/packages/coding-agent/src/modes/components/session-selector.ts @@ -3,6 +3,7 @@ import { Container, Input, matchesKey, + padding, Spacer, Text, truncateToWidth, @@ -122,7 +123,7 @@ class SessionList implements Component { // First line: cursor + title (or first message if no title) const cursorSymbol = `${theme.nav.cursor} `; const cursorWidth = visibleWidth(cursorSymbol); - const cursor = isSelected ? theme.fg("accent", cursorSymbol) : " ".repeat(cursorWidth); + const cursor = isSelected ? theme.fg("accent", cursorSymbol) : padding(cursorWidth); const maxWidth = width - cursorWidth; // Account for cursor width if (session.title) { diff --git a/packages/coding-agent/src/modes/components/status-line-segment-editor.ts b/packages/coding-agent/src/modes/components/status-line-segment-editor.ts index 7394e8402..7ea615c32 100644 --- a/packages/coding-agent/src/modes/components/status-line-segment-editor.ts +++ b/packages/coding-agent/src/modes/components/status-line-segment-editor.ts @@ -8,7 +8,7 @@ * - Shift+J/K: Reorder segment within column * - Live preview shown in the actual status line above */ -import { Container, matchesKey } from "@oh-my-pi/pi-tui"; +import { Container, matchesKey, padding } from "@oh-my-pi/pi-tui"; import type { StatusLineSegmentId } from "../../config/settings-manager"; import { theme } from "../../modes/theme/theme"; import { ALL_SEGMENT_IDS } from "./status-line/segments"; @@ -351,7 +351,7 @@ export class StatusLineSegmentEditorComponent extends Container { } // Pad to column width (accounting for ANSI codes) - const padding = colWidth - label.length - 1; - return text + " ".repeat(Math.max(0, padding)); + const padSize = colWidth - label.length - 1; + return text + padding(Math.max(0, padSize)); } } diff --git a/packages/coding-agent/src/modes/components/status-line.ts b/packages/coding-agent/src/modes/components/status-line.ts index 9cc580d6b..9b8648e0d 100644 --- a/packages/coding-agent/src/modes/components/status-line.ts +++ b/packages/coding-agent/src/modes/components/status-line.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; -import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { $ } from "bun"; import type { StatusLineSegmentOptions, StatusLineSettings } from "../../config/settings-manager"; import { theme } from "../../modes/theme/theme"; @@ -378,7 +378,7 @@ export class StatusLineComponent implements Component { leftWidth = groupWidth(left, leftCapWidth, leftSepWidth); rightWidth = groupWidth(right, rightCapWidth, rightSepWidth); const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth); - return leftGroup + " ".repeat(gapWidth) + rightGroup; + return leftGroup + padding(gapWidth) + rightGroup; } getTopBorder(width: number): { content: string; width: number } { diff --git a/packages/coding-agent/src/modes/components/welcome.ts b/packages/coding-agent/src/modes/components/welcome.ts index b783fc930..3aaaa0492 100644 --- a/packages/coding-agent/src/modes/components/welcome.ts +++ b/packages/coding-agent/src/modes/components/welcome.ts @@ -1,4 +1,4 @@ -import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { APP_NAME } from "../../config"; import { theme } from "../../modes/theme/theme"; @@ -169,7 +169,7 @@ export class WelcomeComponent implements Component { } const leftPad = Math.floor((width - visLen) / 2); const rightPad = width - visLen - leftPad; - return " ".repeat(leftPad) + text + " ".repeat(rightPad); + return padding(leftPad) + text + padding(rightPad); } /** Apply magenta→cyan gradient to a string */ @@ -224,6 +224,6 @@ export class WelcomeComponent implements Component { } return `${truncated}${ellipsis}`; } - return str + " ".repeat(width - visLen); + return str + padding(width - visLen); } } diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index d0e274e18..d7ef0ef4e 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai"; -import { Loader, Markdown, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui"; +import { Loader, Markdown, padding, Spacer, Text, visibleWidth } from "@oh-my-pi/pi-tui"; import { $ } from "bun"; import { nanoid } from "nanoid"; import { loadCustomShare } from "../../export/custom-share"; @@ -773,7 +773,7 @@ function formatAccountHeader(limit: UsageLimit, report: UsageReport, index: numb function padColumn(text: string, width: number): string { const visible = visibleWidth(text); if (visible >= width) return text; - return `${text}${" ".repeat(width - visible)}`; + return `${text}${padding(width - visible)}`; } function resolveAggregateStatus(limits: UsageLimit[]): UsageLimit["status"] { diff --git a/packages/coding-agent/src/patch/normalize.ts b/packages/coding-agent/src/patch/normalize.ts index 386bca218..01dfdd3b9 100644 --- a/packages/coding-agent/src/patch/normalize.ts +++ b/packages/coding-agent/src/patch/normalize.ts @@ -4,6 +4,8 @@ * Handles line endings, BOM, whitespace, and Unicode normalization. */ +import { padding } from "@oh-my-pi/pi-tui"; + // ═══════════════════════════════════════════════════════════════════════════ // Line Ending Utilities // ═══════════════════════════════════════════════════════════════════════════ @@ -194,7 +196,7 @@ export function convertLeadingTabsToSpaces(text: string, spacesPerTab: number): if (trimmed.length === 0) return line; const leading = getLeadingWhitespace(line); if (!leading.includes("\t") || leading.includes(" ")) return line; - const converted = " ".repeat(leading.length * spacesPerTab); + const converted = padding(leading.length * spacesPerTab); return converted + trimmed; }) .join("\n"); diff --git a/packages/coding-agent/src/tui/output-block.ts b/packages/coding-agent/src/tui/output-block.ts index 83a6ae318..6195b9659 100644 --- a/packages/coding-agent/src/tui/output-block.ts +++ b/packages/coding-agent/src/tui/output-block.ts @@ -1,7 +1,7 @@ /** * Bordered output container with optional header and sections. */ -import { visibleWidth } from "@oh-my-pi/pi-tui"; +import { padding, visibleWidth } from "@oh-my-pi/pi-tui"; import type { Theme } from "../modes/theme/theme"; import type { State } from "./types"; import { getStateBgColor, padToWidth, truncateToWidth } from "./utils"; @@ -70,7 +70,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st const allLines = section.lines.flatMap(l => l.split("\n")); for (const line of allLines) { const text = truncateToWidth(line, contentWidth, theme.format.ellipsis); - const innerPadding = " ".repeat(Math.max(0, contentWidth - visibleWidth(text))); + const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(text))); const fullLine = `${contentPrefix}${text}${innerPadding}${contentSuffix}`; lines.push(padToWidth(fullLine, lineWidth, bgFn)); } diff --git a/packages/coding-agent/src/tui/utils.ts b/packages/coding-agent/src/tui/utils.ts index b467429e1..a723a0f3d 100644 --- a/packages/coding-agent/src/tui/utils.ts +++ b/packages/coding-agent/src/tui/utils.ts @@ -1,7 +1,7 @@ /** * Shared helpers for tool-rendered UI components. */ -import { truncateToWidth as truncateToWidthBase, visibleWidth } from "@oh-my-pi/pi-tui"; +import { padding, truncateToWidth as truncateToWidthBase, visibleWidth } from "@oh-my-pi/pi-tui"; import type { Theme, ThemeBg } from "../modes/theme/theme"; import type { IconType, State } from "./types"; @@ -24,7 +24,7 @@ export function truncateToWidth(text: string, width: number, ellipsis: string): export function padToWidth(text: string, width: number, bgFn?: (s: string) => string): string { if (width <= 0) return bgFn ? bgFn(text) : text; const paddingNeeded = Math.max(0, width - visibleWidth(text)); - const padded = paddingNeeded > 0 ? text + " ".repeat(paddingNeeded) : text; + const padded = paddingNeeded > 0 ? text + padding(paddingNeeded) : text; return bgFn ? bgFn(padded) : padded; } diff --git a/packages/react-edit-benchmark/index.ts b/packages/react-edit-benchmark/index.ts index 1bbd7579c..a6414b1e3 100644 --- a/packages/react-edit-benchmark/index.ts +++ b/packages/react-edit-benchmark/index.ts @@ -12,6 +12,7 @@ import * as fs from "node:fs"; import { join } from "node:path"; import { parseArgs } from "node:util"; import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core"; +import { padding } from "@oh-my-pi/pi-tui"; import { TempDir } from "@oh-my-pi/pi-utils"; import { generateJsonReport, generateReport } from "./report"; import { type BenchmarkConfig, type ProgressEvent, runBenchmark } from "./runner"; @@ -460,8 +461,8 @@ class LiveProgress { } private writeLine(line: string): void { - const padding = this.lastLineLength > line.length ? " ".repeat(this.lastLineLength - line.length) : ""; - process.stdout.write(`\r${line}${padding}`); + const pad = this.lastLineLength > line.length ? padding(this.lastLineLength - line.length) : ""; + process.stdout.write(`\r${line}${pad}`); this.lastLineLength = line.length; } @@ -470,7 +471,7 @@ class LiveProgress { return; } if (this.lastLineLength > 0) { - process.stdout.write("\r" + " ".repeat(this.lastLineLength) + "\r"); + process.stdout.write("\r" + padding(this.lastLineLength) + "\r"); this.lastLineLength = 0; } } diff --git a/packages/react-edit-benchmark/mutations.ts b/packages/react-edit-benchmark/mutations.ts index 7f7f514ab..cb0d94556 100644 --- a/packages/react-edit-benchmark/mutations.ts +++ b/packages/react-edit-benchmark/mutations.ts @@ -1,3 +1,5 @@ +import { padding } from "@oh-my-pi/pi-tui"; + /** * Code mutations for edit benchmark generation. * @@ -84,7 +86,7 @@ function applyCandidate(lines: string[], candidate: Candidate): MutationInfo { function stripStrings(line: string): string { const pattern = /(?['"])(?(?:\\.|[^\\\n])*?)\k/g; - return line.replace(pattern, (match) => " ".repeat(match.length)); + return line.replace(pattern, (match) => padding(match.length)); } function mutateIdentifier(identifier: string): string | null { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 393109245..fff3a55db 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] +### Added + +- Exported `padding` utility function for creating space-padded strings efficiently + +### Changed + +- Optimized padding operations across all components to use pre-allocated space buffer for better performance ## [9.2.2] - 2026-01-31 diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index 5dfcdfbc2..efc0abb69 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -1,5 +1,5 @@ import type { Component } from "../tui"; -import { applyBackgroundToLine, visibleWidth } from "../utils"; +import { applyBackgroundToLine, padding, visibleWidth } from "../utils"; type Cache = { key: string[]; @@ -77,7 +77,7 @@ export class Box implements Component { } const contentWidth = Math.max(1, width - this.paddingX * 2); - const leftPad = " ".repeat(this.paddingX); + const leftPad = padding(this.paddingX); // Render all children const childLines: string[] = []; @@ -127,7 +127,7 @@ export class Box implements Component { private applyBg(line: string, width: number): string { const visLen = visibleWidth(line); const padNeeded = Math.max(0, width - visLen); - const padded = line + " ".repeat(padNeeded); + const padded = line + padding(padNeeded); if (this.bgFn) { return applyBackgroundToLine(padded, width, this.bgFn); diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 1c0194378..fb3a151fe 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -2,7 +2,7 @@ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../auto import { matchesKey } from "../keys"; import type { SymbolTheme } from "../symbols"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui"; -import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils"; +import { getSegmenter, isPunctuationChar, isWhitespaceChar, padding, truncateToWidth, visibleWidth } from "../utils"; import { SelectList, type SelectListTheme } from "./select-list"; const segmenter = getSegmenter(); @@ -491,7 +491,7 @@ export class Editor implements Component, Focusable { const borderWidth = paddingX + 1; const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`); const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`); - const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${" ".repeat(Math.max(0, paddingX - 1))}`); + const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`); const horizontal = this.borderColor(box.horizontal); // Layout the text @@ -568,19 +568,19 @@ export class Editor implements Component, Focusable { // All lines have consistent borders based on padding const isLastLine = layoutLine === visibleLayoutLines[visibleLayoutLines.length - 1]; - const padding = " ".repeat(Math.max(0, lineContentWidth - displayWidth)); + const linePad = padding(Math.max(0, lineContentWidth - displayWidth)); const rightPaddingWidth = Math.max(0, paddingX - (cursorInPadding ? 1 : 0)); if (isLastLine) { const bottomRightPadding = Math.max(0, paddingX - 1 - (cursorInPadding ? 1 : 0)); const bottomRightAdjusted = this.borderColor( - `${" ".repeat(bottomRightPadding)}${box.horizontal}${box.bottomRight}`, + `${padding(bottomRightPadding)}${box.horizontal}${box.bottomRight}`, ); - result.push(`${bottomLeft}${displayText}${padding}${bottomRightAdjusted}`); + result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`); } else { - const leftBorder = this.borderColor(`${box.vertical}${" ".repeat(paddingX)}`); - const rightBorder = this.borderColor(`${" ".repeat(rightPaddingWidth)}${box.vertical}`); - result.push(leftBorder + displayText + padding + rightBorder); + const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`); + const rightBorder = this.borderColor(`${padding(rightPaddingWidth)}${box.vertical}`); + result.push(leftBorder + displayText + linePad + rightBorder); } } diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 3f04cb849..52159b9b8 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,6 +1,6 @@ import { getEditorKeybindings } from "../keybindings"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui"; -import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils"; +import { getSegmenter, isPunctuationChar, isWhitespaceChar, padding, visibleWidth } from "../utils"; const segmenter = getSegmenter(); @@ -338,8 +338,8 @@ export class Input implements Component, Focusable { // Calculate visual width const visualLength = visibleWidth(textWithCursor); - const padding = " ".repeat(Math.max(0, availableWidth - visualLength)); - const line = prompt + textWithCursor + padding; + const pad = padding(Math.max(0, availableWidth - visualLength)); + const line = prompt + textWithCursor + pad; return [line]; } diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 3dc88ad4d..4cf9d478e 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -3,7 +3,7 @@ import type { MermaidImage } from "../mermaid"; import type { SymbolTheme } from "../symbols"; import { encodeITerm2, encodeKitty, getCellDimensions, ImageProtocol, TERMINAL_INFO } from "../terminal-image"; import type { Component } from "../tui"; -import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils"; +import { applyBackgroundToLine, padding, visibleWidth, wrapTextWithAnsi } from "../utils"; /** * Default text styling for markdown content. @@ -151,8 +151,8 @@ export class Markdown implements Component { } // Add margins and background to each wrapped line - const leftMargin = " ".repeat(this.paddingX); - const rightMargin = " ".repeat(this.paddingX); + const leftMargin = padding(this.paddingX); + const rightMargin = padding(this.paddingX); const bgFn = this.defaultTextStyle?.bgColor; const contentLines: string[] = []; @@ -174,7 +174,7 @@ export class Markdown implements Component { } // Add top/bottom padding (empty lines) - const emptyLine = " ".repeat(width); + const emptyLine = padding(width); const emptyLines: string[] = []; for (let i = 0; i < this.paddingY; i++) { const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine; @@ -326,7 +326,7 @@ export class Markdown implements Component { } } - const codeIndent = " ".repeat(this.codeBlockIndent); + const codeIndent = padding(this.codeBlockIndent); lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); if (this.theme.highlightCode) { const highlightedLines = this.theme.highlightCode(token.text, token.lang); @@ -577,7 +577,7 @@ export class Markdown implements Component { lines.push(text); } else if (token.type === "code") { // Code block in list item - const codeIndent = " ".repeat(this.codeBlockIndent); + const codeIndent = padding(this.codeBlockIndent); lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); if (this.theme.highlightCode) { const highlightedLines = this.theme.highlightCode(token.text, token.lang); @@ -763,7 +763,7 @@ export class Markdown implements Component { for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) { const rowParts = headerCellLines.map((cellLines, colIdx) => { const text = cellLines[lineIdx] || ""; - const padded = text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text))); + const padded = text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text))); return this.theme.bold(padded); }); lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`); @@ -786,7 +786,7 @@ export class Markdown implements Component { for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) { const rowParts = rowCellLines.map((cellLines, colIdx) => { const text = cellLines[lineIdx] || ""; - return text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text))); + return text + padding(Math.max(0, columnWidths[colIdx] - visibleWidth(text))); }); lines.push(`${v} ${rowParts.join(` ${v} `)} ${v}`); } diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index a848bff4f..bc865c538 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -1,7 +1,7 @@ import { matchesKey } from "../keys"; import type { SymbolTheme } from "../symbols"; import type { Component } from "../tui"; -import { truncateToWidth, visibleWidth } from "../utils"; +import { padding, truncateToWidth, visibleWidth } from "../utils"; export interface SelectItem { value: string; @@ -84,7 +84,7 @@ export class SelectList implements Component { // Calculate how much space we have for value + description const maxValueWidth = Math.min(30, width - prefixWidth - 4); const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); - const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); + const spacing = padding(Math.max(1, 32 - truncatedValue.length)); // Calculate remaining space for description using visible widths const descriptionStart = prefixWidth + truncatedValue.length + spacing.length; @@ -106,13 +106,13 @@ export class SelectList implements Component { } } else { const displayValue = item.label || item.value; - const prefix = " ".repeat(visibleWidth(`${this.theme.symbols.cursor} `)); + const prefix = padding(visibleWidth(this.theme.symbols.cursor) + 1); if (item.description && width > 40) { // Calculate how much space we have for value + description const maxValueWidth = Math.min(30, width - prefix.length - 4); const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); - const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); + const spacing = padding(Math.max(1, 32 - truncatedValue.length)); // Calculate remaining space for description const descriptionStart = prefix.length + truncatedValue.length + spacing.length; diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 25dcd3c19..5b768743c 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -1,6 +1,6 @@ import { matchesKey } from "../keys"; import type { Component } from "../tui"; -import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils"; +import { padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils"; export interface SettingItem { /** Unique identifier for this setting */ @@ -100,7 +100,7 @@ export class SettingsList implements Component { const prefixWidth = visibleWidth(prefix); // Pad label to align values - const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label))); + const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label))); const labelText = this.theme.label(labelPadded, isSelected); // Calculate space for value diff --git a/packages/tui/src/components/text.ts b/packages/tui/src/components/text.ts index baa1d9bb1..269bd54d8 100644 --- a/packages/tui/src/components/text.ts +++ b/packages/tui/src/components/text.ts @@ -1,5 +1,5 @@ import type { Component } from "../tui"; -import { applyBackgroundToLine, wrapTextWithAnsi } from "../utils"; +import { applyBackgroundToLine, padding, wrapTextWithAnsi } from "../utils"; /** * Text component - displays multi-line text with word wrapping @@ -71,8 +71,8 @@ export class Text implements Component { const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth); // Add margins and background to each line - const leftMargin = " ".repeat(this.paddingX); - const rightMargin = " ".repeat(this.paddingX); + const leftMargin = padding(this.paddingX); + const rightMargin = padding(this.paddingX); const contentLines: string[] = []; for (const line of wrappedLines) { @@ -89,7 +89,7 @@ export class Text implements Component { } // Add top/bottom padding (empty lines) - const emptyLine = " ".repeat(width); + const emptyLine = padding(width); const emptyLines: string[] = []; for (let i = 0; i < this.paddingY; i++) { const line = this.customBgFn ? applyBackgroundToLine(emptyLine, width, this.customBgFn) : emptyLine; diff --git a/packages/tui/src/components/truncated-text.ts b/packages/tui/src/components/truncated-text.ts index 861306e9f..f2801b495 100644 --- a/packages/tui/src/components/truncated-text.ts +++ b/packages/tui/src/components/truncated-text.ts @@ -1,5 +1,5 @@ import type { Component } from "../tui"; -import { truncateToWidth } from "../utils"; +import { padding, truncateToWidth } from "../utils"; /** * Text component that truncates to fit viewport width @@ -23,7 +23,7 @@ export class TruncatedText implements Component { const result: string[] = []; // Empty line padded to width - const emptyLine = " ".repeat(width); + const emptyLine = padding(width); // Add vertical padding above for (let i = 0; i < this.paddingY; i++) { @@ -44,8 +44,8 @@ export class TruncatedText implements Component { const displayText = truncateToWidth(singleLineText, availableWidth); // Add horizontal padding - const leftPadding = " ".repeat(this.paddingX); - const rightPadding = " ".repeat(this.paddingX); + const leftPadding = padding(this.paddingX); + const rightPadding = padding(this.paddingX); const lineWithPadding = leftPadding + displayText + rightPadding; // Don't pad to full width - avoids trailing spaces when copying diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 55a84fcc2..b81a63d03 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -85,4 +85,4 @@ export { } from "./terminal-image"; export { type Component, Container, type OverlayHandle, type SizeValue, TUI } from "./tui"; // Utilities -export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils"; +export { padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils"; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 8ca5bcfdc..0347d0b7c 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -7,7 +7,7 @@ import * as path from "node:path"; import { isKeyRelease, matchesKey } from "./keys"; import type { Terminal } from "./terminal"; import { setCellDimensions, TERMINAL_INFO } from "./terminal-image"; -import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils"; +import { extractSegments, padding, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils"; /** * Component interface - all components must implement this @@ -861,14 +861,7 @@ export class TUI extends Container { // Compose result const r = TUI.SEGMENT_RESET; const result = - base.before + - " ".repeat(beforePad) + - r + - overlay.text + - " ".repeat(overlayPad) + - r + - base.after + - " ".repeat(afterPad); + base.before + padding(beforePad) + r + overlay.text + padding(overlayPad) + r + base.after + padding(afterPad); // CRITICAL: Always verify and truncate to terminal width. // This is the final safeguard against width overflow which would crash the TUI. diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index bbf68a8a1..28f5408d9 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -5,6 +5,18 @@ import { visibleWidth as nativeVisibleWidth, } from "@oh-my-pi/pi-natives"; +// Pre-allocated space buffer for padding +const SPACE_BUFFER = " ".repeat(512); + +/** + * Returns a string of n spaces. Uses a pre-allocated buffer for efficiency. + */ +export function padding(n: number): string { + if (n <= 0) return ""; + if (n <= 512) return SPACE_BUFFER.slice(0, n); + return " ".repeat(n); +} + // Grapheme segmenter (shared instance) const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); @@ -155,10 +167,9 @@ export function applyBackgroundToLine(line: string, width: number, bgFn: (text: // Calculate padding needed const visibleLen = visibleWidth(line); const paddingNeeded = Math.max(0, width - visibleLen); - const padding = " ".repeat(paddingNeeded); // Apply background to content + padding - const withPadding = line + padding; + const withPadding = line + padding(paddingNeeded); return bgFn(withPadding); }