From 0b9cf4f0459b2063d4603c2c6d0a81a48391cbe9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 03:50:51 -0500 Subject: [PATCH 1/8] fix(site) Record optional tmux feature requirements why: A shared 3.3a floor does not make every control, buffer, shell or layout operation safe on that release. Readers had no page that says which features need a newer tmux, what a library does below it, and which ports have evidence for their refusal. what: - Add the optional features to the tmux support guide: library-managed control mode from 3.7, named buffer deletion and rename from 3.4, captured run-shell output outside 3.3 and 3.4, exact 3.7 break-pane and name restrictions, split percentages sent as -l N%, and layout validation below 3.4 - Define the five support states (Non-operative, Timing-sensitive, Operative with synchronization, Qualified, Unverified) and give the native state for each affected release - Name what the Python, tmuxp, Go and Rust changes enforce, and mark every other port Unverified until its change has checked evidence - State that guards cover library-chosen operations and literal command items, not opaque shell text or configured alias bodies - Update the guides index card to mention optional features --- site/src/content/docs/guides/index.md | 2 +- site/src/content/docs/guides/tmux-support.md | 83 +++++++++++++++++++- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/site/src/content/docs/guides/index.md b/site/src/content/docs/guides/index.md index b0c677a7..aca5f8bc 100644 --- a/site/src/content/docs/guides/index.md +++ b/site/src/content/docs/guides/index.md @@ -35,7 +35,7 @@ cards: body: Use isolated tmux servers and manage test cleanup. - label: tmux support href: tmux-support/ - body: Find the tmux release every port requires and how an older daemon is refused. + body: Find the tmux release every port requires and which features need a newer one. --- Use these guides to run tmux tasks from a terminal or a shell script. Start diff --git a/site/src/content/docs/guides/tmux-support.md b/site/src/content/docs/guides/tmux-support.md index 1d7c9b11..5c8e0307 100644 --- a/site/src/content/docs/guides/tmux-support.md +++ b/site/src/content/docs/guides/tmux-support.md @@ -1,7 +1,7 @@ --- supportedPorts: [] title: tmux support -description: The tmux release every libtmux port requires and how a library refuses an older daemon. +description: The tmux release every libtmux port requires, how a library refuses an older daemon, and which optional features need a newer release. sidebar: label: tmux support group: Guides @@ -11,7 +11,8 @@ tableOfContents: true Every libtmux port, the MCP servers and the workspace builders support tmux 3.3a and newer. A daemon older than 3.3a is refused before the requested -operation has any effect. +operation has any effect. A few optional features need a newer release; each +one is refused on its own and does not raise the minimum for anything else. ## The core minimum @@ -46,3 +47,81 @@ The check assumes tmux's built-in commands keep their meaning. A [`command-alias`](/tmux/latest/manual/full/#OPTIONS) that redefines a built-in command the library uses to identify or guard the daemon is outside this guarantee, as are the contents of user-written aliases and shell programs a library only forwards. + +## Optional features + +Some operations depend on behavior that only newer releases have, or that a +particular release gets wrong. A library decides from the daemon's release +before it dispatches the operation, then either keeps working through another +path or refuses with an error that names the requirement. + +| Feature | Requires | On an earlier release | +| ------- | -------- | --------------------- | +| Library-managed control mode: automatic transports, attach, reconnect, watches, mirrors, streams, subscriptions | tmux 3.7 | The library uses subprocess and polling paths. An operation with no such path is refused with a version error. A control client that attaches during initialization can crash the server on older releases. | +| Named buffer deletion and rename, and cleanup that relies on them | tmux 3.4 | The command can act on an unrelated buffer when the name is missing. Named `paste-buffer -d -b` stays available on 3.3a. | +| Captured standard output of `run-shell` | Not 3.3 or 3.4 | Those releases send the output to a view-mode pane. Background and pane-targeted jobs keep their native behavior. | +| `break-pane` | Not exact 3.7 | Exact 3.7 aborts on an unnamed call and ignores a name it is given. A call that supplies the window name is repaired by a following rename. 3.7a and later use native behavior. | +| Session and window names containing `:` or `.` | Not exact 3.7 | Exact 3.7 rejects them, so the library refuses before dispatch. Other releases accept them or rewrite them. | +| Percentage splits | Any, with `-l N%` | Typed requests always send `-l N%`. A raw `split-window -p` is refused on 3.4, which reads the value as a size. | +| Layout strings | tmux 3.4 for unchecked input | 3.3a can crash on an invalid layout string. Libraries accept only checksummed layouts or unique preset names before 3.4 and refuse the rest. | + +Two details limit what these promises cover. Control mode also needs a tmux +client that is compatible with the daemon; a client older than the daemon can +fail to register even when the daemon qualifies. Guards apply to library-chosen +operations and to literal tmux command items, including items in a batch. They +do not look inside opaque shell text or the body of a configured alias. + +Libraries also keep their own readiness, buffering and cleanup rules on +current releases. A command reply marks completion only where the command says +it does. Where a result depends on a pane's application, a library waits on a +marker or a state check, and a cursor position is a heuristic, not a promise. + +## Support states + +This page and the port notes use five states. They describe a feature in a +stated scope, never a whole port. + +| State | Meaning | +| ----- | ------- | +| Non-operative | The requested behavior is absent, rejected or incorrect. | +| Timing-sensitive | A known interleaving or workload can fail. | +| Operative with synchronization | The contract needs explicit readiness, buffering or completion conditions. | +| Qualified | The named checks passed for the stated scope. | +| Unverified | Evidence is missing, incomplete or skipped. | + +An unsupported-feature test can qualify a refusal. Skipping that test cannot. +An idle control attachment does not qualify concurrent acquisition, and a +feature that accepts a flag has not shown that it implements the flag. + +On the releases listed in the feature table, the native behavior is +Non-operative for named buffer cleanup below 3.4, `run-shell` capture on 3.3 +and 3.4, unnamed `break-pane` on exact 3.7, names containing `:` or `.` on +exact 3.7, raw `-p` on 3.4 and invalid layouts on 3.3a. Control acquisition +below 3.7 is Timing-sensitive: a short-lived attach can crash the server. Where +a library keeps working above a threshold, control mode is Operative with +synchronization because readiness and output framing need explicit waits. + +## What each port does + +The table lists what each port enforces and the state of that refusal. It +records only ports with checked evidence; every other port is Unverified. +Qualified here means native Linux runs against the stated releases. It does not +extend to macOS or to a hosted CI matrix. + +| Port | Control mode | Named buffers | `run-shell` capture | `break-pane`, names, `-p`, layouts | State | +| ---- | ------------ | ------------- | ------------------- | ---------------------------------- | ----- | +| Python (`libtmux`) | Needs 3.7 before a control client starts | Needs 3.4 | Needs 3.5 for a foreground job | Gated as in the feature table | Qualified | +| tmuxp workspace | Inherits the Python gates; checks the Python engine's policy version before importing builders | Inherits | Inherits | Malformed literal layouts refused on 3.3a; exact 3.7 pane breaks need a window name | Qualified | +| Go | Needs 3.7; pins the socket before attaching | Needs 3.4; MCP `paste_text` stages only with 3.4 | Needs 3.5 for a foreground job | Gated as in the feature table | Qualified | +| Rust | Needs a stable 3.7 or newer; development and release-candidate daemons are refused before control starts | Needs 3.4, including managed paste staging | Needs 3.5 | Gated as in the feature table, checked across a whole literal command chain before the first command runs | Qualified | +| Java, Kotlin, Scala | Not recorded | Not recorded | Not recorded | Not recorded | Unverified | +| C#, F#, PowerShell | Not recorded | Not recorded | Not recorded | Not recorded | Unverified | +| C++, Swift, TypeScript, Ruby, Lua, Julia | Not recorded | Not recorded | Not recorded | Not recorded | Unverified | + +MCP servers apply the same core check to each tool call. A tool that needs +control mode, such as `wait_for_text` in the Go server, reports the 3.7 +requirement and does not raise the server's minimum. + +A port's own compatibility page names the releases it was run on. Those runs +are historical records of the versions they describe; they are not a statement +about the minimum. From 7c2740a0e7ea17d8393d5c8b42cc20aefca42f18 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 03:50:51 -0500 Subject: [PATCH 2/8] test(site) Check the tmux support page states each requirement why: The optional-feature page is only useful while each feature keeps its own requirement, the states use the contract's names and results name ports instead of summarizing them as a ratio. what: - Require a table row for each optional feature with its tmux release - Require a defining row for each of the five support states - Reject ratios such as "5/8" or "five of eight" and require every port group in the port table Verified: the check passes; dropping the Unverified row, changing the named-buffer requirement to 3.3 and replacing a port group with a "five of eight" count each make it fail on the matching case, and the restored page passes. --- site/test/tmux-support-page.test.ts | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 site/test/tmux-support-page.test.ts diff --git a/site/test/tmux-support-page.test.ts b/site/test/tmux-support-page.test.ts new file mode 100644 index 00000000..ffaee77d --- /dev/null +++ b/site/test/tmux-support-page.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +/** + * The optional-feature requirements stay on one page, in the contract's terms. + * + * Each optional feature states its own tmux requirement, the support states + * use the five contract names, and port results are named, not counted. + */ +const PAGE = join(dirname(fileURLToPath(import.meta.url)), '../src/content/docs/guides/tmux-support.md') + +export const STATES = ['Non-operative', 'Timing-sensitive', 'Operative with synchronization', 'Qualified', 'Unverified'] +export const FEATURES: [string, RegExp][] = [ + ['control acquisition', /\| Library-managed control mode[^|]*\| tmux 3\.7 \|/], + ['named buffer deletion and rename', /\| Named buffer deletion and rename[^|]*\| tmux 3\.4 \|/], + ['run-shell captured stdout', /\| Captured standard output of `run-shell` \| Not 3\.3 or 3\.4 \|/], + ['exact 3.7 break-pane', /\| `break-pane` \| Not exact 3\.7 \|/], + ['exact 3.7 names', /\| Session and window names containing `:` or `\.` \| Not exact 3\.7 \|/], + ['percentage splits', /\| Percentage splits \|[^|]*`-l N%`/], + ['layout validation', /\| Layout strings \| tmux 3\.4 for unchecked input \|/], +] + +/** A count of ports such as "5/8" or "five of eight" summarizes instead of naming. */ +export function ratios(text: string): string[] { + return text.match(/\b\d+\s*\/\s*\d+\b|\b(?:one|two|three|four|five|six|seven|eight|\d+)\s+of\s+(?:the\s+)?(?:\d+|one|two|three|four|five|six|seven|eight|nine|ports)\b/gi) ?? [] +} + +describe('tmux support page', () => { + const page = readFileSync(PAGE, 'utf8') + + it('detects a ratio', () => { + expect(ratios('Qualified on 5/8 ports')).toEqual(['5/8']) + expect(ratios('Five of eight ports pass')).toEqual(['Five of eight']) + expect(ratios('Python needs 3.7 and Go needs 3.4')).toEqual([]) + }) + + it.each(FEATURES)('states the requirement for %s', (_name, row) => { + expect(page).toMatch(row) + }) + + it.each(STATES)('defines the %s state', (state) => { + expect(page).toMatch(new RegExp(`^\\| ${state} \\|`, 'm')) + }) + + it('names port results instead of counting them', () => { + expect(ratios(page)).toEqual([]) + }) + + it('keeps every port in the port table', () => { + for (const port of ['Python', 'tmuxp', 'Go', 'Rust', 'Java, Kotlin, Scala', 'C#, F#, PowerShell', 'C++, Swift, TypeScript, Ruby, Lua, Julia']) { + expect(page).toContain(`| ${port}`) + } + }) +}) From 4968585318a3e68fc320611e8df90bd8616794e9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 15:32:40 -0500 Subject: [PATCH 3/8] feat(api-model) Validate port capability manifests why: Each port's typed capability registry is the only source for the capability pages, so the site needs one shape to read and a way to say exactly what a port's emitted manifest gets wrong. what: - Define libtmux-capabilities/1: floor, capabilities with since, except, handling, error, API names and tmux commands, and release workarounds - Order versions as tmux prints them: next-X.Y < X.Y-rc < X.Y-rcN < X.Y < X.Ya, refusing forms tmux never prints - Compute each cell from the entry instead of storing it - Report every problem in a manifest at once, with tests for each --- packages/api-model/src/capabilities.ts | 197 +++++++++++++++++++ packages/api-model/src/index.ts | 14 ++ packages/api-model/test/capabilities.test.ts | 103 ++++++++++ 3 files changed, 314 insertions(+) create mode 100644 packages/api-model/src/capabilities.ts create mode 100644 packages/api-model/test/capabilities.test.ts diff --git a/packages/api-model/src/capabilities.ts b/packages/api-model/src/capabilities.ts new file mode 100644 index 00000000..641fdb17 --- /dev/null +++ b/packages/api-model/src/capabilities.ts @@ -0,0 +1,197 @@ +/** + * Capability manifests: what each port's typed `support.capabilities` registry + * says it can do on each tmux release, as the port itself serializes it. + * + * A port never writes this file by hand. Its own build emits it from the + * registry the library refuses with, and its own drift test fails when the + * two disagree. The site reads the manifest from the same checkout and + * revision as that port's API model, resolves every API name in it through + * the model, and computes each table cell from the release fields. Nothing + * here is a list somebody keeps in step. + */ + +export const CAPABILITY_SCHEMA = 'libtmux-capabilities/1' + +/** A tmux command, and the flags of it a capability depends on. */ +export interface CapabilityCommand { + command: string + flags?: string[] +} + +/** How a library behaves where the capability is not available. */ +export type Handling = 'refuse' | 'fallback' | 'repair' + +/** One positive capability, as the port's registry declares it. */ +export interface CapabilityEntry { + /** Stable id shared by every port (`control-mode`, `buffer-delete-named`). */ + id: string + /** Short name for headings. */ + name: string + /** One sentence: what a caller can do with it. */ + summary: string + /** The first tmux release it works on. */ + since: string + /** Exact releases at or above `since` where it does not work. */ + except?: string[] + /** What the library does below `since` or on an excepted release. */ + handling: Handling + /** The API name of the error a refusal raises. Required when `handling` is `refuse`. */ + error?: string + /** Public API names that need this capability, as the port's API model names them. */ + apis: string[] + /** tmux commands the capability concerns. */ + commands: CapabilityCommand[] +} + +/** A release-specific defect the library works around. Not a capability. */ +export interface Workaround { + id: string + name: string + summary: string + /** Exact releases the workaround applies to. */ + releases: string[] + handling: Exclude + apis: string[] + commands: CapabilityCommand[] +} + +/** The floor from the port's `support.versions`. */ +export interface Floor { + minimum: string +} + +export interface CapabilityManifest { + schema: typeof CAPABILITY_SCHEMA + /** The site's port slug. */ + port: string + floor: Floor + capabilities: CapabilityEntry[] + workarounds: Workaround[] +} + +/** A version tmux has printed: `X.Y`, `X.Ya`, `X.Y-rc`, `X.Y-rcN` or `next-X.Y`. */ +const VERSION = /^(?:next-(0|[1-9]\d*)\.(0|[1-9]\d*)|(0|[1-9]\d*)\.(0|[1-9]\d*)([a-z]?)(?:-rc(\d*))?)$/ + +/** A sortable key in tmux's own order: next-X.Y < X.Y-rc < X.Y-rcN < X.Y < X.Ya. */ +export function tmuxVersionKey(version: string): number[] { + const m = VERSION.exec(version) + if (!m) throw new TypeError(`not a version tmux prints: ${JSON.stringify(version)}`) + if (m[1] !== undefined) return [Number(m[1]), Number(m[2]), 0, 0, 0] + const letter = m[5] ? m[5].charCodeAt(0) - 96 : 0 + if (m[6] !== undefined) { + if (letter) throw new TypeError(`a candidate carries no patch letter: ${JSON.stringify(version)}`) + return [Number(m[3]), Number(m[4]), 1, m[6] === '' ? 0 : Number(m[6]), 0] + } + return [Number(m[3]), Number(m[4]), 2, 0, letter] +} + +export function compareTmuxVersions(a: string, b: string): number { + const ka = tmuxVersionKey(a) + const kb = tmuxVersionKey(b) + for (let i = 0; i < ka.length; i++) if (ka[i] !== kb[i]) return ka[i] - kb[i] + return 0 +} + +/** What a library does with a capability on one release. */ +export type CellState = 'supported' | 'workaround' | 'fallback' | 'refused' | 'repaired' + +export function capabilityState(entry: CapabilityEntry, release: string): CellState { + const available = compareTmuxVersions(release, entry.since) >= 0 && !(entry.except ?? []).includes(release) + if (available) return 'supported' + if (entry.handling === 'fallback') return 'fallback' + if (entry.handling === 'repair') return 'repaired' + return 'refused' +} + +export function workaroundState(entry: Workaround, release: string): CellState { + if (!entry.releases.includes(release)) return 'supported' + return entry.handling === 'repair' ? 'workaround' : 'refused' +} + +const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ + +function isString(v: unknown): v is string { + return typeof v === 'string' && v.length > 0 +} + +function checkVersion(errors: string[], where: string, v: unknown): void { + if (!isString(v)) { + errors.push(`${where}: expected a tmux version`) + return + } + try { + tmuxVersionKey(v) + } catch (e) { + errors.push(`${where}: ${(e as Error).message}`) + } +} + +function checkCommands(errors: string[], where: string, v: unknown): void { + if (!Array.isArray(v)) { + errors.push(`${where}: expected an array of commands`) + return + } + v.forEach((c, i) => { + if (!c || typeof c !== 'object' || !isString((c as CapabilityCommand).command)) { + errors.push(`${where}[${i}]: expected { command, flags? }`) + return + } + const flags = (c as CapabilityCommand).flags + if (flags !== undefined && (!Array.isArray(flags) || !flags.every((f) => /^-[A-Za-z]$/.test(f)))) { + errors.push(`${where}[${i}].flags: expected single-letter flags such as "-b"`) + } + }) +} + +function checkNames(errors: string[], where: string, v: unknown): void { + if (!Array.isArray(v) || !v.every(isString)) errors.push(`${where}: expected an array of API names`) +} + +/** + * Validate a manifest without a schema library. Every problem is reported, so + * a port fixing its emitter sees the whole list at once. + */ +export function validateManifest(value: unknown): { manifest?: CapabilityManifest; errors: string[] } { + const errors: string[] = [] + const m = value as Partial | null + if (!m || typeof m !== 'object') return { errors: ['manifest: expected an object'] } + if (m.schema !== CAPABILITY_SCHEMA) errors.push(`schema: expected "${CAPABILITY_SCHEMA}"`) + if (!isString(m.port)) errors.push('port: expected the site port slug') + checkVersion(errors, 'floor.minimum', m.floor?.minimum) + const ids = new Set() + const seen = (where: string, id: unknown) => { + if (!isString(id) || !ID.test(id)) errors.push(`${where}.id: expected a kebab-case id`) + else if (ids.has(id)) errors.push(`${where}.id: "${id}" appears twice`) + else ids.add(id) + } + if (!Array.isArray(m.capabilities)) errors.push('capabilities: expected an array') + else { + m.capabilities.forEach((c, i) => { + const at = `capabilities[${i}]` + seen(at, c?.id) + if (!isString(c?.name)) errors.push(`${at}.name: expected a name`) + if (!isString(c?.summary)) errors.push(`${at}.summary: expected a sentence`) + checkVersion(errors, `${at}.since`, c?.since) + ;(c?.except ?? []).forEach((r, j) => checkVersion(errors, `${at}.except[${j}]`, r)) + if (!['refuse', 'fallback', 'repair'].includes(c?.handling as string)) errors.push(`${at}.handling: expected refuse, fallback or repair`) + if (c?.handling === 'refuse' && !isString(c?.error)) errors.push(`${at}.error: a refusal names its error type`) + checkNames(errors, `${at}.apis`, c?.apis) + checkCommands(errors, `${at}.commands`, c?.commands) + }) + } + if (!Array.isArray(m.workarounds)) errors.push('workarounds: expected an array') + else { + m.workarounds.forEach((w, i) => { + const at = `workarounds[${i}]` + seen(at, w?.id) + if (!isString(w?.name)) errors.push(`${at}.name: expected a name`) + if (!isString(w?.summary)) errors.push(`${at}.summary: expected a sentence`) + if (!Array.isArray(w?.releases) || w.releases.length === 0) errors.push(`${at}.releases: expected at least one release`) + else w.releases.forEach((r, j) => checkVersion(errors, `${at}.releases[${j}]`, r)) + if (!['refuse', 'repair'].includes(w?.handling as string)) errors.push(`${at}.handling: expected refuse or repair`) + checkNames(errors, `${at}.apis`, w?.apis) + checkCommands(errors, `${at}.commands`, w?.commands) + }) + } + return errors.length ? { errors } : { manifest: m as CapabilityManifest, errors } +} diff --git a/packages/api-model/src/index.ts b/packages/api-model/src/index.ts index 78809ba6..2980c112 100644 --- a/packages/api-model/src/index.ts +++ b/packages/api-model/src/index.ts @@ -28,3 +28,17 @@ export { parentInventory } from './parent-inventory.ts' export { navSidecarFor, type NavConfig } from './nav-sidecar.ts' export { parsePythonDoc } from './doc/python.ts' +export { + CAPABILITY_SCHEMA, + capabilityState, + compareTmuxVersions, + tmuxVersionKey, + validateManifest, + workaroundState, + type CapabilityCommand, + type CapabilityEntry, + type CapabilityManifest, + type CellState, + type Handling, + type Workaround, +} from './capabilities.ts' diff --git a/packages/api-model/test/capabilities.test.ts b/packages/api-model/test/capabilities.test.ts new file mode 100644 index 00000000..7626f1c3 --- /dev/null +++ b/packages/api-model/test/capabilities.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest' +import { + CAPABILITY_SCHEMA, + capabilityState, + compareTmuxVersions, + validateManifest, + workaroundState, + type CapabilityEntry, + type Workaround, +} from '../src/capabilities.ts' + +const control: CapabilityEntry = { + id: 'control-mode', + name: 'Control mode', + summary: 'Attach a library-managed control client.', + since: '3.7', + handling: 'fallback', + apis: ['libtmux.Server.observe'], + commands: [{ command: 'attach-session', flags: ['-C'] }], +} + +const runShell: CapabilityEntry = { + id: 'run-shell-capture', + name: 'Captured run-shell output', + summary: 'Read the standard output of run-shell.', + since: '3.3a', + except: ['3.4'], + handling: 'refuse', + error: 'libtmux.CapabilityUnavailable', + apis: ['libtmux.Server.run_shell'], + commands: [{ command: 'run-shell' }], +} + +const breakPane: Workaround = { + id: 'break-pane-exact-3-7', + name: 'break-pane on 3.7', + summary: 'Exact 3.7 ignores the name given to break-pane; a rename follows.', + releases: ['3.7'], + handling: 'repair', + apis: ['libtmux.Pane.break_out'], + commands: [{ command: 'break-pane', flags: ['-n'] }], +} + +const manifest = { + schema: CAPABILITY_SCHEMA, + port: 'py', + floor: { minimum: '3.3a' }, + capabilities: [control, runShell], + workarounds: [breakPane], +} + +describe('tmux version order', () => { + it('ranks development builds and candidates below the release they name', () => { + const ordered = ['next-3.7', '3.7-rc', '3.7-rc3', '3.7', '3.7a', '3.8', 'next-3.9', '3.10'] + expect([...ordered].reverse().sort(compareTmuxVersions)).toEqual(ordered) + }) + + it('refuses forms tmux never prints', () => { + for (const v of ['master', '3.3a-master', 'next-3.3a', '3.3a-rc1', '03.3a', 'openbsd-7.6']) { + expect(() => compareTmuxVersions(v, '3.3a'), v).toThrow(TypeError) + } + }) +}) + +describe('cells', () => { + it('follows since, except and handling', () => { + expect(capabilityState(control, '3.6')).toBe('fallback') + expect(capabilityState(control, '3.7')).toBe('supported') + expect(capabilityState(control, '3.7-rc')).toBe('fallback') + expect(capabilityState(runShell, '3.3a')).toBe('supported') + expect(capabilityState(runShell, '3.4')).toBe('refused') + expect(capabilityState(runShell, '3.5')).toBe('supported') + }) + + it('marks only the releases a workaround names', () => { + expect(workaroundState(breakPane, '3.7')).toBe('workaround') + expect(workaroundState(breakPane, '3.7a')).toBe('supported') + }) +}) + +describe('validateManifest', () => { + it('accepts a complete manifest', () => { + const { manifest: m, errors } = validateManifest(manifest) + expect(errors).toEqual([]) + expect(m?.capabilities.map((c) => c.id)).toEqual(['control-mode', 'run-shell-capture']) + }) + + const broken: [string, unknown, string][] = [ + ['wrong schema', { ...manifest, schema: 'libtmux-capabilities/0' }, 'schema'], + ['unprintable floor', { ...manifest, floor: { minimum: 'master' } }, 'floor.minimum'], + ['duplicate id', { ...manifest, workarounds: [{ ...breakPane, id: 'control-mode' }] }, 'appears twice'], + ['refusal without an error type', { ...manifest, capabilities: [{ ...runShell, error: undefined }] }, 'names its error type'], + ['non-kebab id', { ...manifest, capabilities: [{ ...control, id: 'ControlMode' }] }, 'kebab-case'], + ['bad since', { ...manifest, capabilities: [{ ...control, since: '3.7-dev' }] }, 'since'], + ['bad flag', { ...manifest, capabilities: [{ ...control, commands: [{ command: 'attach-session', flags: ['C'] }] }] }, 'flags'], + ['empty workaround', { ...manifest, workarounds: [{ ...breakPane, releases: [] }] }, 'releases'], + ] + it.each(broken)('rejects a manifest with %s', (_label, value, message) => { + const { manifest: m, errors } = validateManifest(value) + expect(m).toBeUndefined() + expect(errors.join('\n')).toContain(message) + }) +}) From 1f770bb03d06855d2dbcf92ffc11a1a776dd07d4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 15:32:40 -0500 Subject: [PATCH 4/8] feat(scripts) Generate capability data from port manifests why: A table typed into a guide drifts from the code it describes. The capability pages must come from what each port's registry says, with every API name tied to that port's reference. what: - Read capabilities.json from each port checkout, validate it, and resolve each API and error name through the port's API model - Fail on a name the model lacks instead of rendering plain text - Take release columns from tmux's own refs: final tags, release branches (3.7d), and the next-X.Y master names - Run the check in test-all, with a fixture manifest that names a missing method to show the gate fails --- scripts/gen-capabilities.mjs | 149 ++++++++++++++++++ scripts/test-all.sh | 6 + site/src/data/capabilities.json | 5 + site/test/capabilities.test.ts | 35 ++++ .../fixtures/capabilities/py-unresolved.json | 1 + site/test/fixtures/capabilities/py.json | 1 + 6 files changed, 197 insertions(+) create mode 100644 scripts/gen-capabilities.mjs create mode 100644 site/src/data/capabilities.json create mode 100644 site/test/capabilities.test.ts create mode 100644 site/test/fixtures/capabilities/py-unresolved.json create mode 100644 site/test/fixtures/capabilities/py.json diff --git a/scripts/gen-capabilities.mjs b/scripts/gen-capabilities.mjs new file mode 100644 index 00000000..8091fadd --- /dev/null +++ b/scripts/gen-capabilities.mjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +/** + * Read each port's capability manifest and write site/src/data/capabilities.json. + * + * A port's `capabilities.json` is emitted by that port from the typed + * `support.capabilities` registry its library refuses with, and the port's own + * drift test keeps the two equal. This script does not interpret prose or + * keep a table: it validates each manifest, resolves every API name in it + * through the port's generated API model, and records the tmux releases the + * matrix columns come from. A name that does not resolve fails the run, so a + * renamed method cannot leave a capability pointing at nothing. + * + * Releases come from tmux's own refs: final tags, the version a release + * branch names in `configure.ac` (3.7d shipped from a branch before it was + * tagged), and the `next-X.Y` that master names. Only releases at or above the + * lowest floor any manifest declares are kept. + * + * Usage: node scripts/gen-capabilities.mjs [--port ] [--manifest =] + * [--tmux ] [--out ] [--check] + */ +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { PORTS } from '../site/src/lib/ports.ts' +import { SymbolIndex } from '../packages/api-model/src/link.ts' +import { compareTmuxVersions, tmuxVersionKey, validateManifest } from '../packages/api-model/src/capabilities.ts' + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const args = process.argv.slice(2) +const value = (flag, fallback) => (args.includes(flag) ? args[args.indexOf(flag) + 1] : fallback) +const values = (flag) => args.flatMap((a, i) => (a === flag ? [args[i + 1]] : [])) +const expand = (p) => (p.startsWith('~/') ? join(homedir(), p.slice(2)) : p) +const checking = args.includes('--check') +const only = value('--port') +const out = resolve(value('--out', join(repoRoot, 'site/src/data/capabilities.json'))) +const tmuxCheckout = expand(value('--tmux', '~/study/c/tmux')) +const modelDir = resolve(value('--models', join(repoRoot, 'site/src/data/api'))) + +/** `--manifest py=path/to/capabilities.json` overrides where a port's manifest is read. */ +const overrides = Object.fromEntries(values('--manifest').map((pair) => pair.split('='))) + +/** The manifest sits at the root of the port's checkout, emitted and drift-checked there. */ +function manifestPath(port) { + return overrides[port.slug] ?? join(expand(port.checkout), 'capabilities.json') +} + +function git(...a) { + return execFileSync('git', ['-C', tmuxCheckout, ...a], { encoding: 'utf8' }).trim() +} + +/** Every tmux release a reader can run, from tmux's own refs. */ +function tmuxReleases() { + const versions = new Set() + const add = (v) => { + try { + tmuxVersionKey(v) + versions.add(v) + } catch { + // A ref that names something tmux never prints (a topic branch) is not a release. + } + } + for (const tag of git('tag', '-l').split('\n')) if (/^\d+\.\d+[a-z]?$/.test(tag)) add(tag) + const refs = git('for-each-ref', '--format=%(refname:short)', 'refs/remotes').split('\n') + const named = (ref) => /AC_INIT\(\[tmux\], ([^)]+)\)/.exec(git('show', `${ref}:configure.ac`))?.[1] + for (const ref of refs.filter((r) => /\/release_[\d.]+[a-z]?$/.test(r))) { + const v = named(ref) + if (v && /^\d+\.\d+[a-z]?$/.test(v)) add(v) + } + const master = refs.find((r) => r === 'upstream/master') ?? refs.find((r) => r === 'origin/master') + const next = master && named(master) + if (next?.startsWith('next-')) add(next) + return [...versions].sort(compareTmuxVersions) +} + +/** Resolve a manifest's API name to the public id the reference pages use. */ +function resolver(slug) { + const file = join(modelDir, `${slug}.json`) + if (!existsSync(file)) return undefined + const model = JSON.parse(readFileSync(file, 'utf8')) + const index = new SymbolIndex(model.symbols, () => '', slug) + return (name) => { + const target = index.resolve(name) + return target?.symbol ? (target.symbol.publicId ?? target.symbol.id) : undefined + } +} + +const errors = [] +const ports = {} +for (const port of PORTS) { + if (only && port.slug !== only) continue + const path = manifestPath(port) + if (!existsSync(path)) continue + const { manifest, errors: invalid } = validateManifest(JSON.parse(readFileSync(path, 'utf8'))) + if (!manifest) { + errors.push(...invalid.map((e) => `${port.slug}: ${e}`)) + continue + } + if (manifest.port !== port.slug) errors.push(`${port.slug}: manifest names port "${manifest.port}"`) + const resolve = resolver(port.slug) + if (!resolve) { + errors.push(`${port.slug}: no API model at ${join(modelDir, `${port.slug}.json`)}; run gen-api-model first`) + continue + } + const link = (where, name) => { + const id = resolve(name) + if (!id) errors.push(`${port.slug}: ${where} names "${name}", which is not in the API model`) + return id ?? name + } + const entries = (list, kind) => list.map((e) => ({ + ...e, + apis: e.apis.map((name) => link(`${kind} ${e.id}`, name)), + ...(e.error ? { error: link(`${kind} ${e.id} error`, e.error) } : {}), + })) + ports[port.slug] = { + revision: (() => { + try { + return execFileSync('git', ['-C', dirname(path), 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() + } catch { + return undefined + } + })(), + floor: manifest.floor, + capabilities: entries(manifest.capabilities, 'capability'), + workarounds: entries(manifest.workarounds, 'workaround'), + } +} + +if (errors.length) { + console.error(errors.join('\n')) + process.exit(1) +} + +const floors = Object.values(ports).map((p) => p.floor.minimum) +const lowest = floors.sort(compareTmuxVersions)[0] +const releases = lowest ? tmuxReleases().filter((v) => compareTmuxVersions(v, lowest) >= 0) : [] +const data = `${JSON.stringify({ generated: 'scripts/gen-capabilities.mjs', releases, ports }, null, 2)}\n` + +if (checking) { + const current = existsSync(out) ? readFileSync(out, 'utf8') : '' + if (current !== data) { + console.error(`${out} is stale; run node scripts/gen-capabilities.mjs`) + process.exit(1) + } +} else { + writeFileSync(out, data) + console.log(`wrote ${out}: ${Object.keys(ports).length} ports, ${releases.length} releases`) +} diff --git a/scripts/test-all.sh b/scripts/test-all.sh index f5cbbc02..a3a07570 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -147,6 +147,12 @@ node scripts/gen-shell-ports.negative.mjs step 'mcp tool matrix' node scripts/gen-mcp-tools.mjs --check +# /reference/capabilities/ and the per-command tables are generated from each +# port's capabilities.json, which the port emits from its typed registry. +# Every API name in a manifest must resolve in that port's model. +step 'capability manifests' +node scripts/gen-capabilities.mjs --check + # Each port's own MCP server, built and run to compare its live tool, # resource and prompt schemas against the committed snapshot in # site/src/data/mcp-protocol/. Skips when the sibling checkouts are absent, diff --git a/site/src/data/capabilities.json b/site/src/data/capabilities.json new file mode 100644 index 00000000..b2520005 --- /dev/null +++ b/site/src/data/capabilities.json @@ -0,0 +1,5 @@ +{ + "generated": "scripts/gen-capabilities.mjs", + "releases": [], + "ports": {} +} diff --git a/site/test/capabilities.test.ts b/site/test/capabilities.test.ts new file mode 100644 index 00000000..f8e70e5e --- /dev/null +++ b/site/test/capabilities.test.ts @@ -0,0 +1,35 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const root = resolve(import.meta.dirname, '../..') +const fixtures = join(import.meta.dirname, 'fixtures/capabilities') + +function generate(manifest: string) { + const out = join(mkdtempSync(join(tmpdir(), 'capabilities-')), 'capabilities.json') + const run = spawnSync('node', ['scripts/gen-capabilities.mjs', '--port', 'py', '--manifest', `py=${join(fixtures, manifest)}`, '--out', out], { + cwd: root, + encoding: 'utf8', + }) + return { run, out } +} + +describe('gen-capabilities', () => { + it('links every API name in a manifest to the API model', () => { + const { run, out } = generate('py.json') + expect(run.status, run.stderr).toBe(0) + const data = JSON.parse(readFileSync(out, 'utf8')) + const entry = data.ports.py.capabilities[0] + expect(entry.apis).toEqual(['libtmux.Server.new_session']) + expect(entry.error).toBe('libtmux.exc.VersionTooLow') + expect(data.releases[0]).toBe('3.3a') + }) + + it('fails on a name the API model does not have', () => { + const { run } = generate('py-unresolved.json') + expect(run.status).toBe(1) + expect(run.stderr).toContain('"libtmux.Server.no_such_method", which is not in the API model') + }) +}) diff --git a/site/test/fixtures/capabilities/py-unresolved.json b/site/test/fixtures/capabilities/py-unresolved.json new file mode 100644 index 00000000..35faa541 --- /dev/null +++ b/site/test/fixtures/capabilities/py-unresolved.json @@ -0,0 +1 @@ +{"schema": "libtmux-capabilities/1", "port": "py", "floor": {"minimum": "3.3a"}, "capabilities": [{"id": "buffer-delete-named", "name": "Named buffer delete", "summary": "Delete a buffer by name.", "since": "3.4", "handling": "refuse", "error": "libtmux.exc.VersionTooLow", "apis": ["libtmux.Server.new_session", "libtmux.Server.no_such_method"], "commands": [{"command": "delete-buffer", "flags": ["-b"]}]}], "workarounds": []} \ No newline at end of file diff --git a/site/test/fixtures/capabilities/py.json b/site/test/fixtures/capabilities/py.json new file mode 100644 index 00000000..acded1be --- /dev/null +++ b/site/test/fixtures/capabilities/py.json @@ -0,0 +1 @@ +{"schema": "libtmux-capabilities/1", "port": "py", "floor": {"minimum": "3.3a"}, "capabilities": [{"id": "buffer-delete-named", "name": "Named buffer delete", "summary": "Delete a buffer by name.", "since": "3.4", "handling": "refuse", "error": "libtmux.exc.VersionTooLow", "apis": ["libtmux.Server.new_session"], "commands": [{"command": "delete-buffer", "flags": ["-b"]}]}], "workarounds": []} \ No newline at end of file From a1fa3c14a648dad7f62b202d6bb66278ea4fa3c9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 15:32:40 -0500 Subject: [PATCH 5/8] feat(site) Render capabilities as a fixed-width matrix why: Prose cells of different lengths made the old table jagged and hard to scan. A map reads down a release and across a capability. what: - Lay the table out fixed, with equal-width data columns and a sticky capability column - Show an icon per cell with a screen-reader label, and a legend of only the states the table uses - Test equal widths, one labelled cell per column, and the legend; uneven column widths fail the first test --- .../capabilities/CapabilityMatrix.astro | 84 +++++++++++++++++++ site/src/lib/capability-cells.ts | 43 ++++++++++ site/test/capability-matrix.test.ts | 77 +++++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 site/src/components/capabilities/CapabilityMatrix.astro create mode 100644 site/src/lib/capability-cells.ts create mode 100644 site/test/capability-matrix.test.ts diff --git a/site/src/components/capabilities/CapabilityMatrix.astro b/site/src/components/capabilities/CapabilityMatrix.astro new file mode 100644 index 00000000..01b06466 --- /dev/null +++ b/site/src/components/capabilities/CapabilityMatrix.astro @@ -0,0 +1,84 @@ +--- +/** + * A map of capabilities against columns (tmux releases, or ports). + * + * Every column has the same width (`table-layout: fixed`), so the eye reads + * down a release and across a capability without text reflowing the grid. + * The first column stays put while the releases scroll. A cell carries an + * icon and, for screen readers, the same words the legend uses. + */ +import { CELL_LABEL, type Column, type Row } from '../../lib/capability-cells' + +interface Props { + caption: string + columns: Column[] + rows: Row[] + /** Width of each data column. Ports need more room for "3.7+" than releases need. */ + columnWidth?: 'narrow' | 'wide' + /** Heading of the first column. */ + rowHeader?: string +} + +const { caption, columns, rows, columnWidth = 'narrow', rowHeader = 'Capability' } = Astro.props as Props +const minimum = columnWidth === 'wide' ? '4.5rem' : '3.5rem' + +const ICON: Record = { + supported: '✓', + workaround: '◐', + repaired: '◐', + fallback: '↺', + refused: '✕', + absent: '–', +} + +const TONE: Record = { + supported: 'text-emerald-700 dark:text-emerald-400', + workaround: 'text-sky-700 dark:text-sky-400', + repaired: 'text-sky-700 dark:text-sky-400', + fallback: 'text-amber-700 dark:text-amber-400', + refused: 'text-rose-700 dark:text-rose-400', + absent: 'text-muted', +} +--- + +
+ + + + + {columns.map(() => )} + + + + + {columns.map((c) => ( + + ))} + + + + {rows.map((row) => ( + + + {row.cells.map((cell, i) => ( + + ))} + + ))} + +
{caption}
{rowHeader}{c.label}
+ {row.label} + + + {cell.text && } + {CELL_LABEL[cell.state]}{cell.text ? `, ${cell.text}` : ''} +
+
+ +
    + {(['supported', 'workaround', 'fallback', 'refused', 'absent'] as const) + .filter((s) => rows.some((r) => r.cells.some((c) => c.state === s || (s === 'workaround' && c.state === 'repaired')))) + .map((s) => ( +
  • {CELL_LABEL[s]}
  • + ))} +
diff --git a/site/src/lib/capability-cells.ts b/site/src/lib/capability-cells.ts new file mode 100644 index 00000000..2df8a834 --- /dev/null +++ b/site/src/lib/capability-cells.ts @@ -0,0 +1,43 @@ +/** Cell and row shapes for the capability matrix, kept free of data imports. */ +import type { CapabilityCommand, CellState } from '@libtmux/api-model' + +export interface Column { + key: string + label: string + title: string +} + +export interface Cell { + state: CellState | 'absent' + /** Short text shown with the icon, such as the release a port needs. */ + text?: string +} + +export interface ApiLink { + label: string + href?: string +} + +export interface Row { + id: string + label: string + /** Where the row's name links; its anchor on this page when unset. */ + href?: string + summary: string + cells: Cell[] + apis: ApiLink[] + error?: ApiLink + commands: CapabilityCommand[] + /** Releases in words: "3.7 and later", "3.3a and later, not 3.4". */ + requirement: string +} + +export const CELL_LABEL: Record = { + supported: 'Works', + workaround: 'Works with a workaround', + repaired: 'Repaired after the command', + fallback: 'Uses another path', + refused: 'Refused with an error', + absent: 'No entry in this port', +} + diff --git a/site/test/capability-matrix.test.ts b/site/test/capability-matrix.test.ts new file mode 100644 index 00000000..bf116190 --- /dev/null +++ b/site/test/capability-matrix.test.ts @@ -0,0 +1,77 @@ +import { readFileSync, realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +import { describe, expect, it, vi } from 'vitest' +import { experimental_AstroContainer } from 'astro/container' +import { Window } from 'happy-dom' +import * as cellsModule from '../src/lib/capability-cells' +import type { Column, Row } from '../src/lib/capability-cells' + +/** Compile the component the way the site's other component tests do, with its one helper bound. */ +async function loadMatrix() { + vi.stubGlobal('__capabilityCells', cellsModule) + const astroRequire = createRequire(realpathSync(new URL('../node_modules/astro/package.json', import.meta.url))) + const { transform } = await import(astroRequire.resolve('@astrojs/compiler-rs')) + const runtime = pathToFileURL(astroRequire.resolve('astro/compiler-runtime')).href + const file = new URL('../src/components/capabilities/CapabilityMatrix.astro', import.meta.url) + const result = await transform(readFileSync(file, 'utf8'), { filename: file.pathname, internalURL: 'astro/compiler-runtime', + resultScopedSlot: true, scopedStyleStrategy: 'attribute', resolvePath: (specifier: string) => specifier }) + expect(result.diagnostics.filter((d: { severity: string }) => d.severity === 'error')).toEqual([]) + const bridge = `export const { CELL_LABEL } = globalThis.__capabilityCells;` + const code = result.code.replaceAll('"astro/compiler-runtime"', JSON.stringify(runtime)) + .replaceAll('"../../lib/capability-cells"', JSON.stringify(`data:text/javascript;base64,${Buffer.from(bridge).toString('base64')}`)) + return (await import(/* @vite-ignore */ `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)).default +} + +const columns: Column[] = ['3.3a', '3.4', '3.7', 'next-3.9'].map((v) => ({ key: v, label: v, title: `tmux ${v}` })) +const rows: Row[] = [ + { + id: 'control-mode', label: 'Control mode', summary: 'Attach a control client.', requirement: '3.7 and later', + apis: [], commands: [], + cells: [{ state: 'fallback' }, { state: 'fallback' }, { state: 'supported' }, { state: 'supported' }], + }, + { + id: 'buffer-delete-named', label: 'Named buffer delete with a much longer label', summary: 'Delete by name.', requirement: '3.4 and later', + apis: [], commands: [], + cells: [{ state: 'refused' }, { state: 'supported' }, { state: 'supported' }, { state: 'supported' }], + }, +] + +async function render() { + const container = await experimental_AstroContainer.create() + const html = await container.renderToString(await loadMatrix(), { props: { caption: 'test', columns, rows } }) + const window = new Window() + window.document.body.innerHTML = html + return window.document +} + +describe('CapabilityMatrix', () => { + it('shares the width left by the name column equally among the data columns', async () => { + const doc = await render() + const table = doc.querySelector('table')! + expect(table.className).toContain('table-fixed') + expect(table.className).toContain('w-full') + const cols = [...doc.querySelectorAll('col')] + expect(cols[0].getAttribute('class')).toMatch(/w-\[/) + const data = cols.slice(1).map((c) => c.getAttribute('style')) + expect(data).toHaveLength(columns.length) + // A fixed layout splits the remaining width evenly among columns without a width. + expect(data.every((style) => style === null)).toBe(true) + }) + + it('fills every row with one cell per column, each labelled for screen readers', async () => { + const doc = await render() + for (const tr of doc.querySelectorAll('tbody tr')) { + const cells = tr.querySelectorAll('td') + expect(cells).toHaveLength(columns.length) + for (const td of cells) expect(td.querySelector('.sr-only')?.textContent).toBeTruthy() + } + expect(doc.querySelectorAll('thead th')).toHaveLength(columns.length + 1) + }) + + it('lists only the states the table uses in its legend', async () => { + const doc = await render() + const legend = [...doc.querySelectorAll('[aria-label="Legend"] li')].map((li) => li.textContent?.trim()) + expect(legend).toEqual(['✓Works', '↺Uses another path', '✕Refused with an error']) + }) +}) From 79e2901bd6b1317f398bbdf1058befc756d718b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 15:32:40 -0500 Subject: [PATCH 6/8] feat(site) Generate the capability reference pages why: Readers need to know what a port does on their tmux release and which methods that affects, from the port's own registry. what: - Add reference/capabilities: per port in a port build, a capability by tmux release matrix from the port's floor, plus workarounds - In the root build, a capability by port map naming the release each port needs - List each capability's API, refusal type and tmux commands as links to the reference - Show only ports that publish a manifest, never a stand-in row --- site/src/components/api/ApiCapabilities.astro | 136 +++++++++++++ site/src/lib/capabilities.ts | 191 ++++++++++++++++++ site/src/pages/reference/capabilities.astro | 5 + 3 files changed, 332 insertions(+) create mode 100644 site/src/components/api/ApiCapabilities.astro create mode 100644 site/src/lib/capabilities.ts create mode 100644 site/src/pages/reference/capabilities.astro diff --git a/site/src/components/api/ApiCapabilities.astro b/site/src/components/api/ApiCapabilities.astro new file mode 100644 index 00000000..52170ba5 --- /dev/null +++ b/site/src/components/api/ApiCapabilities.astro @@ -0,0 +1,136 @@ +--- +/** + * tmux capabilities, generated from each port's own registry. + * + * In a port build: that port's capabilities against every tmux release from + * its floor, with the API each one gates. In the root build: one map of every + * capability against every port that publishes a manifest. No row or cell is + * written here; see site/src/lib/capabilities.ts. + */ +import CapabilityMatrix from '../capabilities/CapabilityMatrix.astro' +import DocsLayout from '../../layouts/DocsLayout.astro' +import { DEFAULT_LOCALE, localeRoot } from '../../i18n/locales' +import { CONTRIBUTE_BRANCH, CONTRIBUTE_REPO } from '../../i18n/contribute' +import { PORT_BY_SLUG, portPageUrl, referenceUrl } from '../../lib/ports' +import { buildTarget } from '../../lib/versions' +import { tmuxCommandSources } from '../../lib/tmux-reference' +import { + capabilityFloor, + capabilityPorts, + capabilityRevision, + mapRows, + portColumns, + portRows, + releaseGroups, + type Row, +} from '../../lib/capabilities' + +interface Props { port?: string; version?: string } +const { port, version = buildTarget(process.env).version } = Astro.props as Props +const name = port ? PORT_BY_SLUG[port].name : 'libtmux' +const library = port ? PORT_BY_SLUG[port].projectName : 'libtmux' +const referenceBase = port ? referenceUrl(PORT_BY_SLUG[port], version, 'core') : `${localeRoot(DEFAULT_LOCALE)}reference/` +const pagePath = 'reference/capabilities' +const pageUrl = `${referenceBase}capabilities/` +const title = port ? `${library} tmux capabilities` : 'tmux capabilities' +const description = port + ? `What ${library} can do on each tmux release, generated from its capability registry.` + : 'What each libtmux port can do on each tmux release, generated from each port\'s capability registry.' + +const floor = port ? capabilityFloor(port) : undefined +const revision = port ? capabilityRevision(port) : undefined +const { capabilities, workarounds } = port ? portRows(port) : { capabilities: mapRows(), workarounds: [] as Row[] } +const published = capabilityPorts() +const commandHref = (command: string) => tmuxCommandSources('latest', command)[0]?.href + +const sections = [...capabilities, ...workarounds] +const headings = [ + ...(capabilities.length ? [{ depth: 2, slug: 'capabilities', text: 'Capabilities' }] : []), + ...(workarounds.length ? [{ depth: 2, slug: 'workarounds', text: 'Release workarounds' }] : []), + ...sections.map((r) => ({ depth: 3, slug: r.id, text: r.label })), +] +--- + + + {port && floor ? ( +

+ {library} (libtmux for {name}) requires tmux {floor} or newer. Each capability below needs a newer + tmux release, or avoids one release. Before it sends a command, {library} checks the version of + the running tmux server. The tables and every link on this page are generated from the library's + capability registry{revision && <> at {revision.slice(0, 12)}}. +

+ ) : !port && published.length > 0 ? ( +

+ Every libtmux port supports tmux from its floor up. Each capability below needs a newer release on + some ports; a cell gives the release a port needs. Rows and cells are generated from each port's + capability registry, so a port appears here once it publishes one. +

+ ) : null} + + {published.length === 0 ? ( +

No port publishes a capability registry on this build yet.

+ ) : port && !floor ? ( +

{library} does not publish a capability registry on this build yet.

+ ) : ( + <> +

Capabilities

+ + {workarounds.length > 0 && ( + <> +

Release workarounds

+

A tmux release that gets one command wrong. {library} repairs the result, or refuses the call, on that release only.

+ + + )} + {sections.map((row) => ( +
+

{row.label}

+

{row.summary}

+
+
tmux
+
{row.requirement}
+ {row.error && (<>
Refusal
{row.error.href ? {row.error.label} : {row.error.label}}
)} + {row.apis.length > 0 && ( + <> +
API
+
{row.apis.map((api) => api.href ? {api.label} : {api.label})}
+ + )} + {row.commands.length > 0 && ( + <> +
Commands
+
{row.commands.map((c) => { + const href = commandHref(c.command) + const text = [c.command, ...(c.flags ?? [])].join(' ') + return href ? {text} : {text} + })}
+ + )} +
+
+ ))} + + )} +
diff --git a/site/src/lib/capabilities.ts b/site/src/lib/capabilities.ts new file mode 100644 index 00000000..f5e398bb --- /dev/null +++ b/site/src/lib/capabilities.ts @@ -0,0 +1,191 @@ +/** + * Rows for the capability pages, computed from site/src/data/capabilities.json. + * + * That file is generated by scripts/gen-capabilities.mjs from each port's own + * capability manifest, which the port emits from its typed registry. Nothing + * here stores a cell: every state comes from a capability's `since`, + * `except` and `handling` against the release columns, and every API link + * comes from the port's API model. + */ +import { + capabilityState, + compareTmuxVersions, + workaroundState, + type CapabilityCommand, + type CellState, + type Handling, +} from '@libtmux/api-model' +import data from '../data/capabilities.json' +import { PORT_NAME, referenceHref } from './api-models' +import { PORTS } from './ports' +import type { ApiLink, Column, Row } from './capability-cells' + +export type { ApiLink, Cell, Column, Row } from './capability-cells' +export { CELL_LABEL } from './capability-cells' + +interface PortEntry { + id: string + name: string + summary: string + since?: string + except?: string[] + releases?: string[] + handling: Handling + error?: string + apis: string[] + commands: CapabilityCommand[] +} + +interface PortCapabilities { + revision?: string + floor: { minimum: string } + capabilities: PortEntry[] + workarounds: PortEntry[] +} + +const DATA = data as { releases: string[]; ports: Record } + +/** Ports that publish a manifest, in the site's port order. */ +export function capabilityPorts(): string[] { + return PORTS.map((p) => p.slug).filter((slug) => slug in DATA.ports) +} + +export function capabilityFloor(port: string): string | undefined { + return DATA.ports[port]?.floor.minimum +} + +export function capabilityRevision(port: string): string | undefined { + return DATA.ports[port]?.revision +} + +function releasesFrom(floor?: string): string[] { + return DATA.releases.filter((v) => !floor || compareTmuxVersions(v, floor) >= 0) +} + +const releaseLabel = (v: string) => v.replace(/^next-/, 'next ') +const releaseTitle = (v: string) => (v.startsWith('next-') ? `tmux ${v}, the development build` : `tmux ${v}`) + +/** + * Release columns with neighbours merged where no cell changes. + * + * Fourteen releases do not fit beside a capability name, and most of them + * behave alike for any one port. A column per run of identical releases + * ("3.5 – 3.6b") keeps every column the same width and every change visible. + */ +export function releaseGroups(port: string): Column[] { + const entry = DATA.ports[port] + if (!entry) return [] + const signature = (release: string) => [ + ...entry.capabilities.map((e) => capabilityState(e as never, release)), + ...entry.workarounds.map((e) => workaroundState(e as never, release)), + ].join() + const groups: string[][] = [] + for (const release of releasesFrom(entry.floor.minimum)) { + const last = groups.at(-1) + if (last && signature(last[0]) === signature(release)) last.push(release) + else groups.push([release]) + } + return groups.map((g) => ({ + key: g[0], + label: g.length === 1 ? releaseLabel(g[0]) : `${releaseLabel(g[0])} – ${releaseLabel(g.at(-1)!)}`, + title: g.length === 1 ? releaseTitle(g[0]) : `tmux ${g.join(', ')}`, + })) +} + +function link(port: string, publicId: string): ApiLink { + return { label: publicId, href: referenceHref(port, publicId) } +} + +function requirement(entry: PortEntry): string { + if (entry.releases) return `Applies to ${entry.releases.join(', ')}` + const except = entry.except?.length ? `, not ${entry.except.join(', ')}` : '' + return `${entry.since} and later${except}` +} + +/** One port's capabilities and workarounds against its release columns. */ +export function portRows(port: string): { capabilities: Row[]; workarounds: Row[] } { + const entry = DATA.ports[port] + if (!entry) return { capabilities: [], workarounds: [] } + const columns = releaseGroups(port) + const row = (e: PortEntry, state: (release: string) => CellState): Row => ({ + id: e.id, + label: e.name, + summary: e.summary, + cells: columns.map((c) => ({ state: state(c.key) })), + apis: e.apis.map((id) => link(port, id)), + error: e.error ? link(port, e.error) : undefined, + commands: e.commands, + requirement: requirement(e), + }) + return { + capabilities: entry.capabilities.map((e) => row(e, (r) => capabilityState(e as never, r))), + workarounds: entry.workarounds.map((e) => row(e, (r) => workaroundState(e as never, r))), + } +} + +export function portColumns(): Column[] { + return capabilityPorts().map((slug) => ({ key: slug, label: PORT_NAME[slug] ?? slug, title: PORT_NAME[slug] ?? slug })) +} + +/** + * Capabilities × ports. A cell names the release the port needs, so the map + * answers "can I use this on my tmux" without opening each port's page. + */ +export function mapRows(): Row[] { + const ports = capabilityPorts() + const byId = new Map }>() + for (const port of ports) { + for (const e of DATA.ports[port].capabilities) { + const slot = byId.get(e.id) ?? { entry: e, ports: new Map() } + slot.ports.set(port, e) + byId.set(e.id, slot) + } + } + return [...byId.values()].map(({ entry, ports: present }) => ({ + id: entry.id, + label: entry.name, + summary: entry.summary, + cells: ports.map((port) => { + const e = present.get(port) + if (!e) return { state: 'absent' as const } + const except = e.except?.length ? ` (not ${e.except.join(', ')})` : '' + return { state: 'supported' as const, text: `${e.since}+${except}` } + }), + apis: [], + commands: entry.commands, + requirement: requirement(entry), + })) +} + +/** + * Entries × ports for one tmux command, for its reference page: the MDN + * compatibility-table shape, where a cell gives the release a port needs. + */ +export function commandRows(command: string): Row[] { + const ports = capabilityPorts() + const byId = new Map }>() + for (const port of ports) { + const { capabilities, workarounds } = DATA.ports[port] + for (const e of [...capabilities, ...workarounds]) { + if (!e.commands.some((c) => c.command === command)) continue + const slot = byId.get(e.id) ?? { entry: e, ports: new Map() } + slot.ports.set(port, e) + byId.set(e.id, slot) + } + } + return [...byId.values()].map(({ entry, ports: present }) => ({ + id: entry.id, + label: entry.name, + summary: entry.summary, + cells: ports.map((port) => { + const e = present.get(port) + if (!e) return { state: 'absent' as const } + if (e.releases) return { state: 'workaround' as const, text: `not ${e.releases.join(', ')}` } + const except = e.except?.length ? ` (not ${e.except.join(', ')})` : '' + return { state: 'supported' as const, text: `${e.since}+${except}` } + }), + apis: [], + commands: entry.commands, + requirement: requirement(entry), + })) +} diff --git a/site/src/pages/reference/capabilities.astro b/site/src/pages/reference/capabilities.astro new file mode 100644 index 00000000..3e006b13 --- /dev/null +++ b/site/src/pages/reference/capabilities.astro @@ -0,0 +1,5 @@ +--- +import ApiCapabilities from '../../components/api/ApiCapabilities.astro' +const port = process.env.LIBTMUX_DOCS_PORT +--- + From 8e7d742bb66d0e933c84c477c7b371064114c7f3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 17 Oct 2026 15:32:40 -0500 Subject: [PATCH 7/8] feat(site) Show libtmux support on tmux command pages why: A reader on a tmux command's reference page should see which libtmux ports gate that command and on which releases, the way MDN shows browser support on an API page. what: - Render a ports matrix of every capability and workaround that names the command, linking each row to the capability map - Add it to the page contents when a port gates the command - Bind the capability helper in the glossary navigation test --- site/src/components/api/ApiReference.astro | 7 ++++++ .../capabilities/CommandSupport.astro | 24 +++++++++++++++++++ site/test/api-glossary-navigation.test.ts | 3 ++- 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 site/src/components/capabilities/CommandSupport.astro diff --git a/site/src/components/api/ApiReference.astro b/site/src/components/api/ApiReference.astro index c2287e0f..50d5586b 100644 --- a/site/src/components/api/ApiReference.astro +++ b/site/src/components/api/ApiReference.astro @@ -21,6 +21,9 @@ import { pagePortsFor } from '../../lib/page-ports-for' import { pageBreadcrumbs } from '../../lib/sidebar' import BaseLayout from '../../layouts/BaseLayout.astro' import { withRoot } from '../../lib/site-root' +import CommandSupport from '../capabilities/CommandSupport.astro' +import { commandRows } from '../../lib/capabilities' +import { tmuxSourceCommands } from '../../lib/tmux-reference' import { API_MODELS, API_NAV, PORT_NAME, indexFor, isMemberOwner, ownersOf, pageSlug, referenceAlternatives, topLevelTypesOf } from '../../lib/api-models' import { PORT_BY_SLUG, referenceUrl } from '../../lib/ports' import { productApiHref } from '../../lib/product-api' @@ -357,6 +360,9 @@ const pageSections: ApiSection[] = glossary ? apiGlossaryHeadings(glossaryContex ...(otherInherited.length ? [{ id: 'api-inherited', label: 'Inherited members', count: otherInherited.length }] : []), ] : [] const relationshipSections = owner && model ? apiRelationshipSections(model, owner) : [] +const tmuxCommandNames = source && owner ? tmuxSourceCommands(source.version, owner).map((command) => command.name) : [] +const supportedCommands = tmuxCommandNames.filter((name) => commandRows(name).length > 0) +if (supportedCommands.length) pageSections.push({ id: 'api-libtmux-support', label: 'Support in libtmux', count: supportedCommands.reduce((n, name) => n + commandRows(name).length, 0) }) const paths = (source?.paths ?? []).filter((path) => !owner || path.symbols.some((symbol) => symbol.id === owner.id)) if (relationshipSections.some((section) => ['api-calls', 'api-called-by'].includes(section.id))) pageSections.push({ id: 'api-call-network', label: 'Call neighborhood' }) if (paths.length) pageSections.push({ id: 'api-source-paths', label: 'Paths through the source', count: paths.length }) @@ -827,6 +833,7 @@ const bases = owner && 'extends' in owner ? owner.extends ?? [] : [] )} + {supportedCommands.map((name) => )} {related.length > 0 &&