Zonvie

August 14, 2026 · View on GitHub

Zonvie

Zonvie is a Fast, feature-rich Neovim GUI built with Zig, native on macOS and Windows.

Features

  • Native Performance: Zig core with Metal (macOS) and D3D11 (Windows) rendering
  • Zero-allocation Hot Paths: Optimized for minimal latency during redraw/flush
  • Full Neovim UI API Compliance: Supports ext_cmdline, ext_popupmenu, ext_messages, ext_tabline
  • Remote Development:
    • SSH connection to remote hosts
    • Devcontainer support for containerized development environments
  • Customizable: TOML configuration file

Roadmap

#StepStatus
1Standard Neovim GUI functionality
2Multigrid Events compliance and rich window integration⚠️
3Basic customization (fonts, colors, blur, variable fonts, etc.)
4Cross-platform (macOS, Windows, Linux)⚠️
5Remote connection (SSH, server mode, devcontainer)⚠️
6Fancy features (cursor animation, neon/glow, smooth scroll, etc.)⚠️

Platforms

  • macOS: AppKit + Swift + Metal
  • Windows: Win32 + D3D11/DXGI + DirectWrite

Installation

macOS

Build from source (requires Xcode):

xcodebuild -project macos/zonvie.xcodeproj -scheme zonvie -configuration Release build

Windows

Build from source (requires Zig 0.15.x):

zig build windows -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseFast

Usage

zonvie [OPTIONS] [--] [NVIM_ARGS...]

Command Line Options

OptionDescription
--noforkDon't fork; stay attached to terminal, keep cwd
--nvim <path>Path to Neovim executable (overrides config)
--log <path>Write application logs to specified file path
--extcmdlineEnable external command line UI
--extpopupEnable external popup menu UI
--extmessagesEnable external messages UI
--exttablineEnable external tabline UI
--extwindowsEnable external windows (each Neovim window as OS window)
--ssh=<user@host[:port]>Connect to remote host via SSH
--ssh-identity=<path>Path to SSH private key file
--devcontainer=<workspace>Run inside a devcontainer
--devcontainer-config=<path>Path to devcontainer.json
--devcontainer-rebuildRebuild devcontainer before starting
--connect-nvim=<addr>Attach to a running Neovim server. Address: POSIX (macOS/Linux) — TCP host:port or Unix socket path; Windows — named pipe path (e.g. \\.\pipe\nvim.31920.0). Mutually exclusive with --ssh / --devcontainer / --wsl.
--remote-ui=<addr>Alias of --connect-nvim, mirrors nvim --remote-ui
--installCreate default config file and exit
--Pass all remaining arguments to nvim
--help, -hShow help message

Examples

# Open a file
zonvie file.txt

# Use custom nvim config
zonvie -- -u ~/.config/nvim/minimal.lua

# Connect to remote host via SSH
zonvie --ssh=user@example.com

# Run in devcontainer
zonvie --devcontainer=/path/to/project

# Attach to a Neovim server already running (e.g. started elsewhere with
# `nvim --headless --listen /tmp/nvim.sock` or `:detach`'d from another UI)
zonvie --connect-nvim=/tmp/nvim.sock          # POSIX: Unix socket
zonvie --connect-nvim=127.0.0.1:6789          # POSIX: TCP
zonvie --connect-nvim=\\.\pipe\nvim.31920.0  # Windows: named pipe

Configuration

Configuration file location:

macOS / Linux — a single path, chosen without looking at the filesystem:

  • $XDG_CONFIG_HOME/zonvie/config.toml whenever XDG_CONFIG_HOME is set and non-empty. ~/.config is then never consulted, even if the XDG-rooted file does not exist.
  • ~/.config/zonvie/config.toml otherwise.

Windows — each candidate is tested for existence, and the first one that exists wins (XDG_CONFIG_HOME is not consulted):

  • %APPDATA%\zonvie\config.toml
  • %USERPROFILE%\.config\zonvie\config.toml

zonvie --install writes a default config, and never overwrites an existing one. On macOS it writes to the same single path the app reads. On Windows it always writes to %APPDATA%, so running it while a config lives at the %USERPROFILE%\.config fallback will shadow that config. Windows also creates the default config automatically on first launch when neither candidate exists.

Example Configuration

[neovim]
path = "nvim"
wsl = false
wsl_distro = "Ubuntu"
ssh = false
ssh_host = "user@example.com"
ssh_port = 22
ssh_identity = "~/.ssh/id_rsa"

[font]
family = "JetBrains Mono"
size = 14
linespace = 2

[window]
blur = true
opacity = 0.85
blur_radius = 20

[scrollbar]
enabled = true
show_mode = "scroll"  # "always", "hover", "scroll", or combinations like "hover,scroll"
opacity = 0.7
delay = 1.0

[cmdline]
external = true
copy_button = true   # "copy contents" button on the external cmdline window

[popup]
external = true

[messages]
external = true
copy_button = true   # "copy contents" button on the ext-float message windows
msg_pos = { ext-float = "window", mini = "grid" }  # display, window, or grid

# Where each class of message goes. These retarget the built-in routes, so
# most setups need nothing else.
view = "ext-float"          # ordinary messages
view_error = "ext-float"    # emsg, echoerr, lua_error, rpc_error
view_warn = "ext-float"     # wmsg
view_history = "split"      # :messages / :history
view_search = "mini"        # search_count

# Optional rules for anything the settings above cannot express. They are
# prepended to the built-in routes, never a replacement: events you do not
# mention keep their defaults.
[[messages.routes]]
event = "msg_show"
min_height = 20             # long output is easier to read in a split
view = "split"

[[messages.routes]]
event = "msg_ruler"
skip = true                 # hide it outright

[tabline]
external = true
style = "titlebar"  # "titlebar", "menu", or "sidebar"
sidebar_position = "left"  # "left" or "right" (for sidebar style)
sidebar_width = 200  # 100-500 (for sidebar style)
agent_indicator = true  # show AI-agent status icon on terminal tabs
agent_notification = true  # OS notification when an AI agent finishes

[windows]
external = false  # Each Neovim window as a separate OS window

[log]
enabled = false
path = "/tmp/zonvie.log"

[performance]
glyph_cache_ascii_size = 512
glyph_cache_non_ascii_size = 256
hl_cache_size = 2048
shape_cache_size = 4096
atlas_size = 2048

[input]
swap_colon_semicolon = false  # swap the `:` and `;` keys (single keypresses only)
option_as_meta = "both"  # "both", "none", "only_left", "only_right"
ime_disable_on_activate = false
ime_disable_on_modechange = false
ime_preedit_mode = "overlay"  # "overlay" (floating overlay) or "inline" (inline virt_text)

[server]
single_instance = false  # route `zonvie <file>` to a running instance (Windows only)
open_mode = "tab"        # "tab" (new tab) or "current" (replace current window)
close_to_tray = false    # close button hides to the notification area instead of quitting (Windows only)

[shaders]
enabled = false
post_process = "after_bloom"  # only "after_bloom" is implemented today
preserve_alpha = false  # true = keep window transparency/blur showing through the shader
# Drop-in compatible with Shadertoy / Ghostty GLSL shaders. Multiple
# entries form a chain: each shader's output feeds the next; the
# final pass writes to the swapchain. Paths MUST be absolute — they
# are opened verbatim, so launches from Finder / Explorer (whose
# CWD is set to the system root) won't find relative entries.
paths = [
    # "/absolute/path/to/your/ghostty-shaders/starfield.glsl",
    # "/absolute/path/to/your/ghostty-shaders/cursor_blaze.glsl",
]

Configuration Options

[neovim]

KeyDescription
pathPath to Neovim executable
wslEnable WSL mode on Windows (true/false)
wsl_distroWSL distribution name
sshEnable SSH mode (true/false)
ssh_hostSSH host (user@host format)
ssh_portSSH port number
ssh_identityPath to SSH private key

[font]

KeyDescription
familyFont family name
sizeFont size in points
linespaceExtra line spacing in pixels

[window]

KeyDescription
blurEnable blur effect (true/false)
opacityBackground opacity (0.0-1.0, when blur=true)
blur_radiusBlur radius (1-100, when blur=true)

[scrollbar]

KeyDescription
enabledShow scrollbar (true/false)
show_modeWhen to show: "always", "hover", "scroll", or combinations like "hover,scroll"
opacityScrollbar opacity (0.0-1.0)
delayDelay in seconds before hiding (0.1-10.0, for "scroll" mode)

[cmdline]

KeyDescription
externalUse external command line UI (true/false)
copy_buttonShow a "copy contents" button on the external cmdline window (true/false, default true)
KeyDescription
externalUse external popup menu UI (true/false)

[messages]

KeyDescription
externalUse external messages UI (true/false)
copy_buttonShow a "copy contents" button on the ext-float message windows (true/false, default true)
msg_posPosition anchor for message views: { ext-float = "...", mini = "..." }. Values: "display", "window", "grid"
viewView for ordinary messages (default "ext-float")
view_errorView for errors: emsg, echoerr, lua_error, rpc_error (default "ext-float")
view_warnView for warnings: wmsg (default "ext-float")
view_historyView for :messages / :history (default "split")
view_searchView for search_count (default "mini")

View types are "mini", "ext-float", "confirm", "split", "none" and "notification". Each view has its own auto-hide default: "mini" and "ext-float" hide after 4 seconds, while "split", "confirm" and "notification" stay until dismissed. A route's timeout overrides that.

[[messages.routes]]

Routes you declare are consulted before the built-in ones and never replace them, so declaring a rule for msg_show leaves :messages and the mode/command indicators on their defaults. The first match wins.

KeyDescription
eventEvent type: "msg_show", "msg_showmode", "msg_showcmd", "msg_ruler", "msg_history_show" (optional, omit to match all)
kindArray of message kinds to match (optional, omit to match all). Kinds: "emsg", "echoerr", "lua_error", "rpc_error", "wmsg", "search_count", "shell_out", etc. Interactive prompt kinds are not matchable — see below.
levelMatch by severity instead of kind: "info", "warn" or "error" (optional)
viewView type (see above)
timeoutAuto-hide timeout in seconds (optional, 0 = no auto-hide)
min_heightMinimum line count to match (optional)
max_heightMaximum line count to match (optional)
skipDo not display this message at all (optional)
enterWhether showing the message moves the cursor into the view (optional). Only meaningful for "split" — the ext-float view is a synthetic grid the Neovim cursor cannot enter. Unset means the channel default: :messages takes the cursor, routed messages do not. Entering applies on every show, not just the first: re-running :messages while its split is open moves the cursor back into it, and a routed message with enter = true takes focus each time it fires — including while you are typing, so prefer it only for messages you explicitly ask for. If a show lands mid-insert, insert mode continues inside the split, and keystrokes may leak into its scratch buffer until you leave (an upstream Neovim quirk; the next message repaints it). Press <Esc> and q to get out.

return_prompt is never routed: Zonvie answers the press-enter prompt for you, because the message it is confirming has already been displayed.

Interactive prompts ("confirm", "confirm_sub", "number_prompt") are not configurable: they always reach the confirm view, with no timeout. Neovim blocks until one is answered, so a route that sent a prompt to another view, skipped it, or let it auto-hide would hang the editor on a question the user cannot see or answer. Such routes are overridden, including a route that targets "confirm" itself but attaches a timeout.

("confirm" and "confirm_sub" never enter routing at all — they are handled as a single live dialog rather than as messages — so the override exists for "number_prompt" and to keep the guarantee independent of that.)

[tabline]

KeyDescription
externalUse external tabline UI (true/false)
styleTabline style: "titlebar", "menu", or "sidebar"
sidebar_positionSidebar position: "left" or "right" (for sidebar style)
sidebar_widthSidebar width in pixels (100-500, for sidebar style)
agent_indicatorShow AI-agent status icon on terminal tabs (true/false, default true)
agent_notificationOS notification when an AI agent finishes (true/false, default true)

[windows]

KeyDescription
externalEach Neovim window as a separate OS window (true/false)

[log]

KeyDescription
enabledEnable logging (true/false)
pathLog file path

[performance]

KeyDescription
glyph_cache_ascii_sizeCache size for ASCII glyphs (min: 128, default: 512)
glyph_cache_non_ascii_sizeCache size for non-ASCII glyphs (min: 64, default: 256)
hl_cache_sizeHighlight attribute cache size for vertex generation (range: 64-2048, default: 2048)
shape_cache_sizeText shaping result cache size (range: 512-65536, default: 4096)
atlas_sizeGlyph atlas texture size in pixels (range: 1024-4096, default: 2048)

[input]

KeyDescription
swap_colon_semicolonSwap the : and ; keys (true/false). Applies to single keypresses only; pasted text and IME commits are unaffected
option_as_metaMap Option key as Meta: "both", "none", "only_left", "only_right"
ime_disable_on_activateDisable IME when app becomes active (true/false)
ime_disable_on_modechangeDisable IME on Vim mode change (true/false)
ime_preedit_modeIME preedit display: "overlay" (floating overlay) or "inline" (inline virt_text that shifts following text; falls back to overlay outside insert/replace)

[server]

KeyDescription
single_instanceWindows only. When true, a second zonvie <file> launch routes the file to the already-running instance (and brings it to the front) instead of opening a new window. Default false. macOS gets single-instance behavior from the OS.
open_modeHow a routed file is shown: "tab" opens a new tab (:tab drop), "current" replaces the current window (:drop). Multiple files always open as tabs. Default "tab".
close_to_trayWindows only. When true, the close button hides the window to the notification area (system tray) instead of quitting; Neovim keeps running, so the instance stays resident (and reusable via single_instance). Left-click the tray icon to restore, right-click for Open/Quit. Default false.

[shaders]

KeyDescription
enabledEnable user-supplied custom GLSL post-process shaders (true/false)
post_processWhere the chain runs: "after_bloom" (only implemented mode); "before_bloom" / "replace_bloom" are accepted but warn + fall back to after_bloom
preserve_alphaKeep the terminal's alpha through the shader so window transparency/blur shows through it (true/false, default false). Default false forces opaque output (matching Ghostty). Best for passthrough/tint shaders (CRT, scanline); additive/emissive shaders may over-brighten in transparent regions.
pathsArray of GLSL file paths. Absolute paths only — entries are opened verbatim, so launches from Finder / Explorer break with relative paths. Multiple entries form a chain: each pass's output feeds the next; the final pass writes to the swapchain. Drop-in compatible with Shadertoy / Ghostty shader source.

Supported uniforms (Shadertoy + Ghostty 1.1+): iResolution, iTime, iTimeDelta, iFrame, iFrameRate, iSampleRate, iDate, iWindowOffset, iWindowSize (drop-in for ext windows), iCurrentCursor, iPreviousCursor, iCurrentCursorColor, iPreviousCursorColor, iTimeCursorChange. iMouse is reserved but currently unimplemented (always zero).

iChannel0 aliases the terminal contents (back buffer) so existing Ghostty / Shadertoy shaders that sample texture(iChannel0, uv) work without modification.

Neovim Integration

Zonvie exposes several Neovim-side variables and RPC notifications for runtime customization.

vim.g.zonvie_channel

Set automatically on startup. Contains the RPC channel ID for communication with Zonvie.

vim.g.zonvie_glow (Neon Glow Effect)

Configure a bloom/glow post-processing effect for specific highlight groups:

vim.g.zonvie_glow = {
  groups = { "Keyword", "String", "Function" },  -- or "all" for every cell
  radius = 6,       -- blur radius in pixels (2-16)
  intensity = 0.8,  -- glow brightness (0.0-1.0)
}

Zonvie reads this variable on startup (with automatic retry for lazy plugin initialization) and applies a Dual Kawase bloom shader to matching highlight groups.

zonvie_option_as_meta (RPC notification)

Dynamically change the Option-as-Meta behavior at runtime, equivalent to Neovim-Qt's macmeta option:

vim.rpcnotify(vim.g.zonvie_channel, "zonvie_option_as_meta", "both")
-- Values: "both", "none", "only_left", "only_right"

This can also be set statically via the [input] option_as_meta config key.

zonvie_ime_off (RPC notification)

Programmatically disable the IME input method:

vim.rpcnotify(vim.g.zonvie_channel, "zonvie_ime_off")

Useful for automatically switching off IME when entering normal mode via autocommands.

License

MIT License