@code-yeongyu/senpi-tui 2026.9.29 → 2026.10.1-2

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.
Files changed (151) hide show
  1. package/README.md +38 -0
  2. package/dist/autocomplete.d.ts +5 -0
  3. package/dist/autocomplete.js +38 -14
  4. package/dist/colors.d.ts +73 -0
  5. package/dist/colors.js +273 -0
  6. package/dist/components/box.js +5 -3
  7. package/dist/components/editor.d.ts +4 -1
  8. package/dist/components/editor.js +23 -8
  9. package/dist/components/latex.js +5 -0
  10. package/dist/editor-component.d.ts +7 -2
  11. package/dist/fuzzy.js +23 -22
  12. package/dist/index.d.ts +6 -3
  13. package/dist/index.js +5 -2
  14. package/dist/native-platform.d.ts +2 -0
  15. package/dist/oklab.d.ts +43 -0
  16. package/dist/oklab.js +210 -0
  17. package/dist/slash-command-autocomplete.js +3 -0
  18. package/dist/terminal-capabilities.js +1 -1
  19. package/dist/terminal-colors.d.ts +19 -2
  20. package/dist/terminal-colors.js +12 -7
  21. package/dist/terminal-image.d.ts +3 -1
  22. package/dist/terminal-image.js +33 -9
  23. package/dist/terminal.d.ts +3 -0
  24. package/dist/terminal.js +15 -8
  25. package/dist/tui-alt-screen.d.ts +12 -7
  26. package/dist/tui-alt-screen.js +37 -17
  27. package/dist/tui.d.ts +18 -17
  28. package/dist/tui.js +79 -62
  29. package/dist/utils.d.ts +3 -0
  30. package/dist/utils.js +108 -60
  31. package/dist/wheel-scroll.d.ts +23 -0
  32. package/dist/wheel-scroll.js +70 -0
  33. package/native/clipboard.h +40 -2
  34. package/native/darwin/README.md +1 -1
  35. package/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  36. package/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  37. package/native/darwin/src/darwin-platform.m +39 -0
  38. package/native/napi.h +2 -0
  39. package/package.json +3 -2
  40. package/dist/alt-screen-search.d.ts.map +0 -1
  41. package/dist/alt-screen-search.js.map +0 -1
  42. package/dist/autocomplete.d.ts.map +0 -1
  43. package/dist/autocomplete.js.map +0 -1
  44. package/dist/components/alt-screen-flash.d.ts.map +0 -1
  45. package/dist/components/alt-screen-flash.js.map +0 -1
  46. package/dist/components/box.d.ts.map +0 -1
  47. package/dist/components/box.js.map +0 -1
  48. package/dist/components/cancellable-loader.d.ts.map +0 -1
  49. package/dist/components/cancellable-loader.js.map +0 -1
  50. package/dist/components/editor-line-render.d.ts.map +0 -1
  51. package/dist/components/editor-line-render.js.map +0 -1
  52. package/dist/components/editor.d.ts.map +0 -1
  53. package/dist/components/editor.js.map +0 -1
  54. package/dist/components/h-stack.d.ts.map +0 -1
  55. package/dist/components/h-stack.js.map +0 -1
  56. package/dist/components/image.d.ts.map +0 -1
  57. package/dist/components/image.js.map +0 -1
  58. package/dist/components/input.d.ts.map +0 -1
  59. package/dist/components/input.js.map +0 -1
  60. package/dist/components/latex.d.ts.map +0 -1
  61. package/dist/components/latex.js.map +0 -1
  62. package/dist/components/loader.d.ts.map +0 -1
  63. package/dist/components/loader.js.map +0 -1
  64. package/dist/components/markdown.d.ts.map +0 -1
  65. package/dist/components/markdown.js.map +0 -1
  66. package/dist/components/mouse-region.d.ts.map +0 -1
  67. package/dist/components/mouse-region.js.map +0 -1
  68. package/dist/components/scroll-view.d.ts.map +0 -1
  69. package/dist/components/scroll-view.js.map +0 -1
  70. package/dist/components/select-list.d.ts.map +0 -1
  71. package/dist/components/select-list.js.map +0 -1
  72. package/dist/components/settings-list.d.ts.map +0 -1
  73. package/dist/components/settings-list.js.map +0 -1
  74. package/dist/components/spacer.d.ts.map +0 -1
  75. package/dist/components/spacer.js.map +0 -1
  76. package/dist/components/stack.d.ts.map +0 -1
  77. package/dist/components/stack.js.map +0 -1
  78. package/dist/components/text.d.ts.map +0 -1
  79. package/dist/components/text.js.map +0 -1
  80. package/dist/components/truncated-text.d.ts.map +0 -1
  81. package/dist/components/truncated-text.js.map +0 -1
  82. package/dist/components/v-stack.d.ts.map +0 -1
  83. package/dist/components/v-stack.js.map +0 -1
  84. package/dist/dollar-invocation-autocomplete.d.ts.map +0 -1
  85. package/dist/dollar-invocation-autocomplete.js.map +0 -1
  86. package/dist/editor-component.d.ts.map +0 -1
  87. package/dist/editor-component.js.map +0 -1
  88. package/dist/fuzzy.d.ts.map +0 -1
  89. package/dist/fuzzy.js.map +0 -1
  90. package/dist/image-markers.d.ts.map +0 -1
  91. package/dist/image-markers.js.map +0 -1
  92. package/dist/index.d.ts.map +0 -1
  93. package/dist/index.js.map +0 -1
  94. package/dist/keybindings.d.ts.map +0 -1
  95. package/dist/keybindings.js.map +0 -1
  96. package/dist/keys.d.ts.map +0 -1
  97. package/dist/keys.js.map +0 -1
  98. package/dist/kill-ring.d.ts.map +0 -1
  99. package/dist/kill-ring.js.map +0 -1
  100. package/dist/layout-node.d.ts.map +0 -1
  101. package/dist/layout-node.js.map +0 -1
  102. package/dist/layout.d.ts.map +0 -1
  103. package/dist/layout.js.map +0 -1
  104. package/dist/mouse-input.d.ts.map +0 -1
  105. package/dist/mouse-input.js.map +0 -1
  106. package/dist/mux.d.ts.map +0 -1
  107. package/dist/mux.js.map +0 -1
  108. package/dist/native-modifiers.d.ts.map +0 -1
  109. package/dist/native-modifiers.js.map +0 -1
  110. package/dist/native-module-path.d.ts.map +0 -1
  111. package/dist/native-module-path.js.map +0 -1
  112. package/dist/native-platform.d.ts.map +0 -1
  113. package/dist/native-platform.js.map +0 -1
  114. package/dist/paste-markers.d.ts.map +0 -1
  115. package/dist/paste-markers.js.map +0 -1
  116. package/dist/slash-command-autocomplete.d.ts.map +0 -1
  117. package/dist/slash-command-autocomplete.js.map +0 -1
  118. package/dist/stderr-observer.d.ts.map +0 -1
  119. package/dist/stderr-observer.js.map +0 -1
  120. package/dist/stdin-buffer.d.ts.map +0 -1
  121. package/dist/stdin-buffer.js.map +0 -1
  122. package/dist/terminal-capabilities.d.ts.map +0 -1
  123. package/dist/terminal-capabilities.js.map +0 -1
  124. package/dist/terminal-colors.d.ts.map +0 -1
  125. package/dist/terminal-colors.js.map +0 -1
  126. package/dist/terminal-image.d.ts.map +0 -1
  127. package/dist/terminal-image.js.map +0 -1
  128. package/dist/terminal-text.d.ts.map +0 -1
  129. package/dist/terminal-text.js.map +0 -1
  130. package/dist/terminal.d.ts.map +0 -1
  131. package/dist/terminal.js.map +0 -1
  132. package/dist/tmux-cursor-query.d.ts.map +0 -1
  133. package/dist/tmux-cursor-query.js.map +0 -1
  134. package/dist/tmux-focus.d.ts.map +0 -1
  135. package/dist/tmux-focus.js.map +0 -1
  136. package/dist/tmux-image-capability.d.ts.map +0 -1
  137. package/dist/tmux-image-capability.js.map +0 -1
  138. package/dist/tmux-image-probe.d.ts.map +0 -1
  139. package/dist/tmux-image-probe.js.map +0 -1
  140. package/dist/tui-alt-screen.d.ts.map +0 -1
  141. package/dist/tui-alt-screen.js.map +0 -1
  142. package/dist/tui-main-screen.d.ts.map +0 -1
  143. package/dist/tui-main-screen.js.map +0 -1
  144. package/dist/tui.d.ts.map +0 -1
  145. package/dist/tui.js.map +0 -1
  146. package/dist/undo-stack.d.ts.map +0 -1
  147. package/dist/undo-stack.js.map +0 -1
  148. package/dist/utils.d.ts.map +0 -1
  149. package/dist/utils.js.map +0 -1
  150. package/dist/word-navigation.d.ts.map +0 -1
  151. package/dist/word-navigation.js.map +0 -1
package/dist/terminal.js CHANGED
@@ -188,6 +188,8 @@ export class ProcessTerminal {
188
188
  this._kittyProtocolActive = false;
189
189
  this._modifyOtherKeysActive = false;
190
190
  this.keyboardProtocolPushed = false;
191
+ /** DA1 replies owed to keyboard protocol queries. Later DA1 replies answer other queries and are forwarded. */
192
+ this.pendingKeyboardProtocolDeviceAttributes = 0;
191
193
  this.keyboardProtocolNegotiationBuffer = "";
192
194
  this.discardingPrivateResponse = false;
193
195
  this.writeLogPath = (() => {
@@ -399,15 +401,15 @@ export class ProcessTerminal {
399
401
  return;
400
402
  }
401
403
  }
402
- const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence);
403
- if (negotiationSequence === "pending") {
404
+ const negotiation = this.readKeyboardProtocolNegotiationSequence(sequence);
405
+ if (negotiation === "pending") {
404
406
  this.scheduleKeyboardProtocolNegotiationBufferFlush();
405
407
  return; // Wait briefly for the rest of a split Kitty response.
406
408
  }
407
- if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) {
409
+ if (negotiation && this.handleKeyboardProtocolNegotiationSequence(negotiation.parsed)) {
408
410
  return;
409
411
  }
410
- this.forwardInputSequence(sequence);
412
+ this.forwardInputSequence(negotiation?.sequence ?? sequence);
411
413
  });
412
414
  // Re-wrap paste content with bracketed paste markers for existing editor handling
413
415
  this.stdinBuffer.on("paste", (content) => {
@@ -443,12 +445,11 @@ export class ProcessTerminal {
443
445
  this.enableModifyOtherKeys();
444
446
  }
445
447
  this.keyboardProtocolPushed = true;
448
+ this.pendingKeyboardProtocolDeviceAttributes += 1;
446
449
  this.clearKeyboardProtocolNegotiationBuffer();
447
450
  this.rawWrite(KITTY_KEYBOARD_PROTOCOL_QUERY);
448
451
  }
449
452
  handleKeyboardProtocolNegotiationSequence(negotiationSequence) {
450
- if (!negotiationSequence)
451
- return false;
452
453
  this.clearKeyboardProtocolNegotiationBuffer();
453
454
  if (negotiationSequence.type === "cursor-position") {
454
455
  if (this.cursorQuery?.issued && this.cursorQuery.tmuxPane === undefined) {
@@ -457,6 +458,11 @@ export class ProcessTerminal {
457
458
  }
458
459
  return true;
459
460
  }
461
+ if (negotiationSequence.type === "device-attributes") {
462
+ if (this.pendingKeyboardProtocolDeviceAttributes === 0)
463
+ return false;
464
+ this.pendingKeyboardProtocolDeviceAttributes -= 1;
465
+ }
460
466
  this.keyboardNegotiationSettled = true;
461
467
  this.issueCursorQuery();
462
468
  if (negotiationSequence.type === "kitty-flags") {
@@ -477,13 +483,14 @@ export class ProcessTerminal {
477
483
  }
478
484
  return true;
479
485
  }
486
+ /** Returns the parsed negotiation reply with its full (possibly reassembled) sequence. */
480
487
  readKeyboardProtocolNegotiationSequence(sequence) {
481
488
  if (this.keyboardProtocolNegotiationBuffer) {
482
489
  const bufferedSequence = this.keyboardProtocolNegotiationBuffer + sequence;
483
490
  const negotiationSequence = parseKeyboardProtocolNegotiationSequence(bufferedSequence);
484
491
  if (negotiationSequence) {
485
492
  this.clearKeyboardProtocolNegotiationBuffer();
486
- return negotiationSequence;
493
+ return { parsed: negotiationSequence, sequence: bufferedSequence };
487
494
  }
488
495
  if (isKeyboardProtocolNegotiationSequencePrefix(bufferedSequence)) {
489
496
  this.setKeyboardProtocolNegotiationBuffer(bufferedSequence);
@@ -493,7 +500,7 @@ export class ProcessTerminal {
493
500
  }
494
501
  const negotiationSequence = parseKeyboardProtocolNegotiationSequence(sequence);
495
502
  if (negotiationSequence)
496
- return negotiationSequence;
503
+ return { parsed: negotiationSequence, sequence };
497
504
  if (isKeyboardProtocolNegotiationSequencePrefix(sequence)) {
498
505
  this.setKeyboardProtocolNegotiationBuffer(sequence);
499
506
  return "pending";
@@ -1,8 +1,12 @@
1
1
  import type { Terminal } from "./terminal.ts";
2
2
  import { type Component, TuiBase, type TuiStopOptions, VIEWPORT_TUI, type ViewportTUI } from "./tui.ts";
3
+ import { type WheelScrollLines } from "./wheel-scroll.ts";
3
4
  export interface TuiAltScreenOptions {
4
- /** Number of logical lines moved for each mouse-wheel event. */
5
- wheelScrollLines?: number;
5
+ /**
6
+ * Logical lines moved for each mouse-wheel event (default: 1). `"auto"` accelerates fast wheel
7
+ * spins on terminals that send one event per notch. Alt+wheel moves five times as far.
8
+ */
9
+ wheelScrollLines?: WheelScrollLines;
6
10
  /** Capture mouse events for viewport scrolling and application-owned text selection. */
7
11
  mouse?: boolean;
8
12
  /** Style a non-current transcript search match. */
@@ -23,10 +27,11 @@ export interface TuiAltScreenOptions {
23
27
  /** Automatically copy selected text to the clipboard on mouse release (default: true). */
24
28
  copyOnSelect?: boolean;
25
29
  /**
26
- * Copy selected text to the system clipboard. Return `true` on success; the caller flashes
27
- * an error otherwise. When omitted, the selection is copied via an OSC 52 write.
30
+ * Copy selected text to the system clipboard. Return `true` on success, an error message to
31
+ * display on failure, or `false` for a generic error. When omitted, the selection is copied
32
+ * via an OSC 52 write.
28
33
  */
29
- copySelection?: (text: string) => Promise<boolean>;
34
+ copySelection?: (text: string) => Promise<boolean | string>;
30
35
  }
31
36
  /** Alternate-screen TUI with a scrollable, application-owned viewport. */
32
37
  export declare class TuiAltScreen extends TuiBase implements ViewportTUI {
@@ -65,7 +70,7 @@ export declare class TuiAltScreen extends TuiBase implements ViewportTUI {
65
70
  private mousePressPoint?;
66
71
  private mousePressMoved;
67
72
  private lastComponentClick?;
68
- private readonly wheelScrollLines;
73
+ private readonly wheelScroll;
69
74
  private readonly mouseEnabled;
70
75
  private readonly searchMatchStyle;
71
76
  private readonly searchCurrentMatchStyle;
@@ -78,6 +83,7 @@ export declare class TuiAltScreen extends TuiBase implements ViewportTUI {
78
83
  constructor(terminal: Terminal, showHardwareCursor?: boolean, logDirectory?: string, options?: TuiAltScreenOptions);
79
84
  get viewportTop(): number;
80
85
  get isFollowingOutput(): boolean;
86
+ setWheelScrollLines(lines: WheelScrollLines): void;
81
87
  getCopyOnSelect(): boolean;
82
88
  setCopyOnSelect(enabled: boolean): void;
83
89
  /** Whether the fullscreen viewport has a non-empty active text selection. */
@@ -119,7 +125,6 @@ export declare class TuiAltScreen extends TuiBase implements ViewportTUI {
119
125
  private clearTextSelection;
120
126
  private handleMouseEvent;
121
127
  private parseWheelEvent;
122
- private getWheelScrollLines;
123
128
  private routeWheel;
124
129
  private parseSgrMouseEvent;
125
130
  private handleRightClickPaste;
@@ -10,6 +10,7 @@ import { decodeMouseButton, isMouseSequence, parseSgrMouseEvent, parseWheelEvent
10
10
  import { deleteAllKittyImages, deleteAllKittyPlacements, deleteKittyImage, getCapabilities, getKittyImagePlacement, isImageLine, setCapabilities, } from "./terminal-image.js";
11
11
  import { Container, CURSOR_MARKER, compositeTuiLine, dispatchMouseEvent, retargetMouseEvent, TuiBase, VIEWPORT_TUI, } from "./tui.js";
12
12
  import { extractAnsiCode, getGraphemeCellRange, getOsc8LinkAtColumn, getWordSegmenter, sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, } from "./utils.js";
13
+ import { WheelScrollAccelerator } from "./wheel-scroll.js";
13
14
  const ENTER_ALT_SCREEN = "\x1b[?1049h";
14
15
  const EXIT_ALT_SCREEN = "\x1b[?1049l";
15
16
  const DISABLE_AUTOWRAP = "\x1b[?7l";
@@ -29,6 +30,7 @@ const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
29
30
  const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
30
31
  const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;
31
32
  const DOUBLE_CLICK_INTERVAL_MS = 500;
33
+ const COPY_ERROR_FLASH_DURATION_MS = 5000;
32
34
  // Regular mode delegates double-click selection to the terminal emulator. Fullscreen owns mouse selection,
33
35
  // so mirror common terminal word-selection behavior by keeping paths and kebab-case tokens whole.
34
36
  const TERMINAL_WORD_SELECTION_JOINERS = new Set(["/", "-"]);
@@ -62,7 +64,7 @@ export class TuiAltScreen extends TuiBase {
62
64
  };
63
65
  this.implicitScrollView = new ScrollView(this.implicitDocument, { follow: "end", primary: true });
64
66
  this.flashes = new AltScreenFlashContainer(() => this.requestRender());
65
- this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1));
67
+ this.wheelScroll = new WheelScrollAccelerator(options.wheelScrollLines ?? 1);
66
68
  this.mouseEnabled = options.mouse ?? true;
67
69
  this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`);
68
70
  this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`);
@@ -80,6 +82,9 @@ export class TuiAltScreen extends TuiBase {
80
82
  get isFollowingOutput() {
81
83
  return this.getPrimaryScrollView().isFollowingEnd;
82
84
  }
85
+ setWheelScrollLines(lines) {
86
+ this.wheelScroll.setLines(lines);
87
+ }
83
88
  getCopyOnSelect() {
84
89
  return this.copyOnSelect;
85
90
  }
@@ -459,9 +464,10 @@ export class TuiAltScreen extends TuiBase {
459
464
  return { consume: true };
460
465
  const wheelEvent = this.parseWheelEvent(data);
461
466
  if (wheelEvent) {
462
- const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, {
463
- wheelDelta: wheelEvent.direction * this.getWheelScrollLines(wheelEvent.button),
464
- });
467
+ const lines = this.wheelScroll.next(wheelEvent.direction, performance.now());
468
+ // SGR mouse button codes use bit 3 (value 8) for the Alt modifier.
469
+ const wheelDelta = wheelEvent.direction * ((wheelEvent.button & 8) !== 0 ? lines * ALT_WHEEL_SCROLL_MULTIPLIER : lines);
470
+ const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, { wheelDelta });
465
471
  const overlay = this.dispatchMouseToOverlay(event);
466
472
  const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
467
473
  if (result) {
@@ -471,7 +477,7 @@ export class TuiAltScreen extends TuiBase {
471
477
  }
472
478
  if (this.shouldDeferViewportInputToOverlay())
473
479
  return undefined;
474
- this.routeWheel(wheelEvent);
480
+ this.routeWheel(wheelEvent, wheelDelta);
475
481
  return { consume: true };
476
482
  }
477
483
  const mouseEvent = this.parseSgrMouseEvent(data);
@@ -721,12 +727,8 @@ export class TuiAltScreen extends TuiBase {
721
727
  parseWheelEvent(data) {
722
728
  return parseWheelEvent(data);
723
729
  }
724
- getWheelScrollLines(button) {
725
- // SGR mouse button codes use bit 3 (value 8) for the Alt modifier.
726
- return (button & 8) !== 0 ? this.wheelScrollLines * ALT_WHEEL_SCROLL_MULTIPLIER : this.wheelScrollLines;
727
- }
728
- routeWheel(event) {
729
- let remaining = event.direction * this.getWheelScrollLines(event.button);
730
+ routeWheel(event, delta) {
731
+ let remaining = delta;
730
732
  const seen = new Set();
731
733
  for (const scrollView of this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y) : []) {
732
734
  seen.add(scrollView);
@@ -1153,8 +1155,9 @@ export class TuiAltScreen extends TuiBase {
1153
1155
  // "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux
1154
1156
  // without OSC 52 clipboard passthrough), so only report success when it actually copies.
1155
1157
  if (this.copySelection) {
1156
- const ok = await this.copySelection(text);
1157
- this.flash(ok ? "Copied!" : "Copy failed");
1158
+ const result = await this.copySelection(text);
1159
+ const ok = result === true;
1160
+ this.flash(ok ? "Copied!" : typeof result === "string" ? result : "Copy failed", ok ? undefined : COPY_ERROR_FLASH_DURATION_MS);
1158
1161
  return ok;
1159
1162
  }
1160
1163
  this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
@@ -1329,12 +1332,15 @@ export class TuiAltScreen extends TuiBase {
1329
1332
  if (row >= screen.length || isImageLine(screen[row] ?? ""))
1330
1333
  return screen;
1331
1334
  const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined;
1332
- const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x);
1333
- const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, "");
1335
+ const label = truncateToWidth(this.scrollToEndIndicator(), clip.width, "");
1336
+ const labelWidth = visibleWidth(label);
1337
+ const column = clip.x + Math.floor((clip.width - labelWidth) / 2);
1338
+ const rightEdge = scrollbarColumn ?? clip.x + clip.width;
1339
+ const availableWidth = Math.max(0, rightEdge - column);
1340
+ const text = truncateToWidth(label, availableWidth, "");
1334
1341
  const textWidth = visibleWidth(text);
1335
1342
  if (textWidth === 0)
1336
1343
  return screen;
1337
- const column = clip.x + Math.floor((availableWidth - textWidth) / 2);
1338
1344
  const result = [...screen];
1339
1345
  result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width);
1340
1346
  this.scrollToEndIndicatorRect = { row, column, width: textWidth };
@@ -1402,10 +1408,24 @@ export class TuiAltScreen extends TuiBase {
1402
1408
  buffer += deleteAllKittyPlacements();
1403
1409
  }
1404
1410
  buffer += preparedKittyScreen.evictedImageDeletion;
1411
+ // WezTerm erases intersecting Kitty image cells when a later EL clears a covered row.
1412
+ // Only separate clearing from drawing for WezTerm frames that place images; preserve the
1413
+ // existing interleaved output for text-only frames and every other terminal.
1414
+ const clearRowsBeforeKittyImages = redrawImages &&
1415
+ this.imageProtocol === "kitty" &&
1416
+ screen.some(isImageLine) &&
1417
+ (Boolean(process.env.WEZTERM_PANE) || process.env.TERM_PROGRAM?.toLowerCase() === "wezterm");
1418
+ if (clearRowsBeforeKittyImages) {
1419
+ for (let row = 0; row < height; row++) {
1420
+ if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row])
1421
+ continue;
1422
+ buffer += `\x1b[${row + 1};1H\x1b[2K`;
1423
+ }
1424
+ }
1405
1425
  for (let row = 0; row < height; row++) {
1406
1426
  if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row])
1407
1427
  continue;
1408
- buffer += `\x1b[${row + 1};1H\x1b[2K${preparedKittyScreen.lines[row] ?? ""}`;
1428
+ buffer += `\x1b[${row + 1};1H${clearRowsBeforeKittyImages ? "" : "\x1b[2K"}${preparedKittyScreen.lines[row] ?? ""}`;
1409
1429
  }
1410
1430
  if (cursorPos) {
1411
1431
  buffer += `\x1b[${cursorPos.row + 1};${Math.min(width, cursorPos.col) + 1}H`;
package/dist/tui.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Minimal TUI implementation with differential rendering
3
3
  */
4
4
  import type { Terminal } from "./terminal.ts";
5
- import { type RgbColor, type TerminalColorScheme } from "./terminal-colors.ts";
5
+ import { type TerminalColorScheme, type TerminalColors } from "./terminal-colors.ts";
6
6
  import { visibleWidth } from "./utils.ts";
7
7
  /**
8
8
  * Component interface - all components must implement this
@@ -282,8 +282,11 @@ export declare abstract class TuiBase extends Container {
282
282
  private muxViewportRepaintCount;
283
283
  private overWideCrashDumpWritten;
284
284
  protected stopped: boolean;
285
- private pendingOsc11BackgroundReplies;
286
- private pendingOsc11BackgroundQueries;
285
+ /**
286
+ * Color queries waiting for their DA1 reply, oldest first. Terminals answer in order, so color
287
+ * replies belong to the oldest one. Queries stay here after a timeout to collect late replies.
288
+ */
289
+ private pendingTerminalColorQueries;
287
290
  private terminalColorSchemeListeners;
288
291
  private terminalColorSchemeNotificationsEnabled;
289
292
  /** Directory for debug/crash logs. The fork keeps a concrete default (`~/.senpi/agent`) so PI_DEBUG_REDRAW and crash dumps stay in the agent directory. */
@@ -401,7 +404,9 @@ export declare abstract class TuiBase extends Container {
401
404
  private scheduleRender;
402
405
  private commitExpeditedRender;
403
406
  private handleTerminalInput;
404
- private consumeOsc11BackgroundResponse;
407
+ private consumeTerminalColorResponse;
408
+ private terminalColorQueryResult;
409
+ private completeTerminalColorQuery;
405
410
  private consumeTerminalColorSchemeReport;
406
411
  private consumeCellSizeResponse;
407
412
  /**
@@ -468,21 +473,17 @@ export declare abstract class TuiBase extends Container {
468
473
  private finishFrame;
469
474
  private positionHardwareCursor;
470
475
  /**
471
- * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).
472
- * @param timeoutMs Query timeout in milliseconds.
473
- * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse.
474
- */
475
- queryTerminalBackgroundColor({ timeoutMs }: {
476
- timeoutMs: number;
477
- }): Promise<RgbColor | undefined>;
478
- /**
479
- * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`).
480
- * Terminals that support the color palette notification protocol reply with
481
- * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light.
476
+ * Query the terminal's theme colors: the default foreground (OSC 10), the default background
477
+ * (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the
478
+ * replies. Resolves when the DA1 reply or all color replies arrive, or when the timeout expires.
479
+ * Colors the terminal did not report are undefined; the palette is only set when all 16 arrived.
480
+ * @param timeoutMs Query timeout in milliseconds, for terminals that do not answer DA1 either.
481
+ * @param onLateReply Receives the replies if the query completes after the timeout, e.g. over slow links.
482
482
  */
483
- queryTerminalColorScheme({ timeoutMs }: {
483
+ queryTerminalColors({ timeoutMs, onLateReply, }: {
484
484
  timeoutMs: number;
485
- }): Promise<TerminalColorScheme | undefined>;
485
+ onLateReply?: (colors: TerminalColors) => void;
486
+ }): Promise<TerminalColors>;
486
487
  }
487
488
  /** Legacy main-screen renderer export. */
488
489
  export declare class TUI extends TuiBase {
package/dist/tui.js CHANGED
@@ -7,7 +7,7 @@ import * as path from "node:path";
7
7
  import { performance } from "node:perf_hooks";
8
8
  import { isKeyRelease, matchesKey } from "./keys.js";
9
9
  import { isMultiplexerSession, useLegacyMuxRender, viewportRenderEnabled } from "./mux.js";
10
- import { isOsc11BackgroundColorResponse, parseOsc11BackgroundColor, parseTerminalColorSchemeReport, } from "./terminal-colors.js";
10
+ import { parseOscColorResponse, parseTerminalColorSchemeReport, } from "./terminal-colors.js";
11
11
  import { deleteKittyImage, getCapabilities, isImageLine, resetCapabilitiesCache, setCellDimensions, } from "./terminal-image.js";
12
12
  import { consumeTmuxFocusEvent, DISABLE_FOCUS_REPORTING, ENABLE_FOCUS_REPORTING } from "./tmux-focus.js";
13
13
  import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
@@ -68,8 +68,13 @@ export function dispatchMouseEvent(component, event) {
68
68
  const result = component.handleMouse?.(event);
69
69
  if (!result)
70
70
  return undefined;
71
- if ("target" in result)
72
- return result;
71
+ if ("target" in result) {
72
+ // The component forwarded the event to a child it hosts. Like a delegating container, it routes
73
+ // keys to that child itself, so it keeps keyboard focus. Focusing the child directly would leave
74
+ // focus on a detached component once the host removes it, e.g. a closed settings submenu.
75
+ const forwarded = result;
76
+ return forwarded.focus && component.handleInput ? { ...forwarded, focusTarget: component } : forwarded;
77
+ }
73
78
  if (!result.handled && !result.capture && !result.focus)
74
79
  return undefined;
75
80
  return {
@@ -95,6 +100,16 @@ export function retargetMouseEvent(event, target) {
95
100
  height: target.height,
96
101
  };
97
102
  }
103
+ const TERMINAL_PALETTE_SIZE = 16;
104
+ /** OSC 10 and 11 plus OSC 4 for every palette color. */
105
+ const TERMINAL_COLOR_REPLY_COUNT = 2 + TERMINAL_PALETTE_SIZE;
106
+ /**
107
+ * Default colors, palette colors 0-15, and a trailing primary device attributes (DA1) request.
108
+ * Every terminal answers DA1 and terminals answer in order, so the DA1 reply marks the end of
109
+ * the color replies, including for terminals that ignore the color queries.
110
+ */
111
+ const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from({ length: TERMINAL_PALETTE_SIZE }, (_, index) => `\x1b]4;${index};?\x07`).join("")}\x1b[c`;
112
+ const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/;
98
113
  /** Type guard to check if a component implements Focusable */
99
114
  export function isFocusable(component) {
100
115
  return component !== null && "focused" in component;
@@ -422,8 +437,11 @@ export class TuiBase extends Container {
422
437
  this.muxViewportRepaintCount = 0;
423
438
  this.overWideCrashDumpWritten = false;
424
439
  this.stopped = false;
425
- this.pendingOsc11BackgroundReplies = 0;
426
- this.pendingOsc11BackgroundQueries = [];
440
+ /**
441
+ * Color queries waiting for their DA1 reply, oldest first. Terminals answer in order, so color
442
+ * replies belong to the oldest one. Queries stay here after a timeout to collect late replies.
443
+ */
444
+ this.pendingTerminalColorQueries = [];
427
445
  this.terminalColorSchemeListeners = new Set();
428
446
  this.terminalColorSchemeNotificationsEnabled = false;
429
447
  // Overlay stack for modal components rendered on top of base content
@@ -1075,7 +1093,10 @@ export class TuiBase extends Container {
1075
1093
  this.lastRenderAt = performance.now();
1076
1094
  this.doRender();
1077
1095
  }
1096
+ /** After stop(), the shell owns the cursor and it must stay visible, so only a running TUI hides it. */
1078
1097
  #setCursorVisibility(visible) {
1098
+ if (!visible && this.stopped)
1099
+ return;
1079
1100
  if (this.#lastCursorVisibility === visible)
1080
1101
  return;
1081
1102
  if (visible) {
@@ -1191,7 +1212,7 @@ export class TuiBase extends Container {
1191
1212
  data = focus.data;
1192
1213
  }
1193
1214
  }
1194
- if (this.consumeOsc11BackgroundResponse(data)) {
1215
+ if (this.consumeTerminalColorResponse(data)) {
1195
1216
  return;
1196
1217
  }
1197
1218
  if (this.consumeTerminalColorSchemeReport(data)) {
@@ -1262,27 +1283,50 @@ export class TuiBase extends Container {
1262
1283
  this.requestRender(false, "input");
1263
1284
  }
1264
1285
  }
1265
- consumeOsc11BackgroundResponse(data) {
1266
- if (this.pendingOsc11BackgroundReplies <= 0) {
1286
+ consumeTerminalColorResponse(data) {
1287
+ const query = this.pendingTerminalColorQueries[0];
1288
+ if (!query) {
1267
1289
  return false;
1268
1290
  }
1269
- if (!isOsc11BackgroundColorResponse(data)) {
1291
+ if (DEVICE_ATTRIBUTES_RESPONSE_PATTERN.test(data)) {
1292
+ this.pendingTerminalColorQueries.shift();
1293
+ this.completeTerminalColorQuery(query);
1294
+ return true;
1295
+ }
1296
+ const response = parseOscColorResponse(data);
1297
+ if (!response) {
1270
1298
  return false;
1271
1299
  }
1272
- const rgb = parseOsc11BackgroundColor(data);
1273
- this.pendingOsc11BackgroundReplies -= 1;
1274
- const query = this.pendingOsc11BackgroundQueries.shift();
1275
- if (query && !query.settled) {
1276
- query.settled = true;
1277
- if (query.timer) {
1278
- clearTimeout(query.timer);
1279
- query.timer = undefined;
1280
- }
1281
- query.resolve?.(rgb);
1282
- query.resolve = undefined;
1300
+ const { target, rgb } = response;
1301
+ const key = String(target);
1302
+ if (!query.deliver || query.replied.has(key)) {
1303
+ return true;
1304
+ }
1305
+ query.replied.add(key);
1306
+ if (target === "foreground") {
1307
+ query.foreground = rgb;
1308
+ }
1309
+ else if (target === "background") {
1310
+ query.background = rgb;
1311
+ }
1312
+ else if (target < TERMINAL_PALETTE_SIZE) {
1313
+ query.palette[target] = rgb;
1314
+ }
1315
+ if (query.replied.size === TERMINAL_COLOR_REPLY_COUNT) {
1316
+ this.completeTerminalColorQuery(query);
1283
1317
  }
1284
1318
  return true;
1285
1319
  }
1320
+ terminalColorQueryResult(query) {
1321
+ const palette = query.palette.every((color) => color !== undefined) ? query.palette : undefined;
1322
+ return { foreground: query.foreground, background: query.background, palette };
1323
+ }
1324
+ completeTerminalColorQuery(query) {
1325
+ const deliver = query.deliver;
1326
+ query.deliver = undefined;
1327
+ clearTimeout(query.timer);
1328
+ deliver?.(this.terminalColorQueryResult(query));
1329
+ }
1286
1330
  consumeTerminalColorSchemeReport(data) {
1287
1331
  const scheme = parseTerminalColorSchemeReport(data);
1288
1332
  if (!scheme) {
@@ -2403,55 +2447,28 @@ export class TuiBase extends Container {
2403
2447
  this.terminal.write(TUI.FRAME_BEGIN + buffer + TUI.FRAME_END);
2404
2448
  }
2405
2449
  /**
2406
- * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).
2407
- * @param timeoutMs Query timeout in milliseconds.
2408
- * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse.
2450
+ * Query the terminal's theme colors: the default foreground (OSC 10), the default background
2451
+ * (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the
2452
+ * replies. Resolves when the DA1 reply or all color replies arrive, or when the timeout expires.
2453
+ * Colors the terminal did not report are undefined; the palette is only set when all 16 arrived.
2454
+ * @param timeoutMs Query timeout in milliseconds, for terminals that do not answer DA1 either.
2455
+ * @param onLateReply Receives the replies if the query completes after the timeout, e.g. over slow links.
2409
2456
  */
2410
- queryTerminalBackgroundColor({ timeoutMs }) {
2457
+ queryTerminalColors({ timeoutMs, onLateReply, }) {
2411
2458
  return new Promise((resolve) => {
2412
2459
  const query = {
2413
- settled: false,
2414
- resolve,
2460
+ palette: Array.from({ length: TERMINAL_PALETTE_SIZE }, () => undefined),
2461
+ replied: new Set(),
2462
+ deliver: resolve,
2415
2463
  timer: undefined,
2416
2464
  };
2465
+ // Resolve with the replies so far, and keep collecting late replies for `onLateReply`.
2417
2466
  query.timer = setTimeout(() => {
2418
- if (query.settled) {
2419
- return;
2420
- }
2421
- query.settled = true;
2422
- query.timer = undefined;
2423
- query.resolve?.(undefined);
2424
- query.resolve = undefined;
2467
+ query.deliver = onLateReply;
2468
+ resolve(this.terminalColorQueryResult(query));
2425
2469
  }, timeoutMs);
2426
- this.pendingOsc11BackgroundQueries.push(query);
2427
- this.pendingOsc11BackgroundReplies += 1;
2428
- this.terminal.write("\x1b]11;?\x07");
2429
- });
2430
- }
2431
- /**
2432
- * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`).
2433
- * Terminals that support the color palette notification protocol reply with
2434
- * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light.
2435
- */
2436
- queryTerminalColorScheme({ timeoutMs }) {
2437
- return new Promise((resolve) => {
2438
- let settled = false;
2439
- let timer;
2440
- let unsubscribe = () => { };
2441
- const settle = (scheme) => {
2442
- if (settled)
2443
- return;
2444
- settled = true;
2445
- if (timer) {
2446
- clearTimeout(timer);
2447
- timer = undefined;
2448
- }
2449
- unsubscribe();
2450
- resolve(scheme);
2451
- };
2452
- unsubscribe = this.onTerminalColorSchemeChange(settle);
2453
- timer = setTimeout(() => settle(undefined), timeoutMs);
2454
- this.terminal.write("\x1b[?996n");
2470
+ this.pendingTerminalColorQueries.push(query);
2471
+ this.terminal.write(TERMINAL_COLOR_QUERY);
2455
2472
  });
2456
2473
  }
2457
2474
  }
package/dist/utils.d.ts CHANGED
@@ -15,6 +15,9 @@ interface WidthCacheStats {
15
15
  }
16
16
  export declare function __widthCacheStats(): WidthCacheStats | undefined;
17
17
  export declare const cjkBreakRegex: RegExp;
18
+ export declare const cjkPunctuationRegex: RegExp;
19
+ export declare const autocompleteSeparatorRegex: RegExp;
20
+ export declare const autocompleteBoundaryRegex: RegExp;
18
21
  /**
19
22
  * Calculate the visible width of a string in terminal columns.
20
23
  */