Quirl interactive surface — Ratatui TUI design
Canonical Quirl project documentation synced from docs/tui-design.md.
Status: Implemented baseline and forward design specification. This document turns the §10/§12 vision in language-design.md into an implementation contract. ADR 0012 accepts Ratatui as the capable-TTY default and retains Reedline as the simple fallback. ADR 0022 supersedes ADR 0012's per-command screen release with a persistent, bounded rich-session transcript. Sections marked as future work remain design targets rather than claims about the current binary.
Audience: implementers (human or LLM sessions) picking up any milestone below.
Read AGENTS.md first; every rule there applies. Do not invent
parallel mechanisms — this design reuses ShellError, Catalog, the frozen
completion protocol, QuirlConfig, and the existing prompt scheduler.
1. Summary
Quirl's default capable-terminal shell is a Ratatui-rendered full-screen session: a dirty-tracked UI on the alternate screen. It owns a bounded command transcript, scrolling and selection, the prompt, a syntax-highlighted editor line, a completion popup with a documentation pane, a diagnostics row, and a persistent bottom status bar. Ordinary foreground commands on the rich surface return bounded captured outcomes to that transcript without leaving the alternate screen. The normal screen is restored when the rich session suspends or exits, not between ordinary foreground commands.
What is kept from today's implementation:
QuirlPromptsegment model andPromptContextScheduler(async git/cwd, stale-while-refresh, per-segment deadlines).Catalog::complete,quirl-pickerranking, and the frozen completion protocol v1 (CompletionWorker, request/cancel envelopes, ≤250 ms deadline, ≤1000 results).- Lua extension prompt segments, completion providers, and asynchronously
cached
PanelModelregions. Panel callbacks run only on the fixed extension workers; rendering consumes completed immutable snapshots. - Durable history (
QUIRL_HISTORY/$XDG_STATE_HOME/quirl/history), bounded to 50 000 retained entries, 8 MiB decoded data, and a 32 MiB recent-file read/compaction window. - All accessibility contracts:
NO_COLOR,TERM=dumb, escape filtering, symbol profiles, keyboard-only operation.
What is replaced:
- On the rich path, Reedline's painter,
IdeMenu, hinter, and edit-mode plumbing are replaced by a Quirl-owned editor core plus Ratatui rendering. Reedline stays in the tree as thesimplefallback; its removal is not part of the accepted baseline. - The heuristic
SemanticHighlighteris replaced by real lexer-driven spans fromquirl-syntax(new public API, §6).
2. Goals and non-goals
Goals:
- IDE-grade editing at the prompt: real syntax highlighting, inline diagnostics before Enter, rich completion with docs and provenance.
- A persistent bottom status bar showing mode, keymap state, key hints, and transient notices.
- The rich surface owns a bounded transcript, viewport, selection, and copy model. The simple surface retains native scrollback and inherited execution.
- Meet the §12 budgets: keystroke-to-frame ≤8 ms P95, first prompt paint ≤21 ms P95, cold start ≤25 ms P50.
- Graceful degradation to a line-oriented experience with the same parser and completion data (§12 terminal contract).
Non-goals:
- No embedded interactive terminal in the current stage. The rich surface
captures ordinary noninteractive foreground commands; faithfully embedding
vim,less,top, REPLs, or another alternate-screen child requires the future PTY/VT contract in ADR 0022. The simple surface remains the classic-terminal compatibility path. - No rich-surface background jobs in the current stage. They are rejected before spawn because their uncaptured asynchronous output could race and corrupt later frames. Use the simple surface for background-job workflows.
- No mouse requirement. Mouse support is an enhancement, never a dependency.
- No plugin-drawn raw widgets in v1. Plugins contribute styled values and panel models; Quirl owns layout, focus, theme, and cleanup (§11 of language-design.md).
- No Windows interactive support (ADR 0010). Tier 1 is Linux and macOS.
3. Architecture
3.1 Crate placement
Per ADR 0002, all of this lives in quirl-ui (which may use catalog, core,
lua, syntax) with composition in quirl-cli. No new crate, no inverted edges.
Workspace dependencies to add (root Cargo.toml):
ratatui = { version = "0.30", default-features = false,
features = ["crossterm_0_29", "scrolling-regions"] }
# crossterm 0.29 is already present; ratatui renders through its crossterm backend.Module layout inside crates/quirl-ui/src/:
surface/
mod.rs # Surface: terminal lifecycle, event loop, dirty tracking
frame.rs # FrameModel + render(): layout of all rows, cursor placement
editor.rs # EditorState: buffer, cursor, undo, kill ring, keymaps
highlight.rs # span cache, catalog-aware command resolution, theme mapping
completion.rs # CompletionState: popup model, CompletionWorker wiring
transcript.rs # bounded lines, viewport anchors, selection, copy extraction
statusbar.rs # StatusBarModel + segments
overlay.rs # picker overlays (history/files/palette) on the same frame
theme.rs # Theme: named roles -> ratatui Style, NO_COLOR-aware
degrade.rs # capability probe: rich | simple decision, width/height tiersEverything stays behind #[cfg(test)] mod tests in the same files, per
project convention. ratatui::backend::TestBackend is the snapshot-test
backend (§11).
3.2 Terminal lifecycle: one persistent full-screen session
The frame uses a Viewport::Fixed rectangle covering the complete validated
terminal after entering its alternate screen. Quirl re-measures and manually
resizes that rectangle before each draw, so Ratatui never allocates from an
unvalidated dimension and layout always uses the current terminal rectangle:
/// Owns the ratatui Terminal and its alternate-screen lifecycle.
struct SurfaceTerminal {
terminal: Option<ratatui::Terminal<CrosstermBackend<Stderr>>>,
alternate_screen: bool,
}
impl SurfaceTerminal {
fn draw(&mut self, frame: &FrameModel) -> Result<(), ShellError>;
/// Restore the main screen, cooked mode, and input features on handoff or exit.
fn release_session(&mut self) -> Result<(), ShellError>;
}Accepting an ordinary foreground command does not call release_session. The
rich session retains terminal ownership while the CLI runs a bounded streaming
capture request, admits sanitized lines as they arrive, and commits the final
status after both readers drain. A background
pipeline is rejected before spawn. release_session remains the single cleanup
path for suspension, EOF, fatal errors, explicit compatibility handoff, and
normal exit.
Rules:
- Draw to stderr, not stdout. A rich ordinary foreground child inherits neither stream; its bounded captured stdout and stderr cross the terminal-text filter before transcript admission. Non-interactive Quirl invocations keep stable, undecorated, control-sequence-free stdout (§12).
- The bounded active region (context, editor, diagnostics, completion, and
panels) is aligned immediately above the status bar, keeping the editing locus
stable as overlays open and close. It is clipped to the current screen, while
the status bar is always rendered at
height - 1. Tiny dimensions use saturating layout and the capability probe keeps terminals shorter than five rows on the simple path. - Width is bounded to 512 columns and height to 256 rows (131,072 cells) at
initial selection and again before every resized draw. An oversized runtime
resize returns
ResourceLimitonly after restoring the terminal guard. - Raw mode is enabled while the rich input loop owns input. Stage 1 may pause
raw mode and input features while a captured command runs, but it does not
leave the alternate screen. Bracketed paste is enabled through crossterm;
raw mode, alternate screen, bracketed paste, cursor shape,
and cursor visibility are restored by the same terminal guard. Kitty keyboard and
synchronized-output negotiation remain future progressive enhancements;
Shift-Tabworks today through crossterm's Shift-Tab/BackTab events.
3.3 The ordinary-foreground-command cycle (transcript contract)
┌──────────────────────────── rich session ──────────────────────────────────┐
│ 1. enter raw mode + alternate screen once; build the FrameModel │
│ 2. edit loop: keys → EditorState; async events → completion/segments │
│ 3. on Accept: freeze the command; pause input, not the alternate screen │
│ 4. classify foreground rich execution → bounded streaming capture; reject &│
│ 5. sanitize ≤8 KiB chunks; admit complete progress lines and redraw │
│ 6. drain/reap; append final status, redraw, and return to step 2 │
│ 7. on suspend/EOF/fatal error/exit: restore the main screen and raw state │
└─────────────────────────────────────────────────────────────────────────────┘Ordinary native stdout and stderr appear while the child runs. Reader threads retain at most 1 MiB per stream and publish only retained chunks of at most 8 KiB; the executor's owning thread performs every UI callback after process graph construction. The same capture owner drains and reaps on success, failure, cancellation, observer failure, and resource overflow. Command input remains paused, and concurrent stdout/stderr delivery is not a byte-ordering guarantee.
Stage 1 rejects every command graph containing a background pipeline before spawn. The existing process engine intentionally leaves background streams uncaptured; accepting such a graph would let asynchronous bytes escape the transcript boundary and corrupt a later frame. Background job execution remains available on the simple surface.
Interactive PTY applications are not sent through this contract as though captured text were a terminal. Embedded PTY input, VT parsing, child screen state, replay, and resize propagation remain future work under ADR 0022. The simple surface continues to use inherited streams and native terminal scrollback.
3.4 Transcript, scrolling, selection, and copy
The transcript begins at the top and pushes the context/editor downward like a normal shell session. Once the viewport fills, the live context/editor remains immediately above the status bar on the physical bottom row and the transcript scrolls behind it. A scrolled-away viewport gives the transcript the full body. Completion, documentation, and picker overlays reuse a bounded transcript region without changing the logical scroll anchor or moving the live prompt.
One active transcript record accumulates terminal-safe lines equivalent to:
struct TranscriptEntry {
command: String,
status: i32,
duration: Duration,
stdout_lines: Vec<String>,
stderr_lines: Vec<String>,
rendered_error: Option<String>,
}The concrete type uses bounded retained lines rather than this illustrative shape. The command is admitted before spawn-visible progress, complete lines are appended as chunks arrive, and status/duration commit only after drain and reap. Control-sequence filtering and UTF-8 repair happen before retained byte accounting, and visible loss is marked rather than silently discarded.
The transcript retains at most 16 MiB of terminal-safe text and 50,000 logical lines. Admission computes both costs before changing visible state. When a new line does not fit, oldest complete logical lines are evicted and one bounded omission marker reports that fact. Eviction never removes the active editor, splits a UTF-8 sequence, or duplicates discarded bytes in metadata.
Scrolling is application-owned. The shipped paths provide page up/down, mouse-wheel steps, a proportional draggable scrollbar, and return-to-tail navigation:
scroll_line,scroll_page,scroll_to_start, andscroll_to_endoperate on logical transcript positions, not the host terminal's scrollback;- follow mode tracks new entries only while the viewport is already at the logical end;
- manual keyboard scrolling disables follow mode until the user returns to the end; and
- resize recomputes visual wrapping from retained logical rows and preserves the nearest retained logical anchor. A frame builds only visible rows and a bounded layout margin.
Quirl exposes two bounded selection models. Output-focus mode uses logical transcript positions for keyboard line selection. Repaint, wrapping, and scrolling may change those cells but not the selected text while its source entry remains retained; eviction clamps the selection to retained content.
A mouse drag instead selects exact grapheme-safe cells from the last complete rich frame. It may cross the transcript, context row (including Git and the right-side prompt), live input, completion or panel regions, and bottom status bar. This makes every visible user-facing value selectable even though Quirl owns the alternate screen. Releasing a completed drag copies immediately and returns keyboard focus to the prompt while keeping the highlight available.
Copy removes Ratatui styling and terminal control sequences. Logical
transcript copy emits semantic lines without layout padding; full-frame copy
emits the selected visible text, preserves meaningful internal spacing, trims
trailing row padding, and inserts one newline between selected rows. One copy
is capped at 1 MiB and fails before an oversized allocation. Mouse release,
y, and delivered Ctrl/Cmd-C events use the same OSC 52 transport; a clipboard
error keeps the selection and never changes command status. Paste remains
governed by the 64 KiB editor bound in §4 and is not a PTY input stream.
3.5 Event loop
Single render thread; workers publish through bounded/latest-value state where
possible. No async runtime is introduced: the implementation uses standard
channels/condition variables for PromptContextScheduler, CompletionWorker,
the extension-completion worker, and the PATH snapshot worker.
The current loop polls crossterm at ≤16 ms and separately polls the completion,
extension-completion, and bounded PATH workers. Input or a published worker
snapshot marks the frame dirty; idle polls do not redraw. It performs at most
one draw per observed event/worker publication. A unified SurfaceEvent
channel with ticks, prompt-segment updates, notices, and multi-event batch
draining remains a future refactor. Cursor motion redraws the frame while
reusing the revision-keyed syntax analysis. Every draw records a rolling P95
(§10).
4. Editor core
EditorState is Quirl-owned (no Reedline types). Requirements:
- Buffer: a 64 KiB-bounded
String+ grapheme-aware cursor (reuseunicode-segmentation/unicode-width, already workspace deps). Multi-line editing: whenquirl-syntaxreports recoverable-incomplete input on Accept (open quote, trailing|,&&), insert a newline and continue instead of executing; continuation rows render with a∙gutter. - Undo/redo: linear stacks bounded to 256 states and 8 MiB each. Keystroke coalescing and an undo tree are later enhancements.
- Kill ring:
Ctrl-U/Ctrl-W/Ctrl-Ysemantics in the Emacs keymap;Alt-Q popens the shipped palette, so Emacs kill-to-end and registers remain future keymap-parity work. - Paste safety: bracketed paste inserts literally — newlines in pasted text
never trigger execution; the frame shows
⇪ pasted 3 linesin the status bar until the next keystroke. Oversized paste truncates at a UTF-8 boundary and reports the 64 KiB limit in the status bar. - Keymaps:
emacs(default),helix,vim— the existingeditor.keymapconfig values. The baseline centralizes bindings inEditorState::apply_key, but still uses explicit match branches. Data-driven(mode, key) -> EditActiontables and user remapping are future work. Helix and Vim modal states (NOR,INS, and VimVIS) appear in the status bar. Reedline may only be removed once all three keymaps pass the shared keymap conformance test suite. - History recall:
Up/Downprefix-aware cycling;Ctrl-Ropens the history picker overlay. Inline autosuggestion (dim text after cursor) comes from the most recent matching history entry;→at end-of-line accepts it.
enum EditAction {
None, Insert(char), Backspace, Delete,
MoveLeft, MoveRight, MoveHome, MoveEnd, MoveWordLeft, MoveWordRight,
KillToStart, KillWord, Yank, HistoryPrev, HistoryNext,
Accept, ForceNewline, Complete, ExpandCompletionPicker, Dismiss,
ToggleGrammarMode, OpenPicker(PickerKind), Cancel, ClearScreen, Suspend,
Eof, Undo, Redo,
}Keybindings (shipped defaults; not yet user-remappable)
| Key | Action | Notes |
|---|---|---|
Tab | Open/advance completion popup | preserves today's behavior |
Shift-Tab | Expand completion into full picker | §10 contract |
Enter | Accept (or newline if input incomplete) | |
Alt-Enter | Force newline | |
Alt-Q, then a mnemonic | Quirl leader for modes, pickers, jobs, and results | collision-free internal command namespace |
Ctrl-Space | Command/data mode toggle compatibility alias | some terminals cannot distinguish this from NUL |
Ctrl-R or Up | Cwd-aware fuzzy history | conventional history entrypoint |
Alt-Q f/c/p/j/r | Files / directories / palette / jobs / results | Quirl leader namespace |
Ctrl-G / Alt-D | Active jobs / cached typed-data picker | snapshots only; selection inserts a revalidated command or data expression |
Ctrl-C | Clear line, dismiss popup; never exits | |
Ctrl-D | EOF on empty line → exit | |
Ctrl-Z | Release terminal, SIGTSTP self | resume redraws frame |
Ctrl-L | Clear screen above frame, redraw | |
Esc | Popup dismiss → helix NOR | layered: popup first |
5. Visual specification
All mockups assume a 78-column terminal, unicode symbol profile, defaults.
Glyphs come from PromptSymbols profiles: auto promotes to private-use icons
only for terminals with documented built-in Nerd symbols, while plain
substitutes ASCII (D, AI, *, !) and remains universally safe. The live
input marker is > in the plain profile and the solid Unicode ❯ in the
Unicode and Nerd profiles. No path, right-prompt, continuation, or status-bar
separator uses a thin Powerline/Nerd chevron: Unicode and Nerd context segments
use ·, continuation lines use ∙, and status zones use │.
Accepted transcript command records retain a solid ❯ as their semantic
history marker; it is not profile-dependent chrome.
5.1 Frame at rest (command mode)
~/projects/quirl main ✚2 1 job · 412ms · ✘1
❯ cargo build --release▌
NORMAL Alt-Q Quirl · Tab complete · ↑ / Ctrl-R history quirlRow 1 — context row: existing prompt segments. Left list (directory,
git_branch, git_state, plugin segments) left-aligned; right list (jobs,
duration, status) right-aligned, separated by ·. Right side truncates
first. The prompt producer already compacts the home directory; segment-aware
…/ truncation inside an overlong left side remains future work. The current
surface consumes escaped, rendered left/right QuirlPrompt strings and gives
the branch suffix a secondary style rather than retaining one styled span per
source segment.
Row 2 — input row: the profile-appropriate > or ❯ always marks the
editable buffer. Data (▦) and AI (✧) add their mode indicator after the
chevron; the hardware cursor remains at the edit position
(Frame::set_cursor_position). Inline history autosuggestion renders dim after
the cursor. The simple/Reedline surface follows the same glyph policy after its
textual mode label, for example normal > or normal ❯.
Rows below the editor and any active overlay form the bounded transcript viewport (§3.4). The physical bottom row is always the status bar (§8).
5.2 Completion popup open
~/projects/quirl main 412ms
git che▌
┌ completions ────────────────────────┬ git checkout ─────────────────────┐
│ ▸ checkout switch branches │ git checkout <branch> │
│ cherry find unmerged commits│ │
│ cherry-pick apply commits │ Switch branches or restore │
│ │ working tree files. │
│ │ source: fish-import · trusted │
└─────────────────────────────────────┴───────────────────────────────────┘
command · 3 results (catalog) · streaming… ↑↓ move · Enter accept- Popup anchors its left edge to the column where the completed token starts
(
replace_start), clamped to fit the terminal. - Left pane:
displayvalue withmatch_indiceshighlighted in the accent color, then summary text. Kind glyph column (commandλ, flag–, path/, value≡, history↺; ASCII fallbacksc f p v h). Max 10 rows, virtualized scrolling with a 1-cell scrollbar when overflowing. - Right pane (docs):
detail, catalog-derived I/O/effect capabilities, and a provenance footer (source · trust, derived from matching catalog provenance). Hidden for a normal popup when terminal width < 72. Narrow mode retains the list and count/source status; showing the selected summary in the status bar remains future polish. - Always-on context: typing an exact catalog command opens its explanation;
typing a flag prefix after a known command opens that command's options. This
happens even when broad
completion.autofuzzy matching is disabled. An untouched informational popup leaves Enter bound to command execution; Tab or arrow navigation converts it to a selectable completion menu. - Streaming: catalog and extension completion run on separate workers. Catalog
results normally paint first; later extension results merge without moving a
still-present selected value.
streaming…shows while either source remains outstanding. Every buffer edit cancels the frozen catalog request and suppresses stale extension results. The ≤8 ms first-result target still needs release evidence rather than being assumed from this architecture.
5.3 Diagnostics row
gti status▌
✘ unknown command `gti` — did you mean `git`? quirl.invalid-command
NOR · command …- Produced by the same continuous parse that drives highlighting plus catalog
and asynchronous PATH resolution. The analyzer currently emits parse/unknown
command errors and high/exact-confidence unknown-flag warnings. Rendering
supports
✘error (red),▲warning (yellow), and the reservedℹhint (blue), with ASCIIE W H; no hint producer has shipped yet. - At most one row; highest severity wins; the offending span is underlined
(
Modifier::UNDERLINED) in the input row. - Never blocks Enter. Diagnostics are advisory before execution;
explainremains the deep-preview path.
5.4 Data mode
Identical layout; the mode indicator becomes ▦, the accent color switches to
the data accent (one accent per mode, §7), and the status bar reads
· data ·. Highlighting uses the data-expression lexer once it exposes spans;
until then data mode renders with the plain style rather than wrong guesses.
5.5 Completed ordinary foreground command
On Accept the alternate screen remains active. After bounded execution completes, the accepted command and its complete result are admitted as one transcript entry, and a fresh editor remains at the top:
~/projects/quirl main 2.31s
▌
┌ cargo build --release ✔ 0 · 2.31s
│ Compiling quirl-core v0.1.0 ...
└ Finished `release` profile
NORMAL Alt-Q Quirl · PgUp scroll · copy selection quirlThe header records the terminal-safe accepted command, exit status, and duration. Stdout, stderr, and a structured error stay distinguishable even when the theme presents them with compact shared chrome. A capture-limit error replaces a success footer and states the configured and observed byte counts; it never shows a partial capture as successful.
Stage 1 admits results only after completion. Spinners, live tailing, and
interactive child input are future work. prompt.transient remains in config
schema v2 for compatibility and still applies to the simple surface; the rich
surface always records accepted commands in its bounded transcript.
5.6 Picker overlays
Ctrl-R/Up and the Alt-Q picker chords reuse the frame: the popup region becomes a
picker (query row + virtualized result list + optional preview pane),
honoring picker.layout:
adaptive/bottom: inside the shared full-screen frame, max 10 result rows.full: terminal-height picker using the same full-screen frame and RAII lifecycle as ordinary editing.
The shipped Alt-Q p command palette always requests a terminal-height
region and positions its bounded adaptive content against the bottom edge. It
does not perform another screen transition. The existing terminal guard retains
the shared alternate screen across ordinary foreground captured execution and releases it
for suspension, explicit compatibility handoff, fatal error cleanup, or exit.
The picker engine, ranking, and typed-value return stay in quirl-picker;
the surface uses it through the PickerRanker composition adapter. Source
items are capped at 4 096 and 2 MiB retained data, queries at 1 024 bytes, and
ranked visible results at 256; rendering virtualizes the current window.
Job entries come from NativeExecutor::jobs() after its refresh/prune step and
retain only stable IDs, status, command text, and state-valid fg/bg
commands. Data entries come only from the bounded cache of successful typed
rows already rendered in the session; opening the picker never reruns a source.
6. Syntax highlighting
6.1 New public API in quirl-syntax
The current UI highlighter guesses. Replace it with lexer-truth. Add to
quirl-syntax (foundation crate — pure function, serde-only deps, no UI
types):
pub struct HighlightSpan { pub range: core::ops::Range<usize>, pub kind: HighlightKind }
pub enum HighlightKind {
Command, // first word of each pipeline stage (resolution happens in the UI)
Flag, // words starting with `-` in option position
Argument,
PathLike, // contains `/`, `~`, or glob metacharacters
StringSingle, StringDouble, Escaped,
Operator, // | && || ; &
Redirect, // < > >> <<< and fd forms
Expansion, // $VAR ${...} $(...) $((...))
Number,
Error, // unterminated quote, dangling operator
}
/// Total over arbitrary input: incomplete/broken lines still yield spans
/// covering every byte (recoverable parse, §10 interaction contract).
pub fn highlight(line: &str, mode: Mode) -> Vec<HighlightSpan>;- Spans are byte ranges into the original line, non-overlapping, sorted, and
jointly exhaustive (uncategorized bytes get
Argument-style default). - Must be lossless against the existing lexer: implement it on the same
TokenKind/Word/Quotingmachinery insidelex_command, not a second tokenizer. A property test assertshighlightnever disagrees withparse_command_listabout quoting boundaries. Mode::Datamay return a single default span until the data grammar exposes its own lexer; wire the enum now so the API doesn't change later.
6.2 Catalog-aware resolution (in quirl-ui)
surface::highlight post-processes spans each edit:
Commandspans resolve againstCatalog(+ alias table +$PATHlookup cache). Lexer command spans use the known-command style; after a complete PATH snapshot proves absence, an unknown command becomes a red, underlined diagnostic span and offers the closest catalog name using bounded edit distance. Reusing the picker scorer for did-you-mean remains future cleanup.Flagspans check the resolved command'sArgumentSpecs: undeclared flags render asflag.unknown(yellow underline, warning severity) when the command's catalog provenance is high-confidence; otherwise stay neutral — never punish commands we merely don't know.- Budget: lex + resolve + style ≤8 ms P95 on a 4 KiB line (§12). Cache the
span vector keyed on buffer revision. The shipped
$PATHcache is a complete bounded snapshot warmed off-thread, not an LRU: at most 256 directories, 4 096 entries per directory, 65 536 executable names, and 1 MiB retained name bytes. It refreshes when PATH changes at a prompt boundary and stays conservative if scanning is truncated or uncertain. The editor itself is bounded to 64 KiB. The 8 ms budget is instrumented but not yet enforced as a release gate.
7. Theme
One theme struct centralizes semantic styles; widgets do not choose colors directly. The table is the intended role vocabulary. The baseline exposes methods for accents, lexer kinds, severity, context, selection, and chrome; known/unknown command and unknown-flag distinctions are currently composed by patching the lexer style with a diagnostic severity style.
| Role | Default | Used by |
|---|---|---|
accent.command | green | popup selection and match highlights |
accent.data | magenta | indicator ▦ and all accent uses in data mode |
command.known / command.unknown | green / red | input row |
flag | cyan | input row |
string | yellow | quoted regions |
operator / redirect | white bold | |, &&, > … |
expansion | blue | $VAR, $(...) |
suggestion | dark gray italic | inline history hint |
severity.error/warn/hint | red / yellow / blue | diagnostics, status bar |
chrome.border / chrome.dim | dark gray | popup borders, secondary text |
Rules (unchanged contracts): colors only when stderr is a TTY, NO_COLOR
unset, TERM != dumb. Under NO_COLOR the theme degrades to
bold/underline/dim modifiers only — layout is identical. One accent per mode,
one severity system (§10 visual contract). Theme customization via config is a
later phase; ship the roles first.
8. Bottom status bar
The status bar is Quirl-owned chrome. Plugin status items are a future protocol addition; current plugins do not contribute status-bar values and never draw.
Layout: left │ center(flexible) │ right, single row, chrome.dim
background tint when colors are on. Unicode and Nerd profiles use the ordinary
vertical bar, never a Powerline chevron; the plain profile uses |.
| Zone | Content | Rules |
|---|---|---|
| Left | Keymap state (NOR/INS/VIS for helix, ∅ hidden for emacs) + mode name (command/data) in the mode accent | always visible, never truncated |
| Center | Contextual: fixed shipped key hints at rest; result count + source + streaming… while completing; ⇪ pasted n lines; editor resource-limit notices; compact-terminal diagnostics | truncates first |
| Right | Contextual completion hints (↑↓ move · Enter accept), timing P95 when enabled, else short brand | truncates second |
The shipped hints are fixed strings matching the current bindings because
keymaps are not yet data-driven or user-remappable. Deriving hints from future
live keymap tables remains the release criterion for remapping.
ui.statusline.hints = false hides hint text but keeps the bar. Width < 60
columns drops the center zone; the left zone stays.
Implementation status: the baseline status row, mode/editor labels, completion counts, paste/resource notices, compact diagnostics, width tiers, draw/highlight P95, and hints toggle are landed. Timed asynchronous job/config/plugin notices and live-keymap-derived hints remain follow-up work.
9. Degradation and accessibility
Decision made once at startup (and on SIGWINCH only for width tiers), in
surface::degrade:
| Condition | Behavior |
|---|---|
stderr not a TTY, TERM=dumb, terminal height < 5, or ui.surface = "simple" | Simple surface: current Reedline path — plain prompt and Reedline menus; completion also remains available through quirl complete; identical parser and catalog |
NO_COLOR | Rich layout, modifier-only theme (§7) |
| width < 100 | Normal completion documentation pane hidden; the list and result count remain. A terminal-height full picker may show preview from width 72 when configured |
| width < 60 | Status bar center dropped; context right side is dropped when it collides with the left side |
Popup height is clamped to available rows, and terminals below eight rows move the diagnostic text into the status row. Synchronized-output and kitty-keyboard negotiation remain planned refinements, not current capability claims.
Hard rules carried over: every piece of information in the shipped frame has a
linear text equivalent (diagnostics render through render_error on demand;
standalone panel models require plain_fallback, although panels are not yet
pinned into the frame); plugin-provided strings pass the existing
control-sequence escape filter before entering any buffer; no functionality is
mouse-only or color-only; screen-reader users get a stable, minimally-redrawn
simple surface rather than a chatty rich one.
10. Performance and instrumentation
Budgets (restating §12 as per-component obligations):
| Measure | Budget | Owner |
|---|---|---|
| Keystroke → frame flushed | ≤8 ms P95 | event loop; one draw per batch |
| Lex + resolve + style | ≤8 ms P95 | §6 cache |
| First prompt paint | ≤21 ms P95 | context row paints with cached/stale segments; scheduler fills in |
| Cold start → editable | ≤25 ms P50 | rich catalog admission follows the first flush and completes before input polling; $PATH warmup remains lazy |
| Completion: local results visible | ≤8 ms | catalog worker publishes independently; extensions merge later |
| Memory | 16 MiB/50,000-line transcript; 1 MiB selection/copy; virtualized popup/picker; 64 KiB editor; bounded undo/history |
The first-paint budget is a P95 wall-clock bound over fresh PTY processes. It includes alternate-screen entry and process scheduling, so 21 ms is the smallest stable boundary demonstrated by the rich surface on the release reference machine. Lazy bounded workers keep the median near one 60 Hz frame without hiding tail behavior or weakening the full welcome default.
Instrumentation is part of the release criterion, not optional: the surface
records draw-time and highlight-time histograms in-process, exposed through
the existing benchmark/evidence flow (cargo xtask, release checklist), and a
debug overlay (QUIRL_UI_TIMINGS=1) renders the rolling P95 in the status bar
right zone.
Implementation status: rolling draw and highlight-analysis P95 values are landed and shown together by the debug status. A deterministic 4 KiB analysis test guards totality/cache reuse with a generous non-flaky ceiling. Enforcing the 8/16/25 ms targets in named Linux/macOS release evidence remains work; the instrumentation is not itself proof that every budget passes.
11. Testing strategy
In-crate #[cfg(test)] modules, behavior-sentence names, run by cargo xtask check:
- Rendering snapshots:
ratatui::backend::TestBackend+ buffer assertions for every mockup in §5 (rest, popup, narrow width,NO_COLOR, plain symbols, data mode, diagnostics row). Snapshots compare styled cells, not just text, e.g.unknown_command_renders_red_with_did_you_mean. - Editor conformance: one table-driven suite of
(keys, expected buffer, expected cursor)cases executed against all three keymaps, e.g.helix_normal_mode_w_moves_by_word. Reedline removal is gated on this suite. - Highlight totality: property-style corpus over arbitrary valid UTF-8 edit
strings —
highlight()returns sorted, non-overlapping, exhaustive spans and never panics; agreement test againstparse_command_listquoting. - Adversarial: plugin segment/completion strings containing escape sequences, RTL text, zero-width joiners, and 4 KiB tokens must render filtered and width-correct (extends the existing escape-filter tests).
- Protocol: completion popup honors cancel-on-edit, stale-response
suppression, and the 250 ms deadline using the existing
CompletionWorkertest harness. - Degradation: each row of the §9 table has a test fixing the decision.
- Transcript bounds: exact-byte and exact-line admission, oldest-complete eviction, omission-marker accounting, UTF-8 boundaries, and atomic failure paths at 16 MiB/50,000 lines.
- Scroll/selection: follow mode disengages on manual scroll, resize preserves a logical anchor, the proportional scrollbar uses the actual retained and visible line counts, keyboard and mouse selection survive repaint, exact UTF-8 mouse ranges copy through a real PTY, and a 1 MiB copy succeeds while the next byte fails before allocation.
- Lifecycle: repeated ordinary foreground commands never emit alternate-screen exit; suspension, EOF, fatal render failure, and normal exit each restore the main screen exactly once.
- Execution separation: rich ordinary foreground commands select the 1 MiB-per-stream streaming capture path and commit status only after drain. Rich mode rejects a background pipeline before spawn. Simple mode inherits streams and retains background-job compatibility. PTY-only applications are not presented as supported rich captures.
Sandbox/budget claims need adversarial proof per AGENTS.md; any new Lua-facing surface (status items) gets deny-unknown-fields structs at the boundary.
Current evidence includes styled TestBackend checks for rest/data/diagnostic/
completion/picker/compact/adversarial frames; shared keymap and Shift-Tab
conformance; stale/cancelled asynchronous completion tests; 4 KiB highlighting;
and explicit editor, completion, picker, PATH, undo, and history bounds. The
full permutation implied above (especially every degradation row, NO_COLOR,
plain symbols, persistent-session lifecycle, and named terminal snapshots)
remains release-evidence work. cargo xtask rich-pty must cover deletion,
wrapping, Alt-Q leader repaint, completion, repeated captured execution,
transcript scrolling/copy, and Ctrl-D on a real Unix PTY.
12. Configuration
Additions to QuirlConfig (Lua config.lua). The config schema fingerprint
is frozen under ADR 0008. The interactive-surface fields shipped as config
schema v2; theme selection and bounded custom palettes advanced v3, and the
default active Rust toolchain segment advances v4 with a deterministic
v0/v1/v2/v3-to-v4 migration:
local config = quirl.config {
editor = { keymap = "emacs", semantic_hints = true, banner = "full" }, -- existing
picker = { layout = "adaptive", preview = true }, -- existing
prompt = {
symbols = "auto", -- existing
left = { "directory", "git_branch", "git_state" },
right = { "rust_version", "jobs", "duration", "status" },
transient = true, -- schema-v2 compatibility; simple surface only (§5.5)
},
ui = { -- new
theme = "tokyo-night", -- one of 30 built-ins, or a key in ui.themes
themes = {}, -- bounded semantic #RRGGBB palettes
surface = "auto", -- auto | rich | simple
statusline = { hints = true },
},
completion = { -- new
auto = false, -- manual by default; true enables threshold opening
min_chars = 2, -- threshold when auto is enabled
},
}ADR 0013 later adds bounded built-in and custom semantic themes as config schema v3; v0/v1/v2 documents migrate to the Tokyo Night default.
ui.surface = "auto" applies the §9 probe. Everything else in the frame
derives from existing config (keymap, picker layout, prompt segments,
symbols).
13. Implementation status and remaining delivery
The baseline implementation keeps cargo xtask check green and ships with
catalog metadata, advisory diagnostics, keyboard navigation, accessible text
fallbacks, and optional draw/highlight timing (QUIRL_UI_TIMINGS=1). The rich
surface is now selected by ui.surface = "auto" on capable TTYs. The table
distinguishes landed behavior from remaining parity and release-evidence work.
| Milestone | Current status | Remaining acceptance work |
|---|---|---|
| M1 — Frame + transcript | Landed editor baseline: full-screen alternate viewport, bottom status, Quirl-owned 64 KiB grapheme editor, bounded undo/history, Emacs/Helix/Vim states, flowing transcript/context/input rows, prefix history, autosuggestion, bounded streaming captured foreground commands, proportional keyboard/mouse scrolling, exact mouse/keyboard selection, and bounded copy | Keep interactive PTY/VT support outside this milestone |
| M2 — Highlighting + diagnostics | Landed baseline: revision-cached quirl_syntax::highlight, bounded asynchronous executable-PATH snapshot, parse/unknown-command/unknown-flag diagnostics, severity styling, draw/highlight P95, and Ratatui/adversarial 4 KiB tests | Expand generated totality coverage and record evidence that the 4 KiB/first-paint budgets pass on release terminals |
| M3 — Completion popup | Landed: always-on exact-command information and flag-prefix options, bounded catalog and extension workers/results, catalog-first asynchronous merge, selection stability, stale suppression, docs/provenance pane, token anchoring, match styling, virtualization, and narrow list-only rendering | Record named ≤8 ms first-result evidence and broader provider fault/terminal snapshots |
| M4 — Overlays + keymaps | Landed: history/files/directories/palette overlays use the shared quirl-picker ranker through a composition-root adapter; queries are bounded and editable; Shift-Tab expands completion; adaptive/bottom and terminal-height full layouts honor preview config; Emacs/Helix/Vim editor modes remain available | Decide kitty/synchronized-output negotiation and gather named real-terminal layout evidence |
| M5 — Fallback retirement | Not accepted or implemented. ADR 0012 flips auto to rich but deliberately retains Reedline for simple | Separate decision, full conformance and accessibility evidence, minimal fallback replacement, and removal of Reedline from Cargo.lock |
Bounded extension panels are now pinned into the full-screen frame below the editor
when no completion/picker overlay is active. F6 cycles focus, at most six
rows are visible, and LiveBuffer retains four completed generations per
panel. Typed command output enters the same bounded transcript rather than
turning the surface into an unbounded watch application.
14. Open questions and recorded decisions
- Persistent full-screen lifecycle: record one alternate-screen entry, repeated captured ordinary foreground commands without screen exit, resize, suspend/resume, EOF, and failure cleanup evidence on Ghostty, Terminal.app, iTerm2, and a Linux VTE terminal before calling the behavior release-proven.
- Embedded interactive PTY/VT applications: a separate ADR must define
terminal emulation, input/signal ownership, bounded replay, resize ordering,
and cleanup before the rich surface advertises
vim,less,top, or interactive REPL compatibility. - Data-mode lexer spans: extend
highlight()when the data grammar exposes tokens; until then plain styling (§5.4). - Status items from plugins: reuse
ContributionKindor add aStatusItemkind — needs a protocol-compatibility check under ADR 0008 before exposing to Lua. - Editing-time notices: add a bounded event queue and transcript admission path that cannot corrupt terminal state or delay cancellation.
- Terminal protocols: decide whether measured Tier 1 benefit justifies kitty keyboard and synchronized-output negotiation, with RAII cleanup and fallback tests required before enabling either.
- Keymap data: replace explicit binding branches with validated tables, then generate status hints from the live mapping before advertising user remapping.
15. Interactive runtime integration failure model
The rich surface may present typed output, native jobs, cached data values, and
extension panels, but it does not become an execution owner. Native jobs remain
owned by quirl-process, live data readers remain owned by quirl-data, and
Lua callbacks remain owned by the CLI extension scheduler. The UI receives
only immutable snapshots, terminal-safe typed models, and bounded completed
capture outcomes.
The integration maintains these invariants:
- Cancellation during pull or render. Interactive data output uses the
shared execution request and cancellation identity. Plain rows are pulled and
written one at a time, with cancellation checked before every pull and write.
A cancellation or write failure after partial output remains a
ShellError; already-admitted transcript rows are not reclassified as a successful value. - Ordinary native capture. The rich surface requests at most 1 MiB for each of stdout and stderr through the existing process owner. Spawn failure, cancellation, or overflow kills and reaps the complete process graph before transcript admission. A resource-limit entry may describe discarded bytes but must not present partial capture as successful output.
- Background rejection. Before process creation, the rich path rejects a parsed command graph if any pipeline is marked background. This prevents an uncaptured descendant from surviving the command turn and writing across a later renderer frame. The rejection is recorded as a bounded command error; it does not create a job entry. The simple surface retains normal background execution.
- Transcript admission. The UI computes terminal-safe bytes and logical lines before mutating visible state. Oldest-complete eviction, the omission marker, and new-entry admission form one state transition. Failure preserves the prior transcript and reports bounded status text.
- Resize or suspension during a frame. Resize invalidates the prepared
layout before the next draw. Terminals below five rows hide panels, previews,
and diagnostics in that order and keep the editor/status fallback usable.
Suspension releases the alternate screen, bracketed paste, cursor shape, and
raw mode before the process receives
SIGTSTP; resume reacquires a newly measured viewport. A frame prepared for an older size is never deliberately written after a resize event has been observed. If stage 1 execution delays event polling, output repaint remeasures the viewport and the next input turn applies any queued resize before accepting input. - Provider failure or removal. Panel providers execute only on the existing extension workers. First paint consumes the last complete cache, a failure preserves that last complete per-provider value, and a newer installed extension generation removes providers absent from its complete snapshot. The render path never locks a Lua VM or calls a plugin.
- Stale generations. Runtime and panel generations increase monotonically.
The UI ignores an update older than the active generation; installing a newer
complete generation atomically replaces the visible provider set. Exhausting
a generation counter is
ErrorCode::ResourceLimit, never wraparound. - Terminal write failure or partial frame. Ratatui/crossterm write errors
keep the terminal restoration guard armed. Cleanup restores cooked mode,
cursor visibility, bracketed-paste state, and the alternate screen on explicit
return and again best-effort from
Drop. The original write error wins over cleanup errors. - Post-flush catalog admission. Extension discovery, active configuration,
theme, keymap, runtime activation, terminal guards, cursor negotiation, and
the initial empty-editor frame remain eager. Rich mode then invokes one
bounded catalog loader synchronously after the first successful flush and
before event polling. It publishes one immutable
Arc<Catalog>generation to analysis, completion, picker/help, and the REPL only after every consumer is ready. Input arriving meanwhile remains queued by the terminal. Loader failure preserves the catalog error while the existing drop guards restore cooked mode, cursor visibility and shape, bracketed paste, and the alternate screen. Simple/degraded mode keeps eager catalog construction. - Queue or output flood. One UI turn polls at most one provider snapshot, applies at most eight panel updates, and performs at most sixteen data pulls before checking cancellation and scheduler state again. The panel queue holds at most 32 updates; overflow drops the oldest pending update and records one bounded notice. Transcript admission independently enforces 16 MiB and 50,000 logical lines. Repaints are coalesced to at most one per 16 ms poll turn.
- Non-TTY and tiny-terminal fallback. Non-TTY,
TERM=dumb, explicitly simple, and initially sub-five-row terminals use the bounded Reedline/simple path. A rich terminal resized below five rows uses the minimal editor/status layout and suppresses optional regions until space returns. Provider failure never changes command execution or native history. - Shutdown with blocked workers. The surface owns no extension worker.
Shutdown cancels the current generation through the existing scheduler and
waits only for its bounded safe point; an uncooperative callback is detached
by scheduler-owned
Arcstate and cannot delay terminal restoration. - Stale job selection. Picker entries contain a stable numeric job ID and
insert an explicit
fgorbgcommand. Selection never retains a process handle. The process owner revalidates the ID and state at execution, so a pruned or changed job produces the normal bounded stale-job diagnostic. - Oversized typed values. Data values are validated by the data runtime before rendering. Picker retention then caps labels, previews, value depth, field count, and encoded bytes independently. A value that is too deep, wide, or large may appear in the transcript through bounded incremental rendering but is omitted from the picker cache with a resource notice.
Concrete UI limits are eight panels, sixteen columns and 128 rows per panel, 4 KiB per title/heading/cell, 512 KiB retained panel text, 32 queued updates, eight applied updates per turn, six visible panel rows, four retained live generations per panel, 128 cached typed data items, 512 KiB cached data text, 256 display columns per data label, and 16 pulls per interactive data turn. Job snapshots retain at most 256 action items and 512 KiB of terminal-safe text. All optional regions are virtualized; offscreen rows stay within the declared snapshot bounds and are never rebuilt from Lua during a frame. The session transcript separately retains at most 16 MiB and 50,000 logical lines, and one selection/copy operation retains at most 1 MiB of plain text.