diff --git a/WRITING.md b/WRITING.md
index f7940234..7c644d35 100644
--- a/WRITING.md
+++ b/WRITING.md
@@ -119,6 +119,8 @@ checks their recorded hashes, executes the displayed setup, and saves logs
and a result. Dependency downloads and native builds are separate from the
routine site tests. A changed hash needs a new native run before review.
Use `--example attach` to check the existing-server programs in the attach guide.
+Use `--example query --port ts` to run every complete program on the TypeScript
+filtering page, including its displayed setup and error assertions.
Put explanatory comments on separate lines above the code they describe.
Limit example comments to 100 columns, including indentation; prefer shorter
diff --git a/scripts/check-example-prose.py b/scripts/check-example-prose.py
index c63c8127..9fc7c8e0 100644
--- a/scripts/check-example-prose.py
+++ b/scripts/check-example-prose.py
@@ -19,7 +19,7 @@
def main():
repo = Path(__file__).resolve().parent.parent
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--example', choices=['capture', 'attach'], default='capture')
+ parser.add_argument('--example', choices=['capture', 'attach', 'query'], default='capture')
parser.add_argument('--port', required=True)
parser.add_argument('--output-dir', required=True, type=Path)
args = parser.parse_args()
diff --git a/site/scripts/check-navigation.mjs b/site/scripts/check-navigation.mjs
index 59520bef..27f933fb 100644
--- a/site/scripts/check-navigation.mjs
+++ b/site/scripts/check-navigation.mjs
@@ -103,6 +103,25 @@ export async function checkNavigation(page, base) {
assert.equal(await page.locator('html').getAttribute('data-mcp-install-cooldown-enabled'), '1')
console.log('Navigation: document retained, Back, search, code tabs, menu and saved theme/cooldown pass')
+ for (const [port, version] of [['ts', 'latest'], ['py', 'stable']]) {
+ const prefix = new URL(`${base}/${port}/${version}/`).pathname
+ for (const width of [1440, 390]) {
+ await page.setViewportSize({ width, height: 900 })
+ const response = await page.goto(`${base}/${port}/${version}/examples/capture-pane-output/`, { waitUntil: 'load' })
+ assert(response?.ok(), `${port}: owned example exists in the root build`)
+ const sidebars = page.locator('nav.sidebar-nav')
+ assert.equal(await sidebars.count(), 2, 'Desktop and mobile both have navigation')
+ for (const sidebar of await sidebars.all()) {
+ const query = sidebar.getByRole('link', { name: 'Filtering and queries', exact: true, includeHidden: true })
+ assert.equal(await query.getAttribute('href'), `${prefix}concepts/queries/`,
+ `${port} ${version} at ${width}px: shared query page stays in the selected port`)
+ const links = await sidebar.locator('a[href^="/"]').evaluateAll((items) => items.map((a) => a.getAttribute('href')))
+ for (const href of links) assert(href.startsWith(prefix), `${port} sidebar leaves ${prefix}: ${href}`)
+ }
+ }
+ }
+ console.log('Sidebars: root-mounted port pages retain the selected port and version on desktop and mobile')
+
const legacy = `${base}/go/latest/examples/workspace-from-file/`
const current = `${base}/go/latest/workspace/internals/examples/`
await page.goto(`${legacy}?from=legacy#where-this-comes-from`, { waitUntil: 'load' })
diff --git a/site/src/content/docs/concepts/queries.md b/site/src/content/docs/concepts/queries.md
index 01b33818..d28bee6b 100644
--- a/site/src/content/docs/concepts/queries.md
+++ b/site/src/content/docs/concepts/queries.md
@@ -1,5 +1,5 @@
---
-supportedPorts: [py, ts, rs, go, java, dotnet, cxx, swift]
+supportedPorts: [py, rs, go, java, dotnet, cxx, swift]
title: Filtering and queries
description: How you get from every session on the server to the one pane you mean, and what happens when zero or several match.
sidebar:
@@ -66,25 +66,11 @@ check that the session data is available.
-## Criteria as data
+## TypeScript criteria
-TypeScript's `Selection.where()` accepts structured, serializable criteria that
-can be stored in a configuration file or sent through MCP:
-
-```ts
-snapshot.sessions.where({
- AND: [
- { name: { startsWith: "prod" } },
- { windows: { some: { name: { regex: { pattern: "^log", flags: "" } } } } },
- ],
-});
-```
-
-`some`, `every`, and `none` test related objects. `{ mode: "insensitive" }`
-enables case-insensitive comparison. Use `.where()` for criteria that can be
-encoded with `encodeWhereDocument` and decoded with `decodeWhereDocument`; use
-`.filter()` for a predicate function. `.one()` throws `NoMatchError` or
-`MultipleMatchesError`. `.oneOrUndefined()` permits an absent result.
+The [TypeScript filtering guide](/ts/latest/concepts/queries/) includes complete
+programs for matching names, handling result counts, traversing linked windows,
+refreshing snapshots, validating query documents, and filtering live tmux rows.
diff --git a/site/src/content/docs/ports/ts/concepts/queries.md b/site/src/content/docs/ports/ts/concepts/queries.md
new file mode 100644
index 00000000..0874aa9b
--- /dev/null
+++ b/site/src/content/docs/ports/ts/concepts/queries.md
@@ -0,0 +1,580 @@
+---
+port: ts
+route: concepts/queries
+title: Filtering and queries
+description: Match names, handle missing and ambiguous results, traverse linked windows, refresh snapshots, and validate saved TypeScript queries.
+sidebar:
+ label: Filtering and queries
+ group: Concepts
+ order: 4
+tableOfContents: true
+---
+
+Read a snapshot once, then query its sessions, windows, panes, and clients in
+TypeScript. [`Selection.where()`](../../reference/selection-selection-where/)
+filters that captured state without another tmux command. Use
+[`one()`](../../reference/selection-selection-one/) when the next operation
+requires exactly one target.
+
+## Setup and run
+
+These are independent programs with imports, assertions, and cleanup. Each
+creates a private tmux server, runs `cat` in its panes to keep them alive, and
+stops its server before exiting. They require Bun 1.4.2 or newer, tmux 3.2a
+or newer, and a Unix environment. You do not need an existing tmux session.
+
+In an empty directory, save this file as `package.json`:
+
+```json title="package.json"
+{"type":"module","dependencies":{"libtmux":"file:./libtmux/packages/libtmux"}}
+```
+
+Install the library revision used to run the examples:
+
+```console
+$ git clone https://github.com/libtmux/libtmux-ts libtmux &&
+ git -C libtmux checkout 3fe1ca654b81b8cbf4a13b777a001a3298c87a6f &&
+ bun install
+```
+
+Save any program below in that directory and run its command. Each program
+includes its own setup; none depends on another example's variables or server.
+A failed assertion or cleanup exits with an error. If cleanup fails, the error
+names the retained directory so the private server remains reachable.
+
+
+
+## Match names and combine conditions
+
+A bare value means equality. Matching is case sensitive unless that field's
+criterion has `mode: "insensitive"`. Fields in one object and successive
+`.where()` calls combine with AND; `AND`, `OR`, and `NOT` compose whole criteria.
+`.filter()` accepts a JavaScript predicate when a condition needs application
+code, such as membership in an existing `Set`.
+
+| Task | Criterion for `name` |
+| --- | --- |
+| Equal | `"app-api"` or `{ equals: "app-api" }` |
+| Contain text | `{ contains: "api" }` |
+| Start or end with text | `{ startsWith: "app-" }`, `{ endsWith: "worker" }` |
+| Ignore case | `{ contains: "MYAPP", mode: "insensitive" }` |
+| Be in a list | `{ in: ["app-api", "app-worker"] }` |
+| Be outside a list | `{ notIn: ["shell"] }` |
+| Match a pattern | `{ regex: { pattern: "^app-v[0-9]+-0$", flags: "" } }` |
+
+Save as `matching.ts`. It selects exact names, composes conditions, and compares
+structured criteria with a local predicate.
+
+```typescript title="matching.ts"
+import assert from "node:assert/strict";
+import { mkdtemp, readdir, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Server } from "libtmux";
+
+const directory = await mkdtemp(join(tmpdir(), "libtmux-query-"));
+const server = new Server({
+ socketPath: join(directory, "tmux.sock"), configFile: "/dev/null", timeoutMs: 5_000,
+});
+const failures: unknown[] = [];
+try {
+ const session = await server.newSession({
+ name: "work", windowName: "shell", shellCommand: "cat",
+ });
+ for (const name of ["app-api", "app-worker", "MyApp-logs", "app-v1-0", "app-beta"]) {
+ await session.newWindow({ name, shellCommand: "cat" });
+ }
+ const { windows } = await server.snapshot();
+ const exact = windows.where({ name: "app-api" });
+ assert.equal(exact.one().name, "app-api");
+ console.log("exact:", exact.one().name);
+
+ const worker = windows.where({ name: { startsWith: "app-" } })
+ .where({ name: { endsWith: "worker" } });
+ assert.equal(worker.one().name, "app-worker");
+ console.log("prefix AND suffix:", worker.one().name);
+
+ const apiOrLogs = windows.where({
+ OR: [{ name: "app-api" }, { name: { contains: "logs" } }],
+ NOT: [{ name: "shell" }],
+ }).map((window) => window.name).sort();
+ assert.deepEqual(apiOrLogs, ["MyApp-logs", "app-api"]);
+ console.log("OR and NOT:", apiOrLogs.join(", "));
+
+ const insensitive = windows.where({
+ name: { contains: "MYAPP", mode: "insensitive" },
+ });
+ assert.equal(insensitive.one().name, "MyApp-logs");
+ console.log("case insensitive:", insensitive.one().name);
+
+ const versioned = windows.where({
+ name: { regex: { pattern: "^app-v[0-9]+-0$", flags: "" } },
+ });
+ assert.equal(versioned.one().name, "app-v1-0");
+ console.log("regex:", versioned.one().name);
+
+ const selected = windows.where({ name: { in: ["app-api", "app-worker"] } });
+ assert.equal(selected.count(), 2);
+ assert.equal(selected.where({ name: { notIn: ["app-worker"] } }).one().name, "app-api");
+ console.log("membership:", selected.map((window) => window.name).sort().join(", "));
+
+ const namesFromConfig = new Set(["shell", "app-beta"]);
+ const local = windows.filter((window) => namesFromConfig.has(window.name));
+ assert.equal(local.count(), 2);
+ console.log("predicate:", local.map((window) => window.name).sort().join(", "));
+} catch (error) {
+ failures.push(error);
+} finally {
+ try {
+ if ((await readdir(directory)).includes("tmux.sock")) await server.kill();
+ await rm(directory, { recursive: true });
+ } catch (error) {
+ failures.push(new Error(`Cleanup failed; inspect ${directory}`, { cause: error }));
+ }
+}
+if (failures.length > 0) throw new AggregateError(failures, "Query example failed");
+```
+
+```console
+$ bun run matching.ts
+```
+
+The output identifies `app-api`, `app-worker`, and `MyApp-logs`; the version
+pattern selects `app-v1-0`. Membership selects two application windows, while
+the predicate selects `app-beta` and `shell`.
+
+### Regular expression limits
+
+Structured regex criteria use a restricted grammar. This revision accepts at
+most 512 UTF-16 code units and one repetition operator. A repeated pattern must
+start with `^`, cannot repeat a group, and cannot combine repetition with
+alternation or multiline mode. It rejects lookarounds, backreferences, and
+escapes such as `\d`; use `[0-9]` for digits. Accepted flags are `""`, `"m"`, `"s"`,
+and `"ms"`; use the field's `mode: "insensitive"` for case folding.
+
+A syntactically valid JavaScript pattern can therefore raise
+[`QueryValidationError`](../../reference/errors-queryvalidationerror/) here.
+The saved-query example below checks that rejection. For a trusted pattern
+that needs the full JavaScript grammar, use a predicate with a `RegExp`; that
+predicate is local code and cannot be encoded as a query document.
+
+
+
+## Handle zero, one, and several matches
+
+Choose the result contract before using a target:
+
+| Operation | Zero matches | One match | Several matches |
+| --- | --- | --- | --- |
+| `.where()` | Empty selection | Selection | Selection |
+| `.one()` | `NoMatchError` | Object | `MultipleMatchesError` |
+| `.oneOrUndefined()` | `undefined` | Object | `MultipleMatchesError` |
+| `.first()` | `undefined` | Object | First object in tmux order |
+| `.exists()` | `false` | `true` | `true` |
+| `.count()` | `0` | `1` | Match count |
+
+Use `.first()` when any first result is acceptable. It does not establish that
+there is only one target. `.oneOrUndefined()` relaxes only the missing case;
+it still reports ambiguity.
+
+Save as `single-result.ts`. This handles missing and ambiguous results
+separately and rethrows unexpected failures.
+
+```typescript title="single-result.ts"
+import assert from "node:assert/strict";
+import { mkdtemp, readdir, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Server, MultipleMatchesError, NoMatchError } from "libtmux";
+
+const directory = await mkdtemp(join(tmpdir(), "libtmux-query-"));
+const server = new Server({
+ socketPath: join(directory, "tmux.sock"), configFile: "/dev/null", timeoutMs: 5_000,
+});
+const failures: unknown[] = [];
+try {
+ const session = await server.newSession({
+ name: "work", windowName: "editor", shellCommand: "cat",
+ });
+ await session.newWindow({ name: "logs", shellCommand: "cat" });
+ const { windows } = await server.snapshot();
+ assert.equal(windows.one({ name: "logs" }).name, "logs");
+ assert.equal(windows.oneOrUndefined({ name: "missing" }), undefined);
+ assert.equal(windows.exists({ name: "missing" }), false);
+ console.log("optional missing:", windows.oneOrUndefined({ name: "missing" }));
+
+ try {
+ windows.one({ name: "missing" });
+ throw new Error("Expected the missing lookup to fail");
+ } catch (error) {
+ if (!(error instanceof NoMatchError)) throw error;
+ console.log("missing:", error.code);
+ }
+
+ for (const lookup of [() => windows.one(), () => windows.oneOrUndefined()]) {
+ try {
+ lookup();
+ throw new Error("Expected the ambiguous lookup to fail");
+ } catch (error) {
+ if (!(error instanceof MultipleMatchesError)) throw error;
+ assert.equal(error.count, 2);
+ console.log("ambiguous:", error.code, error.count);
+ }
+ }
+ assert.equal(windows.first()?.name, "editor");
+ console.log("first:", windows.first()?.name);
+} catch (error) {
+ failures.push(error);
+} finally {
+ try {
+ if ((await readdir(directory)).includes("tmux.sock")) await server.kill();
+ await rm(directory, { recursive: true });
+ } catch (error) {
+ failures.push(new Error(`Cleanup failed; inspect ${directory}`, { cause: error }));
+ }
+}
+if (failures.length > 0) throw new AggregateError(failures, "Query example failed");
+```
+
+```console
+$ bun run single-result.ts
+```
+
+The program prints `undefined` for an optional missing window, `NoMatchError`
+for a required missing window, and `MultipleMatchesError 2` for both ambiguous
+lookups. `first()` returns `editor` because it occupies the first window index.
+
+## Query relations and linked windows
+
+A server-wide window selection contains placements: a window linked into two
+sessions appears twice with the same window ID. A pane reached through those
+placements also has session context. Add a `session` criterion when the action
+needs a particular placement. Use
+[`linkedSessions`](../../reference/window-window-linkedsessions/) to inspect
+the sessions holding the window.
+
+Collection relations accept `some`, `every`, and `none`. `every` and `none` are
+true for an empty collection; combine `some: {}` with `every` when you require
+at least one related object. Single-object relations use `is` and `isNot`;
+`is: null` matches an absent relation.
+
+Save as `relations.ts`. It links one logs window into a second session, selects
+its home placement, and queries sessions and panes through their relations.
+
+```typescript title="relations.ts"
+import assert from "node:assert/strict";
+import { mkdtemp, readdir, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Server, MultipleMatchesError } from "libtmux";
+
+const directory = await mkdtemp(join(tmpdir(), "libtmux-query-"));
+const server = new Server({
+ socketPath: join(directory, "tmux.sock"), configFile: "/dev/null", timeoutMs: 5_000,
+});
+const failures: unknown[] = [];
+try {
+ const home = await server.newSession({
+ name: "home", windowName: "logs", shellCommand: "cat",
+ });
+ const guest = await server.newSession({
+ name: "guest", windowName: "shell", shellCommand: "cat",
+ });
+ const shared = home.windows.one({ name: "logs" });
+ await shared.link({ session: guest });
+ const snapshot = await server.snapshot();
+ const placements = snapshot.windows.where({ id: shared.id });
+ assert.equal(placements.count(), 2);
+ console.log("placements:", placements.count());
+
+ try {
+ placements.one();
+ throw new Error("Expected two placements to be ambiguous");
+ } catch (error) {
+ if (!(error instanceof MultipleMatchesError)) throw error;
+ console.log("same ID:", error.code);
+ }
+ const atHome = placements.one({ session: { is: { name: "home" } } });
+ assert.equal(atHome.session?.name, "home");
+ const owners = atHome.linkedSessions.map((session) => session.name).sort();
+ assert.deepEqual(owners, ["guest", "home"]);
+ console.log("linked sessions:", owners.join(", "));
+
+ const hasLogs = snapshot.sessions.where({ windows: { some: { name: "logs" } } });
+ assert.equal(hasLogs.count(), 2);
+ const onlyLogs = snapshot.sessions.where({
+ windows: { some: {}, every: { name: "logs" } },
+ });
+ assert.equal(onlyLogs.one().name, "home");
+ const noShell = snapshot.sessions.where({ windows: { none: { name: "shell" } } });
+ assert.equal(noShell.one().name, "home");
+ console.log("some logs:", hasLogs.map((session) => session.name).sort().join(", "));
+ console.log("every window is logs:", onlyLogs.one().name);
+ console.log("no shell:", noShell.one().name);
+
+ const guestPanes = snapshot.panes.where({ session: { is: { name: "guest" } } });
+ assert.equal(guestPanes.count(), 2);
+ console.log("panes reached through guest:", guestPanes.count());
+} catch (error) {
+ failures.push(error);
+} finally {
+ try {
+ if ((await readdir(directory)).includes("tmux.sock")) await server.kill();
+ await rm(directory, { recursive: true });
+ } catch (error) {
+ failures.push(new Error(`Cleanup failed; inspect ${directory}`, { cause: error }));
+ }
+}
+if (failures.length > 0) throw new AggregateError(failures, "Query example failed");
+```
+
+```console
+$ bun run relations.ts
+```
+
+The logs window has two placements and two linked sessions. Both sessions
+have some logs window. Only `home` has every window named `logs` and no window
+named `shell`. The guest session contains two panes through its two windows.
+
+These relations come from the same snapshot. Traversing them does not refresh
+the server. A scalar window mutation such as `rename()` affects that window
+in every session that links it; selecting a placement does not create a copy.
+
+## Refresh after a mutation
+
+[`Server.snapshot()`](../../reference/server-server-snapshot/) acquires the
+state. Filtering and reading relations use it locally. A handle can issue a
+mutation, but its captured fields and earlier selections keep their old values.
+Acquire another snapshot to observe the result.
+
+Save as `refresh.ts`. It creates a window after a snapshot, then renames it and
+compares old and fresh reads.
+
+```typescript title="refresh.ts"
+import assert from "node:assert/strict";
+import { mkdtemp, readdir, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Server } from "libtmux";
+
+const directory = await mkdtemp(join(tmpdir(), "libtmux-query-"));
+const server = new Server({
+ socketPath: join(directory, "tmux.sock"), configFile: "/dev/null", timeoutMs: 5_000,
+});
+const failures: unknown[] = [];
+try {
+ const session = await server.newSession({
+ name: "work", windowName: "editor", shellCommand: "cat",
+ });
+ const before = await server.snapshot();
+ await session.newWindow({ name: "logs", shellCommand: "cat" });
+ assert.equal(before.windows.exists({ name: "logs" }), false);
+ console.log("old snapshot sees logs:", before.windows.exists({ name: "logs" }));
+
+ const after = await server.snapshot();
+ assert.equal(after.windows.exists({ name: "logs" }), true);
+ console.log("fresh snapshot sees logs:", after.windows.exists({ name: "logs" }));
+ const logs = after.windows.one({ name: "logs" });
+ await logs.rename("archive");
+ assert.equal(logs.name, "logs");
+ assert.equal(after.windows.one({ id: logs.id }).name, "logs");
+ console.log("captured name after rename:", logs.name);
+ const renamed = await server.snapshot();
+ assert.equal(renamed.windows.one({ id: logs.id }).name, "archive");
+ console.log("refreshed name:", renamed.windows.one({ id: logs.id }).name);
+} catch (error) {
+ failures.push(error);
+} finally {
+ try {
+ if ((await readdir(directory)).includes("tmux.sock")) await server.kill();
+ await rm(directory, { recursive: true });
+ } catch (error) {
+ failures.push(new Error(`Cleanup failed; inspect ${directory}`, { cause: error }));
+ }
+}
+if (failures.length > 0) throw new AggregateError(failures, "Query example failed");
+```
+
+```console
+$ bun run refresh.ts
+```
+
+The old snapshot reports no logs window. The fresh one finds it. After the
+rename, the existing handle still reports `logs`; another snapshot reports
+`archive` for the same ID.
+
+Prefer one snapshot when several queries should describe the same captured
+state. Calling `server.sessions()`, `server.windows()`, and `server.panes()`
+separately takes a separate snapshot for each call. A snapshot also cannot
+prevent another process from removing a target before a later mutation; handle
+that command's error at the mutation boundary.
+
+## Save and validate criteria
+
+[`encodeWhereDocument()`](../../reference/selection-encodewheredocument/)
+serializes a versioned query. Parse its JSON and pass the value to
+[`decodeWhereDocument()`](../../reference/selection-decodewheredocument/)
+before applying it. Check the document's model so session criteria go to the
+session selection. Use the encoder and decoder to preserve the wire format;
+do not cast unvalidated JSON to a criteria type.
+
+Save as `query-document.ts`. It round-trips a session query, rejects an unknown
+field, and catches a regex outside the supported grammar.
+
+```typescript title="query-document.ts"
+import assert from "node:assert/strict";
+import { mkdtemp, readdir, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+ Server, decodeWhereDocument, encodeWhereDocument, QueryValidationError,
+} from "libtmux";
+
+const directory = await mkdtemp(join(tmpdir(), "libtmux-query-"));
+const server = new Server({
+ socketPath: join(directory, "tmux.sock"), configFile: "/dev/null", timeoutMs: 5_000,
+});
+const failures: unknown[] = [];
+try {
+ await server.newSession({ name: "prod-api", shellCommand: "cat" });
+ await server.newSession({ name: "dev-api", shellCommand: "cat" });
+ const encoded = encodeWhereDocument({
+ version: 1, model: "session", where: { name: { startsWith: "prod-" } },
+ });
+ const document = decodeWhereDocument(JSON.parse(encoded));
+ assert.equal(document.model, "session");
+ if (document.model !== "session") throw new Error("Expected session criteria");
+ const snapshot = await server.snapshot();
+ const selected = snapshot.sessions.where(document.where);
+ assert.equal(selected.one().name, "prod-api");
+ console.log("decoded query:", selected.one().name);
+ console.log("wire JSON:", encoded);
+
+ try {
+ decodeWhereDocument({ version: 1, model: "session", where: { session_naem: "prod-api" } });
+ throw new Error("Expected an unknown field to fail validation");
+ } catch (error) {
+ if (!(error instanceof QueryValidationError)) throw error;
+ assert.equal(error.reason, "invalid-query");
+ console.log("invalid document:", error.code, error.reason);
+ }
+ try {
+ snapshot.sessions.where({
+ name: { regex: { pattern: "^prod-[a-z]+-[0-9]+$", flags: "" } },
+ });
+ throw new Error("Expected multiple repetitions to fail validation");
+ } catch (error) {
+ if (!(error instanceof QueryValidationError)) throw error;
+ console.log("unsupported regex:", error.code);
+ }
+} catch (error) {
+ failures.push(error);
+} finally {
+ try {
+ if ((await readdir(directory)).includes("tmux.sock")) await server.kill();
+ await rm(directory, { recursive: true });
+ } catch (error) {
+ failures.push(new Error(`Cleanup failed; inspect ${directory}`, { cause: error }));
+ }
+}
+if (failures.length > 0) throw new AggregateError(failures, "Query example failed");
+```
+
+```console
+$ bun run query-document.ts
+```
+
+The decoded query selects `prod-api`. Both invalid inputs raise
+`QueryValidationError`. Its `reason` distinguishes invalid IDs from invalid
+criteria; `path` identifies the failing field or nested condition.
+
+A valid criterion can still name a field introduced after the running tmux
+version. That raises [`VersionTooLowError`](../../reference/errors-versiontoolowerror/),
+which carries `criteriaName`, `serverVersion`, and `since`. Treat a version
+error as an unsupported query, not as evidence that no objects match.
+
+## Filter a live tmux listing
+
+Use snapshot selections when you need typed handles, relations, several local
+queries, or JavaScript predicates. To request only matching rows from tmux,
+call `Server.cmd()` with a list command's `-f` option. This returns output lines;
+it does not return a `Selection` or apply the TypeScript criteria grammar.
+
+Save as `tmux-filter.ts`. It lists production sessions with a tmux glob, then
+shows why an unknown format token can look like a valid empty result.
+
+```typescript title="tmux-filter.ts"
+import assert from "node:assert/strict";
+import { mkdtemp, readdir, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Server } from "libtmux";
+
+const directory = await mkdtemp(join(tmpdir(), "libtmux-query-"));
+const server = new Server({
+ socketPath: join(directory, "tmux.sock"), configFile: "/dev/null", timeoutMs: 5_000,
+});
+const failures: unknown[] = [];
+try {
+ await server.newSession({ name: "prod-api", shellCommand: "cat" });
+ await server.newSession({ name: "dev-api", shellCommand: "cat" });
+ const expression = "#{m:prod-*,#{session_name}}";
+ const names = await server.cmd("list-sessions", [
+ "-f", expression, "-F", "#{session_name}",
+ ]);
+ assert.deepEqual(names, ["prod-api"]);
+ console.log("tmux matches:", names.join(", "));
+
+ const unknown = "#{session_naem}";
+ const empty = await server.cmd("list-sessions", [
+ "-f", unknown, "-F", "#{session_name}",
+ ]);
+ assert.deepEqual(empty, []);
+ console.log("unknown token matches:", empty.length);
+
+ const expansion = await server.cmd("display-message", [
+ "-p", "-t", "=prod-api:", expression,
+ ]);
+ assert.deepEqual(expansion, ["1"]);
+ const badExpansion = await server.cmd("display-message", [
+ "-p", "-t", "=prod-api:", `value=<${unknown}>`,
+ ]);
+ assert.deepEqual(badExpansion, ["value=<>"]);
+ console.log("valid expression:", expansion[0]);
+ console.log("unknown token:", badExpansion[0]);
+} catch (error) {
+ failures.push(error);
+} finally {
+ try {
+ if ((await readdir(directory)).includes("tmux.sock")) await server.kill();
+ await rm(directory, { recursive: true });
+ } catch (error) {
+ failures.push(new Error(`Cleanup failed; inspect ${directory}`, { cause: error }));
+ }
+}
+if (failures.length > 0) throw new AggregateError(failures, "Query example failed");
+```
+
+```console
+$ bun run tmux-filter.ts
+```
+
+The glob returns `prod-api`. The misspelled `session_naem` returns zero rows
+without a command error. Expanding the valid expression prints `1`; expanding
+the unknown token between delimiters prints `value=<>`.
+
+When a live filter unexpectedly returns nothing, first run the same listing
+without `-f` to confirm the objects exist. Expand the expression with
+`display-message -p` against a known target, and check its token names against
+the running tmux version's manual. Keep each command argument separate as above;
+these strings go to tmux without shell interpolation.
+
+## API and related tasks
+
+- [`Selection`](../../reference/selection-selection/) defines iteration,
+ criteria, predicates, counts, and exactly-one lookup.
+- [`Server`](../../reference/server-server/) owns snapshot acquisition and
+ command execution.
+- [Traversal](/topics/traversal/) explains object relationships and identity.
+- [Sending keys](/guides/sending-keys/) uses a selected pane as an input target.
+- [Capturing output](/guides/capturing-output/) reads a selected pane's screen.
diff --git a/site/src/data/mentions.json b/site/src/data/mentions.json
index e366831a..9bcb8671 100644
--- a/site/src/data/mentions.json
+++ b/site/src/data/mentions.json
@@ -6916,6 +6916,13 @@
"title": "Filtering and queries",
"section": "concepts"
},
+ {
+ "port": "ts",
+ "symbol": "errors.MultipleMatchesError",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "errors.NoMatchError",
@@ -6923,6 +6930,20 @@
"title": "Filtering and queries",
"section": "concepts"
},
+ {
+ "port": "ts",
+ "symbol": "errors.NoMatchError",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
+ {
+ "port": "ts",
+ "symbol": "errors.QueryValidationError",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "errors.TmuxCommandError",
@@ -6951,6 +6972,27 @@
"title": "Errors and exceptions",
"section": "topics"
},
+ {
+ "port": "ts",
+ "symbol": "errors.VersionTooLowError",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
+ {
+ "port": "ts",
+ "symbol": "errors.VersionTooLowError.criteriaName",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
+ {
+ "port": "ts",
+ "symbol": "errors.VersionTooLowError.serverVersion",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "errors.VersionTooLowError.since",
@@ -6958,6 +7000,13 @@
"title": "Format-token fields",
"section": "topics"
},
+ {
+ "port": "ts",
+ "symbol": "errors.VersionTooLowError.since",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "mcp.server.createTmuxMcpServer",
@@ -7066,14 +7115,14 @@
{
"port": "ts",
"symbol": "selection.decodeWhereDocument",
- "page": "/concepts/queries/",
+ "page": "/ts/latest/concepts/queries/",
"title": "Filtering and queries",
"section": "concepts"
},
{
"port": "ts",
"symbol": "selection.encodeWhereDocument",
- "page": "/concepts/queries/",
+ "page": "/ts/latest/concepts/queries/",
"title": "Filtering and queries",
"section": "concepts"
},
@@ -7084,6 +7133,20 @@
"title": "Traversal",
"section": "topics"
},
+ {
+ "port": "ts",
+ "symbol": "selection.Selection",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
+ {
+ "port": "ts",
+ "symbol": "selection.Selection.exists",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "selection.Selection.filter",
@@ -7091,6 +7154,20 @@
"title": "Filtering and queries",
"section": "concepts"
},
+ {
+ "port": "ts",
+ "symbol": "selection.Selection.filter",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
+ {
+ "port": "ts",
+ "symbol": "selection.Selection.first",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "selection.Selection.one",
@@ -7098,6 +7175,13 @@
"title": "Filtering and queries",
"section": "concepts"
},
+ {
+ "port": "ts",
+ "symbol": "selection.Selection.one",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "selection.Selection.oneOrUndefined",
@@ -7105,6 +7189,13 @@
"title": "Filtering and queries",
"section": "concepts"
},
+ {
+ "port": "ts",
+ "symbol": "selection.Selection.oneOrUndefined",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "selection.Selection.where",
@@ -7112,6 +7203,13 @@
"title": "Filtering and queries",
"section": "concepts"
},
+ {
+ "port": "ts",
+ "symbol": "selection.Selection.where",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "server.Server",
@@ -7119,6 +7217,13 @@
"title": "Testing with libtmux",
"section": "guides"
},
+ {
+ "port": "ts",
+ "symbol": "server.Server",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "server.Server",
@@ -7168,6 +7273,13 @@
"title": "Control mode vs one-shot",
"section": "concepts"
},
+ {
+ "port": "ts",
+ "symbol": "server.Server.cmd",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "server.Server.connect",
@@ -7203,6 +7315,13 @@
"title": "TypeScript workspace builder examples",
"section": "ports"
},
+ {
+ "port": "ts",
+ "symbol": "server.Server.panes",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "server.Server.pipeline",
@@ -7224,6 +7343,13 @@
"title": "Traversal",
"section": "topics"
},
+ {
+ "port": "ts",
+ "symbol": "server.Server.sessions",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "server.Server.setEnvironment",
@@ -7238,6 +7364,13 @@
"title": "Environment",
"section": "topics"
},
+ {
+ "port": "ts",
+ "symbol": "server.Server.snapshot",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "server.Server.unsetEnvironment",
@@ -7252,6 +7385,13 @@
"title": "Control mode vs one-shot",
"section": "concepts"
},
+ {
+ "port": "ts",
+ "symbol": "server.Server.windows",
+ "page": "/ts/latest/concepts/queries/",
+ "title": "Filtering and queries",
+ "section": "concepts"
+ },
{
"port": "ts",
"symbol": "session.Session",
diff --git a/site/src/lib/sidebar.ts b/site/src/lib/sidebar.ts
index 487ae13b..160252f4 100644
--- a/site/src/lib/sidebar.ts
+++ b/site/src/lib/sidebar.ts
@@ -92,8 +92,9 @@ export function entryPath(entry: CollectionEntry<'docs'>): string {
return docsPath({ ...entry, id: sourceIdOf(entry.id) })
}
-/** `entryPath`, joined to this build's own base and given the trailing slash `trailingSlash: 'always'` expects. */
-function linkHref(entry: CollectionEntry<'docs'>, version: string): string {
+/** Port pages keep their navigation scope even when rendered by a root build. */
+function linkHref(entry: CollectionEntry<'docs'>, version: string, port?: string): string {
+ if (port) return portPageUrl(PORT_BY_SLUG[port], version, entryPath(entry))
const path = docsRoutePath({ ...entry, id: sourceIdOf(entry.id) }, process.env.LIBTMUX_DOCS_PORT,
entry.data.port ? { [entry.data.port]: version } : {})
const base = import.meta.env.BASE_URL
@@ -237,7 +238,7 @@ export async function getSidebar(
const localised = translated.get(entryPath(entry)) ?? entry
return {
label: localised.data.sidebar?.label ?? localised.data.title,
- href: linkHref(entry, version),
+ href: linkHref(entry, version, port),
order: entry.data.sidebar?.order,
group: entry.data.sidebar?.group,
}
diff --git a/site/src/styles/global.css b/site/src/styles/global.css
index 6a1d6218..a7a463a3 100644
--- a/site/src/styles/global.css
+++ b/site/src/styles/global.css
@@ -323,6 +323,11 @@ html[data-theme-mode="dark"] {
--input-text: white;
}
+/* Separate adjacent program and command blocks. */
+.prose .expressive-code + .expressive-code {
+ margin-block-start: 1.5rem;
+}
+
/* Preserve literal tabs while keeping compact indentation. */
.expressive-code .code {
tab-size: 2;
diff --git a/site/test/complete-examples.test.ts b/site/test/complete-examples.test.ts
index 8b4fe985..f00fb297 100644
--- a/site/test/complete-examples.test.ts
+++ b/site/test/complete-examples.test.ts
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import receipt from './fixtures/capture-examples.json'
import attach from './fixtures/attach-examples.json'
import products from './fixtures/product-examples.json'
+import queries from './fixtures/query-examples.json'
import { remarkPortCode, resolvePortCode } from '../src/plugins/remark-port-code.mjs'
import { rehypeCodeTabs } from '../src/plugins/rehype-code-tabs.mjs'
import { docsEntryAvailable, pagePortLinks } from '../src/lib/page-port-links'
@@ -17,7 +18,7 @@ const bodyOf = (page: string) => parsePage(page).content
const fences = (markdown: string) => [...markdown.matchAll(/^```(\S+)([^\n]*)\n([\s\S]*?)^```/gm)]
.map((match) => ({ language: match[1], title: /title="([^"]+)"/.exec(match[2])?.[1], code: match[3] }))
const sha256 = (code: string) => createHash('sha256').update(code).digest('hex')
-const examples = [...receipt.examples, ...attach.examples, ...products.examples]
+const examples = [...receipt.examples, ...attach.examples, ...products.examples, ...queries.examples]
afterEach(() => vi.unstubAllEnvs())
diff --git a/site/test/fixtures/query-examples.json b/site/test/fixtures/query-examples.json
new file mode 100644
index 00000000..e98e4be9
--- /dev/null
+++ b/site/test/fixtures/query-examples.json
@@ -0,0 +1,56 @@
+{
+ "page": "concepts/queries",
+ "examples": [
+ {
+ "port": "ts",
+ "page": "ports/ts/concepts/queries",
+ "sourceRevision": "3fe1ca654b81b8cbf4a13b777a001a3298c87a6f",
+ "runtime": "Bun 1.4.2",
+ "tmuxVersions": [
+ "3.2a",
+ "3.7c"
+ ],
+ "files": [
+ {
+ "name": "package.json",
+ "sha256": "acb06879bf3e3e5ce85125d64a72ff5b2579e87a66085961b1f7b88eaa813020"
+ },
+ {
+ "name": "matching.ts",
+ "sha256": "546bc696c2eedc1955b0a011b7144fad0ab502173e535a6fbb02bb1fce74f88b"
+ },
+ {
+ "name": "single-result.ts",
+ "sha256": "e72643800ae243abfbab8af70b5b76eabc66a2bea2533cbade1024ffb992d584"
+ },
+ {
+ "name": "relations.ts",
+ "sha256": "e31da357f7b06b23a7ea943d500fc8595efe9c6469116f3136afa42106739e1b"
+ },
+ {
+ "name": "refresh.ts",
+ "sha256": "8b6b303c2c0a74b8d66771532dcd697fe4243a9dc17139890308ef939614083b"
+ },
+ {
+ "name": "query-document.ts",
+ "sha256": "8a50296a867ad9a33296906a83ada52fae56932b72b9c3a2c32989e687a13fd4"
+ },
+ {
+ "name": "tmux-filter.ts",
+ "sha256": "8f95f8507965ee6d6428c816c6735ad4ffd306f89f736875c1f8903f743c4fb3"
+ }
+ ],
+ "shellRecipe": [
+ "git clone https://github.com/libtmux/libtmux-ts libtmux &&\n git -C libtmux checkout 3fe1ca654b81b8cbf4a13b777a001a3298c87a6f &&\n bun install",
+ "bun run matching.ts",
+ "bun run single-result.ts",
+ "bun run relations.ts",
+ "bun run refresh.ts",
+ "bun run query-document.ts",
+ "bun run tmux-filter.ts"
+ ],
+ "expectedOutput": "unknown token: value=<>",
+ "verificationScope": "Six independent displayed programs, each with assertions and cleanup. Commands run in page order against the pinned library on both recorded tmux versions."
+ }
+ ]
+}