Skip to content

plan: composer arrow-key history — navigate prior user messages #667

Description

@btipling

Review notes (2026-08-18)

Reviewed issue #667 for correctness / performance / architecture / testing / cloud ops / living docs / layers / UI / cap governance.

Issue Severity Resolution
Empty-composer gate as written blocks every ↑ after the first (field is then non-empty) Blocker Gate is entry only. Once history_index != null, ↑/↓ stay intercepted even though the composer has the loaded text
Step table said ↑ on a non-empty draft enters history Major Contradicts Decision B / Goal 5. Non-empty + not-in-history = arrows pass through to the textEntry
collectUserSlots sketched against bridge / messageHead() Major Pure helper (no bridge.zig import — same reason as submit_queue). Walk visible indices via messageAt, newest-first. messageHead() is the next write slot, not the last message
history_index as “ring slot” aliases after wrap/saturate Major Ordinal into the newest-first user list (0 = newest). Re-resolve each step. Clamp / exit history if count shrinks
resetTranscriptScroll clears history on New/Clear” is false Major That helper is onInit-only. Also drop history state on the existing ui.zig n < prev_msg clear/hydrate path
Tests only pinned collectUserSlots, not the step machine Major Pure stepHistory + load/restore/send-reset/clamp cases in test-rich
Invented HISTORY_MAX = 64 Minor No new named cap. Window is the live ring (messageCount()RING_CAP 2048)
Baseline line numbers stale vs main @ 325e966 Minor Corrected below
dvui .up/.down “best guess” Minor Verified on pinned dvui 4f810ef (src/enums.zig up/down)

Status: HANDOFF-READY
Reviewed: 2026-08-18 (correctness / performance / architecture / testing / cloud ops N/A / living docs / layers / UI / caps)

Plan header

Field Value
Status HANDOFF-READY
Date 2026-08-18
Type single
Parent N/A
Source issue #406 — harness: arrow keys navigate prior user messages into the composer
Branch plan/composer-arrow-history
Layers harness (Wasm)
Reusability impact none
Production mutate? no
Cloud ops path N/A — no Production mutate
Living docs docs/harness-limits.md (composer keyboard table)

Summary

↑/↓ arrow keys in the composer cycle through prior user messages — load text into the composer, edit, resend. ↑ loads progressively older user prompts; ↓ walks forward and restores the in-progress draft at the newest end. All Wasm-internal — no bridge protocol bump, no DOM host changes.

Goals

# Goal Success signal
1 First ↑ from an empty composer (not already in history) loads the newest user message Operator hits ↑ on a blank field, sees last user message text in the composer
2 Further ↑/↓ walk the user list; ↓ past newest restores the saved draft Operator hits ↓ past most recent, sees their original draft returned
3 Only user messages appear in history Assistant / thinking / tool_run / system / error / skill_attached never load
4 Send from history entry creates a new user message Hitting Ctrl+Enter (or Send) while viewing history submits normally; transcript shows a new user row
5 Multi-line editing with arrow keys still works When composer has operator-typed text and we are not in history, ↑/↓ move the caret inside the textEntry

Non-goals / out of scope

  • ↑ history when composer has text and caret is at first line (v2 polish — dvui TextEntry does not expose cursor line)
  • Cross-session history (no host round-trip; current ring window only — Load earlier is required for prompts that have aged out)
  • Searching / fuzzy-finding history
  • Editing or deleting historical transcript rows in-place
  • Mobile touch equivalents (no physical arrows)
  • Walking history while Busy (arrows pass through; feat(harness): queue follow-up prompts while Busy #666 queue-while-busy is a separate PR)
  • Forbidden wiring: dual DOM chat · secrets in Wasm · laptop-only Production ops · protocol bump for ring data (ring is already in Wasm)

Architectural decisions

Decision Options considered Choice Why
Cursor-gate for ↑ history A) Always intercept ↑ · B) Empty to enter; stay intercepted while history_index != null · C) Empty OR caret at line 0 B dvui TextEntry does not expose cursor line/column — can't implement C without a dvui fork. Empty-only entry matches ChatGPT/Claude and never fights multi-line editing. Walking must keep intercepting or the first load fills the field and further ↑ dies.
History collection A) Per-keypress walk of visible messageAt (pure) · B) Pre-computed Vec on ring mutate · C) Bridge export from host A Ring ≤ 2048. Walk visible indices newest-first. Pure module, no bridge.zig import (host test-rich cannot pull the web backend).
history_index meaning A) Physical ring slot · B) Newest-first ordinal (0 = newest user) B Physical slots reuse on wrap (messageRevisionAt). Ordinal 0 is always the current newest user after re-resolve.
Draft save/restore A) Stack-alloc temp · B) File-level buf in state.zig · C) Don't save — lose draft B history_draft_buf: [SUBMIT_CAP]u8 in state.zig — 256 KiB BSS, zero alloc in the frame path.
↓ at "present" (newest end) A) Restore draft + clear history_index · B) Loop to oldest · C) Do nothing A Every chat UI with history — ↓ past newest restores what they were typing.
Ring iteration order A) Newest-first (inverse chronological) · B) Oldest-first A ↑ loads the most recent user message first — standard shell/chat behavior.
New named cap A) HISTORY_MAX = 64 · B) No new cap — window is the ring B Generous default. 64 user turns is a realistic long session; the transport ceiling is already RING_CAP (2048).

Layer placement

Concern Layer Path(s) Rationale
History state + draft buf harness ui/state.zig Same file as prompt_buf — single owner for all composer state
History walk + step machine harness ui/composer_history.zig (new) Pure: no dvui, no bridge.zig
Key intercept + load into prompt_buf harness ui.zig frame() composer event scan Same pre-textEntry loop that already catches Ctrl+Enter (!busy)
Clear / hydrate drop harness resetTranscriptScroll and ui.zig n < prev_msg resetTranscriptScroll is onInit-only today
Unit tests harness ui/composer_history.test.zig (new) Pure data tests — no dvui frame

Current baseline (live code)

main @ 325e966 (2026-08-18). Protocol v17. Open PR #666 (queue-while-busy) is not merged — implement against main. If #666 lands first: share the composer event loop (do not steal arrows from the queue-row editor); submitOrEnqueue still resets history_index.

Claim Path / symbol Notes
Event loop catches Ctrl+Enter before textEntry, only when !busy native/harness/src/ui.zig ~443–468 if (!busy) { for (dvui.events()) … ke.code == .enter } then textEntry. ↑/↓ join this scan so the widget never moves the caret
composer.submitText(typed) ui.zig ~516 / ~549 · ui/composer.zig:12-24 Normalize + queueSubmitFromUi + clearPrompt (@memset 0)
bridge.messageAt(i){kind, text} bridge.zig:183 Visible index 0 = oldest. MessageKind.user = 1
bridge.messageCount() bridge.zig:170 Live window length (≤ 2048)
bridge.messageHead() bridge.zig:179 Physical slot the next write will use — not the last message. Do not start a history walk here
SUBMIT_CAP / MAX_MSG_LEN bridge.zig:73 · ring_slot.MAX_MSG_LEN = 262144 Equal; a user row always fits the composer buffer
RING_CAP / MAX_MSG bridge.zig:61-66 2048
state.prompt_buf ui/state.zig:12 textEntry .buffer
te.getText() after init ui.zig ~505 Sampled after textEntry init; ↑/↓ intercept is before this
resetTranscriptScroll() ui/state.zig:83-98 Called from onInit only. Clear/New/hydrate use ui.zig n < prev_msg (~379) and do not go through this helper
dvui ke.code == .up / .down pinned dvui 4f810ef src/enums.zig Verified (up, down exist; also page_up / page_down)
Kinds to skip bridge.MessageKind assistant 2, system 3, error 4, thinking 5, tool_run 6, skill_attached 7

Design

State (new, in state.zig)

/// Newest-first ordinal of the loaded user message, or null (live draft).
/// 0 = newest user in the current ring window.
pub var history_index: ?usize = null;
/// Saved draft from the ↑ that *entered* history. Restored when ↓ walks past 0.
pub var history_draft_buf: [bridge.SUBMIT_CAP]u8 = [_]u8{0} ** bridge.SUBMIT_CAP;
pub var history_draft_len: usize = 0;

Cleared in resetTranscriptScroll() and in the ui.zig n < prev_msg ring-clear/hydrate branch (same place the chip drops). onInit already calls resetTranscriptScroll — no extra onInit work.

History module (composer_history.zig) — pure

Do not import bridge.zig or dvui. Caller (frame) walks messageAt into a tiny view:

pub const KindText = struct { kind: u8, text: []const u8 };

pub const USER_KIND: u8 = 1;

pub fn userCount(msgs: []const KindText) usize { … }

/// Newest-first: ordinal 0 is the last user message in `msgs` (visible order oldest→newest).
pub fn userTextAt(msgs: []const KindText, ordinal: usize) ?[]const u8 { … }

pub const Step = enum { older, newer };
pub const Outcome = enum { load, restore_draft, noop };

pub fn step(index: ?usize, user_n: usize, dir: Step) struct { outcome: Outcome, index: ?usize } {
    // older: null → 0 (if user_n>0); Some(i) → min(i+1, user_n-1); empty → noop
    // newer: null → noop; Some(0) → restore_draft + null; Some(i) → i-1
}

Frame, on a handled ↑/↓:

  1. Build msgs as messageCount() KindText from messageAt(0..n) (stack [RING_CAP]KindText or walk twice — no GPA).
  2. n_user = userCount(msgs).
  3. If history_index is Some(i) and i >= n_user (saturate / unexpected shrink): treat as restore_draft (exit history).
  4. r = step(history_index, n_user, dir).
  5. Apply: load → memcpy userTextAt into prompt_buf (memset tail / trailing 0 so the buffer stays NUL-clean); restore_draft → copy history_draft_buf[0..history_draft_len] back; noop → nothing.

Key intercept (composer event scan, !busy, before textEntry)

// ↑ — enter history only when the field is empty; walk while already in history
if (ke.code == .up and ke.action == .down) {
    const in_hist = state.history_index != null;
    const buf_empty = state.prompt_buf[0] == 0; // valid: every write/clear NUL-terminates
    if (in_hist or buf_empty) {
        e.handled = true;
        if (ke.action == .down) applyHistory(.older);
    }
}
// ↓ — only while in history (otherwise let the textEntry move the caret)
if (ke.code == .down and ke.action == .down and state.history_index != null) {
    e.handled = true;
    applyHistory(.newer);
}

Empty invariant: clearPrompt, history load, and draft restore all @memset / write a trailing 0. Do not use prompt_buf[0] == 0 as a substitute for getText() anywhere else.

Mark .down and .repeat handled (same as Ctrl+Enter) so a held ↑ does not also move the caret; apply the step once per .down (held-repeat stepping is OK if .repeat also steps — pick one and test: step on .down and .repeat so a held ↑ walks, matching shell history).

Load / step behavior

State
Not in history, composer empty Save draft (len 0), load newest user (index 0). No-op if no user rows Pass through (no-op for history)
Not in history, composer non-empty Pass through — caret moves in the textEntry (Goal 5) Pass through
In history, index 0 (newest) Step to 1 if another user exists; else stay Restore draft, history_index = null
In history, index i in (0, n) Step to i+1 if i+1 < n; else stay at oldest (no wrap) Step to i-1
In history, i >= n after ring shrink Restore draft, clear index Restore draft, clear index
Busy Arrows not intercepted (existing if (!busy) scan) same

Draft save

The ↑ that enters history (transition null → 0) copies prompt_buf into history_draft_buf and records history_draft_len (0 when empty). Subsequent walks do not overwrite the saved draft. Edits to a loaded history line are discarded when the operator steps away without Send (readline-style).

Send interop

composer.submitText always reads the live buffer. After a successful (non-blank) submit, set history_index = null and zero the draft len so the next ↑ picks up the message just sent. Blank-reject (clearPrompt only) also clears history_index if set — the field is empty again, so a following ↑ re-enters at 0.

Edge cases

Edge case Behavior
No user messages in ring ↑ is a silent no-op
Busy (turn in flight) Arrow keys pass through to textEntry — no history intercept
Ring wrap / saturate Re-resolve ordinals from messageAt. If current ordinal vanished, restore draft
User message text Host already clamps to MAX_MSG_LEN == SUBMIT_CAP — always fits
New / Clear / session hydrate Ring count drops (n < prev_msg) → drop history_index + draft. Do not rely on resetTranscriptScroll alone
Refresh Wasm remount → onInitresetTranscriptScroll
User types while in history Buffer is live. Next ↑/↓ reloads from the ordinal (overwrites edits). Send submits the edits as a new user row and exits history
Load earlier Older user rows appear at the oldest end; newest-first ordinal 0 is unchanged
Concurrent #666 If merged first: keep intercepts in the composer scan; queue-row textEntry owns its own chords; do not handle ↑/↓ when a queue row is being edited

Cloud ops path

N/A — no Production mutate. Zig compiles on the self-hosted build-harness runner (existing artifact → Vercel path).

Living docs plan

Surface Change Notes
docs/harness-limits.md Composer keyboard table: ↑/↓ history (empty to enter; walk while in history; ↓ restores draft; user rows only; ring window only). Timeless — no issue/phase numbers
AGENTS.md N/A — no agent rule or infra change
README.md N/A — visitor-facing entry unchanged
SECURITY.md N/A — no secrets or trust boundary change
.env.example N/A — no new env

Implementation order

  1. ui/composer_history.ziguserCount / userTextAt / step (pure)
  2. ui/composer_history.test.zig — cases below; register in build.zig test-rich
  3. ui/state.zighistory_index, history_draft_buf, history_draft_len; clear in resetTranscriptScroll
  4. ui.zig frame() composer !busy event scan — intercept ↑/↓; applyHistory; load/restore NUL-clean prompt_buf before textEntry
  5. ui.zig n < prev_msg branch — drop history state (with the chip)
  6. ui/composer.zig submitText — reset history_index + draft len
  7. docs/harness-limits.md — keyboard table row

Testing

# Case Layer Type Command / method
1 userCount / userTextAt skip assistant/thinking/tool/skill/system/err harness unit zig build test-rich
2 Newest-first: last user in visible order is ordinal 0 harness unit zig build test-rich
3 Empty ring → count 0, userTextAt null harness unit zig build test-rich
4 All-user ring → count == len; ordinal len-1 is the oldest harness unit zig build test-rich
5 step(null, n, older) → load 0; step(null, 0, older) → noop harness unit zig build test-rich
6 step(0, n, older) → load 1; step(n-1, n, older) → stay harness unit zig build test-rich
7 step(0, n, newer) → restore_draft; step(null, n, newer) → noop harness unit zig build test-rich
8 step(Some(i), n, _) with i >= n → restore_draft harness unit zig build test-rich
9 Submit resets history_index (composer helper or state assert) harness unit zig build test-rich
10 Operator: empty ↑ loads newest; second ↑ older; ↓↓ restores draft Wasm operator Preview
11 Operator: type two lines, ↑/↓ move caret (do not load history) Wasm operator Preview
12 Operator: Send from a loaded history line → new user row; next ↑ is that row Wasm operator Preview
13 Operator: New / Clear / session switch while in history → no stale text / no intercept Wasm operator Preview
14 Operator: Busy ↑/↓ do not load history Wasm operator Preview
15 npx tsc --noEmit DOM build gate CI / agent workspace
16 build-harness green harness CI Self-hosted runner

Minimum locked for DoD: 1–9, 15–16, plus operator 10–14.

Caps table

Cap / ceiling Value Rationale Code location
History window live ring (messageCount()RING_CAP 2048) No new named cap. Walking the visible window is the transport ceiling. composer_history.zig consumes a caller-built view of messageAt
history_draft_buf SUBMIT_CAP (262144) Same as prompt_buf. Not a change to SUBMIT_CAP. ui/state.zig

No existing cap is raised or lowered.

Definition of done

  • First ↑ from an empty idle composer loads the newest user row; further ↑ walk older; no wrap
  • ↓ walks newer; ↓ from index 0 restores the draft saved on entry
  • Non-empty composer that is not in history: ↑/↓ move the caret (Goal 5)
  • Assistant / thinking / tool_run / system / error / skill_attached never load
  • Busy: arrows pass through
  • Send from a loaded line creates a new user message and exits history
  • New / Clear / session hydrate drop history state (not only onInit)
  • composer_history + step unit tests green under test-rich
  • build-harness green; npx tsc --noEmit green
  • docs/harness-limits.md keyboard table updated (timeless)
  • Cloud ops: N/A
  • No existing-cap change; no new HISTORY_MAX

Risks & mitigations

Risk Mitigation
Empty-only walk (first ↑ fills the field, second ↑ dies) Locked: empty is entry only; history_index != null keeps intercepting
prompt_buf[0] == 0 false empty if a load forgets to NUL-terminate Every write path memset + trailing 0 (same as clearPrompt)
messageHead() off-by-one Do not walk physical slots; messageAt visible indices only
Stale ordinal after saturate / Clear Re-resolve each step; i >= n restores draft; clear path drops state
Held ↑ also moves caret Mark .down and .repeat handled (Ctrl+Enter pattern)
#666 merges first Share the composer event loop; skip history intercept while a queue row is editing

Open questions

None — in-scope engineering decisions are locked above.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions