Docs
Vesta reads your real Ghostty config, so any Ghostty option works — plus a
handful of vesta- keys for the chrome. Drive it from scripts and agents
over a local control socket.
Vesta config keys
Put these in your Ghostty config (or Vesta's own config after Settings ▸ Import).
libghostty ignores the vesta- prefix, so they pass straight through.
| Key | Default | What it does |
|---|---|---|
| vesta-projects | — | Comma-separated paths (~/a, ~/b), each seeded as a dormant workspace row — clickable, costing no shell until you click it. A path that isn't an existing directory is skipped, and a row already seeded from it is never seeded twice. |
| vesta-accent | theme accent | Selection, focus ring, and active-workspace color. |
| vesta-surface | ghostty background | Chrome surface — sidebar, titlebar, gaps. For the terminal background itself, set the ghostty background key (Settings ▸ Terminal bg). Set both to the same value for a seamless window. |
| vesta-sidebar-width | 224 | Sidebar width, in points. |
| vesta-font-family | GeistMono | Sidebar / UI text family. |
| vesta-font-mono | MartianMono | Instrument-label family (uppercase chips). |
| vesta-font-size | 13 | Base UI font size. |
| vesta-divider-width | 8 | Split-divider grab width. |
| vesta-prefix | ctrl+b | tmux-style prefix key. Empty value disables prefix mode. |
| vesta-prefix-bind | — | Rebind a prefix key: <key>:<action>, comma-separated for multiple, e.g. v:split-vertical. Actions: split-vertical, split-horizontal, focus-left/down/up/right, zoom, new-session, next-session, prev-session, rename, detach, kill. |
| vesta-persist | on | Run sessions through the background daemon so shells survive Vesta quitting. Set false for plain in-process shells (no persistence). |
| vesta-lite | off | Lite mode: new windows (and the next launch) open ghostty-like — titlebar + terminal only, no sidebar, footer, or workspace list. Splits still work; shells are plain processes that end when the window closes. Your full workspace setup is untouched and comes back when turned off. ⌥⌘N opens a single lite window from any setup. Also in Settings → Sessions. |
| vesta-persist-scrollback | on | Mirror scrollback to disk (mode 0600, in Application Support) so it survives a daemon restart — or the computer rebooting, where the restored workspace comes back with a dim ── vesta: session restarted — new shell in <dir> ── divider between the old history and the fresh prompt. On by default; terminal output can contain passwords/tokens, so set vesta-persist-scrollback = false to opt out and keep none of it on disk. Read once at daemon start. |
| vesta-sidebar-tails | true | Workspace cards show the last ~4 rendered lines of their focused pane. Content-aware for agent TUIs — for Claude Code it anchors on the last ⏺ block and filters out the input box / status chrome. Also gates whether restored workspaces materialize in the background at launch (see Workspaces). |
| vesta-sidebar-panes | false | Multi-pane workspace cards draw their real split layout — nested splits at their ratios, focused pane highlighted. With it off, a dim ⊞N pane count still shows. |
| vesta-glass-sidebar | false | Translucent sidebar — behind-window blur with the surface color as a tint; the titlebar band over the sidebar matches. Applies on relaunch. |
| vesta-sidebar-opacity | 0.55 | Strength of the sidebar tint in glass mode, 0–1. |
Inherited Ghostty keys
Anything in the Ghostty config reference works in Vesta. Colors, fonts, and themes sync automatically. A few common ones:
| theme | Named theme, e.g. theme = "Atom" (Vesta bundles all 463). |
| font-family | Terminal font, e.g. font-family = "Redaction 50". |
| background / foreground | Base colors, e.g. #161719. |
| cursor-color | Cursor color. |
| window-padding-x / -y | Inner padding around the grid. |
| background-opacity | Terminal translucency, e.g. 0.9 — independent of the sidebar. The titlebar strip over the terminal matches the terminal's own color and opacity. |
Change anything, then Settings ▸ Reload (or vesta reload) — no relaunch.
Keybindings
vesta-lite is on)Drop files onto a pane and their paths insert shell-escaped, space-separated (matching Terminal.app); drop plain text and it inserts as-is. The Vesta menu also has Make Vesta the Default Terminal, which registers it as the Shell-role handler for unix executables — the same mechanism Ghostty and iTerm2 use.
Prefix mode
With vesta-prefix set (default ⌃B), press the prefix then a key:
Workspaces, groups & persistence
One sidebar row is one workspace: a terminal session rooted at a
directory, with splits inside it — and one workspace is also one "tab" as far as the
CLI's tab verb is concerned. There is no project layer above it — a
workspace owns its own directory, and the titlebar + (or
⌘T) creates one immediately at the active workspace's directory, with no
folder picker in the way. Its name defaults to that directory's basename; rename or
recolor it from the right-click menu.
Groups are packaging and nothing else — a collapsible header with a name, a color and a member count. A group has no directory and no behavior of its own, it can't be empty (the last member leaving deletes it), Ungroup keeps every workspace while Remove Group… closes them behind a confirm. Drag to reorder anywhere — at the top level or inside a group — and drop an ungrouped top-level row onto a group header to join it; a row that already belongs to a group moves between groups from its right-click menu instead (Move to group / Remove from group). Neighbours slide out of the way and the order persists across restarts.
Shells run under a small background daemon (vestad). They survive Vesta
quitting or crashing — and survive closing a window — then reattach on next launch with
native scrollback restored. Scrollback also mirrors to disk (0600) by
default, so it survives a daemon restart — or the computer rebooting entirely, which
kills every pty. If the daemon itself ever stops responding, a built-in watchdog
replaces it within about a minute and panes reconnect on their own — no force-quit
needed. After a reboot a workspace still comes back under its own name, in its
sidebar position, with its split layout rebuilt and a fresh shell in the saved
directory: the old history replays above a dim
── vesta: session restarted — new shell in <dir> ── divider, with the
new prompt below it. Set vesta-persist-scrollback = false to opt out and
keep terminal output off disk entirely.
Restore is lazy: at launch only the visible workspace reattaches; the rest stay listed
in the sidebar and attach instantly on first click, so a big saved sidebar opens fast.
With vesta-sidebar-tails on (the default), restored workspaces still
materialize in the background so their card can show a live output tail; turn it off and
they stay fully dormant until clicked. Until a restored workspace is first activated it
won't ring the attention dot — its shell keeps running under the daemon regardless, and
plugin pane-output taps still receive its output.
The sidebar is the single source of truth: every workspace lives there, saved in
windows.json (flat workspaces + groups, array
order = sidebar order). Files from the old Project → Sessions layout migrate on first
launch, keeping their order: a one-session project becomes a workspace with the
project's name and color, a project with several becomes a group holding them, and the
pre-migration file is kept once alongside it in case you downgrade. Closing a window
just closes a view — the workspaces stay alive and reopen with the window.
Closing a workspace (its last pane) kills its shell, so there are no hidden orphans.
Each card shows more than a name: an output tail (vesta-sidebar-tails), a
pane count or split-schematic for multi-pane workspaces
(vesta-sidebar-panes), and heat. A card flips amber with a ✗
and how long ago on an unseen failure, and a soft accent ✓ on an unseen
success — both need shell-integration's OSC 133 marks to know a command finished;
the bell/attention rail is brightest of all. Actions like close and rename reveal on
hover instead of sitting on the card permanently. A single-line footer along the bottom
carries status text with the app version anchored to the right.
The sidebar can go glass — vesta-glass-sidebar makes it translucent with
behind-window blur, using the surface color as a tint (strength via
vesta-sidebar-opacity); the titlebar band over the sidebar matches, and it
applies on relaunch. Terminal translucency is a separate knob — set ghostty's own
background-opacity and the titlebar strip over the terminal follows the
terminal's own color and opacity. Overlay chrome — the command palette, confirm dialogs,
toasts — always renders on native blur ("glass moments"), independent of either opt-in.
Open more than one window with ⌘N; they all share the same sidebar and workspace pool. Each window views its own workspace, so you can have a different one live in each. If two windows point at the same workspace, the focused window shows it live and the other shows a muted frozen snapshot — focus a window and the live view follows.
Agent control CLI
The vesta command drives and observes the running app over a unix socket
(~/Library/Application Support/vesta/control.sock, owner-only) — built so a
script or an AI agent can run commands and read back what happened. Run bare
vesta while the app is open and it opens a new window in the running instance.
# open an isolated worktree workspace, split it, run tests, read the output vesta worktree fix/login vesta split -h vesta send-keys focused "npm test" # Enter is appended → it runs vesta capture --scrollback
| Verb | What it does |
|---|---|
| split [-h] [--cwd DIR] | Split the focused pane. |
| new-pane [--cwd DIR] | New pane next to the focused one. |
| close | Close the focused pane. |
| focus [ID] | Focus pane ID, or cycle. |
| zoom | Toggle zoom on the focused pane. |
| send-keys <ID|focused> <text> [--no-enter] | Type text into a pane and run it (Enter is appended; --no-enter sends keystrokes without submitting). |
| send-keys --all|--session N|--project NAME <text> | Broadcast the same text to every pane of a target set: --all = the active workspace, --session N = one workspace by its flat index (the legacy P.S pair still works), --project NAME = every workspace in group NAME — an ungrouped row answers to its own name. The reply reports how many panes received input. |
| capture [ID] [--scrollback] | Print a pane's text. |
| pane status <paneID> | JSON status for one pane: cwd, title, alive (a process is running under the pty), attention flag, foreground pid, and the owning workspace index (plus its group, when it's in one). |
| list | List the active workspace's panes as JSON. |
| open [PATH] | Open PATH in a new workspace. |
| tab new|next|prev|close | Manage workspaces (one workspace = one tab). |
| worktree <branch> [--base REF] | Open a git-worktree-isolated workspace. |
| browser [url|port] | Open an embedded browser pane. |
| reload | Re-read the config and apply it live. |
| state | Dump the whole sidebar as JSON for an agent: workspaces (the flat rows — name, cwd, paneIDs, group, color), groups (name, color, collapsed, member indices) and windows (which workspace each one is viewing). A projects compat view rides alongside for scripts written against the old two-level dump. |
| sessions [--json] [--project NAME] | Readable list of workspaces, the active one marked ▸ — the human/agent-friendly view of state. --json returns one structured record per workspace (id, flat workspace index, name, project/group, pane count, active/attention, plus cwd when the row reports one); --project filters by group name and implies --json. |
| select <N> | Switch the active window to workspace N (0-based, flat sidebar order). The legacy select <project> <session> pair still resolves, through the top-level rows. |
| rename <name> | Rename the active workspace (blank clears the custom name). |
| ws new [PATH] [--name X] | Open a new workspace. PATH defaults to the directory you ran the command in, and a relative one resolves against it. |
| ws rename|color|close | Act on the active workspace: rename <name>, color <#hex|none>, close. |
| group new [name] | Wrap the active workspace in a new group — named after the row unless you pass a name. |
| group rename|color|ungroup|remove | Act on the active workspace's group: rename <name>, color <#hex|none>, ungroup (keeps the workspaces), remove (closes them). |
| project rename|remove|color | Legacy alias, kept working: acts on the active row's group when it has one, else on the workspace itself. project new is ws new; project dir is gone — each workspace owns its directory, so cd in the shell. |
| kill <id> | Terminate a workspace's shell under the daemon. |
| notify [--desktop] [--title T] <msg> | In-app toast + an entry in the titlebar bell — handy for agents signalling a human. Posts a macOS Notification Center banner when Vesta is backgrounded; --desktop forces one even when focused. |
| run <name> | Run a command registered with vesta.command(name, fn) in Lua. |
| plugins [list|sync|enable|disable] | Bare vesta plugins (same as plugins list) lists installed plugins with their locked version / ref / commit; disabled ones are marked. |
| plugins sync | git pull every plugin and reload. |
| plugins enable|disable <name> | Turn a plugin on/off (persisted) and reload. Also in Settings ▸ Plugins. |
Lua scripting
Vesta embeds Lua 5.4. On launch (and on vesta reload) it runs
~/.config/vesta/init.lua with a global vesta table. A script error
is shown as a toast — it never crashes the app. Everything the
control CLI can do is reachable from Lua, plus UI you can't get from
the CLI: floating panels, pickers, prompts, a statusline, and live config.
-- ~/.config/vesta/init.lua -- toast when a command/agent turn finishes in any session vesta.on("command-finished", function(id) vesta.notify("done: "..id) end) -- ctrl+g → git status in the focused pane vesta.bind("ctrl+g", function() vesta.send("git status\n") end) -- a command runnable with `vesta run tests` or from a keybind vesta.command("tests", function() local p = vesta.active() vesta.notify("running tests in "..p.cwd) vesta.send("npm test\n") end)
The vesta API
Register handlers (these persist across the script):
| API | What it does |
|---|---|
| vesta.command(name, fn) | Register a named command, runnable via vesta run <name>, from a keybind, or from the ⌘⇧P command palette. |
| vesta.bind(chord, fn) | Bind a key chord ("cmd+shift+h", "ctrl+g", …) to a function. Lua binds win over built-ins — avoid "cmd+shift+p" (that's the command palette). |
| vesta.on(event, fn) | Run fn on an event; the handler gets the relevant paneID. |
| vesta.timer(seconds, fn) | Call fn every seconds (repeating). Cleared on reload. |
| vesta.plugin("owner/repo") | Declare a plugin; cloned from GitHub on first launch. See Plugins. |
Read & drive the terminal (see below):
| vesta.active() | Focused pane: {cwd, title, paneID} (or nil). |
| vesta.state() | The whole sidebar: {workspaces = {{index, name, cwd, panes, paneIDs, group?, color?}, …}, groups = {…}, windows = {…}}. A projects compat view is still included — one pseudo-project per top-level row — but a group has no directory of its own, so its path is just its first member's cwd. |
| vesta.capture([scrollback]) | Focused pane's text as a string; pass true to include scrollback. |
| vesta.send(text) | Type into the focused pane (include "\n" to run). |
| vesta.cmd(verb, …) | Run any CLI verb; returns its result table. |
| vesta.split([h]) · vesta.tab(a) · vesta.select(n) | Split (true = horizontal); tab = "new"/"next"/"prev"/"close"; select jumps to a workspace by flat index (the legacy (project, session) pair still works, and the reply carries both). |
| vesta.open(path) · vesta.zoom() · vesta.browser([url]) · vesta.focus([id]) | Open a workspace at a path; zoom the pane; open a browser pane; focus/cycle panes. |
Build UI (see below):
| vesta.notify(msg [, opts]) | Stacking in-app toast + an entry in the titlebar bell (history persists). opts = {title, desktop}: a macOS Notification Center banner fires when Vesta is backgrounded; desktop = true forces one even when focused. (Desktop banners need the bundled app.) |
| vesta.panel(lines, opts) | Create/update a floating panel; returns its id. Lines can be labels, clickable rows, editable fields ({input=true, placeholder=, action=fn}), images ({svg="<svg…>"} or {image="/path"}, optional h=), or any line with a colored prefix= + prefixColor= (e.g. a graph gutter). opts.height scrolls content in a fixed height. Panels are draggable cards (snap to grid + corners), click to focus, – to edge-minimize; position persists per title. |
| vesta.close(id) | Remove a panel by id. |
| vesta.prompt(msg [, default], fn) | Text-input overlay; fn(text) on submit. Optional pre-filled default. |
| vesta.confirm(msg, fn) | Yes/No dialog; fn(true|false). |
| vesta.pick(items, fn [, opts]) | Filterable list; fn(label). Items are strings or {label, desc}. Hugs content by default. |
| vesta.pickmulti(items, fn [, opts]) | Multi-select (Tab marks); fn(table_of_labels). |
| vesta.menu(items [, opts]) | Action list; each {text, desc, action=fn} runs its own action on select. |
picker opts (optional): {width, height, maxrows, maxheight} — default hugs content + scrolls; height forces a fixed-tall panel, maxrows/maxheight set the scroll threshold. | |
| vesta.status(text) | Set the sidebar-footer statusline text. |
| vesta.set(key, value) | Override a config key from Lua (Lua wins). See Config from Lua. |
Reading & driving the terminal
vesta.cmd(verb, ...) runs any control verb and returns its result as a table;
the named helpers (capture, state, split, …) wrap the
common ones. This is how a Lua command inspects output and reacts — the same surface an
external agent gets, in-process.
-- watch a build; toast pass/fail by scanning the pane after each turn vesta.on("command-finished", function() local out = vesta.capture() -- visible text; capture(true) for scrollback if out:find("FAIL") then vesta.notify("❌ tests failed") elseif out:find("PASS") then vesta.notify("✅ green") end end) -- every workspace in the sidebar, and which group (if any) it sits in for _, w in ipairs(vesta.state().workspaces) do print(w.index, w.name, w.cwd, w.group or "(ungrouped)") end -- open a worktree, split, run, read back — via raw cmd() vesta.cmd("worktree", "fix/login") vesta.split(true) vesta.send("npm test\n")
Custom UI: panels, prompts, pickers, statusline
A panel is a floating, titled box pinned to a corner. lines is a
list where each entry is one of: a plain string or {text=, color="#hex"} — a
label; {text=, color=, click=function() … end} — a button; or
{input=true, placeholder=, action=function(text) … end} — an editable field
whose action fires on Enter. Pass an existing id in
opts to update a panel in place (e.g. from a timer) instead of stacking a new one.
| panel opts | — |
|---|---|
| title | Header text. |
| corner | "topright" (default), "topleft", "bottomright", "bottomleft". |
| bg | Background hex, e.g. "#1a1b26". |
| width | Fixed width in points. |
| window | "active" (default) — lives in the focused window and follows focus; "all" — shows in every window, including new ones. |
| id | Pass a returned id to update that panel instead of creating one. |
-- a live clock that updates in place, in whichever window has focus local clock vesta.timer(1, function() clock = vesta.panel({ { text = os.date("%H:%M:%S"), color = "#e0af68" }, }, { title = "clock", corner = "topright", window = "active", id = clock }) end) -- a button menu shown in EVERY window (click runs the function) vesta.panel({ { text = "▶ split", color = "#9ece6a", click = function() vesta.split() end }, { text = "+ new tab", color = "#7aa2f7", click = function() vesta.tab("new") end }, }, { title = "actions", corner = "bottomright", bg = "#16161e", window = "all" }) -- prompt for text, then run it; or pick from a list -- (⌘⇧P is reserved for the built-in command palette; Lua binds win over built-ins, so use a different chord) vesta.bind("cmd+shift+h", function() vesta.prompt("Run:", function(cmd) if cmd ~= "" then vesta.send(cmd.."\n") end end) end) vesta.pick({ "git status", "npm test", "clear" }, function(c) vesta.send(c.."\n") end) -- a command palette: each item runs its own action vesta.menu({ { text = "split", desc = "vertical", action = function() vesta.split() end }, { text = "new tab", action = function() vesta.tab("new") end }, }) vesta.status("⏱ "..os.date("%H:%M")) -- sidebar-footer statusline
Panels pass clicks through to the terminal underneath except on buttons, so a corner
HUD never gets in your way. Non-active-window copies of an "active" panel
simply disappear until that window is focused again.
Events & timers
vesta.on(event, fn) subscribes; vesta.timer(seconds, fn) polls.
Handlers receive the relevant paneID where it applies.
session-openedA workspace was created — the event name predates the rename (arg: paneID).session-closedThe user closed a workspace (arg: paneID).session-exitedA shell exited on its own, vs. being closed (arg: paneID).focus-changedThe active workspace changed (arg: paneID).command-finishedA foreground command/agent turn returned to the shell (arg: paneID).dir-changedThe focused pane's working directory changed (arg: paneID).pane-outputRaw output bytes from any live pane: fn(paneID, chunk). Best-effort/coalesced; persist mode only. Use chunk:find(s, 1, true).config-reloadedinit.lua (re)loaded — fires once after load and on every vesta reload.Plugins are sandboxed: a callback that errors repeatedly auto-disables its plugin
(vesta plugins enable <name> to restore), and an infinite loop in a
callback is aborted rather than freezing the UI. A pane's history also survives a daemon
restart, since vesta-persist-scrollback is on by default.
Config from Lua
vesta.set(key, value) sets a config key from your script. Short chrome names
(accent, surface, font-family,
sidebar-width, font-size, divider-width,
sidebar-opacity, glass-sidebar, sidebar-tails,
sidebar-panes, persist) get the vesta-
prefix; any other key is treated as a raw Ghostty key (e.g.
background or background-opacity) and reaches libghostty.
Daemon-side keys (vesta-persist-scrollback,
vesta-shell-integration) are read by vestad from the config file
at startup and cannot be set from Lua.
Lua wins over the config file and the Settings UI — a key set here shows
as locked in Settings, with a badge reading “overridden by init.lua” (or the plugin's
name, if a plugin set it).
-- a dark theme as a script: chrome + terminal share one background vesta.set("background", "#0a0b0f") -- ghostty terminal bg vesta.set("surface", "#0a0b0f") -- vesta chrome → vesta-surface vesta.set("accent", "#ff3d5d") -- → vesta-accent
Plugins
A plugin is just Lua using the same vesta API. Declare one in
init.lua and Vesta clones it into ~/.config/vesta/plugins/<name>/
on first launch; or drop a folder there yourself — no declaration needed. Each plugin's
init.lua (or plugin/init.lua) runs at startup and registers its
own commands, events, keybinds, and UI. For ready-made examples to copy and learn
from, see vestaterm/vesta-plugins
(git-tree, command-palette, a pomodoro timer, …).
-- ~/.config/vesta/init.lua vesta.plugin("you/vesta-statusbar") -- latest from github.com/you/vesta-statusbar vesta.plugin("you/vesta-fzf", { ref = "v1.2.0", priority = 10 }) -- pinned + loads first -- ~/.config/vesta/plugins/git-hud/init.lua (a complete drop-in plugin) local id vesta.timer(2, function() local a = vesta.active(); if not a then return end id = vesta.panel({ { text = a.cwd, color = "#7dcfff" }, { text = "⟳ fetch", color = "#9ece6a", click = function() vesta.send("git fetch\n") end }, }, { title = "git", corner = "topleft", window = "all", id = id }) end)
| Manage | — |
|---|---|
| vesta plugins | List installed plugins; disabled ones are marked. |
| vesta plugins sync | Update each plugin and reload — pinned plugins fetch + checkout their ref, the rest fast-forward. |
| vesta plugins enable|disable <name> | Toggle a plugin (persisted) and reload. Also in Settings ▸ Plugins. |
Disabling a plugin re-runs config cleanly, so a theme plugin's colors revert the moment you turn it off — no relaunch.
Pinning, manifest & lockfile
Pass a table to vesta.plugin to pin a ref (tag, branch, or commit)
and/or set a load priority (higher loads first; ties break by name). A plugin
can also self-describe with an optional manifest.lua — useful for drop-ins,
since they have no declaration. Declared options win over the manifest.
-- a plugin's manifest.lua return { version = "1.2.0", priority = 10 }
Every load writes ~/.config/vesta/plugins.lock — the resolved commit, ref, and
version of each installed plugin — so an install is reproducible and you can see exactly
what's running. vesta plugins shows it inline:
vesta plugins → vesta-fzf v1.2.0 @v1.2.0 (9283de5) → vesta-statusbar (a1b2c3d) → git-hud -- drop-in, not a git repo
vesta.plugin also accepts a full URL (https://…, git@…,
file://…) or an absolute path, not just owner/repo — handy for
private repos and local plugin development.
Vesta