feat(app): find text in terminal scrollback

This commit is contained in:
Mohamed Boudra
2026-09-10 10:58:20 +02:00
parent db8c22e135
commit 459e60f9cb
12 changed files with 865 additions and 13 deletions
@@ -0,0 +1,228 @@
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
import { getTerminalBufferText, buildTerminalWorkspaceUrl } from "../support/helpers/terminal-perf";
function query(page: Page) {
return page.getByRole("textbox", { name: "Find in pane", exact: true }).filter({ visible: true });
}
function status(page: Page) {
return page.getByRole("status", { name: "Find matches" }).filter({ visible: true });
}
function input(page: Page) {
return page
.getByTestId("terminal-surface")
.filter({ visible: true })
.locator(".xterm-helper-textarea");
}
async function openFind(page: Page, text?: string) {
await input(page).press("ControlOrMeta+f");
await expect(query(page)).toBeFocused();
if (text !== undefined) await query(page).fill(text);
}
async function expectSelectedQuery(page: Page, text: string) {
await expect(query(page)).toBeFocused();
await expect(query(page)).toHaveValue(text);
await expect
.poll(() =>
query(page).evaluate((el: HTMLInputElement) => [el.selectionStart, el.selectionEnd]),
)
.toEqual([0, text.length]);
}
async function expectUncoveredMatch(page: Page) {
const widget = page.getByLabel("Find", { exact: true });
await expect
.poll(async () => {
const selected = await page.evaluate(() => {
const term = (
window as Window & {
__paseoTerminal?: {
rows: number;
cols: number;
buffer: { active: { viewportY: number } };
getSelectionPosition():
| { start: { x: number; y: number }; end: { x: number; y: number } }
| undefined;
};
}
).__paseoTerminal;
const selection = term?.getSelectionPosition();
return term && selection
? { selection, rows: term.rows, cols: term.cols, firstRow: term.buffer.active.viewportY }
: null;
});
const screen = await page.locator(".xterm-screen").filter({ visible: true }).boundingBox();
const w = await widget.boundingBox();
if (!selected || !screen || !w) return false;
const x = screen.x + (selected.selection.start.x * screen.width) / selected.cols;
const y =
screen.y +
((selected.selection.start.y - selected.firstRow) * screen.height) / selected.rows;
const height = screen.height / selected.rows;
return (
y >= screen.y &&
y + height <= screen.y + screen.height + 1 &&
!(x >= w.x && x < w.x + w.width && y < w.y + w.height && y + height > w.y)
);
})
.toBe(true);
}
async function viewport(page: Page) {
return page.evaluate(
() =>
(window as Window & { __paseoTerminal?: { buffer: { active: { viewportY: number } } } })
.__paseoTerminal?.buffer.active.viewportY,
);
}
let harness: TerminalE2EHarness;
test.beforeEach(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-find-" });
});
test.afterEach(async () => {
await harness.cleanup();
});
test("searches retained output without sending Find input to the shell", async ({
page,
}, testInfo) => {
const received = path.join(harness.tempRepo.path, "received.txt");
const trigger = path.join(harness.tempRepo.path, "output.trigger");
const bottomTrigger = path.join(harness.tempRepo.path, "bottom.trigger");
const script = `stty -echo; printf 'first a.b\\n'; for i in $(seq 1 90); do printf 'line %s\\n' "$i"; done; printf 'last A.B\\nREADY\\n'; (while [ ! -f output.trigger ]; do sleep 0.05; done; printf 'new output\\n'; while [ ! -f bottom.trigger ]; do sleep 0.05; done; for i in $(seq 1 30); do printf 'bottom output %s\\n' "$i"; done) & while IFS= read -r line; do printf '%s\\n' "$line" >> received.txt; done`;
await writeFile(received, "");
const terminal = await harness.createTerminal({
name: "Find fixture",
command: "bash",
args: ["--noprofile", "--norc", "-c", script],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await expect.poll(() => getTerminalBufferText(page)).toContain("READY");
await openFind(page, "a.b");
await expect(status(page)).toHaveText("2 of 2");
await query(page).press("Shift+Enter");
await expect(status(page)).toHaveText("1 of 2");
await expectUncoveredMatch(page);
const findBox = await page.getByLabel("Find", { exact: true }).boundingBox();
const surfaceBox = await page.getByTestId("terminal-surface").boundingBox();
expect(findBox!.y).toBeLessThan(surfaceBox!.y + 70);
const inspected = await viewport(page);
await writeFile(trigger, "go");
await expect.poll(() => getTerminalBufferText(page)).toContain("new output");
await expect.poll(() => viewport(page)).toBe(inspected);
await page.screenshot({ path: testInfo.outputPath("scrollback-find.png") });
await query(page).press("Enter");
await expect(status(page)).toHaveText("2 of 2");
const inspectedBottom = await viewport(page);
await writeFile(bottomTrigger, "go");
await expect.poll(() => getTerminalBufferText(page)).toContain("bottom output 30");
await expect.poll(() => viewport(page)).toBe(inspectedBottom);
await expectUncoveredMatch(page);
await query(page).press("Enter");
await expect(status(page)).toHaveText("1 of 2");
await query(page).press("ControlOrMeta+f");
await expectSelectedQuery(page, "a.b");
await query(page).fill("A.B");
await expect(status(page)).toHaveText("1 of 2");
await query(page).fill("absent[.*]");
await expect(status(page)).toHaveText("No matches");
await query(page).fill("a.b");
await query(page).press("Escape");
await expect(query(page)).toBeHidden();
await expect(input(page)).toBeFocused();
await expect(page.locator(".xterm-find-result-decoration")).toHaveCount(0);
await input(page).pressSequentially("only-shell-input");
await input(page).press("Enter");
await expect.poll(() => readFile(received, "utf8")).toBe("only-shell-input\n");
await input(page).pressSequentially("discard");
await input(page).press("Control+u");
await input(page).pressSequentially("ordinary-input");
await input(page).press("Enter");
await expect.poll(() => readFile(received, "utf8")).toBe("only-shell-input\nordinary-input\n");
await openFind(page);
await expectSelectedQuery(page, "a.b");
await expect(status(page)).toHaveText(/of 2/);
await expectUncoveredMatch(page);
await expect(page.getByRole("button", { name: "Toggle replace" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Find", exact: true })).toHaveCount(0);
});
test("keeps terminal Find owned by retained tabs and the focused split pane", async ({
page,
}, testInfo) => {
const first = await harness.createTerminal({
name: "First search",
command: "bash",
args: ["-c", "printf 'first needle\\n'; cat"],
});
const second = await harness.createTerminal({
name: "Second search",
command: "bash",
args: ["-c", "printf 'second needle\\nsecond needle\\n'; cat"],
});
await harness.openTerminal(page, { terminalId: first.id });
await openFind(page, "needle");
await expect(status(page)).toHaveText("1 of 1");
await page.getByTestId(`workspace-tab-terminal_${second.id}`).first().click();
await openFind(page, "needle");
await expect(status(page).filter({ visible: true })).toHaveText("2 of 2");
await query(page).filter({ visible: true }).press("Escape");
await page.getByTestId(`workspace-tab-terminal_${first.id}`).first().click();
await input(page).press("ControlOrMeta+f");
await expectSelectedQuery(page, "needle");
await expect(status(page).filter({ visible: true })).toHaveText("1 of 1");
await query(page).filter({ visible: true }).press("Escape");
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
await page.getByTestId(`workspace-tab-terminal_${second.id}`).first().click();
await runWorkspaceActionFromCommandCenter(page, "Move tab right");
const left = page
.getByTestId("split-group-child")
.filter({ has: page.getByTestId(`workspace-tab-terminal_${first.id}`) });
const right = page
.getByTestId("split-group-child")
.filter({ has: page.getByTestId(`workspace-tab-terminal_${second.id}`) });
await expect(left.locator(".xterm-helper-textarea")).toHaveCount(1);
await expect(right.locator(".xterm-helper-textarea")).toHaveCount(1);
await right.getByTestId("terminal-surface").click();
await right.locator(".xterm-helper-textarea").press("ControlOrMeta+f");
await expect(right.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
await right.getByRole("textbox", { name: "Find in pane", exact: true }).fill("needle");
await expect(right.getByRole("status", { name: "Find matches" })).toHaveText(/of 2/);
await expect(left.getByRole("textbox", { name: "Find in pane", exact: true })).toHaveCount(0);
await right.getByRole("textbox", { name: "Find in pane", exact: true }).press("Escape");
await left.getByTestId("terminal-surface").click();
await left.locator(".xterm-helper-textarea").press("ControlOrMeta+f");
await expect(left.getByRole("textbox", { name: "Find in pane", exact: true })).toBeFocused();
await left.getByRole("textbox", { name: "Find in pane", exact: true }).fill("needle");
await expect(left.getByRole("status", { name: "Find matches" })).toHaveText("1 of 1");
await page.screenshot({ path: testInfo.outputPath("split-find.png") });
});
test("keeps the shared Find controls usable in a compact dark browser", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 720 });
await page.emulateMedia({ colorScheme: "dark" });
const terminal = await harness.createTerminal({
name: "Compact search",
command: "bash",
args: ["-c", "printf 'needle\\nneedle\\n'; cat"],
});
await page.goto(buildTerminalWorkspaceUrl(harness.workspaceId, terminal.id));
await expect(page.getByTestId("terminal-surface")).toBeVisible();
await page.getByTestId("terminal-surface").click();
await openFind(page, "needle");
await expect(status(page)).toHaveText("2 of 2");
await page.getByRole("button", { name: "Previous match" }).click();
await expect(status(page)).toHaveText("1 of 2");
await page.getByRole("button", { name: "Next match" }).click();
await expect(status(page)).toHaveText("2 of 2");
const box = await page.getByLabel("Find", { exact: true }).boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
await page.screenshot({ path: testInfo.outputPath("compact-find.png") });
await page.getByRole("button", { name: "Close Find" }).click();
await expect(input(page)).toBeFocused();
});
@@ -1,3 +1,7 @@
import type {
TerminalFindHandle,
TerminalFindResult,
} from "../terminal/runtime/terminal-emulator-runtime";
import type { Ref } from "react";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
@@ -12,6 +16,7 @@ import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
@@ -39,6 +44,8 @@ export interface TerminalEmulatorProps {
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void;
onFocus?: () => Promise<void> | void;
onResize?: (input: {
@@ -19,7 +19,10 @@ import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalOutputData,
TerminalFindResult,
} from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
@@ -49,6 +52,9 @@ type BridgeInboundMessage =
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "paste"; streamKey: string; text: string }
| { type: "clear"; streamKey: string }
| { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
| { type: "clearFind"; streamKey: string }
| { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean }
| { type: "setTheme"; streamKey: string; theme: ITheme }
@@ -67,6 +73,8 @@ type BridgeOutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| { type: "findRequest"; streamKey: string }
| { type: "findResult"; streamKey: string; result: TerminalFindResult }
| {
type: "resize";
streamKey: string;
@@ -162,6 +170,8 @@ export default function WebViewTerminalEmulator({
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onFindRequest,
onFindResult,
onInput,
onFocus,
onResize,
@@ -209,6 +219,8 @@ export default function WebViewTerminalEmulator({
swipeGesturesEnabled,
};
const callbacksRef = useRef({
onFindRequest,
onFindResult,
onInput,
onFocus,
onResize,
@@ -222,6 +234,8 @@ export default function WebViewTerminalEmulator({
onSwipeRight,
});
callbacksRef.current = {
onFindRequest,
onFindResult,
onInput,
onFocus,
onResize,
@@ -315,6 +329,11 @@ export default function WebViewTerminalEmulator({
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
find: {
setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }),
search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }),
clear: () => sendToWebView({ type: "clearFind", streamKey }),
},
writeOutput: (data: TerminalOutputData) => {
const output = outputDecoderRef.current.decode(data, { stream: true });
if (output.length === 0) {
@@ -563,6 +582,14 @@ export default function WebViewTerminalEmulator({
) {
return;
}
if (message.type === "findRequest") {
callbacksRef.current.onFindRequest?.();
return;
}
if (message.type === "findResult") {
callbacksRef.current.onFindResult?.(message.result);
return;
}
handleTerminalMessage(message);
},
[handleLifecycleMessage, handleTerminalMessage, streamKey],
@@ -1,5 +1,10 @@
"use dom";
import type {
TerminalFindHandle,
TerminalFindResult,
} from "../terminal/runtime/terminal-emulator-runtime";
import {
useCallback,
useEffect,
@@ -41,6 +46,7 @@ import {
import { getDesktopHost } from "@/desktop/host";
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
@@ -111,6 +117,8 @@ interface TerminalEmulatorProps {
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void;
onFocus?: () => Promise<void> | void;
onResize?: (input: {
@@ -172,6 +180,8 @@ export default function TerminalEmulator({
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onFindRequest,
onFindResult,
onInput,
onFocus,
onResize,
@@ -201,6 +211,8 @@ export default function TerminalEmulator({
const onRendererReadyChangeRef = useRef(onRendererReadyChange);
onRendererReadyChangeRef.current = onRendererReadyChange;
const mountCallbacksRef = useRef({
onFindRequest,
onFindResult,
onInput,
onResize,
onTerminalKey,
@@ -210,6 +222,8 @@ export default function TerminalEmulator({
onOpenLocalFileLink,
});
mountCallbacksRef.current = {
onFindRequest,
onFindResult,
onInput,
onResize,
onTerminalKey,
@@ -283,6 +297,11 @@ export default function TerminalEmulator({
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
find: {
setWidgetSize: (size) => runtimeRef.current?.find.setWidgetSize(size),
search: (query, direction) => runtimeRef.current?.find.search(query, direction),
clear: () => runtimeRef.current?.find.clear(),
},
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
@@ -478,6 +497,8 @@ export default function TerminalEmulator({
useEffect(() => {
runtimeRef.current?.setCallbacks({
callbacks: {
onFindRequest,
onFindResult,
onInput,
onResize,
onTerminalKey,
@@ -489,6 +510,8 @@ export default function TerminalEmulator({
},
});
}, [
onFindRequest,
onFindResult,
onInput,
onInputModeChange,
onOpenLocalFileLink,
@@ -1,3 +1,5 @@
import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -267,6 +269,12 @@ export function TerminalPane({
const [resizeRequestToken, setResizeRequestToken] = useState(0);
useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const findRef = useRef<TerminalPaneFindHandle>(null);
const handleFindRequest = useCallback(() => findRef.current?.open(), []);
const handleFindResult = useCallback(
(result: TerminalFindResult) => findRef.current?.update(result),
[],
);
const terminalIdRef = useRef<string>(terminalId);
const terminalPresentedRef = useRef(isTerminalPresented);
terminalPresentedRef.current = isTerminalPresented;
@@ -326,6 +334,7 @@ export function TerminalPane({
}, [isMobile, isWorkspaceFocused, mobileView]);
const handleRendererReadyChange = useCallback(
(change: TerminalRendererReadyChange) => {
if (!change.isReady && change.streamKey === terminalStreamKey) findRef.current?.reset();
setRendererReadyStreamKey((current) => applyTerminalRendererReadyChange(current, change));
if (!shouldReplayTerminalSnapshotForRenderer({ change, terminalStreamKey })) {
return;
@@ -1060,6 +1069,8 @@ export function TerminalPane({
onRendererReadyChange={handleRendererReadyChange}
onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft}
onFindRequest={handleFindRequest}
onFindResult={handleFindResult}
onInput={handleTerminalData}
onFocus={handleTerminalFocus}
onResize={handleTerminalResize}
@@ -1075,6 +1086,16 @@ export function TerminalPane({
/>
</View>
<TerminalFind
key={terminalStreamKey}
ref={findRef}
terminal={emulatorRef}
active={
isTerminalPresented && isPaneFocused && rendererReadyStreamKey === terminalStreamKey
}
focusTerminal={requestTerminalFocus}
/>
{showLoadingOverlay ? (
<View style={styles.attachOverlay} pointerEvents="none" testID="terminal-attach-loading">
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
+145
View File
@@ -0,0 +1,145 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
type RefObject,
} from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { PaneFind, type PaneFindHandle } from "@/pane-find";
import { isWeb } from "@/constants/platform";
import { hasActiveWebOverlay } from "@/lib/overlay-root";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import type { TerminalEmulatorHandle } from "@/components/terminal-emulator-contract";
import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime";
export interface TerminalPaneFindHandle {
open(): void;
update(result: TerminalFindResult): void;
reset(): void;
}
export const TerminalFind = forwardRef<
TerminalPaneFindHandle,
{
terminal: RefObject<TerminalEmulatorHandle | null>;
active: boolean;
focusTerminal(): void;
}
>(function TerminalFind({ terminal, active, focusTerminal }, ref) {
const { t } = useTranslation();
const widget = useRef<PaneFindHandle>(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [result, setResult] = useState<TerminalFindResult>({
resultIndex: -1,
resultCount: 0,
limited: false,
placement: "top",
});
const show = useCallback(() => {
if (!active || !terminal.current?.find) return;
if (!open && query) terminal.current.find.search(query);
setOpen(true);
widget.current?.focus();
}, [active, open, query, terminal]);
useImperativeHandle(
ref,
() => ({
open: show,
update: setResult,
reset: () => {
setOpen(false);
setQuery("");
},
}),
[show],
);
useEffect(() => {
if (!isWeb || !active) return;
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
return;
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "f" &&
terminal.current?.find
) {
event.preventDefault();
show();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [active, show, terminal]);
const search = useCallback(
(text: string) => {
setQuery(text);
terminal.current?.find?.search(text);
},
[terminal],
);
const next = useCallback(() => terminal.current?.find?.search(query, "next"), [query, terminal]);
const previous = useCallback(
() => terminal.current?.find?.search(query, "previous"),
[query, terminal],
);
const measureWidget = useCallback(
(event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
terminal.current?.find?.setWidgetSize({ width, height });
},
[terminal],
);
const close = useCallback(() => {
terminal.current?.find?.clear();
setOpen(false);
focusTerminal();
}, [focusTerminal, terminal]);
if (!open || !terminal.current?.find) return null;
const total = `${result.resultCount}${result.limited ? "+" : ""}`;
let status = "";
if (query) {
if (result.resultCount === 0) status = t("paneFind.noMatches");
else if (result.resultIndex < 0) status = t("paneFind.total", { total });
else status = t("paneFind.position", { current: result.resultIndex + 1, total });
}
return (
<View
pointerEvents="box-none"
style={[styles.overlay, result.placement === "top" ? styles.top : styles.bottom]}
>
<View onLayout={measureWidget} style={styles.frame}>
<PaneFind
ref={widget}
query={query}
status={status}
canNavigate={result.resultCount > 0}
onQueryChange={search}
onNext={next}
onPrevious={previous}
onClose={close}
/>
</View>
</View>
);
});
const styles = StyleSheet.create((theme) => ({
overlay: {
position: "absolute",
left: 0,
right: 0,
alignItems: "flex-end",
zIndex: 2,
},
frame: { maxWidth: "100%", padding: theme.spacing[2] },
top: { top: 0 },
bottom: { bottom: 0 },
}));
@@ -6,7 +6,7 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
import { Terminal, type ITheme } from "@xterm/xterm";
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import {
type TerminalInputModeState,
@@ -32,6 +32,21 @@ import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-f
export type TerminalOutputData = Uint8Array;
export interface TerminalFindHandle {
setWidgetSize(size: { width: number; height: number }): void;
search(query: string, direction?: "next" | "previous"): void;
clear(): void;
}
export interface TerminalFindResult {
resultIndex: number;
resultCount: number;
limited: boolean;
placement: "top" | "bottom";
}
const FIND_HIGHLIGHT_LIMIT = 20_000;
export interface TerminalEmulatorRuntimeMountInput {
root: HTMLDivElement;
host: HTMLDivElement;
@@ -43,6 +58,8 @@ export interface TerminalEmulatorRuntimeMountInput {
}
export interface TerminalEmulatorRuntimeCallbacks {
onFindRequest?: () => void;
onFindResult?: (result: TerminalFindResult) => void;
onInput?: (data: string) => Promise<void> | void;
onResize?: (input: TerminalResizeEvent) => Promise<void> | void;
onTerminalKey?: (input: {
@@ -173,6 +190,48 @@ export class TerminalEmulatorRuntime {
};
private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null;
private searchAddon: SearchAddon | null = null;
private findResult = { resultIndex: -1, resultCount: 0 };
private findQuery = "";
private findOutputAnchor: IMarker | undefined;
private findWidgetSize = { width: 0, height: 0 };
readonly find: TerminalFindHandle = {
setWidgetSize: (size) => {
this.findWidgetSize = size;
this.emitFindResult();
},
search: (query, direction) => {
this.releaseFindOutputAnchor();
this.findQuery = query;
if (this.terminal && !this.searchAddon) this.loadSearchAddon(this.terminal);
const theme = this.terminal?.options.theme;
const options = {
regex: false,
caseSensitive: false,
incremental: direction === undefined,
decorations: {
matchBorder: theme?.yellow ?? "#b58900",
matchOverviewRuler: theme?.yellow ?? "#b58900",
activeMatchBorder: theme?.blue ?? "#268bd2",
activeMatchColorOverviewRuler: theme?.blue ?? "#268bd2",
},
};
if (direction === "next") this.searchAddon?.findNext(query, options);
else this.searchAddon?.findPrevious(query, options);
},
clear: () => {
this.releaseFindOutputAnchor();
this.findQuery = "";
// clearDecorations leaves the addon's pending refresh alive. Disposal
// cancels its timers and listeners without touching terminal selection.
this.searchAddon?.dispose();
this.searchAddon = null;
this.findResult = { resultIndex: -1, resultCount: 0 };
this.emitFindResult();
},
};
private fitAndEmitResize: ((input?: TerminalResizeRequest) => void) | null = null;
private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null;
@@ -215,6 +274,77 @@ export class TerminalEmulatorRuntime {
this.pendingModifiers = input.pendingModifiers;
}
private loadSearchAddon(terminal: Terminal): void {
const searchAddon = new SearchAddon({ highlightLimit: FIND_HIGHLIGHT_LIMIT });
terminal.loadAddon(searchAddon);
this.searchAddon = searchAddon;
searchAddon.onDidChangeResults((result) => {
this.findResult = result;
this.emitFindResult();
});
}
private releaseFindOutputAnchor(): void {
this.findOutputAnchor?.dispose();
this.findOutputAnchor = undefined;
}
private preserveFindViewport(terminal: Terminal): void {
if (!this.findQuery || this.findResult.resultCount === 0 || this.findOutputAnchor) return;
const buffer = terminal.buffer.active;
this.findOutputAnchor = terminal.registerMarker(
buffer.viewportY - buffer.baseY - buffer.cursorY,
);
}
private handleFindShortcut(event: KeyboardEvent): boolean {
if (
!(event.metaKey || event.ctrlKey) ||
event.shiftKey ||
event.altKey ||
event.key.toLowerCase() !== "f" ||
!this.callbacks.onFindRequest
)
return false;
event.preventDefault();
event.stopPropagation();
this.callbacks.onFindRequest();
return true;
}
private findWidgetCoversSelection(): boolean {
if (!this.findQuery) return false;
const terminal = this.terminal;
const element = terminal?.element;
const screen = element?.querySelector(".xterm-screen");
const selection = terminal?.getSelectionPosition();
if (!terminal || !element || !screen || !selection) return false;
const root = element.getBoundingClientRect();
const grid = screen.getBoundingClientRect();
const cellWidth = grid.width / terminal.cols;
const cellHeight = grid.height / terminal.rows;
const firstRow = terminal.buffer.active.viewportY;
const top = grid.top + (selection.start.y - firstRow) * cellHeight;
const bottom = grid.top + (selection.end.y - firstRow + 1) * cellHeight;
// A wrapped selection can occupy the right edge on its intermediate rows.
const rightCol = selection.start.y === selection.end.y ? selection.end.x : terminal.cols;
const right = grid.left + rightCol * cellWidth;
return (
top < root.top + this.findWidgetSize.height &&
bottom > root.top &&
right > root.right - this.findWidgetSize.width
);
}
private emitFindResult(): void {
const placement = this.findWidgetCoversSelection() ? "bottom" : "top";
this.callbacks.onFindResult?.({
...this.findResult,
limited: this.findResult.resultCount >= FIND_HIGHLIGHT_LIMIT,
placement,
});
}
getInputModeState(): TerminalInputModeState {
return this.inputModeTracker.getState();
}
@@ -267,7 +397,19 @@ export class TerminalEmulatorRuntime {
},
}),
);
terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
this.loadSearchAddon(terminal);
terminal.onWriteParsed(() => {
// Even a bottom viewport is an inspected location while searching. A
// marker follows buffer trimming; output writes remain batched normally.
const anchor = this.findOutputAnchor;
if (anchor && !anchor.isDisposed) terminal.scrollToLine(anchor.line);
// A parser turn may yield with submitted writes still queued. Commit
// callbacks identify completion; plain writes still submit back-to-back.
if (this.pendingWriteCommits.size === 0) this.releaseFindOutputAnchor();
});
terminal.onScroll(() => {
if (this.findQuery) this.emitFindResult();
});
terminal.loadAddon(new ClipboardAddon());
try {
terminal.loadAddon(new LigaturesAddon());
@@ -418,6 +560,8 @@ export class TerminalEmulatorRuntime {
return true;
}
if (this.handleFindShortcut(event)) return false;
if (!isMac && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) {
const key = event.key.toLowerCase();
@@ -775,6 +919,7 @@ export class TerminalEmulatorRuntime {
}
unmount(): void {
this.releaseFindOutputAnchor();
this.clearInFlightOutputTimeout();
const inFlightOperation = this.inFlightOutputOperation;
this.inFlightOutputOperation = null;
@@ -796,6 +941,9 @@ export class TerminalEmulatorRuntime {
window.__paseoTerminal = undefined;
}
this.terminal = null;
this.searchAddon = null;
this.findQuery = "";
this.findResult = { resultIndex: -1, resultCount: 0 };
this.fitAddon = null;
this.fitAndEmitResize = null;
this.lastSize = null;
@@ -870,6 +1018,7 @@ export class TerminalEmulatorRuntime {
}
private submitWrite(terminal: Terminal, operation: TerminalOutputOperation): void {
this.preserveFindViewport(terminal);
// Synchronous per-write tracking must run in frame order; doing it here in the drain
// loop preserves that ordering even though the writes are submitted without waiting.
const text = this.inputModeDecoder.decode(operation.data, { stream: true });
@@ -879,19 +1028,11 @@ export class TerminalEmulatorRuntime {
}
this.hasUngatedWrites = true;
const onCommitted = operation.onCommitted;
if (!onCommitted) {
try {
terminal.write(operation.data);
} catch {
// Match existing behavior: a failed write still proceeds with no commit callback.
}
return;
}
const commit = () => {
if (!this.pendingWriteCommits.delete(commit)) {
return;
}
onCommitted();
onCommitted?.();
};
this.pendingWriteCommits.add(commit);
try {
@@ -0,0 +1,165 @@
import "@xterm/xterm/css/xterm.css";
import { afterEach, expect, test } from "vitest";
import {
TerminalEmulatorRuntime,
encodeTerminalOutput,
type TerminalFindResult,
} from "./terminal-emulator-runtime";
let runtime: TerminalEmulatorRuntime;
let root: HTMLDivElement;
afterEach(() => {
runtime?.unmount();
root?.remove();
});
function mount() {
root = document.createElement("div");
Object.assign(root.style, { width: "480px", height: "200px" });
const host = document.createElement("div");
Object.assign(host.style, { width: "100%", height: "100%" });
root.append(host);
document.body.append(root);
runtime = new TerminalEmulatorRuntime();
const results: TerminalFindResult[] = [];
runtime.setCallbacks({
callbacks: {
onFindResult: (result) => {
results.push(result);
},
},
});
runtime.mount({
root,
host,
initialSnapshot: null,
scrollback: 1000,
theme: { background: "#ffffff", foreground: "#111111" },
});
return results;
}
function write(text: string) {
return new Promise<void>((resolve) =>
runtime.write({ data: encodeTerminalOutput(text), onCommitted: resolve }),
);
}
test("uses xterm's literal matching across wrapped cells and keeps selection when Find closes", async () => {
const results = mount();
// Initial font measurement schedules refits through 2 seconds. Settle them
// before choosing an exact column boundary for the wrapped text fixture.
await new Promise((resolve) => setTimeout(resolve, 2600));
await write("first [a.b] 界\r\n" + "padding\r\n".repeat(30) + "last [A.B] 界");
runtime.find.search("[a.b] 界");
expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 1, limited: false });
runtime.find.search("[a.b] 界", "previous");
expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 0, placement: "top" });
runtime.find.search("[a.b] 界", "previous");
expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 1 });
const selection = window.__paseoTerminal!.getSelection();
runtime.find.clear();
expect(window.__paseoTerminal!.getSelection()).toBe(selection);
expect(results.at(-1)).toMatchObject({ resultCount: 0, resultIndex: -1 });
runtime.find.search("[aXb]");
expect(results.at(-1)).toMatchObject({ resultCount: 0 });
const cols = window.__paseoTerminal!.cols;
await write("\r\n" + "x".repeat(cols - 2) + "wrapped-needle");
runtime.find.search("wrapped-needle");
await expect.poll(() => results.at(-1)).toMatchObject({ resultCount: 1, resultIndex: 0 });
});
test("reports the addon's highlight limit without disabling navigation beyond the counted matches", async () => {
const results = mount();
await write(("a ".repeat(25) + "\r\n").repeat(801));
runtime.find.search("a");
expect(results.at(-1)).toMatchObject({ resultCount: 20000, limited: true, resultIndex: -1 });
expect(window.__paseoTerminal!.getSelection()).toBe("a");
runtime.find.search("a", "next");
expect(results.at(-1)).toMatchObject({ resultCount: 20000, limited: true, resultIndex: 0 });
});
test("keeps the inspected bottom viewport fixed when output arrives", async () => {
mount();
await write("padding\r\n".repeat(30) + "inspected needle");
runtime.find.search("needle");
const term = window.__paseoTerminal!;
const inspected = term.buffer.active.viewportY;
await write("\r\nnew output".repeat(30));
await expect.poll(() => term.buffer.active.viewportY).toBe(inspected);
expect(term.getSelection()).toBe("needle");
});
test("searches the active alternate buffer without moving the restored normal viewport", async () => {
const results = mount();
await write("normal needle\r\n" + "padding\r\n".repeat(30));
const normalViewport = window.__paseoTerminal!.buffer.active.viewportY;
await write("\x1b[?1049hAlt needle\r\nAlt NEEDLE");
runtime.find.search("needle");
expect(results.at(-1)).toMatchObject({ resultCount: 2, resultIndex: 1, limited: false });
expect(window.__paseoTerminal!.getSelection()).toBe("NEEDLE");
runtime.find.search("needle", "previous");
expect(window.__paseoTerminal!.getSelection()).toBe("needle");
await write("\x1b[?1049l");
await expect.poll(() => window.__paseoTerminal!.buffer.active.viewportY).toBe(normalViewport);
runtime.find.search("needle");
await expect.poll(() => results.at(-1)).toMatchObject({ resultCount: 1, resultIndex: 0 });
});
test("keeps Find at the top unless its measured frame covers the active match", async () => {
const results = mount();
await new Promise((resolve) => setTimeout(resolve, 2600));
runtime.find.setWidgetSize({ width: 356, height: 58 });
const cols = window.__paseoTerminal!.cols;
await write("left-needle\r\n" + " ".repeat(cols - 12) + "right-needle");
runtime.find.search("left-needle");
expect(results.at(-1)).toMatchObject({ resultCount: 1, placement: "top" });
runtime.find.search("right-needle");
expect(results.at(-1)).toMatchObject({ resultCount: 1, placement: "bottom" });
});
test("closing Find cancels pending refreshes without clearing old or new selections", async () => {
mount();
await write("first needle\r\n" + "padding\r\n".repeat(30) + "last needle");
const term = window.__paseoTerminal!;
for (const freshSelection of [false, true]) {
runtime.find.search("needle");
await write("\r\nnew output");
// onWriteParsed schedules the addon's refresh after the write callback.
await new Promise<void>((resolve) => {
const subscription = term.onWriteParsed(() => {
subscription.dispose();
resolve();
});
runtime.write({ data: encodeTerminalOutput("\rprogress") });
});
runtime.find.clear();
if (freshSelection) term.select(0, 0, 5);
const selection = term.getSelection();
expect(selection).toBe(freshSelection ? "first" : "needle");
await new Promise((resolve) => setTimeout(resolve, 350));
expect(term.getSelection()).toBe(selection);
}
});
test("preserves inspection through multiple real parser turns of queued output", async () => {
mount();
await new Promise((resolve) => setTimeout(resolve, 2600));
await write("padding\r\n".repeat(30) + "needle\r\nprogress");
runtime.find.search("needle");
const term = window.__paseoTerminal!;
const inspected = term.buffer.active.viewportY;
const samples: { viewport: number; base: number }[] = [];
const subscription = term.onWriteParsed(() => {
samples.push({ viewport: term.buffer.active.viewportY, base: term.buffer.active.baseY });
});
for (let i = 0; i < 150; i++) {
runtime.write({ data: encodeTerminalOutput("\rprogress".repeat(1000)) });
}
await write("\r\nnew output".repeat(100));
await new Promise((resolve) => setTimeout(resolve, 350));
subscription.dispose();
expect(samples.length).toBeGreaterThan(1);
expect(samples[0].base).toBe(inspected);
expect(samples.at(-1)!.base).toBeGreaterThan(inspected);
expect(term.buffer.active.viewportY).toBe(inspected);
expect(term.getSelection()).toBe("needle");
});
@@ -1,3 +1,4 @@
import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime";
import type { ITheme } from "@xterm/xterm";
import xtermCss from "@xterm/xterm/css/xterm.css";
import type { TerminalState } from "@getpaseo/protocol/messages";
@@ -32,6 +33,9 @@ type InboundMessage =
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "paste"; streamKey: string; text: string }
| { type: "clear"; streamKey: string }
| { type: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
| { type: "clearFind"; streamKey: string }
| { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean }
| { type: "setTheme"; streamKey: string; theme: ITheme }
@@ -50,6 +54,8 @@ type OutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| { type: "findRequest"; streamKey: string }
| { type: "findResult"; streamKey: string; result: TerminalFindResult }
| {
type: "resize";
streamKey: string;
@@ -229,6 +235,18 @@ class TerminalWebViewBridge {
this.resolveLocalFileLinkRequest(message.requestId, message.target);
return;
}
if (message.type === "findWidgetSize") {
this.runtime?.find.setWidgetSize(message.size);
return;
}
if (message.type === "find") {
this.runtime?.find.search(message.query, message.direction);
return;
}
if (message.type === "clearFind") {
this.runtime?.find.clear();
return;
}
this.receiveMounted(message);
}
@@ -306,6 +324,9 @@ class TerminalWebViewBridge {
this.runtime = runtime;
runtime.setCallbacks({
callbacks: {
onFindRequest: () => sendToNative({ type: "findRequest", streamKey: message.streamKey }),
onFindResult: (result) =>
sendToNative({ type: "findResult", streamKey: message.streamKey, result }),
onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }),
onResize: ({ rows, cols, shouldClaim, forceClaim }) =>
sendToNative({
File diff suppressed because one or more lines are too long
@@ -0,0 +1,73 @@
import type { Terminal } from "@xterm/xterm";
import { afterEach, expect, test } from "vitest";
import { terminalEmulatorWebViewHtml } from "./terminal-emulator-webview-html";
interface Message {
type: string;
streamKey?: string;
result?: { resultCount: number; resultIndex: number };
data?: string;
}
interface TerminalFrameWindow extends Window {
__PASEO_TERMINAL_WEBVIEW_RECEIVE__(message: unknown): void;
__paseoTerminal?: Terminal;
}
let frame: HTMLIFrameElement;
afterEach(() => frame?.remove());
test("the generated WebView searches its mounted stream and keeps Find commands off the input bridge", async () => {
const messages: Message[] = [];
frame = document.createElement("iframe");
frame.style.width = "500px";
frame.style.height = "300px";
document.body.append(frame);
const win = frame.contentWindow as TerminalFrameWindow;
Object.assign(win, {
ReactNativeWebView: { postMessage: (raw: string) => messages.push(JSON.parse(raw)) },
});
win.document.open();
win.document.write(terminalEmulatorWebViewHtml);
win.document.close();
await expect.poll(() => messages.some((m) => m.type === "bridgeReady")).toBe(true);
const send = (message: object) =>
win.__PASEO_TERMINAL_WEBVIEW_RECEIVE__({ streamKey: "owned", ...message });
send({
type: "mount",
initialSnapshot: null,
scrollbackLines: 1000,
theme: { background: "#ffffff", foreground: "#111111" },
pendingModifiers: { ctrl: false, shift: false, alt: false },
swipeGesturesEnabled: false,
});
await expect
.poll(() => messages.some((m) => m.type === "rendererReady" && m.streamKey === "owned"))
.toBe(true);
send({ type: "writeOutput", text: "first a.b\r\n" + "padding\r\n".repeat(30) + "last A.B" });
await expect
.poll(() => {
const b = win.__paseoTerminal?.buffer.active;
return b
? Array.from({ length: b.length }, (_, i) => b.getLine(i)?.translateToString(true)).join(
"\n",
)
: "";
})
.toContain("last A.B");
send({ type: "findWidgetSize", size: { width: 356, height: 58 } });
send({ type: "find", query: "a.b" });
await expect
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
.toMatchObject({ resultCount: 2, resultIndex: 1 });
send({ type: "find", query: "a.b", direction: "previous" });
await expect
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
.toMatchObject({ resultCount: 2, resultIndex: 0 });
send({ type: "find", streamKey: "stale", query: "absent" });
expect(win.__paseoTerminal!.getSelection()).toBe("a.b");
send({ type: "clearFind" });
await expect
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
.toMatchObject({ resultCount: 0 });
expect(messages.filter((m) => m.type === "input")).toEqual([]);
send({ type: "unmount" });
});