@sayknow-cli/tui 0.3.7 → 0.3.9

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.
@@ -0,0 +1,13 @@
1
+ export type AnimationCadence = 16 | 80;
2
+ type AnimationCallback = (now: number) => void;
3
+ export interface AnimationRegistration {
4
+ unregister(): void;
5
+ }
6
+ export declare function registerAnimationCallback(callback: AnimationCallback, cadence?: AnimationCadence): AnimationRegistration;
7
+ export declare const __animationSchedulerTestHooks: {
8
+ getActiveTimerCount(cadence?: AnimationCadence): number;
9
+ getRegistrantCount(cadence?: AnimationCadence): number;
10
+ getStartedTimerCount(cadence?: AnimationCadence): number;
11
+ reset(): void;
12
+ };
13
+ export {};
@@ -1,4 +1,5 @@
1
1
  export declare function getSlashCommandMatchRank(query: string, commandName: string): number;
2
+ export declare function extractSlashCommandTokenPrefix(text: string): string | null;
2
3
  export interface AutocompleteItem {
3
4
  value: string;
4
5
  label: string;
@@ -1,4 +1,4 @@
1
- import type { AutocompleteProvider } from "../autocomplete";
1
+ import { type AutocompleteProvider } from "../autocomplete";
2
2
  import type { SymbolTheme } from "../symbols";
3
3
  import { type Component, type Focusable } from "../tui";
4
4
  import { type SelectListTheme } from "./select-list";
@@ -24,6 +24,13 @@ interface HistoryStorage {
24
24
  add(prompt: string, cwd?: string): Promise<void>;
25
25
  getRecent(limit: number, cwd?: string): HistoryEntry[];
26
26
  }
27
+ /** Test-only performance counters for advisory baseline tests. */
28
+ export declare const __editorPerfCounters: {
29
+ layoutTextInvocations: number;
30
+ layoutLogicalLinesProcessed: number;
31
+ visibleWidthMeasurements: number;
32
+ reset(): void;
33
+ };
27
34
  export declare class Editor implements Component, Focusable {
28
35
  #private;
29
36
  /** Focusable interface - set by TUI when focus changes */
@@ -46,6 +53,7 @@ export declare class Editor implements Component, Focusable {
46
53
  onTab?: (text: string) => boolean | undefined;
47
54
  disableSubmit: boolean;
48
55
  constructor(theme: EditorTheme);
56
+ dispose(): void;
49
57
  setAutocompleteProvider(provider: AutocompleteProvider): void;
50
58
  getAutocompleteProvider(): AutocompleteProvider | undefined;
51
59
  /** Whether the autocomplete dropdown is currently open. */
@@ -66,7 +74,7 @@ export declare class Editor implements Component, Focusable {
66
74
  setPlaceholder(placeholder: string | undefined): void;
67
75
  /**
68
76
  * Get the available width for top border content given a total terminal width.
69
- * Accounts for the border characters and horizontal padding when visible.
77
+ * Accounts for right gutter, border characters, and horizontal padding when visible.
70
78
  */
71
79
  getTopBorderAvailableWidth(terminalWidth: number): number;
72
80
  /**
@@ -76,6 +84,7 @@ export declare class Editor implements Component, Focusable {
76
84
  getUseTerminalCursor(): boolean;
77
85
  setMaxHeight(maxHeight: number | undefined): void;
78
86
  setPaddingX(paddingX: number): void;
87
+ setRightGutterWidth(width: number): void;
79
88
  getAutocompleteMaxVisible(): number;
80
89
  setAutocompleteMaxVisible(maxVisible: number): void;
81
90
  setHistoryStorage(storage: HistoryStorage): void;
@@ -1,11 +1,20 @@
1
1
  import type { TUI } from "../tui";
2
2
  import { Text } from "./text";
3
+ export interface LoaderOptions {
4
+ timeDependentColor?: boolean;
5
+ }
6
+ /** Test-only performance counters for advisory baseline tests. */
7
+ export declare const __loaderPerfCounters: {
8
+ liveIntervals: number;
9
+ startedIntervals: number;
10
+ reset(): void;
11
+ };
3
12
  export declare class Loader extends Text {
4
13
  #private;
5
14
  private spinnerColorFn;
6
15
  private messageColorFn;
7
16
  private message;
8
- constructor(ui: TUI, spinnerColorFn: (str: string) => string, messageColorFn: (str: string) => string, message?: string, spinnerFrames?: string[]);
17
+ constructor(ui: TUI, spinnerColorFn: (str: string) => string, messageColorFn: (str: string) => string, message?: string, spinnerFrames?: string[], options?: LoaderOptions);
9
18
  render(width: number): string[];
10
19
  start(): void;
11
20
  stop(): void;
@@ -1,8 +1,16 @@
1
1
  import type { SymbolTheme } from "../symbols";
2
2
  import type { Component } from "../tui";
3
+ /** Test-only clock seam for streaming throttle tests. */
4
+ export declare function __setMarkdownNowForTest(now: (() => number) | undefined): void;
3
5
  /** Test/diagnostic seam: number of synchronous highlight invocations since the last reset. */
4
6
  export declare function getMarkdownHighlightCallCount(): number;
5
7
  export declare function resetMarkdownHighlightCallCount(): void;
8
+ /** Test-only performance counters for advisory baseline tests. */
9
+ export declare const __markdownPerfCounters: {
10
+ lexerInvocations: number;
11
+ lexedBytes: number;
12
+ reset(): void;
13
+ };
6
14
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
7
15
  export declare function clearRenderCache(): void;
8
16
  /**
@@ -53,7 +61,12 @@ export interface MarkdownTheme {
53
61
  export declare class Markdown implements Component {
54
62
  #private;
55
63
  constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
56
- setText(text: string): void;
64
+ setOnStaleThrottle(callback: (() => void) | undefined): void;
65
+ setText(text: string, options?: {
66
+ streaming?: boolean;
67
+ }): void;
68
+ setStreaming(streaming: boolean): void;
69
+ dispose(): void;
57
70
  invalidate(): void;
58
71
  render(width: number): string[];
59
72
  }
@@ -1,3 +1,4 @@
1
+ export * from "./animation-scheduler";
1
2
  export * from "./autocomplete";
2
3
  export * from "./components/box";
3
4
  export * from "./components/cancellable-loader";
@@ -47,13 +47,73 @@ export interface ImageRenderOptions {
47
47
  maxWidthCells?: number;
48
48
  maxHeightCells?: number;
49
49
  preserveAspectRatio?: boolean;
50
+ /**
51
+ * Kitty-only: stable placement id (`p=`). Re-emitting the same image id +
52
+ * placement id *replaces* the existing placement instead of stacking a new
53
+ * copy, which makes diff-renderer repaints idempotent. Callers that render
54
+ * a persistent component should allocate one id per component instance.
55
+ */
56
+ placementId?: number;
57
+ /**
58
+ * Kitty-only: stable image id (`i=`). Defaults to a content hash of the
59
+ * base64 payload ({@link kittyImageId}). Pass a precomputed id to avoid
60
+ * re-hashing large payloads on every render.
61
+ */
62
+ imageId?: number;
63
+ /**
64
+ * Kitty-only: sink for the out-of-band data transmission (`a=t`) emitted
65
+ * the first time an image id is rendered. Defaults to the process-wide
66
+ * writer configured via {@link setKittyTransmitWriter} (stdout).
67
+ */
68
+ onTransmit?: (sequence: string) => void;
50
69
  }
70
+ /**
71
+ * Derive a stable 32-bit non-zero kitty image id (`i=`) from image content
72
+ * (FNV-1a over the base64 payload). Identical content maps to the same id, so
73
+ * retransmission replaces the stored image instead of accumulating copies.
74
+ */
75
+ export declare function kittyImageId(base64Data: string): number;
51
76
  export declare function getCellDimensions(): CellDimensions;
52
77
  export declare function setCellDimensions(dims: CellDimensions): void;
53
78
  export declare function encodeKitty(base64Data: string, options?: {
54
79
  columns?: number;
55
80
  rows?: number;
56
81
  imageId?: number;
82
+ placementId?: number;
83
+ }): string;
84
+ /** Test hook: forget which kitty image ids were transmitted. */
85
+ export declare function resetKittyTransmissions(): void;
86
+ /**
87
+ * Override where out-of-band kitty data transmissions (`a=t`) are written.
88
+ * The default writes directly to stdout: a transmit-only escape is
89
+ * cursor-neutral (it uploads pixel data without drawing anything), so the
90
+ * only ordering requirement is that it reaches the terminal before the
91
+ * placement escape that references it — which the synchronous write during
92
+ * render guarantees. Tests use this to capture transmissions.
93
+ */
94
+ export declare function setKittyTransmitWriter(writer: (sequence: string) => void): void;
95
+ /**
96
+ * Encode a kitty transmit-only (`a=t`) escape: uploads image data under a
97
+ * stable id without creating a placement. Chunked at 4096 bytes per spec.
98
+ *
99
+ * This is deliberately separate from placement: re-sending data (`a=t`/`a=T`)
100
+ * for an existing image id deletes the image and ALL of its placements, so
101
+ * data must be uploaded exactly once per id and repaints must go through
102
+ * {@link encodeKittyPlacement} only.
103
+ */
104
+ export declare function encodeKittyTransmit(base64Data: string, imageId: number): string;
105
+ /**
106
+ * Encode a kitty placement-only (`a=p`) escape referencing previously
107
+ * transmitted data. Re-emitting the same i=/p= pair replaces that one
108
+ * placement (never stacks, never touches sibling placements), and C=1
109
+ * keeps the cursor where it is so the escape can be emitted from the
110
+ * component's first row without cursor-up tricks.
111
+ */
112
+ export declare function encodeKittyPlacement(options: {
113
+ imageId: number;
114
+ placementId: number;
115
+ columns: number;
116
+ rows: number;
57
117
  }): string;
58
118
  export declare function encodeITerm2(base64Data: string, options?: {
59
119
  width?: number | string;
@@ -68,8 +128,16 @@ export declare function getJpegDimensions(base64Data: string): ImageDimensions |
68
128
  export declare function getGifDimensions(base64Data: string): ImageDimensions | null;
69
129
  export declare function getWebpDimensions(base64Data: string): ImageDimensions | null;
70
130
  export declare function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null;
71
- export declare function renderImage(base64Data: string, imageDimensions: ImageDimensions, options?: ImageRenderOptions): {
131
+ export interface RenderedImage {
72
132
  sequence: string;
73
133
  rows: number;
74
- } | null;
134
+ /**
135
+ * True when the escape neither moves the cursor nor carries pixel data
136
+ * (kitty `a=p,C=1` placements). Cursor-neutral sequences can be emitted
137
+ * from the component's first row; cursor-advancing protocols
138
+ * (iTerm2/SIXEL) must draw from the last reserved row instead.
139
+ */
140
+ cursorNeutral?: boolean;
141
+ }
142
+ export declare function renderImage(base64Data: string, imageDimensions: ImageDimensions, options?: ImageRenderOptions): RenderedImage | null;
75
143
  export declare function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string;
@@ -32,6 +32,7 @@ export interface Terminal {
32
32
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
33
33
  write(data: string): void;
34
34
  get available(): boolean;
35
+ readonly isProcessTerminal?: boolean;
35
36
  get columns(): number;
36
37
  get rows(): number;
37
38
  get kittyProtocolActive(): boolean;
@@ -64,6 +65,7 @@ export declare function resolveTerminalRows(stream?: TerminalSizeStream, envRows
64
65
  */
65
66
  export declare class ProcessTerminal implements Terminal {
66
67
  #private;
68
+ get isProcessTerminal(): boolean;
67
69
  get kittyProtocolActive(): boolean;
68
70
  get appearance(): TerminalAppearance | undefined;
69
71
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
@@ -72,6 +72,15 @@ export interface OverlayMargin {
72
72
  }
73
73
  /** Value that can be absolute (number) or percentage (string like "50%") */
74
74
  export type SizeValue = number | `${number}%`;
75
+ /**
76
+ * True when repainting only the live viewport is safer than clearing/replaying
77
+ * the full transcript. Native Windows console hosts are included even when
78
+ * WT_SESSION is absent because PowerShell/ConPTY launch chains can drop terminal
79
+ * identity variables while keeping the same scroll-jump behavior.
80
+ */
81
+ export declare function shouldUseViewportRepaintForHost(env?: Record<string, string | undefined>, platform?: NodeJS.Platform, options?: {
82
+ includeNativeWindows?: boolean;
83
+ }): boolean;
75
84
  /**
76
85
  * Options for overlay positioning and sizing.
77
86
  * Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -130,6 +139,11 @@ export declare class Container implements Component {
130
139
  invalidate(): void;
131
140
  render(width: number): string[];
132
141
  }
142
+ type TuiRenderCounterSnapshot = {
143
+ debugRedrawEnvReads: number;
144
+ debugRedrawAppendWrites: number;
145
+ differentialGuardVisibleWidthCalls: number;
146
+ };
133
147
  /**
134
148
  * TUI - Main class for managing terminal UI with differential rendering
135
149
  */
@@ -138,6 +152,8 @@ export declare class TUI extends Container {
138
152
  terminal: Terminal;
139
153
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
140
154
  onDebug?: () => void;
155
+ static resetRenderCountersForTest(): void;
156
+ static getRenderCountersForTest(): TuiRenderCounterSnapshot;
141
157
  overlayStack: {
142
158
  component: Component;
143
159
  options?: OverlayOptions;
@@ -145,6 +161,7 @@ export declare class TUI extends Container {
145
161
  hidden: boolean;
146
162
  }[];
147
163
  constructor(terminal: Terminal, showHardwareCursor?: boolean);
164
+ dispose(): void;
148
165
  get fullRedraws(): number;
149
166
  getShowHardwareCursor(): boolean;
150
167
  setShowHardwareCursor(enabled: boolean): void;
@@ -175,16 +192,17 @@ export declare class TUI extends Container {
175
192
  removeInputListener(listener: InputListener): void;
176
193
  stop(): void;
177
194
  /**
178
- * Multiplexer-aware resize render request.
195
+ * Viewport-repaint-aware resize render request.
179
196
  *
180
197
  * A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
181
198
  * to -1, which makes `#doRender` treat the frame as a width change and fall into the
182
199
  * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
183
200
  * `3J` escape (users navigate scrollback history), so replaying every transcript line
184
201
  * piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
185
- * high speed" resize storm. Here we keep force off in multiplexers so `#doRender`'s
186
- * height-change branch takes the viewport-only `multiplexerViewportRepaint` path instead.
187
- * Set `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy forced redraw.
202
+ * high speed" resize storm. Windows Terminal/ConPTY can also visibly jump to
203
+ * the transcript top during streaming redraws, so viewport-repaint sessions
204
+ * keep force off and let `#doRender` repaint only the live viewport. Set
205
+ * `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
188
206
  */
189
207
  requestResizeRender(): void;
190
208
  requestRender(force?: boolean, source?: string): void;
@@ -1,9 +1,19 @@
1
1
  import { Ellipsis, type ExtractSegmentsResult, type SliceResult } from "@sayknow-cli/natives";
2
2
  export { Ellipsis } from "@sayknow-cli/natives";
3
3
  export { getDefaultTabWidth, getIndentation } from "@sayknow-cli/utils";
4
+ /** Test-only performance counters for advisory baseline tests. */
5
+ export declare const __textHelperPerfCounters: {
6
+ truncateToWidthCalls: number;
7
+ wrapTextWithAnsiCalls: number;
8
+ truncateLinesToWidthCalls: number;
9
+ visibleWidthsCalls: number;
10
+ reset(): void;
11
+ };
12
+ export declare function invalidateTabWidthCache(): void;
4
13
  export declare function isPrintableAscii(text: string): boolean;
5
14
  export declare function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean | null): SliceResult;
6
15
  export declare function truncateToWidth(text: string, maxWidth: number, ellipsisKind?: Ellipsis | null, pad?: boolean | null): string;
16
+ export declare function truncateLinesToWidth(lines: readonly string[], maxWidth: number, ellipsisKind?: Ellipsis | null, pad?: boolean | null): string[];
7
17
  export declare function wrapTextWithAnsi(text: string, width: number): string[];
8
18
  export declare function extractSegments(line: string, beforeEnd: number, afterStart: number, afterLen: number, strictAfter: boolean): ExtractSegmentsResult;
9
19
  /**
@@ -24,6 +34,8 @@ export declare function visibleWidthRaw(str: string): number;
24
34
  * Calculate the visible width of a string in terminal columns.
25
35
  */
26
36
  export declare function visibleWidth(str: string): number;
37
+ export declare function visibleWidthsNative(lines: readonly string[]): number[];
38
+ export declare function visibleWidths(lines: readonly string[]): number[];
27
39
  /**
28
40
  * Normalize text for terminal output without changing logical editor content.
29
41
  * Some terminals render canonically decomposed Hangul jamo or precomposed
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/tui",
4
- "version": "0.3.7",
4
+ "version": "0.3.9",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
- "homepage": "https://github.com/jaybeyond/Sayknow_CLI",
6
+ "homepage": "https://sayknow-cli.com",
7
7
  "author": "jaybeyond",
8
8
  "contributors": [
9
9
  "Mario Zechner"
@@ -38,8 +38,8 @@
38
38
  "fmt": "biome format --write ."
39
39
  },
40
40
  "dependencies": {
41
- "@sayknow-cli/natives": "0.3.7",
42
- "@sayknow-cli/utils": "0.3.7",
41
+ "@sayknow-cli/natives": "0.3.9",
42
+ "@sayknow-cli/utils": "0.3.9",
43
43
  "lru-cache": "11.3.6",
44
44
  "marked": "^18.0.3"
45
45
  },
@@ -0,0 +1,99 @@
1
+ export type AnimationCadence = 16 | 80;
2
+
3
+ type TimerHandle = ReturnType<typeof setInterval>;
4
+ type AnimationCallback = (now: number) => void;
5
+
6
+ interface CadenceBucket {
7
+ callbacks: Set<AnimationCallback>;
8
+ timer?: TimerHandle;
9
+ startedTimers: number;
10
+ }
11
+
12
+ const buckets = new Map<AnimationCadence, CadenceBucket>();
13
+
14
+ function getBucket(cadence: AnimationCadence): CadenceBucket {
15
+ let bucket = buckets.get(cadence);
16
+ if (!bucket) {
17
+ bucket = { callbacks: new Set(), startedTimers: 0 };
18
+ buckets.set(cadence, bucket);
19
+ }
20
+ return bucket;
21
+ }
22
+
23
+ function startBucket(cadence: AnimationCadence, bucket: CadenceBucket): void {
24
+ if (bucket.timer) return;
25
+ bucket.timer = setInterval(() => {
26
+ const now = performance.now();
27
+ // Snapshot so re-entrant register/unregister during a tick is safe, and
28
+ // isolate each callback so one throwing registrant cannot starve siblings
29
+ // or surface as an uncaught exception that kills the shared timer.
30
+ for (const callback of [...bucket.callbacks]) {
31
+ try {
32
+ callback(now);
33
+ } catch (err) {
34
+ console.error("[animation-scheduler] callback threw:", err);
35
+ }
36
+ }
37
+ }, cadence);
38
+ bucket.startedTimers += 1;
39
+ bucket.timer?.unref?.();
40
+ }
41
+
42
+ function stopBucket(bucket: CadenceBucket): void {
43
+ if (!bucket.timer) return;
44
+ clearInterval(bucket.timer);
45
+ bucket.timer = undefined;
46
+ }
47
+
48
+ export interface AnimationRegistration {
49
+ unregister(): void;
50
+ }
51
+
52
+ export function registerAnimationCallback(
53
+ callback: AnimationCallback,
54
+ cadence: AnimationCadence = 80,
55
+ ): AnimationRegistration {
56
+ const bucket = getBucket(cadence);
57
+ bucket.callbacks.add(callback);
58
+ startBucket(cadence, bucket);
59
+ let registered = true;
60
+
61
+ return {
62
+ unregister(): void {
63
+ if (!registered) return;
64
+ registered = false;
65
+ bucket.callbacks.delete(callback);
66
+ if (bucket.callbacks.size === 0) stopBucket(bucket);
67
+ },
68
+ };
69
+ }
70
+
71
+ export const __animationSchedulerTestHooks = {
72
+ getActiveTimerCount(cadence?: AnimationCadence): number {
73
+ if (cadence !== undefined) return getBucket(cadence).timer ? 1 : 0;
74
+ let count = 0;
75
+ for (const bucket of buckets.values()) {
76
+ if (bucket.timer) count += 1;
77
+ }
78
+ return count;
79
+ },
80
+ getRegistrantCount(cadence?: AnimationCadence): number {
81
+ if (cadence !== undefined) return getBucket(cadence).callbacks.size;
82
+ let count = 0;
83
+ for (const bucket of buckets.values()) count += bucket.callbacks.size;
84
+ return count;
85
+ },
86
+ getStartedTimerCount(cadence?: AnimationCadence): number {
87
+ if (cadence !== undefined) return getBucket(cadence).startedTimers;
88
+ let count = 0;
89
+ for (const bucket of buckets.values()) count += bucket.startedTimers;
90
+ return count;
91
+ },
92
+ reset(): void {
93
+ for (const bucket of buckets.values()) {
94
+ stopBucket(bucket);
95
+ bucket.callbacks.clear();
96
+ bucket.startedTimers = 0;
97
+ }
98
+ },
99
+ };