Interactive session picker for tmux
Fast, fuzzy session management from a bare shell, with project memory,
path aliases, reboot-safe restoration, and a keyboard-driven TUI.
Install · Quick Start · How It Works · Commands · Shell Integration · Configuration
Portal runs at a bare shell, before you enter tmux, and gives you an interactive TUI for picking, creating, and managing sessions. It remembers your projects, resolves paths via aliases and zoxide, auto-detects git roots, and restarts the tmux server and restores your saved sessions after a reboot.
After shell setup you drive it through two functions: x (the picker and opener) and xctl (subcommands like list, kill, alias). Both names are configurable with --cmd.
tmux keeps sessions alive; getting back into the right one is the chore. Finding it means tmux ls and a name you half remember. Starting one in the right place means a cd, a new-session and a name to invent. A reboot takes the lot — and the plugins that bring sessions back bring every command back with them, all at once, whether you still need it or not.
Portal folds that into two shell functions and a picker:
- One verb to get anywhere. Jump to a project by path, alias, or zoxide (
x work), attach an existing session by name or glob (x api,x 'api-*'), or search your live sessions by name or directory (x /api), with git-root resolution and project memory built in. - Reboot-safe sessions. Portal starts the tmux server and restores structure, layout, working dirs, and ANSI scrollback after a reboot. Replaces tmux-resurrect / tmux-continuum.
- Commands come back when you ask. Per-pane commands return via resume hooks: each pane comes back holding a panel showing its command, which runs when you answer it, so a reboot doesn't start every dev server you ever left open.
- A keyboard-driven picker. A colourful picker that owns its own canvas, with an in-app
?keymap on every page. Six themes ship — Tokyo Night, Tokyo Night Day, Nord, Nord Wave, GitLab Dark Grey, and Horizon — switched withtand matched to your terminal's background unless you pin one; drop-in.themefiles restyle every screen, andNO_COLORis honoured. - Session grouping and tags. Flip the list between flat, by project, and by tag with one key. Tags live on directories, so every session opened there inherits them.
- Scrollback preview. Hit
Spacefor a read-only peek at any session's saved scrollback, cycling windows and panes without attaching. - Multi-window open. Name several targets (
x work api db) or mark them withmin the picker and pressEnterto open each in its own host-terminal window — rebuild your post-reboot window layout in one action instead of by hand. Ghostty works out of the box; other terminals via aterminals.jsonrecipe.
- tmux ≥ 3.0 (released Feb 2020). Older versions are refused with a clear upgrade message.
- Go (to build from source), macOS or Linux.
macOS
brew install leeovery/tools/portalLinux
curl -fsSL https://raw.githubusercontent.com/leeovery/portal/main/scripts/install.sh | bashGo
go install github.com/leeovery/portal@latesteval "$(portal init zsh)" # in ~/.zshrc — defines x and xctl (bash and fish below)
x # the picker: ↑/↓ move, / filter, Space preview, Enter attach
x ~/Code/api # new session at the repo's git root
x ~/Code/api -e "make dev" # ...with a command running in it
x /api # later: search live sessions by name or directory
xctl alias set work ~/Code/work # alias a path...
x work # ...then open it by name
xctl hook set --on-resume "make dev" # in a pane: bring its command back after a reboot
xctl doctor # save daemon running? hooks registered? state sane?Your sessions are saved as you work. After a reboot the first x starts tmux and rebuilds them — see How It Works.
The full tour: grouping, fuzzy filter, scrollback preview, attach. Stills below.
![]() Grouped by tag |
![]() Peek-mode scrollback preview |
![]() Concurrent cold-boot loading |
![]() Per-page ? keymap |
![]() Projects |
![]() Edit project: name, aliases, tags |
![]() Live fuzzy filter |
![]() Destructive confirm |
The same screens render in light mode and under NO_COLOR (see Configuration).
OPEN x ──▶ the picker
x /term ──▶ search: one match attaches, more open the picker
x <session | glob> ──▶ attach, or switch-client inside tmux
x <path | alias | zox> ──▶ new session at the git root
SAVE _portal-saver ──▶ save daemon ──▶ state/sessions.json + scrollback/*.bin
every second, whatever changed
REBOOT first x ──▶ start tmux ──▶ rebuild sessions ─┬─▶ layout · cwd · scrollback
└─▶ resume hooks wait on a panel
- Opening. Outside tmux Portal hands its process to
tmux attach-session, so nothing of Portal stays running; inside tmux it switches your client, so sessions never nest. Seex(open). - Saving. A daemon in a hidden
_portal-saversession re-reads tmux every second and writes what changed — structure, layout, zoom, working directories, and ANSI scrollback — understate/(see Configuration). - Restoring. After a reboot, the first command that needs tmux starts the server and rebuilds every saved session that isn't already live. See Automatic Server Bootstrap & Restoration.
- Resuming. A pane with a resume hook comes back holding the resume panel;
Enterruns its command,ddiscards it,Escgoes back to the picker and leaves it waiting. - Two sessions you didn't make.
_portal-saverhosts the daemon and_portal-bootstrapkeeps a freshly started server alive. Both show in a rawtmux ls; the picker andxctl listhide them.
Portal generates shell functions via portal init. Add to your shell profile:
# zsh
eval "$(portal init zsh)"
# bash
eval "$(portal init bash)"
# fish
portal init fish | sourceThis creates two functions:
x(): launches Portal (interactive picker or path-based session creation)xctl(): direct access to Portal subcommands (list,kill,alias, etc.)
Customize the function name with --cmd:
eval "$(portal init zsh --cmd p)" # creates p() and pctl()Examples below use the default
x/xctlfunction names. If you used--cmd p, substitutepandpctl. You can also call theportalbinary directly.
The single session verb — the picker, opening a target, and multi-window bursts all live here. x maps to portal open. (x absorbs the former attach and spawn commands; both are retired.)
x # interactive TUI picker
x api # attach the existing session named "api"
x 'api-*' # attach every session matching the glob
x /port # search live sessions for "port"
x / # open the picker with the filter ready to type
x ~/Code/myproject # mint a new session at a path
x myalias # resolve alias → mint a new session there
x ~/Code/app -e "make dev" # mint a session and run a command
x ~/Code/app -- npm start # alternative command syntax
x work api ~/Code/new # open three surfaces at once (see below)Resolution. A bare target runs the precedence chain, first match wins:
exact session name → path → alias → zoxide query.
An exact session name attaches that existing session; a path, alias, or zoxide match mints a brand-new session there (directory targets always create — there is no find-or-create, so x api mints even while an api-* session runs; reach the existing one with the name, a glob, or -s). A target that resolves to nothing is a hard failure — there is no TUI-fallback-on-miss; the error points you at -f.
Session search. A positional beginning with / and containing no further / is a search across your live sessions rather than a target to resolve — x /port. The rule tests the shape of the argument alone and never asks the filesystem anything, so the same command line means the same thing on every machine. Everything else is untouched: x ./port, x ~/port, x /Users/me/Code/portal and x /tmp/ are the paths they always were (the trailing slash is the second one).
The term is matched case-folded, as a contiguous run, against each live session's name and its recorded directory — home-abbreviated (~/Code/portal), exactly as the picker's row displays it. Three outcomes, by how many sessions match:
- exactly one — that session is attached outright, no picker;
- several — the picker opens pre-filtered by the term, cursor on the first match;
- none — the picker opens on the term with nothing surviving it. That is a filter result, not an error: press
Escand your sessions are there.
In a picker a search opened, every row shows its recorded directory beside the session name, so a row that matched on its directory says why it is there; the column stays for that picker's life, however you edit the filter afterwards. A session list tmux cannot read is not a zero match: the search reports tmux's error and exits non-zero rather than opening an empty picker that would suggest your sessions are gone.
x / — the slash with no term — opens the picker with the filter empty, focused and ready to type. It is not an error, and it does not mean "mint at root".
The picker's own hand-typed / filter stays fuzzy (scattered letters anywhere). The search form is deliberately stricter, because it can attach a lone match without ever showing it to you: a fuzzy /port could land you in ~/Projects/rust-tools sight unseen.
The cost: a single-segment absolute directory typed without a trailing slash — x /tmp, x /opt — now searches instead of minting. Mint there with the pin that exists for it: x -p /tmp.
A search composes with nothing — a second target, a trailing command, -f, or any domain pin on the same line is a usage error. Recognition stops at a -- separator, so the words after it stay the trailing command's own (x ~/Code/api -- ls /tmp is unaffected).
-f or /term? They differ by outcome: -f always opens the picker, which makes it the form for a script or a keybinding that must land in the same place every time; /term takes you straight to the session when only one matches, which makes it the interactive form.
Domain pins skip the chain and force one domain — a pin that misses hard-fails without popping the picker. -f, /<term> and -e/-- are not pins:
| Flag / form | Pins to | Behaviour |
|---|---|---|
-s, --session <name/glob> |
session | attach; never mints |
-p, --path <dir> |
path | mint at the directory (must exist) |
-a, --alias <key/glob> |
alias | mint at the aliased directory |
-z, --zoxide <query> |
zoxide | mint at zoxide's best match (errors if zoxide isn't installed) |
-f, --filter <text> |
— | skip resolution, open the picker pre-filtered (mutually exclusive with any target or pin) |
/<term> (positional, not a flag) |
— | open the picker pre-filtered by the term — or attach outright when exactly one live session matches |
-e, --exec <cmd> / -- <cmd> |
— | command to run in a freshly minted session (never an attach target) |
Multi-window bursts. Two or more targets (or one glob expanding to several sessions) open a portal to each: this terminal becomes the first surface and the remaining N−1 open in host-terminal windows — N windows for N targets. Pins and bare targets mix freely (x -s api -p ~/Code/new blog), the command rides only the minted surfaces, and a supported terminal is required for the extra windows (Ghostty natively, others via terminals.json). This is the command-line form of the picker's multi-select mode.
Tab completion. Tab after the session-opening function asks Portal for open's completions: x <TAB> offers your live session names, and x /po<TAB> completes the term after the slash against the sessions a search can reach — every live session but the one you are attached to, since a search never lands you back where you already are — leaving the slash in place. A shell has that behaviour only once it has evaluated the current output of portal init — start a new shell, or re-run the eval in your profile; the /term form itself is portal open's own parsing, independent of the shell integration. The deliberate trade beside it: a path argument after the function does not fall through to filename completion, which is the contract portal open has always had. (Switching that fallback off needs a shell that supports it — bash 4+ with bash-completion, zsh, or fish. macOS's stock /bin/bash 3.2 cannot, so filenames still complete there.)
New sessions auto-resolve to the git repository root when applicable.
List running tmux sessions.
xctl list # auto-detect format
xctl list --long # full details
xctl list --short # names only| Flag | Description |
|---|---|
--long |
Full session details (name, status, window count) |
--short |
Session names only, one per line |
Kill a tmux session by name.
xctl kill myprojectA kill is final: the session's saved state, scrollback and resume hooks are removed, and it does not come back after a reboot.
Manage path aliases for quick session access.
xctl alias set work ~/Code/work # create alias
xctl alias rm work # remove alias
xctl alias list # list all aliasesRegister per-pane commands to bring back after a reboot. Under the shipped default a restored pane comes back holding the resume panel, showing the registered command with ⏎ resume, d discard and esc back, and the command runs when you answer it; a registration pinned eager runs its command as the pane is restored, with no panel. hook set must be run from inside a tmux pane; hook rm defaults to the current pane's token but accepts --pane-key to remove the entry under any hook key, taken verbatim (including panes that no longer exist, and the old-format <session>:<window>.<pane> keys the stale-entry sweep retains forever rather than guessing at — hand removal through this flag is the sanctioned route for those). hook rm exits non-zero when it removes nothing — a missing entry, a pane with no hook of its own, or a pane that is already gone — so a scripted caller can tell a real removal from a no-op.
The verb is hook (singular); hooks is kept as a permanent silent alias, so existing xctl hooks … scripts keep working unchanged.
Hooks stay attached to a session even if you rename it, whether from the picker's r modal or an external tmux rename-session. A renamed session still brings its command back after the next reboot — as the panel under the shipped default, and as a fired command under eager.
Three kinds of name are refused, by the picker's r modal and by Portal's own rename alike, because tmux cannot address them afterwards through the exact-match target every per-session operation composes, or reads them as something other than a name: ":" isn't allowed in a session name — tmux reads it as a separator, "$" isn't allowed at the start of a session name — tmux reads it as a session ID, and "-" isn't allowed at the start of a session name — tmux reads it as a command flag. The picker says exactly that and leaves the session under its old name; nothing is renamed, and a hook already registered against the pane is untouched.
xctl hook set --on-resume "npm start" # register a resume hook
xctl hook set --on-resume "npm start" --resume-mode lazy # register it pinned to a resume mode
xctl hook rm --on-resume # remove the current pane's hook
xctl hook rm --on-resume --pane-key 'k3Xp7Q' # remove the entry under any hook key, verbatim (works outside tmux)
xctl hook list # list hooks: key, event, command, the pane's current location (4th column, empty if no live pane carries the token), and the registration's resume mode (5th column: eager, lazy, or empty when it follows the install)--resume-mode takes eager or lazy and nothing else; any other value is refused and nothing is written. A registration carries eager, lazy or nothing, and carrying nothing means it follows the install-wide setting in prefs.json. The flag's zero value is "names no mode" rather than "keep what was there", so a hook set that does not pass it writes a registration carrying no mode whatever its predecessor carried — re-pass the mode alongside the command to keep a pin.
When hooks fire: a resume hook is only ever offered — as the panel, or fired outright
under eager — when Portal recreates a pane from saved state after a reboot, once the
tmux server has started fresh. Nothing happens on an ordinary detach and reattach within
the same server lifetime, because the pane and its process are still alive; bringing the
command back then would launch a second copy of a long-running command such as a dev
server.
A read-only health report across Portal's resurrection machinery — daemon alive, global hooks registered without duplicates, _portal-saver up, state dir sane, sessions.json valid, no stale entries, and the detected host terminal. It starts nothing (a down runtime is reported honestly, not silently started), and exits 0 only when every check passes, non-zero otherwise — a scriptable health gate. The host-terminal line is informational and never affects the exit code.
xctl doctor # health report (subsumes the retired `state status`)
xctl doctor --fix # apply low-stakes repairs, then re-diagnose--fix performs the reversible-by-reconstruction repairs: prune stale hooks, prune stale projects (replacing the retired clean), and sweep old logs. It re-runs the diagnosis afterwards and the exit code reflects the post-repair state. The daemon already runs these prunes automatically on a slow cadence, so doctor usually reads healthy without you doing anything — --fix is the manual trigger. The host-terminal check (folding in the retired spawn --detect) prints the detected terminal and its bundle id so you can copy it into terminals.json. The report also carries informational lines that state rather than judge — the pending-resume line reports how many panes are holding a resume decision, and the resume-mode line reports the install's resume mode (eager or lazy, the shipped default lazy whenever prefs.json sets none) — and, like the host-terminal line, they never affect the exit code.
Print a theme's file to stdout, comments and all. This is the way to start your own theme from a built-in — copy one, edit it, drop it in your themes directory. See docs/theming.md.
xctl theme export nord # print it
mkdir -p ~/.config/portal/themes
xctl theme export nord > ~/.config/portal/themes/mine.themeRemove Portal's tmux-server footprint — kill the save daemon and unregister the global hooks — without touching any files. Saved sessions and all config are left in place; the next x/portal open re-bootstraps the runtime, so it means "deactivate Portal's machinery now," not "destroy my data." Idempotent: a no-op on already-clean state. See Uninstall.
portal uninstallPrint the Portal version.
xctl versionOutput shell integration script for eval. See Shell Integration. This is the one command you call via the portal binary directly.
portal init zsh
portal init bash --cmd pNavigation is arrows only (no vim or page-jump aliases). Press ? on any page for an in-app help modal listing that page's complete keymap.
| Key | Action |
|---|---|
↑ / ↓ |
Move up / down |
Ctrl+↑ / Ctrl+↓ |
Page up / down |
Enter |
Attach to / open the highlighted session |
Space |
Preview scrollback of highlighted session (sessions list only) |
/ |
Filter mode (fuzzy search) |
s |
Switch view: cycle Flat → By Project → By Tag (sessions list only) |
m |
Multi-select mode: enter marks the highlighted session, then toggle any row's mark (sessions list only) |
x |
Toggle between Sessions and Projects |
t |
Open the theme picker |
r |
Rename session |
k |
Kill session |
n |
New session in the current directory |
? |
Show the full keymap for the current page |
q / Esc |
Quit (Esc clears an active filter first) |
Mouse and touch. The picker reads the mouse, so a wheel, a trackpad, or a swipe on a touch terminal moves the highlight through the sessions, projects and theme lists and scrolls the preview. Tapping a session or project row opens it, as Enter would; in multi-select a tap marks the row instead, as m would. Selecting text while the picker is open takes your terminal's mouse-override modifier, such as Shift-drag in Ghostty.
The TUI has three views: session list, project picker, and scrollback preview. It paints its own canvas in the colours of the active theme — press t for the theme picker, or name a theme in prefs.json (see Configuration and docs/theming.md); NO_COLOR gives a colourless render.
A session row ends in up to two status dots, packed to the right: the attached dot when a client is attached to the session, and a warm-coloured pending dot when any pane in it is waiting on its resume panel. Under NO_COLOR they render as A and P in the same cells. The ? help on the sessions list carries the legend.
Space on the highlighted session opens a Quick Look-style preview of that
session's saved scrollback, so you can tell similarly-named sessions apart
without attaching. The preview is read-only: opening and closing it changes
nothing about the session.
| Key | Action |
|---|---|
← / → |
Previous / next window (wraps) |
Tab |
Next pane within the current window (wraps) |
↑ / ↓, Ctrl+↑ / Ctrl+↓ |
Scroll within the loaded buffer |
Enter |
Attach to this pane |
Space / Esc |
Return to the sessions list |
Each pane shows the last ~1000 lines of saved scrollback. The frame shows the session
name, the current Window x/y · Pane x/y, and a footer of key hints, styled in a cyan
"peek mode" so a preview never looks like a live session. A pane with no saved content
yet renders (no saved content).
Press m on the sessions list to enter multi-select mode, which marks the
currently-highlighted session as your first selection. Press m again on any row to mark or
unmark it — you can also sit in the mode with nothing selected (toggle the auto-marked row off,
or enter while the cursor is on a group header). Press Enter
to open every marked session at once — each springs open attached in its own host-terminal
window. The result is N windows for N sessions: the picker reuses its own window for one
of them and spawns the rest as fresh host windows, so there is never a leftover empty picker
window. Esc cancels and clears the selection.
| Key | Action |
|---|---|
m |
Enter mode marking the highlighted session / toggle a row's mark |
↑ / ↓ |
Move between sessions (marks persist) |
Space |
Preview the highlighted session's scrollback |
/ |
Filter (marks persist underneath) |
Enter |
Open every marked session (one marked → a plain attach in place) |
Esc |
Cancel and clear the selection |
Marks are sticky across filtering, paging, regrouping, and the Space-preview round-trip;
a session killed elsewhere while you were in the mode drops out of the selection.
Spawning host windows needs a supported terminal. Ghostty works out of the box; other
terminals are configured via terminals.json. On an unsupported terminal
Portal shows a banner naming the detected terminal and its bundle id (the key to copy into
terminals.json); a remote/mosh client has no local window to name, so its header stays as
normal with no banner. On either, pressing m does not open multi-select — Portal
shows a brief note that it isn't available (over a remote connection, or on this terminal)
rather than letting you mark sessions for a burst that can never run, and m drops out of the
? help there. Opening a single session the normal way (Enter on a highlighted row) still
works — it attaches in the current window and needs no host-window support. Run xctl doctor
to see what Portal detects. If a burst only partially succeeds, Portal leaves the windows that
did open in place and keeps the failed sessions marked, so pressing Enter again retries just
those.
By default the session list is flat and alphabetical. Press s on the sessions
list to cycle the view through three modes:
- Flat: a single alphabetical list.
- By Project: a heading per directory, with each session listed once under its project name. Useful with no setup at all.
- By Tag: a heading per tag, with a session appearing under each tag its directory carries. Untagged sessions collect under a pinned Untagged group.
Portal remembers the last-used mode across launches in prefs.json. Group headers are
dimmed, non-selectable, and show a count, and the cursor only ever lands on sessions.
While the / filter is active the list flattens to matching sessions and the headers
step aside, returning when the filter clears.
Tags live on directories (projects), not individual sessions. Every session opened
in a directory inherits that directory's tags, so there is nothing to tag per session.
Tags are freeform and trimmed, but case-sensitive: Work and work are different
tags, and each is stored exactly as typed. Applying a tag to a second directory adds it
to that group, and removing a tag's last use makes the group disappear.
Managing tags: open the projects picker (press x, then x again to switch from
sessions to projects), highlight a project, and edit it. The edit modal has Name,
Aliases, and Tags fields. Tab (or ↑/↓) moves between fields, and ←/→
moves between chips and the trailing + add slot. To add a tag, land on + add, press
Enter (or +), type the tag, and press Enter to save; press x on a chip to remove
it. Every edit saves immediately, with no separate confirm step, and Esc never
discards saved work: it just backs out the current edit or closes the modal. Only
directories already opened in Portal are taggable, so open a directory once before
tagging it.
Whenever you run a command that needs tmux, Portal checks that the server is running and starts it if it is not. In the same step it re-creates any saved sessions that are not already live, so after a reboot your sessions come back with their structure, layout, zoom, and working directories intact. Scrollback (including ANSI colour) loads as you attach, and each recreated pane carrying a resume hook comes back holding the resume panel, which runs the command when you answer it.
Only a kill is final. Killing a session — k in the picker, xctl kill, tmux kill-session,
or exiting its last program — removes its saved state, scrollback and resume hooks for
good. Detaching, tmux kill-server and shutting down or rebooting the machine all leave
your sessions restorable, with nothing to run first. One gap remains: a pane whose program
was started directly rather than from a shell can be ended by the shutdown signal before
tmux goes, and a session left with no surviving pane is then saved as closed.
This replaces tmux-continuum and tmux-resurrect for session persistence. If you have
either installed, remove it (or set @continuum-restore off) to avoid restoring twice.
Pair restoration with resume hooks to bring pane commands such as dev
servers and editors back after a reboot — on a panel you answer per pane, or fired
outright for a registration pinned eager.
A restored pane whose resume hook resolves lazy — the shipped default — comes back
with its scrollback replayed underneath and a panel over it: Resume session with a
● PAUSED badge, the registered command under ON RESUME, and ⏎ resume /
d discard / esc back. Nothing runs until you answer, so a reboot no longer starts every
registered command at once — a waiting pane holds a small Portal process in its place,
and the command's own process starts only for the work you go back to.
| Key | On the panel | On the discard confirmation |
|---|---|---|
Enter |
Resume: the panel goes, the transcript is revealed, and the command runs in the pane | — |
d |
Open the ▲ Discard resume? confirmation |
— |
y |
— | Discard the resume command |
Esc |
Back to the session picker; the pane keeps waiting | Back to the panel |
- Enter runs what is registered when you press it, not what was registered when the panel was drawn. A hook removed or rewritten while the pane waited is honoured; one that has gone drops the pane to a plain shell.
- A discard is permanent. It removes that pane's resume command from
hooks.json, so the panel never returns — not on this boot, not after the next reboot. The session, the pane and its scrollback are untouched; the pane drops to a plain shell. Only the command the confirmation showed is removed: if the hook was re-registered with a different command while the pane waited, that one is left in place. The removed command is written toportal.log(grep 'op=discard' portal.log), so it can be copied back out. - Esc backs out without answering. The terminal showing the pane leaves tmux for the session picker, and the pane keeps waiting — nothing about its resume changes, and the panel is there the next time you open the session. If several terminals show the pane, the one you pressed it in goes back.
- Every other key is swallowed, including
Ctrl-C,Ctrl-\,Ctrl-ZandCtrl-D. A key counts only when it arrives on its own, so a stray paste can't answer the panel, and input already in flight when the confirmation opens can't confirm it. - Ignoring it is fine. Detaching, closing the window and rebooting are not answers: the panel stays until you press a key that means something, and a reboot draws it again. The panes beside it stay fully live, and while it waits Portal holds that pane's saved scrollback at what it was when the pane was restored.
- It repaints when you arrive. Attaching to a session, or switching to it, redraws each of its waiting panels, so a panel drawn days ago at restore shows the theme — and the light or dark half of a pair — that is in force now.
- A failed answer says so. If an answer can't be carried out —
hooks.jsonunreadable or locked on a discard, say — the panel states the reason on its own row and the pane keeps waiting, so the key can be pressed again. - A pane too small for the card still shows the title, command and key hints, stacked plainly; the keys act at every size.
Waiting panes show as a pending dot on their session's row in the picker, and
xctl doctor counts them. To go back to commands firing as their panes are restored,
set "resume_mode": "eager" in prefs.json (there is no UI for this setting yet), or
pin a single registration with xctl hook set --resume-mode.
Portal resolves its config directory using XDG: $XDG_CONFIG_HOME/portal/ if set, otherwise ~/.config/portal/. Each entry also has an env var override that takes full precedence.
| File / directory | Purpose | Env override |
|---|---|---|
aliases |
Path aliases (key=value, one per line) | PORTAL_ALIASES_FILE |
projects.json |
Remembered project directories | PORTAL_PROJECTS_FILE |
hooks.json |
Per-pane resume hooks (pane → event → registration). An event's value is either the command as a string or an object carrying the command alongside its settings; both shapes are permanently valid, neither deprecates the other, and there is no migration between them — the writer picks by whether there is anything to carry, so an entry that is only a command stays a plain string and a hand-edited file goes on looking as it does today. Portal creates an empty hooks.json.lock beside it on the first change, which it locks to keep concurrent writers from overwriting each other, and never removes. |
PORTAL_HOOKS_FILE |
prefs.json |
UI preferences: last-used session-list grouping mode; the theme setting — theme for a pinned theme, or the theme_light / theme_dark pair; and resume_mode, holding eager or lazy — the install-wide default a resume hook that names no mode of its own inherits |
PORTAL_PREFS_FILE |
terminals.json |
Host-terminal window recipes for multi-select / multi-target x on custom terminals (Ghostty is built in). User-authored, read-only. |
PORTAL_TERMINALS_FILE |
themes/ |
Drop-in theme files (<slug>.theme); see docs/theming.md. Portal never creates it — no directory simply means no drop-ins. |
PORTAL_THEMES_DIR |
state/ |
Saved session structure + scrollback for automatic restoration on reboot. Contains: sessions.json (structure index), scrollback/*.bin (per-pane content), daemon.pid + daemon.version (liveness markers), portal.log (structured, rotating diagnostics; see Logging). See Privacy Considerations. |
PORTAL_STATE_DIR |
Two of those overrides end in _DIR rather than _FILE: themes/ and state/ resolve a directory, not a single file.
Any of the files may be a symlink, such as one into a dotfiles repository. Portal writes through the link to the file it names, so the link survives Portal's own writes.
Projects are auto-populated when you create new sessions, pruned automatically by the daemon, and cleanable on demand with xctl doctor --fix.
Theme. Every colour Portal draws comes from the active theme, painted onto its own canvas so the palette always sits on the surface it was tuned for. Six themes ship — tokyo-night, tokyo-night-day, nord, nord-wave, gitlab-dark-grey, and horizon — and by default Portal picks a light or dark one by asking your terminal what colour its background is, falling back to dark if the terminal doesn't answer. Press t in the picker to choose a theme, or set "theme": "<slug>" in prefs.json to pin one whatever the terminal looks like; your own themes are .theme files dropped into the themes directory. Setting NO_COLOR to any non-empty value disables the canvas and renders on your terminal's native colours — with no colour to theme, t is unavailable there. See docs/theming.md for the colour roles, the file format, the naming rules, and how the light/dark pair is set.
Custom terminals (terminals.json). Portal opens host windows natively on Ghostty. For any other terminal, add a recipe — see docs/custom-terminals.md for the full setup guide.
Portal writes a structured diagnostic log to state/portal.log (under PORTAL_STATE_DIR). It is human-readable text with a subsystem: prefix on every line, so grep "daemon:" portal.log (or restore:, saver:, hydrate:, spawn:, resolve:, …) reconstructs what any subsystem did. portal.log is a symlink to a calendar-daily file (portal.log.<date>), so tail -f portal.log always follows today's log.
- Rotation: a new file each local day; older files are kept read-only. A size-cap safety valve rolls over to
portal.log.<date>.Nif a single day ever grows huge. - Retention: rotated files older than 30 days are deleted automatically (one breadcrumb logged per deletion).
xctl doctor --fixforces a sweep on demand. - Level: defaults to
info(a few lines per meaningful event). SetPORTAL_LOG_LEVEL=debugto capture full reconstruction detail when investigating an issue. - After a reboot:
grep -E 'session dropped|backed off' portal.logshows what shutdown did to your saved state.session dropped session=<name>names each session removed from it (a rename is not a drop);<stage> backed off: tmux stopped answeringmeans a save was in flight as tmux went down and wrote nothing, leaving the previous save in place.
| Env var | Purpose | Default |
|---|---|---|
PORTAL_LOG_LEVEL |
Verbosity: debug / info / warn / error |
info |
PORTAL_LOG_ROTATE_SIZE |
Per-day size cap before overflow (K/M/G suffix, e.g. 500M, 1G) |
500M |
PORTAL_LOG_RETENTION_DAYS |
Days of rotated logs to keep | 30 |
Portal persists pane scrollback to ~/.config/portal/state/ (override via
PORTAL_STATE_DIR) so it can rehydrate sessions after a reboot. Files are written
mode 0600, directories 0700.
- Same local-filesystem trust model as your shell history: anything visible in your terminal can end up in the saved state.
- No encryption at rest. If a pane displays secrets (tokens, credentials, diffs of sensitive files), they will be captured.
portal.logrecords config changes verbatim. It does not contain pane scrollback, but config-mutation breadcrumbs and exec handoffs are logged as-is: axctl hook set --on-resume "<cmd>"command string, a resume command discarded from the resume panel, alias values, and project paths appear in the log. Redact manually if you share it in a bug report.- Mitigations: for sensitive panes, run
tmux set-option -w history-limit 0to prevent scrollback from accumulating, ortmux clear-historyon demand (run before the next save, which lands at most ~30s later). - v1 has no per-session opt-out; the tmux-native workarounds above are the supported path.
Two paths depending on whether you want to keep your saved state:
- Just remove the binary:
brew uninstall portalorrm $(which portal). Portal's tmux hooks check for the binary before they run, so tmux keeps working normally once it is gone. Your saved state is preserved, and reinstalling picks up where it left off. - Explicit teardown: run
portal uninstall(kills the daemon and unregisters Portal's global tmux hooks). It touches no files — saved sessions and all config are left in place, and runningxagain re-bootstraps the runtime. To remove Portal completely, uninstall the binary and delete~/.config/portal/(which holds both state and config) yourself.
MIT








