KHAELOR — TUI Design (V1, definitive)
Phase 1 deliverable. The terminal IS the product (Absolute Rule #2). This document fixes the layout architecture, every visual element, every interaction, the rendering techniques, and the Phase 2 framework spike that selects the renderer. Techniques here are framework-independent and are adopted regardless of the spike outcome (ADR-14).
Aesthetic contract (CLAUDE.md §14): minimal · calm · dense when needed · keyboard-native · hierarchy from spacing, typography, subtle color, indentation, and status — never from noise. No rainbow colors, no giant banners, no border mazes, no constant animation, no emoji spam.
0. Framework decision: the two candidates, and the recommendation
0.1 Empirical facts (established, not speculative)
@opentui/coreis eliminated. OpenCode's engine depends onbun-ffi-structs— it is Bun-FFI-bound and not viable for KHAELOR's Node-onlynpm install -gdistribution (ADR-1). ADR-14's candidate (b) is dead; its techniques (16 ms coalescing, delta-driven repaints, width-responsive chrome) survive and are adopted below.- Ink 7 requires Node ≥ 22. KHAELOR targets Node ≥ 22, so Ink 7 is compatible.
- Stock Ink's full-rerender model is a demonstrated risk. Hermes had to vendor a ~100-file Ink fork (ScrollBox, alternate screen, mouse, selection, virtualization) to get acceptable streaming behavior (HERMES §8.1).
0.2 The two remaining candidates
- (a) Ink 7, strictly bounded: all settled content rendered through
<Static>(written once to stdout, never reconciled again); the live tree is only the bounded live region (§1). Ink's full-rerender cost is then proportional to ~20 lines, not the session. - (b) Minimal custom ANSI renderer: a purpose-built renderer for KHAELOR's fixed layout — append-only scrollback writes + a repaintable live region, synchronized-output frames, a hand-written line editor. No reconciler, no flexbox, no framework.
Both are wired behind the same thin RendererAdapter (§15) so the Phase 2 spike is cheap and the
loser is discarded without touching the rest of the system.
0.3 Recommendation: (b) the minimal custom ANSI renderer, with Ink 7 as the control
Rationale:
- The layout architecture removes the need for a framework. KHAELOR's design (§1) commits to terminal-native scrollback with print-once immutable settled content. The only thing that ever repaints is a bounded live region of ≤ ~24 lines. A reconciler + Yoga layout engine managing a fixed 24-line strip is machinery without a problem. The same commitment is what makes a custom renderer tractable — ADR-14 predicted exactly this ("option (c) is viable precisely because the adopted techniques are what make a custom renderer tractable").
- Both mature reference teams ended up owning their renderer. OpenCode built
@opentuifrom scratch; Hermes forked Ink into ~100 vendored files. The pattern across the two products whose terminal feel we respect is: at this quality bar you own the paint path eventually. Owning ~1.5–2K purpose-built lines from day one is cheaper than owning a fork of someone else's reconciler. - Exact control of the properties the spec makes non-negotiable: DEC 2026 synchronized-output frames (flicker), cursor parking (stable input), zero framework overhead between keypress and echo (<16 ms), no retained render tree growing with the session (memory), plain stdout writes for settled content (terminal-native selection/copy).
- Honest cost: the line editor, wrapping, and overlay painting are real work (Hermes' composer is a 47 KB hand-written editor). This is the one axis where Ink wins, which is why Ink 7 is built as the spike's control candidate and remains fully acceptable if it passes the criteria in §15.4 and the custom editor cost blows the Phase 2 budget.
The spike (§15) decides on measurements, not taste. If Ink 7 (a) passes every criterion and (b) ships materially sooner, Ink wins — the recommendation is a prior, not a verdict.
1. Layout architecture
1.1 Position: terminal-native scrollback, no alternate screen for conversation
KHAELOR renders the conversation into the main screen buffer, like a well-behaved CLI — not an alternate-screen full-screen app.
Justification:
- Selection/copy is a hard criterion. Native terminal selection, copy, and search work on printed scrollback for free. Alternate-screen apps must reimplement selection (Hermes' fork did; OpenCode disables Escape-dismissal during mouse selection just to protect it — OPENCODE §3.6).
- Scrollback is free and unbounded. No virtualization, no Hermes
useVirtualHistory, no OpenCode 100-message UX cliff (explicitly rejected, OPENCODE NOT-COPY #6). The terminal already ships a better scrollback than we can write. - Crash-safe transcript. If KHAELOR dies, the conversation remains on screen.
- Memory. Settled content leaves the process; the live data structures are O(live region), not O(session).
The settled/live split (the load-bearing rule of the whole design):
- Settled content — completed message blocks, finished tool rows, committed user messages — is printed to scrollback exactly once and never touched again. There is no retroactive mutation of printed lines, ever.
- Live region — a bounded strip pinned to the bottom (≤ ~24 rows) that is the only thing repainted: the streaming tail of the current response, the agent status line, the composer, and the status bar. Every repaint is wrapped in a DEC 2026 synchronized-output frame.
One exception: explicitly-entered full-screen viewers (/diff viewer §7.2, and nothing else
in V1) use the alternate screen like less does — enter, navigate, q, return with the
conversation untouched. A pager is the one UI shape the main buffer genuinely cannot host.
1.2 Screen anatomy
┌─ terminal scrollback (native, unbounded, selectable) ─────────────────┐
│ … earlier conversation, printed once, immutable … │
│ │
│ ❯ add retry logic to the session store │
│ │
│ ▸ Read src/session/store.ts │
│ ▸ Search "retry" · 6 matches │
│ │
│ I found the failure point in SessionStore.append — writes │
│ are not retried on transient EAGAIN. Fixing that first. │
├─ live region (bounded, repainted as one synchronized frame) ──────────┤
│ ▸ Edit src/session/store.ts ← streaming tail│
│ │
│ ● Editing src/session/store.ts · 3.1s ← agent status │
│ │
│ ╭─────────────────────────────────────────────────╮ │
│ │ ❯ _ │ ← composer box │
│ ╰─────────────────────────────────────────────────╯ │
│ main +2 −0 │ claude-sonnet │ context 24% │ $0.31 ← status bar│
└───────────────────────────────────────────────────────────────────────┘(The frame above is illustrative; KHAELOR draws no boxes around these regions.)
Live-region rules:
- Hard caps (Hermes issue #34095 — unbounded live trails OOM-killed Node): live streaming tail
≤ 16,000 chars / ≤
min(240, rows − 8)lines. When the tail exceeds the cap, its settled head is flushed to scrollback (§8 defines "settled") and the region shrinks back. - The composer is always the last editable element; the status bar is always the last row.
- Overlays (palettes, pickers, permission panel, config) render inside the live region, replacing the streaming-tail slot, above the composer/status bar. They are bounded panels, not floating windows — there is no compositor on the main screen buffer.
- On resize (
SIGWINCH): scrollback is left alone (the terminal reflows or doesn't — its business); the live region is fully repainted at the new width within one frame.
1.3 Repaint discipline
- One repaint pass per animation frame; input events, stream deltas, and process output are
coalesced in a 16 ms window into a single frame (ADR-4; OpenCode
batch(), OPENCODE §3.3; Hermes converged at 33 ms — we take the stricter figure). - Every frame:
CSI ?2026h… move cursor to live-region origin … repaint changed rows only (per-row damage tracking) … park the cursor at the composer caret …CSI ?2026l. Terminals without 2026 support get cursor-hide/show bracketing as fallback. - Settled content is emitted between frames as plain writes above the live region (erase live region → print settled block → repaint live region), so scrollback stays clean for selection/copy.
2. Startup experience
khaelor reaches an editable prompt in < 150 ms (§14). Nothing blocks on the network; git status
and index warmup fill in asynchronously (status bar segments appear when real data exists —
Absolute Rule #4).
KHAELOR
~/dev/my-project · main
claude-sonnet-4-5 · thinking adaptive
────────────────────────────────────────────────────
╭──────────────────────────────────────────────────╮
│ ❯ What do you want to build? │
╰──────────────────────────────────────────────────╯- Three lines of identity (the wordmark carries the brand gradient), one rule, the composer box. The question lives inside the box as a dim placeholder on first run — after the first message the box shows just the prompt glyph. No ASCII logo, no version spam, no log lines, no animation.
- The rule line spans the terminal width (dim). The model line shows the configured model id or alias — never a hard-coded name.
- If
ANTHROPIC_API_KEYis missing, the question line is replaced by a single calm instruction and the composer accepts/config:
KHAELOR
~/dev/my-project · main
────────────────────────────────────────────────────
No Anthropic API key found.
Set ANTHROPIC_API_KEY or run /config to add one.
╭──────────────────────────────────────────────────╮
│ ❯ _ │
╰──────────────────────────────────────────────────╯- Resuming (
khaelorin a project with an interrupted session) adds exactly one line:Interrupted session from 12 min ago · /resume to continue— no auto-resume, no modal.
3. The composer
The highest-priority component (CLAUDE.md §14). Reference standard: OpenCode's prompt (OPENCODE §3.5) — structured parts, not a flat string.
3.1 Anatomy
The composer is a rounded bordered box, full terminal width minus a one-column margin:
╭────────────────────────────────────────────────────────────────╮
│ ❯ refactor @src/session/store.ts to journal events atomically, │
│ then run the tests_ │
╰────────────────────────────────────────────────────────────────╯- Prompt glyph
❯(accent color;>in ASCII fallback) on the first content row; continuation rows indent to align. The hardware cursor parks at the real text position inside the box. - Content grows from 1 row to
min(8, rows/3)rows, then scrolls internally; the box height is content rows + 2 border rows. - Border state: brand gradient (violet→cyan) while the agent is idle — the box is the focus; dim
while the agent works. The glyph dims with it; queued input is always allowed (§3.6), and a
⋯ n queuedindicator rides the bottom border. - On first run the empty box shows a dim placeholder (
What do you want to build?); afterwards just the prompt glyph. - Below 40 columns the box degrades to a plain
❯prompt line (no borders). - Palettes (§4), the permission panel (§9), and other overlays render above the box; the box and status bar are never displaced.
3.2 Model: structured parts over a text buffer
The buffer is text + spans (the extmark idea, OPENCODE §3.5): file mentions and collapsed
pastes are spans with display text, style, and structured payload. Span offsets are maintained
through every edit. Submission produces structured parts — text | fileRef{path, range?} | pastedBlock{content} — so the Context Engine receives references, not flattened strings.
- Paste intelligence: bracketed paste; ≥ 3 lines or > 150 chars collapses to a
⧉ pasted 47 linesspan (expanded only at submit); a path-looking paste becomes a file mention. - History:
~/.khaelor/prompt-history.jsonl, 50 entries per project, deduplicated; Up at buffer start / Down at buffer end navigate it (cursor-position-aware, so multiline editing is never hijacked). - Undo/redo: a bounded edit-op stack (
Ctrl+_undo; best-effort, in-composer only). - External editor:
Ctrl+Ground-trips the buffer through$EDITOR, re-locating spans by placeholder tokens on return.
3.3 Slash commands
/ at offset 0 opens the slash palette (§4.1) anchored above the composer. Typing filters;
Enter completes or executes.
3.4 @ file mentions
@ opens fuzzy repository search over the repository index (respecting .gitignore /
.khaelorignore), ranked by match score × frecency (frequency / (1 + ageDays) — OpenCode's
formula, stored in ~/.khaelor/frecency.jsonl):
❯ refactor @agent
┌────────────────────────────────────────┐
src/kernel/agent.ts ★
src/agents/agent-runtime.ts
tests/agent.test.ts
└────────────────────────────────────────┘(★ = frecency-boosted; dim, not loud.) Selection inserts a file-reference span —
@src/kernel/agent.ts — a structured reference the Context Engine resolves, not file contents.
Range syntax @src/kernel/agent.ts:40-90 parses in V1; the picker UI for ranges is post-V1.
3.5 Shell mode
! at offset 0 switches the composer into shell mode for one submission:
! git status --shortThe glyph changes to ! (warning tint). Output prints to scrollback as a settled block, marked
$ git status --short with head/tail truncation for long output; a one-key follow-up hint
(a — add output to context) lets it become agent context explicitly. Shell mode runs under the
same permission rules as agent bash.
3.6 Message queueing while the agent works (steering)
The composer never locks. Text typed mid-run is submitted normally and queued (ADR-11: injected only at safe tool-result boundaries, never breaking role alternation):
▸ Run npm test · running 8s
⋯ Queued — use the smaller fixture instead
Esc cancel run · Ctrl+U discard queuedQueued instructions render in the live region with the ⋯ marker until injected, at which point
they settle into scrollback as a normal user message. Multiple queued messages stack in order.
3.7 Composer key bindings
| Key | Action |
|---|---|
Enter |
Submit (or queue, while agent runs) |
Shift+Enter / Ctrl+J |
Insert newline (Shift+Enter via kitty-keyboard / modifyOtherKeys when detected; Ctrl+J always works; trailing \ + Enter also continues) |
← →, Ctrl+B Ctrl+F |
Move by character |
Alt+←/→, Alt+B Alt+F |
Move by word |
Ctrl+A / Ctrl+E |
Line start / line end |
↑ / ↓ |
Line up/down in multiline; history at buffer edges |
Ctrl+R |
Incremental history search |
Backspace / Ctrl+H |
Delete char back |
Ctrl+W / Alt+Backspace |
Delete word back |
Alt+D |
Delete word forward |
Ctrl+U |
Delete to line start (or discard queued message when composer empty) |
Ctrl+_ |
Undo |
Tab |
Accept selected completion |
Ctrl+G |
Edit buffer in $EDITOR |
/ (at offset 0) |
Slash palette |
@ |
File-mention search |
! (at offset 0) |
Shell mode |
4. Palettes
Both palettes are the same component (one generic filter-list, as OpenCode's dialog-select
proves out — OPENCODE §3.6) with different sources. They render in-live-region, anchored above the
composer, max height min(12, rows − 6).
4.1 Slash palette
❯ /se
┌──────────────────────────────────────────────────────┐
/sessions browse and resume sessions
/new start a new session
/resume resume the most recent session
└──────────────────────────────────────────────────────┘
↑↓ navigate · Enter run · Esc close- Fuzzy filtering (fuzzysort-style scoring with exact-prefix bonus), selected row inverted, match characters underlined (not colored-only — §12).
- Full V1 set:
/model /config /permissions /context /sessions /resume /new /rename /clear /compact /cost /status /diff /processes /help /quit.
4.2 Universal command palette — Ctrl+K
Same panel, sourced from the command registry (one registry powers keys, slash commands, and the palette — OpenCode's proven unification, OPENCODE §3.6), showing live bindings:
┌──────────────────────────────────────────────────────┐
❯ diff_
──────────────────────────────────────────────────────
View diff /diff d
Compact context /compact
Show processes /processes
└──────────────────────────────────────────────────────┘
↑↓ navigate · Enter run · Esc closeUsers never need to memorize commands: everything reachable is listed with its key and slash name.
5. Agent status line
One compact dynamic line in the live region, directly above the composer. States:
thinking · reading · searching · editing · running · waiting · verifying · idle.
● Searching repository · 2.3s
● Editing src/kernel/agent.ts
● Running npm test · 41/148
● Waiting for permission
● Verifying · npm run typecheckRules (Absolute Rule #4 and §18 — no spinner-driven UX):
- Every element is real data: the state derives from actual bus events (
ToolStarted,ModelRequestStarted, …); elapsed time is a real timer; counts like41/148appear only when a tool parser actually extracted them. Never a fabricated percentage, never⠋ Thinking...as a substitute for information we have. ●pulses between two shades at ~2 Hz while active — the only animation in the product — and is○when idle. In monochrome,●/○still carry the distinction.- Width is pre-reserved so the line never jitters as text changes (Hermes' spinner-width trick, HERMES §8.2). One line, always; transient states are never printed into the conversation.
- When idle the line collapses to nothing (the composer moves up a row).
6. Tool call presentation
6.1 Collapsed one-liners (default)
Each tool call settles into scrollback as exactly one line:
▸ Read src/kernel/agent.ts · 212 lines
▸ Search "ContextEngine" · 14 matches
▸ Edit src/context/engine.ts · +31 −12
▸ Run npm test · passed · 4.2s
▸ Run npm test · 2 failed · 6.8s
▸ Start process 3121 · npm run dev▸dim; tool verb normal; argument bright; result annotation dim. Failures swap the annotation to the error color and the wordfailed(never color alone). Counts (+31 −12,14 matches, exit codes, durations) come from real tool results.- While running, the row lives in the live region with the elapsed timer
(
▸ Run npm test · 8s) and a rolling tail of output when useful; it settles to its final one-liner when the tool completes.
6.2 Expansion
Settled scrollback is immutable (§1.1), so expansion prints detail rather than mutating rows:
- Live turn:
Ctrl+Tcycles the detail level of the current turn's live tool row (collapsed → tail (12 lines) → collapsed), Hermes' three-stateDetailsModereduced to two. - After settling: every tool call gets a turn-local index shown on demand.
d(empty composer) prints the most recent edit's diff (§7.1);/toollists this turn's calls;/tool 3prints call 3's full detail as a new settled block:
▸ Run npm test · 2 failed · 6.8s [3]
…
── tool 3 · npm test ────────────────────────────────────
FAIL src/context/engine.test.ts
✕ compaction preserves running processes
… 214 lines omitted · full output: ~/.khaelor/tool-out/8f3a.txt
─────────────────────────────────────────────────────────6.3 Long output
Head/tail truncation with explicit omission markers; the full output is spilled to
~/.khaelor/tool-out/<id>.txt and the path is shown (and given to the model — ADR-8). The
conversation layout is never destroyed by a 40,000-line test log: what settles is bounded
(≤ 12 lines per tool by default, matching Hermes' persisted-trail cap).
7. Diff presentation
7.1 Inline (after every edit)
Never bare "Edited file":
✓ src/context/engine.ts +31 −12 d expand diffd (composer empty) prints the unified diff of the most recent edit as a settled block, syntax
highlighted, + lines in the added color, − in the removed color, with +/− glyphs
preserved for monochrome:
── diff · src/context/engine.ts · +31 −12 ───────────────
@@ -84,7 +84,9 @@ export class ContextEngine {
- const budget = this.window - used;
+ const reserve = this.config.compactionReserve;
+ const budget = this.window - used - reserve;
───────────────────────────────────────────────────────── 7.2 /diff — the full viewer (alternate screen)
The one full-screen surface in V1 (§1.1). Enter → alternate screen; q/Esc → back, conversation
untouched. Shows the session's cumulative changes (baseline attribution per ADR-15 — only
KHAELOR's changes, never pre-existing user diff).
Side-by-side when width > 120 (OpenCode's threshold), unified below:
/diff · 3 files · +64 −21 2/3
─────────────────────────────────────────────────────────────────
src/session/store.ts +18 −6
▸src/context/engine.ts +31 −12
src/tools/edit.ts +15 −3
─────────────────────────────────────────────────────────────────
84 const budget = │ 84 const reserve = this.config.
85 this.window - used; │ 85 const budget = this.window -
─ │ 86 used - reserve; +
─────────────────────────────────────────────────────────────────
↑↓/jk scroll · ]/[ next/prev file · Tab file list · u unified · q closeSyntax highlighting per §8.2; hunk navigation ]/[ (OpenCode's diff-viewer bindings);
added/removed counts per file and total; accept/revert hooks are post-V1 (no shadow git in V1,
ADR-15).
8. Markdown rendering pipeline
Settled-block incremental streaming — Hermes' StreamScanState technique (HERMES §8.2),
adopted as-is:
- Stream deltas append to a raw tail rendered as lightly-styled plain text (inline code and bold get cheap regex styling; nothing structural).
- A scanner advances only over newline-terminated input, detecting settled top-level blocks (boundary: blank line outside a code fence; a fence settles at its closing fence).
- A settled block is rendered once through the full markdown renderer and flushed to scrollback (immutable). Only the live tail is ever re-scanned — never O(blocks²) re-tokenization, and settled text never reflows or flickers.
- On
ModelFinished, the remaining tail settles.
Renderer scope (V1): headings (spacing + weight, no banner rules), bold/italic/strikethrough,
inline code (subtle background tint), fenced code blocks, ordered/unordered/nested lists, tables,
blockquotes, links (OSC 8 hyperlinks when supported; text (url) otherwise), horizontal rules.
- Code blocks: syntax highlighting via a lightweight token highlighter (Hermes-style
hand-rolled per-language rules with an LRU cache, or
highlight.jsgrammars re-emitted as ANSI — spike decides by startup cost; Shiki/WASM is excluded from the hot path for cold-start reasons). Long lines wrap with a dim↪continuation marker; content is copy-friendly plain text in scrollback — no background-color fills that poison copied text, a thin dim gutter│marks the block instead. - Tables render with box-drawing only when they fit the width; otherwise degrade to aligned plain columns.
- A block that would exceed the live cap mid-stream flushes early at the last safe line boundary (§1.2 caps).
9. Panels
All panels are live-region overlays (§1.2): bounded, keyboard-driven, Esc closes, opening takes
one keypress or one slash command. None of them clears the conversation.
9.1 Permission panel (CLAUDE.md §13 — verbatim contract)
╭─ KHAELOR requests permission ─────────────────────╮
│ Run │
│ npm install │
│ │
│ Working directory │
│ ~/dev/project │
│ │
│ [ Enter ] Allow once │
│ [ A ] Always allow in this project │
│ [ Esc ] Deny │
╰───────────────────────────────────────────────────╯- The interaction takes milliseconds: it appears already focused; three keys, no typing.
Ashows the generalized pattern it will persist (always allow: npm install *) derived from conservative shell-word parsing; commands containing shell operators get exact-command approval only (ADR-9). Grants persist to project config.- For edits, the body shows the target path and a ≤ 8-line diff preview instead of a command.
- Denial is recorded and fed to the model as steering (ADR-9); the panel closes instantly either
way. While the panel is open the agent status line reads
● Waiting for permission.
9.2 Model selector — /model
┌─ model ────────────────────────────────────────────┐
current claude-sonnet-4-5 thinking adaptive
─────────────────────────────────────────────────
❯ claude-sonnet-4-5 default · fast
claude-opus-4-5 deepest reasoning
claude-haiku-4-5 cheapest · aux model
─────────────────────────────────────────────────
t thinking: adaptive · o output budget: 16000
└────────────────────────────────────────────────────┘
↑↓ select · Enter apply · t/o cycle · Esc closeEntries come from configuration/aliases (no hard-coded permanent list — CLAUDE.md §6); t cycles
thinking mode, o cycles output budget. Applying updates the status bar immediately.
9.3 /config
Keyboard-navigable panel over the same config the file exposes (file remains editable directly):
┌─ config ── ~/.khaelor/config.json · .khaelor/config.json ─┐
❯ Model claude-sonnet-4-5
Thinking adaptive
Max output 16000
Permissions 12 rules →
Theme khaelor-dark
API key set via environment ✓
└────────────────────────────────────────────────────────────┘
↑↓ navigate · Enter edit · p project scope · Esc closeSecrets are never displayed (set via environment ✓ / stored in keychain ✓). p toggles
whether an edit writes user (~/.khaelor/config.json) or project (.khaelor/config.json) scope,
with the target shown before writing.
9.4 /sessions picker
┌─ sessions · ~/dev/my-project ───────────────────────────┐
❯ retry logic in session store 12m ago $0.42 main
context compaction checkpoint 2h ago $1.13 main
initial TUI scaffolding 1d ago $2.87 tui/shell
└──────────────────────────────────────────────────────────┘
↑↓ select · Enter resume · n new · r rename · x delete · / filter · EscTitles, ages, costs, branches from real session metadata (§8, CLAUDE.md). Resuming replays the event log; the transcript reprints into scrollback as settled content.
9.5 /context inspector
┌─ context · 41,382 / 200,000 tokens · 21% ───────────────┐
system prompt 3,120 ██
project instructions 1,240 █
conversation 24,988 ████████████
tool observations 9,414 █████
repository context 1,620 █
checkpoint — (no compaction yet)
reserved output 16,000 ████████
─────────────────────────────────────────────────────
files in context
src/session/store.ts read · turn 3
src/context/engine.ts edited · turn 5
└──────────────────────────────────────────────────────────┘
Enter file detail · c compact now · Esc closeEvery number from real usage/accounting (Absolute Rule #4). Bars are supplementary — numbers carry the information (§12). After compaction, the checkpoint row expands to show the structured YAML checkpoint on Enter.
9.6 /cost
┌─ cost · this session ───────────────────────────────────┐
input tokens 182,455 $0.27
output tokens 24,110 $0.18
cache write 41,020 $0.08
cache read 512,300 $0.08 (90% hit)
─────────────────────────────────────────────
total $0.61
└──────────────────────────────────────────────────────────┘Exclusively from API usage metadata (ADR-10); cache health made visible (ADR-7). If pricing for
the configured model is unknown, token counts show and the dollar column reads n/a — never an
invented estimate.
9.7 /processes
PROCESSES
● 3121 npm run dev running 04:32
● 3198 pytest running 00:18
○ 3012 npm test exited code 0
↑↓ select · Enter tail output · s stop · Esc close● running / ○ exited (shape carries state, color reinforces). Enter prints the selected
process's recent output as a settled block.
10. Streaming UX rules
Binding rules for any renderer:
- 16 ms coalescing. All deltas (text, thinking, tool output) batch into per-frame updates; one repaint per frame regardless of event rate (ADR-4).
- No scroll jumps. Settled content is appended above the live region; the live region is
repainted in place. The viewport never leaps. If the user scrolls up in native scrollback,
nothing repositions them — new content accumulates below, exactly like
tail -f. - Stable input. The composer and its caret are unconditionally repainted last and the cursor parks at the caret every frame. Typing latency is independent of stream rate (input events are processed before render batching; echo goes in the next frame, < 16 ms).
- No flicker. Synchronized-output frames (§1.3); per-row damage tracking; never clear-screen-and-redraw.
- Thinking display is compact: while the model emits permitted thinking, the status line
reads
● Thinking · 4s; a dim, cap-limited preview line may show beneath it. Thinking is never the centerpiece (CLAUDE.md §14). - Interruption —
Esc. Acknowledgment is immediate (same frame): status line flips to◌ Stopping…before any teardown completes. Then cancellation propagates (AbortSignal tree: model stream → in-flight tools → loop, ADR-11), danglingtool_useclosed with synthetic cancelled results, and the partial output settles into scrollback marked:
▸ Run npm test · cancelled · 3.2s
◌ Interrupted — partial response keptBackground process entries are untouched (ADR-11). Esc with an overlay open closes the
overlay instead; Esc when idle with text in the composer clears selection/completion first
(§13 precedence).
11. Status bar
One row, bottom of screen, dim by default — subtle, persistent, never animated:
main +4 −1 │ claude-sonnet-4-5 │ context 31% │ $0.42 │ ● 2Segments (all real data; a segment with no data is absent, not zeroed):
| Segment | Source | Example |
|---|---|---|
| git branch + dirty counts | repo watcher | main +4 −1 |
| model | session config | claude-sonnet-4-5 |
| context utilization | real usage vs usable window (ADR-6) | context 31% |
| session cost | API usage metadata | $0.42 |
| background processes | process registry | ● 2 |
| queued steering (when any) | queue | ⋯ 1 queued |
Width-responsive degradation (Hermes' progressive disclosure, HERMES §8.2 — thresholds fixed now, tuned in Phase 8). Segments have priorities; lower-priority segments drop whole, never truncate mid-token:
| Width | Shown |
|---|---|
| ≥ 100 | all segments |
| 80–99 | model shortens to alias (sonnet); processes drop if none running |
| 60–79 | cost drops → main +4 −1 │ sonnet │ 31% |
| < 60 | main │ 31% |
Context ≥ 80% tints the percentage warning and appends · /compact as a nudge; ≥ 90% error tint.
Percentages, not bars, at this size.
12. Color and theming
One exceptional default theme (khaelor-dark), khaelor-light variant, /theme deferred
(CLAUDE.md §19).
Palette philosophy — a vibrant, saturated system (per explicit product direction): every surface carries its own hue, hierarchy still comes from brightness and spacing, and monochrome loses zero meaning because state is never color-alone.
| Role | Use | Dark value (truecolor) |
|---|---|---|
| text | body | #d8dee9 |
| dim | chrome, annotations, rules | #6b7280 |
| accent | ❯, selection, active state |
#7aa2f7 |
| violet | thinking state, model segment, gradient start | #bb9af7 |
| cyan | reading state, branch segment, gradient end | #7dcfff |
| teal | searching state, context segment, process tools | #73daca |
| magenta | verifying state | #ff79c6 |
| orange | cost segment, waiting state, numbers | #ff9e64 |
| success | ✓, passed, added +, running state |
#9ece6a |
| warning | context pressure, shell mode !, editing state |
#e0af68 |
| error | failed, removed − |
#f7768e |
Color deployment:
- Brand gradient (violet→cyan, per-character truecolor interpolation): the startup wordmark, H1 headings, and the composer-box border when the agent is idle. Gradients only ever run over plain text; ANSI-256/16 degrade to solid violet, monochrome to identity.
- Agent states each have a saturated color (status-line dot + label): thinking violet · reading cyan · searching teal · editing amber · running green · verifying magenta · waiting orange. The word always accompanies the color.
- Tool one-liners: the
▸glyph is colored by tool family (read cyan · search violet · edit amber · exec green · process teal). - Diffs:
+/−in success/error plus a subtle truecolor background tint per line; glyphs preserved for monochrome. - Status bar: each segment has its own accent (branch cyan · model violet · context teal → warning → error under pressure · cost orange · queued amber); separators stay dim.
- Panels (permission, palettes): accent-dim borders instead of gray.
- Syntax highlighting: keywords violet · strings green · numbers orange · functions cyan · types teal-cyan · comments dim — a real palette derived from the theme.
Rules:
- Capability ladder: truecolor → ANSI-256 (quantized palette) → ANSI-16 (role-mapped) →
monochrome. Detected via
COLORTERM/terminfo. NO_COLOR(andTERM=dumb) honored absolutely: full monochrome, everything still legible because state is never color-alone — every state pairs a symbol or word:✓done ·▸tool ·●/○running/stopped ·⋯queued ·◌interrupted ·+/−diff ·failed/passedspelled out.- Light/dark: background detected via OSC 11 query with a 100 ms timeout (fallback:
COLORFGBG, else assume dark); the matching variant is selected automatically. Both variants pass a contrast check (≥ 4.5:1 for text roles) in tests. - Syntax highlighting derives from the same palette, so code blocks belong to the theme instead of shouting over it. Vibrant ≠ noisy: saturation lives in small marks (glyphs, borders, labels, segments), never in body text.
13. Keyboard reference (complete, V1)
Precedence: overlay bindings (modal) > single-key actions (only when composer empty) > composer bindings > global. A pushed overlay suppresses lower layers automatically (OpenCode's mode stack, OPENCODE §3.4) — no manual focus bookkeeping.
Global
| Key | Action |
|---|---|
Esc |
Interrupt agent run · else close overlay · else clear completion/selection |
Ctrl+K |
Universal command palette |
Ctrl+T |
Cycle live tool detail (current turn) |
Ctrl+C |
Clear composer; twice within 1 s quits |
Ctrl+D |
Quit (empty composer only) |
Ctrl+L |
Repaint live region (recover from external corruption) |
Ctrl+Z |
Suspend (proper renderer.suspend() + restore on SIGCONT) |
Composer
See §3.7 (character/word/line navigation, history, kill, undo, $EDITOR, /, @, !).
Single-key actions (composer empty; hinted inline where relevant)
| Key | Action |
|---|---|
d |
Print diff of most recent edit |
a |
(after shell mode output) add output to context |
Overlays / pickers (uniform)
| Key | Action |
|---|---|
↑/↓ (Ctrl+P/Ctrl+N) |
Navigate |
| typing | Fuzzy filter |
Enter |
Select / apply |
Tab |
Complete without executing (slash palette) |
Esc |
Close |
Permission panel
Enter allow once · A always allow (project) · Esc deny (§9.1).
/diff viewer (alternate screen)
↑↓/j k scroll · PgUp/PgDn/Ctrl+U/Ctrl+D half-page · ]/[ next/prev file · Tab file
list · u unified/split toggle · g/G top/bottom · q/Esc close.
All bindings are declared in the single command registry (§4.2) with name/title/category/key, so
the palette lists them and a future rebinding config gets them for free. Kitty keyboard protocol
and modifyOtherKeys are negotiated at startup for Shift+Enter and key-release fidelity;
capability absence degrades to documented fallbacks, never broken keys.
14. Performance budget and measurement plan
Budgets are release gates, measured — never asserted (CLAUDE.md §18; Absolute Rule #4).
| Metric | Budget | Method |
|---|---|---|
| Cold start → editable prompt | < 150 ms p95 | hyperfine 'khaelor --benchmark-startup' (flag prints ready-timestamp and exits); CI-tracked |
| Input echo latency | < 16 ms p95 | instrumented: keypress-read timestamp → frame-flush timestamp, recorded in-process under --debug; synthetic typing at 30 cps during a full-rate stream replay |
| Live-region repaint | < 8 ms p95 (frame budget headroom) | per-frame timing histogram in debug log |
| Stream throughput | no dropped frames at ≥ 2,000 tokens/s replay | recorded-event replay harness (§15.2) |
| Memory, long session | < 150 MB RSS after 4 h / 500-message synthetic session; flat slope after settling | replay harness + periodic process.memoryUsage() samples |
| Session resume (1,000-event log) | < 500 ms to interactive | benchmark on fixture logs |
Repository search (@ mention, 50K-file repo) |
first results < 100 ms | fixture repo benchmark |
Instrumentation ships in the product behind --debug (histograms to ~/.khaelor/logs/perf.jsonl)
so dogfooding sessions produce real latency data continuously. When a budget is missed: attribute
the layer first (model / network / fs / index / render / architecture), then optimize that layer —
never guess (§18 speed rule).
15. Phase 2 framework spike
15.1 The RendererAdapter seam
The spike builds one UI core twice behind a deliberately thin adapter; everything above it (event bus consumption, markdown scanner, layout logic, key decoding policy) is shared:
interface RendererAdapter {
mount(opts: { stdin: NodeJS.ReadStream; stdout: NodeJS.WriteStream }): void;
/** Append an immutable, pre-rendered block to terminal scrollback. Never repainted. */
printSettled(block: RenderedBlock): void;
/** Repaint the bounded live region (streaming tail, status line, composer, status bar, overlay). */
updateLive(state: LiveRegionState): void;
onKey(handler: (key: KeyEvent) => void): void;
onResize(handler: (size: { rows: number; cols: number }) => void): void;
metrics(): RendererMetrics; // frame timings, bytes written, dropped frames
unmount(): void;
}- Candidate A — Ink 7:
printSettled→<Static>items;updateLive→ the live component tree (status line, composer<TextInput>-equivalent, status bar); measure whether Ink's reconciler + Yoga stays inside budget for a ≤ 24-row tree and whether<Static>output remains byte-clean for selection/copy. - Candidate B — custom ANSI:
printSettled→ direct write above the live region;updateLive→ damage-tracked row repaint inside DEC 2026 frames; the line editor is the main build cost — the spike implements the §3.7 navigation set only (spans/paste/$EDITORdeferred to Phase 2 proper).
Spike scope cap: ~3 days per candidate. Shared harness first, then A, then B.
15.2 The scenario (identical for both, replayed from a recorded event fixture)
- Stream a 10K-token markdown response (headings, tables, 3 fenced code blocks) at recorded-real and 4×-accelerated rates, through the settled-block scanner.
- 30 tool rows: start → live timer → settle; two overlapping with the text stream.
- Synthetic typing at 30 cps into the composer during the full-rate stream (echo latency measured per §14).
- Open/close the palette during streaming; show one permission panel.
- Resize 120→60→200 columns mid-stream.
- Long-session soak: 500 messages replayed, memory sampled.
- Manual flicker/selection pass on: Terminal.app, iTerm2, kitty, Alacritty, VS Code terminal, and
inside tmux. Flicker check: eyeball +
asciinemarecording scrubbed frame-by-frame; selection check: select and copy a settled code block mid-stream, paste, diff against source.
15.3 Decision criteria (pass/fail; all must pass to be eligible)
| # | Criterion | Test |
|---|---|---|
| 1 | Flicker-free streaming | no visible tearing in scrubbed recordings on all 6 terminals |
| 2 | Stable input line | caret never moves or blinks away during scenario 3 |
| 3 | Input latency | < 16 ms p95 during full-rate stream (instrumented) |
| 4 | Long-session memory | flat RSS slope after soak; < 150 MB |
| 5 | Terminal-native selection/copy | copied settled block byte-identical (modulo trailing WS) |
| 6 | Node-only | npm i -g on clean Node 22, no Bun, no native build step |
| 7 | Resize integrity | no corrupted rows after scenario 5 |
Tie-breakers if both pass: (1) measured margins on criteria 3–4, (2) lines of owned code the team must maintain, (3) estimated distance to Phase 2 composer completion, (4) startup cost added.
15.4 Outcome handling
- Both pass → recommendation (§0.3) applies its tie-breakers; expected outcome is B, accepted outcome may be A.
- Only one passes → it wins, recorded as an ADR-14 addendum with the measurement tables.
- Neither passes → the shared harness is the beginning of candidate B done more carefully; fix the failing criterion in B (it is the only candidate whose paint path we fully control).
- The losing candidate's adapter is deleted, not kept "just in case". The
RendererAdapterseam remains — it is also the seam the golden/snapshot tests (§21 of CLAUDE.md) render through.
16. Summary of positions taken
| Question | Position |
|---|---|
| Conversation surface | Main-buffer terminal-native scrollback; settled content printed once, immutable |
| Repaint surface | Bounded live region (≤ ~24 rows), DEC 2026 synchronized frames, 16 ms coalescing |
| Alternate screen | Only for the /diff full viewer (pager semantics) |
| Framework | Spike between Ink 7 (Static + bounded live tree) and custom ANSI renderer; custom renderer recommended |
| Markdown streaming | Hermes settled-block scanning: raw tail live, blocks rendered once when settled |
| Tool calls | Collapsed one-liners; live-turn toggle Ctrl+T; post-hoc expansion prints blocks (d, /tool n) |
| Overlays | In-live-region bounded panels; one generic filter-list powers all pickers; one command registry powers keys + palette + slash |
| Status | One agent status line (real data only) + one width-degrading status bar |
| Honesty | Every number from real events/usage; segments absent rather than faked; no spinner theater |
| Accessibility | Symbols + words always accompany color; NO_COLOR fully supported; keyboard-complete |
Author: Simon-Pierre Boucher · contact@spboucher.ai