codev / src /ink-picture /InkPictureProvider.tsx
chenbhao's picture
fix: use env-var terminal detection to avoid stdin conflicts in TUI
fbafa72
Raw
History Blame Contribute Delete
10.6 kB
import { useStdin } from "src/ink";
import checkIsUnicodeSupported from "is-unicode-supported";
import iterm2Version from "iterm2-version";
import React, {
createContext,
Profiler,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import supportsColor from "supports-color";
import { ImageCache } from "./utils/imageCache.js";
import { queryTerminal } from "./utils/queryTerminal.js";
function resolveConfig(
key: string,
propValue: number | undefined,
fallback: number,
min: number,
): number {
const env = process.env[key];
if (env !== undefined) {
const n = parseInt(env, 10);
if (Number.isFinite(n) && n >= min) return n;
}
if (propValue !== undefined) return Math.max(min, propValue);
return fallback;
}
export interface InkPictureConfig {
pollIntervalMs: number;
/** @deprecated Unused since v2.0.1 — images are now repainted after each React render via the Profiler. */
paintIntervalMs: number;
cacheSize: number;
}
export const defaultConfig: InkPictureConfig = {
pollIntervalMs: 16,
/** @deprecated */
paintIntervalMs: 16,
cacheSize: 10,
};
const InkPictureConfigContext = createContext<InkPictureConfig>(defaultConfig);
export function useInkPictureConfig(): InkPictureConfig {
return useContext(InkPictureConfigContext);
}
const ImageCacheContext = createContext<ImageCache | null>(null);
export function useImageCache(): ImageCache | null {
return useContext(ImageCacheContext);
}
type RenderListener = () => void;
const RenderListenerContext = createContext<Set<RenderListener> | null>(null);
export function useOnRender(cb: RenderListener): void {
const listeners = useContext(RenderListenerContext);
useEffect(() => {
if (listeners === null) return;
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}, [cb, listeners]);
}
export interface TerminalInfo {
/** Terminal viewport width in pixels */
terminalWidth: number;
/** Terminal viewport height in pixels */
terminalHeight: number;
/** Width of a single character cell in pixels */
cellWidth: number;
/** Height of a single character cell in pixels */
cellHeight: number;
/** Whether the terminal supports Unicode characters */
supportsUnicode: boolean;
/** Whether the terminal supports color output */
supportsColor: boolean;
/** Whether the terminal supports Sixel graphics protocol */
supportsSixelGraphics: boolean;
/** Whether the terminal supports Kitty graphics protocol */
supportsKittyGraphics: boolean;
/** Whether the terminal supports iTerm2 inline images */
supportsITerm2Graphics: boolean;
}
export const defaultTerminalInfo: TerminalInfo = {
terminalWidth: 6 * process.stdout.columns,
terminalHeight: 12 * process.stdout.rows,
cellWidth: 6,
cellHeight: 12,
supportsUnicode: false,
supportsColor: false,
supportsSixelGraphics: false,
supportsKittyGraphics: false,
supportsITerm2Graphics: false,
};
export const TerminalInfoContext = createContext<TerminalInfo | undefined>(
undefined,
);
export const useTerminalInfo = () => {
const terminalInfo = useContext(TerminalInfoContext);
return terminalInfo ?? defaultTerminalInfo;
};
function supportsITerm2(context?: {
supportsSixelGraphics: boolean;
hasITermCellSizeResponse: boolean;
}) {
if (context?.hasITermCellSizeResponse) return true;
if (process.env.TERM_PROGRAM === "iTerm.app") {
const version = iterm2Version();
if (!version || Number(version[0]) < 3) return false;
return true;
} else if (process.env.TERM_PROGRAM === "WezTerm") {
// WezTerm is compatible with iTerm2 inline images starting from version 20220319-142410-0fcdea07
// See https://wezterm.org/imgcat.html
const version = process.env.TERM_PROGRAM_VERSION;
if (!version) return false;
const date = parseInt(version.split("-")[0], 10);
if (!Number.isNaN(date) && date >= 20220319) {
return true;
}
} else if (process.env.KONSOLE_VERSION) {
// Konsole supports iTerm2 inline images since some time around 2022
// See https://www.reddit.com/r/kde/comments/ul0irg/konsole_2204_with_sixel_support_is_out_of_beta_now/
const version = process.env.KONSOLE_VERSION;
if (!version) return false;
const date = parseInt(version, 10);
if (!Number.isNaN(date) && date >= 220400) {
return true;
}
} else if (process.env.TERM_PROGRAM === "rio") {
// Rio terminal supports iTerm2 inline images since version 0.1.13
// See https://github.com/raphamorim/rio/releases/tag/v0.1.13
const version = process.env.TERM_PROGRAM_VERSION;
if (!version) return false;
const [major, minor, patch] = version
.split(".")
.map((v) => parseInt(v, 10));
if (
!Number.isNaN(major) &&
!Number.isNaN(minor) &&
!Number.isNaN(patch) &&
(major > 0 ||
(major === 0 && minor > 1) ||
(major === 0 && minor === 1 && patch >= 13))
) {
return true;
}
} else if (process.env.TERM_PROGRAM === "vscode") {
// VS Code's integrated terminal can be configured to support Sixel and iTerm2 graphics
// If Sixel is supported, iTerm2 images are also supported
if (context?.supportsSixelGraphics) {
return true;
}
} else if (process.env.TERM_PROGRAM === "WarpTerminal") {
const version = process.env.TERM_PROGRAM_VERSION;
// Supported since v0.2025.03.05.08.02
// See https://docs.warp.dev/getting-started/changelog
if (version) {
const [, year, month, day] = version
.slice(1)
.split(".")
.map((v) => parseInt(v, 10));
if (
!Number.isNaN(year) &&
!Number.isNaN(month) &&
!Number.isNaN(day) &&
(year > 2025 ||
(year === 2025 && month > 3) ||
(year === 2025 && month === 3 && day >= 5))
) {
return true;
}
}
}
return false;
}
interface InkPictureProviderProps {
children: React.ReactNode;
config?: Partial<InkPictureConfig>;
terminalInfo?: Partial<TerminalInfo>;
onTerminalInfoDetection?: (terminalInfo: TerminalInfo) => void;
}
export function InkPictureProvider({
children,
config,
terminalInfo: overrides,
onTerminalInfoDetection,
}: InkPictureProviderProps) {
const { stdin, setRawMode } = useStdin();
const [terminalInfo, setTerminalInfo] = useState<TerminalInfo | undefined>(
undefined,
);
const resolvedConfig: InkPictureConfig = useMemo(() => {
const c: InkPictureConfig = {
pollIntervalMs: resolveConfig(
"INK_PICTURE_POLL_INTERVAL",
config?.pollIntervalMs,
16,
1,
),
paintIntervalMs: resolveConfig(
"INK_PICTURE_PAINT_INTERVAL",
config?.paintIntervalMs,
16,
1,
),
cacheSize: resolveConfig(
"INK_PICTURE_CACHE_SIZE",
config?.cacheSize,
10,
0,
),
};
return c;
}, [config]);
useEffect(() => {
// When terminalInfo overrides are provided, skip the ANSI escape query
// to avoid stdout/stden conflicts with the host TUI. The overrides take
// precedence over queried results in resolvedInfo anyway.
if (overrides && Object.keys(overrides).length > 0) return;
const controller = new AbortController();
const queryTerminalInfo = async () => {
const result = await queryTerminal(stdin, setRawMode, controller.signal);
const info = { ...defaultTerminalInfo, ...overrides };
if (result.cellWidth && result.cellHeight) {
info.cellWidth = result.cellWidth;
info.cellHeight = result.cellHeight;
} else if (result.terminalWidth && result.terminalHeight) {
info.cellWidth = result.terminalWidth / process.stdout.columns;
info.cellHeight = result.terminalHeight / process.stdout.rows;
}
info.supportsUnicode = checkIsUnicodeSupported();
info.supportsColor = !!supportsColor.stdout;
const supportsITerm2Graphics = supportsITerm2({
hasITermCellSizeResponse: result.iterm2CellWidth !== undefined,
supportsSixelGraphics: result.supportsSixelGraphics,
});
info.supportsSixelGraphics = result.supportsSixelGraphics;
info.supportsKittyGraphics = result.supportsKittyGraphics;
info.supportsITerm2Graphics = supportsITerm2Graphics;
// Ghostty supports Kitty graphics protocol natively
if (process.env.TERM_PROGRAM === 'ghostty') {
info.supportsKittyGraphics = true;
}
// Fallback color detection if supports-color fails
if (!info.supportsColor && process.env.COLORTERM === 'truecolor') {
info.supportsColor = true;
}
// Apply iTerm2 cell size override for HiDPI scaling
// See https://iterm2.com/documentation-escape-codes.html#report-cell-size
if (
result.iterm2CellWidth !== undefined &&
result.iterm2CellHeight !== undefined &&
result.iterm2Scale !== undefined
) {
info.cellWidth = result.iterm2CellWidth * result.iterm2Scale;
info.cellHeight = result.iterm2CellHeight * result.iterm2Scale;
info.terminalWidth = info.cellWidth * process.stdout.columns;
info.terminalHeight = info.cellHeight * process.stdout.rows;
}
setTerminalInfo(info);
onTerminalInfoDetection?.(info);
};
queryTerminalInfo();
return () => controller.abort();
}, [stdin, setRawMode, overrides, onTerminalInfoDetection]);
const resolvedInfo = useMemo(
() => ({ ...defaultTerminalInfo, ...terminalInfo, ...overrides }),
[terminalInfo, overrides],
);
const { current: cache } = useRef(
resolvedConfig.cacheSize === 0
? null
: new ImageCache(resolvedConfig.cacheSize),
);
const renderListeners = useMemo(() => new Set<() => void>(), []);
const handleRender = useCallback(() => {
for (const cb of renderListeners) {
cb();
}
}, [renderListeners]);
if (!terminalInfo && !overrides) {
return null;
}
return (
<InkPictureConfigContext.Provider value={resolvedConfig}>
<ImageCacheContext.Provider value={cache}>
<TerminalInfoContext.Provider value={resolvedInfo}>
<RenderListenerContext.Provider value={renderListeners}>
<Profiler id="ink-picture" onRender={handleRender}>
{children}
</Profiler>
</RenderListenerContext.Provider>
</TerminalInfoContext.Provider>
</ImageCacheContext.Provider>
</InkPictureConfigContext.Provider>
);
}
export const TerminalInfoProvider = InkPictureProvider;