Post
JA EN

Parking tmux in the VS Code Sidebar: A Workspace That Doesn't Let an AI CLI Finish Unnoticed

Parking tmux in the VS Code Sidebar: A Workspace That Doesn't Let an AI CLI Finish Unnoticed
  • Target audience: Developers who run AI CLIs like Claude Code every day inside VS Code (Linux / macOS)
  • Prerequisites: Basic tmux usage (detach / attach), installing VS Code extensions
  • Reading time: About 20 minutes

Disclosure: The extension described here is one I built and published to the Visual Studio Marketplace as the person who runs this blog. This is not a third-party review; it is a write-up from the author of the thing. Statements about how the extension behaves come from its repository and README. Everything about the surrounding software (tmux, xterm.js, the various AI CLIs) is cited from public documentation and source.

Overview

Once you run AI CLIs all day, the terminal changes jobs. It used to be where you typed commands. Now it is where you watch an agent you already started. Keep Claude Code going in three repositories and you have three terminals sitting there in a state best described as “one of these will say something eventually.”

To be clear about what the baseline already does: the VS Code integrated terminal is genuinely good at this. Reloading the window reconnects to the previous process and restores its content, and restarting VS Code restores a terminal’s content and relaunches the process in its original environment (terminal.integrated.enablePersistentSessions, on by default)1. The scrollback you get back after a reload is capped at the last 100 lines by default, but nothing is being thrown away wholesale. Bells have a story too: the terminal that rang gets a marker next to its name in the list (terminal.integrated.enableVisualBell is off by default, and the sound lives under accessibility.signals.terminalBell).

Two differences still bothered me. The first is that closing VS Code ends the programs that were running. What comes back is restored content and a relaunched shell, not an agent that kept working the whole time. A tmux session lives on the server side, and you can attach from another machine and find it still going. The second is how you find out. A marker beside a terminal name works if you are looking at that view. While you are reading a file in the editor, a permission prompt can sit there for as long as you like. What I wanted was an OS-level notification that told me, by name, which repository’s session was calling.

So I built an extension called Tmux Opener that puts tmux sessions in the VS Code secondary sidebar, and published it. It is not a wrapper around the integrated terminal. It is a separate terminal view with its own xterm.js, attaching to a session named after the workspace folder. Close the view or reload the window and the session and its window layout stay on the server side2.

The part that earns its keep is the terminal bell (BEL, \a). When one of the sessions rings, you get a native VS Code notification with that session’s name in it, plus a sound. You can leave an AI CLI running in the background, go do something else, and get pulled back at the moment it needs a human. The sound is synthesized in the webview, so even when the session lives on a remote host over Remote-SSH, the noise happens in front of your own keyboard.

There is a second piece. Japanese fonts in a terminal have a problem where full-width symbols get squashed horizontally, and I ship five typefaces that deal with it as separate font resource extensions. This article stays on setup and daily use. The font side, meaning the homegrown convention for handing fonts from one extension to another and the transform that measures symbol widths and refits them, is covered in a companion article.

An always-on AI CLI changes what a terminal is for

The reason to use tmux has not changed in decades. A session outlives the client that was attached to it. The tmux manual puts it this way: “Each session is persistent and will survive accidental disconnection (such as ssh(1) connection timeout) or intentional detaching”2.

What the AI CLI added is not more value in surviving disconnects. It is a different kind of waiting. Back when I was the one kicking off builds, I could estimate how long a wait would be from experience. Agents do not work like that. One asks for permission after five seconds; another goes quiet and works for eight minutes. When a human supervises a wait they cannot estimate, waiting becomes the task.

This blog has covered the shape of the problem before: hand several tasks to AI and the human ends up in multitasking hell. The cost of parallelism gets pushed onto human attention, and what you need there is not “supervise harder” but a way to stop supervising. A bell notification is about the least clever implementation of that idea available. Terminals have had a single byte for “please look at me” since ASCII.

What Tmux Opener does

A separate terminal in the secondary sidebar

The view opens in the secondary sidebar on the right of VS Code. Since it is not competing for space with the integrated terminal in the bottom panel, the editor, your own terminal, and the agent sit side by side.

The toolbar across the top shows the current session name and holds a + ⌄ button for creating sessions, a toggle for the notification sound, a switch for showing the session list, a kill action for the current session, and a shortcut to the settings. All of it is also available from the command palette under Tmux:.

The session list works the way VS Code terminal tabs work. Click to switch, × (or middle click) to kill, drag the edge to resize. The layout numbers, meaning default, minimum and maximum width, the thresholds where labels and close buttons get hidden, and right-side placement, are matched against VS Code’s own source. If it feels consistent with the rest of the editor, that is because the design was borrowed.

Only one session is attached at a time, and switching detaches the previous one. A detached session keeps running, so switching only means you stopped looking at it.

Session names come from the workspace folder: myrepo, then myrepo--2, myrepo--3. The name tells you which repository’s tmux you are looking at.

Profiles run the command as the session itself

+ opens a plain shell. The next to it drops down a list of profiles. A profile does not run a command inside a session. It runs the command as the session, which means the session ends when the command ends rather than when you type exit.

Claude Code, Codex, Gemini, Cursor, and Grok ship as default profiles. The tmuxOpener.profiles setting merges into those defaults, so setting a key to null hides it and adding your own key adds one.

1
2
3
4
5
"tmuxOpener.profiles": {
  "Gemini": null,                                    // hide a default
  "Claude Code": { "command": "claude --continue" }, // override a default
  "Build": { "command": "npm run watch", "sessionSuffix": "build" }
}

Profile sessions get their own names, like myrepo--claude-code, and do not consume the myrepo--2 / myrepo--3 numbering. Plain shells and agents are not fighting over the same numbers, so the list stays readable.

A profile whose command is not on PATH gets marked in the dropdown, but you can still pick it. The login shell tmux starts may well resolve it, and the extension has no business blocking a choice on the strength of its own lookup.

Ctrl/Cmd+F opens a search bar, and URLs in the output are clickable. For copying you can go through tmux’s own copy mode via OSC 52, or use terminal selection with Shift+drag / Option+drag. Ctrl/Cmd+C copies when there is a selection and passes through as SIGINT when there is not.

Noticing via the bell

The view watches for bells across the sessions that belong to that workspace (myrepo and its myrepo--… namespace), including ones it is not attached to. Bells from unrelated tmux sessions do not produce a notification. When one rings, you get a native VS Code notification containing the session name, and its Open button jumps you to that session.

flowchart TB
    A["AI CLI finishes<br>or waits for permission"] --> B["Rings the BEL"]
    B --> C["Extension watches every<br>session in the workspace"]
    C --> D["Native notification<br>+ sound on your machine"]
    D --> E["Open jumps to that session"]

This is on by default. tmuxOpener.notifyOnBell: false turns it off. The sound can be silenced with tmuxOpener.notifySound, and you can also drop the popup while keeping the sound (tmuxOpener.notifyToast). There are five sound presets (classic / siren / sos / cascade / trill), and changing the setting then ringing a bell doubles as a preview.

The reason the sound is synthesized in the webview is to pin where it comes out. A webview always runs on the local machine, so a session on a remote host over Remote-SSH still makes noise in front of your keyboard.

One browser-derived limitation applies. Audio cannot play until the window has been interacted with once. A bell that arrives in a freshly reloaded window you have not touched yet is silent, but that notification carries a Play Sound button. Press it once and the bell you missed plays, and subsequent ones play on their own.

The setting on the ringing side differs per CLI

This is where real setups get stuck. Whether you get a notification depends on whether the tool you are running rings the bell. Most AI CLIs can, but few do out of the box.

Claude Code’s default (preferredNotifChannel: "auto") sends a desktop notification only in Ghostty, Kitty, and iTerm2, and rings nothing anywhere else. The official documentation names the VS Code integrated terminal as one of the terminals where notifications do not arrive3. So either put the following in ~/.claude/settings.json or select the same setting from /config inside a session.

1
{ "preferredNotifChannel": "terminal_bell" }

Other CLIs have their own switches. Gemini CLI has enableTerminalBell under Settings → Accessibility (off by default). Codex CLI sometimes rings on completion under Linux, depending on the environment. aider takes an arbitrary command through --notifications-command, so passing something like aider --notifications-command 'printf "\a"' turns its notification into a bell (the examples in the official documentation use system speech or Apprise, so the bell is something you specify yourself).

Keep the bell coming from one place

For Claude Code specifically, the repository ships reference hooks (shell scripts wired into Stop and Notification) that let “it finished” and “it is waiting for you” sound different. The Notification hook can match on the kind of notification, such as a permission prompt, idle, or a subagent finishing4, so the script writes the reason to a file first and then rings.

If you install those hooks, turn off Claude Code’s built-in bell. With both active, the same event produces bells from two sources. The extension folds a pair of bells into one notification with a five second per-session cooldown, so you will not get doubled sound or doubled popups. What you get instead is that only the first bell of the pair is classified.

Usually that still works. If a reason file written within the last 30 seconds is on disk at classification time, the extension waits 250 milliseconds and re-reads, which is enough for a hook file that lands a few milliseconds after the built-in bell. It breaks when that re-read never happens. If a long tool call runs right before a permission prompt, the moment of classification looks like “nothing has been written for 30 seconds.” The classification is then finalized without a reason, and the hook’s bell, arriving later, is dropped by the cooldown. The result is that the “waiting for input” sound does not play on exactly the prompt you walked away from.

If you have also added a gate that waits for subagents to finish before ringing, the breakage is simpler. The built-in bell does not consult the gate at all, so it notifies you while a subagent is still working.

If you use the hooks, set the notification channel to anything other than the bell (a value that disables notifications, for instance) and let the scripts be the only thing that rings. The hooks ring for both completion and waiting-for-input, so turning off the built-in bell costs you nothing.

BEL is not an AI-specific signal

Shell completion bells, vim error beeps, and tput bel all ring the same byte, and the extension notifies on all of them. If that gets noisy, turn off the shell’s completion bell (set bell-style none in ~/.inputrc for readline-based shells) or revisit tmux’s own bell settings. The naivety is deliberate: the extension does not try to guess what a bell meant.

Putting context budget and cost in the notification

A reference status line script for Claude Code ships alongside the hooks, and installing it appends context usage and cost to the notification text (tmuxOpener.notifyBridgeStatusline, on by default). You see not only that it finished but roughly how much room is left.

Five terminal fonts, shipped as separate extensions

Using an arbitrary font in a VS Code webview means reading the font’s bytes from somewhere and writing an @font-face. That is a different problem from setting the name of a font already installed on the OS, and there is no official mechanism for sharing fonts between extensions5. So I made extensions whose only job is to carry a font (no UI, no commands), declared a homegrown contract field in their package.json, and had the consuming webview borrow the bytes from there.

Five are published. All of them start from OFL 1.1 fonts.

Extension IDTypeface suppliedUpstream fontNotes
shirokuma-library.font-shirokumagenShirokumaGen TermHackGen Console v2.10.0Upstream is a merge of Hack and GenJyuuGothic6. Modified version refitted for terminals
shirokuma-library.font-mgenplus-1mnMgen+ 1mn TermMgen+ 1mnModified version refitted for terminals
shirokuma-library.font-rounded-mgenplus-1mnRounded Mgen+ 1mn TermRounded Mgen+ 1mnRounded gothic, refitted the same way
shirokuma-library.font-mplus1codeM PLUS 1 Code TermM PLUS 1 CodeVariable font pinned to static 400 / 700 faces before conversion
shirokuma-library.font-plemoljp-consolePlemolJP Console HSPlemolJP Console HSUnmodified. Upstream bytes shipped as-is

The trailing ` Term is a claim about the terminal grid: that a code point counted as one column wide advances exactly one cell, and that its outline does not reach the threshold where it gets squashed. Those are two separate facts, and neither of them says the glyph fits entirely inside the cell (a little overhang remains, centered and symmetric). The reason the threshold matters is that xterm.js rescales a glyph horizontally, and only horizontally, when a one-cell-wide symbol's outline exceeds 1.5 times the cell width, rounded up[^7]. Full-width symbols in Japanese fonts, the Unicode ambiguous-width characters[^8] like ①`, trip that condition and get drawn flattened.

The conversion brings the outline back under the threshold. Shrinking the width alone would cost the same proportion of height, leaving symbols shorter than capital Latin letters, so the vertical axis is scaled by a different factor, with a ceiling measured from the font’s own kanji so it does not overshoot. The design and the measured numbers are in the companion article.

Installing a font extension is not enough on its own. Set the typeface name in tmuxOpener.terminal.fontFamily (if it is not installed, you fall back to the OS monospace). Setting a second family in fontFallback draws only the characters missing from the first family with that second one.

Installation and requirements

You need tmux on PATH. The extension launches tmux directly, so the supported platforms are Linux (x64 / arm64) and macOS (Intel / Apple Silicon). Windows is not supported. Under Remote-SSH the extension runs on the remote side while the sound plays in the local webview.

Install shirokuma-library.tmux-opener from the Marketplace, plus whichever font extensions you want. The source is on GitHub7. The license is MIT, and the extension icon uses the official tmux logo by Jason Long under its ISC license. To be explicit: this is an unofficial extension with no connection to the tmux project.

The main settings:

SettingWhat it does
tmuxOpener.terminal.fontFamilyTerminal font. Empty falls back to terminal.integrated.fontFamily, and if that is empty too, to the built-in monospace (editor.fontFamily is deliberately not consulted, since it would drag in a proportional or serif face)
tmuxOpener.terminal.fontFallbackSecond family, used only for characters the first family has no glyph for
tmuxOpener.terminal.fontSizeUnset means an explicitly configured terminal.integrated.fontSize, then an explicitly configured editor.fontSize, then 14
tmuxOpener.profilesCommands in the dropdown
tmuxOpener.notifyOnBellNotify on a bell (default on)
tmuxOpener.notifySoundPlay a sound with the notification (default on)
tmuxOpener.notifyToastShow the popup (turn off for sound only)
tmuxOpener.notifySoundPresetclassic / siren / sos / cascade / trill
tmuxOpener.notifySoundPresetOnAskSound used when waiting for input (default siren, needs the reference hooks)
tmuxOpener.notifyBridgeStatuslineAdd context usage and cost to the notification (default on, needs the reference script)

When this is the wrong tool

Worth saying plainly: this only pays off when a few conditions line up.

If you do not already use tmux, you first need a reason to install tmux. The integrated terminal is pleasant precisely because everything lives inside the window, and if it has never bothered you that agents stop when you close VS Code, there is not much here for you.

There are lighter alternatives. Run tmux new-session -A -s <name> as an integrated terminal profile and you have session persistence on its own. If notifications are the only thing you want, calling an OS notification command (osascript on macOS, notify-send on Linux) from Claude Code’s Notification hook gets you there without adding an extension. What this extension adds on top of those two is putting them in one view, putting the session name in the notification, and varying the sound by reason. If those two are already enough for you, they are enough.

If you use a tool that never rings the bell, the notification will never come. Until the ringing side is configured, the central feature of this extension is inert.

It also inherits none of the integrated terminal’s specialties. Shell integration (command boundary detection and its history), the task problem matcher, split panes, and screen reader support do not exist on the independent webview side. If you use those daily, you will be running both terminals.

And it is new. Install counts for each extension are in the single digits to low teens. In practice this is a tool polished inside one author’s environment, with a thin track record anywhere else. Compatibility issues with particular tmux versions or shell setups are almost certainly out there, and I am at the stage of waiting for issues.

Wrapping up

Running AI CLIs full time turned the terminal from a place you type into a place you watch. Do the watching with human attention and the time parallelism saved gets spent on supervision instead.

Three things came out of it. tmux sessions now live in the VS Code secondary sidebar as an independent terminal, so agents keep running when the editor closes. Terminal bells turn into native notifications and sound, so you can stop watching. And Japanese terminal fonts that stay readable instead of getting squashed are published, along with the mechanism for passing a font from one extension to another.

None of this is impressive technology. The byte a terminal rings to get attention dates to ASCII, and session persistence has been a tmux property forever. The new part is the reason to want them.

If you want the implementation details: the homegrown contract for passing fonts between extensions (and how it was designed with no official mechanism to build on), plus the measured refit for squashed full-width symbols, are covered in the companion article “Handing a Font from One VS Code Extension to Another: Inventing a Contract Where No Official Mechanism Exists, and Refitting Fullwidth Glyphs by Measurement”.

More on this theme:

References

Numbered citations in the text correspond to the sources below, listed in order.

Additional sources (referenced but not numbered in text)

  1. Terminal Advanced / Terminal Appearance - Visual Studio Code Docs. “When reloading a window (for example, after installing an extension), reconnect to the previous process and restore its content” and “When restarting VS Code, a terminal’s content is restored and the process is relaunched using its original environment”. The visual bell setting (terminal.integrated.enableVisualBell, off by default) is documented in the same pages. 【Reliability: High】 ↩︎

  2. tmux(1) manual page - OpenBSD manual pages. “Each session is persistent and will survive accidental disconnection (such as ssh(1) connection timeout) or intentional detaching …” 【Reliability: High】 ↩︎ ↩︎2

  3. Configure your terminal for Claude Code - Anthropic, Claude Code documentation. “By default Claude Code sends a desktop notification only in Ghostty, Kitty, and iTerm2. In other terminals, set preferredNotifChannel to "terminal_bell"”. The VS Code integrated terminal is named as a terminal where notifications do not arrive. 【Reliability: High】 ↩︎

  4. Hooks reference - Anthropic, Claude Code documentation. Firing conditions for Stop (when a response ends), Notification (when a notification is sent, matchable on things like permission_prompt), and SubagentStop (when a subagent ends). 【Reliability: High】 ↩︎

  5. Webview API / Contribution Points - Visual Studio Code Extension API. Loading resources from a webview, and the full list of contribution points an extension can declare. No contribution point exists for supplying fonts to another extension. 【Reliability: High】 ↩︎

  6. HackGen - yuru7. A programming font merging Hack and GenJyuuGothic. The Console variant treats symbols as closer to half width. 【Reliability: High】 ↩︎

  7. ShirokumaLibrary/tmux-opener - Source repository for the extension described here (MIT). 【Reliability: High (primary source)】 ↩︎

This post is licensed under CC BY 4.0 by the author.