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)