diff --git a/packages/snapcompact/src/prompts/snapcompact-summary.md b/packages/snapcompact/src/prompts/snapcompact-summary.md index 397786c16..286ee0c94 100644 --- a/packages/snapcompact/src/prompts/snapcompact-summary.md +++ b/packages/snapcompact/src/prompts/snapcompact-summary.md @@ -15,10 +15,10 @@ Reading HISTORY: {{/if}}{{/if}}{{#if includedPreviousSummary}}- HISTORY opens with a condensed digest of still-older context that predates the archived turns. {{/if}}{{#if truncatedChars}}- About {{truncatedChars}} characters of older middle history were dropped to fit the archive budget. {{/if}}- When an exact earlier detail matters and a section reads unclearly, re-derive it from the workspace (re-read files, re-run commands) rather than guessing. -{{#if files}} -FILES + +{{#if files}}FILES =================== {{files}} -{{/if}} -HISTORY + +{{/if}}HISTORY =================== diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 317ce747b..7625b97fe 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `Box` now accepts an optional `border` (box-drawing glyphs + colorizer) and exposes `setBorder()`, drawing a colored outline around its padded/background content. The border is automatically dropped at widths too narrow to frame so a bordered box never overflows its given width. + ## [16.0.11] - 2026-06-19 ### Breaking Changes diff --git a/packages/tui/src/components/box.ts b/packages/tui/src/components/box.ts index 2959147a4..1ba1c7212 100644 --- a/packages/tui/src/components/box.ts +++ b/packages/tui/src/components/box.ts @@ -4,10 +4,24 @@ import { applyBackgroundToLine, getPaddingX, padding, visibleWidth } from "../ut type Cache = { width: number; bgSample: string | undefined; + borderSample: string | undefined; childLines: (readonly string[])[]; result: string[]; }; +/** Box-drawing glyphs plus an optional colorizer for an outline drawn around a {@link Box}. */ +export interface BoxBorder { + chars: { + topLeft: string; + topRight: string; + bottomLeft: string; + bottomRight: string; + horizontal: string; + vertical: string; + }; + color?: (text: string) => string; +} + /** * Box component - a container that applies padding and background to all children */ @@ -16,6 +30,7 @@ export class Box implements Component { #paddingX: number; #paddingY: number; #bgFn?: (text: string) => string; + #border?: BoxBorder; #ignoreTight = false; @@ -28,10 +43,11 @@ export class Box implements Component { // Cache for rendered output #cached?: Cache; - constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) { + constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string, border?: BoxBorder) { this.#paddingX = paddingX; this.#paddingY = paddingY; this.#bgFn = bgFn; + this.#border = border; } addChild(component: Component): void { @@ -72,6 +88,11 @@ export class Box implements Component { // Don't invalidate here - we'll detect bgFn changes by sampling output } + setBorder(border?: BoxBorder): void { + this.#border = border; + this.#invalidateCache(); + } + #invalidateCache(): void { this.#cached = undefined; } @@ -87,10 +108,18 @@ export class Box implements Component { const children = this.children; const count = children.length; const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX); - const contentWidth = Math.max(1, width - paddingX * 2); - // bgFn output can change without the function reference changing (theme - // mutation); sample it so a silent palette swap still misses the cache. + // A border eats one column on each side; skip it unless the interior can still + // hold the horizontal padding plus at least one content column, so a bordered + // Box never overflows the width it was given. + const border = this.#border && width - 2 >= paddingX * 2 + 1 ? this.#border : undefined; + const innerWidth = border ? width - 2 : width; + const contentWidth = Math.max(1, innerWidth - paddingX * 2); + // bgFn / border output can change without the function reference changing + // (theme mutation); sample both so a silent palette swap still misses the cache. const bgSample = this.#bgFn ? this.#bgFn("test") : undefined; + const borderSample = border + ? `${border.color ? border.color("|") : "|"}${border.chars.topLeft}${border.chars.vertical}` + : undefined; // Render every child every frame (renders may carry side effects); the // memo only skips re-deriving the padded/background rows. Per the @@ -101,6 +130,7 @@ export class Box implements Component { cached !== undefined && cached.width === width && cached.bgSample === bgSample && + cached.borderSample === borderSample && cached.childLines.length === count; const childLines: (readonly string[])[] = new Array(count); let contentRows = 0; @@ -115,23 +145,39 @@ export class Box implements Component { const result: string[] = []; if (contentRows > 0) { const leftPad = padding(paddingX); + const interior: string[] = []; // Top padding for (let i = 0; i < this.#paddingY; i++) { - result.push(this.#applyBg("", width)); + interior.push(this.#applyBg("", innerWidth)); } // Content for (const lines of childLines) { for (const line of lines) { - result.push(this.#applyBg(leftPad + line, width)); + interior.push(this.#applyBg(leftPad + line, innerWidth)); } } // Bottom padding for (let i = 0; i < this.#paddingY; i++) { - result.push(this.#applyBg("", width)); + interior.push(this.#applyBg("", innerWidth)); + } + + if (border) { + const paint = border.color ?? (s => s); + const rule = border.chars.horizontal.repeat(Math.max(0, innerWidth)); + const side = paint(border.chars.vertical); + result.push(paint(border.chars.topLeft + rule + border.chars.topRight)); + for (const row of interior) { + result.push(side + row + side); + } + result.push(paint(border.chars.bottomLeft + rule + border.chars.bottomRight)); + } else { + for (const row of interior) { + result.push(row); + } } } - this.#cached = { width, bgSample, childLines, result }; + this.#cached = { width, bgSample, borderSample, childLines, result }; return result; } diff --git a/packages/tui/test/box-border.test.ts b/packages/tui/test/box-border.test.ts new file mode 100644 index 000000000..4ee0f487b --- /dev/null +++ b/packages/tui/test/box-border.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "bun:test"; +import { Box, type BoxBorder, Text } from "@oh-my-pi/pi-tui"; + +const CHARS: BoxBorder["chars"] = { + topLeft: "+", + topRight: "+", + bottomLeft: "+", + bottomRight: "+", + horizontal: "-", + vertical: "|", +}; + +function borderedBox(border?: Partial): Box { + // paddingY 0 keeps the row count predictable; ignoreTight pins paddingX to exactly 1. + // Real SGR escapes so Bun.stripANSI removes both bg and border color before width checks. + const box = new Box(1, 0, t => `\x1b[48;5;236m${t}\x1b[49m`, { chars: CHARS, color: t => `\x1b[31m${t}\x1b[39m`, ...border }); + box.setIgnoreTight(true); + box.addChild(new Text("hi", 0, 0)); + return box; +} + +const widths = (rows: readonly string[]): number[] => rows.map(r => Bun.stringWidth(Bun.stripANSI(r))); +const plain = (rows: readonly string[]): string[] => rows.map(r => Bun.stripANSI(r)); + +describe("Box border", () => { + it("frames content without exceeding the given width", () => { + const rows = borderedBox().render(20); + // top rule + single content row + bottom rule + expect(rows.length).toBe(3); + for (const w of widths(rows)) expect(w).toBe(20); + + const flat = plain(rows); + expect(flat[0]).toBe(`+${"-".repeat(18)}+`); + expect(flat[2]).toBe(`+${"-".repeat(18)}+`); + // Interior rows are wrapped by the vertical glyph on both edges. + expect(flat[1]!.startsWith("|")).toBe(true); + expect(flat[1]!.endsWith("|")).toBe(true); + expect(flat[1]).toContain("hi"); + }); + + it("paints border glyphs with the supplied colorizer", () => { + const rows = borderedBox().render(20); + // The top rule is emitted through the color fn (red SGR), the interior is not. + expect(rows[0]).toContain("\x1b[31m"); + }); + + it("recomputes width when the border is toggled off", () => { + const box = borderedBox(); + expect(widths(box.render(20))).toEqual([20, 20, 20]); + + box.setBorder(undefined); + const rows = box.render(20); + // No border rows now; the single content row fills the full width. + expect(rows.length).toBe(1); + expect(Bun.stringWidth(Bun.stripANSI(rows[0]!))).toBe(20); + expect(Bun.stripANSI(rows[0]!)).not.toContain("+"); + }); + + it("frames at the boundary width without overflowing", () => { + // paddingX 1 → the border needs width >= 5 (2 borders + 2 padding + 1 content col). + const rows = borderedBox().render(5); + for (const w of widths(rows)) expect(w).toBe(5); + expect(plain(rows)[0]).toBe(`+${"-".repeat(3)}+`); + }); + + it("drops the border when the interior can't fit padding + content", () => { + for (const width of [3, 4]) { + const rows = borderedBox().render(width); + for (const w of widths(rows)) expect(w).toBeLessThanOrEqual(width); + for (const line of plain(rows)) expect(line).not.toContain("+"); + } + }); +});