← tmux-next EN · 中文 · Manual

What's new in 2.2

The last several releases changed what this thing is for, not just what it can do. It started as a way to see one tmux session from a phone. It now also tracks the Jira ticket that session is for, through to its PRs and their checks, and moves that ticket's status forward on its own. Here's that whole arc, plus today's rework of the list that shows it.

A tmux session doesn't know what it's for

Before any of this, the only place to connect "work happening in a tmux session" to "work tracked in Jira" was the session's own name — sessions got named DL-ENG-45138, after the ticket. That's not a style complaint; it's a real cost. tmux session names have to be unique, so a ticket could have exactly one session, ever — no separate session for running the tests and one for editing code, and no readable name, because the ticket key ate the whole thing.

Plugins, as a seam rather than a Jira-shaped hole

Before Jira existed as a feature, two other page-level features — the artifacts gallery, the notification history — were wired straight into the kernel: one storage path, three route blocks, a page, a hardcoded nav entry, each. Removing either meant subtracting across five files, and it never came out clean. So the fix wasn't "add Jira", it was a seam: one page-level feature is one directory, with a manifest the kernel reads generically, its own server handlers, and its own stylesheet.

Two things were rejected on the way there. Scanning ~/.tmux-next/plugins/ at runtime, because that's running arbitrary code inside a service with no login of its own — a different threat model entirely, not a convenience worth the risk. And a middleware chain of fetch(req) handlers, because a readable if-chain of routes beats not knowing which plugin intercepted a request until it runs. One split enforces the boundary from the browser's side too: the manifest is isomorphic and loaded by the browser directly, so a test asserts its import graph contains zero .ts files — the moment it imports a server handler, server code rides along into the browser bundle.

One ticket, several sessions

Jira became the first plugin, and its founding decision was that a ticket can have more than one session open against it. So the binding between a session and a ticket is keyed by session name, storing both the name and tmux's own #{session_id}: the id survives a rename, the name survives a server restart, so resolving a binding tries the id first and falls back to the name, and nothing needs a "session was renamed" event to stay correct.

Two things were deliberately left out at this stage. Writing anything back to Jira at all — "the write would come from a service with no login of its own" was reason enough to defer that to its own design later. And a database — the state here tops out at however many tmux sessions exist, which stays in the dozens, so plaintext JSON was already enough.

Work items become the kernel's own idea

A ticket needed to outlive whichever session happened to be open for it, so "work items" were promoted out of Jira's own page into a concept the kernel itself understands: a durable, intent-shaped thing, where a session is just one disposable means toward it. A work item's source provider is a plain string the kernel never validates against an installed plugin — a ticket from a since-disabled plugin is still a ticket, it just stops being enriched.

Nothing about a ticket's status is ever stored

Status, epic, PR count, whether checks are green — all of it is recomputed on every request, never cached to disk. "The kernel stores the item, the external system's opinion sits on top of it" only holds if that layer stays purely additive, and a stale status is worse than no status at all, because it looks true. The one rule broken on purpose, later: the lifecycle stage itself (below) does get persisted, once there was a real reason to.

What checks can tell you, and the one thing they can't

Jira's own dev-status endpoint answers "which PRs is this ticket attached to" using a token that was already configured for the ticket data itself — no new credential needed. It can't say whether those PRs pass CI, though: CircleCI never reports back into Jira, so the build count sitting inside Jira's own data is permanently zero. So the second hop asks Bitbucket instead of CircleCI directly — chosen because it's per-PR and CI-agnostic, the same response carries CircleCI and Codacy results alike, and it keeps working even when a PR lives in a different repository, which a CircleCI project slug can't do.

Merge-conflict status: investigated, and declined

Whether a PR would actually merge cleanly looks like the obvious next thing to surface, so it got checked properly rather than assumed away. Bitbucket Cloud's public API, asked for every field it has, returns thirty of them — the only two anywhere near "would this merge" are merge_commit and merge_in_progress, both null on every open PR tested.

The diff endpoint can't stand in either: it's a three-dot diff, so it returns a normal 200 whether or not the branches would actually merge — confirmed against nineteen real open PRs, not assumed. The web UI does know, through a private endpoint that a properly scoped app password gets a flat 403 from.

Reaching it would mean storing a full logged-in Bitbucket browser session inside a service that has no login of its own — a categorically bigger credential than anything else this needs, for one field. So: not offered. If a rough signal is ever wanted, "behind the target branch by N commits" is computable from data already being fetched, and it would be honest about being a proxy rather than a real answer.

A lifecycle that only moves forward

The newest piece: every item now carries a five-stage lifecycle — unclaimed → in progress → in review → in merge → done — derived from signals already on hand (a live bound session, a non-empty PR list, every check green, a PR showing merged), never accepted as input from outside. It only ever advances: a PR that closes without merging leaves the item parked at "in review" rather than sliding it back, because "this once had a PR out" is a fact worth keeping, not erasing.

One approximation is inherited directly from the section above: in review → in merge means "still open, and every check is green" — not "provably mergeable", because provably mergeable isn't available (see the Bitbucket dead end). When checks couldn't even be asked about, that transition is withheld rather than showing a status that might be false.

Writing back, sideways

Once a transition fires, it goes out through the same seam already used for syncing and refreshing — the kernel looks up which enabled plugin claims the item's source, it never hardcodes "Jira". Jira's side does exactly two things: moves the ticket through its real transitions API, with a per-stage mapping that can be left blank for a workflow that doesn't have five matching states; and posts one PR comment, only on the move into review, because that's the one moment a person actually needs pulling in — the rest is internal bookkeeping. Both steps can fail on their own, silently, logged but never thrown: a write-back failure never unwinds the state change that already happened locally.

One small trailing detail from the last few days: the check list inside an item's detail view now groups by PR instead of listing every check flat, each group's project name read from the repository itself rather than the UUID buried in the PR's own URL, with a link back to the original PR on the group's heading.

The same object, a new coat of paint

Today's list-view rework isn't a new feature in this arc — it's what "items became the thing you scan across many rows, each carrying a lifecycle and a stack of facets" (the two sections above) eventually asks of the screen that shows them. The view used to be a real <table>: one column per field, and a horizontal scrollbar the moment the window got narrow — on a phone or a half-width desktop window alike, easy to not even notice you need to pull. Status now lives on the left and wraps instead of scrolling; actions sit pinned on the right; and a field you've chosen to see gets its name spelled out on the chip itself, because there's no column header left to say it for you.

Earlier, in 0.9 to 0.12

0.12 added talking to your agent by voice. 0.9 to 0.11, before that, changed the shape of the thing itself: it stopped being a Claude Code viewer that happens to run tmux.

Dictation, without the keyboard's dictation key

Typing a prompt on a phone is the slowest part of using this thing. The obvious answer is the microphone key on the system keyboard, and it does not work here — not "works badly", does not work. iOS dictation rewrites the whole text repeatedly as it revises its guess, and xterm.js treats every rewrite as fresh keystrokes. Say voice input and the terminal receives voivoice invoice input.

I tried three ways around it and all three failed for different reasons: a capture-phase listener loses to the one xterm registers in open(); dictation's beforeinput is not cancelable; and filtering inside the send path destroys everything else that travels it — paste, image paths, mouse sequences. So tmux-next records the audio itself and sends it to a recogniser.

bunx tmux-next asr <key>   # Volcano Engine; off until you set one

A microphone appears in the terminal toolbar next to the keyboard button — the two are the same kind of thing, whatever fills the bottom of the screen, and opening one closes the other.

Takes pile up in a draft

The first version sent each take straight to the prompt. That was wrong about how people actually speak: you say a sentence, look at what landed, say the next one, and fix the name the recogniser got wrong. So takes accumulate in an editable draft, and one Send delivers the whole thing with Enter.

Tap into the draft first and the next take lands there rather than at the end; select a passage and speaking replaces it, the same as typing would. That only works because the draft box is one element for the panel's lifetime — the first version rebuilt it on every render, which threw the caret away and made "insert before what I already said" impossible.

A failed or silent take never takes the collected sentences with it. That is the one thing in this design that cannot be allowed to go wrong: losing three spoken sentences to a fourth that misheard would be worse than not having the feature.

The measurement that decided the architecture

The plan was to keep the key on the server and let the page call the recogniser directly — fewer hops, and the audio never touches the machine. A CORS preflight said that would work: access-control-allow-origin: *, with the vendor's auth headers explicitly on the allow-list.

Then the actual credential turned out to authenticate only through X-Api-Key, and that header is not on that list. Query-string forms are rejected outright. So the audio goes through the server after all — which is the better shape anyway: the key never reaches a browser, so devtools, screenshots and extensions stop being exposure surfaces, and there is one credential to configure instead of two. The recording is forwarded and dropped; it is never written to disk.

The conclusion I got wrong

A dictated line was landing in the prompt without submitting. I guessed the agent's input treats a large chunk as a paste, where a carriage return is a newline rather than a submission, and shipped a fix that sends the Enter on its own a moment later.

Then I tested it — a real Claude Code TUI, driven through the same websocket the phone uses — and both forms submitted. So I reverted the fix and wrote a comment explaining that the guess had been wrong.

The test sentence was 30 characters. At 80 it fails, and at 121 it fails: paste detection has a length threshold, and my sample sat under it. Dictated sentences sit above it, which is why this only ever appeared for voice and never for the ⏎ key on the toolbar — that one sends a bare return with no text in front of it. The split is back, the comment now carries the measurements, and it records the wrong turn so the next person testing with a short sentence does not reach the same wrong conclusion.

A log for the paths that say nothing

The Claude hooks are built to never disturb the agent, so every way they decline to act is silent — which is what made two earlier bugs so hard to place. Each invocation is now recorded to ~/.tmux-next/hook-events.jsonl: which kind of event arrived, whether it came from a subagent, which session it was attributed to, and whether a push followed.

That shape only — never the message text. Diagnosing an event name is not worth writing what you and the agent said to each other onto disk by default. TMUX_NEXT_HOOK_LOG=off records nothing.

It runs three agents now

Nothing in the terminal path was ever specific to Claude Code. capture-pane and send-keys do not care what is running inside; only four things did: how to start an agent, how to resume one, where its transcript lives, and what its interface looks like on screen. Those four moved into src/agents/, and opencode and pi joined Claude Code as equals — starting, resuming, the task line, and lock-screen notifications all work for each.

The picker only appears when more than one is installed, so a machine with just Claude Code looks exactly as it did.

The part that was not obvious

Agents are probed through a login shell — the same way a launch resolves the command — rather than by checking the server's own PATH. This matters more than it sounds: tmux new-session will happily create a session for a command that does not exist, so without the check you get a session that vanishes the instant it appears and nothing is reported. That is not hypothetical; it is what happened the first time pi was installed somewhere the login shell could not see.

Colour themes

Four presets — Tokyo Night, Catppuccin Mocha, One Dark and Nord — switched from the gear icon, applied without a reload. Until now the terminal used whatever xterm.js ships by default, which is the Tango palette: designed for a mid-grey terminal, and washed out on this app's near-black background.

Every palette is checked against WCAG contrast floors by the test suite. Two results from that were worth acting on, and neither was what I expected going in:

None of the four ships a usable brightBlack. Measured against their own backgrounds they land between 1.7:1 and 2.5:1 — and that is the colour Claude Code draws its secondary text in: the status line, the prefixes, timestamps. Unreadable on a phone outdoors. Each is replaced by a lighter step from the same upstream palette, so the hue is untouched and only the luminance moves.

The bar had to be AA, not AAA. I started with AAA and the tests failed immediately: One Dark's foreground is 6.6:1 and two of Nord's colours fall under 4.5:1. When four established palettes cannot clear a bar, the bar is wrong, not the palettes.

An English interface

The interface was Chinese while the README and the npm page were English, which meant anyone arriving from npm opened a screen they could not read. Both languages now ship. On a machine that has never chosen one, the first request guesses from the browser's Accept-Language and stores that, so the default is right without touching a setting.

Lock-screen notifications follow the same setting — a push is the one piece of text that appears away from the interface, so an English interface with a Chinese lock screen would be the odd one out. A message the agent itself sent is passed through untranslated: it is more specific than anything canned, and already in the language the agent chose.

The check that made this survivable

Roughly 200 strings had to move into a dictionary, in files that are not type-checked — so a mistyped key raises nothing and simply renders blank. A test scans every call site against both dictionaries and fails on a missing key, an unused key, or a key present in one language and not the other. That last case is the one worth having: a key missing from English is invisible to an author who reads Chinese. It caught three real mistakes on the first run.

Getting around

The session list, artifacts and notifications are siblings, but the markup treated the list as a hub: it carried the icons and the other two carried a back link. Going from artifacts to notifications meant passing through the list, and nothing on screen said which page you were on. Every page now carries the same header — a segmented control on the left, the actions on the right, and the count for the page you are on inside its own segment.

Starting a session became a page of its own rather than a sheet. A bottom sheet grows upward, so a long directory list pushes its own top off the screen; the list had been capped at 34% of the viewport, which on a phone is five or six directories — for the one screen whose whole purpose is browsing directories. It also has three input fields, and a sheet is where the iOS soft keyboard causes the most trouble.

The real gain is that the directory now lives in the URL. Back walks up the path you came down instead of discarding it, and new.html?dir=/some/project can be kept as a link. Settings stayed a sheet: its contents are bounded — two languages, four palettes — and it has no input fields.

Two bugs worth naming

The hooks were silently disabling themselves. Both shell hooks began by asking tmux which session the current pane belonged to. But a pane belongs to every session grouped onto its window, and that query has to collapse the set to one answer — it returns the most recently created, which is always tmux-next's own mount point while a browser is watching. So a guard meant to exclude "this is our own attach point" actually meant "the user is looking at this session, so do nothing": no binding record, no push notification, no error anywhere. It cost three sessions when a tmux server went down. The fix asks a question that does not collapse — list every owner, then filter.

A page shipped that could not render. One module called a translation helper 25 times without importing it. That is valid JavaScript — it fails only when the line runs — so the bundler was happy and every test stayed green while the page died on load. There is a test for that now, and, separately, one that mounts a page in a DOM and asserts on what it drew. Proving a file parses is not proving it renders.

Getting it

bunx tmux-next@latest

If you already installed the Claude hooks, re-run bunx tmux-next hook after upgrading. The scripts are copied into ~/.claude/hooks/, so npm cannot update them in place — and a stale hook fails silently, which is exactly the failure described above. The server now checks at startup and prints a line if the installed copies differ from the ones it ships.

GitHub · npm · Manual · Home