Bordered, scrollable, single-selection data table for TUI dialogs.
Composes the lower-level table border/row primitives with
scrollbar so callers don't repeat the same boilerplate (top
border + header row + middle separator + N body rows + selection
marker gutter + scrollbar) at every dialog site.
Given a dialog bounds ({:left :inner-w}) the layout reserves:
col left+1 → table │ left border (flush to dialog edge)
col left+3 → selection marker, INSIDE the first column
cols left+1 .. R-1 → boxed table (own │ borders included)
col R → scrollbar (right of right table border)
where R = left + inner-w. This guarantees the scrollbar never
overpaints the table's right │ border.
The selection marker lives INSIDE the first column: the first data
column is internally widened by p/SELECTION_WIDTH, its cell text
is indented by that many cols, and the marker glyph is painted over
the reserved gutter (matching the Ctrl+G navigator). Callers size
their columns against the reported :table-content-w, which already
excludes the marker reserve — no caller-side change needed.
:top)top+0 ┌── top border ──┐
top+1 │ header row │
top+2 ├── separator ──┤
top+3 │ first body row │
…
top+2+body-h │ last body row │
top+3+body-h └── bottom ──┘ (only when :closed? true)
By default the table is open at the bottom; callers usually follow
the body with a divider + status line and don't want a closing
└┘ cap. Pass :closed? true to draw the closing border (matches
the navigator-style boxed picker).
Bordered, scrollable, single-selection data table for TUI dialogs.
Composes the lower-level `table` border/row primitives with
`scrollbar` so callers don't repeat the same boilerplate (top
border + header row + middle separator + N body rows + selection
marker gutter + scrollbar) at every dialog site.
Geometry rules
--------------
Given a dialog `bounds` ({:left :inner-w}) the layout reserves:
col `left+1` → table `│` left border (flush to dialog edge)
col `left+3` → selection marker, INSIDE the first column
cols `left+1 .. R-1` → boxed table (own `│` borders included)
col `R` → scrollbar (right of right table border)
where `R = left + inner-w`. This guarantees the scrollbar never
overpaints the table's right `│` border.
The selection marker lives INSIDE the first column: the first data
column is internally widened by `p/SELECTION_WIDTH`, its cell text
is indented by that many cols, and the marker glyph is painted over
the reserved gutter (matching the Ctrl+G navigator). Callers size
their columns against the reported `:table-content-w`, which already
excludes the marker reserve — no caller-side change needed.
Row layout (relative to caller-provided `:top`)
-----------------------------------------------
top+0 ┌── top border ──┐
top+1 │ header row │
top+2 ├── separator ──┤
top+3 │ first body row │
…
top+2+body-h │ last body row │
top+3+body-h └── bottom ──┘ (only when `:closed? true`)
By default the table is open at the bottom; callers usually follow
the body with a divider + status line and don't want a closing
`└┘` cap. Pass `:closed? true` to draw the closing border (matches
the navigator-style boxed picker).Built-in TUI channel contributions mounted on the channel-tui extension.
Channel-tui is itself an extension (:ext/name
com.blockether.vis.ext.channel-tui), so it can declare its own
:ext/channel-contributions like any external extension. This gives every
first-party TUI surface (model display, reasoning level, codex
verbosity) the same contribution path third-party extensions use.
Why register here instead of inline in footer.clj:
The data flow is uniform - footer's extension-footer-segments
treats first-party + third-party contributions identically. Settings
UI's contributor toggle (in dialogs.clj) sees these as
regular contributors that the user can hide.
Other channels (Telegram, web, ...) reading
(channel-contributions-for :tui) get the same model/provider data
without channel-tui-specific calls.
Provider extensions can later override / supplement these
contributions with provider-specific contributions (e.g. anthropic could
register a :anthropic/model-footer showing rate-limit
headroom) without touching channel-tui core.
The contribution fns return CANONICAL IR (channel-agnostic). Channels translate IR to their surface.
Built-in TUI channel contributions mounted on the channel-tui extension.
Channel-tui is itself an extension (`:ext/name`
`com.blockether.vis.ext.channel-tui`), so it can declare its own
`:ext/channel-contributions` like any external extension. This gives every
first-party TUI surface (model display, reasoning level, codex
verbosity) the same contribution path third-party extensions use.
Why register here instead of inline in footer.clj:
1. The data flow is uniform - footer's `extension-footer-segments`
treats first-party + third-party contributions identically. Settings
UI's contributor toggle (in dialogs.clj) sees these as
regular contributors that the user can hide.
2. Other channels (Telegram, web, ...) reading
`(channel-contributions-for :tui)` get the same model/provider data
without channel-tui-specific calls.
3. Provider extensions can later override / supplement these
contributions with provider-specific contributions (e.g. anthropic could
register a `:anthropic/model-footer` showing rate-limit
headroom) without touching channel-tui core.
The contribution fns return CANONICAL IR (channel-agnostic). Channels
translate IR to their surface.TUI-side projections over the canonical in-process gateway.
On startup the TUI creates a fresh :tui gateway session by default.
Pass --session-id ID or --resume to pick up an existing one.
Session data is persisted in ~/.vis/vis.mdb so you can come back to it.
TUI-side projections over the canonical in-process gateway. On startup the TUI creates a fresh `:tui` gateway session by default. Pass `--session-id ID` or `--resume` to pick up an existing one. Session data is persisted in `~/.vis/vis.mdb` so you can come back to it.
Headless session → MP4 screencast. Replays a persisted session's transcript
through the REAL TUI render pipeline (screen/render-frame! painting into a
Lanterna VirtualTerminal — no TTY needed), and encodes the captured frames
to .mp4:
pure-JVM H.264 via jcodec's AWTSequenceEncoder (each frame rendered to a
BufferedImage with Java2D). Pixel-exact because WE draw the glyphs — no
external player font drift. No native deps, GraalVM-friendly.
The replay is HUMANIZED rather than a flat scroll-through: every disclosure
COLLAPSED, the session is re-enacted turn by turn as if it were happening
live — a user turn is TYPED IN character by character; before each answer a
WORK beat holds the live Vis is calling the provider… spinner in the
assistant's place; then the finished answer JUMPS IN and SLOWLY scrolls into
view. See session->frames.
The heavy TUI stack (screen, Lanterna) is reached via requiring-resolve
so merely loading this namespace stays cheap.
Headless session → MP4 screencast. Replays a persisted session's transcript through the REAL TUI render pipeline (`screen/render-frame!` painting into a Lanterna `VirtualTerminal` — no TTY needed), and encodes the captured frames to `.mp4`: pure-JVM H.264 via jcodec's `AWTSequenceEncoder` (each frame rendered to a `BufferedImage` with Java2D). Pixel-exact because WE draw the glyphs — no external player font drift. No native deps, GraalVM-friendly. The replay is HUMANIZED rather than a flat scroll-through: every disclosure COLLAPSED, the session is re-enacted turn by turn as if it were happening live — a user turn is TYPED IN character by character; before each answer a WORK beat holds the live `Vis is calling the provider…` spinner in the assistant's place; then the finished answer JUMPS IN and SLOWLY scrolls into view. See `session->frames`. The heavy TUI stack (`screen`, Lanterna) is reached via `requiring-resolve` so merely loading this namespace stays cheap.
Live registry of clickable rectangles painted by the chat
renderer. The render thread calls begin-frame! at the start of
every full repaint, register!s one entry per painted chrome
row, then commit-frame!s the staged set as the new live
registry. The input thread lookups the entry under the mouse
cursor on MOVE (hover) and CLICK_DOWN.
Why double-buffered (staging + published) instead of one atom:
register! runs many times during a paint. Reads from the
input thread can land BETWEEN the per-row registrations of
a single frame. With a single-buffer design the input thread
would see a partially-filled vec and miss clicks on chrome
rows that hadn't been painted yet - the very bug that made
the header copy-id button feel "sometimes broken". The
staged buffer is private to the render thread; commit-frame!
publishes the WHOLE frame's regions in a single atomic swap,
so lookup always sees a complete frame (the previous one
until commit, the new one after).
The renderer paints into a Lanterna TextGraphics object that
has no place to attach "and also - here are the clickable
bits I just drew." We'd otherwise need a parallel return
channel through every paint helper.
The set is small (tens of entries at most - the visible scrollback's worth of links); a linear scan on lookup is fine and removes the need for any spatial-index dance.
Coordinate convention: every region is stored in ABSOLUTE screen
coordinates so the input handler can compare directly against
MouseAction.getPosition().
Live registry of clickable rectangles painted by the chat renderer. The render thread calls `begin-frame!` at the start of every full repaint, `register!`s one entry per painted chrome row, then `commit-frame!`s the staged set as the new live registry. The input thread `lookup`s the entry under the mouse cursor on `MOVE` (hover) and `CLICK_DOWN`. Why double-buffered (staging + published) instead of one atom: - `register!` runs many times during a paint. Reads from the input thread can land BETWEEN the per-row registrations of a single frame. With a single-buffer design the input thread would see a partially-filled vec and miss clicks on chrome rows that hadn't been painted yet - the very bug that made the header copy-id button feel "sometimes broken". The staged buffer is private to the render thread; `commit-frame!` publishes the WHOLE frame's regions in a single atomic swap, so `lookup` always sees a complete frame (the previous one until commit, the new one after). - The renderer paints into a Lanterna `TextGraphics` object that has no place to attach "and also - here are the clickable bits I just drew." We'd otherwise need a parallel return channel through every paint helper. - The set is small (tens of entries at most - the visible scrollback's worth of links); a linear scan on lookup is fine and removes the need for any spatial-index dance. Coordinate convention: every region is stored in ABSOLUTE screen coordinates so the input handler can compare directly against `MouseAction.getPosition()`.
Pure slash-command discovery/filtering for the TUI prompt.
Pure slash-command discovery/filtering for the TUI prompt.
Reusable header/tab UI components.
Each drawable here is a self-contained fn that paints into a Lanterna
TextGraphics and — when asked — registers its OWN click region, so the
same widget can't drift between call sites (the bug that left the tab
close ✕ painted in one place and made clickable in another, or not at
all). Two kinds live here:
truncate-with-ellipsis, center-paddedclose-button!, tab-cell!Components own their visual contract AND their interaction contract
(the click region's :kind), keeping header.clj a thin layout caller.
Reusable header/tab UI components. Each drawable here is a self-contained fn that paints into a Lanterna `TextGraphics` and — when asked — registers its OWN click region, so the same widget can't drift between call sites (the bug that left the tab close `✕` painted in one place and made clickable in another, or not at all). Two kinds live here: - text layout helpers — `truncate-with-ellipsis`, `center-padded` - drawable components — `close-button!`, `tab-cell!` Components own their visual contract AND their interaction contract (the click region's `:kind`), keeping `header.clj` a thin layout caller.
Lightweight TUI channel registration.
Keep this namespace tiny: manifest discovery loads it on every Vis startup. The full Lanterna screen implementation is resolved only when the TUI channel actually runs.
Fast-fail validation:
--session-id ID is parsed, looked up, and (on miss) reported with
a friendly message + exit code 2 BEFORE the heavy
com.blockether.vis.ext.channel-tui.screen namespace (Lanterna +
~14 sibling tui namespaces) is required. Catching the miss early on
the lightweight path keeps vis channels tui --session-id <bad>
from paying full TUI class-loading cost.
Correct hits still go through the normal screen channel-main, so all existing screen behavior - argument parsing, redirects, lifecycle - stays intact. Runtime semantics live below the TUI layer and are unaffected: this only changes WHEN screen.clj is required.
Lightweight TUI channel registration. Keep this namespace tiny: manifest discovery loads it on every Vis startup. The full Lanterna screen implementation is resolved only when the TUI channel actually runs. Fast-fail validation: `--session-id ID` is parsed, looked up, and (on miss) reported with a friendly message + exit code 2 BEFORE the heavy `com.blockether.vis.ext.channel-tui.screen` namespace (Lanterna + ~14 sibling tui namespaces) is required. Catching the miss early on the lightweight path keeps `vis channels tui --session-id <bad>` from paying full TUI class-loading cost. Correct hits still go through the normal screen channel-main, so all existing screen behavior - argument parsing, redirects, lifecycle - stays intact. Runtime semantics live below the TUI layer and are unaffected: this only changes WHEN screen.clj is required.
Inline @ file-mention suggestions for the TUI composer — the SAME
affordance the web composer already has, so both channels share one
behaviour instead of a modal on one side and an inline picker on the
other.
Ranking is powered by fff (internal.file-picker/fuzzy-file-rows) — the
very same engine behind the find_files tool and the gateway
/v1/sessions/:sid/suggest service — so @fpick fuzzily finds
file_picker.clj (typo-tolerant subsequence match ranked by frecency),
not just a literal substring.
The trigger rules mirror the web/JS verbatim so writing a literal @
is never endangered:
@ must begin a word (start of input or right after whitespace),
so foo@bar, user@host, decorators never pop the picker;@@ escapes to a literal @ and suppresses the popup;fff owns a FRESH in-memory index instance that is opened OFF the render
thread and cached for the session; per-keystroke search on the open
instance is sub-millisecond, so filtering stays instant. The instance is
NEVER closed while a search may run (fff's native search SIGSEGVs on a
closed handle); a periodic rebuild swaps a new instance in and closes the
superseded one only after a grace period.
Inline `@` file-mention suggestions for the TUI composer — the SAME affordance the web composer already has, so both channels share one behaviour instead of a modal on one side and an inline picker on the other. Ranking is powered by fff (`internal.file-picker/fuzzy-file-rows`) — the very same engine behind the `find_files` tool and the gateway `/v1/sessions/:sid/suggest` service — so `@fpick` fuzzily finds `file_picker.clj` (typo-tolerant subsequence match ranked by frecency), not just a literal substring. The trigger rules mirror the web/JS verbatim so writing a literal `@` is never endangered: - the `@` must begin a word (start of input or right after whitespace), so `foo@bar`, `user@host`, decorators never pop the picker; - `@@` escapes to a literal `@` and suppresses the popup; - selection is advisory — nothing is rewritten unless the user picks. fff owns a FRESH in-memory index instance that is opened OFF the render thread and cached for the session; per-keystroke `search` on the open instance is sub-millisecond, so filtering stays instant. The instance is NEVER closed while a search may run (fff's native search SIGSEGVs on a closed handle); a periodic rebuild swaps a new instance in and closes the superseded one only after a grace period.
Dedicated one-row status footer rendered below the input box.
Codex-style three-region layout:
[LEFT] [CENTER] [RIGHT]
glm-5.1 (balanced) total tok 12000→800 (cached 8000) $0.04
Each region holds a list of {:text :fg :bold? :region :priority}
spans separated by ' / ' in muted color. The full segment list is
built up-front and shrunk by dropping the highest :priority
number (least important) until it fits the available width.
Footer deliberately avoids transient run-state and cancellation banners. Those live in the assistant bubble and host notifications; this row keeps slow-changing identity + budget bits.
Run-state (spinner, iteration counter, elapsed time, current
phase) lives EXCLUSIVELY in the assistant bubble's progress->text
block. Putting it in the footer too was a duplicate - same
⠋ 11.2s showing twice on screen. The footer keeps slow-changing
identity + budget bits; the bubble keeps the live activity story
(«Vis is thinking (iter 3)... 4.1s / Esc to cancel»).
The first footer row carries repository context on the right: repo/branch, one compact changed-file count, and ahead/behind counts when an upstream is configured. The second footer row carries provider budgets and cumulative usage under that git context. Git status is cached briefly so repainting the TUI does not shell out to git on every frame.
Every numeric format uses Locale/ROOT so a Polish JVM doesn't
produce mixed 5,8k next to English k. The previous footer
embedded the status into the input box's bottom border via
embed-in-bar, which forced single-color rendering and was the
reason run-state had to live inside the assistant bubble; this
namespace replaces that whole path.
Dedicated one-row status footer rendered below the input box.
Codex-style three-region layout:
[LEFT] [CENTER] [RIGHT]
glm-5.1 (balanced) total tok 12000→800 (cached 8000) $0.04
Each region holds a list of `{:text :fg :bold? :region :priority}`
spans separated by ' / ' in muted color. The full segment list is
built up-front and shrunk by dropping the highest `:priority`
number (least important) until it fits the available width.
Footer deliberately avoids transient run-state and cancellation
banners. Those live in the assistant bubble and host notifications;
this row keeps slow-changing identity + budget bits.
Run-state (spinner, iteration counter, elapsed time, current
phase) lives EXCLUSIVELY in the assistant bubble's `progress->text`
block. Putting it in the footer too was a duplicate - same
`⠋ 11.2s` showing twice on screen. The footer keeps slow-changing
identity + budget bits; the bubble keeps the live activity story
(«Vis is thinking (iter 3)... 4.1s / Esc to cancel»).
The first footer row carries repository context on the right:
repo/branch, one compact changed-file count, and ahead/behind counts
when an upstream is configured. The second footer row carries provider
budgets and cumulative usage under that git context. Git status is
cached briefly so repainting the TUI does not shell out to git on every frame.
Every numeric format uses `Locale/ROOT` so a Polish JVM doesn't
produce mixed `5,8k` next to English `k`. The previous footer
embedded the status into the input box's bottom border via
`embed-in-bar`, which forced single-color rendering and was the
reason run-state had to live inside the assistant bubble; this
namespace replaces that whole path.Dedicated header band painted above the messages area.
Three-region layout:
[LEFT] [CENTER] [RIGHT]
✓ Copied! Session title d8d6a0a1
(notification/status) (or fallback placeholder) (id target)
com.blockether.vis.core/notify!),
otherwise live channel status. The session title does NOT live here.:title). When the
session has no title yet, falls back to a placeholder so
the row never looks broken on a fresh run.Pure draw: reads :title and :session from app-db, the
active notifications list from vis.core/notifications, writes
cells, registers ONE click region for the copy affordance.
Repaint: the banner updates as notifications come and go.
screen.clj registers a watcher on screen mount that bumps the
render version for any change, so a (notify! ...) from anywhere
nudges this band to repaint immediately.
Dedicated header band painted above the messages area.
Three-region layout:
[LEFT] [CENTER] [RIGHT]
✓ Copied! Session title d8d6a0a1
(notification/status) (or fallback placeholder) (id target)
- LEFT: latest active host notification (`com.blockether.vis.core/notify!`),
otherwise live channel status. The session title does NOT live here.
- CENTER: session title from app-db (`:title`). When the
session has no title yet, falls back to a placeholder so
the row never looks broken on a fresh run.
- RIGHT: short session id (first 8 chars of the UUID) as the clickable
affordance that drops the FULL UUID onto the system clipboard. No
notifications or channel statuses render here.
Pure draw: reads `:title` and `:session` from app-db, the
active notifications list from `vis.core/notifications`, writes
cells, registers ONE click region for the copy affordance.
Repaint: the banner updates as notifications come and go.
`screen.clj` registers a watcher on screen mount that bumps the
render version for any change, so a `(notify! ...)` from anywhere
nudges this band to repaint immediately.Tree-sitter syntax highlighting for TUI code fences.
The eval / code fences the model emits are colored by parsing them with the
bundled tree-sitter grammars — the SAME com.blockether/tree-sitter-language-pack
the structural editors use — and painting each node by the grammar's OWN
highlights.scm capture scheme. That makes the classification accurate (a ;
inside a string is not a comment, a number inside a symbol is not a number)
and GENERAL: Clojure, Python, JavaScript, JSON, … every fence we render is
colored off each grammar's canonical highlight rules.
The heavy lifting — parse, a real tree-sitter query interpreter (field-scoped
captures, alternations, #match?/#eq? predicates), per-byte capture
labeling, ANSI run coalescing — lives in the pack's JVM-native Highlighter
(Java, on the hot path), NOT in Clojure. This namespace is a thin edge: it
maps a fence :lang to a grammar, hands the pack our capture→SGR theme, and
caches the result. The SGR codes we pick are the ones render/ansi-code->fg
already maps to theme fg slots (string/number/keyword/special/comment plus
function→warning-fg and type→success-fg), so no painter change is needed
and the escapes stay zero-width (column alignment on verbatim fences is
untouched).
Fail-open: if the native pack isn't loadable (e.g. a bare unit-test JVM without the platform lib on the classpath) every entry point returns nil and the caller falls back to plain, uncolored text.
Tree-sitter syntax highlighting for TUI code fences. The eval / code fences the model emits are colored by *parsing* them with the bundled tree-sitter grammars — the SAME `com.blockether/tree-sitter-language-pack` the structural editors use — and painting each node by the grammar's OWN `highlights.scm` capture scheme. That makes the classification accurate (a `;` inside a string is not a comment, a number inside a symbol is not a number) and GENERAL: Clojure, Python, JavaScript, JSON, … every fence we render is colored off each grammar's canonical highlight rules. The heavy lifting — parse, a real tree-sitter query interpreter (field-scoped captures, alternations, `#match?`/`#eq?` predicates), per-byte capture labeling, ANSI run coalescing — lives in the pack's JVM-native `Highlighter` (Java, on the hot path), NOT in Clojure. This namespace is a thin edge: it maps a fence `:lang` to a grammar, hands the pack our capture→SGR theme, and caches the result. The SGR codes we pick are the ones `render/ansi-code->fg` already maps to theme fg slots (string/number/keyword/special/comment plus function→warning-fg and type→success-fg), so no painter change is needed and the escapes stay zero-width (column alignment on verbatim fences is untouched). Fail-open: if the native pack isn't loadable (e.g. a bare unit-test JVM without the platform lib on the classpath) every entry point returns nil and the caller falls back to plain, uncolored text.
Keyboard / paste / clipboard surface for the TUI channel.
Clipboard read/write goes through OS shell helpers only: pbcopy /
pbpaste on macOS (always present since 10.0), wl-copy /
wl-paste on Wayland, xclip / xsel on X11.
Keyboard / paste / clipboard surface for the TUI channel. Clipboard read/write goes through OS shell helpers only: `pbcopy` / `pbpaste` on macOS (always present since 10.0), `wl-copy` / `wl-paste` on Wayland, `xclip` / `xsel` on X11.
Single source of truth for the TUI's keyboard shortcuts.
Shortcuts are CTRL chords, identical on every platform (macOS / Windows / Linux). The old Alt/Option chords were removed: stock macOS terminals send Option+letter as a special character, never an Alt modifier, so those chords silently did nothing. Ctrl always reaches the app.
ONE registry, ONE exception: every vis-side chord is defined HERE — verbs in
bindings, structural keys (palette / help / quit / picker-reorder) as the
constants below. The SOLE exception is the Emacs EDITING chords
(C-a/C-e/C-b/C-f/C-p/C-n/C-k/C-u/C-w/C-d), which live in lanterna's
TextEditKeymap so they work in EVERY input (prompt + dialogs); they are
never re-declared here, and no vis chord may reuse one of those letters
(enforced by keymap-test).
Chord labels use ONE format everywhere: Emacs notation, lower-cased —
C-c, C-g, C-l, and C-x <key> (plain second key) for the prefixed vis
commands (e.g. C-x m, C-x s). chord / label-for / palette-chord all
emit this shape, so a hint never drifts between Ctrl+X and C-x.
Tiers:
palette-chord (C-x p) opens the searchable command palette, which
can run EVERY app verb. It is the discoverable entry point. (A PLAIN
second key after C-x always reaches the app — unlike Ctrl+S/Ctrl+M — so
the palette is C-x p, not C-x C-p.)prefix-commands are the C-x <key> plain-second-key shortcuts for the
named verbs (model, reasoning, length, search, attach, voice, dirs,
resources, help) — the C-x prefix keeps them off the editing letters, and a
PLAIN second key keeps them clear of unusable Ctrl bytes (Ctrl+S=flow
control, Ctrl+M=Enter).bindings (direct Ctrl chords) is EMPTY: every Ctrl letter is an Emacs
editing key, so vis verbs all live behind the C-x prefix or in the palette.Every surface — the input dispatcher, the pickers, footer hints, the help overlay, the clickable header chips — reads this namespace, so a shortcut is defined once and stays in sync.
Single source of truth for the TUI's keyboard shortcuts. Shortcuts are CTRL chords, identical on every platform (macOS / Windows / Linux). The old Alt/Option chords were removed: stock macOS terminals send Option+letter as a special character, never an Alt modifier, so those chords silently did nothing. Ctrl always reaches the app. ONE registry, ONE exception: every vis-side chord is defined HERE — verbs in `bindings`, structural keys (palette / help / quit / picker-reorder) as the constants below. The SOLE exception is the Emacs EDITING chords (C-a/C-e/C-b/C-f/C-p/C-n/C-k/C-u/C-w/C-d), which live in lanterna's `TextEditKeymap` so they work in EVERY input (prompt + dialogs); they are never re-declared here, and no vis chord may reuse one of those letters (enforced by `keymap-test`). Chord labels use ONE format everywhere: Emacs notation, lower-cased — `C-c`, `C-g`, `C-l`, and `C-x <key>` (plain second key) for the prefixed vis commands (e.g. `C-x m`, `C-x s`). `chord` / `label-for` / `palette-chord` all emit this shape, so a hint never drifts between `Ctrl+X` and `C-x`. Tiers: - `palette-chord` (C-x p) opens the searchable command palette, which can run EVERY app verb. It is the discoverable entry point. (A PLAIN second key after C-x always reaches the app — unlike Ctrl+S/Ctrl+M — so the palette is `C-x p`, not `C-x C-p`.) - `prefix-commands` are the `C-x <key>` plain-second-key shortcuts for the named verbs (model, reasoning, length, search, attach, voice, dirs, resources, help) — the C-x prefix keeps them off the editing letters, and a PLAIN second key keeps them clear of unusable Ctrl bytes (Ctrl+S=flow control, Ctrl+M=Enter). - `bindings` (direct Ctrl chords) is EMPTY: every Ctrl letter is an Emacs editing key, so vis verbs all live behind the C-x prefix or in the palette. Every surface — the input dispatcher, the pickers, footer hints, the help overlay, the clickable header chips — reads this namespace, so a shortcut is defined once and stays in sync.
TUI aliases for the channel-neutral limits row formatters.
The real implementation lives in
com.blockether.vis.internal.limits-format (hoisted from here so
the web channel renders the SAME compact quota summaries on its
provider cards). This namespace keeps the TUI-local require sites
(footer.clj, provider.clj) and the existing test suite stable.
TUI aliases for the channel-neutral limits row formatters. The real implementation lives in `com.blockether.vis.internal.limits-format` (hoisted from here so the web channel renders the SAME compact quota summaries on its provider cards). This namespace keeps the TUI-local require sites (`footer.clj`, `provider.clj`) and the existing test suite stable.
Low-level drawing primitives wrapping Lanterna TextGraphics. All rendering code should use these instead of raw Java interop.
String width here means display columns the terminal will paint,
NOT Java .length() and NOT (count s). Those count UTF-16 code units;
a single emoji like 📁 is two code units but two columns,
a CJK glyph like 日 is one code unit but two columns, a flag
sequence 🇵🇱 is four code units, one grapheme,
two columns. Mix the two and your alignment drifts.
Use display-width and truncate-cols for any string that could
contain non-ASCII. Plain count/subs are still allowed for ASCII
internals (box-drawing strings we authored, single-glyph keystroke
labels, etc.) where the answer is identical and the call is a hot path.
Low-level drawing primitives wrapping Lanterna TextGraphics. All rendering code should use these instead of raw Java interop. Width-math contract =================== String width here means **display columns the terminal will paint**, NOT Java `.length()` and NOT (count s). Those count UTF-16 code units; a single emoji like 📁 is two code units but two columns, a CJK glyph like 日 is one code unit but two columns, a flag sequence 🇵🇱 is four code units, one grapheme, two columns. Mix the two and your alignment drifts. Use `display-width` and `truncate-cols` for any string that could contain non-ASCII. Plain `count`/`subs` are still allowed for ASCII internals (box-drawing strings we authored, single-glyph keystroke labels, etc.) where the answer is identical and the call is a hot path.
TUI provider management dialogs - model picker, model manager, provider router. Config I/O and data helpers live in tui/config.clj.
The channel-neutral brain — status probing, limits, live model
catalogs, presets, persistence shapes — lives in
com.blockether.vis.internal.providers (exposed through vis.core)
and is SHARED with the web channel. This namespace owns only the
lanterna interaction layer.
GitHub Copilot OAuth: a hard dep. The TUI ships with the
vis-provider-github-copilot jar on its classpath; the device-flow
fns are required directly. (The previous dynaload indirection has
been removed: explicit beats clever.)
TUI provider management dialogs - model picker, model manager, provider router. Config I/O and data helpers live in tui/config.clj. The channel-neutral brain — status probing, limits, live model catalogs, presets, persistence shapes — lives in `com.blockether.vis.internal.providers` (exposed through `vis.core`) and is SHARED with the web channel. This namespace owns only the lanterna interaction layer. GitHub Copilot OAuth: a hard dep. The TUI ships with the `vis-provider-github-copilot` jar on its classpath; the device-flow fns are required directly. (The previous `dynaload` indirection has been removed: explicit beats clever.)
TUI walker over canonical answer-IR (com.blockether.vis.internal.render/->ast).
Pure data: IR -> vector of lines. Each line is a vector of styled runs. The screen layer turns runs into ANSI / Lanterna cells.
Why a dedicated walker (and not ir/render :plain):
Canonical-IR invariants we rely on (see internal.render docstring):
:span / raw bodies of :code / :c / :kbd;:span;[:br {}];Run shape: {:text String ; never empty, never contains '\n' :style #{:bold :italic :code :dim :link :heading :marker :quote} :href String? ; present iff :link in style :node any?} ; opaque node identity for click/select
Public API: (ir->lines ir width) ; total walker (ir->lines ir width opts) Opts: :heading-prefix? bool ; render '#'-style markers (default false; bold suffices) :code-fence? bool ; render ``` lines around code blocks (default false) :max-lines int ; hard cap (default unlimited)
TUI walker over canonical answer-IR (`com.blockether.vis.internal.render/->ast`).
Pure data: IR -> vector of lines. Each line is a vector of styled
runs. The screen layer turns runs into ANSI / Lanterna cells.
Why a dedicated walker (and not `ir/render :plain`):
- We need styling metadata per character run (bold / italic / inline
code background / link target) so the TUI can paint colours and
attach click + selection regions.
- We need word-wrap at an arbitrary terminal width with hanging
indent inside lists and quotes.
- We need stable per-node identifiers so selection / hover / click
can map a glyph back to its IR node.
Canonical-IR invariants we rely on (see internal.render docstring):
- text only inside `:span` / raw bodies of `:code` / `:c` / `:kbd`;
- no '\n' inside `:span`;
- hard breaks are explicit `[:br {}]`;
- attrs map present on every vector node.
Run shape:
{:text String ; never empty, never contains '\n'
:style #{:bold :italic :code :dim :link :heading :marker :quote}
:href String? ; present iff :link in style
:node any?} ; opaque node identity for click/select
Public API:
(ir->lines ir width) ; total walker
(ir->lines ir width opts)
Opts:
:heading-prefix? bool ; render '#'-style markers (default false; bold suffices)
:code-fence? bool ; render ``` lines around code blocks (default false)
:max-lines int ; hard cap (default unlimited)Messages-area scroll state — one tagged value, no scattered flags.
The old model smeared scroll across four fields with two different
storage scopes (:messages-scroll workspace-local; the animation
target + two intent booleans top-level), plus a nil-vs-concrete
overload on :messages-scroll. Every handler had to hand-maintain
their consistency, and forgetting one dissoc produced the
"flash to the top, then scroll to the bottom" jump.
This namespace replaces all of it with ONE workspace-local value,
:scroll, that is a tagged variant:
{:mode :follow} ;; stick to the bottom (auto-track) {:mode :follow, :pos p} ;; …easing down toward the bottom {:mode :at, :offset n} ;; parked at row n (user scrolled up) {:mode :at, :offset n, :pos p} ;; …easing toward row n
Two orthogonal concerns, cleanly separated:
:mode (+ :offset). :follow means "desired = bottom";
:at means "desired = offset". The user's scroll-up intent is just
:at — no separate :scroll-pinned-up? flag.:pos, the eased on-screen row. Present only while an
animation is in flight (and held at the bottom in :follow so the
NEXT content growth has somewhere to ease FROM instead of
teleporting). Absent ⇒ snapped exactly to desired.Every transition REPLACES the whole value, so nothing can dangle.
ease is called once per render frame; in :follow mode the desired
row simply IS the growing bottom, so streaming content eases in for
free — there is no special "animated follow" path any more.
Pure: no re-frame, no atoms. state.clj events are thin wrappers and
screen.clj reads layout-offset / animating? to drive the loop.
Messages-area scroll state — one tagged value, no scattered flags.
The old model smeared scroll across four fields with two different
storage scopes (`:messages-scroll` workspace-local; the animation
target + two intent booleans top-level), plus a `nil`-vs-concrete
overload on `:messages-scroll`. Every handler had to hand-maintain
their consistency, and forgetting one `dissoc` produced the
"flash to the top, then scroll to the bottom" jump.
This namespace replaces all of it with ONE workspace-local value,
`:scroll`, that is a tagged variant:
{:mode :follow} ;; stick to the bottom (auto-track)
{:mode :follow, :pos p} ;; …easing down toward the bottom
{:mode :at, :offset n} ;; parked at row n (user scrolled up)
{:mode :at, :offset n, :pos p} ;; …easing toward row n
Two orthogonal concerns, cleanly separated:
- INTENT — `:mode` (+ `:offset`). `:follow` means "desired = bottom";
`:at` means "desired = offset". The user's scroll-up intent is just
`:at` — no separate `:scroll-pinned-up?` flag.
- DISPLAY — `:pos`, the eased on-screen row. Present only while an
animation is in flight (and held at the bottom in `:follow` so the
NEXT content growth has somewhere to ease FROM instead of
teleporting). Absent ⇒ snapped exactly to desired.
Every transition REPLACES the whole value, so nothing can dangle.
`ease` is called once per render frame; in `:follow` mode the desired
row simply IS the growing bottom, so streaming content eases in for
free — there is no special "animated follow" path any more.
Pure: no re-frame, no atoms. `state.clj` events are thin wrappers and
`screen.clj` reads `layout-offset` / `animating?` to drive the loop.Single source of truth for vertical scrollbars in the TUI.
One namespace owns geometry, drawing, hit-test, wheel handling and click/drag → scroll math. EVERY scrollbar — chat messages, slash menu, settings, file picker, provider/model cards, sessions, every other dialog — goes through here. No more bespoke thumb math per call site, no more drift between painter and hit-test layers.
Terminal.app renders stacked U+2588 FULL BLOCK cells as a ladder of separate boxes when the window is tall, which reads as multiple scroll positions instead of one current-position marker. So the visible thumb is intentionally one row high; the thumb's row index inside the track encodes the scroll fraction.
Single source of truth for vertical scrollbars in the TUI. One namespace owns geometry, drawing, hit-test, wheel handling and click/drag → scroll math. EVERY scrollbar — chat messages, slash menu, settings, file picker, provider/model cards, sessions, every other dialog — goes through here. No more bespoke thumb math per call site, no more drift between painter and hit-test layers. Why one cell tall thumbs ------------------------ Terminal.app renders stacked U+2588 FULL BLOCK cells as a ladder of separate boxes when the window is tall, which reads as multiple scroll positions instead of one current-position marker. So the visible thumb is intentionally one row high; the thumb's row index inside the track encodes the scroll fraction.
Pure helpers for app-side mouse text selection in the fullscreen TUI.
This is not native terminal selection. When mouse reporting is enabled the terminal sends drag/release events to Vis, so Vis tracks the visible cells, highlights the selected range, and copies that range on release.
Pure helpers for app-side mouse text selection in the fullscreen TUI. This is not native terminal selection. When mouse reporting is enabled the terminal sends drag/release events to Vis, so Vis tracks the visible cells, highlights the selected range, and copies that range on release.
Re-frame-like state management for the TUI. Single app-db atom, pure event handlers, side effects via reg-fx.
Re-frame-like state management for the TUI. Single app-db atom, pure event handlers, side effects via reg-fx.
Reusable fixed-width table primitives for TUI dialogs/pickers.
Reusable fixed-width table primitives for TUI dialogs/pickers.
Per-place tab-set persistence.
The set of open tabs is the thing you want back when you return to a place: launch vis in a directory, restore the tabs you had there. The sessions themselves already persist in the DB (keyed by session id); this only records, per launch directory, WHICH sessions are open as tabs, their order, and which one was active.
Stored as a small EDN sidecar under ~/.vis/tabs/<place>.edn — no schema,
no migration, entirely owned by this channel. Shape:
{:active "<session-id>" ; the focused tab (or nil) :sessions ["<session-id>" …]} ; tab order, left to right
Per-place tab-set persistence.
The set of open tabs is the thing you want back when you return to a
place: launch vis in a directory, restore the tabs you had there. The
sessions themselves already persist in the DB (keyed by session id); this
only records, per launch directory, WHICH sessions are open as tabs, their
order, and which one was active.
Stored as a small EDN sidecar under `~/.vis/tabs/<place>.edn` — no schema,
no migration, entirely owned by this channel. Shape:
{:active "<session-id>" ; the focused tab (or nil)
:sessions ["<session-id>" …]} ; tab order, left to rightInline terminal image rendering — Kitty graphics protocol / iTerm2 inline images.
The pixel-free logic (capability detection, intrinsic dimension sniffing,
cell-box sizing, escape encoding, base64/PNG transcoding) now lives in the
lanterna fork's Java class
com.googlecode.lanterna.terminal.image.TerminalImage. This namespace is the
thin Clojure adapter that keeps vis's map-shaped API ({:images …},
{:w :h}, {:cols :rows}) plus the attachment-aware paste probe, which
reaches back into vis internals and so stays here.
The escape strings are emitted DIRECTLY to the tty AFTER Lanterna's delta refresh (the screen loop owns that), placed over rows the renderer reserved as blanks. Lanterna never sees the graphics bytes, so its cell diff stays intact and the image survives subsequent delta frames.
Inline terminal image rendering — Kitty graphics protocol / iTerm2 inline
images.
The pixel-free logic (capability detection, intrinsic dimension sniffing,
cell-box sizing, escape encoding, base64/PNG transcoding) now lives in the
lanterna fork's Java class
`com.googlecode.lanterna.terminal.image.TerminalImage`. This namespace is the
thin Clojure adapter that keeps vis's map-shaped API (`{:images …}`,
`{:w :h}`, `{:cols :rows}`) plus the attachment-aware paste probe, which
reaches back into vis internals and so stays here.
The escape strings are emitted DIRECTLY to the tty AFTER Lanterna's delta
refresh (the screen loop owns that), placed over rows the renderer
reserved as blanks. Lanterna never sees the graphics bytes, so its cell
diff stays intact and the image survives subsequent delta frames.Lanterna adapter for internal com.blockether.vis.internal.theme tokens.
Lanterna adapter for internal `com.blockether.vis.internal.theme` tokens.
Virtualized chat-panel layout - the React-window pattern for our Lanterna scrollback.
── Why this exists ────────────────────────────────────────────────────────
The pre-virtualisation render path projected (i.e. ran
format-answer-with-thinking over) EVERY assistant message on
EVERY frame, even ones scrolled far off-screen. For finalized
bubbles the outer format-answer-with-thinking LRU made each
per-frame call ~315 ns once warm, but cold-opening a long
session paid the full ~500 ms format cost per big trace
bubble before the FIRST frame ever made it to the terminal.
Session 954bf315 (2 x ~500ms bubbles -> ~870 ms cold paint)
was the live trigger: the screen stayed bg-fill until both big
bubbles formatted, which read as "frozen TUI on open".
── The shape of the fix ───────────────────────────────────────────────────
layout plans the next paint: estimates every message's height,
pins the auto-bottom scroll position, and - only for messages
whose viewport interval is non-empty - runs the FULL projection
bubble-height measurement. Off-screen bubbles never
trigger format-answer-with-thinking.total-h and eff-scroll for
scrollbar geometry. Callers paint visible directly.── What this is NOT ───────────────────────────────────────────────────────
render.clj
(format-answer-with-thinking, bubble-height,
format-answer-markdown). This namespace just decides which
messages get to consult those caches each frame.── Caveat: scrollbar drift on first scroll up ─────────────────────────────
When the user scrolls UP into a region whose estimates were
wrong, the next layout will re-project those messages and refine
their heights. The scrollbar thumb may shift one row as the
total-h sum corrects. Mirrors react-window's
VariableSizeList behaviour; eye-acceptable in practice.
(set! *warn-on-reflection* true) and *unchecked-math* :warn-on-boxed clean across this namespace per the repo's GraalVM
ratchet.
Virtualized chat-panel layout - the React-window pattern for our Lanterna scrollback. ── Why this exists ──────────────────────────────────────────────────────── The pre-virtualisation render path projected (i.e. ran `format-answer-with-thinking` over) EVERY assistant message on EVERY frame, even ones scrolled far off-screen. For finalized bubbles the outer `format-answer-with-thinking` LRU made each per-frame call ~315 ns once warm, but cold-opening a long session paid the full ~500 ms format cost per big trace bubble before the FIRST frame ever made it to the terminal. Session 954bf315 (2 x ~500ms bubbles -> ~870 ms cold paint) was the live trigger: the screen stayed bg-fill until both big bubbles formatted, which read as "frozen TUI on open". ── The shape of the fix ─────────────────────────────────────────────────── 1. Cheap row-height ESTIMATE per message, derived from the trace shape (iteration count, code-form count, answer length) without touching the markdown tokenizer or the wrap-text engine. 2. `layout` plans the next paint: estimates every message's height, pins the auto-bottom scroll position, and - only for messages whose viewport interval is non-empty - runs the FULL projection + real `bubble-height` measurement. Off-screen bubbles never trigger `format-answer-with-thinking`. 3. The result carries the visible subset (with screen-row coordinates) plus a refined `total-h` and `eff-scroll` for scrollbar geometry. Callers paint `visible` directly. ── What this is NOT ─────────────────────────────────────────────────────── * Not a windowing library - we already have the bubble painter. This namespace is pure layout planning. * Not a height-precision oracle - the estimates are intentionally rough (within ~2x). Off-screen accuracy only nudges the scrollbar thumb; visible bubbles always paint at their REAL height because they go through the full projection pass. * Not a cache - the cache layers live in `render.clj` (`format-answer-with-thinking`, `bubble-height`, `format-answer-markdown`). This namespace just decides which messages get to consult those caches each frame. ── Caveat: scrollbar drift on first scroll up ───────────────────────────── When the user scrolls UP into a region whose estimates were wrong, the next layout will re-project those messages and refine their heights. The scrollbar thumb may shift one row as the total-h sum corrects. Mirrors `react-window`'s `VariableSizeList` behaviour; eye-acceptable in practice. `(set! *warn-on-reflection* true)` and `*unchecked-math* :warn-on-boxed` clean across this namespace per the repo's GraalVM ratchet.
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 |