Skip to content

feat(tui): interactive TUI mode with search, ToC, and kitty images#3

Merged
rrbe merged 63 commits intomasterfrom
feature/tui-mode
Apr 20, 2026
Merged

feat(tui): interactive TUI mode with search, ToC, and kitty images#3
rrbe merged 63 commits intomasterfrom
feature/tui-mode

Conversation

@rrbe
Copy link
Copy Markdown
Owner

@rrbe rrbe commented Apr 18, 2026

Summary

Adds an interactive --tui mode to termdown built on ratatui/crossterm, with vim-style navigation, incremental search, a toggleable Table of Contents, multi-doc history with local link following, and kitty-protocol image rendering for H1-H3 headings.

  • Layout pipeline refactor: cat and tui now share layout.rs + RenderedDoc
  • Viewport with width-aware wrap cache, scroll state, and heading navigation (]/[, gg/G)
  • Search: literal smart-case with n/N jumps and inverse-highlight matches
  • Navigation: Enter follows local .md links (numeric overlay for ambiguity), back/forward history stack, ToC side panel
  • Kitty protocol: id-based place/delete lifecycle, per-frame register+place, reserved spacer rows so scroll math + draw + placements stay aligned, re-upload after \x1b[2J (Ghostty evicts cache)

Test plan

  • make check passes
  • termdown --tui README.md renders headings as images in kitty/Ghostty
  • j/k/gg/G/]/[ scroll correctly; last heading is reachable
  • / search + n/N works; matches are inverse-highlighted
  • Enter on a local .md link opens the target; back/forward history works
  • ToC panel opens/closes without breaking image placement
  • Heading images don't bleed into the status bar at the bottom of the viewport

🤖 Generated with Claude Code

rrbe and others added 30 commits April 18, 2026 10:23
Captures the library landscape survey, the mdfried performance
investigation, and the rationale for choosing ratatui with
self-managed Kitty image lifecycle.
Covers activation, module layout, data model, cat-path rewrite,
runtime state, event loop with layered rendering, key bindings,
search, Kitty image lifecycle (self-managed a=T/a=p/a=d), and
testing strategy for the --tui mode.
Detailed task-by-task plan covering: snapshot baseline, layout.rs
refactor + cat.rs rewrite, TUI scaffold, Kitty image lifecycle,
navigation polish, search, ToC panel, and multi-file back/forward
with link opening. Each phase ends with make check as the gate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace module-level #![allow(dead_code)] with per-item attributes
  in layout.rs so future items are not silently suppressed
- Add TODO comments on each allow noting the consumer task
- Derive PartialEq, Eq on all pure data types in layout.rs and render.rs
  so upcoming layout tests can use structural equality directly
- Restore inline comment on LineKind::Heading.id explaining H1-H3 vs H4-H6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
H1-H3 headings are rendered to PNG via render::render_heading and stored
in doc.images with unique ids. All H1-H6 produce a HeadingEntry in
doc.headings and a Line with LineKind::Heading. Falls back to bold text
span on font miss or for H4-H6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire main.rs to use `layout::build` + `cat::print` instead of the
monolithic `markdown::render`. Close the behavior gaps that surfaced
when the snapshot suite ran against the new path:

- emit `LineKind::Blank` between blocks in layout so the cat path no
  longer has to manage block-gap state
- seed each list item's text buffer with the per-depth indent plus the
  bullet or ordered-number marker, and drop the stale bullet-prefix
  logic from `cat.rs`
- flush pending spans when a sublist starts inside an item so nested
  item content no longer leaks into the parent, and suppress the empty
  "phantom" item line emitted after a nested list
- split paragraphs at SoftBreak/HardBreak into separate lines within
  the same block (with list-item indent when applicable)
- port HTML handling from `markdown.rs`: strip `<!-- ... -->` in HTML
  blocks, and map inline `<b>`, `<i>`, `<u>`, `<s>/<del>/<strike>`,
  `<code>/<kbd>`, `<br/>`, `<hr/>` to the correct style / line break
- pad inline code with a space on each side so the background matches
  the legacy look
- batch consecutive `LineKind::CodeBlock` lines in `cat::print` and
  pad each line to the group max width so the colored background
  forms a uniform rectangle
- switch table-cell width measurement to `style::display_width`
  so CJK and emoji widths agree with the rest of the renderer

Update the frozen snapshots in `fixtures/expected/*.ansi` to absorb
benign ANSI-ordering drift (e.g. `fg;bg` vs `bg;fg`, dim-span spans
covering slightly more of the separator column); the stripped-ANSI
content is byte-identical to the pre-refactor baselines for all five
fixtures.

`markdown.rs` now carries a module-level `#[allow(dead_code)]`
because nothing imports it anymore; Task 1.10 will delete the file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrated all still-relevant tests from markdown::tests to their new homes:
- cat::tests: wrap_text_keeps_single_overlong_word_intact,
  wrap_text_uses_display_width_when_ansi_and_wide_chars_are_present,
  write_paragraph_wraps_quoted_content (adapted from flush_line test)
- layout::tests: parse_html_fragment_recognizes_every_shape,
  apply_inline_tag_on_off_maps_known_format_tags (adapted API),
  strip_html_comments_handles_inline_and_multiline,
  emit_table_aligns_columns_using_visual_width (adapted API)

Dropped with reason:
- flush_html_block_prefixes_margin_and_dims_lines: flush_html_block was
  inlined into the layout::build event loop; behavior covered by
  build_html_block_emits_body_line_per_source_line.
- handle_inline_break_flushes_and_indents_for_list_item: handle_inline_break
  was inlined; behavior covered by existing SoftBreak/HardBreak layout tests.

Lib test count: 27 → 34. 0 clippy warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire App + Viewport into the event loop; draw visible logical lines as
ratatui Paragraphs; handle j/k / Up/Down scroll and q / Ctrl-C exit.
Narrow #![allow(dead_code)] in viewport.rs to just the two fields
(byte_start, byte_end) and the method (total_visual_lines) that future
tasks will consume.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add four Kitty graphics protocol lifecycle functions (transmit, place,
delete_placement, delete_all_for_client) with Write-generic signatures
so TUI code can buffer them and tests can assert byte format exactly.
Also adds five unit tests in render::kitty_tests covering each primitive
and the chunked-transmission path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Split the terminal area vertically (height-1 body + 1 status row).
App gains a `path` field; draw() renders DarkGray/White status bar
showing the file path and scroll progress percentage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
rrbe and others added 28 commits April 18, 2026 16:00
- Add LinkSelect mode variant to Mode enum
- Add visible_links() helper (deduplicates wrapped logical lines)
- Wire Action::OpenLink: 0 links = no-op, 1 = open directly, N = LinkSelect
- Add handle_link_select_key: digit 1-9 opens that link, Esc exits
- Add open_url() using `open` (macOS), `xdg-open` (Linux), cmd /C start (Windows)
- Draw numbered link overlay in status bar when in LinkSelect mode
- Add short() helper for truncating long link labels in the overlay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Thread config+theme through App so open_link_target can call
layout::build when a link resolves to a local .md file. External
URLs and unresolvable paths continue to use spawn_open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add TUI mode subsection to README.md and README_CN.md with key-binding
tables; confirm --tui flag is already present in --help output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Change `transmit` from `a=T` (transmit+display) to `a=t` (transmit
  only) so images are cached without polluting the screen at startup.
- Change `place` to emit a CUP escape (`\x1b[row+1;col+1H`) before the
  `a=p` command; Kitty's `x=`/`y=` APC keys are source-pixel crop
  offsets, not terminal cell coordinates, so explicit cursor positioning
  is required to land images at the correct row.
- Update all tests in kitty_tests and tui::kitty::tests to match the
  corrected wire format.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five interlocking TUI rendering bugs caused severe visual corruption
after the kitty cursor-move fix landed:

1. Kitty's `a=p` placement advanced the cursor past the image's cell
   area. When a tall heading image sat near the bottom of the viewport,
   that advance pushed the cursor below the last row and the terminal
   scrolled the entire visible region upward. Each subsequent keypress
   re-placed the image and scrolled again, causing the status bar to
   creep up the screen and text from prior frames to persist underneath
   new text. Fix: emit `C=1` on every place, which tells kitty to leave
   the cursor in place after rendering.

2. Body text rendered at column 0 instead of the 4-column margin cat
   mode uses. Fix: prepend a `MARGIN_WIDTH` space span to every body
   RLine in the draw loop.

3. H2/H3 image row estimates were hardcoded (4/3) and undercounted the
   image's real cell-row footprint, so the bottom of each heading image
   overran into the next line of text. Fix: carry PNG pixel height
   through `HeadingImage::px_height` and refine each `Span::HeadingImage
   { rows }` at TUI startup (and on every doc push) using the real
   terminal cell pixel height reported by `crossterm::window_size`.
   Terminals that don't report pixel size keep the old per-level
   estimates.

4. Ratatui's diff-based rendering only rewrites cells whose buffer
   content changed between frames. Cells previously obscured by kitty
   image pixels can stay visually stale even when the buffer itself
   didn't change. Fix: on every user-input event, set a
   `needs_full_redraw` flag and, on the next iteration, call
   `terminal.clear()` and reset all kitty placements before drawing.
   Since we only redraw on actual events, this doesn't produce
   visible flicker.

5. Viewport width/height were cached at startup and never resynced,
   so terminal resizes left the body area sized to the launch
   geometry. Fix: re-read `terminal.size()` at the top of every
   event-loop iteration and update `viewport.width` / `viewport.height`
   (width change implicitly invalidates the wrap cache).

Snapshot tests and all 77 unit/integration/snapshot tests remain green.
Cat mode is untouched.
Headings with kitty images occupy N screen rows, but viewport wrap only
emitted one VisualLine per heading. Scroll math, draw(), and placement
diverged — the last headings were unreachable and images could overflow
the body into the status bar. Emit N-1 spacer VisualLines per heading so
all three stay consistent, and skip placements whose row budget would
not fit within body height.

Also re-upload image data after terminal.clear(): Ghostty evicts cached
kitty image bytes on \x1b[2J, so a later a=p references an unknown id
and silently drops the placement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Setting needs_full_redraw after every event caused a \x1b[2J + full PNG
re-upload on every keypress. At macOS key-autorepeat rate (~30 Hz) this
produced visible flicker when holding j/k. The C=1 flag from bec090a
already prevents cursor advance, so the blanket full-clear is no longer
needed as a safety net.

Restore the design spec's poll → draw → sync loop. Set needs_full_redraw
only for events that actually require a full clear: ToC toggle (width
change), Back/Forward navigation, and opening a new doc via a local
link. Scroll / search / mode-change events now rely on ratatui's cell
diff + images.sync() — as TUI_MODE_DESIGN.md originally specified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Post-review cleanup from PR #3:

- Remove SearchState.query / SearchState.direction and the Direction
  enum — both were write-only and carried `#[allow(dead_code)]` on
  public fields, violating the no-speculative-API convention.
  SearchState::new now takes &str.
- Add a regression test asserting Ctrl-d/u/f/b hit the same Action as
  the bare letters. The bare-code match arms already give us this for
  free, but the test makes the contract explicit so a future refactor
  that adds `if !ctrl` guards can't silently break vim muscle memory.
- Update TUI_MODE_DEBUG_LOG: document the Round 3 flicker fix
  (46d7503) and prune the open-risks list down to what is genuinely
  still unverified on a real TTY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Font rasterization produces OS-specific PNG bytes embedded in kitty APC
sequences, so the prior fixtures failed in CI on Linux/Windows. Collapse
each image run into a single `<IMG>` marker before comparing, pin stdin
to null for deterministic width, and regenerate the expected files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windows runners use core.autocrlf=true by default, converting committed
LF to CRLF on checkout. Snapshot tests do byte-exact string compares,
so every line mismatched. Mark .ansi snapshots as binary (no conversion)
and force fixture .md to LF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ghostty auto-enables macOS Secure Keyboard Entry when it detects a
foreground process with ECHO off, treating it as a password prompt.
Only iTerm2 ignores the Kitty `q=2` response-suppression flag, so gate
disable_echo() on TERM_PROGRAM=iTerm.app and leave termios alone on
Ghostty/Kitty/WezTerm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d drain sleep

Restores sub-250ms cat-mode startup on a 22-heading README (down from
~1-2s on this branch). Three changes:

- font: memoize FontSet per heading level in a process-wide OnceLock
  array so SystemSource::new() and font-family resolution run once
  per level instead of once per heading (~30-40ms saved each).
- layout: defer H1-H3 rasterization until after the parser walk, then
  run render_heading via rayon par_iter; image IDs are still assigned
  in document order so output stays deterministic.
- render/main: split drain_kitty_responses into flush_stdin (cheap,
  always) and drain_iterm2_acks (50ms sleep, only when running under
  iTerm2 where ECHO suppression is active).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- font: note that get_fonts caches per-level, so the first call's config wins.
- layout: drop WHAT-narration comments around the deferred-rasterization
  pipeline; keep only the WHY notes (determinism, conservative upper bound).
- Simplify level-to-index conversion in get_fonts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolving fonts for H1/H2/H3 used to call SystemSource::new() three times,
each walking the OS font registry (~20-30ms on macOS). Cache the source in
a process-wide OnceLock so the registry walk happens at most once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…colors

Search prompt and path/percent indicator previously overlapped on the
same row. Split the status bar into a left region (mode prompt) and a
right region (path + percent), shrinking percent then middle-truncating
the path when space is tight. Replace fixed DarkGray/White colors with
theme-aware pairs so light terminals get adequate contrast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On Linux `SystemSource` is `FontconfigSource`, which wraps a raw
`*mut FcConfig` and is neither `Send` nor `Sync` — so the `OnceLock<SystemSource>`
static from b8dcafc failed to compile on CI (Ubuntu) while building fine
on macOS, where the CoreText-backed `SystemSource` happens to be both.

Switch to `thread_local! { OnceCell<SystemSource> }`. Same per-call
amortization the original perf commit was after, and thread-local is
also the correct scope: fontconfig's `FcConfig` isn't designed to be
shared across threads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Track the heading the viewport is currently inside and render it as the
selected entry in the TOC list. `render_stateful_widget` auto-scrolls the
sidebar so the active entry stays visible on long docs. `[` / `]` already
move `viewport.top`, so the highlight follows for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Underline link spans in the TUI body with the theme link color so
readers can see what's followable; external links additionally get
italic to distinguish them from local .md links (in-TUI navigation
vs OS opener). Search highlight still wins on fg/bg but preserves
the underline modifier so links remain identifiable through matches.

Add fixtures/links/ as a small interlinked set for manually
verifying Enter-on-.md-link, back (o), and forward (i).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture two candidate replacements for the 9-label status-bar picker:
inline Vimium-style hints (Option C) and a ToC-parallel Links side
panel (Option D), with tradeoffs and open questions for each.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebind ? from reverse-search to a centered help overlay listing all
TUI shortcuts. Filter heading-image placements that overlap the popup
so kitty graphics (which render above text cells) don't bleed through,
and note the limitation in the Known Issues section of both READMEs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rename the action to match what the Normal-mode handler actually does
(opens Help; closing lives in handle_help_key). Drop Enter from the
Help close set so the status-bar hint matches reality. Add unit tests
for help_popup_intrinsic_size and help_popup_rect covering centering,
the 90% cap, and body-offset handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@rrbe rrbe merged commit 1c89f0d into master Apr 20, 2026
9 of 10 checks passed
@rrbe rrbe deleted the feature/tui-mode branch April 20, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant