diff --git a/CHANGELOG.md b/CHANGELOG.md index b64fc30e..f4af0865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,19 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## 0.1.9 + ### Changed +- Focus accents use green `#1F4945` with contrast backing for legible focused controls; primary copy remains white and success/error diagnostics keep their semantic colors. +- README `docs/media/intro.gif` is an offline-rendered macOS desktop animation of the current TUI, with no live coding-service requests. - `/model` shows Effort radios **Low | Medium | High**; **Tab** cycles them. `/effort` opens that same picker (no A★ / standalone effort list). - The TUI enters the **alternate screen** by default (`alternate_screen` always). Interactive launch takes the full viewport. Opt out with `cortex --no-alternate-screen` or `[tui] alternate_screen = false` to stay inline. - Empty-session splash is `Welcome to Cortex, the coding agent CLI` plus `v{package version} · / commands · @ files · ! shell · & cloud`. After the first user turn the splash is dropped (composer + footer only). No mascot, no painted `> cortex` shell lines. - Composer lock: empty is `> ` + white block at input col 0 + dim `Plan, search, build anything` after that cell (never a white rect after the placeholder). Blink-off (~530ms) hides the block so the placeholder starts at col 0. Typed copy is `#F5F5F5` with the block at the caret. -### Changed -- README `docs/media/intro.gif` is the signed lock TUI at 120×40 (1232×912): splash (`Welcome to Cortex, the coding agent CLI`, dual hairline, violet `>`, “Plan, search, build anything”) → typing the rate-limit prompt → working (`Cortex Mini 1 · Agent`). The retired mint-mascot welcome card is gone from the banner. -- Focused composer `>` is violet `#A78BFA`; past user `>` stays white. Interrupt `× Stopped`, quota title, failed MCP `x`, and sandbox deny paint error red `#F87171`. Primary copy is `#F5F5F5`. MCP connect/drop and sandbox deny are live, not painted-only. -- Gray chrome with one accent, replacing the violet wash: the background is still never painted (`Color::Reset`), structure comes from gray — hairlines `#3A3A3A`, filled charcoal panels `#141414`, dim `#6B7280` secondary copy, white primary copy — and the Cortex violet `#A78BFA` appears only on the focused selection (the `>` caret and the selected label on the dark gray `#262626` bar; the bar is never a violet wash). Green `#4ADE80` covers `✓` success and `+N` diff additions; red and amber stay on diagnostics; the Thinking status is a muted gold. The `#221A38` wash and the interim cyan `#7DD3FC` highlight are banned everywhere +- Focused composer `>` uses the green focus palette with contrast backing; past user `>` stays white. Interrupt `× Stopped`, quota title, failed MCP `x`, and sandbox deny paint error red `#F87171`. Primary copy is `#F5F5F5`. MCP connect/drop and sandbox deny are live, not painted-only. - The composer is the Devin-style bar in every session, working and queue state: a full-width thin gray hairline above the `> ` prompt and another below it, dim placeholder, white block cursor; it follows the transcript until the transcript fills the screen - Past user turns sit on a full-width, slightly lighter gray bar behind `> prompt text` -- Login, trust, `/mode`, `/permissions`, permission prompts, plan / clear / delete confirms and questions are numbered pickers: `> 1 …` violet on the selected row, `· 2 …` white on the others, dim descriptions under the titles, `↑↓ select · ↵ confirm · esc …` hints; the sign-in screen reads `Welcome to Cortex CLI!` / `How would you like to log in?` +- Login, trust, `/mode`, `/permissions`, permission prompts, plan / clear / delete confirms and questions are numbered pickers: `> 1 …` with green focus styling on the selected row, `· 2 …` white on the others, dim descriptions under the titles, `↑↓ select · ↵ confirm · esc …` hints; the sign-in screen reads `Welcome to Cortex CLI!` / `How would you like to log in?` - `/model`, `/resume`, `/skills` and the settings hub frame their `/ Type to search` field with two hairlines; no pricing bar - The footer is model left, shortcut hint right, all gray: `Cortex Mini 1 · Agent · 92% context` … `shift+tab to cycle modes` (the palette shows its own keys there); the first-run tips sit on a filled charcoal panel - All 50 lock states recaptured at 40×12 and 120×40 on the gray chrome (raw, plus window-only macOS Terminal composites) @@ -30,9 +31,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Models show as English product names everywhere in the TUI — `Cortex Mini 1`, `Cortex 1`, `Cortex Max 1` — in the footer, `/model`, `/settings`, `/config`, session lists and the splash; served ids stay hyphenated internally - Every 40×12 and 120×40 lock fixture carries whole copy: bodies wrap at word boundaries instead of stopping at a fragment, code spans keep a trailing space (`estimateTokens(prompt) counts`, `rateLimit() checks`), code excerpts keep their indentation, list rows keep their column gaps and end in an ellipsis when shortened - Live session chrome stays complete in the empty, loading, error and no-match states (version, keystroke hints, composer, cwd + model footer); a live run says `Working · 0s · esc to interrupt` and the composer invites `Add a follow-up ↵ to queue`; the settings panel shows a real empty state; *The coding service is temporarily unavailable* is followed by what to do next -- New violet chrome lock, replacing the mint/`#1A3330` chrome: the background is never painted (`Color::Reset` — the host terminal shows through, black by default), the accent is `#A78BFA` violet on the `>` prompt, selection carets, `●` tile dots and `✓` checks, selection bars are light text on `#221A38` (never inverted), and green (`#4ADE80`) appears only on `+` diff additions — `#00F5D4` and `#00FFA3` are banned everywhere - Zero rounded frames: the wide slash popup, inline forms and overlay widgets drop their `╭╮` / rounded borders; the TUI bleeds to the terminal edges -- All 50 lock states recaptured at 40×12 and 120×40 on the violet chrome (layout, copy and wrap rules unchanged), plus a second committed set compositing each capture into a photorealistic macOS Terminal.app window under `docs/media/tui-lock/macos/` +- Sign-in docs point at `api.cortex.foundation` device login +- Stream timeouts use the product-facing coding-service error ### Added - Lock boards for typing, `/model` (compact + full), `/mode`, `/permissions`, working, and Read (states 02, 04–09) with captures at 40×12 and 120×40 @@ -43,17 +44,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Guest session as an explicit TUI login choice ### Fixed +- Terminal lifecycle cleanup restores terminal modes and screen state when interactive screens exit or fail. - Windows `install.ps1` detects CPU architecture without reading `RuntimeInformation.OSArchitecture` under StrictMode, which threw `PropertyNotFoundStrict` on Windows PowerShell 5.1 - Cancel aborts the local SSE stream and best-effort POSTs cancel (API route is still 404) - Task without a live ModelClient reports failure instead of a fake success - Remote tool rows keep their label instead of dropping arguments -### Changed -- Remaining TUI lock states recaptured on the locked gray/white chrome: mint stays on the `>` prompt, `●` success dots, `✓` checks and `+` diff additions; selection bars keep light text on `#1A3330` -- Session view accent aligned to the locked mint; user text, cursors, spinners and hint rows are gray/white -- Sign-in docs point at `api.cortex.foundation` device login -- Stream timeouts use the product-facing coding-service error - --- ## 0.0.5 diff --git a/Cargo.lock b/Cargo.lock index e154fef8..86cf85b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -955,7 +955,7 @@ dependencies = [ [[package]] name = "cortex-app-server" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "async-channel", @@ -995,7 +995,7 @@ dependencies = [ [[package]] name = "cortex-apply-patch" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "serde", @@ -1021,7 +1021,7 @@ dependencies = [ [[package]] name = "cortex-cli" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "base64", @@ -1071,7 +1071,7 @@ dependencies = [ [[package]] name = "cortex-collab" -version = "0.1.8" +version = "0.1.9" dependencies = [ "serde", "thiserror 2.0.20", @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "cortex-commands" -version = "0.1.8" +version = "0.1.9" dependencies = [ "chrono", "dirs 6.0.0", @@ -1096,7 +1096,7 @@ dependencies = [ [[package]] name = "cortex-common" -version = "0.1.8" +version = "0.1.9" dependencies = [ "clap", "cortex-protocol", @@ -1130,7 +1130,7 @@ dependencies = [ [[package]] name = "cortex-core" -version = "0.1.8" +version = "0.1.9" dependencies = [ "ahash", "anyhow", @@ -1153,7 +1153,7 @@ dependencies = [ [[package]] name = "cortex-engine" -version = "0.1.8" +version = "0.1.9" dependencies = [ "aes-gcm", "anyhow", @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "cortex-exec" -version = "0.1.8" +version = "0.1.9" dependencies = [ "cortex-engine", "cortex-protocol", @@ -1237,7 +1237,7 @@ dependencies = [ [[package]] name = "cortex-execpolicy" -version = "0.1.8" +version = "0.1.9" dependencies = [ "serde", "serde_json", @@ -1256,7 +1256,7 @@ dependencies = [ [[package]] name = "cortex-feedback" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "chrono", @@ -1269,7 +1269,7 @@ dependencies = [ [[package]] name = "cortex-file-search" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "ignore", @@ -1311,7 +1311,7 @@ dependencies = [ [[package]] name = "cortex-keyring-store" -version = "0.1.8" +version = "0.1.9" dependencies = [ "keyring", "thiserror 2.0.20", @@ -1335,7 +1335,7 @@ dependencies = [ [[package]] name = "cortex-lmstudio" -version = "0.1.8" +version = "0.1.9" dependencies = [ "reqwest", "serde", @@ -1349,7 +1349,7 @@ dependencies = [ [[package]] name = "cortex-login" -version = "0.1.8" +version = "0.1.9" dependencies = [ "aes-gcm", "anyhow", @@ -1397,7 +1397,7 @@ dependencies = [ [[package]] name = "cortex-mcp-client" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "async-trait", @@ -1413,7 +1413,7 @@ dependencies = [ [[package]] name = "cortex-mcp-server" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "async-trait", @@ -1426,7 +1426,7 @@ dependencies = [ [[package]] name = "cortex-mcp-types" -version = "0.1.8" +version = "0.1.9" dependencies = [ "schemars", "serde", @@ -1443,7 +1443,7 @@ dependencies = [ [[package]] name = "cortex-network-proxy" -version = "0.1.8" +version = "0.1.9" dependencies = [ "serde", "serde_json", @@ -1453,7 +1453,7 @@ dependencies = [ [[package]] name = "cortex-otel" -version = "0.1.8" +version = "0.1.9" dependencies = [ "cortex-common", "opentelemetry", @@ -1467,7 +1467,7 @@ dependencies = [ [[package]] name = "cortex-plugins" -version = "0.1.8" +version = "0.1.9" dependencies = [ "async-trait", "chrono", @@ -1493,14 +1493,14 @@ dependencies = [ [[package]] name = "cortex-process-hardening" -version = "0.1.8" +version = "0.1.9" dependencies = [ "libc", ] [[package]] name = "cortex-prompt-harness" -version = "0.1.8" +version = "0.1.9" dependencies = [ "chrono", "indexmap", @@ -1511,7 +1511,7 @@ dependencies = [ [[package]] name = "cortex-protocol" -version = "0.1.8" +version = "0.1.9" dependencies = [ "base64", "chrono", @@ -1556,7 +1556,7 @@ dependencies = [ [[package]] name = "cortex-sandbox" -version = "0.1.8" +version = "0.1.9" dependencies = [ "dirs 6.0.0", "landlock", @@ -1583,7 +1583,7 @@ dependencies = [ [[package]] name = "cortex-shell-snapshot" -version = "0.1.8" +version = "0.1.9" dependencies = [ "chrono", "dirs 6.0.0", @@ -1597,7 +1597,7 @@ dependencies = [ [[package]] name = "cortex-skills" -version = "0.1.8" +version = "0.1.9" dependencies = [ "chrono", "dashmap", @@ -1616,7 +1616,7 @@ dependencies = [ [[package]] name = "cortex-slack" -version = "0.1.8" +version = "0.1.9" dependencies = [ "async-trait", "axum", @@ -1666,7 +1666,7 @@ dependencies = [ [[package]] name = "cortex-tui" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "arboard", @@ -1711,7 +1711,7 @@ dependencies = [ [[package]] name = "cortex-tui-buffer" -version = "0.1.8" +version = "0.1.9" dependencies = [ "cortex-tui-core", "smallvec", @@ -1719,7 +1719,7 @@ dependencies = [ [[package]] name = "cortex-tui-capture" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "chrono", @@ -1739,7 +1739,7 @@ dependencies = [ [[package]] name = "cortex-tui-components" -version = "0.1.8" +version = "0.1.9" dependencies = [ "cortex-core", "crossterm", @@ -1749,7 +1749,7 @@ dependencies = [ [[package]] name = "cortex-tui-core" -version = "0.1.8" +version = "0.1.9" dependencies = [ "bitflags 2.13.1", "smallvec", @@ -1758,7 +1758,7 @@ dependencies = [ [[package]] name = "cortex-tui-framework" -version = "0.1.8" +version = "0.1.9" dependencies = [ "cortex-tui-buffer", "cortex-tui-core", @@ -1772,7 +1772,7 @@ dependencies = [ [[package]] name = "cortex-tui-input" -version = "0.1.8" +version = "0.1.9" dependencies = [ "bitflags 2.13.1", "crossterm", @@ -1781,7 +1781,7 @@ dependencies = [ [[package]] name = "cortex-tui-layout" -version = "0.1.8" +version = "0.1.9" dependencies = [ "cortex-tui-buffer", "slotmap", @@ -1790,7 +1790,7 @@ dependencies = [ [[package]] name = "cortex-tui-syntax" -version = "0.1.8" +version = "0.1.9" dependencies = [ "ahash", "cortex-tui-text", @@ -1803,7 +1803,7 @@ dependencies = [ [[package]] name = "cortex-tui-terminal" -version = "0.1.8" +version = "0.1.9" dependencies = [ "cortex-tui-buffer", "cortex-tui-core", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "cortex-tui-text" -version = "0.1.8" +version = "0.1.9" dependencies = [ "smallvec", "unicode-segmentation", @@ -1822,7 +1822,7 @@ dependencies = [ [[package]] name = "cortex-tui-widgets" -version = "0.1.8" +version = "0.1.9" dependencies = [ "cortex-tui-buffer", "cortex-tui-core", @@ -1833,7 +1833,7 @@ dependencies = [ [[package]] name = "cortex-update" -version = "0.1.8" +version = "0.1.9" dependencies = [ "chrono", "cortex-common", @@ -1860,11 +1860,11 @@ dependencies = [ [[package]] name = "cortex-utils-cache" -version = "0.1.8" +version = "0.1.9" [[package]] name = "cortex-utils-file-safety" -version = "0.1.8" +version = "0.1.9" dependencies = [ "tempfile", "thiserror 2.0.20", @@ -1872,11 +1872,11 @@ dependencies = [ [[package]] name = "cortex-utils-git" -version = "0.1.8" +version = "0.1.9" [[package]] name = "cortex-utils-image" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "base64", @@ -1884,7 +1884,7 @@ dependencies = [ [[package]] name = "cortex-utils-pty" -version = "0.1.8" +version = "0.1.9" dependencies = [ "anyhow", "portable-pty", @@ -1892,7 +1892,7 @@ dependencies = [ [[package]] name = "cortex-utils-string" -version = "0.1.8" +version = "0.1.9" dependencies = [ "unicode-width", ] diff --git a/Cargo.toml b/Cargo.toml index 2f48eaba..63b99dd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ members = [ [workspace.package] # CLI version - kept in sync with VERSION_CLI and src/cortex-cli/VERSION # Run `scripts/check-cli-version.sh` to verify consistency -version = "0.1.8" +version = "0.1.9" edition = "2024" rust-version = "1.98" authors = ["Cortex Team "] diff --git a/README.md b/README.md index 3a2a4239..b3eee7f2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,13 @@ cortex.foundation

-![Demo of Cortex CLI](./docs/media/intro.gif) +![Animated green Cortex CLI in a complete Terminal window on a macOS-styled desktop, with a moving pointer](./docs/media/intro.gif) + +*Generated macOS-styled demo, not a desktop recording. The green CLI frames +come from the offline `readme_hero` renderer; the desktop and pointer are +composited. No live coding request is performed.* +Regenerate with `./scripts/render-demo-gif.sh` (Cargo, Python with Pillow, and +FFmpeg required); terminal pixels are preserved 1:1 before GIF quantization. ## What is Cortex CLI diff --git a/VERSION_CLI b/VERSION_CLI index 699c6c6d..1a030947 100644 --- a/VERSION_CLI +++ b/VERSION_CLI @@ -1 +1 @@ -0.1.8 +0.1.9 diff --git a/docs/media/intro.gif b/docs/media/intro.gif index 685e0911..30f9331f 100644 Binary files a/docs/media/intro.gif and b/docs/media/intro.gif differ diff --git a/docs/reference/app-server.openapi.json b/docs/reference/app-server.openapi.json index 437f49b3..4ef31a60 100644 --- a/docs/reference/app-server.openapi.json +++ b/docs/reference/app-server.openapi.json @@ -270,7 +270,7 @@ "info": { "description": "Supported local session/health contract. Message POST stores a message; it does not generate a model response. Other development endpoints are not part of this stable contract.", "title": "Cortex local session API", - "version": "0.1.8" + "version": "0.1.9" }, "openapi": "3.1.0", "paths": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 473ac2c2..0d48215b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@cortexlm/sdk", - "version": "0.1.8", + "version": "0.1.9", "description": "Typed, local Cortex CLI subprocess client", "license": "Apache-2.0", "private": true, diff --git a/scripts/ansi-frames-to-gif.py b/scripts/ansi-frames-to-gif.py index d8672ef1..986b6f25 100755 --- a/scripts/ansi-frames-to-gif.py +++ b/scripts/ansi-frames-to-gif.py @@ -14,6 +14,7 @@ from __future__ import annotations import argparse +import importlib.util import json import os import re @@ -54,6 +55,7 @@ "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf", "/usr/share/fonts/truetype/noto/NotoSansSymbols-Regular.ttf", "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", ], "bold": [ os.path.expanduser("~/.local/share/fonts/IBMPlexMono-Bold.ttf"), @@ -67,6 +69,7 @@ "/usr/share/fonts/truetype/noto/NotoSansMono-Bold.ttf", "/usr/share/fonts/truetype/noto/NotoSansSymbols-Bold.ttf", "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", ], } @@ -291,7 +294,7 @@ def render_frame( anchor="ls", ) # FreeType anti-aliases onto the cell background, which turns a - # lone `#A78BFA` `>` on black into a gray fringe. Snap coverage + # lone green `>` on black into a gray fringe. Snap coverage # back to the exact lock colour so composer and picker carets match. cell_bg = style.bg if style.bg != DEFAULT_BG else CANVAS_BG snap_cell_to_fg( @@ -411,6 +414,12 @@ def main() -> int: try: output_index = 0 + total_frames = sum(entry["hold"] for entry in manifest["frames"]) + spec = importlib.util.spec_from_file_location( + "macos_terminal", Path(__file__).with_name("compose-macos-terminal.py") + ) + macos = importlib.util.module_from_spec(spec) + spec.loader.exec_module(macos) for entry in manifest["frames"]: ansi = (args.frames / entry["file"]).read_text() grid = parse_ansi(ansi, width, height) @@ -422,8 +431,11 @@ def main() -> int: image.save(named_dir / f"{safe}.png") output_index += 1 else: + desktop = macos.build_desktop(image, f"Cortex CLI — cortex — {width}×{height}") for _ in range(entry["hold"]): - image.save(png_root / f"{output_index:05d}.png") + macos.animate_mouse(desktop, output_index, total_frames).save( + png_root / f"{output_index:05d}.png" + ) output_index += 1 if named_dir is not None: diff --git a/scripts/check-macos-demo.py b/scripts/check-macos-demo.py new file mode 100644 index 00000000..55863e30 --- /dev/null +++ b/scripts/check-macos-demo.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Run with the renderer's Pillow environment: python3 scripts/check-macos-demo.py.""" +import importlib.util +import hashlib +from pathlib import Path + +from PIL import Image, ImageChops + +spec = importlib.util.spec_from_file_location( + "macos", Path(__file__).with_name("compose-macos-terminal.py") +) +macos = importlib.util.module_from_spec(spec) +spec.loader.exec_module(macos) + +# Deliberately non-black edges catch clipping, shadows and resampling. +raw = Image.new("RGB", (1232, 912), (74, 222, 128)) +raw.putpixel((0, 0), (255, 255, 255)) +desktop = macos.build_desktop(raw, "Cortex CLI — cortex — 120×40") +assert desktop.size == (1416, 1140), desktop.size +x = macos.DESKTOP_MARGIN + macos.CONTENT_INSET +y = macos.WINDOW_TOP + macos.TITLEBAR_H + macos.CONTENT_INSET +box = (x, y, x+raw.width, y+raw.height) +previous = None +for frame in range(98): + image = macos.animate_mouse(desktop, frame, 98) + assert image.crop(box).tobytes() == raw.tobytes(), f"CLI pixels changed at {frame}" + if previous is not None: + assert ImageChops.difference(previous, image).getbbox(), f"Pointer stalled at {frame}" + previous = image +assert macos.animate_mouse(desktop, 0, 98).tobytes() == macos.animate_mouse(desktop, 98, 98).tobytes() + +gif = Path(__file__).resolve().parents[1] / "docs/media/intro.gif" +assert gif.stat().st_size < 5 * 1024 * 1024 +with Image.open(gif) as image: + assert image.size == desktop.size + assert image.n_frames == 98, image.n_frames + assert image.info["loop"] == 0 + pixels = set() + duration = 0 + for frame in range(image.n_frames): + image.seek(frame) + pixels.add(hashlib.sha256(image.convert("RGB").tobytes()).digest()) + duration += image.info["duration"] + assert len(pixels) == 98, len(pixels) + assert 8000 <= duration <= 15000, duration +print("PASS: desktop dimensions, exact CLI pixels, moving looping pointer, 98 unique GIF frames, duration and <5 MiB") diff --git a/scripts/compose-macos-terminal.py b/scripts/compose-macos-terminal.py index 56edd73b..d00a8c69 100644 --- a/scripts/compose-macos-terminal.py +++ b/scripts/compose-macos-terminal.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 -"""Composite raw TUI lock captures into a macOS Terminal.app *window*. +"""Composite raw TUI captures into generated macOS-styled chrome. + +The CLI below retains window-only lock captures. The README rasteriser uses +build_desktop and animate_mouse for wallpaper, menu bar, Dock and pointer. Each REAL capture from `docs/media/tui-lock/{40x12,120x40}/` is pasted 1:1 under a Terminal.app title bar (traffic lights, `cortex-api — cortex — W×H` @@ -11,7 +14,7 @@ Because the content is never resampled, a 40×12 capture yields a genuinely small 40-column window and a 120×40 capture a wide 120-column one — the two packs differ in canvas size, and every locked colour (the `#4ADE80` of a -`+58`, the `#A78BFA` of a selected `>`) survives exactly. No terminal text is +`+58`, the green of a selected `>`) survives exactly. No terminal text is ever invented; the rounded corners belong to the macOS window only, the TUI itself stays frameless. @@ -25,11 +28,13 @@ from __future__ import annotations import argparse +import math import sys +from functools import lru_cache from pathlib import Path try: - from PIL import Image, ImageDraw, ImageFont + from PIL import Image, ImageDraw, ImageFilter, ImageFont except ImportError: # pragma: no cover - dependency guard sys.exit( "Pillow is required to composite the macOS screenshots.\n" @@ -62,6 +67,8 @@ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", + "/System/Library/Fonts/Helvetica.ttc", + "/System/Library/Fonts/Supplemental/Arial.ttf", ] @@ -176,6 +183,68 @@ def compose(raw_png: Path, out_png: Path, title: str) -> tuple[int, int]: window.save(out_png) return window.size +# ponytail: procedural macOS-styled chrome, not an OS recording; use a real +# desktop capture only when demonstrating native OS interactions. +DESKTOP_MARGIN = 80 +WINDOW_TOP = 76 +CONTENT_INSET = 12 + +@lru_cache(maxsize=2) +def desktop_backdrop(content_size: tuple[int, int]) -> Image.Image: + """Static desktop and shadow; never paint over the terminal capture.""" + cw, ch = content_size + ww, wh = cw + 2 * CONTENT_INSET, ch + 2 * CONTENT_INSET + TITLEBAR_H + width, height = ww + 2 * DESKTOP_MARGIN, wh + WINDOW_TOP + 100 + desktop = Image.new("RGB", (width, height)) + draw = ImageDraw.Draw(desktop) + for y in range(height): + t = y / height + draw.line((0, y, width, y), fill=(round(18 + 22*t), round(43 + 40*t), round(58 + 27*t))) + draw.ellipse((-width//2, height//3, width, height*2), fill=(38, 103, 103)) + draw.ellipse((width//3, height//2, width*2, height*2), fill=(62, 133, 119)) + draw.ellipse((width//2, height*3//4, width*2, height*2), fill=(96, 158, 130)) + draw.rectangle((0, 0, width, 27), fill=(22, 37, 43)) + ui_font = font(SANS_REGULAR, 13) + draw.text((20, 6), "● Terminal Shell Edit View Window Help", font=ui_font, fill=(238, 243, 241)) + draw.text((width-207, 6), "Wi-Fi 100% Mon 9:41", font=ui_font, fill=(238, 243, 241)) + + dock_x, dock_y = width//2 - 133, height-76 + draw.rounded_rectangle((dock_x, dock_y, dock_x+266, height-12), radius=18, + fill=(88, 125, 123), outline=(147, 174, 166)) + for i, (label, colour) in enumerate((("Finder", (69, 154, 226)), ("Web", (62, 153, 177)), + (">_", (27, 29, 32)), ("Files", (72, 151, 216)))): + x = dock_x + 13 + i*61 + draw.rounded_rectangle((x, dock_y+8, x+48, dock_y+51), radius=10, fill=colour) + draw.text((x+24, dock_y+29), label, font=font(SANS_REGULAR, 11 if i != 2 else 22), + fill="white", anchor="mm") + draw.ellipse((dock_x+157, height-20, dock_x+161, height-16), fill=(229, 242, 236)) + shadow = Image.new("RGBA", desktop.size) + ImageDraw.Draw(shadow).rounded_rectangle( + (DESKTOP_MARGIN, WINDOW_TOP+14, DESKTOP_MARGIN+ww, WINDOW_TOP+wh+14), + radius=CORNER_RADIUS, fill=(0, 0, 0, 155)) + return Image.alpha_composite(desktop.convert("RGBA"), shadow.filter(ImageFilter.GaussianBlur(18))) + +def build_desktop(content: Image.Image, title: str) -> Image.Image: + """Inset preserves every capture pixel, including the rounded corners.""" + padded = Image.new("RGB", (content.width+2*CONTENT_INSET, content.height+2*CONTENT_INSET)) + padded.paste(content, (CONTENT_INSET, CONTENT_INSET)) + desktop = desktop_backdrop(content.size).copy() + desktop.alpha_composite(build_window(padded, title), (DESKTOP_MARGIN, WINDOW_TOP)) + return desktop.convert("RGB") + +def animate_mouse(desktop: Image.Image, frame: int, total: int) -> Image.Image: + """Smooth looping pointer in the desktop gutter; no obscured CLI pixels.""" + phase = 2 * math.pi * frame / max(1, total) + x = round(desktop.width - 51 + 15 * math.sin(phase)) + y = round(118 + (desktop.height - 264) * (1 - math.cos(phase)) / 2) + image = desktop.copy() + draw = ImageDraw.Draw(image) + points = [(x, y), (x, y+23), (x+6, y+17), (x+11, y+27), + (x+15, y+25), (x+10, y+15), (x+19, y+15)] + draw.polygon(points, fill=(18, 20, 22)) + draw.line(points + [points[0]], fill="white", width=2) + return image + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) diff --git a/scripts/render-demo-gif.sh b/scripts/render-demo-gif.sh index 1b411a83..3660b6d0 100755 --- a/scripts/render-demo-gif.sh +++ b/scripts/render-demo-gif.sh @@ -4,10 +4,13 @@ # Two stages: # 1. generate_tui_demo paints the signed lock TUI (splash → typing → working) # at 120×40 through cortex-tui. -# 2. scripts/ansi-frames-to-gif.py rasterises those frames and calls ffmpeg. +# 2. scripts/ansi-frames-to-gif.py rasterises those frames, composites a +# macOS-styled desktop and moving pointer, and calls ffmpeg. # -# Requires: cargo, ffmpeg, python3 with Pillow. -# Output size is 1232×912 with the default 16px font and 16px padding. +# Requires: cargo, ffmpeg, python3 with Pillow, and a supported sans font +# (Liberation Sans, DejaVu Sans, Noto Sans, or macOS Helvetica/Arial). +# Raw raster: 1232×912 at the default 16px font and padding. +# Composed GIF: 1416×1140. Validate with python3 scripts/check-macos-demo.py. set -euo pipefail diff --git a/src/cortex-cli/VERSION b/src/cortex-cli/VERSION index 699c6c6d..1a030947 100755 --- a/src/cortex-cli/VERSION +++ b/src/cortex-cli/VERSION @@ -1 +1 @@ -0.1.8 +0.1.9 diff --git a/src/cortex-tui/src/runner/app_runner/runner.rs b/src/cortex-tui/src/runner/app_runner/runner.rs index 5381938c..9eeeb2d9 100644 --- a/src/cortex-tui/src/runner/app_runner/runner.rs +++ b/src/cortex-tui/src/runner/app_runner/runner.rs @@ -340,7 +340,10 @@ impl AppRunner { if !is_workspace_trusted(&workspace) { use crate::runner::trust_screen::{TrustResult, TrustScreen}; let mut trust_screen = TrustScreen::new(workspace.clone()); - match trust_screen.run().await? { + match trust_screen + .run_with_options(self.terminal_options.clone()) + .await? + { TrustResult::Trusted => { mark_workspace_trusted(&workspace)?; } @@ -423,7 +426,10 @@ impl AppRunner { let mut login_screen = LoginScreen::new(cortex_home.clone(), message); - match login_screen.run().await? { + match login_screen + .run_with_options(self.terminal_options.clone()) + .await? + { LoginResult::LoggedIn => { tracing::info!("User logged in successfully"); // Reload auth token after login - this is critical! diff --git a/src/cortex-tui/src/runner/app_runner/ux_runner_tests.rs b/src/cortex-tui/src/runner/app_runner/ux_runner_tests.rs index 95a8fab5..820eff12 100644 --- a/src/cortex-tui/src/runner/app_runner/ux_runner_tests.rs +++ b/src/cortex-tui/src/runner/app_runner/ux_runner_tests.rs @@ -39,7 +39,7 @@ fn test_app_runner_terminal_options() { inline.alternate_screen = false; let runner = AppRunner::new(inline); assert!(!runner.terminal_options.alternate_screen); - assert!(!runner.terminal_options.clear_on_start); + assert!(runner.terminal_options.clear_on_start); // Custom options let custom_options = TerminalOptions::new() diff --git a/src/cortex-tui/src/runner/login_screen.rs b/src/cortex-tui/src/runner/login_screen.rs index 8370bf3b..db5af007 100644 --- a/src/cortex-tui/src/runner/login_screen.rs +++ b/src/cortex-tui/src/runner/login_screen.rs @@ -1,9 +1,8 @@ //! Login Screen //! -//! Inline TUI (no alternate screen) so the host shell prompt stays in -//! scrollback above the picker. +//! Uses the alternate screen by default. `run_with_options` follows the +//! caller's terminal preferences; inline mode preserves scrollback. -use std::io::stdout; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; @@ -189,23 +188,17 @@ impl LoginScreen { } pub async fn run(&mut self) -> Result { - crossterm::terminal::enable_raw_mode()?; - let mut stdout = stdout(); - crossterm::execute!(stdout, crossterm::event::EnableMouseCapture)?; - - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let result = self.run_loop(&mut terminal).await; - - crossterm::terminal::disable_raw_mode()?; - crossterm::execute!( - terminal.backend_mut(), - crossterm::event::DisableMouseCapture, - )?; - terminal.show_cursor()?; + self.run_with_options(super::terminal::TerminalOptions::default()) + .await + } - result + /// Run with the same terminal preferences as the main application. + pub async fn run_with_options( + &mut self, + options: super::terminal::TerminalOptions, + ) -> Result { + let mut terminal = super::terminal::CortexTerminal::with_options(options)?; + self.run_loop(terminal.inner_mut()).await } async fn run_loop( diff --git a/src/cortex-tui/src/runner/terminal.rs b/src/cortex-tui/src/runner/terminal.rs index 6cd6a230..5cb9c420 100644 --- a/src/cortex-tui/src/runner/terminal.rs +++ b/src/cortex-tui/src/runner/terminal.rs @@ -3,30 +3,6 @@ //! This module handles crossterm terminal initialization and cleanup for the TUI. //! It provides RAII-based cleanup to ensure the terminal is always restored to //! a sane state, even in panic situations. -//! -//! # Example -//! -//! ```rust,ignore -//! use cortex_tui::runner::terminal::{CortexTerminal, TerminalOptions}; -//! -//! // Create with default options (alternate screen, full viewport) -//! let mut terminal = CortexTerminal::new()?; -//! -//! // Or with custom options -//! let mut terminal = CortexTerminal::with_options( -//! TerminalOptions::new() -//! .alternate_screen(false) -//! .mouse_capture(false) -//! .title("My App") -//! )?; -//! -//! // Draw frames -//! terminal.draw(|frame| { -//! // ... render widgets -//! })?; -//! -//! // Terminal is automatically restored on drop -//! ``` use std::io::{self, IsTerminal, Stdout, stdout}; use std::panic; @@ -209,7 +185,7 @@ impl TerminalOptions { /// Set whether to clear the screen on start. /// - /// When enabled, the screen is cleared before starting the TUI. + /// When enabled, the visible screen is cleared before starting the TUI, not scrollback. pub fn clear_on_start(mut self, enabled: bool) -> Self { self.clear_on_start = enabled; self @@ -220,14 +196,14 @@ impl TerminalOptions { /// Inline mode runs the TUI without using the alternate screen, /// which preserves the terminal scrollback and allows output to /// remain visible after the TUI exits. This is useful for - /// non-fullscreen TUI applications. + /// non-fullscreen TUI applications. The visible viewport is cleared on start. pub fn inline() -> Self { Self { alternate_screen: false, mouse_capture: true, bracketed_paste: true, title: None, - clear_on_start: false, + clear_on_start: true, } } } @@ -292,11 +268,6 @@ impl CortexTerminal { /// /// Returns an error if terminal initialization fails. pub fn with_options(options: TerminalOptions) -> Result { - init_terminal(&options)?; - - let backend = CrosstermBackend::new(stdout()); - let terminal = Terminal::new(backend)?; - let restore_title = options.title.is_some(); let guard = TerminalGuard::new( options.alternate_screen, @@ -305,6 +276,12 @@ impl CortexTerminal { restore_title, ); + // Own cleanup before the first fallible setup operation. + init_terminal(&options)?; + let backend = CrosstermBackend::new(stdout()); + // init_screen already clears when requested; Terminal::clear would query the cursor. + let terminal = Terminal::new(backend)?; + Ok(Self { terminal, _guard: guard, @@ -557,6 +534,12 @@ fn init_terminal(options: &TerminalOptions) -> Result<()> { let mut stdout = stdout(); + init_screen(&mut stdout, options)?; + + Ok(()) +} + +fn init_screen(stdout: &mut impl io::Write, options: &TerminalOptions) -> io::Result<()> { // Enter alternate screen if requested if options.alternate_screen { execute!(stdout, EnterAlternateScreen)?; @@ -574,7 +557,13 @@ fn init_terminal(options: &TerminalOptions) -> Result<()> { // Clear screen if requested if options.clear_on_start { - execute!(stdout, Clear(ClearType::All))?; + execute!( + stdout, + crossterm::style::ResetColor, + crossterm::style::SetAttribute(crossterm::style::Attribute::Reset), + Clear(ClearType::All), + cursor::MoveTo(0, 0) + )?; } // Hide cursor @@ -613,37 +602,58 @@ fn restore_terminal_impl( restore_title: bool, ) -> Result<()> { let mut stdout = stdout(); + let screen_result = restore_screen( + &mut stdout, + alternate_screen, + mouse_capture, + bracketed_paste, + ); + + if restore_title + && let Ok(guard) = ORIGINAL_TITLE.lock() + && let Some(ref title) = *guard + { + let _ = execute!(stdout, SetTitle(title)); + } + + // Always restore termios, even when stdout is broken. + let raw_result = disable_raw_mode(); + screen_result?; + raw_result?; + Ok(()) +} + +fn restore_screen( + stdout: &mut impl io::Write, + alternate_screen: bool, + mouse_capture: bool, + bracketed_paste: bool, +) -> io::Result<()> { + let mut result = execute!( + stdout, + crossterm::style::ResetColor, + crossterm::style::SetAttribute(crossterm::style::Attribute::Reset) + ); // Show cursor - execute!(stdout, cursor::Show)?; + result = result.and(execute!(stdout, cursor::Show)); // Disable bracketed paste if bracketed_paste { - execute!(stdout, DisableBracketedPaste)?; + result = result.and(execute!(stdout, DisableBracketedPaste)); } // Disable mouse capture if mouse_capture { - execute!(stdout, DisableMouseCapture)?; + result = result.and(execute!(stdout, DisableMouseCapture)); } // Leave alternate screen if alternate_screen { - execute!(stdout, LeaveAlternateScreen)?; - } - - // Restore original terminal title if we saved one - if restore_title - && let Ok(guard) = ORIGINAL_TITLE.lock() - && let Some(ref title) = *guard - { - let _ = execute!(stdout, SetTitle(title)); + result = result.and(execute!(stdout, LeaveAlternateScreen)); } - // Disable raw mode - disable_raw_mode()?; - - Ok(()) + result } /// Restore terminal to normal state (public API). @@ -980,58 +990,5 @@ pub fn safe_clipboard_paste() -> Option { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_terminal_options_default() { - let options = TerminalOptions::default(); - assert!( - options.alternate_screen, - "default must enter the alternate screen (always)" - ); - assert!(options.mouse_capture); - assert!(options.bracketed_paste); - assert_eq!(options.title, Some("Cortex".to_string())); - assert!(options.clear_on_start); - } - - #[test] - fn test_terminal_options_builder() { - let options = TerminalOptions::new() - .alternate_screen(false) - .mouse_capture(false) - .bracketed_paste(false) - .title("Test") - .clear_on_start(false); - - assert!(!options.alternate_screen); - assert!(!options.mouse_capture); - assert!(!options.bracketed_paste); - assert_eq!(options.title, Some("Test".to_string())); - assert!(!options.clear_on_start); - } - - #[test] - fn test_terminal_options_inline() { - let options = TerminalOptions::inline(); - assert!(!options.alternate_screen); - assert!(options.mouse_capture); - assert!(options.bracketed_paste); - assert!(options.title.is_none()); - assert!(!options.clear_on_start); - } - - #[test] - fn test_terminal_guard_creation() { - let guard = TerminalGuard::new(true, true, true, true); - assert!(guard.alternate_screen); - assert!(guard.mouse_capture); - assert!(guard.bracketed_paste); - assert!(guard.restore_title); - } - - // Note: Tests that actually create terminals are difficult to run - // in CI environments as they require a real TTY. These would be - // integration tests run manually or in a special test environment. -} +#[path = "terminal_tests.rs"] +mod tests; diff --git a/src/cortex-tui/src/runner/terminal_tests.rs b/src/cortex-tui/src/runner/terminal_tests.rs new file mode 100644 index 00000000..4b388c43 --- /dev/null +++ b/src/cortex-tui/src/runner/terminal_tests.rs @@ -0,0 +1,154 @@ +use super::*; + +#[test] +fn startup_clears_visible_screen_in_both_modes() { + let mut output = Vec::new(); + init_screen(&mut output, &TerminalOptions::default()).unwrap(); + let startup = String::from_utf8(output).unwrap(); + assert!(startup.find("\x1b[?1049h").unwrap() < startup.find("\x1b[2J").unwrap()); + assert!(startup.contains("\x1b[0m\x1b[2J\x1b[1;1H")); + assert!(!startup.contains("\x1b[3J"), "never erase scrollback"); + assert!( + !startup.contains("\x1b[6n"), + "startup must not query the cursor" + ); + + let mut output = Vec::new(); + init_screen(&mut output, &TerminalOptions::inline()).unwrap(); + let startup = String::from_utf8(output).unwrap(); + assert!(startup.contains("\x1b[0m\x1b[2J\x1b[1;1H")); + assert!(!startup.contains("\x1b[3J")); + assert!( + !startup.contains("\x1b[6n"), + "startup must not query the cursor" + ); +} + +#[test] +fn explicit_clear_on_start_false_does_not_clear_screen() { + for options in [TerminalOptions::default(), TerminalOptions::inline()] { + let mut output = Vec::new(); + init_screen(&mut output, &options.clear_on_start(false)).unwrap(); + let startup = String::from_utf8(output).unwrap(); + assert!(!startup.contains("\x1b[2J")); + assert!(!startup.contains("\x1b[3J")); + } +} + +#[test] +fn inline_preflight_options_preserve_scrollback_without_switching_screens() { + use crate::runner::AppRunner; + use cortex_engine::Config; + + let config = Config { + alternate_screen: false, + ..Config::default() + }; + for runner in [ + AppRunner::new(config), + AppRunner::new(Config::default()).inline(), + ] { + let options = runner.terminal_options.clone(); + assert!(!options.alternate_screen); + assert!(options.clear_on_start); + let mut output = Vec::new(); + init_screen(&mut output, &options).unwrap(); + restore_screen( + &mut output, + options.alternate_screen, + options.mouse_capture, + options.bracketed_paste, + ) + .unwrap(); + let output = String::from_utf8(output).unwrap(); + // 2J is allowed; switching screens and purging history are not. + for forbidden in ["\x1b[?1049h", "\x1b[?1049l", "\x1b[3J"] { + assert!(!output.contains(forbidden), "unexpected {forbidden:?}"); + } + } +} + +#[test] +fn cleanup_attempts_remaining_steps_after_write_error() { + struct FailOnce { + failed: bool, + output: Vec, + } + impl io::Write for FailOnce { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if !self.failed { + self.failed = true; + return Err(io::Error::other("injected output failure")); + } + self.output.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let mut writer = FailOnce { + failed: false, + output: Vec::new(), + }; + assert!(restore_screen(&mut writer, true, true, true).is_err()); + let output = String::from_utf8(writer.output).unwrap(); + assert!(output.contains("\x1b[?25h")); + assert!(output.contains("\x1b[?2004l")); + assert!(output.contains("\x1b[?1000l")); + assert!(output.ends_with("\x1b[?1049l")); + assert!(!output.contains("\x1b[2J")); + assert!(!output.contains("\x1b[3J")); +} + +#[test] +fn test_terminal_options_default() { + let options = TerminalOptions::default(); + assert!( + options.alternate_screen, + "default must enter the alternate screen (always)" + ); + assert!(options.mouse_capture); + assert!(options.bracketed_paste); + assert_eq!(options.title, Some("Cortex".to_string())); + assert!(options.clear_on_start); +} + +#[test] +fn test_terminal_options_builder() { + let options = TerminalOptions::new() + .alternate_screen(false) + .mouse_capture(false) + .bracketed_paste(false) + .title("Test") + .clear_on_start(false); + + assert!(!options.alternate_screen); + assert!(!options.mouse_capture); + assert!(!options.bracketed_paste); + assert_eq!(options.title, Some("Test".to_string())); + assert!(!options.clear_on_start); +} + +#[test] +fn test_terminal_options_inline() { + let options = TerminalOptions::inline(); + assert!(!options.alternate_screen); + assert!(options.mouse_capture); + assert!(options.bracketed_paste); + assert!(options.title.is_none()); + assert!(options.clear_on_start); +} + +#[test] +fn test_terminal_guard_creation() { + let guard = TerminalGuard::new(true, true, true, true); + assert!(guard.alternate_screen); + assert!(guard.mouse_capture); + assert!(guard.bracketed_paste); + assert!(guard.restore_title); +} + +// Note: Tests that actually create terminals are difficult to run +// in CI environments as they require a real TTY. These would be +// integration tests run manually or in a special test environment. diff --git a/src/cortex-tui/src/runner/trust_screen.rs b/src/cortex-tui/src/runner/trust_screen.rs index e5fcdc5d..5a265e27 100644 --- a/src/cortex-tui/src/runner/trust_screen.rs +++ b/src/cortex-tui/src/runner/trust_screen.rs @@ -2,7 +2,6 @@ //! //! Security prompt shown before accessing a workspace for the first time. -use std::io::stdout; use std::path::PathBuf; use std::time::Duration; @@ -64,23 +63,17 @@ impl TrustScreen { /// Run the trust screen and return the user's decision. pub async fn run(&mut self) -> Result { - crossterm::terminal::enable_raw_mode()?; - let mut stdout = stdout(); - crossterm::execute!(stdout, crossterm::event::EnableMouseCapture)?; - - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let result = self.run_loop(&mut terminal).await; - - crossterm::terminal::disable_raw_mode()?; - crossterm::execute!( - terminal.backend_mut(), - crossterm::event::DisableMouseCapture, - )?; - terminal.show_cursor()?; + self.run_with_options(super::terminal::TerminalOptions::default()) + .await + } - result + /// Run with the same terminal preferences as the main application. + pub async fn run_with_options( + &mut self, + options: super::terminal::TerminalOptions, + ) -> Result { + let mut terminal = super::terminal::CortexTerminal::with_options(options)?; + self.run_loop(terminal.inner_mut()).await } async fn run_loop( @@ -293,6 +286,87 @@ mod tests { out } + #[test] + fn preflight_first_frames_clear_unknown_primary_screen_cells() { + use ratatui::backend::{Backend, TestBackend}; + use ratatui::buffer::Cell; + + for (width, height) in [(40, 12), (120, 40)] { + for is_trust in [true, false] { + // Pre-existing output is unknown to a newly constructed Terminal. + let mut backend = TestBackend::new(width, height); + let mut stale = Cell::default(); + stale.set_symbol("X"); + let stale = &stale; + backend + .draw((0..height).flat_map(|y| (0..width).map(move |x| (x, y, stale)))) + .unwrap(); + let mut terminal = Terminal::new(backend).unwrap(); + // Match CortexTerminal::with_options: clear physical cells before diffing. + terminal.clear().unwrap(); + terminal + .draw(|frame| { + if is_trust { + TrustScreen::new(PathBuf::from("/tmp/workspace")).render(frame); + } else { + super::super::login_screen::LoginScreen::new( + PathBuf::from("/tmp"), + None, + ) + .render(frame); + } + }) + .unwrap(); + let text = buffer_text(terminal.backend().buffer()); + assert!(!text.contains('X'), "{width}x{height}: {text}"); + assert!(text.contains(if is_trust { + TRUST_TITLE + } else { + super::super::login_screen::LOGIN_TITLE + })); + } + } + } + + #[test] + fn preflight_frames_replace_existing_content_at_both_sizes() { + use ratatui::backend::TestBackend; + use ratatui::widgets::Paragraph; + + for (width, height) in [(40, 12), (120, 40)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let trust = TrustScreen::new(PathBuf::from("/tmp/workspace")); + let login = super::super::login_screen::LoginScreen::new(PathBuf::from("/tmp"), None); + for is_trust in [true, false] { + terminal + .draw(|frame| { + frame.render_widget( + Paragraph::new("STALE TERMINAL CONTENT".repeat(1000)), + frame.area(), + ); + }) + .unwrap(); + terminal + .draw(|frame| { + if is_trust { + trust.render(frame); + } else { + login.render(frame); + } + }) + .unwrap(); + let text = buffer_text(terminal.backend().buffer()); + assert!(!text.contains("STALE"), "{width}x{height}: {text}"); + let title = if is_trust { + TRUST_TITLE + } else { + super::super::login_screen::LOGIN_TITLE + }; + assert!(text.contains(title), "{width}x{height}: {text}"); + } + } + } + #[test] fn trust_prompt_is_a_numbered_picker_at_both_sizes() { for (w, h) in [(40u16, 12u16), (120u16, 40u16)] {