Skip to content

Latest commit

 

History

History
213 lines (186 loc) · 12.4 KB

File metadata and controls

213 lines (186 loc) · 12.4 KB

TODO / future work

Deferred ideas captured so the MVP stays small but nothing gets lost. See DESIGN.md for the authoritative design and CLAUDE.md for conventions.

Side rail (left gutter)

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 satisfied
    • next — the cell that would run next
    • running
    • done (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?).

Code block output (Claude Code–style)

  • 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 N only when the process exited non-zero (✗ error · 0.4s · exit 2). Timing lives on CodeBlock (started_at/elapsed), the exit code threads through RunMsg::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.

Notes

  • These are explicitly post-MVP polish.

  • Cell execution + streaming: built. Enter/r on a runnable shell cell (sh/bash/zsh, not skip=true) runs it as its own process off-thread. Output streams back line-by-line via runner::run_streaming over a RunMsg channel arm in the draw loop (Output chunks then one Finished); each chunk appends to CodeBlock.output and bumps revision. The env map (Runbook::env_for) layers frontmatter envTMP_DIR → preceding answered input cells (DESIGN §4); before_each/after_each and interpreters.<lang>.path remap are honored. TMP_DIR is a real mktemp dir, cleaned on drop unless skip_cleanup. Output renders under the cell as the tail (last OUTPUT_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-TUI exec path.

    • stdout/stderr ordering: built. For shell cells the runner prepends exec 2>&1 (runner::merge_streams, gated by is_shell), so the child merges stderr into stdout at the source; we then read one ordered stdout stream (stream_inner dropped its two-pipe select!). True written order, no pty. Custom non-shell interpreters skip the merge (stderr → null). Strict mode is on too: before_each now defaults to set -eu when omitted (Runbook::script_for), overridable, with an explicit before_each: "" opting out. pipefail is not in the default — it isn't POSIX sh and would break dash.
    • Output sanitization: built. Cell output is cleaned at the TUI boundary before rendering (ansi::sanitize, called from output_lines and the Y copy-output path; CLI keeps raw bytes per DESIGN §7). It strips ANSI/CSI/OSC escapes (via strip-ansi-escapes), collapses \r progress rewrites to the final segment, normalizes CRLF, expands tabs to 8-col stops, and drops residual control bytes — ordered so \r/\t are handled before the vte-based strip (which would otherwise eat them). TUI runs also inject NO_COLOR=1 into 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/null to drop it, or stderr=$TMP_DIR/run.log to 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 revision bump). Lands with the overlay/DocLayout work.
    • 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.
  • 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 shows N/M complete run 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. fg is 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 DocLayout refactor below: streaming did not require it. The draw loop rebuilds the wrapped-line cache lazily — at most once per frame — so many Output chunks 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.

  • Input blocks: widget + state are built (confirm / input / select, with an Enter-to-edit Mode::Active focus model and an answered resolved() -> (target, value) seam, now wired into env_for). Still deferred:

    • option_file / $TMP_DIR resolution for select cells (currently only inline options render; option_file is parsed but not read). Now unblockedTMP_DIR exists, 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).

Testing & robustness

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::sanitize and the markdown → Runbook parse 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: feed sanitize random byte streams (assert the result contains no ESC/CSI/OSC and no C0 controls except \n); feed the parser random markdown/frontmatter (assert it returns Err or a valid Runbook, never panics). cargo-fuzz (libFuzzer) or arbitrary + proptest for property tests.
  • Round-trip / golden tests for output rendering — sample command outputs (git, cargo, docker progress bars, colored ls) snapshotted through sanitize to catch regressions in the strip/collapse/expand logic.
  • Streaming edge cases — escape sequences and \r rewrites split across chunk boundaries (the runner sends arbitrary-sized chunks; sanitize runs on the full accumulated buffer, so this should hold, but it's untested).

Decisions

Per-block layout model (deferred — now a large-doc perf optimization)

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 lines and 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.

Three tiers + where state lives

Keep three layers distinct:

  1. Source / parsemdastRunbook.blocks. Built once at load, immutable for the session (until live reload exists).
  2. Cell / selectable — the navigable units plus their state.
  3. Layout / physical lines — wrapped Vec<Line> per block, derived from 1 + 2 at a given width (the DocLayout above).

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, so j/k re-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 in ScrollState/App. Model state is the document; view state is throwaway.
  • The one deliberate exception: expanded is 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.