@draht/tui 2026.6.11 → 2026.7.7-1

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/dist/tui.js CHANGED
@@ -6,28 +6,42 @@ import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { performance } from "node:perf_hooks";
8
8
  import { isKeyRelease, matchesKey } from "./keys.js";
9
+ import { isOsc11BackgroundColorResponse, parseOsc11BackgroundColor, parseTerminalColorSchemeReport, } from "./terminal-colors.js";
9
10
  import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
10
11
  import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
11
12
  const KITTY_SEQUENCE_PREFIX = "\x1b_G";
12
- function extractKittyImageIds(line) {
13
+ function parseKittyImageHeader(line) {
13
14
  const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
14
15
  if (sequenceStart === -1)
15
- return [];
16
+ return undefined;
16
17
  const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
17
18
  const paramsEnd = line.indexOf(";", paramsStart);
18
19
  if (paramsEnd === -1)
19
- return [];
20
+ return undefined;
21
+ const ids = [];
22
+ let rows = 1;
20
23
  const params = line.slice(paramsStart, paramsEnd);
21
24
  for (const param of params.split(",")) {
22
25
  const [key, value] = param.split("=", 2);
23
- if (key !== "i" || value === undefined)
26
+ if (value === undefined)
27
+ continue;
28
+ const numberValue = Number(value);
29
+ if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff)
24
30
  continue;
25
- const id = Number(value);
26
- if (Number.isInteger(id) && id > 0 && id <= 0xffffffff) {
27
- return [id];
31
+ if (key === "i") {
32
+ ids.push(numberValue);
33
+ }
34
+ else if (key === "r") {
35
+ rows = numberValue;
28
36
  }
29
37
  }
30
- return [];
38
+ return { ids, rows };
39
+ }
40
+ function extractKittyImageIds(line) {
41
+ return parseKittyImageHeader(line)?.ids ?? [];
42
+ }
43
+ function extractKittyImageRows(line) {
44
+ return parseKittyImageHeader(line)?.rows ?? 1;
31
45
  }
32
46
  /** Type guard to check if a component implements Focusable */
33
47
  export function isFocusable(component) {
@@ -115,6 +129,10 @@ export class TUI extends Container {
115
129
  previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
116
130
  fullRedrawCount = 0;
117
131
  stopped = false;
132
+ pendingOsc11BackgroundReplies = 0;
133
+ pendingOsc11BackgroundQueries = [];
134
+ terminalColorSchemeListeners = new Set();
135
+ terminalColorSchemeNotificationsEnabled = false;
118
136
  // Overlay stack for modal components rendered on top of base content
119
137
  focusOrderCounter = 0;
120
138
  overlayStack = [];
@@ -414,6 +432,9 @@ export class TUI extends Container {
414
432
  this.stopped = false;
415
433
  this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
416
434
  this.terminal.hideCursor();
435
+ if (this.terminalColorSchemeNotificationsEnabled) {
436
+ this.terminal.write("\x1b[?2031h");
437
+ }
417
438
  this.queryCellSize();
418
439
  this.requestRender();
419
440
  }
@@ -426,6 +447,21 @@ export class TUI extends Container {
426
447
  removeInputListener(listener) {
427
448
  this.inputListeners.delete(listener);
428
449
  }
450
+ onTerminalColorSchemeChange(listener) {
451
+ this.terminalColorSchemeListeners.add(listener);
452
+ return () => {
453
+ this.terminalColorSchemeListeners.delete(listener);
454
+ };
455
+ }
456
+ setTerminalColorSchemeNotifications(enabled) {
457
+ if (this.terminalColorSchemeNotificationsEnabled === enabled) {
458
+ return;
459
+ }
460
+ this.terminalColorSchemeNotificationsEnabled = enabled;
461
+ if (!this.stopped) {
462
+ this.terminal.write(enabled ? "\x1b[?2031h" : "\x1b[?2031l");
463
+ }
464
+ }
429
465
  queryCellSize() {
430
466
  // Only query if terminal supports images (cell size is only used for image rendering)
431
467
  if (!getCapabilities().images) {
@@ -441,6 +477,9 @@ export class TUI extends Container {
441
477
  clearTimeout(this.renderTimer);
442
478
  this.renderTimer = undefined;
443
479
  }
480
+ if (this.terminalColorSchemeNotificationsEnabled) {
481
+ this.terminal.write("\x1b[?2031l");
482
+ }
444
483
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
445
484
  if (this.previousLines.length > 0) {
446
485
  const targetRow = this.previousLines.length; // Line after the last content
@@ -505,6 +544,12 @@ export class TUI extends Container {
505
544
  }, delay);
506
545
  }
507
546
  handleInput(data) {
547
+ if (this.consumeOsc11BackgroundResponse(data)) {
548
+ return;
549
+ }
550
+ if (this.consumeTerminalColorSchemeReport(data)) {
551
+ return;
552
+ }
508
553
  if (this.inputListeners.size > 0) {
509
554
  let current = data;
510
555
  for (const listener of this.inputListeners) {
@@ -570,6 +615,37 @@ export class TUI extends Container {
570
615
  this.requestRender();
571
616
  }
572
617
  }
618
+ consumeOsc11BackgroundResponse(data) {
619
+ if (this.pendingOsc11BackgroundReplies <= 0) {
620
+ return false;
621
+ }
622
+ if (!isOsc11BackgroundColorResponse(data)) {
623
+ return false;
624
+ }
625
+ const rgb = parseOsc11BackgroundColor(data);
626
+ this.pendingOsc11BackgroundReplies -= 1;
627
+ const query = this.pendingOsc11BackgroundQueries.shift();
628
+ if (query && !query.settled) {
629
+ query.settled = true;
630
+ if (query.timer) {
631
+ clearTimeout(query.timer);
632
+ query.timer = undefined;
633
+ }
634
+ query.resolve?.(rgb);
635
+ query.resolve = undefined;
636
+ }
637
+ return true;
638
+ }
639
+ consumeTerminalColorSchemeReport(data) {
640
+ const scheme = parseTerminalColorSchemeReport(data);
641
+ if (!scheme) {
642
+ return false;
643
+ }
644
+ for (const listener of this.terminalColorSchemeListeners) {
645
+ listener(scheme);
646
+ }
647
+ return true;
648
+ }
573
649
  consumeCellSizeResponse(data) {
574
650
  // Response format: ESC [ 6 ; height ; width t
575
651
  const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
@@ -789,14 +865,37 @@ export class TUI extends Container {
789
865
  }
790
866
  return buffer;
791
867
  }
792
- expandLastChangedForKittyImages(firstChanged, lastChanged) {
868
+ getKittyImageReservedRows(lines, index, maxIndex = lines.length - 1) {
869
+ const rows = extractKittyImageRows(lines[index] ?? "");
870
+ if (rows <= 1)
871
+ return 1;
872
+ const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index);
873
+ let reservedRows = 1;
874
+ while (reservedRows < maxRows) {
875
+ const line = lines[index + reservedRows] ?? "";
876
+ if (isImageLine(line) || visibleWidth(line) > 0)
877
+ break;
878
+ reservedRows++;
879
+ }
880
+ return reservedRows;
881
+ }
882
+ expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines) {
883
+ let expandedFirstChanged = firstChanged;
793
884
  let expandedLastChanged = lastChanged;
794
- for (let i = firstChanged; i < this.previousLines.length; i++) {
795
- if (extractKittyImageIds(this.previousLines[i]).length > 0) {
796
- expandedLastChanged = Math.max(expandedLastChanged, i);
885
+ const expandForLines = (lines) => {
886
+ for (let i = 0; i < lines.length; i++) {
887
+ if (extractKittyImageIds(lines[i]).length === 0)
888
+ continue;
889
+ const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1;
890
+ if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) {
891
+ expandedFirstChanged = Math.min(expandedFirstChanged, i);
892
+ expandedLastChanged = Math.max(expandedLastChanged, blockEnd);
893
+ }
797
894
  }
798
- }
799
- return expandedLastChanged;
895
+ };
896
+ expandForLines(this.previousLines);
897
+ expandForLines(newLines);
898
+ return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged };
800
899
  }
801
900
  deleteChangedKittyImages(firstChanged, lastChanged) {
802
901
  if (firstChanged < 0 || lastChanged < firstChanged)
@@ -910,7 +1009,20 @@ export class TUI extends Container {
910
1009
  for (let i = 0; i < newLines.length; i++) {
911
1010
  if (i > 0)
912
1011
  buffer += "\r\n";
913
- buffer += newLines[i];
1012
+ const line = newLines[i];
1013
+ const isImage = isImageLine(line);
1014
+ const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;
1015
+ if (imageReservedRows > 1 && imageReservedRows <= height) {
1016
+ for (let row = 1; row < imageReservedRows; row++) {
1017
+ buffer += "\r\n";
1018
+ }
1019
+ buffer += `\x1b[${imageReservedRows - 1}A`;
1020
+ buffer += line;
1021
+ buffer += `\x1b[${imageReservedRows - 1}B`;
1022
+ i += imageReservedRows - 1;
1023
+ continue;
1024
+ }
1025
+ buffer += line;
914
1026
  }
915
1027
  buffer += "\x1b[?2026l"; // End synchronized output
916
1028
  this.terminal.write(buffer);
@@ -989,7 +1101,9 @@ export class TUI extends Container {
989
1101
  lastChanged = newLines.length - 1;
990
1102
  }
991
1103
  if (firstChanged !== -1) {
992
- lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
1104
+ const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);
1105
+ firstChanged = expandedRange.firstChanged;
1106
+ lastChanged = expandedRange.lastChanged;
993
1107
  }
994
1108
  const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
995
1109
  // No changes - but still need to update hardware cursor position if it moved
@@ -1090,9 +1204,27 @@ export class TUI extends Container {
1090
1204
  for (let i = firstChanged; i <= renderEnd; i++) {
1091
1205
  if (i > firstChanged)
1092
1206
  buffer += "\r\n";
1093
- buffer += "\x1b[2K"; // Clear current line
1094
1207
  const line = newLines[i];
1095
1208
  const isImage = isImageLine(line);
1209
+ const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1;
1210
+ if (imageReservedRows > 1) {
1211
+ const imageStartScreenRow = i - viewportTop;
1212
+ if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) {
1213
+ logRedraw(`kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`);
1214
+ fullRender(true);
1215
+ return;
1216
+ }
1217
+ buffer += "\x1b[2K";
1218
+ for (let row = 1; row < imageReservedRows; row++) {
1219
+ buffer += "\r\n\x1b[2K";
1220
+ }
1221
+ buffer += `\x1b[${imageReservedRows - 1}A`;
1222
+ buffer += line;
1223
+ buffer += `\x1b[${imageReservedRows - 1}B`;
1224
+ i += imageReservedRows - 1;
1225
+ continue;
1226
+ }
1227
+ buffer += "\x1b[2K"; // Clear current line
1096
1228
  if (!isImage && visibleWidth(line) > width) {
1097
1229
  // Log all lines to crash file for debugging
1098
1230
  const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log");
@@ -1219,5 +1351,57 @@ export class TUI extends Container {
1219
1351
  this.terminal.hideCursor();
1220
1352
  }
1221
1353
  }
1354
+ /**
1355
+ * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).
1356
+ * @param timeoutMs Query timeout in milliseconds.
1357
+ * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse.
1358
+ */
1359
+ queryTerminalBackgroundColor({ timeoutMs }) {
1360
+ return new Promise((resolve) => {
1361
+ const query = {
1362
+ settled: false,
1363
+ resolve,
1364
+ timer: undefined,
1365
+ };
1366
+ query.timer = setTimeout(() => {
1367
+ if (query.settled) {
1368
+ return;
1369
+ }
1370
+ query.settled = true;
1371
+ query.timer = undefined;
1372
+ query.resolve?.(undefined);
1373
+ query.resolve = undefined;
1374
+ }, timeoutMs);
1375
+ this.pendingOsc11BackgroundQueries.push(query);
1376
+ this.pendingOsc11BackgroundReplies += 1;
1377
+ this.terminal.write("\x1b]11;?\x07");
1378
+ });
1379
+ }
1380
+ /**
1381
+ * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`).
1382
+ * Terminals that support the color palette notification protocol reply with
1383
+ * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light.
1384
+ */
1385
+ queryTerminalColorScheme({ timeoutMs }) {
1386
+ return new Promise((resolve) => {
1387
+ let settled = false;
1388
+ let timer;
1389
+ let unsubscribe = () => { };
1390
+ const settle = (scheme) => {
1391
+ if (settled)
1392
+ return;
1393
+ settled = true;
1394
+ if (timer) {
1395
+ clearTimeout(timer);
1396
+ timer = undefined;
1397
+ }
1398
+ unsubscribe();
1399
+ resolve(scheme);
1400
+ };
1401
+ unsubscribe = this.onTerminalColorSchemeChange(settle);
1402
+ timer = setTimeout(() => settle(undefined), timeoutMs);
1403
+ this.terminal.write("\x1b[?996n");
1404
+ });
1405
+ }
1222
1406
  }
1223
1407
  //# sourceMappingURL=tui.js.map