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
-
+
+
+*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)] {