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 project paths for the sidebar (~/a, ~/b).
vesta-accenttheme accentSelection, focus ring, and active-session 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-persist-scrollbackoffMirror scrollback to disk so it survives a daemon restart. Off by default — terminal output can contain passwords/tokens; enable only if you accept on-disk persistence.

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.

Change anything, then Settings ▸ Reload (or vesta reload) — no relaunch.

Keybindings

DSplit vertically
DSplit horizontally
WClose pane → session → window
WClose the session
TNew session
] [Focus next / previous pane
} {Next / previous session
1–9Select session
BToggle sidebar
Open a browser pane
,Settings
FFull screen
NNew window (shares the same sidebar & sessions)
PCommand palette — search and run any built-in action or plugin command

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 session
⌃B N PNext / previous session
⌃B ,Rename session
⌃B DDetach pane (shell keeps running)
⌃B XKill session

Sessions & persistence

Sessions run under a small background daemon (vestad). Shells survive Vesta quitting or crashing — and survive closing a window — then reattach on next launch with native scrollback restored. With vesta-persist-scrollback enabled, scrollback also mirrors to disk so it survives a daemon restart (off by default).

Restore is lazy: at launch only the visible session reattaches; the rest stay listed in the sidebar and attach instantly on first click, so a big saved workspace opens fast. Until a restored session 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 session lives there. Closing a window just closes a view — the sessions stay alive and reopen with the window. Closing a session (its last pane) kills its shell, so there are no hidden orphans.

Open more than one window with ⌘N; they all share the same sidebar and session pool. Each window views its own session, so you can have a different one live in each. If two windows point at the same session, 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 session, 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 P.S|--project NAME <text>Broadcast the same text to every pane of a target set: --all = the focused session, --session P.S = one session by its select-style indices, --project NAME = every session of a project. 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 project / session.
listList panes / sessions as JSON.
open [PATH]Open PATH in a new session.
tab new|next|prev|closeManage sessions.
worktree <branch> [--base REF]Open a git-worktree-isolated session.
browser [url|port]Open an embedded browser pane.
reloadRe-read the config and apply it live.
stateDump every window → project → session → pane as JSON (names, cwds, paneIDs, active/live). The whole sidebar, for an agent.
sessions [--json] [--project NAME]Readable list of projects → sessions, with the active one marked — the human/agent-friendly view of state. --json returns one structured record per session (id, name, project, cwd, pane count, active/attention); --project filters by project name and implies --json.
select <project> <session>Switch the active window to a session (0-based indices).
rename <name>Rename the active session.
project new|dir|rename|remove|colorManage projects: new [PATH] [--name X] and dir [PATH] take a path or the dir you ran it from; also rename <name>, remove, color <#hex|none>. New sessions open in the project's dir; the sidebar + and the titlebar folder icon open a folder picker.
kill <id>Terminate a session'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]List installed plugins; 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 session: {cwd, title, paneID} (or nil).
vesta.state()Full tree: {projects = {{name, path, sessions = {…}}, …}}.
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(p, s)Split (true = horizontal); tab = "new"/"next"/"prev"/"close"; switch session.
vesta.open(path) · vesta.zoom() · vesta.browser([url]) · vesta.focus([id])Open a session 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)

-- count sessions across the whole sidebar
local n = 0
for _, p in ipairs(vesta.state().projects) do n = n + #p.sessions 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 session/tab was created (arg: paneID).
session-closedThe user closed a session (arg: paneID).
session-exitedA shell exited on its own, vs. being closed (arg: paneID).
focus-changedThe active session 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. With vesta-persist-scrollback enabled, a pane's history also survives a daemon restart (off 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) get the vesta- prefix; any other key is treated as a raw Ghostty key (e.g. background) and reaches libghostty. Lua wins over the config file and the Settings UI — a key set here shows as locked with a “set by init.lua” badge in Settings.

-- 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.