SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
48.8 KB

# 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)

  1. @opentui/core is eliminated. OpenCode's engine depends on bun-ffi-structs — it is Bun-FFI-bound and not viable for KHAELOR's Node-only npm install -g distribution (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.
  2. Ink 7 requires Node ≥ 22. KHAELOR targets Node ≥ 22, so Ink 7 is compatible.
  3. 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 @opentui from 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

text
┌─ 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).

text
 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_KEY is missing, the question line is replaced by a single calm instruction and the composer accepts /config:
text
 KHAELOR
 ~/dev/my-project · main
────────────────────────────────────────────────────
 No Anthropic API key found.
 Set ANTHROPIC_API_KEY or run /config to add one.
 ╭──────────────────────────────────────────────────╮
 │ ❯ _                                              │
 ╰──────────────────────────────────────────────────╯
  • Resuming (khaelor in 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:

text
 ╭────────────────────────────────────────────────────────────────╮
 │ ❯ 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 queued indicator 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 lines span (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+G round-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):

text
 ❯ 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:

text
 ! git status --short

The 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):

text
 ▸ Run npm test · running 8s

 ⋯ Queued — use the smaller fixture instead
   Esc cancel run · Ctrl+U discard queued

Queued 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

text
 ❯ /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:

text
   ┌──────────────────────────────────────────────────────┐
    ❯ diff_
    ──────────────────────────────────────────────────────
    View diff                    /diff        d
    Compact context              /compact
    Show processes               /processes
   └──────────────────────────────────────────────────────┘
    ↑↓ navigate · Enter run · Esc close

Users 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.

text
 ● Searching repository · 2.3s
 ● Editing src/kernel/agent.ts
 ● Running npm test · 41/148
 ● Waiting for permission
 ● Verifying · npm run typecheck

Rules (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 like 41/148 appear 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:

text
 ▸ 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 word failed (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+T cycles the detail level of the current turn's live tool row (collapsed → tail (12 lines) → collapsed), Hermes' three-state DetailsMode reduced 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); /tool lists this turn's calls; /tool 3 prints call 3's full detail as a new settled block:
text
 ▸ 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":

text
 ✓ src/context/engine.ts  +31 −12        d expand diff

d (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:

text
 ── 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:

text
 /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 close

Syntax 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:

  1. Stream deltas append to a raw tail rendered as lightly-styled plain text (inline code and bold get cheap regex styling; nothing structural).
  2. 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).
  3. 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.
  4. 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.js grammars 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)

text
╭─ 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.
  • A shows 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

text
 ┌─ 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 close

Entries 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):

text
 ┌─ 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 close

Secrets 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

text
 ┌─ 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 · Esc

Titles, 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

text
 ┌─ 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 close

Every 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

text
 ┌─ 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

text
 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:

  1. 16 ms coalescing. All deltas (text, thinking, tool output) batch into per-frame updates; one repaint per frame regardless of event rate (ADR-4).
  2. 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.
  3. 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).
  4. No flicker. Synchronized-output frames (§1.3); per-row damage tracking; never clear-screen-and-redraw.
  5. 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).
  6. 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), dangling tool_use closed with synthetic cancelled results, and the partial output settles into scrollback marked:
text
 ▸ Run npm test · cancelled · 3.2s
 ◌ Interrupted — partial response kept

Background 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:

text
 main +4 −1  │  claude-sonnet-4-5  │  context 31%  │  $0.42  │  ● 2

Segments (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 (and TERM=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/passed spelled 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:

ts
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/$EDITOR deferred 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)

  1. Stream a 10K-token markdown response (headings, tables, 3 fenced code blocks) at recorded-real and 4×-accelerated rates, through the settled-block scanner.
  2. 30 tool rows: start → live timer → settle; two overlapping with the text stream.
  3. Synthetic typing at 30 cps into the composer during the full-rate stream (echo latency measured per §14).
  4. Open/close the palette during streaming; show one permission panel.
  5. Resize 120→60→200 columns mid-stream.
  6. Long-session soak: 500 messages replayed, memory sampled.
  7. Manual flicker/selection pass on: Terminal.app, iTerm2, kitty, Alacritty, VS Code terminal, and inside tmux. Flicker check: eyeball + asciinema recording 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 RendererAdapter seam 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