Skip to content

feat(tui): render through ratatui and own the input loop - #224

Open
bulters wants to merge 1 commit into
tcballard:mainfrom
bulters:feat/tui-ratatui-widget
Open

feat(tui): render through ratatui and own the input loop#224
bulters wants to merge 1 commit into
tcballard:mainfrom
bulters:feat/tui-ratatui-widget

Conversation

@bulters

@bulters bulters commented Sep 3, 2026

Copy link
Copy Markdown

Work toward OC-023 (#24): the rendering port and the input loop.

I originally proposed splitting these into two PRs. That split does not survive contact — ratatui assumes it owns the screen and the cursor, and that is only true in raw mode. A ratatui frame under line-oriented input corrupts itself: the terminal echoes typed characters into cells the buffer diff cannot see, ratatui hides the cursor unless told otherwise so the echo lands wherever the last diff write ended, and resize.rs only clears on a horizontal shrink so a grown viewport keeps whatever the smaller frame left behind. I had all three on screen before folding the loop in. Scroll/compose modes and reconnect/restoration still follow separately.

What changes

Rendering. UiModel renders through impl Widget for &UiModel instead of building an ANSI string by hand. Layout::vertical splits heading / body / status / prompt and Layout::horizontal splits sidebar / divider / message pane. The layout rules carry over unchanged: single-column fallback below 30 columns or 8 rows, sidebar at one third of width clamped to 18–28.

Input. The client enters raw mode and reads terminal events on a blocking thread that hands them to the runtime over a bounded tokio::sync::mpsc channel. That keeps the only new dependency on crossterm itself rather than pulling in event-stream and a futures stack, which matters against the = pins, the cargo-deny policy and the size ceiling. Composition, backspace, escape-to-clear and enter-to-submit are handled by the client, because raw mode retires the terminal's own line editor. Ctrl-C and Ctrl-D detach, since raw mode also suppresses the terminal's interrupt.

Geometry. Terminal::draw sizes from the backend, so the hardcoded render(80, 24) is gone, Event::Resize repaints immediately, and the narrow layout is reachable in the binary rather than only in tests. A resize forces a full repaint through clear_region plus swap_buffers rather than Terminal::clear, which first queries the cursor position — that fails outright on a pipe and would race the stdin reader for the reply in a terminal.

Redirected output. With stdout not a terminal there is no keyboard and no resize, so the client keeps reading whole lines and repainting a fixed 80×24 viewport. omachat | cat still produces the frame it always did.

Verification

Under a pty, starting at 80×24 and resized to 120×40 with no key pressed:

  • the divider moves from column 27 to column 29 as the sidebar clamp recomputes, and column 27 stops being addressed
  • addressed rows extend to 40
  • 728 bytes are emitted by the resize alone
  • keystrokes redraw incrementally before any Enter
  • Enter still reaches the daemon, and Ctrl-C exits 0

tests/ui.rs keeps the 80×24 and narrow cases and asserts against Buffer cells rather than escape-sequence substrings. The ANSI-16 rule becomes "no cell carries Color::Rgb or Color::Indexed(n > 15)", checked per cell — stricter than the previous !contains("38;2"), which could not see an extended-palette index at all. Added cases cover the sidebar appearing wide and absent narrow, the status bar and prompt, caret placement on the prompt row, and a TestBackend draw through a real Terminal.

Six tests pass, plus the full docs/development.md suite: fmt, clippy with -D warnings, workspace tests, rustdoc, both builds, version contract, release size, packaging.

One caveat on that, since I would rather report it than round it up: cargo test --workspace failed once here on omachatd's reconnect_drains_a_queued_private_message_once, with Protocol(WrongHttpMethod) at crates/omachatd/tests/core.rs:579 followed by the drain timeout at :639. It looks pre-existing and unrelated to this branch. Measured over 15 runs per tree on the same machine, it fails 1/15 on this branch and 1/15 on upstream/main. The lock diff here is purely additive (no package version changes at all) and nothing in this diff touches omachatd, so both test binaries build from identical inputs. The stub server accepts exactly two connections in sequence and assumes the client makes exactly those two with nothing in between; a reconnect race would explain a non-upgrade request reaching accept_async. Happy to open a separate issue with the reproduction if useful.

Dependency and policy note

ratatui = "=0.30.2" with default-features = false, features = ["std", "crossterm"]. Defaults pull every backend — termwiz, termion, termina and the wezterm stack — so they are off. crossterm is used through ratatui's re-export rather than as a second direct dependency, so the backend and the event API cannot drift apart.

This PR needs a deny.toml decision from you. ratatui reaches foldhash through hashbrown, via both kasuari (the layout solver) and lru in ratatui-core. It is a hard dependency, not feature-gated, and foldhash is Zlib-licensed, which is not on the allow list. ratatui 0.29 does not avoid it — it takes the same path through lru.

I added a scoped exception rather than widening the workspace list:

[[licenses.exceptions]]
name = "foldhash"
allow = ["Zlib"]

Zlib is permissive with no source obligation, and scoping it to one crate keeps the policy narrow. cargo-deny 0.20.2 check licenses bans sources reports bans ok, licenses ok, sources ok with this in place. If you would rather add Zlib to the main allow list, or would rather not take ratatui on these terms at all, say so and I will rework it.

Size

omachat grows 536,720 -> 726,448 bytes with opt-level = "z" and fat LTO. The aggregate moves 5,924,280 -> 6,113,272 bytes, leaving 4.17 MiB under the 10 MiB ceiling. The other two binaries are unchanged.

Known limits, deliberately left for the follow-ups

Replace the hand-built ANSI string renderer with `impl Widget for
&UiModel`, keeping `UiModel` as the state type, and move the client into
raw mode so it owns the screen and the keyboard.

These were meant to be two changes. They do not separate: ratatui
assumes it owns the screen and the cursor, which is only true in raw
mode. Under line-oriented input the terminal echoes into cells the
buffer diff cannot see, ratatui hides the cursor unless told otherwise
so that echo lands wherever the last diff write ended, and ratatui only
clears on a horizontal shrink, so a grown viewport keeps whatever the
smaller frame left behind.

`Layout` splits heading, body, status, and prompt, and the sidebar,
divider, and message pane. The single-column fallback still triggers
below 30 columns or 8 rows, and the sidebar is still one third of the
width clamped to 18-28.

Terminal events are read on a blocking thread and handed to the runtime
over a bounded channel, which keeps the new dependency surface at
crossterm rather than an async event-stream stack. The client composes
input itself because raw mode retires the terminal's line editor, and
Ctrl-C or Ctrl-D detaches because raw mode suppresses the terminal's
interrupt. Redirected output keeps the line-reading path and a fixed
80x24 viewport.

Snapshot coverage moves from escape-sequence substrings to `Buffer`
cells: the ANSI-16 rule is asserted per cell as neither `Color::Rgb`
nor `Color::Indexed(n > 15)`, which the previous `38;2` check could not
see. Verified under a pty that a resize alone reflows the frame, moving
the divider from column 27 to 29 and extending addressed rows to 40.

ratatui reaches Zlib-licensed `foldhash` through `hashbrown` as a hard
dependency of `ratatui-core`, so `deny.toml` gains an exception scoped
to that crate rather than a wider allow list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0144gZP6sbqUxk4m1qpjAAmU
@bulters
bulters force-pushed the feat/tui-ratatui-widget branch from 9bdb3e6 to 55c9667 Compare September 3, 2026 12:30
@bulters bulters changed the title feat(tui): render the client through a ratatui widget feat(tui): render through ratatui and own the input loop Sep 3, 2026
@tcballard

Copy link
Copy Markdown
Owner

Thanks @bulters — this is an excellent contribution and very much the direction we intended for OC-023.

Combining the ratatui rendering port with the raw input loop makes sense given the terminal-ownership issues you found. The bounded event handoff, ANSI-16 testing, resize behaviour and redirected-output path are all well considered. I’m also happy with the narrowly scoped Zlib exception for foldhash; that is preferable to widening the workspace allow-list. The size increase remains comfortably within our 10 MiB ceiling.

I found two things I’d like addressed before merging:

  1. Interactive mode currently checks only stdout().is_terminal(). If stdin is piped while stdout remains attached to a terminal, OmaChat will try to enter raw mode against non-terminal input and can fail instead of consuming the lines. Could you require both stdin and stdout to be terminals before selecting the raw-event path, and add a regression test for piped stdin?
  2. prompt_cursor() uses self.input.chars().count(), which does not match terminal cell width for CJK characters, emoji or combining marks. Could you calculate display width using the same width semantics as ratatui and add focused Unicode cursor tests?

Please also update docs/implementation-status.md, which still says the TUI is line-oriented, and correct the now-stale prompt_cursor() comment saying the client is still line-oriented.

The remaining work you identified—live event consumption, reachable scroll mode, reconnect and signal/panic-safe restoration—can stay in follow-up PRs. Your analysis in #223 also looks like the right boundary: IPC demultiplexing belongs in the client layer rather than pushing protocol state into the TUI.

The intermittent daemon test appears unrelated based on your branch-versus-main reproduction. Please open a separate issue with those results so we can track it without blocking this PR.

Once those focused changes are pushed and the fork workflows are green, I’m happy for this to merge. Really appreciate the care taken here.

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.

2 participants