feat(tui): polish TUI with markdown rendering, tool styling, and multi-line input#11
Open
feat(tui): polish TUI with markdown rendering, tool styling, and multi-line input#11
Conversation
Foundation for markdown rendering and multi-line input in the TUI polish pass.
Extract file_path for read/write/edit and pattern for glob/grep, giving every tool a one-line summary instead of only bash commands.
- Add markdown rendering module (tui-markdown / pulldown-cmark + syntect) with streaming-aware line-based commit boundary. - Add theme helpers for tool borders, tool icons, and thinking text; remove dead_code expects from now-consumed muted() and error() slots. - Add tool_call_icon() returning per-tool Unicode icons ($ → ← ✎ ✱ ⌕). - Rewrite ChatView with ChatEntry enum: User (left accent border), Assistant (markdown-rendered), ToolCall (icon + border), ToolResult (✓/✗ status indicator). - Add thinking buffer display (dimmed italic, respects show_thinking). - Add empty-state welcome message. - Wire show_thinking config through App → ChatView. - Forward ThinkingToken events to chat instead of ignoring them. - Use is_error from ToolCallEnd for error-styled results.
Rewrite InputArea using ratatui-textarea for proper multi-line editing.
- Dynamic height: grows from 1 to 6 visible lines as content expands.
- Enter submits, Shift+Enter inserts newline.
- Placeholder text ("Ask anything...") in dimmed style.
- Disabled state dims text; Ctrl+C still quits.
- Updated key binding hints to reflect new controls.
- Braille spinner (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) animates during streaming and tool execution at ~80 ms per frame via tick-based advancement. - Working directory displayed right-aligned in dimmed style, with home directory collapsed to ~/ prefix. - Dynamic input height: layout recalculates each frame as the textarea grows from 1 to 6 lines.
Update crate structure diagram with new markdown.rs module and revised component descriptions. Move TUI polish items from Current Focus to Working Today in roadmap; add viewport virtualization as remaining work.
- cwd_display_width used byte length (String::len) instead of display width (Span::width), causing misaligned right-alignment for non-ASCII paths. - Spinner frame and tick counter now reset on status transitions so the animation always starts from the first frame.
Every branch returned Some, making and_then semantically a map. Flatten to map + and_then + map_or_else for a clearer chain.
- Reorder helper definitions to match call order in build_text (welcome → user → assistant → tool_call → tool_result → thinking → streaming) for top-down readability. - Remove dead is_error parameter from push_tool_call_line — only call site always passed false. - Remove unused width/_width parameter from build_text, push_assistant_message_lines, and push_streaming_lines.
Display tool output content below the result indicator, truncated to 5 lines with a "… N more lines" overflow hint. Content inherits the left border pattern and uses dimmed text to stay visually subordinate to the result summary. Both claude-code and opencode show tool output inline — this was the single biggest UX gap in our TUI.
Track a monotonic boundary in the streaming buffer. On each new token, render only newly completed lines (from the boundary to the last '\n') and store them as owned Line<'static> in a cache. Subsequent frames emit the cached lines directly, then parse only the tail beyond the boundary. Previously, every frame re-parsed all committed text via render_markdown — O(n) per token as the response grows. Now the stable prefix is rendered once and cached, making per-token cost O(new lines). Inspired by claude-code's streaming markdown architecture, which splits at block boundaries with a monotonic stable prefix.
Compute padding from the terminal width instead of using hardcoded spaces, so the welcome message stays centered on narrow and wide terminals.
Add detailed findings from source-level analysis of claude-code and opencode, organized under h4 headings per topic area: - claude-code: streaming markdown (stable-prefix / unstable-suffix, LRU token cache, fast-path regex skip), thinking display (collapsed by default), tool polymorphism, input (vim, history, autocomplete), virtual scrolling (height cache, viewport culling, cascade prevention). - opencode: tree-sitter WASM markdown, two-tier tool display (InlineTool vs BlockTool with truncation limits), thinking at 60% opacity, input (extmarks, prompt stash, $EDITOR integration), footer (LSP/MCP counts). Update streaming markdown strategy to document the stable-prefix cache approach. Update design decisions to reflect implemented choices. Fix ratatui-textarea crate name in ecosystem table.
- ChatView: move show_thinking next to theme (config group), separate fields into config / persistent data / transient buffers / view state with inline group comments. Move MAX_TOOL_OUTPUT_LINES constant from ChatEntry section to ChatView section where it is consumed. - StatusBar: move cwd next to model (static display data), before status and spinner fields (dynamic state).
- Remove throbber-widgets-tui (never used — spinner is hand-rolled). - Remove syntect and ansi-to-tui as standalone entries (transitive deps of tui-markdown, not direct dependencies). - Merge Input & Interaction table into Rendering & Content. - Update Component trait example to match our actual interface (no init/update methods).
c63906e to
c968ac2
Compare
- README: add TUI to "What works today", remove stale "Next up: terminal UI" since TUI is shipped. - Roadmap: mention stable-prefix cache in markdown rendering bullet, add truncated output body to tool call display description.
Cover event, chat, status, and theme modules — raising overall line coverage from 72 % to 85 %. Notable additions: - event.rs: tool_call_title / tool_call_icon mapping, channel pair - chat.rs: build_text rendering for all entry types, streaming cache boundary tracking, scroll math, handle_event key/mouse dispatch, tool output truncation, Component::render via TestBackend - status.rs: tick state machine, set_status transitions, render output via TestBackend for idle / streaming / tool-running states - theme.rs: style helper foreground verification, modifier checks, composite helper assertions Also removes the stale #[expect(dead_code)] on Theme::info() since tests now exercise it (production-only dead_code expect kept).
- status.rs: swap set_status and tick sections (set_status precedes tick in production) - theme.rs: move thinking_is_italic to composite helpers section (thinking() is a composite helper, not a text style) - chat.rs: reorganize all test sections to follow production function order — append_stream_token → commit_streaming → update_layout → handle_event → render → scroll helpers → build_text → push_* helpers → advance_streaming_cache. Rename tests to match their section header function name. Move helper constructors (key_event, mouse_scroll) to top of module.
Save detailed integration test coverage plan to .claude/plans/integration-tests.md covering insta snapshot tests, temp-env config testing, wiremock SSE streaming, and logic extraction. Add test coverage section to roadmap under Current Focus.
…easons - Extract `tool_border_style(is_error)` helper in chat.rs to deduplicate the is_error → border style conditional between push_tool_result_line and push_tool_output_lines. - Add early return in InputArea::set_enabled when value is unchanged, avoiding redundant textarea style reapplication on every StreamToken. - Reword #[expect(dead_code)] reason strings in event.rs and theme.rs to describe current state without "yet" (which reads as future plans).
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
Make the TUI visually complete — from "minimal foundation" to "production-quality". Adds markdown rendering for assistant messages, structured tool call display with per-tool icons, extended thinking visualization, multi-line input, animated spinner, and several visual refinements. Follows up with comprehensive unit tests for all new TUI modules, bringing line coverage from 73% to 85%.
The key design choices: streaming markdown uses a line-based commit boundary (complete lines get full markdown rendering, trailing partial line stays plain text) with a stable-prefix cache for O(new lines) per-token cost. Tool calls display per-tool icons (
$ → ← ✎ ✱ ⌕) with left-border accents and success / error result indicators. Extended thinking shows as a dimmed italic block that clears when streaming begins. The input area now wrapsratatui-textareafor proper multi-line editing with dynamic height.TUI Polish
tui/markdown.rs: thin wrapper aroundtui_markdown::from_strfor pulldown-cmark + syntect rendering.tool_call_titleto extract display info for all tool types (was bash-only). Addtool_call_iconreturning per-tool Unicode icons.ChatViewwithChatEntryenum (User / Assistant / ToolCall / ToolResult), thinking buffer, streaming markdown with stable-prefix cache, user message left borders, and empty-state welcome screen.tool_border(),tool_icon(),thinking(); remove dead_code expects from now-consumedmuted()anderror()slots.InputAreawithratatui-textarea: dynamic height (1–6 lines), Enter to submit, Shift+Enter for newline, placeholder text, disabled state dims text.⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) toStatusBarat ~80 ms per frame, plus right-aligned working directory with~/home prefix.show_thinkingconfig throughApp→ChatView. ForwardThinkingTokenevents to chat. Useis_errorfromToolCallEndfor error-styled results.Test Coverage (85% line coverage, 311 tests)
event.rs(12 tests):tool_call_titlemapping for all tools + edge cases,tool_call_icon, channel pair delivery / close.chat.rs(42 tests):build_textfor all entry types (welcome, user, assistant, tool call/result, thinking, streaming), streaming cache boundary tracking, scroll math,handle_eventkey + mouse dispatch, tool output truncation boundary,Component::renderviaTestBackend.status.rs(12 tests):tick()state machine,set_status()reset / preserve transitions,render()viaTestBackendfor idle / streaming / tool-running / cwd display.theme.rs(9 tests): style helper foreground verification,BOLD/ITALICmodifier checks, composite helpers.Documentation
docs/research/tui.mdwith deep-dive findings from claude-code and opencode reference analysis.Changes
Cargo.tomltui-markdown0.3,ratatui-textarea0.8 to workspace dependenciescrates/oxide-code/Cargo.tomltui.rsmarkdownsubmoduletui/markdown.rsTextconversion via tui-markdown / syntecttui/theme.rstool_border(),tool_icon(),thinking()helpers; 9 unit teststui/event.rstool_call_titlefor all tools; addtool_call_icon; 12 unit teststui/components/chat.rsChatEntryenum, markdown rendering, tool styling, thinking buffer, welcome screen; 42 unit teststui/components/input.rsratatui-textarea: multi-line, dynamic height, Shift+Enter newline, placeholdertui/components/status.rstui/app.rsshow_thinking,cwd; forward thinking tokens; spinner tick; dynamic input heightmain.rsshow_thinkingand computedcwd(with~/prefix) torun_tui→AppCLAUDE.mdmarkdown.rsand revised component descriptionsdocs/roadmap.mddocs/research/tui.mdREADME.mdTest plan
cargo fmt --all --check— cleancargo buildcompiles cleanlycargo clippy --all-targets -- -D warnings— zero warningscargo test— 311 tests passcargo llvm-cov --ignore-filename-regex 'main\.rs'— 85% line coverage (up from 73%)--no-tuiand-pmodes still work (regression check)