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.

KeyDefaultWhat it does
vesta-projectsComma-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-accenttheme accentSelection, focus ring, and active-workspace color.
vesta-surfaceghostty backgroundChrome 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-width224Sidebar width, in points.
vesta-font-familyGeistMonoSidebar / UI text family.
vesta-font-monoMartianMonoInstrument-label family (uppercase chips).
vesta-font-size13Base UI font size.
vesta-divider-width8Split-divider grab width.
vesta-prefixctrl+btmux-style prefix key. Empty value disables prefix mode.
vesta-prefix-bindRebind 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-persistonRun sessions through the background daemon so shells survive Vesta quitting. Set false for plain in-process shells (no persistence).
vesta-liteoffLite 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-scrollbackonMirror 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-tailstrueWorkspace 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-panesfalseMulti-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-sidebarfalseTranslucent sidebar — behind-window blur with the surface color as a tint; the titlebar band over the sidebar matches. Applies on relaunch.
vesta-sidebar-opacity0.55Strength of the sidebar tint in glass mode, 01.

Inherited Ghostty keys

Anything in the Ghostty config reference works in Vesta. Colors, fonts, and themes sync automatically. A few common ones:

themeNamed theme, e.g. theme = "Atom" (Vesta bundles all 463).
font-familyTerminal font, e.g. font-family = "Redaction 50".
background / foregroundBase colors, e.g. #161719.
cursor-colorCursor color.
window-padding-x / -yInner padding around the grid.
background-opacityTerminal 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

DSplit vertically
DSplit horizontally
WClose pane → workspace → window
WClose the workspace
TNew workspace, at the active one's directory (same as the titlebar +)
] [Focus next / previous pane
} {Next / previous workspace
1–9Select the Nth workspace — flat sidebar order, grouped rows counted too
BToggle sidebar
Open a browser pane
,Settings
FFull screen
NNew window (shares the same sidebar & workspaces; lite when vesta-lite is on)
NNew lite window — no sidebar, plain shell that dies on close
PCommand palette — search and run any built-in action or plugin command, auto-scaling as you filter

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:

⌃B %Split vertically
⌃B "Split horizontally
⌃B HJKLFocus pane (or arrows)
⌃B ZZoom pane
⌃B CNew workspace
⌃B N PNext / previous workspace
⌃B ,Rename workspace
⌃B DDetach pane (shell keeps running)
⌃B XKill the focused pane's shell

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
VerbWhat it does
split [-h] [--cwd DIR]Split the focused pane.
new-pane [--cwd DIR]New pane next to the focused one.
closeClose the focused pane.
focus [ID]Focus pane ID, or cycle.
zoomToggle 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).
listList the active workspace's panes as JSON.
open [PATH]Open PATH in a new workspace.
tab new|next|prev|closeManage workspaces (one workspace = one tab).
worktree <branch> [--base REF]Open a git-worktree-isolated workspace.
browser [url|port]Open an embedded browser pane.
reloadRe-read the config and apply it live.
stateDump 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|closeAct 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|removeAct on the active workspace's group: rename <name>, color <#hex|none>, ungroup (keeps the workspaces), remove (closes them).
project rename|remove|colorLegacy 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 syncgit 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):

APIWhat 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
titleHeader text.
corner"topright" (default), "topleft", "bottomright", "bottomleft".
bgBackground hex, e.g. "#1a1b26".
widthFixed width in points.
window"active" (default) — lives in the focused window and follows focus; "all" — shows in every window, including new ones.
idPass 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 pluginsList installed plugins; disabled ones are marked.
vesta plugins syncUpdate 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.