@sayknow-cli/tui 0.3.12 → 0.3.15
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/dist/types/components/markdown.d.ts +9 -0
- package/dist/types/components/sayknow-pet.d.ts +128 -0
- package/dist/types/components/text.d.ts +9 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/terminal-capabilities.d.ts +44 -0
- package/dist/types/tui.d.ts +55 -1
- package/dist/types/utils.d.ts +23 -0
- package/package.json +3 -3
- package/src/components/image.ts +23 -4
- package/src/components/markdown.ts +216 -29
- package/src/components/sayknow-pet.ts +435 -0
- package/src/components/text.ts +60 -36
- package/src/index.ts +1 -0
- package/src/terminal-capabilities.ts +119 -4
- package/src/tui.ts +503 -67
- package/src/utils.ts +144 -8
|
@@ -53,6 +53,77 @@ export function isNotificationSuppressed(): boolean {
|
|
|
53
53
|
return value === "off" || value === "0" || value === "false";
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
const MULTIPLEXER_DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
|
|
57
|
+
|
|
58
|
+
function multiplexerEnvEnabled(value: string | undefined): boolean {
|
|
59
|
+
const normalized = value?.trim().toLowerCase();
|
|
60
|
+
return normalized !== undefined && normalized.length > 0 && !MULTIPLEXER_DISABLED_ENV_VALUES.has(normalized);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Returns whether the process runs under a terminal multiplexer (tmux, GNU
|
|
65
|
+
* screen, or zellij). Recognizes the same host markers as the renderer's
|
|
66
|
+
* multiplexer predicate in tui.ts so capability selection and viewport-repaint
|
|
67
|
+
* policy agree on what counts as a multiplexed host. Multiplexers intercept
|
|
68
|
+
* graphics escapes and OSC 8 hyperlinks instead of forwarding them to the
|
|
69
|
+
* outer terminal.
|
|
70
|
+
*/
|
|
71
|
+
export function isUnderTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
72
|
+
if (
|
|
73
|
+
multiplexerEnvEnabled(env.TMUX) ||
|
|
74
|
+
multiplexerEnvEnabled(env.TMUX_PANE) ||
|
|
75
|
+
multiplexerEnvEnabled(env.STY) ||
|
|
76
|
+
multiplexerEnvEnabled(env.ZELLIJ) ||
|
|
77
|
+
multiplexerEnvEnabled(env.SKC_TMUX_LAUNCHED)
|
|
78
|
+
) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
const term = env.TERM?.trim().toLowerCase() ?? "";
|
|
82
|
+
return term.startsWith("tmux") || term.startsWith("screen");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let terminalGraphicsFallbackDepth = 0;
|
|
86
|
+
let cursorNeutralImageAllowedDepth = 0;
|
|
87
|
+
|
|
88
|
+
export interface TerminalGraphicsFallbackOptions {
|
|
89
|
+
/**
|
|
90
|
+
* Permit cursor-neutral image escapes (kitty `a=p,C=1` placements) to render
|
|
91
|
+
* inside this fallback scope. Cursor-advancing protocols (iTerm2/SIXEL)
|
|
92
|
+
* remain suppressed. A nested scope without this option revokes the
|
|
93
|
+
* permission for its own subtree.
|
|
94
|
+
*/
|
|
95
|
+
allowCursorNeutralImages?: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Synchronously suppress terminal graphics while rendering a text-only surface.
|
|
100
|
+
* Nested scopes remain active until the outermost scope exits.
|
|
101
|
+
*/
|
|
102
|
+
export function withTerminalGraphicsFallback<T>(fn: () => T, options?: TerminalGraphicsFallbackOptions): T {
|
|
103
|
+
terminalGraphicsFallbackDepth++;
|
|
104
|
+
const allow = options?.allowCursorNeutralImages === true;
|
|
105
|
+
if (allow) cursorNeutralImageAllowedDepth++;
|
|
106
|
+
try {
|
|
107
|
+
return fn();
|
|
108
|
+
} finally {
|
|
109
|
+
if (allow) cursorNeutralImageAllowedDepth--;
|
|
110
|
+
terminalGraphicsFallbackDepth--;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Returns whether terminal graphics are currently suppressed by a render scope. */
|
|
115
|
+
export function isTerminalGraphicsFallbackActive(): boolean {
|
|
116
|
+
return terminalGraphicsFallbackDepth > 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns whether cursor-neutral image escapes may render despite an active
|
|
121
|
+
* graphics-fallback scope. True only when every active fallback scope opted in.
|
|
122
|
+
*/
|
|
123
|
+
export function isCursorNeutralImagePermittedInFallback(): boolean {
|
|
124
|
+
return terminalGraphicsFallbackDepth > 0 && cursorNeutralImageAllowedDepth === terminalGraphicsFallbackDepth;
|
|
125
|
+
}
|
|
126
|
+
|
|
56
127
|
function getForcedImageProtocol(): ImageProtocol | null | undefined {
|
|
57
128
|
const raw = $env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase();
|
|
58
129
|
if (!raw) return undefined;
|
|
@@ -63,6 +134,15 @@ function getForcedImageProtocol(): ImageProtocol | null | undefined {
|
|
|
63
134
|
return null;
|
|
64
135
|
}
|
|
65
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Returns whether PI_FORCE_IMAGE_PROTOCOL explicitly configures the image
|
|
139
|
+
* protocol, including an explicit "off". An explicit configuration is
|
|
140
|
+
* authoritative: runtime capability probes must not override it.
|
|
141
|
+
*/
|
|
142
|
+
export function isImageProtocolForced(): boolean {
|
|
143
|
+
return getForcedImageProtocol() !== undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
66
146
|
function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null {
|
|
67
147
|
if (!versionRaw) return null;
|
|
68
148
|
const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim());
|
|
@@ -95,7 +175,7 @@ function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null
|
|
|
95
175
|
if (!process.stdout.isTTY) return null;
|
|
96
176
|
if (terminalId === "vscode" || terminalId === "alacritty") return null;
|
|
97
177
|
const term = Bun.env.TERM?.toLowerCase() ?? "";
|
|
98
|
-
if (term.includes("
|
|
178
|
+
if (term.includes("ghostty")) {
|
|
99
179
|
return ImageProtocol.Kitty;
|
|
100
180
|
}
|
|
101
181
|
return null;
|
|
@@ -178,10 +258,10 @@ export const TERMINAL = (() => {
|
|
|
178
258
|
);
|
|
179
259
|
}
|
|
180
260
|
}
|
|
261
|
+
const underMultiplexer = isUnderTerminalMultiplexer();
|
|
181
262
|
// tmux and screen multiplexers do not reliably forward OSC 8 hyperlinks
|
|
182
263
|
// to the outer terminal, so force them off regardless of detected terminal.
|
|
183
|
-
|
|
184
|
-
if (resolved.hyperlinks && (Bun.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"))) {
|
|
264
|
+
if (resolved.hyperlinks && underMultiplexer) {
|
|
185
265
|
resolved = new TerminalInfo(
|
|
186
266
|
resolved.id,
|
|
187
267
|
resolved.imageProtocol,
|
|
@@ -190,6 +270,17 @@ export const TERMINAL = (() => {
|
|
|
190
270
|
resolved.notifyProtocol,
|
|
191
271
|
);
|
|
192
272
|
}
|
|
273
|
+
// Multiplexers (tmux/screen/zellij) consume raw kitty/iTerm2 graphics
|
|
274
|
+
// escapes instead of forwarding them (no DCS passthrough wrapping is
|
|
275
|
+
// emitted), so a detected image protocol draws nothing while its
|
|
276
|
+
// out-of-band cursor writes corrupt the frame. Graphics are therefore
|
|
277
|
+
// unconditionally suppressed under a multiplexer; the runtime sixel probe
|
|
278
|
+
// never runs there (tmux advertises DA1 ";4" from compile-time support
|
|
279
|
+
// regardless of the attached client), and PI_FORCE_IMAGE_PROTOCOL=sixel
|
|
280
|
+
// is the only opt-in for chains that render sixel end-to-end.
|
|
281
|
+
if (resolved.imageProtocol && forcedImageProtocol === undefined && underMultiplexer) {
|
|
282
|
+
resolved = new TerminalInfo(resolved.id, null, resolved.trueColor, resolved.hyperlinks, resolved.notifyProtocol);
|
|
283
|
+
}
|
|
193
284
|
return resolved;
|
|
194
285
|
})();
|
|
195
286
|
|
|
@@ -197,11 +288,35 @@ type MutableTerminalInfo = {
|
|
|
197
288
|
imageProtocol: ImageProtocol | null;
|
|
198
289
|
};
|
|
199
290
|
|
|
291
|
+
type ImageProtocolChangeListener = (imageProtocol: ImageProtocol | null) => void;
|
|
292
|
+
const imageProtocolChangeListeners = new Set<ImageProtocolChangeListener>();
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Subscribe to runtime image-protocol changes (e.g. the asynchronous sixel
|
|
296
|
+
* capability probe enabling graphics after startup). Returns an unsubscribe
|
|
297
|
+
* function. Listeners fire only on actual changes.
|
|
298
|
+
*/
|
|
299
|
+
export function onImageProtocolChanged(listener: ImageProtocolChangeListener): () => void {
|
|
300
|
+
imageProtocolChangeListeners.add(listener);
|
|
301
|
+
return () => {
|
|
302
|
+
imageProtocolChangeListeners.delete(listener);
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
200
306
|
/**
|
|
201
307
|
* Override terminal image protocol at runtime after capability probes complete.
|
|
202
308
|
*/
|
|
203
309
|
export function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): void {
|
|
204
|
-
|
|
310
|
+
const mutable = TERMINAL as unknown as MutableTerminalInfo;
|
|
311
|
+
if (mutable.imageProtocol === imageProtocol) return;
|
|
312
|
+
mutable.imageProtocol = imageProtocol;
|
|
313
|
+
for (const listener of imageProtocolChangeListeners) {
|
|
314
|
+
try {
|
|
315
|
+
listener(imageProtocol);
|
|
316
|
+
} catch {
|
|
317
|
+
// Listener failures must not break protocol switching.
|
|
318
|
+
}
|
|
319
|
+
}
|
|
205
320
|
}
|
|
206
321
|
|
|
207
322
|
export function getTerminalInfo(terminalId: TerminalId): TerminalInfo {
|