Quirl0.1 RC
Architecture

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 ("Ratatui composes the active prompt region and opt-in panels without stealing normal terminal scrollback") into an implementation contract. ADR 0012 accepts Ratatui as the capable-TTY default and retains Reedline as the simple fallback. 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 inline frame: a small, dirty-tracked UI region anchored at the bottom of the normal terminal flow. It owns the prompt, a syntax-highlighted editor line, a completion popup with a documentation pane, a diagnostics row, and a persistent bottom status bar. Everything above the frame is ordinary terminal scrollback — command output is never trapped inside a TUI.

What is kept from today's implementation:

  • QuirlPrompt segment model and PromptContextScheduler (async git/cwd, stale-while-refresh, per-segment deadlines).
  • Catalog::complete, quirl-picker ranking, and the frozen completion protocol v1 (CompletionWorker, request/cancel envelopes, ≤250 ms deadline, ≤1000 results).
  • Lua extension prompt segments, completion providers, and asynchronously cached PanelModel regions. 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 the simple fallback; its removal is not part of the accepted baseline.
  • The heuristic SemanticHighlighter is replaced by real lexer-driven spans from quirl-syntax (new public API, §6).

2. Goals and non-goals

Goals:

  1. IDE-grade editing at the prompt: real syntax highlighting, inline diagnostics before Enter, rich completion with docs and provenance.
  2. A persistent bottom status bar showing mode, keymap state, key hints, and transient notices.
  3. Scrollback stays native. Command output, Ctrl-Z, PTY handoff to vim/less, and copy/paste behave exactly like a classic shell.
  4. Meet the §12 budgets: keystroke-to-frame ≤8 ms P95, first prompt paint ≤21 ms P95, cold start ≤25 ms P50.
  5. Graceful degradation to a line-oriented experience with the same parser and completion data (§12 terminal contract).

Non-goals:

  • No alternate-screen full-app mode for the shell itself. picker.layout = "full" expands the picker to the terminal-height inline viewport; it does not trade native scrollback and lifecycle safety for an alternate screen.
  • 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
  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 tiers

Everything 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: inline viewport per prompt

The frame uses Viewport::Inline(h) — anchored to the cursor row, full width, scrollback preserved above. Height is fixed at terminal construction, so the surface wraps it:

/// Owns the ratatui Terminal. Recreates it when the frame needs to grow or
/// shrink (popup opened/closed), keeping the frame's top row visually stable.
struct SurfaceTerminal {
    terminal: Option<ratatui::Terminal<CrosstermBackend<Stderr>>>,
    height: u16,
}
impl SurfaceTerminal {
    fn ensure_height(&mut self, rows: u16) -> Result<(), ShellError>;
    fn draw(&mut self, frame: &FrameModel) -> Result<(), ShellError>;
    /// Restore cooked mode + leave the frame as plain text; used before exec.
    fn release(&mut self) -> Result<(), ShellError>;
}

insert_before is a future lifecycle extension for asynchronous notices; it is not part of the current SurfaceTerminal API.

Rules:

  • Draw to stderr, not stdout. Non-interactive stdout stays stable, undecorated, and control-sequence-free (§12).
  • Base frame height is 3 rows (context, input, status bar). It grows for the diagnostics row (+1), multi-line input (+n), and the completion popup / picker overlay (up to picker layout limits, default max 10 popup rows). Growth re-initializes the inline viewport at the new height; ratatui appends lines (scrolling if at screen bottom). The scrolling-regions feature is enabled for the future insert_before path, but no editing-time scrollback notice insertion is currently performed. If re-init proves visually unstable on a Tier 1 terminal, the recorded fallback is fzf-style: reserve the maximum frame height up front while a popup-capable state is possible. Decide with evidence, note the result in the ADR.
  • Raw mode is enabled only while the frame is live. Bracketed paste is enabled through crossterm and restored by the terminal guard. Kitty keyboard and synchronized-output negotiation remain future progressive enhancements; Shift-Tab works today through crossterm's Shift-Tab/BackTab events.

3.3 The per-command cycle (scrollback contract)

┌──────────────────────────── one REPL iteration ────────────────────────────┐
│ 1. build FrameModel; create inline viewport; enter raw mode                │
│ 2. edit loop: keys → EditorState; async events → completion/segments;      │
│    redraw only when dirty                                                  │
│ 3. on Accept: collapse frame to one transient prompt line (§5.5), release  │
│    the terminal entirely (cooked mode, viewport dropped)                   │
│ 4. classify() → execute through the existing native executor with          │
│    inherited stdio/PTY; job control, Ctrl-Z, vim, less all work untouched  │
│ 5. output lands in normal scrollback; errors via quirl_ui::render_error    │
│ 6. loop → step 1                                                           │
└─────────────────────────────────────────────────────────────────────────────┘

The frame never exists while a foreground command runs. This single rule is what keeps PTY handoff, suspension, and signal semantics identical to today. Editing-time insert_before notices for background jobs, config reloads, and plugin diagnostics are a planned extension and are not emitted by the baseline.

3.4 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 (reuse unicode-segmentation / unicode-width, already workspace deps). Multi-line editing: when quirl-syntax reports 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-Y semantics in the Emacs keymap; Ctrl-K opens 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 lines in 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 existing editor.keymap config values. The baseline centralizes bindings in EditorState::apply_key, but still uses explicit match branches. Data-driven (mode, key) -> EditAction tables and user remapping are future work. Helix and Vim modal states (NOR, INS, and Vim VIS) appear in the status bar. Reedline may only be removed once all three keymaps pass the shared keymap conformance test suite.
  • History recall: Up/Down prefix-aware cycling; Ctrl-R opens 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)

KeyActionNotes
TabOpen/advance completion popuppreserves today's behavior
Shift-TabExpand completion into full picker§10 contract
EnterAccept (or newline if input incomplete)
Alt-EnterForce newline
Alt-MCommand/data mode toggleexisting quirl:mode-toggle host action
Ctrl-SpaceCommand/data mode toggle compatibility aliassome terminals cannot distinguish this from NUL
Ctrl-R / Ctrl-T / Alt-C / Ctrl-KHistory / files / directories / palette pickerexisting bindings + Alt-C
Ctrl-G / Alt-DActive jobs / cached typed-data pickersnapshots only; selection inserts a revalidated command or data expression
Ctrl-CClear line, dismiss popup; never exits
Ctrl-DEOF on empty line → exit
Ctrl-ZRelease terminal, SIGTSTP selfresume redraws frame
Ctrl-LClear screen above frame, redraw
EscPopup dismiss → helix NORlayered: popup first

5. Visual specification

All mockups assume a 78-column terminal, unicode symbol profile, defaults. Glyphs come from PromptSymbols profiles — plain profile substitutes ASCII (>, data>, *, !) exactly as today; never require Nerd Fonts.

5.1 Frame at rest (command mode)

 ~/projects/quirl  main ✚2                                 1 job · 412ms · ✘1
❯ cargo build --release▌
 command   Alt-M mode · Tab complete · ^K palette · ^R history        quirl

Row 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: mode indicator ( command / data), one space, the highlighted buffer, hardware cursor at the edit position (Frame::set_cursor_position). Inline history autosuggestion renders dim after the cursor.

Row 3 — 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: display value with match_indices highlighted in the accent color, then summary text. Kind glyph column (command λ, flag , path /, value , history ; ASCII fallbacks c f p v h). Max 10 rows, virtualized scrolling with a 1-cell scrollbar when overflowing.
  • Right pane (docs): detail, documentation, and a provenance footer (source · trust, derived from matching catalog provenance). Hidden for a normal popup when terminal width < 100. Narrow mode retains the list and count/source status; showing the selected summary in the status bar remains future polish.
  • 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 ASCII E 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; explain remains 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 Transient prompt (after Accept)

On Accept the frame collapses to a single scrollback line and the viewport is released before execution:

❯ cargo build --release                                            ✔ 2.31s
   Compiling quirl-core v0.1.0 ...

The shipped transient line is indicator + escaped executed buffer, committed before execution. It does not retain syntax styles or retrofit a result glyph. Final highlighting and a right-aligned ✔ duration / ✘ code are future work only if they can be proven not to reflow scrollback. Controlled by prompt.transient = true (default).

5.6 Picker overlays

Ctrl-R/Ctrl-T/Alt-C/Ctrl-K/Ctrl-G/Alt-D reuse the frame: the popup region becomes a picker (query row + virtualized result list + optional preview pane), honoring picker.layout:

  • adaptive / bottom: inside the inline frame, max 10 result rows.
  • full: terminal-height inline picker with the same RAII lifecycle. A true alternate-screen picker is deliberately unsupported because it would weaken the shell's native-scrollback invariant and require a separate lifecycle.

The shipped Ctrl-K command palette always requests a terminal-height inline viewport and positions its bounded adaptive content against the bottom edge. This is not an alternate-screen transition: Ratatui may scroll visible content upward to reserve the viewport, but that content remains in native scrollback, and the same inline terminal guard releases the viewport before execution, suspension, or error return. Closing the palette recreates the compact viewport against the prior bottom edge so ordinary prompt rendering does not remain near the top of the terminal.

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/Quoting machinery inside lex_command, not a second tokenizer. A property test asserts highlight never disagrees with parse_command_list about quoting boundaries.
  • Mode::Data may 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:

  • Command spans resolve against Catalog (+ alias table + $PATH lookup 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.
  • Flag spans check the resolved command's ArgumentSpecs: undeclared flags render as flag.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 $PATH cache 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.

RoleDefaultUsed by
accent.commandgreenindicator , popup selection, match highlights
accent.datamagentaindicator and all accent uses in data mode
command.known / command.unknowngreen / redinput row
flagcyaninput row
stringyellowquoted regions
operator / redirectwhite bold|, &&, >
expansionblue$VAR, $(...)
suggestiondark gray italicinline history hint
severity.error/warn/hintred / yellow / bluediagnostics, status bar
chrome.border / chrome.dimdark graypopup 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.

ZoneContentRules
LeftKeymap state (NOR/INS/VIS for helix, hidden for emacs) + mode name (command/data) in the mode accentalways visible, never truncated
CenterContextual: fixed shipped key hints at rest; result count + source + streaming… while completing; ⇪ pasted n lines; editor resource-limit notices; compact-terminal diagnosticstruncates first
RightContextual completion hints (↑↓ move · Enter accept), timing P95 when enabled, else short brandtruncates 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:

ConditionBehavior
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_COLORRich layout, modifier-only theme (§7)
width < 100Normal completion documentation pane hidden; the list and result count remain. A terminal-height full picker may show preview from width 72 when configured
width < 60Status 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):

MeasureBudgetOwner
Keystroke → frame flushed≤8 ms P95event loop; one draw per batch
Lex + resolve + style≤8 ms P95§6 cache
First prompt paint≤21 ms P95context row paints with cached/stale segments; scheduler fills in
Cold start → editable≤25 ms P50rich catalog admission follows the first flush and completes before input polling; $PATH warmup remains lazy
Completion: local results visible≤8 mscatalog worker publishes independently; extensions merge later
Memoryvirtualized/bounded popup and picker; one revision-cached span vector; 64 KiB editor; bounded undo/history

The first-paint budget is a P95 wall-clock bound over fresh PTY processes. It includes the inline viewport's cursor-position handshake 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 against parse_command_list quoting.
  • 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 CompletionWorker test harness.
  • Degradation: each row of the §9 table has a test fixing the decision.
  • Lifecycle: release/re-init around execution — after a simulated command, the frame reconstructs and prior scrollback lines are untouched (TestBackend cursor-position setup as in ratatui's inline docs).

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, lifecycle reconstruction, and named terminal snapshots) remains release-evidence work. cargo xtask rich-pty covers deletion, wrapping, Alt-M repaint, completion, execution handoff, 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 advance the contract to v3 with a deterministic v0/v1/v2-to-v3 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 = { "jobs", "duration", "status" },
    transient = true,                                      -- new (§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.

MilestoneCurrent statusRemaining acceptance work
M1 — Frame + editorLanded: inline viewport lifecycle, Quirl-owned 64 KiB grapheme editor, bounded undo/history, Emacs/Helix/Vim states, context/input/status rows, prefix history, autosuggestion, transient prompt, and execution/suspend handoffComplete named real-terminal lifecycle and latency evidence on Linux/macOS; decide terminal protocol negotiation
M2 — Highlighting + diagnosticsLanded 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 testsExpand generated totality coverage and record evidence that the 4 KiB/first-paint budgets pass on release terminals
M3 — Completion popupLanded: 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 renderingRecord named ≤8 ms first-result evidence and broader provider fault/terminal snapshots
M4 — Overlays + keymapsLanded: 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 inline full layouts honor preview config; Emacs/Helix/Vim editor modes remain availableDecide kitty/synchronized-output negotiation and gather named real-terminal layout evidence
M5 — Fallback retirementNot accepted or implemented. ADR 0012 flips auto to rich but deliberately retains Reedline for simpleSeparate decision, full conformance and accessibility evidence, minimal fallback replacement, and removal of Reedline from Cargo.lock

Bounded extension panels are now pinned into the inline 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 remains ordinary scrollback rather than becoming a full-screen watch application.


14. Open questions and recorded decisions

  1. Viewport growth: the baseline reinitializes the inline viewport when height changes. Record evidence on Ghostty, Terminal.app, iTerm2, and a Linux VTE terminal before calling the behavior release-proven.
  2. Transient result glyph (§5.5): the baseline commits only the indicator and accepted buffer. Retrofitting a result into prior scrollback is deferred.
  3. Data-mode lexer spans: extend highlight() when the data grammar exposes tokens; until then plain styling (§5.4).
  4. Status items from plugins: reuse ContributionKind or add a StatusItem kind — needs a protocol-compatibility check under ADR 0008 before exposing to Lua.
  5. Editing-time notices: add a bounded event queue and an insert_before lifecycle that cannot corrupt terminal state or delay cancellation.
  6. 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.
  7. 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 and terminal-safe typed models.

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-written scrollback is not reclassified as a successful value.
  • 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 inline viewport, 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.
  • 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 inline viewport 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 inline viewport. 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. 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 Arc state and cannot delay terminal restoration.
  • Stale job selection. Picker entries contain a stable numeric job ID and insert an explicit fg or bg command. 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 scrollback 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.

On this page