Skip to content

Instantly share code, notes, and snippets.

@possibilities
Created April 3, 2026 01:53
Show Gist options
  • Select an option

  • Save possibilities/a18984f6314468190c75177b20af05c2 to your computer and use it in GitHub Desktop.

Select an option

Save possibilities/a18984f6314468190c75177b20af05c2 to your computer and use it in GitHub Desktop.
Can you create custom UIs that work along prise's own widget...
session-id 65779bc8-c901-4c3f-a4eb-be9eecd35a97

User

/arthack:develop

Teach me about widgets or other features in prise beyond prise'n

Skill loaded: arthack:develop

Assistant

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.


Prise Feature Tour

1. The Widget System (the real star)

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.

2. The Lua Scripting API

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?)     -- Configuration

Built-in Lua API functions:

  • prise.spawn() -- create a new PTY
  • prise.exit() / prise.detach() -- lifecycle
  • prise.get_session_name() / rename_session() / list_sessions() / switch_session()
  • prise.set_timeout() -- async timers
  • prise.request_frame() -- request a UI redraw
  • prise.create_text_input() -- create an input widget instance
  • prise.gwidth() -- Unicode grapheme width calculation
  • prise.get_time() -- clock (HH:MM)
  • prise.get_git_branch() -- git branch for a directory
3. Command Palette

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.

4. Floating Pane

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.

5. Session Picker

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.

6. Pane Borders

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.

7. Client-Server Architecture

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.

8. Event-Driven Frame Scheduler

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).

9. Double-Buffered Rendering

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.

10. Mouse Support

Click-to-focus panes, drag split handles to resize, tab bar click navigation, and mouse events forwarded to terminal applications (like vim, htop).

11. Theming

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:


Deep Dive: The Widget System

How It Works

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 tree
  • Size -- computed width/height propagated back up
  • HitRegion -- mouse target areas for click-to-focus
  • SplitHandle -- draggable resize handles between panes, with ratio calculation
Widget Composition Patterns

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 },
})
Building a 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

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_timeout every 60s)
The ratio Property

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.


Deep Dive: Lua API & Custom UIs

The PriseUI Interface

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 step

That's it. Four functions, only two required.

The Event System

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)
PTY Handle API

When you get a PTY from pty_attach, it's a userdata object with methods:

  • pty:id() -- unique numeric ID
  • Used as the pty field in prise.Terminal({ pty = pty })

New PTYs are created by calling prise.spawn({ cwd = "/some/path" }). The PTY arrives asynchronously via a pty_attach event.

State Persistence

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.

Async Timers

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.

Writing a Minimal Custom UI

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 M

That 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.

LSP Support

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

User

/arthack:open

Skill loaded: arthack:open

Assistant

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

User

Can you create custom UIs that work along prise's own widgets via lua?

Assistant

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 its view() / 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 + List for 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.

User

Could you make something like a high resolution image viewer in prise?

Assistant

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 Image widget in WidgetKind (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:

  1. Add an image: ImageWidget variant to WidgetKind in widget.zig
  2. Wire it to vaxis's Kitty graphics image placement during the render pass
  3. Add a prise.Image({ path = "...", ... }) Lua constructor
  4. 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.

User

I think you missed an important part "high resolution". Unless kitty protocol does that?

Assistant

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.

User

Oh, so I thought it was all botchy and shit, no?

Assistant

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:

  1. Your app sends raw pixel data (PNG or RGB24/RGBA32) to the terminal via escape sequences
  2. The terminal stores the image in GPU memory
  3. You tell it "place this image at cell (x, y), spanning W x H cells"
  4. 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.

User

Can we try it here in ghostty?

Assistant

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:

  1. Claude Code captures stdout -- the escape sequences get intercepted, not rendered by Ghostty
  2. 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?

User

Holy shit that looked good

Assistant

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)
  • Positioned for placement, Box for framing, Stack for 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

User

Can prise be detached and run in background like tmux when the UI is closed/quit?

Assistant

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.

User

Also can I run tmux inside prise?

Assistant

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.

User

Could I make a highly customize-via-lua prise and distribute it as an app? Or would it be better create a plugin?

Assistant

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:

  1. 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.

  2. 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.

  3. 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?

User

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?

Assistant

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 M

The 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:

  1. 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.

  2. 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.

User

In theory can our tiling incorporate widgets that are native zig components like a vnc viewer?

Assistant

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:

  1. Receives framebuffer data from a VNC server (libvncclient or a Zig implementation)
  2. Renders pixels to its region via Kitty graphics protocol (already negotiated by the client)
  3. Forwards mouse events -- prise already captures pixel-accurate mouse coords (cap_sgr_pixels)
  4. 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.

User

Is there any zig vnc project out there?

Assistant

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.

User

Could I run this all inside a browser?

Request interrupted by user

User

Not vnc, just prise

Assistant

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:

  1. 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.

  2. 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.

  3. 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?

User

Tmux I can ssh into and connect to a tmux server already runnign or start one. What about prise?

Assistant

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 running

The 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.

User

Is there any terminal on android that can use it?

Assistant

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:

  1. 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.

  2. Remote desktop -- use a VNC/RDP client on Android to connect to a machine running Ghostty/Kitty with prise. Works but clunky.

  3. 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.

User

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/

Assistant

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).

User

It must be perfect :)

Assistant

Ha -- either that or the maintainer is off somewhere vibing with prise already running exactly how they want it. Hard to tell the difference.

User

How big of a project is it? Is it a bohemouth or just a thin layer of lua love over ghostty?

Assistant

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 work
  • server.zig (3,773) -- session/PTY management, msgpack-RPC
  • client.zig (3,394) -- rendering, input handling, server communication
  • tiling.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.

User

Which layer of ghostty does it integrate with?

Assistant

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.

User

How stable is the ghostty-vt API? If prise isn't being updated often how likely is it that it still works?

Assistant

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>&1 ERROR (2 lines)

Bash gh api repos/ghostty-org/...ha[0:10]) \(.commit.message | split("\n")[0])"' 2>&1 ERROR (1 line)

Bash gh api repos/ghostty-org/ghostty/tags?per_page=5 --jq '.[] | .name' 2>&1 ERROR (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 7642b8be from Dec 10, 2025 (a 1.3.0-dev build)
  • 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-vt isn'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.

User

/arthack:ask

Could we try upgrading and verify how well or poorly it's likely to work statically?

Skill loaded: arthack:ask

Assistant

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:


Ghostty API Compatibility: Pinned (Dec 2025) vs HEAD (Apr 2026)

8,430 lines added, 2,237 removed across the API surface prise depends on. Here are the breaking changes that would hit prise:

Definitely Broken
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
Signature Changes (Error Handling Flipped)

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.

Structural Moves
  • MouseShape moved from terminal/mouse_shape.zig to terminal/mouse.zig as mouse.Shape
  • New mouse_encode.zig in input/ (781 lines, new file)
  • New key_mods.zig in input/ (914 lines, new file)
  • New modules: device_attributes.zig, focus.zig, size_report.zig
Probably Fine
  • Terminal.init -- no signature changes detected
  • page.Cell -- mostly additions, core structure intact
  • modes.ModeState -- additions only (120 new lines)
  • input.KeyEvent, input.Key, input.encodeKey -- no signature changes detected in the diff
  • StreamAction -- still exported from same path
Verdict

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

User

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?

Assistant

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment