feat(tui): interactive TUI mode with search, ToC, and kitty images#3
Merged
feat(tui): interactive TUI mode with search, ToC, and kitty images#3
Conversation
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>
- 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an interactive
--tuimode 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.catandtuinow sharelayout.rs+RenderedDoc]/[,gg/G)n/Njumps and inverse-highlight matches.mdlinks (numeric overlay for ambiguity), back/forward history stack, ToC side panel\x1b[2J(Ghostty evicts cache)Test plan
make checkpassestermdown --tui README.mdrenders headings as images in kitty/Ghosttyj/k/gg/G/]/[scroll correctly; last heading is reachable/search +n/Nworks; matches are inverse-highlighted.mdlink opens the target; back/forward history works🤖 Generated with Claude Code