Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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`**, 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)

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 — 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 |
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