diff --git a/.agents/evidence/composer.md b/.agents/evidence/composer.md index 310f32b..2091e7d 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/evidence/motion.md b/.agents/evidence/motion.md index 6bd4f7f..bcc7503 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/phases/phase-04-product-polish.md b/.agents/phases/phase-04-product-polish.md index 4fd6c5b..8e324a2 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 fd8fb85..d614b31 100644 --- a/.agents/plans/phase-04-stage-34-conversation-chrome.md +++ b/.agents/plans/phase-04-stage-34-conversation-chrome.md @@ -5,12 +5,12 @@ | 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 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. @@ -38,15 +38,20 @@ 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 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 05ec329..4d65a39 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/input-queue.md b/.agents/specs/input-queue.md index aa64f9f..56269eb 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/specs/motion.md b/.agents/specs/motion.md index 5bc1bad..de0858d 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/specs/selection-and-copy.md b/.agents/specs/selection-and-copy.md index 71a1caa..077783f 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/spikes/conversation-chrome/README.md b/.agents/spikes/conversation-chrome/README.md index eed7fb1..35afe67 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 38c7682..775b943 100644 --- a/.agents/ui-ux.md +++ b/.agents/ui-ux.md @@ -153,18 +153,28 @@ 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 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 +- **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 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 ([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. + 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 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 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-core/src/lib.rs b/crates/plexmaton-core/src/lib.rs index 009975c..9a5b06a 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/frames/agents-strip-narrow.txt b/crates/plexmaton-tui/frames/agents-strip-narrow.txt index 7c53c05..cc21227 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 ) │ +│󰝦 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 248607a..5d4a6f7 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 ) │ +│󰝦 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 978d452..447c368 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… │ +│󰝦 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 ce91145..6c5b002 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 ) ││ │ +│󰝦 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 daf0ec5..f8a7e70 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 ) │ +│󰝦 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 7992f46..13c5cc4 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 ) ││ │ +│󰝦 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 2e35208..7494f77 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 ) │ +│󰝦 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 f7c9503..8692371 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 ) │ +│󰝦 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 47dc987..36e9ad4 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 ) │ +│󰝦 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 25dbf67..e2b6a7c 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 ) │ +│󰝦 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 2b3b7c5..ca724fc 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 ) │ +│󰝦 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 f1045b8..9a60619 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 ) │ +│󰝦 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 62d895c..18ba765 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 ) │ +│󰝦 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 4cb9306..46bcb5f 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 ) │ +│󰝦 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 3d2759e..2acc265 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 ) │ +│󰝦 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 1541e8d..e7e473e 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 ) │ +│󰝦 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 32757a0..a748154 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 ) │ +│󰝦 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 8b516d8..f738933 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 ) │ +│󰝦 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 1e75022..b5d72e6 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 ) │ +│󰝦 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 ea83108..08eec72 100644 --- a/crates/plexmaton-tui/frames/input-queue-medium.txt +++ b/crates/plexmaton-tui/frames/input-queue-medium.txt @@ -21,14 +21,14 @@ │ │ │ │ │ │ -│ │ │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. │ │ │ │ │ -│· 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 24dc63d..5f49fa4 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. │ @@ -28,7 +27,8 @@ │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 cd998c6..236215e 100644 --- a/crates/plexmaton-tui/frames/input-queue-wide.txt +++ b/crates/plexmaton-tui/frames/input-queue-wide.txt @@ -21,14 +21,14 @@ │ │ │ │ │ │ -│ │ │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. │ │ │ │ │ -│· 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/markdown-medium.txt b/crates/plexmaton-tui/frames/markdown-medium.txt index 5431f12..83d3260 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 99fbc19..1373b69 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 8ae6864..ce2e319 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 7dbaa0f..4e3294c 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 3c7199c..6c366da 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 dce9dbe..2ada818 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 943b274..57e618b 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 78f4641..bc3c7ab 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 d8b3642..d79ae16 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 d2fc414..04c4cf2 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 5d542ac..a8b5c83 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 df121c9..1362321 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 4db76c3..3a20c1c 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 1d1551d..a89155a 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 a774e79..0ec1def 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 a34a445..7b94438 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 │ @@ -33,7 +32,8 @@ │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 54f7585..a413dde 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? │ │ │ @@ -33,7 +32,8 @@ │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 86b01bb..b45dbb2 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 │ @@ -33,7 +32,8 @@ │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 256b7a6..96ec127 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 │ @@ -33,7 +32,8 @@ │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/session-recovery-medium.txt b/crates/plexmaton-tui/frames/session-recovery-medium.txt index 56c3077..0907375 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 eb085d4..f8851be 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 8b5ae03..97b6500 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 8244b13..91f239e 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 ) │ +│󰝦 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 2558682..04a74ee 100644 --- a/crates/plexmaton-tui/frames/skill-diagnostic-narrow.txt +++ b/crates/plexmaton-tui/frames/skill-diagnostic-narrow.txt @@ -26,14 +26,14 @@ │ │ │ │ │ │ -│ │ │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…│ │[!] 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 fb2cef6..248f012 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 ) │ +│󰝦 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 9acf37e..eda0e17 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 59cf76a..0881e0f 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 de2744d..d891c6f 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 9bc2094..958c11d 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" │ @@ -33,7 +32,8 @@ │ │ 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 5692ab3..34298b2 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" │ @@ -33,7 +32,8 @@ │ │ 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 b9fc8f5..753d15c 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" │ @@ -33,7 +32,8 @@ │ │ 197 tests passed │ │ │ stderr: │ │ │ [empty] │ -│· Thinking… │ +│󰝦 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 605e0b3..8a3ec54 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 6e22773..5b498f1 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 c769b06..d9c6498 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 e1e76c8..0479b25 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( @@ -184,7 +185,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!( @@ -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 new file mode 100644 index 0000000..5f8970d --- /dev/null +++ b/crates/plexmaton-tui/src/render/activity.rs @@ -0,0 +1,261 @@ +//! 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 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. +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 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)> { + 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 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}; + + /// 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 fb3c7a0..1bc9331 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,17 @@ 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 the primary conversation's selection note says, without its separator. +pub(super) fn selection_note(state: &ViewState) -> Option { + selected_suffix(state, SurfaceId::Transcript) + .strip_prefix(" · ") + .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). -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 { @@ -183,9 +145,9 @@ 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, @@ -305,6 +267,7 @@ pub(super) fn render_status( state: &ViewState, palette: &Palette, area: Rect, + activity_shown: bool, ) { let status = state.status(); match status.footer() { @@ -344,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. @@ -371,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 ffb53ec..32640ab 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 3a37ec8..381caee 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 dfb7028..cb39485 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; @@ -97,42 +98,49 @@ 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). - SurfaceId::Transcript => Some(Panel { - insets: crate::surface::ContentInsets::default(), - chrome: Chrome::Box, - footer: Some(chrome::activity_line( + // 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, 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, @@ -171,7 +179,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 } }; @@ -346,7 +359,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 +452,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 +505,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 +527,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 +553,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}" @@ -543,12 +613,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(), @@ -589,9 +660,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) @@ -1396,9 +1467,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 09e2883..a12cb8b 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/state/activity.rs b/crates/plexmaton-tui/src/state/activity.rs new file mode 100644 index 0000000..890f27f --- /dev/null +++ b/crates/plexmaton-tui/src/state/activity.rs @@ -0,0 +1,307 @@ +//! 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 { + 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`. 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 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; + } + _ => {} + } + if heard { + turn.heard = 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 let Some(turn) = &mut self.activity.turn { + turn.now = now; + } + } + + /// How long the turn has run, not counting its waits on the user. + pub(crate) fn activity_elapsed(&self) -> Option { + 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 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 turn = self.activity.turn.as_ref()?; + turn.resumed?; + let quiet = turn.now.saturating_duration_since(turn.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) + } + + /// 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. +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 crate::test_support::Conversation; + use plexmaton_core::{ + AgentId, AgentStatus, ApprovalId, ApprovalReason, AttentionId, AttentionRequest, + ConversationEvent, ToolCallId, ToolCapability, + }; + + 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 the_turn_is_counted_from_its_start_and_forgotten_when_it_ends() { + let mut conversation = Conversation::canonical(); + let start = Instant::now(); + conversation.state.observe_activity(start, true); + conversation.state.observe_activity(at(start, 3), false); + conversation.state.tick_activity(at(start, 4)); + assert_eq!( + conversation.state.activity_elapsed(), + Some(Duration::from_secs(4)) + ); + assert_eq!( + conversation.state.activity_quiet(), + None, + "four silent seconds say nothing" + ); + 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, + }); + conversation.state.observe_activity(at(start, 10), true); + assert_eq!( + conversation.state.activity_elapsed(), + None, + "the ended turn forgets its count" + ); + 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. + #[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 2a37d2d..67adb58 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 c8fc4fd..72b381d 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 45b6485..26a3f4f 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, @@ -1419,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() }; @@ -1446,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/composer_menu_tests.rs b/crates/plexmaton-tui/src/workspace/composer_menu_tests.rs index db4acd5..e9205f2 100644 --- a/crates/plexmaton-tui/src/workspace/composer_menu_tests.rs +++ b/crates/plexmaton-tui/src/workspace/composer_menu_tests.rs @@ -480,8 +480,9 @@ fn a_requested_compaction_shows_on_the_activity_line_and_ends_with_a_note() { assert!( drawn .lines() - .last() - .is_some_and(|row| row.contains("· Compacting…")), + .rev() + .nth(1) + .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 0383ee9..3d5324d 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 b862651..f120e0d 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 0000000..a0f375f --- /dev/null +++ b/crates/plexmaton-tui/src/workspace/producer.rs @@ -0,0 +1,91 @@ +//! 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(); + // 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); + } + 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); + } +} + +#[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" + ); + } +} diff --git a/scripts/smoke-server-tool.py b/scripts/smoke-server-tool.py index b561011..167f117 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