@sayknow-cli/tui 0.6.5 → 0.6.7

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/CHANGELOG.md CHANGED
@@ -2,7 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.6.5] - 2026-09-26
5
+ ## [0.6.7] - 2026-09-28
6
+
7
+ ### Added
8
+
9
+ - `Component.handleClick(line, column)`: top-level children that are not focused can take left clicks, hit-tested against the rows they rendered in the last frame; returning true consumes the click before selection and the focused component.
10
+ - `TUI.setMouseEnabled(enabled)`, `TUI.mouseEnabled` and `TUI.getFocusedComponent()`: switch terminal mouse reporting at runtime and ask which component has keyboard focus.
11
+ - `Editor.setInputBackground(sgr)` paints a background band under every borderless input row (held across colored spans and the cursor); `Editor.isBrowsingHistory()`.
12
+ - `renderPetHalfBlocks(..., { scale: "half" })` draws the small 8×4 octopus.
13
+
14
+ ## [0.6.6] - 2026-09-28
15
+
16
+ ### Added
17
+
18
+ - `renderPetHalfBlocks(frame, skin, colorMode)` draws a Sayknow pet frame as half-block text (two pixel rows per terminal row, truecolor or 256 colors), for surfaces with no sixel/kitty image support.
6
19
 
7
20
  ## [0.5.12] - 2026-09-15
8
21
  ### Fixed
@@ -76,6 +76,14 @@ export declare class Editor implements Component, Focusable {
76
76
  * precedence over `setPromptGutter` and is ignored while the border is visible.
77
77
  */
78
78
  setRailGutter(glyph: string | undefined): void;
79
+ /**
80
+ * Paint a background band (an SGR sequence such as `\x1b[48;2;36;30;24m`) under
81
+ * every input row of a borderless editor, so the line being typed stands out from
82
+ * the transcript. Undefined removes it. Ignored while the border is visible.
83
+ */
84
+ setInputBackground(background: string | undefined): void;
85
+ /** True while Up/Down are walking prompt history rather than editing. */
86
+ isBrowsingHistory(): boolean;
79
87
  setInputPrefix(inputPrefix: string | undefined): void;
80
88
  setPlaceholder(placeholder: string | undefined): void;
81
89
  /**
@@ -125,4 +125,14 @@ export declare function buildSayknowPixelFrames(options: {
125
125
  /** Color skin for the sprite palette (default "red"). */
126
126
  skin?: PetSkinId;
127
127
  }): SayknowPixelFrames;
128
+ /**
129
+ * Draw a pet frame as text with half blocks, one terminal row per two pixel rows,
130
+ * so the same octopus shows up where no pixel protocol is available (the launch
131
+ * card). Fully transparent pixel rows at the top and bottom are cropped; columns
132
+ * are kept so every frame of the animation keeps the same width. `scale: "half"`
133
+ * draws the small octopus (8 columns, 4 rows); frames it has no pose for use its base.
134
+ */
135
+ export declare function renderPetHalfBlocks(frame: SayknowPixelFrameName, skin?: PetSkinId, colorMode?: "truecolor" | "256color", options?: {
136
+ scale?: "full" | "half";
137
+ }): string[];
128
138
  export {};
@@ -44,6 +44,13 @@ export interface Component {
44
44
  handleInput?(data: string): void;
45
45
  /** Optional handler for terminal mouse events when component has focus. */
46
46
  handleMouse?(event: MouseEvent): void;
47
+ /**
48
+ * Optional click hook for a top-level child that is not focused: called with the
49
+ * row (within this component's last rendered lines) and column of a left click.
50
+ * Return true to consume the click. Needs mouse reporting (`enableMouse` or
51
+ * {@link TUI.setMouseEnabled}).
52
+ */
53
+ handleClick?(line: number, column: number): boolean;
47
54
  /**
48
55
  * If true, component receives key release events (Kitty protocol).
49
56
  * Default is false - release events are filtered out.
@@ -298,6 +305,15 @@ export declare class TUI extends Container {
298
305
  /** Check if there are any visible overlays */
299
306
  hasOverlay(): boolean;
300
307
  invalidate(): void;
308
+ /**
309
+ * Turn terminal mouse reporting on or off while running. With it on, the terminal
310
+ * sends clicks and the wheel to the app instead of doing its own selection (most
311
+ * terminals still select with Shift held).
312
+ */
313
+ setMouseEnabled(enabled: boolean): void;
314
+ get mouseEnabled(): boolean;
315
+ /** The component that currently receives keyboard input. */
316
+ getFocusedComponent(): Component | null;
301
317
  start(): void;
302
318
  /**
303
319
  * Wait for a specific render request generation to be written successfully.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/tui",
4
- "version": "0.6.5",
4
+ "version": "0.6.7",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
@@ -38,8 +38,8 @@
38
38
  "fmt": "biome format --write ."
39
39
  },
40
40
  "dependencies": {
41
- "@sayknow-cli/natives": "0.6.5",
42
- "@sayknow-cli/utils": "0.6.5",
41
+ "@sayknow-cli/natives": "0.6.7",
42
+ "@sayknow-cli/utils": "0.6.7",
43
43
  "lru-cache": "11.3.6",
44
44
  "marked": "^18.0.3"
45
45
  },
@@ -428,6 +428,8 @@ export class Editor implements Component, Focusable {
428
428
  cursorOverrideWidth: number | undefined;
429
429
  #promptGutter: string | undefined;
430
430
  #railGutter: string | undefined;
431
+ /** SGR background painted under every borderless input row (the composer band). */
432
+ #inputBackground: string | undefined;
431
433
  #inputPrefix: string | undefined;
432
434
  #inputPrefixWidth = 0;
433
435
  #placeholder: string | undefined;
@@ -578,6 +580,21 @@ export class Editor implements Component, Focusable {
578
580
  this.#invalidateLayoutCache();
579
581
  }
580
582
 
583
+ /**
584
+ * Paint a background band (an SGR sequence such as `\x1b[48;2;36;30;24m`) under
585
+ * every input row of a borderless editor, so the line being typed stands out from
586
+ * the transcript. Undefined removes it. Ignored while the border is visible.
587
+ */
588
+ setInputBackground(background: string | undefined): void {
589
+ this.#inputBackground = background || undefined;
590
+ this.#invalidateLayoutCache();
591
+ }
592
+
593
+ /** True while Up/Down are walking prompt history rather than editing. */
594
+ isBrowsingHistory(): boolean {
595
+ return this.#historyIndex !== -1;
596
+ }
597
+
581
598
  setInputPrefix(inputPrefix: string | undefined): void {
582
599
  this.#inputPrefix = inputPrefix;
583
600
  this.#inputPrefixWidth = inputPrefix ? visibleWidth(inputPrefix) : 0;
@@ -935,6 +952,7 @@ export class Editor implements Component, Focusable {
935
952
  }
936
953
 
937
954
  // Render each layout line
955
+ const inputRowsStart = result.length;
938
956
  // Emit hardware cursor marker only when focused and not showing autocomplete
939
957
  const emitCursorMarker = this.focused && !this.#autocompleteState;
940
958
  const lineContentWidth = Math.max(0, contentAreaWidth - inputPrefixWidth);
@@ -1123,6 +1141,13 @@ export class Editor implements Component, Focusable {
1123
1141
  }
1124
1142
  }
1125
1143
 
1144
+ const background = this.#inputBackground;
1145
+ if (background && !borderVisible) {
1146
+ for (let row = inputRowsStart; row < result.length; row++) {
1147
+ result[row] = paintBackground(result[row]!, background, renderWidth);
1148
+ }
1149
+ }
1150
+
1126
1151
  if (borderVisible && this.#closedBorderBox) {
1127
1152
  const bottomFillWidth = Math.max(0, renderWidth - borderWidth * 2);
1128
1153
  const bottomLeftClosed = this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(paddingX)}`);
@@ -1582,6 +1607,7 @@ export class Editor implements Component, Focusable {
1582
1607
  placeholder: this.#placeholder,
1583
1608
  promptGutter: this.#promptGutter,
1584
1609
  railGutter: this.#railGutter,
1610
+ inputBackground: this.#inputBackground,
1585
1611
  useTerminalCursor: this.#useTerminalCursor,
1586
1612
  cursorOverride: this.cursorOverride,
1587
1613
  cursorOverrideWidth: this.cursorOverrideWidth,
@@ -3105,3 +3131,13 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
3105
3131
  return null;
3106
3132
  }
3107
3133
  }
3134
+
3135
+ /**
3136
+ * Hold a background under a whole row: re-assert it after every reset inside the row
3137
+ * (cursor, ghost text and colored spans all end with one) and pad to `width`.
3138
+ */
3139
+ function paintBackground(line: string, background: string, width: number): string {
3140
+ const fill = Math.max(0, width - visibleWidth(line));
3141
+ const held = line.replace(/\x1b\[(?:0)?m|\x1b\[49m/g, match => `${match}${background}`);
3142
+ return `${background}${held}${padding(fill)}\x1b[0m`;
3143
+ }
@@ -433,3 +433,67 @@ export function buildSayknowPixelFrames(options: {
433
433
 
434
434
  return { frames, protocol: options.protocol, widthPx, heightPx, columns, rows, rasterRows };
435
435
  }
436
+
437
+ // 8x8 small octopus for compact surfaces (the launch card): the same palette and
438
+ // features — rounded mantle with a highlight, white eyes over dark pupils, a row of
439
+ // tentacles — drawn for the size rather than downsampled, which turns the dome blocky.
440
+ // biome-ignore format: pixel grid stays one row per line
441
+ const SMALL_BASE = [
442
+ "..KKKK..",
443
+ ".KRrrRK.",
444
+ "KRRRRRRK",
445
+ "KRWRRWRK",
446
+ "KRVRRVRK",
447
+ "KRRRRRRK",
448
+ "KRKRRKRK",
449
+ "K.K..K.K",
450
+ ];
451
+ const SMALL_GRIDS: Partial<Record<SayknowPixelFrameName, string[]>> = {
452
+ base: SMALL_BASE,
453
+ danceL: mix(SMALL_BASE, { 6: "RKRRKRK.", 7: ".K..K.K." }),
454
+ danceR: mix(SMALL_BASE, { 6: ".KRKRRKR", 7: ".K.K..K." }),
455
+ gazeL: mix(SMALL_BASE, { 3: "KWRRWRRK", 4: "KVRRVRRK" }),
456
+ gazeR: mix(SMALL_BASE, { 3: "KRRWRRWK", 4: "KRRVRRVK" }),
457
+ flicker: mix(SMALL_BASE, { 3: "KRRRRRRK", 4: "KRKRRKRK" }),
458
+ };
459
+
460
+ /**
461
+ * Draw a pet frame as text with half blocks, one terminal row per two pixel rows,
462
+ * so the same octopus shows up where no pixel protocol is available (the launch
463
+ * card). Fully transparent pixel rows at the top and bottom are cropped; columns
464
+ * are kept so every frame of the animation keeps the same width. `scale: "half"`
465
+ * draws the small octopus (8 columns, 4 rows); frames it has no pose for use its base.
466
+ */
467
+ export function renderPetHalfBlocks(
468
+ frame: SayknowPixelFrameName,
469
+ skin: PetSkinId = "red",
470
+ colorMode: "truecolor" | "256color" = "truecolor",
471
+ options: { scale?: "full" | "half" } = {},
472
+ ): string[] {
473
+ const palette = PET_SKINS[skin].palette;
474
+ const grid = options.scale === "half" ? (SMALL_GRIDS[frame] ?? SMALL_BASE) : PIXEL_GRIDS[frame];
475
+ const opaque = (row: string): boolean => [...row].some(ch => palette[ch] != null);
476
+ let top = 0;
477
+ while (top < grid.length && !opaque(grid[top]!)) top++;
478
+ let bottom = grid.length - 1;
479
+ while (bottom > top && !opaque(grid[bottom]!)) bottom--;
480
+ const format = colorMode === "truecolor" ? "ansi-16m" : "ansi-256";
481
+ const fg = (rgb: Rgb): string => Bun.color(`rgb(${rgb[0]},${rgb[1]},${rgb[2]})`, format) ?? "";
482
+ const bg = (rgb: Rgb): string => fg(rgb).replace("\x1b[38;", "\x1b[48;");
483
+ const lines: string[] = [];
484
+ for (let row = top; row <= bottom; row += 2) {
485
+ const upper = grid[row]!;
486
+ const lower = row + 1 <= bottom ? grid[row + 1]! : "";
487
+ let line = "";
488
+ for (let col = 0; col < upper.length; col++) {
489
+ const up = palette[upper[col]!] ?? null;
490
+ const down = palette[lower[col] ?? "."] ?? null;
491
+ if (!up && !down) line += " ";
492
+ else if (up && down) line += `${fg(up)}${bg(down)}▀\x1b[0m`;
493
+ else if (up) line += `${fg(up)}▀\x1b[0m`;
494
+ else line += `${fg(down!)}▄\x1b[0m`;
495
+ }
496
+ lines.push(line);
497
+ }
498
+ return lines;
499
+ }
package/src/tui.ts CHANGED
@@ -132,6 +132,14 @@ export interface Component {
132
132
  /** Optional handler for terminal mouse events when component has focus. */
133
133
  handleMouse?(event: MouseEvent): void;
134
134
 
135
+ /**
136
+ * Optional click hook for a top-level child that is not focused: called with the
137
+ * row (within this component's last rendered lines) and column of a left click.
138
+ * Return true to consume the click. Needs mouse reporting (`enableMouse` or
139
+ * {@link TUI.setMouseEnabled}).
140
+ */
141
+ handleClick?(line: number, column: number): boolean;
142
+
135
143
  /**
136
144
  * If true, component receives key release events (Kitty protocol).
137
145
  * Default is false - release events are filtered out.
@@ -760,6 +768,9 @@ export class TUI extends Container {
760
768
  #mouseSelectionStart: MouseSelectionPoint | null = null;
761
769
  #mouseSelectionEnd: MouseSelectionPoint | null = null;
762
770
  #mouseSelectionDragged = false;
771
+ /** Rows each top-level child occupied in the last frame, for click hit-testing. */
772
+ #childRows = new Map<Component, { start: number; count: number }>();
773
+ #mouseEnabled = false;
763
774
  #viewportOutputSource: ViewportOutputSource | null = null;
764
775
  #manualOutputNotice = false;
765
776
  #manualTranscriptLineCount = 0;
@@ -828,6 +839,7 @@ export class TUI extends Container {
828
839
  } = {},
829
840
  ) {
830
841
  super();
842
+ this.#mouseEnabled = options.enableMouse === true;
831
843
  this.terminal = terminal;
832
844
  if (showHardwareCursor !== undefined) {
833
845
  this.#showHardwareCursor = showHardwareCursor;
@@ -1315,13 +1327,34 @@ export class TUI extends Container {
1315
1327
  for (const overlay of this.overlayStack) overlay.mouseBounds = undefined;
1316
1328
  }
1317
1329
 
1330
+ /**
1331
+ * Turn terminal mouse reporting on or off while running. With it on, the terminal
1332
+ * sends clicks and the wheel to the app instead of doing its own selection (most
1333
+ * terminals still select with Shift held).
1334
+ */
1335
+ setMouseEnabled(enabled: boolean): void {
1336
+ if (this.#mouseEnabled === enabled) return;
1337
+ this.#mouseEnabled = enabled;
1338
+ if (!enabled) this.#clearMouseSelection();
1339
+ if (!this.#stopped) this.terminal.setMouseEnabled?.(enabled);
1340
+ }
1341
+
1342
+ get mouseEnabled(): boolean {
1343
+ return this.#mouseEnabled;
1344
+ }
1345
+
1346
+ /** The component that currently receives keyboard input. */
1347
+ getFocusedComponent(): Component | null {
1348
+ return this.#focusedComponent;
1349
+ }
1350
+
1318
1351
  start(): void {
1319
1352
  this.#stopped = false;
1320
1353
  this.#terminalUnavailable = false;
1321
1354
  // Seed the observed width so a spurious post-start resize event (iTerm2 tab
1322
1355
  // activation, the self-sent SIGWINCH after resume) is not read as a reflow.
1323
1356
  this.#lastObservedWidth = this.terminal.columns;
1324
- this.terminal.setMouseEnabled?.(this.options.enableMouse === true);
1357
+ this.terminal.setMouseEnabled?.(this.#mouseEnabled);
1325
1358
  this.terminal.start(
1326
1359
  data => this.#handleInput(data),
1327
1360
  () => {
@@ -2022,6 +2055,10 @@ export class TUI extends Container {
2022
2055
  this.#clearMouseSelection();
2023
2056
  this.scrollViewportBy(mouse.direction! * DEFAULT_WHEEL_LINES, { pin: "stable" });
2024
2057
  } else if (mouse.kind === "click") {
2058
+ if (this.#dispatchChildClick(mouse)) {
2059
+ this.requestRender(false, "mouse");
2060
+ return;
2061
+ }
2025
2062
  this.#beginMouseSelection(mouse);
2026
2063
  const focusedOverlay = this.overlayStack.find(o => o.component === this.#focusedComponent);
2027
2064
  if (focusedOverlay) {
@@ -2096,6 +2133,22 @@ export class TUI extends Container {
2096
2133
  }
2097
2134
  }
2098
2135
 
2136
+ /** Give a click to the top-level child under it, if that child takes clicks and no overlay is focused. */
2137
+ #dispatchChildClick(mouse: MouseEvent): boolean {
2138
+ if (this.#childRows.size === 0) return false;
2139
+ if (this.overlayStack.some(overlay => overlay.component === this.#focusedComponent)) return false;
2140
+ const point = this.#mouseSelectionPoint(mouse);
2141
+ if (point === null) return false;
2142
+ for (const [child, rows] of this.#childRows) {
2143
+ if (point.line < rows.start || point.line >= rows.start + rows.count) continue;
2144
+ if (child.handleClick?.(point.line - rows.start, point.column)) {
2145
+ this.#clearMouseSelection();
2146
+ return true;
2147
+ }
2148
+ }
2149
+ return false;
2150
+ }
2151
+
2099
2152
  #mouseSelectionPoint(mouse: MouseEvent): MouseSelectionPoint | null {
2100
2153
  if (this.#manualViewportTop === undefined) {
2101
2154
  return { line: this.#viewportTopRow + mouse.y - 1, column: mouse.x - 1 };
@@ -2919,9 +2972,14 @@ export class TUI extends Container {
2919
2972
  const renderedChildren = new Map<Component, string[]>();
2920
2973
  let anchorFrame: ViewportAnchorFrame | null = null;
2921
2974
  const anchorRenderFailureCountBefore = viewportAnchorRenderFailureCount;
2975
+ this.#childRows.clear();
2922
2976
  for (const child of this.children) {
2923
2977
  const rendered = safeRenderComponentWithViewportAnchors(child, width, "tui-child");
2924
2978
  renderedChildren.set(child, rendered.lines);
2979
+ // Children above the pinned suffix keep these rows: padding and suffix trimming
2980
+ // only touch the pinned tail.
2981
+ if (child.handleClick)
2982
+ this.#childRows.set(child, { start: renderedLines.length, count: rendered.lines.length });
2925
2983
  if (child === this.#viewportAnchorComponent && rendered.anchors.some(anchor => anchor !== null)) {
2926
2984
  anchorFrame = { startRow: renderedLines.length, anchors: rendered.anchors };
2927
2985
  }