Library
Terminal renderer
Layout, text rendering, scrolling, and terminal output.
View Markdown source ↗Chat_tui.Renderer is the terminal UI “view” layer. It takes the current
mutable Model.t plus the terminal size and produces:
- a composite
Notty.I.timage for the whole screen, and - an absolute cursor position for the input box.
The renderer is pure with respect to the outside world (no I/O), but it
intentionally mutates a few cache fields inside Model.t (per-message image
caches, cached message heights/prefix sums, and the embedded
Notty_scroll_box.t) to make subsequent frames cheaper.
Internally, Chat_tui.Renderer is a thin façade over a small page/component
framework:
Renderer_pagesdispatches based onModel.active_page.Renderer_page_chatimplements the current chat page.Renderer_component_*modules provide reusable parts (history viewport, message rendering, status bar, input box).
Screen layout
Section titled “Screen layout”Top-to-bottom, the renderer produces:
- A scrollable, virtualised history viewport (rendered through
Notty_scroll_box). - Optionally, a one-row sticky header showing the role of the first fully visible message.
- A one-line status bar (
-- INSERT --,-- NORMAL --,-- CMD --plus draft-mode hints). - A framed, multi-line input box (with selection highlighting when active).
Type-ahead completion rendering (chat page)
Section titled “Type-ahead completion rendering (chat page)”Type-ahead completion is a single-candidate feature that augments the input editor without changing the editor’s geometry.
The renderer implements three UI pieces:
Inline “ghost” suffix in the input box
Section titled “Inline “ghost” suffix in the input box”When Model.typeahead_is_relevant model is true, the input box renderer draws a
dim “ghost” suffix inline at the cursor column on the cursor row only. The
cursor position returned by render_full is computed from the real draft buffer
and is not affected by the ghost text.
Multi-line completions are displayed without changing layout:
- only the first completion line is rendered inline
- if the completion contains newlines, the remainder is represented by a dim
indicator appended on the same row:
… (+N more lines)
The renderer never passes '\n' to Notty.I.string; it renders typed text and
ghost fragments separately.
Status bar hint text (no layout jump)
Section titled “Status bar hint text (no layout jump)”When a completion is relevant, the status bar appends a fixed hint string to the existing status bar line:
[Tab accept all] [Shift+Tab accept line] [Ctrl+Space preview] [Esc dismiss]
The status bar image is horizontally snapped to exactly the requested width, so the presence/absence of hints does not add/remove rows or change the overall layout.
Preview popup overlay (no layout impact)
Section titled “Preview popup overlay (no layout impact)”When Insert mode is active and the preview is open
(Model.typeahead_preview_open model), the chat page renderer overlays a popup
within the transcript (history) region using Notty’s overlay operator. The popup
is bottom-aligned to the history area and does not modify the underlying scroll
box state or the input editor height.
The popup body shows the full completion text (sanitised, split into lines) and
supports internal scrolling via Model.typeahead_preview_scroll.
Message rendering rules
Section titled “Message rendering rules”Within the history viewport:
- Each non-empty message is rendered as:
- a blank line,
- a header row containing an icon plus the capitalised role label, then
- another blank line,
- the body (markdown-aware), and
- a trailing blank spacer line.
- Message bodies are sanitised with
Chat_tui.Util.sanitize ~strip:falseso thatNotty.I.stringdoes not see control characters. - Fenced code blocks (``` or ~~~) are detected via
Chat_tui.Markdown_fences.splitand highlighted viaChat_tui.Highlight_tm_enginewith the shared registry fromChat_tui.Highlight_registry. - Tool output can be rendered specially when the message index is present in
Model.tool_output_by_index(seeChat_tui.Types.tool_output_kind).
Tool output special cases
Section titled “Tool output special cases”The renderer currently has dedicated layouts for some built-in tools:
Apply_patch: splits the output into a “status preamble” and a patch section, and highlights the patch using the internalochat-apply-patchgrammar.Read_file { path }: may infer a language frompath. For Markdown files, the renderer uses the standard Markdown pipeline (including fenced-block splitting) so embedded code blocks can be highlighted by their info strings.Read_directory: applies a different tint to help distinguish directory listings from regular prose.
Public API
Section titled “Public API”Only two identifiers are exported from the library interface.
render_full
Section titled “render_full”val render_full : size:int * int -> model:Chat_tui.Model.t -> Notty.I.t * (int * int)render_full ~size ~model builds the full screen image and returns
(image, (cx, cy)), where (cx, cy) is the caret position inside the input
box in absolute screen coordinates.
The renderer updates caches inside model as part of this call.
Example integration with Notty_eio:
let render term model = let w, h = Notty_eio.Term.size term in let image, (cx, cy) = Chat_tui.Renderer.render_full ~size:(w, h) ~model in Notty_eio.Term.image term image; Notty_eio.Term.cursor term (Some (cx, cy))lang_of_path
Section titled “lang_of_path”val lang_of_path : string -> string optionlang_of_path path performs best-effort language inference for read_file
tool output. It inspects the last file extension (as defined by
Core.Filename.split_extension) and returns a TextMate-style language tag.
Example:
Chat_tui.Renderer.lang_of_path "foo.ml" = Some "ocaml";Chat_tui.Renderer.lang_of_path "README.md" = Some "markdown";Chat_tui.Renderer.lang_of_path "data.json" = Some "json";Chat_tui.Renderer.lang_of_path "script.sh" = Some "bash";Chat_tui.Renderer.lang_of_path "no_extension" = NoneKnown limitations
Section titled “Known limitations”- Input buffers store grapheme-aligned byte offsets;
Input_displaytranslates valid UTF-8 prefixes into terminal-cell positions. Embedders must supply valid UTF-8 buffers. Rendering and editing boundaries are separate concerns; see the editor contract. - Notty’s geometry is based on
Uucp.Break.tty_width_hintand can be wrong for some Unicode sequences (see Notty’s documentation on Unicode vs. text geometry). - If the input box grows taller than the available terminal height, the
composed image can exceed the requested
size. Backends typically crop the output, but the cursor may end up off-screen.
Additional implementation notes
Section titled “Additional implementation notes”The following retained notes describe the original local/controller implementation.
For native/daemon ownership and projections use the current integration guide.
Shared rendering APIs remain useful; legacy persistence/controller assumptions
are not daemon session ownership. Consult the current .mli for exact signatures.
Chat_tui.Renderer
Section titled “Chat_tui.Renderer”Render the current Chat_tui.Model.t into colourful
Notty images for display in a
terminal. The module implements the full-screen view of the terminal chat
UI: it reads the model and terminal size and returns a composite image plus
the cursor position.
State mutations (persistence, networking, input handling) live in other
modules such as Chat_tui.Controller and Chat_tui.App. The renderer is
pure with respect to the outside world, but it does maintain a few
internal caches stored on the model (see below).
Pages and routing
Section titled “Pages and routing”Chat_tui.Renderer.render_full is the single public entry point. Internally
the renderer routes to a full-screen page based on Model.active_page. At
the moment only the chat page exists, but the module structure is intended
to accommodate additional pages later.
Layout
Section titled “Layout”The chat page turns a Model.t into a three-part screen:
- History viewport (top) — scrollable chat transcript, virtualised so that only potentially-visible messages are rendered. When there is enough vertical space, a sticky header row shows the role label (“Assistant”, “User”, …) for the first fully visible message and stays pinned while the viewport scrolls.
- Status bar (middle) — one line summarising the editor mode and draft
mode (e.g.
-- INSERT -- -- RAW --). - Input box (bottom) — a framed, multi-line editor for the pending prompt or command line.
Roughly:
┌──────────── history (scrollable, virtualised) ────────────┐│ Assistant │ ← optional sticky header│ ││ assistant: Hello, how can I help? ││ user: Could you explain… ││ … │├──────────────────── status bar ───────────────────────────┤│ -- INSERT -- │├──────────────────── input editor ─────────────────────────┤│> The current multi-line prompt… │└───────────────────────────────────────────────────────────┘The history viewport is backed by Notty_scroll_box.t from this project; it
tracks a vertical scroll offset and renders a window of fixed height onto a
larger logical image.
Type-ahead completion rendering (chat page)
Section titled “Type-ahead completion rendering (chat page)”Type-ahead completion is a single-candidate feature that augments the input editor without changing the editor’s geometry.
The renderer implements three UI pieces:
Inline “ghost” suffix in the input box
Section titled “Inline “ghost” suffix in the input box”When Model.typeahead_is_relevant model is true, the input box renderer draws a
dim “ghost” suffix inline at the cursor column on the cursor row only. The
cursor position returned by render_full is computed from the real draft buffer
and is not affected by the ghost text.
Multi-line completions are displayed without changing layout:
- only the first completion line is rendered inline
- if the completion contains newlines, the remainder is represented by a dim
indicator appended on the same row:
… (+N more lines)
The renderer never passes '\n' to Notty.I.string; it renders typed text and
ghost fragments separately.
Status bar hint text (no layout jump)
Section titled “Status bar hint text (no layout jump)”When a completion is relevant, the status bar appends a fixed hint string to the existing status bar line:
[Tab accept all] [Shift+Tab accept line] [Ctrl+Space preview] [Esc dismiss]
The status bar image is horizontally snapped to exactly the requested width, so the presence/absence of hints does not add/remove rows or change the overall layout.
Preview popup overlay (no layout impact)
Section titled “Preview popup overlay (no layout impact)”When Insert mode is active and the preview is open
(Model.typeahead_preview_open model), the chat page renderer overlays a popup
within the transcript (history) region using Notty’s overlay operator. The popup
is bottom-aligned to the history area and does not modify the underlying scroll
box state or the input editor height.
The popup body shows the full completion text (sanitised, split into lines) and
supports internal scrolling via Model.typeahead_preview_scroll.
Text and code rendering
Section titled “Text and code rendering”Message bodies are rendered in two stages:
- Block splitting —
Chat_tui.Markdown_fences.splitpartitions the message text into a sequence of blocks:- plain text paragraphs (
Text), and - fenced code blocks (
Code { lang; code }), delimited by three backticks or three tildes.
- plain text paragraphs (
- Per-block rendering — each block is turned into one or more Notty images, respecting the available width.
Key details:
-
Sanitisation – before any rendering, the text is passed through
Chat_tui.Util.sanitize ~strip:false. This guarantees thatNotty.I.stringnever sees forbidden control characters while preserving newlines. (Notty rejects C0 controls inI.string.) -
Headers and roles – each message is preceded by a single header line showing an icon and the capitalised role label (e.g.
"Assistant","User","Tool"). Colours are chosen by a small internal theme that maps role strings toNotty.A.tattributes. Message bodies themselves do not include an inline"role: "prefix, so copying code from the terminal yields clean snippets. -
Developer messages – for role
"developer", a leading"developer:"prefix inside the message body is stripped to avoid duplicated labels between the header and the text. -
Paragraphs – non-code blocks are treated as markdown when a TextMate grammar is available. The renderer uses
Chat_tui.Highlight_tm_enginewith theChat_tui.Highlight_theme.github_darkpalette and the shared registry fromChat_tui.Highlight_registryto obtain(Notty.A.t * string)spans per line. When highlighting falls back (no registry, unknown language, or tokenisation error), paragraphs are rendered as plain text with a small heuristic that recognises"**bold**"/"__bold__"runs and applies a bold attribute. -
Code blocks – fenced blocks (except
lang = "html", which is treated as plain text) are highlighted withHighlight_tm_engine.highlight_textusing the block’s language tag when available. Code is wrapped to the available width while preserving indentation and colouring. -
Tool outputs – messages classified as tool output via
Chat_tui.Types.tool_output_kindreceive specialised treatment:Apply_patchresponses are split into a status preamble and a fenced patch section highlighted using the internal"ochat-apply-patch"grammar.Read_file { path }responses useChat_tui.Renderer.lang_of_path pathto infer a syntax-highlighting language when possible (for example,.mland.mlimap to"ocaml",.mdto"markdown",.jsonto"json",.shto"bash").Read_directoryresponses are rendered as plain text but tinted with a directory-specific style to distinguish them from regular prose.
-
Wrapping – both text and code are wrapped on cell boundaries using Notty’s notion of width (
Notty.I.width (Notty.I.string attr s)). This accounts for most combining characters and emoji; a few terminal/Unicode combinations may still disagree slightly. -
Selection – when a message is “selected” in the model (
Model.selected_msg), its header and body are redrawn in reverse video. This is implemented by composing attributes withNotty.A.st reverse.
Scrolling, caching, and virtualisation
Section titled “Scrolling, caching, and virtualisation”Rendering the entire transcript on each frame would be too slow once a
session grows. Chat_tui.Renderer therefore uses a combination of
virtualisation and caches stored on the model:
-
Per-message image cache – the chat page state stores a cache mapping message indices to
Model.msg_img_cacheentries (seeModel.find_img_cache/Model.set_img_cache). Each entry stores the unselected and selected images for a single message, together with the width they were rendered at and their heights. -
Height prefix sums – the chat page state also stores cached heights and prefix sums (accessible via
Model.msg_heights/Model.height_prefix). Given a scroll offset and viewport height, the renderer performs two binary searches over the prefix array to determine the index range of messages that can be visible. -
Incremental maintenance – when the underlying text of a message may have changed, other parts of the UI mark its index dirty via
Model.invalidate_img_cache_index. The renderer consumes and clears the resulting dirty list viaModel.take_and_clear_dirty_height_indices, recomputes heights for dirty entries, and updates the prefix array in-place. -
Scroll box integration – the chat page’s history viewport is backed by a
Notty_scroll_box.tstored in the chat page state (accessible viaModel.scroll_box). Each call torender_fullupdates the scroll box content image to the most recent history view. IfModel.auto_follow modelistrue, the scroll offset is snapped to the bottom; otherwise the existing offset (possibly adjusted by user input viaNotty_scroll_box.scroll_byand friends) is respected and clamped to the valid range for the current viewport height. -
Sticky headers – when there is at least one free row above the scroll viewport, the renderer duplicates the header line of the first fully visible message and draws it just above the viewport. If that header would overlap the natural header position (very small viewports), the sticky header is suppressed.
These caches are internal to the view layer; user code should treat them as
an implementation detail and use helpers from Chat_tui.Model to invalidate
them when mutating messages outside the normal update path.
Public API
Section titled “Public API”The module exposes two entry points:
render_full : size:int * int -> model:Chat_tui.Model.t -> Notty.I.t * (int * int)
Section titled “render_full : size:int * int -> model:Chat_tui.Model.t -> Notty.I.t * (int * int)”render_full ~size ~model builds the full-screen image and returns the
cursor position.
size—(width, height)of the terminal in character cellsmodel— current UI state (messages,input_line, editor mode, draft mode, selection, chat pagescroll_box, and internal render caches)
The result is (image, (cx, cy)) where:
-
image : Notty.I.tis the composite screen (history, status bar, input), sized exactly tosize. -
(cx, cy)are the absolute screen coordinates of the caret inside the input box, suitable forNotty_eio.Term.cursororNotty_unix.Term.cursor. The origin(0, 0)is the top-left corner of the terminal.The active buffer uses byte offsets (
Model.cursor_posin Insert/Normal mode andModel.cmdline_cursorin Cmdline mode).Input_displayconverts valid UTF-8 prefixes to terminal-cell widths when positioning the caret.
Behavioural notes:
- The history viewport only renders messages that can be visible for the current scroll position, plus transparent padding above and below so that its logical height matches the full transcript.
- Per-message render results are cached in the model, keyed by terminal width and message text. When the width changes (e.g. on terminal resize), caches and prefix arrays are rebuilt or incrementally adjusted.
- When
Model.auto_follow modelistrue, the view automatically scrolls to the bottom after updating its content image. Otherwise the existing scroll offset in the model’sNotty_scroll_box.tis preserved.
lang_of_path : string -> string option
Section titled “lang_of_path : string -> string option”lang_of_path path performs best-effort language inference for read_file
tool outputs.
It inspects the file extension of path and returns a TextMate-style
language identifier when known. In particular:
.mland.mlimap to"ocaml".mdmaps to"markdown".jsonmaps to"json".shmaps to"bash"
Paths without an extension, or with unrecognised extensions, yield None.
The function is exposed primarily for unit tests and to keep the
renderer-specific heuristic out of the higher-level model and controller
modules.
Example: draw once with Notty_eio
Section titled “Example: draw once with Notty_eio”The following example initialises a minimal model with a single assistant
message and draws it once using Notty_eio. Error handling and key events
are omitted for brevity.
open Core
let empty_model () : Chat_tui.Model.t = let msg_buffers : (string, Chat_tui.Types.msg_buffer) Base.Hashtbl.t = Base.Hashtbl.create (module String) in let fn_by_id : (string, string) Base.Hashtbl.t = Base.Hashtbl.create (module String) in let reasoning_by_id : (string, int ref) Base.Hashtbl.t = Base.Hashtbl.create (module String) in let tool_output_by_index : (int, Chat_tui.Types.tool_output_kind) Base.Hashtbl.t = Base.Hashtbl.create (module Int) in let kv_store : (string, string) Base.Hashtbl.t = Base.Hashtbl.create (module String) in let scroll_box = Notty_scroll_box.create Notty.I.empty in Chat_tui.Model.create ~history_items:[] ~messages:[ "assistant", "Welcome to ochat!" ] ~input_line:"" ~auto_follow:true ~msg_buffers ~function_name_by_id:fn_by_id ~reasoning_idx_by_id:reasoning_by_id ~tool_output_by_index ~tasks:[] ~kv_store ~fetch_sw:None ~scroll_box ~cursor_pos:0 ~selection_anchor:None ~mode:Chat_tui.Model.Insert ~draft_mode:Chat_tui.Model.Plain ~selected_msg:None ~undo_stack:[] ~redo_stack:[] ~cmdline:"" ~cmdline_cursor:0
let () = Eio_main.run @@ fun env -> let input = Eio.Stdenv.stdin env in let output = Eio.Stdenv.stdout env in let term = Notty_eio.Term.create ~input ~output () in let model = empty_model () in let size = Notty_eio.Term.size term in let image, (cx, cy) = Chat_tui.Renderer.render_full ~size ~model in Notty_eio.Term.image term image; Notty_eio.Term.cursor term (Some (cx, cy)); (* Keep the frame on screen until the process is cancelled. *) Eio.Fiber.await_cancel ()In a real application you would call render_full from inside a loop that
reacts to key and resize events, updates the model, and re-renders as
necessary.
Known issues and limitations
Section titled “Known issues and limitations”-
Control characters – Notty rejects C0 control characters and newlines in
I.string. The renderer sanitises text once per cached render viaUtil.sanitize ~strip:false, but callers should still avoid embedding raw control characters in messages. -
Unicode width – wrapping relies on Notty’s notion of cell width. Most modern terminals follow the same rules, but some wide or combining characters may still cause minor alignment differences.
-
Highlighting fallbacks – when no TextMate grammar is available or tokenisation fails, both markdown paragraphs and code blocks fall back to uncoloured rendering (apart from the simple bold heuristic for markdown).
-
Caching semantics – the renderer assumes that message text changes are accompanied by appropriate cache invalidation via helpers such as
Model.invalidate_img_cache_indexorModel.clear_all_img_caches. If you mutateModel.messagesdirectly without doing so, the history view may temporarily show stale renders.
Related modules
Section titled “Related modules”Chat_tui.Model— definition ofModel.tand helpers for manipulating the UI state and render caches.Chat_tui.Types— core chat types (role,message,msg_buffer, high-levelpatchcommands).Chat_tui.Highlight_tm_engine/Chat_tui.Highlight_theme— TextMate-based syntax highlighting used for markdown and code.Notty_scroll_box— scrolling helper that backs the history viewport.Notty,Notty_eio— terminal drawing and IO used by the renderer.