Zonvie is a Fast, feature-rich Neovim GUI built with Zig, native on macOS and Windows.
- 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
| # | Step | Status |
|---|---|---|
| 1 | Standard Neovim GUI functionality | β |
| 2 | Multigrid Events compliance and rich window integration | |
| 3 | Basic customization (fonts, colors, blur, variable fonts, etc.) | β |
| 4 | Cross-platform (macOS, Windows, Linux) | |
| 5 | Remote connection (SSH, server mode, devcontainer) | |
| 6 | Fancy features (cursor animation, neon/glow, smooth scroll, etc.) |
- macOS: AppKit + Swift + Metal
- Windows: Win32 + D3D11/DXGI + DirectWrite
Build from source (requires Xcode):
xcodebuild -project macos/zonvie.xcodeproj -scheme zonvie -configuration Release buildBuild from source (requires Zig 0.15.x):
zig build windows -Dtarget=x86_64-windows-gnu -Doptimize=ReleaseFastzonvie [OPTIONS] [--] [NVIM_ARGS...]| Option | Description |
|---|---|
--nofork |
Don'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 |
--extcmdline |
Enable external command line UI |
--extpopup |
Enable external popup menu UI |
--extmessages |
Enable external messages UI |
--exttabline |
Enable external tabline UI |
--extwindows |
Enable 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-rebuild |
Rebuild 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 |
--install |
Create default config file and exit |
-- |
Pass all remaining arguments to nvim |
--help, -h |
Show help message |
# 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 pipeConfiguration file location:
~/.config/zonvie/config.toml- Or
$XDG_CONFIG_HOME/zonvie/config.toml
[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
[popup]
external = true
[messages]
external = true
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
[ime]
disable_on_activate = false
disable_on_modechange = false
option_as_meta = "both" # "both", "none", "only_left", "only_right"
preedit_mode = "overlay" # "overlay" (floating overlay) or "extmark" (inline virt_text)
[input]
swap_colon_semicolon = false # swap the `:` and `;` keys (single keypresses only)
[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",
]| Key | Description |
|---|---|
path |
Path to Neovim executable |
wsl |
Enable WSL mode on Windows (true/false) |
wsl_distro |
WSL distribution name |
ssh |
Enable SSH mode (true/false) |
ssh_host |
SSH host (user@host format) |
ssh_port |
SSH port number |
ssh_identity |
Path to SSH private key |
| Key | Description |
|---|---|
family |
Font family name |
size |
Font size in points |
linespace |
Extra line spacing in pixels |
| Key | Description |
|---|---|
blur |
Enable blur effect (true/false) |
opacity |
Background opacity (0.0-1.0, when blur=true) |
blur_radius |
Blur radius (1-100, when blur=true) |
| Key | Description |
|---|---|
enabled |
Show scrollbar (true/false) |
show_mode |
When to show: "always", "hover", "scroll", or combinations like "hover,scroll" |
opacity |
Scrollbar opacity (0.0-1.0) |
delay |
Delay in seconds before hiding (0.1-10.0, for "scroll" mode) |
| Key | Description |
|---|---|
external |
Use external command line UI (true/false) |
| Key | Description |
|---|---|
external |
Use external popup menu UI (true/false) |
| Key | Description |
|---|---|
external |
Use external messages UI (true/false) |
msg_pos |
Position anchor for message views: { ext-float = "...", mini = "..." }. Values: "display", "window", "grid" |
view |
View for ordinary messages (default "ext-float") |
view_error |
View for errors: emsg, echoerr, lua_error, rpc_error (default "ext-float") |
view_warn |
View for warnings: wmsg (default "ext-float") |
view_history |
View for :messages / :history (default "split") |
view_search |
View 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.
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.
| Key | Description |
|---|---|
event |
Event type: "msg_show", "msg_showmode", "msg_showcmd", "msg_ruler", "msg_history_show" (optional, omit to match all) |
kind |
Array 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. |
level |
Match by severity instead of kind: "info", "warn" or "error" (optional) |
view |
View type (see above) |
timeout |
Auto-hide timeout in seconds (optional, 0 = no auto-hide) |
min_height |
Minimum line count to match (optional) |
max_height |
Maximum line count to match (optional) |
skip |
Do not display this message at all (optional) |
enter |
Whether 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.)
| Key | Description |
|---|---|
external |
Use external tabline UI (true/false) |
style |
Tabline style: "titlebar", "menu", or "sidebar" |
sidebar_position |
Sidebar position: "left" or "right" (for sidebar style) |
sidebar_width |
Sidebar width in pixels (100-500, for sidebar style) |
agent_indicator |
Show AI-agent status icon on terminal tabs (true/false, default true) |
agent_notification |
OS notification when an AI agent finishes (true/false, default true) |
| Key | Description |
|---|---|
external |
Each Neovim window as a separate OS window (true/false) |
| Key | Description |
|---|---|
enabled |
Enable logging (true/false) |
path |
Log file path |
| Key | Description |
|---|---|
glyph_cache_ascii_size |
Cache size for ASCII glyphs (min: 128, default: 512) |
glyph_cache_non_ascii_size |
Cache size for non-ASCII glyphs (min: 64, default: 256) |
hl_cache_size |
Highlight attribute cache size for vertex generation (range: 64-2048, default: 2048) |
shape_cache_size |
Text shaping result cache size (range: 512-65536, default: 4096) |
atlas_size |
Glyph atlas texture size in pixels (range: 1024-4096, default: 2048) |
| Key | Description |
|---|---|
disable_on_activate |
Disable IME when app becomes active (true/false) |
disable_on_modechange |
Disable IME on Vim mode change (true/false) |
option_as_meta |
Map Option key as Meta: "both", "none", "only_left", "only_right" |
preedit_mode |
IME preedit display: "overlay" (floating overlay) or "extmark" (inline virt_text that shifts following text; falls back to overlay outside insert/replace) |
| Key | Description |
|---|---|
swap_colon_semicolon |
Swap the : and ; keys (true/false). Applies to single keypresses only; pasted text and IME commits are unaffected |
| Key | Description |
|---|---|
single_instance |
Windows 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_mode |
How 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_tray |
Windows 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. |
| Key | Description |
|---|---|
enabled |
Enable user-supplied custom GLSL post-process shaders (true/false) |
post_process |
Where the chain runs: "after_bloom" (only implemented mode); "before_bloom" / "replace_bloom" are accepted but warn + fall back to after_bloom |
preserve_alpha |
Keep 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. |
paths |
Array 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.
Zonvie exposes several Neovim-side variables and RPC notifications for runtime customization.
Set automatically on startup. Contains the RPC channel ID for communication with Zonvie.
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.
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 [ime] option_as_meta config key.
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.
MIT License