Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/agent-stream.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`**), 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)

Expand Down
4 changes: 2 additions & 2 deletions docs/harness-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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): 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 |
Expand Down
45 changes: 45 additions & 0 deletions native/harness/src/keymap.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
Expand Down
87 changes: 70 additions & 17 deletions native/harness/src/thinking_collapse.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
};

Expand Down Expand Up @@ -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" {
Expand Down Expand Up @@ -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));
}
}
11 changes: 10 additions & 1 deletion native/harness/src/ui.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions native/harness/src/ui/help_overlay.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
}

Expand Down
Loading
Loading