From 1ea8e0bc7ccf4b7efcf7f297d42194fbfc1c8ed4 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:47:21 -0400 Subject: [PATCH] feat(tui): greet launch with the mark, once, drawn in braille When Plexmaton starts on a new, empty conversation the mark plays once at its centre with the name beneath and leaves after about 2.5 s: the frame fades in, the centre grows from a dot into a circle, becomes a rounded square and a square, turns into a diamond while the frame turns with it, and shrinks away, a sheen passing once, then frame and name fade out. Input never waits; the first message ends it for good; /new, /resume and a reopened conversation are not greeted. The empty conversation it leaves says nothing. The mark is drawn in braille, the way Grok draws its logo: two by four dots a cell make it one small bitmap, squared against the cell the terminal reports, so the circle is round and the growth is smooth. It runs on the workspace's one motion clock and stops waking it once it has left. The user chose the drawing, the motion, the greeting and its pace in kitty, after finding the box-drawn frame around a one-cell glyph too coarse and a mark that stayed until the first message too insistent; the spike keeps the previews and the Rust output matches them dot for dot. The terminal smoke's check that a status-line press repaints nothing now waits for the greeting to leave, since the greeting itself repaints while it plays. --- .agents/evidence/motion.md | 14 +- .agents/phases/phase-04-product-polish.md | 11 +- .agents/plans/phase-04-stage-10-branding.md | 43 -- .agents/roadmap.md | 2 +- .agents/specs/motion.md | 8 +- .agents/spikes/conversation-chrome/README.md | 12 +- .../mockups/mark-braille-frame.py | 124 ++++++ .../mockups/mark-braille.py | 160 +++++++ .agents/ui-ux.md | 13 +- crates/plexmaton-cli/src/main.rs | 14 + .../examples/greeting_preview.rs | 71 ++++ .../plexmaton-tui/examples/mark_greeting.rs | 131 ++++++ .../frames/greeting/greeting-circle-120.svg | 1 + .../frames/greeting/greeting-circle-60.svg | 1 + .../frames/greeting/greeting-circle-88.svg | 1 + .../frames/greeting/greeting-turned-120.svg | 1 + .../frames/greeting/greeting-turned-60.svg | 1 + .../frames/greeting/greeting-turned-88.svg | 1 + .../frames/skill-picker-medium.txt | 2 +- .../frames/skill-picker-narrow.txt | 2 +- .../frames/skill-picker-wide.txt | 2 +- .../plexmaton-tui/src/content_transcript.rs | 4 + crates/plexmaton-tui/src/lib.rs | 5 + crates/plexmaton-tui/src/render/mark.rs | 393 ++++++++++++++++++ crates/plexmaton-tui/src/render/mod.rs | 16 + crates/plexmaton-tui/src/state/mod.rs | 4 + crates/plexmaton-tui/src/state/motion.rs | 34 +- crates/plexmaton-tui/src/theme.rs | 4 + crates/plexmaton-tui/src/theme/mark.rs | 61 +++ crates/plexmaton-tui/src/workspace.rs | 4 + .../src/workspace/greeting_tests.rs | 126 ++++++ crates/plexmaton-tui/src/workspace/motion.rs | 39 +- .../plexmaton-tui/src/workspace/producer.rs | 5 + scripts/smoke-tui.py | 6 + 34 files changed, 1249 insertions(+), 67 deletions(-) delete mode 100644 .agents/plans/phase-04-stage-10-branding.md create mode 100644 .agents/spikes/conversation-chrome/mockups/mark-braille-frame.py create mode 100644 .agents/spikes/conversation-chrome/mockups/mark-braille.py create mode 100644 crates/plexmaton-tui/examples/greeting_preview.rs create mode 100644 crates/plexmaton-tui/examples/mark_greeting.rs create mode 100644 crates/plexmaton-tui/frames/greeting/greeting-circle-120.svg create mode 100644 crates/plexmaton-tui/frames/greeting/greeting-circle-60.svg create mode 100644 crates/plexmaton-tui/frames/greeting/greeting-circle-88.svg create mode 100644 crates/plexmaton-tui/frames/greeting/greeting-turned-120.svg create mode 100644 crates/plexmaton-tui/frames/greeting/greeting-turned-60.svg create mode 100644 crates/plexmaton-tui/frames/greeting/greeting-turned-88.svg create mode 100644 crates/plexmaton-tui/src/render/mark.rs create mode 100644 crates/plexmaton-tui/src/theme/mark.rs create mode 100644 crates/plexmaton-tui/src/workspace/greeting_tests.rs diff --git a/.agents/evidence/motion.md b/.agents/evidence/motion.md index bcc7503..dff51ef 100644 --- a/.agents/evidence/motion.md +++ b/.agents/evidence/motion.md @@ -6,6 +6,16 @@ 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`, `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-1 | `launch_greets_once_and_leaves`, `the_first_message_ends_the_greeting_for_good`, `a_conversation_launch_did_not_greet_shows_no_mark`, `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 | `the_mark_is_braille_one_cell_a_glyph`, `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` | + +## Rendered + +Launch's greeting over a new conversation, drawn by the workspace on its motion clock with +`cargo run -p plexmaton-tui --example greeting_preview -- `: the centre grown into a +circle at [120](../../crates/plexmaton-tui/frames/greeting/greeting-circle-120.svg), [88](../../crates/plexmaton-tui/frames/greeting/greeting-circle-88.svg) and +[60](../../crates/plexmaton-tui/frames/greeting/greeting-circle-60.svg), and centre and frame turned at [120](../../crates/plexmaton-tui/frames/greeting/greeting-turned-120.svg), +[88](../../crates/plexmaton-tui/frames/greeting/greeting-turned-88.svg) and [60](../../crates/plexmaton-tui/frames/greeting/greeting-turned-60.svg). The user watched it play in +the real binary in kitty on 2026-09-23; `cargo run -p plexmaton-tui --example mark_greeting` plays +it alone in any terminal. diff --git a/.agents/phases/phase-04-product-polish.md b/.agents/phases/phase-04-product-polish.md index 48f1bc3..dc5eb71 100644 --- a/.agents/phases/phase-04-product-polish.md +++ b/.agents/phases/phase-04-product-polish.md @@ -2,7 +2,7 @@ | Field | Value | | --- | --- | -| Status | Active; stages 1–9 and 11–34 complete; stage 10 branding planned and unstarted | +| Status | Active; stages 1–34 complete; exit gate not yet assessed | | Parent roadmap | [Plexmaton Roadmap](../roadmap.md) | | Product contract | [UI/UX](../ui-ux.md) | | Depends on | Phase 01 interaction ownership; Phase 02 journal/accounting/budget projections; Phase 03 durable collaboration, owned child scheduling, Handoff/Stop, passive recovery and canonical Attention | @@ -48,10 +48,11 @@ evidence file the frames and tests. Git holds how each was validated. 9. **Composer skill picker — complete.** [SKP-1–SKP-4](../specs/composer-menu.md): `$` discovery above the primary input with keyboard and pointer completion; literal variables, currency and prose stay text. -10. **Branding — unstarted.** The mark, a rounded-square frame around a circular centre, drawn in - cells at the odd size nearest square for the measured cell, first centred in an empty - conversation and moving on the one [motion](../specs/motion.md) clock. The - [stage plan](../plans/phase-04-stage-10-branding.md) owns the order. +10. **Branding — complete.** Launch greets a new conversation with the mark once: drawn in braille, + a rounded-square frame around a round centre, it plays for about 2.5 s on the one + [motion](../specs/motion.md) clock and leaves the conversation blank (ui-ux §input). Chosen + in kitty from the [spike](../spikes/conversation-chrome/README.md)'s previews, after Grok's + braille logo; [frames](../evidence/motion.md#rendered). 11. **Composer menu and Drawer — complete.** A Command is a slash command typed into the conversation it addresses ([CMC-1–CMC-3](../specs/composer-menu.md)); the Drawer, pulled by `Ctrl-P` ([DRW-1–DRW-4](../specs/drawer.md)), holds what outlives a Session. One diff --git a/.agents/plans/phase-04-stage-10-branding.md b/.agents/plans/phase-04-stage-10-branding.md deleted file mode 100644 index 7980a7f..0000000 --- a/.agents/plans/phase-04-stage-10-branding.md +++ /dev/null @@ -1,43 +0,0 @@ -# Plan — Phase 04 stage 10, branding - -| 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 [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; ready to begin: stage 34 landed the motion owner | - -## Outcome - -The mark is a rounded-square frame around a circular centre. In the terminal it is drawn in cells -from box drawing and one glyph of the Material Design set the Nerd Font already supplies, at an odd -size chosen from the terminal's measured cell so it comes out square. It greets an empty -conversation and leaves with the first message. In motion the frame breathes between weights and -drifts between palette slots while the centre morphs through circle, ring, rounded square and -square. It is presentation and nothing else reads it. - -## Slices - -1. **Geometry.** A `mark` module draws the frame at three weights from light rounded, heavy and - half-block box drawing, and the centre from the glyph family recorded in the spike, always on odd - axes so the centre has one cell. The width for a given row count is the odd count nearest square - for the cell the terminal reports in pixels; when it reports none, 7×3 and 11×5. Static previews - first: frames at wide, medium and narrow, reviewed by the user before any motion. -2. **Placement.** Centred in an empty conversation with the product's name beneath; gone with the - first message; nothing else in the frame moves for it. Closes with the empty-state frames at three - widths and the existing canonical frames unchanged. -3. **Motion.** Weight breathing, colour drifting blue, purple, magenta, purple, blue, cyan, and the - centre's morph, all on MOT-1, all presentation (MOT-3); MOT-2 for every glyph the centre uses. -4. **Outside the terminal.** An SVG of the same geometry for the README; documents current; this - plan deleted; the phase row says done. - -## Order and why - -Static geometry before motion, as the stage has always said. Placement before motion so the frames -that motion must not disturb exist first. The mark moves on the motion owner stage 34 landed rather -than growing a second clock. - -## Deliberately not in this plan - -A bitmap or native-protocol logo; a splash that delays input; a fixed cell size; a mark anywhere -but the empty conversation until the user asks for another place. diff --git a/.agents/roadmap.md b/.agents/roadmap.md index 6943983..e3c9e57 100644 --- a/.agents/roadmap.md +++ b/.agents/roadmap.md @@ -34,7 +34,7 @@ table in `AGENTS.md` allows. | 01 | One real agent in the workspace: a thin loop over one provider, and the transcript grammar against its output | Closed 2026-09-03: delivered one live OpenAI-compatible agent, five bounded native tools with explicit approval and cancellation, and the reviewed responsive transcript grammar | | 02 | Canonical session state, persistence, durable permission policy, provider transports, context projection and project instructions | Closed 2026-09-12 by scoped evidence: delivered JSONL sessions and recovery, four provider dialects, compaction, durable permissions, AGENTS.md and conversation tree/rewind; unproven recovery/readmission acceptance inherited by Phase 03, independent export/import by Phase 04; MCP optional | | 03 | Durable multi-agent mailbox and runtime ownership | Closed 2026-09-16: delivered ordered durable delegation/mail, owned child scheduling, Stop and Handoff control, passive, process-kill and in-app switch recovery, canonical Attention and the accepted responsive agent workspace | -| 04 | Product polish, performance hardening, math in production, and extensibility | Active; stages 1–9 and 11–34 complete; stage 10 branding planned and unstarted | +| 04 | Product polish, performance hardening, math in production, and extensibility | Active; stages 1–34 complete; exit gate not yet assessed | ## Locked diff --git a/.agents/specs/motion.md b/.agents/specs/motion.md index 37a6ba2..b099609 100644 --- a/.agents/specs/motion.md +++ b/.agents/specs/motion.md @@ -2,10 +2,10 @@ | Field | Value | | --- | --- | -| Status | Implemented for the effort rail and the activity line's mark | +| Status | Implemented for the effort rail, the activity line's mark and launch's greeting | | 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 and `plexmaton-tui::render` activity-mark tests | +| Proven by | `plexmaton-tui::workspace` effort and greeting tests and `plexmaton-tui::render` activity-mark and mark tests | ## Invariants @@ -28,3 +28,7 @@ clock was doing. 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 and the activity line's mark each complete a cycle every 1.6 s, the mark over seven frames holding unequal numbers of its 24 phases. + +Launch's greeting is not a cycle. It counts the clock's phases from the instant launch greeted, +plays its 38 once (ui-ux §input), and then, or at the conversation's first entry, stops for good +and wakes the clock no more; the braille mark it draws is one cell a glyph (MOT-2). diff --git a/.agents/spikes/conversation-chrome/README.md b/.agents/spikes/conversation-chrome/README.md index 2c96da8..4c9a2f9 100644 --- a/.agents/spikes/conversation-chrome/README.md +++ b/.agents/spikes/conversation-chrome/README.md @@ -3,13 +3,13 @@ | Field | Value | | --- | --- | | Question | How the working agent, the user's turn and the mark should look, decided from rendered candidates | -| Feeds | [Phase 04](../../phases/phase-04-product-polish.md) stage 34, complete, and the [stage 10](../../plans/phase-04-stage-10-branding.md) plan | +| Feeds | [Phase 04](../../phases/phase-04-product-polish.md) stages 34 and 10, both complete | | Method | HTML mockups in the palette, opened for the user; two kitty scripts for what a browser cannot show | Every decision below was made by the user on a rendered frame, 2026-09-21 and 2026-09-22. The generators are in [`mockups/`](./mockups/); each writes one HTML page to `plexmaton-conversation-chrome/` under the system temporary directory, outside this corpus, and -`spin.py`, `mark-lively.py`, `logo_term.py` and `cellsize.py` run in a terminal. Regenerate rather than trusting a description of them. +`spin.py`, `mark-lively.py`, `mark-braille.py`, `mark-braille-frame.py`, `logo_term.py` and `cellsize.py` run in a terminal. Regenerate rather than trusting a description of them. ## What the user chose @@ -18,9 +18,10 @@ generators are in [`mockups/`](./mockups/); each writes one HTML page to | 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 | H: a dot grows into a circle, turns into a rounded square and a square, spins into a diamond and shrinks away through a smaller one, lingering on whole shapes, 1.6 s a cycle; chosen 2026-09-23 in kitty (`mark-lively.py`) after the user found N2 rigid and wanted it to grow from a point | N2, the circle, rounded square and square outline then filled, chosen 2026-09-22 and later found rigid; one size changing shape, and even steps retracing their path, likewise; a slice-by-slice sweep, which reads as progress; C2′ and C2″, which shook 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 | -| Mark glyphs | the Material Design family the Nerd Font carries, one cell each, drawn together so they centre together | Unicode geometric shapes from mixed blocks, which jump between fallback fonts | +| Mark placement | a greeting: at launch onto an empty conversation it plays once at the centre, name beneath, and leaves after about 2.5 s: frame fades in for 0.25 s, the F3 cycle runs in 2 s with one 0.8 s sheen, frame and name fade out in 0.3 s; chosen 2026-09-23 after the user asked for it to be less aggressive and quicker | staying and moving until the first message, too insistent; 3.7 s, too slow; a header or drawer slot, not yet asked for | +| Mark drawing | braille, the way Grok draws its logo: two by four dots a cell, a rounded-square frame around a round centre as one small bitmap, square once corrected for the terminal's cell; chosen 2026-09-23 (`mark-braille.py`) | box drawing at three weights around a one-cell Nerd Font centre, which the user found too coarse to look good at any size | +| Mark motion | F3: the centre grows from a dot into a circle, becomes a rounded square and a square, turns into a diamond while the frame turns with it, and shrinks away, 3.2 s a cycle; Grok's sheen sweeps it every 4 s; chosen 2026-09-23 (`mark-braille-frame.py`) after the user asked for the outside to move as well as the inside | the frame breathing and drifting through palette slots (F1); frame and centre in counterpoint (F2); all of it at once (F4); K4's shape-only morph, rigid | +| Activity mark glyphs | the Material Design family the Nerd Font carries, one cell each, drawn together so they centre together | Unicode geometric shapes from mixed blocks, which jump between fallback fonts | The activity indicator and the mark are two things: the row has one cell, the empty conversation has a block. They share the clock and the glyph family, not the geometry. @@ -60,3 +61,4 @@ the family's, so they cannot join its cycle; `▢`, `◼` and `⬤` are not in t | Stall | glyph and verb fade to red after 3 s without tokens | | phase timer never resets on payload churn | | User turn | band, subtle `❯`, no hanging indent, text colour unchanged | band blended 12 % toward white from the real terminal background, dim `›`, two-space indent, never truncated | band, accent `❯`, indent, folds past three lines | | Clock | one shared interval, halved when the window is blurred, off when scrolled away | | demand-driven; idle parks with no wakeups; every frame unit-tested to one column | +| Logo | | | braille art at seven or five rows by window height, none below; a sheen sweeps bottom-left to top-right in 1.3 s of every 4, over a slow pulse, redrawn at 12 fps | diff --git a/.agents/spikes/conversation-chrome/mockups/mark-braille-frame.py b/.agents/spikes/conversation-chrome/mockups/mark-braille-frame.py new file mode 100644 index 0000000..01e20eb --- /dev/null +++ b/.agents/spikes/conversation-chrome/mockups/mark-braille-frame.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""The braille mark with the frame moving too, around the centre that grows and turns. Ctrl-C to stop.""" +import math, sys, time + +sys.path.insert(0, __import__("os").path.dirname(__file__)) +base = __import__("mark-braille") # noqa: E402 + +DRIFT = [(130, 180, 240), (139, 92, 246), (217, 70, 239), (139, 92, 246), (130, 180, 240), (120, 210, 205)] +CYCLE = 3.2 +ease = lambda k: 0.5 - 0.5 * math.cos(math.pi * max(0.0, min(1.0, k))) + + +def drift(t, period=4.8): + x = (t / period * len(DRIFT)) % len(DRIFT) + i = int(x) + return base.mix(DRIFT[i], DRIFT[(i + 1) % len(DRIFT)], x - i) + + +def phase(t): + return (t % CYCLE) / CYCLE + + +def squareness(t): + """0 while the centre is a circle, 1 once it has become a square (follows growing_core).""" + p = phase(t) + if p < 0.30: + return 0.0 + if p < 0.48: + return ease((p - 0.30) / 0.18) + return 1.0 if p < 0.84 else 1.0 - ease((p - 0.84) / 0.16) + + +def turned(t): + """0 upright, 1 turned a quarter of a right angle further (the diamond), following the centre.""" + p = phase(t) + if p < 0.60: + return 0.0 + if p < 0.78: + return ease((p - 0.60) / 0.18) + return 1.0 if p < 0.84 else 1.0 - ease((p - 0.84) / 0.16) + + +def frame(t, breathe=False, counterpoint=False, turn=False): + """(inside-the-ring test, colour) for the frame at time t.""" + thickness = 0.075 + if breathe: + thickness = 0.05 + 0.06 * (0.5 - 0.5 * math.cos(math.tau * t / 1.6)) + radius = 0.36 + if counterpoint: + radius = 0.20 + 0.66 * squareness(t) # square-ish around a circle, a circle around a square + angle = turned(t) * math.pi / 4 if turn else 0.0 + scale = 1.0 / (1.0 + (math.sqrt(2) - 1.0) * math.sin(angle * 2) ** 2) if turn else 1.0 + half = 0.9 * scale + + def ring(x, y): + c, s = math.cos(angle), math.sin(angle) + x, y = x * c + y * s, -x * s + y * c + return abs(base.rounded_box(x, y, half, min(radius, half))) < thickness + return ring + + +def draw(mark, core, ring, frame_ink, shine=None): + lines = [] + w, h = mark.cols * 2, mark.rows * 4 + cx, cy = w * mark.dw / 2, h * mark.dh / 2 + for row in range(mark.rows): + line = "" + for col in range(mark.cols): + bits, frame_dots, core_dots = 0, 0, 0 + for dy in range(4): + for dx in range(2): + px = ((col * 2 + dx + 0.5) * mark.dw - cx) / mark.half + py = ((row * 4 + dy + 0.5) * mark.dh - cy) / mark.half + on_ring, inside = ring(px, py), core(px, py) + if on_ring or inside: + bits |= base.DOTS[dy][dx] + frame_dots += on_ring + core_dots += inside and not on_ring + ink = frame_ink if frame_dots >= core_dots else base.PURPLE + if shine: + ink = base.mix(ink, base.TEXT, shine(col, row, mark.cols, mark.rows)) + line += f"\x1b[38;2;{ink[0]};{ink[1]};{ink[2]}m{chr(0x2800 + bits)}" if bits else " " + lines.append(line + "\x1b[0m") + return lines + + +CANDIDATES = [ + ("F1 the frame breathes and drifts, with the sheen", lambda t: (frame(t, breathe=True), drift(t), base.grok_shine(t))), + ("F2 inside and outside in counterpoint, with the sheen", lambda t: (frame(t, counterpoint=True), base.BLUE, base.grok_shine(t))), + ("F3 they turn together, with the sheen", lambda t: (frame(t, turn=True), base.BLUE, base.grok_shine(t))), + ("F4 all of it: breathe, drift, counterpoint, turn, sheen", lambda t: (frame(t, True, True, True), drift(t), base.grok_shine(t))), +] + + +def main(): + cell = base.cell_px() + marks = [base.Mark(7, cell), base.Mark(5, cell)] + print(f"\n cell {cell[0]:.1f}×{cell[1]:.1f} px; marks {marks[0].cols}×7 and {marks[1].cols}×5 cells\n") + height = 7 + 3 + sys.stdout.write("\x1b[?25l") + start = time.time() + try: + while True: + t = math.floor((time.time() - start) * base.FPS) / base.FPS + core = base.growing_core(t) + for name, make in CANDIDATES: + ring, ink, shine = make(t) + blocks = [draw(m, core, ring, ink, shine) for m in marks] + for row in range(7): + small = blocks[1][row - 1] if 1 <= row <= 5 else " " * marks[1].cols + sys.stdout.write(f" {blocks[0][row]} {small}\x1b[K\n") + name_row = f"\x1b[38;2;230;233;240m{'Plexmaton':^{marks[0].cols}}\x1b[0m" + sys.stdout.write(f" {name_row} \x1b[38;2;142;162;196m{name}\x1b[0m\x1b[K\n\n\n") + sys.stdout.write(f"\x1b[{height * len(CANDIDATES)}A") + sys.stdout.flush() + time.sleep(1 / base.FPS / 2) + except KeyboardInterrupt: + pass + finally: + sys.stdout.write(f"\x1b[{height * len(CANDIDATES)}B\x1b[?25h\x1b[0m\n") + + +if __name__ == "__main__": + main() diff --git a/.agents/spikes/conversation-chrome/mockups/mark-braille.py b/.agents/spikes/conversation-chrome/mockups/mark-braille.py new file mode 100644 index 0000000..a6ace19 --- /dev/null +++ b/.agents/spikes/conversation-chrome/mockups/mark-braille.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""The Plexmaton mark drawn in braille, the way Grok draws its logo. Ctrl-C to stop. + +Each cell is a 2x4 dot matrix, so the mark is a small bitmap: a rounded-square frame around a +circular centre, corrected for this terminal's cell so it comes out square. Animated at 15 frames +a second, the product's motion clock. +""" +import fcntl, math, struct, sys, termios, time + +BLUE, PURPLE, TEXT, MUTED, GROUND = (130, 180, 240), (139, 92, 246), (230, 233, 240), (142, 162, 196), (17, 19, 28) +DOTS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]] # [row][column] bit of each braille dot +FPS = 15 + + +def cell_px(): + try: + rows, cols, width, height = struct.unpack("HHHH", fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8)) + if width and height and cols and rows: + return width / cols, height / rows + except OSError: + pass + return 9.0, 20.0 + + +def mix(a, b, k): + return tuple(round(x + (y - x) * max(0.0, min(1.0, k))) for x, y in zip(a, b)) + + +def rounded_box(x, y, half, radius): + qx, qy = abs(x) - half + radius, abs(y) - half + radius + return math.hypot(max(qx, 0), max(qy, 0)) + min(max(qx, qy), 0) - radius + + +def superellipse(x, y, size, exponent, turn): + c, s = math.cos(turn), math.sin(turn) + x, y = x * c + y * s, -x * s + y * c + if size <= 0: + return 1.0 + return (abs(x / size) ** exponent + abs(y / size) ** exponent) ** (1 / exponent) - 1 + + +class Mark: + def __init__(self, rows, cell): + cw, ch = cell + self.rows = rows + self.cols = max(3, round(rows * ch / cw)) | 1 + self.dw, self.dh = cw / 2, ch / 4 # one dot, in pixels + self.half = min(self.cols * cw, rows * ch) / 2 # half the block's shorter side, in pixels + + def draw(self, core, shine=None): + """core(x, y) -> inside? in units of the block's half-size; shine(col, row) -> 0..1.""" + lines = [] + w, h = self.cols * 2, self.rows * 4 + cx, cy = w * self.dw / 2, h * self.dh / 2 + for row in range(self.rows): + line = "" + for col in range(self.cols): + bits, frame_dots, core_dots = 0, 0, 0 + for dy in range(4): + for dx in range(2): + px = ((col * 2 + dx + 0.5) * self.dw - cx) / self.half + py = ((row * 4 + dy + 0.5) * self.dh - cy) / self.half + ring = abs(rounded_box(px, py, 0.9, 0.36)) < 0.075 + inside = core(px, py) + if ring or inside: + bits |= DOTS[dy][dx] + frame_dots += ring + core_dots += inside and not ring + ink = BLUE if frame_dots >= core_dots else PURPLE + if shine: + ink = mix(ink, TEXT, shine(col, row, self.cols, self.rows)) + glyph = chr(0x2800 + bits) + line += f"\x1b[38;2;{ink[0]};{ink[1]};{ink[2]}m{glyph}" if bits else " " + lines.append(line + "\x1b[0m") + return lines + + +def still_core(x, y): + return math.hypot(x, y) < 0.32 + + +def grok_shine(t): + """Grok's sheen: a raised-cosine band sweeps bottom-left to top-right, then rests; a slow pulse.""" + band, cycle, sweep, strength, pulse = 0.38, 4.0, 0.32, 0.55, 0.06 + p = (t % cycle) / cycle + position = -band + min(p / sweep, 1.0) * (1 + 2 * band) + breathe = pulse * (0.5 - 0.5 * math.cos(math.tau * t / 5.0)) + + def at(col, row, cols, rows): + diag = (col + (rows - 1 - row)) / (cols + rows) + d = abs(diag - position) + return breathe + (strength * 0.5 * (1 + math.cos(math.pi * d / band)) if d < band else 0.0) + return at + + +def growing_core(t): + """H made smooth: a dot grows into a circle, turns into a rounded square and a square, spins + into a diamond and shrinks away; 3.2 s a cycle, lingering on the whole shapes.""" + p = (t % 3.2) / 3.2 + ease = lambda k: 0.5 - 0.5 * math.cos(math.pi * max(0.0, min(1.0, k))) + if p < 0.18: + size, exponent, turn = 0.04 + 0.30 * ease(p / 0.18), 2.0, 0.0 + elif p < 0.30: + size, exponent, turn = 0.34, 2.0, 0.0 + elif p < 0.48: + k = ease((p - 0.30) / 0.18) + size, exponent, turn = 0.34 - 0.04 * k, 2.0 + 6.0 * k, 0.0 + elif p < 0.60: + size, exponent, turn = 0.30, 8.0, 0.0 + elif p < 0.78: + k = ease((p - 0.60) / 0.18) + size, exponent, turn = 0.30 + 0.04 * k, 8.0, k * math.pi / 4 + elif p < 0.84: + size, exponent, turn = 0.34, 8.0, math.pi / 4 + else: + k = ease((p - 0.84) / 0.16) + size, exponent, turn = 0.34 - 0.30 * k, 8.0, math.pi / 4 + return lambda x, y: superellipse(x, y, size, exponent, turn) < 0 + + +CANDIDATES = [ + ("B1 still", lambda t: (still_core, None)), + ("B2 still, with Grok's sheen", lambda t: (still_core, grok_shine(t))), + ("B3 the centre grows and turns (H, smooth)", lambda t: (growing_core(t), None)), + ("B4 grows and turns, with the sheen", lambda t: (growing_core(t), grok_shine(t))), +] + + +def main(): + cell = cell_px() + marks = [Mark(7, cell), Mark(5, cell)] + print(f"\n cell {cell[0]:.1f}×{cell[1]:.1f} px; marks {marks[0].cols}×7 and {marks[1].cols}×5 cells\n") + height = 7 + 3 + sys.stdout.write("\x1b[?25l") + start = time.time() + try: + while True: + t = time.time() - start + t = math.floor(t * FPS) / FPS + for name, make in CANDIDATES: + core, shine = make(t) + blocks = [m.draw(core, shine) for m in marks] + for row in range(7): + big = blocks[0][row] + small_row = row - 1 + small = blocks[1][small_row] if 0 <= small_row < 5 else " " * marks[1].cols + sys.stdout.write(f" {big} {small}\x1b[K\n") + name_row = f"\x1b[38;2;{TEXT[0]};{TEXT[1]};{TEXT[2]}m{'Plexmaton':^{marks[0].cols}}\x1b[0m" + sys.stdout.write(f" {name_row} \x1b[38;2;{MUTED[0]};{MUTED[1]};{MUTED[2]}m{name}\x1b[0m\x1b[K\n\n\n") + sys.stdout.write(f"\x1b[{height * len(CANDIDATES)}A") + sys.stdout.flush() + time.sleep(1 / FPS / 2) + except KeyboardInterrupt: + pass + finally: + sys.stdout.write(f"\x1b[{height * len(CANDIDATES)}B\x1b[?25h\x1b[0m\n") + + +if __name__ == "__main__": + main() diff --git a/.agents/ui-ux.md b/.agents/ui-ux.md index bc13100..0364d77 100644 --- a/.agents/ui-ux.md +++ b/.agents/ui-ux.md @@ -171,10 +171,15 @@ other rule about input follows from this one. 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: - a fixed cell size, which is square in one font only. +- **Launch greets with the mark, once.** When Plexmaton starts on an empty conversation, the mark + plays once at its centre with the product's name beneath and then leaves: about two and a half + seconds on the one motion clock. It is drawn in braille, a rounded-square frame around a round + centre, at the size nearest square for the terminal's measured cell. Input never waits for it and + a first message ends it; `/new`, `/resume` and a reopened conversation are not greeted. The empty + conversation it leaves says nothing, the composer beneath already saying where to begin. The user + chose all of it in kitty on 2026-09-23. Rejected: a mark that stays and moves until the first + message, too insistent; box drawing around a one-cell glyph, too coarse; a fixed cell size, square + in one font only; a sentence about transcript items once it has gone, which read as a status. - A User-controlled sub-agent's input takes its rows from its **own** surface. It may never consume the rows guaranteed to the primary conversation: focusing a worker never squeezes the primary off screen. - **The composer completes the token it starts with.** `$` lists Skills and `/` lists Commands in diff --git a/crates/plexmaton-cli/src/main.rs b/crates/plexmaton-cli/src/main.rs index fb47378..b7704ee 100644 --- a/crates/plexmaton-cli/src/main.rs +++ b/crates/plexmaton-cli/src/main.rs @@ -249,6 +249,20 @@ async fn run( if let Some(path) = working_directory { workspace.set_working_directory(path); } + // The mark is squared against the terminal's own cell, when it reports one in pixels. + workspace.set_cell_size( + crossterm::terminal::window_size() + .ok() + .filter(|window| window.columns > 0 && window.rows > 0) + .map(|window| plexmaton_tui::mark::CellSize { + width: window.width / window.columns, + height: window.height / window.rows, + }), + ); + // Launch greets a new conversation once; a reopened one is not greeted (ui-ux §input). + if recovery.is_none() { + workspace.greet(std::time::Instant::now()); + } // A resumed root puts every delegation it already created back on the roster without waking // any of them (CHB-3), and merges shared entries at their durable session anchors before the // restoration confirmation takes its final presentation position (ENT-1/JRN-5). diff --git a/crates/plexmaton-tui/examples/greeting_preview.rs b/crates/plexmaton-tui/examples/greeting_preview.rs new file mode 100644 index 0000000..a085154 --- /dev/null +++ b/crates/plexmaton-tui/examples/greeting_preview.rs @@ -0,0 +1,71 @@ +//! Launch's greeting in the workspace, for review (phase 04 stage 10). +//! cargo run -p plexmaton-tui --example greeting_preview -- +//! +//! Two moments of the greeting over a new, empty conversation at three widths: the centre grown +//! into a circle, and centre and frame turned into diamonds. Drawn by the real workspace, which +//! carries the greeting on its motion clock, for a monospace cell of 9 by 20 pixels. + +use std::path::Path; +use std::time::{Duration, Instant}; + +use plexmaton_core::{ + AgentId, AgentStatus, ConversationEvent, ConversationEventEnvelope, EventSequence, +}; +use plexmaton_tui::{ConfigurationSummary, Palette, Workspace, mark::CellSize}; +use ratatui::{Terminal, backend::TestBackend}; + +#[path = "support/frame_svg.rs"] +mod frame_svg; + +type Result = std::result::Result>; + +fn main() -> Result<()> { + let directory = std::env::args() + .nth(1) + .ok_or("provide an output directory")?; + let directory = Path::new(&directory); + std::fs::create_dir_all(directory)?; + for (width, height) in [(120, 30), (88, 32), (60, 36)] { + for (name, at) in [("circle", 700), ("turned", 1850)] { + let mut workspace = Workspace::with_palette(Palette::pastel()); + workspace.emit(vec![ConversationEventEnvelope { + sequence: EventSequence::new(1), + event: ConversationEvent::AgentCreated { + agent_id: AgentId::new("primary")?, + label: "Plexmaton".into(), + status: AgentStatus::Idle, + }, + }]); + workspace.set_model(ConfigurationSummary { + provider: "local".into(), + model: "muse-spark-1.3".into(), + display_name: "Muse Spark 1.3".into(), + configured_name: "muse".into(), + reasoning_effort: plexmaton_core::ReasoningEffort::High, + }); + workspace.set_working_directory("~/plexmaton".into()); + workspace.set_cell_size(Some(CellSize { + width: 9, + height: 20, + })); + let start = Instant::now(); + workspace.greet(start); + let until = start + Duration::from_millis(at); + let mut now = start; + while let Some(deadline) = workspace.motion_deadline(now) { + if deadline > until { + break; + } + now = deadline; + workspace.advance_motion(now); + } + let mut terminal = Terminal::new(TestBackend::new(width, height))?; + workspace.draw(&mut terminal)?; + std::fs::write( + directory.join(format!("greeting-{name}-{width}.svg")), + frame_svg::svg(terminal.backend().buffer()), + )?; + } + } + Ok(()) +} diff --git a/crates/plexmaton-tui/examples/mark_greeting.rs b/crates/plexmaton-tui/examples/mark_greeting.rs new file mode 100644 index 0000000..4eb4eb1 --- /dev/null +++ b/crates/plexmaton-tui/examples/mark_greeting.rs @@ -0,0 +1,131 @@ +//! The launch greeting in the terminal it is run in, for review (phase 04 stage 10). +//! cargo run -p plexmaton-tui --example mark_greeting plays it; Enter replays, q quits +//! cargo run -p plexmaton-tui --example mark_greeting -- 0 12 30 prints those phases as text +//! +//! Drawn with the product's own geometry, timeline and colours, so what is reviewed is what ships. + +use std::io::{Read, Write}; +use std::time::{Duration, Instant}; + +use plexmaton_tui::{ + Palette, + mark::{CellSize, greeting, lines, size}, +}; +use ratatui::{ + crossterm::terminal::{disable_raw_mode, enable_raw_mode, window_size}, + style::{Color, Style}, + text::Line, +}; + +fn cell() -> Option { + window_size().ok().and_then(|window| { + (window.columns > 0 && window.rows > 0 && window.width > 0).then(|| CellSize { + width: window.width / window.columns, + height: window.height / window.rows, + }) + }) +} + +fn ansi(style: Style) -> String { + match style.fg { + Some(Color::Rgb(r, g, b)) => format!("\x1b[38;2;{r};{g};{b}m"), + _ => String::new(), + } +} + +fn paint(line: &Line<'_>, colour: bool) -> String { + line.spans + .iter() + .map(|span| { + if colour { + format!("{}{}\x1b[0m", ansi(span.style), span.content) + } else { + span.content.to_string() + } + }) + .collect() +} + +fn main() -> std::io::Result<()> { + let palette = Palette::pastel(); + let mut out = std::io::stdout(); + let phases: Vec = std::env::args() + .skip(1) + .filter_map(|arg| arg.parse().ok()) + .collect(); + if !phases.is_empty() { + let block = size( + 7, + Some(CellSize { + width: 9, + height: 20, + }), + ); + for phase in phases { + writeln!(out, "-- phase {phase}")?; + match greeting(phase) { + Some(moment) => { + for line in lines( + block, + Some(CellSize { + width: 9, + height: 20, + }), + moment, + &palette, + ) { + writeln!(out, " {}", paint(&line, false))?; + } + } + None => writeln!(out, " gone")?, + } + } + return Ok(()); + } + let cell = cell(); + let block = size(7, cell); + enable_raw_mode()?; + write!(out, "\x1b[?1049h\x1b[?25l")?; + let mut input = std::io::stdin(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut byte = [0u8; 1]; + while input.read(&mut byte).is_ok_and(|read| read == 1) { + if tx.send(byte[0]).is_err() { + break; + } + } + }); + let mut start = Instant::now(); + loop { + let phase = u16::try_from(start.elapsed().as_millis() * 15 / 1000).unwrap_or(u16::MAX); + write!(out, "\x1b[H\x1b[2J")?; + if let Some(moment) = greeting(phase) { + for (row, line) in lines(block, cell, moment, &palette).iter().enumerate() { + write!(out, "\x1b[{};4H{}", row + 3, paint(line, true))?; + } + let name = palette.mark_name_at(moment.level); + let column = 4 + usize::from(block.0).saturating_sub(9) / 2; + write!( + out, + "\x1b[{};{column}H{}Plexmaton\x1b[0m", + usize::from(block.1) + 4, + ansi(name) + )?; + } + write!( + out, + "\x1b[{};4H\x1b[38;2;142;162;196mEnter replays · q quits\x1b[0m", + usize::from(block.1) + 7 + )?; + out.flush()?; + match rx.recv_timeout(Duration::from_millis(33)) { + Ok(b'q') => break, + Ok(b'\r' | b'\n') => start = Instant::now(), + _ => {} + } + } + write!(out, "\x1b[?25h\x1b[?1049l")?; + out.flush()?; + disable_raw_mode() +} diff --git a/crates/plexmaton-tui/frames/greeting/greeting-circle-120.svg b/crates/plexmaton-tui/frames/greeting/greeting-circle-120.svg new file mode 100644 index 0000000..ce472dd --- /dev/null +++ b/crates/plexmaton-tui/frames/greeting/greeting-circle-120.svg @@ -0,0 +1 @@ +┌ Plexmaton ───────────────────────────────────────────────────────────────────────────────────────────────────────────┐│││││││││││││││││⢠⣴⠾⠛⠛⠛⠛⠛⠛⠛⠛⠛⠷⣦⡄││⣿⠁⠈⣿││⣿⣠⣴⣶⣦⣄⣿││⣿⣿⣿⣿⣿⣿⣿││⣿⠙⠻⠿⠟⠋⣿││⣿⡀⢀⣿││⠘⠻⢶⣤⣤⣤⣤⣤⣤⣤⣤⣤⡶⠟⠃││││Plexmaton│││││││││││││││││──Muse Spark 1.3 · high───────────────────────────────────────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/greeting/greeting-circle-60.svg b/crates/plexmaton-tui/frames/greeting/greeting-circle-60.svg new file mode 100644 index 0000000..98d9346 --- /dev/null +++ b/crates/plexmaton-tui/frames/greeting/greeting-circle-60.svg @@ -0,0 +1 @@ +┌ Plexmaton ───────────────────────────────────────────────┐│││││││││││││││││││││││⢠⣴⠾⠛⠛⠛⠛⠛⠛⠛⠛⠛⠷⣦⡄││⣿⠁⠈⣿││⣿⣠⣴⣶⣦⣄⣿││⣿⣿⣿⣿⣿⣿⣿││⣿⠙⠻⠿⠟⠋⣿││⣿⡀⢀⣿││⠘⠻⢶⣤⣤⣤⣤⣤⣤⣤⣤⣤⡶⠟⠃││││Plexmaton│││││││││││││││││││││││──Muse Spark 1.3 · high───────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/greeting/greeting-circle-88.svg b/crates/plexmaton-tui/frames/greeting/greeting-circle-88.svg new file mode 100644 index 0000000..57ba816 --- /dev/null +++ b/crates/plexmaton-tui/frames/greeting/greeting-circle-88.svg @@ -0,0 +1 @@ +┌ Plexmaton ───────────────────────────────────────────────────────────────────────────┐│││││││││││││││││││⢠⣴⠾⠛⠛⠛⠛⠛⠛⠛⠛⠛⠷⣦⡄││⣿⠁⠈⣿││⣿⣠⣴⣶⣦⣄⣿││⣿⣿⣿⣿⣿⣿⣿││⣿⠙⠻⠿⠟⠋⣿││⣿⡀⢀⣿││⠘⠻⢶⣤⣤⣤⣤⣤⣤⣤⣤⣤⡶⠟⠃││││Plexmaton│││││││││││││││││││──Muse Spark 1.3 · high───────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/greeting/greeting-turned-120.svg b/crates/plexmaton-tui/frames/greeting/greeting-turned-120.svg new file mode 100644 index 0000000..012f7d6 --- /dev/null +++ b/crates/plexmaton-tui/frames/greeting/greeting-turned-120.svg @@ -0,0 +1 @@ +┌ Plexmaton ───────────────────────────────────────────────────────────────────────────────────────────────────────────┐│││││││││││││││││⢀⣀⣤⣀⡀││⢀⣴⠟⠉⠉⠉⠻⣦⡀││⢠⡾⠟⠁⣠⣴⣿⣦⣄⠈⠻⢷⡄││⣿⡇⢾⣿⣿⣿⣿⣿⡷⢸⣿││⠘⢷⣦⡀⠙⠻⣿⠟⠋⢀⣴⡾⠃││⠈⠻⣦⣀⣀⣀⣴⠟⠁││⠈⠉⠛⠉⠁││││Plexmaton│││││││││││││││││──Muse Spark 1.3 · high───────────────────────────────────────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/greeting/greeting-turned-60.svg b/crates/plexmaton-tui/frames/greeting/greeting-turned-60.svg new file mode 100644 index 0000000..4671fc8 --- /dev/null +++ b/crates/plexmaton-tui/frames/greeting/greeting-turned-60.svg @@ -0,0 +1 @@ +┌ Plexmaton ───────────────────────────────────────────────┐│││││││││││││││││││││││⢀⣀⣤⣀⡀││⢀⣴⠟⠉⠉⠉⠻⣦⡀││⢠⡾⠟⠁⣠⣴⣿⣦⣄⠈⠻⢷⡄││⣿⡇⢾⣿⣿⣿⣿⣿⡷⢸⣿││⠘⢷⣦⡀⠙⠻⣿⠟⠋⢀⣴⡾⠃││⠈⠻⣦⣀⣀⣀⣴⠟⠁││⠈⠉⠛⠉⠁││││Plexmaton│││││││││││││││││││││││──Muse Spark 1.3 · high───────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/greeting/greeting-turned-88.svg b/crates/plexmaton-tui/frames/greeting/greeting-turned-88.svg new file mode 100644 index 0000000..c58f130 --- /dev/null +++ b/crates/plexmaton-tui/frames/greeting/greeting-turned-88.svg @@ -0,0 +1 @@ +┌ Plexmaton ───────────────────────────────────────────────────────────────────────────┐│││││││││││││││││││⢀⣀⣤⣀⡀││⢀⣴⠟⠉⠉⠉⠻⣦⡀││⢠⡾⠟⠁⣠⣴⣿⣦⣄⠈⠻⢷⡄││⣿⡇⢾⣿⣿⣿⣿⣿⡷⢸⣿││⠘⢷⣦⡀⠙⠻⣿⠟⠋⢀⣴⡾⠃││⠈⠻⣦⣀⣀⣀⣴⠟⠁││⠈⠉⠛⠉⠁││││Plexmaton│││││││││││││││││││──Muse Spark 1.3 · high───────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/skill-picker-medium.txt b/crates/plexmaton-tui/frames/skill-picker-medium.txt index 60e96be..3b4a30b 100644 --- a/crates/plexmaton-tui/frames/skill-picker-medium.txt +++ b/crates/plexmaton-tui/frames/skill-picker-medium.txt @@ -1,5 +1,5 @@ ┌ Plexmaton ──────────────────────────────────────────────────────────────────────────────────┐ -│Agent is active; no transcript item has started yet. │ +│ │ │ │ │ │ │ │ diff --git a/crates/plexmaton-tui/frames/skill-picker-narrow.txt b/crates/plexmaton-tui/frames/skill-picker-narrow.txt index 325ac1d..c1eba88 100644 --- a/crates/plexmaton-tui/frames/skill-picker-narrow.txt +++ b/crates/plexmaton-tui/frames/skill-picker-narrow.txt @@ -1,5 +1,5 @@ ┌ Plexmaton ───────────────────────────────────────────────┐ -│Agent is active; no transcript item has started yet. │ +│ │ │ │ │ │ │ │ diff --git a/crates/plexmaton-tui/frames/skill-picker-wide.txt b/crates/plexmaton-tui/frames/skill-picker-wide.txt index 52fdb53..5085907 100644 --- a/crates/plexmaton-tui/frames/skill-picker-wide.txt +++ b/crates/plexmaton-tui/frames/skill-picker-wide.txt @@ -1,5 +1,5 @@ ┌ Plexmaton ───────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -│Agent is active; no transcript item has started yet. │ +│ │ │ │ │ │ │ │ diff --git a/crates/plexmaton-tui/src/content_transcript.rs b/crates/plexmaton-tui/src/content_transcript.rs index 1c90a67..701f7af 100644 --- a/crates/plexmaton-tui/src/content_transcript.rs +++ b/crates/plexmaton-tui/src/content_transcript.rs @@ -485,12 +485,16 @@ const fn text_treatment(item: &TranscriptItemView) -> (Option<(&'static str, Rol /// /// An empty panel, a panel waiting for its first event, and a panel whose agent has gone all look /// the same and mean different things, so none of them is left to be inferred from blank rows. +/// The user's own empty conversation says nothing: launch greets it, the composer beneath already +/// says where to begin, and the user chose blank on 2026-09-23 over a sentence about transcript +/// items. pub(crate) fn conversation_placeholder( palette: &Palette, surface: SurfaceId, has_agent: bool, ) -> Vec> { let message = match (surface, has_agent) { + (SurfaceId::Transcript, true) => return Vec::new(), (_, true) => "Agent is active; no transcript item has started yet.", (SurfaceId::Inspector, false) => "That agent is no longer in the roster.", (_, false) => "Waiting for the first semantic event…", diff --git a/crates/plexmaton-tui/src/lib.rs b/crates/plexmaton-tui/src/lib.rs index 000df7f..7c9df1b 100644 --- a/crates/plexmaton-tui/src/lib.rs +++ b/crates/plexmaton-tui/src/lib.rs @@ -62,3 +62,8 @@ pub use workspace::{ Workspace, }; pub mod math; + +/// The mark: its block for the terminal's cell, the greeting's timeline, and its rows at a moment. +pub mod mark { + pub use crate::render::mark::{CellSize, Moment, greeting, lines, size}; +} diff --git a/crates/plexmaton-tui/src/render/mark.rs b/crates/plexmaton-tui/src/render/mark.rs new file mode 100644 index 0000000..9a7534c --- /dev/null +++ b/crates/plexmaton-tui/src/render/mark.rs @@ -0,0 +1,393 @@ +//! The mark: a rounded-square frame around a round centre, drawn in braille (phase 04 stage 10). +//! +//! Each cell is a two-by-four dot matrix, so the mark is one small bitmap, the way Grok draws its +//! logo. Its width is chosen from the terminal's cell so the block comes out square, and every +//! dot is placed in the cell's own proportions, so a circle is round in any font. One moment of +//! it is a [`Moment`]; [`greeting`] is the timeline launch plays once. Presentation only. + +use std::f32::consts::{FRAC_PI_4, PI, SQRT_2}; + +use ratatui::text::{Line, Span}; + +use crate::theme::Palette; + +/// One terminal cell, in pixels, as the terminal reports it. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CellSize { + pub width: u16, + pub height: u16, +} + +/// A common monospace cell, 0.6 em by 1.32 em, for a terminal that reports none. +const MONOSPACE: CellSize = CellSize { + width: 5, + height: 11, +}; + +/// The mark's block for `rows` rows, made odd and at least three: the square width for `cell` +/// rounded to the nearest column and then up to an odd count, so the block is square or a little +/// wider and the mark, sized by its height, keeps a margin at each side. A common monospace cell +/// stands in when the terminal reports none. +#[must_use] +pub fn size(rows: u16, cell: Option) -> (u16, u16) { + let rows = rows.max(3) | 1; + let cell = known(cell); + let tall = u32::from(rows) * u32::from(cell.height); + let width = u32::from(cell.width); + let columns = ((2 * tall + width) / (2 * width)) | 1; + (u16::try_from(columns.clamp(3, 41)).unwrap_or(41), rows) +} + +fn known(cell: Option) -> CellSize { + cell.filter(|cell| cell.width > 0 && cell.height > 0) + .unwrap_or(MONOSPACE) +} + +/// One moment of the mark. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Moment { + /// The centre's size as a share of the block's half-side; zero draws no centre. + pub core: f32, + /// How square the centre is: 2 is a circle, 8 a square with softened corners. + pub roundness: f32, + /// How far the centre has turned, from upright (0) to a diamond (1). + pub core_turn: f32, + /// How far the frame has turned, likewise. + pub frame_turn: f32, + /// Where a passing sheen's band sits along the diagonal, bottom-left 0 to top-right 1. + pub sheen: Option, + /// How present the mark is: 0 gone, 1 full. + pub level: f32, +} + +impl Moment { + /// The mark at rest: a round centre in an upright frame. + pub const STILL: Self = Self { + core: 0.32, + roundness: 2.0, + core_turn: 0.0, + frame_turn: 0.0, + sheen: None, + level: 1.0, + }; +} + +/// The motion clock's phases a second (MOT-1). +const PHASES_PER_SECOND: f32 = 15.0; +const FADE_IN: f32 = 0.25; +const PLAY: f32 = 2.0; +const FADE_OUT: f32 = 0.3; +/// Half the sheen's band, along the diagonal. +const BAND: f32 = 0.38; + +fn ease(share: f32) -> f32 { + 0.5 - 0.5 * (PI * share.clamp(0.0, 1.0)).cos() +} + +/// The greeting `phase` motion phases after it began, or `None` once it has left. +/// +/// The frame fades in; the centre grows from a dot into a circle, becomes a rounded square and a +/// square, turns into a diamond while the frame turns with it, and shrinks away as the frame turns +/// back; a sheen passes once; frame and name fade out. About 2.5 s. +#[must_use] +pub fn greeting(phase: u16) -> Option { + let seconds = f32::from(phase) / PHASES_PER_SECOND; + if seconds >= FADE_IN + PLAY + FADE_OUT { + return None; + } + let level = if seconds < FADE_IN { + seconds / FADE_IN + } else if seconds > FADE_IN + PLAY { + 1.0 - (seconds - FADE_IN - PLAY) / FADE_OUT + } else { + 1.0 + }; + let playing = (seconds - FADE_IN).clamp(0.0, PLAY); + let share = playing / PLAY; + let sheen_at = (playing - 0.6) / 0.8; + let mut moment = centre_at(share); + moment.sheen = (0.0..=1.0) + .contains(&sheen_at) + .then(|| -BAND + sheen_at * (1.0 + 2.0 * BAND)); + moment.level = level; + if seconds >= FADE_IN + PLAY { + moment.core = 0.0; + } + Some(moment) +} + +/// The chosen cycle at `share` of its way through: grow, square, turn, shrink. +fn centre_at(share: f32) -> Moment { + let (core, roundness, core_turn) = match share { + p if p < 0.18 => (0.04 + 0.30 * ease(p / 0.18), 2.0, 0.0), + p if p < 0.30 => (0.34, 2.0, 0.0), + p if p < 0.48 => { + let k = ease((p - 0.30) / 0.18); + (0.34 - 0.04 * k, 2.0 + 6.0 * k, 0.0) + } + p if p < 0.60 => (0.30, 8.0, 0.0), + p if p < 0.78 => { + let k = ease((p - 0.60) / 0.18); + (0.30 + 0.04 * k, 8.0, k) + } + p if p < 0.84 => (0.34, 8.0, 1.0), + p => (0.34 - 0.30 * ease((p - 0.84) / 0.16), 8.0, 1.0), + }; + let frame_turn = match share { + p if p < 0.60 => 0.0, + p if p < 0.78 => ease((p - 0.60) / 0.18), + p if p < 0.84 => 1.0, + p => 1.0 - ease((p - 0.84) / 0.16), + }; + Moment { + core, + roundness, + core_turn, + frame_turn, + sheen: None, + level: 1.0, + } +} + +/// What one dot of the block belongs to. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Dot { + Empty, + Frame, + Core, +} + +fn turned(x: f32, y: f32, turn: f32) -> (f32, f32) { + let (sin, cos) = (turn * FRAC_PI_4).sin_cos(); + (x * cos + y * sin, -x * sin + y * cos) +} + +fn rounded_box(x: f32, y: f32, half: f32, radius: f32) -> f32 { + let (qx, qy) = (x.abs() - half + radius, y.abs() - half + radius); + qx.max(0.0).hypot(qy.max(0.0)) + qx.max(qy).min(0.0) - radius +} + +/// The block as dots, `columns * 2` wide and `rows * 4` tall, in the cell's own proportions. +fn dots((columns, rows): (u16, u16), cell: Option, moment: Moment) -> Vec> { + let cell = known(cell); + let (dot_width, dot_height) = (f32::from(cell.width) / 2.0, f32::from(cell.height) / 4.0); + let (wide, tall) = (usize::from(columns) * 2, usize::from(rows) * 4); + let half = (f32::from(columns) * f32::from(cell.width)) + .min(f32::from(rows) * f32::from(cell.height)) + / 2.0; + let (centre_x, centre_y) = ( + wide as f32 * dot_width / 2.0, + tall as f32 * dot_height / 2.0, + ); + // A turned frame reaches further along its diagonal, so it shrinks to stay inside the block. + let spin = (moment.frame_turn * 2.0 * FRAC_PI_4).sin(); + let frame_half = 0.9 / (1.0 + (SQRT_2 - 1.0) * spin * spin); + (0..tall) + .map(|row| { + (0..wide) + .map(|column| { + let x = ((column as f32 + 0.5) * dot_width - centre_x) / half; + let y = ((row as f32 + 0.5) * dot_height - centre_y) / half; + let (fx, fy) = turned(x, y, moment.frame_turn); + if rounded_box(fx, fy, frame_half, frame_half.min(0.36)).abs() < 0.075 { + return Dot::Frame; + } + let (cx, cy) = turned(x, y, moment.core_turn); + let reach = ((cx / moment.core).abs().powf(moment.roundness) + + (cy / moment.core).abs().powf(moment.roundness)) + .powf(1.0 / moment.roundness); + if moment.core > 0.0 && reach < 1.0 { + Dot::Core + } else { + Dot::Empty + } + }) + .collect() + }) + .collect() +} + +/// The braille bit of each dot in a cell, by row and then column. +const BRAILLE: [[u32; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]]; + +/// The mark at `moment` as rows of braille cells, coloured by the palette. +#[must_use] +pub fn lines( + block: (u16, u16), + cell: Option, + moment: Moment, + palette: &Palette, +) -> Vec> { + let dots = dots(block, cell, moment); + let (columns, rows) = (usize::from(block.0), usize::from(block.1)); + (0..rows) + .map(|row| { + let spans: Vec> = (0..columns) + .map(|column| { + let (mut bits, mut frame, mut core) = (0, 0, 0); + for (dy, row_bits) in BRAILLE.iter().enumerate() { + for (dx, bit) in row_bits.iter().enumerate() { + match dots[row * 4 + dy][column * 2 + dx] { + Dot::Empty => {} + Dot::Frame => { + bits |= bit; + frame += 1; + } + Dot::Core => { + bits |= bit; + core += 1; + } + } + } + } + if bits == 0 { + return Span::raw(" "); + } + let glyph = char::from_u32(0x2800 + bits).unwrap_or(' '); + let style = if frame >= core { + let diagonal = (column + rows - 1 - row) as f32 / (columns + rows) as f32; + palette.mark_frame_at(moment.level, shine(moment.sheen, diagonal)) + } else { + palette.mark_core_at(moment.level) + }; + Span::styled(glyph.to_string(), style) + }) + .collect(); + Line::from(spans) + }) + .collect() +} + +/// The greeting at `moment` for a conversation `rows` tall and `width` wide: the mark centred +/// with the product's name beneath, seven rows where there is room and five or three where there +/// is less, or nothing where not even three fit. +pub(crate) fn greeting_lines( + moment: Moment, + cell: Option, + rows: u16, + width: u16, + palette: &Palette, +) -> Vec> { + let Some(block) = [7, 5, 3] + .into_iter() + .map(|tall| size(tall, cell)) + .find(|&(columns, tall)| tall + 2 <= rows && columns <= width) + else { + return Vec::new(); + }; + let mut drawn = vec![Line::default(); usize::from((rows - block.1 - 2) / 2)]; + drawn.extend( + lines(block, cell, moment, palette) + .into_iter() + .map(Line::centered), + ); + drawn.push(Line::default()); + drawn.push(Line::styled("Plexmaton", palette.mark_name_at(moment.level)).centered()); + drawn +} + +/// How much a passing sheen at `band` lights a cell at `diagonal`: a raised cosine, as Grok's. +fn shine(band: Option, diagonal: f32) -> f32 { + band.map_or(0.0, |band| { + let distance = (diagonal - band).abs(); + if distance < BAND { + 0.55 * 0.5 * (1.0 + (PI * distance / BAND).cos()) + } else { + 0.0 + } + }) +} + +#[cfg(test)] +mod tests { + use unicode_width::UnicodeWidthStr; + + use super::{CellSize, Dot, Moment, dots, greeting, lines, size}; + use crate::theme::Palette; + + /// The block is square for the cell the terminal reports, as measured in the spike: a + /// Sarasa Term cell of 8 by 23 pixels gives 9×3 and 15×5. + #[test] + fn the_block_is_nearest_square_for_the_reported_cell() { + let sarasa = Some(CellSize { + width: 8, + height: 23, + }); + assert_eq!(size(3, sarasa), (9, 3)); + assert_eq!(size(5, sarasa), (15, 5)); + } + + /// With no report, a common monospace cell stands in: 7×3, 11×5 and 15×7. + #[test] + fn a_terminal_that_reports_no_cell_gets_monospace_proportions() { + assert_eq!(size(3, None), (7, 3)); + assert_eq!(size(5, None), (11, 5)); + assert_eq!(size(7, None), (15, 7)); + let nothing = Some(CellSize { + width: 0, + height: 0, + }); + assert_eq!(size(5, nothing), (11, 5), "a zero report is no report"); + } + + /// At rest the mark is symmetric about its centre on both axes, so it sits where it is put. + #[test] + fn the_still_mark_is_symmetric_about_its_centre() { + let cell = Some(CellSize { + width: 9, + height: 20, + }); + for rows in [5, 7] { + let grid = dots(size(rows, cell), cell, Moment::STILL); + let (tall, wide) = (grid.len(), grid[0].len()); + for row in 0..tall { + for column in 0..wide { + assert_eq!(grid[row][column], grid[row][wide - 1 - column]); + assert_eq!(grid[row][column], grid[tall - 1 - row][column]); + } + } + let flat = grid.iter().flatten(); + assert!(flat.clone().any(|dot| *dot == Dot::Frame)); + assert!(flat.clone().any(|dot| *dot == Dot::Core)); + } + } + + /// Every cell is a braille pattern or a space, one cell wide (MOT-2), and every row is the + /// block's width. + #[test] + fn the_mark_is_braille_one_cell_a_glyph() { + let palette = Palette::default(); + let block = size(7, None); + for phase in 0..40 { + let Some(moment) = greeting(phase) else { + continue; + }; + for line in lines(block, None, moment, &palette) { + assert_eq!(line.width(), usize::from(block.0)); + for span in &line.spans { + assert_eq!(span.content.width(), 1); + let glyph = span.content.chars().next().unwrap_or(' '); + assert!(glyph == ' ' || ('\u{2800}'..='\u{28FF}').contains(&glyph)); + } + } + } + } + + /// The greeting fades in from nothing, starts its centre as a dot, reaches the whole circle, + /// turns, ends with no centre, and leaves after about two and a half seconds. + #[test] + fn the_greeting_grows_from_a_dot_and_leaves() { + let first = greeting(0).unwrap_or(Moment::STILL); + assert!(first.level < 0.01, "it fades in from nothing"); + let dot = greeting(4).unwrap_or(Moment::STILL); + assert!(dot.core < 0.1, "the centre starts as a dot: {dot:?}"); + let moments: Vec = (0..=38).filter_map(greeting).collect(); + assert_eq!(moments.len(), 39, "every phase of the 2.55 s has a moment"); + assert!(moments.iter().any(|moment| moment.core > 0.33)); + assert!(moments.iter().any(|moment| moment.frame_turn > 0.99)); + assert!(moments.iter().any(|moment| moment.sheen.is_some())); + assert_eq!(moments.last().map(|moment| moment.core), Some(0.0)); + assert_eq!(greeting(39), None, "then it has left"); + assert_eq!(greeting(u16::MAX), None); + } +} diff --git a/crates/plexmaton-tui/src/render/mod.rs b/crates/plexmaton-tui/src/render/mod.rs index 2a32e30..81d2e42 100644 --- a/crates/plexmaton-tui/src/render/mod.rs +++ b/crates/plexmaton-tui/src/render/mod.rs @@ -8,6 +8,7 @@ mod command_inspection; mod configuration; pub(crate) mod conversation_tree; pub(crate) mod effort; +pub(crate) mod mark; mod message_actions; mod panel; pub(crate) mod permission_review; @@ -311,6 +312,21 @@ fn conversation_body( // scroll path resolves against the same one rather than against whatever was measured last. let width = inner_width(area.width); if metrics.measure_with(agent, palette, width, state.disclosure()) == 0 { + // Launch's greeting plays over the empty primary conversation, then leaves it as it was. + if surface == SurfaceId::Transcript + && let Some(moment) = state.greeting_moment() + { + return Body::Whole { + lines: mark::greeting_lines( + moment, + state.cell_size(), + visible_rows, + width, + palette, + ), + follows_tail: false, + }; + } return Body::Whole { lines: agent.note.as_ref().map_or_else( || content::conversation_placeholder(palette, surface, true), diff --git a/crates/plexmaton-tui/src/state/mod.rs b/crates/plexmaton-tui/src/state/mod.rs index 72b381d..3355cf2 100644 --- a/crates/plexmaton-tui/src/state/mod.rs +++ b/crates/plexmaton-tui/src/state/mod.rs @@ -140,6 +140,10 @@ 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, + /// How far the launch greeting has played, in motion phases, while it plays; presentation only. + greeting: Option, + /// The terminal's cell in pixels, when it reports one; the mark is squared against it. + cell: Option, /// When current work began and was last heard; presentation only. activity: activity::ActivityClock, inspector: Inspector, diff --git a/crates/plexmaton-tui/src/state/motion.rs b/crates/plexmaton-tui/src/state/motion.rs index d303358..336294b 100644 --- a/crates/plexmaton-tui/src/state/motion.rs +++ b/crates/plexmaton-tui/src/state/motion.rs @@ -1,6 +1,8 @@ -//! The motion clock's phase, held where every renderer can read it (MOT-1). +//! The motion clock's phase, held where every renderer can read it (MOT-1), and the launch +//! greeting that plays on it. use super::ViewState; +use crate::render::mark::{self, CellSize, Moment}; impl ViewState { pub(crate) const fn motion_phase(&self) -> u16 { @@ -10,4 +12,34 @@ impl ViewState { pub(crate) const fn set_motion_phase(&mut self, phase: u16) { self.motion_phase = phase; } + + /// The greeting's moment, while it plays over a primary conversation that is still empty. + pub(crate) fn greeting_moment(&self) -> Option { + if !self.primary_is_empty() { + return None; + } + self.greeting.and_then(mark::greeting) + } + + pub(crate) const fn greeting_phase(&self) -> Option { + self.greeting + } + + pub(crate) const fn set_greeting(&mut self, phase: Option) { + self.greeting = phase; + } + + /// Whether the primary conversation holds nothing yet: the only place the greeting plays. + pub(crate) fn primary_is_empty(&self) -> bool { + self.primary_agent() + .is_some_and(|agent| agent.entries().next().is_none()) + } + + pub(crate) const fn cell_size(&self) -> Option { + self.cell + } + + pub(crate) const fn set_cell_size(&mut self, cell: Option) { + self.cell = cell; + } } diff --git a/crates/plexmaton-tui/src/theme.rs b/crates/plexmaton-tui/src/theme.rs index ede2bdc..3eff2dc 100644 --- a/crates/plexmaton-tui/src/theme.rs +++ b/crates/plexmaton-tui/src/theme.rs @@ -11,6 +11,7 @@ use ratatui::style::{Color, Modifier, Style}; pub(crate) mod code; mod effort; +mod mark; mod markdown; #[cfg(test)] mod tests; @@ -284,6 +285,7 @@ pub struct Palette { selection: Style, chosen: Style, user_message: Style, + mark: mark::MarkColours, } impl Palette { @@ -330,6 +332,7 @@ impl Palette { .bg(slots.ground) .add_modifier(Modifier::BOLD), user_message: Style::new().bg(lifted(slots.ground, slots.line)), + mark: mark::MarkColours::from_slots(&slots), } } @@ -362,6 +365,7 @@ impl Palette { selection: style(Role::Selection), chosen: style(Role::Chosen), user_message: style(Role::UserMessage), + mark: mark::MarkColours::from_slots(&Slots::designed()), } } diff --git a/crates/plexmaton-tui/src/theme/mark.rs b/crates/plexmaton-tui/src/theme/mark.rs new file mode 100644 index 0000000..f874fb5 --- /dev/null +++ b/crates/plexmaton-tui/src/theme/mark.rs @@ -0,0 +1,61 @@ +//! The mark's colours, spent from the same slots as every role so a theme reaches it too. + +use ratatui::style::{Color, Style}; + +use super::{Palette, Slots}; + +/// A blue frame lit toward the text colour by a passing sheen, around a purple centre, both +/// fading from the ground; chosen in the conversation-chrome spike. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct MarkColours { + frame: Color, + core: Color, + lit: Color, + ground: Color, +} + +impl MarkColours { + pub(super) const fn from_slots(slots: &Slots) -> Self { + Self { + frame: slots.blue, + core: slots.purple, + lit: slots.text, + ground: slots.ground, + } + } +} + +/// `from` carried `share` of the way to `to`; a colour that is not RGB switches at halfway. +fn toward(from: Color, to: Color, share: f32) -> Color { + let share = share.clamp(0.0, 1.0); + let (Color::Rgb(r, g, b), Color::Rgb(tr, tg, tb)) = (from, to) else { + return if share < 0.5 { from } else { to }; + }; + let mix = |a: u8, b: u8| { + let value = f32::from(a) + (f32::from(b) - f32::from(a)) * share; + u8::try_from(value.round() as i32).unwrap_or(b) + }; + Color::Rgb(mix(r, tr), mix(g, tg), mix(b, tb)) +} + +impl Palette { + /// The frame at `level` of its presence, lit by `shine` of a passing sheen. + #[must_use] + pub fn mark_frame_at(&self, level: f32, shine: f32) -> Style { + let colours = self.mark; + let frame = toward(colours.frame, colours.lit, shine); + Style::new().fg(toward(colours.ground, frame, level)) + } + + /// The centre at `level` of its presence. + #[must_use] + pub fn mark_core_at(&self, level: f32) -> Style { + Style::new().fg(toward(self.mark.ground, self.mark.core, level)) + } + + /// The product's name beneath the mark, at `level` of its presence. + #[must_use] + pub fn mark_name_at(&self, level: f32) -> Style { + Style::new().fg(toward(self.mark.ground, self.mark.lit, level)) + } +} diff --git a/crates/plexmaton-tui/src/workspace.rs b/crates/plexmaton-tui/src/workspace.rs index 26a3f4f..03ef99b 100644 --- a/crates/plexmaton-tui/src/workspace.rs +++ b/crates/plexmaton-tui/src/workspace.rs @@ -45,6 +45,8 @@ mod effort; #[cfg(test)] mod effort_tests; #[cfg(test)] +mod greeting_tests; +#[cfg(test)] mod group_spacing_tests; mod hover; #[cfg(test)] @@ -224,6 +226,8 @@ pub struct Workspace { copy: copy::CopyPreparation, native: crate::math::NativeFrame, motion: Option, + /// When the launch greeting began, while it plays; the motion clock carries it forward. + greeting: Option, } impl Workspace { diff --git a/crates/plexmaton-tui/src/workspace/greeting_tests.rs b/crates/plexmaton-tui/src/workspace/greeting_tests.rs new file mode 100644 index 0000000..5bee122 --- /dev/null +++ b/crates/plexmaton-tui/src/workspace/greeting_tests.rs @@ -0,0 +1,126 @@ +//! Launch's greeting: once, over an empty conversation, on the motion clock, then gone. + +use std::time::{Duration, Instant}; + +use super::*; +use plexmaton_core::{ + AgentStatus, ConversationEvent, EventSequence, TranscriptItemId, TranscriptRole, +}; +use ratatui::backend::TestBackend; + +const PRIMARY: &str = "primary"; + +/// A new, empty, idle conversation, as launch opens onto. +fn fresh() -> (Workspace, Terminal) { + let mut workspace = Workspace::default(); + workspace.emit(vec![ConversationEventEnvelope { + sequence: EventSequence::new(1), + event: ConversationEvent::AgentCreated { + agent_id: AgentId::new(PRIMARY).expect("agent"), + label: "Plexmaton".into(), + status: AgentStatus::Idle, + }, + }]); + let terminal = Terminal::new(TestBackend::new(100, 30)).expect("terminal"); + (workspace, terminal) +} + +fn conversation(workspace: &mut Workspace, terminal: &mut Terminal) -> String { + workspace.settled_draw(terminal).expect("draw"); + crate::test_support::region_text( + terminal.backend().buffer(), + workspace + .surfaces + .get(crate::SurfaceId::Transcript) + .expect("conversation") + .bounds, + ) +} + +fn braille(text: &str) -> bool { + text.chars() + .any(|glyph| ('\u{2801}'..='\u{28FF}').contains(&glyph)) +} + +/// Runs the motion clock from `start` to `until`, one wake at a time, as the event loop does. +fn run(workspace: &mut Workspace, start: Instant, until: Duration) { + let mut now = start; + while now <= start + until { + let Some(deadline) = workspace.motion_deadline(now) else { + return; + }; + now = deadline.max(now); + workspace.advance_motion(now); + } +} + +/// Launch greets an empty conversation with the mark and its name, on the motion clock; after +/// about two and a half seconds it has left and wakes the clock no more. +#[test] +fn launch_greets_once_and_leaves() { + let (mut workspace, mut terminal) = fresh(); + let start = Instant::now(); + workspace.greet(start); + run(&mut workspace, start, Duration::from_millis(1200)); + let drawn = conversation(&mut workspace, &mut terminal); + assert!(braille(&drawn), "the mark is drawn: {drawn}"); + assert!(drawn.contains("Plexmaton"), "{drawn}"); + + run(&mut workspace, start, Duration::from_millis(3000)); + let drawn = conversation(&mut workspace, &mut terminal); + assert!(!braille(&drawn), "the mark has left: {drawn}"); + assert_eq!( + workspace.motion_deadline(start + Duration::from_secs(4)), + None, + "nothing is left moving" + ); +} + +/// A conversation that is never greeted, such as one reopened, never draws the mark. +#[test] +fn a_conversation_launch_did_not_greet_shows_no_mark() { + let (mut workspace, mut terminal) = fresh(); + assert!(!braille(&conversation(&mut workspace, &mut terminal))); + assert_eq!(workspace.motion_deadline(Instant::now()), None); +} + +/// The first message ends the greeting at once, and for good: a later empty conversation is not +/// greeted. +#[test] +fn the_first_message_ends_the_greeting_for_good() { + let (mut workspace, mut terminal) = fresh(); + let start = Instant::now(); + workspace.greet(start); + run(&mut workspace, start, Duration::from_millis(500)); + assert!(braille(&conversation(&mut workspace, &mut terminal))); + + let agent_id = AgentId::new(PRIMARY).expect("agent"); + let item_id = TranscriptItemId::new("first").expect("item"); + workspace.emit(vec![ + ConversationEventEnvelope { + sequence: EventSequence::new(2), + event: ConversationEvent::TranscriptItemStarted { + agent_id: agent_id.clone(), + item_id: item_id.clone(), + role: TranscriptRole::User, + }, + }, + ConversationEventEnvelope { + sequence: EventSequence::new(3), + event: ConversationEvent::TranscriptDelta { + agent_id, + item_id, + item_revision: 1, + text: "hello".into(), + }, + }, + ]); + let drawn = conversation(&mut workspace, &mut terminal); + assert!(!braille(&drawn), "{drawn}"); + assert!(drawn.contains("hello")); + assert_eq!( + workspace.greeting, None, + "nothing is left to resume on an empty conversation" + ); + assert_eq!(workspace.state.greeting_phase(), None); +} diff --git a/crates/plexmaton-tui/src/workspace/motion.rs b/crates/plexmaton-tui/src/workspace/motion.rs index f120e0d..7923ccd 100644 --- a/crates/plexmaton-tui/src/workspace/motion.rs +++ b/crates/plexmaton-tui/src/workspace/motion.rs @@ -21,7 +21,41 @@ 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.state.activity_moves() + self.effort_moving() + || self.state.activity_moves() + || self.state.greeting_moment().is_some() + } + + /// Greet the user with the mark, once: launch calls this when it opens onto an empty + /// conversation, and nothing else does, so `/new` and a reopened conversation are not greeted. + pub fn greet(&mut self, now: Instant) { + self.greeting = Some(now); + self.state.set_greeting(Some(0)); + } + + /// The terminal's cell in pixels, so the mark comes out square; `None` when it reports none. + pub fn set_cell_size(&mut self, cell: Option) { + self.state.set_cell_size(cell); + } + + /// Moves the greeting to `now`, ending it once it has played or the conversation has begun. + /// Reports whether what it draws changed. + pub(super) fn advance_greeting(&mut self, now: Instant) -> bool { + let Some(start) = self.greeting else { + return false; + }; + let phase = u16::try_from(now.saturating_duration_since(start).as_millis() * 15 / 1000) + .unwrap_or(u16::MAX); + if !self.state.primary_is_empty() || crate::render::mark::greeting(phase).is_none() { + self.greeting = None; + self.state.set_greeting(None); + return true; + } + if self.state.greeting_phase() == Some(phase) { + return false; + } + self.state.set_greeting(Some(phase)); + true } /// Arm the one shared deadline while anything visible moves; nothing moving owns no wake. @@ -56,6 +90,9 @@ impl Workspace { (now.saturating_duration_since(motion.start).as_millis() * 15 / 1000 % CYCLE) as u16; motion.next = now + TICK; self.state.tick_activity(now); + if self.advance_greeting(now) { + self.painted = None; + } 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 index a0f375f..12997a6 100644 --- a/crates/plexmaton-tui/src/workspace/producer.rs +++ b/crates/plexmaton-tui/src/workspace/producer.rs @@ -30,6 +30,11 @@ impl Workspace { let _outcome = self.state.apply(envelope); } self.state.observe_activity(now, heard); + // The first entry ends the greeting for good, so a later empty conversation is not greeted. + if self.greeting.is_some() && !self.state.primary_is_empty() { + self.greeting = None; + self.state.set_greeting(None); + } if self.state.revision() != before { self.validate_text_selection(); self.reconcile_copy(); diff --git a/scripts/smoke-tui.py b/scripts/smoke-tui.py index a46b81b..b5d0f93 100755 --- a/scripts/smoke-tui.py +++ b/scripts/smoke-tui.py @@ -243,6 +243,12 @@ def run_smoke(model_url: str) -> int: _, full_frame = repaint(master, captured, EXPECTED_ON_FULL_FRAME) click(master, CLICK_IN_RESIZED_COMPOSER, captured, cursor=True) repaint(master, captured, EXPECTED_ON_FULL_FRAME) + # Launch greets a new conversation for about 2.5 s, repainting as it moves; a check that + # nothing repaints waits for the braille mark to leave. + read_until(master, captured, + lambda: not any("\u2800" <= glyph <= "\u28ff" + for glyph in rendered_screen(bytes(captured), RESIZED)), + timeout=5.0, description="launch's greeting to leave") on_chrome = click(master, CLICK_IN_STATUS, captured) on_transcript = click(master, CLICK_IN_TRANSCRIPT, captured, cursor=False)