@sayknow-cli/tui 0.3.11 → 0.3.13

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 (39) hide show
  1. package/package.json +8 -9
  2. package/src/components/image.ts +79 -39
  3. package/src/components/markdown.ts +282 -37
  4. package/src/components/sayknow-pet.ts +435 -0
  5. package/src/components/text.ts +60 -36
  6. package/src/index.ts +1 -0
  7. package/src/terminal-capabilities.ts +42 -0
  8. package/src/tui.ts +471 -55
  9. package/src/utils.ts +144 -8
  10. package/dist/types/animation-scheduler.d.ts +0 -13
  11. package/dist/types/autocomplete.d.ts +0 -83
  12. package/dist/types/bracketed-paste.d.ts +0 -26
  13. package/dist/types/components/box.d.ts +0 -20
  14. package/dist/types/components/cancellable-loader.d.ts +0 -21
  15. package/dist/types/components/editor.d.ts +0 -126
  16. package/dist/types/components/image.d.ts +0 -16
  17. package/dist/types/components/input.d.ts +0 -16
  18. package/dist/types/components/loader.d.ts +0 -23
  19. package/dist/types/components/markdown.d.ts +0 -77
  20. package/dist/types/components/select-list.d.ts +0 -46
  21. package/dist/types/components/settings-list.d.ts +0 -39
  22. package/dist/types/components/spacer.d.ts +0 -11
  23. package/dist/types/components/tab-bar.d.ts +0 -56
  24. package/dist/types/components/text.d.ts +0 -13
  25. package/dist/types/components/truncated-text.d.ts +0 -10
  26. package/dist/types/editor-component.d.ts +0 -36
  27. package/dist/types/fuzzy.d.ts +0 -15
  28. package/dist/types/index.d.ts +0 -27
  29. package/dist/types/keybindings.d.ts +0 -201
  30. package/dist/types/keys.d.ts +0 -208
  31. package/dist/types/kill-ring.d.ts +0 -27
  32. package/dist/types/metrics.d.ts +0 -85
  33. package/dist/types/stdin-buffer.d.ts +0 -50
  34. package/dist/types/symbols.d.ts +0 -23
  35. package/dist/types/terminal-capabilities.d.ts +0 -143
  36. package/dist/types/terminal.d.ts +0 -90
  37. package/dist/types/ttyid.d.ts +0 -9
  38. package/dist/types/tui.d.ts +0 -215
  39. package/dist/types/utils.d.ts +0 -87
@@ -0,0 +1,435 @@
1
+ /**
2
+ * ┌─ SAYKNOW PET SPRITE SPEC ────────────────────────────────────────────────┐
3
+ * The pet is a 16×16 pixel octopus drawn beside the composer. Everything here
4
+ * is data: no PNGs, no assets — each frame is 16 strings of 16 chars, encoded
5
+ * to a sixel or kitty escape at runtime. Author a new frame by drawing a grid.
6
+ *
7
+ * GRID RULES
8
+ * - Exactly 16 rows × 16 columns. Only PALETTE keys below are valid chars.
9
+ * - `.` = transparent. Keep the outer columns transparent so the sprite sits
10
+ * snug beside the input box (the widget reserves +1 column of slack).
11
+ *
12
+ * PALETTE (char → role) — see PALETTE for exact RGB:
13
+ * .=transparent K=dark outline R=mantle body r=body highlight
14
+ * W=eye white V=pupil G=eye sparkle b=underside
15
+ * w=tear H h A=reserved (unused by the octopus art)
16
+ *
17
+ * FRAME CATALOG (SayknowPixelFrameName → PIXEL_GRIDS):
18
+ * base idle rest; also the dance "drop/settle" beat
19
+ * gazeL eyes glance left ┐ idle loop (see sayknow-pet-widget IDLE_LOOP)
20
+ * gazeR eyes glance right │
21
+ * flicker eyes blink ┘
22
+ * flex sparkly "yay" eyes; dance accent + random idle flex burst
23
+ * danceL tentacles sway left ┐ work loop (PARA_PARA_STEPS)
24
+ * danceR tentacles sway right ┘
25
+ * cry1..3 a tear trails from the outer eye corners (BlueOcto sob)
26
+ *
27
+ * RENDERING: buildSayknowPixelFrames({ protocol, cellWidthPx, cellHeightPx,
28
+ * targetRows: 2 }) scales the art to 2 terminal rows and encodes each frame
29
+ * once. Kitty uses a native `Y=` sub-cell drop (set by the widget) to sit on the
30
+ * composer border; sixel uses transparent top padding.
31
+ *
32
+ * BEHAVIOR (timing, positioning, on/off) lives in
33
+ * packages/coding-agent/src/modes/components/sayknow-pet-widget.ts.
34
+ *
35
+ * ADD A FRAME: draw the grid → add its name to SayknowPixelFrameName → register it in
36
+ * PIXEL_GRIDS → reference it from an idle/work loop or a skin burst.
37
+ *
38
+ * ADD A PET (skin): append one entry to PET_SKINS below — { id, label, description,
39
+ * palette, burst }. The id flows into PetSkinId/PetMode automatically, the settings
40
+ * enum, `/pet` command and both selectors derive their options from PET_SKINS, and the
41
+ * widget reads `burst` to animate — no other file needs editing. Recolor with a palette
42
+ * spread (see BLUE_PALETTE); add frames only for poses the catalog lacks.
43
+ * └────────────────────────────────────────────────────────────────────────┘
44
+ */
45
+ type Rgb = readonly [number, number, number];
46
+
47
+ export type Palette = Record<string, Rgb | null>;
48
+ export const PET_SKIN_IDS = ["red", "blue"] as const;
49
+ export type PetSkinId = (typeof PET_SKIN_IDS)[number];
50
+ /** Every pet mode: "off" plus each skin id, in menu order. */
51
+ export const PET_MODE_IDS = ["off", ...PET_SKIN_IDS] as const;
52
+ export type PetMode = (typeof PET_MODE_IDS)[number];
53
+ /** Narrow an arbitrary string to a PetMode. */
54
+ export function isPetMode(value: string): value is PetMode {
55
+ return (PET_MODE_IDS as readonly string[]).includes(value);
56
+ }
57
+
58
+ const RED_PALETTE: Palette = {
59
+ ".": null, // transparent
60
+ K: [74, 20, 8], // outline (dark)
61
+ R: [229, 72, 46], // mantle body
62
+ r: [255, 122, 82], // body highlight
63
+ W: [240, 244, 250], // eye white
64
+ V: [24, 18, 16], // pupil
65
+ G: [255, 214, 128], // eye sparkle
66
+ b: [255, 150, 120], // underside
67
+ H: [232, 180, 90], // reserved
68
+ h: [169, 117, 47], // reserved
69
+ A: [196, 60, 30], // reserved
70
+ w: [200, 230, 255], // tear (BlueOcto sob)
71
+ };
72
+ // BlueOcto recolors the octopus for the blue-octopus theme: ocean outline, a bright
73
+ // mantle, azure highlight and foam tears. Reserved hat keys are shared but unused.
74
+ const BLUE_PALETTE: Palette = {
75
+ ...RED_PALETTE,
76
+ K: [7, 38, 74], // deep ocean (outline)
77
+ R: [47, 155, 255], // mantle body
78
+ r: [94, 200, 255], // highlight
79
+ W: [240, 246, 252], // eye white
80
+ V: [10, 22, 40], // pupil
81
+ G: [188, 244, 255], // eye sparkle
82
+ b: [125, 211, 252], // azure (underside)
83
+ A: [37, 120, 200], // reserved
84
+ w: [230, 247, 255], // foam (tear)
85
+ };
86
+
87
+ // 16x16 octopus grids used by the pixel pet -------------------------------
88
+ const mix = (base: string[], rowOverrides: Record<number, string>): string[] =>
89
+ base.map((row, i) => rowOverrides[i] ?? row);
90
+
91
+ // biome-ignore format: pixel grid stays one row per line
92
+ const F0 = [
93
+ "................",
94
+ ".....KKKKKK.....",
95
+ "...KKRRRRRRKK...",
96
+ "..KRRRRRRRRRRK..",
97
+ ".KRRRRRRRRRRRRK.",
98
+ ".KRRRRRRRRRRRRK.",
99
+ "KRRWWRRRRRRWWRRK",
100
+ "KRRWVRRRRRRVWRRK",
101
+ "KRRRRRRrrRRRRRRK",
102
+ ".KRRRRRRRRRRRRK.",
103
+ ".KRRRRRRRRRRRRK.",
104
+ "KRRKKRRKKRRKKRRK",
105
+ "KRRKKRRKKRRKKRRK",
106
+ ".RK.KRK.KRK.KRR.",
107
+ ".K...K...K...K..",
108
+ "................",
109
+ ];
110
+
111
+ // Eyes glance by shifting the pupils inside the eye whites (row 7).
112
+ const FL = mix(F0, { 7: "KRRVWRRRRRRVWRRK" });
113
+ const FR = mix(F0, { 7: "KRRWVRRRRRRWVRRK" });
114
+ // Blink: both eyes squeeze shut.
115
+ const FF = mix(F0, { 6: "KRRRRRRRRRRRRRRK", 7: "KRRKKRRRRRRKKRRK" });
116
+ // Flex accent: sparkly "yay" eyes.
117
+ const FX = mix(F0, { 7: "KRRGVRRRRRRVGRRK" });
118
+ // Para-para work dance: sway every tentacle one column left, then right.
119
+ const DL = mix(F0, {
120
+ 11: "RRKKRRKKRRKKRRK.",
121
+ 12: "RRKKRRKKRRKKRRK.",
122
+ 13: "RK.KRK.KRK.KRR..",
123
+ 14: "K...K...K...K...",
124
+ });
125
+ const DR = mix(F0, {
126
+ 11: ".KRRKKRRKKRRKKRR",
127
+ 12: ".KRRKKRRKKRRKKRR",
128
+ 13: "..RK.KRK.KRK.KRR",
129
+ 14: "..K...K...K...K.",
130
+ });
131
+ // BlueOcto idle sob: a tear trails down from the outer eye corners.
132
+ const CR1 = mix(F0, { 8: "KRRwRRrrRRRRwRRK" });
133
+ const CR2 = mix(F0, { 9: ".KRwRRRRRRRRwRK." });
134
+ const CR3 = mix(F0, { 10: ".KRwRRRRRRRRwRK." });
135
+
136
+ /** Logical pixel-pet frame names shared by the overlay state machine. */
137
+ export type SayknowPixelFrameName =
138
+ | "base"
139
+ | "gazeL"
140
+ | "gazeR"
141
+ | "flicker"
142
+ | "flex"
143
+ | "danceL"
144
+ | "danceR"
145
+ | "cry1"
146
+ | "cry2"
147
+ | "cry3";
148
+
149
+ const PIXEL_GRIDS: Record<SayknowPixelFrameName, string[]> = {
150
+ base: F0,
151
+ gazeL: FL,
152
+ gazeR: FR,
153
+ flicker: FF,
154
+ flex: FX,
155
+ danceL: DL,
156
+ danceR: DR,
157
+ cry1: CR1,
158
+ cry2: CR2,
159
+ cry3: CR3,
160
+ };
161
+
162
+ /** Para-para work dance beats: the working loop and each skin's burst "work-in" intro. */
163
+ export const PARA_PARA_STEPS: ReadonlyArray<readonly [SayknowPixelFrameName, number]> = [
164
+ ["danceL", 300],
165
+ ["danceR", 300],
166
+ ["base", 260],
167
+ ["flex", 480],
168
+ ["base", 260],
169
+ ];
170
+
171
+ /**
172
+ * A skin's idle burst: a short intro sequence, then an optional looping tail. It drives
173
+ * BOTH the random live show-off AND the selector's preview demo, so give every skin a
174
+ * real animation (reuse PARA_PARA_STEPS for a work-in intro) rather than one held frame.
175
+ */
176
+ export interface PetBurst {
177
+ /** Frames played once, in order, at the start of the burst. */
178
+ intro: ReadonlyArray<readonly [SayknowPixelFrameName, number]>;
179
+ /** Frames cycled every `stepMs` for `ms` after the intro (a held or looping finish). */
180
+ tail?: { frames: readonly SayknowPixelFrameName[]; stepMs: number; ms: number };
181
+ }
182
+
183
+ /** Everything that defines a pet skin: identity, UI copy, colors and behavior. */
184
+ export interface PetSkin {
185
+ id: PetSkinId;
186
+ /** Selector/settings label, e.g. "RedOctopus". */
187
+ label: string;
188
+ /** One-line selector/settings description. */
189
+ description: string;
190
+ palette: Palette;
191
+ /** Idle burst animation played between quiet idle loops. */
192
+ burst: PetBurst;
193
+ }
194
+
195
+ /** Skin registry — the single source for palettes, behavior and selector/command copy. */
196
+ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
197
+ red: {
198
+ id: "red",
199
+ label: "RedOctopus",
200
+ description: "The red octopus, who loves to work out.",
201
+ palette: RED_PALETTE,
202
+ burst: {
203
+ intro: PARA_PARA_STEPS,
204
+ tail: { frames: ["flex", "base"], stepMs: 200, ms: 1000 },
205
+ },
206
+ },
207
+ blue: {
208
+ id: "blue",
209
+ label: "BlueOctopus",
210
+ description: "The blue octopus, who wants to rest.",
211
+ palette: BLUE_PALETTE,
212
+ burst: {
213
+ intro: PARA_PARA_STEPS,
214
+ tail: { frames: ["cry1", "cry2", "cry3"], stepMs: 110, ms: 990 },
215
+ },
216
+ },
217
+ };
218
+
219
+ /** Total burst duration (intro beats plus the looping tail). */
220
+ export function petBurstDurationMs(burst: PetBurst): number {
221
+ const introMs = burst.intro.reduce((sum, [, ms]) => sum + ms, 0);
222
+ return introMs + (burst.tail?.ms ?? 0);
223
+ }
224
+
225
+ /** The frame to show `elapsed` ms into a burst (`now` cycles the looping tail). */
226
+ export function petBurstFrame(burst: PetBurst, elapsed: number, now: number): SayknowPixelFrameName {
227
+ let t = elapsed;
228
+ for (const [frame, ms] of burst.intro) {
229
+ if (t < ms) return frame;
230
+ t -= ms;
231
+ }
232
+ const tail = burst.tail;
233
+ if (!tail) return burst.intro[burst.intro.length - 1][0];
234
+ return tail.frames[Math.floor(now / tail.stepMs) % tail.frames.length];
235
+ }
236
+
237
+ /** Test-only access to logical art; production rendering still uses encoded frames. */
238
+ export const __sayknowPetTestHooks = {
239
+ getPixelGrid(name: SayknowPixelFrameName): string[] {
240
+ return [...PIXEL_GRIDS[name]];
241
+ },
242
+ };
243
+
244
+ /** Encode a grid as a transparent SIXEL image, optionally bottom-aligned by top padding. */
245
+ export function encodeGridSixel(
246
+ grid: string[],
247
+ scale: number,
248
+ topPaddingPx = 0,
249
+ palette: Palette = RED_PALETTE,
250
+ ): string {
251
+ const gw = grid[0].length;
252
+ const gh = grid.length;
253
+ const w = Math.round(gw * scale);
254
+ const h = Math.round(gh * scale) + topPaddingPx;
255
+ const colors: Rgb[] = [];
256
+ const colorIndex = new Map<string, number>();
257
+ // pixel color index per row/col, -1 = transparent
258
+ const px: number[][] = [];
259
+ for (let y = 0; y < h; y++) {
260
+ const row: number[] = [];
261
+ for (let x = 0; x < w; x++) {
262
+ const sourceY = y - topPaddingPx;
263
+ const ch =
264
+ sourceY < 0
265
+ ? "."
266
+ : grid[Math.min(gh - 1, Math.floor(sourceY / scale))][Math.min(gw - 1, Math.floor(x / scale))];
267
+ const rgb = palette[ch];
268
+ if (!rgb) {
269
+ row.push(-1);
270
+ continue;
271
+ }
272
+ const key = rgb.join(",");
273
+ let idx = colorIndex.get(key);
274
+ if (idx === undefined) {
275
+ idx = colors.length;
276
+ colors.push(rgb);
277
+ colorIndex.set(key, idx);
278
+ }
279
+ row.push(idx);
280
+ }
281
+ px.push(row);
282
+ }
283
+
284
+ // DCS is P1;P2;P3: transparency is the second parameter (P2=1).
285
+ let out = `\x1bP0;1;0q"1;1;${w};${h}`;
286
+ for (let i = 0; i < colors.length; i++) {
287
+ const [r, g, b] = colors[i];
288
+ out += `#${i};2;${Math.round((r / 255) * 100)};${Math.round((g / 255) * 100)};${Math.round((b / 255) * 100)}`;
289
+ }
290
+ for (let bandTop = 0; bandTop < h; bandTop += 6) {
291
+ for (let c = 0; c < colors.length; c++) {
292
+ let line = "";
293
+ let used = false;
294
+ for (let x = 0; x < w; x++) {
295
+ let bits = 0;
296
+ for (let dy = 0; dy < 6 && bandTop + dy < h; dy++) {
297
+ if (px[bandTop + dy][x] === c) bits |= 1 << dy;
298
+ }
299
+ if (bits) used = true;
300
+ line += String.fromCharCode(63 + bits);
301
+ }
302
+ if (used) out += `#${c}${line}$`;
303
+ }
304
+ out += "-";
305
+ }
306
+ return `${out}\x1b\\`;
307
+ }
308
+
309
+ /** Encode a bottom-aligned grid as kitty raw RGBA at `scale`. */
310
+ export function encodeGridKitty(
311
+ grid: string[],
312
+ scale: number,
313
+ imageId: number,
314
+ cols: number,
315
+ rows: number,
316
+ topPaddingPx = 0,
317
+ cellYOffsetPx = 0,
318
+ leftPaddingPx = 0,
319
+ rightPaddingPx = 0,
320
+ palette: Palette = RED_PALETTE,
321
+ ): string {
322
+ const gw = grid[0].length;
323
+ const gh = grid.length;
324
+ const spriteW = Math.round(gw * scale);
325
+ // Pad the canvas to the full cell block (cols*cellWidth) so the square sprite
326
+ // renders 1:1 within it.
327
+ const w = spriteW + leftPaddingPx + rightPaddingPx;
328
+ const h = Math.round(gh * scale) + topPaddingPx;
329
+ const rgba = new Uint8Array(w * h * 4);
330
+ for (let y = 0; y < h; y++) {
331
+ for (let x = 0; x < w; x++) {
332
+ const sourceX = x - leftPaddingPx;
333
+ const sourceY = y - topPaddingPx;
334
+ const rgb =
335
+ sourceX < 0 || sourceX >= spriteW || sourceY < 0
336
+ ? null
337
+ : palette[
338
+ grid[Math.min(gh - 1, Math.floor(sourceY / scale))][Math.min(gw - 1, Math.floor(sourceX / scale))]
339
+ ];
340
+ if (!rgb) continue;
341
+ const o = (y * w + x) * 4;
342
+ rgba[o] = rgb[0];
343
+ rgba[o + 1] = rgb[1];
344
+ rgba[o + 2] = rgb[2];
345
+ rgba[o + 3] = 255;
346
+ }
347
+ }
348
+ const data = Buffer.from(rgba).toString("base64");
349
+ const CHUNK = 4000;
350
+ // `Y=` offsets the sprite down by sub-cell pixels within the first cell — the
351
+ // kitty analogue of the sixel top-padding drop. `C=1` keeps the placement
352
+ // cursor-neutral so the overlay never nudges the composer's real cursor.
353
+ const yParam = cellYOffsetPx > 0 ? `,Y=${Math.round(cellYOffsetPx)}` : "";
354
+ let out = `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
355
+ for (let off = 0, first = true; off < data.length; off += CHUNK, first = false) {
356
+ const chunk = data.slice(off, off + CHUNK);
357
+ const more = off + CHUNK < data.length ? 1 : 0;
358
+ out += first
359
+ ? `\x1b_Ga=T,f=32,s=${w},v=${h},c=${cols},r=${rows},i=${imageId},q=2,C=1${yParam},m=${more};${chunk}\x1b\\`
360
+ : `\x1b_Gm=${more};${chunk}\x1b\\`;
361
+ }
362
+ return out;
363
+ }
364
+
365
+ export interface SayknowPixelFrames {
366
+ /** escape payload per logical frame (drawn at the current cursor cell) */
367
+ frames: Record<SayknowPixelFrameName, string>;
368
+ /** protocol the frames were encoded for */
369
+ protocol: "sixel" | "kitty";
370
+ widthPx: number;
371
+ heightPx: number;
372
+ columns: number;
373
+ rows: number;
374
+ /** terminal rows touched by the encoded raster, including pixel offset */
375
+ rasterRows: number;
376
+ }
377
+
378
+ /**
379
+ * Build overlay pixel frames exactly `targetRows` terminal rows tall when the
380
+ * terminal cells permit it. Nearest-neighbor sampling preserves the 16x16 art
381
+ * while allowing fractional scale factors such as 36px / 16px.
382
+ */
383
+ export function buildSayknowPixelFrames(options: {
384
+ protocol: "sixel" | "kitty";
385
+ cellWidthPx: number;
386
+ cellHeightPx: number;
387
+ targetRows?: number;
388
+ /** Transparent pixel offset above sixel art for sub-cell vertical placement. */
389
+ sixelTopPaddingPx?: number;
390
+ /** Native sub-cell `Y=` pixel offset that drops the kitty sprite within its first cell. */
391
+ kittyCellYOffsetPx?: number;
392
+ kittyImageId?: number;
393
+ /** Color skin for the sprite palette (default "red"). */
394
+ skin?: PetSkinId;
395
+ }): SayknowPixelFrames {
396
+ const targetRows = options.targetRows ?? 2;
397
+ const gridSize = 16;
398
+ const scale = Math.max(1, (targetRows * options.cellHeightPx) / gridSize);
399
+ const widthPx = Math.round(gridSize * scale);
400
+ const visibleHeightPx = Math.round(gridSize * scale);
401
+ const columns = Math.ceil(widthPx / options.cellWidthPx);
402
+ const rows = Math.ceil(visibleHeightPx / options.cellHeightPx);
403
+ const allocatedHeightPx = rows * options.cellHeightPx;
404
+ const topPaddingPx =
405
+ allocatedHeightPx - visibleHeightPx + (options.protocol === "sixel" ? (options.sixelTopPaddingPx ?? 0) : 0);
406
+ const heightPx = visibleHeightPx + topPaddingPx;
407
+ const rasterRows = Math.ceil(heightPx / options.cellHeightPx);
408
+ // Center the square sprite in its (cols * cellWidth) block, which the ceil()
409
+ // column rounding can make wider than the sprite itself.
410
+ const horizontalPaddingPx = Math.max(0, columns * options.cellWidthPx - widthPx);
411
+ const leftPaddingPx = Math.floor(horizontalPaddingPx / 2);
412
+ const rightPaddingPx = horizontalPaddingPx - leftPaddingPx;
413
+ const imageId = options.kittyImageId ?? 0xc0de;
414
+ const palette = PET_SKINS[options.skin ?? "red"].palette;
415
+ const frames = {} as Record<SayknowPixelFrameName, string>;
416
+ for (const name of Object.keys(PIXEL_GRIDS) as SayknowPixelFrameName[]) {
417
+ frames[name] =
418
+ options.protocol === "sixel"
419
+ ? encodeGridSixel(PIXEL_GRIDS[name], scale, topPaddingPx, palette)
420
+ : encodeGridKitty(
421
+ PIXEL_GRIDS[name],
422
+ scale,
423
+ imageId,
424
+ columns,
425
+ rows,
426
+ topPaddingPx,
427
+ options.kittyCellYOffsetPx ?? 0,
428
+ leftPaddingPx,
429
+ rightPaddingPx,
430
+ palette,
431
+ );
432
+ }
433
+
434
+ return { frames, protocol: options.protocol, widthPx, heightPx, columns, rows, rasterRows };
435
+ }
@@ -1,5 +1,15 @@
1
1
  import type { Component } from "../tui";
2
- import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
2
+
3
+ import {
4
+ annotateViewportAnchorGraphemes,
5
+ applyBackgroundToLine,
6
+ extractViewportAnchorRows,
7
+ padding,
8
+ replaceTabs,
9
+ type ViewportAnchorSpan,
10
+ visibleWidth,
11
+ wrapTextWithAnsi,
12
+ } from "../utils";
3
13
 
4
14
  /**
5
15
  * Text component - displays multi-line text with word wrapping
@@ -14,6 +24,7 @@ export class Text implements Component {
14
24
  #cachedText?: string;
15
25
  #cachedWidth?: number;
16
26
  #cachedLines?: string[];
27
+ #cachedAnchorSpans?: Array<ViewportAnchorSpan | null>;
17
28
 
18
29
  constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
19
30
  this.#text = text;
@@ -31,6 +42,7 @@ export class Text implements Component {
31
42
  this.#cachedText = undefined;
32
43
  this.#cachedWidth = undefined;
33
44
  this.#cachedLines = undefined;
45
+ this.#cachedAnchorSpans = undefined;
34
46
  }
35
47
 
36
48
  setCustomBgFn(customBgFn?: (text: string) => string): void {
@@ -38,73 +50,85 @@ export class Text implements Component {
38
50
  this.#cachedText = undefined;
39
51
  this.#cachedWidth = undefined;
40
52
  this.#cachedLines = undefined;
53
+ this.#cachedAnchorSpans = undefined;
41
54
  }
42
55
 
43
56
  invalidate(): void {
44
57
  this.#cachedText = undefined;
45
58
  this.#cachedWidth = undefined;
46
59
  this.#cachedLines = undefined;
60
+ this.#cachedAnchorSpans = undefined;
47
61
  }
48
62
 
49
63
  render(width: number): string[] {
50
- // Check cache
51
- if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
52
- return this.#cachedLines;
64
+ return this.#render(width, false).lines;
65
+ }
66
+
67
+ #render(width: number, includeAnchors: boolean): { lines: string[]; spans?: Array<ViewportAnchorSpan | null> } {
68
+ if (
69
+ this.#cachedLines &&
70
+ this.#cachedText === this.#text &&
71
+ this.#cachedWidth === width &&
72
+ (!includeAnchors || this.#cachedAnchorSpans !== undefined)
73
+ ) {
74
+ return { lines: this.#cachedLines, spans: this.#cachedAnchorSpans };
53
75
  }
54
76
 
55
- // Don't render anything if there's no actual text
56
77
  if (!this.#text || this.#text.trim() === "") {
57
78
  const result: string[] = [];
58
79
  this.#cachedText = this.#text;
59
80
  this.#cachedWidth = width;
60
81
  this.#cachedLines = result;
61
- return result;
82
+ this.#cachedAnchorSpans = includeAnchors ? [] : undefined;
83
+ return { lines: result, spans: this.#cachedAnchorSpans };
62
84
  }
63
85
 
64
- // Replace tabs with 3 spaces
65
86
  const normalizedText = replaceTabs(this.#text);
66
-
67
- // Calculate content width (subtract left/right margins)
68
87
  const contentWidth = Math.max(1, width - this.#paddingX * 2);
88
+ let wrappedLines: string[];
89
+ let wrappedSpans: Array<ViewportAnchorSpan | null> | undefined;
90
+ if (includeAnchors) {
91
+ const markedText = annotateViewportAnchorGraphemes(normalizedText);
92
+ const extracted = extractViewportAnchorRows(wrapTextWithAnsi(markedText.text, contentWidth), markedText.token);
93
+ wrappedLines = extracted.lines;
94
+ wrappedSpans = extracted.spans;
95
+ } else {
96
+ wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
97
+ }
69
98
 
70
- // Wrap text (this preserves ANSI codes but does NOT pad)
71
- const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
72
-
73
- // Add margins and background to each line
74
99
  const leftMargin = padding(this.#paddingX);
75
100
  const rightMargin = padding(this.#paddingX);
76
101
  const contentLines: string[] = [];
77
-
78
102
  for (const line of wrappedLines) {
79
- // Add margins
80
103
  const lineWithMargins = leftMargin + line + rightMargin;
81
-
82
- // Apply background if specified (this also pads to full width)
83
- if (this.#customBgFn) {
84
- contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.#customBgFn));
85
- } else {
86
- // No background - just pad to width with spaces
87
- const visibleLen = visibleWidth(lineWithMargins);
88
- const paddingNeeded = Math.max(0, width - visibleLen);
89
- contentLines.push(lineWithMargins + padding(paddingNeeded));
90
- }
104
+ if (this.#customBgFn) contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.#customBgFn));
105
+ else contentLines.push(lineWithMargins + padding(Math.max(0, width - visibleWidth(lineWithMargins))));
91
106
  }
92
-
93
- // Add top/bottom padding (empty lines)
94
107
  const emptyLine = padding(width);
95
- const emptyLines: string[] = [];
96
- for (let i = 0; i < this.#paddingY; i++) {
97
- const line = this.#customBgFn ? applyBackgroundToLine(emptyLine, width, this.#customBgFn) : emptyLine;
98
- emptyLines.push(line);
99
- }
100
-
108
+ const emptyLines = Array.from({ length: this.#paddingY }, () =>
109
+ this.#customBgFn ? applyBackgroundToLine(emptyLine, width, this.#customBgFn) : emptyLine,
110
+ );
101
111
  const result = [...emptyLines, ...contentLines, ...emptyLines];
102
-
103
- // Update cache
112
+ const spans = wrappedSpans && [...emptyLines.map(() => null), ...wrappedSpans, ...emptyLines.map(() => null)];
104
113
  this.#cachedText = this.#text;
105
114
  this.#cachedWidth = width;
106
115
  this.#cachedLines = result;
116
+ this.#cachedAnchorSpans = spans;
117
+ return { lines: result.length > 0 ? result : [""], spans };
118
+ }
107
119
 
108
- return result.length > 0 ? result : [""];
120
+ renderWithViewportAnchorSource(
121
+ width: number,
122
+ source: { id: string },
123
+ ): {
124
+ lines: string[];
125
+ anchors: Array<({ id: string } & ViewportAnchorSpan) | null>;
126
+ } {
127
+ const { lines, spans } = this.#render(width, true);
128
+ if (!spans) throw new Error("Viewport anchor source render completed without row spans");
129
+ return {
130
+ lines,
131
+ anchors: spans.map(span => (span ? { id: source.id, ...span } : null)),
132
+ };
109
133
  }
110
134
  }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export * from "./components/image";
11
11
  export * from "./components/input";
12
12
  export * from "./components/loader";
13
13
  export * from "./components/markdown";
14
+ export * from "./components/sayknow-pet";
14
15
  export * from "./components/select-list";
15
16
  export * from "./components/settings-list";
16
17
  export * from "./components/spacer";
@@ -53,6 +53,48 @@ export function isNotificationSuppressed(): boolean {
53
53
  return value === "off" || value === "0" || value === "false";
54
54
  }
55
55
 
56
+ let terminalGraphicsFallbackDepth = 0;
57
+ let cursorNeutralImageAllowedDepth = 0;
58
+
59
+ export interface TerminalGraphicsFallbackOptions {
60
+ /**
61
+ * Permit cursor-neutral image escapes (kitty `a=p,C=1` placements) to render
62
+ * inside this fallback scope. Cursor-advancing protocols (iTerm2/SIXEL)
63
+ * remain suppressed. A nested scope without this option revokes the
64
+ * permission for its own subtree.
65
+ */
66
+ allowCursorNeutralImages?: boolean;
67
+ }
68
+
69
+ /**
70
+ * Synchronously suppress terminal graphics while rendering a text-only surface.
71
+ * Nested scopes remain active until the outermost scope exits.
72
+ */
73
+ export function withTerminalGraphicsFallback<T>(fn: () => T, options?: TerminalGraphicsFallbackOptions): T {
74
+ terminalGraphicsFallbackDepth++;
75
+ const allow = options?.allowCursorNeutralImages === true;
76
+ if (allow) cursorNeutralImageAllowedDepth++;
77
+ try {
78
+ return fn();
79
+ } finally {
80
+ if (allow) cursorNeutralImageAllowedDepth--;
81
+ terminalGraphicsFallbackDepth--;
82
+ }
83
+ }
84
+
85
+ /** Returns whether terminal graphics are currently suppressed by a render scope. */
86
+ export function isTerminalGraphicsFallbackActive(): boolean {
87
+ return terminalGraphicsFallbackDepth > 0;
88
+ }
89
+
90
+ /**
91
+ * Returns whether cursor-neutral image escapes may render despite an active
92
+ * graphics-fallback scope. True only when every active fallback scope opted in.
93
+ */
94
+ export function isCursorNeutralImagePermittedInFallback(): boolean {
95
+ return terminalGraphicsFallbackDepth > 0 && cursorNeutralImageAllowedDepth === terminalGraphicsFallbackDepth;
96
+ }
97
+
56
98
  function getForcedImageProtocol(): ImageProtocol | null | undefined {
57
99
  const raw = $env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase();
58
100
  if (!raw) return undefined;