From 765af2f46a38ceffd746811094e814f82caddbe1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 30 Sep 2026 17:45:59 -0500 Subject: [PATCH 1/3] fix(site) Keep sidebar links within the port why: Root builds also render owned port pages. Their shared sidebar links used the build context and sent readers to general documentation. what: - Build sidebar URLs from the page port and version - Cover both sidebar copies at desktop and phone widths, including Python stable and TypeScript latest - Prove the browser assertion fails with the original URL builder Validation: pnpm test passed in 47.81 seconds. --- site/scripts/check-navigation.mjs | 19 +++++++++++++++++++ site/src/lib/sidebar.ts | 7 ++++--- 2 files changed, 23 insertions(+), 3 deletions(-) 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/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, } From edb0ea5faa79019f41fc8d4208a7c46c88b670fd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 30 Sep 2026 18:01:21 -0500 Subject: [PATCH 2/3] docs(ts) Explain queries with complete programs why: The filtering page offered one fragment without imports or setup. Readers need the matching rules, result-count errors, linked-window behavior and refresh boundaries before using a selection as a target. what: - Add six independent TypeScript programs with setup and cleanup - Cover matching, cardinality, relations, snapshots, saved criteria and live tmux filters, including regex restrictions and silent empty rows - Route TypeScript to its own page and retain its published anchors - Record the exact displayed files and commands in the native runner Validation: All six programs passed on tmux 3.2a and 3.7c against the pinned library. pnpm test passed in 50.57 seconds. --- WRITING.md | 2 + scripts/check-example-prose.py | 2 +- site/src/content/docs/concepts/queries.md | 24 +- .../content/docs/ports/ts/concepts/queries.md | 580 ++++++++++++++++++ site/src/data/mentions.json | 144 ++++- site/test/complete-examples.test.ts | 3 +- site/test/fixtures/query-examples.json | 56 ++ 7 files changed, 788 insertions(+), 23 deletions(-) create mode 100644 site/src/content/docs/ports/ts/concepts/queries.md create mode 100644 site/test/fixtures/query-examples.json 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/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/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." + } + ] +} From 23a0d35d83ed5c183f97de7d6d95b6729cd6c429 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Wed, 30 Sep 2026 18:18:24 -0500 Subject: [PATCH 3/3] fix(site) Separate adjacent code blocks --- site/src/styles/global.css | 5 +++++ 1 file changed, 5 insertions(+) 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;