# 'terminal-harbor' - a GUI manager for many terminal sessions

> Source: <https://dev.to/emalia/terminal-harbor-a-gui-manager-for-many-terminal-sessions-5f9a>
> Published: 2026-09-28 22:41:48+00:00

**TL;DR**

with several shells and a many AI-agent sessions running at once, I got annoyed having to click through every tab. so I built terminal-harbor, a GUI for managing many terminal sessions with features such as a tab strip tells you what is open, whats waiting on your input, and an easy way to organize and move between a lot of terminals.

[try it out - link to github repo](https://github.com/emihiggins/terminal-harbor)

**terminal harbor is a macOS desktop app:** one large focus area with up to 4 live split panes, plus a left rail of live thumbnails of every session. each session is a real PTY, and the thumbnail is a running mini-preview of it, so you can watch a session do something without switching to it. click a thumbnail to load it into the active pane.

**features:** 

`⌘F` across every session's scrollback`⌘⇧J` to jump to the next session that is waiting`⌘⇧T` to reopen the last terminal you closed,
the headless terminal is the source of truth

every session owns an `@xterm/headless` terminal that is always fed the byte stream, whether or not anything on screen is showing that session. 2000 lines of scrollback each. that headless model is what the thumbnails draw from, what a pane replays from when you point it at a different session (`serialize()` written into a `reset()` pane, lossless), and what `⌘F` searches when it searches across sessions rather than within one.

the obvious alternative is one visible xterm.js instance per session, hidden when it isn't focused, promoted into the focus area when it is. it loses on all 3 of the features:

separating the model from the renderer means a pane is a view and nothing more — you can put any session in any pane, and closing a pane costs a session nothing.

so the focus panes are xterm.js on WebGL, and the thumbnails are not terminals at all. one shared `requestAnimationFrame` loop throttled to 3fps walks the tiles and redraws only the ones that are visible and whose model is marked dirty, which means idle off-screen sessions cost effectively nothing. each tile is the model's current viewport drawn as tiny monospace text on a 2D canvas, with the font sized so `cols` columns fit the tile width. a freshly-registered tile paints on the next frame instead of waiting for the throttled tick, so switching workspaces doesn't flash a rail of blank boxes.

one packaging note. `@xterm/headless@6` is CJS-only, so `vite.config.ts` carries a regex-anchored alias pointing at `lib-headless/xterm-headless.js`.

with no shell integration present, the state comes from 2 signals together:

if the process group leader is the shell's own pid and output has gone quiet, that's considered an idle prompt and no badge. if some other program owns the group and has gone quiet, that's a program probably blocked on stdin, so amber. anything still producing output is running.

when shell integration is there, OSC 133 markers outrank the heuristic completely:

`A` and `B` mean at prompt`C` means command output started`D` means the command finished.
no timing guess at all. the scanner runs over raw bytes and keeps its state across chunk boundaries, so a sequence split across 2 reads still parses, and it only observes (the bytes reach xterm unchanged).

it has to be bytes, not text: a 64KB read can cut a multibyte. character in half, and `String::from_utf8` on a PTY chunk will eventually mangle someone's output.

the blind spot that survives all of this is `read -p`. a shell builtin prompting for input is still the shell as process group leader with no output coming, which is indistinguishable from an idle prompt by the heuristic. OSC 133 injection would fix it exactly, and that's why it's on the list.

agents needed their own path, because an agent parked on a permission prompt is silent, and the thing you actually want on the tile is what it's asking for. so the app binds a loopback HTTP server on a random port with a bearer token, and every shell it spawns gets `HARBOR_SESSION_ID`, `HARBOR_HOOK_URL`, and `HARBOR_HOOK_TOKEN` in its environment. turning it on in settings merges 3 hooks into `~/.claude/settings.json` — `Notification`, `Stop`, and `UserPromptSubmit` — after backing the file up, and every entry it writes is tagged with the hook script's filename so installing is idempotent and uninstalling removes its own hooks and nothing else. notification and stop set waiting, userpromptsubmit clears it, and typing into the session clears it too, since you're plainly already there.

correlation degrades instead of lying. a claude started over ssh, or inside tmux, or in a container doesn't inherit those variables, the hook script no-ops on the missing URL, and that session falls back to the heuristic.

PTY output reaches the frontend as raw bytes over a per-session tauri `Channel` — `InvokeResponseBody::Raw` on the rust side arriving as an `ArrayBuffer` on the JS side, never a JSON array of numbers.

flow control is end to end. the reader thread tracks bytes it has sent but that the frontend hasn't acknowledged, and stops reading once that backlog crosses 2MB. the frontend acks through `ack_pty` once the headless model has finished writing the chunk. the part that makes it real backpressure rather than a dropped-frames policy is what happens while the reader is paused: it isn't draining the PTY, so the OS PTY buffer fills, so the shell's own `write()` blocks. the producer is the thing that slows down. a `yes` in one pane can't outrun the renderer and take the window with it.

the state evaluator is a separate thread that samples every session every 200 ms and emits a `session-state` event only when that session's state actually changed, so a room full of busy terminals isn't also a firehose of IPC.

HTML5 drag-and-drop is unreliable in WKWebView, and 2 of the features are drags:

because of this, both run on `@dnd-kit` instead, with a pointer sensor, an explicit grip handle, and a `DragOverlay` using `snapCenterToCursor` and `pointerWithin`. native `alert` and `confirm` are avoided for the same reason, so deleting a workspace asks inline instead of through the browser dialog.

thumbnail tiles are also fixed-size on purpose — `flex: 0 0 auto` — so a rail with a dozen sessions in it scrolls rather than squashing every preview into an unreadable sliver.

what persists is the arrangement, not the processes: workspaces, terminals, names, colors, rail order, each workspace's split layout, and each session's working directory. on relaunch the shells come back in their saved `cwd`. live process state can't come back, and nothing pretends otherwise. saves are debounced 1.5s and flushed on

`beforeunload`, and suppressed entirely while a restore is in progress so the restore can't overwrite the file it is reading from. reading the cwd shells out to `lsof`, because macOS has no `/proc`.

and the one that was mine. the registry is a `globalThis` singleton, which is the right shape for the thing that owns every PTY and has to survive a hot reload, and the restore runs from a react effect at boot, and in dev those 2 facts collide — fast refresh re-ran the effect while the singleton sat there unchanged, so every restored session got spawned a second time, and I had a rail of duplicates and twice the PTYs I asked for. `boot()` is guarded by a `booted` flag now, and editing `registry.ts` deliberately triggers a full page reload rather than a fast refresh, because a shape change to a stateful singleton must NOT be allowed to leave a stale instance behind.

what I built: the rust core, the PTY and flow-control layer, the detection, the claude code hook integration, and the whole frontend. MIT.

notification banners are unreliable in an unsigned dev build and only fire properly out of a signed, notarized `.app`, which is the next thing on the list, ahead of a command palette and OSC 133 injection. no automated test suite yet, so you verify it by running it.

macOS only for v1, and not incidentally — the cwd lookup is `lsof`, and the detection reads the PTY's process group leader.
