vde-layout
vde-layout is a CLI that reproduces terminal layouts (tmux or WezTerm) from YAML presets. Define the panes you need once, then bring them back with a single command.
Key Capabilities
- Keep reusable presets for development, monitoring, reviews, and more.
- Build nested horizontal/vertical splits with ratio-based sizing and fixed-cell panes.
- Launch commands in each pane with custom working directories, environment variables, delays, and titles.
- Preview every tmux step in dry-run mode before you apply a preset.
- Target tmux or WezTerm backends with the same preset definitions.
- Switch between configuration files by flag or environment variables.
Installation
npm install -g vde-layout
# or
pnpm add -g vde-layout
Development
pnpm install
pnpm run build
pnpm run format:check
pnpm run typecheck
pnpm run lint
pnpm run test
# run all checks in sequence
pnpm run ci
Quick Start
- Create a YAML file at
~/.config/vde/layout/config.yml(legacy~/.config/vde/layout.ymlis also supported; see "Configuration Search Order"). - Paste a preset definition:
presets: web-dev: name: Web Development description: Editor, server, and logs layout: type: horizontal ratio: ["90c", 2] panes: - name: editor command: nvim focus: true - type: vertical ratio: [2, 1] panes: - name: server command: npm run dev cwd: ~/projects/app env: NODE_ENV: development - name: logs command: tail -f logs/app.log title: Logs delay: 500 monitor: name: Monitor command: htop - Start tmux and run:
vde-layout web-dev
CLI Commands
vde-layout [preset]- Apply the named preset. When omitted, vde-layout uses thedefaultpreset; if none exists it lists available presets and exits.vde-layout list- Show available presets with descriptions.vde-layout --select- Open an interactive preset selector (auto mode; currently usesfzf).vde-layout --select --select-ui fzf- Force the selector backend (autoorfzf).vde-layout --select --select-surface tmux-popup- Render selector in a tmux popup (fzf --tmux).vde-layout --select --select-tmux-popup-opts "80%,70%"- Pass popup sizing/placement tofzf --tmux=<opts>.vde-layout --select --fzf-arg "--cycle" --fzf-arg "--info=inline"- Pass additional argument(s) tofzf(repeatable).vde-layout dev --dry-run- Display the tmux steps without executing them.vde-layout dev --verbose- Print informational logs, including resolved presets and plan details.vde-layout dev --backend wezterm- Use the WezTerm backend (defaults totmuxwhen omitted).vde-layout dev --current-window- Reuse the current tmux window (or active WezTerm tab) after confirming that other panes can be closed.vde-layout dev --new-window- Force creation of a new tmux window or WezTerm tab even when presets or defaults request reuse.vde-layout --config /path/to/config.yml- Load presets from a specific file.vde-layout --help- Show usage.vde-layout --version/vde-layout -v- Print package version.
Note: Applying a preset (without
--dry-run) must be done inside an active tmux session when using the tmux backend. For the WezTerm backend, ensure a WezTerm window is running and focused so the CLI can discover it.Selector UI note:
--selectrequires an interactive terminal andfzfon$PATH.--select-surface tmux-popuprequires running inside tmux (fzf --tmux, tmux 3.3+ recommended).
Terminal Backends
vde-layout resolves backends in the following order: CLI flag (--backend), preset configuration, then defaults to tmux.
- tmux (default) - Requires an active tmux session for non-dry runs.
--current-windowcloses other panes in the selected window after confirmation;--new-windowalways creates a new tmux window. - WezTerm - Requires the
weztermCLI to be available (nightly channel recommended). Start WezTerm beforehand so at least one window exists.--current-windowtargets the active tab and confirms before closing other panes.--new-windowspawns a new tab in the active window when one is available, otherwise creates a fresh window.
Configuration Search Order
When no --config flag is provided, vde-layout checks candidate files in this order for findConfigFile():
- Project scope discovered by walking up from the current directory; for each directory, vde-layout checks
.vde/layout/config.ymlfirst, then.vde/layout.yml. $VDE_CONFIG_PATH/layout.yml(ifVDE_CONFIG_PATHis set).$XDG_CONFIG_HOME/vde/layout/config.yml(or~/.config/vde/layout/config.ymlwhenXDG_CONFIG_HOMEis unset).$XDG_CONFIG_HOME/vde/layout.ymlfallback (or~/.config/vde/layout.yml).
For loadConfig(), vde-layout merges shared scopes first and project scope last:
$VDE_CONFIG_PATH/layout.yml- XDG scope (
.../vde/layout/config.ymlor fallback.../vde/layout.yml; first existing file only) - Project scope (
<project-root>/.vde/layout/config.ymlor fallback<project-root>/.vde/layout.yml, discovered by walking up from the current directory)
Preset Structure
Each preset is an object under the presets key:
presets:
preset-key:
name: "Display Name" # required
description: "Summary" # optional
backend: wezterm # optional; "tmux" (default) or "wezterm"
windowMode: new-window # optional; "new-window" (default) or "current-window"
layout: # optional; omit for single command presets
# see Layout Structure
command: "htop" # optional; used when layout is omitted
hooks: # optional; see Hooks
afterApply: "vde-tmux-sidebar open"
Defaults Structure
Global/project defaults can be defined under defaults:
defaults:
windowMode: new-window
selector:
ui: auto # auto | fzf
surface: auto # auto | inline | tmux-popup
tmuxPopupOpts: "80%,70%" # passed to fzf as --tmux=<value>
fzf:
extraArgs: # additional arguments passed to fzf
- --cycle
- --info=inline
Layout Structure
layout:
type: horizontal | vertical # required
ratio: ["90c", 2, 1] # required; number weight or "<positive-integer>c"
panes: # required
- name: "left" # required for terminal panes
command: "npm run start" # optional
cwd: "~/project" # optional
env: # optional
API_BASE_URL: http://localhost:3000
focus: true # optional; only one pane should be true
delay: 500 # optional; wait (ms) before running command
title: "Server" # optional; tmux pane title
ephemeral: true # optional; close pane after command completes
closeOnError: false # optional; if ephemeral, close on error (default: false)
- type: vertical # nested split
ratio: [1, 1]
panes:
- name: "tests"
- name: "shell"
Template Tokens
You can reference dynamically-assigned pane IDs within pane commands using template tokens. These tokens are resolved after the layout finishes splitting panes but before commands execute:
{{this_pane}}- References the current pane receiving the command{{focus_pane}}- References the pane that will receive focus{{pane_id:<name>}}- References a specific pane by its name{{window_id}}- References the real window ID the layout was applied into (e.g. tmux's@5). Only resolved inhooks.afterApply(see Hooks) — using it in a panecommandraises a template token error rather than resolving, the same as referencing an unknown{{pane_id:<name>}}.
Example:
presets:
cross-pane-demo:
name: Cross Pane Coordination
layout:
type: vertical
ratio: [2, 1]
panes:
- name: editor
command: 'echo "Editor pane ID: {{this_pane}}"'
focus: true
- name: terminal
command: 'echo "I can reference the editor pane: {{pane_id:editor}}"'
Common use cases:
- Send commands to other panes:
tmux send-keys -t {{pane_id:editor}} "npm test" Enter - Display pane information for debugging:
echo "Current: {{this_pane}}, Focus: {{focus_pane}}" - Coordinate tasks across multiple panes within your preset configuration
Hooks
hooks.afterApply runs an arbitrary host command once, after a preset has been applied successfully. It is a general-purpose hook (not specific to any tool); one intended use is idempotently opening a sidebar tool such as vde-tmux-sidebar once the layout has finished building — the sidebar itself is managed by that separate tool, not defined as a layout pane in vde-layout's preset.
presets:
dev:
name: Dev
layout:
type: horizontal
ratio: [3, 1]
panes:
- name: editor
command: nvim
focus: true
- name: repl
command: node
hooks:
afterApply: "vde-tmux-sidebar open"
Runs exactly once, only after
applyPlansucceeds; it never runs during--dry-run(dry-run instead prints the unresolved command as a planned step).Executes as a host shell command (equivalent to
sh -c "<command>", so pipes/args/redirection work) in the directory vde-layout was invoked from (the CLI'scwd), not inside any particular tmux pane.If the command fails, or if a template token inside it cannot be resolved, vde-layout logs a warning and the preset apply is still reported as successful (exit code is unaffected).
The command is killed and treated as a failure (logged as a warning) if it runs for longer than 30 seconds.
Template tokens are supported, but
{{pane_id:<name>}}only resolves against the pane names created by this apply's ownlayout— it cannot address an existing external pane such as a sidebar. This is especially easy to get wrong incurrent-windowmode: the reused current pane is bound to the layout tree's first pane name, so a hook that tries{{pane_id:sidebar}}for a preset pane namedsidebarwould silently resolve to the current pane, not the real sidebar. Because the hook doesn't run "in" any specific pane,{{this_pane}}and{{focus_pane}}both resolve to the pane that ended up focused after the apply.{{window_id}}resolves to the real window ID the layout was applied into (tmux's@5-style ID, or wezterm's window ID). This is the recommended way to hand off to an external tool such as vde-tmux-sidebar that needs to target the applied window — tmux's-tdoes not expand formats, so vde-layout resolves it to a literal before running the hook:hooks: afterApply: "command -v vde-tmux-sidebar >/dev/null 2>&1 && vde-tmux-sidebar layout-applied --window '{{window_id}}' || true"If
{{window_id}}is used but the window ID could not be resolved, the hook is skipped (logged as a warning) rather than run with a blank value.
Ephemeral Panes
Ephemeral panes automatically close after their command completes. This is useful for one-time tasks like builds, tests, or initialization scripts.
panes:
- name: build
command: npm run build
ephemeral: true # Pane closes when command finishes
Error handling:
- By default, ephemeral panes remain open if the command fails, allowing you to inspect errors
- Set
closeOnError: trueto close the pane regardless of success or failure
panes:
- name: quick-test
command: npm test
ephemeral: true
closeOnError: false # Default: stays open on error
- name: build-and-exit
command: npm run build
ephemeral: true
closeOnError: true # Closes even if build fails
Combining with template tokens:
panes:
- name: editor
command: nvim
- name: test-runner
command: 'tmux send-keys -t {{pane_id:editor}} ":!npm test" Enter'
ephemeral: true # Run once and close
Ratio and Fixed Cells
ratiosupportsnumber(weight) and"<positive-integer>c"(fixed cells).- Fixed-cell entries are reserved first, then the remaining cells are distributed by numeric weights.
- Each split must include at least one numeric weight.
ratio.lengthmust matchpanes.length.
Examples:
[1, 1]-> equal split["90c", 2, 1]-> first pane fixed to 90 cells, remaining cells split as 2:1[1, "40c", 1]-> middle pane fixed to 40 cells, sides split equally from the remaining cells
Constraints:
["90c", "40c"]is invalid (no numeric weight)0c,1.5c,"90"are invalid- If runtime pane size is too small to satisfy fixed cells plus minimum remaining panes, execution fails with
SPLIT_SIZE_RESOLUTION_FAILED
Single Command Presets
If you omit layout, the preset runs a single command in one pane (or opens the default shell when command is omitted):
presets:
shell:
name: Default Shell
build:
name: Build Script
command: npm run build
Window Mode Selection
defaults.windowModesets the default behavior for presets that omitwindowMode. Allowed values arenew-window(default) andcurrent-window.- Each preset may override the default by specifying its own
windowMode. - CLI flags (
--current-window/--new-window) take highest precedence and override both presets and defaults. - When
current-windowmode is used during an actual run, vde-layout prompts for confirmation before closing panes other than the pane running the command. Dry-run mode prints the intended closures without prompting. - Sidebar protection: panes with the tmux pane user option
@vde_sidebarset to1(as set by tools like vde-tmux-sidebar) are treated as protected sidebar panes incurrent-windowmode. They are never killed when reusing the window, and the preset's layout is built in the remaining, non-sidebar area of the window instead of the sidebar pane itself.
Runtime Behavior
- Dry-run mode prints every tmux command and preserves the execution order you would see in a real run.
- Applying a preset creates (or reuses) a tmux window, splits panes according to the plan, sets environment variables, changes directories, and runs commands sequentially.
- If an error occurs (for example, a tmux command fails or the configuration is invalid), vde-layout returns a structured error with the failing step and guidance.
Environment Variables
VDE_CONFIG_PATH- Override the base directory for configuration files.XDG_CONFIG_HOME- XDG base directory root; defaults to~/.configwhen unset.VDE_DEBUG=true- Enable debug-level logs (includes stack traces).VDE_VERBOSE=true- Enable info-level logs without full debug output.TMUX- Automatically set by tmux. vde-layout checks this to ensure execution happens inside a session.
Requirements
- Node.js 22 or higher
- tmux 2.0 or higher (required for the tmux backend)
- WezTerm nightly build with
weztermon$PATH(required for the WezTerm backend)
Contributing
Please submit bug reports and feature requests through GitHub Issues.
License
MIT