@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@nghyane/arcane-tui",
4
- "version": "0.1.7",
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",
@@ -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
- parts.push("\x1b[49m");
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(""));
@@ -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
- // Calculate available width for content (subtract horizontal padding)
110
- const contentWidth = Math.max(1, width - this.#paddingX * 2);
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
- // Add top/bottom padding (empty lines)
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
- const quoteText = this.#renderInlineTokens(token.tokens || [], quoteStyleContext);
369
- const quoteLines = quoteText.split("\n");
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 quoteLines) {
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 += renderDiff(changes, width);
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 lineEnd = row === endRow ? endCol : visibleWidth(line);
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;