When bound to an atom holding a vector, draw-chat-bubble! conj's the
EXACT painted screen position of every inline-image row it draws:
{:row abs-screen-row :col abs-screen-col :img {...}}. The screen loop
drains it after the delta refresh to place Kitty/iTerm2 graphics precisely
over the reserved cells — recording from inside the paint loop avoids
re-deriving the bubble's chrome offsets (role banner, top pad, turn
separator) that the copy-region geometry deliberately skips.
When bound to an atom holding a vector, `draw-chat-bubble!` conj's the
EXACT painted screen position of every inline-image row it draws:
`{:row abs-screen-row :col abs-screen-col :img {...}}`. The screen loop
drains it after the delta refresh to place Kitty/iTerm2 graphics precisely
over the reserved cells — recording from inside the paint loop avoids
re-deriving the bubble's chrome offsets (role banner, top pad, turn
separator) that the copy-region geometry deliberately skips.(bubble-height {:keys [text role prewrapped-lines turn-separator?
iteration-count duration-ms tokens cost status
llm-selected llm-actual llm-fallback? llm-routing-trace]
:as message}
max-w)Memoized bubble-height*. Keyed by projected line identity when
available; live progress keeps stable prewrapped body lines and only
appends a cheap spinner row. Metadata that can add/remove the
assistant footer is part of the key; otherwise a no-usage render can
stale-cache the shorter height before usage arrives.
Memoized `bubble-height*`. Keyed by projected line identity when available; live progress keeps stable prewrapped body lines and only appends a cheap spinner row. Metadata that can add/remove the assistant footer is part of the key; otherwise a no-usage render can stale-cache the shorter height before usage arrives.
(bubble-height* {:keys [text role prewrapped-lines status] :as message} max-w)Uncached calculation: rows a chat message will consume without drawing. label(1) + optional top-pad(1, user only) + wrapped-lines
draw-chat-bubble!'s wrap width (bubble-w - 2*h-pad) so
layout math stays consistent across the height calc and the draw.Uncached calculation: rows a chat message will consume without drawing. label(1) + optional top-pad(1, user only) + wrapped-lines + optional bottom-pad(1, user only) + optional meta top margin/footer(0|2) + gap(1). Mirrors `draw-chat-bubble!`'s wrap width (`bubble-w - 2*h-pad`) so layout math stays consistent across the height calc and the draw.
(cache-size)Current number of entries (handy for tests/diagnostics).
Current number of entries (handy for tests/diagnostics).
(draw-chat-bubble! g
{:keys [role text timestamp status slash?] :as message}
start-row
left
max-w
&
[{:keys [viewport-top viewport-h]
:or {viewport-top 0 viewport-h 0}}])Draw a chat message at the given row. No border, no bubble container.
message is a map: {:role :user|:assistant, :text str, :timestamp #inst}
Optional :duration-ms for assistant response time.
left and max-w define the horizontal bounds.
Layout (no outer border, no horizontal rule under the label, both roles left-anchored): [role-label] [timestamp] [content lines, each with role bg fill] [meta line, dimmed, when present] [blank gap]
User content rows get a subtle blue-gray background block
(user-bubble-bg) to visually separate user input from the rest of
the session. Assistant content rows render on terminal bg -
the only fills come from inline marker zones (code blocks,
answer-bg, etc.).
Returns the number of screen rows consumed (including spacing).
Extra params:
viewport-top and viewport-h describe the absolute screen
window the messages-area paints into. They’re consulted by the
per-row click-region painters to decide whether to register a
click region for an off-screen row (they don’t).
Callers that paint outside draw-messages-area! (tests, REPL
exploration) can pass 0 / 0 to disable click registration.
Draw a chat message at the given row. No border, no bubble container.
`message` is a map: {:role :user|:assistant, :text str, :timestamp #inst}
Optional `:duration-ms` for assistant response time.
`left` and `max-w` define the horizontal bounds.
Layout (no outer border, no horizontal rule under the label,
both roles left-anchored):
[role-label] [timestamp]
[content lines, each with role bg fill]
[meta line, dimmed, when present]
[blank gap]
User content rows get a subtle blue-gray background block
(`user-bubble-bg`) to visually separate user input from the rest of
the session. Assistant content rows render on terminal bg -
the only fills come from inline marker zones (code blocks,
answer-bg, etc.).
Returns the number of screen rows consumed (including spacing).
Extra params:
`viewport-top` and `viewport-h` describe the absolute screen
window the messages-area paints into. They’re consulted by the
per-row click-region painters to decide whether to register a
click region for an off-screen row (they don’t).
Callers that paint outside `draw-messages-area!` (tests, REPL
exploration) can pass `0 / 0` to disable click registration.(draw-detail-labels! g active? frozen)Vim-style jump-label overlay for collapsible disclosures.
frozen is the [label region] assignment captured ONCE when the mode
opened (cr/assign-labels on that frame), read from db — NOT re-derived
per frame. Freezing matters mid-turn: a live stream keeps re-registering
cr/current as the trace grows, so a per-frame assign-labels would
reshuffle the letters under the user's fingers and race the keypress. With a
frozen map the letter→fold binding is stable. Each badge is re-anchored to
its fold's CURRENT painted position — the region is matched by
[session-id node-id] against this frame's cr/current, so the letter
tracks the fold as the transcript scrolls and is simply dropped when the
fold scrolls off. Falls back to a live assign-labels when no frozen set is
present (the command-palette entry can open the mode from a dialog frame
that had nothing to freeze). Inactive / empty ⇒ paints nothing.
Called by the full-frame painter right AFTER cr/commit-frame!, so
cr/current holds this frame's freshly-registered regions. The input
handler resolves a typed letter against the SAME frozen map, so a badge and
its keypress always point at the same fold without shared mutable state.
Vim-style jump-label overlay for collapsible disclosures. `frozen` is the `[label region]` assignment captured ONCE when the mode opened (`cr/assign-labels` on that frame), read from `db` — NOT re-derived per frame. Freezing matters mid-turn: a live stream keeps re-registering `cr/current` as the trace grows, so a per-frame `assign-labels` would reshuffle the letters under the user's fingers and race the keypress. With a frozen map the letter→fold binding is stable. Each badge is re-anchored to its fold's CURRENT painted position — the region is matched by `[session-id node-id]` against this frame's `cr/current`, so the letter tracks the fold as the transcript scrolls and is simply dropped when the fold scrolls off. Falls back to a live `assign-labels` when no frozen set is present (the command-palette entry can open the mode from a dialog frame that had nothing to freeze). Inactive / empty ⇒ paints nothing. Called by the full-frame painter right AFTER `cr/commit-frame!`, so `cr/current` holds this frame's freshly-registered regions. The input handler resolves a typed letter against the SAME frozen map, so a badge and its keypress always point at the same fold without shared mutable state.
(draw-dialog! g cols rows title body)(draw-dialog! g cols rows title body max-w)Draw a centered confirmation dialog with text wrapping.
body can be a string or vec of strings. Long lines are wrapped to fit.
Optional max-w limits dialog width (defaults to 60% of screen).
Draw a centered confirmation dialog with text wrapping. `body` can be a string or vec of strings. Long lines are wrapped to fit. Optional `max-w` limits dialog width (defaults to 60% of screen).
(draw-input-box! g input box-top text-rows cols hint)Draw bordered input area with internal padding. Returns [cursor-col cursor-row] in screen coords.
Long logical lines are SOFT-WRAPPED at the box width: typing past
the right edge flows onto the next visual row instead of scrolling
the line horizontally. The logical model ({:lines :crow :ccol})
is unchanged - wrapping is a render-time projection only.
hint optionally embeds a short label in the top border. Runtime
keybinding helpers live in the echo area (footer/draw-echo-area!), not here,
so input/editor paint stays isolated from footer chrome.
No left/right side rails: the input area is framed by top and bottom
rules only, so the typing zone sits flush with the message column on
either side and the eye tracks the prompt directly without │-noise.
Draw bordered input area with internal padding. Returns
[cursor-col cursor-row] in screen coords.
Long logical lines are SOFT-WRAPPED at the box width: typing past
the right edge flows onto the next visual row instead of scrolling
the line horizontally. The logical model (`{:lines :crow :ccol}`)
is unchanged - wrapping is a render-time projection only.
`hint` optionally embeds a short label in the top border. Runtime
keybinding helpers live in the echo area (`footer/draw-echo-area!`), not here,
so input/editor paint stays isolated from footer chrome.
No left/right side rails: the input area is framed by top and bottom
rules only, so the typing zone sits flush with the message column on
either side and the eye tracks the prompt directly without `│`-noise.(draw-messages-area! g layout box-top box-bottom cols)Draw structured chat messages as left-aligned blocks inside a clean, border-less scrolling area.
layout is a virtual-layout plan produced by
com.blockether.vis.ext.channel-tui.virtual/layout. Carries
{:total-h :eff-scroll :visible} - the painter only iterates
:visible (the messages whose interval intersects the viewport),
so off-screen bubbles never trigger format-answer-with-thinking
/ wrap-text / markdown->lines. That's the single largest
user-visible win for cold-opening long sessions: cold paint
drops from O(N x trace-size) to O(visible x trace-size).
No outer box, no title bar - just a vertical column of messages with generous side gutters. The right gutter doubles as scrollbar space when the session overflows. The session title (if any) is surfaced via the input-box bottom status line, not here.
Draw structured chat messages as left-aligned blocks inside a clean,
border-less scrolling area.
`layout` is a virtual-layout plan produced by
`com.blockether.vis.ext.channel-tui.virtual/layout`. Carries
`{:total-h :eff-scroll :visible}` - the painter only iterates
`:visible` (the messages whose interval intersects the viewport),
so off-screen bubbles never trigger `format-answer-with-thinking`
/ `wrap-text` / `markdown->lines`. That's the single largest
user-visible win for cold-opening long sessions: cold paint
drops from O(N x trace-size) to O(visible x trace-size).
No outer box, no title bar - just a vertical column of messages with
generous side gutters. The right gutter doubles as scrollbar space
when the session overflows. The session title (if any) is
surfaced via the input-box bottom status line, not here.(draw-messages-box! g messages box-top box-bottom cols scroll)Draw bordered message area with top-anchored scrollable messages.
Draw bordered message area with top-anchored scrollable messages.
(draw-slash-command-suggestions! g suggestions input-top cols)(draw-slash-command-suggestions! g suggestions input-top cols selected-index)Overlay fuzzy slash-command suggestions immediately above the input box.
Visual stack from top to bottom: (top margin row — terminal-bg gap) [Slash commands … Enter/Tab complete] ; title bar (dialog-title-bg accent) ───────────────────────────────────────── ; border under title (dialog-border) suggestion rows…
The accent title + under-rule signal this is a menu, matching the
draw-dialog-chrome! chrome idiom but in overlay form (no side
rails, since the input box below has no side rails either).
The whole overlay is inset by INPUT_BORDER_HORIZONTAL_PAD cols on
each side so the accent stripe and rule line up exactly with the
input box's top/bottom rules below.
Overlay fuzzy slash-command suggestions immediately above the input box. Visual stack from top to bottom: (top margin row — terminal-bg gap) [Slash commands … Enter/Tab complete] ; title bar (dialog-title-bg accent) ───────────────────────────────────────── ; border under title (dialog-border) suggestion rows… The accent title + under-rule signal `this is a menu`, matching the `draw-dialog-chrome!` chrome idiom but in overlay form (no side rails, since the input box below has no side rails either). The whole overlay is inset by `INPUT_BORDER_HORIZONTAL_PAD` cols on each side so the accent stripe and rule line up exactly with the input box's top/bottom rules below.
(error-signature entry)Stable comparison key for two trace :error maps. Returns nil for
non-error iterations (so they never collapse).
Iter-level :error is the canonical surface; iterations whose
only content is a zero-code form carrying the transport error
(the provider-truncated-stream shape) hash with the same key so
they collapse alongside true iter-level errors.
Public so the virtual-layout pre-pass can squash runs of error-only assistant messages across turns using the same key the iteration-level collapser uses.
Stable comparison key for two trace `:error` maps. Returns nil for non-error iterations (so they never collapse). Iter-level `:error` is the canonical surface; iterations whose only content is a zero-code form carrying the transport error (the provider-truncated-stream shape) hash with the same key so they collapse alongside true iter-level errors. Public so the virtual-layout pre-pass can squash runs of error-only assistant messages across turns using the same key the iteration-level collapser uses.
(fill-background! g cols rows)Fill entire screen with the terminal background color.
Fill entire screen with the terminal background color.
(format-answer-markdown answer bubble-w)(format-answer-markdown answer bubble-w opts)(format-answer-markdown-data answer bubble-w)(format-answer-markdown-data answer bubble-w opts)(format-answer-with-thinking answer trace bubble-w)(format-answer-with-thinking answer trace bubble-w settings)(format-answer-with-thinking answer trace bubble-w settings confidence)(format-answer-with-thinking answer
trace
bubble-w
settings
confidence
cancelled?)(format-answer-with-thinking answer
trace
bubble-w
settings
confidence
cancelled?
opts)(format-answer-with-thinking* answer
trace
bubble-w
settings
confidence
cancelled?)(format-answer-with-thinking-data answer trace bubble-w)(format-answer-with-thinking-data answer trace bubble-w settings)(format-answer-with-thinking-data answer trace bubble-w settings confidence)(format-answer-with-thinking-data answer
trace
bubble-w
settings
confidence
cancelled?)(format-answer-with-thinking-data answer
trace
bubble-w
settings
confidence
cancelled?
opts)(format-answer-with-thinking-data* answer
trace
bubble-w
settings
_confidence
cancelled?
opts)Uncached implementation. Returns {:text :lines :line-meta} so the
bubble painter can keep clickable summary-row metadata aligned with
the already-wrapped lines.
STRICT: answer is canonical answer-IR ([:ir & nodes]) or nil.
Uncached implementation. Returns `{:text :lines :line-meta}` so the
bubble painter can keep clickable summary-row metadata aligned with
the already-wrapped lines.
STRICT: `answer` is canonical answer-IR (`[:ir & nodes]`) or nil.(golden-dialog-size cols rows content-w content-h)Compute golden-ratio dialog size [w h] sized to fit content.
content-w = widest content line (chars). content-h = content line count.
Only expands WIDTH when content is too narrow for golden ratio.
Never inflates height beyond what content needs.
Clamped to terminal bounds.
Compute golden-ratio dialog size [w h] sized to fit content. `content-w` = widest content line (chars). `content-h` = content line count. Only expands WIDTH when content is too narrow for golden ratio. Never inflates height beyond what content needs. Clamped to terminal bounds.
Internal vertical padding (rows above/below text) inside the input box. Set to 0: the top + bottom rule lines already provide all the visual breathing room the editor needs; an extra padded row on each side just wasted vertical space and pushed the cursor down a line. The previous value (1) made an empty editor render four rows tall (rule + pad + line + pad + rule) when three is the right minimum.
Internal vertical padding (rows above/below text) inside the input box. Set to 0: the top + bottom rule lines already provide all the visual breathing room the editor needs; an extra padded row on each side just wasted vertical space and pushed the cursor down a line. The previous value (1) made an empty editor render four rows tall (rule + pad + line + pad + rule) when three is the right minimum.
(input-text-w cols)Visible text width (in columns) inside the input box for a given
terminal cols. Single source of truth so screen.clj can compute
wrapped row counts and draw-input-box! can render with the same
wrap point.
The input box is SIDELESS (top/bottom rules only, no │ rails)
and input-pad-x is now 0, so the typing zone spans the full
terminal width - text-w = cols (clamped to >=1).
Visible text width (in columns) inside the input box for a given terminal `cols`. Single source of truth so screen.clj can compute wrapped row counts and `draw-input-box!` can render with the same wrap point. The input box is SIDELESS (top/bottom rules only, no `│` rails) and `input-pad-x` is now 0, so the typing zone spans the full terminal width - `text-w` = `cols` (clamped to >=1).
(input-visual-row-count lines text-w)Total visual rows occupied by every logical line in the input
editor when soft-wrapped at text-w cols.
Total visual rows occupied by every logical line in the input editor when soft-wrapped at `text-w` cols.
(invalidate-cache!)Drop every cached projection. Call on settings changes that the key tuple doesn't capture, or from tests.
Drop every cached projection. Call on settings changes that the key tuple doesn't capture, or from tests.
(message-detail-expansions-key session-id message detail-expansions)Per-message disclosure-expansion fingerprint for the height cache: the
subset of detail-expansions whose disclosure node-ids belong to
message's turn (matched by the :t<short-id> token every node-id
carries), or :expand-all when the global expand flag is set. A bulk
:baseline (C-x [ collapse-all / C-x ] expand-all) is prepended so a bulk
op busts every message's cached height; with no bulk active the key keeps
its original shape so unrelated fold clicks don't churn the cache. Lets the
height cache key a message's height to its OWN expand/collapse state
without busting every other message's cached height — the SAME per-turn
scoping this file already uses for its projection cache.
Per-message disclosure-expansion fingerprint for the height cache: the subset of `detail-expansions` whose disclosure node-ids belong to `message`'s turn (matched by the `:t<short-id>` token every node-id carries), or `:expand-all` when the global expand flag is set. A bulk `:baseline` (C-x [ collapse-all / C-x ] expand-all) is prepended so a bulk op busts every message's cached height; with no bulk active the key keeps its original shape so unrelated fold clicks don't churn the cache. Lets the height cache key a message's height to its OWN expand/collapse state without busting every other message's cached height — the SAME per-turn scoping this file already uses for its projection cache.
(progress->lines-data progress bubble-w settings)(progress->lines-data progress bubble-w settings extra)Build prewrapped lines for the live progress placeholder bubble.
The bubble lives in the assistant slot (right where the final answer will land), so the user sees the agent thinking/working in place instead of a status line wedged into the input box.
Layout while loading (spinner ALWAYS at the bottom): <iteration trace, if iterations exist and not hidden> <blank> [spinner] phase... elapsed / Esc to cancel
The activity row sits last so it tracks the natural reading direction - trace history flows top-down like a transcript, and what is happening RIGHT NOW lives where the cursor is about to write next. Putting the spinner above the trace forced the user to look at a moving line, then drop their eye further down to read static history that doesn't change. Inverted now.
progress is the :progress slot from app-db: {:iterations [...]}.
bubble-w is the outer bubble width in chars (we subtract the
bubble's symmetric inner padding to match draw-chat-bubble!).
settings is the display settings map: {:show-thinking bool :show-iterations bool}.
extra carries:
:now-ms - System/currentTimeMillis from the render thread
(drives the spinner frame); defaults to current ms
:turn-start-ms - wall-clock start, used for elapsed time
:cancelling? - true once Esc was pressed
:session-id - current session id; enables live detail rows
:session-turn-id - optional turn id, when known
:detail-expansions - detail expansion state keyed by session/node
Build prewrapped lines for the live progress placeholder bubble.
The bubble lives in the assistant slot (right where the final
answer will land), so the user sees the agent thinking/working in
place instead of a status line wedged into the input box.
Layout while loading (spinner ALWAYS at the bottom):
<iteration trace, if iterations exist and not hidden>
<blank>
[spinner] phase... elapsed / Esc to cancel
The activity row sits last so it tracks the natural reading
direction - trace history flows top-down like a transcript, and
what is happening RIGHT NOW lives where the cursor is about to
write next. Putting the spinner above the trace forced the user
to look at a moving line, then drop their eye further down to
read static history that doesn't change. Inverted now.
`progress` is the `:progress` slot from app-db: `{:iterations [...]}`.
`bubble-w` is the outer bubble width in chars (we subtract the
bubble's symmetric inner padding to match draw-chat-bubble!).
`settings` is the display settings map: `{:show-thinking bool :show-iterations bool}`.
`extra` carries:
:now-ms - `System/currentTimeMillis` from the render thread
(drives the spinner frame); defaults to current ms
:turn-start-ms - wall-clock start, used for elapsed time
:cancelling? - true once Esc was pressed
:session-id - current session id; enables live detail rows
:session-turn-id - optional turn id, when known
:detail-expansions - detail expansion state keyed by session/node(progress->text progress bubble-w settings)(progress->text progress bubble-w settings extra)Build the text body of the live progress placeholder bubble.
Build the text body of the live progress placeholder bubble.
(repeat-str ch n)Allocate a String of n copies of ch. Drop-in replacement for
(apply str (repeat n ch)) - bypasses lazy-seq + per-char
StringBuilder appends, so the box-border + padding code paths stop
showing up on the flame graph.
Allocate a String of `n` copies of `ch`. Drop-in replacement for `(apply str (repeat n ch))` - bypasses lazy-seq + per-char StringBuilder appends, so the box-border + padding code paths stop showing up on the flame graph.
(spinner-frame now-ms)Pick the spinner glyph for now-ms. Pure - same input always
returns the same frame, which makes the render loop testable.
Pick the spinner glyph for `now-ms`. Pure - same input always returns the same frame, which makes the render loop testable.
(total-messages-height messages max-w)Calculate total row height for a vec of structured messages.
Calculate total row height for a vec of structured messages.
(visual-rows-for-line line text-w)How many visual rows logical line line occupies when soft-wrapped
at text-w cols. Empty line still counts as 1 (a typable row).
How many visual rows logical line `line` occupies when soft-wrapped at `text-w` cols. Empty line still counts as 1 (a typable row).
(wrap-messages messages max-width)Wrap a vec of display lines to fit within max-width. Returns flat vec of wrapped lines.
Wrap a vec of display lines to fit within max-width. Returns flat vec of wrapped lines.
(wrap-text text max-width)Memoized wrap-text*. Keyed by source-string identity so finalized
message texts hit the cache across frames (their string instance is
stable on the immutable message map).
Memoized `wrap-text*`. Keyed by source-string identity so finalized message texts hit the cache across frames (their string instance is stable on the immutable message map).
(wrap-text* text max-width)Uncached implementation. Prefer wrap-text everywhere except inside
wrap-text itself.
Uncached implementation. Prefer `wrap-text` everywhere except inside `wrap-text` itself.
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |