@sayknow-cli/tui 0.3.13 → 0.3.16

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 (34) hide show
  1. package/dist/types/animation-scheduler.d.ts +13 -0
  2. package/dist/types/autocomplete.d.ts +83 -0
  3. package/dist/types/bracketed-paste.d.ts +26 -0
  4. package/dist/types/components/box.d.ts +20 -0
  5. package/dist/types/components/cancellable-loader.d.ts +21 -0
  6. package/dist/types/components/editor.d.ts +126 -0
  7. package/dist/types/components/image.d.ts +18 -0
  8. package/dist/types/components/input.d.ts +16 -0
  9. package/dist/types/components/loader.d.ts +23 -0
  10. package/dist/types/components/markdown.d.ts +87 -0
  11. package/dist/types/components/sayknow-pet.d.ts +128 -0
  12. package/dist/types/components/select-list.d.ts +46 -0
  13. package/dist/types/components/settings-list.d.ts +39 -0
  14. package/dist/types/components/spacer.d.ts +11 -0
  15. package/dist/types/components/tab-bar.d.ts +56 -0
  16. package/dist/types/components/text.d.ts +22 -0
  17. package/dist/types/components/truncated-text.d.ts +10 -0
  18. package/dist/types/editor-component.d.ts +36 -0
  19. package/dist/types/fuzzy.d.ts +15 -0
  20. package/dist/types/index.d.ts +28 -0
  21. package/dist/types/keybindings.d.ts +201 -0
  22. package/dist/types/keys.d.ts +208 -0
  23. package/dist/types/kill-ring.d.ts +27 -0
  24. package/dist/types/metrics.d.ts +85 -0
  25. package/dist/types/stdin-buffer.d.ts +50 -0
  26. package/dist/types/symbols.d.ts +23 -0
  27. package/dist/types/terminal-capabilities.d.ts +187 -0
  28. package/dist/types/terminal.d.ts +90 -0
  29. package/dist/types/ttyid.d.ts +9 -0
  30. package/dist/types/tui.d.ts +269 -0
  31. package/dist/types/utils.d.ts +110 -0
  32. package/package.json +9 -8
  33. package/src/terminal-capabilities.ts +77 -4
  34. package/src/tui.ts +76 -29
package/src/tui.ts CHANGED
@@ -9,7 +9,14 @@ import { getKeybindings } from "./keybindings";
9
9
  import { isKeyRelease } from "./keys";
10
10
  import { renderMetrics } from "./metrics";
11
11
  import type { Terminal } from "./terminal";
12
- import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
12
+ import {
13
+ ImageProtocol,
14
+ isImageProtocolForced,
15
+ isUnderTerminalMultiplexer,
16
+ setCellDimensions,
17
+ setTerminalImageProtocol,
18
+ TERMINAL,
19
+ } from "./terminal-capabilities";
13
20
  import {
14
21
  Ellipsis,
15
22
  extractSegments,
@@ -217,7 +224,6 @@ function isTermuxSession(env: Record<string, string | undefined> = Bun.env): boo
217
224
  return Boolean(env.TERMUX_VERSION);
218
225
  }
219
226
 
220
- const SKC_TMUX_LAUNCHED_ENV = "SKC_TMUX_LAUNCHED";
221
227
  const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
222
228
  const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "y"]);
223
229
 
@@ -231,25 +237,38 @@ function envFlagEnabled(value: string | undefined): boolean {
231
237
  return normalized !== undefined && TRUTHY_ENV_VALUES.has(normalized);
232
238
  }
233
239
 
234
- function termLooksMultiplexed(value: string | undefined): boolean {
235
- const term = value?.trim().toLowerCase() ?? "";
236
- return term.startsWith("tmux") || term.startsWith("screen");
237
- }
238
-
239
240
  function isWindowsTerminalSession(env: Record<string, string | undefined> = Bun.env): boolean {
240
241
  return envIsEnabled(env.WT_SESSION) || env.TERM_PROGRAM === "Windows_Terminal";
241
242
  }
242
243
 
243
- /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
244
+ /**
245
+ * Detect terminal multiplexers where scrollback clearing and height-change
246
+ * redraws are hostile. Delegates to the shared capability predicate so the
247
+ * renderer and graphics-protocol selection agree on what counts as a
248
+ * multiplexed host.
249
+ */
244
250
  function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env): boolean {
245
- return Boolean(
246
- envIsEnabled(env.TMUX) ||
247
- envIsEnabled(env.TMUX_PANE) ||
248
- envIsEnabled(env.STY) ||
249
- envIsEnabled(env.ZELLIJ) ||
250
- envIsEnabled(env[SKC_TMUX_LAUNCHED_ENV]) ||
251
- termLooksMultiplexed(env.TERM),
252
- );
251
+ return isUnderTerminalMultiplexer(env as NodeJS.ProcessEnv);
252
+ }
253
+
254
+ /**
255
+ * Startup sixel capability probe policy (pure; exported for tests):
256
+ * - Never probe when PI_FORCE_IMAGE_PROTOCOL is set — an explicit
257
+ * configuration (including "off") is authoritative.
258
+ * - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
259
+ * whenever it was compiled with sixel support, regardless of whether the
260
+ * attached client terminal can render sixel, so a positive reply is not
261
+ * end-to-end evidence. Graphics under a multiplexer are strictly opt-in
262
+ * via PI_FORCE_IMAGE_PROTOCOL=sixel.
263
+ * - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
264
+ */
265
+ export function shouldProbeSixelCapability(
266
+ env: NodeJS.ProcessEnv = Bun.env,
267
+ platform: NodeJS.Platform = process.platform,
268
+ ): boolean {
269
+ if (isImageProtocolForced()) return false;
270
+ if (isUnderTerminalMultiplexer(env)) return false;
271
+ return platform === "win32" && Boolean(env.WT_SESSION?.trim());
253
272
  }
254
273
 
255
274
  function useLegacyMultiplexerFullRender(env: Record<string, string | undefined> = Bun.env): boolean {
@@ -1008,8 +1027,7 @@ export class TUI extends Container {
1008
1027
 
1009
1028
  #querySixelSupport(): void {
1010
1029
  if (TERMINAL.imageProtocol) return;
1011
- if (process.platform !== "win32") return;
1012
- if (!Bun.env.WT_SESSION) return;
1030
+ if (!this.#isSixelProbeCandidate()) return;
1013
1031
  if (!process.stdin.isTTY || !process.stdout.isTTY) return;
1014
1032
 
1015
1033
  this.#clearSixelProbeState();
@@ -1023,6 +1041,10 @@ export class TUI extends Container {
1023
1041
  }, 250);
1024
1042
  }
1025
1043
 
1044
+ #isSixelProbeCandidate(): boolean {
1045
+ return shouldProbeSixelCapability();
1046
+ }
1047
+
1026
1048
  #handleSixelProbeInput(data: string): InputListenerResult {
1027
1049
  if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) {
1028
1050
  return undefined;
@@ -1049,11 +1071,15 @@ export class TUI extends Container {
1049
1071
 
1050
1072
  if (useDa && this.#sixelProbePendingDa) {
1051
1073
  this.#sixelProbePendingDa = false;
1052
- const attributes = (match[1] ?? "")
1074
+ const params = (match[1] ?? "")
1053
1075
  .split(";")
1054
1076
  .map(value => Number.parseInt(value, 10))
1055
1077
  .filter(value => Number.isFinite(value));
1056
- const hasSixelAttribute = attributes.includes(4);
1078
+ // The first DA1 parameter is the device/operating class (e.g. 1,
1079
+ // 62, 64), not an extension attribute: `CSI ?4;6c` identifies a
1080
+ // VT132, it does not advertise sixel. Only the parameters after
1081
+ // the class carry attributes like 4 (sixel graphics).
1082
+ const hasSixelAttribute = params.slice(1).includes(4);
1057
1083
  if (hasSixelAttribute) {
1058
1084
  this.#sixelProbePendingGraphics = false;
1059
1085
  probeOutcome = true;
@@ -1062,8 +1088,11 @@ export class TUI extends Container {
1062
1088
  }
1063
1089
  } else if (!useDa && this.#sixelProbePendingGraphics) {
1064
1090
  this.#sixelProbePendingGraphics = false;
1091
+ // XTSMGRAPHICS reply is `CSI ? 2 ; Ps ; ... S` where Ps=0 means
1092
+ // success and 1/2/3 are errors (tmux answers our unsupported
1093
+ // read with `CSI ?2;3;0S`). Only a success reply proves sixel.
1065
1094
  const status = Number.parseInt(match[1] ?? "", 10);
1066
- const supportsSixel = !Number.isNaN(status) && status !== 0;
1095
+ const supportsSixel = status === 0;
1067
1096
  if (supportsSixel) {
1068
1097
  this.#sixelProbePendingDa = false;
1069
1098
  probeOutcome = true;
@@ -2153,16 +2182,25 @@ export class TUI extends Container {
2153
2182
  this.#previousHeight = height;
2154
2183
  };
2155
2184
 
2156
- const viewportRepaint = (reason: string): void => {
2185
+ const viewportRepaint = (reason: string, absoluteClear = false): void => {
2157
2186
  this.#fullRedrawCount += 1;
2158
2187
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
2159
2188
  const nextViewportTop = Math.max(0, newLines.length - height);
2160
- const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
2161
2189
  let buffer = "\x1b[?2026h";
2162
- if (currentScreenRow > 0) {
2163
- buffer += `\x1b[${currentScreenRow}A`;
2190
+ if (absoluteClear) {
2191
+ // A width reflow under a terminal multiplexer re-wraps the on-screen rows to
2192
+ // the new width before SIGWINCH fires, so cursor-relative row math (from the
2193
+ // old width) lands on the wrong physical row and corrupts the repaint. Clear
2194
+ // the visible screen and repaint from absolute home; skip 3J so scrollback
2195
+ // history is preserved.
2196
+ buffer += "\x1b[2J\x1b[H";
2197
+ } else {
2198
+ const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
2199
+ if (currentScreenRow > 0) {
2200
+ buffer += `\x1b[${currentScreenRow}A`;
2201
+ }
2202
+ buffer += "\r";
2164
2203
  }
2165
- buffer += "\r";
2166
2204
  for (let screenRow = 0; screenRow < height; screenRow++) {
2167
2205
  if (screenRow > 0) buffer += "\r\n";
2168
2206
  buffer += "\x1b[2K";
@@ -2224,13 +2262,22 @@ export class TUI extends Container {
2224
2262
 
2225
2263
  // Width changes always need a full re-render because wrapping changes.
2226
2264
  if (widthChanged) {
2265
+ // A forced render (requestRender(true)) resets #previousWidth to -1: that is a
2266
+ // *fake* width change (the terminal never reflowed), so it keeps the cheap
2267
+ // cursor-relative repaint. A *real* width change (a valid prior width that
2268
+ // differs) means the multiplexer already re-wrapped the on-screen rows,
2269
+ // invalidating cursor-relative row math, so it needs the absolute clear.
2270
+ const realWidthChange = this.#previousWidth > 0 && this.#previousWidth !== width;
2227
2271
  logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
2228
2272
  if (useViewportRepaintPath(this.terminal)) {
2229
2273
  // In viewport-repaint sessions a full replay can either pile the transcript
2230
2274
  // back onto scrollback (tmux/screen) or visibly jump to the transcript top
2231
- // (Windows Terminal). Repaint the viewport only, mirroring the height-change
2232
- // branch and neutralizing fake width changes from requestRender(true).
2233
- viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
2275
+ // (Windows Terminal). For a real width change the reflow invalidates
2276
+ // cursor-relative math, so clear the visible screen and repaint the viewport
2277
+ // from absolute home (absoluteClear) fixing the corruption without replaying
2278
+ // the whole transcript (no scrollback storm). A fake (force) width change
2279
+ // keeps the in-place relative repaint.
2280
+ viewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`, realWidthChange);
2234
2281
  } else {
2235
2282
  fullRender(true, "terminal width changed");
2236
2283
  }