diff --git a/plugins/tmux-keybindings/herdr-plugin.toml b/plugins/tmux-keybindings/herdr-plugin.toml index 4fe6714..511b23e 100644 --- a/plugins/tmux-keybindings/herdr-plugin.toml +++ b/plugins/tmux-keybindings/herdr-plugin.toml @@ -47,5 +47,5 @@ id = "bindings" title = "Tmux keybindings" placement = "popup" width = 84 -height = 24 +height = 30 command = ["node", "index.ts", "bindings"] diff --git a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts index 8ddfb26..3333b4c 100644 --- a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts +++ b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts @@ -1,87 +1,81 @@ -import { PANEL_GROUPS, type BindingDescription, type BindingGroup } from '#tmux-keybindings/domain/keybinding-profile'; +import { PANEL_GROUPS } from '#tmux-keybindings/domain/keybinding-profile'; +import { DialogPalette } from '#tmux-keybindings/presentation/terminal/dialog-palette'; +import { StyledSegment } from '#tmux-keybindings/presentation/terminal/styled-segment'; +import { TerminalLine } from '#tmux-keybindings/presentation/terminal/terminal-line'; -const maximumChordWidth = 12; -const minimumChordWidth = 7; -const chordWidthRatio = 0.28; +const minimumWidth = 20; +const minimumHeight = 4; +const shortcutWidth = 20; export class BindingsPanelRenderer { render(width: number, height: number): string { - const safeWidth = Math.max(20, width); - const safeHeight = Math.max(4, height); - const contentWidth = safeWidth - 4; - const lines = safeWidth >= 72 ? this.wideLines(contentWidth) : this.narrowLines(contentWidth); - const header = this.centre('TMUX KEYBINDINGS', contentWidth); - const shortcuts = this.shortcutLines(contentWidth); - const divider = this.centre('━'.repeat(Math.min(contentWidth, 42)), contentWidth); - const body = [header, ...shortcuts, divider, '', ...lines]; - const visible = body.slice(0, safeHeight - 1); - - if (body.length > visible.length) { - visible[visible.length - 1] = this.truncate('… resize dialog to see all bindings', contentWidth); - } - - return visible.map((line) => ` ${this.truncate(line, contentWidth)}`).join('\n'); + const safeWidth = Math.max(minimumWidth, width); + const safeHeight = Math.max(minimumHeight, height); + const palette = DialogPalette.tmux(); + const contentRows = this.contentRows(safeWidth, palette); + const footer = this.footer(palette); + const visibleRows = this.visibleRows(contentRows, footer, safeHeight, palette); + + return visibleRows.map((segments) => TerminalLine.from(segments, safeWidth, palette.background).render()).join('\n'); } - private narrowLines(width: number): readonly string[] { - return PANEL_GROUPS.flatMap((group, index) => [...(index === 0 ? [] : ['']), this.sectionHeading(group.title, width), ...group.bindings.map((binding) => this.bindingLine(binding, width))]); - } - - private wideLines(width: number): readonly string[] { - const columnWidth = Math.floor((width - 3) / 2); - const columns: readonly BindingGroup[][] = [[PANEL_GROUPS[0] as BindingGroup, PANEL_GROUPS[1] as BindingGroup], [PANEL_GROUPS[2] as BindingGroup]]; - - const rendered = columns.map((groups) => - groups.flatMap((group, index) => [...(index === 0 ? [] : ['']), this.sectionHeading(group.title, columnWidth), ...group.bindings.map((binding) => this.bindingLine(binding, columnWidth))]), - ); - - const rowCount = Math.max(...rendered.map((column) => column.length)); - const lines: string[] = []; - - for (let row = 0; row < rowCount; row += 1) { - const left = (rendered[0]?.[row] ?? '').padEnd(columnWidth); - const right = rendered[1]?.[row] ?? ''; - - lines.push(`${left} │ ${right}`.trimEnd()); + private contentRows(width: number, palette: DialogPalette): readonly (readonly StyledSegment[])[] { + const rows: (readonly StyledSegment[])[] = [ + this.title(width, palette), + this.text('available commands and configured shortcuts', palette.mutedText, palette), + this.text('', palette.primaryText, palette), + ]; + + for (const [groupIndex, group] of PANEL_GROUPS.entries()) { + if (groupIndex > 0) { + rows.push(this.text('', palette.primaryText, palette)); + } + + rows.push(this.text(group.title.toLowerCase(), palette.sectionHeading, palette, true)); + + for (const binding of group.bindings) { + rows.push([ + StyledSegment.from(binding.chord.padEnd(shortcutWidth), palette.shortcut, palette.background, false), + StyledSegment.from(binding.description, palette.primaryText, palette.background, false), + ]); + } } - return lines; + return rows; } - private bindingLine(binding: BindingDescription, width: number): string { - const chordWidth = Math.min(maximumChordWidth, Math.max(minimumChordWidth, Math.floor(width * chordWidthRatio))); + private title(width: number, palette: DialogPalette): readonly StyledSegment[] { + const title = 'tmux keybindings'; + const badge = 'esc close'; + const spacing = ' '.repeat(Math.max(1, width - Array.from(title).length - Array.from(badge).length)); - return `${binding.chord.padEnd(chordWidth)} ${binding.description}`; + return [ + StyledSegment.from(title, palette.primaryText, palette.background, true), + StyledSegment.from(spacing, palette.primaryText, palette.background, false), + StyledSegment.from(badge, palette.closeBadgeText, palette.closeBadgeBackground, true), + ]; } - private shortcutLines(width: number): readonly string[] { - if (width >= 48) { - return [this.centre('Open/close Option+Command+T • Esc closes', width)]; - } - - if (width >= 25) { - return [this.centre('Option+Command+T', width), this.centre('Esc closes', width)]; - } - - return [this.centre('Esc closes', width)]; + private footer(palette: DialogPalette): readonly StyledSegment[] { + return this.text('toggle Option+Command+T close Esc', palette.mutedText, palette); } - private sectionHeading(title: string, width: number): string { - const heading = title.toUpperCase(); - const rule = '─'.repeat(Math.max(0, width - heading.length - 1)); - - return `${heading} ${rule}`; - } + private visibleRows(contentRows: readonly (readonly StyledSegment[])[], footer: readonly StyledSegment[], height: number, palette: DialogPalette): readonly (readonly StyledSegment[])[] { + const contentHeight = height - 1; + const visible = contentRows.slice(0, contentHeight); - private centre(value: string, width: number): string { - return value.padStart(Math.max(value.length, Math.floor((width + value.length) / 2))); - } + if (contentRows.length > contentHeight) { + visible[visible.length - 1] = this.text('... resize dialog to see all bindings', palette.mutedText, palette); + } - private truncate(value: string, width: number): string { - if (value.length <= width) { - return value; + while (visible.length < contentHeight) { + visible.push(this.text('', palette.primaryText, palette)); } - return `${value.slice(0, Math.max(0, width - 1))}…`; + return [...visible, footer]; + } + + private text(value: string, foreground: string, palette: DialogPalette, emphasized = false): readonly StyledSegment[] { + return [StyledSegment.from(value, foreground, palette.background, emphasized)]; } } diff --git a/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts b/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts index c55ca81..ffc184e 100644 --- a/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts +++ b/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts @@ -1,14 +1,13 @@ const controlC = 0x03; const escape = 0x1b; +const legacyAltT = 0x74; export class PanelCloseShortcut { accept(input: Uint8Array): boolean { - for (const byte of input) { - if (byte === escape || byte === controlC) { - return true; - } + if (input.length === 1) { + return input[0] === escape || input[0] === controlC; } - return false; + return input.length === 2 && input[0] === escape && input[1] === legacyAltT; } } diff --git a/plugins/tmux-keybindings/src/presentation/terminal/dialog-palette.ts b/plugins/tmux-keybindings/src/presentation/terminal/dialog-palette.ts new file mode 100644 index 0000000..33dc398 --- /dev/null +++ b/plugins/tmux-keybindings/src/presentation/terminal/dialog-palette.ts @@ -0,0 +1,68 @@ +/** Immutable colours used to render the tmux keybindings dialog. */ +export class DialogPalette { + private readonly backgroundValue: string; + private readonly primaryTextValue: string; + private readonly mutedTextValue: string; + private readonly sectionHeadingValue: string; + private readonly shortcutValue: string; + private readonly closeBadgeBackgroundValue: string; + private readonly closeBadgeTextValue: string; + + private constructor( + backgroundValue: string, + primaryTextValue: string, + mutedTextValue: string, + sectionHeadingValue: string, + shortcutValue: string, + closeBadgeBackgroundValue: string, + closeBadgeTextValue: string, + ) { + this.backgroundValue = backgroundValue; + this.primaryTextValue = primaryTextValue; + this.mutedTextValue = mutedTextValue; + this.sectionHeadingValue = sectionHeadingValue; + this.shortcutValue = shortcutValue; + this.closeBadgeBackgroundValue = closeBadgeBackgroundValue; + this.closeBadgeTextValue = closeBadgeTextValue; + } + + /** Creates the tmux help-dialog palette. */ + static tmux(): DialogPalette { + return new DialogPalette('#1a1b26', '#c0caf5', '#737aa2', '#7dcfff', '#bb9af7', '#7aa2f7', '#1a1b26'); + } + + /** Returns the dialog background colour. */ + get background(): string { + return this.backgroundValue; + } + + /** Returns the primary text colour. */ + get primaryText(): string { + return this.primaryTextValue; + } + + /** Returns the muted text colour. */ + get mutedText(): string { + return this.mutedTextValue; + } + + /** Returns the section-heading colour. */ + get sectionHeading(): string { + return this.sectionHeadingValue; + } + + /** Returns the shortcut colour. */ + get shortcut(): string { + return this.shortcutValue; + } + + /** Returns the close-badge background colour. */ + get closeBadgeBackground(): string { + return this.closeBadgeBackgroundValue; + } + + /** Returns the close-badge text colour. */ + get closeBadgeText(): string { + return this.closeBadgeTextValue; + } +} diff --git a/plugins/tmux-keybindings/src/presentation/terminal/styled-segment.ts b/plugins/tmux-keybindings/src/presentation/terminal/styled-segment.ts new file mode 100644 index 0000000..df7222f --- /dev/null +++ b/plugins/tmux-keybindings/src/presentation/terminal/styled-segment.ts @@ -0,0 +1,39 @@ +/** Immutable terminal text with explicit foreground, background, and emphasis. */ +export class StyledSegment { + private readonly textValue: string; + private readonly foregroundValue: string; + private readonly backgroundValue: string; + private readonly emphasizedValue: boolean; + + private constructor(textValue: string, foregroundValue: string, backgroundValue: string, emphasizedValue: boolean) { + this.textValue = textValue; + this.foregroundValue = foregroundValue; + this.backgroundValue = backgroundValue; + this.emphasizedValue = emphasizedValue; + } + + /** Creates a styled terminal-text segment. */ + static from(text: string, foreground: string, background: string, emphasized: boolean): StyledSegment { + return new StyledSegment(text, foreground, background, emphasized); + } + + /** Returns the unstyled text. */ + get text(): string { + return this.textValue; + } + + /** Returns the foreground colour. */ + get foreground(): string { + return this.foregroundValue; + } + + /** Returns the background colour. */ + get background(): string { + return this.backgroundValue; + } + + /** Reports whether the segment is emphasized. */ + get emphasized(): boolean { + return this.emphasizedValue; + } +} diff --git a/plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts b/plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts new file mode 100644 index 0000000..5f17668 --- /dev/null +++ b/plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts @@ -0,0 +1,95 @@ +import type { StyledSegment } from '#tmux-keybindings/presentation/terminal/styled-segment'; + +const escape = String.fromCodePoint(0x1b); + +/** Renders immutable styled segments to one fixed-width terminal row. */ +export class TerminalLine { + private readonly segments: readonly StyledSegment[]; + private readonly width: number; + private readonly background: string; + + private constructor(segments: readonly StyledSegment[], width: number, background: string) { + this.segments = segments; + this.width = width; + this.background = background; + } + + /** Creates a fixed-width terminal row from concrete styled segments. */ + static from(segments: readonly StyledSegment[], width: number, background: string): TerminalLine { + return new TerminalLine(Object.freeze([...segments]), width, background); + } + + /** Renders ANSI styling after measuring, truncating, and padding unstyled code points. */ + render(): string { + const renderedSegments: string[] = []; + + let visibleWidth = 0; + + for (const segment of this.segments) { + const remainingWidth = this.width - visibleWidth; + + if (remainingWidth <= 0) { + break; + } + + const text = this.truncate(segment.text, remainingWidth); + + if (text.length === 0) { + continue; + } + + renderedSegments.push(this.renderSegment(segment, text)); + visibleWidth += this.visibleWidth(text); + } + + const padding = ' '.repeat(Math.max(0, this.width - visibleWidth)); + + return `${renderedSegments.join('')}${this.backgroundSequence(this.background)}${padding}${escape}[0m`; + } + + private renderSegment(segment: StyledSegment, text: string): string { + const emphasis = segment.emphasized ? `${escape}[1m` : `${escape}[22m`; + + return `${this.backgroundSequence(segment.background)}${emphasis}${this.foregroundSequence(segment.foreground)}${text}`; + } + + private visibleWidth(value: string): number { + return Array.from(value).length; + } + + private truncate(value: string, width: number): string { + const codePoints = Array.from(value); + + if (codePoints.length <= width) { + return value; + } + + if (width === 1) { + return '…'; + } + + return `${codePoints.slice(0, Math.max(0, width - 1)).join('')}…`; + } + + private foregroundSequence(colour: string): string { + const [red, green, blue] = this.parseColour(colour); + + return `${escape}[38;2;${red};${green};${blue}m`; + } + + private backgroundSequence(colour: string): string { + const [red, green, blue] = this.parseColour(colour); + + return `${escape}[48;2;${red};${green};${blue}m`; + } + + private parseColour(colour: string): readonly [number, number, number] { + const hexadecimal = colour.startsWith('#') ? colour.slice(1) : colour; + + if (!/^[0-9a-f]{6}$/iu.test(hexadecimal)) { + return [0, 0, 0]; + } + + return [Number.parseInt(hexadecimal.slice(0, 2), 16), Number.parseInt(hexadecimal.slice(2, 4), 16), Number.parseInt(hexadecimal.slice(4, 6), 16)]; + } +} diff --git a/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts b/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts index f72aa1e..b0e6891 100644 --- a/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts +++ b/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts @@ -28,7 +28,7 @@ describe('tmux-keybindings plugin contract', () => { expect(manifest).toContain('on = "workspace.created"'); expect(manifest).toContain('placement = "popup"'); expect(manifest).toContain('width = 84'); - expect(manifest).toContain('height = 24'); + expect(manifest).toContain('height = 30'); }); it('uses concern aliases and is listed in the root catalogue', () => { diff --git a/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts b/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts index c47a544..a4700be 100644 --- a/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts +++ b/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts @@ -1,38 +1,104 @@ import { describe, expect, it } from 'vite-plus/test'; +import { PANEL_GROUPS } from '#tmux-keybindings/domain/keybinding-profile'; import { BindingsPanelRenderer } from '#tmux-keybindings/presentation/bindings-panel-renderer'; +import { TerminalOutput } from '#tmux-keybindings/testing/support/terminal-output'; describe('BindingsPanelRenderer', () => { const renderer = new BindingsPanelRenderer(); + const escape = String.fromCodePoint(0x1b); - it('renders every group in a narrow pane', () => { - const panel = renderer.render(42, 30); + it('renders the complete styled catalogue within the target dialog', () => { + const panel = renderer.render(84, 30); + const output = TerminalOutput.from(panel); + const primary = `${escape}[38;2;192;202;245m`; + const shortcut = `${escape}[38;2;187;154;247m`; - expect(panel).toContain('Option+Command+T'); - expect(panel).toContain('Esc closes'); - expect(panel).toContain('GLOBAL'); - expect(panel).toContain('TABS'); - expect(panel).toContain('PANES'); - expect(panel).toContain('% / "'); + expect(output.rows).toHaveLength(30); expect( - panel.split('\n').every((line) => line.length <= 42), + output.visibleWidths.every((width) => width === 84), + ).toBe(true); + expect( + output.rows[0]?.startsWith('tmux keybindings'), + ).toBe(true); + expect( + output.rows[0]?.endsWith('esc close'), + ).toBe(true); + expect(output.rows).toContain('available commands and configured shortcuts'.padEnd(84)); + expect( + output.rows.at(-1)?.trim(), + ).toBe('toggle Option+Command+T close Esc'); + + for (const group of PANEL_GROUPS) { + expect( + output.rows.some((row) => row.trim() === group.title.toLowerCase()), + ).toBe(true); + + for (const binding of group.bindings) { + expect(panel).toContain(binding.chord); + expect(panel).toContain(binding.description); + expect( + output.hasStyledText(shortcut, binding.chord), + ).toBe(true); + expect( + output.hasStyledText(primary, binding.description), + ).toBe(true); + } + } + + expect( + output.hasAnsiSequence(`${escape}[48;2;26;27;38m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[38;2;192;202;245m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[38;2;115;122;162m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[38;2;125;207;255m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[38;2;187;154;247m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[48;2;122;162;247m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[38;2;26;27;38m`), + ).toBe(true); + expect( + output.everyRowEndsWithReset(), ).toBe(true); }); - it('uses two columns in a wide pane', () => { - const panel = renderer.render(90, 20); + it('preserves the resize hint and footer at short heights', () => { + const output = TerminalOutput.from(renderer.render(84, 6)); - expect(panel).toContain('│'); - expect(panel).toContain('GLOBAL'); - expect(panel).toContain('PANES'); + expect(output.rows).toHaveLength(6); + expect( + output.rows.some((row) => row.includes('... resize dialog to see all bindings')), + ).toBe(true); + expect( + output.rows.at(-1)?.trim(), + ).toBe('toggle Option+Command+T close Esc'); + expect( + output.everyRowEndsWithReset(), + ).toBe(true); }); - it('adds an overflow hint at short heights', () => { - const panel = renderer.render(38, 6); + it('keeps narrow and minimum-height output within the safe viewport', () => { + const output = TerminalOutput.from(renderer.render(12, 2)); + expect(output.rows).toHaveLength(4); + expect( + output.visibleWidths.every((width) => width <= 20), + ).toBe(true); + expect( + output.rows.some((row) => row.includes('resize dialog')), + ).toBe(true); expect( - panel.split('\n'), - ).toHaveLength(5); - expect(panel).toContain('resize dialog'); + output.everyRowEndsWithReset(), + ).toBe(true); }); }); diff --git a/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts b/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts index 436e5d6..a212709 100644 --- a/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts +++ b/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts @@ -3,21 +3,32 @@ import { describe, expect, it } from 'vite-plus/test'; import { PanelCloseShortcut } from '#tmux-keybindings/presentation/panel-close-shortcut'; describe('PanelCloseShortcut', () => { - it('closes when Option+Command+T arrives as a legacy alt sequence', () => { + const shortcut = new PanelCloseShortcut(); + + it('closes for the exact supported input sequences', () => { + expect( + shortcut.accept(Uint8Array.from([0x1b])), + ).toBe(true); + expect( + shortcut.accept(Uint8Array.from([0x03])), + ).toBe(true); expect( - new PanelCloseShortcut().accept(Uint8Array.from([0x1b, 0x74])), + shortcut.accept(Uint8Array.from([0x1b, 0x74])), ).toBe(true); }); - it('ignores plain input', () => { + it('rejects terminal arrow sequences', () => { expect( - new PanelCloseShortcut().accept(Uint8Array.from([0x74])), + shortcut.accept(Uint8Array.from([0x1b, 0x5b, 0x41])), ).toBe(false); }); - it('also closes a modal dialog with Escape', () => { + it('rejects unrelated Alt and plain input sequences', () => { expect( - new PanelCloseShortcut().accept(Uint8Array.from([0x1b])), - ).toBe(true); + shortcut.accept(Uint8Array.from([0x1b, 0x78])), + ).toBe(false); + expect( + shortcut.accept(Uint8Array.from([0x74])), + ).toBe(false); }); }); diff --git a/plugins/tmux-keybindings/tests/presentation/terminal-line.test.ts b/plugins/tmux-keybindings/tests/presentation/terminal-line.test.ts new file mode 100644 index 0000000..6e6fd1d --- /dev/null +++ b/plugins/tmux-keybindings/tests/presentation/terminal-line.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vite-plus/test'; + +import { StyledSegment } from '#tmux-keybindings/presentation/terminal/styled-segment'; +import { TerminalLine } from '#tmux-keybindings/presentation/terminal/terminal-line'; + +describe('TerminalLine', () => { + const escape = String.fromCodePoint(0x1b); + + it('parses six-digit colours with or without a hash', () => { + const segment = StyledSegment.from('ok', 'c0caf5', '#1a1b26', false); + const output = TerminalLine.from([segment], 2, '#1a1b26').render(); + + expect(output).toContain(`${escape}[38;2;192;202;245mok`); + expect(output).toContain(`${escape}[48;2;26;27;38m`); + }); + + it('falls back to black for malformed colours', () => { + const segment = StyledSegment.from('ok', '#xyzxyz', '#abc', false); + const output = TerminalLine.from([segment], 3, 'invalid').render(); + + expect(output).not.toContain('NaN'); + expect(output).toContain(`${escape}[38;2;0;0;0mok`); + expect( + output.match(new RegExp(`${escape}\\[48;2;0;0;0m`, 'gu')), + ).toHaveLength(2); + }); +}); diff --git a/plugins/tmux-keybindings/tests/support/terminal-output.ts b/plugins/tmux-keybindings/tests/support/terminal-output.ts new file mode 100644 index 0000000..3506dc8 --- /dev/null +++ b/plugins/tmux-keybindings/tests/support/terminal-output.ts @@ -0,0 +1,48 @@ +const ansiReset = `${String.fromCodePoint(0x1b)}[0m`; +const ansiSequencePattern = new RegExp(`${String.fromCodePoint(0x1b)}\\[[0-9;]*m`, 'gu'); + +/** Provides immutable textual and terminal-style views of rendered output. */ +export class TerminalOutput { + private readonly rawValue: string; + private readonly rawRowsValue: readonly string[]; + private readonly rowsValue: readonly string[]; + + private constructor(rawValue: string, rawRowsValue: readonly string[], rowsValue: readonly string[]) { + this.rawValue = rawValue; + this.rawRowsValue = rawRowsValue; + this.rowsValue = rowsValue; + } + + /** Creates terminal output assertions from an ANSI-rendered value. */ + static from(value: string): TerminalOutput { + const rawRows = Object.freeze(value.split('\n')); + const rows = Object.freeze(rawRows.map((row) => row.replace(ansiSequencePattern, ''))); + + return new TerminalOutput(value, rawRows, rows); + } + + /** Returns rendered rows with ANSI sequences removed. */ + get rows(): readonly string[] { + return this.rowsValue; + } + + /** Returns the Unicode code-point width of every unstyled row. */ + get visibleWidths(): readonly number[] { + return Object.freeze(this.rowsValue.map((row) => Array.from(row).length)); + } + + /** Reports whether the raw output includes an ANSI sequence. */ + hasAnsiSequence(sequence: string): boolean { + return this.rawValue.includes(sequence); + } + + /** Reports whether text immediately follows a required ANSI style sequence. */ + hasStyledText(sequence: string, text: string): boolean { + return this.rawValue.includes(`${sequence}${text}`); + } + + /** Reports whether every raw row safely resets terminal state. */ + everyRowEndsWithReset(): boolean { + return this.rawRowsValue.every((row) => row.endsWith(ansiReset)); + } +}