@nghyane/arcane-tui 0.1.8 → 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.8",
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(""));
@@ -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";
@@ -715,7 +725,7 @@ export class TUI extends Container {
715
725
  // Output only the changed cells
716
726
  let out = "\x1b[?2026h"; // Synchronized output
717
727
  out += "\x1b7"; // Save cursor position
718
- out += renderDiff(changes, width);
728
+ out += renderDiff(changes, width, this.#appBg);
719
729
  out += "\x1b8"; // Restore cursor position
720
730
  out += "\x1b[?2026l";
721
731
  this.terminal.write(out);
@@ -1046,8 +1056,12 @@ export class TUI extends Container {
1046
1056
  // No previous buffer — full render
1047
1057
  this.#fullRedrawCount += 1;
1048
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
+ }
1049
1063
  out += this.#clearScrollbackOnNextFullRender ? "\x1b[3J\x1b[2J\x1b[H" : "\x1b[2J\x1b[H";
1050
- const renderedLines = renderBuffer(currentBuffer);
1064
+ const renderedLines = renderBuffer(currentBuffer, this.#appBg);
1051
1065
  for (let i = 0; i < renderedLines.length; i++) {
1052
1066
  if (i > 0) out += "\r\n";
1053
1067
  out += renderedLines[i];
@@ -1083,7 +1097,7 @@ export class TUI extends Container {
1083
1097
  } else {
1084
1098
  // Emit minimal ANSI for changed cells
1085
1099
  let out = "\x1b[?2026h"; // Begin synchronized output
1086
- out += renderDiff(changes, width);
1100
+ out += renderDiff(changes, width, this.#appBg);
1087
1101
  const adjCursorPos = cursorPos
1088
1102
  ? { row: cursorPos.row - Math.max(0, newLines.length - height), col: cursorPos.col }
1089
1103
  : null;