From 3eafa8d3db6b771ea652c10befadd8a53e88e5f7 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:57:08 -0400 Subject: [PATCH 1/3] feat(tui): move the activity line while the agent works, and say for how long The activity line opened with a static dot, so a thirty-second silent turn read as a hang. It now opens with the mark, the Nerd Font's circle, rounded square and square, outline then filled, on the workspace's motion clock: blue for the agent's own work, the server-tool colour while a hosted search runs, standing still in the action-required colour while approval waits. After the label it reads, muted, how long the current work has run, the effort, and after five silent seconds how long it has been quiet; a short row drops effort, then quiet, then elapsed. The readings are presentation. Events are dated as the workspace applies them, and the motion clock supplies the instant each frame is drawn for, so renderers never read the system clock and tests pass instants explicitly. The user chose the mark in their own terminal over Unicode cycles that shook. The activity line leaves chrome for its own module, and the workspace's producer methods leave for theirs, which keeps both files under the line sentinel. --- .agents/evidence/motion.md | 4 +- .../phase-04-stage-34-conversation-chrome.md | 21 +- .agents/specs/motion.md | 10 +- .agents/spikes/conversation-chrome/README.md | 2 +- .agents/ui-ux.md | 20 +- .../frames/agents-strip-narrow.txt | 2 +- .../frames/agents-strip-one-column.txt | 2 +- .../frames/agents-strip-tally.txt | 2 +- .../frames/agents-strip-two-columns.txt | 2 +- .../plexmaton-tui/frames/approval-medium.txt | 2 +- crates/plexmaton-tui/frames/approval-wide.txt | 2 +- .../plexmaton-tui/frames/canonical-medium.txt | 2 +- .../plexmaton-tui/frames/canonical-narrow.txt | 2 +- .../plexmaton-tui/frames/canonical-wide.txt | 2 +- .../frames/current-work-responding-medium.txt | 2 +- .../frames/current-work-responding-narrow.txt | 2 +- .../frames/current-work-responding-wide.txt | 2 +- .../current-work-running-tool-medium.txt | 2 +- .../current-work-running-tool-narrow.txt | 2 +- .../frames/current-work-running-tool-wide.txt | 2 +- crates/plexmaton-tui/frames/drawer-medium.txt | 2 +- crates/plexmaton-tui/frames/drawer-narrow.txt | 2 +- .../plexmaton-tui/frames/drawer-ultrawide.txt | 2 +- crates/plexmaton-tui/frames/drawer-wide.txt | 2 +- .../frames/input-queue-medium.txt | 2 +- .../frames/input-queue-narrow.txt | 2 +- .../plexmaton-tui/frames/input-queue-wide.txt | 2 +- .../frames/server-tool-medium.txt | 2 +- .../frames/server-tool-narrow.txt | 2 +- .../frames/server-tool-open-wide.txt | 2 +- .../plexmaton-tui/frames/server-tool-wide.txt | 2 +- .../frames/skill-diagnostic-medium.txt | 2 +- .../frames/skill-diagnostic-narrow.txt | 2 +- .../frames/skill-diagnostic-wide.txt | 2 +- .../plexmaton-tui/frames/tool-open-medium.txt | 2 +- .../plexmaton-tui/frames/tool-open-narrow.txt | 2 +- .../plexmaton-tui/frames/tool-open-wide.txt | 2 +- crates/plexmaton-tui/src/frames.rs | 2 +- crates/plexmaton-tui/src/render/activity.rs | 182 ++++++++++++++++++ crates/plexmaton-tui/src/render/chrome.rs | 49 +---- crates/plexmaton-tui/src/render/mod.rs | 78 +++++++- crates/plexmaton-tui/src/state/activity.rs | 134 +++++++++++++ .../plexmaton-tui/src/state/current_work.rs | 14 +- crates/plexmaton-tui/src/state/mod.rs | 4 + crates/plexmaton-tui/src/workspace.rs | 33 +--- .../src/workspace/composer_menu_tests.rs | 2 +- .../src/workspace/effort_tests.rs | 17 ++ crates/plexmaton-tui/src/workspace/motion.rs | 3 +- .../plexmaton-tui/src/workspace/producer.rs | 48 +++++ scripts/smoke-server-tool.py | 2 +- 50 files changed, 531 insertions(+), 158 deletions(-) create mode 100644 crates/plexmaton-tui/src/render/activity.rs create mode 100644 crates/plexmaton-tui/src/state/activity.rs create mode 100644 crates/plexmaton-tui/src/workspace/producer.rs diff --git a/.agents/evidence/motion.md b/.agents/evidence/motion.md index 6bd4f7f5..bcc75039 100644 --- a/.agents/evidence/motion.md +++ b/.agents/evidence/motion.md @@ -6,6 +6,6 @@ resolves every one against the source, so a renamed or deleted test fails the bu | Invariant | Proven by | | --- | --- | -| MOT-1 | `mot_1_one_deadline_serves_every_mover_and_late_wakes_coalesce`, `effort_xhigh_labels_remain_static_without_an_animation_deadline`, `effort_animation_changes_only_visible_max_cells_and_stops_when_hidden` | -| MOT-2 | Unproven beyond `effort_animation_changes_only_visible_max_cells_and_stops_when_hidden`, which checks the effort markers' width | +| MOT-1 | `mot_1_one_deadline_serves_every_mover_and_late_wakes_coalesce`, `the_mark_cycle_divides_the_clock_cycle`, `effort_xhigh_labels_remain_static_without_an_animation_deadline`, `effort_animation_changes_only_visible_max_cells_and_stops_when_hidden` | +| MOT-2 | `mot_2_every_activity_mark_frame_is_one_cell`, `effort_animation_changes_only_visible_max_cells_and_stops_when_hidden` | | MOT-3 | `effort_animation_changes_only_visible_max_cells_and_stops_when_hidden` | diff --git a/.agents/plans/phase-04-stage-34-conversation-chrome.md b/.agents/plans/phase-04-stage-34-conversation-chrome.md index fd8fb85e..287310d3 100644 --- a/.agents/plans/phase-04-stage-34-conversation-chrome.md +++ b/.agents/plans/phase-04-stage-34-conversation-chrome.md @@ -5,7 +5,7 @@ | Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 34 | | Contract | [ui-ux](../ui-ux.md) §transcript grammar and its activity-line rule; EFF-4 in [reasoning-effort](../specs/reasoning-effort.md); FR-1 in [frame-loop](../specs/frame-loop.md); TR-6 in [transcript-layout](../specs/transcript-layout.md); SEL-1 and SEL-2 in [selection-and-copy](../specs/selection-and-copy.md) | | Evidence | [Conversation chrome spike](../spikes/conversation-chrome/README.md): the rendered candidates the user chose from and the survey of three other CLIs | -| Status | Slices 1–3 of 5 implemented and locally verified | +| Status | Slices 1–4 of 5 implemented and locally verified | ## Outcome @@ -38,15 +38,16 @@ MOT-1 to MOT-3 live in [motion](../specs/motion.md), promoted when the motion ow of view state where every renderer reads it. The effort rail is its first user with no change in behaviour. Closed by the effort tests passing unchanged and `mot_1_one_deadline_serves_every_mover_and_late_wakes_coalesce`. -4. **Activity line.** The row opens with the mark's core moving, then the label, then muted `· 11s · - high effort`, and after five seconds without a model event `· quiet for 31s`. Elapsed reads - `11s`, `2m 11s`, `1h 2m`. The mark wears blue, the server-tool colour while a provider-run search - is the work, and stands still in the action-required colour while approval is required. The - core's cycle is the outline morph the user chose in the spike; whether Unicode geometric shapes - or the Nerd Font's Material Design outlines draw it, and at what length, is the user's call once - their terminal font settles, recorded there. Closes with frames for thinking, responding, running - tool, running search, approval, compacting and quiet at three widths; MOT-2 for the cycle; MOT-3 - as a test that a tick advances no semantic revision. +4. **Activity line — implemented.** The row opens with the mark, then the label, then muted + readings: elapsed since the current work began, the effort, and after five silent seconds `quiet + for`. Elapsed reads `11s`, `2m 11s`, `1h 2m`; a short row drops effort, then quiet, then elapsed. + The mark is the Nerd Font's circle, rounded square and square, outline then filled, eight frames + at 200 ms, which the user chose in their own terminal over the Unicode cycles that shook. It + wears blue, the server-tool colour while a hosted search runs, and stands still in the + action-required colour during approval. The readings are presentation: events are dated as they + are applied and the motion clock supplies the instant a frame is drawn for. Closed by refreshed + current-work frames, the readings and work-clock tests, MOT-2 for the mark, and the activity + smokes. 5. **Documents.** Evidence tables current; SVG review of the frames attached; this plan deleted; the phase row says done. diff --git a/.agents/specs/motion.md b/.agents/specs/motion.md index 5bc1bad0..de0858d5 100644 --- a/.agents/specs/motion.md +++ b/.agents/specs/motion.md @@ -2,10 +2,10 @@ | Field | Value | | --- | --- | -| Status | MOT-1 and MOT-3 implemented for the effort rail; MOT-2 unproven until the activity line moves | +| Status | Implemented for the effort rail and the activity line's mark | | Owns | When anything in the workspace moves, what a moving frame may change, and the one clock it runs on | | Depends on | FR-1 and FR-3 in [frame-loop](./frame-loop.md); EFF-4 in [reasoning-effort](./reasoning-effort.md) as the first thing that moves | -| Proven by | `plexmaton-tui::workspace` effort tests below | +| Proven by | `plexmaton-tui::workspace` effort tests and `plexmaton-tui::render` activity-mark tests | ## Invariants @@ -17,8 +17,7 @@ phase and counts the next tick from itself, queuing no missed frames. Rejected: thing, which is how the effort rail began and would have given the activity line a second one. **MOT-2 — A motion frame is one cell.** Every glyph in a motion sequence has display width one, and -a test proves it for each sequence, so a frame can never widen or reflow a row. Unproven: the -effort markers are checked; no other sequence exists yet. +a test proves it for each sequence, so a frame can never widen or reflow a row. **MOT-3 — Motion changes presentation only.** A phase change invalidates the painted frame and never the semantic revision or layout (FR-1), so replaying a journal reproduces the same rows whatever the @@ -27,4 +26,5 @@ clock was doing. ## Grammar The phase is a count, not a time: renderers derive their frame from it, so two moving things on -screen stay in step. The effort rail's marker completes a cycle every 1.6 s (EFF-4). +screen stay in step. The effort rail's marker and the activity line's mark each complete a cycle every +1.6 s, the mark at three phases a frame over eight frames. diff --git a/.agents/spikes/conversation-chrome/README.md b/.agents/spikes/conversation-chrome/README.md index eed7fb19..35afe678 100644 --- a/.agents/spikes/conversation-chrome/README.md +++ b/.agents/spikes/conversation-chrome/README.md @@ -16,7 +16,7 @@ generators are in [`mockups/`](./mockups/); each writes one HTML page to | Surface | Chosen | Rejected on sight | | --- | --- | --- | | Activity line composition | mark, label, muted `· 32s · high effort`, `· quiet for 31s` once nothing has arrived; no token count | a red stall tell; estimated tokens | -| Activity mark | the outline morph: ring grows, rounds into the frame, frame sharpens (`◦ ○ ◯ ▢ □ ◻`). Drawn with Unicode geometric shapes (C2′, 12 frames; C2″, 8) or the Nerd Font's Material Design outlines (N2): the user decides once their terminal font settles | filled squares, which the font draws as rectangles; braille; quarter circles; Claude Code's star | +| Activity mark | N2, the Nerd Font's circle, rounded square and square, outline then filled: chosen 2026-09-22 in a kitty window beside the Unicode cycles; the user asked for the filled half to keep it simple yet ever changing | C2′ and C2″, which shook in the user's terminal because mixed-size Unicode shapes centre differently; filled squares; braille; quarter circles; Claude Code's star | | User message | band of lifted ground with a blue `›`, continuation indented | the accent bar; a bare prefix; a hairline bar; a band alone | | Mark placement | centred in the empty conversation, name beneath, gone with the first message | a header or drawer slot, not yet asked for | | Mark motion | K4 as the base: frame breathes between weights and drifts between palette slots; centre morphs circle, ring, rounded square, square, solid and outline | K1 to K3 as they stood | diff --git a/.agents/ui-ux.md b/.agents/ui-ux.md index 38c76828..6a5e9646 100644 --- a/.agents/ui-ux.md +++ b/.agents/ui-ux.md @@ -156,15 +156,17 @@ other rule about input follows from this one. - **The conversation's last row is its activity line**, above the composer: `Thinking`, `Responding`, `Running `, or `Approval required`. It follows semantic state; action required outranks ambient work, and idle is blank. While the agent works the row opens with the mark's core - moving, then the label, then in muted text how long the step has run and at what effort, and once - nothing has arrived for a while, how long it has been quiet. The mark wears blue, the server-tool - colour while a provider-run search is the work, and stands still in the action-required colour - while approval is required. Motion runs on the one visible-only clock the effort rail shares - ([motion](./specs/motion.md)); it changes presentation only. Show the approval label in the - visible primary card; otherwise in the activity line. Rejected: duplicate labels on adjacent - lines; a still row, which during a thirty-second silent turn read as a hang; a token count - estimated from streamed characters, because the row claims only what the route reported; turning - the row red when the route is quiet, because quiet is not failure. + moving, then the label, then in muted text how long the current work has run and at what effort, + and after five seconds with nothing arriving, how long it has been quiet; a short row drops + effort, then quiet, then elapsed. The mark cycles the Nerd Font's circle, rounded square and + square, outline then filled, and wears blue, the server-tool colour while a provider-run search is + the work, and stands still in the action-required colour while approval is required. Motion runs + on the one visible-only clock the effort rail shares ([motion](./specs/motion.md)); it changes + presentation only. Show the approval label in the visible primary card; otherwise in the activity + line. Rejected: duplicate labels on adjacent lines; a still row, which during a thirty-second + silent turn read as a hang; a token count estimated from streamed characters, because the row + claims only what the route reported; turning the row red when the route is quiet, because quiet is + not failure. - **An empty conversation shows the mark.** Before the first message the transcript holds the mark centred, the product's name beneath it, drawn in cells at the odd size nearest square for the terminal's measured cell; it moves on the same clock and leaves with the first message. Rejected: diff --git a/crates/plexmaton-tui/frames/agents-strip-narrow.txt b/crates/plexmaton-tui/frames/agents-strip-narrow.txt index 7c53c052..73626792 100644 --- a/crates/plexmaton-tui/frames/agents-strip-narrow.txt +++ b/crates/plexmaton-tui/frames/agents-strip-narrow.txt @@ -17,7 +17,7 @@ │transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centrali…│ -│· Thinking… ( !2 ) │ +│󰝦 Thinking… ( !2 ) │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/agents-strip-one-column.txt b/crates/plexmaton-tui/frames/agents-strip-one-column.txt index 248607a8..bddb68ee 100644 --- a/crates/plexmaton-tui/frames/agents-strip-one-column.txt +++ b/crates/plexmaton-tui/frames/agents-strip-one-column.txt @@ -17,7 +17,7 @@ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ -│· Thinking… ( !2 ) │ +│󰝦 Thinking… ( !2 ) │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/agents-strip-tally.txt b/crates/plexmaton-tui/frames/agents-strip-tally.txt index 978d4525..54911471 100644 --- a/crates/plexmaton-tui/frames/agents-strip-tally.txt +++ b/crates/plexmaton-tui/frames/agents-strip-tally.txt @@ -13,7 +13,7 @@ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ -│· Thinking… │ +│󰝦 Thinking… │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/agents-strip-two-columns.txt b/crates/plexmaton-tui/frames/agents-strip-two-columns.txt index ce911456..d12b6167 100644 --- a/crates/plexmaton-tui/frames/agents-strip-two-columns.txt +++ b/crates/plexmaton-tui/frames/agents-strip-two-columns.txt @@ -17,7 +17,7 @@ │interactive. ││ │ │ ││ │ │received from Agent B · UI study · Routing stays centralized and z-…││ │ -│· Thinking… ( !2 ) ││ │ +│󰝦 Thinking… ( !2 ) ││ │ ── Message ───────────────────────────────────────────────────────────│ │ Type a message · ⇥ to focus │Controller unavailable · input locked │ ──────────────────────────────────────────────────────────────────────└────────────────────────────────────────────────────────────────────┘ diff --git a/crates/plexmaton-tui/frames/approval-medium.txt b/crates/plexmaton-tui/frames/approval-medium.txt index daf0ec51..ba6b9b1d 100644 --- a/crates/plexmaton-tui/frames/approval-medium.txt +++ b/crates/plexmaton-tui/frames/approval-medium.txt @@ -22,7 +22,7 @@ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ -│· Thinking… ( !1 ) │ +│󰝦 Thinking… ( !1 ) │ ── Approval required · edit ─────────────────────────────────────────────────────────────────── Change crates/plexmaton-core/src/lib.rs and preserve its current revision. diff --git a/crates/plexmaton-tui/frames/approval-wide.txt b/crates/plexmaton-tui/frames/approval-wide.txt index 7992f462..82e193dc 100644 --- a/crates/plexmaton-tui/frames/approval-wide.txt +++ b/crates/plexmaton-tui/frames/approval-wide.txt @@ -22,7 +22,7 @@ │transcript remains interactive. ││ │ │ ││ │ │received from Agent B · UI study · Routing stays centrali…││ │ -│· Thinking… ( !1 ) ││ │ +│󰝦 Thinking… ( !1 ) ││ │ ── Approval required · edit ────────────────────────────────│ │ │ │ Change crates/plexmaton-core/src/lib.rs and preserve … │ │ diff --git a/crates/plexmaton-tui/frames/canonical-medium.txt b/crates/plexmaton-tui/frames/canonical-medium.txt index 2e35208a..687c2645 100644 --- a/crates/plexmaton-tui/frames/canonical-medium.txt +++ b/crates/plexmaton-tui/frames/canonical-medium.txt @@ -33,7 +33,7 @@ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ -│· Thinking… ( !1 ) │ +│󰝦 Thinking… ( !1 ) │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/canonical-narrow.txt b/crates/plexmaton-tui/frames/canonical-narrow.txt index f7c9503c..17654514 100644 --- a/crates/plexmaton-tui/frames/canonical-narrow.txt +++ b/crates/plexmaton-tui/frames/canonical-narrow.txt @@ -33,7 +33,7 @@ │transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centrali…│ -│· Thinking… ( !1 ) │ +│󰝦 Thinking… ( !1 ) │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/canonical-wide.txt b/crates/plexmaton-tui/frames/canonical-wide.txt index 47dc987f..3022b460 100644 --- a/crates/plexmaton-tui/frames/canonical-wide.txt +++ b/crates/plexmaton-tui/frames/canonical-wide.txt @@ -33,7 +33,7 @@ │remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ -│· Thinking… ( !1 ) │ +│󰝦 Thinking… ( !1 ) │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/current-work-responding-medium.txt b/crates/plexmaton-tui/frames/current-work-responding-medium.txt index 25dbf67c..3019aa66 100644 --- a/crates/plexmaton-tui/frames/current-work-responding-medium.txt +++ b/crates/plexmaton-tui/frames/current-work-responding-medium.txt @@ -1,4 +1,4 @@ -│· Responding… ( !1 ) │ +│󰝦 Responding… ( !1 ) │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/current-work-responding-narrow.txt b/crates/plexmaton-tui/frames/current-work-responding-narrow.txt index 2b3b7c53..5d4020e4 100644 --- a/crates/plexmaton-tui/frames/current-work-responding-narrow.txt +++ b/crates/plexmaton-tui/frames/current-work-responding-narrow.txt @@ -1,4 +1,4 @@ -│· Responding… ( !1 ) │ +│󰝦 Responding… ( !1 ) │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/current-work-responding-wide.txt b/crates/plexmaton-tui/frames/current-work-responding-wide.txt index f1045b86..28e47aef 100644 --- a/crates/plexmaton-tui/frames/current-work-responding-wide.txt +++ b/crates/plexmaton-tui/frames/current-work-responding-wide.txt @@ -1,4 +1,4 @@ -│· Responding… ( !1 ) │ +│󰝦 Responding… ( !1 ) │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/current-work-running-tool-medium.txt b/crates/plexmaton-tui/frames/current-work-running-tool-medium.txt index 62d895cf..478b3cc5 100644 --- a/crates/plexmaton-tui/frames/current-work-running-tool-medium.txt +++ b/crates/plexmaton-tui/frames/current-work-running-tool-medium.txt @@ -1,4 +1,4 @@ -│· Running read_file… ( !1 ) │ +│󰝦 Running read_file… ( !1 ) │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/current-work-running-tool-narrow.txt b/crates/plexmaton-tui/frames/current-work-running-tool-narrow.txt index 4cb93062..d255c3b1 100644 --- a/crates/plexmaton-tui/frames/current-work-running-tool-narrow.txt +++ b/crates/plexmaton-tui/frames/current-work-running-tool-narrow.txt @@ -1,4 +1,4 @@ -│· Running read_file… ( !1 ) │ +│󰝦 Running read_file… ( !1 ) │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/current-work-running-tool-wide.txt b/crates/plexmaton-tui/frames/current-work-running-tool-wide.txt index 3d2759e3..d1cfc713 100644 --- a/crates/plexmaton-tui/frames/current-work-running-tool-wide.txt +++ b/crates/plexmaton-tui/frames/current-work-running-tool-wide.txt @@ -1,4 +1,4 @@ -│· Running read_file… ( !1 ) │ +│󰝦 Running read_file… ( !1 ) │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/drawer-medium.txt b/crates/plexmaton-tui/frames/drawer-medium.txt index 1541e8d9..21eb58b9 100644 --- a/crates/plexmaton-tui/frames/drawer-medium.txt +++ b/crates/plexmaton-tui/frames/drawer-medium.txt @@ -33,7 +33,7 @@ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ -│· Thinking… ( !1 ) │ +│󰝦 Thinking… ( !1 ) │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/drawer-narrow.txt b/crates/plexmaton-tui/frames/drawer-narrow.txt index 32757a03..ee340f3c 100644 --- a/crates/plexmaton-tui/frames/drawer-narrow.txt +++ b/crates/plexmaton-tui/frames/drawer-narrow.txt @@ -33,7 +33,7 @@ │transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centrali…│ -│· Thinking… ( !1 ) │ +│󰝦 Thinking… ( !1 ) │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/drawer-ultrawide.txt b/crates/plexmaton-tui/frames/drawer-ultrawide.txt index 8b516d8f..08b096ca 100644 --- a/crates/plexmaton-tui/frames/drawer-ultrawide.txt +++ b/crates/plexmaton-tui/frames/drawer-ultrawide.txt @@ -33,7 +33,7 @@ │I will inspect the workspace and delegate the focused UI study. Agent B is running independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ -│· Thinking… ( !1 ) │ +│󰝦 Thinking… ( !1 ) │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/drawer-wide.txt b/crates/plexmaton-tui/frames/drawer-wide.txt index 1e750226..0f0da8d5 100644 --- a/crates/plexmaton-tui/frames/drawer-wide.txt +++ b/crates/plexmaton-tui/frames/drawer-wide.txt @@ -33,7 +33,7 @@ │remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ -│· Thinking… ( !1 ) │ +│󰝦 Thinking… ( !1 ) │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/input-queue-medium.txt b/crates/plexmaton-tui/frames/input-queue-medium.txt index ea831083..2fdd0449 100644 --- a/crates/plexmaton-tui/frames/input-queue-medium.txt +++ b/crates/plexmaton-tui/frames/input-queue-medium.txt @@ -28,7 +28,7 @@ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │ │ │ │ -│· Responding… ( !1 ) │ +│󰝦 Responding… ( !1 ) │ ── Waiting to send · 2 ──────────────────────────────────────────────────────────────────────── Sends when this turn ends ↳ Also check the compaction path while you are in there diff --git a/crates/plexmaton-tui/frames/input-queue-narrow.txt b/crates/plexmaton-tui/frames/input-queue-narrow.txt index 24dc63d7..8df574f9 100644 --- a/crates/plexmaton-tui/frames/input-queue-narrow.txt +++ b/crates/plexmaton-tui/frames/input-queue-narrow.txt @@ -28,7 +28,7 @@ │received from Agent B · UI study · Routing stays centrali…│ │ │ │ │ -│· Responding… ( !1 ) │ +│󰝦 Responding… ( !1 ) │ ── Waiting to send · 2 ───────────────────────────────────── Sends when this turn ends ↳ Also check the compaction path while you are in there diff --git a/crates/plexmaton-tui/frames/input-queue-wide.txt b/crates/plexmaton-tui/frames/input-queue-wide.txt index cd998c6e..a309bbcc 100644 --- a/crates/plexmaton-tui/frames/input-queue-wide.txt +++ b/crates/plexmaton-tui/frames/input-queue-wide.txt @@ -28,7 +28,7 @@ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │ │ │ │ -│· Responding… ( !1 ) │ +│󰝦 Responding… ( !1 ) │ ── Waiting to send · 2 ───────────────────────────────────────────────────────────────────────────────────────────────── Sends when this turn ends ↳ Also check the compaction path while you are in there diff --git a/crates/plexmaton-tui/frames/server-tool-medium.txt b/crates/plexmaton-tui/frames/server-tool-medium.txt index a34a445b..70b060b0 100644 --- a/crates/plexmaton-tui/frames/server-tool-medium.txt +++ b/crates/plexmaton-tui/frames/server-tool-medium.txt @@ -33,7 +33,7 @@ │Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the │ │release post on the Rust blog lists what it fixes. │ │ │ -│· Running web_search… │ +│󰝦 Running web_search… │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/server-tool-narrow.txt b/crates/plexmaton-tui/frames/server-tool-narrow.txt index 54f75850..badaf434 100644 --- a/crates/plexmaton-tui/frames/server-tool-narrow.txt +++ b/crates/plexmaton-tui/frames/server-tool-narrow.txt @@ -33,7 +33,7 @@ │point release over 1.98.0, and the release post on │ │the Rust blog lists what it fixes. │ │ │ -│· Running web_search… │ +│󰝦 Running web_search… │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/server-tool-open-wide.txt b/crates/plexmaton-tui/frames/server-tool-open-wide.txt index 86b01bb6..2bea6568 100644 --- a/crates/plexmaton-tui/frames/server-tool-open-wide.txt +++ b/crates/plexmaton-tui/frames/server-tool-open-wide.txt @@ -33,7 +33,7 @@ │Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust │ │blog lists what it fixes. │ │ │ -│· Running web_search… │ +│󰝦 Running web_search… │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/server-tool-wide.txt b/crates/plexmaton-tui/frames/server-tool-wide.txt index 256b7a62..af4a4bcf 100644 --- a/crates/plexmaton-tui/frames/server-tool-wide.txt +++ b/crates/plexmaton-tui/frames/server-tool-wide.txt @@ -33,7 +33,7 @@ │Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust │ │blog lists what it fixes. │ │ │ -│· Running web_search… │ +│󰝦 Running web_search… │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/skill-diagnostic-medium.txt b/crates/plexmaton-tui/frames/skill-diagnostic-medium.txt index 8244b137..1c02d8c4 100644 --- a/crates/plexmaton-tui/frames/skill-diagnostic-medium.txt +++ b/crates/plexmaton-tui/frames/skill-diagnostic-medium.txt @@ -33,7 +33,7 @@ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │[!] skill · failed │ -│· Thinking… ( !1 ) │ +│󰝦 Thinking… ( !1 ) │ ── Message ──────────────────────────────────────────────────────────────────────────────────── $review check this change ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/skill-diagnostic-narrow.txt b/crates/plexmaton-tui/frames/skill-diagnostic-narrow.txt index 2558682c..d71227ab 100644 --- a/crates/plexmaton-tui/frames/skill-diagnostic-narrow.txt +++ b/crates/plexmaton-tui/frames/skill-diagnostic-narrow.txt @@ -33,7 +33,7 @@ │ │ │received from Agent B · UI study · Routing stays centrali…│ │[!] skill · failed │ -│· Thinking… ( !1 ) │ +│󰝦 Thinking… ( !1 ) │ ── Message ───────────────────────────────────────────────── $review check this change ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/skill-diagnostic-wide.txt b/crates/plexmaton-tui/frames/skill-diagnostic-wide.txt index fb2cef63..9460cbe4 100644 --- a/crates/plexmaton-tui/frames/skill-diagnostic-wide.txt +++ b/crates/plexmaton-tui/frames/skill-diagnostic-wide.txt @@ -33,7 +33,7 @@ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │[!] skill · failed │ -│· Thinking… ( !1 ) │ +│󰝦 Thinking… ( !1 ) │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── $review check this change ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/tool-open-medium.txt b/crates/plexmaton-tui/frames/tool-open-medium.txt index 9bc20941..43b46e77 100644 --- a/crates/plexmaton-tui/frames/tool-open-medium.txt +++ b/crates/plexmaton-tui/frames/tool-open-medium.txt @@ -33,7 +33,7 @@ │ │ 197 tests passed │ │ │ stderr: │ │ │ [empty] │ -│· Thinking… │ +│󰝦 Thinking… │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/tool-open-narrow.txt b/crates/plexmaton-tui/frames/tool-open-narrow.txt index 5692ab30..ae4dc269 100644 --- a/crates/plexmaton-tui/frames/tool-open-narrow.txt +++ b/crates/plexmaton-tui/frames/tool-open-narrow.txt @@ -33,7 +33,7 @@ │ │ 197 tests passed │ │ │ stderr: │ │ │ [empty] │ -│· Thinking… │ +│󰝦 Thinking… │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/tool-open-wide.txt b/crates/plexmaton-tui/frames/tool-open-wide.txt index b9fc8f59..0e740930 100644 --- a/crates/plexmaton-tui/frames/tool-open-wide.txt +++ b/crates/plexmaton-tui/frames/tool-open-wide.txt @@ -33,7 +33,7 @@ │ │ 197 tests passed │ │ │ stderr: │ │ │ [empty] │ -│· Thinking… │ +│󰝦 Thinking… │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/src/frames.rs b/crates/plexmaton-tui/src/frames.rs index e1e76c8f..6ba23e28 100644 --- a/crates/plexmaton-tui/src/frames.rs +++ b/crates/plexmaton-tui/src/frames.rs @@ -184,7 +184,7 @@ mod tests { let mut rows = drawn.lines(); let activity = rows.next().unwrap_or_default(); assert!( - activity.contains(&format!("· {label}")), + activity.contains(&format!("{} {label}", crate::render::activity::MARK[0])), "{name}: the accepted label is on the activity line: {activity:?}" ); assert!( diff --git a/crates/plexmaton-tui/src/render/activity.rs b/crates/plexmaton-tui/src/render/activity.rs new file mode 100644 index 00000000..3e6f8c1f --- /dev/null +++ b/crates/plexmaton-tui/src/render/activity.rs @@ -0,0 +1,182 @@ +//! The conversation's activity line: its mark, the logo's core in one cell moving on the motion +//! clock (MOT-1), then what the agent is doing and the readings that say for how long. + +use plexmaton_core::ReasoningEffort; +use ratatui::text::{Line, Span}; +use unicode_width::UnicodeWidthStr; + +use super::chrome::{attention_pill, selected_suffix}; +use crate::{ + ViewState, + state::CurrentWork, + surface::SurfaceId, + theme::{Palette, Role}, +}; + +/// Outline, then filled, each through circle, rounded square, square and back. One icon family +/// from the Nerd Font the product already requires, so every frame centres alike. Rejected: +/// Unicode geometric shapes of mixed sizes, which the user watched shake in their terminal. +pub(crate) const MARK: [&str; 8] = [ + "\u{F0766}", // circle-outline + "\u{F14FC}", // square-rounded-outline + "\u{F0764}", // square-outline + "\u{F14FC}", + "\u{F0765}", // circle + "\u{F14FB}", // square-rounded + "\u{F0763}", // square + "\u{F14FB}", +]; + +/// Three of the clock's fifteen phases a second per frame: 200 ms, so a cycle takes 1.6 s, the +/// effort rail's own period. +const PHASES_PER_FRAME: u16 = 3; + +/// The frame for a clock phase. +pub(crate) fn mark(phase: u16) -> &'static str { + MARK[usize::from(phase / PHASES_PER_FRAME) % MARK.len()] +} + +/// The conversation's last row: what the agent is doing on the left; what the reader has +/// selected and what is still waiting on them on the right (ui-ux §input, COM-5, SEL-5, ATT-1). +/// +/// Derived from the same facts the composer's divider used to carry, and drawn where the +/// conversation ends rather than where the user types, so the two never read as one thing. +pub(crate) fn activity_line( + state: &ViewState, + palette: &Palette, + width: u16, + approval_visible: bool, +) -> Line<'static> { + // The label keeps the role it always had; the mark carries whose work it is: the user's own + // blue, the provider's colour while a hosted search runs, and action required standing still. + let work = match state.current_work() { + None => None, + Some(CurrentWork::Thinking) => { + Some(("Thinking…".to_owned(), Role::Ambient, Role::SurfacePrimary)) + } + Some(CurrentWork::Responding) => Some(( + "Responding…".to_owned(), + Role::Ambient, + Role::SurfacePrimary, + )), + Some(CurrentWork::RunningTool(tool)) => Some(( + format!("Running {tool}…"), + Role::Ambient, + Role::SurfacePrimary, + )), + Some(CurrentWork::RunningServerTool(tool)) => { + Some((format!("Running {tool}…"), Role::Ambient, Role::ServerTool)) + } + Some(CurrentWork::ApprovalRequired) if approval_visible => None, + Some(CurrentWork::ApprovalRequired) => Some(( + "Approval required".to_owned(), + Role::ActionRequired, + Role::ActionRequired, + )), + Some(CurrentWork::Compacting) => Some(( + "Compacting…".to_owned(), + Role::Ambient, + Role::SurfacePrimary, + )), + }; + let mut right = Vec::new(); + let selected = selected_suffix(state, SurfaceId::Transcript); + if let Some(note) = selected.strip_prefix(" · ") { + right.push(Span::styled(note.to_owned(), palette.style(Role::Muted))); + } + if let Some(pill) = attention_pill(state, palette) { + right.extend(pill.spans); + } + let right_width = Line::from(right.clone()).width(); + let mut left = Vec::new(); + if let Some((text, role, mark_role)) = work { + let moving = state.activity_moves(); + let mark = if moving { + mark(state.motion_phase()) + } else { + MARK[0] + }; + left.push(Span::styled(mark, palette.style(mark_role))); + left.push(Span::raw(" ")); + left.push(Span::styled(text, palette.style(role))); + if moving { + let readings = activity_readings(state); + let label = Line::from(left.clone()).width(); + // Dropped in this order when the row is short: effort, then quiet, then elapsed. + let fits = |kept: &[&(usize, String)]| { + label + + kept.iter().map(|(_, text)| 3 + text.width()).sum::() + + 1 + + right_width + <= usize::from(width) + }; + let mut kept: Vec<&(usize, String)> = readings.iter().collect(); + for drop in [1, 2, 0] { + if fits(&kept) { + break; + } + kept.retain(|(slot, _)| *slot != drop); + } + for (_, reading) in kept { + left.push(Span::styled( + format!(" · {reading}"), + palette.style(Role::Muted), + )); + } + } + } + let used = Line::from(left.clone()).width() + right_width; + let gap = usize::from(width).saturating_sub(used); + let mut spans = left; + if !right.is_empty() { + spans.push(Span::raw(" ".repeat(gap))); + spans.extend(right); + } + Line::from(spans) +} + +/// The muted readings after the label, in display order and tagged with the slot that orders +/// their dropping: elapsed (0), effort (1), quiet (2). Each is left out until it is known. +fn activity_readings(state: &ViewState) -> Vec<(usize, String)> { + let mut readings = Vec::new(); + if let Some(elapsed) = state.activity_elapsed() { + readings.push((0, crate::state::elapsed_label(elapsed))); + } + if let Some(effort) = state + .reasoning_effort() + .filter(|effort| !matches!(effort, ReasoningEffort::Default | ReasoningEffort::None)) + { + readings.push((1, format!("{} effort", effort.as_str()))); + } + if let Some(quiet) = state.activity_quiet() { + readings.push(( + 2, + format!("quiet for {}", crate::state::elapsed_label(quiet)), + )); + } + readings +} + +#[cfg(test)] +mod tests { + use unicode_width::UnicodeWidthStr; + + use super::{MARK, PHASES_PER_FRAME, mark}; + + /// MOT-2: every frame is one cell, so the mark can never widen the row. + #[test] + fn mot_2_every_activity_mark_frame_is_one_cell() { + for frame in MARK { + assert_eq!(frame.width(), 1, "{frame:?}"); + } + } + + /// MOT-1: the cycle divides the clock's, so the mark never jumps where the clock wraps. + #[test] + fn the_mark_cycle_divides_the_clock_cycle() { + let frames = u16::try_from(MARK.len()).expect("small"); + assert_eq!(480 % (frames * PHASES_PER_FRAME), 0); + assert_eq!(mark(0), mark(480 - 480 % (frames * PHASES_PER_FRAME))); + assert_ne!(mark(0), mark(PHASES_PER_FRAME)); + } +} diff --git a/crates/plexmaton-tui/src/render/chrome.rs b/crates/plexmaton-tui/src/render/chrome.rs index fb3c7a05..99646f51 100644 --- a/crates/plexmaton-tui/src/render/chrome.rs +++ b/crates/plexmaton-tui/src/render/chrome.rs @@ -14,10 +14,10 @@ use ratatui::{ }; use super::panel::{Chrome, Edges}; + use crate::{ ViewState, content, layout::{MIN_HEIGHT, MIN_WIDTH}, - state::CurrentWork, surface::SurfaceId, theme::{Palette, Role}, }; @@ -107,55 +107,10 @@ pub(super) fn attention_pill(state: &ViewState, palette: &Palette) -> Option Line<'static> { - let work = match state.current_work() { - None => None, - Some(CurrentWork::Thinking) => Some(("Thinking…".to_owned(), Role::Ambient)), - Some(CurrentWork::Responding) => Some(("Responding…".to_owned(), Role::Ambient)), - Some(CurrentWork::RunningTool(tool)) => Some((format!("Running {tool}…"), Role::Ambient)), - Some(CurrentWork::ApprovalRequired) if approval_visible => None, - Some(CurrentWork::ApprovalRequired) => { - Some(("Approval required".to_owned(), Role::ActionRequired)) - } - Some(CurrentWork::Compacting) => Some(("Compacting…".to_owned(), Role::Ambient)), - }; - let mut left = Vec::new(); - if let Some((text, role)) = work { - left.push(Span::styled("· ", palette.style(Role::Muted))); - left.push(Span::styled(text, palette.style(role))); - } - let mut right = Vec::new(); - let selected = selected_suffix(state, SurfaceId::Transcript); - if let Some(note) = selected.strip_prefix(" · ") { - right.push(Span::styled(note.to_owned(), palette.style(Role::Muted))); - } - if let Some(pill) = attention_pill(state, palette) { - right.extend(pill.spans); - } - let used = Line::from(left.clone()).width() + Line::from(right.clone()).width(); - let gap = usize::from(width).saturating_sub(used); - let mut spans = left; - if !right.is_empty() { - spans.push(Span::raw(" ".repeat(gap))); - spans.extend(right); - } - Line::from(spans) -} - /// What a surface says about the selection it is holding. /// /// The retained selection is separate from transient transport feedback in the status row (SEL-5). -fn selected_suffix(state: &ViewState, surface: SurfaceId) -> String { +pub(super) fn selected_suffix(state: &ViewState, surface: SurfaceId) -> String { if let Some(note) = state.copy_note(surface) { use crate::state::CopyNote; return match note { diff --git a/crates/plexmaton-tui/src/render/mod.rs b/crates/plexmaton-tui/src/render/mod.rs index dfb7028e..6c302862 100644 --- a/crates/plexmaton-tui/src/render/mod.rs +++ b/crates/plexmaton-tui/src/render/mod.rs @@ -1,5 +1,6 @@ use ratatui::{Frame, layout::Rect, widgets::Clear}; +pub(crate) mod activity; pub(crate) mod agents_handle; mod child_control; mod chrome; @@ -103,7 +104,7 @@ pub fn render( SurfaceId::Transcript => Some(Panel { insets: crate::surface::ContentInsets::default(), chrome: Chrome::Box, - footer: Some(chrome::activity_line( + footer: Some(activity::activity_line( state, palette, inner_width(bounds.width), @@ -346,7 +347,8 @@ mod tests { }; use super::{ - chrome::{activity_line, block_with, composer_title}, + activity::activity_line, + chrome::{block_with, composer_title}, panel::{Body, Chrome, Edges, Panel, draw_panel}, }; @@ -438,6 +440,47 @@ mod tests { ink(style) } + /// The activity line from its label onward, without the right-hand pill. + fn reading(state: &ViewState, width: u16) -> String { + let line = activity_line(state, &Palette::default(), width, false).to_string(); + let label = line.find("Thinking…").expect("the label is on the row"); + line[label..] + .split("( !") + .next() + .unwrap_or_default() + .trim_end() + .to_owned() + } + + /// ui-ux §input: while work runs the row reads elapsed time, effort and, after five silent + /// seconds, how long it has been quiet; a short row drops effort, then quiet, then elapsed. + #[test] + fn the_activity_line_reads_elapsed_effort_and_quiet_and_drops_them_in_order() { + let mut state = canonical_state(); + state.set_model(crate::test_support::configuration_summary()); + let started = std::time::Instant::now(); + state.observe_activity(started, true); + state.tick_activity(started + std::time::Duration::from_secs(12)); + let effort = crate::test_support::configuration_summary().reasoning_effort; + assert_eq!( + reading(&state, 80), + format!( + "Thinking… · 12s · {} effort · quiet for 12s", + effort.as_str() + ) + ); + assert_eq!(reading(&state, 48), "Thinking… · 12s · quiet for 12s"); + assert_eq!(reading(&state, 30), "Thinking… · 12s"); + assert_eq!(reading(&state, 18), "Thinking…"); + + state.observe_activity(started + std::time::Duration::from_secs(12), true); + assert_eq!( + reading(&state, 80), + format!("Thinking… · 12s · {} effort", effort.as_str()), + "just heard: nothing to say about quiet" + ); + } + /// COM-5: the activity line names each current-work state in its role, idle draws nothing, /// and the composer's rule never carries any of it (ui-ux §input). #[test] @@ -450,12 +493,18 @@ mod tests { .unwrap_or_else(|| panic!("the canonical scenario creates a primary agent")); let assert_activity = |state: &ViewState, expected: &str, role: Role| { let line = activity_line(state, &palette, 80, false); + let mark = super::activity::MARK[0]; assert!( - line.to_string().starts_with(expected), - "{expected:?} leads the activity line: {line}" + line.to_string().starts_with(&format!("{mark} {expected}")), + "the mark then {expected:?} lead the activity line: {line}" + ); + assert_eq!( + line.spans[0].style, + palette.style(Role::SurfacePrimary), + "the mark wears the user's own hue" ); assert_eq!( - line.spans[1].style, + line.spans[2].style, palette.style(role), "the current-work label must carry {role:?}" ); @@ -466,11 +515,11 @@ mod tests { ); }; - assert_activity(&canonical, "· Thinking…", Role::Ambient); - assert_activity(¤t_responding_state(), "· Responding…", Role::Ambient); + assert_activity(&canonical, "Thinking…", Role::Ambient); + assert_activity(¤t_responding_state(), "Responding…", Role::Ambient); assert_activity( ¤t_running_tool_state(), - "· Running read_file…", + "Running read_file…", Role::Ambient, ); @@ -492,8 +541,17 @@ mod tests { }, }); let line = activity_line(&approval.state, &palette, 80, false); - assert!(line.to_string().starts_with("· Approval required")); - assert_eq!(line.spans[1].style, palette.style(Role::ActionRequired)); + assert!( + line.to_string() + .starts_with(&format!("{} Approval required", super::activity::MARK[0])), + "approval stands still on the mark's first frame: {line}" + ); + assert_eq!(line.spans[0].style, palette.style(Role::ActionRequired)); + assert_eq!(line.spans[2].style, palette.style(Role::ActionRequired)); + assert!( + !line.to_string().contains(" · "), + "no readings while the user is the one being waited on: {line}" + ); assert!( line.to_string().trim_end().ends_with("( !2 )"), "the pill counts both unanswered requests at the activity line's right end: {line}" diff --git a/crates/plexmaton-tui/src/state/activity.rs b/crates/plexmaton-tui/src/state/activity.rs new file mode 100644 index 00000000..49cca065 --- /dev/null +++ b/crates/plexmaton-tui/src/state/activity.rs @@ -0,0 +1,134 @@ +//! When the primary agent's current work began and when its producer was last heard, for the +//! activity line's elapsed and quiet readings (ui-ux §input). +//! +//! Presentation only: nothing here moves the semantic revision, and a replayed journal reproduces +//! the same rows because the rows never read these instants — only the frame drawn now does. + +use std::time::{Duration, Instant}; + +use super::{CurrentWork, ViewState}; + +/// Silence shorter than this is ordinary streaming cadence and says nothing. +pub(crate) const QUIET_AFTER: Duration = Duration::from_secs(5); + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ActivityClock { + since: Option, + heard: Option, + now: Option, +} + +impl ViewState { + /// Note a batch of producer events applied at `now`: work that has just begun starts its + /// count, work that has ended forgets it, and any event is the producer being heard. + pub(crate) fn observe_activity(&mut self, now: Instant, heard: bool) { + let working = self.current_work().is_some(); + let clock = &mut self.activity; + if !working { + *clock = ActivityClock::default(); + return; + } + clock.since.get_or_insert(now); + if heard || clock.heard.is_none() { + clock.heard = Some(now); + } + clock.now = Some(now); + } + + /// The motion clock's wake: the instant the next frame is drawn for. + pub(crate) fn tick_activity(&mut self, now: Instant) { + if self.activity.since.is_some() { + self.activity.now = Some(now); + } + } + + /// How long the current work has run, once its beginning was seen. + pub(crate) fn activity_elapsed(&self) -> Option { + let clock = &self.activity; + Some(clock.now?.saturating_duration_since(clock.since?)) + } + + /// How long the producer has said nothing, once that is long enough to be worth saying. + pub(crate) fn activity_quiet(&self) -> Option { + let clock = &self.activity; + let quiet = clock.now?.saturating_duration_since(clock.heard?); + (quiet >= QUIET_AFTER).then_some(quiet) + } + + /// Whether the activity line has a mark that moves: some work, and not a wait on the user. + pub(crate) fn activity_moves(&self) -> bool { + self.current_work() + .is_some_and(|work| work != CurrentWork::ApprovalRequired) + } +} + +/// `11s`, `2m 11s`, `1h 2m`: whole units, the largest two. +pub(crate) fn elapsed_label(elapsed: Duration) -> String { + let seconds = elapsed.as_secs(); + match seconds { + 0..60 => format!("{seconds}s"), + 60..3600 => format!("{}m {}s", seconds / 60, seconds % 60), + _ => format!("{}h {}m", seconds / 3600, seconds % 3600 / 60), + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::elapsed_label; + use plexmaton_core::{ + AgentId, AgentStatus, ConversationEvent, ConversationEventEnvelope, EventSequence, + }; + + /// ui-ux §input: the count starts when work is first seen, holds while it runs, and is + /// forgotten when it ends; quiet appears only after five silent seconds. + #[test] + fn work_is_counted_from_its_start_and_forgotten_when_it_ends() { + let mut state = crate::test_support::canonical_state(); + let start = Instant::now(); + state.observe_activity(start, true); + state.observe_activity(start + Duration::from_secs(3), false); + state.tick_activity(start + Duration::from_secs(4)); + assert_eq!(state.activity_elapsed(), Some(Duration::from_secs(4))); + assert_eq!( + state.activity_quiet(), + None, + "four silent seconds say nothing" + ); + state.tick_activity(start + Duration::from_secs(9)); + assert_eq!(state.activity_quiet(), Some(Duration::from_secs(9))); + + let _ = state.apply(ConversationEventEnvelope { + sequence: EventSequence::new(u64::MAX / 2), + event: ConversationEvent::AgentStatusChanged { + agent_id: AgentId::new("agent-a").expect("primary"), + status: AgentStatus::Idle, + }, + }); + state.observe_activity(start + Duration::from_secs(10), true); + assert_eq!( + state.activity_elapsed(), + None, + "ended work forgets its count" + ); + assert!(!state.activity_moves()); + } + + /// ui-ux §input: elapsed reads in whole units, the largest two. + #[test] + fn elapsed_reads_in_the_largest_two_whole_units() { + for (seconds, label) in [ + (0, "0s"), + (11, "11s"), + (59, "59s"), + (60, "1m 0s"), + (131, "2m 11s"), + (3599, "59m 59s"), + (3600, "1h 0m"), + (3720, "1h 2m"), + ] { + assert_eq!(elapsed_label(Duration::from_secs(seconds)), label); + } + } +} diff --git a/crates/plexmaton-tui/src/state/current_work.rs b/crates/plexmaton-tui/src/state/current_work.rs index 2a37d2df..67adb585 100644 --- a/crates/plexmaton-tui/src/state/current_work.rs +++ b/crates/plexmaton-tui/src/state/current_work.rs @@ -13,6 +13,8 @@ pub(crate) enum CurrentWork<'a> { Thinking, Responding, RunningTool(&'a str), + /// A tool the provider runs on its own side, named as its row is. + RunningServerTool(&'a str), ApprovalRequired, /// A compaction the user asked for owns the summarizer (CPL-9). Compacting, @@ -46,13 +48,13 @@ impl<'a> CurrentWork<'a> { TranscriptEntryView::Tool(tool) if tool.status == ToolCallStatus::Running && running_tool.is_none() => { - running_tool = Some(tool.label.as_str()); + running_tool = Some(Self::RunningTool(tool.label.as_str())); } // A call the provider is still running is work in progress like any tool's. TranscriptEntryView::ServerTool(view) if view.call.is_none() && running_tool.is_none() => { - running_tool = Some(view.tool.name()); + running_tool = Some(Self::RunningServerTool(view.tool.name())); } TranscriptEntryView::Text(item) if item.role == TranscriptRole::Assistant && !item.finalized => @@ -78,8 +80,8 @@ impl<'a> CurrentWork<'a> { return Some(Self::Compacting); } - if let Some(tool) = running_tool { - return Some(Self::RunningTool(tool)); + if running_tool.is_some() { + return running_tool; } if agent.status != AgentStatus::Running { @@ -202,7 +204,7 @@ mod tests { .unwrap_or_else(|error| panic!("fixture: {error}")); assert_eq!( CurrentWork::derive(&searching, std::iter::empty(), false), - Some(CurrentWork::RunningTool("web_search")) + Some(CurrentWork::RunningServerTool("web_search")) ); searching .note_server_tool( @@ -219,7 +221,7 @@ mod tests { .unwrap_or_else(|error| panic!("fixture: {error}")); assert_ne!( CurrentWork::derive(&searching, std::iter::empty(), false), - Some(CurrentWork::RunningTool("web_search")) + Some(CurrentWork::RunningServerTool("web_search")) ); } diff --git a/crates/plexmaton-tui/src/state/mod.rs b/crates/plexmaton-tui/src/state/mod.rs index c8fc4fd4..72b381d0 100644 --- a/crates/plexmaton-tui/src/state/mod.rs +++ b/crates/plexmaton-tui/src/state/mod.rs @@ -1,3 +1,4 @@ +mod activity; mod agent; mod approval; mod asking; @@ -39,6 +40,7 @@ use std::collections::BTreeMap; use plexmaton_core::{AgentId, EventSequence}; +pub(crate) use activity::elapsed_label; pub use agent::AgentView; pub(crate) use approval::ApprovalTarget; pub use approval::{ @@ -138,6 +140,8 @@ pub struct ViewState { composer_menu: composer_menu::ComposerMenu, /// The shared motion clock's phase (MOT-1); presentation reads it, semantics never do. motion_phase: u16, + /// When current work began and was last heard; presentation only. + activity: activity::ActivityClock, inspector: Inspector, /// Which semantic entries the user opened, plus the one under the pointer. disclosure: DisclosureState, diff --git a/crates/plexmaton-tui/src/workspace.rs b/crates/plexmaton-tui/src/workspace.rs index 45b6485f..5f5ff567 100644 --- a/crates/plexmaton-tui/src/workspace.rs +++ b/crates/plexmaton-tui/src/workspace.rs @@ -70,6 +70,7 @@ mod preparation_tests; mod pressed; #[cfg(test)] mod pressed_tests; +mod producer; mod retry; #[cfg(test)] mod skill_menu_tests; @@ -293,38 +294,6 @@ impl Workspace { self.frames } - /// Applies producer events to the projection. - /// - /// A producer contract violation is a visible, typed notice inside the projection rather than a - /// reason to tear down the user's terminal (`state::notices`), so nothing is returned to check - /// here. - pub fn emit(&mut self, events: Vec) { - let before = self.state.revision(); - for envelope in events { - let _outcome = self.state.apply(envelope); - } - if self.state.revision() != before { - self.validate_text_selection(); - self.reconcile_copy(); - // Producer changes can move rows under a stationary pointer. The next motion resolves - // a fresh frame target; keeping the old identity would make the accent move with it. - self.state.hover_entry(None); - } - } - - /// Restores user text a runtime returned instead of silently discarding its ownership. - pub fn return_input(&mut self, to: AgentId, text: String) { - self.state.return_input(to, text); - // Composer growth can change the transcript viewport beneath a stationary pointer. - self.state.hover_entry(None); - } - - /// Restores returned text and its deliberate skill binding when the composer is otherwise empty. - pub fn return_skill_input(&mut self, to: AgentId, text: String, skill: Option) { - self.state.return_skill_input(to, text, skill); - self.state.hover_entry(None); - } - /// Anchors a saved-grant receipt beside its tool without changing semantic copy or events. pub fn report_saved_project_permission( &mut self, diff --git a/crates/plexmaton-tui/src/workspace/composer_menu_tests.rs b/crates/plexmaton-tui/src/workspace/composer_menu_tests.rs index db4acd5b..99420e63 100644 --- a/crates/plexmaton-tui/src/workspace/composer_menu_tests.rs +++ b/crates/plexmaton-tui/src/workspace/composer_menu_tests.rs @@ -481,7 +481,7 @@ fn a_requested_compaction_shows_on_the_activity_line_and_ends_with_a_note() { drawn .lines() .last() - .is_some_and(|row| row.contains("· Compacting…")), + .is_some_and(|row| row.contains(" Compacting…")), "{drawn}" ); diff --git a/crates/plexmaton-tui/src/workspace/effort_tests.rs b/crates/plexmaton-tui/src/workspace/effort_tests.rs index 0383ee98..3d5324d6 100644 --- a/crates/plexmaton-tui/src/workspace/effort_tests.rs +++ b/crates/plexmaton-tui/src/workspace/effort_tests.rs @@ -10,6 +10,23 @@ fn open( current: Effort, ) -> (Workspace, Terminal) { let (mut workspace, mut terminal) = setup(width); + // The primary agent rests, so the effort rail is the only thing on screen that moves. + workspace.emit(vec![ConversationEventEnvelope { + sequence: plexmaton_core::EventSequence::new( + crate::test_support::canonical_runtime() + .ready(u64::MAX) + .last() + .map_or(1, |envelope| envelope.sequence.get() + 1), + ), + event: plexmaton_core::ConversationEvent::AgentStatusChanged { + agent_id: AgentId::new("agent-a").expect("primary"), + status: plexmaton_core::AgentStatus::Idle, + }, + }]); + assert!( + !workspace.state.activity_moves(), + "the fixture's primary agent rests" + ); let mut model = crate::test_support::configuration_summary(); model.reasoning_effort = current; workspace.set_model(model); diff --git a/crates/plexmaton-tui/src/workspace/motion.rs b/crates/plexmaton-tui/src/workspace/motion.rs index b8626515..f120e0d0 100644 --- a/crates/plexmaton-tui/src/workspace/motion.rs +++ b/crates/plexmaton-tui/src/workspace/motion.rs @@ -21,7 +21,7 @@ pub(super) struct Motion { impl Workspace { /// Whether any visible cell moves. Each moving thing answers for its own visibility. fn moving(&self) -> bool { - self.effort_moving() + self.effort_moving() || self.state.activity_moves() } /// Arm the one shared deadline while anything visible moves; nothing moving owns no wake. @@ -55,6 +55,7 @@ impl Workspace { let phase = (now.saturating_duration_since(motion.start).as_millis() * 15 / 1000 % CYCLE) as u16; motion.next = now + TICK; + self.state.tick_activity(now); if phase != self.state.motion_phase() { self.state.set_motion_phase(phase); self.painted = None; diff --git a/crates/plexmaton-tui/src/workspace/producer.rs b/crates/plexmaton-tui/src/workspace/producer.rs new file mode 100644 index 00000000..b90f6469 --- /dev/null +++ b/crates/plexmaton-tui/src/workspace/producer.rs @@ -0,0 +1,48 @@ +//! What producers hand the workspace: conversation events, and user input a runtime returned. + +use std::time::Instant; + +use plexmaton_core::{AgentId, ConversationEventEnvelope}; + +use super::Workspace; + +impl Workspace { + /// Applies producer events to the projection. + /// + /// A producer contract violation is a visible, typed notice inside the projection rather than a + /// reason to tear down the user's terminal (`state::notices`), so nothing is returned to check + /// here. + pub fn emit(&mut self, events: Vec) { + self.emit_at(events, Instant::now()); + } + + /// Apply producer events heard at `now`, which dates the activity line's readings. + pub fn emit_at(&mut self, events: Vec, now: Instant) { + let before = self.state.revision(); + let heard = !events.is_empty(); + for envelope in events { + let _outcome = self.state.apply(envelope); + } + self.state.observe_activity(now, heard); + if self.state.revision() != before { + self.validate_text_selection(); + self.reconcile_copy(); + // Producer changes can move rows under a stationary pointer. The next motion resolves + // a fresh frame target; keeping the old identity would make the accent move with it. + self.state.hover_entry(None); + } + } + + /// Restores user text a runtime returned instead of silently discarding its ownership. + pub fn return_input(&mut self, to: AgentId, text: String) { + self.state.return_input(to, text); + // Composer growth can change the transcript viewport beneath a stationary pointer. + self.state.hover_entry(None); + } + + /// Restores returned text and its deliberate skill binding when the composer is otherwise empty. + pub fn return_skill_input(&mut self, to: AgentId, text: String, skill: Option) { + self.state.return_skill_input(to, text, skill); + self.state.hover_entry(None); + } +} diff --git a/scripts/smoke-server-tool.py b/scripts/smoke-server-tool.py index b5610115..167f1179 100755 --- a/scripts/smoke-server-tool.py +++ b/scripts/smoke-server-tool.py @@ -112,7 +112,7 @@ def run_smoke(provider): with Terminal(project, environment, "server-tool", "placement") as terminal: terminal.wait(COMPOSER) screen = terminal.prompt("What is the latest stable Rust release?", "SMOKE_ANSWER", "[+] web_search · Rust 1.98.1 release", - absent=("Preparing text", "Running web_search", "· Thinking")) + absent=("Preparing text", "Running web_search", " Thinking…")) # Placed where the provider put them: between the narration lines, not after the answer. order(screen, "SMOKE_FIRST", "[+] web_search", "SMOKE_SECOND", "[+] web_search · Rust 1.98.1 release", "SMOKE_ANSWER") assert "󱌣 2" in screen, screen From 2c25584acc419bd4ec478c07aa4e4575bbe41443 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:16:22 -0400 Subject: [PATCH 2/3] fix(tui): give the activity line air above the composer, and its rows back when idle The user's first look in kitty found the row pressed against the composer's rule while working and a hole under the last reply once done: the row held its place blank while idle. The activity line now takes its row and a blank one beneath while there is work or a request waiting, and no rows otherwise, so an idle reply sits one blank row from the composer. The rows open and close only with the conversation's own facts. A selection note that opened them lifted the whole bottom-anchored conversation two rows under the reader's drag, which the selection tests caught; while the rows are closed, the selection note rides the status row's right end beside the copy receipt instead. --- .agents/phases/phase-04-product-polish.md | 2 +- .../phase-04-stage-34-conversation-chrome.md | 8 +- .agents/specs/input-queue.md | 4 +- .agents/ui-ux.md | 33 ++++---- .../frames/agents-strip-narrow.txt | 2 +- .../frames/agents-strip-one-column.txt | 2 +- .../frames/agents-strip-tally.txt | 2 +- .../frames/agents-strip-two-columns.txt | 2 +- .../plexmaton-tui/frames/approval-medium.txt | 2 +- crates/plexmaton-tui/frames/approval-wide.txt | 2 +- .../plexmaton-tui/frames/canonical-medium.txt | 2 +- .../plexmaton-tui/frames/canonical-narrow.txt | 2 +- .../plexmaton-tui/frames/canonical-wide.txt | 2 +- .../frames/current-work-responding-medium.txt | 1 + .../frames/current-work-responding-narrow.txt | 1 + .../frames/current-work-responding-wide.txt | 1 + .../current-work-running-tool-medium.txt | 1 + .../current-work-running-tool-narrow.txt | 1 + .../frames/current-work-running-tool-wide.txt | 1 + crates/plexmaton-tui/frames/drawer-medium.txt | 2 +- crates/plexmaton-tui/frames/drawer-narrow.txt | 2 +- .../plexmaton-tui/frames/drawer-ultrawide.txt | 2 +- crates/plexmaton-tui/frames/drawer-wide.txt | 2 +- .../frames/input-queue-medium.txt | 2 +- .../frames/input-queue-narrow.txt | 2 +- .../plexmaton-tui/frames/input-queue-wide.txt | 2 +- .../plexmaton-tui/frames/markdown-medium.txt | 2 +- .../plexmaton-tui/frames/markdown-narrow.txt | 2 +- crates/plexmaton-tui/frames/markdown-wide.txt | 2 +- .../frames/message-actions-medium.txt | 2 +- .../frames/message-actions-narrow.txt | 2 +- .../frames/message-actions-wide.txt | 2 +- .../frames/native-approval-medium.txt | 2 +- .../frames/native-approval-narrow.txt | 2 +- .../frames/native-approval-wide.txt | 2 +- .../project-permission-receipt-medium.txt | 2 +- .../project-permission-receipt-narrow.txt | 2 +- .../project-permission-receipt-wide.txt | 2 +- crates/plexmaton-tui/frames/retry-medium.txt | 2 +- crates/plexmaton-tui/frames/retry-narrow.txt | 2 +- crates/plexmaton-tui/frames/retry-wide.txt | 2 +- .../frames/server-tool-medium.txt | 2 +- .../frames/server-tool-narrow.txt | 2 +- .../frames/server-tool-open-wide.txt | 2 +- .../plexmaton-tui/frames/server-tool-wide.txt | 2 +- .../frames/session-recovery-medium.txt | 2 +- .../frames/session-recovery-narrow.txt | 2 +- .../frames/session-recovery-wide.txt | 2 +- .../frames/skill-diagnostic-medium.txt | 2 +- .../frames/skill-diagnostic-narrow.txt | 2 +- .../frames/skill-diagnostic-wide.txt | 2 +- .../frames/text-selection-medium.txt | 4 +- .../frames/text-selection-narrow.txt | 4 +- .../frames/text-selection-wide.txt | 4 +- .../plexmaton-tui/frames/tool-open-medium.txt | 2 +- .../plexmaton-tui/frames/tool-open-narrow.txt | 2 +- .../plexmaton-tui/frames/tool-open-wide.txt | 2 +- .../frames/transcript-grammar-medium.txt | 2 +- .../frames/transcript-grammar-narrow.txt | 2 +- .../frames/transcript-grammar-wide.txt | 2 +- crates/plexmaton-tui/src/frames.rs | 26 +++--- crates/plexmaton-tui/src/render/activity.rs | 79 +++++++++++++++++++ crates/plexmaton-tui/src/render/chrome.rs | 30 ++++++- .../src/render/command_inspection.rs | 4 +- .../src/render/conversation_tree.rs | 2 +- crates/plexmaton-tui/src/render/mod.rs | 72 ++++++++++------- crates/plexmaton-tui/src/render/panel.rs | 18 +++-- crates/plexmaton-tui/src/workspace.rs | 3 +- .../src/workspace/composer_menu_tests.rs | 3 +- 69 files changed, 269 insertions(+), 127 deletions(-) diff --git a/.agents/phases/phase-04-product-polish.md b/.agents/phases/phase-04-product-polish.md index 4fd6c5b7..8e324a28 100644 --- a/.agents/phases/phase-04-product-polish.md +++ b/.agents/phases/phase-04-product-polish.md @@ -252,7 +252,7 @@ Visual changes are reviewed against real frames before the contract adopts them. queues above the composer. `Alt-↑` restores the newest message and its explicit skill only into an empty primary draft; an occupied draft preserves both messages and advertises the precondition. Completed skill preparation wakes the projection without requiring provider - output or another key. The activity row meets the waiting rule without an extra blank row. + output or another key. The waiting rule follows the activity line's own blank row. The user delegated completion on 2026-09-12; the agent reviewed the existing design and these fixes, and [ui-ux](../ui-ux.md) adopts the resulting input/state grammar. Reviewed frames at [wide](../../crates/plexmaton-tui/frames/input-queue-wide.txt), diff --git a/.agents/plans/phase-04-stage-34-conversation-chrome.md b/.agents/plans/phase-04-stage-34-conversation-chrome.md index 287310d3..fd400ba5 100644 --- a/.agents/plans/phase-04-stage-34-conversation-chrome.md +++ b/.agents/plans/phase-04-stage-34-conversation-chrome.md @@ -45,9 +45,11 @@ MOT-1 to MOT-3 live in [motion](../specs/motion.md), promoted when the motion ow at 200 ms, which the user chose in their own terminal over the Unicode cycles that shook. It wears blue, the server-tool colour while a hosted search runs, and stands still in the action-required colour during approval. The readings are presentation: events are dated as they - are applied and the motion clock supplies the instant a frame is drawn for. Closed by refreshed - current-work frames, the readings and work-clock tests, MOT-2 for the mark, and the activity - smokes. + are applied and the motion clock supplies the instant a frame is drawn for. After the user's + first look in kitty the row gained a blank row beneath it and gives up its rows when idle with + nothing waiting; a selection note never opens them, and rides the status row while they are + closed. Closed by refreshed current-work frames, the readings, work-clock and footer-rows tests, + the selection drags that no longer move, MOT-2 for the mark, and the activity smokes. 5. **Documents.** Evidence tables current; SVG review of the frames attached; this plan deleted; the phase row says done. diff --git a/.agents/specs/input-queue.md b/.agents/specs/input-queue.md index aa64f9f7..56269eb8 100644 --- a/.agents/specs/input-queue.md +++ b/.agents/specs/input-queue.md @@ -32,8 +32,8 @@ every entry it did not list. Cut below what it asked for, it lists fewer entries rather than losing the rows off its bottom, and the count grows to cover them; below its floor — its rule, one heading, one entry, that count and -the way back — it takes no rows at all. Its top rule meets the activity row without an extra -bottom border or blank row. It is chrome, so a row past its rectangle is a row with no +the way back — it takes no rows at all. Its top rule follows the blank row the activity line keeps +beneath itself, with no bottom border or blank row of its own. It is chrome, so a row past its rectangle is a row with no way to reach it: what it cannot show it must count, and the way back is the last row it gives up. Rejected: letting the rows it was granted simply clip its content, which drops the key in IQU-3 first and leaves the title counting messages the band has stopped showing. diff --git a/.agents/ui-ux.md b/.agents/ui-ux.md index 6a5e9646..e9182184 100644 --- a/.agents/ui-ux.md +++ b/.agents/ui-ux.md @@ -153,20 +153,25 @@ other rule about input follows from this one. row** reading `Message Agent A · ⇥ to return`, which stays clickable and stays a focus stop. Rejected: hiding it, which costs the affordance and jumps the tail of the transcript three rows; one row of jump is acceptable and zero costs too much screen on a small terminal. -- **The conversation's last row is its activity line**, above the composer: `Thinking`, - `Responding`, `Running `, or `Approval required`. It follows semantic state; action required - outranks ambient work, and idle is blank. While the agent works the row opens with the mark's core - moving, then the label, then in muted text how long the current work has run and at what effort, - and after five seconds with nothing arriving, how long it has been quiet; a short row drops - effort, then quiet, then elapsed. The mark cycles the Nerd Font's circle, rounded square and - square, outline then filled, and wears blue, the server-tool colour while a provider-run search is - the work, and stands still in the action-required colour while approval is required. Motion runs - on the one visible-only clock the effort rail shares ([motion](./specs/motion.md)); it changes - presentation only. Show the approval label in the visible primary card; otherwise in the activity - line. Rejected: duplicate labels on adjacent lines; a still row, which during a thirty-second - silent turn read as a hang; a token count estimated from streamed characters, because the row - claims only what the route reported; turning the row red when the route is quiet, because quiet is - not failure. +- **The conversation ends in its activity line** while there is work or a request waiting, with one + blank row between it and the composer: `Thinking`, `Responding`, `Running `, or `Approval + required`. It follows semantic state; action required outranks ambient work. Idle with nothing + waiting, it takes no rows, so the last reply sits one blank row from the composer; the rows open + and close only with the conversation's own facts, never with the pointer, and while they are + closed the selection note rides the status row's right end. While the agent works the row opens + with the mark's core moving, then the label, then in muted text how long the current work has run + and at what effort, and after five seconds with nothing arriving, how long it has been quiet; a + short row drops effort, then quiet, then elapsed. The mark cycles the Nerd Font's circle, rounded + square and square, outline then filled, and wears blue, the server-tool colour while a + provider-run search is the work, and stands still in the action-required colour while approval is + required. Motion runs on the one visible-only clock the effort rail shares + ([motion](./specs/motion.md)); it changes presentation only. Show the approval label in the + visible primary card; otherwise in the activity line. Rejected: duplicate labels on adjacent + lines; a row kept blank while idle, which left a hole under the last reply; opening the rows for a + selection note, which lifted the conversation under the reader's drag; a still row, which during a + thirty-second silent turn read as a hang; a token count estimated from streamed characters, + because the row claims only what the route reported; turning the row red when the route is quiet, + because quiet is not failure. - **An empty conversation shows the mark.** Before the first message the transcript holds the mark centred, the product's name beneath it, drawn in cells at the odd size nearest square for the terminal's measured cell; it moves on the same clock and leaves with the first message. Rejected: diff --git a/crates/plexmaton-tui/frames/agents-strip-narrow.txt b/crates/plexmaton-tui/frames/agents-strip-narrow.txt index 73626792..cc21227a 100644 --- a/crates/plexmaton-tui/frames/agents-strip-narrow.txt +++ b/crates/plexmaton-tui/frames/agents-strip-narrow.txt @@ -11,13 +11,13 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused │ │UI study. Agent B is running independently; this │ │transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centrali…│ │󰝦 Thinking… ( !2 ) │ +│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/agents-strip-one-column.txt b/crates/plexmaton-tui/frames/agents-strip-one-column.txt index bddb68ee..5d4a6f73 100644 --- a/crates/plexmaton-tui/frames/agents-strip-one-column.txt +++ b/crates/plexmaton-tui/frames/agents-strip-one-column.txt @@ -12,12 +12,12 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running │ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │󰝦 Thinking… ( !2 ) │ +│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/agents-strip-tally.txt b/crates/plexmaton-tui/frames/agents-strip-tally.txt index 54911471..447c3680 100644 --- a/crates/plexmaton-tui/frames/agents-strip-tally.txt +++ b/crates/plexmaton-tui/frames/agents-strip-tally.txt @@ -8,12 +8,12 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running │ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │󰝦 Thinking… │ +│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/agents-strip-two-columns.txt b/crates/plexmaton-tui/frames/agents-strip-two-columns.txt index d12b6167..6c5b0022 100644 --- a/crates/plexmaton-tui/frames/agents-strip-two-columns.txt +++ b/crates/plexmaton-tui/frames/agents-strip-two-columns.txt @@ -11,13 +11,13 @@ │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │I will inspect the workspace and delegate the focused UI study. ││ │ │Agent B is running independently; this transcript remains ││ │ │interactive. ││ │ │ ││ │ │received from Agent B · UI study · Routing stays centralized and z-…││ │ │󰝦 Thinking… ( !2 ) ││ │ +│ ││ │ ── Message ───────────────────────────────────────────────────────────│ │ Type a message · ⇥ to focus │Controller unavailable · input locked │ ──────────────────────────────────────────────────────────────────────└────────────────────────────────────────────────────────────────────┘ diff --git a/crates/plexmaton-tui/frames/approval-medium.txt b/crates/plexmaton-tui/frames/approval-medium.txt index ba6b9b1d..f8a7e705 100644 --- a/crates/plexmaton-tui/frames/approval-medium.txt +++ b/crates/plexmaton-tui/frames/approval-medium.txt @@ -17,12 +17,12 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running │ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │󰝦 Thinking… ( !1 ) │ +│ │ ── Approval required · edit ─────────────────────────────────────────────────────────────────── Change crates/plexmaton-core/src/lib.rs and preserve its current revision. diff --git a/crates/plexmaton-tui/frames/approval-wide.txt b/crates/plexmaton-tui/frames/approval-wide.txt index 82e193dc..13c5cc4b 100644 --- a/crates/plexmaton-tui/frames/approval-wide.txt +++ b/crates/plexmaton-tui/frames/approval-wide.txt @@ -16,13 +16,13 @@ │ ││ │ │ ││ │ │ ││ │ -│ ││ │ │I will inspect the workspace and delegate the focused ││ │ │UI study. Agent B is running independently; this ││ │ │transcript remains interactive. ││ │ │ ││ │ │received from Agent B · UI study · Routing stays centrali…││ │ │󰝦 Thinking… ( !1 ) ││ │ +│ ││ │ ── Approval required · edit ────────────────────────────────│ │ │ │ Change crates/plexmaton-core/src/lib.rs and preserve … │ │ diff --git a/crates/plexmaton-tui/frames/canonical-medium.txt b/crates/plexmaton-tui/frames/canonical-medium.txt index 687c2645..7494f770 100644 --- a/crates/plexmaton-tui/frames/canonical-medium.txt +++ b/crates/plexmaton-tui/frames/canonical-medium.txt @@ -28,12 +28,12 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running │ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │󰝦 Thinking… ( !1 ) │ +│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/canonical-narrow.txt b/crates/plexmaton-tui/frames/canonical-narrow.txt index 17654514..8692371a 100644 --- a/crates/plexmaton-tui/frames/canonical-narrow.txt +++ b/crates/plexmaton-tui/frames/canonical-narrow.txt @@ -27,13 +27,13 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused │ │UI study. Agent B is running independently; this │ │transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centrali…│ │󰝦 Thinking… ( !1 ) │ +│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/canonical-wide.txt b/crates/plexmaton-tui/frames/canonical-wide.txt index 3022b460..36e9ad41 100644 --- a/crates/plexmaton-tui/frames/canonical-wide.txt +++ b/crates/plexmaton-tui/frames/canonical-wide.txt @@ -28,12 +28,12 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running independently; this transcript │ │remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │󰝦 Thinking… ( !1 ) │ +│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/current-work-responding-medium.txt b/crates/plexmaton-tui/frames/current-work-responding-medium.txt index 3019aa66..e2b6a7cb 100644 --- a/crates/plexmaton-tui/frames/current-work-responding-medium.txt +++ b/crates/plexmaton-tui/frames/current-work-responding-medium.txt @@ -1,4 +1,5 @@ │󰝦 Responding… ( !1 ) │ +│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/current-work-responding-narrow.txt b/crates/plexmaton-tui/frames/current-work-responding-narrow.txt index 5d4020e4..ca724fc1 100644 --- a/crates/plexmaton-tui/frames/current-work-responding-narrow.txt +++ b/crates/plexmaton-tui/frames/current-work-responding-narrow.txt @@ -1,4 +1,5 @@ │󰝦 Responding… ( !1 ) │ +│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/current-work-responding-wide.txt b/crates/plexmaton-tui/frames/current-work-responding-wide.txt index 28e47aef..9a60619d 100644 --- a/crates/plexmaton-tui/frames/current-work-responding-wide.txt +++ b/crates/plexmaton-tui/frames/current-work-responding-wide.txt @@ -1,4 +1,5 @@ │󰝦 Responding… ( !1 ) │ +│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/current-work-running-tool-medium.txt b/crates/plexmaton-tui/frames/current-work-running-tool-medium.txt index 478b3cc5..18ba7650 100644 --- a/crates/plexmaton-tui/frames/current-work-running-tool-medium.txt +++ b/crates/plexmaton-tui/frames/current-work-running-tool-medium.txt @@ -1,4 +1,5 @@ │󰝦 Running read_file… ( !1 ) │ +│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/current-work-running-tool-narrow.txt b/crates/plexmaton-tui/frames/current-work-running-tool-narrow.txt index d255c3b1..46bcb5f3 100644 --- a/crates/plexmaton-tui/frames/current-work-running-tool-narrow.txt +++ b/crates/plexmaton-tui/frames/current-work-running-tool-narrow.txt @@ -1,4 +1,5 @@ │󰝦 Running read_file… ( !1 ) │ +│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/current-work-running-tool-wide.txt b/crates/plexmaton-tui/frames/current-work-running-tool-wide.txt index d1cfc713..2acc265d 100644 --- a/crates/plexmaton-tui/frames/current-work-running-tool-wide.txt +++ b/crates/plexmaton-tui/frames/current-work-running-tool-wide.txt @@ -1,4 +1,5 @@ │󰝦 Running read_file… ( !1 ) │ +│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/drawer-medium.txt b/crates/plexmaton-tui/frames/drawer-medium.txt index 21eb58b9..e7e473e1 100644 --- a/crates/plexmaton-tui/frames/drawer-medium.txt +++ b/crates/plexmaton-tui/frames/drawer-medium.txt @@ -28,12 +28,12 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running │ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │󰝦 Thinking… ( !1 ) │ +│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/drawer-narrow.txt b/crates/plexmaton-tui/frames/drawer-narrow.txt index ee340f3c..a7481547 100644 --- a/crates/plexmaton-tui/frames/drawer-narrow.txt +++ b/crates/plexmaton-tui/frames/drawer-narrow.txt @@ -27,13 +27,13 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused │ │UI study. Agent B is running independently; this │ │transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centrali…│ │󰝦 Thinking… ( !1 ) │ +│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/drawer-ultrawide.txt b/crates/plexmaton-tui/frames/drawer-ultrawide.txt index 08b096ca..f738933e 100644 --- a/crates/plexmaton-tui/frames/drawer-ultrawide.txt +++ b/crates/plexmaton-tui/frames/drawer-ultrawide.txt @@ -29,11 +29,11 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │󰝦 Thinking… ( !1 ) │ +│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/drawer-wide.txt b/crates/plexmaton-tui/frames/drawer-wide.txt index 0f0da8d5..b5d72e67 100644 --- a/crates/plexmaton-tui/frames/drawer-wide.txt +++ b/crates/plexmaton-tui/frames/drawer-wide.txt @@ -28,12 +28,12 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running independently; this transcript │ │remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │󰝦 Thinking… ( !1 ) │ +│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/input-queue-medium.txt b/crates/plexmaton-tui/frames/input-queue-medium.txt index 2fdd0449..08eec726 100644 --- a/crates/plexmaton-tui/frames/input-queue-medium.txt +++ b/crates/plexmaton-tui/frames/input-queue-medium.txt @@ -21,7 +21,6 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running │ │independently; this transcript remains interactive. │ │ │ @@ -29,6 +28,7 @@ │ │ │ │ │󰝦 Responding… ( !1 ) │ +│ │ ── Waiting to send · 2 ──────────────────────────────────────────────────────────────────────── Sends when this turn ends ↳ Also check the compaction path while you are in there diff --git a/crates/plexmaton-tui/frames/input-queue-narrow.txt b/crates/plexmaton-tui/frames/input-queue-narrow.txt index 8df574f9..5f49fa4c 100644 --- a/crates/plexmaton-tui/frames/input-queue-narrow.txt +++ b/crates/plexmaton-tui/frames/input-queue-narrow.txt @@ -20,7 +20,6 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused │ │UI study. Agent B is running independently; this │ │transcript remains interactive. │ @@ -29,6 +28,7 @@ │ │ │ │ │󰝦 Responding… ( !1 ) │ +│ │ ── Waiting to send · 2 ───────────────────────────────────── Sends when this turn ends ↳ Also check the compaction path while you are in there diff --git a/crates/plexmaton-tui/frames/input-queue-wide.txt b/crates/plexmaton-tui/frames/input-queue-wide.txt index a309bbcc..236215e1 100644 --- a/crates/plexmaton-tui/frames/input-queue-wide.txt +++ b/crates/plexmaton-tui/frames/input-queue-wide.txt @@ -21,7 +21,6 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running independently; this transcript │ │remains interactive. │ │ │ @@ -29,6 +28,7 @@ │ │ │ │ │󰝦 Responding… ( !1 ) │ +│ │ ── Waiting to send · 2 ───────────────────────────────────────────────────────────────────────────────────────────────── Sends when this turn ends ↳ Also check the compaction path while you are in there diff --git a/crates/plexmaton-tui/frames/markdown-medium.txt b/crates/plexmaton-tui/frames/markdown-medium.txt index 5431f121..83d3260e 100644 --- a/crates/plexmaton-tui/frames/markdown-medium.txt +++ b/crates/plexmaton-tui/frames/markdown-medium.txt @@ -17,6 +17,7 @@ │ │ │ │ │ │ +│ │ │ › Show **Markdown** and a Rust example. │ │ │ │Build summary │ @@ -41,7 +42,6 @@ │ │ │See the docs (https://example.com/docs). │ │ │ -│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/markdown-narrow.txt b/crates/plexmaton-tui/frames/markdown-narrow.txt index 99fbc197..1373b69d 100644 --- a/crates/plexmaton-tui/frames/markdown-narrow.txt +++ b/crates/plexmaton-tui/frames/markdown-narrow.txt @@ -13,6 +13,7 @@ │ │ │ │ │ │ +│ │ │ › Show **Markdown** and a Rust example. │ │ │ │Build summary │ @@ -41,7 +42,6 @@ │ │ │See the docs (https://example.com/docs). │ │ │ -│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/markdown-wide.txt b/crates/plexmaton-tui/frames/markdown-wide.txt index 8ae6864e..ce2e319b 100644 --- a/crates/plexmaton-tui/frames/markdown-wide.txt +++ b/crates/plexmaton-tui/frames/markdown-wide.txt @@ -17,6 +17,7 @@ │ │ │ │ │ │ +│ │ │ › Show **Markdown** and a Rust example. │ │ │ │Build summary │ @@ -41,7 +42,6 @@ │ │ │See the docs (https://example.com/docs). │ │ │ -│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/message-actions-medium.txt b/crates/plexmaton-tui/frames/message-actions-medium.txt index 7dbaa0f4..4e3294c5 100644 --- a/crates/plexmaton-tui/frames/message-actions-medium.txt +++ b/crates/plexmaton-tui/frames/message-actions-medium.txt @@ -9,6 +9,7 @@ │ │ │ │ │ │ +│ │ │─────────────────────────────────────────────────────────────────────────────────────────────│ │ › Original question 󰆏 │ │─────────────────────────────────────────────────────────────────────────────────────────────│ @@ -17,7 +18,6 @@ │ │ │[ Retry ] [ Edit & retry ] · r / e │ │ │ -│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/message-actions-narrow.txt b/crates/plexmaton-tui/frames/message-actions-narrow.txt index 3c7199c2..6c366da5 100644 --- a/crates/plexmaton-tui/frames/message-actions-narrow.txt +++ b/crates/plexmaton-tui/frames/message-actions-narrow.txt @@ -9,6 +9,7 @@ │ │ │ │ │ │ +│ │ │──────────────────────────────────────────────────────────│ │ › Original question 󰆏 │ │──────────────────────────────────────────────────────────│ @@ -17,7 +18,6 @@ │ │ │[ Retry ] [ Edit & retry ] · r / e │ │ │ -│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/message-actions-wide.txt b/crates/plexmaton-tui/frames/message-actions-wide.txt index dce9dbeb..2ada8188 100644 --- a/crates/plexmaton-tui/frames/message-actions-wide.txt +++ b/crates/plexmaton-tui/frames/message-actions-wide.txt @@ -9,6 +9,7 @@ │ │ │ │ │ │ +│ │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ │ › Original question 󰆏 │ │──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│ @@ -17,7 +18,6 @@ │ │ │[ Retry ] [ Edit & retry ] · r / e │ │ │ -│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/native-approval-medium.txt b/crates/plexmaton-tui/frames/native-approval-medium.txt index 943b2741..57e618b1 100644 --- a/crates/plexmaton-tui/frames/native-approval-medium.txt +++ b/crates/plexmaton-tui/frames/native-approval-medium.txt @@ -15,13 +15,13 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running │ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │[?] exec_command · approval required │ │ ( !2 ) │ +│ │ ── Approval required · exec_command ─────────────────────────────────────────────────────────── Command "cargo test" · cwd "/home/dev/plexmaton" · timeout 120000 ms diff --git a/crates/plexmaton-tui/frames/native-approval-narrow.txt b/crates/plexmaton-tui/frames/native-approval-narrow.txt index 78f4641c..bc3c7abc 100644 --- a/crates/plexmaton-tui/frames/native-approval-narrow.txt +++ b/crates/plexmaton-tui/frames/native-approval-narrow.txt @@ -14,7 +14,6 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused │ │UI study. Agent B is running independently; this │ │transcript remains interactive. │ @@ -22,6 +21,7 @@ │received from Agent B · UI study · Routing stays centrali…│ │[?] exec_command · approval required │ │ ( !2 ) │ +│ │ ── Approval required · exec_command ──────────────────────── Command "cargo test" · cwd "/home/dev/plexmaton" · ti… diff --git a/crates/plexmaton-tui/frames/native-approval-wide.txt b/crates/plexmaton-tui/frames/native-approval-wide.txt index d8b3642a..d79ae160 100644 --- a/crates/plexmaton-tui/frames/native-approval-wide.txt +++ b/crates/plexmaton-tui/frames/native-approval-wide.txt @@ -15,13 +15,13 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running independently; this transcript │ │remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │[?] exec_command · approval required │ │ ( !2 ) │ +│ │ ── Approval required · exec_command ──────────────────────────────────────────────────────────────────────────────────────── Command "cargo test" · cwd "/home/dev/plexmaton" · timeout 120000 ms diff --git a/crates/plexmaton-tui/frames/project-permission-receipt-medium.txt b/crates/plexmaton-tui/frames/project-permission-receipt-medium.txt index d2fc414f..04c4cf2f 100644 --- a/crates/plexmaton-tui/frames/project-permission-receipt-medium.txt +++ b/crates/plexmaton-tui/frames/project-permission-receipt-medium.txt @@ -13,8 +13,8 @@ │ │ │ │ │ │ +│ │ │[-] exec_command · cancelled │ │Warning · Project permission saved; tool did not run. │ │Review it under Ctrl-P · Permissions. │ │ │ -│ 1 selected│ diff --git a/crates/plexmaton-tui/frames/project-permission-receipt-narrow.txt b/crates/plexmaton-tui/frames/project-permission-receipt-narrow.txt index 5d542ac6..a8b5c830 100644 --- a/crates/plexmaton-tui/frames/project-permission-receipt-narrow.txt +++ b/crates/plexmaton-tui/frames/project-permission-receipt-narrow.txt @@ -13,8 +13,8 @@ │ │ │ │ │ │ +│ │ │[-] exec_command · cancelled │ │Warning · Project permission saved; tool did not run. │ │Review it under Ctrl-P · Permissions. │ │ │ -│ 1 selected│ diff --git a/crates/plexmaton-tui/frames/project-permission-receipt-wide.txt b/crates/plexmaton-tui/frames/project-permission-receipt-wide.txt index df121c98..13623217 100644 --- a/crates/plexmaton-tui/frames/project-permission-receipt-wide.txt +++ b/crates/plexmaton-tui/frames/project-permission-receipt-wide.txt @@ -13,8 +13,8 @@ │ │ │ │ │ │ +│ │ │[-] exec_command · cancelled │ │Warning · Project permission saved; tool did not run. │ │Review it under Ctrl-P · Permissions. │ │ │ -│ 1 selected│ diff --git a/crates/plexmaton-tui/frames/retry-medium.txt b/crates/plexmaton-tui/frames/retry-medium.txt index 4db76c3a..3a20c1c6 100644 --- a/crates/plexmaton-tui/frames/retry-medium.txt +++ b/crates/plexmaton-tui/frames/retry-medium.txt @@ -10,6 +10,7 @@ │ │ │ │ │ │ +│ │ │ › Original question │ │ │ │error │ @@ -17,7 +18,6 @@ │ │ │[ Retry ] [ Edit & retry ] · r / e │ │ │ -│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/retry-narrow.txt b/crates/plexmaton-tui/frames/retry-narrow.txt index 1d1551dd..a89155a5 100644 --- a/crates/plexmaton-tui/frames/retry-narrow.txt +++ b/crates/plexmaton-tui/frames/retry-narrow.txt @@ -10,6 +10,7 @@ │ │ │ │ │ │ +│ │ │ › Original question │ │ │ │error │ @@ -17,7 +18,6 @@ │ │ │[ Retry ] [ Edit & retry ] · r / e │ │ │ -│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/retry-wide.txt b/crates/plexmaton-tui/frames/retry-wide.txt index a774e793..0ec1def0 100644 --- a/crates/plexmaton-tui/frames/retry-wide.txt +++ b/crates/plexmaton-tui/frames/retry-wide.txt @@ -10,6 +10,7 @@ │ │ │ │ │ │ +│ │ │ › Original question │ │ │ │error │ @@ -17,7 +18,6 @@ │ │ │[ Retry ] [ Edit & retry ] · r / e │ │ │ -│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/server-tool-medium.txt b/crates/plexmaton-tui/frames/server-tool-medium.txt index 70b060b0..7b944389 100644 --- a/crates/plexmaton-tui/frames/server-tool-medium.txt +++ b/crates/plexmaton-tui/frames/server-tool-medium.txt @@ -14,7 +14,6 @@ │ │ │ │ │ │ -│ │ │ › What is the latest stable Rust release, and when did it ship? │ │ │ │reasoning │ @@ -34,6 +33,7 @@ │release post on the Rust blog lists what it fixes. │ │ │ │󰝦 Running web_search… │ +│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/server-tool-narrow.txt b/crates/plexmaton-tui/frames/server-tool-narrow.txt index badaf434..a413dde8 100644 --- a/crates/plexmaton-tui/frames/server-tool-narrow.txt +++ b/crates/plexmaton-tui/frames/server-tool-narrow.txt @@ -10,7 +10,6 @@ │ │ │ │ │ │ -│ │ │ › What is the latest stable Rust release, and when │ │ did it ship? │ │ │ @@ -34,6 +33,7 @@ │the Rust blog lists what it fixes. │ │ │ │󰝦 Running web_search… │ +│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/server-tool-open-wide.txt b/crates/plexmaton-tui/frames/server-tool-open-wide.txt index 2bea6568..b45dbb26 100644 --- a/crates/plexmaton-tui/frames/server-tool-open-wide.txt +++ b/crates/plexmaton-tui/frames/server-tool-open-wide.txt @@ -11,7 +11,6 @@ │ │ │ │ │ │ -│ │ │ › What is the latest stable Rust release, and when did it ship? │ │ │ │reasoning │ @@ -34,6 +33,7 @@ │blog lists what it fixes. │ │ │ │󰝦 Running web_search… │ +│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/server-tool-wide.txt b/crates/plexmaton-tui/frames/server-tool-wide.txt index af4a4bcf..96ec127a 100644 --- a/crates/plexmaton-tui/frames/server-tool-wide.txt +++ b/crates/plexmaton-tui/frames/server-tool-wide.txt @@ -14,7 +14,6 @@ │ │ │ │ │ │ -│ │ │ › What is the latest stable Rust release, and when did it ship? │ │ │ │reasoning │ @@ -34,6 +33,7 @@ │blog lists what it fixes. │ │ │ │󰝦 Running web_search… │ +│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/session-recovery-medium.txt b/crates/plexmaton-tui/frames/session-recovery-medium.txt index 56c30779..09073755 100644 --- a/crates/plexmaton-tui/frames/session-recovery-medium.txt +++ b/crates/plexmaton-tui/frames/session-recovery-medium.txt @@ -26,6 +26,7 @@ │ │ │ │ │ │ +│ │ │warning │ │The previous turn didn't finish. You can continue from here; no model requests or tools │ │were rerun. │ @@ -33,7 +34,6 @@ │isolated 37-byte incomplete tail │ │✓ Conversation restored. │ │ │ -│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/session-recovery-narrow.txt b/crates/plexmaton-tui/frames/session-recovery-narrow.txt index eb085d4f..f8851be1 100644 --- a/crates/plexmaton-tui/frames/session-recovery-narrow.txt +++ b/crates/plexmaton-tui/frames/session-recovery-narrow.txt @@ -26,6 +26,7 @@ │ │ │ │ │ │ +│ │ │warning │ │The previous turn didn't finish. You can continue │ │from here; no model requests or tools were rerun. │ @@ -33,7 +34,6 @@ │isolated 37-byte incomplete tail │ │✓ Conversation restored. │ │ │ -│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/session-recovery-wide.txt b/crates/plexmaton-tui/frames/session-recovery-wide.txt index 8b5ae03a..97b65004 100644 --- a/crates/plexmaton-tui/frames/session-recovery-wide.txt +++ b/crates/plexmaton-tui/frames/session-recovery-wide.txt @@ -27,13 +27,13 @@ │ │ │ │ │ │ +│ │ │warning │ │The previous turn didn't finish. You can continue from here; no model requests or tools were rerun. │ │ │ │isolated 37-byte incomplete tail │ │✓ Conversation restored. │ │ │ -│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/skill-diagnostic-medium.txt b/crates/plexmaton-tui/frames/skill-diagnostic-medium.txt index 1c02d8c4..91f239e0 100644 --- a/crates/plexmaton-tui/frames/skill-diagnostic-medium.txt +++ b/crates/plexmaton-tui/frames/skill-diagnostic-medium.txt @@ -27,13 +27,13 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running │ │independently; this transcript remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │[!] skill · failed │ │󰝦 Thinking… ( !1 ) │ +│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── $review check this change ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/skill-diagnostic-narrow.txt b/crates/plexmaton-tui/frames/skill-diagnostic-narrow.txt index d71227ab..04a74ee0 100644 --- a/crates/plexmaton-tui/frames/skill-diagnostic-narrow.txt +++ b/crates/plexmaton-tui/frames/skill-diagnostic-narrow.txt @@ -26,7 +26,6 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused │ │UI study. Agent B is running independently; this │ │transcript remains interactive. │ @@ -34,6 +33,7 @@ │received from Agent B · UI study · Routing stays centrali…│ │[!] skill · failed │ │󰝦 Thinking… ( !1 ) │ +│ │ ── Message ───────────────────────────────────────────────── $review check this change ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/skill-diagnostic-wide.txt b/crates/plexmaton-tui/frames/skill-diagnostic-wide.txt index 9460cbe4..248f0124 100644 --- a/crates/plexmaton-tui/frames/skill-diagnostic-wide.txt +++ b/crates/plexmaton-tui/frames/skill-diagnostic-wide.txt @@ -27,13 +27,13 @@ │ │ │ │ │ │ -│ │ │I will inspect the workspace and delegate the focused UI study. Agent B is running independently; this transcript │ │remains interactive. │ │ │ │received from Agent B · UI study · Routing stays centralized and z-ordered. │ │[!] skill · failed │ │󰝦 Thinking… ( !1 ) │ +│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── $review check this change ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/text-selection-medium.txt b/crates/plexmaton-tui/frames/text-selection-medium.txt index 9acf37ea..eda0e17c 100644 --- a/crates/plexmaton-tui/frames/text-selection-medium.txt +++ b/crates/plexmaton-tui/frames/text-selection-medium.txt @@ -7,6 +7,7 @@ │ │ │ │ │ │ +│ │ │ › Review the Markdown update. │ │ │ │Summary │ @@ -17,8 +18,7 @@ ││ println!("hello, 世界"); │ │└ │ │ │ -│ text selected│ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── - + text selected diff --git a/crates/plexmaton-tui/frames/text-selection-narrow.txt b/crates/plexmaton-tui/frames/text-selection-narrow.txt index 59cf76ac..0881e0f6 100644 --- a/crates/plexmaton-tui/frames/text-selection-narrow.txt +++ b/crates/plexmaton-tui/frames/text-selection-narrow.txt @@ -7,6 +7,7 @@ │ │ │ │ │ │ +│ │ │ › Review the Markdown update. │ │ │ │Summary │ @@ -17,8 +18,7 @@ ││ println!("hello, 世界"); │ │└ │ │ │ -│ text selected│ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── - + text selected diff --git a/crates/plexmaton-tui/frames/text-selection-wide.txt b/crates/plexmaton-tui/frames/text-selection-wide.txt index de2744de..d891c6f4 100644 --- a/crates/plexmaton-tui/frames/text-selection-wide.txt +++ b/crates/plexmaton-tui/frames/text-selection-wide.txt @@ -7,6 +7,7 @@ │ │ │ │ │ │ +│ │ │ › Review the Markdown update. │ │ │ │Summary │ @@ -17,8 +18,7 @@ ││ println!("hello, 世界"); │ │└ │ │ │ -│ text selected│ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - + text selected diff --git a/crates/plexmaton-tui/frames/tool-open-medium.txt b/crates/plexmaton-tui/frames/tool-open-medium.txt index 43b46e77..958c11da 100644 --- a/crates/plexmaton-tui/frames/tool-open-medium.txt +++ b/crates/plexmaton-tui/frames/tool-open-medium.txt @@ -20,7 +20,6 @@ │ │ │ │ │ │ -│ │ │[+] exec_command · succeeded │ │ invocation │ │ │ Command "cargo test -p plexmaton-tui" │ @@ -34,6 +33,7 @@ │ │ stderr: │ │ │ [empty] │ │󰝦 Thinking… │ +│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/tool-open-narrow.txt b/crates/plexmaton-tui/frames/tool-open-narrow.txt index ae4dc269..34298b2e 100644 --- a/crates/plexmaton-tui/frames/tool-open-narrow.txt +++ b/crates/plexmaton-tui/frames/tool-open-narrow.txt @@ -20,7 +20,6 @@ │ │ │ │ │ │ -│ │ │[+] exec_command · succeeded │ │ invocation │ │ │ Command "cargo test -p plexmaton-tui" │ @@ -34,6 +33,7 @@ │ │ stderr: │ │ │ [empty] │ │󰝦 Thinking… │ +│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/tool-open-wide.txt b/crates/plexmaton-tui/frames/tool-open-wide.txt index 0e740930..753d15c1 100644 --- a/crates/plexmaton-tui/frames/tool-open-wide.txt +++ b/crates/plexmaton-tui/frames/tool-open-wide.txt @@ -20,7 +20,6 @@ │ │ │ │ │ │ -│ │ │[+] exec_command · succeeded │ │ invocation │ │ │ Command "cargo test -p plexmaton-tui" │ @@ -34,6 +33,7 @@ │ │ stderr: │ │ │ [empty] │ │󰝦 Thinking… │ +│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/transcript-grammar-medium.txt b/crates/plexmaton-tui/frames/transcript-grammar-medium.txt index 605e0b31..8a3ec545 100644 --- a/crates/plexmaton-tui/frames/transcript-grammar-medium.txt +++ b/crates/plexmaton-tui/frames/transcript-grammar-medium.txt @@ -6,6 +6,7 @@ │ │ │ │ │ │ +│ │ │ › Change the colour constant and show me the patch you applied. │ │ │ │Done. The retained patch is below, with the reasoning that led to it. │ @@ -33,7 +34,6 @@ │ │ -pub const COLOR: &str = "blue"; │ │ │ +pub const COLOR: &str = "pastel"; │ │ │ *** End Patch │ -│ │ ── Message ──────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/transcript-grammar-narrow.txt b/crates/plexmaton-tui/frames/transcript-grammar-narrow.txt index 6e227739..5b498f15 100644 --- a/crates/plexmaton-tui/frames/transcript-grammar-narrow.txt +++ b/crates/plexmaton-tui/frames/transcript-grammar-narrow.txt @@ -4,6 +4,7 @@ │ │ │ │ │ │ +│ │ │ › Change the colour constant and show me the patch │ │ you applied. │ │ │ @@ -33,7 +34,6 @@ │ │ -pub const COLOR: &str = "blue"; │ │ │ +pub const COLOR: &str = "pastel"; │ │ │ *** End Patch │ -│ │ ── Message ───────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/transcript-grammar-wide.txt b/crates/plexmaton-tui/frames/transcript-grammar-wide.txt index c769b062..d9c64980 100644 --- a/crates/plexmaton-tui/frames/transcript-grammar-wide.txt +++ b/crates/plexmaton-tui/frames/transcript-grammar-wide.txt @@ -6,6 +6,7 @@ │ │ │ │ │ │ +│ │ │ › Change the colour constant and show me the patch you applied. │ │ │ │Done. The retained patch is below, with the reasoning that led to it. │ @@ -33,7 +34,6 @@ │ │ -pub const COLOR: &str = "blue"; │ │ │ +pub const COLOR: &str = "pastel"; │ │ │ *** End Patch │ -│ │ ── Message ───────────────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/src/frames.rs b/crates/plexmaton-tui/src/frames.rs index 6ba23e28..0479b259 100644 --- a/crates/plexmaton-tui/src/frames.rs +++ b/crates/plexmaton-tui/src/frames.rs @@ -153,7 +153,8 @@ mod tests { let conversation = surfaces .get(SurfaceId::Transcript) .unwrap_or_else(|| panic!("the conversation is registered at {width}x{height}")); - let top = conversation.bounds.bottom().saturating_sub(1); + // The activity line sits above its blank row, the conversation's last (ui-ux §input). + let top = conversation.bounds.bottom().saturating_sub(2); region_text( &buffer, Rect::new( @@ -193,8 +194,8 @@ mod tests { ); assert_eq!( rows.count(), - 3, - "{name}: the fixture is the activity line, then the composer's rules and body" + 4, + "{name}: the fixture is the activity line, its blank row, then the composer's rules and body" ); crate::test_support::assert_frame(&name, &drawn); @@ -1525,11 +1526,11 @@ mod tests { let conversation = surfaces .get(SurfaceId::Transcript) .unwrap_or_else(|| panic!("{width}x{height}: the conversation is registered")); - // The pill rides the activity line, the conversation's last row. + // The pill rides the activity line, above the conversation's last, blank row. let border = region_text( &buffer, Rect { - y: conversation.bounds.bottom().saturating_sub(1), + y: conversation.bounds.bottom().saturating_sub(2), height: 1, ..conversation.bounds }, @@ -1573,9 +1574,16 @@ mod tests { }, )); text.push('\n'); + // The primary's activity line keeps a blank row beneath it; a worker window's + // footer is its last row. + let footer = if surface.id == SurfaceId::Transcript { + 2 + } else { + 1 + }; Rect { - y: bounds.bottom().saturating_sub(1), - height: 1, + y: bounds.bottom().saturating_sub(footer), + height: footer, ..bounds } } @@ -1648,10 +1656,10 @@ mod tests { assert!( region_text( &buffer, - Rect::new(band.bounds.x, band.bounds.y - 1, band.bounds.width, 1), + Rect::new(band.bounds.x, band.bounds.y - 2, band.bounds.width, 1), ) .contains("Responding"), - "{name}: the activity row must meet the waiting rule without an extra blank row" + "{name}: the activity row keeps one blank row above the waiting rule" ); assert_eq!( band.bounds.bottom(), diff --git a/crates/plexmaton-tui/src/render/activity.rs b/crates/plexmaton-tui/src/render/activity.rs index 3e6f8c1f..8f7a2011 100644 --- a/crates/plexmaton-tui/src/render/activity.rs +++ b/crates/plexmaton-tui/src/render/activity.rs @@ -135,6 +135,37 @@ pub(crate) fn activity_line( Line::from(spans) } +/// The activity line as the conversation's footer: the line and a blank row beneath it, so it +/// never sits against the composer's rule, or no rows at all when it has nothing to say, so an idle +/// conversation keeps its last reply one blank row from the composer (ui-ux §input). +pub(crate) fn activity_footer( + state: &ViewState, + palette: &Palette, + width: u16, + approval_visible: bool, +) -> Option>> { + activity_shows(state, palette, approval_visible).then(|| { + vec![ + activity_line(state, palette, width, approval_visible), + Line::default(), + ] + }) +} + +/// Whether the activity line takes its rows: while there is work to name or a request waiting. +/// +/// Both come from the conversation, never from the pointer. A selection or a copy note that opened +/// the rows would lift the whole bottom-anchored conversation two rows under the reader's drag; +/// while the rows are closed those notes ride the status row instead. +pub(crate) fn activity_shows(state: &ViewState, palette: &Palette, approval_visible: bool) -> bool { + let work = match state.current_work() { + None => false, + Some(CurrentWork::ApprovalRequired) => !approval_visible, + Some(_) => true, + }; + work || attention_pill(state, palette).is_some() +} + /// The muted readings after the label, in display order and tagged with the slot that orders /// their dropping: elapsed (0), effort (1), quiet (2). Each is left out until it is known. fn activity_readings(state: &ViewState) -> Vec<(usize, String)> { @@ -159,6 +190,54 @@ fn activity_readings(state: &ViewState) -> Vec<(usize, String)> { #[cfg(test)] mod tests { + use plexmaton_core::{ + AgentId, AgentStatus, ConversationEvent, ConversationEventEnvelope, EventSequence, + }; + + use super::activity_footer; + use crate::{ViewState, theme::Palette}; + + fn apply(state: &mut ViewState, sequence: u64, event: ConversationEvent) { + let _ = state.apply(ConversationEventEnvelope { + sequence: EventSequence::new(sequence), + event, + }); + } + + /// ui-ux §input: an idle conversation with nothing waiting gives the activity line no rows; + /// work gives it its row and a blank one beneath, so it never sits against the composer. + #[test] + fn the_activity_line_takes_rows_only_while_there_is_work_or_a_request() { + let palette = Palette::default(); + let primary = AgentId::new("primary").expect("identity"); + let mut state = ViewState::default(); + apply( + &mut state, + 1, + ConversationEvent::AgentCreated { + agent_id: primary.clone(), + label: "Plexmaton".into(), + status: AgentStatus::Idle, + }, + ); + assert!(activity_footer(&state, &palette, 80, false).is_none()); + apply( + &mut state, + 2, + ConversationEvent::AgentStatusChanged { + agent_id: primary, + status: AgentStatus::Running, + }, + ); + let footer = activity_footer(&state, &palette, 80, false).expect("work opens the rows"); + assert_eq!(footer.len(), 2); + assert!(footer[0].to_string().contains("Thinking…")); + assert!( + footer[1].spans.is_empty(), + "the second row is the blank one" + ); + } + use unicode_width::UnicodeWidthStr; use super::{MARK, PHASES_PER_FRAME, mark}; diff --git a/crates/plexmaton-tui/src/render/chrome.rs b/crates/plexmaton-tui/src/render/chrome.rs index 99646f51..bb802fab 100644 --- a/crates/plexmaton-tui/src/render/chrome.rs +++ b/crates/plexmaton-tui/src/render/chrome.rs @@ -110,6 +110,13 @@ pub(super) fn attention_pill(state: &ViewState, palette: &Palette) -> Option Option { + selected_suffix(state, SurfaceId::Transcript) + .strip_prefix(" · ") + .map(str::to_owned) +} + pub(super) fn selected_suffix(state: &ViewState, surface: SurfaceId) -> String { if let Some(note) = state.copy_note(surface) { use crate::state::CopyNote; @@ -260,6 +267,7 @@ pub(super) fn render_status( state: &ViewState, palette: &Palette, area: Rect, + activity_shown: bool, ) { let status = state.status(); match status.footer() { @@ -299,7 +307,7 @@ pub(super) fn render_status( crate::state::Footer::Default => {} } if status.armed().is_none() && !matches!(status.footer(), crate::state::Footer::Default) { - render_copy_receipt(frame, state, palette, area); + render_copy_receipt(frame, state, palette, area, activity_shown); return; } // An armed question may need a second row; the cwd baseline never does. @@ -326,16 +334,32 @@ pub(super) fn render_status( }) .collect(); frame.render_widget(Paragraph::new(lines), area); - render_copy_receipt(frame, state, palette, area); + render_copy_receipt(frame, state, palette, area, activity_shown); } -fn render_copy_receipt(frame: &mut Frame<'_>, state: &ViewState, palette: &Palette, area: Rect) { +/// The status row's right end: a copy receipt, or, while the activity line is closed, what the +/// reader has selected, so selection feedback never has to open rows in the conversation. +fn render_copy_receipt( + frame: &mut Frame<'_>, + state: &ViewState, + palette: &Palette, + area: Rect, + activity_shown: bool, +) { if state.status().armed().is_some() || area.is_empty() { return; } + let note; let text = match state.status().copy_receipt() { Some(crate::CopyReceipt::Copied) => " ✓ Copied ", Some(crate::CopyReceipt::Sent) => " Copy sent ", + None if !activity_shown => match selection_note(state) { + Some(selected) => { + note = format!(" {selected} "); + note.as_str() + } + None => return, + }, None => return, }; let width = (Line::raw(text).width() as u16).min(area.width); diff --git a/crates/plexmaton-tui/src/render/command_inspection.rs b/crates/plexmaton-tui/src/render/command_inspection.rs index ffb53ec9..32640ab3 100644 --- a/crates/plexmaton-tui/src/render/command_inspection.rs +++ b/crates/plexmaton-tui/src/render/command_inspection.rs @@ -39,10 +39,10 @@ pub(super) fn panel(state: &ViewState, palette: &Palette, bounds: Rect) -> Panel edges: Edges::All, chrome: Chrome::Box, insets, - footer: Some(Line::styled( + footer: Some(vec![Line::styled( "c copy · ↑↓ scroll · Esc back", palette.style(Role::Muted), - )), + )]), } } diff --git a/crates/plexmaton-tui/src/render/conversation_tree.rs b/crates/plexmaton-tui/src/render/conversation_tree.rs index 3a37ec8c..381caeea 100644 --- a/crates/plexmaton-tui/src/render/conversation_tree.rs +++ b/crates/plexmaton-tui/src/render/conversation_tree.rs @@ -201,7 +201,7 @@ pub(super) fn panel(state: &ViewState, palette: &Palette, bounds: Rect) -> Panel Panel { insets: crate::surface::ContentInsets::default(), chrome: Chrome::Box, - footer: Some(footer), + footer: Some(vec![footer]), body: Body::Window { lines, // The semantic row slice is already resolved above; only the viewport metadata uses diff --git a/crates/plexmaton-tui/src/render/mod.rs b/crates/plexmaton-tui/src/render/mod.rs index 6c302862..346daff4 100644 --- a/crates/plexmaton-tui/src/render/mod.rs +++ b/crates/plexmaton-tui/src/render/mod.rs @@ -101,39 +101,45 @@ pub fn render( // background rather than as a place, and its hue has no edge to say whose it is. Its // last row stays the activity line, carrying the selection note and the attention pill // as the box's footer rather than instead of a border (ui-ux §input). - SurfaceId::Transcript => Some(Panel { - insets: crate::surface::ContentInsets::default(), - chrome: Chrome::Box, - footer: Some(activity::activity_line( + SurfaceId::Transcript => { + let footer = activity::activity_footer( state, palette, inner_width(bounds.width), state.approval_in_primary() && surfaces.get(SurfaceId::Approval).is_some(), - )), - body: conversation_body( - state, - palette, - metrics, - bounds, - id, - stacking.over_composer(SurfaceId::Transcript), - 1, - ), - title: chrome::conversation_title(state, palette, inner_width(bounds.width)), - badge: None, - edges: stacking.over_composer(SurfaceId::Transcript), - }), + ); + let footer_rows = footer + .as_ref() + .map_or(0, |lines| u16::try_from(lines.len()).unwrap_or(u16::MAX)); + Some(Panel { + insets: crate::surface::ContentInsets::default(), + chrome: Chrome::Box, + footer, + body: conversation_body( + state, + palette, + metrics, + bounds, + id, + stacking.over_composer(SurfaceId::Transcript), + footer_rows, + ), + title: chrome::conversation_title(state, palette, inner_width(bounds.width)), + badge: None, + edges: stacking.over_composer(SurfaceId::Transcript), + }) + } // The inspected agent's conversation uses the same unified entry grammar as the // primary: the workspace shows one conversation, and the journey needs it to show two. SurfaceId::Inspector => Some(Panel { insets: crate::surface::ContentInsets::default(), chrome: Chrome::Box, - footer: Some(child_control::footer( + footer: Some(vec![child_control::footer( state, palette, inner_width(bounds.width), has_focus, - )), + )]), body: conversation_body( state, palette, @@ -172,7 +178,12 @@ pub fn render( Some(composer_panel(state, palette, has_focus, bounds, &stacking)) } SurfaceId::Status => { - render_status(frame, state, palette, bounds); + let activity_shown = activity::activity_shows( + state, + palette, + state.approval_in_primary() && surfaces.get(SurfaceId::Approval).is_some(), + ); + render_status(frame, state, palette, bounds, activity_shown); None } }; @@ -601,12 +612,13 @@ mod tests { viewport.is_scrollable(), "the fixture has to overflow at {width}x{height} or this proves nothing" ); - // The conversation's last row is its activity line, not content (ui-ux §input); - // the reference paints only the rows the conversation itself occupies. + // The conversation's last two rows are its activity line and the blank row under + // it, not content (ui-ux §input); the reference paints only the conversation's rows. let painted = session.region(SurfaceId::Transcript); - let (conversation, _activity) = painted - .rsplit_once('\n') - .unwrap_or_else(|| panic!("the region has an activity row under it")); + let rows: Vec<&str> = painted.lines().collect(); + assert!(rows.len() > 2, "the region has an activity footer under it"); + let conversation = rows[..rows.len() - 2].join("\n"); + let conversation = conversation.as_str(); let reference = whole_conversation( &session.conversation.state, &Palette::default(), @@ -647,9 +659,9 @@ mod tests { ); // The conversation is boxed and open at the bottom, so its own border spends the top row // and the side columns and the content needs no padding of its own; the activity row is - // the panel's footer and stays out of the reference. + // the panel's footer, with the blank row beneath it, and stays out of the reference. let bounds = Rect { - height: bounds.height.saturating_sub(1), + height: bounds.height.saturating_sub(2), ..bounds }; let paragraph = Paragraph::new(lines) @@ -1454,9 +1466,9 @@ mod tests { .get(SurfaceId::Transcript) .expect("the conversation is registered at wide") .bounds; - // The pill rides the activity line, the conversation's last row (ui-ux §input). + // The pill rides the activity line, above the conversation's last, blank row (ui-ux §input). let border = Rect { - y: conversation.bottom().saturating_sub(1), + y: conversation.bottom().saturating_sub(2), height: 1, ..conversation }; diff --git a/crates/plexmaton-tui/src/render/panel.rs b/crates/plexmaton-tui/src/render/panel.rs index 09e28834..a12cb8be 100644 --- a/crates/plexmaton-tui/src/render/panel.rs +++ b/crates/plexmaton-tui/src/render/panel.rs @@ -35,7 +35,8 @@ pub(super) struct Panel { pub(super) chrome: Chrome, pub(super) insets: ContentInsets, /// One row of the region's own, painted last inside it: the conversation's activity line. - pub(super) footer: Option>, + /// The region's own last rows, top to bottom: they neither scroll nor count as content. + pub(super) footer: Option>>, } /// How a region's edges are painted. The geometry is the edges'; this is only ink. @@ -216,11 +217,14 @@ pub(super) fn draw_panel( let mut inside = chrome.inner(area); // The footer is the region's own last row, not content: it neither scrolls nor counts. let footer = match &panel.footer { - Some(line) if inside.height > 0 => { - inside.height = inside.height.saturating_sub(1); + Some(lines) if inside.height > 0 => { + let rows = u16::try_from(lines.len()) + .unwrap_or(u16::MAX) + .min(inside.height); + inside.height = inside.height.saturating_sub(rows); Some(( - Rect::new(inside.x, inside.bottom(), inside.width, 1), - line.clone(), + Rect::new(inside.x, inside.bottom(), inside.width, rows), + lines.clone(), )) } _ => None, @@ -256,8 +260,8 @@ pub(super) fn draw_panel( }; frame.render_widget(paragraph.scroll((scroll, 0)), inside); - if let Some((row, line)) = footer { - frame.render_widget(Paragraph::new(line), row); + if let Some((rows, lines)) = footer { + frame.render_widget(Paragraph::new(lines), rows); } viewport } diff --git a/crates/plexmaton-tui/src/workspace.rs b/crates/plexmaton-tui/src/workspace.rs index 5f5ff567..f5634617 100644 --- a/crates/plexmaton-tui/src/workspace.rs +++ b/crates/plexmaton-tui/src/workspace.rs @@ -1388,7 +1388,8 @@ mod tests { let activity = |terminal: &Terminal, workspace: &Workspace| { painted(terminal, workspace, SurfaceId::Transcript) .lines() - .last() + .rev() + .nth(1) .unwrap_or_default() .to_owned() }; diff --git a/crates/plexmaton-tui/src/workspace/composer_menu_tests.rs b/crates/plexmaton-tui/src/workspace/composer_menu_tests.rs index 99420e63..e9205f2d 100644 --- a/crates/plexmaton-tui/src/workspace/composer_menu_tests.rs +++ b/crates/plexmaton-tui/src/workspace/composer_menu_tests.rs @@ -480,7 +480,8 @@ fn a_requested_compaction_shows_on_the_activity_line_and_ends_with_a_note() { assert!( drawn .lines() - .last() + .rev() + .nth(1) .is_some_and(|row| row.contains(" Compacting…")), "{drawn}" ); From 2c3cb44b3bde730ad2e9bbd577d5cd185423b266 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:19:58 -0400 Subject: [PATCH 3/3] fix(tui): count the activity line's elapsed across the turn, and hear only the primary for quiet Review of the activity line found the count followed whether the label named any work: the gap between steps, while queued tools wait to start, restarted it, and an approval kept it running unseen, so the next tool read the user's decision time as its own run. The count now belongs to the primary's turn: it opens and ends with the turn, carries through the gaps between steps, and stops while an approval or a question holds the turn on the user, resuming where it stopped. The user chose this over a count per step, which never shows the turn's total, and over one that runs through the user's waits. Quiet was restarted by any event in the session, so a child streaming during Thinking kept the primary's line from ever saying it had been quiet. It now hears only the primary's own events, and counts from the answer after a wait on the user. The idle assertion read a row that stopped being the activity line once the idle rows closed, so it looked at the whole conversation instead. COM-5, SEL-2, SEL-5 and the renderer's comments described the row before its rows could close; they are rewritten, and a doc comment the first commit detached from its function is back on it. --- .agents/evidence/composer.md | 2 +- .../phase-04-stage-34-conversation-chrome.md | 26 +- .agents/specs/composer.md | 19 +- .agents/specs/selection-and-copy.md | 8 +- .agents/ui-ux.md | 11 +- crates/plexmaton-core/src/lib.rs | 24 ++ crates/plexmaton-tui/src/render/activity.rs | 4 +- crates/plexmaton-tui/src/render/chrome.rs | 12 +- crates/plexmaton-tui/src/render/mod.rs | 7 +- crates/plexmaton-tui/src/state/activity.rs | 267 +++++++++++++++--- crates/plexmaton-tui/src/workspace.rs | 5 +- .../plexmaton-tui/src/workspace/producer.rs | 45 ++- 12 files changed, 343 insertions(+), 87 deletions(-) diff --git a/.agents/evidence/composer.md b/.agents/evidence/composer.md index 310f32b9..2091e7d9 100644 --- a/.agents/evidence/composer.md +++ b/.agents/evidence/composer.md @@ -11,5 +11,5 @@ resolves every one against the source, so a renamed or deleted test fails the bu | COM-2 | `backspace_removes_a_whole_grapheme_cluster`, `deleting_an_empty_draft_changes_nothing`, `editing_happens_at_the_caret_rather_than_at_the_end`, `motion_steps_over_whole_clusters_and_stops_at_the_ends`, `word_deletion_takes_the_trailing_space_and_the_word`, `killing_binds_to_the_logical_line_the_caret_is_on`, `the_visible_window_follows_the_caret_above_the_tail`, `a_draft_grows_to_a_third_of_the_column_then_its_window_follows_the_caret` with the `composer-grown-*` and `composer-windowed-*` frames, `the_wheel_over_the_composer_walks_the_draft_one_row_per_notch` | | COM-3 | `a_refused_dispatch_returns_the_draft_and_opens_one_notice`, `a_refused_dispatch_is_one_bounded_notice_and_not_an_exit`, `ctrl_j_breaks_the_conversation_draft_but_not_the_drawer_filter`, `enter_submits_and_modified_enter_or_ctrl_j_breaks_the_line`, `a_blank_draft_submits_nothing_and_is_left_alone`, `taking_the_draft_returns_it_exactly_and_clears_it`, `returned_text_lands_after_the_existing_draft`, `a_typed_message_reaches_the_transcript_by_way_of_the_runtime`, `a_submitted_message_is_a_finished_user_item`, `a_live_dispatch_restores_undelivered_user_text`, `persistence_failure_restores_the_draft_and_opens_one_notice` | | COM-4 | `the_composer_names_the_model_while_another_agent_is_selected`, `the_user_controlled_child_input_submits_a_message_when_finished`, `ccv_2_user_child_input_uses_its_lifecycle_without_primary_commands`, `production_mapping_preserves_message_steering_interrupt_and_approval`, `production_handoff_routes_child_input_and_retains_the_locked_draft`; `scripts/smoke-delegate.py`; CCV-2/CCV-4 | -| COM-5 | `current_work_priority_is_derived_from_semantic_facts`, `parallel_running_tools_use_stable_first_appearance_order`, `the_activity_line_names_each_current_work_state_and_the_rule_carries_none`, `current_work_does_not_move_input_and_repeated_facts_cost_no_frame`, `the_current_work_frames_match_their_fixtures` with the `current-work-*` frames | +| COM-5 | `current_work_priority_is_derived_from_semantic_facts`, `parallel_running_tools_use_stable_first_appearance_order`, `the_activity_line_names_each_current_work_state_and_the_rule_carries_none`, `current_work_does_not_move_input_and_repeated_facts_cost_no_frame`, `the_activity_line_takes_rows_only_while_there_is_work_or_a_request`, `the_activity_line_reads_elapsed_effort_and_quiet_and_drops_them_in_order`, `the_turn_is_counted_from_its_start_and_forgotten_when_it_ends`, `the_count_carries_through_the_gap_between_steps`, `the_count_stops_while_the_turn_waits_on_the_user`, `a_child_streaming_does_not_break_the_primary_quiet`, `the_current_work_frames_match_their_fixtures` with the `current-work-*` frames | | COM-6 | `pointer_clicks_place_the_caret_in_each_input`, `edits_that_join_clusters_restore_the_grapheme_boundary`, `a_full_input_row_never_places_the_caret_on_the_border`, `terminal_paste_edits_the_focused_input_without_submitting`; `scripts/smoke-tui.py` drives Chinese paste, pointer insertion, selection replacement and source copying | diff --git a/.agents/plans/phase-04-stage-34-conversation-chrome.md b/.agents/plans/phase-04-stage-34-conversation-chrome.md index fd400ba5..d614b317 100644 --- a/.agents/plans/phase-04-stage-34-conversation-chrome.md +++ b/.agents/plans/phase-04-stage-34-conversation-chrome.md @@ -10,7 +10,7 @@ ## Outcome The user's turn is told by its surface rather than by a shout, and a working agent is told by a -row that moves and says only what is known: how long the step has run, at what effort, and how long +row that moves and says only what is known: how long the turn has run, at what effort, and how long the route has been quiet. Both are presentation. Removing either changes no journal entry, no request and no replay. @@ -39,17 +39,19 @@ MOT-1 to MOT-3 live in [motion](../specs/motion.md), promoted when the motion ow behaviour. Closed by the effort tests passing unchanged and `mot_1_one_deadline_serves_every_mover_and_late_wakes_coalesce`. 4. **Activity line — implemented.** The row opens with the mark, then the label, then muted - readings: elapsed since the current work began, the effort, and after five silent seconds `quiet - for`. Elapsed reads `11s`, `2m 11s`, `1h 2m`; a short row drops effort, then quiet, then elapsed. - The mark is the Nerd Font's circle, rounded square and square, outline then filled, eight frames - at 200 ms, which the user chose in their own terminal over the Unicode cycles that shook. It - wears blue, the server-tool colour while a hosted search runs, and stands still in the - action-required colour during approval. The readings are presentation: events are dated as they - are applied and the motion clock supplies the instant a frame is drawn for. After the user's - first look in kitty the row gained a blank row beneath it and gives up its rows when idle with - nothing waiting; a selection note never opens them, and rides the status row while they are - closed. Closed by refreshed current-work frames, the readings, work-clock and footer-rows tests, - the selection drags that no longer move, MOT-2 for the mark, and the activity smokes. + readings: elapsed across the turn, stopped while an approval or a question waits on the user, the + effort, and after five seconds with nothing from the primary's own route `quiet for`, which a + child's stream does not interrupt. Elapsed reads `11s`, `2m 11s`, `1h 2m`; a short row drops + effort, then quiet, then elapsed. The mark is the Nerd Font's circle, rounded square and square, + outline then filled, eight frames at 200 ms, which the user chose in their own terminal over the + Unicode cycles that shook. It wears blue, the server-tool colour while a hosted search runs, and + stands still in the action-required colour during approval. The readings are presentation: events + are dated as they are applied and the motion clock supplies the instant a frame is drawn for. + After the user's first look in kitty the row gained a blank row beneath it and gives up its rows + when idle with nothing waiting; a selection note never opens them, and rides the status row while + they are closed. Closed by refreshed current-work frames, the readings, turn-count, child-quiet + and footer-rows tests, the selection drags that no longer move, MOT-2 for the mark, and the + activity smokes. 5. **Documents.** Evidence tables current; SVG review of the frames attached; this plan deleted; the phase row says done. diff --git a/.agents/specs/composer.md b/.agents/specs/composer.md index 05ec3297..4d65a392 100644 --- a/.agents/specs/composer.md +++ b/.agents/specs/composer.md @@ -47,12 +47,17 @@ production snapshot delivery and focused child routing are accepted by `scripts/ The product retains an addressed child submission before cold activation or journal work and returns any later refusal to that same worker composer. -**COM-5 — Current work is derived and static.** The conversation's activity line, its last row -above the composer's top rule, shows at most one label: `Approval required` outranks -`Running `, then `Responding`, then `Thinking`; idle shows none. The label is derived from -the semantic projection and owns no timer; the composer's rules never carry it. The same row's -right end holds the selection note (SEL-5) and the attention pill (ATT-1), which no longer have a -border to ride (ui-ux §input). +**COM-5 — Current work is derived; its readings are presentation.** The conversation's activity +line, over one blank row above the composer's top rule, shows at most one label: `Approval +required` outranks `Running `, then `Responding`, then `Thinking`; idle with nothing waiting +takes no rows. The label is derived from the semantic projection; the composer's rules never carry +it. The readings are dated as the workspace applies events and drawn for the motion clock's +instant, so they never move the semantic revision. Elapsed counts the primary's turn, from when it +opens until it ends: it carries through the gaps between steps and stops while an approval or a +question holds the turn on the user. Quiet hears only the primary's own events, never a child's, +and says nothing while the turn waits on the user. The row's right end holds the selection note +(SEL-2) and the attention pill (ATT-1); while its rows are closed the selection note rides the +status row instead (ui-ux §input). **COM-6 — Input selection names editable source.** A click places the caret; dragging retains a grapheme-boundary anchor, paints the source range, and release copies it through SEL-4. Typing or @@ -91,7 +96,7 @@ Ctrl-C ──▶ non-empty draft ──▶ clear | --- | --- | | Place | Directly under the primary conversation, between two rules (ui-ux §input): the top rule carries the title and the resolved model's reasoning effort, then the lines, then the bottom rule. The columns a box's sides would spend stay blank, so the caret and the pointer keep a box's geometry | | Height | One row per wrapped line, at the width of the conversation column it actually occupies, up to a third of the terminal's height and never fewer than three; a taller draft shows the window containing the caret, which is its newest lines until `↑`/`↓` or the wheel over the composer walk the caret out of them | -| Current work | One semantic suffix in the existing divider; action required uses its role and other work is ambient | +| Current work | Never on the composer's rules: the conversation's activity line names it (COM-5) | | On a short terminal | Served before the notice strip and the agent list: a workspace that cannot be typed into is not a supported shape | | While a sub-agent's input holds the cursor | One row over the bottom rule, `Message Agent A · ⇥ to return`: no top rule, no title, still a focus stop and a pointer target. `Tab` from that input lands on it, because the composer follows the second window in the focus ring | diff --git a/.agents/specs/selection-and-copy.md b/.agents/specs/selection-and-copy.md index 71a1caa3..077783f2 100644 --- a/.agents/specs/selection-and-copy.md +++ b/.agents/specs/selection-and-copy.md @@ -42,7 +42,9 @@ capability/failure labels are not copied. Clipping and source fallback preserve Maps and prepared rows share MD-4's bounded cache; cache hits borrow mapping data, and highlighting copies only the rows it paints. Copying reads only the selected entries, not the whole history; PRE-4 prepares missing maps through the owned worker rather than reparsing on release. Pending, -changed-source and failure feedback appears on the selected conversation's activity line, not as a delivery acknowledgement. +changed-source and failure feedback appears on the selected conversation's activity line, or, for +the primary while that line's rows are closed, at the status row's right end; never as a delivery +acknowledgement. **SEL-3 — One selection, in one surface, for one agent.** A selection carries the surface and the agent it indexes. Extending in a different surface replaces it, and a selection whose surface stops @@ -62,7 +64,9 @@ successful terminal write has no acknowledgement; helper success proves only tha accepted the request. Native helper failure is returned without a silent route change. The CLI publishes `✓ Copied` only for native acceptance, or `Copy sent` for terminal/tmux send. A two-second receipt overlays the bottom-right status cells without layout or focus changes; quit has priority without extending -its window. New admitted copies withdraw old receipts; cancellation, replacement and delivery +its window. While the primary's activity line is closed those cells otherwise carry its selection +note (SEL-2), which a receipt replaces for its two seconds. New admitted copies withdraw old +receipts; cancellation, replacement and delivery failure produce none. The two tmux legs remain independent: a successful terminal send can report `Copy sent` even when the helper rejects; this does not claim clipboard acceptance. All helper operations bound both stdin writes and exit waits to 500 ms, kill and reap on failure, timeout or diff --git a/.agents/ui-ux.md b/.agents/ui-ux.md index e9182184..775b9431 100644 --- a/.agents/ui-ux.md +++ b/.agents/ui-ux.md @@ -159,9 +159,10 @@ other rule about input follows from this one. waiting, it takes no rows, so the last reply sits one blank row from the composer; the rows open and close only with the conversation's own facts, never with the pointer, and while they are closed the selection note rides the status row's right end. While the agent works the row opens - with the mark's core moving, then the label, then in muted text how long the current work has run - and at what effort, and after five seconds with nothing arriving, how long it has been quiet; a - short row drops effort, then quiet, then elapsed. The mark cycles the Nerd Font's circle, rounded + with the mark's core moving, then the label, then in muted text how long the turn has run, not + counting its waits on the user, and at what effort, and after five seconds with nothing arriving + from the agent's own route, how long it has been quiet; a short row drops effort, then quiet, then + elapsed. The mark cycles the Nerd Font's circle, rounded square and square, outline then filled, and wears blue, the server-tool colour while a provider-run search is the work, and stands still in the action-required colour while approval is required. Motion runs on the one visible-only clock the effort rail shares @@ -169,7 +170,9 @@ other rule about input follows from this one. visible primary card; otherwise in the activity line. Rejected: duplicate labels on adjacent lines; a row kept blank while idle, which left a hole under the last reply; opening the rows for a selection note, which lifted the conversation under the reader's drag; a still row, which during a - thirty-second silent turn read as a hang; a token count estimated from streamed characters, + thirty-second silent turn read as a hang; a count that restarts with each step, which never + shows the turn's total; a count that runs through an approval, which reports the user's decision + as the next tool's run; a token count estimated from streamed characters, because the row claims only what the route reported; turning the row red when the route is quiet, because quiet is not failure. - **An empty conversation shows the mark.** Before the first message the transcript holds the mark diff --git a/crates/plexmaton-core/src/lib.rs b/crates/plexmaton-core/src/lib.rs index 009975c2..9a5b06ad 100644 --- a/crates/plexmaton-core/src/lib.rs +++ b/crates/plexmaton-core/src/lib.rs @@ -47,6 +47,30 @@ pub use transcript::{ pub use usage::{TokenCounts, TokenUsage}; impl ConversationEvent { + /// The conversation whose projection holds this fact. + #[must_use] + pub fn agent(&self) -> &AgentId { + match self { + Self::AgentCreated { agent_id, .. } + | Self::AgentStatusChanged { agent_id, .. } + | Self::TurnUsageUpdated { agent_id, .. } + | Self::TranscriptItemStarted { agent_id, .. } + | Self::TranscriptDelta { agent_id, .. } + | Self::TranscriptItemFinalized { agent_id, .. } + | Self::ToolCallChanged { agent_id, .. } + | Self::ServerToolStarted { agent_id, .. } + | Self::ServerToolCalled { agent_id, .. } + | Self::AttentionRequested { agent_id, .. } + | Self::AttentionResolved { agent_id, .. } + | Self::TaskAssigned { agent_id, .. } + | Self::MailDelivered { agent_id, .. } + | Self::HandoffCompleted { agent_id, .. } + | Self::ArtifactAnnounced { agent_id, .. } + | Self::RuntimeWarning { agent_id, .. } + | Self::RuntimeError { agent_id, .. } => agent_id, + } + } + /// The conversation whose projection holds this fact, so a reader can re-address it. /// /// A delegated child numbers its own conversation and names itself by its own agent identity. diff --git a/crates/plexmaton-tui/src/render/activity.rs b/crates/plexmaton-tui/src/render/activity.rs index 8f7a2011..5f8970de 100644 --- a/crates/plexmaton-tui/src/render/activity.rs +++ b/crates/plexmaton-tui/src/render/activity.rs @@ -36,8 +36,8 @@ pub(crate) fn mark(phase: u16) -> &'static str { MARK[usize::from(phase / PHASES_PER_FRAME) % MARK.len()] } -/// The conversation's last row: what the agent is doing on the left; what the reader has -/// selected and what is still waiting on them on the right (ui-ux §input, COM-5, SEL-5, ATT-1). +/// The activity line: what the agent is doing on the left; what the reader has selected and what +/// is still waiting on them on the right (ui-ux §input, COM-5, SEL-2, ATT-1). /// /// Derived from the same facts the composer's divider used to carry, and drawn where the /// conversation ends rather than where the user types, so the two never read as one thing. diff --git a/crates/plexmaton-tui/src/render/chrome.rs b/crates/plexmaton-tui/src/render/chrome.rs index bb802fab..1bc93312 100644 --- a/crates/plexmaton-tui/src/render/chrome.rs +++ b/crates/plexmaton-tui/src/render/chrome.rs @@ -107,9 +107,6 @@ pub(super) fn attention_pill(state: &ViewState, palette: &Palette) -> Option Option { selected_suffix(state, SurfaceId::Transcript) @@ -117,6 +114,9 @@ pub(super) fn selection_note(state: &ViewState) -> Option { .map(str::to_owned) } +/// What a surface says about the selection it is holding. +/// +/// The retained selection is separate from transient transport feedback in the status row (SEL-5). pub(super) fn selected_suffix(state: &ViewState, surface: SurfaceId) -> String { if let Some(note) = state.copy_note(surface) { use crate::state::CopyNote; @@ -145,9 +145,9 @@ pub(super) fn selected_suffix(state: &ViewState, surface: SurfaceId) -> String { /// /// The composer's rule below says whom the next message addresses; this says what is being read. /// The two are the same name until a child's window is open, which is exactly when telling them -/// apart matters. The lifecycle and the selection note ride -/// the activity line at the other end of the same box, and saying them twice in one frame would -/// make the box noisier than the bare conversation it replaced. +/// apart matters. The lifecycle and the selection note ride the activity line at the other end of +/// the same box, or the status row while that line is closed, and saying them twice in one frame +/// would make the box noisier than the bare conversation it replaced. pub(super) fn conversation_title( state: &ViewState, palette: &Palette, diff --git a/crates/plexmaton-tui/src/render/mod.rs b/crates/plexmaton-tui/src/render/mod.rs index 346daff4..cb394854 100644 --- a/crates/plexmaton-tui/src/render/mod.rs +++ b/crates/plexmaton-tui/src/render/mod.rs @@ -98,9 +98,10 @@ pub fn render( SurfaceId::Agents => Some(agents_panel(state, palette, bounds)), // The primary conversation carries its own box, the same as the roster's and the // inspected child's: with two conversations on one screen, a bare one reads as - // background rather than as a place, and its hue has no edge to say whose it is. Its - // last row stays the activity line, carrying the selection note and the attention pill - // as the box's footer rather than instead of a border (ui-ux §input). + // background rather than as a place, and its hue has no edge to say whose it is. While + // there is work or a request it ends in the activity line over a blank row, carrying the + // selection note and the attention pill as the box's footer rather than instead of a + // border; idle, it ends in its last reply (ui-ux §input). SurfaceId::Transcript => { let footer = activity::activity_footer( state, diff --git a/crates/plexmaton-tui/src/state/activity.rs b/crates/plexmaton-tui/src/state/activity.rs index 49cca065..890f27fd 100644 --- a/crates/plexmaton-tui/src/state/activity.rs +++ b/crates/plexmaton-tui/src/state/activity.rs @@ -1,57 +1,95 @@ -//! When the primary agent's current work began and when its producer was last heard, for the -//! activity line's elapsed and quiet readings (ui-ux §input). +//! How long the primary agent's turn has run and when its route was last heard, for the activity +//! line's elapsed and quiet readings (ui-ux §input, COM-5). //! //! Presentation only: nothing here moves the semantic revision, and a replayed journal reproduces //! the same rows because the rows never read these instants — only the frame drawn now does. use std::time::{Duration, Instant}; +use plexmaton_core::AgentStatus; + use super::{CurrentWork, ViewState}; /// Silence shorter than this is ordinary streaming cadence and says nothing. pub(crate) const QUIET_AFTER: Duration = Duration::from_secs(5); +/// The open turn's count, or nothing between turns. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct ActivityClock { - since: Option, - heard: Option, - now: Option, + turn: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct TurnClock { + /// Time counted before the latest resume. + counted: Duration, + /// When counting last resumed; `None` while the turn waits on the user. + resumed: Option, + /// When the primary's route was last heard, or counting last resumed. + heard: Instant, + /// The instant the next frame is drawn for. + now: Instant, } impl ViewState { - /// Note a batch of producer events applied at `now`: work that has just begun starts its - /// count, work that has ended forgets it, and any event is the producer being heard. + /// Note a batch of producer events applied at `now`. The count opens with the turn and survives + /// the gaps between its steps, stops while the turn waits on the user, and is forgotten when the + /// turn ends. `heard` says whether the batch carried anything from the primary's own route. pub(crate) fn observe_activity(&mut self, now: Instant, heard: bool) { - let working = self.current_work().is_some(); - let clock = &mut self.activity; - if !working { - *clock = ActivityClock::default(); - return; + let waiting = match self.turn_phase() { + TurnPhase::Closed => { + self.activity = ActivityClock::default(); + return; + } + TurnPhase::Working => false, + TurnPhase::WaitingOnUser => true, + }; + let turn = self.activity.turn.get_or_insert(TurnClock { + counted: Duration::ZERO, + resumed: Some(now), + heard: now, + now, + }); + match (waiting, turn.resumed) { + (true, Some(resumed)) => { + turn.counted += now.saturating_duration_since(resumed); + turn.resumed = None; + } + // The user's wait is not the route's silence either. + (false, None) => { + turn.resumed = Some(now); + turn.heard = now; + } + _ => {} } - clock.since.get_or_insert(now); - if heard || clock.heard.is_none() { - clock.heard = Some(now); + if heard { + turn.heard = now; } - clock.now = Some(now); + turn.now = now; } /// The motion clock's wake: the instant the next frame is drawn for. pub(crate) fn tick_activity(&mut self, now: Instant) { - if self.activity.since.is_some() { - self.activity.now = Some(now); + if let Some(turn) = &mut self.activity.turn { + turn.now = now; } } - /// How long the current work has run, once its beginning was seen. + /// How long the turn has run, not counting its waits on the user. pub(crate) fn activity_elapsed(&self) -> Option { - let clock = &self.activity; - Some(clock.now?.saturating_duration_since(clock.since?)) + let turn = self.activity.turn.as_ref()?; + let running = turn.resumed.map_or(Duration::ZERO, |resumed| { + turn.now.saturating_duration_since(resumed) + }); + Some(turn.counted + running) } - /// How long the producer has said nothing, once that is long enough to be worth saying. + /// How long the primary's route has said nothing, once that is long enough to be worth saying. + /// A turn waiting on the user is not quiet: it is waiting. pub(crate) fn activity_quiet(&self) -> Option { - let clock = &self.activity; - let quiet = clock.now?.saturating_duration_since(clock.heard?); + let turn = self.activity.turn.as_ref()?; + turn.resumed?; + let quiet = turn.now.saturating_duration_since(turn.heard); (quiet >= QUIET_AFTER).then_some(quiet) } @@ -60,6 +98,36 @@ impl ViewState { self.current_work() .is_some_and(|work| work != CurrentWork::ApprovalRequired) } + + /// A turn is open while the primary runs or waits on its own work, and while it compacts; the + /// label can go blank between steps, while queued tools wait to start, without ending it. An + /// open turn is held while an approval or a question only the user can answer is outstanding. + fn turn_phase(&self) -> TurnPhase { + let Some(primary) = self.agents.primary() else { + return TurnPhase::Closed; + }; + let work = self.current_work(); + let open = + matches!(primary.status, AgentStatus::Running | AgentStatus::Waiting) || work.is_some(); + if !open { + TurnPhase::Closed + } else if work == Some(CurrentWork::ApprovalRequired) + || self + .attention + .iter() + .any(|request| request.agent_id == primary.id) + { + TurnPhase::WaitingOnUser + } else { + TurnPhase::Working + } + } +} + +enum TurnPhase { + Closed, + Working, + WaitingOnUser, } /// `11s`, `2m 11s`, `1h 2m`: whole units, the largest two. @@ -77,42 +145,147 @@ mod tests { use std::time::{Duration, Instant}; use super::elapsed_label; + use crate::test_support::Conversation; use plexmaton_core::{ - AgentId, AgentStatus, ConversationEvent, ConversationEventEnvelope, EventSequence, + AgentId, AgentStatus, ApprovalId, ApprovalReason, AttentionId, AttentionRequest, + ConversationEvent, ToolCallId, ToolCapability, }; - /// ui-ux §input: the count starts when work is first seen, holds while it runs, and is - /// forgotten when it ends; quiet appears only after five silent seconds. + fn primary() -> AgentId { + AgentId::new("agent-a").expect("the canonical primary") + } + + fn at(start: Instant, seconds: u64) -> Instant { + start + Duration::from_secs(seconds) + } + + /// COM-5: the count starts with the turn and quiet appears only after five silent seconds; + /// the turn's end forgets both. #[test] - fn work_is_counted_from_its_start_and_forgotten_when_it_ends() { - let mut state = crate::test_support::canonical_state(); + fn the_turn_is_counted_from_its_start_and_forgotten_when_it_ends() { + let mut conversation = Conversation::canonical(); let start = Instant::now(); - state.observe_activity(start, true); - state.observe_activity(start + Duration::from_secs(3), false); - state.tick_activity(start + Duration::from_secs(4)); - assert_eq!(state.activity_elapsed(), Some(Duration::from_secs(4))); + conversation.state.observe_activity(start, true); + conversation.state.observe_activity(at(start, 3), false); + conversation.state.tick_activity(at(start, 4)); assert_eq!( - state.activity_quiet(), + conversation.state.activity_elapsed(), + Some(Duration::from_secs(4)) + ); + assert_eq!( + conversation.state.activity_quiet(), None, "four silent seconds say nothing" ); - state.tick_activity(start + Duration::from_secs(9)); - assert_eq!(state.activity_quiet(), Some(Duration::from_secs(9))); - - let _ = state.apply(ConversationEventEnvelope { - sequence: EventSequence::new(u64::MAX / 2), - event: ConversationEvent::AgentStatusChanged { - agent_id: AgentId::new("agent-a").expect("primary"), - status: AgentStatus::Idle, - }, + conversation.state.tick_activity(at(start, 9)); + assert_eq!( + conversation.state.activity_quiet(), + Some(Duration::from_secs(9)) + ); + + conversation.emit(ConversationEvent::AgentStatusChanged { + agent_id: primary(), + status: AgentStatus::Idle, }); - state.observe_activity(start + Duration::from_secs(10), true); + conversation.state.observe_activity(at(start, 10), true); assert_eq!( - state.activity_elapsed(), + conversation.state.activity_elapsed(), None, - "ended work forgets its count" + "the ended turn forgets its count" ); - assert!(!state.activity_moves()); + assert!(!conversation.state.activity_moves()); + } + + /// COM-5: the gap between steps, while queued tools wait to start and nothing is named, is + /// still the turn; the count carries through it rather than starting over. + #[test] + fn the_count_carries_through_the_gap_between_steps() { + let mut conversation = Conversation::canonical(); + let start = Instant::now(); + conversation.state.observe_activity(start, true); + conversation.emit(ConversationEvent::AgentStatusChanged { + agent_id: primary(), + status: AgentStatus::Waiting, + }); + assert_eq!( + conversation.state.current_work(), + None, + "the gap names no work" + ); + conversation.state.observe_activity(at(start, 3), true); + assert_eq!( + conversation.state.activity_elapsed(), + Some(Duration::from_secs(3)) + ); + conversation.emit(ConversationEvent::AgentStatusChanged { + agent_id: primary(), + status: AgentStatus::Running, + }); + conversation.state.observe_activity(at(start, 6), true); + conversation.state.tick_activity(at(start, 8)); + assert_eq!( + conversation.state.activity_elapsed(), + Some(Duration::from_secs(8)), + "the next step continues the turn's count" + ); + } + + /// COM-5: an approval or a question holds the turn on the user. The count stops for it and + /// resumes where it stopped once answered, and the wait is not the route being quiet. + #[test] + fn the_count_stops_while_the_turn_waits_on_the_user() { + let approval = AttentionRequest::Approval { + reason: ApprovalReason::PermissionRequired, + remember: None, + approval_id: ApprovalId::new("approval-a-wait").expect("fixture"), + call_id: ToolCallId::new("tool-a-wait").expect("fixture"), + tool: "edit".into(), + capabilities: vec![ToolCapability::FileWrite], + detail: "Approve writing the findings file.".into(), + }; + let question = AttentionRequest::Clarification { + summary: "Which branch should the findings go on?".into(), + }; + for (name, request) in [("approval", approval), ("question", question)] { + let mut conversation = Conversation::canonical(); + let start = Instant::now(); + conversation.state.observe_activity(start, true); + let attention_id = AttentionId::new(format!("attention-a-{name}")).expect("fixture"); + conversation.emit(ConversationEvent::AttentionRequested { + agent_id: primary(), + attention_id: attention_id.clone(), + request, + }); + conversation.state.observe_activity(at(start, 4), true); + conversation.state.tick_activity(at(start, 64)); + assert_eq!( + conversation.state.activity_elapsed(), + Some(Duration::from_secs(4)), + "{name}: a minute waiting on the user is not counted" + ); + assert_eq!( + conversation.state.activity_quiet(), + None, + "{name}: and waiting is not quiet" + ); + + conversation.emit(ConversationEvent::AttentionResolved { + agent_id: primary(), + attention_id, + }); + conversation.state.observe_activity(at(start, 64), true); + conversation.state.tick_activity(at(start, 70)); + assert_eq!( + conversation.state.activity_elapsed(), + Some(Duration::from_secs(10)), + "{name}: the count resumes where it stopped" + ); + assert_eq!( + conversation.state.activity_quiet(), + Some(Duration::from_secs(6)), + "{name}: quiet counts from the answer, not from before the wait" + ); + } } /// ui-ux §input: elapsed reads in whole units, the largest two. diff --git a/crates/plexmaton-tui/src/workspace.rs b/crates/plexmaton-tui/src/workspace.rs index f5634617..26a3f4f1 100644 --- a/crates/plexmaton-tui/src/workspace.rs +++ b/crates/plexmaton-tui/src/workspace.rs @@ -1416,9 +1416,10 @@ mod tests { assert_eq!(bounds(&workspace, SurfaceId::Transcript), transcript); assert_eq!(bounds(&workspace, SurfaceId::Composer), composer); + // Idle closes the activity line's rows, so no row of the conversation is it any longer. assert!( - !activity(&terminal, &workspace).contains("Thinking"), - "idle draws nothing on the activity line" + !painted(&terminal, &workspace, SurfaceId::Transcript).contains("Thinking…"), + "idle names no work anywhere in the conversation" ); conversation.emit(ConversationEvent::AgentStatusChanged { diff --git a/crates/plexmaton-tui/src/workspace/producer.rs b/crates/plexmaton-tui/src/workspace/producer.rs index b90f6469..a0f375f7 100644 --- a/crates/plexmaton-tui/src/workspace/producer.rs +++ b/crates/plexmaton-tui/src/workspace/producer.rs @@ -19,7 +19,13 @@ impl Workspace { /// Apply producer events heard at `now`, which dates the activity line's readings. pub fn emit_at(&mut self, events: Vec, now: Instant) { let before = self.state.revision(); - let heard = !events.is_empty(); + // Quiet is the primary's route saying nothing: a child's stream is not the primary heard. + let heard = { + let primary = self.state.primary_agent().map(|agent| &agent.id); + events + .iter() + .any(|envelope| primary.is_none_or(|primary| envelope.event.agent() == primary)) + }; for envelope in events { let _outcome = self.state.apply(envelope); } @@ -46,3 +52,40 @@ impl Workspace { self.state.hover_entry(None); } } + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use plexmaton_core::AgentId; + + use crate::{Workspace, test_support::Conversation}; + + /// COM-5: quiet is the primary's route saying nothing. A child streaming meanwhile does not + /// keep the primary's line from saying so; the primary's own delta does. + #[test] + fn a_child_streaming_does_not_break_the_primary_quiet() { + let mut conversation = Conversation::canonical(); + let mut workspace = Workspace::default(); + let start = Instant::now(); + workspace.emit_at(conversation.drain(), start); + let child = AgentId::new("agent-b").expect("the canonical child"); + for second in 1..=6 { + conversation.extend_agent(&child, 1); + workspace.emit_at(conversation.drain(), start + Duration::from_secs(second)); + } + assert_eq!( + workspace.state.activity_quiet(), + Some(Duration::from_secs(6)), + "six seconds of the child is six seconds of the primary's quiet" + ); + + conversation.extend(1); + workspace.emit_at(conversation.drain(), start + Duration::from_secs(7)); + assert_eq!( + workspace.state.activity_quiet(), + None, + "the primary is heard" + ); + } +}