A cross-platform terminal workspace manager with built-in file previews, themes, and cmux-compatible config
I love cmux. It's currently my favorite terminal multiplexer for working with AI coding agents. But there were a few things I wanted:
- Cross-platform — cmux is macOS-only (Swift/AppKit). I needed something that runs on Linux and Windows too. gnar-term is built with Tauri, so it runs everywhere.
- Built-in file previews — Click a file path in the terminal and preview it right there. Markdown renders with GitHub styling, PDFs page through, CSVs become tables, images and videos display inline. No context switching to Finder or another app.
- Command palette —
⌘Pto fuzzy-search commands, switch workspaces, change themes, and load saved layouts. One keystroke to do anything. - Themes — 10 built-in themes (6 dark, 4 light) that switch instantly and persist across restarts.
- cmux-compatible config — Your
cmux.jsonworkspace definitions work in gnar-term. Copy it over and go.
gnar-term isn't trying to replace cmux. If you're on macOS and want native Metal performance with Ghostty rendering, cmux is incredible. gnar-term is for when you want those workflows on any platform, plus file previews and a command palette baked in.
| Vertical sidebar tabs with drag-to-reorder, inline rename, and workspace close on hover. Save and load workspace layouts from the command palette. Autoload workspaces on startup. |
|
Split horizontally (⌘D) and vertically (⇧⌘D). Each split has its own independent direction using a binary tree layout. Tabs within each pane. Pane zoom (⇧⌘Enter) for focus mode.
|
|
| Click any file path in the terminal to preview it in a new tab. Handles bare filenames, relative paths, and quoted paths with spaces. Live-reloads when the file changes on disk. |
Supported formats:
|
Switch themes instantly from the command palette (⌘P) or the native View → Theme menu. Persists to gnar-term.json across restarts.
|
Dark:
Light:
|
Define workspace layouts and custom commands in gnar-term.json. Copy your cmux.json and it just works. Autoload workspaces on startup.
|
{
"theme": "tokyo-night",
"autoload": ["Dev"],
"commands": [
{
"name": "Dev",
"workspace": {
"name": "Dev",
"cwd": "~/projects/myapp",
"layout": {
"direction": "horizontal",
"split": 0.6,
"children": [
{ "pane": { "surfaces": [
{ "type": "terminal", "command": "npm run dev" }
]}},
{ "pane": { "surfaces": [
{ "type": "terminal" }
]}}
]
}
}
}
]
} |
| New tabs and splits inherit the working directory of the active terminal. Automatic OSC 7 shell integration for zsh via ZDOTDIR — no manual config needed. |
Tab titles show the current directory or running process name. |
| Right-click in the terminal for contextual actions. File-specific actions appear automatically when you right-click on text that looks like a file path. |
|
- Command palette (
⌘P) — fuzzy search across all commands, workspaces, and themes - GPU-accelerated rendering — WebGL terminal renderer for smooth scrolling and fast TUI apps
- Bundled Nerd Font — JetBrainsMono Nerd Font Mono included, powerline glyphs work out of the box
- Flow control — PTY backpressure prevents the terminal from choking on fast output
- Process cleanup — closing a tab kills the child process tree (no zombie processes)
- Ctrl+Tab / Ctrl+Shift+Tab — cycle through tabs in the active pane
- Modular preview system — add new file type previewers by dropping an extension in
src/preview/ - Cross-platform — macOS, Linux, and Windows via Tauri v2
gnar-term [PATH] # open with working directory
gnar-term -e <COMMAND> # run a command in the terminal
gnar-term -d <DIR> # explicit working directory flag
gnar-term --title <TITLE> # set window/workspace title
gnar-term -w <NAME> # load a named workspace from config
gnar-term -c <FILE> # use a specific config file
gnar-term --help # show all options
gnar-term --version # print versionExamples:
# Open in a project directory (workspace named after the folder)
gnar-term ~/projects/myapp
# Open and run a dev server
gnar-term ~/projects/myapp -e "npm run dev"
# Load a saved workspace layout
gnar-term -w Dev
# Custom window title
gnar-term --title "API Server" ~/projects/apiWhen launched without arguments, gnar-term loads workspaces from config (if autoload is set) or opens a default workspace.
brew tap TheGnarCo/tap
brew install --cask gnar-termGrab the latest release for your platform:
- macOS —
.dmg(Apple Silicon + Intel, signed and notarized) - Linux —
.AppImage/.deb/.rpm - Windows —
.msi/.exe
git clone https://github.com/TheGnarCo/gnar-term.git
cd gnar-term
npm install
npm run buildThe built app will be in src-tauri/target/release/bundle/.
npm install
npm run dev| Shortcut | Action |
|---|---|
⌘N |
New workspace |
⌘1–⌘8 |
Jump to workspace 1–8 |
⌘9 |
Jump to last workspace |
⌃⌘] |
Next workspace |
⌃⌘[ |
Previous workspace |
⇧⌘W |
Close workspace |
⇧⌘R |
Rename workspace |
⌘B |
Toggle sidebar |
| Shortcut | Action |
|---|---|
⌘T |
New tab |
⇧⌘] |
Next tab |
⇧⌘[ |
Previous tab |
⌃Tab |
Next tab |
⌃⇧Tab |
Previous tab |
⌘W |
Close tab |
| Shortcut | Action |
|---|---|
⌘D |
Split right |
⇧⌘D |
Split down |
⌥⌘←→↑↓ |
Focus pane directionally |
⇧⌘Enter |
Toggle pane zoom |
⇧⌘H |
Flash focused panel |
| Shortcut | Action |
|---|---|
⌘P |
Command palette |
⌘K |
Clear scrollback |
gnar-term reads configuration from:
./gnar-term.json(per-project, highest priority)~/.config/gnar-term/gnar-term.json(global)./cmux.json(per-project, cmux compatibility)~/.config/cmux/cmux.json(global, cmux compatibility)
The config format is a superset of cmux.json. Any valid cmux.json works as a gnar-term.json.
| Key | Type | Description |
|---|---|---|
theme |
string | Theme ID (e.g. "tokyo-night", "molly", "github-light") |
autoload |
string[] | Workspace command names to launch on startup |
commands[].workspace.layout...surfaces[].type |
"markdown" |
Markdown preview surface (in addition to "terminal") |
github-dark, tokyo-night, catppuccin-mocha, dracula, solarized-dark, one-dark, molly, molly-disco, github-light, solarized-light, catppuccin-latte
The preview system is modular. To add a new file type:
- Create
src/preview/myformat.ts - Call
registerPreviewer()with file extensions and a render function - Import it in
src/preview/init.ts
import { registerPreviewer } from "./index";
registerPreviewer({
extensions: ["xyz"],
render(content, filePath, element) {
element.innerHTML = `<pre>${content}</pre>`;
},
});Gnar Term ships an optional MCP (Model Context Protocol) server that lets
an AI agent — Claude Code, Cursor, or anything else that speaks MCP over
stdio — drive real, visible gnar-term panes. The agent calls tools
like spawn_agent, send_prompt, and read_output; each call creates
or acts on a live pane the user can see in gnar-term.
Gnar Term is a terminal first. The MCP module is a strictly optional feature: if you never install Claude Code, you will never see or pay any cost for the MCP plumbing. There is no sidecar to build, no extra process, and no configuration required unless you want to disable it.
A single field in gnar-term.json controls the module:
{ "mcp": "auto" }auto(default) — enable if Claude Code is detected (claudeon PATH or~/.claude.jsonexists). Otherwise completely dormant: no Unix socket bound, no files outside gnar-term's own config touched, no extra threads.on— always enable the module.off— hard opt-out. The module never starts and~/.claude.jsonis never read or written.
gnar-term (single binary)
───────────────────────
[Claude Code] --stdio--> [gnar-term --mcp-stdio] byte
(shim, ~30 LOC Rust) pipe
│
UDS (chmod 600)
│
[Rust UDS bridge] ──events──> [Webview MCP server]
(no protocol parsing) (JSON-RPC + 19 tools
in ~500 LOC TypeScript)
There is no sidecar package. Claude Code spawns gnar-term --mcp-stdio
as a subprocess; that mode is a pure byte pipe that connects stdin/stdout
to the Unix domain socket exposed by the running gnar-term GUI. The Rust
bridge forwards raw bytes; the MCP protocol and all tool handlers live in
TypeScript inside the webview. Adding a new tool is a pure TypeScript
change in src/lib/services/mcp-server.ts.
Security: the socket is chmod'd 600 so only the owning user can connect. There is no network listening port, no HTTP, no auth token, and no DNS-rebinding attack surface. Same-user trust boundary.
On first launch when MCP is enabled, gnar-term registers itself with
Claude Code by shelling out to claude mcp add-json -s user gnar-term ...
with a pointer to its own binary. If the CLI isn't available, it falls
back to an atomic write of ~/.claude.json. After registration you only
need to restart Claude Code once; no manual claude mcp add is needed.
| Category | Tools |
|---|---|
| Session management | spawn_agent, list_sessions, get_session_info, kill_session |
| Interaction | send_prompt, send_keys, read_output |
| Orchestration | dispatch_tasks |
| UI writes | render_sidebar, remove_sidebar_section, create_preview |
| Agent introspection | get_agent_context |
| UI introspection | get_active_workspace, list_workspaces, get_active_pane, list_panes |
| Lifecycle events | poll_events |
| Filesystem | list_dir, read_file, file_exists |
See the Spacebase spec (doc id jzvBxDRrkevx) for full argument schemas
and the wire-level contract, or read src/lib/services/mcp-server.ts for
the authoritative TypeScript definitions.
When gnar-term spawns a PTY for any pane, it injects
GNAR_TERM_PANE_ID and GNAR_TERM_WORKSPACE_ID into the child process's
environment. Agents launched inside the pane (e.g. claude) inherit these
vars; the gnar-term --mcp-stdio shim forwards them in a
$/gnar-term/hello notification on connect. The webview binds the
connection to that pane / workspace.
UI-mutating tools then resolve their target deterministically:
- Explicit
pane_idargument wins. - Explicit
workspace_idargument wins. - Connection's bound
pane_id(workspace re-derived in case the pane was moved). - Connection's bound
workspace_id. - Otherwise: error. The server never falls back to "user GUI focus" for write decisions — that's how the v1 routing-follows-focus bug shipped.
Agents can call get_agent_context to learn their binding. UI introspection
tools (get_active_workspace, get_active_pane) still report user GUI focus —
they're observers, not authoritative for routing.
A Node script at tests/mcp-integration.mjs speaks JSON-RPC 2.0 over the
UDS to a running gnar-term instance. Useful for smoke-testing a real
build end-to-end:
# Start gnar-term (with mcp: "on" or mcp: "auto" + Claude Code installed)
node tests/mcp-integration.mjsFor the full mandatory scenario matrix from the spec (15 scenarios covering multi-connection isolation, binding rules, override semantics, error paths):
node tests/mcp-scenarios.mjsBuilt with:
- Tauri v2 — native app shell, Rust backend
- xterm.js — terminal emulation with WebGL GPU rendering
- portable-pty — cross-platform PTY spawning
- marked + github-markdown-css — Markdown rendering
- pdf.js — PDF rendering
git clone https://github.com/TheGnarCo/gnar-term.git
cd gnar-term
npm install
npm run devPRs welcome. Check ISSUES.md for known issues and planned enhancements.
MIT
