import type { Terminal } from "@blit-sh/browser"; import type { BlitWorkspace } from "./BlitWorkspace"; import type { BlitConnection } from "./BlitConnection"; import type { TerminalPalette, ConnectionStatus, SessionId } from "./types"; import { type UrlAssessment } from "./urlSecurity"; /** What the pointer is currently over, handed to `onLinkHover` listeners. */ export interface LinkHover { assessment: UrlAssessment; /** * True for an OSC 8 link, where the application chose the target * independently of the text on screen. A regex-detected link is its own * text, so there is nothing for it to misrepresent; an explicit one is the * case where showing the user the real target actually matters. */ explicit: boolean; /** The on-screen text of the link, for comparison against the target. */ text: string; } /** Resolve a terminal surface from the hidden textarea that currently holds * keyboard focus. UI chrome uses this instead of retaining whichever split * happened to mount last. */ export declare function terminalSurfaceForInput(input: Element | null): BlitTerminalSurface | null; export interface BlitTerminalSurfaceOptions { sessionId: SessionId | null; fontFamily?: string; fontSize?: number; palette?: TerminalPalette; readOnly?: boolean; /** Resize the remote session to this surface. Disable for passive previews. Default: true. */ resizable?: boolean; showCursor?: boolean; onRender?: (renderMs: number) => void; scrollbarColor?: string; scrollbarWidth?: number; advanceRatio?: number; /** Coverage gamma for glyph antialiasing. See DEFAULT_TEXT_GAMMA. */ textGamma?: number; } export interface BlitTerminalSurfaceHandle { terminal: Terminal | null; rows: number; cols: number; status: ConnectionStatus; focus(): void; } declare function isIOS(): boolean; export { isIOS }; /** * Framework-agnostic terminal surface. Manages DOM elements, WebGL rendering, * keyboard/mouse input, selection, scrollbar, DPR tracking, and resize * observation. Framework bindings (React, Solid, etc.) attach this to a * container element and forward option changes. */ export declare class BlitTerminalSurface { private _sessionId; private _fontFamily; private _fontSize; private _palette; private _readOnly; private _resizable; private _showCursor; private _onRender; private _scrollbarColor; private _scrollbarWidth; private _advanceRatio; private _textGamma; private _workspace; private _blitConn; private container; private glCanvas; private inputEl; /** Transparent overlay sized to the canvas that captures pointer/wheel/ * touch input and provides native scrolling for scrollback navigation. */ private scrollEl; /** Inner spacer that gives `scrollEl` enough scrollable content height * for the current scrollback range. */ private scrollSpacer; /** True while a resize is re-clamping `scrollEl.scrollTop` under us, so the * scroll listener doesn't read the browser's reflow as the user scrolling. * A span of time, because a reflow's scroll events cannot be named. */ private suppressScrollSync; /** The exact `scrollTop` the sync last asked for, waiting for its own echo. * * Named rather than timed, because a span of time swallows whatever else * lands inside it. A wheel notch is one scroll event now that its travel * is quantised — it used to be a burst of six from the browser's scroll * animation, of which losing one went unnoticed — so a notch that arrived * during the window lost the whole gesture: the surface moved and nothing * else did, leaving the reader at the bottom having plainly scrolled up. */ private pendingScrollTopWrite; /** scrollEl's client height, refreshed from the ResizeObserver and the * scroll listener — both of which run after layout, so the measurement * costs nothing. Never read inside the render loop (see * syncScrollSurface). */ private scrollViewH; /** The last scrollTop we assigned, so the render loop can tell whether a * write is needed without reading the element back. */ private lastScrollTop; /** When the user last moved the scroll surface themselves, so the render * loop can keep its hands off a gesture that is still in flight. */ private lastUserScrollAt; /** Rows the server's re-anchor moved scrollOffset by since the last * syncScrollSurface, so the sync can tell anchor-driven drift (deferrable * mid-gesture) from an external jump (lands immediately). */ private anchorRowsSinceSync; private viewId; private terminal; private renderer; private displayCtx; private cell; private _rows; private _cols; private contentDirty; private lastOffset; /** Device-pixel offset of the grid inside the canvas, from `lastOffset`'s * packing — the IME capture element is placed against it. */ private renderOffsetX; private renderOffsetY; /** Last composited device pixel size, used to detect resizes and schedule a * one-frame catch-up render on the WebGPU backend (see doRender). */ private lastRenderedPw; private lastRenderedPh; private lastWasmBuffer; private raf; private renderScheduled; /** Correction computed by `measureSnap`, awaiting `applySnap`. */ private pendingSnap; /** Last known canvas box, reused by mouse handlers. Null means "re-read * on next use". Mouse events fire many times per frame, and reading the * box in each one forced a style recalc + layout every time — a profile * of pointer movement put ~9% of the whole recording in Recalculate * style, blamed on `mouseToCell`. */ private canvasRect; /** Last value written to the scroll surface's cursor, so a mousemove that * changes nothing does not dirty style. Writing the same value still * invalidates it, which is what made the read above expensive. * Re-seeded to the element's inline baseline in `attach()`. */ private lastCursor; private dpr; /** Sub-pixel correction currently applied to the canvas, in CSS px. * See snapToDevicePixels. */ private snapX; private snapY; private scrollOffset; private scrollFade; private scrollFadeTimer; private scrollbarGeo; private scrollDragging; private scrollDragOffset; private cursorBlinkOn; private cursorBlinkTimer; private selStart; private selEnd; /** The word/line a granularity drag started on, held so the selection can * grow outward from it in either direction. Fields rather than locals in * the mouse handlers: like `selStart`/`selEnd` they are positions in the * scrollback, so they travel when a scrolled view is re-anchored. */ private selAnchorStart; private selAnchorEnd; /** Where a touch selection was anchored, for the same reason. */ private touchSelAnchor; private _selectionListeners; private hoveredUrl; private _linkHoverListeners; private _linkActivate; private predicted; private predictedFromRow; private predictedFromCol; /** Platform gate, fixed at mount: whether the capture field is allowed to * accumulate the line so the host can predict against it. */ private _predictionCapture; /** The part of the capture field already forwarded to the pty. Everything * the field holds beyond this is either untyped proposal or a delta still * to send; see `prediction.ts`. */ private _mirror; /** What the chip is showing: an IME composition being built, or a tail the * host is proposing. "" when there is nothing to show. */ private _chipText; private _chipKind; /** Floating chip beside the terminal cursor. * * Neither kind of text can be drawn into the grid: those cells belong to * the app, which is painting its own output (and, at a fish prompt, its * own autosuggestion) into them. A composition is not the app's text * either — it is not text at all until it is committed. */ private chipEl; private disposed; private _ctrlModifier; private _ctrlModifierListeners; private _altModifier; private _altModifierListeners; /** Tracks the composition string already forwarded to the shell on Android, * so insertCompositionText updates can be streamed letter-by-letter instead * of waiting for compositionend and dumping the whole word at once. */ private _androidCompositionValue; /** True from compositionstart through compositionend. KeyboardEvent and * InputEvent `isComposing` are not reliable on every browser (notably for * the key that completes a macOS dead-key composition), so the DOM * lifecycle is the authority. */ private _compositionActive; /** True when the hidden textarea is kept seeded with filler so iOS soft * keyboards auto-repeat a held Backspace (see IOS_PAD). */ private _iosPad; /** Idle timer that tops the iOS filler buffer back up once a Backspace * repeat burst ends (re-padding mid-burst would cancel iOS's repeat). */ private _iosRepadTimer; private dirtyUnsub; private scrollAnchorUnsub; private resizeObserver; /** Used by BlitConnection to reap a surface whose HMR cleanup was skipped. */ private readonly viewIsActive; private dprMq; private dprCheckHandler; /** Re-snaps the canvas when layout moves it, not only when a frame renders * (see setupDeviceSnapping). */ private snapScrollHandler; private fontsHandler; private boundKeyDown; private boundCompositionStart; private boundCompositionEnd; private boundInput; private boundPaste; private boundScrollListener; private _ctrlVPastePending; private _ctrlVFallbackTimer; private mouseCleanup; constructor(options: BlitTerminalSurfaceOptions); get rows(): number; get cols(): number; get currentTerminal(): Terminal | null; get status(): ConnectionStatus; focus(): void; /** Fill the hidden textarea with the NBSP filler buffer and park the cursor * at the end, so a held Backspace on the iOS soft keyboard keeps having * content to delete and iOS auto-repeats the deletion. No-op off iOS. */ private seedIosPad; /** Top the filler buffer back up once a Backspace repeat burst has gone * idle. Re-padding while the burst is live would reset the field and * cancel iOS's key-repeat, so we wait for a gap between deletions. */ private scheduleIosRepad; /** Reset the capture textarea after an input event: re-seed the iOS filler * buffer, or just empty the field on every other platform. */ private resetCaptureField; /** * Set the Ctrl modifier state for the next typed character. * When active, the next character typed via the soft keyboard will be * converted to its Ctrl+char byte equivalent (e.g. 'c' → Ctrl+C = 0x03). * The modifier auto-resets after one character is consumed. */ setCtrlModifier(active: boolean): void; get ctrlModifier(): boolean; /** Subscribe to Ctrl modifier state changes. Returns unsubscribe function. */ onCtrlModifierChange(listener: (active: boolean) => void): () => void; /** * Set the Alt modifier state for the next typed character. * When active, the next character typed via the soft keyboard will be * prefixed with ESC (0x1b), producing an Alt+char sequence. * The modifier auto-resets after one character is consumed. */ setAltModifier(active: boolean): void; get altModifier(): boolean; /** Subscribe to Alt modifier state changes. Returns unsubscribe function. */ onAltModifierChange(listener: (active: boolean) => void): () => void; /** True when there is a non-empty active selection on this terminal. */ hasSelection(): boolean; /** Subscribe to selection-presence changes. Returns unsubscribe function. */ onSelectionChange(listener: (hasSelection: boolean) => void): () => void; /** * Subscribe to hyperlink hover. The listener receives a classified * assessment, or null when the pointer leaves a link. * * Render `assessment.display` — never `assessment.raw`. The raw target can * contain codepoints that reorder or conceal the text around them, which is * precisely what a preview exists to defeat. */ onLinkHover(listener: (h: LinkHover | null) => void): () => void; /** * Replace the built-in link activation policy with a custom one, typically * to swap the blocking `window.confirm` for an in-app dialog. * * The handler receives an already-classified assessment and is responsible * for honouring its verdict: a `deny` must not be opened, and a `confirm` * must not be opened without asking. Pass null to restore the default. */ setLinkActivateHandler(handler: ((a: UrlAssessment) => void) | null): void; private emitLinkHover; private activateLink; /** Clear any active selection. */ clearSelection(): void; /** * Copy the current selection to the clipboard. Returns the copied text, * or null when there is no selection or copy is unavailable. Must be * invoked from a user gesture (click / pointer / key handler) for * `navigator.clipboard.writeText` to succeed in browsers that gate it. */ copySelection(): Promise; /** * Read text from the active clipboard and send it to the focused session, * wrapped in bracketed-paste markers when the terminal is in * bracketed-paste mode. A Wayland-owned selection is read directly through * the connection; otherwise `navigator.clipboard.readText` must be invoked * from a user gesture in browsers that gate it. Returns the pasted text, or * null when nothing is available. An image-only browser clipboard (e.g. a * fresh phone screenshot) is forwarded to the server clipboard instead, * followed by a ^V so the app reads it — the same convention as the Ctrl+V * paste-event path. */ pasteFromClipboard(): Promise; /** Paste an image-only clipboard (e.g. a fresh phone screenshot) by * pushing it to the server clipboard and triggering the app's read with * ^V — the same convention as the Ctrl+V paste-event path. Returns true * when an image was forwarded. */ private pasteImageFromClipboard; /** * Send arbitrary text to the focused session as if pasted, wrapped in * bracketed-paste markers when the terminal is in bracketed-paste mode. * Newlines are normalised to CR so shells that read them as "Enter" * behave the same as a desktop paste. */ pasteText(text: string): void; private notifySelectionChange; private applyCanvasLayout; /** Attach to a container element. Creates the canvas + textarea inside it. */ attach(container: HTMLDivElement): void; /** Detach from the current container. Removes all DOM elements and listeners. */ detach(): void; /** Clean up all resources. Must be called when the surface is no longer needed. */ dispose(): void; setWorkspace(workspace: BlitWorkspace | null): void; setConnection(conn: BlitConnection | null): void; setSessionId(id: SessionId | null): void; setPalette(palette: TerminalPalette | undefined): void; setFontFamily(fontFamily: string | undefined): void; setFontSize(fontSize: number | undefined): void; /** * Update the read-only flag. Note: this only takes full effect when set * before `attach()`. Changing it while attached will not create/remove the * input textarea or toggle keyboard/mouse listeners. */ setReadOnly(readOnly: boolean | undefined): void; setResizable(resizable: boolean | undefined): void; setShowCursor(show: boolean | undefined): void; setOnRender(fn: ((renderMs: number) => void) | undefined): void; setAdvanceRatio(ratio: number | undefined): void; setTextGamma(gamma: number | undefined): void; private scheduleRender; /** Frame phase 1. Reads layout; writes nothing. */ measureFrame(): void; /** Frame phase 2. Writes and paints; reads no layout. */ paintFrame(): void; private setupDprDetection; /** * Keep the canvas on the device-pixel grid when layout moves it without a * frame to piggyback on. * * {@link snapToDevicePixels} runs inside `doRender`, which is enough while * frames arrive but not otherwise: a pane whose origin moves for a reason of * its own — chrome above it changing height, a dock opening — keeps its stale * correction until the server happens to send an update, and until then every * glyph is resampled off-grid. * * Deliberately no ResizeObserver of its own. A resizable surface already has * one (`setupResizeObserver`) and the snap hangs off that; a passive surface * must not register its container size at all, which a second observer would * do. Scroll covers movement that changes no box. */ private setupDeviceSnapping; private teardownDeviceSnapping; private teardownDprDetection; private setupCellMeasure; private teardownCellMeasure; private remeasureCells; private setupCursorBlink; private teardownCursorBlink; private setupRenderer; private teardownRenderer; private setupTerminal; private teardownTerminal; private setupDirtyListener; private teardownDirtyListener; /** * Follow the server's re-anchoring of a scrolled-back view. * * The offset names a distance from the live bottom, so it has to grow as * the app prints for the text to stay where the reader left it. The * server does that arithmetic — it is the one that knows how many lines * scrolled, including once the scrollback is full and the depth stops * growing — and we take its answer so the scrollbar, the selection * anchors, and the next offset we send all keep meaning the same rows the * frames do. */ private setupScrollAnchorListener; private applyPaletteToTerminal; private applyMetricsToTerminal; private syncTerminalSize; private setupResizeObserver; private teardownResizeObserver; private _resizeTimer; private _lastViewSizeAt; /** Container CSS size, cached in handleResize (post-layout) so doRender * can center a grid smaller than its pane without a forced reflow. */ private _containerW; private _containerH; /** Container size in device pixels, tracked only for a non-resizable view. * Presentation only — it never reaches handleResize, so a thumbnail can't * drag the session's grid down to its own box. */ private _presentBox; /** Wire rate limit for size changes. Low enough that a drag stays * roughly live, high enough not to flood the server with intermediate * sizes (each one can cost an encoder rebuild for h264-software). */ private static readonly RESIZE_THROTTLE_MS; private handleResize; /** Re-send dimensions when connection becomes ready. */ resendSize(): void; private setupRenderLoop; private teardownRenderLoop; /** * Cancel the canvas's fractional device-pixel offset. * * Everything about the backing store is device-pixel exact — cell metrics * snap to whole device pixels (measureCell), glyph quads land on integer * boundaries, the composite blit is 1:1. None of that survives if the * element itself is painted at a fractional offset, which is the normal * outcome of laying panes out with flex weights: the compositor resamples * the whole canvas and every glyph in it softens at once. * * So measure where the box actually lands and translate by the remainder. * The correction is always under one device pixel, and it is applied via * `transform` precisely because transforms do not perturb layout — nothing * reflows, and the sibling scroll/input overlays stay put. */ /** * Read half of device-pixel snapping: work out the correction, write * nothing. Safe only while layout is clean — the frame scheduler's * measure phase, a ResizeObserver callback, or a scroll callback. */ private measureSnap; /** The canvas box, re-read only when something may have moved it. */ private canvasBox; /** Forget the cached box. Call whenever layout may have shifted it. */ private invalidateCanvasBox; /** * Set the scroll surface's cursor, skipping a redundant write. * * The dedup is against {@link lastCursor}, which mirrors the element's * inline style — so the two are seeded together where `scrollEl` is * created. Let them drift and the guard starts suppressing writes the * element never received. */ private setCursor; /** Write half: apply whatever `measureSnap` worked out. */ private applySnap; /** * Both halves back to back. Only for callers that already run after * layout — a ResizeObserver or scroll callback — where the read is a * cached value rather than a forced reflow. The render path must use the * split halves instead, or it reintroduces exactly the cross-pane * read-after-write this was built to remove. */ private snapToDevicePixels; private doRender; /** * Park the hidden capture textarea over the terminal's own cursor, so the * host IME opens its candidate window at the cell being typed into rather * than in the corner of the screen. * * Only the focused pane's element is worth placing — an unfocused one hosts * no composition — and an unfocused one goes back to the corner, which is * where a software keyboard can never cover it. While the view is scrolled * back the cursor is not the thing on screen, so the corner stands in until * the next keystroke snaps the viewport back to it. */ private syncImeTarget; private drawSelectionOverlay; private drawUrlOverlay; private drawOverflowText; private drawPredictedEcho; private drawScrollbar; private reconcilePrediction; /** * Whether the capture field should be accumulating the line right now. * * The question is whether keys are *text* or *commands*, and the alternate * screen is what answers it: editors, pagers and full-screen TUIs switch to * it, prompts do not. * * `echo`/`icanon` look like the obvious test and are exactly backwards. * Every interactive shell turns canonical mode off to do its own line * editing, so a fish/bash/zsh prompt — the one place text prediction * belongs — reports `-icanon -echo`, while `cat` reports cooked. Gating on * them meant the feature engaged nowhere a human types. */ private predictionActive; /** Match the chip to the terminal's own font and palette. */ private styleChip; /** * Forget the line: empty the capture field, drop the mirror, hide the chip. * * Called wherever the field would start lying about what the app is * editing — a key we forward ourselves, a paste, focus loss, a cursor jump. * The cost of resetting when we needn't is one missed prediction; the cost * of not resetting when we should is bytes sent twice. */ private resetPrediction; /** * Reconcile the capture field against the mirror and forward the delta. * * Idempotent by construction — a second call with the field unchanged * computes an empty append — which is what makes it safe to drive from * both `compositionend` and the `input` event that follows it, in whichever * order a given engine emits them. */ private syncPredictionFromField; /** * Draw the composition being built next to the cursor. * * A terminal has nowhere to put a preedit: the pty protocol has no notion * of one, and the cells are the app's. So the client draws it, and until * it does the only thing on screen is the system's candidate window — * which shows the *candidates*, not the buffer they are being chosen for. * * The field holds the composition on every platform: this path does not * depend on prediction mode, and the mirror is "" wherever that is off. */ private showComposition; /** Show `text` in the chip, or hide it when empty. */ private setChip; /** Add text to the dimmed local echo, which is otherwise fed from keydown. */ private echoLocally; /** Push `_chipText` into the chip. Placement happens at render time, * against the caret `syncImeTarget` has already worked out. */ private updateChip; private setupKeyboard; private teardownKeyboard; /** Arm the Ctrl+V deferral: don't send ^V yet, wait for the `paste` event * to forward any clipboard image first. A fallback timer sends the raw * ^V if no paste event materialises (empty clipboard, denied permission, * or a browser that won't fire paste without content) so quoted-insert and * app paste-triggers still work. */ private beginCtrlVPaste; private sendCtrlV; /** Find the first image entry on a clipboard payload, if any. */ private findClipboardImage; private handlePaste; /** Stream Android IME composition updates to the shell one character at a * time. Android soft keyboards (Gboard, Samsung) keep the whole word in * an active composition and only commit it on space/suggestion, which * makes the terminal feel like it accepts input word-by-word. By sending * the delta between consecutive composition values we get letter-by-letter * behaviour for Latin input while still letting compositionend deliver the * final result for non-Latin IMEs. */ private handleAndroidCompositionInput; private setupScrollSurface; private teardownScrollSurface; /** * Resize the spacer so the scroll range matches the current scrollback * depth, and align scrollEl.scrollTop with this.scrollOffset. * * Called from the render loop (cheap idempotent updates) and whenever * scrollOffset changes from a non-scroll source (e.g. Shift+PageUp). */ private syncScrollSurface; /** * True when the only disagreement is where inside a row the surface sits. * * `scrollOffset` is whole lines, so the position it maps back to is the * nearest row boundary — never more than half a row from wherever the user * actually is. Writing that back is not worth doing at any time, because * nothing renders from `scrollTop`: the canvas draws rows from * `scrollOffset`, the scrollbar beside it is ours and drawn from * `scrollOffset` too, and the surface's own scrollbar is hidden. The * difference is invisible until the write makes it visible, by taking the * scroll away from the browser mid-flight and putting it somewhere else. * * This used to hold only for the length of a gesture, which cured a flick * and left the wheel alone: a notch settles in well under * `SCROLL_SETTLE_MS`, so every one of them ended with up to half a row of * correction, in whichever direction its remainder fell. It rides the * render loop, and an idle shell only renders on the cursor blink, so it * arrived as much as half a second late — long after the wheel had stopped, * which is what made it read as the terminal moving on its own. * * A jump from somewhere else — Shift+PageUp, a paste, the server * re-anchoring a scrolled view — moves by rows, not by a fraction of one, * and still lands immediately. */ private subRowDrift; private setupMouse; private teardownMouse; private flashScrollbar; private sendInput; /** Forward text the user typed or pasted, bracketing a paste when the app * asked for it. Newlines are carriage returns on a terminal. */ private sendTypedText; private sendScroll; /** * Report a scroll the user *moved* rather than one they aimed at. * * Everything incremental — a wheel notch, a page key, a selection drag * running off the edge — belongs here: the absolute offset it works out * to counts from a live bottom that the app may move before the message * lands, and the server re-anchors us in that same window. Sent as a * relative move, the two compose instead of racing. `offset` rides along * for servers that only know the absolute form. */ private sendScrollBy; } //# sourceMappingURL=BlitTerminalSurface.d.ts.map