diff --git a/README.md b/README.md
index b149fbd..0b6e8fb 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ wterm ("dub-term") renders to the DOM — native text selection, copy/paste, fin
| [`@wterm/react`](packages/@wterm/react) | React component + `useTerminal` hook (TypeScript) |
| [`@wterm/just-bash`](packages/@wterm/just-bash) | In-browser Bash shell powered by just-bash |
| [`@wterm/markdown`](packages/@wterm/markdown) | Render Markdown in the terminal |
+| [`@wterm/search`](packages/@wterm/search) | Grid and scrollback search |
## Features
diff --git a/apps/docs/src/app/api-reference/page.mdx b/apps/docs/src/app/api-reference/page.mdx
index 0058866..2ab414c 100644
--- a/apps/docs/src/app/api-reference/page.mdx
+++ b/apps/docs/src/app/api-reference/page.mdx
@@ -405,3 +405,68 @@ interface CursorState {
visible: boolean;
}
```
+
+## @wterm/search
+
+Search across both terminal grid rows and scrollback rows.
+
+### `Search`
+
+```ts
+import { Search } from "@wterm/search";
+
+const search = new Search(term);
+const match = search.findNext("error");
+```
+
+
+
+
+ | Method |
+ Description |
+
+
+
+
+ new Search(term) |
+ Create a search instance for an initialized WTerm |
+
+
+ findNext(query, opts?) |
+ Find the next match after the internal cursor |
+
+
+ findPrevious(query, opts?) |
+ Find the previous match before the internal cursor |
+
+
+ findAll(query, opts?) |
+ Return all matches in oldest-first order (scrollback then grid) |
+
+
+ reset() |
+ Clear the internal cursor so the next findNext starts from the top |
+
+
+
+
+### `SearchOptions`
+
+```ts
+interface SearchOptions {
+ caseSensitive?: boolean;
+ regex?: boolean;
+ wholeWord?: boolean;
+}
+```
+
+### `SearchMatch`
+
+```ts
+interface SearchMatch {
+ row: number; // negative for scrollback, 0+ for grid
+ col: number;
+ length: number;
+ text: string;
+}
+```
diff --git a/apps/docs/src/app/search/layout.tsx b/apps/docs/src/app/search/layout.tsx
new file mode 100644
index 0000000..a24c3c0
--- /dev/null
+++ b/apps/docs/src/app/search/layout.tsx
@@ -0,0 +1,7 @@
+import { pageMetadata } from "@/lib/page-metadata";
+
+export const metadata = pageMetadata("search");
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return children;
+}
diff --git a/apps/docs/src/app/search/page.mdx b/apps/docs/src/app/search/page.mdx
new file mode 100644
index 0000000..93bd60a
--- /dev/null
+++ b/apps/docs/src/app/search/page.mdx
@@ -0,0 +1,142 @@
+# Search
+
+`@wterm/search` adds grid and scrollback search to wterm terminals. Browser `Ctrl+F` can only search visible DOM rows, but most terminal history lives in WASM scrollback. This package searches both.
+
+## Why
+
+Terminal apps often emit errors above the visible viewport. The browser can only find text already rendered in the DOM, so searching misses history still in scrollback. `@wterm/search` scans scrollback and grid together, in buffer order.
+
+## Install
+
+```bash
+npm install @wterm/search
+```
+
+## Quick Start
+
+### Vanilla JS
+
+```js
+import { WTerm } from "@wterm/dom";
+import { Search } from "@wterm/search";
+import "@wterm/dom/css";
+
+const term = new WTerm(document.getElementById("terminal"));
+await term.init();
+
+term.write("error: db unavailable\\r\\n");
+term.write("info: retrying\\r\\n");
+
+const search = new Search(term);
+const match = search.findNext("error");
+
+if (match) {
+ console.log(match.row, match.col, match.text);
+}
+```
+
+## API Reference
+
+### SearchOptions
+
+
+
+
+ | Option |
+ Type |
+ Default |
+ Description |
+
+
+
+
+ caseSensitive |
+ boolean |
+ false |
+ Match case exactly |
+
+
+ regex |
+ boolean |
+ false |
+ Treat query as a regular expression |
+
+
+ wholeWord |
+ boolean |
+ false |
+ Match only whole words |
+
+
+
+
+### SearchMatch
+
+
+
+
+ | Field |
+ Type |
+ Description |
+
+
+
+
+ row |
+ number |
+ Match row (-1 is most recent scrollback line, 0+ is grid row) |
+
+
+ col |
+ number |
+ Zero-based column of the match |
+
+
+ length |
+ number |
+ Match length in characters |
+
+
+ text |
+ string |
+ Matched text |
+
+
+
+
+### Search
+
+
+
+
+ | Method |
+ Description |
+
+
+
+
+ new Search(term) |
+ Create a search instance for an initialized terminal |
+
+
+ findNext(query, opts?) |
+ Find the next match after the current cursor position |
+
+
+ findPrevious(query, opts?) |
+ Find the previous match before the current cursor position |
+
+
+ findAll(query, opts?) |
+ Return all matches in oldest-first order (scrollback then grid) |
+
+
+ reset() |
+ Reset the internal cursor so the next findNext starts from the top |
+
+
+
+
+## Limitations
+
+`@wterm/search` returns match positions and text only. Visual highlighting through the CSS Highlight API is a follow-up PR.
diff --git a/apps/docs/src/lib/docs-navigation.ts b/apps/docs/src/lib/docs-navigation.ts
index 854fa00..6d9ea12 100644
--- a/apps/docs/src/lib/docs-navigation.ts
+++ b/apps/docs/src/lib/docs-navigation.ts
@@ -34,6 +34,7 @@ export const navGroups: NavGroup[] = [
items: [
{ name: "Just Bash", href: "/just-bash" },
{ name: "Markdown", href: "/markdown" },
+ { name: "Search", href: "/search" },
{ name: "Core / Advanced", href: "/core" },
],
},
diff --git a/apps/docs/src/lib/page-titles.ts b/apps/docs/src/lib/page-titles.ts
index ef9bedb..ecbdcc7 100644
--- a/apps/docs/src/lib/page-titles.ts
+++ b/apps/docs/src/lib/page-titles.ts
@@ -8,6 +8,7 @@ export const PAGE_TITLES: Record = {
vanilla: "Vanilla JS",
"just-bash": "Just Bash",
markdown: "Markdown",
+ search: "Search",
core: "Core / Advanced",
"api-reference": "API Reference",
};
diff --git a/packages/@wterm/core/README.md b/packages/@wterm/core/README.md
index 34c04e3..cab7bbd 100644
--- a/packages/@wterm/core/README.md
+++ b/packages/@wterm/core/README.md
@@ -10,6 +10,7 @@ Headless terminal emulator core for [wterm](https://github.com/vercel-labs/wterm
| [`@wterm/react`](https://www.npmjs.com/package/@wterm/react) | React component + `useTerminal` hook |
| [`@wterm/just-bash`](https://www.npmjs.com/package/@wterm/just-bash) | In-browser Bash shell powered by just-bash |
| [`@wterm/markdown`](https://www.npmjs.com/package/@wterm/markdown) | Streaming Markdown-to-ANSI renderer for terminals |
+| [`@wterm/search`](https://www.npmjs.com/package/@wterm/search) | Grid and scrollback search |
## Install
diff --git a/packages/@wterm/search/README.md b/packages/@wterm/search/README.md
new file mode 100644
index 0000000..05d7828
--- /dev/null
+++ b/packages/@wterm/search/README.md
@@ -0,0 +1,71 @@
+# @wterm/search
+
+Grid and scrollback search for [wterm](https://github.com/vercel-labs/wterm) terminals.
+
+## Search
+
+Search across both visible rows and WASM-backed scrollback.
+
+## Install
+
+```bash
+npm install @wterm/search
+```
+
+## Quick Start
+
+```ts
+import { WTerm } from "@wterm/dom";
+import { Search } from "@wterm/search";
+import "@wterm/dom/css";
+
+const term = new WTerm(document.getElementById("terminal"));
+await term.init();
+
+term.write("error: failed to connect\\r\\n");
+term.write("warning: retrying\\r\\n");
+
+const search = new Search(term);
+const match = search.findNext("error");
+
+if (match) {
+ console.log(match.row, match.col, match.text);
+}
+```
+
+## API
+
+### `SearchOptions`
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `caseSensitive` | `boolean` | `false` | Match case exactly |
+| `regex` | `boolean` | `false` | Treat `query` as a regular expression |
+| `wholeWord` | `boolean` | `false` | Match only whole words |
+
+### `SearchMatch`
+
+| Field | Type | Description |
+|---|---|---|
+| `row` | `number` | Match row (`-1` is most recent scrollback line, `0+` is grid row) |
+| `col` | `number` | Zero-based column of the match |
+| `length` | `number` | Match length in characters |
+| `text` | `string` | Matched text |
+
+### `Search`
+
+| Method | Description |
+|---|---|
+| `new Search(term)` | Create a search instance for an initialized `WTerm` |
+| `findNext(query, opts?)` | Find the next match after the internal cursor |
+| `findPrevious(query, opts?)` | Find the previous match before the internal cursor |
+| `findAll(query, opts?)` | Return all matches in oldest-first buffer order |
+| `reset()` | Clear the internal cursor |
+
+## Limitations
+
+`@wterm/search` returns match positions and text. Visual highlighting (for example via the CSS Highlight API) is planned as a follow-up PR.
+
+## License
+
+Apache-2.0
diff --git a/packages/@wterm/search/package.json b/packages/@wterm/search/package.json
new file mode 100644
index 0000000..c3b4c12
--- /dev/null
+++ b/packages/@wterm/search/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@wterm/search",
+ "version": "0.1.8",
+ "description": "Grid and scrollback search for wterm terminals",
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsc",
+ "prepublishOnly": "pnpm build",
+ "test": "vitest run",
+ "type-check": "tsc --noEmit"
+ },
+ "peerDependencies": {
+ "@wterm/core": "workspace:*",
+ "@wterm/dom": "workspace:*"
+ },
+ "devDependencies": {
+ "@internal/ts": "workspace:*",
+ "typescript": "^6.0.2"
+ },
+ "keywords": [
+ "terminal",
+ "wterm",
+ "search",
+ "scrollback",
+ "find"
+ ],
+ "license": "Apache-2.0",
+ "homepage": "https://wterm.dev",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/vercel-labs/wterm",
+ "directory": "packages/@wterm/search"
+ }
+}
diff --git a/packages/@wterm/search/src/__tests__/engine.test.ts b/packages/@wterm/search/src/__tests__/engine.test.ts
new file mode 100644
index 0000000..071fc9a
--- /dev/null
+++ b/packages/@wterm/search/src/__tests__/engine.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import { compileMatcher } from "../engine.js";
+
+describe("compileMatcher", () => {
+ it("literal match is case-insensitive by default", () => {
+ const matcher = compileMatcher("error");
+ expect(matcher.test("ERROR")).toBe(true);
+ });
+
+ it("caseSensitive true switches to case-sensitive", () => {
+ const matcher = compileMatcher("Error", { caseSensitive: true });
+ expect(matcher.test("Error")).toBe(true);
+ matcher.lastIndex = 0;
+ expect(matcher.test("error")).toBe(false);
+ });
+
+ it("regex true compiles user regex", () => {
+ const matcher = compileMatcher("err(or)?", { regex: true });
+ expect(matcher.test("error")).toBe(true);
+ matcher.lastIndex = 0;
+ expect(matcher.test("err")).toBe(true);
+ });
+
+ it("wholeWord wraps in word boundaries", () => {
+ const matcher = compileMatcher("error", { wholeWord: true });
+ expect(matcher.test("an error happened")).toBe(true);
+ matcher.lastIndex = 0;
+ expect(matcher.test("terror")).toBe(false);
+ });
+
+ it("invalid regex throws wrapped error", () => {
+ expect(() => compileMatcher("(", { regex: true })).toThrow(
+ /^wterm: invalid search pattern:/,
+ );
+ });
+});
diff --git a/packages/@wterm/search/src/__tests__/helpers.ts b/packages/@wterm/search/src/__tests__/helpers.ts
new file mode 100644
index 0000000..eb790ae
--- /dev/null
+++ b/packages/@wterm/search/src/__tests__/helpers.ts
@@ -0,0 +1,17 @@
+import type { WTerm } from "@wterm/dom";
+import { WasmBridge } from "@wterm/core";
+
+export async function makeTerm(
+ cols: number,
+ rows: number,
+): Promise> {
+ const bridge = await WasmBridge.load();
+ bridge.init(cols, rows);
+ return {
+ bridge,
+ write(data: string | Uint8Array) {
+ if (typeof data === "string") bridge.writeString(data);
+ else bridge.writeRaw(data);
+ },
+ };
+}
diff --git a/packages/@wterm/search/src/__tests__/iterator.test.ts b/packages/@wterm/search/src/__tests__/iterator.test.ts
new file mode 100644
index 0000000..d3da349
--- /dev/null
+++ b/packages/@wterm/search/src/__tests__/iterator.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { iterRows } from "../engine.js";
+import { makeTerm } from "./helpers.js";
+
+describe("iterRows", () => {
+ it("yields oldest scrollback rows first, then grid rows", async () => {
+ const term = await makeTerm(80, 3);
+
+ for (let i = 0; i < 8; i++) {
+ term.write(`iter-${i}\r\n`);
+ }
+
+ const rows = [...iterRows(term.bridge!)];
+ const negatives = rows.filter((r) => r.row < 0);
+ const grid = rows.filter((r) => r.row >= 0);
+
+ if (negatives.length > 1) {
+ for (let i = 1; i < negatives.length; i++) {
+ expect(negatives[i]!.row).toBe(negatives[i - 1]!.row + 1);
+ }
+ }
+
+ expect(grid.map((r) => r.row)).toEqual([0, 1, 2]);
+
+ if (negatives.length > 0 && grid.length > 0) {
+ expect(rows.indexOf(negatives[0]!)).toBeLessThan(rows.indexOf(grid[0]!));
+ }
+ });
+});
diff --git a/packages/@wterm/search/src/__tests__/scrollback.test.ts b/packages/@wterm/search/src/__tests__/scrollback.test.ts
new file mode 100644
index 0000000..fb78889
--- /dev/null
+++ b/packages/@wterm/search/src/__tests__/scrollback.test.ts
@@ -0,0 +1,21 @@
+import type { WTerm } from "@wterm/dom";
+import { describe, expect, it } from "vitest";
+import { Search } from "../index.js";
+import { makeTerm } from "./helpers.js";
+
+describe("Search scrollback", () => {
+ it("finds matches in negative-row scrollback entries", async () => {
+ const term = await makeTerm(80, 10);
+
+ for (let i = 0; i < 30; i++) {
+ term.write(`line-${i}\r\n`);
+ }
+
+ const search = new Search(term as WTerm);
+ const match = search.findNext("line-0");
+
+ expect(match).not.toBeNull();
+ expect(match!.row).toBeLessThan(0);
+ expect(match!.text).toBe("line-0");
+ });
+});
diff --git a/packages/@wterm/search/src/__tests__/search.test.ts b/packages/@wterm/search/src/__tests__/search.test.ts
new file mode 100644
index 0000000..ee9c13d
--- /dev/null
+++ b/packages/@wterm/search/src/__tests__/search.test.ts
@@ -0,0 +1,105 @@
+import type { WTerm } from "@wterm/dom";
+import { describe, expect, it } from "vitest";
+import { Search } from "../index.js";
+import { makeTerm } from "./helpers.js";
+
+describe("Search", () => {
+ it("throws when terminal is not initialized", () => {
+ expect(() => new Search({ bridge: null } as unknown as WTerm)).toThrow(
+ "wterm: search requires an initialized terminal",
+ );
+ });
+
+ it("findNext returns first match", async () => {
+ const term = await makeTerm(40, 4);
+ term.write("error one\r\nerror two\r\n");
+
+ const search = new Search(term as WTerm);
+ const match = search.findNext("error");
+
+ expect(match).toEqual({ row: 0, col: 0, length: 5, text: "error" });
+ });
+
+ it("successive findNext returns second match", async () => {
+ const term = await makeTerm(40, 4);
+ term.write("error one error two\r\n");
+
+ const search = new Search(term as WTerm);
+ const first = search.findNext("error");
+ const second = search.findNext("error");
+
+ expect(first?.col).toBe(0);
+ expect(second?.col).toBeGreaterThan(0);
+ });
+
+ it("findNext returns null when no more matches and cursor stays put", async () => {
+ const term = await makeTerm(40, 4);
+ term.write("error\r\n");
+
+ const search = new Search(term as WTerm);
+ const first = search.findNext("error");
+ const none = search.findNext("error");
+ const prev = search.findPrevious("error");
+
+ expect(first).not.toBeNull();
+ expect(none).toBeNull();
+ expect(prev).not.toBeNull();
+ expect(prev?.col).toBe(first?.col);
+ });
+
+ it("findPrevious walks backward", async () => {
+ const term = await makeTerm(40, 4);
+ term.write("error a error b error c\r\n");
+
+ const search = new Search(term as WTerm);
+ const m1 = search.findNext("error");
+ const m2 = search.findNext("error");
+ const m3 = search.findNext("error");
+
+ const back1 = search.findPrevious("error");
+ const back2 = search.findPrevious("error");
+
+ expect(m1?.col).toBe(0);
+ expect(m2?.col).toBeGreaterThan(m1!.col);
+ expect(m3?.col).toBeGreaterThan(m2!.col);
+ expect(back1?.col).toBe(m3?.col);
+ expect(back2?.col).toBe(m2?.col);
+ });
+
+ it("reset returns cursor to top", async () => {
+ const term = await makeTerm(40, 4);
+ term.write("error one error two\r\n");
+
+ const search = new Search(term as WTerm);
+ const first = search.findNext("error");
+ search.findNext("error");
+ search.reset();
+ const afterReset = search.findNext("error");
+
+ expect(afterReset?.col).toBe(first?.col);
+ });
+
+ it("findAll returns all matches in oldest-first order", async () => {
+ const term = await makeTerm(40, 3);
+ term.write("match-1\r\nmatch-2\r\nmatch-3\r\nmatch-4\r\n");
+
+ const search = new Search(term as WTerm);
+ const matches = search.findAll("match");
+
+ expect(matches.length).toBeGreaterThan(0);
+ const rows = matches.map((m) => m.row);
+ const sorted = [...rows].sort((a, b) => a - b);
+ expect(rows).toEqual(sorted);
+ });
+
+ it("empty query returns null and empty list", async () => {
+ const term = await makeTerm(40, 4);
+ term.write("anything\r\n");
+
+ const search = new Search(term as WTerm);
+
+ expect(search.findNext("")).toBeNull();
+ expect(search.findPrevious("")).toBeNull();
+ expect(search.findAll("")).toEqual([]);
+ });
+});
diff --git a/packages/@wterm/search/src/engine.ts b/packages/@wterm/search/src/engine.ts
new file mode 100644
index 0000000..c6571b4
--- /dev/null
+++ b/packages/@wterm/search/src/engine.ts
@@ -0,0 +1,156 @@
+import type { SearchMatch, SearchOptions } from "./index.js";
+
+export interface BridgeLike {
+ getScrollbackCount(): number;
+ getScrollbackLineLen(offset: number): number;
+ getScrollbackCell(
+ offset: number,
+ col: number,
+ ): {
+ char: number;
+ fg: number;
+ bg: number;
+ flags: number;
+ };
+ getRows(): number;
+ getCols(): number;
+ getCell(
+ row: number,
+ col: number,
+ ): {
+ char: number;
+ fg: number;
+ bg: number;
+ flags: number;
+ };
+}
+
+export interface RowText {
+ row: number;
+ text: string;
+ trimmedLen: number;
+}
+
+const DEFAULT_COLOR = 256;
+const DEFAULT_SPACE = 0x20;
+const MAX_SCAN_MATCHES = 50000;
+
+function isDefaultSpaceCell(cell: {
+ char: number;
+ fg: number;
+ bg: number;
+ flags: number;
+}): boolean {
+ return (
+ cell.char === DEFAULT_SPACE &&
+ cell.flags === 0 &&
+ cell.fg === DEFAULT_COLOR &&
+ cell.bg === DEFAULT_COLOR
+ );
+}
+
+function readLine(
+ len: number,
+ getCell: (col: number) => {
+ char: number;
+ fg: number;
+ bg: number;
+ flags: number;
+ },
+): { text: string; trimmedLen: number } {
+ const chars: string[] = new Array(len);
+ const cells = new Array(len);
+
+ for (let col = 0; col < len; col++) {
+ const cell = getCell(col);
+ cells[col] = cell;
+ chars[col] = String.fromCodePoint(cell.char || DEFAULT_SPACE);
+ }
+
+ let trimmedLen = len;
+ while (trimmedLen > 0 && isDefaultSpaceCell(cells[trimmedLen - 1]!)) {
+ trimmedLen--;
+ }
+
+ return {
+ text: chars.slice(0, trimmedLen).join(""),
+ trimmedLen,
+ };
+}
+
+export function* iterRows(bridge: BridgeLike): IterableIterator {
+ const sbCount = bridge.getScrollbackCount();
+
+ for (let i = sbCount - 1; i >= 0; i--) {
+ const row = -(i + 1);
+ const len = bridge.getScrollbackLineLen(i);
+ const line = readLine(len, (col) => bridge.getScrollbackCell(i, col));
+ yield { row, text: line.text, trimmedLen: line.trimmedLen };
+ }
+
+ const rows = bridge.getRows();
+ const cols = bridge.getCols();
+
+ for (let row = 0; row < rows; row++) {
+ const line = readLine(cols, (col) => bridge.getCell(row, col));
+ yield { row, text: line.text, trimmedLen: line.trimmedLen };
+ }
+}
+
+function escapeRegexLiteral(query: string): string {
+ return query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+export function compileMatcher(
+ query: string,
+ opts: SearchOptions = {},
+): RegExp {
+ const sourceBase = opts.regex ? query : escapeRegexLiteral(query);
+ const source = opts.wholeWord ? `\\b(?:${sourceBase})\\b` : sourceBase;
+ const flags = opts.caseSensitive ? "g" : "gi";
+
+ try {
+ return new RegExp(source, flags);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ throw new Error(`wterm: invalid search pattern: ${message}`);
+ }
+}
+
+export function findAllMatches(
+ bridge: BridgeLike,
+ matcher: RegExp,
+ limit = MAX_SCAN_MATCHES,
+): SearchMatch[] {
+ const matches: SearchMatch[] = [];
+
+ for (const row of iterRows(bridge)) {
+ matcher.lastIndex = 0;
+
+ while (true) {
+ const match = matcher.exec(row.text);
+ if (!match) break;
+
+ matches.push({
+ row: row.row,
+ col: match.index,
+ length: match[0].length,
+ text: match[0],
+ });
+
+ if (matches.length >= limit) {
+ return matches;
+ }
+
+ if (matcher.lastIndex === match.index) {
+ matcher.lastIndex++;
+ }
+ }
+ }
+
+ return matches;
+}
+
+export function getMaxScanMatches(): number {
+ return MAX_SCAN_MATCHES;
+}
diff --git a/packages/@wterm/search/src/index.ts b/packages/@wterm/search/src/index.ts
new file mode 100644
index 0000000..d649803
--- /dev/null
+++ b/packages/@wterm/search/src/index.ts
@@ -0,0 +1,177 @@
+import type { WTerm } from "@wterm/dom";
+import {
+ compileMatcher,
+ findAllMatches,
+ getMaxScanMatches,
+ iterRows,
+} from "./engine.js";
+
+export interface SearchOptions {
+ caseSensitive?: boolean;
+ regex?: boolean;
+ wholeWord?: boolean;
+}
+
+export interface SearchMatch {
+ // row: negative = scrollback (-1 is most recent scrollback line), 0+ = grid row
+ row: number;
+ col: number;
+ length: number;
+ text: string;
+}
+
+interface Cursor {
+ row: number;
+ col: number;
+}
+
+export class Search {
+ private _term: WTerm;
+ private _cursor: Cursor | null = null;
+
+ constructor(term: WTerm) {
+ if (!term.bridge) {
+ throw new Error("wterm: search requires an initialized terminal");
+ }
+ this._term = term;
+ }
+
+ findNext(query: string, opts: SearchOptions = {}): SearchMatch | null {
+ if (!query) {
+ return null;
+ }
+
+ const bridge = this._term.bridge;
+ if (!bridge) {
+ throw new Error("wterm: search requires an initialized terminal");
+ }
+
+ const matcher = compileMatcher(query, opts);
+ const maxScan = getMaxScanMatches();
+ let scanned = 0;
+
+ for (const row of iterRows(bridge)) {
+ if (this._cursor && row.row < this._cursor.row) {
+ continue;
+ }
+
+ matcher.lastIndex = 0;
+ while (true) {
+ const match = matcher.exec(row.text);
+ if (!match) {
+ break;
+ }
+
+ scanned++;
+ if (scanned > maxScan) {
+ return null;
+ }
+
+ if (
+ this._cursor &&
+ row.row === this._cursor.row &&
+ match.index <= this._cursor.col
+ ) {
+ if (matcher.lastIndex === match.index) {
+ matcher.lastIndex++;
+ }
+ continue;
+ }
+
+ const found: SearchMatch = {
+ row: row.row,
+ col: match.index,
+ length: match[0].length,
+ text: match[0],
+ };
+
+ this._cursor = {
+ row: found.row,
+ col: found.col + found.length,
+ };
+
+ return found;
+ }
+ }
+
+ return null;
+ }
+
+ findPrevious(query: string, opts: SearchOptions = {}): SearchMatch | null {
+ if (!query) {
+ return null;
+ }
+
+ const bridge = this._term.bridge;
+ if (!bridge) {
+ throw new Error("wterm: search requires an initialized terminal");
+ }
+
+ const matcher = compileMatcher(query, opts);
+ const maxScan = getMaxScanMatches();
+ let scanned = 0;
+
+ let candidate: SearchMatch | null = null;
+
+ for (const row of iterRows(bridge)) {
+ matcher.lastIndex = 0;
+
+ while (true) {
+ const match = matcher.exec(row.text);
+ if (!match) {
+ break;
+ }
+
+ scanned++;
+ if (scanned > maxScan) {
+ return null;
+ }
+
+ const found: SearchMatch = {
+ row: row.row,
+ col: match.index,
+ length: match[0].length,
+ text: match[0],
+ };
+
+ const isBeforeCursor =
+ this._cursor == null ||
+ found.row < this._cursor.row ||
+ (found.row === this._cursor.row && found.col < this._cursor.col);
+
+ if (isBeforeCursor) {
+ candidate = found;
+ }
+
+ if (matcher.lastIndex === match.index) {
+ matcher.lastIndex++;
+ }
+ }
+ }
+
+ if (!candidate) {
+ return null;
+ }
+
+ this._cursor = { row: candidate.row, col: candidate.col };
+ return candidate;
+ }
+
+ findAll(query: string, opts: SearchOptions = {}): SearchMatch[] {
+ if (!query) {
+ return [];
+ }
+
+ const bridge = this._term.bridge;
+ if (!bridge) {
+ throw new Error("wterm: search requires an initialized terminal");
+ }
+
+ const matcher = compileMatcher(query, opts);
+ return findAllMatches(bridge, matcher);
+ }
+
+ reset(): void {
+ this._cursor = null;
+ }
+}
diff --git a/packages/@wterm/search/tsconfig.json b/packages/@wterm/search/tsconfig.json
new file mode 100644
index 0000000..67ad8c2
--- /dev/null
+++ b/packages/@wterm/search/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@internal/ts/tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src"]
+}
diff --git a/packages/@wterm/search/vitest.config.ts b/packages/@wterm/search/vitest.config.ts
new file mode 100644
index 0000000..4762117
--- /dev/null
+++ b/packages/@wterm/search/vitest.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["src/**/*.test.ts"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "lcov"],
+ include: ["src/**/*.ts"],
+ exclude: ["src/**/*.test.ts", "src/**/__tests__/**"],
+ },
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0522f7c..c27166d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -503,6 +503,22 @@ importers:
specifier: ^6.0.2
version: 6.0.2
+ packages/@wterm/search:
+ dependencies:
+ '@wterm/core':
+ specifier: workspace:*
+ version: link:../core
+ '@wterm/dom':
+ specifier: workspace:*
+ version: link:../dom
+ devDependencies:
+ '@internal/ts':
+ specifier: workspace:*
+ version: link:../../@internal/ts
+ typescript:
+ specifier: ^6.0.2
+ version: 6.0.2
+
packages:
'@adobe/css-tools@4.4.4':
@@ -10402,7 +10418,7 @@ snapshots:
eslint: 9.39.4(jiti@2.6.1)
eslint-import-resolver-node: 0.3.10
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1))
@@ -10455,7 +10471,7 @@ snapshots:
tinyglobby: 0.2.16
unrs-resolver: 1.11.1
optionalDependencies:
- eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
+ eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
transitivePeerDependencies:
- supports-color
@@ -10495,7 +10511,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
+ eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@2.6.1))(typescript@6.0.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9