Architecture
cornercase is a single Rust binary that plays two roles: a server that owns every terminal and all the state, and a thin client that only shows frames and forwards input. This page explains how the pieces fit, for contributors. The reasons behind each decision are kept in design/ in the repository, one file per area, listed in DESIGN.md.
The big picture
Section titled “The big picture” terminal ─ cornercase ─┐ ┌───── cornercase server ─────────────┐ terminal ─ cornercase ─┼── Unix socket ────▶ │ App ──▶ View ──▶ frame per client │ terminal ─ cornercase ─┘ events ▶ ◀ frames │ ▲ │ │ PTYs + libghostty-vt emulators │ └───┬─────────────────────────────────┘ ▼ your shells, editors and agentssrc/main.rshands the arguments tocli.rs, which parses them withclap: none for the client,server, orkill-server;restartrestarts the server;updateinstalls the latest release and restarts the server;--versiononly prints the version; the commands for scripts and agents send one request to the running server and print its answer.- The client (
client.rs) starts the server if needed, queries the outer terminal’s colours and name, sets up raw mode, mouse capture, bracketed paste and the kitty keyboard flags, sends aHello, then has two jobs: write every frame it receives to stdout, and decode keyboard, mouse and paste input into crossterm events and send them to the server. Its input decoder drops late startup replies and recovers from malformed input; a lost terminal detaches the window. - The server (
server.rs) owns theApp, every PTY and every emulator. It renders a ratatui frame for each client and sends the bytes.
The server loop
Section titled “The server loop”The accept thread, one reader thread per client, the signal thread and a forwarder for PTY output all send ServerEvents down one mpsc channel. Each turn of the loop:
- drives pending agent launches, answers the waits of the commands for scripts whose condition holds and, at most once a second, looks for new git worktrees;
- draws one frame per client;
- lets the session
Saverdecide whether to write the session to disk; - waits for an event or a 500 ms tick, then drains everything queued, feeding input and PTY output to the
App.
Each step (the refresh, sending the answers to commands, the draw, the save and every event) runs under catch_unwind. A step that panics is dropped, the loop goes on, and the user sees cornercase hit a bug, see server.log; a command for scripts that the panic left without an answer fails with the same message instead of waiting forever; a panic in input or drawing also closes the open dialog or menu and ends any drag, and one while drawing starts every client’s screen over. Otherwise a single bug would end the process and every shell in it. Background jobs that the UI waits for turn a panic into an error answer (panics::job), so no dialog stays busy forever. That is why every profile must keep panic = "unwind".
After every step that can change the app (a refresh, input, a command, a background job’s answer), App::trace compares the projects, workspaces, tabs and panes, the focus and the open dialog with what it saw before and writes what changed to server.log, so each code path doesn’t have to log on its own. Lines are formatted on the thread that logs them and written by a thread of their own, so a slow disk never holds up the loop. When you debug cornercase, start with cornercase logs, and restart the server with CORNERCASE_LOG=debug to see every event it handled. What the log may and may not contain is in design/log.md.
Each client has its own writer thread, so a slow client never blocks the loop. All window clients share one size, that of the client that attached, typed or clicked last (a smaller client gets the frame cropped); a program that opened a view sizes the tabs it claims instead. They see the same App, which is why hover, menus and scroll positions are mirrored.
AppEvent has 13 variants. Background answers are grouped into WorktreeEvent, IssueEvent, ChangesEvent, FilesEvent and UpdateEvent, each defined and handled in its concern’s file under app/. Every arm of App::handle_event calls one method; input filtering and PTY output stay in app.rs. The nested enums supply the existing event names for server.log, and the issues handler keeps the connection epochs that reject stale answers.
App groups state into issues (IssueConnections in app/issues_dialog.rs), update (UpdateState in app/update_dialog.rs), agents (AgentRuntime in app/agent_watch.rs), scroll (Scrolls in app/layout.rs) and upstream (Upstream in app/worktrees.rs). Each group keeps its construction and defaults with its concern. A private Outbox in app.rs holds host writes, notifications, detach and restart requests for the server to take through the same methods. The grouped fields are visible only inside app; callers borrow individual fields so independent state can still be read and updated together. Core state, the panels and the knobs tests set stay as they were.
App::drag holds one Drag variant from app/interaction.rs: Border, Divider, Row, Pane, Selection, Link or Lines. A text selection can carry a pending path press; a press held for a program that reads the mouse uses Link and keeps the path underlined. A file-line drag holds its workspace, path and anchor and ends on the next mouse event if that file is no longer shown. Starting a drag replaces the previous one. Exhaustive matches keep dispatch, pointer shapes, hover and drawing consistent. Esc and the prefix key end border, divider, row and pane drags; text and file-line selections continue.
Each dialog is an Overlay variant whose view, submission and mouse dispatch arms call one method or return a plain value. Forms and closing confirmations live in app/dialogs.rs, with a shared confirmation builder; menu clicks live in app/menus.rs, usage clicks and sharing in app/usage_dialog.rs, and remote clicks in app/remote_access.rs. Mouse dispatch borrows the overlay and checks a left press outside before calling its handler. Menus close on any press and act only on a left one; forms act only on left presses. Submission takes the current overlay and installs the handler’s returned successor, keeping busy overlays unchanged. Adding a dialog adds its variant, its own file under app/ and one line per dispatcher.
Protocol
Section titled “Protocol”protocol.rs frames messages with a little-endian length prefix and encodes them with postcard; messages over 64 MiB are refused.
Hellocarries a protocol version and a build id (the executable’s modification time). A server rejects a client from another build withServerMessage::Rejected.- After an in-app update, or when
cornercase updatesendsClientMessage::Restart, the server saves the session and sends every clientServerMessage::Restart; each client waits for the old server to exit and runs its own executable (which the update replaced), and that starts a new server. A server from beforeRestartcannot decode it and answersRejected, socornercase updatefalls back toKillServer. ClientMessage::KillServerandServerMessage::Rejectedmust stay the first variants of their enums, so that any two builds can still tell each other to stop.protocol::tests::compatibilityguards this.- The commands for scripts send
ClientMessage::Requestand get oneServerMessage::Response, both carrying JSON (control.rs) instead of postcard, so a command and a server from different releases still understand each other: unknown fields are ignored, and an unknown command is answered with an error that names the server’s version. They skip the build check ofHello, and a server from before them answersRejected. The server handles a request on its loop like input, but it never counts as a use, so it never changes the shared size. Waits stay inApp(app/control.rs) as pending requests, checked on every turn of the loop, and are dropped when their client goes. ClientMessage::Activated(token)(protocol 5) is how a window says that a desktop notification was clicked: the client’s input decoder recognises the terminal’sESC ] 99 ; i=<token> ;answer, never passes it to a pane, and the app shows the agent that notification was about.- One server per session is enforced with
flockonserver.lock, which sits with the socket next tosession.json. The server callssetsidand ignoresSIGHUP.
Rendering
Section titled “Rendering”ui::draw takes a plain View, built by App, and knows nothing about PTYs. That makes every screen testable with ratatui’s TestBackend and insta snapshots.
Geometry functions such as ui::layout, entry_row, workspace_row, form_buttons and picker_item are shared by drawing and hit testing, so a click lands exactly on what was drawn, and tests take their click positions from the same functions.
Types saved in the session and choices read from the config belong to their models: project/group.rs owns groups and their styles, config.rs owns Sidebar and Tabs, state.rs owns the saved Widths fields, and agents.rs owns the AgentState a pane resumes. ui/widths.rs keeps width defaults and geometry. Callers import them from these owners.
Colours are palette indices (cyan, the brand blue 27, the surfaces 236 and 254, the lines 239 and 250), so the user’s terminal theme applies. Grey text is View::muted: the theme’s bright black, or the fixed greys 243 and 245 when that one would not show (ui::muted). Below 90 columns, ui::layout::compact_layout reuses the same areas with a top bar and three-row bands.
Terminal emulation
Section titled “Terminal emulation”Panes are emulated with libghostty-vt, Ghostty’s terminal core, wrapped in term/emulator.rs:
- It answers the terminal queries programs wait for (DSR, DA, DECRQM, kitty keyboard queries…) and reflows text on resize.
- Its types are
!Send, so every emulator lives on the server’s main thread; each PTY’s reader thread only forwards bytes. Replies go out fromon_pty_write, in order with the output that asked for them. Everything written to a PTY, keys, pastes and replies alike, goes through a writer thread per pane, so a program that stops reading never blocks the server. - Cells keep palette indices. The colours queried from the outer terminal (OSC 10 and 11, plus OSC 4 for ANSI colours 0-15, with DA1 as an end marker) become every emulator’s defaults. The colour cube and greys at 16-255 keep the emulator’s defaults; indexed cells still use the outer terminal’s own palette. The small query fits the tty input queue. The same query asks for the terminal’s name (XTVERSION), which decides how desktop notifications are written to it.
take_copiedhands over OSC 52 clipboard writes from programs; reads and primary-selection writes are dropped.
Keys are encoded in term/keys.rs: Ghostty’s encoder for special keys, and for every key once a program enables the kitty protocol; a legacy encoder for the rest. Mouse events are encoded in term/mouse.rs, in the mode and encoding the program asked for, relative to its pane.
process.rs finds the cwd, name and arguments of a PTY’s foreground process: /proc on Linux, proc_pidinfo, proc_name and sysctl(KERN_PROCARGS2) on macOS (the only unsafe code). Tab names, agent detection and agent launches depend on it. It also lists a process’s descendants and reads the memory each holds on its own (RssAnon and VmSwap in /proc/<pid>/status, ri_phys_footprint from proc_pid_rusage on macOS) for the memory on a tab’s second line.
State and side effects
Section titled “State and side effects”- Ids, not indices. Every project, workspace, tab and pane gets an id from one counter. Menus, dialogs and background jobs refer to ids, so nothing breaks when the lists change underneath them.
- Closing is asynchronous. Closing kills processes; a pane is removed when its shell exits, whether its PTY reader sends
ExitedorApp::refreshfinds the shell reaped first (a detached process can keep the PTY open long after the shell is gone). - Pure state machines.
issues::browser::Browser,settings::Settingsandagents::launch::Launchcontain no I/O: they take input and return actions or steps, whichAppcarries out on threads. Answers carry their query and issue key, so stale ones are dropped.Settingsalso builds no UI view: it returns row and field text, picker titles and selection, and hints as plain data;app/settings_dialog.rsassembles its view and handles hit testing. The issues browser builds no UI view either: it takes sizes and semantic hits, keeps its button labels, masked token-field text and Markdown lines, andapp/issues_dialog.rsbuilds and hit-tests its view. - Saving settles.
persist::Saverwritessession.json(andtodos.json) only after the state has been stable for 2 seconds, andstate.rsmigrates older session versions.
Module map
Section titled “Module map”main.rs hands the arguments to cli.rs (the only place using anyhow). lib.rs lists the library’s modules in five groups, and a module’s children live in its folder: agents/activity.rs is the module agents::activity.
Shared leaves
Section titled “Shared leaves”They import only other leaves, the standard library and external crates.
| Module | Responsibility |
|---|---|
error.rs |
The library’s error type. |
http.rs |
HTTP requests and downloads for issue trackers and updates. |
list.rs |
List selection, scroll and query, shared by the pickers, settings, issues and the search. |
log.rs |
server.log: levels, the key=value line format, the writer thread and its rotation, background job timings, and cornercase logs. app/trace.rs logs what changed in the app after each step of the server loop. |
panics.rs |
Containing panics: catch_unwind around each step of the server loop and each background job, and the hook that writes them to server.log. |
paths.rs |
The socket, the lock and every file cornercase keeps. |
persist.rs |
Safe JSON writes and the quiet-time Saver. |
process.rs |
A process’s cwd, name, arguments, environment, children and memory footprint, per platform; it also stops and reaps a child. |
text.rs |
Truncation, displayed paths, folder names, breadcrumbs, one-line text. |
time.rs |
Timestamps, calendar dates and ages. |
version.rs |
The binary version, version comparison and build id. |
Domains
Section titled “Domains”Each holds a model and its own I/O jobs, and never uses app or ui.
| Module | Responsibility |
|---|---|
agents.rs |
Launch catalogue, shared modes and arguments, choosing and detecting an agent, the trust prompt, and AgentState for resuming a pane. |
agents/known.rs |
Known, the supported agent identities and exhaustive dispatch to each folder’s rules. Its order preserves the saved stats positions. |
agents/activity.rs |
Shared activity and status, the finished-but-unseen mark, notifications and rollups. |
agents/context.rs, agents/context/ |
Shared pane state, lazy Look, exhaustive Reader and Record dispatch, shared discovery jobs, incremental JSONL reads, counting and backwards last-message reads. |
agents/claude/ |
Agent rules, reader, session status and terminal title (mod.rs), transcript and configured window rules (transcript.rs), last message (message.rs), input box and survey (screen.rs), plan probe (usage.rs) and test fake (fake.rs). |
agents/codex/ |
Agent rules, reader and terminal title (mod.rs), rollout discovery and incremental parsing (rollout.rs), last message (message.rs), plan probe (usage.rs) and test fake (fake.rs). |
agents/gemini/ |
Agent rules, reader and dynamic terminal title (mod.rs), session discovery, JSONL message updates, context and last message (session.rs), and test fake (fake.rs). |
agents/opencode/ |
Agent rules, reader and turn status (mod.rs), SQLite database discovery and parsing, context and last message (database.rs), and test fake (fake.rs). |
agents/usage/ |
Shared plan-probe runner, answer reading and dialog state, one section per supported agent. |
agents/stats.rs |
Local per-agent work counts, hourly buckets, windows, heatmap, private storage and share links. |
agents/launch.rs |
Starting an agent in a new tab, as a pure state machine. |
agents/awake.rs |
Keeping the server’s machine from sleeping while an agent works, with caffeinate, gnome-session-inhibit or systemd-inhibit as a child. |
changes/ |
The changes panel: its state, refresh pacing and folds (mod.rs), the git commands and the base (git.rs), the patch parser, word emphasis and highlighting (diff.rs), and the path filter (filter.rs). |
changes/every.rs |
The Every project view of the changes panel: which checkouts to scan and how their summaries group into one diff with headers. |
config.rs |
User settings in config.json, with the Sidebar and Tabs choices. |
files/ |
The files panel: its state per workspace, the tree’s git marks and the marks beside each line (mod.rs), reading folders and files on threads (disk.rs), the list of files and the name and text searches (search.rs), and the paths printed in panes (link.rs); ui/files.rs draws the panel, app/files_panel.rs wires it into App, and app/links.rs wires the paths printed in panes. |
git.rs |
Branch from .git/HEAD, repository roots, linked worktrees, without running git. |
git/worktree.rs |
git worktree add and remove, checkout paths, .worktreeinclude; git/worktree/setup.rs the setup wrapper and the teardown. |
git/upstream.rs |
git fetch and the commits each workspace can pull (↓n). |
graphics/ |
Images in the files panel: which protocol each window’s terminal understands (detect.rs), decoding an image and fitting it to the panel (decode.rs, encode.rs), the bytes for kitty graphics, iTerm2 inline images and sixel (kitty.rs, iterm.rs, sixel.rs), and wrapping them for tmux (tmux.rs). |
highlight.rs |
Syntax highlighting of Markdown code blocks with palette colours. |
highlight/syntax.rs |
The files panel’s highlighting: tree-sitter grammars through arborium, the language of a file, palette colours. |
highlight/markdown.rs |
Markdown to wrapped lines. |
host/ |
The outer terminal: asking for its colours and its name (host/theme.rs), desktop notifications, which escape sequence each terminal understands and reading a click on one (host/notify.rs), the resize pointer (host/pointer.rs) and OSC 52 (host/clipboard.rs). |
issues/ |
The issue model and clients for GitHub (gh), GitLab (REST, for the project of the repository’s remote), Shortcut (REST), Linear (GraphQL), Jira Cloud (REST, with an Atlassian Document Format to Markdown converter) and Plane and Asana (REST, sharing an HTML to Markdown converter in issues/html.rs), the browser state, the on-disk cache, and the trackers’ tokens in secrets.json (issues/secrets.rs). browser.rs and browser/ (buttons, connect form, people filter, picker, keys and mouse, detail text) take sizes and hits; app/issues_dialog.rs builds and hit-tests its UI view. |
project.rs, project/group.rs, project/hierarchy.rs |
Project › workspace › tab › panes, labels, removal; groups, icons, colours and presets. Hierarchy on [Project] owns lookups by id and walks across levels; group_indices and sidebar_order share the project order across the app. Each level owns index_of, and tabs also resolve panes and map layouts to pane positions. |
project/split.rs |
A tab’s split tree: rects, dividers, splitting, removing, ratios. |
project/search.rs, project/picker.rs |
Global search, whose candidates project::search::candidates builds from the hierarchy; the folder picker. |
project/vscode.rs |
Reading a VS Code .code-workspace file into a group of projects. |
settings.rs, settings/ |
The settings modal’s state and plain row, edit and pick texts; app/settings_dialog.rs builds and hit-tests its UI view. |
shortcuts.rs |
The prefix key and the keys menu’s tree of actions. |
state.rs |
The saved session and its Widths schema, its migrations and the mapping between the hierarchy and it (ProjectState::of and restore, and the same for workspaces and tabs). |
term.rs |
A shell in a PTY, its emulator, and the reader thread. |
term/emulator.rs |
The libghostty-vt wrapper; plain snapshots for the UI. |
term/keys.rs, term/mouse.rs |
Key and mouse encoding for programs. |
term/memory.rs |
How much memory a pane uses: the memory its shell and everything under it hold on their own, measured on a thread every 2 seconds. |
todo/ |
The TODO list (mod.rs: ordering, undo, todos.json) and the text field with a cursor (editor.rs); ui/todo.rs draws the panel and app/todo_panel.rs wires it into App. |
update.rs |
The hourly check for a newer release on GitHub and its changelog; downloading, verifying and swapping the binary. |
update/restart.rs |
What a restart stops: the programs running in the terminals, and the texts that ask before restarting. |
| Module | Responsibility |
|---|---|
ui.rs |
A plain View, draw() and the re-exports of ui/. |
ui/*.rs |
One file per part of the screen with its view types, layout, hit testing and drawing: sidebar, workspaces, tree, agents_section, drop, chrome, pane, toast, dialog (and dialog/), plus the shared theme, widgets, layout, widths and rows; tab_bar.rs lays out and draws the tab bar above the pane. |
Application
Section titled “Application”| Module | Responsibility |
|---|---|
app.rs, app/ |
App state, construction, server-facing methods, refresh and event dispatch, and drawing the View stay in app.rs. Children wire each concern: layout and input, hierarchy and tabs, sidebar and pane interaction, agent watching and launches, worktrees, panels and dialogs. app/event.rs defines AppEvent and Start, re-exported by app.rs. app/control.rs answers commands and resolves their targets through project::Hierarchy, and app/control/ holds one file per family of requests (waits, restart when idle, creating, sending and reading, closing, renaming, status); events.rs, trace.rs, images.rs, shortcuts.rs and remote_access.rs keep their existing jobs. |
Processes
Section titled “Processes”| Module | Responsibility |
|---|---|
cli.rs |
Running every command: the commands for scripts send a request and print its answer. cli/args.rs holds the clap definitions and cli/help.rs their help texts, cli/status.rs the output of status, cli/update.rs update, restart and kill-server. |
client.rs |
Terminal setup and teardown. client/window.rs forwards events, writes frames and reconnects a remote window, client/probe.rs asks the outer terminal for its colours and what it can show, client/requests.rs sends a command’s request, client/server_process.rs starts and stops the server (kill-server). |
control.rs |
The JSON of requests and answers, shared by the commands and the server. |
protocol.rs |
Messages and framing. |
remote.rs |
cornercase remote: the ssh command, the proxy at the other end and reconnecting. |
server.rs |
The daemon: owns App and every terminal, accepts clients, draws a frame per client. |
web/ |
Browser access: the loopback listener, pairing, one WebSocket per browser as a client of the server, the vendored page, and tailscale serve (web/tailscale.rs). |