Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .agents/evidence/motion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Evidence — Motion

What proves [motion](../specs/motion.md)'s invariants. Named functions only: the gate
resolves every one against the source, so a renamed or deleted test fails the build here.


| 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-3 | `effort_animation_changes_only_visible_max_cells_and_stops_when_hidden` |
13 changes: 7 additions & 6 deletions .agents/phases/phase-04-product-polish.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,12 @@ Visual changes are reviewed against real frames before the contract adopts them.
set the Nerd Font already supplies; the block is odd on both axes and its width is chosen from
the terminal's measured cell so it comes out square, because no fixed size is square in two
fonts. It first appears centred in an empty conversation and leaves with the first message. In
motion the frame breathes between weights and drifts between palette slots, and the centre morphs
through circle, ring, rounded square and square, on the visible-only clock EFF-4 owns. Palette,
pure Ratatui drawing and the clock stay separate responsibilities; static previews at three
widths precede motion; no script reruns per frame. The
[stage plan](../plans/phase-04-stage-10-branding.md) owns the order.
motion the frame breathes between weights and drifts between palette slots, and the centre
morphs through circle, ring, rounded square and square, on the workspace's one motion clock
([motion](../specs/motion.md)). Palette, pure Ratatui drawing and the clock stay separate
responsibilities; static previews at three widths precede motion; no script reruns per frame.
The [stage plan](../plans/phase-04-stage-10-branding.md) owns the order.

11. **Composer menu and Drawer — complete.** A Command is a slash command only, typed into the
conversation it addresses and run from there with a captured target: `/new`, `/resume` over
saved conversations, `/compact` and `/permissions` for the Session, under
Expand Down Expand Up @@ -396,7 +397,7 @@ Rejected: treating JSONL round trips as independent export/import acceptance.
repeating the agent, and the activity line moves while the agent works:
the mark's core, the label, elapsed time, effort, and how long the route has been quiet. Both
were chosen from rendered candidates beside Claude Code, Codex and Grok, and both change
presentation only; motion shares EFF-4's clock rather than owning one. The
presentation only; motion runs on the workspace's one clock ([motion](../specs/motion.md)). The
[stage plan](../plans/phase-04-stage-34-conversation-chrome.md) owns the order.

Other roadmap work in performance, math and extensibility receives a stage when its evidence is
Expand Down
4 changes: 2 additions & 2 deletions .agents/plans/phase-04-stage-10-branding.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
| Field | Value |
| --- | --- |
| Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 10 |
| Contract | [ui-ux](../ui-ux.md) "An empty conversation shows the mark"; MOT-1 to MOT-3 in [stage 34's plan](./phase-04-stage-34-conversation-chrome.md) until promoted; FR-1 in [frame-loop](../specs/frame-loop.md) |
| Contract | [ui-ux](../ui-ux.md) "An empty conversation shows the mark"; MOT-1 to MOT-3 in [motion](../specs/motion.md); FR-1 in [frame-loop](../specs/frame-loop.md) |
| Evidence | [Conversation chrome spike](../spikes/conversation-chrome/README.md): mark proportions, motion choreographies, the Nerd Font shape family and the measured cells |
| Status | Slice 0 of 4, unstarted; begins after stage 34's slice 3 lands the motion owner |
| Status | Slice 0 of 4, unstarted; ready to begin: stage 34 landed the motion owner |

## Outcome

Expand Down
28 changes: 10 additions & 18 deletions .agents/plans/phase-04-stage-34-conversation-chrome.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
| Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 34 |
| Contract | [ui-ux](../ui-ux.md) §transcript grammar and its activity-line rule; EFF-4 in [reasoning-effort](../specs/reasoning-effort.md); FR-1 in [frame-loop](../specs/frame-loop.md); TR-6 in [transcript-layout](../specs/transcript-layout.md); SEL-1 and SEL-2 in [selection-and-copy](../specs/selection-and-copy.md) |
| Evidence | [Conversation chrome spike](../spikes/conversation-chrome/README.md): the rendered candidates the user chose from and the survey of three other CLIs |
| Status | Slices 1 and 2 of 5 implemented and locally verified |
| Status | Slices 1–3 of 5 implemented and locally verified |

## Outcome

Expand All @@ -14,18 +14,9 @@ row that moves and says only what is known: how long the step has run, at what e
the route has been quiet. Both are presentation. Removing either changes no journal entry, no
request and no replay.

## Contract, inline until code cites it
## Contract

**MOT-1 — One visible-only motion clock.** Every moving cell in the workspace wakes on the deadline
EFF-4 already owns. Absence of any visible moving cell disarms it; late wakes coalesce into the
current phase without queuing missed frames.

**MOT-2 — A mark frame is one cell.** Every glyph in a motion sequence has display width one under
the width rules the product already applies to its markers, and a test proves it for every
sequence, so a frame can never widen a row.

**MOT-3 — Motion changes presentation only.** A phase change invalidates the painted frame and never
the semantic revision (FR-1). Replaying a journal reproduces the same rows with the clock stopped.
MOT-1 to MOT-3 live in [motion](../specs/motion.md), promoted when the motion owner cited them.

## Slices

Expand All @@ -42,10 +33,11 @@ the semantic revision (FR-1). Replaying a journal reproduces the same rows with
plain `Message` before a model is named; a collapsed composer and a worker's input keep naming
their agent, because there the name does distinguish. Closed by the renamed COM-4 test, refreshed
frames, and every PTY smoke waiting on its own model's title.
3. **Motion owner.** The effort animation in `workspace/effort.rs` becomes the workspace's motion
owner: one deadline, any number of visible users, the effort rail its first user with no change
in behaviour. Closes with the existing effort tests passing unchanged and a test that nothing
visible moving means no wake (MOT-1).
3. **Motion owner — implemented.** The effort rail's clock became the workspace's one motion owner:
one deadline, each moving thing answering only whether it is visible, the phase held at the top
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
Expand All @@ -55,8 +47,8 @@ the semantic revision (FR-1). Replaying a journal reproduces the same rows with
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.
5. **Documents.** Evidence tables current; SVG review of the frames attached; MOT-1 to MOT-3
promoted to a spec when code cites them; this plan deleted; the phase row says done.
5. **Documents.** Evidence tables current; SVG review of the frames attached; this plan deleted;
the phase row says done.

## Order and why

Expand Down
4 changes: 2 additions & 2 deletions .agents/specs/frame-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ clock. Producer traffic that alters nothing visible costs no frame. An admitted
completion invalidates presentation without changing semantic state (PRE-3).
An owned status command may publish a changed footer (STL-4); only changed decoded output advances
the view revision. Its optional refresh clock schedules a command, not a frame or transcript scan.
EFF-4 owns a separate visible-only effort animation deadline; its phase invalidates presentation
without advancing semantic revision. The same cell/native commit path publishes every frame.
MOT-1 owns the one visible-only motion deadline; its phase invalidates presentation without
advancing semantic revision (MOT-3). The same cell/native commit path publishes every frame.

**FR-2 — A frame's layout work is bounded by its viewport, not by the conversation's length.** What
a frame builds is what its viewport reaches (TR-2). Cold/new-width literal heights use count-only
Expand Down
30 changes: 30 additions & 0 deletions .agents/specs/motion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Spec — Motion

| Field | Value |
| --- | --- |
| Status | MOT-1 and MOT-3 implemented for the effort rail; MOT-2 unproven until the activity line moves |
| 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 |

## Invariants

**MOT-1 — One visible-only motion clock.** The workspace owns one deadline at fifteen phases a
second over a 32 s cycle, and every moving cell reads its phase; nothing that moves owns a timer of
its own. Each moving thing answers only whether it is visible now. Absence of any visible moving
cell disarms the deadline, so an idle workspace wakes for nothing; a late wake lands on the current
phase and counts the next tick from itself, queuing no missed frames. Rejected: a clock per moving
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.

**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
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).
5 changes: 3 additions & 2 deletions .agents/specs/reasoning-effort.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ neutral and its vertical-only ticks are colored. Composer rules stay static; the
letters and the selector's selected max marker/letters share one color phase. Xhigh stays static.
The status script owns its original styling.

**EFF-4 — Motion has a bounded visible owner.** Visible max labels share one 67 ms deadline;
late wakes coalesce and absence of a visible max label disarms it. Animation changes three composer
**EFF-4 — Motion has a bounded visible owner.** A visible max label is one of the moving things
under [MOT-1](./motion.md): it runs on the workspace's one motion clock, and while no max label is
visible it contributes no wake. Animation changes three composer
foreground cells and, while selected, four selector cells; its marker cycles triangle, square,
hexagon and circle in one cell every 1.6 s. Covered, collapsed, clipped or retry-editing composer
labels own no wake. Animation changes neither semantic revision nor layout; FR-3 owns output.
Expand Down
12 changes: 6 additions & 6 deletions .agents/ui-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,12 +159,12 @@ other rule about input follows from this one.
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 EFF-4 owns, shared with the
effort rail; 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.
while approval is required. Motion runs on the one visible-only clock the effort rail shares
([motion](./specs/motion.md)); it changes presentation only. Show the approval label in the
visible primary card; otherwise in the activity line. Rejected: duplicate labels on adjacent
lines; a still row, which during a thirty-second silent turn read as a hang; a token count
estimated from streamed characters, because the row claims only what the route reported; turning
the row red when the route is quiet, because quiet is not failure.
- **An empty conversation shows the mark.** Before the first message the transcript holds the mark
centred, the product's name beneath it, drawn in cells at the odd size nearest square for the
terminal's measured cell; it moves on the same clock and leaves with the first message. Rejected:
Expand Down
4 changes: 2 additions & 2 deletions crates/plexmaton-cli/src/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ where
let note_deadline = workspace.note_deadline();
let drag_deadline = workspace.drag_autoscroll_deadline();
let frame_deadline = frames.deadline();
let effort_deadline = workspace.effort_animation_deadline(Instant::now());
let motion_deadline = workspace.motion_deadline(Instant::now());
let collaboration_enabled = collaboration
.as_ref()
.is_some_and(|collaboration| collaboration.can_poll(runtime));
Expand Down Expand Up @@ -112,7 +112,7 @@ where
workspace.advance_drag_autoscroll(Instant::now());
}
() = wait_for_deadline(frame_deadline) => {}
() = wait_for_deadline(effort_deadline) => { workspace.advance_effort_animation(Instant::now()); }
() = wait_for_deadline(motion_deadline) => { workspace.advance_motion(Instant::now()); }
runtime_update = runtime.next_update() => {
let update = runtime_update.context("receive live runtime update")?;
let finished = apply_runtime_progress(
Expand Down
2 changes: 1 addition & 1 deletion crates/plexmaton-tui/src/render/chrome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ pub(super) fn composer_title(state: &ViewState, palette: &Palette) -> Line<'stat
line.spans
.push(Span::styled(" · ", palette.style(Role::Muted)));
line.spans
.extend(super::effort::effort_spans(effort, state.effort_phase()));
.extend(super::effort::effort_spans(effort, state.motion_phase()));
line.spans.push(Span::raw(" "));
}
line
Expand Down
2 changes: 1 addition & 1 deletion crates/plexmaton-tui/src/render/effort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ pub(crate) fn lines(
let x = stop(width, index);
let available = state.effort_available(level);
let chosen = selected == Some(level);
let phase = if chosen { state.effort_phase() } else { 0 };
let phase = if chosen { state.motion_phase() } else { 0 };
let glyph = if chosen && level == ReasoningEffort::Max {
marker(phase)
} else if chosen {
Expand Down
1 change: 0 additions & 1 deletion crates/plexmaton-tui/src/state/composer_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,6 @@ pub(crate) enum MenuRow {
pub(crate) struct ComposerMenu {
pub(crate) efforts: Option<Vec<ReasoningEffort>>,
pub(crate) effort_feedback: Option<String>,
pub(crate) effort_phase: u16,
skills: Vec<SkillChoice>,
models: models::ModelCatalog,
pub(crate) model_feedback: Option<String>,
Expand Down
8 changes: 0 additions & 8 deletions crates/plexmaton-tui/src/state/effort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,6 @@ impl ViewState {
}
}

pub(crate) fn effort_phase(&self) -> u16 {
self.composer_menu.effort_phase
}

pub(crate) fn set_effort_phase(&mut self, phase: u16) {
self.composer_menu.effort_phase = phase;
}

pub(crate) fn effort_visible(&self) -> bool {
self.menu_listing() == Some(Listing::Effort)
&& self.composer_menu.is_open()
Expand Down
3 changes: 3 additions & 0 deletions crates/plexmaton-tui/src/state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ mod ingest;
mod input_queue;
mod inspect;
mod inspector;
mod motion;
mod notices;
mod ordered;
mod restoration;
Expand Down Expand Up @@ -135,6 +136,8 @@ pub struct ViewState {
inputs: BTreeMap<AgentId, TextInput>,
skill_bindings: composer_menu::SkillBindings,
composer_menu: composer_menu::ComposerMenu,
/// The shared motion clock's phase (MOT-1); presentation reads it, semantics never do.
motion_phase: u16,
inspector: Inspector,
/// Which semantic entries the user opened, plus the one under the pointer.
disclosure: DisclosureState,
Expand Down
13 changes: 13 additions & 0 deletions crates/plexmaton-tui/src/state/motion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! The motion clock's phase, held where every renderer can read it (MOT-1).

use super::ViewState;

impl ViewState {
pub(crate) const fn motion_phase(&self) -> u16 {
self.motion_phase
}

pub(crate) const fn set_motion_phase(&mut self, phase: u16) {
self.motion_phase = phase;
}
}
3 changes: 2 additions & 1 deletion crates/plexmaton-tui/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ mod markdown_tests;
mod math_tests;
#[cfg(test)]
mod model_tests;
mod motion;
mod notices;
mod paint;
#[cfg(test)]
Expand Down Expand Up @@ -221,7 +222,7 @@ pub struct Workspace {
preparation: preparation::Preparation,
copy: copy::CopyPreparation,
native: crate::math::NativeFrame,
effort_animation: Option<effort::EffortAnimation>,
motion: Option<motion::Motion>,
}

impl Workspace {
Expand Down
50 changes: 4 additions & 46 deletions crates/plexmaton-tui/src/workspace/effort.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,7 @@
//! Visible max labels share one deadline; animation invalidates presentation, never semantic state.
//! Effort's choices and refusals, and whether its max label is one of the workspace's moving things.

use super::Workspace;
use plexmaton_core::ReasoningEffort;
use std::time::{Duration, Instant};

#[derive(Debug)]
pub(super) struct EffortAnimation {
start: Instant,
next: Instant,
}

impl Workspace {
/// Publish the model's declared choices; absent capabilities are not inferred.
Expand All @@ -21,7 +14,9 @@ impl Workspace {
self.state.report_effort(result);
}

fn effort_animation_visible(&self) -> bool {
/// Whether a max label is on screen, which is what makes effort one of the moving things
/// (EFF-4 under MOT-1).
pub(super) fn effort_moving(&self) -> bool {
let selector = self.state.effort_visible()
&& self.state.selected_effort() == Some(ReasoningEffort::Max)
&& self
Expand All @@ -30,41 +25,4 @@ impl Workspace {
.is_some_and(|surface| surface.bounds.height >= 4 && surface.bounds.width >= 20);
selector || crate::render::effort::composer_max_visible(&self.state, &self.surfaces)
}

/// Arm a single shared deadline while the composer or selector has a visible max label.
pub fn effort_animation_deadline(&mut self, now: Instant) -> Option<Instant> {
if !self.effort_animation_visible() {
self.effort_animation = None;
return None;
}
Some(
self.effort_animation
.get_or_insert(EffortAnimation {
start: now,
next: now + Duration::from_millis(67),
})
.next,
)
}

/// Late wakes coalesce into the current phase, without queuing missed animation frames.
pub fn advance_effort_animation(&mut self, now: Instant) {
if !self.effort_animation_visible() {
self.effort_animation = None;
return;
}
let Some(animation) = &mut self.effort_animation else {
return;
};
if now < animation.next {
return;
}
let phase =
(now.saturating_duration_since(animation.start).as_millis() * 15 / 1000 % 480) as u16;
animation.next = now + Duration::from_millis(67);
if phase != self.state.effort_phase() {
self.state.set_effort_phase(phase);
self.painted = None;
}
}
}
Loading
Loading