{"about":"tmuxy's docs, one entry per section. Every doc in one file: https://tmuxy.sh/llms-full.txt","sections":[{"doc":"docs/README.md","heading":"Tmuxy Documentation","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/README.md#tmuxy-documentation","text":"This directory contains architectural and design documentation for the tmuxy project. It is written for both **human developers** and **AI coding agents** (Claude Code, Copilot, etc.) that work on this codebase."},{"doc":"docs/README.md","heading":"How to Use These Docs","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/README.md#how-to-use-these-docs","text":"**Before starting work:** Read the docs relevant to the area you're changing. They provide critical context about architecture, constraints, and conventions that aren't obvious from reading code alone.\n\n**After finishing work:** If your changes affect behavior described here, update the relevant docs or flag the misalignment.\n\n**For AI agents:** The project's `AGENTS.md` references these docs and includes rules about reviewing them. Key constraints (e.g., all tmux commands must go through control mode) are documented here because they prevent crashes and data loss.\n\n**On the website:** every doc here is also served by [tmuxy.sh](https://tmuxy.sh) for agents that cannot clone the repository. `/llms-full.txt` is all of them in one file, each under a header naming its canonical GitHub URL; `/search.json` is one entry per section (doc, heading, level, canonical URL with its anchor, and the section's text), for an agent to fetch once and filter itself — the site is static, so there is no query endpoint. Both are built from this directory at the site's build (`packages/tmuxy-demo/scripts/agent-docs.mjs`), and a link between docs whose anchor names no heading fails the unit tests."},{"doc":"docs/README.md","heading":"Document Guide","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/README.md#document-guide","text":"Every file in this directory. A doc that outgrew the size cap (below) continues in companions named after it, listed under it."},{"doc":"docs/README.md","heading":"Core Architecture","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/README.md#core-architecture","text":"| Document                                                   | What it covers                                                                                                                                                                                              | When to read it                                                                               |\n| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |\n| [ARCHITECTURE.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md)                         | High-level system overview: components, how they interact, key design decisions, file structure                                                                                                             | Starting any work on the project; onboarding                                                  |\n| [ARCHITECTURE-WIDGETS.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-WIDGETS.md)         | Panes that draw something other than a terminal: the widget registry, the tree and the preview                                                                                                              | Adding or changing a widget                                                                   |\n| [ARCHITECTURE-INDEX.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-INDEX.md)             | Compact feature-to-files lookup index optimized for retrieval by AI agents                                                                                                                                  | Quickly locating implementation entry points                                                  |\n| [STATE-MANAGEMENT.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md)                 | Frontend XState machine (states, context, actors, child machines, selectors, React hooks) and backend Rust state (AppState, SessionConnections, TmuxMonitor, StateAggregator, MonitorCommand, StateEmitter) | Changing state handling, adding events, modifying the machine, or working on the Rust backend |\n| [STATE-MANAGEMENT-ACTORS.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-ACTORS.md)   | The app machine's context, its actors and child machines, and how the code is split                                                                                                                         | Adding an actor or a context field                                                            |\n| [STATE-MANAGEMENT-UPDATES.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-UPDATES.md) | Optimistic updates (`TmuxClientModel`), read-only focus, keystroke routing, user-facing errors, React integration                                                                                           | Changing a prediction, a reconcile verdict or what the user sees of a failure                 |\n| [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md)                               | SSE/HTTP protocol, adapter pattern, delta protocol, connection lifecycle, keyboard input flow                                                                                                               | Working on client-server communication or the adapter layer                                   |\n| [DATA-FLOW-DEPLOYMENTS.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW-DEPLOYMENTS.md)       | The Tauri transport, real-world deployment scenarios and the extra HTTP endpoints                                                                                                                           | Deploying, or touching the desktop IPC                                                        |\n| [DESKTOP.md](https://github.com/flplima/tmuxy/blob/main/docs/DESKTOP.md)                                   | What only the desktop app has (trackpad gestures, GUI windows, window chrome, native menus, other servers) and why                                                                                          | Working on the Tauri app, or wondering why the web build lacks something                      |"},{"doc":"docs/README.md","heading":"tmux Integration","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/README.md#tmux-integration","text":"| Document                                     | What it covers                                                                                                                                                | When to read it                                                            |\n| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |\n| [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md)                           | Control mode architecture, command routing rules (which commands must use control mode vs. safe as subprocesses), `new-window` crash workaround, flow control | Any work involving tmux commands, pane/window operations, or shell scripts |\n| [TMUX-TAGS.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-TAGS.md)                 | The `@tmuxy-*` window/pane tag schema and the pane-group model built on it (floats, groups, the stash session, window filtering)                              | Floats, pane groups, anything that reads or writes a tag                   |\n| [TMUX-SIDEBARS.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-SIDEBARS.md)         | The two sidebar columns and `tmuxy pane send -t`, both built on the tags                                                                                      | The tree, the dock, cross-pane sends                                       |\n| [TMUX-CONFIG.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md)             | The dedicated socket, the config chain, session groups, servers and client-side key tables                                                                    | The socket, `~/.config/tmuxy`, PANE/TAB modes, remote servers              |\n| [TMUX-VERSION-BUGS.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md) | Version-specific tmux behaviour tmuxy depends on or works around, 3.2 to HEAD                                                                                 | A bug that shows on one tmux version                                       |\n| [COPY-MODE.md](https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md)                 | Client-side copy mode reimplementation: vi keybindings, scrollback loading, selection/clipboard, entry/exit triggers, key files                               | Working on copy mode, scrollback, or keyboard handling during copy mode    |"},{"doc":"docs/README.md","heading":"Security & Constraints","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/README.md#security--constraints","text":"| Document                                   | What it covers                                                                                                                          | When to read it                                                               |\n| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |\n| [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md)                 | Threat model, optional password auth, loopback by default, known risks (no TLS, file access, run-shell), deployment recommendations     | Deploying tmuxy, adding network-facing features, or assessing risk            |\n| [SECURITY-NETWORK.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md) | Where the server listens, what it serves, and how to deploy it                                                                          | Exposing a server beyond the machine it runs on                               |\n| [SECURITY-CONTENT.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md) | Previewed files, session snapshots and hostile pane output                                                                              | The preview widget, snapshots, anything that renders what a program wrote     |\n| [TELEMETRY.md](https://github.com/flplima/tmuxy/blob/main/docs/TELEMETRY.md)               | Every network connection the server, the desktop app, the CLI and the website make: when, to where, what is sent, how to turn it off    | Adding anything that talks to the network; answering \"does tmuxy phone home?\" |\n| [NON-GOALS.md](https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md)               | What tmuxy intentionally does NOT do (no terminal emulation, no live local scrollback buffer, no local echo, no canvas rendering, etc.) | Before proposing a new feature; understanding design boundaries               |"},{"doc":"docs/README.md","heading":"Testing","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/README.md#testing","text":"| Document                                                 | What it covers                                                                                                                            | When to read it                                                    |\n| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |\n| [TESTS.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md)                                     | The guidelines for every test type, known coverage gaps, and where a new test belongs                                                     | Writing, placing or debugging tests                                |\n| [TESTS-LAYERS.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md)                       | Every test layer, the CI job that runs it, the local gates, and the tiers (per commit, nightly, before a release)                         | Reading a CI failure; deciding which layer a test goes in          |\n| [TESTS-E2E.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md)                             | The browser + tmux suites, what their helpers guarantee, the latency gate and the Tauri suite                                             | Writing or fixing an E2E test                                      |\n| [TESTS-STORYBOOK.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md)                 | The deterministic and v86 Storybook probes, quarantine, accessibility                                                                     | Writing or fixing a story                                          |\n| [INVARIANTS.md](https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md)                           | The ledger of cross-layer promises — effect ordering, stream sequencing, predictions, read-only gating — each with the test that holds it | Fixing a bug on a seam; adding a test for ordering or a race       |\n| [INVARIANTS-UI-LIFECYCLE.md](https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS-UI-LIFECYCLE.md) | The ledger's `U` and `L` rows: UI-mode records, session lifecycle                                                                         | Fixing a bug in copy mode, resize preview, or restore              |\n| [PERFORMANCE.md](https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md)                         | Speed measurement along two independent axes: core + client processing (Axis A) vs transport (Axis B), and the harness for each           | Benchmarking, profiling, or investigating latency                  |\n| [PERFORMANCE-BASELINE.md](https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE-BASELINE.md)       | The numbers measured so far, long-session retention, and snapshot costs                                                                   | Judging whether a measurement regressed                            |\n| [TRACING.md](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md)                                 | Unified cross-layer action tracing (XState/Effect/Rust/Tauri) into one local NDJSON file: reading it, the redaction boundary              | Debugging complex cross-layer issues; reporting a bug with a trace |\n| [TRACING-DESIGN.md](https://github.com/flplima/tmuxy/blob/main/docs/TRACING-DESIGN.md)                   | The trace file's schema, its instrumentation seams and correlation                                                                        | Adding instrumentation                                             |\n| [CI-TRIAGE.md](https://github.com/flplima/tmuxy/blob/main/docs/CI-TRIAGE.md)                             | Fast triage workflow for CI failures, with job-to-command mappings and artifact entry points                                              | Investigating red GitHub Actions runs                              |"},{"doc":"docs/README.md","heading":"Protocols & Rendering","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/README.md#protocols--rendering","text":"| Document                                                   | What it covers                                                                                                                       | When to read it                                                                  |\n| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |\n| [RICH-RENDERING.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md)                     | Terminal image protocols (iTerm2, Kitty, Sixel), OSC sequences (hyperlinks, clipboard, notifications), current implementation status | Working on terminal rendering, OSC parsing, or considering rich content features |\n| [RICH-RENDERING-CELL-GRID.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-CELL-GRID.md) | Why every glyph must land on tmux's cell grid, and how widths are kept in agreement                                                  | Fonts, wide characters, anything that measures a cell                            |\n| [RICH-RENDERING-BROWSER.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-BROWSER.md)     | `tmuxy widget browser`: a headless Chrome drawn into a pane as Kitty frames and driven from it over DevTools                         | A live page in a pane, driving a page from a script                              |"},{"doc":"docs/README.md","heading":"Conventions","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/README.md#conventions","text":"- **No project-specific code** in docs. Describe architecture in prose and tables. Reference file paths instead of embedding code snippets (they go stale).\n- **Use ASCII diagrams**, not Mermaid. Plain ASCII art in fenced code blocks works everywhere.\n- **Uppercase filenames** (e.g., `ARCHITECTURE.md`) to match README.md convention and distinguish docs from code.\n- **Cross-reference** related docs with a \"Related\" section at the bottom of each file.\n- **Under 5,000 tokens per file** (bytes / 4), enforced repo-wide by `bin/check-repo-policy.mjs`. A topic that outgrows it moves whole sections into a companion named after its parent (`TMUX-TAGS.md`, `TESTS-E2E.md`, `STATE-MANAGEMENT-ACTORS.md`), the parent keeps a one-line pointer where the section was, and the guide above lists the companion under its parent. Tables stay unpadded (`| a | b |`); column padding once made up half the bytes of a doc."},{"doc":"docs/ARCHITECTURE-INDEX.md","heading":"Architecture Index","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-INDEX.md#architecture-index","text":"Feature-to-files retrieval map for agents.\n\n| Feature | Key files |\n| --- | --- |\n| tmux control-mode command routing | `packages/tmuxy-core/src/command_router.rs`<br>`packages/tmuxy-ui/src/infra/transport/drivers/TauriAdapter.ts` |\n| What tmux leaves unannounced (the re-lists behind a command), and the map of version workarounds | `packages/tmuxy-core/src/tmux_compat.rs` |\n| The monitor loop (connect, back off, give up, restore before create, autosave) and what each host does around it | `packages/tmuxy-core/src/transport/supervise.rs`<br>`packages/tmuxy-server/src/sse/monitor.rs`<br>`packages/tmuxy-tauri-app/src/monitor.rs` |\n| The tmux config tmuxy ships (the defaults, the essentials, the user template) and the code that writes it | `packages/tmuxy-core/assets/*.conf`<br>`packages/tmuxy-core/src/session/config.rs` |\n| Pane groups: membership, order and every group operation | `packages/tmuxy-core/src/groups.rs` (the rules)<br>`packages/tmuxy-group/src/lib.rs` (`tmuxy-server group`, `tmuxy-group`)<br>`packages/tmuxy-cli/bin/tmuxy/pane-group` (wrapper) |\n| Typed ids (pane, window, group) | `packages/tmuxy-core/src/ids.rs`<br>`packages/tmuxy-ui/src/domain/ids.ts` |\n| Wire shapes and the client model derived from them | `packages/tmuxy-ui/src/domain/wire.ts`<br>`packages/tmuxy-ui/src/domain/client.ts` |\n| The command vocabulary (every intent the UI sends) | `packages/tmuxy-ui/src/domain/commands.ts` (`TmuxOp`, `toTmuxCommand`)<br>`packages/tmuxy-ui/src/machines/app/dispatch.ts` |\n| Client command errors | `packages/tmuxy-core/src/command_error.rs`<br>`packages/tmuxy-ui/src/infra/transport/AdapterError.ts` |\n| The transport service, its Layers and the state stream | `packages/tmuxy-ui/src/infra/transport/` (`TmuxTransport.ts`, `layers.ts`, `stateFeed.ts`, `drivers/`)<br>`packages/tmuxy-ui/src/infra/runtime.ts` |\n| Backend state aggregation and emit | `packages/tmuxy-core/src/control_mode/state.rs`<br>`packages/tmuxy-server/src/state.rs` |\n| SSE/HTTP server flow | `packages/tmuxy-server/src` (see docs references) |\n| Frontend state machine | `packages/tmuxy-ui/src` (XState machine and hooks) |\n| CLI dispatcher and event queue | `packages/tmuxy-cli/bin/tmuxy-cli` |\n| Tauri bridge and desktop shell | `packages/tmuxy-tauri-app` |\n| Menus: one spec per surface, rendered in-app or natively | `packages/tmuxy-ui/src/domain/menus/` (the specs; `icons.ts`, the item icons both renderers draw)<br>`packages/tmuxy-ui/src/components/menus/SpecMenu.tsx` (the in-app renderer and `useMenuDispatch`)<br>`packages/tmuxy-ui/src/infra/menus/nativeMenu.ts`, `components/menus/NativeMenuBar.tsx`, `useNativeMenu.ts` (the desktop's)<br>`packages/tmuxy-ui/src/components/menus/menuActions.ts` (`executeMenuAction`, `MENU_OPS`) |\n| GUI windows on one session, and the Window menu | `packages/tmuxy-tauri-app/src/windows.rs`<br>`packages/tmuxy-ui/src/domain/menus/windowMenu.ts`<br>`packages/tmuxy-ui/src/utils/guiWindows.ts` |\n| Window styles (iTerm2's set) | `packages/tmuxy-tauri-app/src/window_style.rs` |\n| Trackpad gestures (desktop only): native phases → the grid | `packages/tmuxy-tauri-app/src/gestures.rs` (the shell's half)<br>`packages/tmuxy-ui/src/machines/actors/gestureActor.ts` (`window.tmuxyGesture`)<br>`packages/tmuxy-ui/src/machines/app/actions/gestures.ts`, `utils/gestures.ts`, `components/GestureStage.tsx` |\n| Menus, the tab preview, and the one-surface-at-a-time rule | `packages/tmuxy-ui/src/components/floating/Menu.tsx`<br>`packages/tmuxy-ui/src/components/floating/useFloatingSurface.ts`<br>`packages/tmuxy-ui/src/components/floating/surfaceRegistry.ts`<br>`packages/tmuxy-ui/src/components/TabPreview.tsx` |\n| Session snapshots: save on change, restore on start, forget | `packages/tmuxy-core/src/session_snapshot.rs` (model, queries, `ps` discovery, storage, restore plan, keeper)<br>`packages/tmuxy-server/src/session_cli.rs` (`tmuxy session …`)<br>`packages/tmuxy-ui/src/domain/menus/sessionSwitcher.ts` (restorable rows) |\n| The `preview` widget: `tmuxy open`, what it may read, the sandboxed page | `packages/tmuxy-cli/bin/tmuxy/tmuxy-widget-preview` (the CLI, the pane options, the file watch)<br>`packages/tmuxy-core/src/preview.rs` (authorisation, resource rules, the page policy and store)<br>`packages/tmuxy-server/src/sse/preview.rs`, `packages/tmuxy-tauri-app/src/commands.rs` (`read_preview`, `stage_preview`, `tmuxypreview:`)<br>`packages/tmuxy-ui/src/components/widgets/preview/` (markdown, page composition, bridge)<br>`packages/tmuxy-ui/src/domain/preview.ts` |\n| Copy mode behavior | `docs/COPY-MODE.md` + related UI files |\n| Rich rendering / OSC / image protocols | `docs/RICH-RENDERING.md` |\n| Performance harnesses | `perf`<br>`packages/tmuxy-ui/scripts` |\n| E2E and helper layers | `tests`<br>`tests/helpers` |\n| Cross-layer invariants and the test holding each | `docs/INVARIANTS.md` |\n| Every network connection (listeners, outbound requests, the website's analytics) | `docs/TELEMETRY.md` |"},{"doc":"docs/ARCHITECTURE-INDEX.md","heading":"Related docs","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-INDEX.md#related-docs","text":"- `docs/ARCHITECTURE.md`\n- `docs/STATE-MANAGEMENT.md`\n- `docs/DATA-FLOW.md`\n- `docs/DESKTOP.md`\n- `docs/TMUX.md`\n- `docs/CI-TRIAGE.md`"},{"doc":"docs/ARCHITECTURE-WIDGETS.md","heading":"Widgets","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-WIDGETS.md#widgets","text":"Panes that draw something other than a terminal. Part of [ARCHITECTURE.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md)."},{"doc":"docs/ARCHITECTURE-WIDGETS.md","heading":"Widgets","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-WIDGETS.md#widgets-1","text":"A **widget** is a React component rendered _in place of_ a pane's terminal. The pane is a real\ntmux pane running a real process — it can be focused, navigated into, resized and closed like any\nother — but what it draws is a component, not cells.\n\nA pane declares itself a widget by printing a marker line: `__TMUXY_WIDGET__:<name>`, which\n`packages/tmuxy-cli/bin/tmuxy/tmuxy-widget` emits before passing stdin through. The frontend scans pane content for\nit (`components/widgets/registry.ts`), and everything the pane prints after the marker is the\nwidget's content — the channel a widget's script uses to hand it a payload. The marker alone is\nnot enough: it is pane output, and anything a pane prints could forge it, so `tmuxy-widget` also\ntags the pane with `@tmuxy-pane-widget` out of band before printing, and the frontend renders a\nwidget only when that tag names the same one (see [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md) and the option\nschema in [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md)). The process must stay\nalive; closing the pipe fires the wrapper's EXIT trap, which clears the marker and hands the pane\nback to a shell. That is what ctrl+c in a widget pane does.\n\n| Widget    | Started by                                                                                                                                     | Content it reads                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |\n| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `tree`    | `tmuxy widget tree`                                                                                                                            | none — the tabs/panes tree is derived from state the app already holds                                                                                                                                                                                                                                                                                                                                                                                                                         |\n| `preview` | `tmuxy open [--net] [--no-theme] [file]` (alias: `tmuxy widget preview`; without a file, `index.html` or `README.md` in the current directory) | `__FILE__:<name>` and `__REV__:<fingerprint>` — the file's name and a line rewritten in place whenever the file changes (inotify where there is one, a poll every second otherwise). The file itself never travels in pane output: the widget reads it with `read_preview`, which tmux authorises (below). A markdown document is drawn by the app, mermaid fences as diagrams, in the theme's colours; an HTML page is drawn in a sandboxed frame, in the theme's colours unless `--no-theme` |\n\n`tmuxy widget browser <url>` (alias: `tmuxy browser`) is listed with the widgets but is not one in\nthis sense: it prints no marker and the frontend renders nothing for it specially. It is a program\nin the pane — a headless Chrome's screencast written as Kitty graphics, the pane's input forwarded\nto the page over DevTools — and so goes through the image pipeline every pane has. It is described\nin [RICH-RENDERING-BROWSER.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-BROWSER.md)."},{"doc":"docs/ARCHITECTURE-WIDGETS.md","heading":"A preview: what it reads, and the page it frames","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-WIDGETS.md#a-preview-what-it-reads-and-the-page-it-frames","text":"A preview's path is a pane option, `@tmuxy-pane-preview`, written by `tmuxy-widget-preview` out of band like the widget tag, and the widget asks for the document by naming its pane, never a path (`read_preview`, both transports, `tmuxy_core::preview`). What it links — stylesheets, scripts, images, fonts — it may read only from the document's own folder. Why, and what else holds, is [A Preview Reads One File](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#a-preview-reads-one-file).\n\nA new rev is the cue to read the document again; the old one stays on screen meanwhile, and on a failed read, so a save never blanks the pane. A markdown document is replaced inside the scroller it was drawn in, so the reader stays where they were. A page is composed by the client (`widgets/preview/previewPage.ts`) — its local resources inlined as code and `data:` URLs, the theme and a small bridge script put first — staged with the backend, and loaded by the frame from a URL that carries the page's own policy as a header. A new composition is a new URL, so the frame reloads, and it reopens at the scroll position the bridge last reported.\n\nThe frame is the same hole in the event surface the old browser widget was, and it is handled the same way: the pane is activated from the focus moving into the frame (`hooks/useFramedPaneFocus.ts`), and the pane's content takes no pointer while the app is dragging a divider or a pane. The keyboard is the page's while the user is in it, so the bridge sends a Ctrl or Alt chord pressed there back to the app, replayed on the pane: Ctrl+C reaches `WidgetPane` and ends the widget, the prefix reaches the keyboard actor. In the markdown view the pager keys scroll (`j`/`k`, space and `b`, `ctrl-d`/`ctrl-u`, `g`/`G`), and any other plain key stops at the widget rather than being typed into a pane where nothing reads it."},{"doc":"docs/ARCHITECTURE-WIDGETS.md","heading":"A pane group's header","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-WIDGETS.md#a-pane-groups-header","text":"A group shares one header between its members: each gets an equal share of the width, its title at the left of that share and its own ⋮ and ✕ at the right, with the member not in view dimmed by a darker ground. Per-member controls are what let a parked member be closed without showing it first. An ungrouped pane keeps the plain shape: one title across the header and its buttons at the header's own right edge."},{"doc":"docs/ARCHITECTURE-WIDGETS.md","heading":"A parked widget's name","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-WIDGETS.md#a-parked-widgets-name","text":"A pane PARKED in a pane group streams no content, so there is no widget to ask for a title — all a group's tab strip has for it is tmux's `pane_title`. `packages/tmuxy-cli/bin/tmuxy/tmuxy-widget` therefore announces the widget's name over OSC 2 when it starts, so a parked member reads as `tree` rather than as the CLI's own path, and hands the title back on exit."},{"doc":"docs/ARCHITECTURE-WIDGETS.md","heading":"A widget's definition","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-WIDGETS.md#a-widgets-definition","text":"A widget registers a **definition**, not just a component, so it can furnish the parts of the pane\nchrome it does not own: its tab `icon`, a `selectTitle` for the tab, and a `selectMenuItems`\nsection that leads the pane's ⋮ menu. Each is a pure function of machine context plus the pane's\nwidget content, so a menu that is not the widget's own child can still ask the widget what it can\ncurrently do. Adding a widget means writing a definition and registering it in\n`components/widgets/init.ts`; no shared component needs to learn its name.\n\nWidget state that outlives a render belongs in the app machine rather than in the component, so\nthe menu and the pane read the same source of truth. See [STATE-MANAGEMENT.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md)."},{"doc":"docs/ARCHITECTURE.md","heading":"Tmuxy Architecture","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md#tmuxy-architecture","text":"Tmuxy is a web-based tmux interface. It provides a browser UI (or native desktop app) for managing tmux sessions with real-time state synchronization."},{"doc":"docs/ARCHITECTURE.md","heading":"Components","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md#components","text":"```\n┌─────────────────────────────────────────────────────────┐\n│  Clients                                                │\n│  ┌───────────────────────┐  ┌────────────────────────┐  │\n│  │ Browser (React+XState)│  │ Tauri Desktop App      │  │\n│  └───────────┬───────────┘  └────────────┬───────────┘  │\n└──────────────│───────────────────────────│──────────────┘\n               │ SSE + HTTP POST           │ Tauri IPC\n┌──────────────▼───────────────────────────▼──────────────┐\n│  Backend                                                │\n│  ┌───────────────────────┐  ┌────────────────────────┐  │\n│  │ Web Server (Axum)     │  │ Tauri Shell            │  │\n│  └───────────┬───────────┘  └────────────┬───────────┘  │\n└──────────────│───────────────────────────│──────────────┘\n               └─────────────┬─────────────┘\n┌────────────────────────────▼────────────────────────────┐\n│  tmuxy-core (Rust library)                              │\n│  Runtime ↔ sans-IO state machine ↔ tmux -CC connection  │\n└────────────────────────────┬────────────────────────────┘\n                             │ control mode (tmux -CC)\n┌────────────────────────────▼────────────────────────────┐\n│  tmux server                                            │\n└─────────────────────────────────────────────────────────┘\n```\n\n**tmuxy-core** — Rust library that manages tmux control mode connections. Owns the sans-IO state aggregator, the `TmuxMonitor` runtime that drives it against a live `tmux -CC` subprocess, and the tmux reads both transports share (`transport.rs`: scrollback, key bindings, theme, snapshots), all of which go over the control-mode connection. `executor` holds the command-string helpers (quoting, the `new-window` rewrite, `list-keys` parsing); the only subprocesses left are the session preflight and creation in `session.rs`, before any client is attached. See [STATE-MANAGEMENT.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md) for details.\n\n**tmuxy-server** — Axum HTTP server providing SSE streaming (with `Last-Event-Id` resync), HTTP POST command endpoints, and embedded frontend assets. Manages per-session connections, multi-client viewport sizing, and structured shutdown. Supports both production mode (embedded assets) and dev mode (`--dev` flag, proxies to Vite).\n\n**tmuxy-ui** — React frontend using XState for all state management. Reaches the backend through the `TmuxTransport` Effect service (one Layer per transport). Includes in-browser engines, `V86TmuxAdapter` and `CheerpXTmuxAdapter` — real tmux and bash in an x86 emulator or under CheerpX's x86-to-wasm JIT, the control-mode stream parsed by tmuxy-core compiled to wasm — which the demo site and the v86 stories run. The rest of Storybook replays states recorded from real tmux (`src/stories/fixtures/`). See [STATE-MANAGEMENT.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md) for the XState architecture.\n\n**tmuxy-tauri-app** — Optional desktop wrapper using Tauri. Communicates via native IPC instead of HTTP, offering lower latency. Each OS window is its own client with its own monitor, in a tmux session group (see \"One monitor per GUI window\" in [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md), which also covers the Tauri data flow)."},{"doc":"docs/ARCHITECTURE.md","heading":"How They Interact","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md#how-they-interact","text":"1. The **frontend** connects to the backend via SSE (web) or Tauri events (desktop) to receive real-time state updates, and sends commands via HTTP POST (web) or Tauri invoke (desktop).\n\n2. The **backend** maintains one `TmuxMonitor` per tmux session. When the first client connects to a session, a monitor is spawned. When the last client disconnects, the monitor shuts down.\n\n3. The **monitor** holds a `ControlModeConnection` — a persistent `tmux -CC attach-session` subprocess. All state-modifying commands go through the control mode stdin connection. See [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md) for why this is critical.\n\n4. tmux sends real-time notifications (`%output`, `%layout-change`, `%window-add`, etc.) through control mode stdout. The `StateAggregator` processes these into `StateUpdate` objects (full snapshots or incremental deltas).\n\n5. State updates are emitted via the `StateEmitter` trait — `SseEmitter` broadcasts to all SSE clients in a session, `TauriEmitter` emits Tauri events to the desktop app.\n\n6. The frontend's XState machine merges state updates into its context, and React components re-render via selector hooks. See [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md) for detailed flow diagrams.\n\nSee [ARCHITECTURE-WIDGETS.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-WIDGETS.md) for this section: Widgets."},{"doc":"docs/ARCHITECTURE.md","heading":"The user's config directory","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md#the-users-config-directory","text":"Everything a user can change lives in `~/.config/tmuxy/` (`$XDG_CONFIG_HOME/tmuxy`), materialized by `tmuxy-core/src/session.rs` at every launch of either host. Two kinds of file live there, and the kind decides what an edit means:\n\n| File | Owner | On launch |\n| --- | --- | --- |\n| `tmuxy.conf` | user | created once from a template, never touched |\n| `custom.css`, `themes/<own name>.css` | user | left alone |\n| `tmuxy.defaults.conf`, `tmuxy.essentials.conf` | app | rewritten to this build's copy |\n| `themes/<built-in>.css` | app | rewritten, with a header saying so |\n| `tmuxy.user.conf` | app | regenerated from the `use-tmux-conf` choice |\n| `tmuxy.state.json` | app | choices made in the menus, re-applied after the conf |\n| `README.md`, `bin/` | app | rewritten |\n\nThe user never overrides what the app bundles; they copy a built-in theme to a new name, write `custom.css`, or set options in `tmuxy.conf`. CSS applies in that order, most specific first: `custom.css` → the `@tmuxy-css-<variable>` options → the theme file. Every edit is live: both hosts watch the directory and either push `css-changed` or source the conf (see [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md)). The `README.md` written there is the user-facing guide, and the one the bundled agent skill points at."},{"doc":"docs/ARCHITECTURE.md","heading":"Multi-Client Viewport Sizing","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md#multi-client-viewport-sizing","text":"Each client reports the viewport its pane container holds, in cells of the font it measures. The server sizes the session from those reports by the config's `@tmuxy-size` and sends the resize through the monitor's control-mode connection (external `tmux resize-window` commands are ignored while a control-mode client is attached). The policy is parsed in `tmuxy-core/src/client_size.rs`, read with the appearance options, and applied in `tmuxy-server/src/sse/clients.rs`.\n\n| `@tmuxy-size` | the session is | clients draw it |\n| --- | --- | --- |\n| `smallest` (default) | the smallest viewport, like native tmux | at their measured cell; a larger client leaves blank space |\n| `largest` | the largest viewport | a smaller client scales the grid down to fit |\n| `latest` | the viewport of the client used last: the last to send a command or report a new size | a smaller client scales down to fit |\n| `COLSxROWS` (`80x25`) | that grid, whatever the clients report | every client scales the grid to fill its container, up or down |\n\nThe scaling is the client's: `fittedGridCell` (`tmuxy-ui/src/machines/app/helpers.ts`, over `utils/gridFit.ts`) fits the measured cell to the grid, the same way the phone layout fits 80 columns, and the pane container carries the fitted font. The chrome and the sidebar columns keep the measured cell. Under a fixed size a client also asks for that size, so opening a sidebar re-tiles nothing: in a browser the grid shrinks into the room left, and the desktop app instead widens the OS window by the column (`grow_window`, as far as the screen allows) and narrows it back when the column closes.\n\nA report is taken no smaller than a floor (`MIN_CLIENT_COLS` × `MIN_CLIENT_ROWS` in `client_size.rs`): a grid that measured as a sliver mid-load, or a forged size, must not shrink the session to nothing for every other client under `smallest`.\n\nThe desktop app has one client per window, so only a fixed size changes anything there."},{"doc":"docs/ARCHITECTURE.md","heading":"Key Design Decisions","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md#key-design-decisions","text":"1. **One monitor per session** — Avoids duplicate control mode connections and ensures resize commands work reliably.\n\n2. **All commands through control mode** — External tmux subprocess calls can crash the tmux server when control mode is attached. See [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md).\n\n3. **State machine + client model in frontend** — XState owns UI-mode finite states (connecting / idle / reconnecting / disconnected, drag, resize, copy mode, command mode). The tmux world itself lives in a dedicated `TmuxClientModel` (`src/domain/store/`) with explicit committed / pending-ops / derived layers: plain state and pure functions (`src/domain/store/`), held by `src/infra/store/TmuxStore.ts`, whose only effect is sending an op through the `TmuxTransport` service. The appMachine bridges them: every intent is a `TmuxOp` dispatched to `tmuxStoreActor` (`DISPATCH_OP`), and state from the transport stream reaches it as `TMUX_STATE_UPDATE`. Components render; business logic belongs in the machines, and `useEffect` is kept to DOM and timer work that has nowhere else to live.\n\n4. **One transport service** — the `TmuxTransport` Effect service abstracts SSE/HTTP, Tauri IPC and the in-browser engines behind one interface and one ordered event stream, so the frontend is transport-agnostic.\n\n5. **Delta protocol** — After the initial full state snapshot, the server sends incremental deltas (changed panes, windows) to minimize bandwidth.\n\n6. **Adaptive throttling** — The monitor throttles state emissions during high-frequency output (~60fps cap), and emits immediately during low-frequency interactions for responsive typing feedback. Tunables live on `MonitorConfig`.\n\n7. **Sans-IO core, runtime at the edges** — The state aggregator is pure: events in, typed `SideEffect`s out, no I/O of its own. The runtime executes those effects; the aggregator's time-based steps take an explicit instant, so tests can drive it without spinning a real tmux.\n\n8. **One cursor for the whole app** — Each pane still renders a cursor element at its cursor cell, but only as an anchor: it is not painted. The cursor the user sees is a single fixed overlay (`tmuxy-ui/src/components/SmoothCursor.tsx`) that glides to whichever anchor belongs to the pane holding the keyboard, Neovide-style — the corners facing the direction of travel arrive first, the trailing ones lag, so a jump smears and snaps back into a cell. Because the overlay is one element, the same glide covers a move within a pane, a jump between panes, and a jump into the dock or a float. It re-measures the anchor every frame while anything moves, so it also follows transitions running underneath it (a re-tile, a zoom). Only the pane holding the keyboard has an anchor at all; a pane without it draws no cursor in any mode."},{"doc":"docs/ARCHITECTURE.md","heading":"Crate layout","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md#crate-layout","text":"Each crate's source tree is one `ls packages/<crate>/src` away — the durable thing to know is **what each crate owns**, not which files happen to currently exist.\n\n| Crate | Owns |\n| --- | --- |\n| `tmuxy-core` | `tmux -CC` subprocess management, control-mode event parsing, the sans-IO state aggregator, `TmuxMonitor` runtime, the shared control-mode reads in `transport.rs`, typed `TmuxError`. |\n| `tmuxy-server` | Axum HTTP server, SSE streaming with `Last-Event-Id` resync, typed `ClientCommand` enum for the HTTP POST endpoint, per-session client tracking, structured shutdown, embedded frontend assets (prod) or Vite proxy (dev); the `tmuxy server <verb>` tools. |\n| `tmuxy-ui` | React frontend, XState machine, optimistic `TmuxClientModel`, Effect-based adapter facade with typed errors, and the v86 client-side adapter (real tmux in an in-browser x86 emulator). |\n| `tmuxy-wasm` | wasm-bindgen facade over tmuxy-core's sans-IO control-mode parser + state aggregator, so browsers can reconstruct tmux state with the exact code the native server runs. Build via the root `build:wasm` script. |\n| `tmuxy-tauri-app` | Tauri desktop wrapper. Uses the same `TmuxMonitor` plumbing as the server; transport is native IPC instead of SSE/HTTP. |\n| `tmuxy-group` | The pane-group operations the `pane-group` script runs (`tmuxy-server group <verb>`), over the rules in `tmuxy_core::groups` — also built as a `tmuxy-group` binary so the v86 guest, which has no server, can run them. |\n| `tmuxy-pty` | The terminal each pane of the CheerpX guest runs on: CheerpX gives every process one pty, so the guest's patched tmux runs a pane on a socket pair under this helper, which does the line discipline, and its LD_PRELOAD (`preload.c`) supplies the process-group behaviour CheerpX lacks. See `packages/tmuxy-ui/cheerpx/README.md`. |"},{"doc":"docs/ARCHITECTURE.md","heading":"Related Documentation","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md#related-documentation","text":"Every doc in this directory, what it covers, and when to read it: [docs/README.md](https://github.com/flplima/tmuxy/blob/main/docs/README.md)."},{"doc":"docs/CI-TRIAGE.md","heading":"CI Triage Guide","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/CI-TRIAGE.md#ci-triage-guide","text":"Quick path for agents to diagnose failures in tmuxy GitHub Actions."},{"doc":"docs/CI-TRIAGE.md","heading":"First-pass workflow","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/CI-TRIAGE.md#first-pass-workflow","text":"1. Find the failing run in the Actions tab.\n2. Open failing job logs first (not successful jobs).\n3. Check matching local command from this table.\n4. Re-run the smallest equivalent local check.\n5. If flaky/perf/storybook related, collect artifacts noted below.\n\nA run that is green but slow is triaged from its summary page: the `timing` job writes a table of jobs, steps and the slowest tests there, and `bin/ci/ci-timing.mjs <run-id>` prints the same locally."},{"doc":"docs/CI-TRIAGE.md","heading":"Job-to-command map","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/CI-TRIAGE.md#job-to-command-map","text":"| Workflow job | Local command | Primary failure surface |\n| --- | --- | --- |\n| `lint` | `bin/lint-gate check` — the job's steps are its steps, so a red step is `bin/lint-gate check <step>` (`policy`, `prettier`, `rustfmt`, `eslint`, `types`, `knip`, `clippy`, `clippy-wasm`); the pre-push hook runs the same gate | JS/TS + Rust lint/type/format, the demo's types, the scripts under `bin/`, `tests/perf/` and the UI package, and exports, files and dependencies nothing uses (`knip` in `package.json`) |\n| `changes` | `node bin/ci/ci-changes.mjs <base-sha>` | Which of `desktop`, `storybook-v86-probe`, `audit` and the Mac `cargo test` this push may skip; the rules and their tests are `bin/ci/ci-changes.mjs` and `packages/tmuxy-ui/scripts/ci-changes.test.mjs`. A skipped job is not a failure; a job that should have run and did not is a wrong rule there |\n| `node-checks / unit-tests` | `npm test -- --run` | UI unit tests |\n| `node-checks / cli-tests` | `npm run test:cli` | CLI dispatcher behavior |\n| `rust-tests` | `cargo test --workspace` | Rust unit/integration |\n| `e2e (...)` | `npm run test:e2e` | Browser + tmux integration; `e2e (tmux-3.4)` is the snapshot and alternate-screen suites against tmux 3.4 (`tests/e2e-suites.json`, the `tmux` field) |\n| `desktop` | `npm run test:tauri` (the debug build) + `node tests/smoke/smoke-test.js target/debug/tmuxy`; skipped when the push touched nothing the app is built from or tested by (`changes`) | Desktop IPC, Tauri runtime, the launch path |\n| `storybook-probe` / `storybook-v86-probe` | `PROBE_TIER=commit npm run test-storybook -w tmuxy-ui` (the axe pass included) / `npm run test-storybook:v86 -w tmuxy-ui`; the v86 probe is skipped when the push touched nothing it loads (`changes`) | Story play-function and accessibility regressions |\n| nightly `v86 / deterministic` | `npm run test-storybook -w tmuxy-ui` (every story, the axe pass on) | The `nightly`-tagged stories and accessibility |\n| `audit` | `npm audit --omit=dev --audit-level=high` + `cargo audit`; runs only when a lockfile or `.cargo/audit.toml` changed (`changes`), the Sunday sweep covers the days between | Dependency security alerts |\n| `build-frontend` / `build-server` | `npm run build -w tmuxy-ui` / `cargo build --profile ci -p tmuxy-server` | The frontend bundle and the `ci`-profile server every later job downloads; a failure here fails everything after it |\n| `node-checks / demo-build` | `npm run build --ignore-scripts -w tmuxy-demo` | The demo site compiles (without staging its v86 assets; a full `npm run build -w tmuxy-demo` needs `npm run build:wasm`, `fetch:v86-image` and `build:v86-snapshot` first) |\n| `macos-tests` | `npm run test:cli` every push; `cargo test --workspace` (on a Mac, with Homebrew tmux) when the push touches `packages/tmuxy-tauri-app/`, `control_mode/connection.rs`, `bin/` or the Cargo manifests, and on every tag — `build-app.yml` also runs it on a Mac daily and on tags | macOS-only shell, path and tmux behaviour |\n| nightly `tmux matrix / suites` | `npx jest tests/snapshots/` and `npx jest tests/8-tui-alternate-screen.test.js`, against the tmux version the job names | A tmux version the default CI tmux does not exercise |\n| nightly `v86 / sweep` | `npm run test-storybook:v86 -w tmuxy-ui` | The full v86 story sweep; a failure opens an issue |\n| nightly `perf measurements / interaction-latency` | `npm run perf:interactions` + `npm run perf:compare` | Web interaction budgets (the ratio gate) |\n| nightly `perf measurements / interaction-latency-desktop` | `(cd packages/tmuxy-tauri-app && npx tauri build --no-bundle)`, then `npm run perf:interactions:tauri -- --binary target/release/tmuxy` + `npm run perf:compare -- --report tests/perf/interaction-report-tauri.json` | Desktop interaction budgets, against the release build |\n| nightly `perf measurements / core-bench` | `cargo bench -p tmuxy-core --bench core_pipeline` + `node tests/perf/compare-core-bench.mjs --threshold 20` | The Axis-A pipeline bench and its ratio budget |\n| `perf measurements / soak` (nightly) and `interaction-baseline` (on dispatch) | `npm run perf:soak`, `npm run perf:interactions` | Report numbers for a human; none gates |\n| weekly `sunday sweep / sweep` | `GH_REPO=flplima/tmuxy node bin/ci/sunday-sweep.mjs --dry-run` (needs a `gh` that can read the security alerts) |\n| `dependabot actions merge / merge` | nothing to run: it only merges a green `dependabot/github_actions/*` PR |"},{"doc":"docs/CI-TRIAGE.md","heading":"Artifact/log entry points","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/CI-TRIAGE.md#artifactlog-entry-points","text":"- `interaction-latency-*`: `tests/perf/interaction-report.json` (nightly `perf measurements`)\n- `interaction-latency-desktop-*`: `tests/perf/interaction-report-tauri.json` (nightly `perf measurements`)\n- `core-pipeline-bench-*`: `tests/perf/core-pipeline-report.json` (nightly `perf measurements`)\n- `v86-probe-timings-*`: `tests/perf/v86-probe-timings.json`\n- `storybook-*-artifacts`: screenshots, DOM dumps, stack traces\n- `v86-storybook-log-*`: Storybook startup/runtime logs\n- `e2e-<suite>-attempt-<n>`: a failed E2E shard's action trace (`tmuxy-e2e-trace.ndjson`, read with `tmuxy trace --check --window \"<file> › <test> › start\" <file>`), server log and Jest output\n- Annotations titled **Silent shell pane** on any E2E job: the trace health check found a shell pane that never showed a byte — even on a green run"},{"doc":"docs/CI-TRIAGE.md","heading":"Common causes","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/CI-TRIAGE.md#common-causes","text":"- Missing `packages/tmuxy-ui/dist` before Rust/Tauri checks.\n- tmux version drift (CI pins 3.7a in several jobs).\n- Environment mismatch for browser/driver/Xvfb jobs.\n- Tests asserting internals instead of user-visible behavior (see `docs/TESTS.md`)."},{"doc":"docs/CI-TRIAGE.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/CI-TRIAGE.md#related","text":"- `docs/TESTS.md`\n- `docs/PERFORMANCE.md`"},{"doc":"docs/COPY-MODE.md","heading":"Scrollback: the scroll view and copy mode","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md#scrollback-the-scroll-view-and-copy-mode","text":"Tmuxy renders scrollback **on the client** and scrolls it with the **native browser scroll**. The\nfrontend fetches a pane's history as structured cells, renders them in a real scrollable container\n(`ScrollbackTerminal`), and lets the browser handle wheel/touch/drag scrolling — so scrollback feels\nlike a native terminal rather than a series of `send-keys -X scroll` round-trips.\n\nThere are **two views** over that one machinery, and which one a pane is showing is\n`copyModeStates[paneId].mode` (`ScrollbackMode` in `domain/copyMode.ts`):\n\n|              | `scroll` — the native-like view                                                 | `copy` — tmux copy mode                                     |\n| ------------ | ------------------------------------------------------------------------------- | ----------------------------------------------------------- |\n| Opened by    | wheel or touch scroll up on a pane that is not a full-screen application        | `prefix [`, a CLI `copy-mode`, or tmux entering it itself   |\n| Told to tmux | **nothing** — the pane never enters `in_mode` and the application keeps running | `copy-mode -t <pane>`                                       |\n| Cursor       | none                                                                            | block copy cursor, which is also the selection's moving end |\n| Selection    | the **browser's own**, on `user-select: text` content                           | cell ranges the client computes (`v`, `V`, `y`)             |\n| Keys         | go to the pane; the first one closes the view                                   | intercepted and resolved as vi motions                      |\n| Closed by    | typing, `Escape`, or scrolling back to the bottom                               | `q` / `Escape` / `y`, or scrolling to the bottom            |\n\nThey share the record, the loader, the chunk merge and the renderer, so a pane has at most one of\nthem and the difference is only in cursor, selection, key routing and what tmux is told.\n\n**Selection outside copy mode is the browser's.** Terminal content that is not a mouse-tracking\napplication carries `terminal-selectable` (`user-select: text`), so dragging, double-clicking and\ntriple-clicking select text on the live screen and in the scroll view without entering any mode —\nand `Cmd+C` copies it. Only copy mode drives selection from the client, because only there is the\nselection anchored to a cursor the keyboard moves. Panes whose application tracks the mouse are\ndeliberately left unselectable: there the drag belongs to the application and is forwarded as SGR.\nWhere the browser owns the drag, the pane does not take the hidden keyboard input's focus on the\npress: focusing an input collapses the page's selection, so a drag across text selected nothing. The\nkeyboard actor hands focus to the input on the release instead, and only if nothing was selected.\nWhile the browser holds a selection on the live screen, the screen holds still\n(`hooks/useHeldWhileSelected.ts`): the live grid reuses row nodes by screen index, so output\nthat scrolled the screen slid other text under the highlight and `Cmd+C` copied the wrong lines.\nThe program keeps running; the pane catches up the moment the selection collapses. The scroll\nview needs none of this — its rows are keyed by absolute line.\n\n**Select all is the client's selection, in either view.** `Cmd+A` (`Ctrl+Shift+A` off macOS) selects a\npane's whole scrollback — opening the scroll view on a pane that has no view yet, since there is\nnothing else to hold a selection over history that is not on screen. On a pane whose application owns the screen (alternate screen, or tmux's own mode) it does nothing —\nthe same gate the wheel uses, since there is no scrollback behind such a screen. It is a _client_\nselection (a line selection from the first row of history to the last row on screen), not the\nbrowser's: most of\nthe history has no DOM node to select, so the browser could not express it and the copy could not\nread it. `Cmd+C` (`Ctrl+Shift+C` off macOS — plain `Ctrl+C` stays the interrupt) copies it from the\nloaded rows and closes the view with the usual blink, as a yank does. Because every selected row has\nto be backed by loaded cells, select-all keeps asking for the next unloaded gap\n(`firstUnloadedGap`) until there are none — lazy loading leaves holes on purpose, and a hole under a\nselect-all is a hole in the middle of the copied text."},{"doc":"docs/COPY-MODE.md","heading":"Architecture","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md#architecture","text":"```\nInput (keyboard / mouse / wheel / touch)\n    │\n    ├─ keyboard ───────────────────────────────────────────────────────────────┐\n    │     when the active pane is in copy mode, keyboardActor.ts intercepts      │\n    │     keydowns (preventDefault) and sends COPY_MODE_KEY; copyModeKeys.ts     │\n    │     (handleCopyModeKey) resolves the vi motion/selection client-side.      │\n    │                                                                            ▼\n    ├─ wheel / touch / drag ───────────────────────────────┐            tmux (control mode)\n    │     usePaneMouse.ts / usePaneTouch.ts scroll the      │            copy-mode, send-keys -X,\n    │     ScrollbackTerminal's native scroll container;     │            capture-pane -p -e -S -E\n    │     onScroll → COPY_MODE_SCROLL. Mouse drag / double- │                    │\n    │     / triple-click → COPY_MODE_* selection events.    │                    │\n    │                                                       ▼                    ▼\n    └─ appMachine.ts copyMode actions (actions/copyMode.ts) ◄───────── FETCH_SCROLLBACK_CELLS\n          own copyModeStates[paneId]: cursor, selection,          (tmuxActor → adapter.invoke\n          scrollTop, and the loaded scrollback `lines` map.        'get_scrollback_cells')\n                                                       │\n                                                       ▼\n   ScrollbackTerminal.tsx renders the loaded cells + the client cursor/selection into a tall,\n   natively-scrollable <div>; TerminalPane pins the live Terminal to the bottom when NOT in copy mode.\n```\n\nWhen a pane is in **copy mode**, the keyboard actor derives it fresh on every keydown from\n`copyModeStates[activePaneId]` (a focused float always takes priority) and routes keys to the client\nengine instead of tmux — so switching or closing the pane instantly stops copy-mode routing with no\nextra plumbing. A pane in the **scroll view** routes nothing: the keystroke closes the view and\ncarries on to tmux.\n\nCopy mode is **per-pane**: `TerminalPane` reads `copyModeStates[paneId]` and renders `ScrollbackTerminal`\nwhen present, otherwise the live `Terminal`. State for a pane is pruned when the pane leaves copy mode or\nis closed — the `TMUX_STATE_UPDATE` reconciliation in `appMachine.ts` owns that pruning."},{"doc":"docs/COPY-MODE.md","heading":"Entry and Exit","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md#entry-and-exit","text":"**Scroll view — entry and exit:**\n\n- Wheel-up / touch-scroll-up on a pane with history that is **not** on the alternate screen and is\n  **not** tracking the mouse — `usePaneMouse` / `usePaneTouch` send `ENTER_SCROLL_MODE`. That gate is\n  what keeps the view out of nvim, htop, less and Claude Code: a full-screen application's scroll is\n  its own, forwarded as SGR or arrow keys (`scrollUtils.sendScrollLines`).\n- The first real keystroke closes it (`keyboardActor` sends `EXIT_SCROLL_MODE`) and the key goes on to\n  the pane, so typing while scrolled up lands at the prompt. Modifier-only presses do not, or holding\n  Shift to extend a selection would close what you are selecting from. `Escape` closes it and is spent.\n- Scrolling back to the bottom with nothing selected closes it (`copyMode_scroll`).\n- No `send-keys -X cancel` and no re-entry cooldown on the way out — both belong to a tmux mode this\n  view never enters.\n\n**Copy mode — entry and exit:**\n\n- `prefix + [` — the `copy-mode` command is intercepted in `appMachine` and raised as `ENTER_COPY_MODE`\n  (the original command still forwards to tmux, which flips `in_mode`).\n- A pane reported `in_mode` by tmux (e.g. a CLI `copy-mode`, a custom binding) — the `TMUX_STATE_UPDATE`\n  reconciliation initializes a client copy-mode state and fetches its full history.\n- `q` / `Escape` / `y` — handled by tmux's `copy-mode-vi` bindings; the pane leaves `in_mode` and the\n  reconciliation drops the client state.\n- Scrolling back to the bottom of history with no active selection — `copyMode_scroll` auto-exits.\n  A selection holds the view open — copy mode's own, or the browser's in the scroll view, which\n  the scroll event reports (`nativeSelection`) since only the component can see it.\n- A 2-second re-entry cooldown (`COPY_MODE_REENTRY_COOLDOWN`) prevents a stale `in_mode` flag from\n  immediately re-opening copy mode after an exit.\n\nThe mouse never opens copy mode. A wheel gesture opening a mode with a cursor and vi keys is what\nthis split exists to undo."},{"doc":"docs/COPY-MODE.md","heading":"Scrollback loading","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md#scrollback-loading","text":"Row indices are absolute: a row's index is `history_size + <tmux offset>`. A view can open before tmux\nhas reported the pane's real `history_size` (it starts near zero), so every response carries the size\nit was answered with and rows already merged are re-keyed by the difference\n(`shiftScrollbackRows`) before the new chunk lands. Without that the screen rows sit on top of the\noldest history, and a copy of the whole scrollback comes back with its tail at the front.\n\nHistory is loaded lazily as structured cells, never as a client-maintained scrollback buffer of live\noutput. On entry the client fetches the full backlog; as the user scrolls, `getNeededChunk` requests\n200-line chunks around the viewport and `mergeScrollbackChunk` merges them into the `lines` map\n(tracked by `loadedRanges`). Rows that exist but aren't loaded yet render as dim placeholders.\n\nThe fetch is a single adapter call — `adapter.invoke('get_scrollback_cells', { paneId, start, end })` —\nimplemented per transport:\n\n- **HTTP/SSE server** — `ClientCommand::GetScrollbackCells` runs `capture-pane -p -e -S start -E end`\n  and parses it with `parse_scrollback_to_cells` (`tmuxy-server/src/sse.rs`, `tmuxy-core`).\n- **Tauri desktop** — the `get_scrollback_cells` command mirrors the same capture + parse\n  (`tmuxy-tauri-app/src/commands.rs`).\n- **Fully client-side (v86, CheerpX)** — there is no server, so `GuestEngine.captureScrollback` runs the same\n  `capture-pane` over the in-browser control connection (bracketed by unique markers so its lines can\n  be picked out of the stream) and hands the raw text to the core's `parse_scrollback` (a wasm export\n  of `parse_scrollback_to_cells`), reusing the identical ANSI parser instead of a JS reimplementation."},{"doc":"docs/COPY-MODE.md","heading":"Keyboard","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md#keyboard","text":"Copy-mode vi keybindings are reimplemented client-side in `copyModeKeys.ts` (`handleCopyModeKey`). While\na pane is in copy mode, `keyboardActor.ts` calls `preventDefault()` on every keydown and dispatches\n`COPY_MODE_KEY`; the `copyMode_key` action resolves it to cursor motion, selection, page/word/line\nmotions, yank, or exit against the pane's `CopyModeState`. Supported keys include `h`/`j`/`k`/`l` (+\narrows), `0`/`$`, `w`/`b`/`e`, `H`/`M`/`L`, `gg`/`G`, `Ctrl-u`/`Ctrl-d`/`Ctrl-b`/`Ctrl-f`, `Space`/`v`\n(char select), `V` (line select), `y` (yank), and `q`/`Escape` (exit). Yank and `Ctrl/Cmd-C` copy the\nextracted selection to the system clipboard via the keyboard actor's native `copy` event."},{"doc":"docs/COPY-MODE.md","heading":"Mouse and selection","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md#mouse-and-selection","text":"**In copy mode**, `usePaneMouse` drives the client selection:\n\n- **Drag** — starts a char selection at the drag origin and extends it as the cursor moves\n  (`COPY_MODE_SELECTION_START` / `COPY_MODE_CURSOR_MOVE`). Auto-scrolls when dragging past an edge.\n- **Double-click** — selects the word under the cursor (`COPY_MODE_WORD_SELECT`).\n- **Triple-click** — selects the whole logical line (`COPY_MODE_LINE_SELECT`), expanded across wrapped rows.\n- **Release** of a drag copies the selection to the clipboard and leaves copy mode\n  (`COPY_MODE_MOUSE_COPY`), as tmux's `MouseDragEnd1Pane → copy-pipe-and-cancel` does — the default\n  config binds exactly that, so a native tmux client behaves the same. tmux leaves its mode at once;\n  the client's view stays for the copy blink and then closes (`COPY_MODE_COPIED_EXIT`).\n\n**Everywhere else** the hook deliberately does nothing on those gestures and prevents no default, so\nthe browser selects — which also means double-click word boundaries come from the engine rather than\na cell heuristic. Right-click reads that selection (`utils/nativeSelection.ts`) and opens the\nselection's menu over it; with nothing selected it opens the pane's own menu instead, the one its\nheader opens (`PaneContextMenu`). A right-click does not move browser focus to the hidden keyboard\ninput, which would collapse the selection it is about to read. While the menu is up\nthe selection is pinned (`SelectionContextMenu`): the menu takes focus on open and each item on\nhover, and WebKit collapses the document selection whenever focus moves — so whatever collapses it,\nit is put back until the menu closes.\n\nA browser selection lives in DOM nodes, so the scroll view keeps them: `ScrollbackTerminal` mounts\neach row as its own absolutely positioned node, repaints a row only when its content changes (never\nbecause the window moved), and keeps every row a selection spans mounted while it lasts — so the\nselection survives scrolling, in either direction and all the way to the bottom, and reads back\nwhole, since selection text is document order.\n\n**Wheel / touch** scroll the native container while a view is open, a whole row at a time\n(`takeWholeRows` / `scrollByRows` in `hooks/scrollUtils.ts`) — the sub-row part of a trackpad delta is\ncarried into the next event rather than painted, so the container always rests on a row boundary and\nthe top row is never half drawn. That matches the rest of the pane: copy mode moves a cursor that\nlives on a row, and a full-screen application is sent rows. `onScroll` reports the new top row\nvia `COPY_MODE_SCROLL`. Mouse-tracking applications (`mouse_any_flag`, e.g. nvim/htop) receive\nforwarded SGR mouse sequences instead — that path is unchanged.\n\nA **swipe that opens a view** has two hazards, both about the gesture outliving the DOM it started\non. The browser cancels a touch sequence when its target leaves the document, so the live screen\nstays mounted (hidden) behind an open view instead of being replaced, and the move/end listeners sit\non the window rather than on the pane — a pane-level listener stopped hearing the swipe at the very\nrow that opened the view. And until React has re-rendered with the view open, each further move\nstill asks to open one, so `ENTER_SCROLL_MODE` on an already-open view scrolls it further instead of\nrebuilding it at the bottom. Without those three, a long swipe landed one row above the live screen."},{"doc":"docs/COPY-MODE.md","heading":"Clipboard","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md#clipboard","text":"**Copy on select**, as the Claude Code TUI does it: the mouse release that ends a browser selection\nover terminal text (a drag, a double or triple click, on the live screen or in the scroll view) puts\nthe text on the clipboard at once, blinks it, and raises an info notification saying how many\ncharacters went. The selection stays on screen, and the screen holds still under it. The next key —\n`Ctrl+C`, `Cmd+C`, a letter — clears the selection and is processed as typed: `Ctrl+C` interrupts\nthe shell, a letter goes to the pane, `Cmd+C` copies the same text once more before the highlight\ngoes. A modifier on its own leaves it. The same text still selected on a later release is not copied\ntwice; once the selection has gone, selecting it again copies again. The keyboard actor's clipboard\nmodule owns this (`machines/actors/keyboard/imeAndClipboard.ts`); the touch hold on a phone keeps\nits own Copy/Cancel bar.\n\nSelected text is extracted client-side (`extractSelectedText`, which joins wrapped rows into logical\nlines). Every copy blinks the copied text where it is (`utils/copyFlash.ts`). Copy mode paints its own\nselection, so its copy sets `copiedAt` on the view, the scrollback marks itself `data-copied`, and the\nview closes when the blink is over — keyboard yank, `Ctrl/Cmd-C` and a drag release alike. A browser\nselection is covered by fixed boxes laid over its rectangles instead, on `Cmd+C` and on the\nright-click **Copy**. With animations off the highlight holds steady for the same time. Keyboard yank (`y`/`Enter`) and `Ctrl/Cmd-C` set the extracted text on the keyboard actor's\nnative `copy` event (`document.execCommand('copy')` → `clipboardData`). The right-click **Copy** action\nwrites it via `navigator.clipboard.writeText`; the selection context menu's other item, **Send keys**,\ntypes the selection into the pane. Either closes the view."},{"doc":"docs/COPY-MODE.md","heading":"Key files","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md#key-files","text":"| File                                                                 | Responsibility                                                                                                 |\n| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |\n| `packages/tmuxy-ui/src/components/ScrollbackTerminal.tsx`            | Virtual-scrolling renderer for loaded scrollback + client cursor/selection                                     |\n| `packages/tmuxy-ui/src/components/TerminalPane.tsx`                  | Chooses `ScrollbackTerminal` vs live `Terminal`; owns the native scroll container and `onScroll`               |\n| `packages/tmuxy-ui/src/machines/app/actions/copyMode.ts`             | XState actions for both views: enter/exit, cursor/selection, scroll, chunk merge, prefetch                     |\n| `packages/tmuxy-ui/src/utils/nativeSelection.ts`                     | Reading the browser's selection and selecting the word under a point                                           |\n| `packages/tmuxy-ui/src/machines/app/states/copyMode.ts`              | Wires `COPY_MODE_*` events to their actions (idle-only)                                                        |\n| `packages/tmuxy-ui/src/machines/actors/keyboardActor.ts`             | Intercepts keydowns in copy mode → `COPY_MODE_KEY` / `COPY_SELECTION`; native clipboard `copy` handler         |\n| `packages/tmuxy-ui/src/utils/copyModeKeys.ts`                        | Pure vi-key handler (`handleCopyModeKey`): motions, selection, page/word/line, yank, exit                      |\n| `packages/tmuxy-ui/src/utils/copyMode.ts`                            | Pure helpers: scrollback merge, row re-keying, gap detection, needed-chunk detection, selected-text extraction |\n| `packages/tmuxy-ui/src/hooks/usePaneMouse.ts` / `usePaneTouch.ts`    | Mouse/touch → copy-mode enter, selection, and native scroll                                                    |\n| `packages/tmuxy-ui/src/machines/actors/tmuxActor.ts`                 | `FETCH_SCROLLBACK_CELLS` → `adapter.invoke('get_scrollback_cells')` → `COPY_MODE_CHUNK_LOADED`                 |\n| `packages/tmuxy-core/src/lib.rs`                                     | `parse_scrollback_to_cells` (shared by server, Tauri, and the wasm core)                                       |\n| `packages/tmuxy-ui/src/infra/transport/drivers/guest/GuestEngine.ts` | `captureScrollback` (marker-bracketed capture) + `parseScrollback` for the in-browser deployment               |"},{"doc":"docs/COPY-MODE.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md#related","text":"- [STATE-MANAGEMENT.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md) — the `copyMode` slice and `copyModeStates` context\n- [NON-GOALS.md](https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md) — why client scrollback rendering is the one scrollback-like feature we implement\n- [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md) — the SSE/HTTP/Tauri/v86 transports the scrollback fetch rides on"},{"doc":"docs/DATA-FLOW-DEPLOYMENTS.md","heading":"Data Flow: Desktop IPC, Deployments and Endpoints","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW-DEPLOYMENTS.md#data-flow-desktop-ipc-deployments-and-endpoints","text":"The Tauri transport, real-world deployment scenarios and the extra HTTP endpoints. Part of [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md)."},{"doc":"docs/DATA-FLOW-DEPLOYMENTS.md","heading":"Transport: Tauri IPC (Desktop Version)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW-DEPLOYMENTS.md#transport-tauri-ipc-desktop-version","text":"The Tauri desktop app bypasses the network stack entirely:\n\n**`invoke(cmd, args)`** — Client-to-server commands (equivalent to HTTP POST). Calls Rust functions directly through Tauri's IPC bridge. The `TauriAdapter` dynamically imports `@tauri-apps/api/core` to call `invoke()`.\n\n**Tauri events** — Server-to-client state updates (equivalent to SSE). The `TauriEmitter` pushes state changes with `emit_to(<window label>, \"tmux-state-update\", …)`, addressed to one window rather than broadcast. The frontend listens via `listen<StateUpdate>('tmux-state-update', handler)`.\n\nTauri IPC has lower latency than HTTP since communication is in-process.\n\n**One monitor per GUI window.** The desktop app can have several OS windows open on one tmux session (Window ▸ New Window). Each is its own client: its own webview, its own control-mode monitor, and its own tmux session, with the sessions members of one tmux _session group_ — they share every window and pane while each keeps its own current window, which is what puts two tabs of one session on screen at once. The first window holds the group's base session (the one `TMUXY_SESSION` names) and every later one attaches to `<base>+<index>` (`tmux -CC new-session -A -s <base>+2 -t <base>`); a window's session is killed when its window closes, which leaves the shared windows alive in the remaining members. A registry keyed by webview label (`tmuxy-tauri-app/src/windows.rs`) is what turns a command back into the monitor that must run it, so a mutation from window 2 moves window 2's current tab and not the other's. Per-session events (state, errors, logs, clipboard, detach) are addressed to the one window; server-wide ones (keybindings, theme settings) are still broadcast."},{"doc":"docs/DATA-FLOW-DEPLOYMENTS.md","heading":"Connection Lifecycle (Tauri)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW-DEPLOYMENTS.md#connection-lifecycle-tauri","text":"1. App starts, reads `TMUXY_SESSION` env var (defaults to \"tmuxy\")\n2. `monitor::start_monitoring()` spawns a background task that connects to tmux control mode\n3. On connection failure: exponential backoff (100ms to 10s max); after 5 consecutive failures the monitor **parks** — the loop stays alive but stops retrying until a user-requested reconnect (`tmuxy connect` or the sidebar server picker) revives it. The loop is `transport::supervise` in tmuxy-core, shared with the web server; parking, the server switch and the detach are the desktop's `Host` (`packages/tmuxy-tauri-app/src/monitor.rs`), where the web server's stops instead\n4. Once connected: emits keybindings, then enters the monitor event loop\n5. Frontend's `TauriAdapter.connect()` sets up event listeners for `tmux-state-update`, `tmux-keybindings`, `tmux-theme-settings`, `tmux-css-changed`, and `tmux-error`\n6. No explicit disconnect — the monitor runs for the app's lifetime"},{"doc":"docs/DATA-FLOW-DEPLOYMENTS.md","heading":"Real-World Deployment Scenarios","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW-DEPLOYMENTS.md#real-world-deployment-scenarios","text":""},{"doc":"docs/DATA-FLOW-DEPLOYMENTS.md","heading":"Scenario 1: Developer Using Tauri Desktop App with Local tmux","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW-DEPLOYMENTS.md#scenario-1-developer-using-tauri-desktop-app-with-local-tmux","text":"**Setup:** Developer runs the Tauri desktop app on their workstation. tmux server runs locally.\n\n```\n┌─────────────────────────────────────────────────┐\n│  Developer's Machine                            │\n│                                                 │\n│  ┌─────────────┐    ┌────────────────────────┐  │\n│  │ Tauri App   │    │ tmuxy-core (Rust)      │  │\n│  │ (React UI)  │◄──►│ TmuxMonitor            │  │\n│  │             │IPC │ ControlModeConnection   │  │\n│  └─────────────┘    └───────────┬────────────┘  │\n│                                 │ stdin/stdout   │\n│                       ┌─────────▼──────────┐    │\n│                       │ tmux server        │    │\n│                       │ (local daemon)     │    │\n│                       └────────────────────┘    │\n└─────────────────────────────────────────────────┘\n```\n\n**Data flow:**\n\n1. Tauri app starts, reads `TMUXY_SESSION=dev` from environment\n2. `TmuxMonitor` attaches to local tmux session via `tmux -CC attach-session -t dev`\n3. All IPC is in-process — no network involved\n4. Latency: sub-millisecond for commands, near-instant state updates\n\n**Characteristics:**\n\n- Lowest possible latency (no network stack)\n- No security concerns (local IPC only)\n- One control-mode monitor per GUI window, each on its own session in a shared session group (see **One monitor per GUI window** above)\n- Native desktop integration (system menus, keyboard shortcuts)"},{"doc":"docs/DATA-FLOW-DEPLOYMENTS.md","heading":"Scenario 2: Developer Using Tauri Desktop App with Remote tmux via SSH","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW-DEPLOYMENTS.md#scenario-2-developer-using-tauri-desktop-app-with-remote-tmux-via-ssh","text":"**Setup:** Developer runs the Tauri app locally but attaches to a tmux session on a remote server via SSH.\n\n```\n┌──────────────────────────┐         ┌──────────────────────────┐\n│  Developer's Machine     │         │  Remote Server           │\n│                          │         │                          │\n│  ┌──────────┐            │   SSH   │   ┌────────────────────┐ │\n│  │ Tauri App│◄──────────►│◄───────►│   │ tmux server       │ │\n│  │ (React)  │  IPC       │  tunnel │   │ (remote daemon)   │ │\n│  └──────────┘            │         │   └────────────────────┘ │\n└──────────────────────────┘         └──────────────────────────┘\n```\n\n**How it works:**\n\n- There is no SSH library — tmuxy shells out to the system `ssh` client. The `TMUXY_SSH` env var holds a whitespace-separated ssh argv tail (options plus destination, e.g. `-p 2222 user@host`). When set, every tmux invocation is wrapped as `ssh … tmux …` by `tmux_argv`/`ssh_target` in `packages/tmuxy-core/src/session.rs` — with `-tt` (remote tty allocation) for the `-CC` control-mode attach, and without it for one-off reads.\n- Saved servers live in `~/.config/tmuxy/servers.json` (`packages/tmuxy-core/src/servers.rs`); each entry's optional `ssh` field maps to the same `TMUXY_SSH` value. The desktop sidebar's server picker selects an entry and `tmuxy session connect` adds one; both reconnect the monitor live.\n- The `TmuxMonitor` and `ControlModeConnection` are transport-agnostic — they read/write stdin/stdout of a child process, so the ssh hop is invisible to them.\n- The Tauri monitor's reconnect loop (backoff, park after repeated failures, revive on user reconnect — see Connection Lifecycle above) applies to SSH targets the same as local ones.\n- Latency is handled by the monitor's adaptive throttling; there is no local echo or input prediction (see [NON-GOALS.md](https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md))."},{"doc":"docs/DATA-FLOW-DEPLOYMENTS.md","heading":"Scenario 3: Remote Server with Web Access","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW-DEPLOYMENTS.md#scenario-3-remote-server-with-web-access","text":"**Setup:** User runs `tmuxy server` on a remote cloud VM and accesses it from a browser on their laptop or mobile phone.\n\n```\n┌───────────────────┐         ┌──────────────────────────────────────┐\n│  User's Device    │         │  Cloud VM                            │\n│  (laptop/mobile)  │         │                                      │\n│                   │         │  ┌──────────────┐  ┌──────────────┐  │\n│  ┌────────────┐   │  HTTPS  │  │ tmuxy-server │  │ tmux server  │  │\n│  │  Browser   │◄──┼────────►│  │ (Axum)       │◄►│ (daemon)     │  │\n│  │  (React)   │   │   SSE   │  │ port 9000    │  │              │  │\n│  └────────────┘   │  + POST │  └──────────────┘  └──────────────┘  │\n│                   │         │                                      │\n└───────────────────┘         └──────────────────────────────────────┘\n```\n\n**Data flow:**\n\n1. User runs `TMUXY_PASSWORD=… tmuxy server --host 0.0.0.0` on the VM (plain `tmuxy server` listens on `127.0.0.1:9000` only, and a routable address needs a password or an explicit `--no-auth`)\n2. User opens `https://vm-ip:9000` in their browser (requires a reverse proxy for HTTPS — see below)\n3. Browser opens SSE connection to `/events?session=<name>`\n4. All subsequent commands use HTTP POST to `/commands?session=<name>`\n5. State updates stream via SSE in real-time\n\n**Security considerations (critical):**\n\nThe tmuxy server has **no TLS**, and on a routable address its only gate is an HTTP Basic password (`--password` / `TMUXY_PASSWORD`) — or none, with `--no-auth`. See SECURITY.md. Exposing it directly on a public IP means:\n\n- Anyone who discovers the IP and port can try the password, with no rate limit — and with `--no-auth` simply controls your tmux session\n- All traffic is in cleartext (eavesdropping reveals terminal content and lets attackers inject commands; Basic-auth credentials are only base64, not encrypted)\n- `run-shell` commands allow arbitrary code execution on the server\n- File reading endpoints have no path restrictions\n\n**Required mitigations for this scenario:**\n\n1. **Never expose tmuxy directly to the internet.** Use one of:\n   - SSH tunnel: `ssh -L 9000:localhost:9000 user@vm` (recommended for single user)\n   - VPN: WireGuard, Tailscale, or similar (recommended for mobile access)\n   - Reverse proxy with authentication: nginx/Caddy + basic auth or OAuth + TLS (start the server with `--allowed-host <public name>`)\n2. **Keep the default address for a tunnel:** plain `tmuxy server` listens on 127.0.0.1, which is all an SSH tunnel needs\n3. **Set a password on any other address:** `TMUXY_PASSWORD=… tmuxy server --host …` gates all routes with HTTP Basic auth — a barrier against opportunistic scans, but pair it with TLS since credentials cross the wire in cleartext\n4. **Use a reverse proxy for TLS:** The server does not support HTTPS natively\n\nSee [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md) for the full threat model and recommendations.\n\n**What works today:**\n\n- SSE streaming with delta protocol works well over high-latency connections\n- `EventSource` auto-reconnects on brief network drops\n- Multi-client support — multiple browser tabs/devices can connect simultaneously\n- Viewport auto-sizing across clients by `@tmuxy-size` (the smallest by default; the largest, the latest used, or a fixed grid every client scales to fit)\n- Mobile browsers work (touch events are translated to mouse events)\n\n**Limitations:**\n\n- No offline capability — requires constant network connection\n- No compression — JSON payloads can be large during rapid output (mitigated by delta protocol)\n- Only HTTP Basic auth (`--password`) built in; for anything stronger rely on external layers (SSH, VPN, reverse proxy)\n- Latency affects typing feel — no local echo or input prediction (see [NON-GOALS.md](https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md))"},{"doc":"docs/DATA-FLOW-DEPLOYMENTS.md","heading":"Scenario 4: Fully Client-Side — Real tmux in the Browser (v86 or CheerpX + WASM)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW-DEPLOYMENTS.md#scenario-4-fully-client-side--real-tmux-in-the-browser-v86-or-cheerpx--wasm","text":"No server at all. Real tmux runs inside an in-browser guest machine, and its `tmux -CC` control-mode stream is parsed by **tmuxy-core compiled to WebAssembly** — the same Rust parser and state aggregator the native server runs. Two guests share everything but the machine:\n\n- **v86** — an x86 emulator running a small buildroot Linux restored from a pre-booted state snapshot; the control client is on its UART.\n- **CheerpX** — an x86-to-WebAssembly JIT running 32-bit Alpine binaries from an ext2 image streamed over HTTP; the control client is on its console, through the guest's `tmuxy-console` relay. Faster and with no kernel to boot, but it needs a cross-origin isolated page (COOP + COEP headers), and its kernel lacks enough that the guest's tmux is patched and every pane runs under `tmuxy-pty` (see `packages/tmuxy-ui/cheerpx/README.md`).\n\n```\nTmuxyApp ──invoke(run_tmux_command)──> GuestTmuxAdapter ──> GuestEngine (V86Engine | CheerpXEngine)\n   ▲                                                              │ send\n   │                                                              ▼\nonStateChange <── tmuxy-wasm (parse + aggregate) <──stream── tmux -CC in the guest\n```\n\nKey pieces (under `tmuxy-ui/src/infra/transport/drivers/`):\n\n| Piece | Responsibility |\n| --- | --- |\n| `guest/GuestEngine` | The machine-independent control-mode client: the wasm core, tick/sync timers, `%exit`→fatal detection, the attach-and-sync loop with a per-attach guest setup, and a serialized boot/reset lifecycle. Marker-bracketed commands (tracked mutations, scrollback captures, in `guest/controlMode.ts`) send their markers as separate lines, as the native monitor does — invariant A6 in INVARIANTS.md. |\n| `guest/GuestTmuxAdapter` | The driver behind `GuestTransportLive`: translates frontend commands for raw control-mode stdin (separator + format-expansion rewrites per TMUX.md), serves themes/keybindings/images locally. The first frame is the finished start: the session is sized to the client's viewport before the first state is answered, as the server does, and an init whose work outlives its commands (a shell line it typed) names a tmux option it sets when done (`initDoneOption`), which `connect()` waits for. Files the page hands it (`files`: text, or a URL it fetches) are in the guest's `/mnt` before the init runs: v86's 9p share, a CheerpX in-memory device mounted at the same path. |\n| `v86/V86Engine` | The v86 machine: a UART writer paced under the rate the guest drains its input (v86's UART queue is unbounded; the guest reads ~45 KB/s, and a slower pace turns the queue into the bottleneck behind every keystroke) and serial coalescing. Snapshot restores rewind the filesystem, so the guest setup is re-applied on every attach. |\n| `v86` shared engine | Opt-in: many adapters reuse one booted machine; each consumer restores the pinned snapshot with a fresh WASM core (~1s) instead of cold-booting (~5s). |\n| `cheerpx/CheerpXEngine` | The CheerpX machine: CheerpX from its CDN, the image with a fresh IndexedDB overlay per page load, the relay on the console. |\n\nThe public demo site (`packages/tmuxy-demo`) runs CheerpX where the page is cross-origin isolated and v86 elsewhere; `?guest=v86` asks for v86. Its dev server sends the isolation headers; GitHub Pages, which hosts the static export, cannot, so a service worker adds them to the site's own responses (`public/coi-sw.js`), reloading a page it does not serve yet (a first visit, a hard reload) once to put it under the worker. A browser without service workers gets v86. The Storybook `v86` stories run v86. Assets (kernel, BIOS, state snapshot, ext2 image, wasm bindings) are served statically; nothing leaves the browser.\n\nThe v86 guest is CI-tested: the `storybook-v86-probe` job builds the wasm bindings and guest assets (cached on the build-script hash), starts a Storybook dev server, and drives the gate stories on one shared page (`tmuxy-ui/scripts/probe-spikes.mjs`); every `v86`-tagged story runs nightly in `nightly-v86.yml`. The deterministic stories run in the parallel `storybook-probe` job. See `docs/TESTS.md` § Storybook Tests. The CheerpX guest has no automated test yet."},{"doc":"docs/DATA-FLOW-DEPLOYMENTS.md","heading":"Additional API Endpoints","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW-DEPLOYMENTS.md#additional-api-endpoints","text":"Beyond the core SSE/HTTP protocol, the web server exposes:\n\n| Endpoint | Method | Purpose |\n| --- | --- | --- |\n| `/events` | GET | SSE stream (state updates, connection info) |\n| `/commands` | POST | tmux commands (no authentication unless `--password` is set — see SECURITY.md) |\n| `/api/images/{pane_id}/{image_id}` | GET | Serve a decoded inline-image blob |\n| `/themes/{name}.css`, `/custom.css` | GET | The user's stylesheets from the config dir (a built-in theme name falls back to the bundled copy) |\n\nThe stylesheet routes are answered by `tmuxy_core::session::resolve_user_css` in both builds: the web server serves them at the app's origin (ahead of the embedded bundle, and ahead of the Vite proxy in dev mode), the desktop app over its `tmuxycss:` scheme, and `tmuxy-ui/src/utils/themeManager.ts` picks between the two. Both hosts poll the config dir once a second (`transport::watch_config`): a saved stylesheet becomes a `css-changed` push, a saved `tmuxy.conf` is sourced on the control-mode connection and followed by the `keybindings` and `theme-settings` pushes a client's own `source-file` gets. `tmuxy config reload` is that second path — it marks the file modified and nothing more. See [ARCHITECTURE.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md) for what the config dir holds.\n\nEvery route is gated by the optional `--password` Basic auth. See [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md)."},{"doc":"docs/DATA-FLOW.md","heading":"Data Flow","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#data-flow","text":"This document describes how data moves through tmuxy in different deployment scenarios, covering the SSE/HTTP protocol, Tauri IPC, and real-world use cases."},{"doc":"docs/DATA-FLOW.md","heading":"Transport: SSE/HTTP (Web Version)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#transport-ssehttp-web-version","text":"The web version uses two HTTP endpoints on the Axum server:\n\n**`GET /events?session=<name>`** — Server-Sent Events stream (server-to-client):\n\n- `connection-info` — Connection ID, default shell, and whether the server is tracing (sent on connect)\n- `keybindings` — Prefix key and all key bindings from tmux config\n- `theme-settings` — Theme name/mode, appearance (surface opacities, blur flag) and the `@tmuxy-css-*` variables from tmux config; re-sent after a `source-file`, and after the server itself sources a saved `tmuxy.conf`\n- `css-changed` — A stylesheet under the user's config dir (`themes/*.css`, `custom.css`) was saved; the client re-fetches its stylesheets and the themes list. No payload\n- `state-update` — Full state snapshots and incremental deltas (serialized JSON)\n- `clipboard` — OSC 52 clipboard payloads forwarded from terminal applications\n- `tmux-message` — `{ text }`, a `display-message` tmux showed this client (its `%message` notification), shown in the snackbar. Named `tmux-message`, never `message`: that is `EventSource`'s default event type, which `onmessage` receives too. The desktop app emits the same name.\n- `ping` — keepalive on an idle stream, once a second; the client times a silent link out by it (see Reconnecting below)\n- `log`, `tmux-error`, `fatal` — Diagnostic and error notifications. The error event is named `tmux-error`, never `error`: the browser hands a server event called `error` to `EventSource.onerror` too, and the adapter would treat every reported error as a dropped connection.\n\n**`POST /commands?session=<name>`** — HTTP POST (client-to-server):\n\n- Request body: `{ \"cmd\": \"command_name\", \"args\": {...} }`\n- Response: `{ \"result\": ... }` or `{ \"error\": \"message\" }`\n- **Loopback by default** — a routable `--host` needs an HTTP Basic password (or an explicit `--no-auth`), and every API route refuses requests from other origins — see [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md).\n\nSSE was chosen over WebSocket because: server-to-client is the dominant direction, `EventSource` has built-in browser reconnection, SSE works through all proxies/CDNs, and the standard `Last-Event-Id` mechanism gives us a clean reconnect path (see below)."},{"doc":"docs/DATA-FLOW.md","heading":"Reconnecting","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#reconnecting","text":"The adapter reopens a dropped stream on an exponential backoff (1s, 2s, 4s … capped at 30s), and three rules keep that from turning into a long wait on a link that drops often and briefly — a laptop waking, a Wi-Fi roam:\n\n- **The backoff belongs to one outage.** A connection that was established and then dropped starts the next one on a fresh schedule, after a short beat. One schedule for the page's whole life only climbs, and a few drops in every reconnect waited out the cap.\n- **A reason to retry now beats the timer.** The browser's `online` event, the tab becoming visible, and the overlay's _Retry now_ button (`RECONNECT_NOW` → `adapter.reconnectNow()`) all abandon the current wait and try immediately. None of them does anything while connected.\n- **Silence is a drop.** The server's keepalive is a named `ping` event rather than an SSE comment (a page never sees comments), sent whenever the stream has been idle for a second. Once a client has seen one, five seconds without any event ends the connection — a link that dies without an error is otherwise noticed minutes later, if at all. A server that sends no pings is never timed out."},{"doc":"docs/DATA-FLOW.md","heading":"A refused stream is not a dropped one","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#a-refused-stream-is-not-a-dropped-one","text":"`EventSource` reports every failure as the same bare error, with no status: a server that is restarting and a server that refuses the request look identical, and retrying the second never ends. So when a stream fails before it ever opened, the adapter asks the same URL with a plain `fetch` and reads the status. A 403 is the request guard's verdict on this page's `Host` or origin (see [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md)) — the retry loop stops and the reason in the response body is shown as the fatal error. No answer, a 5xx, or a stream that does open are outages, and the supervised retry carries on."},{"doc":"docs/DATA-FLOW.md","heading":"SSE resync via `Last-Event-Id`","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#sse-resync-via-last-event-id","text":"Every event the server broadcasts is tagged with a monotonic per-session sequence id (set as the SSE `id:` field). `EventSource` persists the last received id across reconnects and sends it back as the `Last-Event-Id` request header on retry. The server keeps a small ring buffer of recent events per session — the last 128, within 16 MB (`EVENT_BUFFER_SIZE`, `EVENT_BUFFER_MAX_BYTES` in `tmuxy-server/src/state.rs`) — and replays everything strictly newer than the supplied id before resuming the live stream. If the client's id is older than the buffer head (a long disconnect, or a burst of large deltas that outran the byte budget), nothing is replayed: the first live delta's `seq` does not follow the client's last one, and the sequencer refetches a full state — no client-side panic, no data corruption.\n\nThis is independent from the delta protocol's own `seq` field: the SSE id keeps the _transport_ in sync after a reconnect; the delta `seq` keeps the _application state_ in sync after each individual update.\n\nSee [DATA-FLOW-DEPLOYMENTS.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW-DEPLOYMENTS.md) for these sections: Transport: Tauri IPC (Desktop Version); Connection Lifecycle (Tauri); Real-World Deployment Scenarios; Additional API Endpoints."},{"doc":"docs/DATA-FLOW.md","heading":"Adapter Pattern","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#adapter-pattern","text":"The frontend reaches tmux through one Effect service, `TmuxTransport` (`tmuxy-ui/src/infra/transport/TmuxTransport.ts`): `connect`/`disconnect`, `invoke` for commands (fire-and-forget `run_tmux_command` among them), `query` for read-only tmux commands answered in-band on the control-mode connection — each failing with a typed `AdapterError` — and `subscribe`, one ordered `Stream` of tagged transport events (state, connection info, reconnection, keybindings, theme settings, logs, errors, fatal, detach, clipboard), so the first state never overtakes the connection info it depends on. Four Layers provide it (`layers.ts`): HTTP/SSE and Tauri IPC, which share one stream stage (`stateFeed.ts`: decode, sequence, and on a gap or an undecodable payload refetch the full state once at the last viewport the client reported), and the in-browser demo and v86 engines. Each Layer wraps a driver under `drivers/` whose events go through one generic hub (`infra/eventHub.ts`). The app builds one `ManagedRuntime` from the Layer for its environment (`infra/runtime.ts`), and the XState actors run every effect on it; tests swap in a scripted driver behind the real Layer (`src/test/transport.ts`)."},{"doc":"docs/DATA-FLOW.md","heading":"Connection Lifecycle (Web)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#connection-lifecycle-web","text":"1. Client opens SSE connection: `GET /events?session=<name>`\n2. Server assigns a unique `connection_id`\n3. Server checks if a `TmuxMonitor` exists for the session:\n   - **No monitor:** Spawns a new monitor (connects `tmux -CC`), stores handle in `SessionConnections`\n   - **Has monitor:** Subscribes to the existing broadcast channel and replays from the ring buffer if the client supplied a `Last-Event-Id`\n4. Client receives `connection-info` event with connection ID and default shell\n5. Client sends `get_initial_state` (via HTTP POST) with its viewport size (cols, rows)\n6. Server stores the client size, computes the minimum viewport across all clients, and sends a resize command through the monitor's control mode connection\n7. Client receives full state snapshot, then incremental deltas as tmux state changes. The stream (one full state, then deltas against the server's previous emission) is the client's state from its first full state on; the `get_initial_state` answer is a separate snapshot, so once the stream is synced it only fills panes the stream has not sent content for yet. It is the starting state only before the stream's first full state, after a delta sequence gap, or on a new connection (`adoptInitialState` in `tmuxy-ui/src/domain/deltaProtocol.ts`).\n8. On disconnect: server removes the client, recomputes minimum viewport, and shuts down the monitor if no clients remain"},{"doc":"docs/DATA-FLOW.md","heading":"State Update Flow","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#state-update-flow","text":"```\ntmux server\n    │ control mode stdout (%output, %layout-change, etc.)\n    ▼\nTmuxMonitor                              ← runtime (drives the aggregator,\n    │                                      executes the SideEffects it returns)\n    │  aggregator.step(event) → SideEffect[]\n    ▼\nStateAggregator (sans-IO state machine)\n    │  no I/O of its own — only describes what should happen\n    ▼\nSideEffect dispatch (refresh-panes, emit-state, store-image, ...)\n    │\n    │  emit_state path:\n    ▼                     ┌─────────────────────┐\n                          │   StateEmitter trait │\n                          └──────────┬──────────┘\n              ┌──────────────────────┴──────────────────────┐\n              ▼                                             ▼\n         SseEmitter                                  TauriEmitter\n   (per-session ring buffer + broadcast)            (app.emit())\n              │                                             │\n        ┌─────┼─────┐                                       │\n        ▼     ▼     ▼                                       ▼\n    Client1 Client2 Client3                          Tauri frontend\n```\n\nThe monitor multiplexes control-mode events with timer-driven flushes (throttle, settling, layout debounce, periodic sync) and external commands (resize, run-command, shutdown). The exact set of `tokio::select!` arms drifts as we tune timings; the durable contract is:\n\n- The aggregator decides **what** state effects exist.\n- The monitor decides **when** to flush them (throttle / debounce / settle).\n- The emitter decides **where** they go (SSE broadcast vs Tauri event)."},{"doc":"docs/DATA-FLOW.md","heading":"Command Execution Flow","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#command-execution-flow","text":"Frontend `adapter.invoke(cmd, args)` is decoded into a typed `ClientCommand` variant on the server (or routed straight through Tauri IPC). Mutating commands route through the monitor's control-mode connection — never through external subprocesses, because external `tmux` calls crash tmux 3.5a when a control-mode client is attached (see [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md)).\n\nReads go the same way: scrollback, key bindings, theme settings and snapshot checks are command lists answered in-band by the monitor (`tmuxy-core/src/transport.rs`, shared by both transports). Sync helpers in `executor::*` remain only for CLI paths with no client attached.\n\n**Two operations, the same on every transport.** `run_tmux_command` is a mutation: it is handed to the monitor's control-mode channel fire-and-forget and resolves to `null` — the result of a control-mode command arrives later as a state event, not as the response. `query_tmux` is a read: the monitor brackets the command with marker lines, collects the `%begin…%end` blocks between them, and answers with what the command printed (`MonitorCommand::RunCommandWithReply`); a tmux `%error` rejects the call with tmux's message. A mutation is bracketed the same way, with nobody waiting for the output: when tmux answers it with `%error`, the monitor emits the message as an error event (`SseEvent::Error` / `tmux-error`), which the frontend shows in the snackbar — the only way the user learns why a split or a kill did nothing. Keystrokes (`send-keys`, pinned or not) are the exception and go out bare: one command per key, and the only way they fail is a pane that is already gone from the layout. Web (`ClientCommand::QueryTmux`) and desktop (the `query_tmux` Tauri command) implement both identically, and the frontend reaches them as `adapter.invoke('run_tmux_command', …)` and `adapter.query(command)`. There is no subprocess path and no allowlist: nothing a client sends reaches a shell.\n\n**Failures carry a kind.** A failed command answers `{ \"error\": \"<message>\", \"kind\": … }` on both transports (the HTTP body of `POST /commands`, the object a Tauri command rejects with): `tmux` when tmux itself refused it (a query answered with `%error`), `unavailable` (no monitor, session or channel; a timeout), `invalid` (a malformed payload or id) and `forbidden` (a blocked command). The client maps `tmux` to `TmuxError` and the rest to `TransportError` (`tmuxy-ui/src/infra/transport/AdapterError.ts`). See `tmuxy_core::CommandError`.\n\n```\nFrontend\n    │ adapter.invoke(cmd, args)\n    ▼\n┌──────────────────────────────────────────┐\n│  Backend                                 │\n│  ┌──────────────┐    ┌────────────────┐  │\n│  │ Web: POST    │    │ Tauri:         │  │\n│  │ /commands    │    │ invoke()       │  │\n│  └──────┬───────┘    └─────────┬──────┘  │\n│         ▼                      ▼         │\n│   ClientCommand variant   Tauri command  │\n│         │                      │         │\n│         └──────────┬───────────┘         │\n│                    ▼                     │\n│      command_router::route_command       │\n│                    │                     │\n│     monitor channel (mutations and       │\n│     in-band reads, transport.rs)         │\n└────────────────────┬─────────────────────┘\n                     ▼\n            Monitor stdin → tmux\n```"},{"doc":"docs/DATA-FLOW.md","heading":"Delta Protocol","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#delta-protocol","text":"After the initial full state snapshot, the server sends incremental deltas to minimize bandwidth:\n\n- Each delta has a `seq` number for ordering; a full state and the `get_initial_state` answer carry the `seq` they were taken at, so the delta right after either one is checked against it\n- Deltas contain only changed fields: modified panes (content, cursor, metadata), added/removed panes, added/removed windows, active pane/window changes, status line changes\n- The frontend merges deltas into its cached state via `handleStateUpdate()` in `tmuxy-ui/src/domain/deltaProtocol.ts`\n- If a delta arrives with a sequence gap, the client requests a full state resync"},{"doc":"docs/DATA-FLOW.md","heading":"Keyboard Input Flow","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#keyboard-input-flow","text":"1. User presses a key in the browser\n2. `keyboardActor` captures the DOM `keydown` event. A hidden text input (`tmuxy-ui/src/utils/mobileKeyboard.ts`) owns browser focus on every device and follows the pane holding the keyboard (pane, float, or dock); an IME can only begin a composition inside an editable element. Text typed into it reaches the actor as `input` events and is sent once; its keydowns still run through the prefix and binding checks so a bound printable key wins\n3. If in copy mode: key routed to `COPY_MODE_KEY` handler (handled client-side, see [COPY-MODE.md](https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md))\n4. If prefix key pressed: enters prefix mode, waits for next key to match a binding\n5. Otherwise the keydown is classified as text or chord (see below) and `keyboardActor` sends `SEND_TMUX_COMMAND` — literal text with `send-keys -l`, a chord as a tmux key name\n6. The `KeyBatcher` in the adapter batches rapid keystrokes (e.g., typing \"hello\") into single `send-keys` commands\n7. Command reaches tmux via control mode stdin\n8. tmux processes the keystroke and sends `%output` event back through control mode stdout\n9. Monitor emits a state delta with the updated pane content\n10. Frontend re-renders the affected pane"},{"doc":"docs/DATA-FLOW.md","heading":"Text vs. chord","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#text-vs-chord","text":"Step 5 cannot be decided by \"no modifiers held\". Keyboards compose characters through three paths that all set flags reading like a chord:\n\n| Path | What the keydown looks like | Example |\n| --- | --- | --- |\n| Dead key | the finished character on one keydown, stamped `keyCode` 229 because the OS composed it through the IME | `´` then `a` → `á` |\n| macOS Option as compose key | `altKey` set, but the letter already replaced by a non-ASCII character | Option+c → `ç` |\n| AltGr third level | legacy ctrl+alt flags; only the `AltGraph` modifier state separates it from a real Ctrl+Alt chord | AltGr+Q on ABNT2 → `@` |\n\nForwarding one of these as a tmux key name is not rejected — tmux accepts `send-keys M-ç` and delivers ESC + `ç`, an unbound meta sequence the application discards. The classifier reads a non-ASCII key under bare Alt (and any key with `AltGraph`) as text, an ASCII key under Alt as a chord, and anything under Ctrl or Cmd as a chord. IME composition bypasses keydown and sends the committed string whole on `compositionend`."},{"doc":"docs/DATA-FLOW.md","heading":"Servers, Sessions and the 4-Step Connect Flow","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#servers-sessions-and-the-4-step-connect-flow","text":"A server is one tmux socket — local (`-L` / `-S` under `/tmp/tmux-$UID/`) or remote over SSH — and holds zero or more sessions. Saved entries live in `~/.config/tmuxy/servers.json` (`packages/tmuxy-core/src/servers.rs`), enriched on list with live local sockets from `packages/tmuxy-core/src/servers/discovery.rs`. Switching among them is the session switcher (`components/SessionMenu.tsx`, and `components/SessionList.tsx` on the detached overlay), both drawing `components/SessionPicker.tsx`: a filter-as-you-type box over `sessionSwitcherSpec`'s list, which scrolls inside a capped box. Reaching a new one is one flow, `tmuxy session connect` (`packages/tmuxy-cli/bin/tmuxy/session-connect`), which `tmuxy connect` with no arguments and the switcher's \"Connect over SSH…\" float both run. It asks for the destination, then takes 4 stages:\n\n```\n[1. ssh / localhost] ──► [2. tmux -V] ──► [3. listing sessions] ──► [4. attach]\n```\n\nEach stage prints `✓` on success or `✗` with verbatim `ssh`/`tmux` stderr on failure. The server is saved to `servers.json`, and the attach is published as `TMUXY_CONNECT_*` for the desktop app to pick up (see [TMUX-CONFIG.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md)).\n\n---"},{"doc":"docs/DATA-FLOW.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md#related","text":"- [ARCHITECTURE.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md) — the components each transport connects\n- [STATE-MANAGEMENT.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md) — what the state updates flowing over these transports contain\n- [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md) — why mutations must ride control mode rather than a subprocess\n- [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md) — what the unauthenticated `POST /commands` endpoint exposes\n- [INVARIANTS.md](https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md) — the sequencing promises the stream makes (`S` and `C` rows) and what tests them"},{"doc":"docs/DESKTOP.md","heading":"Desktop-only features","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DESKTOP.md#desktop-only-features","text":"tmuxy is one React UI served two ways: the **web app** (`tmuxy-server`, a\nbrowser tab) and the **desktop app** (`tmuxy-tauri-app`, the same UI in a\nWKWebView on macOS or WebKitGTK on Linux, over native IPC). Everything about\ntmux — panes, tabs, floats, groups, copy mode, widgets, the CLI — is the same\non both. What follows is the list of things only the desktop app has, and\nwhy: each one needs something a browser tab is not given.\n\nThe desktop app runs on **macOS and Linux**. There is no Windows build.\n\n| Feature | Why the web cannot have it | Where it lives |\n| --- | --- | --- |\n| Trackpad gestures: two-finger slide between tabs, pinch to zoom a pane, pinch in to the \"all tabs\" view | A page never learns when the fingers leave the trackpad. A slide reaches it as `wheel` events with no phase, and the only sign of a lift is silence — the same silence as fingers resting on the pad, so every web implementation either snaps back under a pause or guesses from a timer. The shell sees the real phases (`NSEvent.phase` / `momentumPhase` on macOS; GTK's smooth-scroll stream and its stop event on Linux) and hands them to the page. | `packages/tmuxy-tauri-app/src/gestures.rs` → `window.tmuxyGesture` → `machines/actors/gestureActor.ts`; drawn by `GestureStage.tsx` |\n| Several GUI windows on one tmux session, with the Window menu and its shortcuts | A browser tab is one window. | `packages/tmuxy-tauri-app/src/windows.rs`, `components/menus/WindowMenu.tsx`, `utils/guiWindows.ts` |\n| Window styles (iTerm2's set), the native title bar, dragging the window by its status bar | Window chrome belongs to the shell. On macOS the title bar is transparent and full-size — the status bar is the title bar — so \"No Title Bar\" hides the three window buttons (and the page drops the room it keeps for them) rather than rewriting the window's decorations, which would rebuild the style mask without the full-size-content bit and leave a bare title strip above a pushed-down page. A style change that leaves full screen or a maximized frame waits for that exit to land before it applies. Each applied style is announced to the page as `window-style`. | `packages/tmuxy-tauri-app/src/window_style.rs`, `titlebar.rs`, `components/StatusBar.tsx`, `utils/guiWindows.ts` |\n| Window transparency: the blurred desktop behind the chrome (macOS), and View ▸ Enable Window Transparency to turn it off | A page cannot see through its own browser window; the desktop's window is transparent and puts a native blur behind the page. The switch is `@tmuxy-transparency`, remembered in `tmuxy.state.json`: off, the page paints its surfaces opaque and every window's blur is cleared at once; the opacities and `@tmuxy-blur` keep their values for when it goes back on. | `packages/tmuxy-tauri-app/src/gui/window_setup.rs`, `monitor/emitter.rs` (`emit_theme_settings`)<br>`packages/tmuxy-ui/src/utils/appearanceManager.ts` |\n| Native menus: the menu bar (macOS) and native popups for every context menu (macOS + Linux) — not the session switcher, whose filter box a native popup cannot hold — built from the frontend's menu spec; the in-app menus are the fallback, and what `@tmuxy-native-menus off` keeps | A page has no menu bar and no native popups; the web app draws the same specs with its own floating menu. The desktop sets the bar from the page (`NativeMenuBar`), debounced on the spec's shape and on window focus so the focused window owns it; a popup that is refused falls back to the in-app menu in the same gesture. An item's icon is the in-app menu's own strokes, rasterised in the page in the system's menu text colour — the platform takes a plain bitmap, not a template it recolours — so the bar is rebuilt when the system appearance changes. | `packages/tmuxy-ui/src/domain/menus/` (the specs, and `icons.ts`)<br>`packages/tmuxy-ui/src/infra/menus/nativeMenu.ts`, `nativeIcons.ts`<br>`packages/tmuxy-ui/src/components/menus/NativeMenuBar.tsx`, `useNativeMenu.ts`, `menuHost.ts` |\n| Other servers in the session menu, and `tmuxy session connect` to add one | A web client is already talking to the one server that serves it. | `components/SessionMenu.tsx` |\n| Links opened by the system (browser, viewer), images served over an app scheme | A page may only open a tab; the shell asks the OS. The web app serves the same images over an HTTP route instead. | `utils/openUrl.ts`, `components/Terminal.tsx` (`tmuxyimg:`) |\n| Restart the app | A page can only reload itself. | `utils/restartApp.ts` |\n\nThe switch for all of it is `isTauri()` (`utils/platform.ts`), set from the\nTauri IPC bridge being present; `main.tsx` also marks the document with\n`data-desktop` for styling. Nothing else probes the platform."},{"doc":"docs/DESKTOP.md","heading":"Trackpad gestures","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/DESKTOP.md#trackpad-gestures","text":"The pane grid follows the fingers (`GestureStage` writes each step as an inline\n`transform`, nothing re-renders), the tab being pulled in is drawn beside it\nfrom its real panes, and the release decides: past the mark the tab switches\nat once and the grid runs home; short of it the grid slides back. The\ndecision and the drawing are `machines/app/actions/gestures.ts` and\n`utils/gestures.ts`; the only thing the shell supplies is _where the fingers\nare and when they lift_.\n\n**The contract** (one call per native scroll event the shell claims):\n\n```\nwindow.tmuxyGesture('slide', phase, dx, dy, t)\n```\n\n- `phase`: `began` · `changed` · `ended` · `cancelled` · `momentum` ·\n  `momentum-ended`. The fingers are on the pad from `began` to `ended` or\n  `cancelled`, for as long as that takes — a pause is simply no event. The\n  momentum phases are the tail macOS sends after a lift; Linux has none.\n- `dx`, `dy`: points, positive when the fingers move right or down (the\n  content follows the fingers).\n- `t`: the event's own timestamp in seconds, so the speed a release carries\n  on at is measured on the device's clock.\n\n**What the shell claims.** A scroll stream whose first steps are horizontal\n(the same axis rule the actor used to apply, `|dx| > 1.5·|dy|`) from a\ntouchpad (`hasPreciseScrollingDeltas` on macOS; a touchpad device on Linux),\nwhile `@tmuxy-gesture-swipe-tabs` is on — the page is told the flag through\na command whenever it changes. The claimed events, and the momentum tail\nthat follows them, never reach the webview: the page does not scroll on\nthem, and nothing has to `preventDefault`. Vertical scrolls and mouse wheels\nare left alone. A slide over a pane whose program tracks the mouse is still\nclaimed: Ghostty gates its tab switch on that, but Claude Code and nvim track\nthe mouse, so the gate left most tabs with no slide at all, and what such a\nprogram gives up is only a horizontal two-finger scroll.\n\n**macOS** installs an `NSEvent` local monitor for `.scrollWheel` from Rust\n(`objc2-app-kit`): it runs before `-[NSApplication sendEvent:]`, so before\nthe WKWebView, and returning `nil` is what swallows an event. It does not\nconsult the Trackpad preference \"Swipe between pages\": that switch is about\npage history, the two-finger scroll arrives whatever it says, and\n`@tmuxy-gesture-swipe-tabs` is the one setting for the tab slide. **Linux** connects\n`scroll-event` on the WebKitGTK widget: two-finger motion is\n`GDK_SCROLL_SMOOTH` ending in a stop event at lift-off, before any kinetic\nscrolling, and `Propagation::Stop` is what swallows it.\n\nA pinch needs none of this: WebKit reports it to the page itself as\n`gesturestart` / `gesturechange` / `gestureend` with the cumulative scale, and\nthose have phases. The actor keeps a ctrl+wheel pinch too, which is how\nlibinput's pinch arrives on WebKitGTK.\n\nThe web build runs none of the gesture code: `AppProvider` leaves the\nmachine's no-op gesture actor in place off the desktop, so the browser keeps\nits own wheel, pinch-zoom and history swipe. The Storybook stories under\n_Mocked App / Trackpad Gestures_ opt the actor in (`config.desktopGestures`)\nand drive it with the shell's own calls, which is how the JS half is tested\non the web build; the native half is covered by the desktop suite."},{"doc":"docs/INVARIANTS-UI-LIFECYCLE.md","heading":"Invariants — UI-mode records, session lifecycle","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS-UI-LIFECYCLE.md#invariants--ui-mode-records-session-lifecycle","text":"The `U` and `L` rows of the ledger in [INVARIANTS.md](https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md), which holds the column legend, the seam prefixes and the other rows. Same rules: cite a row in a commit on its seam, and a row's enforcer is a test title that exists."},{"doc":"docs/INVARIANTS-UI-LIFECYCLE.md","heading":"U — UI-mode records","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS-UI-LIFECYCLE.md#u--ui-mode-records","text":"| Id | Invariant | Kind | Stated in | Enforced by |\n| --- | --- | --- | --- | --- |\n| U1 | At most one copy-mode record per pane; exit closes only if seen in mode, and cooldown ignores stale `in_mode`. | ordering | `copyMode.ts`, `docs/COPY-MODE.md` | `reconcilePaneMode.test.ts › a snapshot…`, `› is ignored…`; `copyMode.test.ts › the end of a blink…` (examples) |\n| U2 | Resize preview holds for two matching quiet updates or a 2 s fallback that never clobbers newer resizes. | liveness | `appMachine.ts`, `layout.ts` | `layout.test.ts › the 2s RESIZE_COMPLETED…`, `› …does not clobber…` (two-quiet-updates hold is —) |\n| U3 | Only the latest scrollback fetch per pane applies; different panes fetch concurrently without cancelling. | ordering | `tmuxActor.ts` | `tmuxActor.test.ts › interrupts the previous fetch…`, `› different paneId fetches…` |\n| U4 | Group switch holds swap over stale snapshots, drops if a pane dies, and never selects parked/stash panes. | safety | `appMachine.ts`, `windowOps.ts` | `tests/7-regression-bugs.test.js › Clicking three pane-group tabs…` [nightly] (E2E only) |\n| U5 | Every menu renders a `domain/menus/` spec and dispatches via `executeMenuAction` or machine events. | safety | `domain/menus/`, `SpecMenu.tsx`, `nativeMenu.ts` | `specs.test.ts`; `menuActions.test.ts › sends the op of every op-backed item…` |"},{"doc":"docs/INVARIANTS-UI-LIFECYCLE.md","heading":"L — session lifecycle","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS-UI-LIFECYCLE.md#l--session-lifecycle","text":"| Id | Invariant | Kind | Stated in | Enforced by |\n| --- | --- | --- | --- | --- |\n| L1 | Snapshot restore runs after attach/sync and holds autosave from before spawn so placeholders aren't saved and live sessions aren't overwritten. | ordering | `transport.rs`, `session_snapshot.rs` | `session_snapshot.rs › a_restore_in_flight…`; `transport.rs › a_running_session_is_not_restored_over` |\n| L2 | Restore plans avoid `new-window`, split panes in index order, apply layouts after splits, and restore focus last. | ordering | `session_snapshot.rs` | `session_snapshot.rs › a_restore_never_uses…`, `› focus_is_restored_last`, `› a_reordered_group…`; `tests/15-session-snapshot.test.js › the shape survives…` [nightly] |\n| L3 | New sessions are never created on a closing connection. | safety | `appMachine.ts`, `HttpAdapter.ts` | `tests/4-session-connectivity.test.js` (regression example) |\n| L4 | Disconnect during `connecting` transitions to `disconnected`, never a stuck spinner. | liveness | `appMachine.ts` (`connecting`) | `disconnected.test.ts › ends a connection…`, `› ends a reconnection…` |"},{"doc":"docs/INVARIANTS.md","heading":"Invariants","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md#invariants","text":"Cross-layer promises, in one place, each with the test that holds it. Doc comments explain _how_ an invariant is kept; this file indexes _what_ is promised across a seam and _whether anything checks it_. Module-local assertions stay in code.\n\n| Column | Meaning |\n| --- | --- |\n| `Id` | Stable name prefixed by seam. Cite in commits and test titles; never renumber. |\n| `Kind` | `ordering` (A before B), `safety` (X never happens), `liveness` (Y happens within a bound). Ordering/liveness want a property or interleaving test, not one scripted run. |\n| `Stated in` | Code defining the mechanism. |\n| `Enforced by` | `file › test title`. `(example)` marks a single scripted run standing in for a property; `—` means uncovered. |\n\n| Prefix | Seam |\n| --- | --- |\n| `A` | Control-mode events → `StateAggregator` → `SideEffect`s (`tmuxy-core`, sans-IO) |\n| `M` | `SideEffect`s → `TmuxMonitor` dispatch → tmux commands (`tmuxy-core` runtime) |\n| `X` | Tmux connection and command router (`tmuxy-core`) |\n| `S` | Server → client stream: SSE seq, ring buffer, first state (`tmuxy-server`) |\n| `C` | Client stream stage: sequencer, resync, per-frame coalescing (`tmuxy-ui`) |\n| `Q` | Client command queue and keyboard batching (`tmuxy-ui`) |\n| `P` | Client model: predictions and reconcile (`TmuxClientModel`) |\n| `U` | UI-mode records: copy mode, resize preview, scrollback, menus |\n| `L` | Session lifecycle: attach, restore, autosave |\n\nSee `AGENTS.md`: a bug-fix commit on a seam cites or adds a row, and a row's enforcer is a test title that exists."},{"doc":"docs/INVARIANTS.md","heading":"A — aggregator effects","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md#a--aggregator-effects","text":"| Id | Invariant | Kind | Stated in | Enforced by |\n| --- | --- | --- | --- | --- |\n| A1 | Step effect order: `AdoptUntaggedWindows` → `StoreImages`/`WriteClipboard` → `SendTmuxCommand`/`CommandReply` → `RefreshAfterWindowAdd` → `RefreshPanes` → `ResumePane` → `EmitState`. | ordering | `state.rs` (`step_at`) | `state_aggregator_props.rs › effects_within_a_step…`, `› the_seam_events_reach…` (property) |\n| A2 | Window add/close relists before emit: `RefreshAfterWindowAdd` precedes `EmitState`. | ordering | `state.rs` (`step_at`) | `state_aggregator_props.rs › window_add_refresh_precedes_any_emit` (property); A1 |\n| A3 | Tag-only steps promote to `Window` change so untagged windows emit immediately. | liveness | `state.rs` (`step_at`) | — |\n| A4 | Captures route by marker, never arrival order or shape; dead-pane captures are discarded. | safety | `state.rs` (`queue_captures`) | `state.rs › capture_routing…`, `› capture_marker…`, `› capture_for_dead_pane…`, `› a_failed_capture…`; `state_aggregator_props.rs › queue_captures…` (property) |\n| A5 | Output between reply markers skips pane/window parsers; mismatched END fails the older reply cleanly. | safety | `state.rs` (replies) | `state.rs › a_query_between_markers…`, `› a_mismatched_end_marker…`, `› non_list_panes_output…` |\n| A6 | Reply-wrapped commands send `BEGIN`, cmd, `END` in order, each its own line; `END` runs even after `%error`. | ordering | `state.rs` (`reply_wrapped_lines`); `guest/controlMode.ts` (`TrackedCommandBook`, `CaptureBook`) | `state.rs › reply_markers_collect…`, `› a_failed_block_fails…`; `reply_channel.rs › replies_come_back…` (real tmux); `trackedCommandBook.test.ts › sends its markers as their own lines…`, `› a command tmux refuses resolves…`; `AppOptimistic › RefusedSplitReportsTmuxsReason` (v86) |\n| A7 | Settling emits at most one `EmitState` and clears by safety ceiling (silently if idle). | liveness | `state.rs` (`arm_settling`, `tick`) | `state_aggregator_props.rs › settling_window…`, `› settling_round_trips`, `› suppressed_window…` (properties); `state_aggregator_sideeffects.rs › tick_after_deadline…`, `› tick_safety_timeout…` |\n| A8 | `change_type` surfaces during suppressed emissions so settling timers extend on window changes. | liveness | `state.rs` (`step_at`) | `state_aggregator_sideeffects.rs › change_type_is_surfaced…`, `› step_at_extends…`; `state_aggregator_props.rs › emit_state_change…` (property) |\n| A9 | Moved panes hold `%output` until capture lands and drop old content; others keep content during capture. | safety | `state.rs` (refill) | — |\n| A10 | Stray `%end`/`%error` without `%begin` never produces a command response. | safety | `parser.rs` | `parser.rs › stray_end_without_begin_is_ignored`; `pane_bytes_props.rs › …arbitrary_lines`, `› …hostile_notifications` (properties) |\n| A11 | A pane's output bytes reach the emulator exactly, wherever tmux cut its `%output` lines — a character split across lines included; pane output is never read as text. | safety | `parser.rs` (`parse_line`), `connection/spawn.rs` | `pane_bytes_props.rs › output_cut_anywhere_carries_the_bytes` (property); `control_mode_push_api.rs › a_character_split_across_output_lines_reaches_the_screen_whole` |\n| A12 | An in-browser guest's query reply is what came between its markers less tmux's notifications: another pane's `%output` arriving meanwhile stays out, a `run-shell` job's lines (printed after its block closed) stay in, and a block closes only on the `%end`/`%error` that repeats its `%begin`. | safety | `guest/controlMode.ts` (`CaptureBook`) | `trackedCommandBook.test.ts › keeps tmux's notifications out of the reply…`, `› keeps a line inside a block…` |"},{"doc":"docs/INVARIANTS.md","heading":"M — monitor dispatch","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md#m--monitor-dispatch","text":"| Id | Invariant | Kind | Stated in | Enforced by |\n| --- | --- | --- | --- | --- |\n| M1 | Monitor dispatches step effects one-to-one in exact aggregator order. | ordering | `monitor.rs` | `monitor.rs › a_step_sends_its_effects_commands_in_effect_order` (example; order is A1) |\n| M2 | Window add/close and initial sync send `list-panes` before `list-windows` (stash last) in monitor and wasm. | ordering | `state.rs`, `monitor.rs` | `monitor_command_order.rs › a_window_relist_sends_panes_before_windows…` |\n| M3 | `RefreshPanes` sends `list-panes` before per-pane `capture-pane`s for fresh cursor positions. | ordering | `state.rs`, `monitor.rs` | `monitor_command_order.rs › a_pane_refresh_lists_panes_before_every_capture` (property) |\n| M4 | Windows set `window-size manual` and `aggressive-resize off` before first `resizew` so SIGWINCH never races captures. | ordering | `monitor.rs` | `monitor.rs › a_window_is_told_to_stop_sizing_itself_before_it_is_sized` (example) |\n| M5 | Client-size pass runs after the step's own effects, never ahead of `EmitState`. | ordering | `monitor.rs` | — |\n| M6 | `GetState` waits for sync, captures, and queued refreshes (15 s cap); first state awaits resize up to 750 ms. | liveness | `monitor.rs`, `transport.rs`, `sse.rs` | `sse.rs › a_clients_first_state…`, `› a_first_state_waits…`, `› a_client_that_sets_no_size…`; `initial_state.rs`, `initial_state_history.rs` (real tmux) |\n| M7 | Every reply resolves within `REPLY_TIMEOUT`, or immediately on send failure. | liveness | `monitor.rs` | `reply_channel.rs` (success/error; timeout is —) |\n| M8 | Reply ids are monotonic so expired reply blocks never match newer ones. | safety | `monitor.rs` | — |\n| M9 | `%session-changed` for another session is filtered before reaching the aggregator. | safety | `monitor.rs` | — |\n| M10 | Resizes retry <=3 times per size after new tmux reports; new sizes reset count; unmonitored sizes re-request. | liveness | `monitor.rs`, `sse.rs` | `monitor.rs › a_window_tmux_did_not_resize…`, `› …not_asked_again…`, `› a_size_tmux_keeps_refusing…`, `› a_new_size_starts…`, `› a_window_with_no_panes…`; `sse.rs › a_size_that_reached_no_monitor…` |\n| M11 | UI choices re-apply after `source-file` (UI wins), and enforced settings after both. | ordering | `monitor.rs` | `enforced_settings.rs › connecting_enforces_the_settings…` (final values; order is —) |"},{"doc":"docs/INVARIANTS.md","heading":"X — the tmux connection and the router","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md#x--the-tmux-connection-and-the-router","text":"| Id | Invariant | Kind | Stated in | Enforced by |\n| --- | --- | --- | --- | --- |\n| X1 | Client commands keep pins intact, refuse `resize-window`, and rewrite `new-window` to `splitw ; breakp`. | safety | `command_router.rs` | `command_router.rs › a_pinned_list…`, `› resize_window_is_refused…`, `› new_window_is_rewritten…`; `pinned_split_race.rs › a_pinned_split_survives…` (real tmux) |\n| X2 | No tmux subprocess runs while control mode is attached; all reads/writes use control mode (`docs/TMUX.md`). | safety | `command_router.rs`, `docs/TMUX.md` | `tests/cli/` (socket flag); ESLint ban on `tmuxExec` in E2E (`docs/TESTS.md`) |\n| X3 | Control-mode client is never killed with SIGKILL; it detaches with a 3 s grace period. | safety | `connection.rs` | — |\n| X4 | Attach and create serialize under one lock via `new-session -A`; attach never creates a session. | safety | `connection.rs` | `connection.rs › creating_a_session_attaches…`, `› attaching_never_creates_a_session` (lock is —) |"},{"doc":"docs/INVARIANTS.md","heading":"S — server stream","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md#s--server-stream","text":"| Id | Invariant | Kind | Stated in | Enforced by |\n| --- | --- | --- | --- | --- |\n| S1 | Per-session broadcast seqs count from 1 (0 = empty) and the ring preserves concurrent broadcast order. | ordering | `tmuxy-server/src/state.rs` | `state.rs › seqs_are_monotonic…`, `› concurrent_broadcasters_keep…`, `› replay_since_returns…`, `› ring_buffer_drops_oldest…` |\n| S2 | No duplicates or fillable gaps: connect replays from `Last-Event-Id + 1`; lag replays ring tail (dropping `<= last_replayed`) or leaves one edge gap for C1. | safety | `sse.rs` | `sse.rs › the_first_broadcast…`, `› a_lagged_stream…`, `› a_client_that_stops_reading…`, `› a_client_beyond_the_buffer…` |\n| S3 | Reconnecting beyond the ring serves no replay so the first live delta triggers a gap refetch (C1). | safety | `sse.rs` | `sse.rs › a_client_beyond_the_buffer…` (`can_replay_from`; connect path is —) |\n| S4 | Streams end on shutdown; the last client leaving keeps the monitor for a 2 s grace period. | liveness | `sse.rs` | `sse.rs › open_stream_ends…`, `› the_last_client_leaving_keeps…` |\n| S5 | Monitor reconnect gives up after 5 short runs and broadcasts fatal so clients stop retrying. | liveness | `sse.rs` | — |"},{"doc":"docs/INVARIANTS.md","heading":"C — client stream stage","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md#c--client-stream-stage","text":"| Id | Invariant | Kind | Stated in | Enforced by |\n| --- | --- | --- | --- | --- |\n| C1 | Deltas apply only to their base state; missing, misordered, repeated or undecodable deltas drop and refetch full state. | safety | `stateSequencer.ts`, `stateFeed.ts`, `deltaProtocol.ts` | `stateSequencer.test.ts › asks for a resync…`, `› treats a payload…`; `stateFeed.test.ts › a payload…`; `deltaProtocol.test.ts`; `HttpAdapter.test.ts › a state update…` |\n| C2 | Pre-full-state deltas are ignored; full states/answers carry `seq` so a delta lost after sync triggers a gap. | safety | `stateSequencer.ts`, `deltaProtocol.ts` | `stateSequencer.test.ts › ignores a delta…`, `› a delta lost…`; `stateFeed.props.test.ts › never paints backwards…` (property); `deltaProtocol.test.ts › no gap…` |\n| C3 | At most one refetch runs in flight; arriving deltas are boundedly held, and failed refetches retry on next delta. | safety | `stateFeed.ts`, `stateSequencer.ts` | `stateFeed.props.test.ts › never paints backwards…` (property); `stateFeed.test.ts › a gap refetches…`; `stateSequencer.test.ts › the deltas…` |\n| C4 | Post-refetch publish merges the answer, held deltas `> seq`, and queued deltas monotonically; held gaps refetch. | ordering | `stateFeed.ts`, `stateSequencer.ts` | `stateFeed.props.test.ts › never paints backwards…` (property) |\n| C5 | Once synced, initial-state answers only fill empty panes; before sync or after a gap, they set starting state. | ordering | `deltaProtocol.ts`, `stateSequencer.ts` | `HttpAdapter.test.ts › an initial-state answer…`, `› …does not take back…`, `› after a gap…`; `stateSequencer.test.ts › after a gap…`; `stateFeed.props.test.ts › never paints backwards…` (property) |\n| C6 | New connections or session switches reset sequencing so old delta seqs never gate new streams. | safety | `HttpAdapter.ts`, `TauriAdapter.ts` | — |\n| C7 | Transport publishes events in arrival order (including pre-`connect` subscribers); web frame-hold never reorders state ahead of earlier events. | ordering | `TmuxTransport.ts`, `events.ts`, `stateFeed.ts` | `TmuxTransport.test.ts › publishes what the driver pushes…`; `stateFeed.test.ts › publishes a full state…` (examples; per-frame hold is —) |\n| C8 | States coalesce to one per frame (latest wins); older states never paint after newer ones. | ordering | `stateFeed.ts` | — |\n| C9 | Each pane id appears at most once in client state, and a delta removes exactly the panes it nulls. | safety | `deltaProtocol.ts` | `deltaProtocol.props.test.ts` (property) |\n| C10 | Dropped/silent streams retry with backoff, online/visibility/Retry reconnect immediately, refused streams stop, and `EventSource` is singleton. | liveness | `HttpAdapter.ts` | `HttpAdapter.test.ts › keeps retrying…`, `› ten drops…`, `› a pinged stream…`, `› a stream the server refuses…`, `› dedupes concurrent…`; `tests/4-session-connectivity.test.js › six drops…` [nightly] |"},{"doc":"docs/INVARIANTS.md","heading":"Q — command queue and input","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md#q--command-queue-and-input","text":"| Id | Invariant | Kind | Stated in | Enforced by |\n| --- | --- | --- | --- | --- |\n| Q1 | Mutations serialize in issue order so splits never land in a tab a queued `select-window` is leaving. | ordering | `HttpAdapter.ts`, `TauriAdapter.ts` | `HttpAdapter.test.ts › runs run_tmux_command…` (example; Tauri is —); `tests/4-session-connectivity.test.js › 2 panes → reload…` |\n| Q2 | Keystroke batches share the mutation queue and non-`send-keys` flush pending batches first. | ordering | `TauriAdapter.ts`, `HttpAdapter.ts`, `keyBatching.ts` | `keyBatching.test.ts › flushAll drains…` (batcher only; mutation interleaving is —) |\n| Q3 | Lone keystrokes send on leading edge; literal/special order holds; hex-byte sends (`-H`) never batch. | ordering | `keyBatching.ts` | `keyBatching.test.ts › sends an isolated…`, `› preserves literal→special…`, `› preserves special→literal…`, `› never intercepts…`; `tests/1-input-interaction.test.js › Type → backspace…` |\n| Q4 | Client reads (`query_tmux`) run unserialized; order vs. mutations holds in-band on control mode (A6). | ordering | `HttpAdapter.ts`, `docs/DATA-FLOW.md` | — |"},{"doc":"docs/INVARIANTS.md","heading":"P — client model","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md#p--client-model","text":"| Id | Invariant | Kind | Stated in | Enforced by |\n| --- | --- | --- | --- | --- |\n| P1 | Placeholder (predicted) pane or window ids are never sent to tmux as targets — not by a dispatched op, a binding's pin, or a tab switch. | safety | `commands.ts`, `dispatch.ts`, `keyboardActor.ts` (`bindingPin`), `layout.ts` (`layout_selectTab`) | `dispatch.test.ts › leaves the target to tmux…`, `› once tmux has confirmed them…`; `keyboardActor.bindings.test.ts › leaves a placeholder window and pane unpinned…`; `selectTab.test.ts › switches the view to a placeholder tab…` |\n| P2 | Ops mark in-flight before send and always settle: unacked sweeps at `OP_STALE_TIMEOUT_MS`, acked at `OP_ACKED_STALE_TIMEOUT_MS`, errors roll back. | liveness | `TmuxStore.ts`, `model.ts`, `types.ts` | `model.test.ts › stale ops…`, `› in-flight ops…`; `TmuxStore.test.ts › dispatch rolls back…`, `› TransportError…` |\n| P3 | Pending ops re-reconcile on a timer so time-based verdicts fire on an idle stream. | liveness | `TmuxStore.ts` | `tests/7-regression-bugs-b.test.js › Creating tabs then switching keeps the rendered tab in sync with tmux` [nightly] (its `waitForSettled` holds while a switch is pending; on an idle terminal only this timer can release it) |\n| P4 | Confirmed focus ops linger (`FOCUS_CONFIRM_LINGER_MS`), yield to a 3rd pane after `FOCUS_SUPERSEDE_GRACE_MS`, drop on newer focus, and skip dead panes. | ordering | `ops.ts`, `model.ts` | `ops.test.ts › holds a confirmed…`, `› holds through stale…`, `› holds briefly…` (examples; `dropSupersededFocusOps` is —); `tests/2-layout-navigation.test.js › Clicking an inactive tab…` [nightly] |\n| P5 | In-flight splits never claim the same pane id within a reconcile pass. | safety | `ops.ts` (`reconcile`) | — |\n| P6 | `NewWindow` placeholders match only tab windows with a pane; the patch hides unmatched newborns. | safety | `windowOps.ts` | `ops.test.ts › stays pending until…`, `› patch hides a tab-typed-but-paneless…` |\n| P7 | Session switch clears pending ops and committed state before the new session's first state. | ordering | `tmuxStoreActor.ts`, `appMachine.ts` | `workflows.test.ts › TmuxStore — clear…` (store only; order vs. first state is —) |\n| P8 | `MoveWindow` predicts dropped strip order immediately and holds until server places the window beside its anchor. | ordering | `windowOps.ts`, `tabOverview.ts` | `ops.test.ts › MoveWindow predict…`; `tests/2-layout-navigation.test.js › a tab dragged along the strip…` |\n| P9 | A focus the client shows is one it asked tmux for: every optimistic active-pane change goes out with an op that makes tmux select that pane, so tmux's next state confirms it rather than taking it back. | safety | `focusAndDrag.ts` (`focusAndDrag_selectPaneGroupTab`, `focusAndDrag_focusPane`) | `AppPaneGroups.stories.tsx › ClickingTheShownMembersTabTakesTheFocus` [v86] (the group tab of the member on screen; other focus paths are —) |"},{"doc":"docs/INVARIANTS.md","heading":"U and L — UI-mode records, session lifecycle","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md#u-and-l--ui-mode-records-session-lifecycle","text":"In [INVARIANTS-UI-LIFECYCLE.md](https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS-UI-LIFECYCLE.md), the ledger's companion."},{"doc":"docs/INVARIANTS.md","heading":"What the ledger says today","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md#what-the-ledger-says-today","text":"- **Uncovered (`—`)**: A3, A9, M5, M8, M9, X3, S5, C6, C8, Q4, P3, P5.\n- **Partial**: M7 (timeout), M11 (order), X4 (lock), S3 (connect), C7 (frame hold), Q1 (Tauri), Q2 (interleaving), P4 (`dropSupersededFocusOps`), P7 (first-state order), U2 (2-update hold).\n- **Example only**: M1, M4, Q1, U1.\n- **Property**: C3–C5 (`stateFeed.props.test.ts`) and S2 (`sse.rs`)."},{"doc":"docs/INVARIANTS.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md#related","text":"- `docs/STATE-MANAGEMENT.md`, `docs/DATA-FLOW.md`, `docs/TESTS.md`, `docs/TMUX.md`"},{"doc":"docs/NON-GOALS.md","heading":"Non-Goals","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#non-goals","text":"This document describes what tmuxy intentionally does NOT do. These are conscious decisions, not missing features."},{"doc":"docs/NON-GOALS.md","heading":"Target Audience","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#target-audience","text":"Tmuxy is for people whose work **runs in tmux** — long-lived sessions, agents left working, a laptop picked up from a phone — whether they know tmux's keys yet or not. The README's \"beginner-friendly\" means a newcomer can drive it with the mouse, the menus and the key hints and learn the keys as they go; it does not mean tmuxy courts people who do not want tmux at all, and it is not a replacement for a standalone terminal emulator."},{"doc":"docs/NON-GOALS.md","heading":"Core Philosophy","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#core-philosophy","text":"**Tmuxy is a tmux UI, not a terminal emulator.**\n\nWe delegate terminal emulation to tmux. We render what tmux gives us. We don't re-implement what tmux already handles well.\n\n---"},{"doc":"docs/NON-GOALS.md","heading":"Non-Goals","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#non-goals-1","text":""},{"doc":"docs/NON-GOALS.md","heading":"1. Full Terminal Emulation","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#1-full-terminal-emulation","text":"We do NOT maintain a local terminal state machine (cursor position, SGR attributes, scroll regions). Tmux already does this."},{"doc":"docs/NON-GOALS.md","heading":"2. Live Local Scrollback Buffer","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#2-live-local-scrollback-buffer","text":"We do NOT continuously buffer a pane's output on the client. tmux is the only owner of scrollback history.\n\nWhat we _do_ render client-side is a **view** of it: the mouse wheel in a normal shell opens the client-only scroll view, which renders scrollback **fetched on demand from tmux** (`get_scrollback_cells` → `capture-pane`), lazily in chunks as the user scrolls, and discards it when the view closes; tmux itself is never put into copy mode by the wheel — see [COPY-MODE.md](https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md). In alternate screen (vim, less), the wheel sends arrow keys. So scrolling works and scrollback renders client-side, but the history always comes from tmux at scroll time, never from a buffer we keep in sync with live output.\n\n**Why no live buffer?**\n\n- Duplicates tmux's work\n- Unbounded memory growth on the client\n- State divergence risk against the pane tmux actually holds\n- Lost on page refresh anyway"},{"doc":"docs/NON-GOALS.md","heading":"3. Local Search (Cmd+F / Ctrl+F)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#3-local-search-cmdf--ctrlf","text":"We do NOT implement browser-style find-in-page for terminal content. Users search via tmux copy mode (`Prefix + [` then `/` or `?`).\n\n**Why?** A find-in-page would have to search history we don't hold — the copy-mode `lines` map covers only the chunks fetched around the current viewport, so a client-side search would silently miss matches outside it. Searching in tmux searches the real buffer."},{"doc":"docs/NON-GOALS.md","heading":"4. Local Echo / Input Prediction","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#4-local-echo--input-prediction","text":"We do NOT predict keystrokes locally to reduce perceived latency (like mosh does). Every keystroke round-trips through tmux.\n\n**Why?**\n\n- Only benefits high-latency connections\n- Tmuxy is primarily for local/LAN use\n- Adds significant complexity\n- Risk of prediction errors"},{"doc":"docs/NON-GOALS.md","heading":"5. Binary Protocol / Compression","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#5-binary-protocol--compression","text":"We use JSON over SSE/HTTP, not binary encoding (MessagePack, Protobuf) or compression.\n\n**Why?**\n\n- For local tmux, bandwidth is not the bottleneck\n- JSON is debuggable and simple\n- Premature optimization"},{"doc":"docs/NON-GOALS.md","heading":"6. Unicode Width Calculation","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#6-unicode-width-calculation","text":"We do NOT maintain our own `wcwidth` tables for character width calculation. Tmux renders the grid; we display it.\n\nText selection does not need one either: copy mode selects cells of the grid tmux laid out, one cell per column, where a wide glyph's second column is a cell of its own (blank). A selection's columns are therefore tmux's columns, with no width worked out on our side. See [COPY-MODE.md](https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md)."},{"doc":"docs/NON-GOALS.md","heading":"7. Canvas/WebGL Rendering","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#7-canvaswebgl-rendering","text":"We use DOM rendering (spans), not canvas or WebGL.\n\n**Why?**\n\n- DOM is simpler and works\n- Native text selection works with DOM\n- Accessibility (screen readers) works with DOM\n- Canvas/WebGL is premature optimization"},{"doc":"docs/NON-GOALS.md","heading":"8a. Resuming Processes Across a Restart","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#8a-resuming-processes-across-a-restart","text":"A session snapshot (`tmuxy_core::session_snapshot`) records a session's SHAPE —\ntabs, splits, floats, sidebars, groups, each pane's directory and the program\nit ran — and rebuilds that. It does not and cannot resume the processes: no\nportable mechanism checkpoints a live process with its ptys and sockets, and\nthe one that tries (CRIU) is Linux-only and privileged. A restored pane offers\nits program at the prompt; a program that wants to come back to where it was\nsays how through `tmuxy pane restore-cmd` (`claude --resume <id>`, `nvim -S`).\nShell variables, half-typed input and remote connections are gone with the\nprocess, as they are in tmux-resurrect and Zellij. A pane group comes back whole —\nits members parked out of view return parked, in pane-id order — but the\nposition of the visible member among them is not kept: it comes back first."},{"doc":"docs/NON-GOALS.md","heading":"8. Simultaneous Multi-Session Views","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#8-simultaneous-multi-session-views","text":"We do NOT render more than one tmux session at a time. One tmuxy instance is attached to one session; there is no split view across sessions and no cross-session pane layout.\n\nSwitching between them _is_ supported — the session switcher (a dropdown that filters as you type, `SessionMenu.tsx`) and `tmuxy session switch` reattach without a page reload. The switcher also attaches to a different tmux socket, locally or over SSH, by retargeting the running monitor rather than relaunching."},{"doc":"docs/NON-GOALS.md","heading":"9. SSH via Web Server","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#9-ssh-via-web-server","text":"SSH connections (remote server attachment) are only available in the Tauri desktop app. The web server accesses the host's local tmux; there is no browser-to-SSH tunnel.\n\n---"},{"doc":"docs/NON-GOALS.md","heading":"What We DO","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#what-we-do","text":"- Render tmux panes accurately\n- Handle keyboard input and forward to tmux\n- Support tmux window/pane operations (split, resize, close, navigate)\n- Provide a clean, modern UI for tmux\n- Parse OSC sequences for hyperlinks and clipboard (targeted, not full emulation)\n- Decode terminal image protocols (iTerm2 OSC 1337, Kitty Graphics, Sixel) and render them as inline `<img>` placements — see [RICH-RENDERING.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md)\n- Auto-reconnect on connection drop\n- Handle flow control for stability\n\n---"},{"doc":"docs/NON-GOALS.md","heading":"Revisiting Non-Goals","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#revisiting-non-goals","text":"These decisions can be revisited if:\n\n1. Target audience changes (e.g., targeting non-tmux users)\n2. A feature becomes trivial due to other work (e.g., local scrollback after OSC parsing)\n3. Strong user demand with clear use cases\n\nUntil then, we stay focused on being the best tmux UI, not another terminal emulator."},{"doc":"docs/NON-GOALS.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md#related","text":"- [COPY-MODE.md](https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md) — Client-side scrollback rendering (the one scrollback-like feature we implement; history fetched from tmux on demand)\n- [RICH-RENDERING.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md) — Image protocol support (iTerm2, Kitty, Sixel) and OSC 8 hyperlinks"},{"doc":"docs/PERFORMANCE-BASELINE.md","heading":"Performance: Measured Baseline and Retention","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE-BASELINE.md#performance-measured-baseline-and-retention","text":"The numbers measured so far, long-session retention, and snapshot costs. Part of [PERFORMANCE.md](https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md)."},{"doc":"docs/PERFORMANCE-BASELINE.md","heading":"Measured baseline","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE-BASELINE.md#measured-baseline","text":"First full run of both harnesses. Treat these as the current baseline to\nregress against, not as fixed constants — re-run the harnesses after any change\nto the parse/aggregate/delta pipeline or the transport."},{"doc":"docs/PERFORMANCE-BASELINE.md","heading":"Axis A — core + client processing (`cargo bench -p tmuxy-core`)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE-BASELINE.md#axis-a--core--client-processing-cargo-bench--p-tmuxy-core","text":"The bench fills panes with a real screenful (empty grids made content cost\nlook free).\n\nDevcontainer (aarch64), same machine for both columns. \"Before\" is the\nper-cell deep-copy pipeline; \"after\" is the `Arc`-shared-content pipeline\n(`TmuxPane.content: Arc<PaneContent>`, `ptr_eq` skip in the grid diff):\n\n| Bench | Before | After | Change | What it is |\n| --- | --- | --- | --- | --- |\n| `full_sync` | 206 µs | 136 µs | −34% | first full snapshot + screenful, 2-pane 80×24 |\n| `delta_rename` | 160 µs | **23.5 µs** | −85% | single-field change on an already-synced grid |\n| `output_burst/200` | 413 µs | 321 µs | −22% | 200-line flood parse+aggregate |\n| `output_burst/2000` | 2.52 ms | 2.37 ms | −6% | 2000-line flood parse+aggregate |\n\n**The construction pathology is fixed.** Before, a one-field delta cost ~78%\nof a full sync because every update deep-copied each pane's cell grid three\ntimes (content-cache clone, `prev_state` clone, full-grid diff walk). Grids\nare now `Arc`-shared between snapshots: an unchanged pane costs a refcount\nbump, and the diff skips it by pointer identity. A metadata-only delta is\n**6.8× cheaper** and no longer scales with grid size. Locked in by the\n`metadata_delta_shares_content_and_omits_grids` test in\n`tmuxy-core/src/control_mode/state.rs`.\n\nRemaining honest cost: when content _does_ change, extraction + line diff\nstill walk the grid (the µs-scale `full_sync`/burst numbers above) — that is\nreal work the pipeline must do, and byte parsing itself remains cheap\n(~40 MiB/s)."},{"doc":"docs/PERFORMANCE-BASELINE.md","heading":"Axis B — transport (input → paint)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE-BASELINE.md#axis-b--transport-input--paint","text":"Release `tmuxy-server` on loopback, 26 keystrokes per condition spaced 400 ms\napart (clean per-key round trips, no batching), driven headless through the\nreal `POST /commands` + `GET /events` path. RTT injected with a one-off\nlatency proxy, since removed. All latencies in ms.\n\n| Condition | Injected 1-way / RTT | p50 | p95 | p99 | max | pending | added vs C0 |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| C0 direct | 0 / 0 | 22.3 | 42.3 | 44.4 | 67.4 | 0 | — |\n| C1 LAN | 30 / ~60 | 86.7 | 99.0 | 100.0 | 243.2 | 0 | +64 |\n| C2 typical remote VM | 75 / ~150 | 173.4 | 194.5 | 194.6 | 215.7 | 0 | +151 |\n| C3 far / bad mobile | 150 / ~300 | 325.8 | 355.8 | 356.4 | 359.4 | 0 | +304 |\n| C4 150 ms RTT + 5% loss | 75 / ~150 + loss | 589.2 | 710.2 | 978.1 | 1034.5 | 1 | tail blows up |\n\n**Transport is a clean additive term.** The added latency over the C0 floor\ntracks the injected RTT almost exactly (+64, +151, +304) — SSE+POST introduces\nno head-of-line amplification on a clean link, and there is no local echo, so\nevery millisecond of RTT lands directly on input→paint. The **~22 ms tracker\nfloor** (C0) is send→apply: POST round trip + server step + SSE + client\napply. (Measured against a debug server the floor was ~24.5 ms — only ~2 ms\nhigher — which confirms it is transport-bound, not compute-bound.)\n\n**The tracker starts at send, not at keydown.** `markInput()` fires when the\ncommand leaves the adapter — so any client-side delay _before_ the send is\ninvisible to the table above. That mattered: the `KeyBatcher` used to hold\nevery keystroke for its full 16 ms window before sending, an extra ~16 ms of\nreal, user-felt latency the tracker never saw. Measured keydown→paint\n(MutationObserver on the pressed letter's echo, debug server, same machine):\n\n| keydown→paint | always-batch (old) | leading-edge flush (new) |\n| --- | --- | --- |\n| p50 | 42.5 ms | **25.4 ms** |\n| p95 | 62.5 ms | 38.8 ms |\n| max | 66.0 ms | 40.9 ms |\n\nThe `KeyBatcher` now sends an isolated keystroke immediately (leading edge)\nand opens its 16 ms window for what follows; a non-empty trailing flush\nre-opens the window, so sustained fast input (paste, key-repeat) still\ncoalesces to ~one send per frame. Keydown→POST for an isolated key dropped\nfrom ~17 ms to ~1 ms. The `key-echo` interaction in the Axis-C suite measures\nthis dimension now — on every commit, on both targets, and gated — which is\nwhy the throwaway harness that produced the table above no longer exists.\nThe tracker and HUD remain the transport (send→apply) instrumentation.\n\n**Loss is where the transport model actually hurts (C4).** At the same 150 ms\nbase RTT as C2, 5% loss-as-retransmit-stall pushes p99 from 195 ms to 978 ms and\nleaves an input still un-applied (`pending` 1) — TCP head-of-line blocking\nturning one lost segment into a multi-hundred-ms stall of everything behind it.\nThis, not steady-state RTT, is the signal a QUIC/WebTransport move would flatten.\n\n**Throughput is decoupled from output volume.** A `seq 1 50000` flood (50k\nlines) produced only **~6–8 client-side state updates** total, peaking at ~35\nupdates/sec, with `pending` never above 2 — the client never backed up. tmuxy\nrenders the _current visible grid_, not the scrollback, so the server's\naggregator coalesces an arbitrarily large burst into a handful of snapshots.\nThe real throughput ceiling is the Axis-A cost of extracting + diffing a\nchanged grid (µs-scale per snapshot, see the bench table), not client render\ncount. (Scrollback replay is copy-mode's job, a separate client-side path —\nsee [COPY-MODE.md](https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md).)"},{"doc":"docs/PERFORMANCE-BASELINE.md","heading":"Where tmuxy sits vs terminal emulators (honest framing)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE-BASELINE.md#where-tmuxy-sits-vs-terminal-emulators-honest-framing","text":"tmuxy is **not** a local GPU terminal emulator; it is a tmux UI that renders\nserver-parsed cell-grid state to the DOM over a transport. The category\ndifference matters when comparing:\n\n| Class | Input latency (reference) | Output throughput | Network |\n| --- | --- | --- | --- |\n| Native GPU (alacritty/kitty/wezterm) | ~5–45 ms (Typometer/Dan Luu) | multi-GB/s `cat` | none (local) |\n| Browser/xterm.js (VS Code terminal) | higher; DOM/canvas render cost | DOM/canvas-bound | none (local) |\n| mosh | ~0 perceived (local echo) | predicted locally | RTT hidden |\n| **tmuxy** | ~25 ms keydown→paint + RTT | volume-decoupled (see above) | the whole point |\n\nThe honest read: tmuxy's ~25 ms local keydown→paint floor is competitive with\nthe upper end of a local GPU terminal, and its snapshot model makes it\nstructurally immune to output-volume blowups. But it has **no input\nprediction / local echo** (an explicit Non-Goal, see\n[NON-GOALS.md](https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md), \"Local Echo / Input Prediction\"), so unlike mosh it pays the full RTT on every\nkeystroke — which is fine on LAN (C1) and acceptable on a typical remote VM\n(C2, ~180 ms p50) but degrades on high-RTT links (C3+)."},{"doc":"docs/PERFORMANCE-BASELINE.md","heading":"Open improvement areas (against measured bottlenecks)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE-BASELINE.md#open-improvement-areas-against-measured-bottlenecks","text":"1. **Transport — targeted, not blanket.** The curve shows SSE+POST is a clean\n   additive-RTT transport with no HoL cost until loss. The measurable QUIC/\n   WebTransport win is specifically the C4 loss tail (p99 195 → 978 ms), not\n   steady-state RTT. Input prediction (a Non-Goal) is the only thing that hides\n   RTT itself; the data says revisit it only for genuinely high-RTT (C3+) remote\n   use, not for LAN/typical-remote.\n2. **Navigation shells out at all.** `Ctrl+hjkl` runs the `nav` helper through\n   `run-shell` + bash, ~30 ms before the script does anything, while the Rust\n   monitor already holds the window/pane/group state the script rediscovers.\n   The client predicts the move, so the user does not wait for it, but moving\n   navigation into the server is the honest fix."},{"doc":"docs/PERFORMANCE-BASELINE.md","heading":"Axis D — retention over a long session","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE-BASELINE.md#axis-d--retention-over-a-long-session","text":"Every axis above measures one operation against a budget. None of them can see\na leak: a retained reference keeps each individual interaction just as fast\nwhile the heap, the DOM and the server's memory climb underneath it, and no\nsuite runs long enough for the climb to matter. Axis D is the question nothing\nelse asks — **does a long session degrade?**\n\n| --- | --- |\n| --- | --- |\n| Harness | `packages/tmuxy-ui/scripts/measure-soak.mjs` (`npm run perf:soak`) |\n| Load | Tens of MB of output through one pane, then hundreds of split/close cycles |\n| Measures | JS heap, DOM nodes, JS event listeners (CDP `Performance.getMetrics`), server RSS |\n| Verdict | A **plateau**, not a ceiling: each measure's second-half mean against its first-half mean, counted as growth only while it is still climbing at the end |\n| Where | `soak` job in `.github/workflows/nightly-perf.yml` — nightly, table in the run summary |\n| Gating | Not yet. `--gate` turns it on once the nightly numbers have a known shape |\n\nThree things about it are deliberate and worth not undoing:\n\n**It asserts a plateau rather than a number.** Absolute heap and RSS depend on\nthe machine, the Chrome build and what else the runner is doing, so a ceiling in\nmegabytes is either meaningless or permanently red. Growth between the halves of\none run divides all of that out, and it is also the only form that distinguishes\na leak from warm-up — which a single before/after reading cannot. Warm-up can\nstill lift the second half over the first (the output phase fills the pane with\nrows for its first few windows), so a measure past its tolerance also has to be\nclimbing between the start and the end of the second half: a leak is, a filled\npane is flat by then.\n\n**Load comes from the CLI, not the keyboard.** Axis C types, because the\nkeystroke path is its subject. Axis D's subject is what the app retains while\nrendering someone else's output, so it drives load through `tmuxy pane send`.\nThat is also a practical necessity: typing a long command line into a pane drops\ncharacters often enough that the load generator sometimes runs a different\ncommand than the one it was given.\n\n**It needs a tmux socket of its own.** It types into the active pane and splits\npanes in whatever session the server it is pointed at is serving. A server\nstarted with no `TMUX_SOCKET` serves the default `tmuxy` socket — on a dev\nmachine, the session someone is working in.\n\nIt is the one perf job that is scheduled, because its product is a trend rather\nthan a number: a soak run answers a yes/no question about that run — did\neverything plateau — and the answer is only interesting as a sequence. One\nnight proves nothing; thirty nights show a slope. For the same reason it prints\nits verdict table into the run's step summary, where it is readable from the\nrun page: an artifact is only read by someone who already suspects something.\nCI runs it with a smaller load than the harness defaults — a shared runner is\nslower at both halves, and the plateau comparison needs its windows to be clean\nmore than it needs them to be large."},{"doc":"docs/PERFORMANCE-BASELINE.md","heading":"Session snapshots","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE-BASELINE.md#session-snapshots","text":"A snapshot (`tmuxy_core::session_snapshot`) is written on every change to a\nsession's SHAPE, debounced one second, and once more on shutdown. Measured on an\nM-series mac, a session of three windows (a two-pane split, a float, a tab):\n\n| --- | --- |\n| --- | --- |\n| Snapshot file | ~1.2 KB (JSON, pretty-printed) |\n| Autosave cost | two `list-*` queries through control mode plus one `ps` per pane, off the async runtime; nothing when the serialized form is unchanged |\n| `tmuxy session save` (subprocess path) | 0.63 s wall, 0.05 s CPU — the wall is tmux round-trips, one per query and per `ps` |\n| Writes over a build-up of three windows | 5 files (one per settled change), never one per keystroke: output is not a shape change (`is_structural`) |\n\nScreen contents are not captured unless `--scrollback N` asks; a capture is one\nmore control-mode round-trip per pane and competes with the stream Axis C is\nmeasured on, which is why it is opt-in."},{"doc":"docs/PERFORMANCE.md","heading":"Performance Measurement","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#performance-measurement","text":"How tmuxy tracks speed, and how to run each measurement. There are **two\nindependent axes**, they cost in different places, and they need different\nharnesses. Conflating them is the most common way to measure the wrong thing.\n\n| Axis | What it costs | Harness | Network |\n| --- | --- | --- | --- |\n| **A. Core + client processing** | parse → aggregate → delta → apply → render CPU, render churn, frames-to-paint | v86/wasm probes + native criterion bench | removed |\n| **B. Transport** | wire RTT, head-of-line stalls, reconnect/roaming | `latencyTracker` + the `?perf` HUD | the whole point |\n| **C. Whole interaction** | everything one keypress sets off, end to end, per user action | `measure-interactions.mjs` in CI | included |\n\nAxes A and B decompose a single round trip; axis C asks a different question —\n**how much does one thing a user does actually cost**, with every layer, every\nextra round trip and every subprocess the action drags along still in the\nnumber. An interaction can regress badly (a binding grows a `run-shell`, a\nwindow-level refresh lands on the keystroke path) without A or B moving at all,\nwhich is exactly the gap it exists to close.\n\nThe v86/wasm path measures Axis A precisely _because_ it removes the network as\na variable — so it is a clean \"how fast is our code with zero transport cost\"\nbaseline. It structurally cannot see Axis B (there is no socket in that path).\nA transport change (SSE+POST → QUIC/WebTransport, or local → remote-VM) moves\nAxis B only. Measure both, and the difference is the transport's contribution."},{"doc":"docs/PERFORMANCE.md","heading":"Axis A — core + client processing","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#axis-a--core--client-processing","text":""},{"doc":"docs/PERFORMANCE.md","heading":"Native benchmark (deterministic, CI-friendly)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#native-benchmark-deterministic-ci-friendly","text":"`packages/tmuxy-core/benches/core_pipeline.rs` (criterion). Runs the **exact**\n`Parser` + `StateAggregator` + `to_state_update` code that the browser runs via\n`tmuxy-wasm` — one source of truth, no VT reimplementation — so native numbers\ntrack the wasm hot path. Three groups: `full_sync` (first snapshot),\n`delta_rename` (single-field delta), `output_burst` (`seq`-style flood, reported\nas bytes/sec).\n\nRun: `cargo bench -p tmuxy-core`. These are absolute, reproducible numbers —\nthe right place to catch a regression in the parse/aggregate/delta pipeline.\n\n`tests/perf/compare-core-bench.mjs` reads criterion's output and judges it the same\ntwo ways Axis C does, for the same reason:\n\n| Signal | Compared against | On regression |\n| --- | --- | --- |\n| ratio between two benchmarks of the same run | a budget in `compare-core-bench.mjs` | **fails the job** |\n| absolute mean | `tests/perf/core-pipeline-baseline.json`, keyed by platform | warns in the step summary |\n\nThe ratio is what makes this a gate at all. Absolute nanoseconds from a shared\nrunner cannot block a merge, and a platform with no baseline recorded has\nnothing to compare against — but `delta_rename / full_sync` is a statement\nabout the _shape_ of the pipeline that holds on any machine. It is budgeted at\n0.4 and measures 0.11–0.17 depending on the machine; before grids were\n`Arc`-shared it was ~0.78, because a one-field delta deep-copied every pane's\ncell grid and therefore scaled with grid size. If that pathology returns, this\nratio catches it without anyone having recorded a number first.\n\nA benchmark named in a budget but missing from a run also fails, so renaming a\nbench cannot silently drop its gate.\n\n**Refreshing `tests/perf/core-pipeline-baseline.json`.** A baseline must be measured\non the platform it describes, so the file holds an entry per platform and CI\nnever writes it — a baseline change is always a reviewed commit, the same rule\nas `tests/perf/interaction-baseline.json`. For your own machine, run `cargo bench -p\ntmuxy-core --bench core_pipeline` and then `node tests/perf/compare-core-bench.mjs\n--update-baseline`, which merges the run under its platform key. For the CI\nplatform, take `core-pipeline-report.json` from the `core-pipeline-bench-<run>`\nartifact of a nightly `perf measurements` run you trust and paste\nit under `platforms[\"linux-x64\"]`. Until someone does, a CI run records its\nnumbers and warns about nothing; the ratio budget gates regardless."},{"doc":"docs/PERFORMANCE.md","heading":"v86/wasm story probes (integration, relative)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#v86wasm-story-probes-integration-relative","text":"`packages/tmuxy-ui/scripts/probe-spikes.mjs` drives every `v86`-tagged\nStorybook story (real tmux in an x86 emulator, real Rust core in wasm) in one\nshared page. It enforces the in-story budgets (glitch/size-jump, paint-frame\nimmediacy, render-commit, asset-weight, throughput/ordering) and, with\n`PROBE_TIMINGS_JSON=<path>` set, also emits a per-story wall-clock report.\nThose durations are **relative** regression signals (shared engine,\nrunner-load-sensitive, emulator byte-pacing artifacts), not production\nlatencies — useful for trend, not for absolute claims.\n\nSee [TESTS-STORYBOOK.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md) for the budgets and the CI wiring."},{"doc":"docs/PERFORMANCE.md","heading":"Axis B — transport latency","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#axis-b--transport-latency","text":"Instrumentation the running product previously lacked entirely.\n\n`packages/tmuxy-ui/src/infra/latencyTracker.ts` records the **input → paint**\nround trip: when a keystroke/command leaves the client (`markInput`) and when\nthe resulting state update is applied (`recordUpdate`). It reports a latency\ndistribution (p50/p95/p99/max), the count of outstanding un-applied inputs\n(`pending`), the applied-update rate, and the worst recent inter-update gap\n(the stall signal). Inputs are matched to applies FIFO, so a burst of inputs\nwith few applies — a head-of-line stall — shows up as rising `pending` and\ninflating latency, which is exactly what a roaming/QUIC transport would flatten.\n\nIt is **dev-gated**: disabled by default, so the two hooks in `HttpAdapter` and\n`TauriAdapter` are a boolean check and an early return in production. Enable via\n`?perf` in the URL, `localStorage.tmuxyPerf = '1'`, `window.__tmuxyPerf = true`\nbefore connect, or `latencyTracker.setEnabled(true)`. When enabled at load, the\n`PerfHud` overlay (`packages/tmuxy-ui/src/components/PerfHud.tsx`) shows the live\nnumbers; its store updates are coalesced to one animation frame so it can't\ndistort what it measures."},{"doc":"docs/PERFORMANCE.md","heading":"Measuring a link","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#measuring-a-link","text":"Drive the app over the link in question with the HUD open (or read\n`window.__tmuxyLatency.getSnapshot()`) to get the input→paint distribution for\nthat transport — the question the v86/wasm harness structurally cannot answer.\nThe C1–C4 rows in the results below came from a one-off latency-injection\nproxy that no longer exists; a controlled re-run needs delay injected the same\nway, between the browser and a real server, on `POST /commands` and the\n`GET /events` stream.\n\nBecause the transports run over TCP, real packet loss reaches the app as delay\n(head-of-line retransmit), not dropped events; the loss condition in that table\nmodelled it as a random extra stall rather than truly dropping bytes."},{"doc":"docs/PERFORMANCE.md","heading":"Axis C — whole-interaction latency (the CI regression gate)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#axis-c--whole-interaction-latency-the-ci-regression-gate","text":"`packages/tmuxy-ui/scripts/measure-interactions.mjs` drives the handful of\nthings a tmuxy user does constantly and times each one from the real browser\n`keydown` to the first DOM change that shows the result — arming on the\nkeydown, resolving on the first probe change after it. Everything goes through\nthe real user path: the key reaches the page, the keyboard actor resolves the\ntmux binding, the command crosses the transport, tmux acts, state comes back.\nNothing is short-circuited with an adapter call, so a regression anywhere in\nthat chain lands in the number.\n\nMeasured today: `key-echo`, `pane-nav-keyboard`, `pane-zoom-toggle`,\n`pane-split`, `tab-switch`.\n\n**Two targets, one suite.** The same interactions are measured against the web\napp and against the desktop app, because they reach the same Rust core over\ndifferent transports — `POST /commands` + SSE versus Tauri IPC — and a\ntransport regression on one is invisible in the other. What is measured lives\nonce in `packages/tmuxy-ui/scripts/lib/perf-harness.mjs`; only the driver\ndiffers:\n\n| Target | Harness | Driver | Report |\n| --- | --- | --- | --- |\n| web | `measure-interactions.mjs` | Playwright (or an existing Chrome over CDP) | `target: \"web\"` |\n| desktop | `measure-interactions-tauri.mjs` | WebdriverIO → `tauri-driver` → WebKitWebDriver | `target: \"tauri\"` |\n\nBaselines are keyed by `<platform>/<target>`, so desktop milliseconds are never\ncompared against web ones measured on the same machine. The ratio budgets are\nshared: a keystroke round trip is the cheapest complete operation on either\ntransport, so \"how many keystrokes does a split cost\" is a fair question to ask\nof both.\n\nThe desktop harness drives the **release** binary (`target/release/tmuxy`), on\nits own tmux socket (`tmuxy-perf`), and adopts an already-running `tauri-driver`\nrather than fighting one — so it slots into a job that has already built the app\nand started a driver."},{"doc":"docs/PERFORMANCE.md","heading":"The runner-noise problem, and the ratio that solves it","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#the-runner-noise-problem-and-the-ratio-that-solves-it","text":"Absolute milliseconds on a GitHub runner are unusable as a merge gate — the\nsame commit can vary two-fold between runs. So each interaction is also\nreported as a **ratio to the keystroke echo measured in the same run on the\nsame machine**. A keystroke is the cheapest complete round trip tmuxy has; a\nloaded runner inflates it and every other interaction together, and the ratio\ndivides that out. Only a real change in how much _work_ an interaction does\nmoves a ratio.\n\nThat gives two signals with very different standing, and\n`compare-interactions.mjs` treats them differently:\n\n| Signal | Compared against | On regression |\n| --- | --- | --- |\n| ratio to `key-echo` | a per-interaction budget in `compare-interactions.mjs` | **fails the job** |\n| absolute p50 | `tests/perf/interaction-baseline.json`, keyed by platform | warns in the step summary |\n\nBudgets are ceilings with headroom, not targets. Raising one is a deliberate\nedit that shows up in review; tightening one belongs in the commit that earns\nit. The baseline is keyed by platform because milliseconds from a macOS laptop\nsay nothing about `ubuntu-latest` — a platform with no baseline yet simply\nleaves the absolute column blank and rides on the ratio gate."},{"doc":"docs/PERFORMANCE.md","heading":"Running it","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#running-it","text":"```\nnpm run perf:interactions -- --url http://localhost:9000 --samples 12 \\\n                             --label local --out tests/perf/interaction-report.json\nnpm run perf:compare -- --report tests/perf/interaction-report.json\n```\n\nAdd `--cdp http://localhost:9222` to attach to the Chrome the dev environment\nalready runs instead of launching one (the devcontainer deliberately does not\ninstall Playwright's browsers; CI is the reverse and launches). Refresh the\ncommitted baseline for a platform with `npm run perf:compare -- --report … --update-baseline`\nand commit the result. For the Linux platforms, run the \"perf measurements\"\nworkflow (`.github/workflows/nightly-perf.yml`, `interaction-baseline` job)\ninstead: it measures on a runner, with more samples than the gate job takes,\nand opens a PR with the refreshed file. Either way a baseline change lands\nthrough review — CI never writes one straight to `main`.\n\nThat job is on demand, not scheduled, and a PR rather than a commit, for one\nreason: a baseline that refreshes itself ratchets to whatever the runner did\nlast, which is the opposite of a baseline. A nightly run of it wrote artifacts\nnobody opened, and the file it exists to feed was never once refreshed from\none — because the review used to mean downloading an artifact and copying a\nfile over by hand. The PR keeps the review and removes the chore; read the\nmilliseconds before merging, since a runner having a bad night produces a\nbaseline that hides real regressions for as long as it stands.\n\nThe desktop equivalent needs the app built first, and takes the binary rather\nthan a URL:\n\n```\n(cd packages/tmuxy-tauri-app && npx tauri build --no-bundle)\nnpm run perf:interactions:tauri -- --samples 12 --label local \\\n                                   --out tests/perf/interaction-report-tauri.json\nnpm run perf:compare -- --report tests/perf/interaction-report-tauri.json\n```\n\nCI runs both nightly, in `nightly-perf.yml` (\"perf measurements\"), uploading\nthe report as an artifact; the ratio budgets fail the run there, so a\nregression shows as a red nightly the next morning:\n\n| Job | Measures | Builds |\n| --- | --- | --- |\n| `interaction-latency` | Axis C, web | the frontend and the release server |\n| `interaction-latency-desktop` | Axis C, desktop | the release Tauri binary, under Xvfb |\n| `core-bench` | Axis A bench | tmuxy-core under the bench (release) profile |\n\nThey ran per commit until the per-commit `desktop` job switched to a debug\nbuild: the desktop measurement needs the release binary, and that build was the\nlongest step on every push's critical path. With no branch protection the\nper-commit gates were informational anyway. `workflow_dispatch` with `gates`\nchecked runs all three on demand."},{"doc":"docs/PERFORMANCE.md","heading":"Measured","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#measured","text":"Local server on an isolated socket, macOS arm64, 10 samples per interaction,\ntmux 3.7. Same machine and same session shape for both columns. Treat the\nratios as the durable part and the milliseconds as machine-specific.\n\n| Interaction | before | after | × keystroke (before → after) |\n| --- | --- | --- | --- |\n| `key-echo` | 29.2 ms | 38.3 ms | 1× → 1× |\n| `pane-nav-keyboard` | 356 ms | **10.1 ms** | 12.2× → **0.3×** |\n| `pane-zoom-toggle` | 235.1 ms | **92.3 ms** | 8.1× → 2.4× |\n| `pane-split` | 82.1 ms | 78.1 ms | 2.8× → 2.0× |\n| `tab-switch` | 3.5 ms | 4.7 ms | 0.1× → 0.1× |\n\n\"Before\" is the state this harness was written to explain: keyboard pane\nnavigation cost twelve keystroke round trips. What the three fixes were, and\nhow the number moved, is improvement area 4 below.\n\nThe sub-keystroke numbers are not magic — `tab-switch` and (now)\n`pane-nav-keyboard` paint from the optimistic prediction before tmux answers.\nThe harness measures what the user sees, and for a predicted op that is the\nprediction; the server's answer arrives later and reconciles.\n\nSee [PERFORMANCE-BASELINE.md](https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE-BASELINE.md) for these sections: Measured baseline; Axis D — retention over a long session; Session snapshots."},{"doc":"docs/PERFORMANCE.md","heading":"What's still absent (by choice, for now)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#whats-still-absent-by-choice-for-now","text":"- No live production telemetry / metrics endpoint — the adaptive throttle in the\n  Rust monitor keeps its counters internal; latency tracking is dev-gated.\n- No client-side input prediction / local echo — an explicit Non-Goal (see\n  [NON-GOALS.md](https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md), \"Local Echo / Input Prediction\"). Axis B exists in part to decide, with data,\n  whether a high-latency use case ever justifies revisiting that.\n- **No committed baseline for the platforms CI actually runs on.**\n  `tests/perf/interaction-baseline.json` holds `darwin-arm64/*` only, and\n  `tests/perf/core-pipeline-baseline.json` holds `darwin-arm64` only. Both CI perf\n  jobs therefore ride on their ratio gates alone and leave the absolute column\n  blank. That is the designed fallback, not a failure — but it means a uniform\n  slowdown that inflates every number together, keystroke echo included, is\n  currently invisible on CI. Seeding a `linux-x64` interaction baseline is\n  still a human act, but no longer a manual one: run the \"perf measurements\"\n  workflow (`.github/workflows/nightly-perf.yml`) and it opens a PR with the\n  measured file for you to read and merge. The review is the point — CI must\n  never ratchet a baseline to whatever the runner did last — and it had never\n  happened because the review used to mean downloading an artifact and copying\n  a file over by hand. The core bench baseline is seeded from the report\n  `rust-tests` uploads on every commit (see Axis A above).\n- **No Axis B measurement in CI.** The RTT curve and the latency-injection\n  proxy are a controlled experiment run by hand, not a gate — injected delay is\n  the independent variable, so there is nothing for a runner to regress.\n- **Axis D does not gate yet.** Its plateau tolerances are guesses until a\n  corpus of nightly runs exists to calibrate them against, and a guessed\n  threshold on a long job produces a nightly nobody trusts. The numbers are\n  published every night in the run summary; turning on `--gate` in the workflow\n  is the follow-up."},{"doc":"docs/PERFORMANCE.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md#related","text":"- [TRACING.md](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md) — the trace file that generalizes `latencyTracker` into a cross-layer timeline\n- [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md) — the transports Axis B measures\n- [NON-GOALS.md](https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md) — input prediction and the other latency techniques we deliberately skip\n- [TESTS.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md) — the Storybook probes the Axis-A story measurements ride on, and where the Axis-C job sits among the suites"},{"doc":"docs/RICH-RENDERING-BROWSER.md","heading":"A live browser in a pane","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-BROWSER.md#a-live-browser-in-a-pane","text":"Companion to [RICH-RENDERING.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md): `tmuxy widget browser`, the moving-picture\ncase of the image pipeline, built in.\n\n`tmuxy widget browser <url>` (also `tmuxy browser`) is the moving-picture case\nabove, built in: a headless Chrome drawn into a pane and driven from it. The\nshell entry is `packages/tmuxy-cli/bin/tmuxy/tmuxy-widget-browser`, which only\nfinds a node 22 or newer (the script opens its own WebSocket, so it needs no\npackage) and runs `browser.js` beside it; the pure mappings live in\n`browser-lib.js` and are what `tests/cli/cli-browser.test.js` reads.\n\nThe script starts a Chrome of its own (`--chrome`, `TMUXY_CHROME`, or the\nfirst of google-chrome, chromium and Playwright's chromium it finds) or\nattaches to one by its DevTools endpoint (`--cdp`: a `ws://` URL, an\n`http://host:port`, or a port — what `agent-browser get cdp-url` prints\nworks). It opens the page in a tab whose viewport is the pane's cells, every\nrow but the last, at a fixed 8 × 16 pixels a cell (`--cell`), and takes a\nDevTools screenshot of it `--fps` times a second (the DevTools screencast was\ntried first and stalls in a headless window after its first frames; a\nscreenshot never does). A frame that differs from the last is one Kitty\ntransfer in `f=100` form — the JPEG (or `--png`) bytes as Chrome encoded them,\nin 4096-byte chunks, wrapped in tmux's passthrough — sized to the cells with\n`c=` and `r=` and anchored at the home cell, so it replaces the previous frame\nin place through the same-anchor rule above, one write at a time. A frame\nasked for while a navigation swaps the page's renderer fails and the next\ntick asks again. The last row is a status line in plain text: the\npage's title and address, so `tmuxy pane capture` still says what the pane\nshows. On a resize the tab follows the pane and the next frame is the new\nsize.\n\nThe pane's input is the page's: the script puts the terminal on the alternate\nscreen with the cursor hidden and the mouse tracked (`?1003h`, `?1006h`), and\nreads its stdin raw. Printable text is inserted, named keys and Ctrl chords\nare key presses, and the SGR mouse reports the UI forwards while the program\ntracks the mouse become clicks, drags and wheels on the pixel at the middle\nof their cell — which is why the viewport is cells times a known cell size: a\ncell is a block of pixels the page can be addressed by. A script drives it\nthe same way a person does: `tmuxy pane send -t <pane>` types into the page,\nand an SGR report sent as literal keys is a click on that cell. Ctrl+Q ends\nthe session and leaves the alternate screen, which takes the pictures with\nit; Ctrl+C reaches the page.\n\nLimits worth knowing: a frame is a screenshot, so a page that changes between\ntwo ticks shows only what the second one caught, and `--fps` is a ceiling the\npane's output path may not reach; the picture is a cell grid's\nworth of pixels (640 × 368 for an 80 × 24 pane), so small text is small;\naudio, file dialogs, popups and new windows stay in the headless browser; and\nthe keyboard layer forwards what the pane delivers, so a key the UI or tmux\nkeeps for itself (a prefix chord) never reaches the page. The E2E\n`tests/17-widget-browser.test.js` drives the whole path on CI's runner, whose\nimage ships a Chrome."},{"doc":"docs/RICH-RENDERING-CELL-GRID.md","heading":"The Cell Grid Invariant","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-CELL-GRID.md#the-cell-grid-invariant","text":"Why every glyph must land on tmux's cell grid, and how widths are kept in agreement. Part of [RICH-RENDERING.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md)."},{"doc":"docs/RICH-RENDERING-CELL-GRID.md","heading":"The cell grid invariant","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-CELL-GRID.md#the-cell-grid-invariant-1","text":"A terminal is a grid of cells, but a browser lays out text by glyph advance. Everything tmuxy paints into a pane must therefore be addressed in **cell coordinates**, never allowed to inherit a position from the surrounding text flow. Three rules follow, and each has already been violated once:\n\n**0. One measurement, one cell width.** The font's natural advance is a fraction of a pixel (FiraCode at 15px measures 9.23px), and boxes laid out at multiples of a fractional advance land on fractional pixel edges that the compositor snaps — so a wide pane's text ends a pixel away from where its box does, and adjacent pane outlines stop coinciding. The size actor therefore measures the advance once (inside the pane container, with the real `.terminal-content` styles), snaps it to a whole device pixel, and publishes two custom properties on the app root: `--cell-w`, the snapped cell width every cell-addressed box is a multiple of, and `--cell-gap`, the `letter-spacing` that pads the natural advance up to `--cell-w` so text really advances one cell per character. That same snapped width is the `charWidth` the pane rectangles and the cols→tmux calculation use, so the grid, the text, the cursor and the images all derive from a single number. `ch` is deliberately **not** the grid unit: it is the advance of \"0\" _without_ letter-spacing, so once the advance is snapped `1ch` is no longer a cell. `cellsToCss` in `terminalShared.ts` is the only place that spells the unit out; it falls back to `1ch` outside the app (component stories) — see `utils/cellMetrics.ts`, `stories/cellGrid.tsx`.\n\n**1. Position by cell, not by flow.** Style-group spans are pinned to an exact cell count (`width: calc(<n> * var(--cell-w))`), so a glyph whose advance differs from a cell paints within or over its own box instead of displacing the rest of the line. The cursor obeys the same rule: it is an absolutely positioned overlay at the cursor's cell in grid units (`--cell-w` and `--line-height-terminal`), in both renderers. It was previously spliced inline into the line's text, which made its position depend on the advance of every glyph before it; a webfont that had not finished loading, an RTL script, or a dingbat then pushed it off the grid.\n\n**1b. Row edges reach the pane border.** Each pane reaches half a separator column past its cells (`--pane-h-padding-left/right` on `.pane-content`), and a row painted edge to edge — a status line, an editor gutter — used to stop half a cell short of the border on both sides. The rows cannot paint that padding themselves: the touch-scroll container they live in clips at its own box. `RowEdges` (`components/RowEdges.tsx`) sits beside that container inside the padded box and paints one strip per row in the padding columns — the first cell's background on the left, the last cell's on the right, bottom-anchored like the terminal. Colours come from `rowEdgeBackground` in `terminalRendering.ts`; inverse video counts the way the cell renders.\n\nThe right strip is painted only for a row that reaches the **last column**, and nothing is painted past a row's cells at all. A row's last cell is not its right edge: tmux reports a row as long as its content, so the last cell of a line of block art is a pixel of the picture, and taking it for an edge ran that colour to the border — an ASCII-art logo drew a bar across the pane, which no other terminal does (the cells beyond the content hold the default background). A status line does reach the last column, which is the case the padding was painted for.\n\n**1c. Paint a cell's glyph into a cell.** Block Elements (`█ ▀ ▌ ▘ ░`) are drawn as CSS gradients rather than font outlines, because a font inks less than the cell box and stacked block art comes out striped (`components/blockGlyphs.ts`). The box they are painted into is a style-group span, which covers however many consecutive cells share a style — so the tile has to be sized in cells and repeated, not in percent. Sized in percent, a run of `▌` was one left half across the whole run and a quadrant covered half of it: every logo and bar built from half blocks came out stretched at exactly the width of the run that drew it.\n\n**2. Index by cell, not by UTF-16 offset.** One cell may hold several code units — a variation selector (`U+FE0F`), a combining mark, a ZWJ or skin-tone sequence. Any code that joins a line's cells into a string and then indexes that string with a column number is wrong: the two diverge at the first multi-unit cell and stay diverged for the rest of the line. This is why the cursor character comes from the cell array and why auto-detected URL ranges are translated through a per-cell offset map before being compared against cell indices.\n\n`isWideChar` (in `terminalShared.ts`, shared by both renderers) decides which cells get their own box. It inspects the whole cell string, not just the first code point, because emoji presentation is requested by a trailing `U+FE0F` that the base code point knows nothing about. It deliberately over-detects — a one-cell glyph in its own one-cell box renders identically, so the only cost of a false positive is an extra span. Regional indicators are in its ranges because a flag cell starts with one (see below).\n\nThe opposite mismatch also exists: a glyph that is **one** column to tmux but wider than one cell in the font. `⎿` (U+23BF, the elbow Claude Code draws before every tool result) is ~1.6 cells in FiraCode Nerd Font, and Nerd Font icons in the private-use area can be nearly 2. Grouped into a run, such a glyph laid the rest of the run out ~0.6 cell too long — the box stayed pinned, but the text inside painted past it and over the run that followed. `utils/glyphFit.ts` measures a symbol's advance against the font's cell once (a hidden probe with the real `.terminal-content` styles; the ratio is size-independent and cached per cell string, cleared when the webfont finishes loading), and a glyph that measures more than 1.15 cells gets its own 1-cell box with the glyph `scale()`d into it from the left edge (`.terminal-fit-glyph`). Only code points from U+2000 up are measured, so ordinary text costs one compare per cell. Story: `TerminalLine › FatNarrowGlyph`."},{"doc":"docs/RICH-RENDERING-CELL-GRID.md","heading":"Cell widths must agree with tmux","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-CELL-GRID.md#cell-widths-must-agree-with-tmux","text":"tmux reports cursor positions and lays out everything on **its** grid; tmuxy re-emulates the raw output with the vendored `vt100` crate. Any character whose width the two disagree on shifts every later cell on that line — the line looks fine on its own but the cursor lands two cells off, and the next absolute cursor move writes over the wrong columns. Per-code-point East Asian Width (what `unicode-width` gives) is not enough: tmux 3.4+ combines sequences into one cell in `screen_write_combine`, and the vendored `vt100` (`packages/vt100/src/screen.rs`) mirrors those rules exactly:\n\n| Input | tmux / tmuxy cell | Naive per-code-point width |\n| --- | --- | --- |\n| ZWJ sequence `👩‍💻` | one 2-column cell (the character after a ZWJ joins the previous cell) | 4 columns |\n| emoji + skin tone `👍🏽` | one 2-column cell (only for tmux's list of 66 base emoji; the modifier alone is its own 2-column character) | 4 columns |\n| two regional indicators `🇺🇸` | one 2-column cell (a third indicator starts a new cell) | 2 × 1 column |\n| text symbol + VS16 `❤️` | one 2-column cell (tmux default: `variation-selector-always-wide` on) | 2 columns |\n| combining mark `é` | joins the previous cell, through a wide character's continuation half | 1 column |\n| `U+3164` Hangul filler | dropped | 1 column |\n| ZWJ / VS16 at column 0 | dropped | — |\n| sequence longer than the cell buffer (22 bytes; tmux 21) | the overflowing character starts a new cell | — |\n\nThese are the rules of tmux 3.4 and later. tmux 3.2 sizes ZWJ sequences, skin-tone sequences and VS16 symbols differently, and tmuxy does not follow it there — a known limitation on the oldest supported tmux, described in [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#tmux-version).\n\nThe E2E test in `tests/3-rendering-protocols.test.js` (\"Wide glyphs stay on the tmux cell grid\") is the contract: it prints each case, parks the shell in `read`, and compares the _rendered_ cursor and marker positions against tmux's own `#{cursor_x}`. Add a row to that test whenever a width rule changes."},{"doc":"docs/RICH-RENDERING.md","heading":"Rich Terminal Rendering","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#rich-terminal-rendering","text":"Tmuxy intercepts terminal escape sequences that would otherwise be passed through to (or dropped by) tmux and renders them as real DOM elements. The browser shows actual `<a>` for hyperlinks, `<img>` for inline images, and clipboard requests round-trip through `navigator.clipboard`.\n\nThis document covers what's supported, how the pipeline works, and how to test each protocol."},{"doc":"docs/RICH-RENDERING.md","heading":"Supported protocols","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#supported-protocols","text":"| Protocol                             | DCS / OSC / APC                            | Backend                                                                                                                 | Frontend                                                                                                   | Notes                                                                                                                                                                                                                                                                                                                                                                                                                      |\n| ------------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| **OSC 8 — Hyperlinks**               | `ESC ] 8 ; … ; <url> ST`                   | `control_mode/osc.rs` (style on cell)                                                                                   | `TerminalLine.tsx` → `<a href>`                                                                            | Parsed from the control-mode stream; no `terminal-features` setting needed. Clicks open through `utils/openUrl.ts`, not the anchor's own navigation: `window.open` on the web, the `open_url` Tauri command (system browser, http(s)/mailto only) on the desktop, whose webview denies new windows. Auto-detected URLs (`utils/urlDetect.ts`) use the same path and are live only while the platform link modifier is held |\n| **OSC 1337 — iTerm2 Inline Images**  | `ESC ] 1337 ; File=… : <base64> BEL`       | `control_mode/images.rs::try_parse_iterm2`                                                                              | `Terminal.tsx` → `<img src=\"/api/images/…\">`                                                               | Base64 of any browser-renderable format                                                                                                                                                                                                                                                                                                                                                                                    |\n| **APC \\_G — Kitty Graphics**         | `ESC _ G <keys> ; <payload> ESC \\`         | `control_mode/images.rs::try_parse_kitty`                                                                               | same                                                                                                       | Supports chunked transfers whose final packet sets `m=0` or omits `m`, formats `f=24`/`f=32`/`f=100` (the bytes of an `f=100` image are kept as they came and typed by their signature, so a GIF sent that way animates — what the demo's nyan cat does, `packages/tmuxy-demo/public/guest/icat`), ZLIB compression (`o=z`), and Unicode placeholders (`U=1`) used by Kitty's tmux passthrough mode                        |\n| **DCS Pq — Sixel**                   | `ESC P q … ESC \\`                          | `control_mode/images.rs::try_parse_sixel`                                                                               | same                                                                                                       | Decoded by `icy_sixel`, re-encoded as PNG before serving. Only a DCS whose bytes before `q` are digits and `;` is Sixel: other DCS strings holding a `q`, such as nvim's XTGETTCAP query (`ESC P + q`), are not pictures                                                                                                                                                                                                   |\n| **OSC 52 — Clipboard**               | `ESC ] 52 ; c ; <base64> ST`               | `control_mode/osc.rs` parser → `StateEmitter::write_clipboard` → SSE `clipboard` event (web) / `tmux-clipboard` (Tauri) | the transport's `Clipboard` event → `TMUX_CLIPBOARD` event → `navigator.clipboard.writeText` in appMachine | Outbound only — pasting back is not implemented. Covered end to end by `tests/3-rendering-protocols.test.js` Scenario 14, which reads the clipboard write.                                                                                                                                                                                                                                                                 |\n| **DEC Line Sizes — DECDHL / DECDWL** | `ESC # 3`, `ESC # 4`, `ESC # 5`, `ESC # 6` | `packages/vt100` (`LineSize` per row) + `extract_cells_with_urls` (`CellStyle.size`)                                    | `TerminalLine.tsx` & `terminalRendering.ts` → `.terminal-scaled` (`dwl`, `dhl-top`, `dhl-bottom`)          | `ESC # 3`/`# 4` render top/bottom halves of 2×2 double-height lines; `ESC # 6` renders 2×1 double-width; `ESC # 5` resets to single width. Preserved across `capture-pane` refreshes while the row's text matches                                                                                                                                                                                                          |\n| **OSC 66 — Kitty Text Sizing**       | `ESC ] 66 ; s=<n>:w=<n> ; <text> ST`       | `control_mode/osc.rs` (`emit_osc66`, `sized_cells`)                                                                     | same                                                                                                       | `s=2` emits `dhl-top` on row `r` and mirrors `dhl-bottom` onto row `r+1`; `w=2` (`s=1:w=2`) emits `dwl`                                                                                                                                                                                                                                                                                                                    |\n\n**Where the cell -> URL mapping comes from.** The link is recorded against the\ncells vt100 reports as it writes them: `OscParser::process` strips the escapes\nand hands back the byte ranges a link was open over, and `PaneState` feeds those\nranges to vt100 one character at a time, marking whatever cell the cursor\nactually landed on. The marks then travel with their line, driven by the vt100\nscreen's own scroll counter. (The parser used to derive coordinates from a\nprivate cursor it advanced on newlines and printable ASCII only — blind to every\nCSI cursor move, so the URL drifted onto unrelated text.) Because tmux strips\nOSC 8 from `capture-pane` history, the map is cleared on a capture refresh and\nrepopulates from live output — a link is not restored by reloading the page.\n\n**A mark is only good while its text is.** Each mark also records the character\nthat was under it, and the map is read back a row at a time\n(`OscParser::row_urls`), never by coordinate alone: a run of adjacent cells\nsharing one URL was written as one label, so it is validated as one — if any\ncharacter under it has changed, the whole run goes. vt100 carries no per-cell\nhyperlink attribute, so a cell being overwritten is invisible to a coordinate\nmap; without the check, an application that repaints in place (Claude Code,\nvim, htop) scrolled nothing and cleared nothing, and every frame's text\ninherited the last frame's link — a long underline over unrelated text. The\nrun, not the cell, is the unit, or a redraw that happens to refill a cell with\nthe same character leaves a stray underline under it.\n\n**Auto-detected URLs stop where a URL must.** `utils/urlDetect.ts` matches the\ncharacters RFC 3986 allows in a URI, plus Unicode letters, digits and marks for\ninternationalised addresses. It is an allowlist on purpose: terminal UIs butt\ntheir text against separators with no space in between, and a class defined by\nwhat it excluded swallowed all of them — `…/bar│Files:12│Status:ok` came back\nas one link covering most of the line. Detection runs over a _logical_ line:\nrows that only soft-wrapped (the same full-width rule the copy path uses,\n`isWrappedRow`) are joined first, so a URL longer than the pane is one link on\nevery row it covers, each opening the whole address. OSC 8 links already arrive\nwhole on every row from the server. Either kind is one `<a>` per row, so\nhovering any row marks every anchor with the same target (`utils/linkHover.ts`).\n\nOSC 8 has been supported for a long time. The image protocols landed together with the OSC 52 parser — all parsing lives in `tmuxy-core/src/control_mode/images.rs` and `tmuxy-core/src/control_mode/osc.rs` — but only the SSE `clipboard` event + `TMUX_CLIPBOARD` plumbing finished the round-trip into `navigator.clipboard.writeText`. On the frontend, `Terminal.tsx` renders image placements and `TerminalLine.tsx` renders hyperlink cells."},{"doc":"docs/RICH-RENDERING.md","heading":"How tmux preserves the sequences","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#how-tmux-preserves-the-sequences","text":"Tmuxy speaks **tmux control mode** (`-CC`), and tmux forwards every escape sequence the running application emits inside `%output` events on stdout. That includes passthrough payloads. Earlier versions of this document claimed image escapes were stripped — that was wrong. The Rust parser sits on top of the control-mode byte stream and never needs to touch the pty directly.\n\nTwo tmux options matter:\n\n- `set -g allow-passthrough on` — required so tmux doesn't refuse to relay the sequences. The monitor sets this automatically on every session at connect time (see `sync_initial_state` in `tmuxy-core/src/control_mode/monitor.rs`).\n- `set -g default-terminal \"tmux-256color\"` (or another terminfo entry that does **not** include image capabilities) — keeps tmux from trying to act on the sequences itself.\n\nThe parser is permissive about line wrapping and stray printable bytes between chunked Kitty packets, since tmux re-flows output around its own column wrapping.\n\nAn application inside tmux wraps these escapes in **DCS passthrough** (`ESC P tmux; <payload, every ESC doubled> ESC \\`), which is how they reach a terminal sitting behind tmux at all. tmuxy is not behind tmux — it reads the pane stream over control mode — so the wrapper arrives here intact and the parser undoes it before anything else (`unwrap_passthrough`). Sending the escapes bare works too, but wrapping is better even here: tmux reads a lone APC string as a **pane title** (`input_exit_apc`), so an unwrapped Kitty frame turns the pane's title into base64 while it draws.\n\nA program can draw a moving picture this way: move the cursor to the picture's corner and emit one `OSC 1337` image per frame, with `preserveAspectRatio=0` when the picture should fill its cells exactly (so a clicked cell maps straight to one of its pixels). The same-anchor replacement below gives flicker-free frames, and the per-pane store cap (`MAX_PANE_IMAGE_BYTES` in `tmuxy-core/src/transport/bindings.rs`) is what keeps a pane that publishes a picture several times a second from growing without limit.\n\nA picture is retired in one of four ways. A screen clear takes it away: `ESC [ 2 J`, or `ESC [ J` straight after the cursor goes home (how `clear` and curses applications empty the screen), and a full reset (`ESC c`). A screen switch sets it aside: entering the alternate screen (`?1049` / `?1047` / `?47`) hides the main screen's pictures, anything drawn there is dropped on leaving, and the main screen's come back — a placement is anchored to cells rather than drawn into them, so vt100 clearing its grid never touched it, and an image outlived the command or the editor that showed it. Kitty's `a=d` deletes placements (the default `d=a` and `d=A` selectors, which mean \"all of them\"; the narrower ones address images by a kitty id tmuxy does not keep, so they are left alone). And a new placement anchored at the same cell **replaces** the one already there, so a preview that homes the cursor and repaints keeps exactly one frame on screen without stacking — and without the gap that deleting first would leave while the next frame crosses control mode. The frontend keys each `<img>` by that anchor rather than by the placement id, so a repaint reuses the element: the browser holds the previous picture until the new bytes have decoded, where building a fresh element painted nothing until they arrived and the preview blinked once a second."},{"doc":"docs/RICH-RENDERING.md","heading":"A live browser in a pane","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#a-live-browser-in-a-pane","text":"`tmuxy widget browser <url>` (also `tmuxy browser`) is the moving-picture case above, built in: a\nheadless Chrome drawn into a pane as Kitty frames and driven from it. See\n[RICH-RENDERING-BROWSER.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-BROWSER.md)."},{"doc":"docs/RICH-RENDERING.md","heading":"Where the bytes come from","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#where-the-bytes-come-from","text":"The decoder hands the host two things: a placement (where on the grid, how big) and the picture's bytes. Placements travel with the pane state; the bytes do not, so each host serves them itself and the frontend asks for whichever URL that host understands.\n\n| Host             | Serves the bytes as                       | Registered in                |\n| ---------------- | ----------------------------------------- | ---------------------------- |\n| Web server       | `/api/images/<pane>/<id>`                 | `tmuxy-server/src/state.rs`  |\n| Desktop app      | `tmuxyimg://localhost/<pane>/<id>`        | `tmuxy-tauri-app/src/gui.rs` |\n| Storybook, tests | whatever `window.__tmuxyImageSrc` returns | the story or test            |\n\nA host that keeps no bytes renders no pictures, whatever the decoder did with them: `StateEmitter::store_images` defaults to discarding, and the desktop app took that default for a long time while its frontend asked for the web server's route, so every image protocol silently drew nothing there. Neither URL is cached — placement ids restart with the process, so a cached response is the previous run's picture."},{"doc":"docs/RICH-RENDERING.md","heading":"End-to-end pipeline","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#end-to-end-pipeline","text":"```\nrunning app\n   │ writes escape bytes\n   ▼\ntmux (-CC, passthrough)\n   │ wraps as %output %<pane> <bytes>\n   ▼\ntmuxy-core / control_mode/parser.rs\n   │ feeds raw payload to\n   ▼\ncontrol_mode/images.rs::ImageParser::process\n   │ ─► extracts payload, decodes, stores image bytes in pane state\n   │ ─► returns ImageProcessResult { stripped: bytes_to_render,\n   │                                  new_placements: [(id, StoredImage)…] }\n   ▼\nServerPane.images  (snake_case, serialized over SSE)\n   │\n   ▼\nappMachine.helpers::transformServerState  (snake → camel via camelize())\n   │\n   ▼\nTmuxPane.images : ImagePlacement[]\n   │\n   ▼\nTerminalPane.tsx → Terminal.tsx\n   │ renders <img class=\"terminal-image\"\n   │              src=\"/api/images/<paneNum>/<imageId>\"\n   │              data-protocol=\"iterm2|kitty|sixel\"\n   │              style=\"top: …; left: …; width: …; height: …\" />\n   ▼\nGET /api/images/{pane_id}/{image_id}\n   │ served by tmuxy-server (see api_routes in state.rs)\n   ▼\nstored bytes (already in the format the browser expects: PNG/JPEG/WebP)\n```\n\n`ImageParser` keeps the raw bytes per `(pane_id, image_id)` so that re-renders, viewport changes, and reconnects all read the same content. Sixel input is converted to PNG once at parse time so the server doesn't repeat the decode on every fetch."},{"doc":"docs/RICH-RENDERING.md","heading":"Placement geometry","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#placement-geometry","text":"Each `ImagePlacement` carries:\n\n- `id` — monotonic per-pane image counter, matches the URL path\n- `row`, `col` — top-left cell coordinates (`row` is signed `i32`: `0..height-1` on the live viewport, negative when scrolled into scrollback history)\n- `width_cells`, `height_cells` — bounding box in terminal cells\n- `protocol` — one of `iterm2`, `kitty`, `sixel`\n\nThe frontend positions the `<img>` absolutely inside `.terminal-images` in cell units — `--cell-w` horizontally, `--line-height-terminal` vertically — so the image stays aligned to the same cell grid as the surrounding text. This is the same grid the rows and the cursor use; see [The cell grid invariant](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-CELL-GRID.md). When `width=auto` / `height=auto` is requested, the parser converts pixels to cells using the pane's current cell size estimate.\n\n**Content-tracked scrolling and scroll/copy mode.** `PaneState::sync_url_scroll` reads `vt100::Screen::scroll_delta()` as terminal output is processed and calls `ImageParser::shift_rows_up` / `shift_rows_down` to shift each placement's `row` in lockstep with the surrounding text. Placements that scroll above the live viewport (`row < 0`) are retained until their bottom edge (`row + height_cells`) passes the scrollback limit (`IMAGE_HISTORY_ROWS`), and `ScrollbackTerminal.tsx` renders `.terminal-images` at `top: calc((historySize + row) * var(--line-height-terminal))` so images remain visible when scrolling back in scroll or copy mode. Primary-screen placements also survive pane zoom and resize (`ImageParser::reset_for_resize` only clears placements on the alternate screen, where full-screen apps redraw on `SIGWINCH`).\n\nSee [RICH-RENDERING-CELL-GRID.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-CELL-GRID.md) for this section: The cell grid invariant."},{"doc":"docs/RICH-RENDERING.md","heading":"Testing","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#testing","text":""},{"doc":"docs/RICH-RENDERING.md","heading":"Manual","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#manual","text":"```bash\n# OSC 8 hyperlink\nprintf '\\e]8;;https://example.com\\e\\\\Click me\\e]8;;\\e\\\\\\n'\nls --hyperlink=auto\n\n# iTerm2 inline image (requires `imgcat` from iTerm2 utilities, or any\n# tool that emits OSC 1337 File=…)\nimgcat path/to/image.png\n\n# Kitty graphics protocol\nkitten icat path/to/image.png   # the official client\n# or any application using zellij / ranger's kitty image preview\n\n# Sixel\nimg2sixel path/to/image.png\nchafa --format sixel path/to/image.png\n```\n\nInside a real tmuxy session, each of these renders the image inline in the pane."},{"doc":"docs/RICH-RENDERING.md","heading":"Automated","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#automated","text":"- **Rust unit tests** (`tmuxy-core/src/control_mode/images.rs`) cover the parsers and placement geometry — including chunked Kitty transfers, RGBA-to-PNG conversion, and Sixel decode.\n- **E2E** runs a real tmux session, emits each protocol with a shell command, and asserts the `<img>` shows up in the page (see `tests/`)."},{"doc":"docs/RICH-RENDERING.md","heading":"Other rich-rendering opportunities","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#other-rich-rendering-opportunities","text":"These are documented because they reuse the same passthrough pipeline; whether to implement them is a product decision, not a technical one.\n\n- **OSC 9 / OSC 777 — Desktop notifications**\n- **OSC 9;4 — ConEmu progress bars**\n- **OSC 133 — FinalTerm semantic zones** (prompt / command / output markers)\n- **OSC 7 — Working-directory hints** (already partially wired for tab titles)"},{"doc":"docs/RICH-RENDERING.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md#related","text":"- [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md) — control-mode protocol and version quirks\n- [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md) — SSE/HTTP delivery of placements and image bytes\n- [NON-GOALS.md](https://github.com/flplima/tmuxy/blob/main/docs/NON-GOALS.md) — what we still deliberately don't render"},{"doc":"docs/SECURITY-CONTENT.md","heading":"Security: Untrusted Content and Recorded State","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#security-untrusted-content-and-recorded-state","text":"Previewed files, session snapshots and hostile pane output. Part of [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md)."},{"doc":"docs/SECURITY-CONTENT.md","heading":"A Preview Reads One File","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#a-preview-reads-one-file","text":"`tmuxy open <file>` shows a local markdown document or HTML page in a pane (the `preview` widget, see [ARCHITECTURE-WIDGETS.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE-WIDGETS.md)). A file is the least trusted thing tmuxy draws: a page downloaded from anywhere, a README from a cloned repository, a report a tool wrote from data it did not check. The design assumes the document is hostile and keeps two questions apart — what it may **read**, and what it may **do**.\n\n**What may be read is decided by tmux, not by the client or the document.** The UI reads the file through a client command, `read_preview`, naming only the pane; the path is the pane's `@tmuxy-pane-preview`, honoured only while `@tmuxy-pane-widget` is `preview`. `tmuxy-widget-preview` writes both out of band through `run-shell` and the widget clears its tag on exit, so neither pane output nor a client can point a preview at another file, and a pane that has gone back to being a shell reads nothing. Both transports call the same check (`tmuxy_core::preview`), on every read. This replaced a file route that read any path the server process could; nothing serves an arbitrary path any more.\n\n**A document's own references are held to its folder.** A resource the document links — a stylesheet, a script, an image, a font — is read only when its path, relative to the document, stays inside the document's folder once symlinks are resolved, passes through no hidden name (`.ssh`, `.env`, `.git`, `..`), and is a type a page loads (`RESOURCE_EXTENSIONS`). A hostile page in `~/Downloads` cannot name `../.aws/credentials` as a stylesheet, nor `notes.txt` beside it. Every read is a regular file under a size cap (8 MiB a document, 16 MiB a resource), done off the async workers. The client applies the same rules before it asks (`domain/preview.ts`) so that what the server would refuse costs no round trip, but the server's check is the one that holds.\n\n**What a document may do depends on what it is.**\n\n- **Markdown is rendered by the app**, in the app's own origin, so nothing in it may execute: raw HTML in the document is not rendered at all, a link opens in the system browser only when it is `http(s)` or `mailto` and never navigates the app, and a remote image is not loaded unless the preview was opened with `--net`, since loading it tells its host the document was read. A ```mermaid fence becomes an SVG on this side of the boundary, where anything that executes can drive the API, so it is sanitized twice (SEC-20): by mermaid (`securityLevel: 'strict'`), and again at the sink (`widgets/preview/sanitizeSvg.ts`) — parsed in an inert document, every `on*` attribute and every element that fetches or runs removed (SVG's animation elements too, which could write a `javascript:` link after the walk), the HTML in a label held to text formatting, `<style>` held to rules scoped to the diagram, nothing in CSS or an attribute that fetches (`url(` other than a same-document `url(#…)`), a link's `href` narrowed to `http(s):` and same-document fragments and any other reference to `data:image/` and fragments (plus `http(s):` with `--net`). A link click opens the system browser and never navigates the app. Mermaid's theme options are locked (`secure`), so a diagram's `%%{init}%%` cannot bring CSS of its own. Two passes, because mermaid has had bypasses before and the blast radius here is a shell.\n- **A page runs in a sandboxed frame**, served under a policy of its own (`page_csp`): `sandbox allow-scripts` without `allow-same-origin`, so the page has an opaque origin — no cookies, no storage, no access to the app's DOM or API; it cannot open windows, submit forms, or navigate the app — and, unless the preview was opened with `--net`, nothing may be fetched at all (`connect-src 'none'`, images and fonts from `data:` only). The resources beside it are inlined into the page by the client, through `read_preview` under the rules above, before it is staged; `<base>` and `<meta http-equiv>` are taken out, so the page can neither re-aim its references nor add a policy that overrules the served one. The page reaches tmuxy's API no more than any other site does: its requests come from an opaque origin, which the [request guard](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#cross-origin-requests) refuses.\n\n  The policy is a response header rather than a `srcdoc`, because a `srcdoc` frame inherits the embedding app's policy — the desktop app forbids inline script, which would stop the page and the bridge alike — and because a meta tag is the document's to precede. So the client stages the composed page (`POST /api/preview/{pane}` on the web, `stage_preview` on the desktop), the server checks with tmux that the pane is previewing a page and takes `--net` from tmux too, never from the client, and the frame loads it from `GET /api/preview/{pane}` or the `tmuxypreview:` scheme with the policy as a header. Both routes exist only on a writable server.\n\n**The page talks to the app through one narrow bridge** (`widgets/preview/previewBridge.ts`): a script put first in the page posts where it is scrolled to (so a reload after the file changes opens in the same place), its `<title>`, a link the user followed (opened in the system browser when it is `http(s)` and leaves tmuxy), and a Ctrl or Alt chord pressed in the page — without which Ctrl+C would never reach the widget and the prefix never reach the app. Anything can post a message, so the app accepts one only from its own frame, in one of those four shapes, and replays a chord only while that frame holds the keyboard; it then moves the keyboard to the pane, so a page cannot follow one replayed chord with another, and a plain key is never replayed at all. Ctrl/Cmd+Enter is never replayed either, and the app answers waiting `pane send` questions with it only from a trusted (user-made) key event. The worst a hostile page can do through it is press one Ctrl or Alt chord each time the user clicks into it. An inlined stylesheet cannot close its `<style>` (`</style` is escaped as CSS), so a local `.css` file cannot add markup after the page's `<base>` and `<meta http-equiv>` were removed.\n\n**What a preview does not protect.** A document opened with `--net` can tell a remote host anything it can read — which, by the rules above, is its own folder's pages, styles, scripts and pictures. A page's scripts run, in an opaque origin, which is what local reports and charts need. And a preview shows the user what the file says: a convincing fake prompt drawn by a page is a page, in a pane whose header names the file."},{"doc":"docs/SECURITY-CONTENT.md","heading":"A Session Snapshot Is a Record of the Machine","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#a-session-snapshot-is-a-record-of-the-machine","text":"`tmuxy_core::session_snapshot` writes, on every change to a session's shape,\nwhat it would take to rebuild it: every pane's working directory, the argv of\nthe program it was running, whatever a program declared through\n`@tmuxy-pane-restore`, and — only when asked with `--scrollback` — the text on\nits screen. That is a map of what the user was doing, kept on disk.\n\nWhere it is kept follows from that. Snapshots live under the state directory\n(`TMUXY_STATE_DIR`, else the XDG state dir), one directory per tmux socket\n(`sessions/<socket>/`, since a `tmuxy` session on `tmuxy-dev` is not the one\non `tmuxy`), next to the trace, and no route serves them. The directory is\n`0700` and every snapshot `0600` (`tmuxy_core::private_files`), as are the\ntrace, the desktop log and the app's managed state, whatever the umask. The commands that\ntouch them are `list_snapshots` and `restore_session`.\n\nA restore runs what a snapshot says only in the sense that each pane OFFERS\nits shell line, never runs it, unless `--run` is given: the line is hung on the\npane as an `@tmuxy-ask` question of kind `offer`, shown as a one-line banner,\nand runs only when the user presses Enter on it or clicks it. Nothing is typed\ninto the shell, so nothing reaches it before the user has read it. A program that\nwas running when the snapshot was taken is not evidence that it should run\nagain; `rm -rf …` was also running. The one exception is tmuxy's own chrome:\nthe left sidebar comes back running its widget (`tmuxy widget tree`), and only\nthat exact command, because that is tmuxy's program, not the user's; anything\nelse a snapshot names for a sidebar is offered like any other command. A\nrestore puts back only the options capture writes (the float and sidebar\n`@tmuxy-*` window options, a pane's group id and place), never another option\na hand-edited file names, and a pane's scrollback reaches `run-shell` with its\n`#` doubled, so no `#(…)` in it runs. `tmuxy session restore --run` needs a\nterminal; a script or an agent gets every command offered. What `ps` finds in\na sidebar (the helper the widget sleeps in) is never recorded. A word a\nprogram declared through `@tmuxy-pane-restore` is written back onto the\nrestored pane as well as offered, and an offer nobody has answered is saved\nwith the pane, so the next restore offers it again rather than losing it. What `ps` finds is also held to an age: a\nforeground program younger than two seconds is a prompt's helper (`git\nstatus`, `id -Gn`) as often as a program the user just started, so it is left\nout and the keeper looks again once that age has passed. While a restore is rebuilding a session the autosave is held, and a shutdown in that window skips its last save: half a session must never become the latest snapshot. Snapshot files are\nread with the same suspicion as anything on disk: a file that does not parse,\nor claims a newer format, is logged and ignored, and the session starts fresh\n— it can never stop the server from starting."},{"doc":"docs/SECURITY-CONTENT.md","heading":"Pane Output Threat Model","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#pane-output-threat-model","text":"Bytes emitted by any command running in a pane reach parsers, image decoders, and link handlers in a browser or webview context. Unlike commands entered by the user, terminal output is frequently untrusted: displaying a file (`cat untrusted.txt`), running a test suite, fetching logs, or inspecting untrusted git repositories can output arbitrary byte streams to stdout or stderr."},{"doc":"docs/SECURITY-CONTENT.md","heading":"The Pipeline","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#the-pipeline","text":"```\n+----------------+      +-------------+      +----------------+      +-----------------+\n| Program in | ---> | tmux Server | ---> | tmuxy Server | ---> | Frontend Web/ |\n| Pane (stdout) |  | (%output) |  | (core stream) |  | Desktop Webview |\n+----------------+      +-------------+      +----------------+      +-----------------+\n  Untrusted bytes        Control mode         Deltas / SSE           Parsers, decoders,\n                                                                     DOM & Canvas render\n```"},{"doc":"docs/SECURITY-CONTENT.md","heading":"Trust Boundaries","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#trust-boundaries","text":"| Component | Trust Level | Rationale |\n| --- | --- | --- |\n| The host machine & tmux server | Trusted | Runs with the authenticated user's permissions |\n| The tmuxy backend server | Trusted | Local process mediating control-mode and state |\n| Pane output byte streams | **Untrusted** | Any program or untrusted input file can write arbitrary escape sequences or payload bytes |\n| Browser / Webview environment | Semi-trusted context | Has access to DOM, clipboard APIs, web workers, and backend API routes |"},{"doc":"docs/SECURITY-CONTENT.md","heading":"Threat Vectors","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#threat-vectors","text":"1. **DOM Injection & Cross-Site Scripting (XSS)**\n   - _Vector:_ Escape sequences or text runs attempting to inject HTML/SVG tags or execute script via DOM rendering.\n   - _Policy:_ Terminal text must render as plain text nodes or canvas ink, never via unescaped `innerHTML`. Dynamic titles, tab labels, and pane annotations originating from terminal output must be escaped or treated as text content.\n\n2. **Unsafe Hyperlinks (OSC 8 & Linkifiers)**\n   - _Vector:_ Explicit OSC 8 hyperlinks (`\\e]8;;<URL>\\e\\\\`) or implicit URL regex linkifiers matching dangerous schemes (e.g. `javascript:`, `data:`, `file:`, `blob:`).\n   - _Policy:_ URLs must pass an explicit allowlist of safe schemes (`http:`, `https:`, `mailto:`, `git:`). Any URL using a prohibited or unknown scheme must either be stripped or rendered as plain, unclickable text. Clicks must open in a new context with `rel=\"noopener noreferrer\"`. The URL is also pane output as far as memory goes: one longer than `MAX_HYPERLINK_URL_BYTES` opens no link, and the cells one OSC 8 pair covers share a single copy of it (`control_mode/osc.rs`) rather than each holding its own.\n\n3. **Image Decoder Exploits & Memory Exhaustion**\n   - _Vector:_ Protocols supporting embedded graphics (OSC 1337 iTerm2 images, Kitty graphics protocol, Sixel) transmitting malformed base64 payloads, decompression bombs, or extreme dimensions (e.g. 65535x65535 canvas).\n   - _Policy:_ Image dimensions and payload byte sizes must be strictly bounded before memory allocation or canvas creation (see [RICH-RENDERING.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING.md)). Image decoding must occur in isolated sandboxes or off-thread decoders, and invalid image headers must fail fast without consuming excessive CPU or memory. A Sixel image's canvas is bounded before it is decoded, from the raster attributes it declares and the columns and bands it draws (`sixel_extent`), to the same `MAX_DECODED_IMAGE_BYTES` as a Kitty or iTerm2 image. An escape split across many `%output` chunks is buffered without its earlier chunks being scanned again, and a run of unterminated escapes in one chunk is scanned once, so neither turns into quadratic work. A chunked Kitty transfer is bounded three ways (`control_mode/images.rs`): how many may be open at once, how large one may grow, and how long one may stay open without finishing — a transfer that stops sending is dropped after `MAX_CHUNKED_AGE` instead of living as long as the pane.\n\n4. **Clipboard Poisoning (OSC 52)**\n   - _Vector:_ A program writing malicious shell commands to the system clipboard via OSC 52, tricking the user into pasting and executing dangerous commands.\n   - _Policy:_ OSC 52 clipboard writes obey bounds rather than a prompt — a confirmation on every yank would break the legitimate use (nvim or tmux yanking over ssh, which is the reason the sequence is honoured at all) and train the user to click through it. The bounds: only the **active pane** may write, so a tailed log or a stray ssh session cannot replace what the user is about to paste; the decoded payload is capped (`MAX_CLIPBOARD_BYTES`); one write per pane per `MIN_CLIPBOARD_INTERVAL`, so a program writing in a loop cannot keep the clipboard from the user; and every accepted write announces itself on the status line, naming the pane and the size, so a clipboard the user did not set is never silent. tmux paste buffers, which are global to the tmux server, are mirrored only when a pane of _this_ session is in copy mode — and tmux's own OSC 52 handling is turned off on the session (`set-clipboard off`, enforced with the other settings in `control_mode/monitor.rs`), so a pane's write cannot reach the buffer mirror and arrive past the active-pane gate that way. A plain `tmux attach` to the tmuxy socket therefore does not get OSC 52 to its own terminal; tmuxy's clients do, through tmuxy. The server or client must never allow unauthorized background clipboard reads.\n\n5. **ReDoS & Parser Desynchronization**\n   - _Vector:_ Pathological escape sequences designed to trigger exponential regex backtracking in parsers or desynchronize the terminal state machine.\n   - _Policy:_ All sequence parsers and URL detectors must guarantee linear-time parsing. Malformed or truncated sequences must be discarded cleanly without locking the main rendering thread."},{"doc":"docs/SECURITY-CONTENT.md","heading":"What Fuzz Tests Must Guarantee","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#what-fuzz-tests-must-guarantee","text":"Automated fuzzing over terminal parsers and sequence handlers must verify:\n\n- **No Panics or Unhandled Exceptions:** The Rust aggregator (`tmuxy-core`) and frontend terminal stream consumers must never panic, throw unhandled exceptions, or crash when fed completely random or adversarial byte streams.\n- **Strict Scheme Filtering:** Fuzzing URI inputs against OSC 8 handlers and regex linkifiers must never emit an `<a>` tag with an href containing `javascript:`, `vbscript:`, or unvetted pseudo-protocols.\n- **Bounded Resource Consumption:** Feeding arbitrarily large or invalid base64 image chunks must terminate within bounded memory and time budgets.\n- **Parser Resynchronization:** An incomplete, nested, or corrupt escape sequence must not corrupt subsequent valid output lines or state updates."},{"doc":"docs/SECURITY-NETWORK.md","heading":"Security: Network Exposure","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#security-network-exposure","text":"Where the server listens, what it serves, and how to deploy it. Part of [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md)."},{"doc":"docs/SECURITY-NETWORK.md","heading":"Where the Server Listens","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#where-the-server-listens","text":"`tmuxy server` listens on **127.0.0.1** by default: reachable from this machine only, and from anywhere an SSH tunnel brings that port. No password is needed there.\n\nAny other `--host` (a LAN address, a VPN address, `0.0.0.0`) puts a shell on the network, so the server **refuses to start** unless one of these is given:\n\n| Flag | Meaning |\n| --- | --- |\n| `--password …` or `TMUXY_PASSWORD` | Every route requires HTTP Basic auth (below) |\n| `--no-auth` | Serve it open. Only for a network where everyone who can reach the port may already run commands as you — a container's published port, a VPN with nobody else on it. The server prints a warning at startup. |\n\nA `--host` that is not an IP address is an error; the server never falls back to listening on every interface. The routing and the startup check live in `tmuxy-server/src/server.rs`."},{"doc":"docs/SECURITY-NETWORK.md","heading":"Optional HTTP Basic Auth","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#optional-http-basic-auth","text":"For a barrier against unauthenticated access (e.g. a port scan reaching an exposed instance), start the server with a password:\n\n```bash\nTMUXY_PASSWORD='your-secret' tmuxy server --host 0.0.0.0  # env var keeps it out of `ps`\ntmuxy server --host 0.0.0.0 --password 'your-secret'      # or on the command line\n```\n\nWhen a password is set, **every** route — the frontend, `/events` (SSE), `/commands`, and all `/api/*` endpoints — requires HTTP Basic auth. The browser shows a native login prompt on first load; enter **any username** and the configured password (only the password is checked). Once entered, the browser caches the credentials and attaches them automatically to the SSE stream and every request. The password is compared in constant time, and unauthenticated requests get a `401` with a `WWW-Authenticate` challenge.\n\nA wrong password costs the peer time. The first few failures are free — someone at a browser prompt mistypes — and after that the refusal is held, doubling from one second to a cap of thirty, forgotten ten minutes after that peer stops trying; a correct password clears the count immediately. Every refusal is logged with the address it came from. The count is per peer IP, so **behind a reverse proxy the peer is the proxy** and the delay is shared by everyone behind it: the proxy is expected to do its own limiting, and `X-Forwarded-For` is deliberately not trusted here because anyone can send one.\n\nBasic auth is **not** a substitute for TLS (#2) — over plain HTTP the credentials are base64, not encrypted; combine it with an SSH tunnel, VPN, or a TLS-terminating reverse proxy."},{"doc":"docs/SECURITY-NETWORK.md","heading":"Behind a Reverse Proxy","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#behind-a-reverse-proxy","text":"A proxy on the same machine forwards to `127.0.0.1`, but it usually passes its public name through as the `Host` header, which the server does not recognise as itself (see below). Name it: `tmuxy server --allowed-host tmux.example.com` (repeatable, or `TMUXY_ALLOWED_HOSTS` comma-separated).\n\nForgetting it is easy to diagnose: the page itself still loads (static files are not guarded), every API route answers 403, and the app says so — _The server refused this page: request Host is not this server (see --allowed-host)_ — instead of waiting on a connection that cannot open (see `explainRefusal` in `tmuxy-ui/src/infra/transport/drivers/HttpAdapter.ts`)."},{"doc":"docs/SECURITY-NETWORK.md","heading":"Tauri Desktop App","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#tauri-desktop-app","text":"The desktop app serves no HTTP: all communication is local IPC within the app process. Its webview runs under a Content-Security-Policy (see [What the App Will Load and Draw](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#what-the-app-will-load-and-draw))."},{"doc":"docs/SECURITY-NETWORK.md","heading":"Cross-Origin Requests","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#cross-origin-requests","text":"The API is a remote shell, and a browser sends requests on behalf of whatever page is open in it. A site the user visits can POST to `http://localhost:9000/commands` without a CORS preflight (a `text/plain` body is enough), and a site whose domain is re-pointed at 127.0.0.1 (DNS rebinding) looks same-origin to the browser. So every API route checks where a request came from before any handler runs (`tmuxy-server/src/request_guard.rs`):\n\n| Header | Rule | Stops |\n| --- | --- | --- |\n| `Sec-Fetch-Site` | Must be `same-origin` (the app) or `none` (typed in the address bar) | Any other origin, including another port on localhost and a sandboxed page |\n| `Origin` | Must name the host the request was sent to | The same, in a browser without Fetch Metadata |\n| `Host` | Must be a loopback name, the address the server bound, or an `--allowed-host` | DNS rebinding |\n\nThe API sends **no CORS headers**, so no other origin can read a response even when a request is let through. A request with none of these headers is not a browser acting for a page (`curl`, a script) and is allowed. Cached Basic-auth credentials do not help a hostile page: its requests are refused by origin before the password matters.\n\nThe `Host` rule applies to a routable bind too: the address it bound is a name it answers to, and so is anything passed with `--allowed-host` (repeatable, or `TMUXY_ALLOWED_HOSTS`). The one gap left is a **wildcard** bind (`0.0.0.0`, `::`) given no allowed list, where the server genuinely does not know which of its addresses a request arrived on and any `Host` passes — the container case, where the published port is the boundary. Naming the host you reach it by closes that too."},{"doc":"docs/SECURITY-NETWORK.md","heading":"What the App Will Load and Draw","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#what-the-app-will-load-and-draw","text":"The desktop webview carries its own `Content-Security-Policy` (`tauri.conf.json`). It is defence in depth rather than a boundary — an XSS from pane output would run with the Tauri IPC behind it, which is `open_url`, the app's schemes and CLI exec, so it is worth more here than on the web. The policy names each scheme the app actually loads from (`tauri:`, `asset:`, `tmuxyimg:`, `tmuxycss:`, and their `http://<scheme>.localhost` forms on Windows), and its `frame-src` names one: `tmuxypreview:`, the page a `preview` pane frames, which is served under its own sandboxing policy (see [A Preview Reads One File](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#a-preview-reads-one-file)). The app frames nothing else. **Adding a resource the app loads means adding it here too** — a CSP refuses quietly, and the symptom is a picture that simply does not appear.\n\nWhich pane is a widget at all is not decided by pane content. The `__TMUXY_WIDGET__:<name>` marker is output, so anything a pane prints could otherwise replace it with app UI the user never asked for: the client renders a widget only when the pane also carries `@tmuxy-pane-widget` naming it, which `tmuxy-widget` writes out of band and clears on exit (see [docs/TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md))."},{"doc":"docs/SECURITY-NETWORK.md","heading":"Deployment Recommendations","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#deployment-recommendations","text":""},{"doc":"docs/SECURITY-NETWORK.md","heading":"Local Development (Lowest Risk)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#local-development-lowest-risk","text":"```\nDeveloper → Tauri Desktop App → local tmux\n```\n\nNo network exposure. Use the Tauri app for local development — it communicates via in-process IPC only."},{"doc":"docs/SECURITY-NETWORK.md","heading":"Remote Access via SSH Tunnel (Recommended)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#remote-access-via-ssh-tunnel-recommended","text":"```\nDeveloper → SSH tunnel → localhost:9000 → tmuxy server → tmux\n```\n\n1. Run `tmuxy server` on the remote machine (it listens on 127.0.0.1)\n2. From your local machine: `ssh -L 9000:localhost:9000 user@remote`\n3. Open `http://localhost:9000` in your browser\n\nSSH provides authentication, encryption, and access control. This is the recommended approach for single-user remote access."},{"doc":"docs/SECURITY-NETWORK.md","heading":"Remote Access via VPN (Recommended for Mobile)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#remote-access-via-vpn-recommended-for-mobile","text":"```\nMobile/Laptop → VPN (WireGuard/Tailscale) → tmuxy server → tmux\n```\n\n1. Set up a VPN between your devices and the remote machine\n2. Run `TMUXY_PASSWORD=… tmuxy server --host <vpn address>` on the remote machine\n3. Access via the VPN IP address\n\nThis is the recommended approach for mobile access where SSH tunnels are impractical."},{"doc":"docs/SECURITY-NETWORK.md","heading":"Remote Access via Reverse Proxy (Alternative)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#remote-access-via-reverse-proxy-alternative","text":"```\nBrowser → HTTPS → nginx/Caddy (+ auth) → HTTP → tmuxy server → tmux\n```\n\n1. Run `tmuxy server --allowed-host <public name>` on the server\n2. Configure nginx or Caddy with:\n   - TLS certificate (Let's Encrypt or self-signed)\n   - Authentication (basic auth, OAuth, client certificates)\n   - Proxy pass to `http://127.0.0.1:9000`\n   - SSE support enabled (no response buffering)"},{"doc":"docs/SECURITY-NETWORK.md","heading":"What NOT to Do","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#what-not-to-do","text":"- **Do NOT** expose tmuxy directly on a public IP\n- **Do NOT** pass `--no-auth` on a network other people share\n- **Do NOT** run tmuxy as root\n- **Do NOT** use tmuxy on shared/multi-tenant servers without network isolation\n- **Do NOT** store secrets (API keys, passwords, SSH passphrases) in tmux sessions that are connected to tmuxy on a network"},{"doc":"docs/SECURITY.md","heading":"Security","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#security","text":"Tmuxy is a **development tool** that is **not production-ready**. It provides direct access to a user's tmux session, which means full shell access to the host machine. This document describes the security model, known risks, and mitigations."},{"doc":"docs/SECURITY.md","heading":"Development Status Warning","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#development-status-warning","text":"Tmuxy is under active development and has not undergone a security audit. It is designed for use on **trusted networks** (localhost, LAN, VPN) by a **single user**. Do not deploy it on untrusted networks without additional security layers."},{"doc":"docs/SECURITY.md","heading":"Threat Model","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#threat-model","text":"Tmuxy assumes:\n\n- **Single user** per deployment (no multi-tenant access control)\n- **Trusted network** (localhost, LAN behind firewall, or VPN)\n- **Server runs as the same user** who owns the tmux session\n- **All clients of one server are equally trusted** (no per-client permissions) — a server is writable by everyone it serves\n\nIt does **not** assume the user's browser is trusted: the same browser that has tmuxy open visits other sites, and any of them can try to send requests to the tmuxy server. See [Cross-Origin Requests](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#cross-origin-requests).\n\nIf any of these assumptions are violated, the risks described below apply.\n\nSee [SECURITY-NETWORK.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md) for these sections: Where the Server Listens; Cross-Origin Requests; What the App Will Load and Draw; Deployment Recommendations.\n\nSee [SECURITY-CONTENT.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md) for these sections: A Preview Reads One File; A Session Snapshot Is a Record of the Machine; Pane Output Threat Model."},{"doc":"docs/SECURITY.md","heading":"Input That Reaches Control Mode","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#input-that-reaches-control-mode","text":"Control mode reads one command per line, so a newline inside anything written into a command line would end that command and start another.\n\n- **Session names** from `/events?session=` and `/commands?session=` are held to letters, digits and `_ - @ +` (`tmuxy_core::session::SessionName`, the only type the `has-session` check and the stream handler accept) and refused with `400` otherwise. The name goes into control-mode command lines and into `run-shell` strings that tmux format-expands before a shell sees them; an alphabet nothing in those contexts can misread replaces escaping for each. A `--session` pin outside it is refused at startup, since no client could name it. Every name tmuxy creates fits, and tmux itself forbids `.` and `:`. A stream for a session that does not exist has the session created for it, and that session and its shell outlive the stream — so the server creates one only while it has fewer than `MAX_AUTO_CREATED_SESSIONS` sessions (`tmuxy-server/src/sse.rs`) and answers `503` past that; a script that needs more creates them itself through `POST /commands`.\n- **Pane ids** a client sends (`get_scrollback_cells`) must parse as tmux's canonical `%<digits>` (`tmuxy_core::ids::PaneId`) before they are used as a `-t` target; a client only ever learned ids from `list-panes`, so `other:0.0` or `{last}` is not a pane it was shown.\n- **Literal text** — an IME composition, the selection menu's _Send keys_ — is typed one line at a time, one `send-keys -l` per line with `Enter` between them (`literalTextCommands` in `tmuxy-ui/src/infra/transport/keyBatching.ts`).\n- **A paste** is staged in a named tmux buffer and delivered with `paste-buffer -p` (`pastedText` in `tmuxy-ui/src/domain/commandHelpers.ts`). The text travels as a double-quoted string in which every newline, quote, `\\`, `$`, `~`, `;` and `#{` is an octal escape, so no part of it can end the command line or be read as tmux syntax, and any bracketed-paste markers in it are removed, so it cannot close the paste early. A program that turned bracketed paste on receives the text as one paste; into a shell that did not, it still runs line by line, exactly as in any terminal. In a window with `synchronize-panes` on, the paste goes to every synchronized pane, as typed input does (`packages/tmuxy-cli/bin/tmuxy/paste-synced`); only a pane id is ever put on that script's command line, never the session name a paste can fall back to."},{"doc":"docs/SECURITY.md","heading":"Known Risks","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#known-risks","text":""},{"doc":"docs/SECURITY.md","heading":"1. Remote Access When Exposed (High)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#1-remote-access-when-exposed-high","text":"**Risk:** A server reachable from other machines gives whoever reaches it full control over the tmux session.\n\n**Impact:** Arbitrary command execution on the host machine via `run-shell` commands or by typing into any pane.\n\n**Mitigation:**\n\n- The default listens on 127.0.0.1 only, and a routable address needs a password or an explicit `--no-auth` ([Where the Server Listens](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#where-the-server-listens))\n- **Never expose tmuxy directly to the internet**\n- Use an SSH tunnel: `ssh -L 9000:localhost:9000 user@server`\n- Use a VPN: WireGuard, Tailscale, or similar\n- Use a reverse proxy with authentication (nginx + basic auth, Caddy + OAuth)"},{"doc":"docs/SECURITY.md","heading":"2. No TLS/HTTPS (High)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#2-no-tlshttps-high","text":"**Risk:** All communication is over plain HTTP. Terminal content and commands are transmitted in cleartext.\n\n**Impact:** Network eavesdropping can observe all terminal output and see all keystrokes sent to tmux. An observer on-path can also capture Basic-auth credentials and inject commands.\n\n**Mitigation:**\n\n- Use a reverse proxy (nginx, Caddy) with TLS certificates for HTTPS\n- For LAN use, self-signed certificates are acceptable\n- SSH tunnels provide encryption by default"},{"doc":"docs/SECURITY.md","heading":"3. Arbitrary Command Execution (High)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#3-arbitrary-command-execution-high","text":"**Risk:** Any client allowed through can send any tmux command, including `run-shell` which executes arbitrary shell commands within the tmux server process.\n\n**Impact:** Full shell access as the user running the tmux server. Can read/write files, start processes, modify system state.\n\n**Context:** This is by design — tmuxy is a tmux UI, and tmux provides full shell access. Reaching the server as an allowed client is sufficient for code execution.\n\nWhat the server does _not_ do is interpolate a client's command into a shell of its own: every command goes down the monitor's control-mode connection as a tmux command line, reads included, so there is no `sh -c` for shell metacharacters to escape from."},{"doc":"docs/SECURITY.md","heading":"4. `--no-auth` on a Routable Address (Medium)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#4---no-auth-on-a-routable-address-medium","text":"**Risk:** Everyone on the network can reach the server with no password.\n\n**Impact:** Anyone on the network gets shell access.\n\n**Mitigation:** Use a password instead, or listen on 127.0.0.1 and tunnel. Keep `--no-auth` to an isolated network such as a container's published port on a single-user machine. DNS rebinding on top of this is now refused by the `Host` check unless the bind is a wildcard with no `--allowed-host` ([Cross-Origin Requests](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#cross-origin-requests)); naming the host closes that case."},{"doc":"docs/SECURITY.md","heading":"5. Browsers Without Fetch Metadata (Low)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#5-browsers-without-fetch-metadata-low","text":"**Risk:** A browser that sends neither `Sec-Fetch-Site` nor `Origin` on a simple `GET` (old releases) lets a hostile page trigger `GET` routes — open an event stream, request a picture.\n\n**Impact:** The page cannot read any response (no CORS headers), and a session name that would inject a command is refused. The requests themselves still reach the server."},{"doc":"docs/SECURITY.md","heading":"6. No Audit Logging (Medium)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#6-no-audit-logging-medium","text":"**Risk:** No logging of commands executed, sessions created, or clients connected. Requests refused by the origin check are logged as warnings.\n\n**Impact:** No forensic trail if unauthorized access occurs."},{"doc":"docs/SECURITY.md","heading":"LLM-Assisted Development Risks","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#llm-assisted-development-risks","text":"When using AI coding assistants (Claude, Copilot, etc.) with tmuxy running:"},{"doc":"docs/SECURITY.md","heading":"The AI Has Your tmux Session","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#the-ai-has-your-tmux-session","text":"If an AI agent has access to the machine where tmuxy is running, it can interact with your tmux sessions. This includes:\n\n- Reading terminal output from all panes\n- Sending keystrokes to any pane (`tmuxy pane send -t` into a pane that is not the caller's asks the user there first, unless `@tmuxy-send-ask` is off or the caller is on `@tmuxy-send-allow`)\n- Running shell commands via `run-shell`\n- Creating/destroying windows and panes\n\n**What the send question holds, and what it cannot.** Through tmuxy's CLI there is one way into another pane: `tmuxy pane send -t` (and `tmuxy pane paste -t`, which asks the same question). A caller counts as being in a pane only when that pane's process is one of its ancestors, not because it sets `TMUX_PANE`; a caller in no pane is asked before typing into the one in view. `tmuxy run` refuses input into another pane, commands that run a shell or change what one runs (`run-shell`, `if-shell`, `respawn-*`, `set-environment`, `set-hook`, `source-file`), and writes to the question's own options (`@tmuxy-ask*`, `@tmuxy-send-*`, `@tmuxy-pane-restore`); `tmuxy pane restore-cmd` sets only the caller's own pane; `tmuxy session restore --run` needs a terminal.\n\nNone of that reaches a process that runs raw `tmux` on the socket as the same Unix user: tmux has no notion of which client may do what. Close that door in the agent's own permissions — for Claude Code, deny rules such as `Bash(tmux *)` and `Bash(* tmux *)` while allowing `Bash(tmuxy *)` — or run the agent as another user."},{"doc":"docs/SECURITY.md","heading":"Prompt Injection via Terminal Output","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#prompt-injection-via-terminal-output","text":"Terminal output from running processes could contain text that looks like instructions to an AI agent. If the agent reads pane content and acts on it, malicious programs could manipulate the agent's behavior. This is a general risk of AI agents interacting with untrusted output."},{"doc":"docs/SECURITY.md","heading":"Agents the Repository Starts Itself","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#agents-the-repository-starts-itself","text":"The weekly `sunday sweep` workflow (`bin/ci/sunday-sweep.mjs`) hands open high and critical Dependabot and CodeQL alerts, and Dependabot PRs whose checks fail, to the Copilot cloud agent. It runs on a fine-grained token, `COPILOT_AGENT_PAT`, scoped to this repository alone: metadata, Dependabot alerts and code scanning alerts read; contents, issues and pull requests read/write; agent tasks write. `GITHUB_TOKEN` can neither read Dependabot alerts nor start the agent. The token is used by that scheduled job only, never by a workflow a pull request can trigger, so a fork or a Dependabot PR cannot reach it.\n\nAlert text and CI logs are other people's words, and they become part of the agent's prompt: an advisory summary or a failing log could carry instructions. The sweep fences that text as data and caps how much of it is passed. The real safeguard is that the agent's PRs are drafts whose workflows wait for a maintainer's \"Approve and run\": nothing the agent writes runs in CI, or reaches main, without a person reading it first. Keep that setting on. At most three tasks start per run, so a flood of alerts cannot run up the agent's cost unattended.\n\n`dependabot actions merge` merges Dependabot's bumps of the workflows' own actions once `lint and tests` is green on them, and nothing else. It runs on `workflow_run`, in this repository's context, and never checks the PR's code out. npm and cargo bumps stay manual."},{"doc":"docs/SECURITY.md","heading":"Recommendations for AI-Assisted Development","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#recommendations-for-ai-assisted-development","text":"- Review AI-generated commands before they execute in tmux\n- Use separate tmux sessions for sensitive work (SSH keys, credentials, production systems)\n- Be cautious about AI agents that have both tmuxy access and internet access\n- Monitor what commands the AI sends through the tmuxy interface"},{"doc":"docs/SECURITY.md","heading":"Future Security Improvements","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#future-security-improvements","text":"Implemented:\n\n- **Loopback by default** — a routable address needs a password or `--no-auth`\n- **Optional HTTP Basic auth** — `--password` / `TMUXY_PASSWORD` gates every route\n- **Cross-origin guard** — Fetch Metadata, `Origin` and `Host` checks on every API route; no CORS headers\n\nNot yet implemented, but would improve the security posture:\n\n- **Bearer token auth** — token-based auth as an alternative to Basic\n- **TLS support** — Built-in HTTPS with certificate configuration\n- **Command allowlisting** — Restrict which tmux commands clients can execute\n- **Per-client permissions** — different rights for different clients of one server\n- **Audit logging** — Log all commands and client connections\n- **Rate limiting** — command flooding (failed passwords are already rate-limited, see [Optional HTTP Basic Auth](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md#optional-http-basic-auth))"},{"doc":"docs/SECURITY.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md#related","text":"- [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md) — Deployment scenarios with security guidance for each\n- [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md) — Control mode constraints (commands that must go through control mode)"},{"doc":"docs/STATE-MANAGEMENT-ACTORS.md","heading":"Frontend State: Context, Actors and Layout","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-ACTORS.md#frontend-state-context-actors-and-layout","text":"The app machine's context, its actors and child machines, and how the code is split. Part of [STATE-MANAGEMENT.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md)."},{"doc":"docs/STATE-MANAGEMENT-ACTORS.md","heading":"Machine Context","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-ACTORS.md#machine-context","text":"The context holds all frontend state. Key fields:\n\n**From backend (synchronized via state updates):**\n\n- `panes: TmuxPane[]` — All panes from tmux (content, cursor, dimensions, metadata)\n- `windows: TmuxWindow[]` — All windows\n- `activePaneId`, `activeWindowId` — Current focus\n- `totalWidth`, `totalHeight` — tmux grid dimensions\n- `keybindings` — Prefix key and all bindings from tmux config\n- `defaultShell` — Default shell (bash, zsh, etc.)\n\n**Client-only state:**\n\n- `drag: DragState | null` — Current pane drag operation\n- `resize: ResizeState | null` — Current pane resize operation\n- `paneGroups: Record<string, PaneGroup>` — Pane group tabs (hidden windows)\n- `floatPanes: Record<string, FloatPaneState>` — Floating pane positions\n- `commandMode` — Client-side command prompt (`:` mode)\n- `paneActivationOrder` — Most-recently-used pane order\n- `paneKeyOverrides` — Real-pane-id → placeholder-id mapping (mirrored from TmuxStore so React keys stay stable across optimistic-placeholder → real-pane id swaps)\n- `charWidth`, `charHeight`, `cellGap` — the cell grid: `charWidth` is the font advance measured once and snapped to a whole device pixel, `cellGap` the letter-spacing that makes text advance by exactly that width; both are published to CSS as `--cell-w` / `--cell-gap` on the app root (see `utils/cellMetrics.ts`, [RICH-RENDERING.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-CELL-GRID.md#the-cell-grid-invariant))\n- `measuredCell` — the cell the size actor measured (or cut for the phone) before any fit; `charWidth`/`charHeight`/`cellGap`/`gridFontSize` are the grid's cell fitted from it (`fittedGridCell`), and the sidebar columns and `targetCols`/`targetRows` are measured in it\n- `sizePolicy` — the config's `@tmuxy-size` (`smallest`, `largest`, `latest` or a fixed `COLSxROWS`), read with the appearance options. Every mode but `smallest` fits the grid's cell to the window tmux was given; a fixed one is also what `targetCols`/`targetRows` ask for\n- `targetCols`, `targetRows` — Browser viewport in character units\n- `containerWidth`, `containerHeight` — Browser container pixel dimensions\n- `themeName`, `themeMode`, `availableThemes` — Theme settings\n- `enableAnimations` — CSS transitions toggle (disabled on load, enabled after state settles)\n- `animationsAllowed` — the config's `@tmuxy-animations` switch (`tmuxy.conf`, read with the appearance options); false strips the pane transitions and the float keyframes for the session, whatever `enableAnimations` settles to\n- The pictures a tab shows of itself — the Tab Overview's cards and the strip's hover preview — read the panes through `hooks/useTabStill.ts`, which re-samples once a second. Following the panes live churns a busy tab's thumbnail on every frame of output; freezing them when the picture opens lets it go stale while it is on screen. No machine field holds it: it is what the two components are looking at, not session state\n- `cursorBlink` — the config's `@tmuxy-cursor-blink` switch, read with the appearance options and flipped from View > Blinking Cursor; the backend writes the new value to the managed state file so it outlives a tmux restart. It is a permission, not an order: a running application that asked for a steady cursor through DECSCUSR still gets one, and the copy-mode cursor never blinks\n- `transparency` — the config's `@tmuxy-transparency` switch, read with the appearance options and flipped from View > Enable Window Transparency (macOS desktop only); the backend writes it to the managed state file and pushes the theme settings straight back, so the surfaces repaint opaque (`applyAppearance`) as every window's blur is cleared\n- `tabOverviewCols` — the config's `@tmuxy-tab-overview-cols` (default 3, capped at 12 by the backend), read with the appearance options: how many cards each row of the \"all tabs\" view holds. A phone-width window shows at most 2; the grid scrolls vertically when the rows outgrow the view\n- `gestureFlags` — the config's `@tmuxy-gesture-swipe-tabs`, `@tmuxy-gesture-pinch-zoom` and `@tmuxy-gesture-pinch-overview` (all on by default), read with the appearance options. `gestureActor` reads them off the machine as each event arrives and claims nothing for a gesture that is off; the desktop shell is told the slide flag so it leaves those scroll events to the webview\n- `gesture` — the trackpad gesture being drawn, owned by the `gestures` state. **Desktop only** (`docs/DESKTOP.md`): the web build runs the machine's no-op gesture actor. On the desktop `gestureActor` takes the slide from the shell (`window.tmuxyGesture`, with the real began/changed/ended phases a page never gets) and the pinch from WebKit's `gesturestart/change/end` (or ctrl+wheel on WebKitGTK), and turns them into `GESTURE_*` events. `GestureStage` writes each step as an inline `transform` on `.pane-layout` (or on the pinched pane) and nudges the cursor overlay: `transform` is not inherited and runs on the compositor, so a step invalidates no terminal styles and re-renders nothing; `PaneLayout` only re-renders when the gesture's shape changes. A slide moves the grid with the tab it pulls in drawn beside it from its real (normally hidden) panes. Releasing it past the mark switches tabs in that same step — the keyboard, the tab strip and tmux never wait for an animation — and because the tab switching in is already drawn where it will rest, the grid is simply put back by that offset and run home (`finishing`), with the tab just left sliding out beside it; released short, it slides back (`cancelling`). Neither ever reverses. What commits is where the slide would land carried on at the speed the fingers had (`swipeCommits`): a quarter of the pane width, capped at a distance a trackpad can cover, so a slow drag short of that settles back — and a flick switches on its speed alone, however short; the finish then takes as long as that distance at that speed (`swipeSettleMs`, 90-320ms). Past either end of the strip the grid gives with rubber-band resistance. The fingers resting on the pad mid-slide is simply no event: the slide ends at the shell's `ended`, never on a timer, and the momentum tail macOS sends after a lift is swallowed by the shell. A pinch in shrinks the grid (opening the \"all tabs\" view, or unzooming), a pinch out grows the pane under the fingers (zooming it), and a pinch out of the \"all tabs\" view grows the grid out of the current tab's card and enters that tab. A committed zoom holds the drawing (`handoff`) until `PaneLayout`'s zoom MutationObserver sees the new geometry land and animates on from the held box (`utils/zoomHandoff.ts`)\n- Zoom animation — `PaneLayout` animates a pane zooming or unzooming from a MutationObserver on the pane boxes' inline style, not from a render diff: the zoomed flag can land a render after the geometry, so the observer catches the exact mutation, before paint, with the box the pane really had"},{"doc":"docs/STATE-MANAGEMENT-ACTORS.md","heading":"Actors","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-ACTORS.md#actors","text":"The machine invokes five persistent actors:\n\n**`tmuxActor`** (`tmuxy-ui/src/machines/actors/tmuxActor.ts`) — Bridge to the Rust backend. Receives `SEND_COMMAND`, `INVOKE`, `FETCH_INITIAL_STATE`, `FETCH_THEME_SETTINGS`, etc. from the parent. Sends `TMUX_CONNECTED`, `TMUX_STATE_UPDATE`, `TMUX_ERROR`, `CONNECTION_INFO`, `KEYBINDINGS_RECEIVED`, `TMUX_CLIPBOARD` to the parent.\n\n**`tmuxStoreActor`** (`tmuxy-ui/src/machines/actors/tmuxStoreActor.ts`) — Bridges the client model (`TmuxStore`) into XState. Receives `DISPATCH_COMMAND` (optimistic dispatch) and `RECONCILE_SERVER` (server snapshot reconciliation) from the parent; forwards every model change back as `TMUX_MODEL_UPDATE`.\n\n**`keyboardActor`** (`tmuxy-ui/src/machines/actors/keyboardActor.ts`) — DOM keyboard input handling. Manages prefix mode (waits for next key after prefix), the text-vs-chord classification that decides whether a keydown is sent as literal text or as a tmux key name (dead keys, macOS Option and AltGr all compose characters behind chord-looking flags — see [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md)), IME composition support (a hidden input in `tmuxy-ui/src/utils/mobileKeyboard.ts` holds browser focus on every device and follows the pane, float or dock holding the keyboard so a composition can start; individual keydowns are suppressed during CJK input and the composed string is committed to the pane where the composition began), the Tab rule (Tab is the terminal's while a pane holds the keyboard; on a chrome control the keyboard reached, such as a header button, a tab strip or the tree's selected row, it walks the page's tab order, while a clicked control hands the focus straight back to the pane; panes are focusable for the pointer but never Tab stops; see `tmuxy-ui/src/utils/chromeTab.ts`), copy mode interception (all keys captured and sent as `COPY_MODE_KEY` when the active pane is in client-side copy mode), root bindings (bypass prefix), and paste chunking (large pastes split into 500-char chunks). Sends `SEND_TMUX_COMMAND`, `KEY_PRESS`, and `COPY_SELECTION` to the parent.\n\n**`sizeActor`** (`tmuxy-ui/src/machines/actors/sizeActor.ts`) — Viewport tracking. Measures monospace font char dimensions on start, listens to window resize (debounced 100ms), observes container with `ResizeObserver`. Sends `SET_CHAR_SIZE`, `SET_TARGET_SIZE`, `SET_CONTAINER_SIZE` to the parent. A sidebar column opens and shuts at once, and its toggle sends the grid its size for the width the column leaves (`selectSettledPaneWidth`) in the same step, so tmux re-tiles once rather than after the container's next measure.\n\nOn a phone (`(pointer: coarse), (max-width: 600px)`, or the `phoneLayout` config override a story uses) the size actor sends `SET_PHONE_LAYOUT` and the machine fixes every `SET_TARGET_SIZE` at 80 × 24: the window is never sized to the screen. Instead the cell shrinks until 80 columns span the pane container exactly (`utils/phoneLayout.ts`), which on a phone has 4px of padding each side so the grid's outer borders stay on screen; `SET_CHAR_SIZE` carries the grid's own font size, which `App.tsx` scopes to the terminal so the chrome keeps its size. The layout is then a tab strip of the app menu at its left end, the tabs and a \"+\" at its right end (`components/PhoneTabStrip.tsx`), the terminal with tmux's line under it in the chrome's type (the host at the left edge, the session at the right, a mode's chip alone in a mode), the key row (`components/KeyRow.tsx`: two rows of seven — esc, tab, ctrl and alt, whose latches go through `utils/keyLatch.ts` for the next key from the row or the phone keyboard; the prefix key, which presses the bindings' prefix as one chord and is lit while the prefix is armed, a second tap cancelling it; the ⌨ key, the one thing that puts the phone's keyboard up or away; the magnifier zoom key; the arrows, which repeat while held and hand over to the arrow a finger slides onto (`utils/keyRepeat.ts`); and enter, three keys wide), and the bottom view below (`components/PhoneBottomView.tsx`): the sidebar tree, or, after a sideways swipe on it, the side pane (the dock) — the swipe a slide like the one between tabs, the view following the finger with the other riding beside it and settling on release by the tab slide's commit rule (`utils/bottomSlide.ts`) — the desktop's pinned right-column terminal, the same `sidebar-right` window, created the same way on first use and sized to the view it is drawn in (`SET_PHONE_DOCK_SIZE` writes `@tmuxy-sidebar-cols`/`-rows`). Held sideways (`SET_PHONE_LAYOUT`'s `landscape`, read from the app body's keyboard-free size rather than `(orientation: landscape)`, which a keyboard on a portrait phone flips), the terminal takes the right of the screen with the cell cut from the height so its 24 rows and the status line fit (`phoneLandscapeCell`), and the strip, keys and bottom view stack in the column left of it.\n\nWhat one finger does on the terminal is `hooks/usePaneTouch.ts` over the pure `utils/touchGesture.ts`: a tap focuses the pane (an open keyboard follows it; a closed one stays closed); a double tap zooms the terminal in the browser around the point tapped — the `phone` slice's `phoneZoom`, drawn as a `transform` on the pane container inside a scrolling viewport, so tmux and the cell measurement see nothing, and a finger then pans it; a hold selects the word under the finger in copy mode (`COPY_MODE_WORD_SELECT`, kept as a `pendingWordSelection` until the row loads) and dragging on extends it, with Copy and Cancel over the pane (`components/TouchSelectionBar.tsx`); a mostly-sideways slide is the desktop's two-finger tab slide (`GESTURE_SWIPE`, the same `gestures` slice). The dock's pane is drawn by the phone too but is not the tab grid, so a slide on it switches nothing. The page is installable as a standalone app (`public/manifest.webmanifest`, the icons beside it and the meta tags in `index.html`); installed, `standalone.css` keeps the phone layout clear of the status bar and home indicator. A phone's keyboard covers the page rather than resizing it, so the app follows the visual viewport (`hooks/usePhoneViewport.ts`): the tree gives way first, then the terminal is cut short and scrolled to keep the cursor's row in view; the type never shrinks for a keyboard. A phone on a session shared with a desktop client shrinks that client's window to 80 × 24 too, as any smaller client does.\n\n**`serversActor`** (`tmuxy-ui/src/machines/actors/serversActor.ts`) — Polls the sessions tree for the sidebar (runs on web and desktop when the adapter sets `enumeratesSessions`), and every ~15s the git worktree discovery behind the tree's branch badges (`GIT_REPOSITORIES_UPDATED` → `repositories`; see [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md) \"Sessions tree\")."},{"doc":"docs/STATE-MANAGEMENT-ACTORS.md","heading":"Child Machines","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-ACTORS.md#child-machines","text":"**`dragMachine`** (`tmuxy-ui/src/machines/drag/dragMachine.ts`) — Pane drag-to-swap. States: `idle` and `dragging`. During drag, finds swap targets based on cursor position and sends a `swap-pane` once the pointer has rested on a pane for a moment (`SWAP_DWELL_MS`), so a pane the pointer only crosses on its way somewhere else stays put. Updates the parent's `drag` context via `DRAG_STATE_UPDATE`. Carried up onto the tab strip the same drag means the pane leaves its tab: over a tab's button it joins that tab (`join-pane`), over the empty space past the last button it becomes a tab of its own (`break-pane`). The strip's boxes are measured once at drag start (`utils/tabStripDrop.ts`) because the machine owns the pointer and the strip sees no events of its own; while the pointer is up there no swap runs, and unlike a swap the move waits for the release, since passing over a tab on the way somewhere else must not rearrange two tabs. Over a pane's header the drag is about pane groups (`utils/groupDrop.ts`): a header is divided into equal shares, one per member, so where the pointer sits names a place in the order by arithmetic over the pane boxes. A member dragged along its own header moves there (`pane-group move`), an ungrouped pane dropped on another header joins that group at the nearest gap (`pane-group join`), and a parked member dragged out by its tab leaves the group — beside the pane it is dropped on, to the side the pointer is nearest, or as a tab on the strip (`pane-group leave`). These wait for the release too, and the header marks where the pane would go.\n\n**`resizeMachine`** (`tmuxy-ui/src/machines/resize/resizeMachine.ts`) — Pane resize via divider dragging. States: `idle` and `resizing`. Tracks pixel delta, converts to character units, sends tmux `resize-pane` commands when delta >= 1 char. Throttles to avoid command spam."},{"doc":"docs/STATE-MANAGEMENT-ACTORS.md","heading":"How the app machine is split","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-ACTORS.md#how-the-app-machine-is-split","text":"`appMachine.ts` holds the lifecycle (connecting / idle / reconnecting), the actor wiring and the handlers that cut across concerns, including the one command path every tmux command goes through. Everything else is grouped by the context fields it owns: `machines/app/states/<concern>.ts` holds that concern's event handlers (an `on` map spread into the machine), and `machines/app/actions/<concern>.ts` the named actions they refer to. `context.ts` builds the initial context and owns the field-ownership map. Optimistic tmux state is not machine state at all: it lives in the store under `src/domain/store/`.\n\n**One-owner-per-field invariant.** `FIELD_OWNERS` in `context.ts` maps every\n`AppMachineContext` field to the concern that owns it (the owner names are the `states/` file names, plus `parent` for the machine itself). The\n`tmuxy/state-field-ownership` ESLint rule (in `packages/tmuxy-ui/eslint-rules/`)\nreads that map out of `context.ts` itself, so there is one copy, and\nenforces it: any `assign({...})` (or `assignCtx({...})`, the same builder\nbound to the machine's types in `machines/app/actionTypes.ts`) inside a\n`states/<name>.ts` or `actions/<name>.ts` file may only mutate fields owned\nby `<name>`.\nCross-cutting handlers that legitimately span states opt out with a\n`// cross-cutting: <reason>` comment on the assign.\n\nAction names are prefixed with the owning state (`uiPrefs_applyTheme`,\n`layout_selectTab`, etc.) so they don't collide when merged into the\nparent's `setup({ actions })` block."},{"doc":"docs/STATE-MANAGEMENT-ACTORS.md","heading":"Domain and infrastructure","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-ACTORS.md#domain-and-infrastructure","text":"The frontend splits along one line: `src/domain/` is pure — branded ids (`ids.ts`), the wire schemas and the decoding into them (`wire.ts`), the client model derived from the wire (`client.ts`), the command vocabulary (`commands.ts`: `TmuxOp` and `toTmuxCommand`, the only code that spells a tmux command), and the store's pure model (`store/`: predict, reconcile, replay). `src/infra/` is everything with an effect, as Effect services and Layers: the `TmuxTransport` service and its four Layers (`transport/`, see [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md)), the one `ManagedRuntime` the app runs them on (`runtime.ts`), the store that holds the model and sends ops (`store/TmuxStore.ts`), the tracer and the latency tracker. XState machines orchestrate between the two: the actors run effects on the runtime, and machine actions call domain functions.\n\nFailures are one tagged union (`infra/transport/AdapterError.ts`): `TmuxError` when tmux itself refused a command (the server's `kind: \"tmux\"`), `TransportError` for everything a transport reports otherwise (no monitor, a timeout, a malformed or forbidden request), and `ProtocolError` for a payload that does not decode. The tmux actor turns a failure into `TMUX_ERROR { error }`, a display string for the snackbar."},{"doc":"docs/STATE-MANAGEMENT-UPDATES.md","heading":"Frontend State: Predictions, Input and Rendering","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-UPDATES.md#frontend-state-predictions-input-and-rendering","text":"Optimistic updates, keystroke routing, user-facing errors and React integration. Part of [STATE-MANAGEMENT.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md)."},{"doc":"docs/STATE-MANAGEMENT-UPDATES.md","heading":"Optimistic Updates — TmuxClientModel","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-UPDATES.md#optimistic-updates--tmuxclientmodel","text":"Optimistic state lives outside XState in a dedicated client model: `tmuxy-ui/src/domain/store/`. The model splits server-confirmed state from in-flight predictions and replays predictions on top.\n\n| Concept | Lives in | Purpose |\n| --- | --- | --- |\n| `committed` | `TmuxClientModel.committed` | Last server-confirmed snapshot (panes, windows, active\\*). |\n| `ops` | `TmuxClientModel.ops` | Ordered log of in-flight optimistic operations. |\n| `derived` | `TmuxClientModel.derived` | `committed` with every `ops[i].patch` replayed in order. UI reads this. |\n| `paneKeyOverrides` | `TmuxClientModel` | Maps real pane IDs to placeholder IDs so React keys stay stable. |\n\nA `TmuxOp` is a typed value, not a parsed command string. The store knows how to:\n\n1. **Predict** — `ops.ts`'s `predict(op, snapshot, ctx) → Patch | null` produces the patch applied on top of `committed`. The predict context (default shell + MRU pane activation order for the Navigate tiebreak) is mirrored from the machine on every model update via `UPDATE_PREDICT_CONTEXT`.\n2. **Dispatch** — `TmuxStore.dispatch(op)` runs predict → applies the patch → marks the op `in-flight` → sends the command through the adapter → rolls the patch back if the send fails. Once the adapter acks, the op moves to `awaiting-confirm`.\n3. **Reconcile** — `TmuxStore.reconcile(serverState)` advances `committed`, runs each pending op's reconciler, drops matched/stale ones, recomputes `derived`.\n\nStale sweeping is status-aware: only an op the adapter was never asked to send (`pending`) is swept quickly (`OP_STALE_TIMEOUT_MS`) — its command may be a phantom with nothing coming. An op whose adapter call is still running (`in-flight`) is exempt from the quick sweep: the ack alone can outlast it on a slow transport (v86 serial, loaded server), the call is guaranteed to settle either way, and sweeping earlier blinks the optimistic UI away exactly when the backend is slowest. Both `in-flight` and acked (`awaiting-confirm`) ops fall to the longer `OP_ACKED_STALE_TIMEOUT_MS` backstop (the confirming delta may arrive slowly — e.g. a new window's type tag on a later list-windows sync). Rollbacks of structural ops surface in the snackbar via `TMUX_ERROR`; focus-op rollbacks stay console-only.\n\nPredicted ops today: Split, NewWindow, SelectPane, Navigate (with the MRU tiebreak), Swap, SelectWindow, KillPane (removal + aligned-neighbor expansion + MRU refocus), KillWindow, RenameWindow, and ZoomToggle (zoom-in only — the pre-zoom slot is unknown client-side, so unzoom waits for the server). Deliberately NOT predicted: break-pane, layout cycling, and plain resizes (server-side layout math), plus keystrokes (see NON-GOALS.md). While ops are pending the store also re-reconciles on a timer, so age-based verdicts fire even when the control stream is idle.\n\nFocus ops (SelectPane / Navigate) additionally **linger** after confirmation (`FOCUS_CONFIRM_LINGER_MS`): snapshots computed before the focus change can arrive after it, and dropping the op's pin on first confirmation would flap the highlight. A confirmed-then-superseded focus (the server reports a third pane past `FOCUS_SUPERSEDE_GRACE_MS`) yields to the server immediately. Placeholder ids (`__placeholder_*`) are client-only: the keyboardActor never targets them (it falls back to no pane target while a placeholder is active — commands queue FIFO, so by execution time tmux's own active pane is the pane the placeholder stands for).\n\nThe XState `appMachine` is a thin bridge: `SEND_TMUX_COMMAND` routes to `tmuxStoreActor` → `store.dispatch`; `TMUX_STATE_UPDATE` routes to `tmuxStoreActor` → `store.reconcile`. Every model change fires a `TMUX_MODEL_UPDATE` event back to the parent, where context.panes/windows/activePaneId are mirrored from `model.derived`. The 600+ lines of split-anti-flicker, position-tolerance heuristics, and stale-timeout fallbacks that used to live inline in `appMachine.ts` are gone — the store owns all of it via pure reducers in `store/model.ts`."},{"doc":"docs/STATE-MANAGEMENT-UPDATES.md","heading":"Keystroke-routing contract","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-UPDATES.md#keystroke-routing-contract","text":"Anything that changes the user's perceived \"focused pane\" MUST keep three pieces of state in lockstep, or keystrokes will land in the wrong pane during the brief window before tmux confirms the change:\n\n| State | Owner | Why it matters |\n| --- | --- | --- |\n| `context.activePaneId` | appMachine | Drives the UI focus indicator (PaneHeader tab highlight, pane border, etc.). |\n| `activePaneId` (local) | keyboardActor | Resolved as the `-t` target for every `send-keys`, paste, and IME-commit command: a focused overlay (float, dock) wins, then the active pane, then the session name; a placeholder id at either step falls through to the session. The hidden keyboard input is re-aimed on every change. |\n| Server-side active pane | tmux | Used by any prefix/root binding that omits `-t` (e.g., `split-window`). |\n\nRules an action that flips focus must follow:\n\n1. **Set `activePaneId` in context** — via `assign({ activePaneId: ... })`.\n2. **Push `UPDATE_ACTIVE_PANE` to the keyboard actor** — so the next keypress already targets the new pane. The keyboardActor stores `activePaneId` as a local variable (see `tmuxy-ui/src/machines/actors/keyboardActor.ts`) and only updates it on this event.\n3. **Don't trust tmux's active pane** — for the third case, prefix and root bindings are auto-pinned at dispatch time: keyboardActor prepends `select-pane -t <activePaneId> \\;` to every binding command. New code should not bypass this.\n\nReference implementations: `SELECT_TAB` (top tab clicks) and `SELECT_PANE_GROUP_TAB` (pane-group tab clicks) in `appMachine.ts`. Pane-group prev/next, tab next/prev/last, and Ctrl+1..9 all route through these via `resolveTabNavTarget` / `resolvePaneGroupNavTarget` so they share the same optimism."},{"doc":"docs/STATE-MANAGEMENT-UPDATES.md","heading":"Errors and messages the user sees","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-UPDATES.md#errors-and-messages-the-user-sees","text":"There is one surface for errors: the snackbar (`components/Snackbar.tsx`),\nstacked in the top-right corner of the app chrome, each entry with a close\nbutton and an expiry. It renders the `notifications` field, owned by the\n`notifications` slice; anything that has an error for the user\nraises `NOTIFY { text }` — the connected-state `TMUX_ERROR` handler (backend\nerrors, rejected commands, optimistic rollbacks), a refused clipboard write,\na menu action that cannot run on this transport. The same text arriving while\nstill on screen refreshes the entry rather than stacking a duplicate.\n\ntmux's own messages share the corner. A `display-message` aimed at the\ncontrol client reaches it as a `%message` notification (tmux 3.4+; older tmux\nsends nothing). The parser strips its control characters and caps its length,\nthe monitor hands it straight to the emitter (`show_message`, never through\nthe aggregator), and it goes out as `tmux-message` on SSE and on the desktop\nevent channel. The tmux actor raises `NOTIFY { text, kind: 'info' }`: the same\ncard in a neutral border, a `status` rather than an `alert`. Because the client\nno longer intercepts `display-message`, tmux expands its formats\n(`display-message '#{pane_current_path}'` shows the path). With a second client\nattached, tmux sends the message to the most recently active one.\n\nThe status line (`statusMessage`, commandUi) is for the client's own status —\n\"Copied …\" — never errors. While the app is still connecting, the connection\noverlay shows the transient `error` instead, so the same message is not shown\ntwice."},{"doc":"docs/STATE-MANAGEMENT-UPDATES.md","heading":"React Integration","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-UPDATES.md#react-integration","text":"Components consume the machine via hooks defined in `tmuxy-ui/src/machines/appHooks.ts`:\n\n- `useAppSelector(selector)` — Subscribe to derived state\n- `useAppSend()` — Get event sender function\n- `useAppState(stateValue)` — Check current machine state\n- `usePane(paneId)` — Get a single pane by ID\n- `usePaneGroup(paneId)` — Get group info for a pane\n- `useIsDragging()`, `useIsResizing()` — Operation state checks\n\nSelectors are defined in `tmuxy-ui/src/machines/selectors/` (`layout.ts`, `panesAndSidebars.ts`, `ui.ts`) and include: `selectPreviewPanes` (with resize preview), `selectVisiblePanes`, `selectWindows`, `selectVisibleWindows`, `selectGridDimensions`, `selectCharSize`, `selectPaneById`, `selectPaneGroupForPane`, etc."},{"doc":"docs/STATE-MANAGEMENT-UPDATES.md","heading":"Pane enter/leave animations (component-local)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-UPDATES.md#pane-enterleave-animations-component-local","text":"Split and kill morphs are deliberately **not** machine state — the lifecycle lives entirely in `tmuxy-ui/src/components/PaneLayout.tsx` as refs plus a tick reducer. Each render is diffed against a snapshot of the previous render, keyed by the pane's effective React key (`paneKeyOverrides` honored, so the optimistic placeholder→real-id swap never re-triggers an animation):\n\n- **Enter** (split): a newly visible key mounts at its final geometry, is FLIP-rewound before paint to the pre-split box of the sibling that shrank for it, then transitions into place while fading in (`pane-entering`).\n- **Leave** (kill): the closed pane's node goes away in the same commit that drops it from the model; only the survivors animate, growing into the freed space (`pane-shifting`).\n- **Shift**: pre-existing panes whose geometry changed alongside an enter/leave animate on the same clock (`pane-shifting`) so converging edges track.\n\nThe from/to geometry is inferred generically from previous-render pixel boxes (`tmuxy-ui/src/utils/paneTransitions.ts`), so splits/kills initiated from the CLI or another client animate identically to optimistic local ones. The lifecycle classes intentionally out-specify the `enableAnimations`/`suppressLayoutTransition` container gates in CSS; detection is skipped on initial load, window switches, and during drag/resize.\n\n---"},{"doc":"docs/STATE-MANAGEMENT.md","heading":"State Management","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#state-management","text":"This document describes how state is managed on both the frontend (React + XState) and the backend (Rust), and how they stay synchronized."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"Overview","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#overview","text":"State flows unidirectionally from tmux through the Rust backend to the browser frontend:\n\n```\ntmux server → ControlModeConnection → StateAggregator → StateEmitter → Frontend XState\n```\n\nThe backend maintains the **authoritative** tmux state. The frontend maintains a **derived** copy with additional client-only state (copy mode, drag, resize, animations, UI preferences). The delta protocol keeps them synchronized.\n\n---"},{"doc":"docs/STATE-MANAGEMENT.md","heading":"Backend State (Rust)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#backend-state-rust","text":"The backend is the **authoritative** owner of tmux state. Everything below is a layer that exists to make that ownership testable, resilient, and shareable across multiple clients. Component shape and field-by-field details live in the source — this section describes **roles and contracts**."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"AppState","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#appstate","text":"Top-level server state. Holds the per-session connection map, structured-shutdown handles (`JoinSet` + `CancellationToken`), and the shared image store. Handlers reach tmux through the session's monitor, never a subprocess.\n\nSee `tmuxy-server/src/state.rs`."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"SessionConnections","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#sessionconnections","text":"One per tmux session, shared by every client connected to that session. Owns the list of connected client IDs, each client's reported viewport size (for minimum-size computation), the channel to the session's `TmuxMonitor`, and the `SessionBroadcast` — a broadcast sender plus a ring-buffer + monotonic sequence id used for SSE `Last-Event-Id` resync (see [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md)).\n\nSame file as `AppState`."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"TmuxMonitor — the runtime","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#tmuxmonitor--the-runtime","text":"The bridge between the sans-IO state machine and a live `tmux -CC` subprocess. Receives control-mode events, drives the aggregator step-by-step, and executes the typed `SideEffect`s the aggregator returns (sending tmux commands, refreshing panes, emitting state). Multiplexes control-mode events, periodic syncs, the settling/throttle/debounce timers, and external commands from the frontend.\n\nThe reconnect loop re-creates the monitor on disconnect: one loop, `transport::supervise` in `tmuxy-core/src/transport/supervise.rs` (connect, serve, back off, give up after five failures in a row, restore a snapshot before a session is created, autosave while connected), with what differs per host — who may stop it, how a session is brought into being, where the command channel is kept, what giving up means — behind the `Host` trait that `tmuxy-server/src/sse/monitor.rs` and `tmuxy-tauri-app/src/monitor.rs` each implement. The monitor reads the system clock directly; the time-based policies it applies (`RunState`, the aggregator's `step_at`/`tick`) take an explicit instant, which is how their tests drive time without a tmux.\n\nSee `tmuxy-core/src/control_mode/monitor.rs`. The split into one handler method per `select!` arm is purely organisational — the docblocks on each method spell out the load-bearing ordering invariants. The monitor is generic over its link to tmux (`ControlLink`): the real control-mode PTY in production, a scripted one in tests, which is how the order it puts commands on the wire is asserted without a tmux (see [INVARIANTS.md](https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md), M1)."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"MonitorCommand","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#monitorcommand","text":"The typed envelope external code uses to drive the monitor (resize the window, run a tmux command through control mode, gracefully shut down). See the enum in `monitor.rs`; new variants get docblocks explaining _when_ you'd send them, since the variant name alone isn't usually enough."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"ControlModeConnection","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#controlmodeconnection","text":"Manages the `tmux -CC` subprocess and the stdin/stdout framing on top of it. Spawns the subprocess under a PTY (via `script`), runs a background parser task that converts stdout lines into `ControlModeEvent`s, and exposes `send_command` / `send_commands_batch` for writes. `graceful_close()` always sends `detach-client` and waits — abrupt kills crash tmux 3.5a (see [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md)).\n\nSee `tmuxy-core/src/control_mode/connection.rs`."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"StateAggregator — sans-IO state machine","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#stateaggregator--sans-io-state-machine","text":"The heart of the system. Consumes `ControlModeEvent`s and returns typed `SideEffect`s (`AdoptUntaggedWindows`, `RefreshPanes`, `EmitState { change }`, `StoreImages`, `WriteClipboard`, etc.). Performs no I/O itself — every command send, every emit, every image store is described, not performed. The runtime (`TmuxMonitor`) is what actually executes them.\n\nThis separation is what makes the aggregator testable without tokio: drive it with synthetic event sequences and assert on the returned effects. The settling mechanism (window-emission suppression during compound commands) lives here as a sticky flag the runtime arms/disarms; the time-based debounce/safety timer that decides _when_ to disarm still lives in the monitor.\n\n`step(event) -> StepResult` is the public entry point. `tick(now)` drives the time-based transitions: once `now` passes the settling deadline after a burst of structural events, it ends the settling window and returns one consolidated `EmitState` (or nothing, when no event arrived during it).\n\nSee `tmuxy-core/src/control_mode/state.rs` and the `SideEffect` enum's docblocks for the ordering invariants the runtime relies on."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"StateUpdate, TmuxState, TmuxDelta","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#stateupdate-tmuxstate-tmuxdelta","text":"The wire-shaped types the backend emits. `StateUpdate` is either a `Full` snapshot (initial sync, full resync) or a `Delta` (sequenced incremental change). The delta encoding distinguishes \"no change\" from \"removed\" so the frontend's reconciliation is unambiguous.\n\nSee `tmuxy-core/src/lib.rs`. The TypeScript side is Effect Schemas in `tmuxy-ui/src/domain/wire.ts`; the TS types are derived from them and every update is decoded once where it enters the client."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"StateEmitter trait","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#stateemitter-trait","text":"The seam between the monitor and a specific transport. Two implementations:\n\n- `SseEmitter` (`tmuxy-server/src/sse.rs`) — broadcasts via `SessionBroadcast` to every SSE client in the session.\n- `TauriEmitter` (`packages/tmuxy-tauri-app/src/monitor.rs`) — emits Tauri events to the desktop frontend.\n\nThe trait keeps `TmuxMonitor` transport-agnostic; adding a third transport means implementing the trait, nothing else."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"Settling, throttling, debouncing","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#settling-throttling-debouncing","text":"Three timing policies the monitor applies on top of the aggregator's effects. All are about _when_ to flush state, not _what_ the state contains.\n\n- **Settling** suppresses intermediate window/layout emissions while a compound command (`splitw ; breakp ; set-option ...`) is mid-flight. The aggregator owns the suppression flag; the monitor owns the debounce/safety timer that decides when to disarm it.\n- **Adaptive throttling** caps state emissions during high-frequency output (rate-window hysteresis with a ~60fps ceiling) so terminal-output bursts don't drown the SSE channel. Below the threshold, emissions are immediate for low-latency typing feedback.\n- **Layout debounce** coalesces rapid layout changes (e.g., zoom-out cascades) into a single emission.\n\nThe tunables are constants in `control_mode/monitor.rs` (only the throttle interval is a `MonitorConfig` field). The exact durations + thresholds drift as we tune for real workloads; the durable contract is \"the aggregator is correct; the monitor decides cadence.\"\n\n---"},{"doc":"docs/STATE-MANAGEMENT.md","heading":"Frontend State (XState)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#frontend-state-xstate","text":""},{"doc":"docs/STATE-MANAGEMENT.md","heading":"appMachine","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#appmachine","text":"The main state machine, defined in `tmuxy-ui/src/machines/app/appMachine.ts`. Four top-level states arranged as a connection lifecycle:\n\n- **`connecting`** — Initial. Waiting for the backend handshake. Transitions to `idle` on `TMUX_CONNECTED`. Like every live state it ends in `disconnected` on `TMUX_DISCONNECTED` / `TMUX_FATAL`, both handled at the machine root.\n- **`idle`** — Live and operational. Handles all normal interactions. The \"syncing\" sub-flavor — connected, but with one or more optimistic ops in flight — is a derived flag (`tmuxStore.model.ops.length > 0`) surfaced to selectors; it does not gate any handlers, so the user never feels a perceptible mode change when an op is pending.\n- **`reconnecting`** — The adapter detected the SSE/Tauri channel dropped and is retrying. Distinct from `connecting` so the UI keeps the last frame of the panes mounted, in view and usable: nothing covers them. Two sub-states: `quiet` for the first two seconds (`RECONNECT_NOTICE_DELAY_MS`), in which nothing is drawn, so a drop that heals at once — a Wi-Fi roam, a sleep/wake — is never seen; then `noticed`, which the snackbar corner renders as a card with a spinner, \"Reconnecting…\" and a \"Retry now\" (`tmuxy-ui/src/components/ReconnectNotice.tsx`). The card leaves on its own when the channel is back. Transitions to `idle` on `TMUX_RECONNECTED` (next live snapshot) or `disconnected` on `TMUX_DISCONNECTED` / `TMUX_FATAL`.\n- **`disconnected`** — Terminal. Backend gave up or an explicit disconnect happened. The `ConnectionOverlay` reads `fatalError` to show the one-line reason with a Retry button (a reload) and the command/error log behind a collapsed Details disclosure; the dead layout stays underneath as the blurred backdrop. No auto-recovery; an adapter-initiated `TMUX_RECONNECTING` is still accepted, so a server that comes back later can pull the UI out of this state.\n\nReconnection flow: the adapter (`HttpAdapter` / `TauriAdapter`) fires `onReconnection(true)` on every drop. `tmuxActor` forwards this as `TMUX_RECONNECTING` and the machine transitions to `reconnecting`. When the channel recovers, the adapter fires `onReconnection(false)` → `TMUX_RECONNECTED` → back to `idle`. Pending optimistic ops carry across reconnection: the store's `applyServerSnapshot` runs against the first post-recovery full state and reconciles or rolls back each op (stale ops older than `OP_STALE_TIMEOUT_MS` are dropped in that same pass).\n\nSee [STATE-MANAGEMENT-ACTORS.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-ACTORS.md) for these sections: Machine Context; Actors; Child Machines; How the app machine is split; Domain and infrastructure.\n\nSee [STATE-MANAGEMENT-UPDATES.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT-UPDATES.md) for these sections: Optimistic Updates — TmuxClientModel; Keystroke-routing contract; Errors and messages the user sees; React Integration; Pane enter/leave animations (component-local)."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"How Backend and Frontend Stay in Sync","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#how-backend-and-frontend-stay-in-sync","text":"1. **Initial sync** — On connection, the frontend sends `get_initial_state` with viewport size. The backend answers with the monitor's own `TmuxState` (`MonitorCommand::GetState`, the same picture its `Full` broadcast carries), so a client that connects after that broadcast — the desktop webview always does, its monitor starts first — begins from a baseline the deltas agree with. The frontend starts from it only until the stream has delivered its own full state: after that the stream is the state, and a later answer only fills panes the stream has no content for yet (`adoptInitialState` in `domain/deltaProtocol.ts`), since the answer sits outside the delta sequence and putting it back would undo what the stream delivered since.\n\n2. **Incremental updates** — The backend sends `TmuxDelta` updates with sequence numbers. The frontend merges these via `handleStateUpdate()` in `tmuxy-ui/src/domain/deltaProtocol.ts`. Only changed fields are transmitted.\n\n3. **Sequence gaps** — If a delta arrives with an unexpected sequence number, the frontend requests a full resync. A full state and the `get_initial_state` answer carry the sequence number they were taken at (`seq` on `TmuxState`), so the delta right after either is checked the same way: a delta lost between a sync point and the next delta is a gap, never a delta merged over a hole.\n\n4. **Optimistic reconciliation** — Every dispatched op stays in `TmuxClientModel.ops` until a server state update either matches it (the predicted real id appears in `committed`) or it stale-expires after `OP_STALE_TIMEOUT_MS`. Multiple in-flight ops are reconciled in dispatch order, each claiming its own real id so concurrent Split / NewWindow ops don't collide. On `TmuxError`, the store rolls the patch back immediately and surfaces `OpRejectedByTmux { stderr }` to the caller.\n\n5. **Copy mode is client-side** — the `copyMode` slice owns per-pane `CopyModeState` (loaded scrollback `lines`, cursor, selection, scrollTop). Scrollback is fetched on demand from tmux (`FETCH_SCROLLBACK_CELLS` → `get_scrollback_cells` → `COPY_MODE_CHUNK_LOADED`) and rendered in a natively-scrolling container; vi keybindings (via `COPY_MODE_KEY` → `handleCopyModeKey`), cursor movement, mouse selection, and scroll position are all client-owned. The only backend interaction is entering/exiting tmux's copy mode for the `in_mode` flag and capturing history. See [COPY-MODE.md](https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md).\n\n6. **Group state** — Pane-group membership is intrinsic to the pane: every member carries the same `@tmuxy-group-id` pane option, the visible member lives in the attached session and the hidden ones are parked in the `__tmuxy_stash` session (see [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md) \"Window Tags\"). The backend enumerates both on state sync and emits hidden members as pane stubs carrying `group_id`. The frontend sends group mutations via `run-shell` commands that execute shell scripts in `packages/tmuxy-cli/bin/tmuxy/`."},{"doc":"docs/STATE-MANAGEMENT.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md#related","text":"- [ARCHITECTURE.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md) — where each state layer sits in the system\n- [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md) — the delta protocol and transports these updates travel over\n- [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md) — control-mode routing and the `@tmuxy-*` tags the window state is built from\n- [COPY-MODE.md](https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md) — the client-owned `copyMode` slice\n- [INVARIANTS.md](https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md) — the promises the aggregator, the monitor and the client model make to each other, and what tests them"},{"doc":"docs/TELEMETRY.md","heading":"Telemetry and Network Connections","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TELEMETRY.md#telemetry-and-network-connections","text":"**tmuxy collects no telemetry.** The server, the desktop app and the CLI send\nnothing about you, your machine or your sessions to us or to anyone else: no\nanalytics, no crash reports, no usage counters, no update check. Every\nconnection tmuxy makes is listed below, with what starts it and how to stop it.\n\nThis page covers tmuxy itself. The programs you run in its panes make their own\nconnections, which tmuxy neither sees nor controls. The website at\n[tmuxy.sh](https://tmuxy.sh) is a separate thing and has a section of its own\n(it does use analytics). If something here is wrong, or a connection is missing,\ntreat it as a bug and open an issue."},{"doc":"docs/TELEMETRY.md","heading":"The app (server, desktop, CLI)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TELEMETRY.md#the-app-server-desktop-cli","text":"| What connects | When | To where | What is sent | How to turn it off |\n| --- | --- | --- | --- | --- |\n| The web server listens | While `tmuxy server` runs | Inbound on `127.0.0.1:9000` by default (`--host`, `--port`) | It serves the UI, the state stream (`/events`: everything the session shows) and takes commands and keystrokes (`/commands`, `/api/*`) | Do not run `tmuxy server`, or keep the loopback default; see [SECURITY-NETWORK.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md) |\n| The browser UI talks to the server it came from | While a tab of the web UI is open | The same origin that served it | Keystrokes, commands, its viewport size; the trace, only when tracing is on (below) | Close the tab |\n| The desktop app | Never, on its own | Nowhere: it serves no HTTP and talks to tmux over a local socket | Nothing | Nothing to turn off |\n| A remote server over SSH | When you attach the desktop app to a saved SSH server | The host you added, through your own `ssh` and its config | tmux commands and your keystrokes go out; the session's screen comes back | Do not attach one; remove it from `~/.config/tmuxy/servers.json`. See [TMUX-CONFIG.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#remote-servers-over-ssh-desktop-app) |\n| `tmuxy session connect <host>` | When you run it (also from the switcher's \"Connect over SSH…\") | The host you name, through `ssh -o BatchMode=yes` | `true`, `tmux -V` and a session listing, to check the host before saving it | Do not run it |\n| A link you click | When you click a link in a pane or a preview | Your default browser opens it (desktop), or the browser follows it (web) | Whatever a browser sends to that site; tmuxy only hands over `http(s)` and `mailto` links | Do not click it |\n| A preview opened with `--net` | When you run `tmuxy open --net <file>` and the document references remote resources | The hosts the document names | Whatever the document's images, styles and scripts request | Leave out `--net`: by default a preview fetches nothing remote. See [SECURITY-CONTENT.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#a-preview-reads-one-file) |\n| The trace | When tracing is on: by default in a development build, by your own choice in a release | A local file (`trace.ndjson` in the state directory); a browser client posts its events to its own server's `/trace`, which writes them there | Action names and timings, never what you type or what programs print | It is off unless you turn it on (the Debug menu). See [TRACING.md](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md) |\n| The dev proxy | `tmuxy server --dev` only, a contributor's setup | `localhost:9001` (Vite) | The page requests it forwards | Do not pass `--dev` |\n| The WebDriver plugin | Test builds of the desktop app only, built with the `webdriver` feature | Inbound on a local port (4445) | WebDriver commands from the test runner | A release build does not contain it |\n\nWhat stays on the machine and is easy to mistake for a connection: the git\nbranch badges in the tree run `git` locally on the panes' directories and\nreturn nothing for an SSH server; session snapshots, the theme files and the\nsaved-servers list are local files."},{"doc":"docs/TELEMETRY.md","heading":"Updates","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TELEMETRY.md#updates","text":"tmuxy does not check for updates. Neither the desktop app nor the server asks\nanywhere whether a newer version exists. You update it the way you installed\nit: `brew upgrade` (Homebrew has analytics of its own, which `brew analytics\noff` turns off) or a new download from GitHub Releases."},{"doc":"docs/TELEMETRY.md","heading":"The website (tmuxy.sh)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TELEMETRY.md#the-website-tmuxysh","text":"The website is a static page with the demo in it. It runs a copy of tmuxy in a\nvirtual machine inside your browser tab; that machine has no network.\n\n| What connects | When | To where | What is sent | How to turn it off |\n| --- | --- | --- | --- | --- |\n| The page and its files | When you open the site | GitHub Pages, which hosts it | Ordinary page requests (IP address, user agent), logged under GitHub's own privacy terms | Do not open the site |\n| Google Analytics | On each visit, when the site was built with an analytics id, as the published site is | Google (`googletagmanager.com`, `google-analytics.com`) | A page view, and these events: using the demo, switching its tab (with the tab's name), copying an install command (with the OS it was for), and clicking the GitHub, releases, DeepWiki or share links | A content blocker, your browser's tracking protection, or Google's opt-out add-on; the demo works the same without it |\n| The CheerpX runtime | When your browser can run it (the page is cross-origin isolated) | `cxrtnc.leaningtech.com`, Leaning Technologies' CDN | The script request; what the runtime does after it loads is Leaning Technologies' | A browser that cannot isolate the page runs the demo on v86 instead, from the site itself |\n| The demo's nyan cat | When the demo starts | `gist.githubusercontent.com` | The image request | Block the host; the demo starts without the picture |\n\nThe site also serves `/llms-full.txt` and `/search.json`, the docs for\nagents: static files from the same host, fetched only by whoever asks for them."},{"doc":"docs/TELEMETRY.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TELEMETRY.md#related","text":"- [SECURITY-NETWORK.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-NETWORK.md) — where the server listens and how to expose it safely\n- [TRACING.md](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md) — the local trace and its redaction boundary\n- [DESKTOP.md](https://github.com/flplima/tmuxy/blob/main/docs/DESKTOP.md) — what only the desktop app does, SSH servers among it"},{"doc":"docs/TESTS-E2E.md","heading":"E2E, Latency and Desktop Tests","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#e2e-latency-and-desktop-tests","text":"The browser + tmux suites, the latency gate and the Tauri suite. Part of [TESTS.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md)."},{"doc":"docs/TESTS-E2E.md","heading":"E2E Tests","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#e2e-tests","text":""},{"doc":"docs/TESTS-E2E.md","heading":"Suites","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#suites","text":"`npm run test:e2e` runs every `*.test.js` under `tests/` except `tests/cli/` and `tests/tauri/` (`jest.config.js`). In CI each file below is its own matrix entry.\n\n| File | Covers |\n| --- | --- |\n| `tests/1-input-interaction.test.js` | Keyboard, paste, international input, mouse click/scroll/drag/SGR, mouse selection, copy mode navigate/yank, touch scroll (CDP touch events), viewport resize at 800x600 and 1920x1080 |\n| `tests/2-layout-navigation.test.js` | Windows/tabs, tab overview, marked and collapsible panes, zoom, drag to tab strip, pane groups, float lifecycle and close paths, sidebar tree, pinned dock, status bar, fzf in a float |\n| `tests/3-rendering-protocols.test.js` | OSC 8 hyperlinks, OSC 52 clipboard, Unicode, SGR faint, image protocols |\n| `tests/4-session-connectivity.test.js` | Reload preserves state, offline/online connection overlay, multi-client, command routing, multi-session sidebar |\n| `tests/5-stress-stability.test.js` | Large output (`yes \\| head -500`, `seq 1 2000`), rapid operations, glitch detection |\n| `tests/6-nvim-performance.test.js` | One nvim boot: rendering a 500-line file, cursor shape (DECSCUSR) through insert mode, half-page scrolling, discarding the edit |\n| `tests/7-regression-bugs.test.js` | One scenario per production bug the other suites missed |\n| `tests/8-tui-alternate-screen.test.js` | Heavy alt-screen TUI (`tests/fixtures/heavy-tui.sh`) compared line by line with `capture-pane`; wheel to a mouse-tracking TUI started before attach |\n| `tests/11-webkit.test.js` | The same selection, copy and focus flows under Playwright WebKit — the only proxy CI has for WKWebView, where those bugs escaped. Skips loudly when WebKit is absent; `TMUXY_E2E_REQUIRE_WEBKIT=1` makes that a failure |\n| `tests/12-touch*.test.js` | `hasTouch` phone contexts: portrait 390x844 (tap to focus the hidden input, swipe to open the scroll view, the tab strip, no horizontal overflow) and `-sideways` 844x390 (terminal right of the chrome, cursor in view under a keyboard) |\n| `tests/13-send-permission.test.js` | `tmuxy pane send -t` into another pane — the question drawn over the target, allowed from the sending pane (keys land) or declined (nothing lands), a long multi-line send in its scrolling block, and `@tmuxy-send-ask off` / `@tmuxy-send-allow` skipping it with the unread mark left behind |\n| `tests/14-widget-authorisation.test.js` | SEC-18: a widget marker printed by a shell draws nothing; `tmuxy widget tree` does, and its exit takes the authorisation with it |\n| `tests/14-widget-preview.test.js` | `tmuxy open` on a markdown document (diagram and image drawn, followed as the file changes at the same scroll position, Ctrl+C back to a shell that still takes Ctrl+C, and the read refused afterwards) and on a page (framed offline with only its own folder's files, kept in place across a change, Ctrl+C from inside the frame) |\n| `tests/15-session-snapshot.test.js` | Session snapshots on a server of the test's own (own socket, port and state dir) — autosave follows split, float, tab, group, sidebar and restore tags; SIGTERM + `kill-server` + restart rebuilds the shape; `forget` needs `--force` |\n| `tests/snapshots/snapshot.test.js` | Read-only UI ↔ tmux comparison, no interactions |\n\nHelpers live in `tests/helpers/`, one file per domain: `browser.js` (CDP connect or launch), `own-browser.js` (a browser context the suite owns — touch, phone width, WebKit), `test-setup.js` (`createTestContext`), `extra-server.js` / `read-only-server.js` / `public-name-proxy.js` (a second server beside the suite's own), `snapshot-server.js` (a server of the test's own that can be stopped and restarted), `server-binary.js` (the newest built `tmuxy-server`), `TmuxTestSession.js`, `tmux-side.js` (what tmux holds for a session, for failure messages), `reap.js` (shells left in the kernel by a torn-down pane), `keyboard.js`, `pane-ops.js`, `window-ops.js`, `pane-groups.js`, `copy-mode.js` / `copy-mode-ui.js`, `selection-drag.js`, `mouse-capture.js` (with the `mouse-capture.py` fixture it runs in a pane), `cell-grid.js`, `layout.js`, `glitch-detector.js`, `snapshot-compare.js`, `content-match.js`, `consistency.js`, `cli.js`, `tmux-socket.js`, `config.js`, and `trace-environment.js` (the Jest environment that stamps a trace marker per test). Import them through `tests/helpers/index.js`."},{"doc":"docs/TESTS-E2E.md","heading":"Environment","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#environment","text":"- Tests connect to an existing Chrome via CDP on port 9222 — never install Playwright browsers locally (CI provisions its own chromium; that's the one exception)\n- All E2E tests run sequentially (`maxWorkers: 1`) — they share one tmux server\n- A tmuxy server must be reachable on `TMUXY_PORT` (default 9100, `tests/helpers/config.js`); the suite builds and starts one itself — on that port — if nothing answers. One port per environment, like one socket per environment: 9000 belongs to a released build and `bin/dev-server`, 9100 to this suite. A server that is already up is asked which tmux socket it drives and REFUSED unless it is this run's, because a stranger's server fails the suite as a wall of unrelated flakes rather than as a port conflict\n- Missing prerequisites FAIL the run. A suite that cannot reach Chrome or the server used to skip every test and report green, which is worse than a red: it says a suite ran that never did. Set `TMUXY_E2E_ALLOW_SKIP=1` to opt into the old behaviour"},{"doc":"docs/TESTS-E2E.md","heading":"What the helpers guarantee","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#what-the-helpers-guarantee","text":"Three rules live in `tests/helpers/` rather than in each test, so a fix lands once instead of ninety times:\n\n- **Text has to be on screen to count.** `getTerminalText` and `waitForTerminalText` read only the terminals the user can actually see — a pane in a background tab, a parked pane-group member and a closed sidebar all keep their content mounted, and joining all of them let a test pass on text nobody could read. `waitForTerminalText` goes further and checks the line carrying the text has a real box, inside its pane and inside the viewport. Pass `{ scope }` to narrow to one pane (`'.pane-active'`, or `[data-pane-id=\"%3\"]`)\n- **Wait for the state, don't sleep.** `waitForLayoutSettled` returns once the panes, their boxes and the focus have been the same twice in a row; every helper that changes the layout (split, nav, swap, zoom, kill, layout cycle) ends with it instead of a flat 500ms. `typeInTerminal` waits for the app to be holding the keyboard rather than sleeping before it types\n- **The app says when it has caught up.** `waitForSettled` asks `window.app.settled()` (`AppProvider`): no command queued or in flight to the backend, no keystroke batched and unsent, no optimistic op awaiting tmux's verdict, and the store holding the state at the stream's last sequence number — then two painted frames. It is the question a `delay(DELAYS.SYNC)` after a click or a prefix key was guessing at, answered the moment the round trip is over, and waited for when the trip takes longer than a guess. It says nothing about a program in a pane; that is `waitForTerminalText`'s question. `waitForCondition` returns the truthy value its condition produced, so a wait that needs what it waited for (a title, a set of images) is one call rather than a loop of its own\n- **A wait is patience, not a stopwatch.** `waitForShellPrompt` bounds how long the terminal may go _without changing_ and resets that on any change, so a slow runner still drawing keeps its wait. A fixed deadline is a constant that encodes how fast the machine is: at 10s it passed locally and failed on a loaded CI runner whose shell had not yet written a byte. It also requires the prompt to be the _last_ thing in a terminal — a `>` in the output of an earlier command used to count, reporting a shell ready while it was still mid-command, so the failure landed on the next step instead\n- **A wait budget is relative, not absolute.** The 145 `waitForCondition` bounds in the suite are numbers someone measured on their own laptop, spanning 1000ms to 45000ms. `TMUXY_WAIT_SCALE` restates all of them at once for a machine that is not that one, so a slower or busier box is one knob rather than 145 edits (`waitBudget` in `tests/helpers/config.js`; every wait helper routes through it). Default 1, so it changes nothing where the numbers were already right. Raising it cannot turn a failing test green — a condition that will never hold still never holds — so it buys a slow machine time to be right and nothing else\n- **A slow runner can be summoned.** `TMUXY_E2E_CPU_THROTTLE=<factor>` slows the page's renderer by that factor (the storybook probe's `PROBE_CPU_THROTTLE`, for the web suite), which is how to see on a laptop what a loaded CI runner sees. It throttles the browser only: a race that lives in the server or tmux round trip — the `-X cancel` that ends copy mode landing a snapshot late — is not reached by it, and has to be reasoned out and pinned by a unit test instead. The page's own errors and warnings print into the test output (`[page error]`, `[page warning]`), so a product path that should not have run says so next to the assertion it broke.\n- **Teardown waits for the monitor, not a clock.** `afterEach` polls tmux until the session is gone and no client is left attached to it. That was a flat `delay(4000)` — about 6.5 minutes of pure sleep across the suite, and a coupling where one test's slow shutdown failed the next one\n\nStart the Chrome the tests attach to with any system Chrome/Chromium:\n\n```bash\ngoogle-chrome --headless=new --remote-debugging-port=9222 \\\n  --user-data-dir=/tmp/tmuxy-e2e-chrome --no-first-run --no-default-browser-check &\ncurl -s http://127.0.0.1:9222/json/version   # confirm it answers before running the suite\n```\n\n**Confirm that Chrome is up first.** Without it every test fails at `skipIfNotReady()` with the reason (server unreachable, or no CDP on 9222). It used to skip and report green in a couple of milliseconds, which looks identical to a real pass; that is now opt-in with `TMUXY_E2E_ALLOW_SKIP=1`, for running one suite without the full stack.\n\nThe suite pins `TMUX_SOCKET` to **`tmuxy-test`** and clears `$TMUX` in `tests/jest.setup.js` (the default lives in `tests/helpers/tmux-socket.js`), so it is safe to run from inside a tmux pane: it creates and kills sessions on a socket of its own, never the `tmuxy` socket a running tmuxy — quite possibly the one you are sitting in — is serving.\n\n**The server under test has to be on that socket too.** It is the other half of every round trip: a server attached elsewhere leaves the tests reading and writing different tmux servers, and every one of them fails at \"session not found\" while the UI looks perfectly healthy. The suite gets this right on its own — a server it starts inherits the pinned socket, and CI sets `TMUX_SOCKET` for the whole e2e job. Only a server you started by hand can diverge, so start it to match:\n\n```bash\nTMUX_SOCKET=tmuxy-test ./target/release/tmuxy-server   # or just let the suite start its own\n```\n\nThe setup step warns when it reuses a server it did not start, because a running server reports no socket and the mismatch cannot be detected — only flagged.\n\n**Another tmux.** `TMUX_BIN` names the tmux binary for the server, the CLI and the harness (`tmuxBin()`): `TMUX_BIN=/opt/tmux-3.2a/bin/tmux npm run test:e2e -- <suite>` runs against the floor without touching the one on `PATH`.\n\nThe Tauri suite (`tests/tauri/`) pins the same socket, in its `jest.global-setup.js` and before `tauri-driver` starts, because the driver hands its environment to the app binary it launches. There the app and the assertions about it are the two halves that have to agree — unset, the app falls back to its own `tmuxy` default while the shared helpers resolve `tmuxy-test`, and the suite reports panes and windows missing from a server the app never touched."},{"doc":"docs/TESTS-E2E.md","heading":"Session Lifecycle","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#session-lifecycle","text":"- Each **test** gets a fresh tmux session: `createTestContext()`'s `beforeEach` creates a `TmuxTestSession`, and `afterEach` destroys it\n- Never leave tmux sessions or windows behind — the context's `afterEach`/`afterAll` handle cleanup; don't bypass them"},{"doc":"docs/TESTS-E2E.md","heading":"Timing","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#timing","text":"- Use `waitForCondition` or `page.waitForFunction` with explicit conditions instead of `delay()`\n- When you must wait, prefer polling for the expected state over sleeping a fixed duration\n- Flaky waits indicate the test is not waiting for the right condition"},{"doc":"docs/TESTS-E2E.md","heading":"Visual Verification","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#visual-verification","text":"Every E2E test that creates UI elements verifies they are visually present, not just in the DOM:\n\n| Instead of | Also assert |\n| --- | --- |\n| The element handle is not null | Its `boundingBox()` exists with width and height above a meaningful floor (e.g. 50px) |\n| A container's `textContent` contains the token | That same container's `getBoundingClientRect()` has visible width and height |\n\nFor terminal text, verify positions **in cells** and against tmux, not just that the text exists: `tests/helpers/cell-grid.js` converts bounding rects into cells using the published `--cell-w` (`getCellWidth`, `getCursorGeometry`, `getRunGeometry`), and tmux's `#{cursor_x}` (via `session.runCommand('display-message -p -t <session> ...')`) is the oracle for where a run must end. A wide glyph is expected to own a 1-cell box with ~2 cells of ink; the ASCII run after it must start on the cell tmux says."},{"doc":"docs/TESTS-E2E.md","heading":"Keyboard Input Tests","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#keyboard-input-tests","text":"When testing keyboard input to a specific pane (float, tiled, etc.):\n\n1. Verify the input target is correct before typing (check `focusedFloatPaneId` or `activePaneId`)\n2. After typing and pressing Enter, verify the **output** appears in the correct pane's visible area — not just in the DOM\n3. Verify the output did NOT appear in other panes (input isolation)"},{"doc":"docs/TESTS-E2E.md","heading":"Escape Key and Modal Close","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#escape-key-and-modal-close","text":"The Escape key has dual meaning: it can close a modal OR be sent to the terminal application. Tests for Escape-to-close must verify the modal actually closes (element removed from DOM), not just that the focus state changed."},{"doc":"docs/TESTS-E2E.md","heading":"Target by Stable IDs, Not Indices","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#target-by-stable-ids-not-indices","text":"When tests need to target specific tmux windows or panes (e.g., to kill a window or send keys), always use stable IDs (`@N` for windows, `%N` for panes) rather than indices (`:N`, `.N`). Indices shift when objects are created or destroyed, causing races between the query that reads the index and the command that uses it. See [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#targeting-use-stable-ids-not-indices) for the full rationale."},{"doc":"docs/TESTS-E2E.md","heading":"No Skipped Tests","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#no-skipped-tests","text":"Never commit `it.skip`, `test.skip`, `describe.skip`, `xit`, `xtest`, or `xdescribe`. ESLint enforces this via `jest/no-disabled-tests` (error) for `tests/`; the Vitest files under `packages/tmuxy-ui/src` have no such rule, so review catches them there. Fix the test, fix the bug, or remove the test entirely."},{"doc":"docs/TESTS-E2E.md","heading":"Interaction-Latency Tests","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#interaction-latency-tests","text":"A nightly job (`interaction-latency` in `nightly-perf.yml`, with a desktop twin) times the\ninteractions a user performs constantly — typing, moving between panes,\nsplitting, zooming, switching tabs — through the same real keyboard path the\nE2E suites use, and gates on how expensive each one is **relative to a single\nkeystroke round trip measured in the same run**. That ratio is what survives\nrunner load; absolute milliseconds are recorded as a trend only. It exists to\ncatch the class of regression the functional suites pass straight through: the\nfeature still works, it just costs three times what it used to.\n\nAdding an interaction means adding one entry in\n`packages/tmuxy-ui/scripts/lib/perf-harness.mjs` (how to trigger it, and the\nvisible thing that says it happened) and one budget in\n`compare-interactions.mjs`; the web and desktop harnesses both pick it up from\nthere. See [PERFORMANCE.md](https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md) § Axis C for the design and the\ncurrent numbers."},{"doc":"docs/TESTS-E2E.md","heading":"Desktop (Tauri) Tests","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#desktop-tauri-tests","text":"The desktop app wraps the same React UI with native IPC instead of HTTP/SSE, so desktop tests cover the seam, not the UI again.\n\n- **Tauri E2E** (`tests/tauri/tauri-app.test.js`): Jest → WebdriverIO → `tauri-driver` on port 4444 → WebKitWebDriver → a debug build at `target/debug/tmuxy`. The global setup builds the frontend and the binary, starts Xvfb on `:99` and the driver; each test launches a fresh app with its own session. Covers app lifecycle, IPC commands through `invoke()`, events through `listen()`, and state sync. Linux only (WebKitGTK and Xvfb).\n- **Smoke tests** (`tests/smoke/`, run by `build-app.yml` after the release build and by `desktop` on PRs): `smoke-test.js` launches the app on Linux (`tauri-driver` + Xvfb) and macOS (`tauri-webdriver` against a debug build with `--features webdriver`), types a command, sees the output, and reads the app's log file (`tmuxy.log` in its state dir) to require no `FATAL` and at most two control-mode connects. `macos-sparse-path-test.js` launches the release binary under launchd's sparse `PATH` and requires a stable connection.\n- Visual behavior assertions follow the same guidelines as E2E (verify visible, not just in DOM)."},{"doc":"docs/TESTS-E2E.md","heading":"Debugging","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#debugging","text":"```bash\n# Single scenario by name\nnpm run test:e2e -- --testNamePattern=\"Scenario 22\"\n\n# Verbose output\nnpm run test:e2e -- --verbose\n```\n\n**Where a run's time went.** Every `lint and tests` run ends with a `timing` job that writes a job, step and test timing table to the run's summary page, and `bin/ci/ci-timing.mjs <run-id>` prints the same for any run. A green run says nothing about what got slower; the table does — a quarantined story at the deadline, a shard twice the size of its sibling and a cache key that stopped hitting were each found this way and invisible from the tick. Look at it before adding a runner or moving a test to the nightly tier: the fix is usually one line in the table.\n\n**What the suite costs over time.** `bin/ci/test-cost.mjs` reports two numbers per month: **minutes-to-green** — for every push to main, the minutes until main is next green, re-runs included, so a slow suite and a red stretch both count — and the commits that went to the tests themselves (✅, and the share of those that say they are about timing). The `monthly test cost` workflow comments them on the \"Test cost by month\" issue on the 1st, so the trend is on record. De-flaking pays off when to-green p90 and the ✅ share both fall; a falling blind-wait ceiling with flat numbers means the work went somewhere that was not the problem.\n\nWhen CI's E2E or latency job fails, its last step prints the tail of the server log (`/tmp/tmuxy-server.log`). A local suite-started server writes stderr to `/tmp/tmuxy-server-stderr.log`.\n\n**Reading a failed E2E test.** The server under test writes its action trace (docs/TRACING.md) to `TMUXY_E2E_TRACE` — in CI `/tmp/tmuxy-e2e-trace.ndjson`, locally `trace.ndjson` in the run's scratch state dir — and the suites' Jest environment (`tests/helpers/trace-environment.js`) stamps a marker at each test's start and end. When a test fails, the trace health check for that test's slice is printed after it: silent panes, reconnects, rejected commands. Every CI E2E job runs the same check on the whole run as GitHub annotations, passing or not, and a failed shard uploads the trace, the server log and the Jest output as `e2e-<suite>-attempt-<n>`. Setup helpers fail where they fail: `navigateToSession` and `verifyRoundTrip` throw with what the page showed and what tmux holds for the session (`tests/helpers/tmux-side.js`) rather than letting a later wait report a bare \"no prompt\". The shared server runs with `TMUXY_NO_SNAPSHOT=1` and `TMUXY_NO_RESTORE=1` and a scratch `TMUXY_STATE_DIR` (`tmuxEnv` in `tests/helpers/tmux-socket.js`, and the CI job's `env`): it neither saves sessions nor rebuilds one from a previous run, so a suite always starts on an empty tab. Session snapshots are tested on a server of the test's own (`tests/helpers/snapshot-server.js`), whose `serverLog()` is what a failed wait there prints."},{"doc":"docs/TESTS-LAYERS.md","heading":"Test Layers and Tiers","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md#test-layers-and-tiers","text":"Every test layer, where a new test goes, and which tier runs it. Part of [TESTS.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md)."},{"doc":"docs/TESTS-LAYERS.md","heading":"The Test Layers","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md#the-test-layers","text":"```\n                 cost / realism\n                       ^\n                       |   Desktop smoke (built app, Linux + macOS)   build-app.yml, tags + daily\n                       |   Tauri E2E (WebDriver -> WebKitGTK app)     desktop\n                       |   Web E2E + snapshots (Chromium -> server    e2e matrix (10 runners)\n                       |     -> tmux 3.7a)\n                       |   Storybook v86 probe (real tmux in an       storybook-v86-probe\n                       |     x86 emulator, in the browser)\n                       |   Storybook probe (recorded tmux states,     storybook-probe\n                       |     Chromium)\n                       |   CLI suite (mocked tmux)                    cli-tests\n                       |   Vitest (jsdom)  |  cargo test              unit-tests | rust-tests\n                       |   ESLint, tsc, Prettier, rustfmt, clippy     lint (+ pre-commit hook)\n                       +-------------------------------------------------------------------->\n```\n\n| Layer | Tool | Location | What it is for | Run locally | CI job | Fails the run? |\n| --- | --- | --- | --- | --- | --- | --- |\n| Lint / types / format | ESLint, `tsc`, Prettier, rustfmt, clippy | `eslint.config.mjs` (tests), `packages/tmuxy-ui/eslint.config.js` (+ `eslint-rules/`), `Cargo.toml` `[workspace.lints.clippy]` | Static gates; bans skipped tests and tmux shortcuts in E2E code | `npm run lint`, `npx tsc --noEmit` (in `packages/tmuxy-ui`), `cargo clippy -p tmuxy-core -p tmuxy-server -p tmuxy-tauri-app -p tmuxy-group -p tmuxy-pty -- -D warnings` | `lint` | Yes |\n| Rust unit + integration | `cargo test` (proptest, fixtures) | `#[cfg(test)]` modules in every crate; `packages/tmuxy-core/tests/` | Parser, state aggregator, reflow, command routing, server/Tauri seams; five core integration tests drive a real `tmux -CC` | `cargo test --workspace` (needs `tmux` on PATH) | `rust-tests` | Yes |\n| UI unit | Vitest + Testing Library, jsdom | `packages/tmuxy-ui/src/**/__tests__/`, `src/test/` (setup: `src/test/setup.ts`, config: `vite.config.ts`) | Pure logic, machine state handlers, adapters, a few mocked component renders | `npm test -- --run` (plain `npm test` watches) | `unit-tests` | Yes |\n| CLI | Jest, mocked binaries | `tests/cli/` (`mocks/tmux`, `mocks/tmuxy-server`) | `packages/tmuxy-cli/bin/tmuxy-cli` dispatch and argv; asserts every tmux call carries the dedicated socket flag | `npm run test:cli` | `cli-tests` | Yes |\n| Storybook probe | Playwright + `storybook dev` | `packages/tmuxy-ui/src/**/*.stories.tsx`, `scripts/probe-stories.mjs`; recordings in `src/stories/fixtures/` | Every non-`v86` story renders over states recorded from real tmux and its play function passes (render, glitch, immediacy budgets) | `npm run storybook -w tmuxy-ui`, then `npm run test-storybook -w tmuxy-ui` | `storybook-probe` (commit tier); nightly `deterministic` (all + axe) | Yes |\n| Storybook v86 probe | Playwright, v86 + `tmuxy-wasm` | `v86`-tagged stories (`src/stories/App*.stories.tsx`), `scripts/probe-spikes.mjs` | Real tmux behind the real UI with no server: the stories whose subject is tmux's reply — optimistic updates reconciled, `%output` rendering, `%exit` — and the demo site's own guards | `npm run test-storybook:v86 -w tmuxy-ui` (needs the assets below) | `storybook-v86-probe` | Gate step (three id filters): yes. Full sweep: nightly, blocking there |\n| Web E2E | Jest + Playwright over CDP | `tests/1-…` to `tests/15-…`, `tests/helpers/` | Whole chain: keyboard/mouse in Chromium → server → tmux → SSE → DOM | `npm run test:e2e` (Chrome on 9222, see below) | `e2e` matrix, one runner per file (`2-layout` is split across two) | Yes |\n| Snapshot | Jest + Playwright | `tests/snapshots/snapshot.test.js` | Read-only UI ↔ tmux consistency checks (structure, then the tab strip); also the nightly tmux-matrix smoke | `npx jest tests/snapshots/` | `e2e (snapshots)` | Yes |\n| Interaction latency | Playwright | `packages/tmuxy-ui/scripts/measure-interactions.mjs`, `compare-interactions.mjs`, `tests/perf/interaction-baseline.json` | Cost of key-echo, pane nav, zoom, split, tab switch, as a ratio to a keystroke | See [Interaction-Latency Tests](https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md#interaction-latency-tests) | nightly `perf measurements` (`interaction-latency`, `-desktop`) | Nightly only. Ratio budgets fail that run; absolute ms warn |\n| Tauri E2E | Jest + WebdriverIO + `tauri-driver`, Xvfb | `tests/tauri/` | Desktop IPC seam (`invoke`/`listen`), app lifecycle, state sync. Linux/WebKitGTK only | `npm run test:tauri` (Linux) | `desktop` | Yes |\n| Desktop smoke | WebdriverIO, `tauri-driver` / `tauri-webdriver` | `tests/smoke/` | The packaged app launches, runs a command, connects once; launch-environment regressions | Built by CI; `node tests/smoke/smoke-test.js <binary>` with a driver on 4444 | `build-app.yml` `build` matrix | `smoke-test.js` and macOS `macos-sparse-path-test.js`: yes |\n| Dependency audit | `npm audit`, `cargo audit` | `.cargo/audit.toml` | Production npm deps (high+) and the Rust workspace | same commands | `audit` | Yes |\n\nThe criterion benchmark `packages/tmuxy-core/benches/core_pipeline.rs` (see [PERFORMANCE.md](https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md) Axis A) runs nightly in `nightly-perf.yml` (`core-bench`); its ratio budget fails that run, its absolute numbers only warn."},{"doc":"docs/TESTS-LAYERS.md","heading":"CI Workflows","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md#ci-workflows","text":"| Workflow | Trigger | Jobs | Runner |\n| --- | --- | --- | --- |\n| `.github/workflows/lint-and-tests.yml` | push to `main`, PRs to `main` | `build-frontend` + `build-server` (the dist and a `ci`-profile server, shared by the jobs below), `changes` (what the push touched: `bin/ci/ci-changes.mjs`), `lint` (`bin/lint-gate check`), `unit-tests`, `macos-tests` (the CLI suite; the Rust suite only when macOS-specific code changed, and on tags), `rust-tests`, `cli-tests`, `demo-build`, `e2e` (matrix), `desktop` (debug Tauri build, smoke test + Tauri E2E; skipped when nothing it covers changed), `storybook-probe`, `storybook-v86-probe` (skipped likewise), `audit` (only when a lockfile moved) | `ubuntu-latest`, plus `macos-latest` for `macos-tests` |\n| `.github/workflows/nightly-v86.yml` | nightly cron, manual | the full v86 story sweep; a failure opens an issue | `ubuntu-latest` |\n| `.github/workflows/nightly-tmux-matrix.yml` | nightly cron, manual | the tmux-facing suites against each supported tmux version; released versions gate, `HEAD` reports only | `ubuntu-latest` |\n| `.github/workflows/build-app.yml` | `v*` tags, daily cron, manual | `build` (Tauri build + smoke tests) + `upgrade-path`, then on tags `release`, `bump-cask`, `bump-formula` | `ubuntu-22.04` (amd64), `ubuntu-22.04-arm`, `macos-latest` |\n| `.github/workflows/nightly-perf.yml` | nightly cron, manual | `interaction-latency`, `interaction-latency-desktop` and `core-bench` (their ratio budgets fail the run), `soak` (reports only), plus on dispatch an interaction-baseline refresh | `ubuntu-latest` |\n| `.github/workflows/deploy-demo.yml` | push to `main` touching the demo or UI source, manual | demo build + Pages deploy (no tests) | `ubuntu-latest` |\n\n**\"Blocking\" means the job turns the run red — nothing is enforced at merge.** `main` has no branch protection or required status checks; its only ruleset blocks deletion and force-push. Nothing in the PR run is non-blocking any more: the suites that used to be waved through (`9-animations`, `hostile-config-test.js`) were deleted rather than left reporting a result nobody could act on, and the v86 sweep moved to its own nightly workflow where it blocks and opens an issue.\n\nQuarantine is the one deliberate exception, and it is bounded: a story listed in `packages/tmuxy-ui/scripts/probe-quarantine.json` (deterministic probe) still runs and is still reported, but does not fail the job until its expiry date, after which it blocks again. The list is capped, and every entry carries a reason and an ISO expiry — `scripts/probe-quarantine.mjs` enforces that and refuses to run on a list that breaks it.\n\nJobs that need tmux build **3.7a** from source and cache it (`e2e`, `desktop`, `rust-tests`, and the nightly perf jobs). The macOS smoke test uses Homebrew's `tmux` and the Linux smoke test apt's `tmux`."},{"doc":"docs/TESTS-LAYERS.md","heading":"Red-Main Policy & Bug-Fix Attribution","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md#red-main-policy--bug-fix-attribution","text":"The rules for a red `main` — fix or quarantine within 24 hours, no features on a red main, quarantine only through the bounded lists — and the `[missed-by: <layer>]` and `[invariant: <id>]` tags a bug-fix commit carries live in `AGENTS.md` (Testing & Bug Fixes). The ids point at rows of [INVARIANTS.md](https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md), where each cross-layer promise names the test that holds it; a row whose kind is ordering or liveness wants a property or interleaving test, since one scripted ordering only proves the bug it was written for. One addition that matters here: **re-runs are not fixes.** Clicking re-run on a failing job masks a race and degrades confidence in CI; fix the synchronization or the wait condition instead."},{"doc":"docs/TESTS-LAYERS.md","heading":"Local Gates","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md#local-gates","text":"One list, `bin/lint-gate`, says what is formatted, linted, type-checked and clippy'd — the UI sources, the E2E suites, the scripts under `bin/`, `tests/perf/` and the UI package, the demo site, every crate — and the hooks and CI's `lint` job all run it, so they cannot disagree about what a clean tree is. `.github/pre-commit` (enabled by `npm install` through the `prepare` script) runs `bin/lint-gate format` (Prettier, `eslint --fix`, `cargo fmt`), re-staging rewrites only for fully staged files, then a check that `eslint.config.mjs` still bans `tmuxExec`, `tmuxRun`, `tmuxCmd` and `execSync`, `vitest related --run` for staged UI sources, and the repo policy checks. `.github/pre-push` runs `bin/lint-gate check`, the read-only gate CI's `lint` job runs step by step: type checks and clippy with `-D warnings` once per push rather than once per commit.\n\nESLint rules that exist to protect test quality (`eslint.config.mjs`):\n\n- `jest/no-disabled-tests` is an error for `tests/**/*.js`.\n- Every `tests/**/*.test.js` outside `tests/tauri/` may not call `tmuxRun` or `tmuxCmd`, use `execSync` or import `child_process`; setup and ground-truth reads go through `tmuxExec()` in `tests/helpers/tmux-socket.js`. The interaction helpers `tests/helpers/pane-ops.js` and `tests/helpers/keyboard.js` may not call `tmuxExec` or `tmuxRun` at all, since they must act the way a user does.\n\nClippy warns on `unwrap_used` and `expect_used` workspace-wide; CI promotes all warnings to errors. Test files opt out explicitly with an `allow` attribute."},{"doc":"docs/TESTS-LAYERS.md","heading":"Which Layer Does a New Test Go In?","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md#which-layer-does-a-new-test-go-in","text":"Pick the cheapest layer that can still fail for the bug you care about. If the bug lives in the chain between two layers, the test belongs in the layer that contains the whole chain.\n\n| The behavior is… | Put the test in |\n| --- | --- |\n| A pure function, parser or state transition in TypeScript (layout math, copy-mode engine, key mapping, a machine handler) | Vitest, next to the code in `__tests__/` |\n| Rust parsing, aggregation, reflow or routing that can be fed recorded control-mode text | `cargo test` — a `#[cfg(test)]` module, or `packages/tmuxy-core/tests/` with a fixture |\n| Rust behavior that depends on how real tmux answers or orders replies | `packages/tmuxy-core/tests/` against a scratch socket, like `reply_channel.rs` |\n| What the `tmuxy` CLI sends to tmux | `tests/cli/` |\n| How a component looks or animates, render counts, flicker, optimistic paint timing with controlled latency or forced rejection | A Storybook story with a play function on `FixtureApp`/`FixtureProvider` (`src/stories/FixtureHarness.tsx`), over a recording of real tmux ([Recorded fixtures](https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md#recorded-fixtures)) |\n| \"tmux did X and the UI showed it\" without needing the Rust server or HTTP | A `v86`-tagged story on `V86AppHarness` |\n| A user-visible feature through the real server and transport: keyboard, mouse, touch, floats, groups, reconnect, OSC, widgets | Web E2E — extend the numbered file whose theme fits (below); a production bug with no better home goes in `tests/7-regression-bugs.test.js` |\n| Something that got slower but still works | An entry in `measure-interactions.mjs` plus a budget in `compare-interactions.mjs` |\n| Anything that differs on desktop: IPC commands, events, app lifecycle | `tests/tauri/tauri-app.test.js` |\n| The packaged app failing to start in a particular launch environment | `tests/smoke/` |\n\nWhatever the layer, the rules below apply: assert what the user sees, drive it through the user's path, and keep one feature in one test. A unit or story test does not replace an E2E test for a user path — it catches the bug earlier and cheaper."},{"doc":"docs/TESTS-LAYERS.md","heading":"Tiers: per commit, nightly, and before a release","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md#tiers-per-commit-nightly-and-before-a-release","text":"Not every E2E test earns a runner on every push. A test whose logic a cheaper layer already holds — a zoom whose geometry `zoomIdentity.test.ts` and a story both check, a context menu every `PaneContextMenu` story opens, a backoff schedule `disconnected.test.ts` times — still has value against the real server and real tmux, but a regression there is not something a push has to wait for. Those tests carry **`[nightly]`** at the end of their name, and run in three places:\n\n| Run | What runs | How |\n| --- | --- | --- |\n| every push and PR (`lint-and-tests.yml`) | every E2E test **without** the tag | `bin/ci/e2e-plan.mjs commit` builds the matrix from `tests/e2e-suites.json`, with a jest name pattern that skips `[nightly]`; a file marked `tier: nightly` there (every test tagged) gets no runner at all |\n| a push or PR that edits the web suites | the above, plus every test **with** the tag | `bin/ci/ci-changes.mjs` sees a changed file under `tests/` (not `tests/cli/` or `tests/tauri/`) and the plan adds one `nightly-tier` runner (about five minutes) that runs the tagged tests as the nightly does, so a push that breaks one fails then rather than the next night |\n| nightly (`nightly-tmux-matrix.yml`, on 3.7a) | every test **with** the tag, every file | `npx jest tests/ --testNamePattern '\\[nightly\\]'` on the runner that already has the server up; a failure opens or comments on the nightly tracking issue, and a run where every released version passes closes it (`bin/ci/nightly-issue`) |\n| a tag build (`build-app.yml`) | everything | it calls `lint-and-tests.yml` with `e2e-tier: full`, so the release is gated on the whole suite, the nightly tier included |\n\nSo the tag is the one source of truth: adding it to a test moves it out of the push and into the nightly and the release gate; nothing else needs listing. The per-commit matrix is still the one that fails a push, and a test in it is one the push cannot do without — moving a test to the nightly tier is a claim that a cheaper layer covers its logic, and the test's doc comment should name that layer. A test that only matters for packaging or a deployment shape (a reverse proxy, a sparse launchd `PATH`) belongs in the nightly tier too: the tag build runs it before anything ships.\n\nThe same tag works for stories (`tags: ['nightly']` on a story), where the per-commit probe leaves them out and the nightly `deterministic` job runs them. A quarantined story that fails by **timing out** belongs there as well while it is quarantined: it sits at the per-story deadline on every push and gates nothing, so the minutes it costs are better spent nightly.\n\nA push that changes only prose — `docs/**` or any `*.md` — runs no workflow at all (`paths-ignore` on `lint-and-tests.yml`). Nothing in the suite reads those files, and `main` has no required check that a skipped run could leave pending."},{"doc":"docs/TESTS-LAYERS.md","heading":"UI Unit Tests","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md#ui-unit-tests","text":"- Vitest with jsdom, configured in `packages/tmuxy-ui/vite.config.ts`; `src/test/setup.ts` installs jest-dom matchers, an in-memory `localStorage` for Node ≥ 22, and cleans up after each test\n- Tests live beside the code in `__tests__/` directories: `src/utils/`, `src/machines/` (state handlers share `src/machines/app/states/__tests__/testHarness.ts`), `src/domain/` (the command vocabulary, the delta protocol, the store's pure logic), `src/infra/` (the transport service and its drivers — HTTP, Tauri, the demo backend — key batching, the store), `src/components/`, `src/hooks/`\n- Test pure logic: parsers, state transformations, utility functions, adapter protocol handling\n- Component rendering in jsdom is limited to a few mocked-context tests in `src/test/`. jsdom has no layout, so a jsdom render test can prove \"does not throw\" and \"renders this text\", never \"is visible\" — anything visual belongs in a story or E2E\n- Keep unit tests fast (< 1s per file)"},{"doc":"docs/TESTS-LAYERS.md","heading":"Rust Tests","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md#rust-tests","text":"- `cargo test --workspace` runs every crate's `#[cfg(test)]` modules plus the integration tests in `packages/tmuxy-core/tests/`. Most of the count is in `tmuxy-core`; `tmuxy-server`, `tmuxy-tauri-app`, `tmuxy-wasm`, `tmuxy-group` and `tmuxy-pty` (whose `tests/pane.rs` drives the helper over a socket pair) have smaller suites\n- Prefer fixtures over a live tmux: `control_mode_push_api.rs` feeds recorded `tmux -CC` text through the parser and aggregator, `terminal_fidelity.rs` and `pane_reflow_parity.rs` replay byte streams through the emulator (`tests/fixtures/`), and `state_aggregator_props.rs` is a proptest suite (its regressions file is committed)\n- `initial_state.rs`, `initial_state_history.rs`, `reply_channel.rs`, `pinned_split_race.rs` and `enforced_settings.rs` drive a real `tmux -CC` monitor on a per-process scratch socket and need a `tmux` binary; use that pattern only when the bug depends on how tmux itself answers\n- Test code opts out of the `unwrap_used`/`expect_used` lints with an `allow` attribute"},{"doc":"docs/TESTS-LAYERS.md","heading":"CLI Tests","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md#cli-tests","text":"`tests/cli/` has its own Jest config (`tests/cli/jest.config.js`) and runs `packages/tmuxy-cli/bin/tmuxy-cli` against the mock `tmux` and `tmuxy-server` scripts in `tests/cli/mocks/`, which log every invocation. `tests/cli/helpers/run-cli.js` fails any recorded tmux call that does not lead with the dedicated socket flag, so socket isolation is enforced for every subcommand. It is hermetic: no tmux, no server, no browser."},{"doc":"docs/TESTS-STORYBOOK.md","heading":"Storybook Tests","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md#storybook-tests","text":"The deterministic and v86 Storybook probes. Part of [TESTS.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md)."},{"doc":"docs/TESTS-STORYBOOK.md","heading":"Storybook Tests","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md#storybook-tests-1","text":"Two tiers, cheapest first. All play functions follow the same rules as E2E tests (real user paths, visible-rect assertions, unique sentinels).\n\n| Tier | What runs | Where |\n| --- | --- | --- |\n| Deterministic probe (`npm run test-storybook -w tmuxy-ui`) | Every non-`v86` story + its play function, fresh Chromium page each, 1280x800, 3 at a time | CI: `storybook-probe` (blocking) runs the commit tier with the axe pass — `PROBE_TIER=commit` leaves out stories tagged `nightly`. Every story runs nightly in `nightly-v86.yml` (`deterministic`) |\n| v86 probe (`npm run test-storybook:v86 -w tmuxy-ui`) | Every `v86`-tagged story on ONE shared page (real tmux in the x86 emulator, snapshot-reset between stories; periodic cold-boot to cap accumulated drift) | CI: `storybook-v86-probe` runs the gate, blocking: the ids matching `split-optimistic-timeline`, `split-rejected-rollback` and `resize-pane-drag` (a substring match, so the last also takes `ResizePaneDragVertical` and `ResizePaneDragGrid`). The full sweep moved to the nightly `nightly-v86.yml`, where it blocks and opens an issue |\n\nThe `v86` tier needs two gitignored artifact sets that Storybook mounts as static dirs (`.storybook/main.ts`): `packages/tmuxy-wasm/pkg` from `npm run build:wasm` (needs the `wasm32-unknown-unknown` target and a `wasm-bindgen` CLI matching the version in `Cargo.lock`), and `packages/tmuxy-ui/v86-assets` from `npm run fetch:v86-image -w tmuxy-ui` plus `npm run build:v86-snapshot -w tmuxy-ui` (needs the `i686-unknown-linux-musl` target for the guest `tmuxy-group` and `zstd`). Without them the `V86AppHarness` stories render the app's Connection Error screen with a failed dynamic import of `/wasm/tmuxy_wasm.js`. Both directories must at least exist for Storybook to start; the `storybook-probe` CI job creates them empty because it only runs the deterministic tier. The `v86` tag is set at the meta level of a story file; `probe-stories.mjs` excludes it and `probe-spikes.mjs` selects it. The `nightly` tag is set per story (`tags: ['nightly']` on the export) and is the story-side of [Tiers](https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md#tiers-per-commit-nightly-and-before-a-release): frame-sampled animations, render-count budgets and language sweeps carry it, and `PROBE_TIER` decides whether a run takes them.\n\n**Seeing the raw tmux TUI.** The toolbar's \"tmux view\" global (`.storybook/preview.ts`, decorator in `src/stories/tmuxView.tsx`) attaches a second, read-only tmux client on the guest's VGA console and shows v86's rendering of it — tmux drawing its own borders, status line and cursor, no tmuxy code involved — either beside the story or as a cell-aligned translucent overlay. It applies to the shared-engine `Scenarios/Application` stories; use it to eyeball what tmux thinks the screen looks like versus what tmuxy rendered.\n\nBoth probes expect a running Storybook (`npm run storybook -w tmuxy-ui`). CI runs the **dev** server (no build step needed; on-demand compilation). Filter either probe to specific stories by id substring: `npm run test-storybook:v86 -w tmuxy-ui -- split-optimistic resize-pane-drag`. Set `PROBE_TIMINGS_JSON=<path>` to write per-story timings. The fonts a story's wide-glyph checks depend on are `bin/chromium-fonts.txt` — the list CI installs and `bin/install-dev-tools` installs in a container, so the two measure a CJK or emoji cell the same way; a host without them sees every fullwidth glyph as a narrow fallback and fails those stories for a reason that has nothing to do with the code."},{"doc":"docs/TESTS-STORYBOOK.md","heading":"When the probe goes red","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md#when-the-probe-goes-red","text":"The probe used to report `playFunctionThrewException` and nothing else, which is why it became the single biggest source of red `main`. It now writes a screenshot, a DOM dump and the real error with its stack to `packages/tmuxy-ui/probe-artifacts/` (gitignored, uploaded by CI on failure), and prints page and console errors.\n\nTo reproduce a CI failure locally, the knobs are `PROBE_REPEAT`, `PROBE_CONCURRENCY`, `PROBE_CPU_THROTTLE` (renderer throttling — a loaded runner is usually what a \"flaky\" story is actually reacting to) and `PROBE_A11Y=0`.\n\n**Quarantine** (`packages/tmuxy-ui/scripts/probe-quarantine.json`) is the one way a known failure stops gating, and it is deliberately hard to abuse: every entry needs a reason and a `YYYY-MM-DD` expiry, after which it blocks again; there is a hard cap on how many entries may exist; and a malformed or over-cap file makes the probe exit without running anything. A quarantined story still runs and is still reported — it just does not fail the job, and it is listed as ready to remove once it passes.\n\nA quarantined story that fails by **timing out** is the one kind quarantine must not hide: it sits at the per-story deadline on every push, reports nothing new, and once cost the commit probe two minutes a run for weeks before anyone looked. Its entry carries `failure: \"probe-timeout\"`, and the story must then also carry the `nightly` tag — the commit-tier probe refuses to run otherwise. The probe prints the ten slowest stories of every run, and names any shielded timeout whose entry does not declare it."},{"doc":"docs/TESTS-STORYBOOK.md","heading":"Accessibility","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md#accessibility","text":"Every story is scanned with axe-core (already installed via `@storybook/addon-a11y`) once its play function settles — on every push (`storybook-probe`) and nightly over the full set (the `deterministic` job of `nightly-v86.yml`). The scan was nightly-only while every blocking rule sat in quarantine; with the quarantine empty it gates, and `PROBE_A11Y=0` is a local knob for a run that wants only the play functions. **`critical` and `serious` violations fail the job; `moderate` and `minor` are printed only.** A story that navigates its own page is recorded as skipped rather than failed. `Mocked App/Sidebar Layout → TabOrderNeverTrapsTheKeyboard` is the keyboard-only check: it tabs through the chrome and asserts focus moves, is visible at every stop, never repeats before wrapping and is never trapped."},{"doc":"docs/TESTS-STORYBOOK.md","heading":"Choosing a harness","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md#choosing-a-harness","text":"- **`FixtureApp` / `FixtureProvider`** (`src/stories/FixtureHarness.tsx`, deterministic): component behaviour, optimistic-update timing that needs controlled latency (`commandDelayMs`) or forced rejections (`failCommand`), animation, render and glitch budgets. They replay states recorded from real tmux (below), so what a story shows is a screen tmux drew; `FixtureProvider` puts a single component inside the app's context.\n- **`V86AppHarness`** (`src/stories/StoryHarness.tsx`, real tmux): anything whose bugs live in the real chain — command transport, control-mode parsing, reconcile timing, `%output` rendering, tmux's own replies. If a story asserts \"tmux did X\", it belongs here. If the behaviour is already driven end to end by a `tests/*.test.js` suite, it belongs there instead, not in both."},{"doc":"docs/TESTS-STORYBOOK.md","heading":"Recorded fixtures","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md#recorded-fixtures","text":"A fixture-tier story starts from a state recorded from real tmux and names the commands that move it to another: `FixtureAdapter` (`src/stories/fixtures/FixtureAdapter.ts`) answers a command matching one of the story's transitions with the recorded state it names, and leaves the state alone for any other command. It imitates nothing, so a story that waits on a command's effect has to script it.\n\nRecordings are made by `scripts/record-story-fixtures.mjs` from the scenarios in `src/stories/fixtures/scenarios/` — one module per recording, a list of steps (`src/stories/fixtures/scenario.ts`): tmux commands, spelled by the app's own `toTmuxCommand` where the app sends them, files to write, a client size, text to wait for, preview documents to keep. Each scenario runs against a fresh tmux server on a socket and a port of its own, through the real tmuxy server, with a scratch home and a plain bash at a `$ ` prompt, and the state after every step goes to `src/stories/fixtures/gen/<scenario>.json`. A recording carries nothing of the machine it was made on, and two runs write the same bytes. Re-record (`node scripts/record-story-fixtures.mjs [name…]` in `packages/tmuxy-ui`, after a release build of the server) when the wire state changes shape or a scenario changes; a recording cannot re-tile panes for a client size it was not made at, which is why the stories about a column reflowing the grid are `v86` ones."},{"doc":"docs/TESTS-STORYBOOK.md","heading":"Immediacy assertions (optimistic rendering)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md#immediacy-assertions-optimistic-rendering","text":"\"Immediate\" is measured, not assumed: arm `armPaintProbe` (`src/stories/immediacy.ts`) just before the input, and assert the first matching DOM mutation lands within a few animation frames (≤5 absorbs userEvent dispatch overhead; a real round-trip takes dozens). A painted `__placeholder_*` pane id is itself proof of optimism — the server never emits one. After the optimistic paint, assert the reconcile is invisible: no pane-node removals (`LayoutMutationRecorder`), no highlight flaps (record the class/attribute history with a MutationObserver — polling misses one-frame reverts)."},{"doc":"docs/TESTS-STORYBOOK.md","heading":"Glitch budgets","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md#glitch-budgets","text":"`src/stories/glitchRecorder.ts` is the one glitch detector: MutationObserver-based node-flicker/attribute-churn detection plus rect sampling for size jumps, with the collector (runs in the page) separate from the classifier (pure). Stories use its `GlitchRecorder` in-page; the Jest helper `tests/helpers/glitch-detector.js` loads the same file through Node's own loader (Node strips the types, so the file keeps to erasable syntax), serialises the collector into the page with Playwright and classifies the recording in Node. Budgets are code: one `src/stories/glitch-thresholds.json` — loosening a budget is a reviewable diff, not a silent drift.\n\n`src/stories/resizeGlitch.ts` (`ResizeGlitchRecorder`) is the resize-specific counterpart: it logs every pane's `top`/`left`/`width`/`height` — from both the inline-`style` MutationObserver (every React commit, so a 1-frame revert can't hide) and an rAF rect sampler — and flags any A→B→A _reversal_. A threshold-on-consecutive-frames detector misses these; a value that leaves and returns does not. It samples the outer box (top/left/width/height, via style + rAF) AND each pane's terminal-content top (`[role=log]`) — the content shifts a row when the header appears/disappears even while the box stays the same size. Because the resize stories drive a MONOTONIC drag, any reversal is a real glitch: a mid-drag grid shift, the pane flashing back to an old size after mouse-up, or an uninvolved pane's content jumping up a row. See `Scenarios/Application` → `ResizePaneDrag` (horizontal), `ResizePaneDragVertical` (stacked), `ResizePaneDragGrid` (2x2 tiled — a whole-band resize)."},{"doc":"docs/TESTS-STORYBOOK.md","heading":"Render budgets","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md#render-budgets","text":"`src/utils/renderLog.tsx` places `LogProfiler` markers inside key components; each marker records one entry per RENDER of its host component to `window.__tmuxyRenderLog` when a story enables it (`enableRenderLog()` before mount). Budgets assert render counts per component id (e.g. typing into pane A must not render `Pane:B` or `WindowTabs`). React's own `<Profiler onRender>` is deliberately not used — it over-reports in this tree, firing for subtrees that fully bailed out. MutationObserver cannot see this class of waste — a re-render that produces identical DOM still costs CPU."},{"doc":"docs/TESTS.md","heading":"Testing","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#testing","text":"How tmuxy is tested today, where a new test belongs, and the rules every test follows. The first half is the map (layers, CI, gaps); the second half is the guidelines per layer.\n\nSee [TESTS-LAYERS.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS-LAYERS.md) for these sections: The Test Layers; Which Layer Does a New Test Go In?; Tiers: per commit, nightly, and before a release; UI Unit Tests; Rust Tests; CLI Tests."},{"doc":"docs/TESTS.md","heading":"Core Principle: Test What the User Sees","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#core-principle-test-what-the-user-sees","text":"A test passes when a real user would say \"this works.\" A test that checks internal state while the feature is visually broken is worse than no test — it creates false confidence.\n\nEvery assertion must answer: **\"If this assertion passes but everything else about the feature is wrong, would a user still consider it working?\"** If the answer is no, the assertion is testing an implementation detail, not the feature."},{"doc":"docs/TESTS.md","heading":"The DOM Trap","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#the-dom-trap","text":"The most common failure mode in UI testing is asserting against the DOM instead of what the user perceives. The DOM and the rendered output can disagree in many ways:\n\n- An element exists but is **clipped** by `overflow: hidden` on a parent\n- An element has the correct text but is **off-screen** or **zero-sized**\n- A CSS property has the right value in `getComputedStyle` but a parent's style **overrides it visually** (opacity, visibility, display, z-index)\n- Content is in the DOM but **behind another element** (z-index, overlapping absolutes)\n- An element is **positioned outside its container** due to double-positioning bugs\n\n**Rules:**\n\n1. After checking that an element exists, always verify it is **visually present**: bounding rect has non-zero width/height, it is within the viewport, and it is not obscured.\n2. Never trust `textContent` alone. If the test claims \"output is visible,\" verify the element containing that text has a visible bounding rect.\n3. Never trust `getComputedStyle` alone. A green border on an element clipped to 0px height is not a green border.\n4. When testing content rendering (e.g., \"type a command and see output\"), verify the content container has **visible dimensions** and the text is within the visible region."},{"doc":"docs/TESTS.md","heading":"Test What the Feature Does, Not How It Works","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#test-what-the-feature-does-not-how-it-works","text":"Bad test: \"After split-window, XState context has 2 panes and the DOM has 2 `.pane-layout-item` elements.\"\nGood test: \"After split-window, two terminal areas are visible, each with non-zero size, and typing in one does not affect the other.\"\n\nBad test: \"Float pane has `border-color: rgb(0, 205, 0)` in computed style.\"\nGood test: \"Float pane is visible (has area > 0), shows terminal content, and accepts keyboard input that produces visible output.\"\n\nThe bad tests would pass even if the float were invisible due to overflow clipping. The good tests would catch it."},{"doc":"docs/TESTS.md","heading":"User Paths Over Adapter Calls","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#user-paths-over-adapter-calls","text":"Tests should exercise features the way a user would trigger them, not the way the code internally implements them.\n\n- If a user creates a float by typing `tmuxy pane float` in the terminal, the test should type that command in the terminal — not call `ctx.session._exec('break-pane ...')`.\n- If a user closes a float by clicking the X button, the test should click the X button — not call `tmux kill-window`.\n- If a keyboard shortcut triggers an action, the test should press that keyboard shortcut.\n\nWhen a test uses an internal adapter call instead of the real user path, it skips the entire chain that can break: shell script execution, tmux command routing, control mode event propagation, React state updates, and DOM rendering. This is exactly the chain where bugs live.\n\n**Exception:** Setup steps that aren't part of the feature under test can use adapter calls for speed. For example, splitting panes as a prerequisite for testing float behavior is fine via adapter. But the float creation itself must go through the user path."},{"doc":"docs/TESTS.md","heading":"One Feature, One Test","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#one-feature-one-test","text":"Each test should cover one user-visible behavior end-to-end. Do not split a feature into \"check state\" and \"check DOM\" and \"check style\" as separate tests — that creates the illusion of coverage while missing the integration between them.\n\nA float pane test should, in a single test:\n\n1. Create the float (via user path)\n2. Verify it appeared visually (bounding rect, visible content)\n3. Interact with it (type, see output)\n4. Close it (via user path)\n5. Verify it is gone\n\nDo not write five separate tests for these steps. The value is in the chain."},{"doc":"docs/TESTS.md","heading":"Does This Test Earn Its Place?","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#does-this-test-earn-its-place","text":"Three questions for a new test, and for any test you touch; a \"yes\" to any one of them is a test to delete or fold into its neighbour. Sixty cases went in one pass once these were asked, and none had ever caught a tmuxy bug.\n\n1. **Could it fail only if a dependency changed?** A test of `thiserror`'s `Display`, of `vt100` measuring an emoji, of Effect's `catchTags`, of a derived `Default` — tests the library, not the product.\n2. **Is there another test in the file that fails for the same reason?** Every `-h` row beside its `--help` row; six copy-pasted direction tests that differ in one string; a \"renders the container\" test before a test that renders the container and then checks its contents. Tables and `test.each` are for the rows that differ in data, not for repeating a row.\n3. **Does it assert something the code never does?** A hook that never sends an event, asserted not to send it; a store rejection whose error type is never checked; a test helper tested instead of the thing it stands in for. These pass forever and say nothing.\n\nThe matching rule for waits: a wait names the **event** it expects, never a count of events or a state derived from one. A count is satisfied by a late report of the previous event; an optimistic client state is set before anything happened; tmux's current pane during a group swap is whichever window the swap is passing through. Where no settled signal exists, a fixed wait with a comment saying why is the honest choice, and `BLIND_WAIT_CEILING` in `bin/check-repo-policy.mjs` exists to make it explicit, not to be ratcheted to zero."},{"doc":"docs/TESTS.md","heading":"Assertions That Catch Real Bugs","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#assertions-that-catch-real-bugs","text":"For every assertion, ask: **\"What bug would make this assertion fail?\"** If you cannot name a specific, plausible bug, the assertion is not useful.\n\n| Assertion | Bug it catches |\n| --- | --- |\n| Float container bounding rect > 100x100 | Overflow clipping, zero-height parent, missing content |\n| Typed text appears within the visible area of the float | Keyboard routing broken, content not rendering, wrong pane targeted |\n| Float is gone after close AND no orphan tmux window | Close handler broken, tmux window leak |\n| Background pane still interactive after float closes | Focus not restored, keyboard routing stuck |\n\n| Assertion | What it does NOT catch |\n| --- | --- |\n| `getComputedStyle(fc).borderColor === green` | Border on invisible element |\n| `log.textContent.includes(token)` | Text in DOM but clipped/hidden |\n| `floatPanes.length === 1` | Float exists in state but not rendered |\n| `focusedFloatPaneId !== null` | Focus set but keyboard not actually routing |\n\nSee [TESTS-E2E.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS-E2E.md) for these sections: E2E Tests; Interaction-Latency Tests; Desktop (Tauri) Tests; Debugging.\n\nSee [TESTS-STORYBOOK.md](https://github.com/flplima/tmuxy/blob/main/docs/TESTS-STORYBOOK.md) for this section: Storybook Tests."},{"doc":"docs/TESTS.md","heading":"What Not to Test","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#what-not-to-test","text":"- Framework behavior (React renders components, XState transitions on events)\n- CSS values in isolation (computed styles without visual verification)\n- Internal state that has no user-visible consequence\n- Implementation details that could change without affecting the user experience\n- Third-party libraries doing what their docs say they do"},{"doc":"docs/TESTS.md","heading":"Running Tests","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#running-tests","text":"```bash\nnpm run lint                               # ESLint: tests/ and tmuxy-ui\nnpm test -- --run                          # Vitest once (without --run it watches)\ncargo test --workspace                     # Rust (needs tmux on PATH)\nnpm run test:cli                           # CLI suite, hermetic\n\nnpm run test:e2e                           # Web E2E + snapshots; starts its own server if needed\nnpx jest tests/2-layout-navigation.test.js # One E2E file\n\nnpm run storybook -w tmuxy-ui              # Storybook dev server (required for probes)\nnpm run test-storybook -w tmuxy-ui         # Probe all non-v86 stories\nnpm run test-storybook:v86 -w tmuxy-ui     # Probe v86 stories (shared engine)\n\nnpm run test:tauri                         # Tauri E2E (Linux: tauri-driver, Xvfb, webkit2gtk-driver)\n\n# Interaction latency (needs a running server; --cdp reuses the dev browser)\nnpm run perf:interactions -- --url http://localhost:9000 --out tests/perf/interaction-report.json\nnpm run perf:compare -- --report tests/perf/interaction-report.json\n```"},{"doc":"docs/TESTS.md","heading":"Known Gaps","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#known-gaps","text":"| Gap | Evidence |\n| --- | --- |\n| No check is required to merge | `main` has no branch protection; its ruleset only blocks deletion and force-push. A red `lint-and-tests` run does not stop a merge. |\n| The packaged macOS app is only tested after merge | The Linux desktop smoke test runs on PRs (`desktop` in `lint-and-tests.yml`), but `build-app.yml` — which builds the macOS app and runs the sparse-PATH test — triggers only on tags, a daily cron and manual dispatch. Dispatch it before tagging when a change touches the desktop app or packaging. |\n| macOS WKWebView gets no functional desktop suite | `tests/tauri/` is Linux-only (`tests/tauri/helpers/xvfb.js`, `tests/tauri/helpers/tauri-driver.js`); macOS only gets `tests/smoke/smoke-test.js`, post-merge. |\n| Output flood tests are light | The heaviest is `tests/5-stress-stability.test.js` Scenario 17: `yes \\| head -500` and `seq 1 2000`. No sustained, multi-megabyte or backpressure test exists, and the latency gate measures no output throughput. |\n| Uncovered crates and scripts | `packages/tmuxy-demo` has no tests and `deploy-demo.yml` deploys without testing. |\n| `deltaProtocol.ts` is largely untested in substance | A one-off Stryker run scored it at 24.96% (285 surviving mutants, 175 lines with no coverage) despite unit and property tests existing for it; most survivors were the conditional-spread guards the property tests exercise without asserting on. |\n| Clippy is not run over test code | CI runs `cargo clippy` without `--tests`, so lints in `#[cfg(test)]` modules and `tests/` go unchecked — `packages/tmuxy-core/tests/control_mode_push_api.rs` is missing the `allow` attribute every sibling has and would fail today. |"},{"doc":"docs/TESTS.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TESTS.md#related","text":"- [ARCHITECTURE.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md) — the components each test tier covers\n- [PERFORMANCE.md](https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md) — the benchmark and probe harnesses that share this infrastructure\n- [COPY-MODE.md](https://github.com/flplima/tmuxy/blob/main/docs/COPY-MODE.md) — how to drive copy mode from a test through real user input\n- [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md) — the control-mode constraints test sessions must respect\n- [INVARIANTS.md](https://github.com/flplima/tmuxy/blob/main/docs/INVARIANTS.md) — the cross-layer promises and which test enforces each; the `—` cells are where a new property test belongs"},{"doc":"docs/TMUX-CONFIG.md","heading":"tmux Server, Configuration and Key Tables","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#tmux-server-configuration-and-key-tables","text":"The dedicated socket, the config chain, session groups and client-side key tables. Part of [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md)."},{"doc":"docs/TMUX-CONFIG.md","heading":"Dedicated Server Socket","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#dedicated-server-socket","text":"Tmuxy never talks to the user's default tmux server. Every component targets a **dedicated socket**, resolved the same way everywhere:\n\n| Priority | Source | Used by |\n| --- | --- | --- |\n| 1 | `TMUX_SOCKET` env var | all components — see the per-environment table below |\n| 2 | Socket derived from `$TMUX` | shell scripts running inside a pane (`packages/tmuxy-cli/bin/tmuxy-cli`, `packages/tmuxy-cli/bin/tmuxy/_lib`) — so they always target the server hosting them |\n| 3 | `tmuxy` (the default) | everything else — in practice a released build |\n\nOne socket per environment, so a dev server, a test run and an installed build\nnever collide:\n\n| Environment | Socket | Set by |\n| --- | --- | --- |\n| Release bundle | `tmuxy` | nothing — the `DEFAULT_TMUX_SOCKET` fallback in `tmuxy-core/src/session.rs` |\n| Development | `tmuxy-dev` | `bin/dev-server`, which exports it so the server it spawns agrees |\n| E2E tests | `tmuxy-test` | `tests/jest.setup.js` via `tests/helpers/tmux-socket.js` |\n\nThe development socket is identical across bare metal, the local devcontainer\nand Codespaces: all three reach it through `bin/dev-server`, so none of them hardcodes\na socket of its own.\n\nA `TMUX_SOCKET` value containing a slash is treated as a **full socket path** (`tmux -S <path>`); any other value is a **socket name** in tmux's default socket directory (`tmux -L <name>`). The Rust side resolves via `tmux_socket()` / `tmux_socket_args()` in `tmuxy-core/src/session.rs` and always passes the socket flag explicitly, which also overrides an inherited `$TMUX` — the server behaves identically whether launched from a terminal, a tmux pane, or Finder. Every bundled shell script sources the same resolution from `packages/tmuxy-cli/bin/tmuxy/_lib` (or defines it inline in `packages/tmuxy-cli/bin/tmuxy-cli`); none may call bare `tmux`."},{"doc":"docs/TMUX-CONFIG.md","heading":"Live socket switch (desktop app)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#live-socket-switch-desktop-app","text":"The **web server** binds its socket once at process launch (from the env), so it cannot change sockets without a restart. The **desktop (Tauri) app** can switch servers live: `tmuxy connect <socket> [session]` publishes the request as two tmux global env vars (`TMUXY_CONNECT_TO`, `TMUXY_CONNECT_SESSION`) on the current server; a watcher task in the app (`packages/tmuxy-tauri-app/src/monitor.rs`, `poll_connect_requests`) reads them, sets `TMUX_SOCKET`/`TMUXY_SESSION` in-process, and drives the monitor loop to reconnect (a graceful `MonitorCommand::Shutdown` interrupts the live connection). Because every tmux call — the control-mode connection and the one-off executor commands alike — resolves its socket from the env, updating those two vars retargets the whole app. Adopting an existing server applies tmuxy's config and window-type tagging to it, so this is an explicit opt-in, distinct from the isolated default."},{"doc":"docs/TMUX-CONFIG.md","heading":"Remote servers over SSH (desktop app)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#remote-servers-over-ssh-desktop-app","text":"A \"server\" in the desktop app is a tmux server tmuxy drives — the local machine or a **remote host reached over SSH**. Saved servers live in `~/.config/tmuxy/servers.json` (`tmuxy-core/src/servers.rs`); the sidebar footer's **server picker** lists them, and `tmuxy session connect` (`packages/tmuxy-cli/bin/tmuxy/session-connect`, which the switcher's \"Connect over SSH…\" opens in a float) adds new ones. Attaching to a server sets `TMUX_SOCKET` and, for a remote, `TMUXY_SSH` (an ssh argv tail like `-p 2222 user@host`); the Tauri `connect_server` command routes both through the same `request_reconnect` path as a local socket switch.\n\n`TMUXY_SSH` is resolved centrally by `ssh_target()` / `tmux_argv(pty)` in `session.rs`: when set, every tmux invocation is wrapped as `ssh [-tt] <tail> tmux -L <socket> …` (the `-tt` pty flag is used for the `-CC` control-mode connection, omitted for one-off reads so captured output stays clean; the remote binary is bare `tmux`, resolved by the remote login shell). The local `-f <config>` flag is skipped over SSH — that path is local-only. This means the whole app (control mode + executor reads) drives the remote tmux server transparently."},{"doc":"docs/TMUX-CONFIG.md","heading":"The sidebar tree, and the sessions poll behind it","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#the-sidebar-tree-and-the-sessions-poll-behind-it","text":"The live state the app holds is single-session (the attached session's windows/panes), and the sidebar's **tabs→panes tree** draws exactly that: this session's tabs and their panes, from live state, never the socket's other sessions. Rows for another session had no live state behind them — no content, no geometry, nothing the tree's own keys could act on — and \"which session am I on\" is asked and answered in the session switcher (`components/SessionMenu.tsx`), which is where the socket's other sessions are listed.\n\nA poll (`serversActor`, `packages/tmuxy-ui/src/machines/actors/serversActor.ts`) shells `list-windows -a` / `list-panes -a` through `run_tmux_command` every ~4s while the tree is open. It feeds the switcher's session list and the tree's git context (below) — the pane cwds live state does not carry. It runs on both the web and desktop builds and is gated on the adapter's `enumeratesSessions` capability, so it stays inert on the single-session in-browser sandboxes (demo, v86). Switching session is `SWITCH_SESSION`; on web `switchSession` reconnects the SSE stream to the chosen session. The **server picker** (saved-server list via `list_servers`) remains desktop-only.\n\nA pane row is **two lines**: `%id process` with the state indicator on the first, the pane's title on the second (`components/paneTabDisplay.ts`, `paneRowLines`). They answer different questions and truncate at different rates — a process name is short and stable, while a title is a path or a command with its arguments and is the one thing in the tree that routinely outgrows a 30-column column. Sharing one line, the title pushed the state indicator around and was the half that got cut. A pane whose title adds nothing (an unset one, or one that only repeats the process) draws a single line, so a column of plain shells stays compact.\n\nThe same poll gives the tree its **git context** (read for the attached session alone, the one the tree draws). Each pane row carries its `#{pane_current_path}`, and every ~15s (and on each forced refresh) the client asks the host for the worktrees those directories sit in — the `list_git_worktrees` command, served by `tmuxy-core/src/worktrees.rs` on both the web server and the desktop app. The host reads the pane paths from tmux itself (`list-panes -a -F '#{pane_current_path}'`) rather than from the request, so a web client can never point git at a directory of its choosing; discovery is read-only (`rev-parse --git-common-dir`, `worktree list --porcelain`), scrubs an inherited `GIT_DIR`/`GIT_WORK_TREE`, skips a repository git cannot read instead of failing the whole listing, falls back to newline porcelain on a git older than 2.36, and returns nothing for an SSH-backed server (its pane paths are remote). The result is published as `repositories`; `tmuxy-ui/src/components/gitContext.ts` matches each cwd to the longest worktree root (component-aware, so `/repo-copy` never matches `/repo`) and the tree shows the branch — or a detached checkout's short head — as a badge on the pane row, and on a tab row when every pane in the tab shares the worktree."},{"doc":"docs/TMUX-CONFIG.md","heading":"Session groups: two GUI windows over one session","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#session-groups-two-gui-windows-over-one-session","text":"tmux's _current window_ belongs to a session, not to a client, so two clients on one session always\nlook at the same tab. The desktop app's second OS window therefore gets its own session in a session\n**group**: `tmux -CC new-session -A -s <base>~<index> -t <base>`. Group members share every window\nand pane — the same windows, linked, not copies — while each keeps its own current window, which is\nwhat lets one OS window sit on one tab and another on a different one. `-A` makes the call idempotent,\nso re-opening a window reattaches the session it had rather than failing.\n\nKilling a group member is safe: its windows stay alive in the remaining members, so closing the second\nGUI window (which kills `<base>~<index>`) takes nothing with it. A window close that the app does not\nsee — a crash, a `kill -9` — leaves the grouped session behind; the next window to take that index\nreattaches it.\n\nThe keys stay distinct from the tab keys: `ctrl+1`…`ctrl+9` select a **tab**, while `cmd+1`…`cmd+9`\n(`ctrl+shift+1`…`9` off macOS) select a **GUI window**, and `cmd+n` / `ctrl+shift+n` opens one. See\n[DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md) for the per-window monitor and the event addressing that goes with it."},{"doc":"docs/TMUX-CONFIG.md","heading":"tmux Configuration","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#tmux-configuration","text":"No manual `~/.tmux.conf` changes are required — tmuxy enforces the options it needs automatically, and by default does not read `~/.tmux.conf` at all (see [the config chain](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#the-config-chain-and-where-a-users-own-tmuxconf-fits)). On every session connect, the monitor's initial sync (`sync_initial_state` in `tmuxy-core/src/control_mode/monitor.rs`) sets `window-size manual` and `aggressive-resize off` (so multi-client viewport sizing stays under tmuxy's control), plus `allow-passthrough on`, `mouse on`, `focus-events on`, pane-border options, and title options. Settings are applied per-session rather than globally, to avoid a tmux 3.5a crash triggered by global settings under control mode.\n\nOSC 8 hyperlinks are parsed by tmuxy's own control-mode parser (`tmuxy-core/src/control_mode/osc.rs`), so no `terminal-features` setting is required either."},{"doc":"docs/TMUX-CONFIG.md","heading":"The config chain, and where a user's own `~/.tmux.conf` fits","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#the-config-chain-and-where-a-users-own-tmuxconf-fits","text":"tmux starts with `-f tmuxy.conf`, so a user's `~/.tmux.conf` is NOT read by default. That is deliberate — it is also the single loudest first-hour complaint from a tmux user, because their prefix, their status line and their plugins all vanish on tmuxy's socket with nothing saying why.\n\nThe chain, in order:\n\n| # | File | Owner | Refreshed |\n| --- | --- | --- | --- |\n| 1 | `tmuxy.conf` | user | written once, never again |\n| 2 | ↳ `tmuxy.defaults.conf` | app | every launch |\n| 3 | &nbsp;&nbsp;↳ `tmuxy.essentials.conf` | app | every launch |\n| 4 | &nbsp;&nbsp;↳ _(tmuxy's defaults: prefix, status line, bindings)_ | app | every launch |\n| 5 | &nbsp;&nbsp;↳ `tmuxy.user.conf` | app, generated | every launch |\n| 6 | &nbsp;&nbsp;&nbsp;&nbsp;↳ `~/.tmux.conf`, then `tmuxy.essentials.conf` again | user | opt-in only |\n| 7 | ↳ _(the user's own edits in `tmuxy.conf`)_ | user | — |\n\n`tmuxy config use-tmux-conf on` fills in step 6. The order is the whole design: the user's config comes after tmuxy's taste, so **their** prefix, status line and plugins win; `tmuxy.essentials.conf` is then sourced **again** on top, so the settings tmuxy cannot work without are restored whatever their config did to them.\n\nThe generated bridge is a separate file, sourced from the end of `tmuxy.defaults.conf`, because `tmuxy.conf` is written once and never again — anything that has to reach people who already installed tmuxy must live in a file the app refreshes."},{"doc":"docs/TMUX-CONFIG.md","heading":"What a user's config can break, and what protects it","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#what-a-users-config-can-break-and-what-protects-it","text":"Three layers protect the app, and they cover different things:\n\n| Layer | Where | Covers |\n| --- | --- | --- |\n| `enforce_settings()` | `control_mode/monitor.rs`, every connect, per-session | `pane-border-status`/`-format`, `mouse`, `focus-events`, `allow-passthrough`, `allow-rename`, `set-titles`, `window-size manual`, `aggressive-resize off` |\n| `tmuxy.essentials.conf` | sourced after the user's config | the `tmuxy-*` command-aliases, `destroy-unattached off`, `exit-empty off`, `default-terminal`, `terminal-overrides`, `escape-time 0`, `history-limit` |\n| nothing | — | everything else, which is taste |\n\nThe ones that actually bite, and why each is in the essentials file rather than left to chance:\n\n- **`destroy-unattached on`** — a reasonable thing to have in a personal config, and fatal here. tmuxy's client is a control-mode connection that comes and goes with every page reload, so the session would be destroyed the moment a browser tab closed.\n- **`default-terminal screen-256color`** — the renderer decodes 24-bit colour from the control-mode stream; a 256-colour TERM makes every truecolour application render in the wrong palette.\n- **`history-limit 500`** — scrollback is a client-side reimplementation over tmux's history. A small limit reads to the user as \"tmuxy lost my scrollback\".\n- **`command-alias[100…122]`** — every tmuxy binding and the whole CLI resolve through these. A config that sets the same indices silently replaces them, and nav, pane groups and stacks stop working with no error anywhere.\n- **`escape-time`** — a non-zero value makes Escape feel broken in vim.\n\nWhat is deliberately NOT protected, because protecting it would defeat the point: the prefix, the status line, `mode-keys`, `base-index`, key bindings, colours, and anything a plugin does. A user who opts in gets their own.\n\nTwo hazards that remain the user's to judge, and are called out in `tmuxy config use-tmux-conf --help` rather than silently handled:\n\n- **tpm and plugins** load on tmuxy's socket too. `tmux-continuum` in particular will happily auto-save and auto-restore sessions there.\n- **A status line that draws itself** may fight tmuxy's own chrome, which is cosmetic but ugly."},{"doc":"docs/TMUX-CONFIG.md","heading":"Key Tables Are the Client's","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md#key-tables-are-the-clients","text":"tmuxy answers keys itself — the browser or webview holds the keyboard, and\ncontrol mode has no key handling to give it — so the prefix and every table\nbeyond it run in the client's keyboard actor\n(`tmuxy-ui/src/machines/actors/keyboardActor.ts`), from the bindings the server\nreads with one `list-keys` and sends whole (`KeyBindings` in\n`tmuxy-core/src/transport.rs`: prefix, root, and every other table by name;\ncopy mode's tables stay out, copy mode being the client's too).\n\nA binding enters a table the tmux way, with `switch-client -T <table>`\n(`bind p switch-client -T pane` in `tmuxy.defaults.conf`). The client reads the\nswitch out of the binding and keeps the table as its own state; only the rest\nof the binding goes to tmux (`tmuxy-ui/src/domain/keyTables.ts`). A table is\n**sticky** for as long as each key's binding enters it again\n(`bind -T pane x kill-pane \\; switch-client -T pane`) — again exactly how tmux\ndoes it — and `Esc`, a key the table does not bind, or a binding that names no\ntable leaves it. So the same config drives a raw `tmux attach` the same way.\n\nThe defaults:\n\n```\n  prefix (ctrl+a)          the short menu\n    -  |   split           c   new tab        s / S   the left / right sidebar\n    p      PANE mode       t   TAB mode       ?       every key, table by table\n\n  PANE (sticky)                             TAB (sticky)\n    h j k l   focus          H J K L  resize    h l   prev / next     c   new\n    - |       split          x        close     ,     rename          x   close\n    z         zoom           s / S    stack     1-9   go              < > move (client)\n    = group   < > swap       f float  b break   o     overview (client)\n    m mark    , rename       g join\n```\n\nA few keys have no tmux command behind them and are answered by the client in\nwhichever table they belong to: `prefix s` / `S` (open a sidebar over the\npanes; the same key again while it is open docks it, and again frees it — the\nprefix stays armed for that one key, tmux's repeat, and any other key goes on\nas if no prefix came first), `prefix w` and TAB's `o` (the Tab Overview),\n`prefix ?` (the keys view), and TAB's `<` / `>` (move the tab along the strip,\nwhich skips the windows floats and sidebars occupy where a relative\n`swap-window` would not). The status line names the mode in force and shows a\nhandful of its keys, each line fitting 80 columns\n(`tmuxy-ui/src/components/statusLineHints.ts`)."},{"doc":"docs/TMUX-SIDEBARS.md","heading":"Sidebars and Cross-Pane Sends","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-SIDEBARS.md#sidebars-and-cross-pane-sends","text":"The two sidebar columns and `tmuxy pane send -t`, both built on the tags in [TMUX-TAGS.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-TAGS.md)."},{"doc":"docs/TMUX-SIDEBARS.md","heading":"The two sidebars","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-SIDEBARS.md#the-two-sidebars","text":"Both sidebars are **chrome windows of exactly the same shape as a float**: a single-pane window, broken out and tagged in one atomic command list, excluded from the tab strip, sized to its own column instead of the viewport. They differ only in the tag, the width, and what the pane runs.\n\n|  | left (`sidebar-left`) | right (`sidebar-right`) |\n| --- | --- | --- |\n| Pane runs | `tmuxy widget tree` | the default shell |\n| Title click | opens the session switcher — a dropdown (`SessionMenu.tsx`) listing the socket's sessions under a filter-as-you-type box, with the connect flow (`tmuxy session connect`, in a float) behind one item on desktop | — |\n| Started in | — | the current pane's directory (a bare `split-window`, like any fresh pane) |\n| Width | `sidebar_dock::LEFT_COLS` | `sidebar_dock::RIGHT_COLS` |\n| Dragged width | `@tmuxy-sidebar-cols` on its window | same |\n| Closing the column | hides it (`@tmuxy-sidebar-hidden`); the widget pane survives | hides it (`@tmuxy-sidebar-hidden`); the shell survives |\n| Window name | `__sidebar-left` | `__sidebar-right` |\n\nThe left column's pane carries **no content**: `packages/tmuxy-cli/bin/tmuxy/tmuxy-widget-tree` prints the widget marker and blocks, and the UI renders the registered `tree` widget in place of the pane's terminal, deriving the tree from state it already holds. The pane exists so the column has a real pane identity — something `ctrl+hjkl` and `tmuxy nav` can move into, and the backend can size — rather than being invisible to tmux entirely.\n\nBoth columns are always real flex siblings of the pane area, whatever the window's size, so an open sidebar shrinks the pane container and tmux re-tiles the panes into what is left (`selectSidebarLayout`). Under a fixed `@tmuxy-size` the grid keeps its cols/rows instead: the desktop window widens by the column, and whatever room that leaves the grid is drawn in a cell fitted to (see Multi-Client Viewport Sizing in [ARCHITECTURE.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md)). The columns are always laid out in the measured cell, never the fitted one. A column opens and shuts at once, with no slide; `prefix s` / `prefix S` toggle them.\n\nNeither column draws a header — its title lives in the app header, in a cluster sized to exactly that column's width so the two dividers line up.\n\nThree consequences for the backend:\n\n- **Sizing.** Every other window is resized to the client viewport (`window-size manual` means tmux sizes nothing on its own). A sidebar window instead gets `sidebar_dock::size(window_type, user_cols, rows)` from `tmuxy-core/src/constants.rs` — its own column width, and the viewport's rows unchanged (a headerless column runs the full height of the app body). Both `control_mode::monitor::apply_client_size` and `executor::resize_window` implement this split, and the widths are mirrored in `tmuxy-ui/src/machines/constants.ts` (`LEFT_SIDEBAR_COLS` / `RIGHT_SIDEBAR_COLS`). If the two sides disagree, the pane wraps at a width the UI does not draw.\n- **Resizing.** Dragging a column's inner edge writes `@tmuxy-sidebar-cols` on its window, and the sizing pass above reads it back — so a drag moves the drawn column and the tmux pane together, in whole terminal columns, clamped to `sidebar_dock::MIN_COLS`/`MAX_COLS` (mirrored as `SIDEBAR_MIN_COLS`/`MAX_COLS` in the frontend). Because the width lives on the window rather than in a client, it survives a reload and every client attached to the session draws the column the same. The value reaches the client through the control-mode aggregator, which also answers `get_initial_state` (`MonitorCommand::GetState`), so a client's baseline and its deltas come from the same picture.\n- **Keyboard.** A column is focused without `select-pane` — that would switch the active window and blank the tab behind it. Keys reach the right column through the same overlay target a focused float uses (`overlayPaneId` in `machines/actors/keyboardActor.ts`); the left column's tree handles its own keys while focused (j/k/Enter, `l` to hand the keyboard back, `q` to close the column). Neither column claims Escape: a program pinned in a sidebar may need it, so Escape is forwarded like any other key, and leaving a column is Ctrl+h / Ctrl+l, a click on a pane, or the tree's `q`.\n- **Focus requests.** Because focusing a column is client-side, a shell helper has no tmux command for it. `packages/tmuxy-cli/bin/tmuxy/nav` instead sets the session-scoped `@tmuxy-focus-request` when the calling pane is at the grid's edge (`#{pane_at_left}` / `#{pane_at_right}` — a `select-pane -L/-R` at the edge _wraps_ on current tmux, so \"was it a no-op\" cannot tell) and that edge has a column, or when the caller is itself inside a column (`$TMUX_PANE`, passed by `tmuxy nav`; run-shell's own `#{pane_id}` is the tab's active pane). It rides the `list-windows` poll (read as a per-window column, set at session scope so every row carries it) and the client that acts on it unsets it. This is what makes `tmuxy nav left` from a shell behave like `Ctrl+h`, which the frontend intercepts before it ever reaches tmux.\n\nCreating a chrome window is one atomic tmux command list: `split-window ; break-pane -d -n <name> [-t :<index>] ; set-option -w -t <target> @tmuxy-window-type <type>`. `break-pane -d` deliberately leaves the session's _current_ window alone, so an untargeted `set-option -w` would tag the window the user is looking at instead of the new one — the list has to name the new window. A **float** names a free index up front. A **sidebar** is targeted by its fixed window name (`:__sidebar-left` / `:__sidebar-right`) instead: the client's copy of the window indices goes stale for a beat after any window closes (`renumber-windows on` shifts the rest and `%window-close` carries no indices), and a guessed index tmux already used made `break-pane` fail after `split-window` had run, leaving a raw widget pane in the tab. Tagging inside the same list also means the marker exists before the monitor reacts to `%window-add`, so a chrome window is never briefly rendered as a tab. One list is also one command queue: the split and the break reach every attached client in the same batch of notifications, which is what keeps the pane from being drawn in the tab it was split from. `packages/tmuxy-cli/bin/tmuxy/float-create` builds the same list for `tmuxy pane float` (it used to split, read the new pane's id back through a temp file, and break in a second call — long enough that the float visibly appeared as a split first). Nothing in the list can name the new pane, since its id does not exist until `split-window` has run: an untargeted `break-pane` moves it because `split-window` without `-d` makes it current for the commands that follow, and the CLI's list ends with a `select-pane` back to the caller's pane, which `break-pane` took away. If the pane never appears (or exits at once — a missing `tmuxy` CLI on the server), the client shows a failure state in the column after a short timeout instead of \"starting…\".\n\n**Open state.** Because closing a column keeps its pane, the window's existence no longer means \"shown\": a client derives open = window exists ∧ not `@tmuxy-sidebar-hidden`. Closing sets the option, reopening unsets it, so the choice survives a reload and is the same in every client.\n\n**Keyboard contract while a column has the keyboard.** Plain keys and non-binding chords stay in the column (the tree swallows them; the dock's pane receives them, Escape included). Prefix bindings and root bindings still run, and they act on the _tab grid_ — tmux cannot make a chrome window current without blanking the tab, so `prefix %` with the dock focused splits the tab's active pane, not the dock. Leaving a column is Ctrl+h / Ctrl+l (or `l`/`q` in the tree), a click on a pane, or `tmuxy nav`."},{"doc":"docs/TMUX-SIDEBARS.md","heading":"Sends into another pane (`tmuxy pane send -t`)","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-SIDEBARS.md#sends-into-another-pane-tmuxy-pane-send--t","text":"`tmuxy pane send -t <pane> <keys...>` into a pane the caller does not own (any pane but its own `$TMUX_PANE`) does not send the keys. It writes the question — who is asking (the program in front in the caller's pane) and the `send-keys` arguments — to the TARGET pane's `@tmuxy-ask`, marks its own pane `needs-input`, and blocks; the client blurs the target's content and asks \"`claude` wants to send `npm test`. Allow?\", and what the user answers is written back to `@tmuxy-ask-answer`, which the waiting CLI reads before it sends the keys (yes) or exits 1 (no). The round trip is the point: the sender — usually an agent in another pane — learns exactly when the command started, so it knows when the output is worth capturing. The script is `packages/tmuxy-cli/bin/tmuxy/send`.\n\n`set -g @tmuxy-send-ask off` (View ▸ Ask Before Sending Keys, remembered in `tmuxy.state.json`) sends without asking, and `@tmuxy-send-allow` is a comma list of caller names and target panes that skip the question while it is on. A send that skips it still sets the target's `@tmuxy-pane-state` to `unread` (unless the pane declared something else), and the client takes that back once the user brings the pane in front, so nothing is typed into a pane unseen.\n\nThe payload is base64 rather than the text itself because `list-panes` rows are comma-separated and the keys are free text; base64 cannot contain a comma, so the value rides the format whatever the keys say. The `token` inside it pins an answer to the question that was on screen: a question withdrawn and replaced while the user was reading gets a new token, and the stale answer is ignored rather than acted on.\n\nThe question lives on the pane, not in a client, so every client attached to the session shows it and answering in one answers it in all. Answering is a pane-scoped option write, which is why the monitor re-lists PANES behind such a write (see below) — otherwise the question would stay on screen until the next event moved it. A pane with a pending question also reads as `needs-input` in the sidebar tree whatever it last declared about itself, so the tab holding it is the one the tree points at.\n\nAnswering from the keyboard is handled in `machines/actors/keyboardActor.ts` before any key is forwarded: while a pane holds a question it is not a terminal to type into, ↑/↓ or 1/2 move between the answers, Enter takes one, Escape declines, and Cmd+Enter (Ctrl+Enter off macOS) allows every send pending in the ACTIVE tab — deliberately not the others, since agreeing to something you cannot see is what the confirmation exists to prevent.\n\n**Pane titles.** A title change has no control-mode notification either, whether it came from a program's OSC 0/2, a script's `select-pane -T`, or `tmuxy run`. The monitor therefore subscribes to `#{pane_title}` on every pane at attach (`refresh-client -B`, tmux 3.2+; see `SUBSCRIBE_PANE_TITLES_CMD` in `tmuxy-core/src/constants.rs`): tmux checks the subscription about once a second and answers with `%subscription-changed`, which re-lists that pane. A title set from outside reaches the client in about half a second instead of waiting for the 15 s idle heartbeat.\n\n**Option writes and latency.** tmux emits no control-mode notification when a user option changes. The monitor therefore re-lists windows right after forwarding any client command that writes a `@tmuxy-*` option — and re-lists PANES when the write was pane-scoped (`set-option -p`), which is what takes an answered question off the screen at once. A shell helper that writes one (`request_focus` in `packages/tmuxy-cli/bin/tmuxy/_lib`) follows it with a harmless `%window-renamed` (renaming a sidebar's chrome window to the name it already has), which the monitor's deferred metadata sync turns into a `list-windows`. `packages/tmuxy-cli/bin/tmuxy/send` instead prints its waiting notice to stderr: that is real pane output, and pane output is what schedules the deferred metadata sync, so a question asked from a silent pane still appears within a round trip rather than waiting for the idle heartbeat."},{"doc":"docs/TMUX-TAGS.md","heading":"Window and Pane Tags (`@tmuxy-*`)","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-TAGS.md#window-and-pane-tags-tmuxy-","text":"The `@tmuxy-*` tag schema and the pane-group model built on it. Part of [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md)."},{"doc":"docs/TMUX-TAGS.md","heading":"Window Tags (`@tmuxy-*`)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-TAGS.md#window-tags-tmuxy-","text":"tmuxy marks the windows and panes it manages with tmux user-options under the `@tmuxy-*` namespace. This is the canonical reference for that schema and how the backend, frontend, and shell scripts consume it."},{"doc":"docs/TMUX-TAGS.md","heading":"Filtering rule","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-TAGS.md#filtering-rule","text":"**Tabs carry no marker.** Any window in the attached session that is _not_ tagged `float`, `sidebar-left` or `sidebar-right` is a tab — including foreign windows a user creates with a raw `tmux neww`, which simply appear as tabs. Only the non-tab chrome windows are tagged, and only those are filtered out of the tab strip. Window names are purely cosmetic and never used to infer type.\n\nSo the attached session's window list maps 1:1 to the tab strip (minus any open floats), and a native `tmux attach` and tmuxy agree on what the tabs are. Hidden pane-group members live in a separate stash session, so they are not windows in the attached session at all."},{"doc":"docs/TMUX-TAGS.md","heading":"Schema","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-TAGS.md#schema","text":"Window options are scoped per window (`set-option -w -t <window-id>`). The pane options (`@tmuxy-group-id`, `@tmuxy-group-pos`, `@tmuxy-pane-state`, `@tmuxy-ask`, `@tmuxy-ask-answer`, `@tmuxy-pane-widget`) are scoped per pane (`set-option -p -t <pane-id>`).\n\n| Option | Scope | Values | Set on |\n| --- | --- | --- | --- |\n| `@tmuxy-window-type` | window | `float` \\| `sidebar-left` \\| `sidebar-right` | non-tab chrome windows only (tabs are untagged) |\n| `@tmuxy-float-parent` | window | `@<window-id>` | floats |\n| `@tmuxy-float-width` | window | integer (columns) | floats |\n| `@tmuxy-float-height` | window | integer (rows) | floats |\n| `@tmuxy-float-drawer` | window | `top` \\| `bottom` \\| `left` \\| `right` \\| unset | drawer-style floats |\n| `@tmuxy-float-bg` | window | `blur` \\| `dim` \\| unset | floats with a backdrop |\n| `@tmuxy-float-noheader` | window | `1` \\| unset | floats that hide the header chrome |\n| `@tmuxy-group-id` | pane | `g<n>`, e.g. `g5` | every member of a pane group |\n| `@tmuxy-group-pos` | pane | integer (0 is first) \\| unset | a member's place in its group, once the group was reordered or joined; members without one follow, by pane id |\n| `@tmuxy-group-rev` | global | opaque \\| unset | bumped by the group scripts after a change tmux does not announce, so the pane-metadata subscription fires (every pane inherits it) |\n| `@tmuxy-pane-state` | pane | `idle` \\| `working` \\| `needs-input` \\| `error` \\| `unread` \\| unset | what the pane says it is doing; set by whatever runs in it |\n| `@tmuxy-ask` | pane | base64 of `{token, caller, keys}` or `{token, kind: \"offer\", command}` \\| unset | a send into this pane waiting on the user (`tmuxy pane send -t`), or a command a session restore offers to run again (the client shows it as a one-line banner and runs or drops it itself) |\n| `@tmuxy-ask-answer` | pane | `<token>:yes` \\| `<token>:no` \\| unset | the answer a client recorded for the pending `@tmuxy-ask` |\n| `@tmuxy-pane-widget` | pane | a widget name, e.g. `tree` \\| unset | which widget this pane may render; written by `tmuxy-widget` and cleared when it exits |\n| `@tmuxy-pane-preview` | pane | an absolute path \\| unset | the one file a `preview` pane may read (`tmuxy open`); written by `tmuxy-widget-preview` and cleared when it exits, honoured only while `@tmuxy-pane-widget` is `preview` (see [A Preview Reads One File](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY-CONTENT.md#a-preview-reads-one-file)) |\n| `@tmuxy-pane-preview-net` | pane | `1` \\| unset | the preview was opened with `--net`: its page may load from the network |\n| `@tmuxy-pane-restore` | pane | a shell line \\| unset | how to bring this pane back after a session restore (`tmuxy pane restore-cmd`); set by the program itself, wins over what `ps` finds. See `tmuxy_core::session_snapshot` |\n| `@tmuxy-focus-request` | session | `left` \\| `right` \\| `panes` \\| unset | a shell helper asking a client to move keyboard focus |\n| `@tmuxy-sidebar-cols` | window | integer (columns) \\| unset | a sidebar column the user has dragged off its default width — for the dock these are its own cells: the right column runs in the sidebar font (80% of the pane font), so its cell width is the pane grid's advance scaled to that size (`selectSidebarCellMetrics`) |\n| `@tmuxy-sidebar-hidden` | window | `1` \\| unset | a sidebar column the user has closed; its pane stays alive, no client draws it |\n| `@tmuxy-collapsible` | window | `1` \\| unset | collapsible panes: only the first-level row holding the active pane is expanded (see below) |\n| `@tmuxy-sidebar-rows` | window | integer (rows) \\| unset | rows the dock's pane is sized to; the client sets it from its own (shorter, sidebar-font) row height, so the column holds more rows than the viewport |\n\n`@tmuxy-float-parent` is the **window id** of the window the float was launched from; focus returns there when the float closes. A float's backdrop is a style of the float (`@tmuxy-float-bg`), not a window of its own.\n\n`@tmuxy-pane-state` is the one tag tmuxy never writes itself: whatever runs in a pane declares its own state through `tmuxy pane state <value>`, which the sidebar tree draws at the row's right edge. An agent sets it from its hooks, a shell from a `precmd`/`preexec` pair, a build script on failure. Nothing infers it — tmux's activity flag means \"bytes arrived\", which a spinner produces continuously, so activity cannot tell working from waiting.\n\n**Coding agents.** `tmuxy agent install [claude|codex|agy]` puts tmuxy into an agent's own lifecycle hooks — Claude Code's `settings.json`, Codex's `hooks.json`, Antigravity's `hooks.json`; the home directory's by default, the project's with `--project` — and installs the tmuxy skill beside them. Every installed hook is the same bare command, `tmuxy agent hook <agent> <event>`, called through `tmuxy` on PATH and guarded so that settings synced to a machine without tmuxy do nothing there: what an event means (`working` on a prompt or a finished tool call, `needs-input` on a permission request, `idle` on stop, `error` on a failed turn, unset when the session ends) is decided by the server binary when the hook fires (`tmuxy-server/src/agents.rs`), so an upgrade changes behaviour without a reinstall and uninstall finds tmuxy's entries by that command alone. The hook script (`packages/tmuxy-cli/bin/tmuxy/agent-hook`) never makes the agent wait on tmux: it records the wanted state and applies the latest one from a background job, and outside a tmuxy pane it does nothing. Known gaps: Antigravity has no hook for a permission prompt, so it never reads `needs-input`; and a Claude Code permission prompt dismissed with Esc fires no hook, so the pane stays `needs-input` until the next prompt.\n\n**Unread is the client's.** An agent only ever says `idle` when it stops. A pane that goes from `working` or `needs-input` to `idle` while another pane is active is drawn `unread` until the user activates it (`nextUnreadPaneIds` in `packages/tmuxy-ui/src/utils/paneState.ts`) — only the client knows which pane is in front of the user. The value rides the ordinary `list-panes` subscription (it is a fixed tail field, like `@tmuxy-group-id`) and is carried verbatim: the client owns the vocabulary and collapses anything it does not recognize to `idle`, so a writer is free to use its own status names without waiting for a release. As with every user option, tmux emits no notification when it changes — the value lands on the next metadata sync, which any pane producing output triggers continuously.\n\n`@tmuxy-pane-widget` is what makes a widget a widget. The `__TMUXY_WIDGET__:<name>` marker a widget prints is ordinary pane **output**, and anything a pane prints carries the same weight — a crafted file passed to `cat`, a commit message in `git log`, an ssh MOTD. So the marker alone renders nothing: the client honours it only when this option, written out of band by `tmuxy-widget` through `run-shell`, names the same widget. It rides the `list-panes` tail like `@tmuxy-pane-state`, and `tmuxy-widget` unsets it on the way out, so a pane that has gone back to being a shell cannot print its way into a widget again.\n\nOn a float the parent is also **which tab the float belongs to**. A float is an overlay over the tab it was opened from, not over the session: the client shows only the floats whose parent is the active window (`selectVisibleFloats`), so one opened on tab 1 is off screen on tab 2 and comes back when that tab does. A float with no parent, or whose parent window has since closed, is shown on every tab — an orphan nothing could bring on screen could not be closed either. Its backdrop is rendered inside the pane container rather than portaled to the document body, so it dims that tab's content and leaves the sidebars, the tab strip and the status line usable.\n\nDrawer direction, backdrop style, and the no-header flag live in their own options rather than being encoded in the window name — float names are user-facing labels (the running command, or a user-set title).\n\nSee [TMUX-SIDEBARS.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-SIDEBARS.md) for these sections: The two sidebars; Sends into another pane (`tmuxy pane send -t`)."},{"doc":"docs/TMUX-TAGS.md","heading":"Collapsible panes","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-TAGS.md#collapsible-panes","text":"A window tagged `@tmuxy-collapsible` keeps only the **first-level** row that holds the active pane expanded; every other first-level row collapses to one line per pane, which the UI draws as just the header. \"First level\" is the root of tmux's layout tree when it is a vertical stack (`[…]` in `#{window_layout}`): a row that is itself a nested split collapses as a unit and keeps its inner proportions, and navigating among panes inside the expanded row changes nothing.\n\nThe backend owns the reshaping (`tmuxy-core/src/layout.rs`, `collapsible_layout_commands` in `control_mode/state.rs`): it parses the layout string, edits the first-level heights, re-serialises it with tmux's checksum and sends `select-layout`. It runs on focus changes, layout changes and option changes, and is idempotent, so the `%layout-change` its own command causes never echoes another. Zoomed windows are left alone.\n\nPANE mode's `s` (`tmuxy-stack`) sets the option and adds a full-width row below (`split-window -v -f`); its `S` (`tmuxy-stack-toggle`) flips it, and turning it off evens the first-level rows back out. The tab's context menu toggles the same option."},{"doc":"docs/TMUX-TAGS.md","heading":"The marked pane","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-TAGS.md#the-marked-pane","text":"tmux's marked pane (`select-pane -m`, cleared with `select-pane -M`; `prefix m` / `prefix M` by default) is carried on the wire as `#{pane_marked}` in the fixed tail of the monitor's `list-panes` format, so a mark set from any client — a plain `tmux` too — shows in every tmuxy client. The UI draws a flag in the pane header and the sidebar tree row and an outline on the pane (`--pane-marked-border`, themed). The pane context menu offers _Mark/Unmark Pane_, _Swap with Marked Pane_ and _Join Marked Pane Here_, which run bare `swap-pane` / `join-pane`: without `-s`, tmux takes the marked pane as the source."},{"doc":"docs/TMUX-TAGS.md","heading":"Pane groups and the stash session","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-TAGS.md#pane-groups-and-the-stash-session","text":"Pane groups are **not** a window type. A group is a set of panes sharing a `@tmuxy-group-id` pane option, minted from the anchor pane id (`%5` → `g5`) and unique for the group's life because tmux never reuses pane ids. The **visible** member is an ordinary pane in the attached session; the **hidden** members are parked one-per-window in a dedicated session, `__tmuxy_stash`, which is never attached and is filtered out of every session enumeration (the sidebar tree, `session switch`, the server picker).\n\nBecause a pane's options travel with it across a **cross-session `swap-pane`**, switching group tabs moves the target member into the visible slot and the previous one into the stash with no membership bookkeeping — verified behavior on tmux 3.7.\n\nThe group rules live once, in `tmuxy_core::groups`: pure functions that take the members read from one `list-panes -a` and return the tmux command list for each operation (add, park, close, switch, next, prev, move, join, leave). That one read is a `list-panes -a -F` whose fields are separated by a **space**, the session name last, because tmux 3.4 prints a control character in a format as `_` — a tab-separated row came back as one field there, and every verb refused with \"no pane\". The `tmuxy-group` crate runs them from inside `run-shell`, the same safe path the session CLI uses — as `tmuxy-server group <verb>`, and as a `tmuxy-group` binary of its own for the v86 guest, which has no server — and the `packages/tmuxy-cli/bin/tmuxy/pane-group <verb>` script is a thin wrapper around it, so its name and arguments (which the UI, the key bindings and snapshot restore call) stay put. It uses `tmuxy-group` when it is on `PATH`, and otherwise finds the server binary through `TMUXY_SERVER_BIN` / `TMUXY_SERVER_SUBCOMMAND`, which every tmuxy process publishes to its tmux server's global environment on attach, then `tmuxy-server` on `PATH`, the repo's `target/`, and the desktop launcher. The stash session is created lazily (`new-session -d -s __tmuxy_stash`, control-mode safe). Closing a member sweeps orphaned stash members, and so does each add, so hidden members orphaned by a wholesale tab-kill are reaped on the next group operation. Even before that sweep they are invisible: the backend prunes any stash member whose group has no visible pane from the emitted state, so an orphan never renders as a phantom tab.\n\nA group's **order** is `@tmuxy-group-pos` on each member, ascending, with members that carry none after them in pane-id order — so a group nobody rearranged reads in creation order, and the backend (`groups::order_key`, also used by snapshots) and the client (`groupMembers` in `tmuxy-ui/src/domain/client.ts`) apply the same rule. `nav` steps between members with `group next|prev --no-wrap` (exit 3 means no member that way, and the step falls through to the neighbouring pane or sidebar). Move, join and leave rewrite every member's place 0..n-1. `join` breaks the pane out of its window into the stash like a new member; `leave` first hands an on-screen member's slot to the next member in order, then moves the pane out as a tab of its own (`move-window` of its stash window) or splits it in beside a target (`join-pane`), and a group left with one member stops being one. None of these produce a notification tmux sends for options, so each ends by bumping the global `@tmuxy-group-rev`, which the monitor's pane-metadata subscription sees within a second. The order is saved with the session snapshot (a visible member's place as a pane option, a parked member's as its `position`) and restored by `pane-group park`."},{"doc":"docs/TMUX-TAGS.md","heading":"How the tags are consumed","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-TAGS.md#how-the-tags-are-consumed","text":"The backend (`tmuxy-core`) reads the options off `list-windows` and emits each window's `windowType` on the wire, defaulting an untagged window to `tab` (see `WindowState::to_tmux_window`). The setup pass (`collect_window_tag_commands` in `control_mode/state.rs`) re-tags a float/sidebar window whose option went missing, takes the `pane-border-status` row back off a window that turns out to be a sidebar, and enforces `pane-border-status top` per tab window (a session-level `set` is not inherited by windows), but it never writes a `tab` marker. Hidden group members are enumerated with a separate `list-panes` against the stash session (rows carry a `stashmember,` sentinel) and emitted as lightweight pane stubs carrying `group_id`.\n\nThe frontend filters on `windowType`:\n\n- `selectVisibleWindows` (`packages/tmuxy-ui/src/machines/selectors/layout.ts`) keeps only `windowType === 'tab'` windows for the tab strip.\n- the sidebar tree (`machines/actors/serversActor.ts`) hides `float` / `sidebar-left` / `sidebar-right` windows and the `__tmuxy_stash` session; everything else (including untagged foreign windows) shows as a tab.\n- floats are rebuilt from `windowType === 'float'` windows and their `@tmuxy-float-*` metadata, and each group from the panes sharing a `group_id` (`machines/app/helpers.ts`).\n\nWindow/pane mutations go through the optimistic pipeline in `packages/tmuxy-ui/src/domain/store/` — each op predicts a local patch, dispatches the tmux command, and reconciles against the next server snapshot (`ops.ts`, `TmuxStore.ts`)."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"tmux Versions, Bugs and Workarounds","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#tmux-versions-bugs-and-workarounds","text":"Version-specific tmux behaviour tmuxy depends on or works around. Part of [TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md)."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"tmux Version","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#tmux-version","text":"Tmuxy targets **tmux 3.7a** (devcontainer, CI, and the in-browser v86 guest all build it from source). Several workarounds below were discovered on 3.3a/3.5a and are kept because they remain safe on 3.7a.\n\nThe minimum is **tmux 3.2**. The desktop app and `tmuxy server` run `tmux -V` at startup and refuse an older or missing tmux with a message saying how to install or update it — a dialog on the desktop, stderr for the server (`tmuxy-core/src/tmux_check.rs`). A build that reports no version number (`tmux master`) is accepted. The Homebrew cask and the `.deb`/`.rpm` packages declare tmux as a dependency.\n\n3.2 is the floor because control-mode flow control (`pause-after`) and format subscriptions (`refresh-client -B`) arrived in it, and because it is what Ubuntu 22.04 LTS ships — the oldest system the AppImage catalog tests on. The nightly matrix runs 3.2a alongside 3.4, 3.5a, 3.7a and 3.8 (what Homebrew ships), the full E2E suite on each, so the floor and the newest release are both versions something exercises. Every push also runs the snapshot and alternate-screen suites on 3.4 — the tmux Ubuntu ships, and the version two product bugs were specific to — as the `tmux-3.4` entry of the E2E matrix (`tests/e2e-suites.json`).\n\nWhat differs between the supported versions, and how tmuxy copes (the commands tmux does not announce, which the monitor re-lists behind, are one table: `tmuxy-core/src/tmux_compat.rs`):\n\n| tmux | Difference | tmuxy |\n| --- | --- | --- |\n| 3.2 | No `allow-passthrough` option (passthrough is always on) | The monitor sets it with `set -q`, which an older tmux skips silently |\n| 3.2 | No `%layout-change` after `swap-pane` | The monitor re-lists the panes behind every swap a client sends |\n| 3.2 | Emoji sequences get different widths: a ZWJ sequence (`👩‍💻`) and an emoji with a skin tone (`👍🏽`) take 4 columns instead of 2, a text symbol with VS16 (`❤️`) takes 1 instead of 2 | **Known limitation.** tmuxy's emulator follows the 3.4+ rules, so on a line holding such a sequence everything after it renders a column or two off tmux's own grid. CJK, single emoji, flags and combining marks agree. See [RICH-RENDERING.md](https://github.com/flplima/tmuxy/blob/main/docs/RICH-RENDERING-CELL-GRID.md#cell-widths-must-agree-with-tmux) |\n| 3.4 | `run-shell` output — and a \"returned 1\" for a command that failed — is shown in `view-mode` on the target pane rather than handed to the calling client; every key typed afterwards goes to the mode, not the shell | The CLI runs every command it sends through `run-shell` with its output and exit status redirected to files, and prints them itself (`_run_shell` in `packages/tmuxy-cli/bin/tmuxy/_lib`), so `run-shell` has nothing to show. A session restore, which reads what its pane-group park step prints, has it write the answer into the `@tmuxy-restore-answer` server option instead (`ask_run_shell` in `tmuxy-core/src/session_snapshot/restore.rs`). A `run-shell` the UI sends without waiting for its output (a binding, a pane-group or navigation script, the command prompt) runs inside a wrapper that keeps the output and exit status in a per-command `@tmuxy-run-<id>` option, which the same command list reads back; the client shows the output as a notice and a failure as an error (`tmuxy-core/src/control_mode/monitor/run_shell.rs`). That relay runs on every version, so later ones show the output too |\n| 3.8 | `#{window_layout}` is JSON (`{\"V\":2,\"L\":{…}}`) rather than `csum,WxH,X,Y[…]`, and no earlier tmux's `select-layout` takes it back; `%layout-change` still carries the classic string | Session snapshots store the classic string: `tmuxy-core/src/layout.rs` (`parse_json`) reads the JSON into the same tree the classic parser builds, and serialises it with tmux's checksum. A snapshot saved on 3.8 restores on any supported version |"},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"`new-window` Crashes Control Mode","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#new-window-crashes-control-mode","text":"**Bug:** Sending `new-window` (or `neww`) through control mode stdin crashes the tmux server in tmux 3.5a. This also happens when `new-window` is called from a `run-shell` command while a control mode client is attached.\n\n**Workaround:** Use `split-window` + `break-pane` as a compound command:\n\n```\nsplitw -t <session> ; breakp\n```\n\nThis creates a new pane in the current window, then immediately breaks it into its own window — replicating `new-window` behavior without the crash.\n\n**Where it's applied:**\n\n- `packages/tmuxy-core/src/command_router.rs` — `route_command` finds a `neww`/`new-window` in any client command (also inside a pinned compound) and swaps it for the rewrite, via `rewrite_new_window_in_compound` and `new_window_rewrite` in `executor.rs`, the single place the `splitw ; breakp` string (with the optional resize) is spelled out. Both transports route every client command through it.\n- `packages/tmuxy-cli/bin/tmuxy/` shell scripts — Use `split-window -dP` + `break-pane -d -s $PANE -n name` when creating windows from `run-shell`.\n- `tests/helpers/TmuxTestSession.js` — Test session creation uses the same workaround."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"Tauri Desktop App: `new-window` Handling","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#tauri-desktop-app-new-window-handling","text":"The Tauri `run_tmux_command` handler (`packages/tmuxy-tauri-app/src/commands.rs`) routes through `command_router::route_command` like the web server, so a `new-window`/`neww` reaches the control-mode connection as the `splitw ; breakp` rewrite.\n\nThe native desktop menus (the macOS menu bar and the native popups) are built by the frontend from its menu spec (`packages/tmuxy-ui/src/domain/menus/`, rendered natively by `infra/menus/nativeMenu.ts`), so every item's callback runs in the page and dispatches through `executeMenuAction` — the same control-mode-safe adapter path as the in-app menu (including the `new-window` rewrite and the `@tmuxy-window-type` tag). The Rust side holds no menu of its own."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"`%unlinked-window-close` Events","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#unlinked-window-close-events","text":"**Behavior:** tmux fires `%unlinked-window-close` (instead of `%window-close`) for windows from **other sessions** sharing the same tmux server. The parser handles both event types (`parser.rs`), but `state.rs` intentionally **ignores** `UnlinkedWindowClose` events to avoid polluting the current session's state with events from other sessions.\n\n`%window-close` handles window removal for the current session. `%unlinked-window-close` is only relevant in multi-session environments and is correctly ignored."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"`pane_in_mode` Is Not \"In Copy Mode\"","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#pane_in_mode-is-not-in-copy-mode","text":"`#{pane_in_mode}` is 1 for **every** mode tmux has for a pane — copy-mode, but also\nview-mode, tree-mode, buffer-mode and client-mode. The client turns that flag into its own\ncopy mode, which renders the scrollback and **hides the live terminal**, so a pane in any\nother mode disappeared behind a scrollback view that nothing would close.\n\nThe pane row therefore asks `#{?#{==:#{pane_mode},copy-mode},1,0}` instead\n(`COPY_MODE_FLAG` in `tmuxy-core/src/constants.rs`). It expands to the same single\nint-like field, so the row parser's positions and its `@window` anchor are unchanged.\n\nThis surfaced on **tmux 3.4**, where a pane landed in `view-mode` as soon as a CLI command\nsent through `run-shell` printed anything or failed (the version table above): the whole\nterminal rendered as a 0×0 box and never recovered. It was not version-specific in\nprinciple — `prefix s` on any version could do the same — and 3.4 is simply where it was\nguaranteed. A lockstep test in `constants.rs`\nkeeps the format from regressing to the broader field."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"Flow Control","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#flow-control","text":"tmux 3.2+ supports `pause-after` flow control. The monitor configures `pause-after=5` (pause if a client falls 5 seconds behind). When a pane is paused, the monitor responds with `refresh-client -A '%pane:continue'` to resume. This prevents unbounded memory growth during heavy output."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"tmux 3.7a Format Expansion (Critical)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#tmux-37a-format-expansion-critical","text":"tmux 3.7a expands format strings (`#{...}`) in **more places** than earlier versions. Two of these bit tmuxy in practice; both will affect any code path that upgrades past 3.6b."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"`run-shell` expands its command string","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#run-shell-expands-its-command-string","text":"`run-shell \"...\"` format-expands the whole string before handing it to the shell. A nested `-F '#{pane_id}'` inside a run-shell'd tmux command is therefore pre-expanded against the **currently active pane** — not the pane the inner command creates. This made `float-create` break the _wrong_ pane into the float window.\n\nRule: inside any `run-shell` string, write `##{...}` — run-shell's expansion halves it to `#{...}` for the inner command. `shquote` in `packages/tmuxy-cli/bin/tmuxy-cli` doubles every `#` it interpolates for this reason. (3.6b behaves the same; the bug had simply never been triggered through this path before.) `float-create` was the path that found the bug and no longer needs the escape: it stopped asking run-shell for the new pane's id (see below)."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"`run-shell` hands its command no pane, and an outside client has none","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#run-shell-hands-its-command-no-pane-and-an-outside-client-has-none","text":"The `tmux` a run-shell'd command runs is a client of its own, and run-shell does not set `TMUX_PANE` for it. A target-less `send-keys` or `break-pane` in there is resolved the way tmux resolves anything for an unattached client with no pane: the session with the latest activity. Usually that is the one the user is in — until `pane group add` parks a member in the hidden `__tmuxy_stash` session, after which the stash is \"latest\" and a `tab create` or `pane send` from a script, a test or an SSH agent lands where no client looks. (Nothing bumps a session's activity back short of a client's key press; `select-window`, `set-option` and the rest leave it alone.)\n\n`packages/tmuxy-cli/bin/tmuxy-cli` therefore resolves the pane it acts on once per invocation when the caller has no `$TMUX_PANE` (a read-only `list-panes -a -f …`, preferring an attached real session, never the stash), exports it, and `run_safe` passes it to the inner client as `TMUX_PANE=%N tmux …` — tmux reads an unattached client's `TMUX_PANE` the way it reads a pane's own. Every `breakp` the CLI issues also names its destination session, `-t '#{session_id}:'`, expanded by run-shell for that pane. Inside a pane none of this changes anything: the caller's pane is the one resolved."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"`send-keys` expands its arguments in the v86 guest — and `##` does NOT protect valid variables","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#send-keys-expands-its-arguments-in-the-v86-guest--and--does-not-protect-valid-variables","text":"Through the v86 guest's console (tmux 3.7a on a serial line), `send-keys -l 'text'` format-expands the literal. Empirically:\n\n| Payload sent | Pane receives |\n| --- | --- |\n| `#{pane_id}` | `%0` (expanded) |\n| `##{pane_id}` | `#%0` (still expanded!) |\n| `#{not_a_var}` | `#{not_a_var}` (unknown names pass through) |\n| `#(date)` | `#(date)` (command formats not run) |\n\nBecause doubling the hash does **not** protect a valid variable, the only reliable transport-level fix is to **split the literal into separate `send-keys -l` chunks at every `#`/`{` boundary** so the two characters never share a format context. The in-browser guest clients do this in `toControlModeCommand` (`tmuxy-ui/src/infra/transport/drivers/guest/GuestTmuxAdapter.ts`).\n\nThe native control-mode path does not need it: a `#{pane_id}` typed at the prompt reaches the shell verbatim on 3.8 and on every tmux the per-commit and nightly suites run, which `tests/1-input-interaction-keys.test.js` (\"typed text reaches the shell verbatim\") checks on each of them. Should a tmux ever expand it there, that test is where it shows."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"Mouse-tracking panes eat pasted SGR sequences — inject with `send-keys -H`","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#mouse-tracking-panes-eat-pasted-sgr-sequences--inject-with-send-keys--h","text":"On 3.7a, when a pane's application has enabled mouse reporting (`?1000h`/`?1006h`, i.e. `mouse_any_flag` is set), an SGR mouse sequence (`ESC [< b;x;y M`) delivered to that pane via `paste-buffer` is **consumed by tmux and never reaches the application** — silently, with no error. The same bytes reach a pane that has NOT enabled mouse tracking. This broke tmuxy's synthetic mouse forwarding (browser click → SGR injection), which previously piped through `load-buffer`/`paste-buffer`.\n\nThe reliable transport is `send-keys -t <pane> -H <hex bytes>`: raw hex key bytes bypass both the paste path and 3.7a's `send-keys -l` format expansion. The frontend builds these in `tmuxy-ui/src/hooks/scrollUtils.ts` (`sgrMouseCommand`).\n\nOne trap: `-H` commands must never be merged by the frontend's send-keys batcher — joining two puts a literal `-H` token mid-keys, tmux rejects it as an unknown key, and the whole combined command fails (a click's press+release land in one batch window, so a plain click would deliver nothing)."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"Control-mode stdin wants bare `;` separators","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#control-mode-stdin-wants-bare--separators","text":"The frontend joins compound commands with a shell-escaped `\\;` (correct for commands that pass through a shell or `run-shell` context). But tmux's control-mode line parser treats `\\;` as a literal argument, silently erroring the whole command — which orphans the frontend's optimistic state (the \"frozen UI after keyboard split\" bug). Raw control-mode transports must rewrite the separator to a bare `;` — never inside a `send-keys -l` literal."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"`list-keys` Reports Aliases Already Expanded","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#list-keys-reports-aliases-already-expanded","text":"A `command-alias` is a config-time convenience, not something the client ever\nsees. `bind -n C-l tmuxy-nav-right` reads back through `list-keys` — the source\nthe frontend builds its binding table from — as the alias's **expansion**:\n`run-shell \"bash $HOME/.config/tmuxy/bin/tmuxy/nav right #{pane_id}\"`.\n\nThis matters because the client classifies each binding's command to decide\nwhether it can predict the result optimistically\n(`tmuxy-ui/src/domain/store/parseCommand.ts`). Anything it fails to recognise\nbecomes a `RawCommand` — correct, but unpredicted, so the user waits out the\nfull round trip instead of seeing the result on the next frame. Matching only\nthe alias spelling is therefore a silent latency bug, not a parse error: the\nkey still works, it is just several hundred milliseconds slower. Any classifier\nthat keys off a bound command must match the **expanded** form (and may accept\nthe alias too, for commands built in-code)."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"Client-Side Placeholder Substitution","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#client-side-placeholder-substitution","text":"Independent of tmux's own expansion, the frontend substitutes `#{pane_id}`, `#{pane_width}`, and `#{pane_height}` in **every outgoing command** with the active pane's values (`appMachine`'s SEND_TMUX_COMMAND handler). This is deliberate — prefix-binding commands are written against these placeholders — but it means text typed into a terminal containing those three exact placeholders is substituted before tmux ever sees it, on every transport (server, Tauri, v86). A paste is exempt: it travels with `#` and `{` as octal escapes (see `pastedText`), so the placeholder never appears in the command."},{"doc":"docs/TMUX-VERSION-BUGS.md","heading":"Bash Variable Conflicts","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md#bash-variable-conflicts","text":"`GROUPS` is a bash built-in variable (array of user group IDs). Never use it as a custom variable name in shell scripts executed via `run-shell` — it silently contains the wrong value. Use `GRP_JSON` or similar instead."},{"doc":"docs/TMUX.md","heading":"tmux Integration","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#tmux-integration","text":"This document covers how tmuxy interacts with tmux: control mode architecture, command routing rules, version-specific bugs, and operational constraints.\n\nSee [TMUX-VERSION-BUGS.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-VERSION-BUGS.md) for these sections: tmux Version; `new-window` Crashes Control Mode; Tauri Desktop App: `new-window` Handling; `%unlinked-window-close` Events; `pane_in_mode` Is Not \"In Copy Mode\"; Flow Control; tmux 3.7a Format Expansion (Critical); `list-keys` Reports Aliases Already Expanded; Client-Side Placeholder Substitution; Bash Variable Conflicts.\n\nSee [TMUX-CONFIG.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-CONFIG.md) for these sections: Dedicated Server Socket; Session groups: two GUI windows over one session; tmux Configuration; Key Tables Are the Client's."},{"doc":"docs/TMUX.md","heading":"Control Mode Architecture","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#control-mode-architecture","text":"Tmuxy communicates with tmux through **control mode** (`tmux -CC`), which provides real-time event notifications and command execution through a single stdin/stdout connection. No polling is required.\n\n```\nCommands:  Frontend → Backend → MonitorCommand → Monitor → stdin → tmux -CC\nEvents:    Frontend ← Backend ← StateEmitter ← Monitor ← stdout ← tmux -CC\n```\n\nThe `TmuxMonitor` (in `tmuxy-core/src/control_mode/monitor.rs`) maintains a persistent `tmux -CC attach-session` subprocess via `ControlModeConnection`. All state-modifying commands flow through the monitor's command channel as `MonitorCommand::RunCommand` messages. The monitor writes commands to control mode stdin, and tmux sends notifications back through stdout (`%output`, `%layout-change`, `%window-add`, etc.)."},{"doc":"docs/TMUX.md","heading":"Why Control Mode Only","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#why-control-mode-only","text":"Running external `tmux` commands (as separate subprocesses) while a control mode client is attached can **crash the tmux server**. Observed in tmux 3.3a and 3.5a. The [tmux Control Mode wiki](https://github.com/tmux/tmux/wiki/Control-Mode) states that commands should be sent through the control mode client.\n\nAll HTTP command handlers in the web server route through `send_via_control_mode()` in `tmuxy-server/src/sse.rs`, which looks up the session's `monitor_command_tx` and sends `MonitorCommand::RunCommand` through the channel."},{"doc":"docs/TMUX.md","heading":"Command Routing Rules","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#command-routing-rules","text":""},{"doc":"docs/TMUX.md","heading":"Commands That MUST Go Through Control Mode","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#commands-that-must-go-through-control-mode","text":"Any command that **modifies** session state when a control mode client is attached:\n\n| Command | Short Form |\n| --- | --- |\n| `split-window` | `splitw` |\n| `new-window` | `neww` (but see crash workaround below) |\n| `select-pane` | `selectp` |\n| `select-window` | `selectw` |\n| `kill-pane` | `killp` |\n| `kill-window` | `killw` |\n| `resize-pane` | `resizep` |\n| `resize-window` | `resizew` (ignored if sent externally) |\n| `swap-pane` | — |\n| `break-pane` | `breakp` |\n| `send-keys` / `send` | (for key input, not SGR mouse sequences) |\n| `copy-mode` | — |\n| `next-window` | `next` |\n| `previous-window` | `prev` |\n| `next-layout` | `nextl` |\n| `run-shell` | — |\n| `set-environment` | — |\n| `rename-window` | — |\n\nUse short command forms when sending through control mode.\n\n**Note:** `new` is short for `new-session`, NOT `new-window`. Use `neww` for creating windows."},{"doc":"docs/TMUX.md","heading":"What still runs outside control mode, and why","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#what-still-runs-outside-control-mode-and-why","text":"Every command a client sends — mutations through `run_tmux_command`, reads through\n`query_tmux` — rides the monitor's control-mode connection. Reads are answered in-band:\nthe monitor brackets the command with marker lines and hands back the `%begin…%end`\nblocks between them (`MonitorCommand::RunCommandWithReply`, `reply_wrapped_lines` in\n`tmuxy-core/src/control_mode/state.rs`). Mutations other than keystrokes are bracketed\ntoo, so an `%error` can be attributed to the command that caused it and reported to the\nuser instead of being lost in the stream. Nothing a client sends reaches a shell, and no\nper-command `tmux` process is spawned on either transport. The policy for what goes\nwhere lives once, in `tmuxy-core/src/command_router.rs`, and both transports call it.\n\nThe subprocess calls that remain are in `tmuxy-core/src/session.rs` and\n`control_mode/connection.rs`, and each is needed before any control-mode client exists\n(`session::tmux_output` also serves the CLI's own verbs, which tmux runs inside\n`run-shell`). The paste-buffer mirror is not among them: a copy-mode yank is read back\nin-band, by the aggregator, on both hosts.\n\n| Command | Location | Justification |\n| --- | --- | --- |\n| `has-session` | `session.rs`, `connection.rs` | Check if session exists **before** connecting control mode |\n| `new-session` | `session.rs` | Create session **before** control mode attaches |\n| `source-file` | `session.rs`, `monitor.rs` | Source config during session creation and initial state sync |\n| `kill-session` | `session.rs` | Destroy session (no control mode attached) |\n| `show-options` | `gui.rs` (desktop setup) | The window's blur/appearance is read before the monitor starts |\n| `load-buffer -` | `packages/tmuxy-cli/bin/tmuxy/send` (`pane paste`, through `send --paste`) | Reads the payload from stdin, which `run-shell` cannot supply. Mutates only the paste buffer, never session/window/pane state, so it does not touch what control mode is tracking. The `paste-buffer` that follows does route through `run-shell`. |\n\nThese are safe because they run **before** control mode connects. Do not add to this\ntable: a read a client needs goes through `query_tmux`, and a read the core needs while\nattached goes through the aggregator's in-band path."},{"doc":"docs/TMUX.md","heading":"Shell Scripts and `run-shell`","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#shell-scripts-and-run-shell","text":"Shell scripts in `packages/tmuxy-cli/bin/tmuxy/` are executed via tmux's `run-shell` command (sent through control mode). Since `run-shell` executes within the tmux server process itself (not as an external subprocess), scripts can safely call tmux CLI commands internally — except `new-window` (see below).\n\nA `run-shell` the CLI sends from a pane carries that pane as its `-t` target (`_run_shell` in `packages/tmuxy-cli/bin/tmuxy/_lib`), and every pane- or window-scoped command the CLI builds names the caller's pane explicitly. Both are needed: the CLI's own `tmux` client is unattached, and for an unattached client tmux resolves a target-less command — and the formats `run-shell` expands, such as `#{pane_id}` — to the session's current window and active pane, not to the pane in `TMUX_PANE`. Without them a `pane split` or `pane group add` typed in a background tab acted on the tab in front."},{"doc":"docs/TMUX.md","heading":"Targeting: Use Stable IDs, Not Indices","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#targeting-use-stable-ids-not-indices","text":"Always target tmux objects by their **stable identifiers**, not by indices:\n\n| Object | Stable ID | Unstable Index | Example |\n| --- | --- | --- | --- |\n| Session | name | — | `-t mysession` |\n| Window | `@N` | `:N` | `-t @3` not `-t :3` |\n| Pane | `%N` | `.N` | `-t %5` not `-t .2` |\n\nWindow indices (`:0`, `:1`, `:3`) can shift when windows are created or destroyed. Pane indices (`.0`, `.1`) are relative to the current window and change when panes are added/removed. Session names, window IDs (`@N`), and pane IDs (`%N`) are assigned by tmux at creation and never change.\n\nThis matters especially in automation and tests where multiple operations happen in sequence — between a query and the next command, indices may have shifted.\n\nThe same rule shapes the tab strip. Chrome windows (floats, sidebars) hold tmux indices the user never sees, so `select-window -t N` from a root binding lands on the wrong tab whenever one sits before it. tmuxy therefore resolves `ctrl+1`…`ctrl+9` on the client, by **position in the strip** (the index-ordered visible tabs), and no longer ships `bind -n C-N` lines in its config. Every window or pane the client then names in a command is an **id** (`@N` / `%N`), never a tmux index: `renumber-windows` shifts indices whenever a window closes and tmux announces none of the shifts, so an index the client holds can be a beat stale (a split once landed in the wrong tab that way). The backend re-lists windows after any window add, close or reorder, but the id rule is what makes a stale list harmless. A command must also name the WINDOW it acts on, not just the pane: `select-pane` on a pane in another window sets that window's active pane and leaves tmux's current window alone, so a `split-window` after it still runs wherever tmux already was. The keyboard actor's pin therefore always leads with `select-window` (taking the window from the pinned pane when the machine has not published an active one yet), and the sidebar and float creators name the pane they split, because nothing in their command list switches windows first. No target is ever added on the way to tmux, on either transport: the pinned list reaches the control-mode connection byte-identical (`command_router::route_command`). A session target would be resolved late, against the session's _current_ window, so a concurrent `select-window` between the pin and the split could move the split to another tab; the untargeted command after a pin, sent as-is, inherits the queue's current target and holds it. `ctrl+0` (and `prefix w`, and the grid button at the right end of the app header) opens the Tab Overview — a client-side grid of every tab where a slot click selects, the trailing \"+\" creates, a slot's ✕ kills, and a drag reorders by sending `move-window -b`/`-a` against the neighbouring tab's `@id` (a tab dragged along the strip itself reorders the same way). Each slot draws its tab's panes with the screen each pane had when the overview opened: a still taken once by the `useTabStill` hook (`tmuxy-ui/src/hooks/useTabStill.ts`), so a tab that keeps printing does not churn its thumbnail. See `tmuxy-ui/src/machines/app/actions/tabOverview.ts` and `tmuxy-ui/src/components/TabOverview.tsx`."},{"doc":"docs/TMUX.md","heading":"Pane Titles","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#pane-titles","text":"A pane header shows what the running application called itself, falling back to its process name. The two sources disagree often enough that the order matters:\n\n| Source | tmux format | Notes |\n| --- | --- | --- |\n| App title | `pane_title` | Set by the application over OSC 0/2 — `nvim README.md`, an ssh host, a Claude Code session summary. |\n| Process name | `pane_current_command` | Only the executable's **file name**. A version-pinned launcher symlink (`~/.local/bin/claude` → `…/versions/2.1.251`) reports the version number, which is useless as a label. |\n\nThe catch is that tmux **seeds every pane's `pane_title` with the host name**, so a non-empty value does not by itself mean an application set one. Rather than shipping the host name to every client to compare there, the pane enumerations ask tmux to do the comparison and return an empty field when the title is still the seed — `tmux_formats::APP_PANE_TITLE` in `tmuxy-core/src/constants.rs`, mirrored by the `list-panes` formats in `packages/tmuxy-cli/bin/tmuxy-cli` and the sessions-tree poll in `tmuxy-ui/src/machines/actors/serversActor.ts`. All four must stay in step; the `constants.rs` tests guard the Rust pair. The sessions poll reads its rows through `query_tmux`, answered in-band on the control-mode connection like every other read.\n\nDownstream, an empty title unambiguously means \"no app title\", so every consumer resolves `title → command → 'shell'` (`getTabText` in `tmuxy-ui/src/components/paneTabDisplay.ts`). One consequence is inherent to the rule: tmux keeps the last title a pane was given, so a title can outlive the program that set it until the shell's own prompt hook replaces it.\n\nTitles change without any control-mode event, so they ride the regular `list-panes` refresh and the pane delta must carry the `title` field for a change to reach a connected client."},{"doc":"docs/TMUX.md","heading":"Pane rows","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#pane-rows","text":"The `list-panes` format the monitor polls with is built from one column table (`PaneColumn` in `tmuxy-core/src/constants/tmux_formats.rs`): each column's name is the index the parser reads its field at, and the command string is assembled from the same list, so the two cannot drift. Every free-text column — the app title, the border title, the process name, the restore line — is comma-escaped by tmux itself (`s/,/%2C/`), so a row always has exactly one field per column and a comma in a title cannot shift the fields after it; the parser drops a row with any other field count rather than guess. Only `list-panes -F` output is read this way: `display-message -p` strips the `%` from the escape.\n\nSee [TMUX-TAGS.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX-TAGS.md) for this section: Window Tags (`@tmuxy-*`)."},{"doc":"docs/TMUX.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md#related","text":"- [STATE-MANAGEMENT.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md) — TmuxMonitor, ControlModeConnection, and command channel details\n- [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md) — How commands flow through control mode in different deployment scenarios\n- [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md) — Security implications of `run-shell` and arbitrary command execution"},{"doc":"docs/TRACING-DESIGN.md","heading":"Action Tracing Design","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING-DESIGN.md#action-tracing-design","text":"The trace file's schema, its instrumentation seams and correlation. Part of [TRACING.md](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md)."},{"doc":"docs/TRACING-DESIGN.md","heading":"Design","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING-DESIGN.md#design","text":""},{"doc":"docs/TRACING-DESIGN.md","heading":"One Rust-owned file, one shared schema","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING-DESIGN.md#one-rust-owned-file-one-shared-schema","text":"The trace is a single **NDJSON** file (one JSON object per line): crash-safe\nappends, streamable, greppable, and trivially convertible to a Perfetto\ntimeline. NDJSON is the source of truth; the timeline view is an export, not the\nstorage format.\n\nA **Rust process owns the file.** All Rust-side events are written directly;\nfrontend events are shipped to that process and merged into the same file, so\nthere is exactly one writer and one file regardless of how many browser tabs are\nconnected.\n\n```\n  Tauri desktop                          tmuxy server + browser\n  -------------                          ----------------------\n  React/XState/Effect                    React/XState/Effect\n        | client trace events                  | client trace events\n        v  (Tauri IPC)                          v  (POST /trace, batched)\n  Rust (Tauri shell) --------.           Rust (server) --------.\n        |                    |                  |               |\n  tracing Layer -------> trace.ndjson     tracing Layer --> trace.ndjson\n   (all Rust spans)      (one file)        (all Rust spans)   (one file, on VM)\n```"},{"doc":"docs/TRACING-DESIGN.md","heading":"The Rust side is (almost) free","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING-DESIGN.md#the-rust-side-is-almost-free","text":"Every Rust layer already emits `tracing` spans and events. A custom\n`tracing-subscriber` **Layer** that serializes each span/event to one NDJSON\nline captures all of it — the monitor's spans, the `#[instrument]` handlers,\nevery `info!`/`warn!`/`error!` — with **zero new call sites**. The work is:\n\n1. Write the NDJSON `Layer` and add it to the subscriber in `init_logging()`.\n2. **Install the subscriber on every entry path**, including the Tauri GUI path\n   that currently skips it (the gap above).\n3. Take timestamps at the layer, from the system clock, so the pure core\n   stays pure.\n\nThe handful of hot-path events that matter for causality but aren't yet spans\n(aggregator `step`, monitor flush decision, emitter dispatch) get one\n`#[instrument]` or structured event each — recording the **typed variant name\nand ids only**, never the command string (see [§ Safety](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#safety--the-redaction-boundary))."},{"doc":"docs/TRACING-DESIGN.md","heading":"The frontend side generalizes what exists","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING-DESIGN.md#the-frontend-side-generalizes-what-exists","text":"Three seams are already tapped; the tracer reuses them rather than adding new\nones:\n\n- The **`send` monkey-patch** in `packages/tmuxy-ui/src/machines/AppContext.tsx`\n  sees every event dispatched to the app machine and emits one trace event per\n  event type.\n- The **Effect chokepoint**: every adapter call runs through\n  `Effect.runPromiseExit` in `packages/tmuxy-ui/src/machines/actors/tmuxActor.ts`,\n  yielding a typed `AdapterError` (`packages/tmuxy-ui/src/infra/transport/AdapterError.ts`).\n  That exit is the natural place to record the outcome of every client→backend\n  call.\n- The **adapter round-trip hooks**: `latencyTracker.markInput()` (send) and\n  `recordUpdate()` (apply) already fire in both `HttpAdapter` and `TauriAdapter`.\n  The tracer emits alongside them.\n\nA small `tracer` singleton mirrors `latencyTracker`'s structure — same runtime\ngating, same `window.__*` exposure, a buffer that batches events and ships them:\n`POST /trace` on the web, Tauri IPC on the desktop. Both land in the one\nRust-owned file."},{"doc":"docs/TRACING-DESIGN.md","heading":"Event schema","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING-DESIGN.md#event-schema","text":"One flat object per line. Content-free by construction.\n\n| Field | Meaning |\n| --- | --- |\n| `ts_wall` | wall-clock time (ms), for human reading and cross-machine ordering |\n| `ts_mono` | monotonic time (µs) from the layer's clock, for duration math within a process |\n| `layer` | `xstate` \\| `effect` \\| `adapter` \\| `http` \\| `monitor` \\| `aggregator` \\| `emitter` \\| `tmux` \\| `tauri` \\| `render` |\n| `component` | originating module/actor (e.g. `keyboardActor`, `transport`, `SessionBroadcast`) |\n| `name` | event or span name (a **typed variant**, never a command string) |\n| `phase` | `event` \\| `start` \\| `end` (spans emit start/end; point events use `event`) |\n| `dur_us` | span duration on `end`, when known |\n| `action_id` | causal id minted at action origin; see [§ Correlation](https://github.com/flplima/tmuxy/blob/main/docs/TRACING-DESIGN.md#correlation) |\n| `session` | tmux session name |\n| `conn_id` | client connection id (distinguishes browser tabs on a shared server) |\n| `seq` | SSE monotonic id and/or delta `seq`, for return-path joins |\n| `pane` / `window` | tmux target ids (`%N` / `@N`) |\n| `attrs` | small map of non-content metadata (counts, flags, error tag, latency) |"},{"doc":"docs/TRACING-DESIGN.md","heading":"Instrumentation seams","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING-DESIGN.md#instrumentation-seams","text":"Each seam already exists in the code; the tracer emits at it.\n\n| Layer | Seam (file) | What it records |\n| --- | --- | --- |\n| xstate | `send` tap, `machines/AppContext.tsx` | app-machine transition (event name only); the derived `TMUX_MODEL_UPDATE` firehose is coalesced to a periodic `phase:'count'` |\n| store | dispatch, `machines/actors/tmuxStoreActor.ts` (via `parseCommandToOp`) | `TmuxOp` variant (Split, SelectPane, KillWindow, ZoomToggle, …) + target ids — the WHAT, args discarded |\n| effect | `Effect.runPromiseExit`, `tmuxActor.ts` / `tmuxStoreActor.ts` | adapter/op **failures** by typed `code` (`AdapterError` / `OpError` tag) |\n| adapter | `markInput` in `drivers/HttpAdapter.ts` / `drivers/TauriAdapter.ts`, `recordApplied` in `stateFeed.ts` | send (kind, `action_id`) and apply (delta `seq`) boundaries + latency |\n| http | `POST /commands` header, `sse.rs` | `action_id` (`X-Action-Id`) → the exact request-leg join |\n| server | `send_via_control_mode`, `sse.rs` | the mutating ingress by command **verb** (first token; args only at `full`) |\n| emitter | `emit_state`, `sse.rs` | each state emit by delta `seq` + kind, joinable to the client `apply` |\n| tmux | the in-band reads in `tmuxy-core/src/transport.rs` | read op name + argc |\n| tauri | title-bar chrome, `infra/desktopWindow.ts` → `tmuxy-tauri-app/src/titlebar.rs` | each status-bar action (`set_titlebar_height`, `titlebar_double_click`) under a `titlebar-*` `action_id`, joined to its native outcome: the traffic-light centre `y` for a bar `height`, or the double-click `kind`/`variant` (zoom → maximized/restored, minimize, none) |\n| tauri | title-bar chrome, `infra/desktopWindow.ts` → `tmuxy-tauri-app/src/titlebar.rs` | each status-bar action (`set_titlebar_height`, `titlebar_double_click`) under a `titlebar-*` `action_id`, joined to the native outcome: traffic-light centre `y` for a bar `height`, or the double-click `kind`/`variant` (zoom → maximized/restored, minimize, none) |\n| marker | `tmuxy trace --mark`, `trace_view.rs` | a user-stamped \"bug happened here\" label |\n| pane | aggregator, `control_mode/state.rs` | each pane's life, content-free: `pane appeared` (window, size, `shell`, `bytes` replayed from before it was listed), `pane first output` (`bytes`), `capture requested`, `pane captured` (non-blank `lines`), `pane resized`, `pane gone`. The session comes from the monitor's `run{session}` span |\n| server | session creation, `sse.rs` | `session creation path`: `via` `courier` (through another session's control client, with its `viewers`) or `direct` |\n| test | `tests/helpers/trace-environment.js` | an E2E run's markers: `<file> › <test> › start` and `› pass`/`› fail`, so one test's slice can be read with `--window` |\n\nThe durable contract from [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md) maps cleanly to spans:\nthe **aggregator** decides _what_ (delta kind), the **monitor** decides _when_\n(flush), the **emitter** decides _where_ (SSE vs Tauri). Those three are the\nserver-side span boundaries."},{"doc":"docs/TRACING-DESIGN.md","heading":"Correlation","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING-DESIGN.md#correlation","text":"An **`action_id`** is minted client-side when a user action originates (a\nkeydown that produces a command, or a typed op dispatch) and threaded through\nthe **request leg**: adapter → `POST /commands` (header or body field) →\n`ClientCommand` → `MonitorCommand`. Every event on that leg carries the same\n`action_id`, so the outbound half of a chain joins exactly.\n\nThe **return leg is a heuristic join, not an exact one.** tmux emits `%output`\nand other events asynchronously, not tagged to the command that caused them, so\na state-update cannot carry the originating `action_id`. State-updates are\ncorrelated to actions by the existing counters — SSE monotonic `id`, delta\n`seq` — plus FIFO timing, exactly the matching `latencyTracker` already does\n(`packages/tmuxy-ui/src/infra/latencyTracker.ts`). The trace records the join\nkeys and leaves the ambiguity visible rather than pretending to a precision the\ntransport can't provide."},{"doc":"docs/TRACING.md","heading":"Action Tracing","level":1,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#action-tracing","text":"**tmuxy does not track you.** There is no analytics, no phone-home, no data sent\nto us or anyone else — ever. What this document describes is a _local debugging\ntrace_: an optional, off-by-default record of your **actions** (not what you type\nor what your programs print) that stays in a single file on your own machine, for\nyou to read or delete, and that exists only to help developers diagnose hard\ncross-layer bugs."},{"doc":"docs/TRACING.md","heading":"The no-tracking model","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#the-no-tracking-model","text":"In four plain points:\n\n- **Off by default.** A normal install records nothing. You get a trace only if\n  you turn it on.\n- **On only for development, or when you opt in.** Development builds enable it to\n  help build tmuxy; on a release build it happens only behind an explicit flag\n  you pass yourself.\n- **Never leaves your machine.** The trace is a local file. It is never uploaded,\n  never sent to a server we control, never shared. tmuxy has no telemetry\n  endpoint to send it to.\n- **For the builders, not about the user.** Its purpose is to let a developer\n  (often you, filing a bug) reconstruct what happened across the layers — not to\n  measure or profile people.\n\nThe rest of this document is the design that delivers that: what a trace\ncaptures — a debuggable, cross-layer record of **what happened** across the\nfrontend (React/XState/Effect), the Rust backend, and the Tauri shell, into\n**one local file** loadable long after the fact — where it plugs in, the boundary\nthat keeps your terminal content out of it, and how to use it. All three phases\nof the plan are implemented; see [§ Using it](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#using-it)."},{"doc":"docs/TRACING.md","heading":"Using it","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#using-it","text":"- **Turn it on (app):** the **Debug** menu — `Enable Traces`, then a level, and\n  `Open trace.ndjson` / `Copy trace.ndjson Path` to get at the file. The switch\n  gates every item below it, and the choice is remembered for next launch. It is\n  the same menu on macOS (native menu bar) and elsewhere (the in-app hamburger)\n  — one spec, `debugMenuSpec` in `tmuxy-ui/src/domain/menus/appMenu.ts`, so the\n  two cannot disagree — and a browser client sees it too, minus `Open` — the\n  file is on the machine running the backend, not the one running the browser.\n- **Turn it on (server):** `tmuxy server --trace` (default file under the state\n  dir) or `tmuxy server --trace /path/to/trace.ndjson`.\n- **Turn it off:** off by default on release builds. The Debug menu's switch\n  turns it off as well as on, including on a development build (which starts it\n  automatically). `DO_NOT_TRACK=1` or `TMUXY_NO_TRACE=1` force it off everywhere,\n  overriding `--trace`, the saved choice, and dev builds — the menu then shows\n  its switch disabled rather than one that silently refuses.\n- **Pick a level:** the Debug menu's `Shape` / `Labeled` / `Full`, or\n  `TMUXY_TRACE_LEVEL=shape|labeled|full` for a one-off run (the env var wins for\n  that launch). Default `shape` — see [§ Trace levels](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#trace-levels). An\n  unrecognised name resolves to `shape`, so a typo can never raise sensitivity.\n- **Inspect it:** `tmuxy trace` prints a summary correlating actions by\n  `action_id`; `tmuxy trace check` (or `--health`) runs a field health check\n  reporting reconnects, rejected commands, and errors per session; `tmuxy trace\n--export out.json` writes a Chrome-trace/Perfetto timeline you open at\n  ui.perfetto.dev; `tmuxy trace --mark \"<label>\"` stamps a \"the bug happened\n  here\" marker into the running trace. All accept an explicit file path.\n- **Where it lives:** `trace.ndjson` in the state directory — `TMUXY_STATE_DIR`\n  when set (the dev server and the test harness point it somewhere of their\n  own), else `~/.local/state/tmuxy` on Linux and\n  **`~/Library/Application Support/tmuxy` on macOS**, which has no XDG state\n  dir. One resolution (`tmuxy-core/src/paths.rs`) serves the writer, `tmuxy\ntrace` and the snapshots, so a `--mark` lands in the\n  file the server is writing. Mode `0600`, rotated at 64 MiB with one `.1`\n  backup. The\n  remembered switch/level sit next to the other config, in\n  `~/.config/tmuxy/trace.json`."},{"doc":"docs/TRACING.md","heading":"Goals","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#goals","text":"- **One artifact, one clock.** A single append-only file that shows a causal\n  chain — keydown → XState → adapter → HTTP/IPC → Rust monitor → tmux → SSE →\n  apply → render — on a shared timeline, instead of three unrelated buffers\n  (browser console ring, the app's log file, server stderr) cross-referenced by\n  eye.\n- **Every layer.** Frontend XState transitions, Effect outcomes, adapter round\n  trips, and the Rust pipeline all emit into the same stream with the same\n  shape.\n- **Off by default, on only on purpose.** Nothing is recorded on a normal\n  install. Development builds enable it to help build tmuxy; release builds record\n  only when you pass an explicit flag (see [§ Gating](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#gating)).\n- **Actions, not content.** Record the _shape_ of what a user did, never what\n  they typed or what a program printed (see [§ Safety](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#safety--the-redaction-boundary)).\n- **Loadable later.** The file is greppable/`jq`-able as-is and convertible to a\n  Perfetto/Chrome-trace timeline for flame-graph inspection of complex issues."},{"doc":"docs/TRACING.md","heading":"Non-goals","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#non-goals","text":"- **Not a metrics/observability endpoint.** No Prometheus scrape, no live\n  dashboard, no aggregation service. This is a local debugging trace, not\n  production monitoring.\n- **Not record/replay.** The trace records causal structure and timing, not the\n  raw `%output`/`%begin` control-mode byte stream needed to replay a session.\n  Replay would have to capture terminal content, which [§ Safety](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#safety--the-redaction-boundary)\n  forbids.\n- **Not a keylogger or audit log of commands.** The existing in-app activity log\n  (`LOG_APPEND` → `context.log`, `packages/tmuxy-ui/src/machines/app/appMachine.ts`)\n  records command _strings_ and is **out of scope** as a trace source precisely\n  because it carries content.\n- **Browser-only deployments are out of scope for the single file.** The demo\n  and v86/wasm builds have no host filesystem and no server; they are not\n  traced to a file. See [§ Deployment scope](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#deployment-scope)."},{"doc":"docs/TRACING.md","heading":"What it complements","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#what-it-complements","text":"The trace is the shared, persisted timeline. The per-layer buffers you reach\nfor interactively still exist beside it: `RUST_LOG`-filtered `tracing` on\nstderr (the desktop app also appends its `info`-and-above lines to `tmuxy.log`\nin the state dir, since an app launched from Finder has no stderr anyone sees;\nit is owner-only, rotates to one `.1` backup like the trace, and names a\nrefused command by the reason it was refused, never by its text),\nthe in-memory SSE replay ring and control-mode tail, the in-app activity log\n(`LOG_APPEND` → `context.log`, which carries command strings and so never\nleaves the app), and the dev-gated `latencyTracker` + `PerfHud`.\n\nSee [TRACING-DESIGN.md](https://github.com/flplima/tmuxy/blob/main/docs/TRACING-DESIGN.md) for this section: Design."},{"doc":"docs/TRACING.md","heading":"Safety — the redaction boundary","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#safety--the-redaction-boundary","text":"The trace records **the shape of an action, never its content.** This is the\nline [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md) draws (Risk #2: keystrokes and terminal output\nare the eavesdropping target; Risk #6 names an action-trace as a _wanted_ future\ncapability — but only one that excludes content).\n\n**Never recorded:**\n\n- `send-keys` / `send -l` / paste payloads (the literal characters typed) and\n  `-H` hex byte forms\n- `%output` and `capture-pane` content — the rendered grid, scrollback, cell\n  data\n- OSC 52 clipboard payloads and the `clipboard` SSE event body\n\n**Safe to record** (the action's shape): the typed `TmuxOp` / `MonitorCommand` /\n`ClientCommand` variant name; target pane/window ids; session name; connection\nid; timing and latency; SSE `id` and delta `seq`; op status transitions\n(`pending` / `in-flight` / `awaiting-confirm` / rolled-back); lifecycle changes\n(connect/reconnect/disconnect); error tags.\n\nStructural enforcements, not just discipline:\n\n1. **Allowlist at the write site, not a blacklist at the source.** The trace\n   Layer serializes only an explicit set of event types and fields; anything not\n   on the list is dropped. A newly added event, or a stray `attrs` field, cannot\n   leak by default — it has to be deliberately opted in. The famous terminal\n   leaks came from the vector nobody thought to blacklist (see\n   [§ Why local-only](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#why-local-only-prior-art)).\n2. **Trace the typed variant, never the string.** Recording the `TmuxOp` /\n   `MonitorCommand` variant instead of the rewritten command is content-free by\n   construction, and is _also_ more robust — it survives the\n   `neww` → `splitw ; breakp` rewrite and the `#{pane_id}` placeholder\n   substitution that mangle raw command strings ([TMUX.md](https://github.com/flplima/tmuxy/blob/main/docs/TMUX.md)).\n3. **Scrub the unavoidable vectors, VS Code–style.** A few genuinely useful\n   fields are inherently content-bearing — window/session names, cwd and file\n   paths in ops, and error / `%error` strings (which routinely quote the\n   offending command or output). These are never stored raw: names and paths are\n   **hashed to a stable opaque id** (the way VS Code identifies a folder by a\n   hash of its git remote rather than its name), and error strings are\n   **path-scrubbed and truncated** (the way VS Code scrubs user paths out of\n   stack traces) or reduced to a typed error code. And raw control-mode output\n   — tmux's parting words on EOF, the monitor's command/output log — is logged\n   at `trace` level, below the layer's `debug` floor, so the catch-all\n   subscriber never sees its content.\n4. **A test that the tracer never emits a raw command string, grid cell, or\n   unhashed name/path.** The redaction boundary is verified, not assumed."},{"doc":"docs/TRACING.md","heading":"Trace levels","level":3,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#trace-levels","text":"The one real usefulness↔sensitivity tension is that the most diagnostic fields\n(names, paths, command args) are also the identifying ones. Rather than one fixed\nsetting, `TMUXY_TRACE_LEVEL` picks where on that dial to sit — evaluated once at\nenable time, applied by the same allowlist visitor and the ingest sanitizer:\n\n| Level | Adds over the previous | Sensitivity | Shareable? |\n| --- | --- | --- | --- |\n| `shape` (default) | typed ops + target ids, delta `seq`, command **verb** (not args), timing, **hashed** names, typed error **codes** | none — no content | yes, attach to a bug report |\n| `labeled` | window/session **names**, cwd, paths in the clear; error text | reveals project/dir names + activity | trusted only |\n| `full` | full command **strings** (incl. `run-shell` args) | high | never |\n\nThe bright line holds at **every** level: pane `%output` and keystroke payloads\nare never captured — they are not on any instrumented path. `full` is a\nlocal-debug escape hatch, not a content firehose.\n\nName/path **hashing is salted per process** (a random `RandomState` seed), so a\nhashed value can't be confirmed by hashing a guessed name and can't be correlated\nacross separate trace files — at the cost of ids not being stable across runs."},{"doc":"docs/TRACING.md","heading":"Deployment scope","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#deployment-scope","text":"Where the one file can exist depends on who owns a filesystem\n([DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md) deployment scenarios).\n\n| Scenario | File location | Client events reach it via |\n| --- | --- | --- |\n| Tauri desktop (local or SSH tmux) | local disk, single client | Tauri IPC |\n| `tmuxy server` on a VM + browser | on the VM, multi-client (tag by `conn_id`) | `POST /trace`, batched |\n| Demo / v86-wasm | — (no host FS, no server) | **out of scope** |"},{"doc":"docs/TRACING.md","heading":"Gating","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#gating","text":"Off by default everywhere. It turns on only on purpose, and never silently.\nFour inputs decide, highest precedence first (`trace::init`,\n`packages/tmuxy-core/src/trace.rs`):\n\n| # | Input | Effect |\n| --- | --- | --- |\n| 1 | `DO_NOT_TRACK=1` / `TMUXY_NO_TRACE=1` | Always off. Overrides everything below, and disables the app's switch. |\n| 2 | `tmuxy server --trace [path]` | On. An operator naming the flag means it. |\n| 3 | The saved Debug-menu choice | On or **off** — this is how a user turns a development build's automatic tracing off. |\n| 4 | Development build (`debug_assertions`) or `--dev` | On, and **announced loudly at startup** (a log line naming the trace file) so it is never a surprise. |\n\nWhen on, the server advertises tracing in the `connection-info` SSE event so\nbrowsers know to ship their events; the Tauri app reads the same state locally\nover IPC. Toggling from the menu takes effect immediately — the writer thread is\ncreated once and parked when off, so flipping the switch never needs a restart.\n\nOff-switches people already expect, honored explicitly:\n\n- **`DO_NOT_TRACK=1`** (the cross-tool convention) and **`TMUXY_NO_TRACE=1`**\n  force tracing off, overriding everything else — including a development build\n  and the saved choice.\n- The opt-out is documented here, at the flag, and in the menu, never buried in\n  code.\n\nThe gate is **enforced server-side and fails closed**: the `POST /trace` ingest\nendpoint rejects unless tracing is on, regardless of what any client believes,\nso a stale client-side flag can't reopen it. **Client hot-path cost when off is a\nsingle boolean early-return**, the same pattern `latencyTracker` uses so\nproduction pays nothing."},{"doc":"docs/TRACING.md","heading":"Inspection","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#inspection","text":"- **Immediately:** the file is NDJSON — `grep` for a `session`/`action_id`, or\n  `jq` to filter by `layer`/`name`/time window.\n- **Timeline:** a `tmuxy trace` viewer subcommand loads the file, joins by\n  `action_id`, and exports Chrome-trace/Perfetto JSON so it drops straight into\n  `ui.perfetto.dev` for a flame-graph timeline across all layers — the view that\n  makes a complex cross-layer stall legible at a glance.\n- **Health check:** `tmuxy trace --check` reports reconnects (a second `connect`\n  for a session), rejected commands, errors, and **silent shell panes**: a pane\n  running a shell that lived 10 s or more without one byte of output, replay or\n  non-empty capture — a shell that never started talking, or output that never\n  reached the aggregator. Each names the marker it appeared under (in an E2E\n  run, the test). `--github` prints the findings as GitHub Actions annotations;\n  `--window \"<marker label>\"` reads only the events from that marker to the\n  next, which is one test's slice of a run."},{"doc":"docs/TRACING.md","heading":"Where the code lives","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#where-the-code-lives","text":"All of the below ships today; the trace file is one Rust-owned NDJSON sink that\nevery layer writes into.\n\n- **Rust** — `tmuxy_core::trace` defines the `TraceLayer` (allowlist +\n  hash/scrub visitor) and a lossy background writer (mode `0600`, 64 MiB\n  rotation). Registered by `tmuxy_server::init_logging`, installed on the server\n  binary, the `tmuxy server` subcommand, and the Tauri GUI path alike. Gating and\n  the `--trace` flag live in `trace::init` / `ServerArgs`. Existing spans\n  (monitor `connect`/`run`, the `transport.rs` reads) flow in with no new call sites.\n- **Client** — `tmuxy-ui/src/infra/tracer.ts` mirrors `latencyTracker`'s gating,\n  fed from the XState `send` tap (`AppContext.tsx`) and the adapter send/apply\n  hooks, batch-shipping to `POST /trace` (web) or the `record_trace` Tauri\n  command. The server advertises `trace_enabled` in `connection-info`, the\n  ingest endpoint **fails closed** and re-sanitizes every field, and\n  `tracer.test.ts` asserts no raw command string escapes.\n- **Correlation** — the client mints an `action_id` per command and threads it\n  via the `X-Action-Id` header through `POST /commands`; the server records it,\n  giving an exact request-leg join. Return-leg correlation stays heuristic on\n  `seq`/timing. `tmuxy trace` (the `trace_view` module) prints a per-action\n  summary and `--export`s Chrome-trace/Perfetto JSON."},{"doc":"docs/TRACING.md","heading":"Why local-only (prior art)","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#why-local-only-prior-art","text":"tmuxy's users are tmux and CLI power users — the population most hostile to\ntelemetry. Two different things get called \"telemetry\": **phone-home** sends data\noff your machine to a vendor (VS Code, Homebrew, JetBrains), while **local\ndiagnostics** stay on the box (crash dumps — and tmuxy's trace). tmuxy does only\nthe second, which is what lets the no-tracking model at the top of this document\nbe true.\n\nFour lessons the design is shaped by:\n\n- **The leak arrives through the path nobody guarded.** iTerm2's worst privacy\n  failure was not telemetry — a URL-preview feature made a DNS request for\n  whatever you _hovered_, leaking passwords in cleartext, on by default, for\n  months. Hence guarding at the write site, not the source\n  ([§ Safety](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#safety--the-redaction-boundary)).\n- **Scrub and hash the unavoidable.** VS Code identifies a folder by a hash of\n  its git remote rather than its name, and scrubs user paths out of stack\n  traces. Same discipline here for names, paths, and error strings.\n- **Opt-out-by-default is a trust bomb.** Homebrew silently enabled opt-out\n  analytics and met a revolt. Ours is off by default and, because nothing is\n  ever sent, needs no consent machinery at all.\n- **Document the off-switch even when collection is tiny.** Windows Terminal's\n  community demanded one regardless of volume. Ours is in [§ Gating](https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#gating),\n  not buried in code.\n\nThe bright line all of them draw: **never add a remote reporting endpoint\nwithout an explicit, up-front opt-in.** If tmuxy ever wants aggregate insight it\nmust be a separate, consented feature — the local trace described here stays\nlocal."},{"doc":"docs/TRACING.md","heading":"Related","level":2,"url":"https://github.com/flplima/tmuxy/blob/main/docs/TRACING.md#related","text":"- [PERFORMANCE.md](https://github.com/flplima/tmuxy/blob/main/docs/PERFORMANCE.md) — the two measurement axes; `latencyTracker`\n  is the model this generalizes and the Axis-B round-trip source.\n- [DATA-FLOW.md](https://github.com/flplima/tmuxy/blob/main/docs/DATA-FLOW.md) — the full user-action path, the seams, and the\n  deployment scenarios that decide where the file lives.\n- [ARCHITECTURE.md](https://github.com/flplima/tmuxy/blob/main/docs/ARCHITECTURE.md) — the sans-IO core and the `StateEmitter` trait.\n- [STATE-MANAGEMENT.md](https://github.com/flplima/tmuxy/blob/main/docs/STATE-MANAGEMENT.md) — the XState actors and typed\n  `TmuxOp` vocabulary that make content-free action tracing possible.\n- [SECURITY.md](https://github.com/flplima/tmuxy/blob/main/docs/SECURITY.md) — the threat model that defines the redaction\n  boundary."}]}