From 1cc65b5d70bfa687dee5f15abf57c56b8d2eefde Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 19 Aug 2026 02:41:06 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(harness):=20composer=20=E2=86=91/?= =?UTF-8?q?=E2=86=93=20arrow-key=20history=20=E2=80=94=20navigate=20prior?= =?UTF-8?q?=20user=20messages=20(plan=20#667)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/harness-limits.md | 1 + native/harness/build.zig | 16 +- native/harness/src/ui.zig | 96 ++++++++++++ native/harness/src/ui/composer.zig | 13 ++ .../harness/src/ui/composer_history.test.zig | 147 ++++++++++++++++++ native/harness/src/ui/composer_history.zig | 83 ++++++++++ native/harness/src/ui/state.zig | 11 ++ 7 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 native/harness/src/ui/composer_history.test.zig create mode 100644 native/harness/src/ui/composer_history.zig diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 2dc32c8d..9e7cea3c 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -47,6 +47,7 @@ click/tap scrolls the transcript so the message is back in view near the top. |-------|--------| | **Enter** (composer focused) | Insert a newline (composer is multi-line) | | **Ctrl+Enter** / **Cmd+Enter** (composer focused) | Send prompt when idle; **enqueue** a follow-up when Busy | +| **↑** / **↓** (composer focused, idle) | **↑** on an empty composer loads the newest user message; further ↑ walk older user rows. **↓** walks forward; ↓ past the newest restores the in-progress draft. Only user messages appear (assistant/thinking/tool/system/error/skill rows never load). Not active while Busy — arrows pass through to the text caret. Ring window only (Load earlier for prompts that have aged out) | | Tab | DOM nav / Clear (canvas uses pointer + dvui focus) | | Composer focus | Requested on ready and after each send | diff --git a/native/harness/build.zig b/native/harness/build.zig index 51cd71d4..c57f3c70 100644 --- a/native/harness/build.zig +++ b/native/harness/build.zig @@ -171,7 +171,7 @@ pub fn build(b: *std.Build) void { test_parse.dependOn(&run_parse_tests.step); // Host unit tests for cache / link allowlist / kind gate (no dvui frame). - const test_rich = b.step("test-rich", "Run rich/* host unit tests (parse, cache, links, link_click, kinds, image_cache, math, math_cache, diff_lang, highlight, unicode_face, blockquote, table, thematic, footnote, deflist) + composer_text + cwd_slot + ring_slot (#404 write seam) + chip_preview (#645) + text_wave (#655) + rect_spinner (#651) + busy_spinner + elapsed_clock + model_catalog + session_catalog + submit_queue + queue_preview + queue_band + paint_diff"); + const test_rich = b.step("test-rich", "Run rich/* host unit tests (parse, cache, links, link_click, kinds, image_cache, math, math_cache, diff_lang, highlight, unicode_face, blockquote, table, thematic, footnote, deflist) + composer_text + composer_history + cwd_slot + ring_slot (#404 write seam) + chip_preview (#645) + text_wave (#655) + rect_spinner (#651) + busy_spinner + elapsed_clock + model_catalog + session_catalog + submit_queue + queue_preview + queue_band + paint_diff"); test_rich.dependOn(&run_parse_tests.step); const cache_tests = b.addTest(.{ @@ -199,6 +199,20 @@ pub fn build(b: *std.Build) void { test_rich.dependOn(&b.addRunArtifact(composer_text_tests).step); } + // Host unit tests for composer_history.zig (plan #667): userCount, + // userTextAt, step machine. Pure, no dvui, no bridge.zig. + { + const composer_history_tests = b.addTest(.{ + .name = "composer_history", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/ui/composer_history.test.zig"), + .target = host_target, + .optimize = optimize, + }), + }); + test_rich.dependOn(&b.addRunArtifact(composer_history_tests).step); + } + // Host unit tests for cwd_slot.zig (plan #579, adversarial review #584 // Minor L6): the "."-hidden predicate. Pure, no dvui. { diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index dcee943f..0f6c3f5e 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -26,11 +26,78 @@ const chip = @import("ui/chip.zig"); const status = @import("ui/status.zig"); const skill = @import("ui/skill.zig"); const composer = @import("ui/composer.zig"); +const composer_history = @import("ui/composer_history.zig"); const queue_band = @import("ui/queue_band.zig"); /// Baked at compile time (`-Dbuild-id=…`); shown in header to detect stale wasm. pub const BUILD_ID: []const u8 = build_options.build_id; +/// Apply one arrow-key history step (plan #667). Called from the composer +/// event scan BEFORE textEntry — the step machine loads a prior user message +/// into `prompt_buf` or restores the saved draft. Pure data walk: no dvui, +/// no alloc, no bridge.zig import in the history module. +fn historyApply(dir: composer_history.Step) void { + const n = bridge.messageCount(); + // Stack allocate a KindText view from the ring. RING_CAP = 2048. + // Walk visible indices only (messageAt), newest-first. + var msgs_buf: [2048]composer_history.KindText = undefined; + var user_n: usize = 0; + var newest: usize = 0; // ordinal counter for newest-first collection + { + var i: usize = 0; + while (i < n) : (i += 1) { + if (bridge.messageAt(i)) |m| { + msgs_buf[i] = .{ .kind = m.kind, .text = m.text }; + if (m.kind == composer_history.USER_KIND) { + newest += 1; + } + } else { + msgs_buf[i] = .{ .kind = 0, .text = "" }; + } + } + user_n = newest; + } + const msgs = msgs_buf[0..n]; + + // Save draft on the step that ENTERS history (null → some index). + // Subsequent walks leave the saved draft alone. + const entering = state.history_index == null and dir == .older and user_n > 0; + if (entering) { + // Use the caret-positioned buffer from the current textEntry by + // reading prompt_buf directly — this is the same buffer textEntry + // points at, so it captures the most recent operator keystrokes. + const draft = std.mem.sliceTo(&state.prompt_buf, 0); + const dlen = @min(draft.len, state.history_draft_buf.len); + if (dlen > 0) @memcpy(state.history_draft_buf[0..dlen], draft[0..dlen]); + state.history_draft_len = dlen; + } + + const r = composer_history.step(state.history_index, user_n, dir); + state.history_index = r.index; + + switch (r.outcome) { + .load => { + if (r.index) |idx| { + if (composer_history.userTextAt(msgs, idx)) |text| { + const ncopy = @min(text.len, state.prompt_buf.len - 1); + @memset(&state.prompt_buf, 0); + if (ncopy > 0) @memcpy(state.prompt_buf[0..ncopy], text[0..ncopy]); + state.prompt_buf[ncopy] = 0; + } + } + }, + .restore_draft => { + @memset(&state.prompt_buf, 0); + const dlen = @min(state.history_draft_len, state.prompt_buf.len - 1); + if (dlen > 0) @memcpy(state.prompt_buf[0..dlen], state.history_draft_buf[0..dlen]); + state.prompt_buf[dlen] = 0; + state.history_draft_len = 0; + @memset(&state.history_draft_buf, 0); + }, + .noop => {}, + } +} + pub fn onInit() void { bridge.reset(); @memset(&state.prompt_buf, 0); @@ -396,6 +463,11 @@ pub fn frame() !void { // New/Clear or a session switch ghosts a band and blocks promote // until an unmarked Escape (adversarial review #666 Major L1). queue_band.resetQueueEditState(); + // Drop composer arrow-key history state (plan #667) — a New / + // Clear / session hydrate drops the ring, so ordinals are stale. + state.history_index = null; + state.history_draft_len = 0; + @memset(&state.history_draft_buf, 0); } else if (count_changed or content_grew) { const newest_is_user = blk: { if (n == 0) break :blk false; @@ -481,6 +553,7 @@ pub fn frame() !void { .key => |k| k, else => continue, }; + if (ke.code == .enter and (ke.mod.control() or ke.mod.command())) { // A multiline textEntry consumes Enter and inserts '\n', // ignoring the modifier (verified against pinned dvui), so @@ -489,6 +562,29 @@ pub fn frame() !void { // stroke. Submit once per gesture, on the initial .down. e.handled = true; if (ke.action == .down) composer_submit = true; + continue; + } + + // ── Composer arrow-key history (plan #667) ─────────────────── + // ↑ enters history when the composer is empty OR while already + // in history. ↓ walks history forward; outside history ↓ passes + // through (textEntry moves the caret). Both .down and .repeat + // are handled so a held arrow walks instead of also moving the + // caret (matching shell readline). + if (ke.action == .down or ke.action == .repeat) { + if (ke.code == .up) { + const in_hist = state.history_index != null; + const buf_empty = state.prompt_buf[0] == 0; + if (in_hist or buf_empty) { + e.handled = true; + if (ke.action == .down) historyApply(.older); + } + } else if (ke.code == .down) { + if (state.history_index != null) { + e.handled = true; + if (ke.action == .down) historyApply(.newer); + } + } } } } diff --git a/native/harness/src/ui/composer.zig b/native/harness/src/ui/composer.zig index 6740f65c..fe17f93b 100644 --- a/native/harness/src/ui/composer.zig +++ b/native/harness/src/ui/composer.zig @@ -8,6 +8,14 @@ pub fn clearPrompt() void { @memset(&state.prompt_buf, 0); } +/// Reset history state after the operator submits (or blank-rejects) from +/// history — the next ↑ must re-enter from the newest (plan #667). +pub fn resetHistory() void { + state.history_index = null; + state.history_draft_len = 0; + @memset(&state.history_draft_buf, 0); +} + pub fn submitText(text: []const u8) void { // Normalize CRLF/lone-CR -> LF and clamp to SUBMIT_CAP at a codepoint // boundary (composer_text.zig). Blank/whitespace after normalization is @@ -16,23 +24,28 @@ pub fn submitText(text: []const u8) void { const norm = composer_text.normalizeInto(text, state.prompt_buf[0..], bridge.SUBMIT_CAP); if (norm.is_blank) { clearPrompt(); + resetHistory(); return; } bridge.queueSubmitFromUi(norm.text); clearPrompt(); + resetHistory(); } pub fn submitOrEnqueue(text: []const u8) void { const norm = composer_text.normalizeInto(text, state.prompt_buf[0..], bridge.SUBMIT_CAP); if (norm.is_blank) { clearPrompt(); + resetHistory(); return; } if (bridge.getLifecycle() == .busy) { bridge.enqueueFromUi(norm.text) catch return; clearPrompt(); + resetHistory(); return; } bridge.queueSubmitFromUi(norm.text); clearPrompt(); + resetHistory(); } diff --git a/native/harness/src/ui/composer_history.test.zig b/native/harness/src/ui/composer_history.test.zig new file mode 100644 index 00000000..208c60f4 --- /dev/null +++ b/native/harness/src/ui/composer_history.test.zig @@ -0,0 +1,147 @@ +//! Unit tests for composer_history.zig — pure data module (no dvui, no bridge). +//! Plan #667: composer arrow-key history. +const std = @import("std"); +const hist = @import("composer_history.zig"); +const testing = std.testing; + +const KindText = hist.KindText; + +fn makeMsgs(comptime kinds: []const u8) [2048]KindText { + var msgs: [2048]KindText = undefined; + @memset(&msgs, KindText{ .kind = 0, .text = "" }); + for (kinds, 0..) |k, i| { + msgs[i] = KindText{ .kind = k, .text = "x" }; + } + return msgs; +} + +fn slice(msgs: []KindText, n: usize) []const KindText { + return msgs[0..n]; +} + +// ── userCount ────────────────────────────────────────────────────────────── + +test "userCount: empty → 0" { + var msgs = makeMsgs(&[_]u8{}); + try testing.expectEqual(0, hist.userCount(slice(&msgs, 0))); +} + +test "userCount: only user rows" { + var msgs = makeMsgs(&[_]u8{ 1, 1, 1 }); + try testing.expectEqual(3, hist.userCount(slice(&msgs, 3))); +} + +test "userCount: skips assistant/thinking/tool/skill/system/error" { + var msgs = makeMsgs(&[_]u8{ 2, 5, 6, 7, 3, 4, 1 }); + try testing.expectEqual(1, hist.userCount(slice(&msgs, 7))); +} + +// ── userTextAt (newest-first) ────────────────────────────────────────────── + +test "userTextAt: ordinal 0 = last user in visible order" { + var msgs = makeMsgs(&[_]u8{ 1, 2, 1 }); + // visible: user(user→"x"), assistant, user(user→"x") + const last = hist.userTextAt(slice(&msgs, 3), 0); + try testing.expect(last != null); + // ordinal 0 is the LAST user (index 2 in the msgs) +} + +test "userTextAt: ordinal 1 = second-to-last user" { + var msgs = makeMsgs(&[_]u8{ 1, 2, 1 }); + // ordinal 1 = the first user (index 0) + const second = hist.userTextAt(slice(&msgs, 3), 1); + try testing.expect(second != null); +} + +test "userTextAt: out of range → null" { + var msgs = makeMsgs(&[_]u8{1}); + const absent = hist.userTextAt(slice(&msgs, 1), 1); + try testing.expectEqual(@as(?[]const u8, null), absent); +} + +test "userTextAt: no user rows → null" { + var msgs = makeMsgs(&[_]u8{ 2, 3, 5 }); + const absent = hist.userTextAt(slice(&msgs, 3), 0); + try testing.expectEqual(@as(?[]const u8, null), absent); +} + +// ── step — older ─────────────────────────────────────────────────────────── + +test "step: null older → load 0" { + const r = hist.step(null, 3, .older); + try testing.expectEqual(hist.Outcome.load, r.outcome); + try testing.expectEqual(@as(?usize, 0), r.index); +} + +test "step: null older, no user rows → noop" { + const r = hist.step(null, 0, .older); + try testing.expectEqual(hist.Outcome.noop, r.outcome); + try testing.expectEqual(@as(?usize, null), r.index); +} + +test "step: i=0 older → load 1" { + const r = hist.step(0, 3, .older); + try testing.expectEqual(hist.Outcome.load, r.outcome); + try testing.expectEqual(@as(?usize, 1), r.index); +} + +test "step: i=n-1 older → stay (noop)" { + const r = hist.step(3, 4, .older); + try testing.expectEqual(hist.Outcome.noop, r.outcome); + try testing.expectEqual(@as(?usize, 3), r.index); +} + +// ── step — newer ─────────────────────────────────────────────────────────── + +test "step: null newer → noop" { + const r = hist.step(null, 3, .newer); + try testing.expectEqual(hist.Outcome.noop, r.outcome); + try testing.expectEqual(@as(?usize, null), r.index); +} + +test "step: i=0 newer → restore_draft" { + const r = hist.step(0, 3, .newer); + try testing.expectEqual(hist.Outcome.restore_draft, r.outcome); + try testing.expectEqual(@as(?usize, null), r.index); +} + +test "step: i=2 newer → load 1" { + const r = hist.step(2, 3, .newer); + try testing.expectEqual(hist.Outcome.load, r.outcome); + try testing.expectEqual(@as(?usize, 1), r.index); +} + +// ── step — saturate ──────────────────────────────────────────────────────── + +test "step: i >= n, older → restore_draft" { + // index 5 but only 3 user rows (ring shrunk) + const r = hist.step(5, 3, .older); + try testing.expectEqual(hist.Outcome.restore_draft, r.outcome); + try testing.expectEqual(@as(?usize, null), r.index); +} + +test "step: i >= n, newer → restore_draft" { + const r = hist.step(5, 3, .newer); + try testing.expectEqual(hist.Outcome.restore_draft, r.outcome); + try testing.expectEqual(@as(?usize, null), r.index); +} + +// ── edge: single user, walk both directions ───────────────────────────────── + +test "step: single user, older from null → load 0" { + const r = hist.step(null, 1, .older); + try testing.expectEqual(hist.Outcome.load, r.outcome); + try testing.expectEqual(@as(?usize, 0), r.index); +} + +test "step: single user, older from 0 → stay" { + const r = hist.step(0, 1, .older); + try testing.expectEqual(hist.Outcome.noop, r.outcome); + try testing.expectEqual(@as(?usize, 0), r.index); +} + +test "step: single user, newer from 0 → restore_draft" { + const r = hist.step(0, 1, .newer); + try testing.expectEqual(hist.Outcome.restore_draft, r.outcome); + try testing.expectEqual(@as(?usize, null), r.index); +} diff --git a/native/harness/src/ui/composer_history.zig b/native/harness/src/ui/composer_history.zig new file mode 100644 index 00000000..5e4fcbc5 --- /dev/null +++ b/native/harness/src/ui/composer_history.zig @@ -0,0 +1,83 @@ +//! Composer arrow-key history — navigate prior user messages (plan #667). +//! Pure data module: no dvui, no bridge.zig import. The caller (ui.zig frame) +//! walks `messageAt` into a tiny `KindText` view and drives the step machine. +//! +//! Newest-first: ordinal 0 is the most recent user message in the visible +//! ring window. Re-resolve each step so ordinals stay correct after wrap/shrink. + +pub const USER_KIND: u8 = 1; // bridge.MessageKind.user + +pub const KindText = struct { + kind: u8, + text: []const u8, +}; + +/// Count user-message (kind=1) rows in `msgs`. +pub fn userCount(msgs: []const KindText) usize { + var n: usize = 0; + for (msgs) |m| { + if (m.kind == USER_KIND) n += 1; + } + return n; +} + +/// Newest-first: ordinal 0 is the LAST user message in `msgs` (visible order +/// oldest→newest). Returns null when ordinal is out of range or no user rows. +pub fn userTextAt(msgs: []const KindText, ordinal: usize) ?[]const u8 { + var idx: usize = msgs.len; + var found: usize = 0; + while (idx > 0) { + idx -= 1; + const m = &msgs[idx]; + if (m.kind == USER_KIND) { + if (found == ordinal) return m.text; + found += 1; + } + } + return null; +} + +pub const Step = enum { older, newer }; + +pub const Outcome = enum { + load, // copy userTextAt into prompt_buf + restore_draft, // restore saved draft, clear history_index + noop, // nothing to do +}; + +pub const StepResult = struct { + outcome: Outcome, + index: ?usize, // next history_index (null = not in history) +}; + +/// Pure step machine — no side effects (no alloc, no global state). +/// +/// - older: null → 0 (enter, if user_n > 0); Some(i) → i+1 (stay at oldest) +/// - newer: null → noop; Some(0) → restore_draft; Some(i) → i-1 +/// - saturate: Some(i) with i >= user_n → restore_draft (ring shrink / wrap) +pub fn step(index: ?usize, user_n: usize, dir: Step) StepResult { + switch (dir) { + .older => { + if (index) |i| { + if (i >= user_n) return .{ .outcome = .restore_draft, .index = null }; + if (user_n == 0) return .{ .outcome = .noop, .index = null }; + const next = i + 1; + if (next < user_n) return .{ .outcome = .load, .index = next }; + return .{ .outcome = .noop, .index = i }; // at oldest already + } else { + if (user_n == 0) return .{ .outcome = .noop, .index = null }; + return .{ .outcome = .load, .index = 0 }; + } + }, + .newer => { + if (index) |i| { + if (i >= user_n) return .{ .outcome = .restore_draft, .index = null }; + if (i == 0) return .{ .outcome = .restore_draft, .index = null }; + // i > 0 → walk forward + return .{ .outcome = .load, .index = i - 1 }; + } else { + return .{ .outcome = .noop, .index = null }; + } + }, + } +} diff --git a/native/harness/src/ui/state.zig b/native/harness/src/ui/state.zig index 6c153906..795f06ae 100644 --- a/native/harness/src/ui/state.zig +++ b/native/harness/src/ui/state.zig @@ -82,6 +82,14 @@ pub var prev_chip_visible: bool = false; /// Queue-row being edited, or null. Held promote while non-null. pub var queue_editing_index: ?usize = null; /// One-frame settle for the queue band height (same pattern as chip / composer). +/// Composer arrow-key history (plan #667). Newest-first ordinal into the +/// visible user-message list, or null when not in history (live draft). +/// 0 = the most recent user message 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; + pub var prev_queue_band_h: f32 = 0; /// Set when an edit is saved or cancelled — Trigger B for `tryPromoteQueued`. pub var queue_closed_edit: bool = false; @@ -119,6 +127,9 @@ pub fn resetTranscriptScroll() void { @memset(&msg_content_y, 0); last_user_slot = null; prev_chip_visible = false; + history_index = null; + history_draft_len = 0; + @memset(&history_draft_buf, 0); queue_editing_index = null; queue_edit_textentry_id = null; queue_want_editor_focus = false; From 92f0ab67308d6145cc97a6de51ee263a7389fcc0 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 19 Aug 2026 03:06:45 +0000 Subject: [PATCH 2/8] fix(composer_history): add busy + queue-edit guards, .repeat walk, hydrate drop, RING_CAP, distinct-text tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address adversarial review #686: #1 Major (L1): Skip history intercept when busy or editing a queued item. Busy → arrows pass through to textEntry; queue-row editor owns the caret. #2 Major (L1): Same guard above covers queue_editing_index. #3 Major (L1): Call historyApply on .repeat as well as .down so a held arrow walks through history (matching shell readline). #4 Major (L1): Drop history state on hydrate-to-same-or-longer-session. The n < prev_msg block catches clear-to-zero; the new guard after shouldDropEditOnEmptyQueue catches clearMessages+push in one batch. Condition: prev_msg > 0, history active, ring changed, FIFO empty. #5 Minor (L6): Use distinct text strings in userTextAt tests so ordinal 0 → "last" and ordinal 1 → "first" are verifiable. #6 Minor (L8): Replace [2048]KindText with [bridge.RING_CAP]; move prev_queue_band_h doc comment to its own field in state.zig. --- native/harness/src/ui.zig | 45 +++++++++++++------ .../harness/src/ui/composer_history.test.zig | 31 ++++++++++--- native/harness/src/ui/state.zig | 2 +- 3 files changed, 58 insertions(+), 20 deletions(-) diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index 0f6c3f5e..6d8e8d37 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -38,9 +38,9 @@ pub const BUILD_ID: []const u8 = build_options.build_id; /// no alloc, no bridge.zig import in the history module. fn historyApply(dir: composer_history.Step) void { const n = bridge.messageCount(); - // Stack allocate a KindText view from the ring. RING_CAP = 2048. + // Stack allocate a KindText view from the ring (bridge.RING_CAP). // Walk visible indices only (messageAt), newest-first. - var msgs_buf: [2048]composer_history.KindText = undefined; + var msgs_buf: [bridge.RING_CAP]composer_history.KindText = undefined; var user_n: usize = 0; var newest: usize = 0; // ordinal counter for newest-first collection { @@ -493,6 +493,17 @@ pub fn frame() !void { if (queue_band.shouldDropEditOnEmptyQueue()) { queue_band.resetQueueEditState(); } + // Drop composer arrow-key history state on the same hydrate path that + // the queue-band guard catches (plan #667, adversarial review #686). + // During not-busy (when history is active per the event-scan guard), + // queuedCount() == 0 is the steady state and submit is not in flight, + // so a non-zero ring change with an empty FIFO signals a hydrate where + // clearMessages + push in one batch left msg_count >= prev_msg. + if (prev_msg > 0 and state.history_index != null and n != prev_msg and bridge.queuedCount() == 0) { + state.history_index = null; + state.history_draft_len = 0; + @memset(&state.history_draft_buf, 0); + } // Always refresh trackers (grow, shrink, no-op) so stream deltas stay accurate. state.last_shown_count = shown; @@ -571,18 +582,24 @@ pub fn frame() !void { // through (textEntry moves the caret). Both .down and .repeat // are handled so a held arrow walks instead of also moving the // caret (matching shell readline). - if (ke.action == .down or ke.action == .repeat) { - if (ke.code == .up) { - const in_hist = state.history_index != null; - const buf_empty = state.prompt_buf[0] == 0; - if (in_hist or buf_empty) { - e.handled = true; - if (ke.action == .down) historyApply(.older); - } - } else if (ke.code == .down) { - if (state.history_index != null) { - e.handled = true; - if (ke.action == .down) historyApply(.newer); + // + // Skip history when busy (model is running — arrows pass through + // to textEntry) or when editing a queued item (queue-row editor + // owns the caret). + if (!busy and state.queue_editing_index == null) { + if (ke.action == .down or ke.action == .repeat) { + if (ke.code == .up) { + const in_hist = state.history_index != null; + const buf_empty = state.prompt_buf[0] == 0; + if (in_hist or buf_empty) { + e.handled = true; + historyApply(.older); + } + } else if (ke.code == .down) { + if (state.history_index != null) { + e.handled = true; + historyApply(.newer); + } } } } diff --git a/native/harness/src/ui/composer_history.test.zig b/native/harness/src/ui/composer_history.test.zig index 208c60f4..cb03ddab 100644 --- a/native/harness/src/ui/composer_history.test.zig +++ b/native/harness/src/ui/composer_history.test.zig @@ -15,6 +15,17 @@ fn makeMsgs(comptime kinds: []const u8) [2048]KindText { return msgs; } +/// Build a slice of KindText with distinct text per row. Each user row +/// gets its own text label so `userTextAt` ordinals are verifiable. +fn buildMsgs(comptime rows: []const struct { kind: u8, text: []const u8 }) [2048]KindText { + var msgs: [2048]KindText = undefined; + @memset(&msgs, KindText{ .kind = 0, .text = "" }); + for (rows, 0..) |r, i| { + msgs[i] = KindText{ .kind = r.kind, .text = r.text }; + } + return msgs; +} + fn slice(msgs: []KindText, n: usize) []const KindText { return msgs[0..n]; } @@ -39,18 +50,28 @@ test "userCount: skips assistant/thinking/tool/skill/system/error" { // ── userTextAt (newest-first) ────────────────────────────────────────────── test "userTextAt: ordinal 0 = last user in visible order" { - var msgs = makeMsgs(&[_]u8{ 1, 2, 1 }); - // visible: user(user→"x"), assistant, user(user→"x") + var msgs = buildMsgs(&.{ + .{ .kind = 1, .text = "first" }, + .{ .kind = 2, .text = "ignored" }, + .{ .kind = 1, .text = "last" }, + }); + // visible: user(text="first"), assistant, user(text="last") + // ordinal 0 = newest-first = the LAST user (index 2 → "last") const last = hist.userTextAt(slice(&msgs, 3), 0); try testing.expect(last != null); - // ordinal 0 is the LAST user (index 2 in the msgs) + try testing.expectEqualStrings("last", last.?); } test "userTextAt: ordinal 1 = second-to-last user" { - var msgs = makeMsgs(&[_]u8{ 1, 2, 1 }); - // ordinal 1 = the first user (index 0) + var msgs = buildMsgs(&.{ + .{ .kind = 1, .text = "first" }, + .{ .kind = 2, .text = "ignored" }, + .{ .kind = 1, .text = "last" }, + }); + // ordinal 1 = the first user (index 0 → "first") const second = hist.userTextAt(slice(&msgs, 3), 1); try testing.expect(second != null); + try testing.expectEqualStrings("first", second.?); } test "userTextAt: out of range → null" { diff --git a/native/harness/src/ui/state.zig b/native/harness/src/ui/state.zig index 795f06ae..d18dec19 100644 --- a/native/harness/src/ui/state.zig +++ b/native/harness/src/ui/state.zig @@ -81,7 +81,6 @@ pub var prev_chip_visible: bool = false; /// Queue-row being edited, or null. Held promote while non-null. pub var queue_editing_index: ?usize = null; -/// One-frame settle for the queue band height (same pattern as chip / composer). /// Composer arrow-key history (plan #667). Newest-first ordinal into the /// visible user-message list, or null when not in history (live draft). /// 0 = the most recent user message in the current ring window. @@ -90,6 +89,7 @@ pub var history_index: ?usize = null; pub var history_draft_buf: [bridge.SUBMIT_CAP]u8 = [_]u8{0} ** bridge.SUBMIT_CAP; pub var history_draft_len: usize = 0; +/// One-frame settle for the queue band height (same pattern as chip / composer). pub var prev_queue_band_h: f32 = 0; /// Set when an edit is saved or cancelled — Trigger B for `tryPromoteQueued`. pub var queue_closed_edit: bool = false; From f671289c1763848be6bff19ea0863b4cacd0420f Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 19 Aug 2026 03:20:35 +0000 Subject: [PATCH 3/8] =?UTF-8?q?fix(harness):=20fingerprint-based=20history?= =?UTF-8?q?=20drop=20=E2=80=94=20survives=20Load=20earlier,=20catches=20sa?= =?UTF-8?q?me-count=20hydrate=20(R2=20#686)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native/harness/src/ui.zig | 59 +++++++++++++++++++++++++----- native/harness/src/ui/composer.zig | 2 + native/harness/src/ui/state.zig | 8 ++++ 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index 6d8e8d37..eb975cd8 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -70,6 +70,25 @@ fn historyApply(dir: composer_history.Step) void { const dlen = @min(draft.len, state.history_draft_buf.len); if (dlen > 0) @memcpy(state.history_draft_buf[0..dlen], draft[0..dlen]); state.history_draft_len = dlen; + + // Record a fingerprint of the newest user row so frame() can detect + // session hydrate vs Load earlier (plan #667, review #686 R2). + // Load earlier preserves the newest user → fingerprint matches. + // Session hydrate replaces all rows → fingerprint mismatches → drop. + { + var ri: usize = n; + while (ri > 0) { + ri -= 1; + if (bridge.messageAt(ri)) |m| { + if (m.kind == composer_history.USER_KIND) { + const fplen = @min(m.text.len, state.history_newest_fingerprint.len); + state.history_newest_fp_len = @intCast(fplen); + @memcpy(state.history_newest_fingerprint[0..fplen], m.text[0..fplen]); + break; + } + } + } + } } const r = composer_history.step(state.history_index, user_n, dir); @@ -493,16 +512,36 @@ pub fn frame() !void { if (queue_band.shouldDropEditOnEmptyQueue()) { queue_band.resetQueueEditState(); } - // Drop composer arrow-key history state on the same hydrate path that - // the queue-band guard catches (plan #667, adversarial review #686). - // During not-busy (when history is active per the event-scan guard), - // queuedCount() == 0 is the steady state and submit is not in flight, - // so a non-zero ring change with an empty FIFO signals a hydrate where - // clearMessages + push in one batch left msg_count >= prev_msg. - if (prev_msg > 0 and state.history_index != null and n != prev_msg and bridge.queuedCount() == 0) { - state.history_index = null; - state.history_draft_len = 0; - @memset(&state.history_draft_buf, 0); + // Drop composer arrow-key history when the newest user row's identity + // changed since entry (plan #667, adversarial review #686 R2). + // The n < prev_msg block above already handles ring-clear (New / Clear). + // This fingerprint check catches session hydrate (same-or-different-count + // batch replace) where the newest user message is a different row than the + // one we entered on. Load earlier preserves the newest user row unchanged → + // fingerprint matches → history survives. Submit already resets history_index + // via resetHistory(), so this guard only fires when the ring mutated without + // a submit — i.e. hydrate or Load earlier. + if (state.history_index != null and state.history_newest_fp_len > 0) { + var fp_match = false; + // Walk newest-first to find the current newest user row. + var ri: usize = n; + while (ri > 0) { + ri -= 1; + if (bridge.messageAt(ri)) |m| { + if (m.kind == composer_history.USER_KIND) { + const fp = state.history_newest_fingerprint[0..state.history_newest_fp_len]; + fp_match = std.mem.eql(u8, fp, m.text[0..@min(m.text.len, fp.len)]); + break; + } + } + } + if (!fp_match) { + state.history_index = null; + state.history_draft_len = 0; + @memset(&state.history_draft_buf, 0); + @memset(&state.history_newest_fingerprint, 0); + state.history_newest_fp_len = 0; + } } // Always refresh trackers (grow, shrink, no-op) so stream deltas stay accurate. diff --git a/native/harness/src/ui/composer.zig b/native/harness/src/ui/composer.zig index fe17f93b..100c1a58 100644 --- a/native/harness/src/ui/composer.zig +++ b/native/harness/src/ui/composer.zig @@ -14,6 +14,8 @@ pub fn resetHistory() void { state.history_index = null; state.history_draft_len = 0; @memset(&state.history_draft_buf, 0); + @memset(&state.history_newest_fingerprint, 0); + state.history_newest_fp_len = 0; } pub fn submitText(text: []const u8) void { diff --git a/native/harness/src/ui/state.zig b/native/harness/src/ui/state.zig index d18dec19..f2e43e46 100644 --- a/native/harness/src/ui/state.zig +++ b/native/harness/src/ui/state.zig @@ -88,6 +88,12 @@ 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; +/// Fingerprint of the newest user row at history-entry time (first 64 bytes). +/// Compared each frame while history is active: a mismatch means the newest +/// user row changed identity (session hydrate / ring wrap), so drop history. +/// Load earlier preserves the newest user row → fingerprint matches → no drop. +pub var history_newest_fingerprint: [64]u8 = [_]u8{0} ** 64; +pub var history_newest_fp_len: u6 = 0; /// One-frame settle for the queue band height (same pattern as chip / composer). pub var prev_queue_band_h: f32 = 0; @@ -130,6 +136,8 @@ pub fn resetTranscriptScroll() void { history_index = null; history_draft_len = 0; @memset(&history_draft_buf, 0); + @memset(&history_newest_fingerprint, 0); + history_newest_fp_len = 0; queue_editing_index = null; queue_edit_textentry_id = null; queue_want_editor_focus = false; From 031407deed4a19abf5b7f8254ce7962dbc61b8e6 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 19 Aug 2026 03:40:20 +0000 Subject: [PATCH 4/8] =?UTF-8?q?fix(composer=5Fhistory):=20fingerprint=20u6?= =?UTF-8?q?=E2=86=92u8,=20length-checked=20compare,=20draft=20restore,=20h?= =?UTF-8?q?elper=20+=207=20tests=20(#686=20R3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 Blocker: history_newest_fp_len u6 (0-63) panicked in Debug for >=64B text; ReleaseSmall wrapped to 0 disabling the drop entirely (any-text eql([0]u8{},text[0..0])==true). R3 Major: eql prefix match now uses fingerprintMatch helper with length guard (fp_len==0 returns false instead of matching any text). R3 Minor: on fingerprint-mismatch drop, restore saved draft to prompt_buf so the operator's pre-history prompt survives a session switch. Tests: 7 fingerprintMatch cases (empty, shorter, exact, prefix, 63-byte, 64-byte) so test-rich can fail the 0-length / prefix class. --- native/harness/src/ui.zig | 10 ++++- .../harness/src/ui/composer_history.test.zig | 43 +++++++++++++++++++ native/harness/src/ui/composer_history.zig | 16 +++++++ native/harness/src/ui/state.zig | 4 +- 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index eb975cd8..ec0d84a1 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -530,13 +530,21 @@ pub fn frame() !void { if (bridge.messageAt(ri)) |m| { if (m.kind == composer_history.USER_KIND) { const fp = state.history_newest_fingerprint[0..state.history_newest_fp_len]; - fp_match = std.mem.eql(u8, fp, m.text[0..@min(m.text.len, fp.len)]); + fp_match = composer_history.fingerprintMatch(fp, m.text); break; } } } if (!fp_match) { state.history_index = null; + // Restore saved draft so the operator's pre-history prompt + // survives a session switch / hydrate (#686 R3 Minor L1). + if (state.history_draft_len > 0) { + const dlen = @min(state.history_draft_len, state.prompt_buf.len - 1); + @memset(&state.prompt_buf, 0); + @memcpy(state.prompt_buf[0..dlen], state.history_draft_buf[0..dlen]); + state.prompt_buf[dlen] = 0; + } state.history_draft_len = 0; @memset(&state.history_draft_buf, 0); @memset(&state.history_newest_fingerprint, 0); diff --git a/native/harness/src/ui/composer_history.test.zig b/native/harness/src/ui/composer_history.test.zig index cb03ddab..bc2b49b8 100644 --- a/native/harness/src/ui/composer_history.test.zig +++ b/native/harness/src/ui/composer_history.test.zig @@ -166,3 +166,46 @@ test "step: single user, newer from 0 → restore_draft" { try testing.expectEqual(hist.Outcome.restore_draft, r.outcome); try testing.expectEqual(@as(?usize, null), r.index); } + +// ── fingerprintMatch ─────────────────────────────────────────────────────── + +test "fingerprintMatch: empty fp → false" { + try testing.expectEqual(false, hist.fingerprintMatch("", "any")); +} + +test "fingerprintMatch: candidate shorter than fp → false" { + try testing.expectEqual(false, hist.fingerprintMatch("hello world", "hi")); +} + +test "fingerprintMatch: exact match → true" { + try testing.expectEqual(true, hist.fingerprintMatch("exact", "exact")); +} + +test "fingerprintMatch: fp prefix of longer candidate → true (documented residual)" { + // R3 Major L1: with the length check in place, a 2-byte fingerprint + // "ok" DOES match candidate "okay rewrite the tests" because the first + // 2 bytes are identical. This is the documented residual of Strategy A + // (no session id). The real bug was fp_len==0 (u6 overflow in R3 Blocker) + // where `eql([0]u8{}, text[0..0])` == true for ANY text. + try testing.expectEqual(true, hist.fingerprintMatch("ok", "okay rewrite the tests")); +} + +test "fingerprintMatch: fp.len == 64, both match first 64 → true" { + // R3 Blocker: u6 overflow caused 64-byte text to panic or wrap. + // u8 stores 64 safely. Both candidate and fingerprint are identical. + const fp = "A" ** 64; + const candidate = "A" ** 64 ++ "extra"; + try testing.expectEqual(true, hist.fingerprintMatch(fp, candidate)); +} + +test "fingerprintMatch: fp.len == 63, candidate == 63 → true" { + const fp = "B" ** 63; + try testing.expectEqual(true, hist.fingerprintMatch(fp, fp)); +} + +test "fingerprintMatch: fp.len == 63, candidate == 64 with same prefix → true" { + // The first 63 bytes match → true (length-exact 63-byte compare). + const fp = "C" ** 63; + const candidate = "C" ** 63 ++ "D"; + try testing.expectEqual(true, hist.fingerprintMatch(fp, candidate)); +} diff --git a/native/harness/src/ui/composer_history.zig b/native/harness/src/ui/composer_history.zig index 5e4fcbc5..cebea3d3 100644 --- a/native/harness/src/ui/composer_history.zig +++ b/native/harness/src/ui/composer_history.zig @@ -4,6 +4,7 @@ //! //! Newest-first: ordinal 0 is the most recent user message in the visible //! ring window. Re-resolve each step so ordinals stay correct after wrap/shrink. +const std = @import("std"); pub const USER_KIND: u8 = 1; // bridge.MessageKind.user @@ -37,6 +38,21 @@ pub fn userTextAt(msgs: []const KindText, ordinal: usize) ?[]const u8 { return null; } +/// Compare candidate text against a stored fingerprint (first N bytes of the +/// newest user row at history-entry time). Returns true when the candidate +/// matches the fingerprint exactly (length checked, not a prefix). +/// +/// - fp.len == 0 → false (no fingerprint stored) +/// - candidate shorter than fp → false +/// - exact fp.len bytes match → true +/// - longer message starting with fp → false (length guards prefix collision: +/// fp="ok" does not match candidate="okay rewrite the tests") +pub fn fingerprintMatch(fp: []const u8, candidate: []const u8) bool { + if (fp.len == 0) return false; + if (candidate.len < fp.len) return false; + return std.mem.eql(u8, fp, candidate[0..fp.len]); +} + pub const Step = enum { older, newer }; pub const Outcome = enum { diff --git a/native/harness/src/ui/state.zig b/native/harness/src/ui/state.zig index f2e43e46..e6a824cd 100644 --- a/native/harness/src/ui/state.zig +++ b/native/harness/src/ui/state.zig @@ -93,7 +93,9 @@ pub var history_draft_len: usize = 0; /// user row changed identity (session hydrate / ring wrap), so drop history. /// Load earlier preserves the newest user row → fingerprint matches → no drop. pub var history_newest_fingerprint: [64]u8 = [_]u8{0} ** 64; -pub var history_newest_fp_len: u6 = 0; +/// Length 0..64. u8 so 64 is storable (Debug @intCast of 64 into u6 panics; +/// ReleaseSmall wraps to 0 which disables the drop entirely — #686 R3 Blocker). +pub var history_newest_fp_len: u8 = 0; /// One-frame settle for the queue band height (same pattern as chip / composer). pub var prev_queue_band_h: f32 = 0; From 9e2729d46c559d44255c8d81a42ed156ef6c62a2 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 19 Aug 2026 14:40:22 +0000 Subject: [PATCH 5/8] fix(composer_history): exact fingerprint when fp < 64; restore empty draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R4 #686: short fingerprints now require full-string identity (ok ≠ okay). Truncated 64-byte store still prefix-matches. Session-switch drop always memsets prompt_buf then copies the saved draft, including empty. Refs #686 --- native/harness/src/ui.zig | 15 ++++++------ .../harness/src/ui/composer_history.test.zig | 23 +++++++++--------- native/harness/src/ui/composer_history.zig | 24 ++++++++++++------- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index ec0d84a1..66e20ba5 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -537,14 +537,13 @@ pub fn frame() !void { } if (!fp_match) { state.history_index = null; - // Restore saved draft so the operator's pre-history prompt - // survives a session switch / hydrate (#686 R3 Minor L1). - if (state.history_draft_len > 0) { - const dlen = @min(state.history_draft_len, state.prompt_buf.len - 1); - @memset(&state.prompt_buf, 0); - @memcpy(state.prompt_buf[0..dlen], state.history_draft_buf[0..dlen]); - state.prompt_buf[dlen] = 0; - } + // Always restore the saved draft — including empty — so a + // session switch does not leave the foreign history line in + // prompt_buf as a fake typed draft (#686 R4 Major L1). + @memset(&state.prompt_buf, 0); + const dlen = @min(state.history_draft_len, state.prompt_buf.len - 1); + if (dlen > 0) @memcpy(state.prompt_buf[0..dlen], state.history_draft_buf[0..dlen]); + state.prompt_buf[dlen] = 0; state.history_draft_len = 0; @memset(&state.history_draft_buf, 0); @memset(&state.history_newest_fingerprint, 0); diff --git a/native/harness/src/ui/composer_history.test.zig b/native/harness/src/ui/composer_history.test.zig index bc2b49b8..958c5f88 100644 --- a/native/harness/src/ui/composer_history.test.zig +++ b/native/harness/src/ui/composer_history.test.zig @@ -181,18 +181,13 @@ test "fingerprintMatch: exact match → true" { try testing.expectEqual(true, hist.fingerprintMatch("exact", "exact")); } -test "fingerprintMatch: fp prefix of longer candidate → true (documented residual)" { - // R3 Major L1: with the length check in place, a 2-byte fingerprint - // "ok" DOES match candidate "okay rewrite the tests" because the first - // 2 bytes are identical. This is the documented residual of Strategy A - // (no session id). The real bug was fp_len==0 (u6 overflow in R3 Blocker) - // where `eql([0]u8{}, text[0..0])` == true for ANY text. - try testing.expectEqual(true, hist.fingerprintMatch("ok", "okay rewrite the tests")); +test "fingerprintMatch: fp prefix of longer candidate → false" { + // fp_len < 64 stores the whole short message — exact identity only. + try testing.expectEqual(false, hist.fingerprintMatch("ok", "okay rewrite the tests")); } test "fingerprintMatch: fp.len == 64, both match first 64 → true" { - // R3 Blocker: u6 overflow caused 64-byte text to panic or wrap. - // u8 stores 64 safely. Both candidate and fingerprint are identical. + // Truncated store: first 64 equal is the Strategy A residual. const fp = "A" ** 64; const candidate = "A" ** 64 ++ "extra"; try testing.expectEqual(true, hist.fingerprintMatch(fp, candidate)); @@ -203,9 +198,13 @@ test "fingerprintMatch: fp.len == 63, candidate == 63 → true" { try testing.expectEqual(true, hist.fingerprintMatch(fp, fp)); } -test "fingerprintMatch: fp.len == 63, candidate == 64 with same prefix → true" { - // The first 63 bytes match → true (length-exact 63-byte compare). +test "fingerprintMatch: fp.len == 63, candidate == 64 with same prefix → false" { const fp = "C" ** 63; const candidate = "C" ** 63 ++ "D"; - try testing.expectEqual(true, hist.fingerprintMatch(fp, candidate)); + try testing.expectEqual(false, hist.fingerprintMatch(fp, candidate)); +} + +test "fingerprintMatch: fp.len == 64, candidate shorter → false" { + const fp = "D" ** 64; + try testing.expectEqual(false, hist.fingerprintMatch(fp, "D" ** 63)); } diff --git a/native/harness/src/ui/composer_history.zig b/native/harness/src/ui/composer_history.zig index cebea3d3..d70fe62c 100644 --- a/native/harness/src/ui/composer_history.zig +++ b/native/harness/src/ui/composer_history.zig @@ -8,6 +8,11 @@ const std = @import("std"); pub const USER_KIND: u8 = 1; // bridge.MessageKind.user +/// Bytes stored from the newest user row at history-entry. Messages shorter +/// than this are stored in full (exact compare). Longer messages store a +/// prefix; only that truncated case may match a different longer string. +pub const FINGERPRINT_MAX: usize = 64; + pub const KindText = struct { kind: u8, text: []const u8, @@ -38,19 +43,20 @@ pub fn userTextAt(msgs: []const KindText, ordinal: usize) ?[]const u8 { return null; } -/// Compare candidate text against a stored fingerprint (first N bytes of the -/// newest user row at history-entry time). Returns true when the candidate -/// matches the fingerprint exactly (length checked, not a prefix). +/// Compare candidate text against a stored fingerprint of the newest user +/// row at history-entry time. /// /// - fp.len == 0 → false (no fingerprint stored) -/// - candidate shorter than fp → false -/// - exact fp.len bytes match → true -/// - longer message starting with fp → false (length guards prefix collision: -/// fp="ok" does not match candidate="okay rewrite the tests") +/// - fp.len < FINGERPRINT_MAX → exact identity (`eql(fp, candidate)`). +/// `fp="ok"` does **not** match `"okay rewrite the tests"`. +/// - fp.len >= FINGERPRINT_MAX → first-64 compare (truncated store). Two +/// different ≥64 B messages that share those 64 bytes still match — that +/// is the Strategy A residual without a session id. pub fn fingerprintMatch(fp: []const u8, candidate: []const u8) bool { if (fp.len == 0) return false; - if (candidate.len < fp.len) return false; - return std.mem.eql(u8, fp, candidate[0..fp.len]); + if (fp.len < FINGERPRINT_MAX) return std.mem.eql(u8, fp, candidate); + if (candidate.len < FINGERPRINT_MAX) return false; + return std.mem.eql(u8, fp[0..FINGERPRINT_MAX], candidate[0..FINGERPRINT_MAX]); } pub const Step = enum { older, newer }; From c880d3127c0e285b06b5fa38a84be098d34062b3 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 19 Aug 2026 15:18:38 +0000 Subject: [PATCH 6/8] fix(composer_history): extract restoreDraftToPrompt; n 0) { var fp_match = false; // Walk newest-first to find the current newest user row. @@ -537,13 +546,10 @@ pub fn frame() !void { } if (!fp_match) { state.history_index = null; - // Always restore the saved draft — including empty — so a - // session switch does not leave the foreign history line in - // prompt_buf as a fake typed draft (#686 R4 Major L1). - @memset(&state.prompt_buf, 0); - const dlen = @min(state.history_draft_len, state.prompt_buf.len - 1); - if (dlen > 0) @memcpy(state.prompt_buf[0..dlen], state.history_draft_buf[0..dlen]); - state.prompt_buf[dlen] = 0; + _ = composer_history.restoreDraftToPrompt( + &state.prompt_buf, + state.history_draft_buf[0..state.history_draft_len], + ); state.history_draft_len = 0; @memset(&state.history_draft_buf, 0); @memset(&state.history_newest_fingerprint, 0); diff --git a/native/harness/src/ui/composer_history.test.zig b/native/harness/src/ui/composer_history.test.zig index 958c5f88..decc9c19 100644 --- a/native/harness/src/ui/composer_history.test.zig +++ b/native/harness/src/ui/composer_history.test.zig @@ -208,3 +208,30 @@ test "fingerprintMatch: fp.len == 64, candidate shorter → false" { const fp = "D" ** 64; try testing.expectEqual(false, hist.fingerprintMatch(fp, "D" ** 63)); } + +// ── restoreDraftToPrompt ─────────────────────────────────────────────────── + +test "restoreDraftToPrompt: empty draft → all zeros, returns 0" { + var buf: [32]u8 = [_]u8{'X'} ** 32; + const n = hist.restoreDraftToPrompt(&buf, &.{}); + try testing.expectEqual(@as(usize, 0), n); + for (buf) |b| try testing.expectEqual(@as(u8, 0), b); +} + +test "restoreDraftToPrompt: non-empty draft → copied + nul-terminated" { + var buf: [32]u8 = [_]u8{'X'} ** 32; + const n = hist.restoreDraftToPrompt(&buf, "hello"); + try testing.expectEqual(@as(usize, 5), n); + try testing.expectEqualStrings("hello", buf[0..5]); + try testing.expectEqual(@as(u8, 0), buf[5]); + // Remainder zeroed. + for (buf[6..]) |b| try testing.expectEqual(@as(u8, 0), b); +} + +test "restoreDraftToPrompt: draft larger than prompt → truncated, nul-terminated" { + var buf: [5]u8 = [_]u8{'X'} ** 5; + const n = hist.restoreDraftToPrompt(&buf, "hello world"); + try testing.expectEqual(@as(usize, 4), n); + try testing.expectEqualStrings("hell", buf[0..4]); + try testing.expectEqual(@as(u8, 0), buf[4]); +} diff --git a/native/harness/src/ui/composer_history.zig b/native/harness/src/ui/composer_history.zig index d70fe62c..f0ae7e02 100644 --- a/native/harness/src/ui/composer_history.zig +++ b/native/harness/src/ui/composer_history.zig @@ -72,6 +72,19 @@ pub const StepResult = struct { index: ?usize, // next history_index (null = not in history) }; +/// Restore a saved draft into a prompt buffer. Always memsets prompt_buf to 0 +/// first, then copies up to draft.len bytes (0-length copy is a no-op for empty +/// drafts). Always nul-terminates. Returns the actual bytes copied (≤ prompt.len-1). +/// Caller sets history_index = null and clears history-state fields after calling. +pub fn restoreDraftToPrompt(prompt_buf: []u8, draft: []const u8) usize { + @memset(prompt_buf, 0); + if (draft.len == 0) return 0; + const ncopy = @min(draft.len, prompt_buf.len - 1); + @memcpy(prompt_buf[0..ncopy], draft[0..ncopy]); + prompt_buf[ncopy] = 0; + return ncopy; +} + /// Pure step machine — no side effects (no alloc, no global state). /// /// - older: null → 0 (enter, if user_n > 0); Some(i) → i+1 (stay at oldest) From 669a2fd4ce22129580037955a2dbcf8897a7df55 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 19 Aug 2026 19:28:20 +0000 Subject: [PATCH 7/8] fix(composer-history): gate n 0) { @@ -106,10 +108,10 @@ fn historyApply(dir: composer_history.Step) void { } }, .restore_draft => { - @memset(&state.prompt_buf, 0); - const dlen = @min(state.history_draft_len, state.prompt_buf.len - 1); - if (dlen > 0) @memcpy(state.prompt_buf[0..dlen], state.history_draft_buf[0..dlen]); - state.prompt_buf[dlen] = 0; + _ = composer_history.restoreDraftToPrompt( + &state.prompt_buf, + state.history_draft_buf[0..state.history_draft_len], + ); state.history_draft_len = 0; @memset(&state.history_draft_buf, 0); }, @@ -484,13 +486,16 @@ pub fn frame() !void { queue_band.resetQueueEditState(); // Drop composer arrow-key history state (plan #667) — a New / // Clear / session hydrate drops the ring, so ordinals are stale. - // Always restore the saved draft (including empty) so the composer - // never carries a foreign history line as a fake live draft (#686 R5). + // Only restore the saved draft when actually in history (#686 R6): + // a live draft in prompt_buf is operator content and must survive + // ring shrink (New / Clear / hydrate-to-shorter). + if (state.history_index != null) { + _ = composer_history.restoreDraftToPrompt( + &state.prompt_buf, + state.history_draft_buf[0..state.history_draft_len], + ); + } state.history_index = null; - _ = composer_history.restoreDraftToPrompt( - &state.prompt_buf, - state.history_draft_buf[0..state.history_draft_len], - ); state.history_draft_len = 0; @memset(&state.history_draft_buf, 0); @memset(&state.history_newest_fingerprint, 0); @@ -524,10 +529,11 @@ pub fn frame() !void { // changed since entry (plan #667, adversarial review #686 R2). // This fingerprint check catches session hydrate (same-or-different-count // batch replace) where the newest user message is a different row than the - // one we entered on. Load earlier preserves the newest user row unchanged → - // fingerprint matches → history survives. Submit already resets history_index - // via resetHistory(), so this guard only fires when the ring mutated without - // a submit — i.e. hydrate or Load earlier. + // one we entered on. Load earlier changes the ring via a sliding window + // (not a prepend), so the newest user usually changes and the fingerprint + // WILL mismatch — dropping history. This is acceptable because ordinals + // would name a different window after sliding. Submit already resets + // history_index via resetHistory(). // The n < prev_msg block above handles New / Clear / hydrate-to-shorter // with the same restoreDraftToPrompt helper (#686 R5). if (state.history_index != null and state.history_newest_fp_len > 0) { From ecb0465edd3dad4ae817117dc80a5b240fd6ae16 Mon Sep 17 00:00:00 2001 From: btipling Date: Wed, 19 Aug 2026 19:44:14 +0000 Subject: [PATCH 8/8] =?UTF-8?q?docs(state):=20fix=20history=5Fnewest=5Ffin?= =?UTF-8?q?gerprint=20field=20doc=20=E2=80=94=20Load=20earlier=20is=20a=20?= =?UTF-8?q?sliding=20window,=20not=20a=20prepend=20(#686=20R7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- native/harness/src/ui/state.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/native/harness/src/ui/state.zig b/native/harness/src/ui/state.zig index e6a824cd..ef78eb19 100644 --- a/native/harness/src/ui/state.zig +++ b/native/harness/src/ui/state.zig @@ -91,7 +91,9 @@ pub var history_draft_len: usize = 0; /// Fingerprint of the newest user row at history-entry time (first 64 bytes). /// Compared each frame while history is active: a mismatch means the newest /// user row changed identity (session hydrate / ring wrap), so drop history. -/// Load earlier preserves the newest user row → fingerprint matches → no drop. +/// Load earlier is a sliding window — the newest user usually changes, so the +/// fingerprint WILL mismatch and drop. Acceptable: ordinals name a different +/// ring window after sliding; re-entering history shows the new window's rows. pub var history_newest_fingerprint: [64]u8 = [_]u8{0} ** 64; /// Length 0..64. u8 so 64 is storable (Debug @intCast of 64 into u6 panics; /// ReleaseSmall wraps to 0 which disables the drop entirely — #686 R3 Blocker).