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.
Getting Started · Install · 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.
eval "$(portal init zsh)" # add to ~/.zshrc: defines the x() and xctl() functions
x # launch the interactive picker
x ~/Code/myproject # open a session at a path (creates it, or attaches)
x ~/Code/api -e "make dev" # open a session and run a command
xctl alias set work ~/Code/work # alias a path...
x work # ...then open it by name
xctl list # list running sessions- 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@latestThe 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).
- Modern Vivid TUI: a colourful, keyboard-driven picker that owns its own light or dark canvas (auto-detected, or pinned via
appearance, and honoursNO_COLOR), with an in-app?keymap on every page. - 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. - Reboot-safe sessions: starts the tmux server and restores structure, layout, working dirs, and ANSI scrollback after a reboot, optionally re-running per-pane commands via resume hooks. Replaces tmux-resurrect / tmux-continuum.
- Multi-window spawn: mark several sessions with
mand 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. - Fast open: jump to a project by path, alias, or zoxide (
x work), with git-root resolution and project memory built in.
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.
Interactive session picker or path-based session creation. x maps to portal open.
x # interactive TUI
x ~/Code/myproject # open session at path
x myalias # resolve alias → path → session
x ~/Code/app -e "make dev" # run command in new session
x ~/Code/app -- npm start # alternative command syntax| Flag | Description |
|---|---|
-e, --exec |
Command to execute in the new session |
Path resolution order: aliases → zoxide → TUI with filter.
New sessions auto-resolve to the git repository root when applicable.
Attach to an existing tmux session by name.
xctl attach myprojectOpen one or more sessions, each in its own host-terminal window — the command-line form of the picker's multi-select mode. Portal reuses the calling window for one session and spawns the rest as fresh host windows (net N windows for N sessions). Needs a supported terminal (Ghostty natively, others via terminals.json).
xctl spawn --detect # print the detected terminal + bundle id, open nothing
xctl spawn work api db # open three sessions across three windows| Flag | Description |
|---|---|
--detect |
Dry run: print the detected host terminal (friendly name + bundle id) and exit without opening anything |
--detect names the terminal so you can copy its bundle id into terminals.json. Without it, at least one session name is required. Exit codes: a pre-flight failure (a marked session is gone), a partial spawn failure, or two-or-more sessions on an unsupported terminal exit 1 with a one-line reason on stderr; a usage error (no sessions and no --detect, or an unknown flag) exits 2.
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 myprojectManage 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 that re-execute automatically when a session is attached after a reboot. hooks set must be run from inside a tmux pane; hooks rm defaults to the current pane but accepts --pane-key to remove a hook for any pane (including ones that no longer exist).
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 re-runs its command after the next reboot.
xctl hooks set --on-resume "npm start" # register a resume hook
xctl hooks rm --on-resume # remove the current pane's hook
xctl hooks rm --on-resume --pane-key 'sess:0.1' # remove a specific entry (works outside tmux)
xctl hooks list # list all hooksWhen hooks fire: resume hooks run only when Portal recreates a pane from saved state after a reboot, once the tmux server has started fresh. They do not run on an ordinary detach and reattach within the same server lifetime, because the pane and its process are still alive; re-running the hook then would launch a second copy of a long-running command such as a dev server.
Remove stale projects whose directories no longer exist on disk, and prune hooks for panes that no longer exist.
xctl clean # prune stale projects + hooks (rotated logs preserved)
xctl clean --logs # also delete rotated portal.log.<date> files, keeping today'sRotated logs are kept unless you pass --logs (see Logging).
Inspect or tear down Portal's saved-session state used for reboot restoration.
xctl state status # daemon + state health
xctl state cleanup # remove hooks + stop daemon
xctl state cleanup --purge # also wipe ~/.config/portal/state/xctl state status: print daemon status, last save time, captured counts, state size, and recent warnings. Exits non-zero when the daemon is down, last save is stale, or warnings are present in the last hour.xctl state cleanup [--purge]: kills the daemon and removes Portal's tmux hook entries. With--purge, also removes~/.config/portal/state/.
Print 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 |
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) |
The TUI has three views: session list, project picker, and scrollback preview. It paints its own light/dark canvas (set appearance in prefs.json, or NO_COLOR for a colourless render; see Configuration).
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
(or a remote/mosh client with no local window) Portal shows a banner naming the detected
terminal and its bundle id, and opening two or more marked sessions is a no-op — a single
marked session still attaches in the current window, which needs no host-window support. Run
xctl spawn --detect to see what Portal detects. If a spawn 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 resume hooks run on the recreated panes.
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 re-run pane commands such as dev servers and editors after a reboot.
Portal resolves its config directory using XDG: $XDG_CONFIG_HOME/portal/ if set, otherwise ~/.config/portal/. Each file also has a per-file env var override that takes full precedence.
| File | 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 → command) | PORTAL_HOOKS_FILE |
prefs.json |
UI preferences: last-used session-list grouping mode and the owned-canvas appearance (auto/light/dark) |
PORTAL_PREFS_FILE |
terminals.json |
Host-terminal spawn recipes for multi-select / xctl spawn on custom terminals (Ghostty is built in). User-authored, read-only. |
PORTAL_TERMINALS_FILE |
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 |
Projects are auto-populated when you create new sessions and cleaned with xctl clean.
Appearance. Portal paints its own light or dark canvas so its colours always sit on the surface they were tuned for. By default ("appearance": "auto") it detects your terminal's background and matches it, falling back to dark if the terminal doesn't answer. Set "appearance": "light" or "dark" in prefs.json to pin the canvas and skip detection, which helps when auto-detection misfires (for example under tmux passthrough). Setting NO_COLOR to any non-empty value disables the canvas and renders on your terminal's native colours.
Custom terminals (terminals.json). Portal spawns host windows natively on Ghostty. For any other terminal, add a recipe keyed by the identity Portal shows you (run xctl spawn --detect, or read the unsupported banner — a friendly .app name, a raw bundle id, or a *-glob). Each recipe describes how that terminal opens a window running a command, with {command} as the placeholder Portal fills in:
// ~/.config/portal/terminals.json
{
"dev.warp.Warp-*": {
"commands": { "open": { "argv": ["osascript", "-e", "tell app \"Warp\" to create window with command \"{command}\""] } }
},
"com.example.MyTerm": {
"commands": { "open": { "script": "~/.config/portal/terminals/myterm.sh" } }
}
}A recipe is either an argv array (Portal substitutes {command} into one element) or a script path (Portal runs the file with the command as $1) — exactly one of the two. The file is tolerant: a malformed or invalid entry is skipped with a spawn: log breadcrumb and Portal falls back to the native adapter. {command} already carries its own PATH/environment, so recipes never need env plumbing.
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:, …) 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 clean --logssweeps them on demand. - Level: defaults to
info(a few lines per meaningful event). SetPORTAL_LOG_LEVEL=debugto capture full reconstruction detail when investigating an issue.
| 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 hooks set --on-resume "<cmd>"command string, 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 state cleanup(kills the daemon and removes Portal's tmux hook entries), orportal state cleanup --purgeto also wipe saved state under~/.config/portal/state/. Then uninstall the binary. Non-state config (hooks.json,projects.json,aliases) is preserved either way; remove it manually if you want.
MIT








