| session-id | 65779bc8-c901-4c3f-a4eb-be9eecd35a97 |
|---|
Teach me about widgets or other features in prise beyond prise'n
Skill loaded: arthack:develop
Thought (1 line)
Let me explore the prise codebase to understand what it is and what features it offers.
Agent
Explore prise codebase features(222 lines)
Thought (1 line)
Great exploration. Let me read a few key files to give you a solid tour.
Read
~/src/rockorager--prise/README.md(303 lines)Read
~/src/rockorager--prise/src/lua/prise.lua(286 lines)Read
~/src/rockorager--prise/docs/prise.5.md(441 lines)
Thought (1 line)
Let me dig into the architecture and the Lua tiling UI to get the full picture.
Read
~/src/rockorager--prise/ARCHITECTURE.md(89 lines)Read
~/src/rockorager--prise/src/lua/tiling.lua(120 lines)Read
~/src/rockorager--prise/src/action.zig(80 lines)
Thought (6 lines)
Alright, here's the tour of prise beyond just "being a terminal multiplexer." There's a lot going on under the hood.
The entire UI is built on a composable widget tree, defined in Lua and rendered by Zig. This isn't just "tabs and panes" -- it's a full layout engine. From src/lua/prise.lua:
| Widget | What it does |
|---|---|
Terminal |
Displays a live PTY session |
Text |
Styled text with segment support (multiple colors/styles in one line) |
Row |
Horizontal layout container |
Column |
Vertical layout container |
Stack |
Overlays children on top of each other (z-axis) |
Box |
Adds borders + background styling around a child |
Padding |
Adds spacing/margins |
Positioned |
Absolute positioning with anchors (for floating elements) |
List |
Scrollable list with selection tracking |
TextInput |
Interactive text input field |
Separator |
Visual divider (horizontal or vertical) |
These compose together like a declarative UI framework. Your init.lua returns a view() function that builds a widget tree every frame. Think React, but for terminals.
The whole UI is replaceable. prise.tiling() is just the default -- you could write your own from scratch. The PriseUI interface is:
---@class PriseUI
---@field update fun(event: table) -- Handle input events
---@field view fun(): table -- Return the widget tree to render
---@field get_state? fun(): table -- Serialize state for persistence
---@field set_state? fun(saved) -- Restore state after restart
---@field setup? fun(opts: table?) -- ConfigurationBuilt-in Lua API functions:
prise.spawn()-- create a new PTYprise.exit()/prise.detach()-- lifecycleprise.get_session_name()/rename_session()/list_sessions()/switch_session()prise.set_timeout()-- async timersprise.request_frame()-- request a UI redrawprise.create_text_input()-- create an input widget instanceprise.gwidth()-- Unicode grapheme width calculationprise.get_time()-- clock (HH:MM)prise.get_git_branch()-- git branch for a directory
Super+p opens a fuzzy-search command palette (like VS Code). All built-in actions are searchable, plus custom commands can be registered. It uses the TextInput + List widgets internally with hit regions for mouse support.
A special overlay terminal that hovers above your tiling layout -- toggle with leader+f. Resizable (leader++/leader+-), configurable default size. Uses the Stack and Positioned widgets to layer it on top.
switch_session opens a fuzzy-searchable session picker with inline renaming. Sessions persist to ~/.local/share/prise/sessions/ and survive server restarts. PTY IDs get remapped on restore.
Configurable border system with two modes:
- Box mode -- full borders around each pane (like a window manager)
- Separator mode -- tmux-style lines only between panes
Four styles: single (┌─┐), double (╔═╗), rounded (╭─╮), none. Focused vs unfocused colors.
This is a big deal architecturally. The server (prise serve) runs as a background service (launchd on macOS, systemd on Linux). Clients connect over a Unix socket with msgpack-RPC. Multiple clients can attach to the same session simultaneously. The server manages PTYs, the client handles rendering.
Each PTY has its own pipe pair for signaling dirty state. The main thread uses an 8ms render budget with timer-based frame scheduling -- no busy polling. Supports kqueue (macOS/BSD) and io_uring (Linux).
Each Surface (remote PTY) maintains front/back screen buffers. Updates go to the back buffer, then swap on frame boundaries. Vaxis handles the final diff to minimize terminal output.
Click-to-focus panes, drag split handles to resize, tab bar click navigation, and mouse events forwarded to terminal applications (like vim, htop).
11 configurable colors with semantic meaning: mode indicators (normal/command), 4 background levels (bg1-bg4), 3 text levels (bright/dim/dark), accent, green, yellow. Defaults to Catppuccin Mocha.
The tiling UI itself (tiling.lua at 122KB) is essentially a full application built on top of the widget system -- managing splits as a tree data structure, tabs, focus navigation, zoom, the palette, session picker, and all the keybinds. It's the reference implementation for what you can build with the primitives.
AskUserQuestion (1 line)
Thought (1 line)
Let me read the deeper widget and Lua code to give you a proper walkthrough.
Read
~/src/rockorager--prise/src/widget.zig(150 lines)Read
~/src/rockorager--prise/src/lua/tiling.lua(200 lines)Read
~/src/rockorager--prise/src/lua/types/prise.lua(143 lines)
Now let me look at how the view function actually builds a frame -- the status bar, tab bar, and the command palette:
Grep
function.*view\(\)(1 line)Grep
function.*update\(event\)(1 line)Grep
function build_status_bar(1 line)Read
~/src/rockorager--prise/src/lua/tiling.lua(200 lines)Read
~/src/rockorager--prise/src/lua/tiling.lua(80 lines)Read
~/src/rockorager--prise/src/lua/tiling.lua(100 lines)
Now I've got the full picture. Here's the deep dive on both topics:
Every frame, prise calls your Lua view() function. You return a widget tree -- plain Lua tables with a type field. The Zig engine (widget.zig, 144KB) takes that tree, runs a constraint-based layout pass, and renders it to the terminal.
It's an immediate-mode UI. No retained state in the widget tree itself -- you rebuild it every frame from your own state. The Zig side handles:
BoxConstraints-- min/max width/height propagated down the treeSize-- computed width/height propagated back upHitRegion-- mouse target areas for click-to-focusSplitHandle-- draggable resize handles between panes, with ratio calculation
The view() function in tiling.lua (line 3596) shows the full composition pattern:
-- The main frame is a Column (vertical stack):
-- [tab_bar] -- optional, only if multiple tabs
-- [content] -- the pane tree (terminals in rows/columns)
-- [status_bar] -- optional bottom bar
local main_ui = prise.Column({
cross_axis_align = "stretch",
children = main_children,
})
-- Overlays use Stack (z-axis layering):
-- Layer 0: main_ui (the full tiling layout)
-- Layer 1: floating pane (if visible)
-- Layer 2: modal (palette, rename dialog, session picker)
return prise.Stack({
children = { main_ui, floating, modal },
})The command palette / session picker / rename dialogs all follow the same pattern -- a Positioned widget anchored to top_center, wrapping a Box with a Column of TextInput + List:
prise.Positioned({
anchor = "top_center",
y = 5,
child = prise.Box({
border = "none",
max_width = 60,
style = { bg = THEME.bg1, fg = THEME.fg_bright },
child = prise.Padding({
all = 1,
child = prise.Column({
cross_axis_align = "stretch",
children = {
prise.TextInput({ input = my_input, style = input_style }),
prise.List({
items = filtered_items,
selected = selected_index,
scroll_offset = scroll_offset,
}),
},
}),
}),
}),
})The status bar (line 3442) builds a prise.Text() with styled segments -- Powerline-style with arrow separators between sections. Each segment gets { text = "...", style = { bg = ..., fg = ..., bold = true } }. It shows:
- Mode indicator (NORMAL blue / CMD pink)
- Git branch (with nerd font icon)
- Zoom indicator (yellow)
- Floating resize indicator
- Clock (right-aligned, refreshed via
set_timeoutevery 60s)
Any widget can have a ratio (0.0-1.0) that controls how it shares space with siblings in a Row or Column. When you drag a split handle, the Zig side calls SplitHandle.calculateNewRatio() and sends a split_resize event to Lua, which updates the ratio on the split node.
To write your own UI from scratch, you implement this interface and return it from init.lua:
---@class PriseUI
---@field update fun(event: table) -- Called on every input event
---@field view fun(): table -- Called every frame, returns widget tree
---@field get_state? fun(): table -- Serialize for session persistence
---@field set_state? fun(saved) -- Restore after server restart
---@field setup? fun(opts: table?) -- Optional config stepThat's it. Four functions, only two required.
update(event) receives typed events:
| Event | When | Key data |
|---|---|---|
pty_attach |
New PTY spawned | event.data.pty (the PTY handle) |
pty_exited |
PTY process died | event.data.id |
key_press / key_release |
Keyboard input | Key code, modifiers, text |
paste |
Clipboard paste | event.data.text |
mouse |
Click, drag, scroll | action, button, x, y, target (PTY ID under cursor) |
winsize |
Terminal resized | cols, rows |
focus_in / focus_out |
Terminal focus | (empty data) |
split_resize |
Drag split handle | parent_id, child_index, ratio |
cwd_changed |
PTY working dir changed | (for tab titles, git branch) |
When you get a PTY from pty_attach, it's a userdata object with methods:
pty:id()-- unique numeric ID- Used as the
ptyfield inprise.Terminal({ pty = pty })
New PTYs are created by calling prise.spawn({ cwd = "/some/path" }). The PTY arrives asynchronously via a pty_attach event.
If you implement get_state() and set_state(), prise will save/restore your UI across server restarts. The tiling UI serializes the full split tree, tab layout, titles, and floating pane state. PTY IDs get remapped on restore via a lookup function.
prise.set_timeout(ms, callback) returns a Timer handle. The tiling UI uses this for:
- Clock updates (60s interval)
- Leader key timeout (1s to cancel pending command mode)
- Floating pane resize mode timeout
After the callback fires, call prise.request_frame() to trigger a re-render.
Here's what a dead-simple custom UI would look like in ~/.config/prise/init.lua:
local prise = require("prise")
local M = {}
local pty = nil
function M.update(event)
if event.type == "pty_attach" then
pty = event.data.pty
elseif event.type == "key_press" then
-- Pass all keys to the terminal
if pty then
-- keys get forwarded automatically when terminal has focus
end
end
end
function M.view()
if not pty then
return prise.Text("Loading...")
end
return prise.Terminal({ pty = pty, focus = true })
end
return MThat gives you a single fullscreen terminal with no chrome. From there you can add whatever you want -- tabs, status bars, a totally different layout paradigm. The tiling UI is just one possible skin.
The type definitions at <prefix>/share/prise/lua/ give you full autocomplete in your editor. Point your Lua LS at that path and you get typed completions for every widget constructor, event type, and API function.
AskUserQuestion rejected
Request interrupted by user for tool use
Skill loaded: arthack:open
ExitPlanMode (1 line)
First, let me save the prise feature tour content to ~/code/docs/.
Bash
ls ~/code/docs/ 2>/dev/null || echo "directory doesn't exist"(152 lines)Write
~/code/docs/prise-features-beyond-basics.md(1 line)Bash
gh gist create --web ~/code/docs/prise-features-beyond-basics.md(3 lines)
Saved to ~/code/docs/prise-features-beyond-basics.md and opened as a gist:
https://gist.github.com/possibilities/a1cf23d4fb59eabd2568d6db30dbc607
Permission mode: default
Can you create custom UIs that work along prise's own widgets via lua?
Yes -- that's the core design. Your init.lua returns a PriseUI table with update() and view() functions. The view() function builds a widget tree from prise's primitives (Terminal, Text, Row, Column, Stack, Box, Padding, Positioned, List, TextInput, Separator) every frame. The tiling UI itself is just one such implementation at src/lua/tiling.lua -- 3700 lines of Lua composing those same widgets.
You can:
- Replace the entire UI by returning your own module from
init.lua - Extend the tiling UI by calling
prise.tiling(), then wrapping or modifying itsview()/update()before returning it - Build custom overlays using
Stack+Positioned(how the command palette, session picker, and floating pane work) - Create interactive components with
TextInput+Listfor fuzzy finders, dialogs, etc. - Add custom keybinds that trigger arbitrary Lua functions via
setup({ keybinds = { ... } }) - Use timers (
prise.set_timeout) for animations, polling, periodic updates
The widget tree is just plain Lua tables -- no special framework. You build them, prise's Zig engine lays them out and renders them.
Could you make something like a high resolution image viewer in prise?
Thought (8 lines)
Good question. Let me check what the rendering layer actually supports.
Grep
image|Image|sixel|Sixel|kitty.graph|iterm.inline|graphic(1 line)Grep
image|Image|graphic|Graphic(1 line)Read
~/src/rockorager--prise/src/client.zig(30 lines)Grep
kitty_graphics|kitty.graph(1 line)Grep
image|Image|addImage|drawImage|placement(1 line)Grep
WidgetKind|kind:(1 line)Read
~/src/rockorager--prise/src/widget.zig(60 lines)
Short answer: not yet from Lua alone, but the plumbing is almost there.
Here's the situation:
What exists today:
- The client already detects and enables Kitty graphics protocol support (
client.zig:1306) -- this is the modern terminal protocol for rendering actual pixel images inline - The underlying libvaxis library has image rendering support via Kitty graphics
- Terminals like Ghostty, Kitty, and iTerm2 all support this
What's missing:
- There's no
Imagewidget inWidgetKind(widget.zig:683-695). The current set is: surface, text, text_input, list, box, padding, column, row, stack, positioned, separator - No corresponding
prise.Image()constructor in Lua - No Lua API to load image data and pass it to the renderer
What you could do today (workaround):
Run an image viewer inside a PTY -- tools like chafa, timg, or viu render images using Kitty graphics/Sixel/Unicode blocks. Wrap that in a Terminal widget:
-- Spawn a PTY running an image viewer
prise.spawn({ cmd = "chafa", args = { "photo.png" } })You'd get the image displayed inside a pane, but you're delegating rendering to a subprocess. No direct pixel control from Lua.
What it would take to do it properly:
- Add an
image: ImageWidgetvariant toWidgetKindinwidget.zig - Wire it to vaxis's Kitty graphics image placement during the render pass
- Add a
prise.Image({ path = "...", ... })Lua constructor - Optionally add image data loading (from file or base64) to the Lua API
It's a natural extension of the architecture -- the constraint-based layout, Positioned for placement, Box for framing all already work. The missing piece is just the leaf widget that says "render these pixels here." Given that vaxis and the client already negotiate Kitty graphics capability, the hard protocol work is done.
I think you missed an important part "high resolution". Unless kitty protocol does that?
You're right -- Kitty graphics protocol does render at full pixel resolution, not character-cell resolution. It transmits actual PNG/RGB pixel data to the terminal and the terminal composites it at native pixel density. So on a Retina display you'd get true high-res output, not half-block Unicode art.
That's why it matters that prise already negotiates cap_kitty_graphics -- the hard part (capability detection, protocol support in the terminal) is handled. An Image widget would place real pixels into a region measured in cells but rendered at the terminal's actual pixel resolution.
So yes, a proper high-res image viewer in prise is architecturally feasible. The layout system gives you cell-level positioning, Kitty graphics gives you pixel-level rendering within those cells, and Retina terminals give you the density. You'd get sharp images, not upscaled block art.
Oh, so I thought it was all botchy and shit, no?
Nah, Kitty graphics protocol is legit. It's not the old-school Sixel stuff or Unicode half-blocks that look like pixel art from 1993.
How it actually works:
- Your app sends raw pixel data (PNG or RGB24/RGBA32) to the terminal via escape sequences
- The terminal stores the image in GPU memory
- You tell it "place this image at cell (x, y), spanning W x H cells"
- The terminal renders the pixels at its native resolution within that cell region
So on a Retina display where each character cell might be 14x28 physical pixels, you're getting all of those pixels. It's the same rendering pipeline the terminal uses for its own UI -- GPU-composited, full resolution, proper alpha blending.
Ghostty (which prise is built on via libghostty) has first-class Kitty graphics support. So does Kitty itself, obviously, and WezTerm. It's the real deal -- people run actual image viewers, plot libraries (matplotlib can output to Kitty graphics), even video players through it.
The botchy reputation comes from the older approaches -- Sixel (limited palette, chunky), Unicode blocks (fake pixels from characters), iTerm2's inline image protocol (simpler but less capable). Kitty graphics is the one that actually solved it properly.
Can we try it here in ghostty?
Thought (3 lines)
Bash
which chafa timg viu kitt...v/null; echo "---"; which convert magick 2>/dev/null(7 lines)
You've got viu. Let's grab an image and display it.
Bash
viu --help 2>&1 | head -20(20 lines)Bash
ls /Users/mike/src/rockorager--prise/assets/(2 lines)Bash
viu -w 60 /Users/mike/src/rockorager--prise/assets/prise.png(13 lines)
Hmm, that fell back to Unicode half-blocks (the ▄ characters with colored foreground/background). That's the botchy mode. Let me check if viu is actually using Kitty graphics or falling back.
Bash
viu -b /Users/mike/src/ro...-help 2>&1 | grep -i "kitty\|protocol\|sixel\|iterm"(6 lines)
Yeah, that's the botchy half-block mode. viu isn't using Kitty graphics here -- probably because it's running inside Claude Code's subprocess capture, not a real TTY. Let me try a different approach.
Bash
tmuxctl run-command prise...5; echo "---"; tmuxctl list-sessions 2>&1 | head -10(5 lines)Bash
tmuxctl --help 2>&1 | head -30(16 lines)Bash
tmuxctl create-session im...c/rockorager--prise/assets/prise.png 2>&1 | head -3"(5 lines)
Still half-blocks. The problem is viu falls back to blocks when it doesn't detect Kitty graphics support (tmux doesn't pass it through). Let me try the actual Kitty graphics escape sequence directly in Ghostty.
Bash
brew list | grep -i "chaf...k\|timg" 2>/dev/null; echo "---"; viu --version 2>&1(3 lines)Bash
brew install chafa 2>&1 | tail -3(3 lines)Bash
chafa --version 2>&1 | head -3(3 lines)Bash
chafa --format kitty /Use...ise/assets/prise.png --size 60x30 2>&1 | head -c 200(3 lines)
OK so chafa can produce Kitty graphics, but we're hitting two problems:
- Claude Code captures stdout -- the escape sequences get intercepted, not rendered by Ghostty
- tmux doesn't passthrough Kitty graphics (it strips the protocol)
The only way to actually see it is to run it in a context where stdout goes straight to Ghostty. Let me open a direct Ghostty window.
Bash
cat > /tmp/show-image.sh ...tmp/show-image.sh open -a Ghostty /tmp/show-image.sh(1 line)
A Ghostty window should have popped up with the prise logo rendered at full pixel resolution via Kitty graphics. That's the real deal -- actual pixels, not character blocks.
The reason we couldn't see it here is that both Claude Code's output capture and tmux strip the Kitty graphics escape sequences. The protocol only works when escape sequences go straight from the app to the terminal emulator. That's exactly why a native prise Image widget would be powerful -- it would render directly via the vaxis layer, which talks straight to Ghostty.
Did the image show up in the new window?
Holy shit that looked good
Right?? That's Kitty graphics doing its thing. Full pixel resolution, GPU-composited, native to the display. No Unicode trickery, no half-blocks, no squinting.
And that's with chafa just blasting raw pixel data through escape sequences. Imagine that integrated as a first-class prise widget -- positioned, sized, and layered within the constraint layout system alongside terminals, text, and borders. You could build a file manager with image previews, a dashboard with charts, a presentation tool -- all at Retina resolution inside a terminal multiplexer.
The pieces that already exist in prise:
- Kitty graphics capability detection (
cap_kitty_graphics) - vaxis underneath (which has image support)
Positionedfor placement,Boxfor framing,Stackfor layering- The constraint-based layout engine to size it properly
What's missing is just the Image widget type bridging Lua to vaxis's image API. Not a huge lift given the architecture.
Permission mode: plan
Can prise be detached and run in background like tmux when the UI is closed/quit?
Yes. That's the whole point of the client-server architecture.
The server (prise serve) runs as a persistent background service -- launchd on macOS, systemd on Linux. It owns all the PTY sessions. The client is just a rendering frontend that connects over a Unix socket.
When you press leader+d (detach) or leader+q (quit), the client disconnects but the server keeps running with all your shells alive. You reattach with prise or prise session attach <name>.
From the docs and code:
- Server socket:
/tmp/prise-{uid}.sock - Session persistence:
~/.local/share/prise/sessions/ - Multiple clients can attach to the same session simultaneously
- Sessions survive client crashes, terminal closes, SSH disconnects -- anything that kills the client process
It's the same model as tmux, just with msgpack-RPC instead of tmux's custom protocol.
Also can I run tmux inside prise?
Yeah, nothing stops you. A prise pane is just a PTY running a shell -- you can run tmux inside it the same way you'd run tmux inside any terminal. tmux doesn't care what's above it, it just needs a PTY.
The real question is why would you. You'd have two layers of multiplexing, two leader keys to juggle, two sets of keybinds. It works, but it's like running a window manager inside a window manager.
The one legit use case: SSH into a remote machine where tmux is the only option for persistent sessions. Prise handles the local side, tmux handles the remote side. That's a totally normal setup.
Could I make a highly customize-via-lua prise and distribute it as an app? Or would it be better create a plugin?
Thought (17 lines)
Interesting question. There are a few paths here depending on what you mean by "distribute."
What prise supports today:
The UI is just Lua files. Your ~/.config/prise/init.lua can require other modules. So a "distribution" could literally be a Lua package -- a folder of .lua files that someone drops in and requires:
-- Their init.lua
return require("my-cool-ui").tiling()That's how the built-in tiling UI already works -- prise.tiling() just loads prise_tiling_ui (which is tiling.lua). Your custom UI would be the same pattern.
No plugin system exists yet. There's no runtime plugin loading, no registry, no hooks API beyond the PriseUI interface. It's all-or-nothing: you own the entire update() + view() lifecycle, or you use the default.
Three realistic distribution models:
-
Lua UI package -- share your Lua files, users require them from init.lua. Lightest touch, works today. Downside: no way to compose multiple plugins together since only one UI module owns the render loop.
-
Fork with custom default -- fork prise, swap in your tiling.lua as the default, distribute the binary. You get full control including Zig-side changes (like adding an Image widget). Downside: you're maintaining a fork.
-
Custom client -- prise's README lists extensibility via "third-party client" as a goal. The server speaks msgpack-RPC over a Unix socket. You could write a completely separate client in any language that connects to the same server. Wild but architecturally supported.
For the "app" feeling, option 2 is the most straightforward -- it's what you'd do if you wanted to ship something like "prise but it's a tiling image browser" or "prise but it's a dashboard." Option 1 is better if you're just sharing a theme/layout/workflow that works within vanilla prise.
What kind of thing are you thinking of building?
What if people installed my tiling.lua and it gave a way to toggle between the default tiling and the plugged in tiling, effectively allowed to swap between two different experiences?
Or we could have a way that any given session would use one or the other tiling that we can decide at start time.
What are the prospects of building something like this in prise?
Thought (25 lines)
Both are doable in pure Lua today, no prise changes needed. The wrapper pattern is straightforward.
Runtime toggle -- a meta-UI that delegates to one of two implementations:
local prise = require("prise")
local M = {}
local uis = {
require("prise").tiling(), -- built-in
require("my_custom_ui"), -- yours
}
local active = 1
function M.update(event)
-- Toggle on some keybind (e.g., Super+F12)
if event.type == "key_press" and is_toggle_key(event.data) then
active = (active == 1) and 2 or 1
prise.request_frame()
return
end
uis[active].update(event)
end
function M.view()
return uis[active].view()
end
function M.setup(opts)
for _, ui in ipairs(uis) do
if ui.setup then ui.setup(opts) end
end
end
return MThe catch is state. When you toggle, the inactive UI doesn't know about PTYs that were spawned while it wasn't listening. You've got two options:
-
Both UIs receive all events, only one renders -- both maintain their own state independently. Toggle just switches which one's
view()gets called. Simple, but you get two separate layouts that diverge over time. Could actually be a feature -- "workspace A" vs "workspace B" with the same sessions underneath. -
Shared state, different renderers -- both UIs share the same pane tree / tab structure but render it differently. This is cleaner but means your custom UI needs to understand the tiling UI's state format (or you define a common one).
Per-session UI selection is trickier. Sessions are managed inside the UI module -- the server just has PTYs. The tiling UI's session switching (prise.switch_session()) is a Lua-level concept. So your wrapper would need to:
- Track which session maps to which UI
- On
switch_session, change the active renderer - Route events to the right UI based on current session
Feasible but more wiring.
The honest assessment: The runtime toggle with independent state (option 1) is the easiest win and probably the most useful. You get two "modes" you can flip between with a keybind, each with their own layout. The per-session approach needs more thought about how session state flows between the wrapper and the inner UIs.
None of this requires changes to prise itself -- it's all Lua composition on top of the existing PriseUI interface.
In theory can our tiling incorporate widgets that are native zig components like a vnc viewer?
Thought (18 lines)
Yeah, absolutely. This is where it gets wild.
The WidgetKind enum in Zig is the extension point:
pub const WidgetKind = union(enum) {
surface: SurfaceWidget, // <-- Terminal (PTY output via cells)
text: Text,
text_input: TextInputWidget,
list: List,
box: Box,
padding: Padding,
column: Column,
row: Row,
stack: Stack,
positioned: Positioned,
separator: Separator,
// vnc: VncWidget, // <-- you'd add this
// image: ImageWidget, // <-- and this
};A VNC widget would be a new variant that:
- Receives framebuffer data from a VNC server (libvncclient or a Zig implementation)
- Renders pixels to its region via Kitty graphics protocol (already negotiated by the client)
- Forwards mouse events -- prise already captures pixel-accurate mouse coords (
cap_sgr_pixels) - Forwards keyboard events -- already captured and routed through the widget tree's hit regions
Architecturally it's the same shape as the SurfaceWidget (which displays a PTY), just with a different data source and rendering path:
| Terminal widget | VNC widget | |
|---|---|---|
| Input source | PTY fd (shell output) | VNC socket (framebuffer updates) |
| Rendering | Cell-based via vaxis Screen | Pixel-based via Kitty graphics |
| Keyboard | Write bytes to PTY | Send key events to VNC server |
| Mouse | Forward to PTY (SGR mouse) | Forward to VNC server |
| Threading | PTY thread reads fd | VNC thread reads socket |
The threading model even fits -- prise already runs one thread per PTY with a pipe to signal dirty state to the main event loop. A VNC connection would follow the exact same pattern: background thread receiving framebuffer updates, signaling via pipe, main thread rendering on the next frame.
The Lua side would look like:
prise.Vnc({
host = "192.168.1.50",
port = 5900,
-- gets sized by the layout engine,
-- rendered via Kitty graphics,
-- mouse/keyboard forwarded automatically
})And then you could tile it alongside terminals:
prise.Row({
prise.Terminal({ pty = shell, ratio = 0.5 }),
prise.Vnc({ host = "...", ratio = 0.5 }),
})The pieces that make this realistic and not just theoretical:
- Kitty graphics for pixel rendering -- already detected
- Pixel-accurate mouse -- already detected (
cap_sgr_pixels) - Per-connection background threads with pipe signaling -- already the pattern
- Constraint-based layout giving the widget its dimensions -- already works
- Zig can link C libraries (libvncclient) trivially
The hard engineering is the VNC client itself and efficient incremental framebuffer-to-Kitty-graphics conversion (you'd want dirty rectangles, not full frames every time). But prise's architecture doesn't block any of it -- it's actually set up remarkably well for this kind of thing.
Is there any zig vnc project out there?
Bash
searchctl web-search "zig language VNC client library implementation"(44 lines)Bash
searchctl web-search "github zig vnc viewer rfb protocol"(76 lines)
Yep -- ikskuh/zvnc. Pure Zig VNC server & client library. No C interop needed.
That's the one you'd want. It implements the RFB protocol natively in Zig, so it would integrate cleanly as a build.zig.zon dependency alongside ghostty, vaxis, and ziglua. Same build system, same language, same memory model.
The pieces for a VNC widget in prise would be:
- zvnc -- RFB protocol, framebuffer reception
- vaxis Kitty graphics -- pixel rendering to terminal
- prise widget system -- layout, mouse/keyboard routing
All Zig, all the way down. No shelling out to external viewers, no C glue. Pretty clean stack.
Could I run this all inside a browser?
Request interrupted by user
Not vnc, just prise
Thought (16 lines)
Yeah, the straightforward path: run prise inside a browser-based terminal emulator. Something like ttyd or gotty -- they serve a web terminal (xterm.js) over WebSocket, connected to a PTY on the server side. Prise would run in that PTY and just see a normal terminal.
The catch is prise's "modern terminal" requirement. It checks for:
- 24-bit color (RGB)
- Kitty keyboard protocol
- Kitty graphics (for images)
- SGR pixel mouse
- Unicode width
xterm.js supports 24-bit color and basic mouse, but not Kitty keyboard protocol or Kitty graphics. Prise would likely refuse to start, or run in a degraded mode if it falls back gracefully.
Three realistic paths:
-
ttyd + xterm.js -- works today if prise degrades gracefully on missing capabilities. No Kitty graphics (so no high-res images), possibly broken keybinds. Quick to try though.
-
Ghostty's terminal library as WASM -- Ghostty/libghostty is what prise already depends on. If someone compiled vaxis + the rendering layer to WebAssembly targeting a Canvas element, you'd get full capability support. Massive project though.
-
Web client speaking msgpack-RPC -- skip terminal emulation entirely. Write a browser-native prise client in JS/TS that connects to the prise server's Unix socket (via WebSocket proxy), receives screen updates, and renders to Canvas/DOM. You'd get the same widget tree but rendered with web tech instead of terminal escape sequences. This is the "third-party client" path from prise's README goals.
Option 3 is the most interesting -- prise's server already speaks a clean protocol. A web frontend would just be another client, like how VS Code Remote works.
AskUserQuestion ERROR (20 lines)
AskUserQuestion ERROR (20 lines)
ToolSearch
select:AskUserQuestion
Still exploring or ready to build?
Tmux I can ssh into and connect to a tmux server already runnign or start one. What about prise?
Same deal. Prise works exactly like tmux here.
SSH in, then:
prise # attach to default session (or create one)
prise session attach <name> # attach to a specific session
prise session list # see what's runningThe server is already running as a background service on the machine (launchd/systemd), so your sessions are alive and waiting. Multiple clients -- local, SSH, whatever -- can attach to the same session simultaneously.
The only requirement is that your SSH terminal supports the modern features prise needs (24-bit color, Kitty keyboard). Ghostty, Kitty, WezTerm all do. Most basic SSH clients (like macOS Terminal.app) might not.
Is there any terminal on android that can use it?
Bash
searchctl quick-search "A...yboard protocol 24-bit color modern terminal Termux"(74 lines)
Short answer: not yet. No Android terminal emulator supports the Kitty keyboard protocol, which prise requires.
Termux has 24-bit color but no Kitty keyboard protocol. Prise would refuse to start (or run broken with garbled keybinds).
Your options on Android today:
-
SSH from Termux into a machine running prise -- but you'd still need the local terminal to support Kitty keyboard, which Termux doesn't. Same problem.
-
Remote desktop -- use a VNC/RDP client on Android to connect to a machine running Ghostty/Kitty with prise. Works but clunky.
-
Wait -- Termux is open source and active. Kitty keyboard protocol adoption is spreading. It could land eventually.
This is actually one of the tradeoffs of prise's "modern terminals only" stance. It gets to use the good protocols, but it limits where it can run. tmux works everywhere because it targets the lowest common denominator.
Just getting back into being curious about prise. We should pull the repo and tell me if anything has changed since you made this: https://gist.github.com/possibilities/a1cf23d4fb59eabd2568d6db30dbc607
The repo is at @~/src/rockorager--prise/
Thought (1 line)
Bash
git pull 2>&1(1 line)Bash
git log --oneline -30(30 lines)Bash
gh gist view a1cf23d4fb59eabd2568d6db30dbc607 --raw 2>&1 | head -5(5 lines)Bash
gh api gists/a1cf23d4fb59eabd2568d6db30dbc607 --jq '.created_at'(1 line)Bash
git log --oneline --after...; echo "---"; git log --oneline --after="2026-03-02"(3 lines)Bash
git log --oneline --after="2026-03-01"(1 line)Bash
git log --format="%H %ai %s" | head -30(30 lines)
Repo is up to date -- only 1 commit since the gist was created on March 2nd:
aa2fcb7(Mar 5) -- fix copy-selection sentinel free mismatch
That's a bugfix for text selection, not a feature change. Everything we covered in the gist is still current. The widget system, Lua API, all the features -- nothing has changed structurally.
The project's been quiet for about a month. Last big burst of activity was early January (floating panes, session persistence, mouse selection improvements).
It must be perfect :)
Ha -- either that or the maintainer is off somewhere vibing with prise already running exactly how they want it. Hard to tell the difference.
How big of a project is it? Is it a bohemouth or just a thin layer of lua love over ghostty?
Bash
find src -name '*.zig' | ...-" && find src -name '*.lua' | xargs wc -l | tail -1(3 lines)Bash
find src -name '*.zig' | xargs wc -l | sort -rn | head -15(15 lines)Bash
find src -name '*.lua' | xargs wc -l | sort -rn | head -10(10 lines)
It's surprisingly small. ~29K lines total:
| Layer | Lines | What it does |
|---|---|---|
| Zig | 23,800 | Server, client, widget engine, I/O, msgpack, PTY |
| Lua | 5,000 | Tiling UI, API surface, type defs, tests |
The big files:
widget.zig(4,533) -- the layout engine. This is the beefiest piece and it's original workserver.zig(3,773) -- session/PTY management, msgpack-RPCclient.zig(3,394) -- rendering, input handling, server communicationtiling.lua(3,810) -- the entire default UI
So it's neither. It's not a behemoth, and it's not just a thin wrapper. It's a real but compact multiplexer -- the Zig side is substantial original code (widget layout, client-server protocol, event loop, msgpack implementation). ghostty provides VT parsing and vaxis provides terminal rendering, but prise builds everything else: the multiplexing, the widget system, the Lua bridge, the session persistence, the I/O layer.
For comparison, tmux is ~70K lines of C. Prise does less (younger project, fewer features) but the architecture is arguably more ambitious (widget-based UI, full Lua scripting, client-server with msgpack-RPC).
29K lines is "one person can hold it all in their head" territory. Tight.
Which layer of ghostty does it integrate with?
Bash
grep -r "ghostty" src/ --include="*.zig" -l(5 lines)Grep
@import.*ghostty|ghostty\.(1 line)Read
~/src/rockorager--prise/src/vt_handler.zig(40 lines)Grep
ghostty_vt\.|ghostty\.(1 line)Grep
ghostty(1 line)
It uses ghostty-vt -- the VT terminal state machine / escape sequence parser. Not the Ghostty terminal emulator application, not the GUI, not the renderer.
Specifically, prise uses ghostty as a headless terminal emulation library:
| What prise uses | What it does |
|---|---|
ghostty_vt.Terminal |
The terminal state object -- screen buffer, cursor, modes, scrollback |
ghostty_vt.Screen |
Screen/page storage |
ghostty_vt.StreamAction |
Parsed VT escape sequence actions |
ghostty_vt.input.encodeKey |
Encode keystrokes into terminal escape sequences |
ghostty_vt.input.KeyEvent |
Key event types |
ghostty_vt.modes |
Terminal mode flags (mouse tracking, bracketed paste, etc.) |
ghostty_vt.RenderState |
Cursor position, selection, dirty tracking |
ghostty_vt.page.Cell |
Individual character cells with attributes |
ghostty_vt.Style |
Cell styling (colors, bold, italic, etc.) |
ghostty_vt.osc.color / ghostty_vt.kitty.color |
Color query/response handling |
So the relationship is: ghostty parses VT sequences and maintains terminal state, prise does everything else. The server feeds PTY output into ghostty_vt.Terminal, which updates its internal screen buffer. Then prise reads that buffer to send screen updates to clients via msgpack-RPC. On the input side, prise uses ghostty_vt.input.encodeKey to convert key events back into escape sequences for the PTY.
It's the same layer that Ghostty-the-app uses internally for its own terminal emulation -- but prise uses it headlessly on the server side, with its own rendering pipeline (vaxis) on the client side.
Clean separation. ghostty is the "understands terminal protocols" layer, vaxis is the "draws to a real terminal" layer, prise is everything in between.
How stable is the ghostty-vt API? If prise isn't being updated often how likely is it that it still works?
Bash
grep -A2 '\.ghostty' /Users/mike/src/rockorager--prise/build.zig.zon | head -5(3 lines)Bash
searchctl quick-search "g...294ecdaf9184fd69ed761a7e2aa422 date" 2>&1 | head -20(15 lines)Bash
gh api repos/ghostty-org/...mmit.committer.date, .commit.message' 2>&1 | head -5(2 lines)Bash
gh api repos/ghostty-org/...releases/latest --jq '.tag_name, .published_at' 2>&1ERROR (2 lines)Bash
gh api repos/ghostty-org/...ha[0:10]) \(.commit.message | split("\n")[0])"' 2>&1ERROR (1 line)Bash
gh api repos/ghostty-org/ghostty/tags?per_page=5 --jq '.[] | .name' 2>&1ERROR (2 lines)Bash
gh api 'repos/ghostty-org/ghostty/tags?per_page=5' --jq '.[] | .name' 2>&1(5 lines)Bash
gh api 'repos/ghostty-org...ha[0:10]) \(.commit.message | split("\n")[0])"' 2>&1(5 lines)
Here's the picture:
- Prise pins ghostty at commit
7642b8befrom Dec 10, 2025 (a1.3.0-devbuild) - Ghostty is very actively developed -- commits landing today, already on v1.3.1
- Prise's pin is ~4 months behind HEAD
But this is actually fine. Zig dependencies are pinned by hash in build.zig.zon -- prise will always build against that exact snapshot regardless of what ghostty does upstream. It won't break spontaneously.
The real question is: can prise bump to current ghostty without breakage? That depends on whether ghostty-vt's API (Terminal, Screen, StreamAction, input encoding) has had breaking changes since December. ghostty doesn't publish ghostty-vt as a stable API with semver guarantees -- it's an internal library that prise happens to use. The version string 1.3.0-dev tells you it's a development build, not a stable release.
So the honest answer:
- It works right now -- pinned dependency, deterministic build
- It'll keep working as long as you don't bump the pin
- Bumping is a risk -- ghostty could rename types, change signatures, restructure modules at any time since
ghostty-vtisn't a public API with stability promises - The maintainer (rockorager) is also the author of vaxis and a contributor in the Ghostty/Zig ecosystem, so they likely track breaking changes closely
It's the typical "building on someone else's internals" tradeoff. Stable today, maintenance tax tomorrow.
Could we try upgrading and verify how well or poorly it's likely to work statically?
Skill loaded: arthack:ask
ExitPlanMode (1 line)
Let me check what's changed in ghostty-vt's API between prise's pinned commit and current HEAD.
Bash
gh repo clone ghostty-org/ghostty /tmp/ghostty -- --depth=1 2>&1 | tail -3(1 line)Bash
cd /tmp/ghostty && git fe...2b8bec4294ecdaf9184fd69ed761a7e2aa422 2>&1 | tail -3(4 lines)
Now let me find what prise actually imports and check if those APIs changed.
Bash
cd /tmp/ghostty && ls pkg.... -name "build.zig" -maxdepth 1 -exec head -50 {} \;(26 lines)Bash
cd /tmp/ghostty && find ....tty-vt\|ghostty_vt" build.zig 2>/dev/null | head -10(14 lines)Bash
cd /tmp/ghostty && find src -name "Terminal.zig" -path "*terminal*" | head -5(3 lines)
Now let me diff the key API surfaces between the pinned version and HEAD.
Bash
cd /tmp/ghostty && git di...ig src/input/ src/terminal/modes.zig 2>&1 | tail -20(16 lines)
Whew. That's a lot of churn. Let me check the specific APIs prise uses.
Bash
# Extract the specific ty...\)' /Users/mike/src/rockorager--prise/src/ | sort -u(24 lines)Bash
cd /tmp/ghostty && git di...[-+].*pub const Terminal\|^[-+].*pub fn ' | head -30(24 lines)Bash
cd /tmp/ghostty && git di...deKey\|^[-+].*KeyEvent\|^[-+].*KeyAction' | head -30(2 lines)Bash
cd /tmp/ghostty && git di...184fd69ed761a7e2aa422..HEAD -- src/terminal/main.zig(66 lines)Bash
cd /tmp/ghostty && git di.../terminal/render.zig | grep '^[-+].*pub ' | head -20(4 lines)Bash
cd /tmp/ghostty && git di...c/terminal/style.zig | grep '^[-+].*pub ' | head -20(5 lines)Bash
cd /tmp/ghostty && git di...g | grep '^[-+].*pub.*Cell\|^[-+].*Cell ' | head -20(7 lines)Bash
cd /tmp/ghostty && git di...].*pub.*KeyAction\|^[-+].*pub const Key ' | head -20(2 lines)Bash
cd /tmp/ghostty && git di...daf9184fd69ed761a7e2aa422..HEAD -- src/input/ --stat(785 lines)Bash
cd /tmp/ghostty && git di... /dev/null 2>&1 && echo "existed" || echo "NEW FILE"(9 lines)Bash
cd /tmp/ghostty && git di...echo "existed at pin" || echo "did not exist at pin"(145 lines)
OK, now I've got the full picture. Here's the damage report:
8,430 lines added, 2,237 removed across the API surface prise depends on. Here are the breaking changes that would hit prise:
| Prise uses | What changed | Impact |
|---|---|---|
ghostty_vt.MouseShape |
Deleted. Moved to ghostty_vt.mouse.Shape |
server.zig mapMouseShape breaks |
ReadonlyStream / ReadonlyHandler |
Removed. Replaced by TerminalStream / Stream.Handler |
Not directly used by prise, but if vtHandler API changed it could ripple |
ghostty_vt.Style.Id |
Changed from size.CellCountInt to size.StyleCountInt |
Could break style resolution in server.zig |
RenderState.Dirty |
Changed from plain enum to lib.Enum(lib.target, ...) |
Pattern matching in render code may break |
Several Terminal methods changed their error signatures:
| Method | Was | Now |
|---|---|---|
restoreCursor |
!void (can error) |
void (can't) |
horizontalTab |
!void |
void |
scrollUp |
void |
!void (now errors!) |
scrollViewport |
!void |
void |
getPwd |
?[]const u8 |
?[:0]const u8 (sentinel change) |
If prise calls any of these through the vt_handler, the try/no-try mismatch would be a compile error.
MouseShapemoved fromterminal/mouse_shape.zigtoterminal/mouse.zigasmouse.Shape- New
mouse_encode.zigininput/(781 lines, new file) - New
key_mods.zigininput/(914 lines, new file) - New modules:
device_attributes.zig,focus.zig,size_report.zig
Terminal.init-- no signature changes detectedpage.Cell-- mostly additions, core structure intactmodes.ModeState-- additions only (120 new lines)input.KeyEvent,input.Key,input.encodeKey-- no signature changes detected in the diffStreamAction-- still exported from same path
It would NOT compile against current ghostty HEAD without fixes. The MouseShape rename alone is a guaranteed compile error. The error signature flips would catch a few more. But the core architecture (Terminal, Screen, Stream, Cell, Style) is structurally the same -- these are renames and refinements, not a rewrite.
Estimated fix effort: an afternoon. Maybe 5-10 call sites to update. The hard stuff (Terminal init, Screen, PageList, input encoding) appears stable.
Permission mode: default
Can you explore the repos's branches to see if any work is being done to keep it up to date outside of main? Also check the forks on github to see if anyone is doing this work?
Bash
git branch -r 2>&1(8 lines)Bash
gh api 'repos/rockorager/...0' --jq '.[] | "\(.name) \(.commit.sha[0:10])"' 2>&1(7 lines)Bash
gh api 'repos/rockorager/...(.pushed_at[0:10]) stars:\(.stargazers_count)"' 2>&1(23 lines)Bash
# Check the most recently...10]) \(.commit.message | split("\n")[0])"' 2>&1 done(30 lines)Bash
# Check branch dates and ...10]) \(.commit.message | split("\n")[0])"' 2>&1 done(24 lines)Bash
# Check PRs - any open on...e) \(.created_at[0:10]) #\(.number) \(.title)"' 2>&1(30 lines)
Nobody's doing it. Here's the full picture:
Branches on the repo: All stale -- the newest branch commits are from mid-December 2025. None relate to dependency upgrades. They're all feature branches (separator borders, session picker, review workflow) that either got merged or abandoned.
Forks: 23 forks, all just mirrors of main. The most recently pushed forks (gigamaax, oneopane, noamsto -- Feb 2026) are just syncs with upstream main. No fork has any ghostty upgrade work. jossephus and max-legrand are even further behind.
Open PRs: 5 open PRs, none about ghostty:
- #97 -- scroll mode for keyboard navigation
- #96 -- deadlock fix when closing last tab
- #83 -- configurable status bar widgets
- #77 -- last-focus pane navigation
- #59 -- capture pane functionality
- #60 -- segmented separator dividers
Closed PRs show the project was quite active Dec-Jan with community contributions (floating panes, session management, tab features), then slowed to a trickle in Feb-Mar.
Bottom line: Nobody -- not the maintainer, not any fork, not any branch -- is working on bumping ghostty. The project is in a quiet period. The pinned dependency will keep it building fine as-is, but the gap with ghostty HEAD is only going to grow.