@nghyane/arcane-tui 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/buffer/render.ts +22 -6
- package/src/components/editor.ts +8 -0
- package/src/components/markdown.ts +87 -10
- package/src/index.ts +1 -1
- package/src/terminal.ts +50 -0
- package/src/tui.ts +25 -7
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@nghyane/arcane-tui",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.9",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/nghyane/arcane",
|
|
7
7
|
"author": "Can Bölük",
|
package/src/buffer/render.ts
CHANGED
|
@@ -5,7 +5,7 @@ function styleEquals(a: Style, b: Style): boolean {
|
|
|
5
5
|
return a.fg === b.fg && a.bg === b.bg && a.mods === b.mods && a.link === b.link;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
function emitStyleDiff(prev: Style, next: Style, parts: string[]): void {
|
|
8
|
+
function emitStyleDiff(prev: Style, next: Style, parts: string[], defaultBg = 0): void {
|
|
9
9
|
// Check if reset is simpler
|
|
10
10
|
const needReset =
|
|
11
11
|
(prev.mods & ~next.mods) !== 0 ||
|
|
@@ -23,6 +23,9 @@ function emitStyleDiff(prev: Style, next: Style, parts: string[]): void {
|
|
|
23
23
|
if (hasColor(next.bg)) {
|
|
24
24
|
const [r, g, b] = unpackRgb(next.bg);
|
|
25
25
|
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
26
|
+
} else if (defaultBg) {
|
|
27
|
+
const [r, g, b] = unpackRgb(defaultBg);
|
|
28
|
+
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
26
29
|
}
|
|
27
30
|
} else {
|
|
28
31
|
if (prev.fg !== next.fg) {
|
|
@@ -38,7 +41,12 @@ function emitStyleDiff(prev: Style, next: Style, parts: string[]): void {
|
|
|
38
41
|
const [r, g, b] = unpackRgb(next.bg);
|
|
39
42
|
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
40
43
|
} else {
|
|
41
|
-
|
|
44
|
+
if (defaultBg) {
|
|
45
|
+
const [r, g, b] = unpackRgb(defaultBg);
|
|
46
|
+
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
47
|
+
} else {
|
|
48
|
+
parts.push("\x1b[49m");
|
|
49
|
+
}
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
if (prev.mods !== next.mods) {
|
|
@@ -78,7 +86,7 @@ function emitMods(prev: number, next: number, parts: string[]): void {
|
|
|
78
86
|
if (removed & Mod.Strikethrough) parts.push("\x1b[29m");
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
export function renderDiff(changes: CellChange[], _width: number): string {
|
|
89
|
+
export function renderDiff(changes: CellChange[], _width: number, defaultBg = 0): string {
|
|
82
90
|
if (changes.length === 0) return "";
|
|
83
91
|
|
|
84
92
|
const sorted = [...changes].sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
|
@@ -104,7 +112,7 @@ export function renderDiff(changes: CellChange[], _width: number): string {
|
|
|
104
112
|
|
|
105
113
|
// Style changes
|
|
106
114
|
if (!styleEquals(curStyle, cell.style)) {
|
|
107
|
-
emitStyleDiff(curStyle, cell.style, parts);
|
|
115
|
+
emitStyleDiff(curStyle, cell.style, parts, defaultBg);
|
|
108
116
|
curStyle = { ...cell.style };
|
|
109
117
|
}
|
|
110
118
|
|
|
@@ -113,10 +121,14 @@ export function renderDiff(changes: CellChange[], _width: number): string {
|
|
|
113
121
|
}
|
|
114
122
|
|
|
115
123
|
parts.push("\x1b[0m");
|
|
124
|
+
if (defaultBg) {
|
|
125
|
+
const [r, g, b] = unpackRgb(defaultBg);
|
|
126
|
+
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
127
|
+
}
|
|
116
128
|
return parts.join("");
|
|
117
129
|
}
|
|
118
130
|
|
|
119
|
-
export function renderBuffer(buf: Buffer): string[] {
|
|
131
|
+
export function renderBuffer(buf: Buffer, defaultBg = 0): string[] {
|
|
120
132
|
const lines: string[] = [];
|
|
121
133
|
|
|
122
134
|
for (let row = 0; row < buf.height; row++) {
|
|
@@ -130,7 +142,7 @@ export function renderBuffer(buf: Buffer): string[] {
|
|
|
130
142
|
if (cell.width === 0) continue;
|
|
131
143
|
|
|
132
144
|
if (!styleEquals(curStyle, cell.style)) {
|
|
133
|
-
emitStyleDiff(curStyle, cell.style, parts);
|
|
145
|
+
emitStyleDiff(curStyle, cell.style, parts, defaultBg);
|
|
134
146
|
curStyle = { ...cell.style };
|
|
135
147
|
}
|
|
136
148
|
|
|
@@ -140,6 +152,10 @@ export function renderBuffer(buf: Buffer): string[] {
|
|
|
140
152
|
// Reset at end of line
|
|
141
153
|
if (!styleEquals(curStyle, DEFAULT_STYLE)) {
|
|
142
154
|
parts.push("\x1b[0m");
|
|
155
|
+
if (defaultBg) {
|
|
156
|
+
const [r, g, b] = unpackRgb(defaultBg);
|
|
157
|
+
parts.push(`\x1b[48;2;${r};${g};${b}m`);
|
|
158
|
+
}
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
lines.push(parts.join(""));
|
package/src/components/editor.ts
CHANGED
|
@@ -93,6 +93,10 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
93
93
|
|
|
94
94
|
// Skip leading whitespace at line start
|
|
95
95
|
if (atLineStart && token.isWhitespace) {
|
|
96
|
+
// Extend previous chunk's range to cover the skipped whitespace (no cursor gap)
|
|
97
|
+
if (chunks.length > 0) {
|
|
98
|
+
chunks[chunks.length - 1]!.endIndex = token.endIndex;
|
|
99
|
+
}
|
|
96
100
|
chunkStartIndex = token.endIndex;
|
|
97
101
|
continue;
|
|
98
102
|
}
|
|
@@ -162,6 +166,10 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
|
|
162
166
|
// Start new line - skip leading whitespace
|
|
163
167
|
atLineStart = true;
|
|
164
168
|
if (token.isWhitespace) {
|
|
169
|
+
// Extend the just-pushed chunk's range to cover this whitespace (no cursor gap)
|
|
170
|
+
if (chunks.length > 0) {
|
|
171
|
+
chunks[chunks.length - 1]!.endIndex = token.endIndex;
|
|
172
|
+
}
|
|
165
173
|
currentChunk = "";
|
|
166
174
|
currentWidth = 0;
|
|
167
175
|
chunkStartIndex = token.endIndex;
|
|
@@ -22,6 +22,8 @@ export interface DefaultTextStyle {
|
|
|
22
22
|
strikethrough?: boolean;
|
|
23
23
|
/** Underline text */
|
|
24
24
|
underline?: boolean;
|
|
25
|
+
leftBorder?: string;
|
|
26
|
+
/** Left border string (already styled, prepended to each line) */
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -106,8 +108,9 @@ export class Markdown implements Component {
|
|
|
106
108
|
return this.#cachedLines;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
+
const leftBorder = this.#defaultTextStyle?.leftBorder ?? "";
|
|
112
|
+
const borderWidth = visibleWidth(leftBorder);
|
|
113
|
+
const contentWidth = Math.max(1, width - this.#paddingX * 2 - borderWidth);
|
|
111
114
|
|
|
112
115
|
// Don't render anything if there's no actual text
|
|
113
116
|
if (!this.#text || this.#text.trim() === "") {
|
|
@@ -153,26 +156,23 @@ export class Markdown implements Component {
|
|
|
153
156
|
const contentLines: string[] = [];
|
|
154
157
|
|
|
155
158
|
for (const line of wrappedLines) {
|
|
156
|
-
// Image lines must be output raw - no margins or background
|
|
157
159
|
if (TERMINAL.isImageLine(line)) {
|
|
158
160
|
contentLines.push(line);
|
|
159
161
|
continue;
|
|
160
162
|
}
|
|
161
163
|
|
|
162
|
-
const lineWithMargins = leftMargin + line + rightMargin;
|
|
164
|
+
const lineWithMargins = leftBorder + leftMargin + line + rightMargin;
|
|
163
165
|
|
|
164
166
|
if (bgFn) {
|
|
165
167
|
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
|
|
166
168
|
} else {
|
|
167
|
-
// No background - just pad to width
|
|
168
169
|
const visibleLen = visibleWidth(lineWithMargins);
|
|
169
170
|
const paddingNeeded = Math.max(0, width - visibleLen);
|
|
170
171
|
contentLines.push(lineWithMargins + padding(paddingNeeded));
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
|
|
174
|
-
|
|
175
|
-
const emptyLine = padding(width);
|
|
175
|
+
const emptyLine = leftBorder + padding(width - borderWidth);
|
|
176
176
|
const emptyLines: string[] = [];
|
|
177
177
|
for (let i = 0; i < this.#paddingY; i++) {
|
|
178
178
|
const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;
|
|
@@ -365,13 +365,15 @@ export class Markdown implements Component {
|
|
|
365
365
|
applyText: quoteStyle,
|
|
366
366
|
stylePrefix: this.#getStylePrefix(quoteStyle),
|
|
367
367
|
};
|
|
368
|
-
|
|
369
|
-
|
|
368
|
+
|
|
369
|
+
// Blockquote tokens contain block-level children (paragraph, code, list, etc.)
|
|
370
|
+
// Render each child appropriately, then prefix every line with the quote border.
|
|
371
|
+
const quoteContentLines = this.#renderBlockquoteTokens(token.tokens || [], quoteStyleContext, width - 2);
|
|
370
372
|
|
|
371
373
|
// Calculate available width for quote content (subtract border + space = 2 chars)
|
|
372
374
|
const quoteContentWidth = Math.max(1, width - 2);
|
|
373
375
|
|
|
374
|
-
for (const quoteLine of
|
|
376
|
+
for (const quoteLine of quoteContentLines) {
|
|
375
377
|
// Wrap the styled line, then add border to each wrapped line
|
|
376
378
|
const wrappedLines = wrapTextWithAnsi(quoteLine, quoteContentWidth);
|
|
377
379
|
for (const wrappedLine of wrappedLines) {
|
|
@@ -601,6 +603,81 @@ export class Markdown implements Component {
|
|
|
601
603
|
return lines;
|
|
602
604
|
}
|
|
603
605
|
|
|
606
|
+
/**
|
|
607
|
+
* Render tokens inside a blockquote, handling block-level elements
|
|
608
|
+
* (code, list, heading) that #renderInlineTokens cannot process.
|
|
609
|
+
*/
|
|
610
|
+
#renderBlockquoteTokens(tokens: Token[], styleContext: InlineStyleContext, availableWidth: number): string[] {
|
|
611
|
+
const lines: string[] = [];
|
|
612
|
+
|
|
613
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
614
|
+
const token = tokens[i];
|
|
615
|
+
const nextToken = tokens[i + 1];
|
|
616
|
+
|
|
617
|
+
switch (token.type) {
|
|
618
|
+
case "paragraph": {
|
|
619
|
+
const text = this.#renderInlineTokens(token.tokens || [], styleContext);
|
|
620
|
+
lines.push(text);
|
|
621
|
+
if (nextToken && nextToken.type !== "space") {
|
|
622
|
+
lines.push("");
|
|
623
|
+
}
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
case "code": {
|
|
628
|
+
const codeIndent = padding(this.#codeBlockIndent);
|
|
629
|
+
lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
|
|
630
|
+
if (this.#theme.highlightCode) {
|
|
631
|
+
const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
|
|
632
|
+
for (const hlLine of highlightedLines) {
|
|
633
|
+
lines.push(`${codeIndent}${hlLine}`);
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
const codeLines = token.text.split("\n");
|
|
637
|
+
for (const codeLine of codeLines) {
|
|
638
|
+
lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
lines.push(this.#theme.codeBlockBorder("```"));
|
|
642
|
+
if (nextToken && nextToken.type !== "space") {
|
|
643
|
+
lines.push("");
|
|
644
|
+
}
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
case "heading": {
|
|
649
|
+
const headingLines = this.#renderToken(token, availableWidth, nextToken?.type);
|
|
650
|
+
lines.push(...headingLines);
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
case "list": {
|
|
655
|
+
const listLines = this.#renderList(token as any, 0);
|
|
656
|
+
lines.push(...listLines);
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
case "hr": {
|
|
661
|
+
lines.push(this.#theme.hr(this.#theme.symbols.hrChar.repeat(Math.min(availableWidth, 80))));
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
case "space":
|
|
666
|
+
lines.push("");
|
|
667
|
+
break;
|
|
668
|
+
|
|
669
|
+
default: {
|
|
670
|
+
const text = this.#renderInlineTokens([token], styleContext);
|
|
671
|
+
if (text) {
|
|
672
|
+
lines.push(text);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return lines;
|
|
679
|
+
}
|
|
680
|
+
|
|
604
681
|
/**
|
|
605
682
|
* Get the visible width of the longest word in a string.
|
|
606
683
|
*/
|
package/src/index.ts
CHANGED
|
@@ -59,7 +59,7 @@ export {
|
|
|
59
59
|
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer";
|
|
60
60
|
export type { BoxSymbols, SymbolTheme } from "./symbols";
|
|
61
61
|
// Terminal interface and implementations
|
|
62
|
-
export { emergencyTerminalRestore, ProcessTerminal, type Terminal } from "./terminal";
|
|
62
|
+
export { emergencyTerminalRestore, ProcessTerminal, queryTerminalBackground, type Terminal } from "./terminal";
|
|
63
63
|
// Terminal image support
|
|
64
64
|
export * from "./terminal-capabilities";
|
|
65
65
|
// TTY ID
|
package/src/terminal.ts
CHANGED
|
@@ -56,6 +56,56 @@ export function emergencyTerminalRestore(): void {
|
|
|
56
56
|
// Terminal may already be dead during crash cleanup - ignore errors
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Query terminal background color via OSC 11.
|
|
62
|
+
* Returns hex color string (e.g. "#1c1e26") or null if terminal does not respond.
|
|
63
|
+
* Must be called before TUI start() — temporarily enters raw mode.
|
|
64
|
+
*/
|
|
65
|
+
export async function queryTerminalBackground(timeoutMs = 150): Promise<string | null> {
|
|
66
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
|
|
67
|
+
|
|
68
|
+
const wasRaw = process.stdin.isRaw;
|
|
69
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
70
|
+
process.stdin.setEncoding("utf8");
|
|
71
|
+
process.stdin.resume();
|
|
72
|
+
|
|
73
|
+
const { promise, resolve } = Promise.withResolvers<string | null>();
|
|
74
|
+
let settled = false;
|
|
75
|
+
let buf = "";
|
|
76
|
+
|
|
77
|
+
const onData = (data: string) => {
|
|
78
|
+
buf += data;
|
|
79
|
+
// OSC 11 response: \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\ or ...\x07
|
|
80
|
+
const match = buf.match(/\x1b\]11;rgb:([0-9a-fA-F]{2,4})\/([0-9a-fA-F]{2,4})\/([0-9a-fA-F]{2,4})/);
|
|
81
|
+
if (match && !settled) {
|
|
82
|
+
settled = true;
|
|
83
|
+
const r = parseInt(match[1].slice(0, 2), 16);
|
|
84
|
+
const g = parseInt(match[2].slice(0, 2), 16);
|
|
85
|
+
const b = parseInt(match[3].slice(0, 2), 16);
|
|
86
|
+
resolve(
|
|
87
|
+
`#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
process.stdin.on("data", onData);
|
|
93
|
+
process.stdout.write("\x1b]11;?\x1b\\");
|
|
94
|
+
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
if (!settled) {
|
|
97
|
+
settled = true;
|
|
98
|
+
resolve(null);
|
|
99
|
+
}
|
|
100
|
+
}, timeoutMs);
|
|
101
|
+
|
|
102
|
+
const result = await promise;
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
process.stdin.removeListener("data", onData);
|
|
105
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(wasRaw);
|
|
106
|
+
if (!wasRaw) process.stdin.pause();
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
59
109
|
export interface Terminal {
|
|
60
110
|
// Start the terminal with input and resize handlers
|
|
61
111
|
start(onInput: (data: string) => void, onResize: () => void): void;
|
package/src/tui.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { linesToBuffer } from "./buffer/ansi-parser.js";
|
|
2
2
|
import type { Buffer as CellBuffer, CellChange } from "./buffer/buffer.js";
|
|
3
|
-
import { Mod } from "./buffer/cell.js";
|
|
3
|
+
import { Mod, unpackRgb } from "./buffer/cell.js";
|
|
4
4
|
import { renderBuffer, renderDiff } from "./buffer/render.js";
|
|
5
5
|
import { isKeyRelease, matchesKey } from "./keys";
|
|
6
6
|
import { type MouseEvent, SCROLL_DOWN, SCROLL_UP, type Terminal } from "./terminal";
|
|
@@ -222,6 +222,7 @@ export class TUI extends Container {
|
|
|
222
222
|
#scrollFlushScheduled = false; // Whether a flush is scheduled on next tick
|
|
223
223
|
#fullRenderCache: string[] = []; // Cached full render output for native scroll
|
|
224
224
|
#previousBuffer: CellBuffer | null = null; // Previous viewport buffer for cell-level diff
|
|
225
|
+
#appBg = 0; // Packed RGB for app-wide background (0 = terminal default)
|
|
225
226
|
|
|
226
227
|
// Selection state for mouse text selection
|
|
227
228
|
#selectionActive = false;
|
|
@@ -275,6 +276,11 @@ export class TUI extends Container {
|
|
|
275
276
|
this.#clearOnShrink = enabled;
|
|
276
277
|
}
|
|
277
278
|
|
|
279
|
+
setAppBg(packedRgb: number): void {
|
|
280
|
+
this.#appBg = packedRgb;
|
|
281
|
+
this.#previousBuffer = null; // Force full redraw
|
|
282
|
+
}
|
|
283
|
+
|
|
278
284
|
setFocus(component: Component | null): void {
|
|
279
285
|
// Clear focused flag on old component
|
|
280
286
|
if (isFocusable(this.#focusedComponent)) {
|
|
@@ -475,10 +481,14 @@ export class TUI extends Container {
|
|
|
475
481
|
if (this.#previousBuffer) {
|
|
476
482
|
const changes = currentBuffer.diff(this.#previousBuffer);
|
|
477
483
|
if (changes.length > 0) {
|
|
478
|
-
out += renderDiff(changes, width);
|
|
484
|
+
out += renderDiff(changes, width, this.#appBg);
|
|
479
485
|
}
|
|
480
486
|
} else {
|
|
481
487
|
// No previous buffer — full viewport redraw
|
|
488
|
+
if (this.#appBg) {
|
|
489
|
+
const [r, g, b] = unpackRgb(this.#appBg);
|
|
490
|
+
out += `\x1b[48;2;${r};${g};${b}m`;
|
|
491
|
+
}
|
|
482
492
|
out += "\x1b[H";
|
|
483
493
|
for (let i = 0; i < viewportLines.length; i++) {
|
|
484
494
|
if (i > 0) out += "\r\n";
|
|
@@ -646,6 +656,7 @@ export class TUI extends Container {
|
|
|
646
656
|
this.#selectionActive = false;
|
|
647
657
|
this.#selectionAnchor = null;
|
|
648
658
|
this.#selectionEnd = null;
|
|
659
|
+
this.requestRender();
|
|
649
660
|
}
|
|
650
661
|
|
|
651
662
|
#getSelectionRange(): { startRow: number; startCol: number; endRow: number; endCol: number } | null {
|
|
@@ -713,7 +724,9 @@ export class TUI extends Container {
|
|
|
713
724
|
|
|
714
725
|
// Output only the changed cells
|
|
715
726
|
let out = "\x1b[?2026h"; // Synchronized output
|
|
716
|
-
out +=
|
|
727
|
+
out += "\x1b7"; // Save cursor position
|
|
728
|
+
out += renderDiff(changes, width, this.#appBg);
|
|
729
|
+
out += "\x1b8"; // Restore cursor position
|
|
717
730
|
out += "\x1b[?2026l";
|
|
718
731
|
this.terminal.write(out);
|
|
719
732
|
}
|
|
@@ -733,11 +746,12 @@ export class TUI extends Container {
|
|
|
733
746
|
const line = cache[row];
|
|
734
747
|
if (TERMINAL.isImageLine(line)) continue;
|
|
735
748
|
const lineStart = row === startRow ? startCol : 0;
|
|
736
|
-
const
|
|
749
|
+
const isFullLine = row !== endRow;
|
|
750
|
+
const lineEnd = isFullLine ? visibleWidth(line) : endCol;
|
|
737
751
|
const sliced = sliceByColumn(line, lineStart, lineEnd - lineStart, true);
|
|
738
752
|
// Strip ANSI from the sliced segment
|
|
739
753
|
const plain = sliced.replace(/\x1b\[[^a-zA-Z]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b_[^\x07]*\x07/g, "");
|
|
740
|
-
textLines.push(plain);
|
|
754
|
+
textLines.push(isFullLine ? plain.trimEnd() : plain);
|
|
741
755
|
}
|
|
742
756
|
|
|
743
757
|
const text = textLines.join("\n");
|
|
@@ -1042,8 +1056,12 @@ export class TUI extends Container {
|
|
|
1042
1056
|
// No previous buffer — full render
|
|
1043
1057
|
this.#fullRedrawCount += 1;
|
|
1044
1058
|
let out = "\x1b[?2026h"; // Begin synchronized output
|
|
1059
|
+
if (this.#appBg) {
|
|
1060
|
+
const [r, g, b] = unpackRgb(this.#appBg);
|
|
1061
|
+
out += `\x1b[48;2;${r};${g};${b}m`;
|
|
1062
|
+
}
|
|
1045
1063
|
out += this.#clearScrollbackOnNextFullRender ? "\x1b[3J\x1b[2J\x1b[H" : "\x1b[2J\x1b[H";
|
|
1046
|
-
const renderedLines = renderBuffer(currentBuffer);
|
|
1064
|
+
const renderedLines = renderBuffer(currentBuffer, this.#appBg);
|
|
1047
1065
|
for (let i = 0; i < renderedLines.length; i++) {
|
|
1048
1066
|
if (i > 0) out += "\r\n";
|
|
1049
1067
|
out += renderedLines[i];
|
|
@@ -1079,7 +1097,7 @@ export class TUI extends Container {
|
|
|
1079
1097
|
} else {
|
|
1080
1098
|
// Emit minimal ANSI for changed cells
|
|
1081
1099
|
let out = "\x1b[?2026h"; // Begin synchronized output
|
|
1082
|
-
out += renderDiff(changes, width);
|
|
1100
|
+
out += renderDiff(changes, width, this.#appBg);
|
|
1083
1101
|
const adjCursorPos = cursorPos
|
|
1084
1102
|
? { row: cursorPos.row - Math.max(0, newLines.length - height), col: cursorPos.col }
|
|
1085
1103
|
: null;
|