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 project paths for the sidebar (~/a, ~/b). |
| vesta-accent | theme accent | Selection, focus ring, and active-session 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-persist-scrollback | off | Mirror 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:
| 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. |
Change anything, then Settings ▸ Reload (or vesta reload) — no relaunch.
Keybindings
Prefix mode
With vesta-prefix set (default ⌃B), press the prefix then a key:
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
| 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 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. |
| list | List panes / sessions as JSON. |
| open [PATH] | Open PATH in a new session. |
| tab new|next|prev|close | Manage sessions. |
| worktree <branch> [--base REF] | Open a git-worktree-isolated session. |
| browser [url|port] | Open an embedded browser pane. |
| reload | Re-read the config and apply it live. |
| state | Dump 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|color | Manage 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 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 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 | — |
|---|---|
| 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 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 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