| 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; |
| |
| paintIntervalMs: number; |
| cacheSize: number; |
| } |
|
|
| export const defaultConfig: InkPictureConfig = { |
| pollIntervalMs: 16, |
| |
| 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 { |
| |
| terminalWidth: number; |
| |
| terminalHeight: number; |
| |
| cellWidth: number; |
| |
| cellHeight: number; |
| |
| supportsUnicode: boolean; |
| |
| supportsColor: boolean; |
| |
| supportsSixelGraphics: boolean; |
| |
| supportsKittyGraphics: boolean; |
| |
| 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") { |
| |
| |
| 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) { |
| |
| |
| 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") { |
| |
| |
| 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") { |
| |
| |
| if (context?.supportsSixelGraphics) { |
| return true; |
| } |
| } else if (process.env.TERM_PROGRAM === "WarpTerminal") { |
| const version = process.env.TERM_PROGRAM_VERSION; |
| |
| |
| 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(() => { |
| |
| |
| |
| 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; |
|
|
| |
| if (process.env.TERM_PROGRAM === 'ghostty') { |
| info.supportsKittyGraphics = true; |
| } |
|
|
| |
| if (!info.supportsColor && process.env.COLORTERM === 'truecolor') { |
| info.supportsColor = true; |
| } |
|
|
| |
| |
| 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; |
|
|