diff --git a/docs/media/tui-lock-v2/120x40/session-shared.png b/docs/media/tui-lock-v2/120x40/session-shared.png new file mode 100644 index 00000000..61f9cf22 Binary files /dev/null and b/docs/media/tui-lock-v2/120x40/session-shared.png differ diff --git a/docs/media/tui-lock-v2/40x12/session-shared.png b/docs/media/tui-lock-v2/40x12/session-shared.png new file mode 100644 index 00000000..d3ef3d57 Binary files /dev/null and b/docs/media/tui-lock-v2/40x12/session-shared.png differ diff --git a/docs/media/tui-lock-v2/SPEC.md b/docs/media/tui-lock-v2/SPEC.md index fd93beb0..07defb0f 100644 --- a/docs/media/tui-lock-v2/SPEC.md +++ b/docs/media/tui-lock-v2/SPEC.md @@ -5,12 +5,12 @@ Design-only deliverable for the full TUI redesign. Layout language is taken composer with the model chip in the bottom border, Settings modal with search + categorised rows + tip/nav footer, slash autocomplete above the composer, effort radios under `/model`, token counter top-right, footer shortcut strip) and -re-skinned to the Cortex chrome. Designer boards stay design-only; runtime -captures use `MinimalSessionView` plus lock flags (`computer_held`, -`show_computer_default`, `offline_held`, `rate_limit_held`) so MockTerminal -matches live chrome. +re-skinned to the Cortex chrome. Designer boards are the pixel target; matching +runtime chrome ships in `cortex-tui` (`lock_v2_*` scenes plus production paints +and lock flags: `computer_held`, `show_computer_default`, `offline_held`, +`rate_limit_held`, `share_link`) so MockTerminal captures can lock the live UI. -- Boards: [`index.md`](index.md) — **95** runtime boards at 120×40, 49 of them also at 40×12. Designer PNG files under `{40x12,120x40}/` from other PRs are not rewritten; new scenes are captured with `generate_tui_lock_screenshots --v2 --only`. +- Boards: [`index.md`](index.md) — **96** runtime boards at 120×40, 50 of them also at 40×12. Designer PNG files under `{40x12,120x40}/` from other PRs are not rewritten; new scenes are captured with `generate_tui_lock_screenshots --v2 --only`. - Grids: `txt//.txt` — the exact character grid of every board (diff a `MockTerminal` capture against these). - Renderer: `tools/render_lock_v2.py` + `tools/boards.py` (Python 3 + Pillow, IBM Plex Mono @@ -98,7 +98,8 @@ Board geometry used by the renderer (documentation only — the product renders ``` Header-left is intentionally empty on launch. It never paints a shell echo -(`~/…`, `> cortex`). Optional: the session name after `/rename`, dim. +(`~/…`, `> cortex`). Optional, dim: the session name after `/rename`, or the +`Shared · read-only` marker while a `/share` link is live (`Shared` at 40 cols). --- @@ -116,6 +117,13 @@ Legend for recipes: `T` text `#F5F5F5` · `D` dim `#6B7280` · `M` muted `#4B556 - ≥ 90 % of the window: counter turns `A` and the transcript gets one line `Context is 92% full — /compact summarizes the thread to free room.` (`A` + `T` + `D`). +``` + Shared · read-only 14K / 500K ← marker D, left margin col 1 +``` +- Persistent status chrome while a read-only `/share` link is live — `D`, never + accent, never a wash. Clipped before the counter; `Shared` alone at 40 cols. +- `/unshare` clears the marker; the link line stays in the transcript as history. + ### 3.2 User prompt bar ``` @@ -409,6 +417,7 @@ Narrow: no bars, `used / total pct%`. |---|---| | welcome-cortex / welcome-agent / session-empty | `welcome-cortex`, `welcome-agent`, `session-empty` (+ `first-run-tips`) | | session-user-bars / thought / assistant / worked / optin | `session-user-bars`, `session-thought`, `session-thought-expanded`, `session-thinking-live`, `session-assistant`, `session-worked`, `session-optin`, `session-optin-hover` | +| `/share` link live (status-line marker) · `/unshare` clears it | `session-shared` | | composer-empty / typing (+blink) / hover | `composer-empty`, `composer-typing`, `composer-typing-blink`, `composer-hover`, `composer-multiline` | | footer-shortcuts / tokens-topright | `footer-shortcuts`, `footer-hover`, `tokens-topright`, `tokens-topright-warn` | | slash-palette / slash-model-typed | `slash-palette` (`/goal` after `/plan`), `slash-model-typed` | @@ -426,7 +435,8 @@ Narrow: no bars, `used / total pct%`. | local-tools consent · @file chip · undo/redo/rewind | `consent-local-tools`, `composer-file-chip`, `undo-sheet` | Narrow (40×12) set: `welcome-cortex`, `welcome-agent`, `first-run-tips`, `session-empty`, `session-user-bars`, -`session-thinking-live`, `session-assistant`, `session-optin`, `composer-empty`, `composer-typing`, `composer-hover`, +`session-thinking-live`, `session-assistant`, `session-optin`, `session-shared`, `composer-empty`, `composer-typing`, +`composer-hover`, `tokens-topright`, `compact-chat`, `slash-palette`, `goal-chip-active`, `goal-chip-paused`, `goal-chip-done`, `goal-chip-budget`, `goal-chip-blocked`, `slash-model-typed`, `model-list`, `model-effort-high`, `settings-appearance`, `settings-mouse`, `settings-row-hover`, `settings-theme-submenu`, `theme-picker`, `session-fork`, `init-agents`, `custom-commands`, `hooks-lifecycle`, `handoff-confirm`, `mode-plan`, `mode-ask`, diff --git a/docs/media/tui-lock-v2/index.md b/docs/media/tui-lock-v2/index.md index 32cab5c1..720d4f67 100644 --- a/docs/media/tui-lock-v2/index.md +++ b/docs/media/tui-lock-v2/index.md @@ -27,6 +27,7 @@ Regenerate: `python3 tools/render_lock_v2.py --index` (fetches IBM Plex Mono on | `session-worked` | `Worked for Xs` after a reply | [120x40](120x40/session-worked.png) | — | | `session-optin` | `Help improve Cortex` banner — Opt out | Opt in | [120x40](120x40/session-optin.png) | [40x12](40x12/session-optin.png) | | `session-optin-hover` | Banner with the mouse over `[Opt in]` | [120x40](120x40/session-optin-hover.png) | — | +| `session-shared` | `/share` link live — dim `Shared` marker in the status line | [120x40](120x40/session-shared.png) | [40x12](40x12/session-shared.png) | | `composer-empty` | Empty composer — caret before the placeholder, banner green `>` | [120x40](120x40/composer-empty.png) | [40x12](40x12/composer-empty.png) | | `composer-typing` | Mid-type, caret on | [120x40](120x40/composer-typing.png) | [40x12](40x12/composer-typing.png) | | `composer-typing-blink` | Mid-type, caret off (blink phase) | [120x40](120x40/composer-typing-blink.png) | — | @@ -126,4 +127,6 @@ Regenerate: `python3 tools/render_lock_v2.py --index` (fetches IBM Plex Mono on | `composer-file-chip` | Composer with attached @file chip in prompt | [120x40](120x40/composer-file-chip.png) | [40x12](40x12/composer-file-chip.png) | | `undo-sheet` | /undo /redo /rewind sheet | [120x40](120x40/undo-sheet.png) | [40x12](40x12/undo-sheet.png) | -**95** runtime boards at 120x40 · **49** at 40x12. Designer PNG pack from other PRs is unchanged; new COR-18/225/227/228 scenes are runtime captures. Computer lock boards from #69 stay in the pack. +**96** runtime boards at 120x40 · **50** at 40x12. Designer PNG pack from other PRs is unchanged; new COR-18/225/227/228 scenes are runtime captures. Computer lock boards from #69 stay in the pack. `session-shared` (COR-226) is at both sizes. + +Runtime MockTerminal pack (same ids): [`runtime/120x40`](runtime/120x40/) (96) · [`runtime/40x12`](runtime/40x12/) (50). diff --git a/docs/media/tui-lock-v2/runtime/120x40/session-shared.png b/docs/media/tui-lock-v2/runtime/120x40/session-shared.png new file mode 100644 index 00000000..079eec2c Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/session-shared.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/session-shared.png b/docs/media/tui-lock-v2/runtime/40x12/session-shared.png new file mode 100644 index 00000000..d4f57215 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/session-shared.png differ diff --git a/docs/media/tui-lock-v2/runtime/README.md b/docs/media/tui-lock-v2/runtime/README.md index d26c662d..4a6a1977 100644 --- a/docs/media/tui-lock-v2/runtime/README.md +++ b/docs/media/tui-lock-v2/runtime/README.md @@ -8,10 +8,11 @@ is banner green `#1F4945`; historical violet `#A78BFA` is not the lock. Designer boards (pixel target) live in `docs/media/tui-lock-v2/{40x12,120x40}/`. These runtime frames are what Designer cli signs off against. -SPEC §7: **95** boards at 120×40 and **49** at 40×12. Each filename is one +SPEC §7: **96** boards at 120×40 and **50** at 40×12. Each filename is one distinct live state — no two PNGs share a sha256. Includes `/goal` composer chips (`goal-chip-*`), distinct `offline` and `rate-limit` diagnostics, Computer lock boards (`computer-disconnected`, `computer-cloud-default`), local-tools consent, composer `@file` chip, `/undo` `/redo` `/rewind` sheet, -and runtime-only COR-18/225/227/228 scenes (`theme-picker`, `handoff-confirm`, -`session-fork`, `init-agents`, `custom-commands`, `hooks-lifecycle`). +runtime-only COR-18/225/227/228 scenes (`theme-picker`, `handoff-confirm`, +`session-fork`, `init-agents`, `custom-commands`, `hooks-lifecycle`), +and the `session-shared` status-line marker while a read-only `/share` link is live. diff --git a/docs/media/tui-lock-v2/tools/boards.py b/docs/media/tui-lock-v2/tools/boards.py index 8c2603b2..8a8861eb 100644 --- a/docs/media/tui-lock-v2/tools/boards.py +++ b/docs/media/tui-lock-v2/tools/boards.py @@ -57,6 +57,15 @@ PLACEHOLDER = "Plan, search, build anything" TOKENS = "14K / 500K" +# `/share` chrome — the marker is dim status chrome and stays up until `/unshare`. +SHARE_MARKER = "Shared · read-only" +SHARE_MARKER_NARROW = "Shared" +SHARE_TITLE = "Shared this session — read-only link is live." +SHARE_TITLE_NARROW = "Shared — read-only link is live." +SHARE_LINK = "cortex.foundation/share/9f4c2a71 · copied to your clipboard" +SHARE_LINK_NARROW = "cortex.foundation/share/9f4c2a71" +SHARE_SCOPE = "Anyone with the link can read this session. /unshare turns it off." + @dataclass class Ctx: @@ -774,6 +783,19 @@ def board_session_optin_hover(s, c): footer(s, c) +def board_session_shared(s, c): + """`/share` is live — dim `Shared` marker in the status line, idle session chrome.""" + header(s, c, left=SHARE_MARKER_NARROW if c.narrow else SHARE_MARKER) + top = composer(s, c) + f = Flow(s, c, limit=top - 1) + f.user("/share", "04:41 PM") + f.dim(SHARE_TITLE_NARROW if c.narrow else SHARE_TITLE) + f.dim(SHARE_LINK_NARROW if c.narrow else SHARE_LINK, indent=2) + if not c.narrow: + f.dim(SHARE_SCOPE, indent=2) + footer(s, c) + + def board_composer_empty(s, c): header(s, c) composer(s, c) @@ -2016,6 +2038,7 @@ def board_undo_sheet(s, c): ("session-worked", board_session_worked, False, "B", "`Worked for Xs` after a reply"), ("session-optin", board_session_optin, True, "B", "`Help improve Cortex` banner — Opt out | Opt in"), ("session-optin-hover", board_session_optin_hover, False, "B", "Banner with the mouse over `[Opt in]`"), + ("session-shared", board_session_shared, True, "B", "`/share` link live — dim `Shared` marker in the status line"), ("composer-empty", board_composer_empty, True, "B", "Empty composer — caret before the placeholder, banner green `>`"), ("composer-typing", board_composer_typing, True, "B", "Mid-type, caret on"), ("composer-typing-blink", board_composer_typing_blink, False, "B", "Mid-type, caret off (blink phase)"), diff --git a/docs/media/tui-lock-v2/txt/120x40/session-shared.txt b/docs/media/tui-lock-v2/txt/120x40/session-shared.txt new file mode 100644 index 00000000..503d8b4c --- /dev/null +++ b/docs/media/tui-lock-v2/txt/120x40/session-shared.txt @@ -0,0 +1,40 @@ + Shared · read-only 14K / 500K + + > /share 04:41 PM + + Shared this session — read-only link is live. + cortex.foundation/share/9f4c2a71 · copied to your clipboard + Anyone with the link can read this session. /unshare turns it off. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭─ Agent ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ > Plan, search, build anything │ + ╰─────────────────────────────────────────────────────────────────────────────────────────── Cortex Mini 1 (medium) ─╯ + + Shift+Tab:mode | Ctrl+x:shortcuts diff --git a/docs/media/tui-lock-v2/txt/40x12/session-shared.txt b/docs/media/tui-lock-v2/txt/40x12/session-shared.txt new file mode 100644 index 00000000..48d0e1d4 --- /dev/null +++ b/docs/media/tui-lock-v2/txt/40x12/session-shared.txt @@ -0,0 +1,12 @@ + Shared 14K / 500K + + > /share + + Shared — read-only link is live. + cortex.foundation/share/9f4c2a71 + + ╭─ Agent ────────────────────────────╮ + │ > Plan, search, build anything │ + ╰─────────── Cortex Mini 1 (medium) ─╯ + + Shift+Tab:mode | Ctrl+x:shortcuts diff --git a/scripts/render-tui-lock-v2.sh b/scripts/render-tui-lock-v2.sh index 552319d6..9720d369 100755 --- a/scripts/render-tui-lock-v2.sh +++ b/scripts/render-tui-lock-v2.sh @@ -59,8 +59,8 @@ for spec in 40x12 120x40; do python3 scripts/ansi-frames-to-gif.py --frames "$frames" --png-only "$pngs" done -unique_pngs "$output_dir/40x12" 49 -unique_pngs "$output_dir/120x40" 95 +unique_pngs "$output_dir/40x12" 50 +unique_pngs "$output_dir/120x40" 96 python3 - "$output_dir" <<'PY' from pathlib import Path @@ -100,12 +100,13 @@ is banner green `#1F4945`; historical violet `#A78BFA` is not the lock. Designer boards (pixel target) live in `docs/media/tui-lock-v2/{40x12,120x40}/`. These runtime frames are what Designer cli signs off against. -SPEC §7: **95** boards at 120×40 and **49** at 40×12. Each filename is one +SPEC §7: **96** boards at 120×40 and **50** at 40×12. Each filename is one distinct live state — no two PNGs share a sha256. Includes `/goal` composer chips (`goal-chip-*`), distinct `offline` and `rate-limit` diagnostics, Computer lock boards (`computer-disconnected`, `computer-cloud-default`), local-tools consent, composer `@file` chip, `/undo` `/redo` `/rewind` sheet, -and runtime-only COR-18/225/227/228 scenes. +runtime-only COR-18/225/227/228 scenes, +and the `session-shared` status-line marker while a read-only `/share` link is live. EOF diff --git a/src/cortex-core/src/animation/mod.rs b/src/cortex-core/src/animation/mod.rs index b47bb867..0689bffd 100644 --- a/src/cortex-core/src/animation/mod.rs +++ b/src/cortex-core/src/animation/mod.rs @@ -121,23 +121,29 @@ mod tests { #[test] fn test_fade_in() { - let fade = Fade::fade_in(100); + // Long duration so a loaded nextest worker cannot advance past mid-fade + // between construction and the first progress() sample. + let fade = Fade::fade_in(60_000); assert!(!fade.is_complete()); assert_eq!(fade.direction(), FadeDirection::In); - // Initial progress should be near 0 let initial = fade.progress(); - assert!(initial < 0.5); + assert!( + initial < 0.5, + "fresh fade-in should start near 0, got {initial}" + ); } #[test] fn test_fade_out() { - let fade = Fade::fade_out(100); + let fade = Fade::fade_out(60_000); assert_eq!(fade.direction(), FadeDirection::Out); - // Initial progress should be near 1 (since it's fading out) let initial = fade.progress(); - assert!(initial > 0.5); + assert!( + initial > 0.5, + "fresh fade-out should start near 1, got {initial}" + ); } #[test] diff --git a/src/cortex-tui/src/app/state.rs b/src/cortex-tui/src/app/state.rs index 6109dd4c..328422eb 100644 --- a/src/cortex-tui/src/app/state.rs +++ b/src/cortex-tui/src/app/state.rs @@ -210,6 +210,8 @@ pub struct AppState { pub computer_held: bool, /// Welcome info card should lock-paint Computer · Cloud as the shipped default. pub show_computer_default: bool, + /// Live read-only share link minted by `/share`; `None` once `/unshare` clears it. + pub share_link: Option, /// Launched via the `agent` binary / alias. pub agent_entrypoint: bool, /// Token counter used / window. @@ -354,6 +356,7 @@ impl AppState { rate_limit_held: false, computer_held: false, show_computer_default: false, + share_link: None, agent_entrypoint: false, tokens_used: 0, context_window: 500_000, diff --git a/src/cortex-tui/src/lib.rs b/src/cortex-tui/src/lib.rs index af0ce279..3e94a5c6 100644 --- a/src/cortex-tui/src/lib.rs +++ b/src/cortex-tui/src/lib.rs @@ -112,6 +112,7 @@ mod lock_v2_ids; mod lock_v2_network; mod lock_v2_parity; mod lock_v2_scenes; +mod lock_v2_share; pub mod readme_hero; pub mod readme_hero_boards; pub mod splash_chrome; diff --git a/src/cortex-tui/src/lock_v2.rs b/src/cortex-tui/src/lock_v2.rs index 2cec5e16..5fcaf074 100644 --- a/src/cortex-tui/src/lock_v2.rs +++ b/src/cortex-tui/src/lock_v2.rs @@ -158,8 +158,8 @@ mod tests { #[test] fn lock_v2_wide_count_is_spec() { - assert_eq!(LOCK_V2_WIDE_IDS.len(), 95); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 49); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 96); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 50); } #[test] diff --git a/src/cortex-tui/src/lock_v2_boards.rs b/src/cortex-tui/src/lock_v2_boards.rs index d2ddf81b..61ae180c 100644 --- a/src/cortex-tui/src/lock_v2_boards.rs +++ b/src/cortex-tui/src/lock_v2_boards.rs @@ -21,6 +21,7 @@ use crate::lock_v2_goal::{ use crate::lock_v2_network::apply_offline_rate_limit_scene; use crate::lock_v2_parity::apply_parity_scene; use crate::lock_v2_scenes::*; +use crate::lock_v2_share::apply_share_scene; use crate::modal::mcp_manager::{McpServerInfo, McpStatus}; use crate::session::SessionSummary; use crate::ui::consts::SERVICE_UNAVAILABLE_NEXT_STEP; @@ -814,6 +815,7 @@ Tell me what you'd like to do.", id if apply_offline_rate_limit_scene(id, &mut state) => {} id if apply_parity_scene(id, &mut state, width) => {} id if apply_designed_scene(id, &mut state, width) => {} + id if apply_share_scene(id, &mut state) => {} id if apply_goal_chip_scene(id, &mut state) => {} "computer-disconnected" | "computer-cloud-default" => { assert!( diff --git a/src/cortex-tui/src/lock_v2_computer.rs b/src/cortex-tui/src/lock_v2_computer.rs index 9dfe1019..8c251b68 100644 --- a/src/cortex-tui/src/lock_v2_computer.rs +++ b/src/cortex-tui/src/lock_v2_computer.rs @@ -115,8 +115,8 @@ mod tests { assert!(LOCK_V2_WIDE_IDS.contains(id), "{id} missing from wide"); assert!(LOCK_V2_NARROW_IDS.contains(id), "{id} missing from narrow"); } - assert_eq!(LOCK_V2_WIDE_IDS.len(), 95); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 49); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 96); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 50); let mut seen = std::collections::HashSet::new(); for id in LOCK_V2_WIDE_IDS.iter().chain(LOCK_V2_NARROW_IDS) { seen.insert(*id); diff --git a/src/cortex-tui/src/lock_v2_designed.rs b/src/cortex-tui/src/lock_v2_designed.rs index 35f0fc15..8a20698d 100644 --- a/src/cortex-tui/src/lock_v2_designed.rs +++ b/src/cortex-tui/src/lock_v2_designed.rs @@ -246,8 +246,8 @@ mod tests { } assert!(!LOCK_V2_WIDE_IDS.contains(&"share-link")); assert!(!LOCK_V2_WIDE_IDS.contains(&"unshare")); - assert_eq!(LOCK_V2_WIDE_IDS.len(), 95); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 49); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 96); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 50); } #[test] diff --git a/src/cortex-tui/src/lock_v2_ids.rs b/src/cortex-tui/src/lock_v2_ids.rs index beb017d3..a59cb585 100644 --- a/src/cortex-tui/src/lock_v2_ids.rs +++ b/src/cortex-tui/src/lock_v2_ids.rs @@ -1,7 +1,7 @@ //! Lock v2 scene id lists. Split out of [`crate::lock_v2`] so adding `/goal` //! boards does not grow that file past the source-policy line-count baseline. -/// Narrow (40×12) SPEC §7 set — 49 boards. +/// Narrow (40×12) SPEC §7 set — 50 boards. pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "welcome-cortex", "welcome-agent", @@ -11,6 +11,7 @@ pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "session-thinking-live", "session-assistant", "session-optin", + "session-shared", "composer-empty", "composer-typing", "composer-hover", @@ -54,7 +55,7 @@ pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "undo-sheet", ]; -/// Wide (120×40) SPEC §7 set — 95 boards. +/// Wide (120×40) SPEC §7 set — 96 boards. pub const LOCK_V2_WIDE_IDS: &[&str] = &[ "welcome-cortex", "welcome-agent", @@ -68,6 +69,7 @@ pub const LOCK_V2_WIDE_IDS: &[&str] = &[ "session-worked", "session-optin", "session-optin-hover", + "session-shared", "composer-empty", "composer-typing", "composer-typing-blink", @@ -169,8 +171,8 @@ mod tests { #[test] fn lock_v2_id_counts_and_unique() { - assert_eq!(LOCK_V2_WIDE_IDS.len(), 95); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 49); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 96); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 50); let mut wide = HashSet::new(); for id in LOCK_V2_WIDE_IDS { assert!(wide.insert(*id), "duplicate wide id {id}"); diff --git a/src/cortex-tui/src/lock_v2_share.rs b/src/cortex-tui/src/lock_v2_share.rs new file mode 100644 index 00000000..6cd013c1 --- /dev/null +++ b/src/cortex-tui/src/lock_v2_share.rs @@ -0,0 +1,182 @@ +//! `/share` lock v2 scene — read-only share-link chrome. +//! +//! Split out of [`crate::lock_v2`] so the source-policy line-count baseline on +//! that file does not regress. The `Shared` marker is persistent status chrome: +//! it stays up for as long as the link is live, and `/unshare` clears it by +//! dropping [`crate::app::AppState::share_link`] back to `None` — idle chrome, +//! no confirmation sheet. + +use cortex_core::widgets::Message; + +use crate::app::AppState; + +/// Read-only link minted by `/share`, as it appears in the transcript. +pub const SHARE_URL: &str = "cortex.foundation/share/9f4c2a71"; +pub const SHARE_TITLE: &str = "Shared this session — read-only link is live."; +pub const SHARE_TITLE_NARROW: &str = "Shared — read-only link is live."; +pub const SHARE_LINK_LINE: &str = "cortex.foundation/share/9f4c2a71 · copied to your clipboard"; +pub const SHARE_SCOPE: &str = "Anyone with the link can read this session. /unshare turns it off."; + +/// Apply the `session-shared` lock scene. Returns `false` for any other `id`. +pub fn apply_share_scene(id: &str, state: &mut AppState) -> bool { + if id != "session-shared" { + return false; + } + let narrow = state.terminal_size.0 <= 40; + state.show_launch_splash = false; + state.tokens_used = 14_000; + state.share_link = Some(SHARE_URL.to_string()); + state.add_message(Message::user("/share").with_timestamp("04:41 PM")); + state.add_message(Message::system(if narrow { + SHARE_TITLE_NARROW + } else { + SHARE_TITLE + })); + state.add_message(Message::system(if narrow { + SHARE_URL + } else { + SHARE_LINK_LINE + })); + if !narrow { + state.add_message(Message::system(SHARE_SCOPE)); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lock_v2::render_lock_v2_scene; + use crate::ui::consts::{SHARE_MARKER, SHARE_MARKER_NARROW}; + use crate::views::minimal_session::PLACEHOLDER_IDLE; + use cortex_core::style::{ERROR, TEXT_DIM}; + use ratatui::style::Color; + + fn row_text(frame: &crate::lock_proof::LockFrame, y: u16) -> String { + (0..frame.buffer.area.width) + .map(|x| frame.buffer[(x, y)].symbol()) + .collect() + } + + fn first_fg(frame: &crate::lock_proof::LockFrame, needle: &str) -> Option { + for y in 0..frame.buffer.area.height { + let row = row_text(frame, y); + if let Some(idx) = row.find(needle) { + return Some(frame.buffer[(idx as u16, y)].fg); + } + } + None + } + + #[test] + fn share_marker_is_dim_status_chrome_at_both_sizes() { + for (width, height, marker) in + [(120u16, 40u16, SHARE_MARKER), (40, 12, SHARE_MARKER_NARROW)] + { + let frame = render_lock_v2_scene("session-shared", width, height) + .unwrap_or_else(|e| panic!("session-shared {width}x{height}: {e}")); + let header = row_text(&frame, 0); + assert!( + header.contains(marker), + "share marker on the status line at {width}x{height}:\n{header}" + ); + assert!( + header.contains("Shared"), + "narrow must keep the word Shared at {width}x{height}:\n{header}" + ); + assert!( + header.contains("14K / 500K"), + "token counter stays on the status line at {width}x{height}:\n{header}" + ); + assert_eq!( + first_fg(&frame, "Shared"), + Some(TEXT_DIM), + "share marker is dim at {width}x{height}" + ); + assert!( + frame.plain.contains(SHARE_URL), + "minted read-only link at {width}x{height}:\n{}", + frame.plain + ); + } + } + + #[test] + fn shared_session_stays_interactive_with_the_idle_footer() { + let frame = render_lock_v2_scene("session-shared", 120, 40).expect("wide"); + assert!( + frame.plain.contains(PLACEHOLDER_IDLE), + "composer stays interactive — not held:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("Shift+Tab:mode") && frame.plain.contains("Ctrl+x:shortcuts"), + "normal session footer:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("Choose an option above"), + "share chrome is not an approval sheet:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("copied to your clipboard"), + "wide board notes the clipboard copy:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("/unshare"), + "wide board names /unshare as the way out:\n{}", + frame.plain + ); + assert_ne!( + first_fg(&frame, "Shared this session"), + Some(ERROR), + "share copy is not an error" + ); + } + + #[test] + fn session_shared_is_distinct_from_neighbouring_boards() { + let shared_wide = render_lock_v2_scene("session-shared", 120, 40).expect("wide"); + let shared_narrow = render_lock_v2_scene("session-shared", 40, 12).expect("narrow"); + assert_ne!(shared_wide.ansi, shared_narrow.ansi); + for other in [ + "session-empty", + "session-user-bars", + "cloud-handoff", + "goal-chip-active", + ] { + let frame = render_lock_v2_scene(other, 120, 40).expect(other); + assert_ne!( + shared_wide.ansi, frame.ansi, + "session-shared must differ from {other}" + ); + assert!( + !row_text(&frame, 0).contains("Shared"), + "{other} must not paint the share marker:\n{}", + row_text(&frame, 0) + ); + } + } + + #[test] + fn unshare_clears_the_marker() { + let mut state = AppState::default(); + state.terminal_size = (120, 40); + assert!(apply_share_scene("session-shared", &mut state)); + assert_eq!(state.share_link.as_deref(), Some(SHARE_URL)); + // `/unshare` is idle chrome: dropping the link is all it takes. + state.share_link = None; + assert!(state.share_link.is_none()); + } + + #[test] + fn apply_rejects_unknown_ids() { + let mut state = AppState::default(); + assert!(!apply_share_scene("session-empty", &mut state)); + assert!(!apply_share_scene("cloud-handoff", &mut state)); + assert!(state.share_link.is_none()); + assert!(state.messages.is_empty()); + } +} diff --git a/src/cortex-tui/src/ui/chrome.rs b/src/cortex-tui/src/ui/chrome.rs index 82f7b84e..7e1338fb 100644 --- a/src/cortex-tui/src/ui/chrome.rs +++ b/src/cortex-tui/src/ui/chrome.rs @@ -74,6 +74,29 @@ pub fn paint_token_counter( buf.set_string(x, y, &text, style); } +/// Dim status marker on the header row, left margin, clipped so it never runs +/// into the token counter. Status chrome only — never accent. +pub fn paint_status_marker(area: Rect, y: u16, buf: &mut Buffer, label: &str, reserved: u16) { + if area.width == 0 || y >= area.bottom() || label.is_empty() { + return; + } + let gutter = content_gutter(false, area.width); + let room = area + .width + .saturating_sub(gutter) + .saturating_sub(reserved.saturating_add(2)); + if room == 0 { + return; + } + let text: String = label.chars().take(room as usize).collect(); + buf.set_string( + area.x.saturating_add(gutter), + y, + text, + Style::default().fg(TEXT_DIM), + ); +} + /// 12-hour `h:mm AM` clock. pub fn format_clock_12h(hour: u32, minute: u32) -> String { use chrono::Timelike; diff --git a/src/cortex-tui/src/ui/consts.rs b/src/cortex-tui/src/ui/consts.rs index 3d98eab0..e0695595 100644 --- a/src/cortex-tui/src/ui/consts.rs +++ b/src/cortex-tui/src/ui/consts.rs @@ -66,6 +66,12 @@ pub const PLACEHOLDER_DISCONNECTED: &str = "Connect a host — or unset CORTEX_C /// Narrow (40-column) form of the disconnected placeholder. pub const PLACEHOLDER_DISCONNECTED_NARROW: &str = "Connect a host — or unset for Cloud"; +/// Status-line marker shown while a read-only `/share` link is live. Dim status +/// chrome, never accent — `/unshare` clears it. +pub const SHARE_MARKER: &str = "Shared · read-only"; +/// Narrow (40-column) form of the share marker. Keeps the word `Shared`. +pub const SHARE_MARKER_NARROW: &str = "Shared"; + /// Sandbox deny title painted in error red. pub const SANDBOX_DENIED_TITLE: &str = "Sandbox denied"; diff --git a/src/cortex-tui/src/views/minimal_session/view.rs b/src/cortex-tui/src/views/minimal_session/view.rs index 1fa5695f..03bcf703 100644 --- a/src/cortex-tui/src/views/minimal_session/view.rs +++ b/src/cortex-tui/src/views/minimal_session/view.rs @@ -12,8 +12,8 @@ use super::rendering::{ use crate::app::AppState; use crate::commands::PALETTE_HOME_LIMIT; use crate::ui::chrome::{ - FooterSet, composer_caret_style, composer_inner, fill_inky, model_chip, paint_composer_box, - paint_footer, paint_token_counter, + FooterSet, composer_caret_style, composer_inner, fill_inky, format_token_counter, model_chip, + paint_composer_box, paint_footer, paint_status_marker, paint_token_counter, }; use crate::ui::colors::AdaptiveColors; use cortex_core::style::{ACCENT, TEXT, TEXT_BRIGHT, TEXT_DIM}; @@ -605,6 +605,18 @@ impl<'a> Widget for MinimalSessionView<'a> { self.app_state.context_window, warn_tokens, ); + if self.app_state.share_link.is_some() { + let label = if area_is_narrow(area.width) { + crate::ui::consts::SHARE_MARKER_NARROW + } else { + crate::ui::consts::SHARE_MARKER + }; + let counter_cols = + format_token_counter(self.app_state.tokens_used, self.app_state.context_window) + .chars() + .count() as u16; + paint_status_marker(area, area.y, buf, label, counter_cols); + } let autocomplete_visible = self.app_state.autocomplete.visible; let palette_cap = if area.height >= 20 {