diff --git a/docs/agent-stream.md b/docs/agent-stream.md index 8c5de7d..240ce5a 100644 --- a/docs/agent-stream.md +++ b/docs/agent-stream.md @@ -69,7 +69,7 @@ Assistant and thinking growth use **protocol v8** `inv_update_last_message` so s ### Thinking collapse -While a Thinking segment is **open**, the host grows the full monologue (≤ Wasm `MAX_MSG_LEN`, currently **256 KiB**). While the harness is **Busy**, every thinking row that belongs to the current turn stays **fully visible** (including the live streaming one and any segments the turn has already closed). Once the **turn completes** (`done`, `error`, or cancel → Ready/err), every **committed** thinking row from completed turns **auto-collapses** to a compact expandable control: a `Thinking` expander + a one-click re-expand to the full markdown monologue (muted one-line preview while collapsed, Copy on the header). This collapses accumulated reasoning so scrolled-away thinking stops being laid out and repainted every frame (composing with the O(dirty) slot-keyed parse + `cache_layout` paint for the rich transcript). Expand state is **in-memory only** — thinking is ephemeral and never survives refresh. There is **no** per-turn thinking-segment or live-tool line product cap — use **Stop** to cancel. +While a Thinking segment is **open**, the host grows the full monologue (≤ Wasm `MAX_MSG_LEN`, currently **256 KiB**). While the harness is **Busy**, every thinking row that belongs to the current turn stays **fully visible** (including the live streaming one and any segments the turn has already closed) **only while the thinking default-collapsed preference is OFF** — when it is ON (the default, Leader then **`t`**, plan #742), the Busy turn's thinking **also starts collapsed** unless the operator re-expands a row (which still works mid-Busy). Once the **turn completes** (`done`, `error`, or cancel → Ready/err), every **committed** thinking row from completed turns **auto-collapses** to a compact expandable control: a `Thinking` expander + a one-click re-expand to the full markdown monologue (muted one-line preview while collapsed, Copy on the header). This collapses accumulated reasoning so scrolled-away thinking stops being laid out and repainted every frame (composing with the O(dirty) slot-keyed parse + `cache_layout` paint for the rich transcript). Expand state and the preference are **in-memory only** — thinking and the preference are ephemeral and never survive refresh; a New/Clear/session switch returns the preference to ON. There is **no** per-turn thinking-segment or live-tool line product cap — use **Stop** to cancel. ### User cancel (Stop) diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 9f81ba5..1d38777 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -60,7 +60,7 @@ closed). | **↑** / **↓** (composer focused) | **↑** 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). Works while Busy. Ring window only (Load earlier for prompts that have aged out) | | **Escape** | Cancel the in-progress turn when Busy (same as ■ Stop); **dismisses a queue-row editor first** (does not abort the turn); **closes the help overlay** (wins over busy cancel); **disarms the leader**. Idle: no-op (textEntry / dvui menus keep it) | | **Ctrl+**/**Cmd+/** | Toggle the in-canvas **help overlay** (TEAL panel over the transcript band) | -| **Ctrl+Shift+Space** | Arm the **leader** prefix (800 ms). Within the window press **`?`** to toggle help; **Escape** cancels; an unmatched key swallows (never lands in the prompt); a reserved browser chord still yields to the browser | +| **Ctrl+Shift+Space** | Arm the **leader** prefix (800 ms). Within the window press **`?`** to toggle help; **`t`** toggles **thinking default-collapsed** (collapse thinking by default, including the active Busy turn; in-memory only, resets to on on reload/New/Clear); **Escape** cancels; an unmatched key swallows (never lands in the prompt); a reserved browser chord still yields to the browser | | Tab / Ctrl+Left / Ctrl+Right | DOM nav / text caret (not harness chords; Ctrl+Left/Right are word-jumps, left to the textEntry) | | Composer focus | Requested on ready and after each send | @@ -146,7 +146,7 @@ Not a dual-chat surface: scrolling and typing stay inside the harness canvas. | Ring capacity | **2048** messages in Wasm (`bridge.zig` `MAX_MSG` / `HARNESS_RING_MAX`) | | Visible paint | Every in-ring message (≤2048) is painted **except** empty/blank **assistant** rows, which are omitted at paint (see **Empty assistant rows**); scroll to read older in-ring turns. Ring still drops oldest when full. No “N earlier” black-hole hint. **Per-frame cost is O(dirty), not O(N):** the painter keys its rich-parsed doc and `tool_run` decode caches on (**physical ring slot**, per-slot **write-revision**), so an unchanged message reuses its cached parse/layout with **zero** re-parse — only the row that actually changed (new push / `update_last` stream growth) re-parses. Committed rows paint with DVUI `cache_layout` (no per-frame re-shafting); the live streaming row stays `cache_layout=false`. See **Frame budget** | | Empty assistant rows | An **assistant** message whose text is empty or whitespace-only (e.g. a transient host slot opened during a multi-tool turn) is **omitted at paint** — no blank card. System/error lines and tool traces stay visible; while busy the compact `Waiting for model…` status keeps the turn honest. Ring data is untouched (skip is paint-time only), so Copy, `update_last`, and id allocation still work off ring indices | -| Thinking (kind 5) collapse | Committed (completed-turn) thinking rows are **default-collapsed** to a compact expandable control (`Thinking` expander + muted one-line preview + Copy) so scrolled-away reasoning stops full-body per-frame paint; the **active Busy turn** keeps thinking fully expanded. Re-expanding a row renders its full GFM monologue (via the O(dirty) slot-keyed parse + `cache_layout`). Expand state is in-memory only — thinking is ephemeral (never survives refresh). See [agent-stream.md](agent-stream.md) · Thinking collapse | +| Thinking (kind 5) collapse | Committed (completed-turn) thinking rows are **default-collapsed** to a compact expandable control (`Thinking` expander + muted one-line preview + Copy) so scrolled-away reasoning stops full-body per-frame paint. **The active Busy turn keeps thinking fully expanded only while the thinking default-collapsed preference is OFF** (Leader then **`t`**, default ON — plan #742): when ON, the Busy turn's thinking **also starts collapsed** unless the operator re-expands a row (including mid-Busy); the expander still opens any row into the in-memory `thinking_open_l1` set. Re-expanding a row renders its full GFM monologue (via the O(dirty) slot-keyed parse + `cache_layout`). Expand state and the preference are in-memory only — thinking and the preference are ephemeral (never survive refresh, and a New/Clear/session switch returns the preference to ON). See [agent-stream.md](agent-stream.md) · Thinking collapse | | Row height | Message rows have **no reserved min-height band**; height tracks content + normal padding (`padding .y=6`, `margin .y=4`). The only enforced touch target is the `≈40px` **📋** (copy) control on the kind row, which shows only for non-empty bodies. Omitting the empty card removes the large blank band without adding a new height contract | | Line size | **262 144** UTF-8 bytes max per message (`MAX_MSG_LEN`) | | Host history fold | `formatPromptWithHistory` default **maxMessages=400**, **maxChars≈3.5M** (model token limit is the real cap); prefer Clear for a fresh workspace | diff --git a/native/harness/src/keymap.zig b/native/harness/src/keymap.zig index 1ae574d..e205793 100644 --- a/native/harness/src/keymap.zig +++ b/native/harness/src/keymap.zig @@ -107,6 +107,9 @@ pub const Action = enum { leader, /// Escape while leader pending — disarm (no insert, no product action). leader_cancel, + /// Leader then `t` — flip the thinking default-collapsed preference + /// (`state.thinking_default_collapsed`) and close the leader (#742). + thinking_default_toggle, }; /// Frame context bits the `when` gating reads. `leader_pending` is managed by @@ -287,6 +290,15 @@ pub const KEY_TABLE = [_]Row{ .action = .help_toggle_leader, .help = "Toggle help (leader)", }, + // ── leader command: t (thinking default-collapsed, plan #742) ───────── + .{ + .id = "thinking_default_toggle", + .key = .t, + .prereq = .none, + .when_true = .{ .leader_pending = true }, + .action = .thinking_default_toggle, + .help = "Thinking default collapsed", + }, }; /// Reserved-browser deny-list — (key, prereq) pairs never marked handled. @@ -388,6 +400,12 @@ pub fn match(key: Key, action: KeyAction, mods: Mods, ctx: Context) Match { if (key == .space and modsMatch(mods, .ctrl_shift)) { return .{ .outcome = .action, .action = .leader }; // re-arm } + // Leader command `t`: flip thinking default-collapsed (#742). Mirrors + // the `?` arm — handled, disarms (the dispatcher closes the leader), + // and never inserts `t` into the prompt. + if (key == .t and modsMatch(mods, .none)) { + return .{ .outcome = .action, .action = .thinking_default_toggle }; + } // Unmatched key while leader pending: swallow (handled, disarmed) so it // never lands in the prompt (fail closed). return .{ .outcome = .swallow_leader }; @@ -541,6 +559,33 @@ test "keymap: leader + ? within window → help_toggle_leader (row 7)" { try std.testing.expectEqual(Action.help_toggle_leader, m.action.?); } +test "keymap: leader + t → thinking_default_toggle (plan #742 test 5)" { + const m = match(.t, .down, .{}, .{ .leader_pending = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.thinking_default_toggle, m.action.?); +} + +test "keymap: leader + t disarms the leader and never inserts t (plan #742 test 5b)" { + // The arm returns an `.action` (handled) with no swallow ambiguity — the + // dispatcher marks it handled (no `t` in the prompt) and closes the leader. + const m = match(.t, .down, .{}, .{ .leader_pending = true }); + try std.testing.expectEqual(Outcome.action, m.outcome); + try std.testing.expectEqual(Action.thinking_default_toggle, m.action.?); + // A plain `t` (no leader) is NOT a command — it must flow through (.none). + try std.testing.expectEqual(Outcome.none, match(.t, .down, .{}, .{ .composer = true }).outcome); + // Ctrl+T stays reserved (browser new-tab) — never the thinking toggle. + try std.testing.expectEqual(Outcome.browser, match(.t, .down, .{ .control = true }, .{ .leader_pending = true }).outcome); +} + +test "keymap: table is within KEYMAP_MAX with the thinking toggle row present" { + try std.testing.expect(tableLen() <= KEYMAP_MAX); + var found = false; + for (KEY_TABLE) |row| { + if (std.mem.eql(u8, row.id, "thinking_default_toggle")) found = true; + } + try std.testing.expect(found); +} + test "keymap: leader + Esc → leader_cancel (not turn cancel)" { const m = match(.escape, .down, .{}, .{ .leader_pending = true }); try std.testing.expectEqual(Outcome.action, m.outcome); diff --git a/native/harness/src/thinking_collapse.zig b/native/harness/src/thinking_collapse.zig index 33a1588..4c9c69d 100644 --- a/native/harness/src/thinking_collapse.zig +++ b/native/harness/src/thinking_collapse.zig @@ -88,13 +88,32 @@ pub const State = struct { } /// Whether a thinking row in physical ring slot `slot` should render the FULL - /// monologue: True when it is part of the current Busy turn OR the operator - /// explicitly opened it. Otherwise it renders collapsed. Operator-open rows - /// (committed, outside the active turn) can be toggled back off; active-turn - /// rows stay pinned full regardless of the operator set. This is THE policy - /// API — callers render full iff it returns true. - pub fn shouldRenderFull(self: *const State, slot: usize, head: usize, cap: usize, operator_open: bool) bool { - return self.isActiveTurnFull(slot, head, cap) or operator_open; + /// monologue: True when the operator explicitly opened it, OR (when thinking + /// is NOT default-collapsed) it is part of the current Busy turn. Otherwise + /// it renders collapsed. + /// + /// `default_collapsed` is the "thinking default = collapsed" preference + /// (plan #742): when ON (the default), even active-turn rows render + /// collapsed unless the operator opened them — the Busy pin is relaxed so + /// live reasoning starts out collapsed too. When OFF, the today-Busy pin is + /// restored: active-turn rows stay full regardless of the operator set. + /// + /// This is THE policy API — callers render full iff it returns true. The + /// preference is threaded as an explicit boolean parameter — the module + /// stays PURE (imports nothing), so the host unit suite can exercise both + /// preference values without a dvui/bridge frame. Callers pass the live + /// `state.thinking_default_collapsed` (product default `true`). Operator- + /// open rows (committed, outside the active turn) can be toggled back off; + /// a pinned-active row (preference OFF) stays full regardless. + pub fn shouldRenderFull( + self: *const State, + slot: usize, + head: usize, + cap: usize, + operator_open: bool, + default_collapsed: bool, + ) bool { + return operator_open or (!default_collapsed and self.isActiveTurnFull(slot, head, cap)); } }; @@ -148,14 +167,14 @@ test "after turn completion everything collapses unless operator_open" { s.onLifecycleTransition(.busy, .ready, 204); try std.testing.expect(!s.isActiveTurnFull(100, 204, cap)); try std.testing.expect(!s.isActiveTurnFull(203, 204, cap)); - // operator override still re-expands a row. - try std.testing.expect(s.shouldRenderFull(100, 204, cap, true)); + // operator override still re-expands a row (true regardless of preference). + try std.testing.expect(s.shouldRenderFull(100, 204, cap, true, true)); } test "operator_open overrides even during an active turn" { var s: State = .{}; // no busy start: fully idle. const cap: usize = 2048; - try std.testing.expect(s.shouldRenderFull(0, 1, cap, true)); + try std.testing.expect(s.shouldRenderFull(0, 1, cap, true, true)); } test "reset clears busy_start_slot" { @@ -198,16 +217,50 @@ test "ring wrap keeps the busy turn full and stale prior turn collapsed" { try std.testing.expect(!s.isActiveTurnFull(3, 3, cap)); } -test "shouldRenderFull is the single policy API and honors operator_open" { +test "shouldRenderFull is the single policy API: default (collapsed ON) relaxes the busy pin" { + // Plan #742 test 1 — default `default_collapsed = true`: a Busy-turn thinking + // row renders COLLAPSED (not full) unless the operator opened it. Today's + // always-pin is now gated behind the OFF preference value. + var s: State = .{}; + const cap: usize = 2048; + s.onLifecycleTransition(.ready, .busy, 0); + // Active-turn row is NOT rendered full by default anymore. + try std.testing.expect(!s.shouldRenderFull(0, 4, cap, false, true)); + try std.testing.expect(!s.shouldRenderFull(1, 4, cap, false, true)); + // operator_open still expands an active-turn row (expander works mid-Busy). + try std.testing.expect(s.shouldRenderFull(0, 4, cap, true, true)); + // Pre-turn (older) row stays collapsed under both open states. + try std.testing.expect(!s.shouldRenderFull(cap - 1, 4, cap, false, true)); +} + +test "shouldRenderFull: preference OFF restores the busy-turn pin (today)" { + // Plan #742 test 2 — `default_collapsed = false`: Busy-turn thinking stays + // full even when operator_open is false (unchanged today behavior). var s: State = .{}; const cap: usize = 2048; s.onLifecycleTransition(.ready, .busy, 0); - try std.testing.expect(s.shouldRenderFull(0, 4, cap, false)); // active turn - try std.testing.expect(!s.shouldRenderFull(cap - 1, 4, cap, false)); // pre-turn - // operator_open re-expands a committed (pre-turn) row. - try std.testing.expect(s.shouldRenderFull(cap - 1, 4, cap, true)); + try std.testing.expect(s.shouldRenderFull(0, 4, cap, false, false)); + try std.testing.expect(s.shouldRenderFull(1, 4, cap, false, false)); + // Pre-turn (older) row still collapses unless opened. + try std.testing.expect(!s.shouldRenderFull(cap - 1, 4, cap, false, false)); + try std.testing.expect(s.shouldRenderFull(cap - 1, 4, cap, true, false)); // After turn completion, operator_open is the only path to full. s.onLifecycleTransition(.busy, .ready, 4); - try std.testing.expect(!s.shouldRenderFull(0, 4, cap, false)); - try std.testing.expect(s.shouldRenderFull(0, 4, cap, true)); + try std.testing.expect(!s.shouldRenderFull(0, 4, cap, false, false)); + try std.testing.expect(s.shouldRenderFull(0, 4, cap, true, false)); +} + +test "shouldRenderFull: committed rows are collapsed unless operator_open under both preference values" { + // Plan #742 test 3 — after the turn completes, committed thinking collapses + // regardless of the preference; only operator_open re-expands. + var s: State = .{}; + const cap: usize = 2048; + s.onLifecycleTransition(.ready, .busy, 100); + s.onLifecycleTransition(.busy, .ready, 204); + for ([_]bool{ true, false }) |def| { + try std.testing.expect(!s.shouldRenderFull(100, 204, cap, false, def)); + try std.testing.expect(!s.shouldRenderFull(203, 204, cap, false, def)); + // operator override still re-expands a committed row. + try std.testing.expect(s.shouldRenderFull(100, 204, cap, true, def)); + } } diff --git a/native/harness/src/ui.zig b/native/harness/src/ui.zig index ef4f910..121340f 100644 --- a/native/harness/src/ui.zig +++ b/native/harness/src/ui.zig @@ -122,6 +122,12 @@ fn historyApply(dir: composer_history.Step) void { } } +/// Plan #742 — leader command `t` seam: flip the thinking default-collapsed +/// preference. The dispatcher closes the leader after invoking this. +fn toggleThinkingDefault() void { + state.thinking_default_collapsed = !state.thinking_default_collapsed; +} + pub fn onInit() void { bridge.reset(); @memset(&state.prompt_buf, 0); @@ -492,6 +498,9 @@ pub fn frame() !void { // armed leader would ghost chrome. state.help_overlay_open = false; state.leader_armed = false; + // Plan #742 — a fresh surface resets the thinking preference to its + // collapsed-default (same reset site as help_overlay_open / leader_armed). + state.thinking_default_collapsed = true; // Drop composer arrow-key history state (plan #667) — a New / // Clear / session hydrate drops the ring, so ordinals are stale. // Only restore the saved draft when actually in history (#686 R6): @@ -646,7 +655,7 @@ pub fn frame() !void { // the submit stroke. Submit is requested via `state.request_submit` // (consumed after the chrome paint, which hands back the live prompt // text + measured outer height). - keymap_dispatch.dispatch(.{ .history = &historyApply }); + keymap_dispatch.dispatch(.{ .history = &historyApply, .toggleThinkingDefault = &toggleThinkingDefault }); // The chrome paints the field on an explicit trailing-RESERVED sub-rect // of width `avail.w − (TOUCH_H×n + 8)` so its reported min width (the diff --git a/native/harness/src/ui/help_overlay.zig b/native/harness/src/ui/help_overlay.zig index 55f12c1..72eb0f4 100644 --- a/native/harness/src/ui/help_overlay.zig +++ b/native/harness/src/ui/help_overlay.zig @@ -30,6 +30,7 @@ fn rowChord(row: keymap.Row) []const u8 { .help_toggle_leader => "Leader Space, then ?", .leader => "Ctrl+Shift+Space", .leader_cancel => "Esc (leader)", + .thinking_default_toggle => "Leader Space, then t", }; } diff --git a/native/harness/src/ui/keymap_dispatch.zig b/native/harness/src/ui/keymap_dispatch.zig index a722a04..f41309c 100644 --- a/native/harness/src/ui/keymap_dispatch.zig +++ b/native/harness/src/ui/keymap_dispatch.zig @@ -28,9 +28,12 @@ const composer_history = @import("composer_history.zig"); const LEADER_TIMER_ID: dvui.Id = @enumFromInt(0x7410_0000_0000_0001); /// Product seams that live in `ui.zig` (which imports this module) — passed in -/// to avoid an import cycle: `history` calls ui.zig's `historyApply`. +/// to avoid an import cycle: `history` calls ui.zig's `historyApply`, and +/// `toggleThinkingDefault` flips the thinking default-collapsed preference +/// (plan #742, Leader then `t`). pub const Handlers = struct { history: *const fn (composer_history.Step) void, + toggleThinkingDefault: *const fn () void, }; /// Convert dvui's `enums.Key` to the keymap `Key` subset. @@ -180,6 +183,12 @@ fn runAction(action: keymap.Action, down: bool, handlers: Handlers) void { if (!down) return; disarmLeader(); }, + .thinking_default_toggle => { + if (!down) return; + // Flip the preference and close the leader (leader command `t`). + handlers.toggleThinkingDefault(); + disarmLeader(); + }, } } diff --git a/native/harness/src/ui/state.zig b/native/harness/src/ui/state.zig index e7c40db..c8f2cdf 100644 --- a/native/harness/src/ui/state.zig +++ b/native/harness/src/ui/state.zig @@ -126,6 +126,13 @@ pub var queue_list_scroll: dvui.ScrollInfo = .{ /// Enqueue runs after the band paints this frame (plan #699). pub var queue_follow: bool = false; +/// Plan #742 — thinking default = collapsed preference (in-memory only, no +/// session `meta` / protocol carrier; refresh returns to collapsed-default). +/// Default ON: thinking rows (including the active Busy-turn load) start +/// collapsed unless the operator expands one. OFF restores today's Busy pin. +/// Toggled by the keymap (Leader then `t`) via `keymap_dispatch`. +pub var thinking_default_collapsed: bool = true; + /// Plan #741 — help overlay visibility (in-memory, toggled by Ctrl/Cmd+/ and /// leader+`?`; Esc closes). Not persisted, no session carrier. pub var help_overlay_open: bool = false; @@ -175,4 +182,7 @@ pub fn resetTranscriptScroll() void { help_overlay_open = false; leader_armed = false; request_submit = false; + // Plan #742 — preference always returns to collapsed-default on a reset + // session surface (init / New / Clear / switch); refresh never carries it. + thinking_default_collapsed = true; } diff --git a/native/harness/src/ui/thinking.zig b/native/harness/src/ui/thinking.zig index 86727f2..285a7b6 100644 --- a/native/harness/src/ui/thinking.zig +++ b/native/harness/src/ui/thinking.zig @@ -79,9 +79,19 @@ pub fn paintThinking( const ring_cap = bridge.RING_CAP; const is_active = state.thinking_collapse_state.isActiveTurnFull(slotp, ring_head, ring_cap); const open_by_operator = state.thinking_open_l1.contains(key); - // Single policy entry point — both the active-turn pin and the operator - // override live inside `shouldRenderFull`. - const full = state.thinking_collapse_state.shouldRenderFull(slotp, ring_head, ring_cap, open_by_operator); + // Single policy entry point — the active-turn pin (relaxed when thinking + // default-collapsed is ON), the Busy pin (OFF), and the operator override + // all live inside `shouldRenderFull` (pure, host-tested). Threading the + // preference in here keeps `thinking_default_collapsed` the single source + // of policy truth for paint. + const default_collapsed = state.thinking_default_collapsed; + const full = state.thinking_collapse_state.shouldRenderFull( + slotp, + ring_head, + ring_cap, + open_by_operator, + default_collapsed, + ); // Layout mutates `expanded` across the head + body blocks below. Starts at // the policy/output state; for a pinned active-turn row we re-assert `full` @@ -108,8 +118,15 @@ pub fn paintThinking( .color_text = palette.warm_muted, .font = .theme(.heading), }); - if (is_active) { - // Pinned; never let a click collapse the live/active-turn reasoning. + // Active-turn rows stay pinned FULL (not operator-collapsible) only + // while thinking default-collapsed is OFF — today's policy. When the + // preference is ON (default), even an active-row click routes through + // the normal toggle into `thinking_open_l1`, so the operator can open a + // live row mid-Busy (if it's collapsed by default) exactly like a + // committed one. Do NOT leave a second `if (is_active) expanded = true` + // that ignores the preference. + const pinned_active = is_active and !default_collapsed; + if (pinned_active) { expanded = true; } else if (open) { state.thinking_open_l1.put(key, {}) catch {};