Deferred ideas captured so the MVP stays small but nothing gets lost. See
DESIGN.md for the authoritative design and CLAUDE.md for conventions.
A vertical status rail down the left edge of the document view, aligned to each runnable block, to make run state legible at a glance.
- Per-block status, by color — distinct colors/glyphs for each state:
blocked— earlier cell must run / gate not satisfiednext— the cell that would run nextrunningdone(success)error
- Run-order number — when a cell has been run, show a small ordinal (1, 2, 3, …) in the rail marking the order cells were executed. Absent until the cell runs.
- Only runnable cells (code; maybe input) get a rail marker; prose does not.
- Open question: how the rail interacts with the existing full-width selection highlight bar (layer the rail on top? reserve a left column outside it?).
- Truncated by default — show at most ~5–10 lines of a cell's output.
- Verbose toggle — a key/mode to expand a cell's full output on demand.
Run metadata on the cell — elapsed run time + exit code.Built. A finished cell's status line shows✔ ok · 1.2s(elapsed always) and appends· exit Nonly when the process exited non-zero (✗ error · 0.4s · exit 2). Timing lives onCodeBlock(started_at/elapsed), the exit code threads throughRunMsg::Finished { code }. A live ticking timer for a running cell is deliberately the footer's job (it redraws every frame), not the cell's — updating it in the cached doc would force a per-frame re-wrap.- Ties into the streaming/ANSI handling still under review in
DESIGN.md§7.
-
These are explicitly post-MVP polish.
-
Cell execution + streaming: built. Enter/
ron a runnable shell cell (sh/bash/zsh, notskip=true) runs it as its own process off-thread. Output streams back line-by-line viarunner::run_streamingover aRunMsgchannel arm in the draw loop (Outputchunks then oneFinished); each chunk appends toCodeBlock.outputand bumpsrevision. The env map (Runbook::env_for) layers frontmatterenv→TMP_DIR→ preceding answered input cells (DESIGN §4);before_each/after_eachandinterpreters.<lang>.pathremap are honored.TMP_DIRis a realmktempdir, cleaned on drop unlessskip_cleanup. Output renders under the cell as the tail (lastOUTPUT_MAX_LINES= 25 lines, "… N earlier lines" marker above). Status (CodeBlockState) and the output buffer are separate fields, per the three-tier note.runner::run_script(collect-all) is kept for the non-TUIexecpath.- stdout/stderr ordering: built. For shell cells the runner prepends
exec 2>&1(runner::merge_streams, gated byis_shell), so the child merges stderr into stdout at the source; we then read one ordered stdout stream (stream_innerdropped its two-pipeselect!). True written order, no pty. Custom non-shell interpreters skip the merge (stderr →null). Strict mode is on too:before_eachnow defaults toset -euwhen omitted (Runbook::script_for), overridable, with an explicitbefore_each: ""opting out.pipefailis not in the default — it isn't POSIXshand would break dash. - Output sanitization: built. Cell output is cleaned at the TUI boundary
before rendering (
ansi::sanitize, called fromoutput_linesand theYcopy-output path; CLI keeps raw bytes per DESIGN §7). It strips ANSI/CSI/OSC escapes (viastrip-ansi-escapes), collapses\rprogress rewrites to the final segment, normalizes CRLF, expands tabs to 8-col stops, and drops residual control bytes — ordered so\r/\tare handled before the vte-based strip (which would otherwise eat them). TUI runs also injectNO_COLOR=1into the child env (overridable) so many tools emit no SGR at all. Still deferred: - ANSI color rendering (DESIGN §7) — the MVP is the strip-everything path:
SGR color is removed, not rendered. The follow-up parses SGR into ratatui styles
(e.g.
ansi-to-tui) so colored output survives. Edge case the eventual vte-grid/SGR path also fixes: a tab right after an escape miscounts its column. - stderr redirection / output sinks (DESIGN §7) — let a cell send stderr
(and/or stdout) somewhere other than the merged inline view:
stderr=/dev/nullto drop it, orstderr=$TMP_DIR/run.logto capture it. Today shell cells merge stderr into stdout at the source (exec 2>&1) with no way to split or discard either stream; needs the runner to honor per-block redirection config instead of always merging. - Terminal fidelity / pty (DESIGN §7) — piped programs see no tty (so they disable color / use full buffering). A real pty would fix both; ordering itself is already handled by the source-merge above.
- Animated spinner while running — needs a draw-time overlay (the running
status line is in the cached doc, so it can't animate without a per-frame
revisionbump). Lands with the overlay/DocLayoutwork. Run metadata — elapsed time + exit code on the cell.Built — see the "Code block output" section above.- Verbose toggle to expand past the 25-line output tail.
- stdout/stderr ordering: built. For shell cells the runner prepends
-
Footer / status bar: wired to real run state. The bottom bar's badge now reflects an aggregate derived fresh each frame from
Runbook::run_counts():running(any cell executing) →error(any current failure) →done(any success) →ready. The braille spinner lives inside the badge, left of the text, and only animates while running; otherwise the badge shows a static state glyph (◦/✔/✗). The middle shows mode-aware key hints (navigate vs. edit); the right showsN/M completerun progress. The badge tracks run state only — input-editing mode is conveyed by the cell highlight, not the footer. Still to polish:- Tune the badge colors.
fgis currently black on every status; black-on-red (error) reads muddy and black-on-blue (ready) is low-contrast on some themes. Pick per-status fg/bg pairs with decent contrast (e.g. white-on-red for error), ideally against both light and dark terminal backgrounds.
Note on the
DocLayoutrefactor below: streaming did not require it. The draw loop rebuilds the wrapped-line cache lazily — at most once per frame — so manyOutputchunks between frames coalesce into a single whole-doc re-wrap at 30fps, not one per line. The refactor is now a large-document perf optimization (and the enabler for the animated spinner overlay), not a prerequisite for streaming. - Tune the badge colors.
-
Input blocks: widget + state are built (confirm / input / select, with an Enter-to-edit
Mode::Activefocus model and an answeredresolved() -> (target, value)seam, now wired intoenv_for). Still deferred:option_file/$TMP_DIRresolution for select cells (currently only inlineoptionsrender;option_fileis parsed but not read). Now unblocked —TMP_DIRexists, so a preceding cell can produce the file.- Multi-select, and a real terminal cursor for the text field (today's caret is a synthetic reversed cell, fine for the flat-line scrollview).
The current suite is unit-level and happy-path-ish. Before (or alongside) the CLI work, broaden it — especially around untrusted/odd input, where a panic is a real risk:
- More extensive testing, including fuzzing.
ansi::sanitizeand the markdown →Runbookparse path both take arbitrary bytes from the outside world (command output; user-authored runbooks) and should never panic, hang, or emit corrupting control bytes. Good fuzz targets: feedsanitizerandom byte streams (assert the result contains no ESC/CSI/OSC and no C0 controls except\n); feed the parser random markdown/frontmatter (assert it returnsError a validRunbook, never panics).cargo-fuzz(libFuzzer) orarbitrary+proptestfor property tests. - Round-trip / golden tests for output rendering — sample command outputs
(git, cargo, docker progress bars, colored
ls) snapshotted throughsanitizeto catch regressions in the strip/collapse/expand logic. - Streaming edge cases — escape sequences and
\rrewrites split across chunk boundaries (the runner sends arbitrary-sized chunks;sanitizeruns on the full accumulated buffer, so this should hold, but it's untested).
Today the document is rendered as one flat Vec<Line> cached by width + revision, with a ranges: Vec<Range<usize>> sidecar mapping block → line span.
Any content change bumps revision and re-wraps the whole document. This held
up fine through execution + streaming output: because the cache rebuilds lazily
(once per frame at most), a burst of streamed chunks coalesces into a single
re-wrap per frame, not one per line. So the trigger for this refactor is no
longer "streaming lands" — it's a document large enough that one whole-doc
re-wrap per frame is visibly costly, or wanting the draw-time overlay that
the animated running-spinner needs.
Decision: when we build execution + output blocks, move to a retained per-block layout:
struct DocLayout { width: u16, blocks: Vec<BlockLayout> }
struct BlockLayout { height: u16, lines: Vec<Line<'static>>, dirty: bool }- Scrolling runs off
height(prefix-sum); selection range = sum up to block. - Localized invalidation is the payoff: a changed block re-wraps alone;
others keep cached
linesand only their y-offset shifts (re-sum heights). Update cost goes O(whole-doc re-wrap) → O(one block) + O(blocks) re-sum. - A width change still triggers a full rebuild (rare; fine).
- Lazy/windowed materialization only for output blocks (which can be huge / streaming): cache height, materialize just the visible slice. Prose/code/input materialize eagerly — full laziness buys little there. Output is also capped to ~5–10 lines unless verbose, which sidesteps most of it.
- Watch scroll-offset stability when a block above the viewport grows: anchor scroll to a block + intra-block offset (or tail-follow only at bottom) rather than a raw global line index, so the view doesn't jump.
Keep three layers distinct:
- Source / parse —
mdast→Runbook.blocks. Built once at load, immutable for the session (until live reload exists). - Cell / selectable — the navigable units plus their state.
- Layout / physical lines — wrapped
Vec<Line>per block, derived from 1 + 2 at a given width (theDocLayoutabove).
Tier-2 state is not monolithic. Split it by (a) does it change the block's rendered lines, and (b) how fast does it change:
| State | Changes lines? | Frequency | Lives in |
|---|---|---|---|
| selected (highlight) | no — overlay | every j/k |
view state |
| active / focused | no (ring); yes (draft) | rare toggle | view state |
| status idle/run/ok/err | yes | few per run | model (on block) |
| streamed output | yes (appends) | fast | model (on block) |
| draft text (editing) | yes | fast | model-ish |
| expanded / collapsed | yes (height) | rare toggle | view state |
Design rules:
- Decoration that changes often but is cheap (selection highlight, rail
marker, focus ring) → draw-time overlay, never invalidates layout. Already
true: selection lives in
ScrollState, not the cache key, soj/kre-wraps nothing. Keep it that way. - Content that changes fast (output, draft) → per-block dirty, so a fast update re-wraps exactly one block.
- Model state vs view state: status, output buffer, answered value belong to
the document → store on the block (like
CodeBlock.state). Selection, scroll offset, focus, expanded-set are the UI's opinion → store inScrollState/App. Model state is the document; view state is throwaway. - The one deliberate exception:
expandedis view state yet changes height, so it must mark the block's layout dirty. Rare, so it's free — just conscious that "view state never touches layout" has that single hole.
Net: nothing is both fast and expensive, as long as decoration stays a draw-time overlay and the two fast content streams (output, draft) route through per-block invalidation.