ADR: Why Textual¶
An Architecture Decision Record explaining why Zorac uses Textual as its TUI framework, what alternatives were considered, and the trade-offs involved.
Context¶
The Need for a Rich Terminal UI¶
Zorac is an interactive chat client for local LLMs. The UI needs to support:
- Real-time streaming — Tokens must appear as they're generated (60+ tok/s)
- Markdown rendering — Code blocks, headings, bold, lists, links
- Persistent layout — Input bar and stats bar stay pinned while chat scrolls
- Keyboard handling — Enter to submit, Shift+Enter for newlines, Ctrl+C to cancel, Up/Down for history
- Async operations — Network calls, streaming, and UI updates must not block each other
A basic print()/input() loop can't deliver this experience. We need a framework that manages the terminal, handles events, and provides rich rendering.
Requirements: Streaming, Markdown, Widgets¶
The specific requirements that drove the framework choice:
| Requirement | Why It Matters |
|---|---|
| Streaming Markdown display | Core UX — tokens render as formatted Markdown in real-time |
| Fixed layout with docking | Input bar must stay visible while chat scrolls |
| Multiline input with key bindings | Enter submits, Shift+Enter newlines, Tab autocomplete |
| Background workers for async I/O | Streaming can't block the UI thread |
| CSS-like styling | Consistent look without manual escape codes |
| Cross-terminal compatibility | Must work in iTerm2, kitty, WezTerm, macOS Terminal |
Alternatives Considered¶
prompt_toolkit¶
prompt_toolkit is a mature library for building interactive command-line applications. It's the foundation of IPython and many popular CLI tools.
What it offers:
- Excellent input handling (multiline, completion, history, key bindings)
- Full-screen applications with layout system
- Syntax highlighting
- Well-documented and battle-tested
Why it wasn't chosen:
- No built-in Markdown rendering — Would need to integrate Rich for output formatting, creating two separate rendering systems
- No streaming Markdown API — Real-time token streaming with incremental Markdown parsing would require custom implementation
- No widget system — Layout is functional but primitive compared to Textual's widget model
- Event loop complexity — Mixing prompt_toolkit's event loop with async OpenAI streaming would require careful orchestration
Zorac originally used prompt_toolkit (with Rich for output). The migration to Textual was driven by the need for streaming Markdown — prompt_toolkit couldn't provide the real-time rendering experience that Textual's Markdown.get_stream() API delivers natively.
Rich Live Display¶
Rich (by the same author as Textual) provides beautiful terminal output with Markdown, syntax highlighting, tables, and progress bars. Its Live display allows updating a renderable in place.
What it offers:
- Excellent Markdown rendering
Live()context manager for in-place updates- Same aesthetic as Textual (they share the Rich rendering engine)
Why it wasn't chosen:
- Flickering with complex Markdown — Rich's
Livedisplay refreshes the entire output on each update. With long Markdown content (code blocks, nested lists), this causes visible flickering during streaming. - No widget layout — No concept of docked widgets, scrollable regions, or fixed-position elements. The input bar and stats bar would need to be manually positioned.
- No input handling — Rich is output-only. Input would still require prompt_toolkit or raw terminal handling.
- No streaming Markdown API — Would need to replace the entire Markdown content on each token, rather than incrementally appending.
Rich is still used by Zorac — the Console singleton for pre-TUI output, and Rich's rendering engine powers Textual's widgets internally.
Blessed / Curses¶
Blessed (a modern wrapper around curses) and raw curses provide low-level terminal control.
What they offer:
- Complete terminal control (positioning, colors, input)
- Maximum flexibility
- No dependencies (curses is in the Python standard library)
Why they weren't chosen:
- No Markdown rendering — Would need to build or integrate a Markdown parser
- No widget abstraction — Everything is manual: drawing, scrolling, layout, event handling
- Significant development effort — Building a scrollable chat log with streaming Markdown would be a project unto itself
- Cross-terminal quirks — Curses applications often break in modern terminal emulators without careful testing
Curses-based approaches make sense for applications that need pixel-level control. For a chat application, the development cost vastly outweighs the benefits.
Web-Based (Gradio, Streamlit)¶
Web-based frameworks provide the richest UI capabilities with the least effort.
What they offer:
- Full HTML/CSS/JS rendering
- Rich Markdown with code highlighting
- Built-in streaming support (Gradio)
- Accessible via any browser
Why they weren't chosen:
- Separate server process — Requires running a web server in addition to the vLLM server
- Browser dependency — Adds a heavyweight dependency for a terminal-focused tool
- Mismatch with the project philosophy — Zorac is about running AI locally and staying lightweight. A web UI contradicts the terminal-native approach.
- Overhead — Python web server + browser rendering is heavier than a TUI that runs directly in the terminal
Gradio is an excellent choice for sharing demos or building multi-user interfaces. For a personal tool that lives in the terminal, it's unnecessarily complex.
Decision: Textual¶
Widget System and Layout¶
Textual provides a proper widget system with composition, containment, and layout:
def compose(self) -> ComposeResult:
yield VerticalScroll(id="chat-log") # Scrollable chat area
yield Vertical(
ChatInput(id="user-input"), # Multiline input
Static(" Ready ", id="stats-bar"), # Performance metrics
id="bottom-bar",
)
Widgets are composed declaratively (via yield in compose()) and styled with CSS. The framework handles mounting, unmounting, layout calculation, and painting — the same concerns a web framework handles, but for the terminal.
Built-in Markdown Support¶
Textual includes a Markdown widget that renders formatted Markdown with syntax-highlighted code blocks, headings, lists, tables, and links. This is powered by Rich's rendering engine internally, but wrapped in a proper widget that integrates with Textual's layout and scrolling.
Streaming API (Markdown.get_stream)¶
The single most important feature for Zorac. Markdown.get_stream() provides a write-based interface for incrementally building Markdown content:
md_widget = Markdown("")
stream = Markdown.get_stream(md_widget)
async for chunk in llm_response:
await stream.write(chunk) # Widget re-renders incrementally
await stream.stop()
This API handles incremental parsing, partial code blocks, and progressive rendering — all the hard problems of streaming Markdown. No other framework offered this capability out of the box.
CSS-Based Styling¶
Textual CSS (TCSS) provides a familiar styling model:
This replaces manual Rich markup strings with a centralized stylesheet, making the UI easier to maintain and modify.
Worker Threads for Async Operations¶
The @work decorator provides managed background threads that can safely interact with the widget tree:
@work(exclusive=True, group="stream")
async def _stream_response(self) -> None:
stats_bar.update(...) # Thread-safe widget update
chat_log.mount(...) # Thread-safe widget mounting
This solves the threading problem cleanly — no manual locks, no thread-safety bugs, no complex event loop coordination.
Trade-offs¶
Learning Curve¶
Textual is a full TUI framework with its own widget model, CSS subset, message system, and lifecycle. The learning curve is steeper than a simple print/input loop or even prompt_toolkit.
Mitigation: Textual has excellent documentation and the concepts map well to web development (widgets ≈ components, TCSS ≈ CSS, messages ≈ events). Developers with React or similar experience adapt quickly.
Terminal Compatibility¶
Textual works well in modern terminal emulators but has known quirks:
- macOS Terminal.app — Limited support for Kitty keyboard protocol (Shift+Enter indistinguishable from Enter)
- Older terminals — May not render all Unicode characters correctly
- SSH sessions — Generally works, but some features (like clipboard) may not function
Zorac documents terminal compatibility in its README and uses fallbacks where possible (e.g., ctrl+j as an alternative Shift+Enter signal for iTerm2).
Shift+Enter Behavior Across Terminals¶
The most notable terminal compatibility issue. Zorac needs Enter to submit and Shift+Enter to insert a newline. But different terminals report Shift+Enter differently:
| Terminal | Shift+Enter Reported As | Zorac Handling |
|---|---|---|
| kitty | shift+enter |
Native support |
| WezTerm | shift+enter |
Native support |
| iTerm2 | ctrl+j |
Handled as newline |
| macOS Terminal.app | enter |
No distinction — Enter always submits |
Zorac handles both shift+enter and ctrl+j as newline insertion. In terminals that can't distinguish (Terminal.app), multiline input is still possible via pasting — the bracket paste mode preserves newlines in pasted text.
Outcome¶
Migration from prompt_toolkit¶
Zorac was originally built with prompt_toolkit for input and Rich for output. The migration to Textual:
- Unified the rendering stack — One framework for input, output, layout, and styling
- Enabled streaming Markdown — The primary motivation for the migration
- Simplified the architecture — Replaced manual screen management, cursor positioning, and refresh logic with Textual's widget model
- Improved the UX — Persistent stats bar, smooth scrolling, and cleaner layout
What Worked Well¶
Markdown.get_stream()— Exactly the API needed for LLM streaming. The streaming experience is smooth and handles all Markdown edge cases.- Worker threads — Clean separation between UI and I/O. No thread-safety issues.
- CSS styling — Easy to adjust the look without touching logic code.
- Widget composition — Adding new UI elements (like the stats bar) was straightforward.
- Mixin compatibility — Textual's
Appclass works well with Python's MRO and mixin pattern.
What Could Be Better¶
- Startup time — Textual adds ~200ms to application startup compared to a bare terminal. Acceptable but noticeable.
- Terminal.app support — The Shift+Enter limitation on macOS Terminal.app is a recurring support question. Users are advised to use iTerm2 or kitty.
- Custom widget complexity — Building
ChatInput(extendingTextArea) required understanding Textual's internals for key handling and auto-resize. The framework could benefit from a chat-oriented input widget in its standard library. - Debugging — Textual's alternate screen makes print-debugging harder. The
textual consoledevelopment tool helps but adds process overhead.