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:
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.
- Scrollback stays native. Command output,
Ctrl-Z, PTY handoff to vim/less, and copy/paste behave exactly like a classic shell. - 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 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 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: 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
pickerlayout limits, default max 10 popup rows). Growth re-initializes the inline viewport at the new height; ratatui appends lines (scrolling if at screen bottom). Thescrolling-regionsfeature is enabled for the futureinsert_beforepath, 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-Tabworks 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 (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;Ctrl-Kopens 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-M | Command/data mode toggle | existing quirl:mode-toggle host action |
Ctrl-Space | Command/data mode toggle compatibility alias | some terminals cannot distinguish this from NUL |
Ctrl-R / Ctrl-T / Alt-C / Ctrl-K | History / files / directories / palette picker | existing bindings + Alt-C |
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 — 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 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: 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:
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, 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 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 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/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 | indicator ❯, popup selection, 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.
| 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 | virtualized/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 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.
- 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.
| Milestone | Current status | Remaining acceptance work |
|---|---|---|
| M1 — Frame + editor | Landed: 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 handoff | Complete named real-terminal lifecycle and latency evidence on Linux/macOS; decide terminal protocol negotiation |
| 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: 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 inline 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 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
- 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.
- Transient result glyph (§5.5): the baseline commits only the indicator and accepted buffer. Retrofitting a result into prior scrollback is deferred.
- 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 an
insert_beforelifecycle 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 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
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 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.