mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
A beta tester photographed claude's `❯` prompt and its `⏵⏵` bypass-permissions marker rendering as empty boxes in the preview pane. Their font has no coverage for those codepoints while drawing `·`, `─`, `│` and `▶` perfectly. The glyph TIER cannot help here. It answers "can this terminal do Unicode at all", which is a locale question, and it correctly says yes for exactly the terminals this affects. Coverage is per-glyph and undetectable from inside the process, so the handful of rare glyphs CLIs use as chrome are folded to the ASCII arrows they already look like, and everything a plain font does render is left alone. Scoped tightly: the preview only, never the TUI's own chrome, and skipped entirely at the `nerd` tier where the user has declared a font that can draw anything. The table is short and every entry was seen as tofu in a real terminal rather than guessed at. The fold is length-preserving, so the preview pane's column arithmetic is unaffected.
572 lines
19 KiB
TypeScript
572 lines
19 KiB
TypeScript
/**
|
||
* @fileoverview Pure ANSI helpers for the TUI preview pane.
|
||
*
|
||
* The preview shows the tail of a session's raw terminal stream, which is
|
||
* xterm-bound bytes: SGR colors, cursor jumps, OSC titles, DECSET modes and
|
||
* carriage-return repaints. This is NOT a terminal emulator. It reconstructs a
|
||
* readable, color-preserving tail: SGR survives, everything else that steers a
|
||
* cursor is dropped, and a `\r` is honored as "back to column 0" so a spinner
|
||
* that repaints its line 200 times contributes one line instead of 200.
|
||
*
|
||
* CURSOR ADDRESSING (`ESC [ r ; c H`) is honored too, and it has to be: an Ink
|
||
* TUI like Claude Code repaints by ROW and emits almost no newlines, so
|
||
* dropping those sequences collapses a whole screen into one unreadable line
|
||
* (measured against a live pane, 2026-08-16). A jump to column 1 starts a new
|
||
* display line, a jump within a row moves the write position, which is the same
|
||
* reading `normalizeCapturedFrame` in `web/approval-inbox.ts` takes of the same
|
||
* kind of frame.
|
||
*
|
||
* Two approximations are deliberate, because the alternative is an emulator:
|
||
* a carriage-return overwrite counts CODE POINTS, not display columns (so a
|
||
* repaint over CJK text can land one cell off), and tab stops are counted the
|
||
* same way. Neither can corrupt output, they only shift a repaint's alignment.
|
||
* Absolute ROW numbers are ignored as well: rows arrive in the order they are
|
||
* painted, which for a tail is the order worth reading.
|
||
*
|
||
* @module tui/tui-ansi
|
||
*/
|
||
|
||
const ESC = 0x1b;
|
||
const BEL = 0x07;
|
||
const ST_C1 = 0x9c;
|
||
const DEL = 0x7f;
|
||
|
||
/** SGR reset, appended by `clipStyledLine` so a clipped line cannot bleed. */
|
||
export const SGR_RESET = '\x1b[0m';
|
||
|
||
const TAB_WIDTH = 8;
|
||
/** Cap on remembered SGR sequences per cell, so a pathological stream cannot grow one unboundedly. */
|
||
const MAX_ACTIVE_SGR = 32;
|
||
/** Ceiling on a display line's cells: a stream may address column 99999, a terminal has none. */
|
||
const MAX_LINE_CELLS = 1000;
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Escape-sequence scanning
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
interface EscapeScan {
|
||
/** Index just past the sequence; `text.length` for a truncated one. */
|
||
next: number;
|
||
/** The sequence itself, only when it is SGR (`CSI ... m`) and therefore kept. */
|
||
sgr?: string;
|
||
/** 1-based column of a cursor-position sequence (`CSI r ; c H` or `f`). */
|
||
column?: number;
|
||
}
|
||
|
||
/** The column a `CSI r ; c H` addresses. Both parameters default to 1. */
|
||
function cursorColumn(params: string): number {
|
||
const parts = params.split(';');
|
||
const column = Number.parseInt(parts[1] ?? '', 10);
|
||
return Number.isSafeInteger(column) && column > 0 ? column : 1;
|
||
}
|
||
|
||
/** Scan a CSI body starting at `from` (params, then intermediates, then a final byte). */
|
||
function readCsi(text: string, start: number, from: number, keepSgr: boolean): EscapeScan {
|
||
let j = from;
|
||
while (j < text.length && text.charCodeAt(j) >= 0x30 && text.charCodeAt(j) <= 0x3f) j++;
|
||
while (j < text.length && text.charCodeAt(j) >= 0x20 && text.charCodeAt(j) <= 0x2f) j++;
|
||
if (j >= text.length) return { next: text.length };
|
||
const next = j + 1;
|
||
if (keepSgr && text[j] === 'm') return { next, sgr: text.slice(start, next) };
|
||
if (keepSgr && (text[j] === 'H' || text[j] === 'f')) {
|
||
return { next, column: cursorColumn(text.slice(from, j)) };
|
||
}
|
||
return { next };
|
||
}
|
||
|
||
/** Scan an OSC/DCS/PM/APC body: everything up to BEL, C1 ST or `ESC \`. */
|
||
function readStringSequence(text: string, from: number): number {
|
||
let j = from;
|
||
while (j < text.length) {
|
||
const code = text.charCodeAt(j);
|
||
if (code === BEL || code === ST_C1) return j + 1;
|
||
if (code === ESC && text[j + 1] === '\\') return j + 2;
|
||
j++;
|
||
}
|
||
return text.length;
|
||
}
|
||
|
||
/** Scan the escape sequence starting at `i` (which must be an ESC). */
|
||
function readEscape(text: string, i: number): EscapeScan {
|
||
const second = text[i + 1];
|
||
if (second === undefined) return { next: text.length };
|
||
if (second === '[') return readCsi(text, i, i + 2, true);
|
||
if (second === ']' || second === 'P' || second === 'X' || second === '^' || second === '_') {
|
||
return { next: readStringSequence(text, i + 2) };
|
||
}
|
||
// Charset / character-set selection: one more byte belongs to the sequence.
|
||
if (second === '(' || second === ')' || second === '*' || second === '+' || second === '#' || second === '%') {
|
||
return { next: Math.min(text.length, i + 3) };
|
||
}
|
||
return { next: i + 2 };
|
||
}
|
||
|
||
/** Scan a single-byte C1 control at `i` (0x80-0x9f). */
|
||
function readC1(text: string, i: number): number {
|
||
const code = text.charCodeAt(i);
|
||
if (code === 0x9b) return readCsi(text, i, i + 1, false).next;
|
||
if (code === 0x90 || code === 0x9d || code === 0x9e || code === 0x9f) return readStringSequence(text, i + 1);
|
||
return i + 1;
|
||
}
|
||
|
||
function isC1(code: number): boolean {
|
||
return code >= 0x80 && code <= 0x9f;
|
||
}
|
||
|
||
/** `CSI 0 m`, `CSI m` and `CSI 0;0 m` all mean "back to plain". */
|
||
function isSgrReset(seq: string): boolean {
|
||
const params = seq.slice(2, -1);
|
||
return params === '' || /^0(?:;0)*$/.test(params);
|
||
}
|
||
|
||
/**
|
||
* Fold one SGR sequence into the active set. Sequences accumulate in arrival
|
||
* order (a later color simply wins when replayed), a reset clears them, and a
|
||
* repeat moves rather than duplicates.
|
||
*/
|
||
function applySgr(active: string[], seq: string): string[] {
|
||
if (isSgrReset(seq)) return [];
|
||
const next = active.filter((s) => s !== seq);
|
||
next.push(seq);
|
||
return next.length > MAX_ACTIVE_SGR ? next.slice(-MAX_ACTIVE_SGR) : next;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Display width
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Combining marks, variation selectors and other zero-advance code points.
|
||
* Pragmatic, not exhaustive: enough that accents and emoji modifiers do not
|
||
* inflate a measured width.
|
||
*/
|
||
const ZERO_WIDTH_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||
[0x0300, 0x036f],
|
||
[0x0483, 0x0489],
|
||
[0x0591, 0x05bd],
|
||
[0x05bf, 0x05bf],
|
||
[0x0610, 0x061a],
|
||
[0x064b, 0x065f],
|
||
[0x0670, 0x0670],
|
||
[0x06d6, 0x06dc],
|
||
[0x0e31, 0x0e31],
|
||
[0x0e34, 0x0e3a],
|
||
[0x0e47, 0x0e4e],
|
||
[0x200b, 0x200f],
|
||
[0x2028, 0x202e],
|
||
[0x2060, 0x2064],
|
||
[0x20d0, 0x20f0],
|
||
[0xfe00, 0xfe0f],
|
||
[0xfe20, 0xfe2f],
|
||
[0xfeff, 0xfeff],
|
||
];
|
||
|
||
/**
|
||
* East Asian Wide + Fullwidth, plus the standalone code points UAX #11 marks
|
||
* Wide because they are emoji-presentation by default. This repo ships a zh-CN
|
||
* locale, so CJK correctness is the point; exhaustive Unicode is not required,
|
||
* but the scattered BMP entries below are not optional either: `✋` (U+270B) is
|
||
* one of them and it is a glyph this TUI draws in every waiting row, so getting
|
||
* it wrong mis-pads a column on every frame.
|
||
*/
|
||
const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||
[0x1100, 0x115f],
|
||
[0x231a, 0x231b],
|
||
[0x23e9, 0x23ec],
|
||
[0x23f0, 0x23f0],
|
||
[0x23f3, 0x23f3],
|
||
[0x25fd, 0x25fe],
|
||
[0x2614, 0x2615],
|
||
[0x2648, 0x2653],
|
||
[0x267f, 0x267f],
|
||
[0x2693, 0x2693],
|
||
[0x26a1, 0x26a1],
|
||
[0x26aa, 0x26ab],
|
||
[0x26bd, 0x26be],
|
||
[0x26c4, 0x26c5],
|
||
[0x26ce, 0x26ce],
|
||
[0x26d4, 0x26d4],
|
||
[0x26ea, 0x26ea],
|
||
[0x26f2, 0x26f3],
|
||
[0x26f5, 0x26f5],
|
||
[0x26fa, 0x26fa],
|
||
[0x26fd, 0x26fd],
|
||
[0x2705, 0x2705],
|
||
[0x270a, 0x270b],
|
||
[0x2728, 0x2728],
|
||
[0x274c, 0x274c],
|
||
[0x274e, 0x274e],
|
||
[0x2753, 0x2755],
|
||
[0x2757, 0x2757],
|
||
[0x2795, 0x2797],
|
||
[0x27b0, 0x27b0],
|
||
[0x27bf, 0x27bf],
|
||
[0x2b1b, 0x2b1c],
|
||
[0x2b50, 0x2b50],
|
||
[0x2b55, 0x2b55],
|
||
[0x2e80, 0x303e],
|
||
[0x3041, 0x33ff],
|
||
[0x3400, 0x4dbf],
|
||
[0x4e00, 0x9fff],
|
||
[0xa000, 0xa4cf],
|
||
[0xa960, 0xa97f],
|
||
[0xac00, 0xd7a3],
|
||
[0xf900, 0xfaff],
|
||
[0xfe10, 0xfe19],
|
||
[0xfe30, 0xfe6f],
|
||
[0xff00, 0xff60],
|
||
[0xffe0, 0xffe6],
|
||
[0x1f004, 0x1f004],
|
||
[0x1f0cf, 0x1f0cf],
|
||
[0x1f18e, 0x1f18e],
|
||
[0x1f191, 0x1f19a],
|
||
[0x1f200, 0x1f320],
|
||
[0x1f32d, 0x1f335],
|
||
[0x1f337, 0x1f37c],
|
||
[0x1f37e, 0x1f393],
|
||
[0x1f3a0, 0x1f3ca],
|
||
[0x1f3cf, 0x1f3d3],
|
||
[0x1f3e0, 0x1f3f0],
|
||
[0x1f3f4, 0x1f3f4],
|
||
[0x1f3f8, 0x1f43e],
|
||
[0x1f440, 0x1f440],
|
||
[0x1f442, 0x1f4fc],
|
||
[0x1f4ff, 0x1f53d],
|
||
[0x1f54b, 0x1f54e],
|
||
[0x1f550, 0x1f567],
|
||
[0x1f57a, 0x1f57a],
|
||
[0x1f595, 0x1f596],
|
||
[0x1f5a4, 0x1f5a4],
|
||
[0x1f5fb, 0x1f64f],
|
||
[0x1f680, 0x1f6c5],
|
||
[0x1f6cc, 0x1f6cc],
|
||
[0x1f6d0, 0x1f6d2],
|
||
[0x1f6eb, 0x1f6ec],
|
||
[0x1f6f4, 0x1f6fc],
|
||
[0x1f7e0, 0x1f7eb],
|
||
[0x1f90c, 0x1f93a],
|
||
[0x1f93c, 0x1f945],
|
||
[0x1f947, 0x1f9ff],
|
||
[0x1fa70, 0x1faff],
|
||
[0x20000, 0x2fffd],
|
||
[0x30000, 0x3fffd],
|
||
];
|
||
|
||
function inRanges(cp: number, ranges: ReadonlyArray<readonly [number, number]>): boolean {
|
||
for (const [lo, hi] of ranges) {
|
||
if (cp < lo) return false;
|
||
if (cp <= hi) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Columns one code point advances the cursor by: 0, 1 or 2. */
|
||
export function charWidth(codePoint: number): number {
|
||
if (codePoint < 0x20 || (codePoint >= DEL && codePoint <= 0x9f)) return 0;
|
||
if (inRanges(codePoint, ZERO_WIDTH_RANGES)) return 0;
|
||
if (inRanges(codePoint, WIDE_RANGES)) return 2;
|
||
return 1;
|
||
}
|
||
|
||
/** Display width of a string: escape sequences take no columns, CJK takes two. */
|
||
export function visibleWidth(text: string): number {
|
||
let width = 0;
|
||
let i = 0;
|
||
while (i < text.length) {
|
||
const code = text.charCodeAt(i);
|
||
if (code === ESC) {
|
||
i = readEscape(text, i).next;
|
||
continue;
|
||
}
|
||
if (isC1(code)) {
|
||
i = readC1(text, i);
|
||
continue;
|
||
}
|
||
if (code < 0x20 || code === DEL) {
|
||
i++;
|
||
continue;
|
||
}
|
||
const cp = text.codePointAt(i) as number;
|
||
i += cp > 0xffff ? 2 : 1;
|
||
width += charWidth(cp);
|
||
}
|
||
return width;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Raw stream to display lines
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/** One printed code point (plus any combining marks) and the SGR state under it. */
|
||
interface Cell {
|
||
text: string;
|
||
sgr: string;
|
||
}
|
||
|
||
/**
|
||
* Replay cells into a string, emitting an SGR change only where the state
|
||
* actually changes and closing the line so it is self-contained.
|
||
*/
|
||
function renderCells(cells: Cell[]): string {
|
||
let out = '';
|
||
let active = '';
|
||
for (const cell of cells) {
|
||
if (cell.sgr !== active) {
|
||
if (active !== '') out += SGR_RESET;
|
||
out += cell.sgr;
|
||
active = cell.sgr;
|
||
}
|
||
out += cell.text;
|
||
}
|
||
if (active !== '') out += SGR_RESET;
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Turn a raw terminal stream into display lines: SGR preserved, every other
|
||
* escape sequence dropped, `\r` treated as a return to column 0 (the following
|
||
* text overwrites what is there), tabs expanded, other control characters
|
||
* dropped.
|
||
*
|
||
* Splitting matches `String.split('\n')`, so `''` yields `['']` and a trailing
|
||
* newline yields a trailing empty line.
|
||
*/
|
||
/**
|
||
* Glyphs a CLI draws as chrome that a plain terminal font very often has no
|
||
* coverage for, and the ASCII that means the same thing.
|
||
*
|
||
* ⚠️ This is NOT a substitute for the glyph TIER. The tier answers "can this
|
||
* terminal do Unicode at all", which is a locale question, and it says yes for
|
||
* exactly the terminals this table exists for: a beta tester's font rendered
|
||
* `·`, `─`, `│` and `▶` perfectly while drawing claude's `❯` prompt and its
|
||
* `⏵⏵` mode marker as empty boxes. Coverage is per-glyph and undetectable from
|
||
* here, so the rare ones are folded and the common ones are left alone.
|
||
*
|
||
* Kept deliberately SHORT. Every entry is a glyph seen rendering as tofu in a
|
||
* real terminal, not a guess, and each maps to the arrow it already looks like.
|
||
*/
|
||
const PREVIEW_GLYPH_FOLD: ReadonlyMap<string, string> = new Map([
|
||
['\u276F', '>'], // ❯ heavy right-pointing angle quotation mark (claude, starship, zsh prompts)
|
||
['\u276E', '<'], // ❮
|
||
['\u23F5', '>'], // ⏵ black medium right-pointing triangle (claude's bypass-permissions marker)
|
||
['\u23F4', '<'], // ⏴
|
||
['\u23F6', '^'], // ⏶
|
||
['\u23F7', 'v'], // ⏷
|
||
['\u2771', '>'], // ❱
|
||
['\u2770', '<'], // ❰
|
||
]);
|
||
|
||
/**
|
||
* Replace preview glyphs a plain font is likely to draw as an empty box.
|
||
*
|
||
* Applied to ANOTHER program's output on its way into the preview pane, never
|
||
* to the TUI's own chrome, and skipped at the `nerd` tier where the user has
|
||
* declared a font that can draw anything.
|
||
*/
|
||
export function foldPreviewGlyphs(line: string): string {
|
||
let out = '';
|
||
for (const char of line) out += PREVIEW_GLYPH_FOLD.get(char) ?? char;
|
||
return out;
|
||
}
|
||
|
||
export function toDisplayLines(raw: string): string[] {
|
||
const lines: string[] = [];
|
||
let cells: Cell[] = [];
|
||
let col = 0;
|
||
let active: string[] = [];
|
||
let sgr = '';
|
||
|
||
const endLine = (): void => {
|
||
lines.push(renderCells(cells));
|
||
cells = [];
|
||
col = 0;
|
||
};
|
||
|
||
/**
|
||
* Park the write position at a column, padding the gap so the cell array
|
||
* never grows a hole (a hole would crash the replay, and a stream can address
|
||
* any column it likes).
|
||
*/
|
||
const moveTo = (column: number): void => {
|
||
const target = Math.min(column, MAX_LINE_CELLS);
|
||
while (cells.length < target) cells.push({ text: ' ', sgr: '' });
|
||
col = target;
|
||
};
|
||
|
||
const write = (text: string, width: number): void => {
|
||
if (width === 0) {
|
||
// A combining mark belongs to the character it follows, never to a cell
|
||
// of its own: keeping them together is what stops a clip from severing
|
||
// an accent from its base letter.
|
||
if (col > 0) cells[col - 1].text += text;
|
||
return;
|
||
}
|
||
cells[col] = { text, sgr };
|
||
col++;
|
||
};
|
||
|
||
let i = 0;
|
||
while (i < raw.length) {
|
||
const code = raw.charCodeAt(i);
|
||
if (code === ESC) {
|
||
const scan = readEscape(raw, i);
|
||
if (scan.sgr !== undefined) {
|
||
active = applySgr(active, scan.sgr);
|
||
sgr = active.join('');
|
||
} else if (scan.column !== undefined) {
|
||
// Column 1 is a fresh row, which is the only thing a repainting TUI
|
||
// gives us to split lines on.
|
||
if (scan.column <= 1) endLine();
|
||
else moveTo(scan.column - 1);
|
||
}
|
||
i = scan.next;
|
||
continue;
|
||
}
|
||
if (isC1(code)) {
|
||
i = readC1(raw, i);
|
||
continue;
|
||
}
|
||
if (code === 0x0a) {
|
||
endLine();
|
||
i++;
|
||
continue;
|
||
}
|
||
if (code === 0x0d) {
|
||
col = 0;
|
||
i++;
|
||
continue;
|
||
}
|
||
if (code === 0x09) {
|
||
const stop = TAB_WIDTH - (col % TAB_WIDTH);
|
||
for (let n = 0; n < stop; n++) write(' ', 1);
|
||
i++;
|
||
continue;
|
||
}
|
||
if (code < 0x20 || code === DEL) {
|
||
i++;
|
||
continue;
|
||
}
|
||
const cp = raw.codePointAt(i) as number;
|
||
const text = String.fromCodePoint(cp);
|
||
i += text.length;
|
||
write(text, charWidth(cp));
|
||
}
|
||
endLine();
|
||
return lines;
|
||
}
|
||
|
||
/**
|
||
* The parameter bytes plus final byte of a CSI sequence whose `ESC [` was cut
|
||
* off. Requires at least one parameter byte, so ordinary text starting with a
|
||
* letter is never mistaken for one.
|
||
*/
|
||
const SEVERED_CSI = /^[0-9;?:<>=]+[A-Za-z]/;
|
||
|
||
/**
|
||
* Drop the remains of an escape sequence a byte-sliced tail begins in the
|
||
* middle of.
|
||
*
|
||
* `GET /api/sessions/:id/terminal?tail=N` cuts the buffer at a byte offset, so
|
||
* a tail can start inside `ESC [ 12 ; 1 H` and hand the parser `;1H` as text,
|
||
* which is exactly what it then prints (observed against a live Claude pane).
|
||
* Only the severed head is dropped, never a whole line.
|
||
*/
|
||
export function dropSeveredEscape(raw: string): string {
|
||
return raw.replace(SEVERED_CSI, '');
|
||
}
|
||
|
||
/**
|
||
* Drop every escape sequence, keeping the visible text. Needed because the
|
||
* preview carries the session's OWN colors: under NO_COLOR the frame must not
|
||
* smuggle them back in.
|
||
*/
|
||
export function stripStyles(text: string): string {
|
||
let out = '';
|
||
let i = 0;
|
||
while (i < text.length) {
|
||
const code = text.charCodeAt(i);
|
||
if (code === ESC) {
|
||
i = readEscape(text, i).next;
|
||
continue;
|
||
}
|
||
if (isC1(code)) {
|
||
i = readC1(text, i);
|
||
continue;
|
||
}
|
||
if (code < 0x20 || code === DEL) {
|
||
i++;
|
||
continue;
|
||
}
|
||
const cp = text.codePointAt(i) as number;
|
||
const size = cp > 0xffff ? 2 : 1;
|
||
out += text.slice(i, i + size);
|
||
i += size;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Clipping and padding
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Clip a line that carries SGR to `width` display columns, keeping the styling
|
||
* that is active up to the clip point and closing it with a reset. Never splits
|
||
* a code point, a combining sequence or an escape sequence, and never emits
|
||
* half of a double-width character (the cell is dropped instead).
|
||
*/
|
||
export function clipStyledLine(line: string, width: number): string {
|
||
if (width <= 0) return '';
|
||
let out = '';
|
||
let used = 0;
|
||
let active: string[] = [];
|
||
// Styles are emitted lazily, right before the character that wears them, so a
|
||
// sequence sitting exactly on the clip boundary is not carried into a line it
|
||
// no longer styles.
|
||
let emitted = '';
|
||
let i = 0;
|
||
while (i < line.length) {
|
||
const code = line.charCodeAt(i);
|
||
if (code === ESC) {
|
||
const scan = readEscape(line, i);
|
||
if (scan.sgr !== undefined) active = applySgr(active, scan.sgr);
|
||
i = scan.next;
|
||
continue;
|
||
}
|
||
if (isC1(code)) {
|
||
i = readC1(line, i);
|
||
continue;
|
||
}
|
||
if (code < 0x20 || code === DEL) {
|
||
i++;
|
||
continue;
|
||
}
|
||
const cp = line.codePointAt(i) as number;
|
||
const w = charWidth(cp);
|
||
if (used + w > width) break;
|
||
const style = active.join('');
|
||
if (style !== emitted) {
|
||
if (emitted !== '') out += SGR_RESET;
|
||
out += style;
|
||
emitted = style;
|
||
}
|
||
out += String.fromCodePoint(cp);
|
||
used += w;
|
||
i += cp > 0xffff ? 2 : 1;
|
||
}
|
||
return emitted !== '' ? out + SGR_RESET : out;
|
||
}
|
||
|
||
/**
|
||
* Pad or clip to exactly `width` display columns. A clip that lands on a
|
||
* double-width boundary leaves one column short, so the pad runs after it.
|
||
*/
|
||
export function padDisplay(text: string, width: number): string {
|
||
if (width <= 0) return '';
|
||
const w = visibleWidth(text);
|
||
if (w === width) return text;
|
||
if (w < width) return text + ' '.repeat(width - w);
|
||
const clipped = clipStyledLine(text, width);
|
||
return clipped + ' '.repeat(Math.max(0, width - visibleWidth(clipped)));
|
||
}
|