You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.zign < 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.zigup/down)
#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
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.zigframe() composer event scan
Same pre-textEntry loop that already catches Ctrl+Enter (!busy)
Clear / hydrate drop
harness
resetTranscriptScrollandui.zign < 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
/// Newest-first ordinal of the loaded user message, or null (live draft)./// 0 = newest user in the current ring window.pubvarhistory_index: ?usize=null;
/// Saved draft from the ↑ that *entered* history. Restored when ↓ walks past 0.pubvarhistory_draft_buf: [bridge.SUBMIT_CAP]u8= [_]u8{0} **bridge.SUBMIT_CAP;
pubvarhistory_draft_len: usize=0;
Cleared in resetTranscriptScroll()and in the ui.zign < 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:
// ↑ — enter history only when the field is empty; walk while already in historyif (ke.code==.upandke.action==.down) {
constin_hist=state.history_index!=null;
constbuf_empty=state.prompt_buf[0] ==0; // valid: every write/clear NUL-terminatesif (in_historbuf_empty) {
e.handled=true;
if (ke.action==.down) applyHistory(.older);
}
}
// ↓ — only while in history (otherwise let the textEntry move the caret)if (ke.code==.downandke.action==.downandstate.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 .downand.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 → onInit → resetTranscriptScroll
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
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
Review notes (2026-08-18)
Reviewed issue
#667for correctness / performance / architecture / testing / cloud ops / living docs / layers / UI / cap governance.history_index != null, ↑/↓ stay intercepted even though the composer has the loaded textcollectUserSlotssketched againstbridge/messageHead()bridge.zigimport — same reason assubmit_queue). Walk visible indices viamessageAt, newest-first.messageHead()is the next write slot, not the last messagehistory_indexas “ring slot” aliases after wrap/saturateresetTranscriptScrollclears history on New/Clear” is falseui.zign < prev_msgclear/hydrate pathcollectUserSlots, not the step machinestepHistory+ load/restore/send-reset/clamp cases intest-richHISTORY_MAX = 64messageCount()≤RING_CAP2048)main@325e966.up/.down“best guess”4f810ef(src/enums.zigup/down)Status: HANDOFF-READY
Reviewed: 2026-08-18 (correctness / performance / architecture / testing / cloud ops N/A / living docs / layers / UI / caps)
Plan header
plan/composer-arrow-historydocs/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
Non-goals / out of scope
Architectural decisions
history_index != null· C) Empty OR caret at line 0messageAt(pure) · B) Pre-computed Vec on ring mutate · C) Bridge export from hostbridge.zigimport (hosttest-richcannot pull the web backend).history_indexmeaningmessageRevisionAt). Ordinal 0 is always the current newest user after re-resolve.history_draft_buf: [SUBMIT_CAP]u8in state.zig — 256 KiB BSS, zero alloc in the frame path.HISTORY_MAX = 64· B) No new cap — window is the ringRING_CAP(2048).Layer placement
ui/state.zigprompt_buf— single owner for all composer stateui/composer_history.zig(new)bridge.zigprompt_bufui.zigframe()composer event scantextEntryloop that already catches Ctrl+Enter (!busy)resetTranscriptScrollandui.zign < prev_msgresetTranscriptScrollis onInit-only todayui/composer_history.test.zig(new)Current baseline (live code)
main@325e966(2026-08-18). Protocol v17. Open PR #666 (queue-while-busy) is not merged — implement againstmain. If #666 lands first: share the composer event loop (do not steal arrows from the queue-row editor);submitOrEnqueuestill resetshistory_index.!busynative/harness/src/ui.zig~443–468if (!busy) { for (dvui.events()) … ke.code == .enter }thentextEntry. ↑/↓ join this scan so the widget never moves the caretcomposer.submitText(typed)ui.zig~516 / ~549 ·ui/composer.zig:12-24queueSubmitFromUi+clearPrompt(@memset0)bridge.messageAt(i)→{kind, text}bridge.zig:183MessageKind.user = 1bridge.messageCount()bridge.zig:170bridge.messageHead()bridge.zig:179SUBMIT_CAP/MAX_MSG_LENbridge.zig:73·ring_slot.MAX_MSG_LEN= 262144RING_CAP/MAX_MSGbridge.zig:61-66state.prompt_bufui/state.zig:12textEntry.bufferte.getText()after initui.zig~505resetTranscriptScroll()ui/state.zig:83-98onInitonly. Clear/New/hydrate useui.zign < prev_msg(~379) and do not go through this helperke.code == .up/.down4f810efsrc/enums.zigup,downexist; alsopage_up/page_down)bridge.MessageKindDesign
State (new, in
state.zig)Cleared in
resetTranscriptScroll()and in theui.zign < prev_msgring-clear/hydrate branch (same place the chip drops).onInitalready callsresetTranscriptScroll— no extra onInit work.History module (
composer_history.zig) — pureDo not import
bridge.zigor dvui. Caller (frame) walksmessageAtinto a tiny view:Frame, on a handled ↑/↓:
msgsasmessageCount()KindTextfrommessageAt(0..n)(stack[RING_CAP]KindTextor walk twice — no GPA).n_user = userCount(msgs).history_indexisSome(i)andi >= n_user(saturate / unexpected shrink): treat as restore_draft (exit history).r = step(history_index, n_user, dir).load→ memcpyuserTextAtintoprompt_buf(memset tail / trailing 0 so the buffer stays NUL-clean);restore_draft→ copyhistory_draft_buf[0..history_draft_len]back;noop→ nothing.Key intercept (composer event scan,
!busy, beforetextEntry)Empty invariant:
clearPrompt, history load, and draft restore all@memset/ write a trailing 0. Do not useprompt_buf[0] == 0as a substitute forgetText()anywhere else.Mark
.downand.repeathandled (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.repeatalso steps — pick one and test: step on.downand.repeatso a held ↑ walks, matching shell history).Load / step behavior
0(newest)1if another user exists; else stayhistory_index = nulliin(0, n)i+1ifi+1 < n; else stay at oldest (no wrap)i-1i >= nafter ring shrinkif (!busy)scan)Draft save
The ↑ that enters history (transition
null → 0) copiesprompt_bufintohistory_draft_bufand recordshistory_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.submitTextalways reads the live buffer. After a successful (non-blank) submit, sethistory_index = nulland zero the draft len so the next ↑ picks up the message just sent. Blank-reject (clearPromptonly) also clearshistory_indexif set — the field is empty again, so a following ↑ re-enters at 0.Edge cases
messageAt. If current ordinal vanished, restore draftMAX_MSG_LEN==SUBMIT_CAP— always fitsn < prev_msg) → drophistory_index+ draft. Do not rely onresetTranscriptScrollaloneonInit→resetTranscriptScrolltextEntryowns its own chords; do not handle ↑/↓ when a queue row is being editedCloud ops path
N/A — no Production mutate. Zig compiles on the self-hosted
build-harnessrunner (existing artifact → Vercel path).Living docs plan
docs/harness-limits.mdAGENTS.mdREADME.mdSECURITY.md.env.exampleImplementation order
ui/composer_history.zig—userCount/userTextAt/step(pure)ui/composer_history.test.zig— cases below; register inbuild.zigtest-richui/state.zig—history_index,history_draft_buf,history_draft_len; clear inresetTranscriptScrollui.zigframe() composer!busyevent scan — intercept ↑/↓;applyHistory; load/restore NUL-cleanprompt_bufbeforetextEntryui.zign < prev_msgbranch — drop history state (with the chip)ui/composer.zigsubmitText— resethistory_index+ draft lendocs/harness-limits.md— keyboard table rowTesting
userCount/userTextAtskip assistant/thinking/tool/skill/system/errzig build test-richzig build test-richuserTextAtnullzig build test-richlen-1is the oldestzig build test-richstep(null, n, older)→ load 0;step(null, 0, older)→ noopzig build test-richstep(0, n, older)→ load 1;step(n-1, n, older)→ stayzig build test-richstep(0, n, newer)→ restore_draft;step(null, n, newer)→ noopzig build test-richstep(Some(i), n, _)withi >= n→ restore_draftzig build test-richhistory_index(composer helper or state assert)zig build test-richnpx tsc --noEmitbuild-harnessgreenMinimum locked for DoD: 1–9, 15–16, plus operator 10–14.
Caps table
messageCount()≤RING_CAP2048)composer_history.zigconsumes a caller-built view ofmessageAthistory_draft_bufSUBMIT_CAP(262144)prompt_buf. Not a change toSUBMIT_CAP.ui/state.zigNo existing cap is raised or lowered.
Definition of done
onInit)composer_history+stepunit tests green undertest-richbuild-harnessgreen;npx tsc --noEmitgreendocs/harness-limits.mdkeyboard table updated (timeless)HISTORY_MAXRisks & mitigations
history_index != nullkeeps interceptingprompt_buf[0] == 0false empty if a load forgets to NUL-terminateclearPrompt)messageHead()off-by-onemessageAtvisible indices onlyi >= nrestores draft; clear path drops state.downand.repeathandled (Ctrl+Enter pattern)Open questions
None — in-scope engineering decisions are locked above.
References
docs/harness-limits.md— existing composer keyboard table