Skip to content

feat(tui): polish TUI with markdown rendering, tool styling, and multi-line input#11

Open
hakula139 wants to merge 21 commits intomainfrom
feat/tui-polish
Open

feat(tui): polish TUI with markdown rendering, tool styling, and multi-line input#11
hakula139 wants to merge 21 commits intomainfrom
feat/tui-polish

Conversation

@hakula139
Copy link
Copy Markdown
Owner

@hakula139 hakula139 commented Apr 8, 2026

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 wraps ratatui-textarea for proper multi-line editing with dynamic height.

TUI Polish

  • Add tui/markdown.rs: thin wrapper around tui_markdown::from_str for pulldown-cmark + syntect rendering.
  • Expand tool_call_title to extract display info for all tool types (was bash-only). Add tool_call_icon returning per-tool Unicode icons.
  • Rewrite ChatView with ChatEntry enum (User / Assistant / ToolCall / ToolResult), thinking buffer, streaming markdown with stable-prefix cache, user message left borders, and empty-state welcome screen.
  • Add theme helpers: tool_border(), tool_icon(), thinking(); remove dead_code expects from now-consumed muted() and error() slots.
  • Rewrite InputArea with ratatui-textarea: dynamic height (1–6 lines), Enter to submit, Shift+Enter for newline, placeholder text, disabled state dims text.
  • Add braille spinner animation (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) to StatusBar at ~80 ms per frame, plus right-aligned working directory with ~/ home prefix.
  • Wire show_thinking config through AppChatView. Forward ThinkingToken events to chat. Use is_error from ToolCallEnd for error-styled results.

Test Coverage (85% line coverage, 311 tests)

  • event.rs (12 tests): tool_call_title mapping for all tools + edge cases, tool_call_icon, channel pair delivery / close.
  • chat.rs (42 tests): build_text for all entry types (welcome, user, assistant, tool call/result, thinking, streaming), streaming cache boundary tracking, scroll math, handle_event key + mouse dispatch, tool output truncation boundary, Component::render via TestBackend.
  • status.rs (12 tests): tick() state machine, set_status() reset / preserve transitions, render() via TestBackend for idle / streaming / tool-running / cwd display.
  • theme.rs (9 tests): style helper foreground verification, BOLD / ITALIC modifier checks, composite helpers.

Documentation

  • Research notes in docs/research/tui.md with deep-dive findings from claude-code and opencode reference analysis.

Changes

File Description
Cargo.toml Add tui-markdown 0.3, ratatui-textarea 0.8 to workspace dependencies
crates/oxide-code/Cargo.toml Wire new workspace deps to binary crate
tui.rs Add markdown submodule
tui/markdown.rs New: markdown → ratatui Text conversion via tui-markdown / syntect
tui/theme.rs Add tool_border(), tool_icon(), thinking() helpers; 9 unit tests
tui/event.rs Expand tool_call_title for all tools; add tool_call_icon; 12 unit tests
tui/components/chat.rs Rewrite with ChatEntry enum, markdown rendering, tool styling, thinking buffer, welcome screen; 42 unit tests
tui/components/input.rs Rewrite with ratatui-textarea: multi-line, dynamic height, Shift+Enter newline, placeholder
tui/components/status.rs Braille spinner animation, right-aligned cwd display; 12 unit tests
tui/app.rs Wire show_thinking, cwd; forward thinking tokens; spinner tick; dynamic input height
main.rs Pass show_thinking and computed cwd (with ~/ prefix) to run_tuiApp
CLAUDE.md Update crate structure diagram with markdown.rs and revised component descriptions
docs/roadmap.md Move TUI polish to Working Today; add test coverage to Current Focus
docs/research/tui.md Deep-dive findings from claude-code and opencode TUI architectures
README.md Update status section with shipped TUI features

Test plan

  • cargo fmt --all --check — clean
  • cargo build compiles cleanly
  • cargo clippy --all-targets -- -D warnings — zero warnings
  • cargo test — 311 tests pass
  • cargo llvm-cov --ignore-filename-regex 'main\.rs' — 85% line coverage (up from 73%)
  • Streaming text renders with markdown (headings, code blocks, bold, lists)
  • Tool calls show correct icons and styled borders
  • Tool errors show red border
  • Thinking block appears dimmed during thinking, clears on stream start
  • Spinner animates during streaming and tool execution
  • Multi-line input: Shift+Enter inserts newline, Enter submits
  • Input grows dynamically up to max height
  • Empty state shows welcome message
  • Working directory shows in status bar
  • Scrolling still works (up/down/page/home/end/mouse)
  • Ctrl+C quits from any state
  • --no-tui and -p modes still work (regression check)

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.
@hakula139 hakula139 added the enhancement New feature or request label Apr 8, 2026
@hakula139 hakula139 self-assigned this Apr 8, 2026
@hakula139 hakula139 added the enhancement New feature or request label Apr 8, 2026
- 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).
- 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant