diff --git a/docs/media/tui-lock-v2/120x40/composer-file-chip.png b/docs/media/tui-lock-v2/120x40/composer-file-chip.png new file mode 100644 index 00000000..3408c32e Binary files /dev/null and b/docs/media/tui-lock-v2/120x40/composer-file-chip.png differ diff --git a/docs/media/tui-lock-v2/120x40/consent-local-tools.png b/docs/media/tui-lock-v2/120x40/consent-local-tools.png new file mode 100644 index 00000000..5cebe990 Binary files /dev/null and b/docs/media/tui-lock-v2/120x40/consent-local-tools.png differ diff --git a/docs/media/tui-lock-v2/120x40/undo-sheet.png b/docs/media/tui-lock-v2/120x40/undo-sheet.png new file mode 100644 index 00000000..1ef1e607 Binary files /dev/null and b/docs/media/tui-lock-v2/120x40/undo-sheet.png differ diff --git a/docs/media/tui-lock-v2/40x12/composer-file-chip.png b/docs/media/tui-lock-v2/40x12/composer-file-chip.png new file mode 100644 index 00000000..0f58e6c9 Binary files /dev/null and b/docs/media/tui-lock-v2/40x12/composer-file-chip.png differ diff --git a/docs/media/tui-lock-v2/40x12/consent-local-tools.png b/docs/media/tui-lock-v2/40x12/consent-local-tools.png new file mode 100644 index 00000000..218e6f95 Binary files /dev/null and b/docs/media/tui-lock-v2/40x12/consent-local-tools.png differ diff --git a/docs/media/tui-lock-v2/40x12/undo-sheet.png b/docs/media/tui-lock-v2/40x12/undo-sheet.png new file mode 100644 index 00000000..ef86891e Binary files /dev/null and b/docs/media/tui-lock-v2/40x12/undo-sheet.png differ diff --git a/docs/media/tui-lock-v2/SPEC.md b/docs/media/tui-lock-v2/SPEC.md index 6107e15d..a8ef6a6a 100644 --- a/docs/media/tui-lock-v2/SPEC.md +++ b/docs/media/tui-lock-v2/SPEC.md @@ -7,7 +7,7 @@ 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. **No runtime code changes ship with this pack.** -- Boards: [`index.md`](index.md) — 84 boards at 120×40, 38 of them also at 40×12 (122 PNGs). +- Boards: [`index.md`](index.md) — 87 boards at 120×40, 41 of them also at 40×12 (128 PNGs). - 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 @@ -419,13 +419,15 @@ Narrow: no bars, `used / total pct%`. | login / first-run | `login`, `login-waiting`, `login-success`, `login-error`, `first-run-tips` | | compact chat (ref 5) | `compact-chat` | | other product surfaces (not in the brief, kept complete) | `shortcuts-overlay`, `resume-picker`, `clear-confirm`, `plan-confirm`, `queue`, `files-picker`, `jobs`, `skills`, `todos`, `question`, `sudo`, `config-tree`, `btw` | +| 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`, `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`, `mode-plan`, `mode-ask`, -`permission-prompt`, `mcp-servers`, `usage`, `diagnostics`, `interrupt-stopped`, `offline`, `rate-limit`, `diff-hunk`, `login`, `shortcuts-overlay`. +`permission-prompt`, `mcp-servers`, `usage`, `diagnostics`, `interrupt-stopped`, `offline`, `rate-limit`, `diff-hunk`, `login`, `shortcuts-overlay`, +`consent-local-tools`, `composer-file-chip`, `undo-sheet`. --- diff --git a/docs/media/tui-lock-v2/index.md b/docs/media/tui-lock-v2/index.md index 8ee26a96..cff79b57 100644 --- a/docs/media/tui-lock-v2/index.md +++ b/docs/media/tui-lock-v2/index.md @@ -114,5 +114,8 @@ Regenerate: `python3 tools/render_lock_v2.py --index` (fetches IBM Plex Mono on | `sudo` | Elevated Shell — password row on gray | [120x40](120x40/sudo.png) | — | | `config-tree` | `/config` read-only key tree | [120x40](120x40/config-tree.png) | — | | `btw` | `/btw` side note during a running turn | [120x40](120x40/btw.png) | — | +| `consent-local-tools` | Consent card — run tools locally in CWD / This PC | [120x40](120x40/consent-local-tools.png) | [40x12](40x12/consent-local-tools.png) | +| `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) | -**84** boards at 120x40 · **38** at 40x12 · 122 PNGs. +**87** boards at 120x40 · **41** at 40x12 · 128 PNGs. diff --git a/docs/media/tui-lock-v2/runtime/120x40/composer-file-chip.png b/docs/media/tui-lock-v2/runtime/120x40/composer-file-chip.png new file mode 100644 index 00000000..4195ed48 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/composer-file-chip.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/consent-local-tools.png b/docs/media/tui-lock-v2/runtime/120x40/consent-local-tools.png new file mode 100644 index 00000000..546022a7 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/consent-local-tools.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/undo-sheet.png b/docs/media/tui-lock-v2/runtime/120x40/undo-sheet.png new file mode 100644 index 00000000..407116aa Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/undo-sheet.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/composer-file-chip.png b/docs/media/tui-lock-v2/runtime/40x12/composer-file-chip.png new file mode 100644 index 00000000..d07d052b Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/composer-file-chip.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/consent-local-tools.png b/docs/media/tui-lock-v2/runtime/40x12/consent-local-tools.png new file mode 100644 index 00000000..af9f0519 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/consent-local-tools.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/undo-sheet.png b/docs/media/tui-lock-v2/runtime/40x12/undo-sheet.png new file mode 100644 index 00000000..99c5a9a6 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/undo-sheet.png differ diff --git a/docs/media/tui-lock-v2/runtime/README.md b/docs/media/tui-lock-v2/runtime/README.md index 4955b603..a45e0eeb 100644 --- a/docs/media/tui-lock-v2/runtime/README.md +++ b/docs/media/tui-lock-v2/runtime/README.md @@ -8,6 +8,7 @@ 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: **84** boards at 120×40 and **38** at 40×12. Each filename is one +SPEC §7: **87** boards at 120×40 and **41** at 40×12. Each filename is one distinct live state — no two PNGs share a sha256. Includes `/goal` composer -chips (`goal-chip-*`) plus distinct `offline` and `rate-limit` diagnostics. +chips (`goal-chip-*`), distinct `offline` and `rate-limit` diagnostics, +local-tools consent, composer `@file` chip, and `/undo` `/redo` `/rewind` sheet. diff --git a/docs/media/tui-lock-v2/tools/boards.py b/docs/media/tui-lock-v2/tools/boards.py index 1c6f6e3c..2e03c2c6 100644 --- a/docs/media/tui-lock-v2/tools/boards.py +++ b/docs/media/tui-lock-v2/tools/boards.py @@ -1811,6 +1811,76 @@ def board_btw(s, c): footer(s, c, [("Esc", "interrupt"), ("Enter", "queue follow-up"), ("Ctrl+x", "shortcuts")]) +def board_consent_local_tools(s, c): + """COR-222 — consent card to run tools locally in the CWD / This PC. + + Distinct from ``permission-prompt`` (no ``$`` command block) and + ``session-optin`` (no retain-coding-data privacy copy). + """ + header(s, c) + top = composer(s, c, focused=False, caret=False, placeholder="Choose an option above") + f = Flow(s, c, y=1 if c.narrow else 2, limit=top if c.narrow else top - 1) + if not c.narrow: + f.user("fix the failing test in this repo", "10:18 AM") + f.thought("Thought for 0.6s") + f.line([("●", S_DIM), (" Run tools locally?", S)]) + if c.narrow: + f.dim("This PC · current directory · Cloud is default") + else: + f.dim("Tools run on This PC against the current working directory. Cloud is the default when unset.") + yes = "Yes — run tools here" if c.narrow else "Yes — run tools in this directory" + always = "Always allow project" if c.narrow else "Always allow for this project" + no = "No — keep using Cloud" + f.options([yes, always, no], focused=0) + footer( + s, + c, + [("↑↓", "select"), ("Enter", "confirm"), ("Esc", "cancel")] + if not c.narrow + else [("Enter", "confirm"), ("Esc", "cancel")], + ) + + +def board_composer_file_chip(s, c): + """COR-223 — post-attach composer with an ``@file`` chip (picker closed).""" + header(s, c) + path = "@src/composer.rs" if c.narrow else "@src/cortex-tui/src/composer.rs" + top = composer(s, c, content=[("explain ", S), (path, S_ACC), (" ", S)]) + if not c.narrow: + s.put(c.x0 + 2, top - 2, clip("@ attaches files · ! enters Bash", c.inner_w - 4), S_DIM) + footer( + s, + c, + [("Enter", "send"), ("@", "files"), ("!", "bash")] + if not c.narrow + else [("Enter", "send"), ("@", "files")], + ) + + +def board_undo_sheet(s, c): + """COR-224 — ``/undo`` ``/redo`` ``/rewind`` sheet (not clear / resume).""" + header(s, c) + top = composer(s, c, content=[("/undo", S_ACC), (" ", S)]) + if c.narrow: + f = Flow(s, c, y=1, limit=top) + f.line([("Undo", S)]) + f.options(["Undo last turn", "Redo", "Rewind…"], focused=0) + else: + y = top - 6 + backdrop_tail(s, c, y) + f = Flow(s, c, y=y, limit=top - 1) + f.line([("Undo", S)]) + f.dim("Undo the last turn, redo it, or rewind this Cortex session to a checkpoint.") + f.options(["Undo last turn", "Redo", "Rewind to checkpoint"], focused=0) + footer( + s, + c, + [("↑↓", "select"), ("Enter", "confirm"), ("Esc", "close")] + if not c.narrow + else [("Enter", "confirm"), ("Esc", "close")], + ) + + # --------------------------------------------------------------------------- # # Registry — (id, painter, meta). ``narrow`` = also rendered at 40×12. # --------------------------------------------------------------------------- # @@ -1914,6 +1984,9 @@ def board_btw(s, c): ("sudo", board_sudo, False, "E", "Elevated Shell — password row on gray"), ("config-tree", board_config_tree, False, "E", "`/config` read-only key tree"), ("btw", board_btw, False, "E", "`/btw` side note during a running turn"), + ("consent-local-tools", board_consent_local_tools, True, "E", "Consent card — run tools locally in CWD / This PC"), + ("composer-file-chip", board_composer_file_chip, True, "E", "Composer with attached @file chip in prompt"), + ("undo-sheet", board_undo_sheet, True, "E", "/undo /redo /rewind sheet"), ] BOARDS = [(bid, fn, {"narrow": narrow, "section": sec, "desc": desc}) for bid, fn, narrow, sec, desc in BOARDS_META] diff --git a/docs/media/tui-lock-v2/txt/120x40/composer-file-chip.txt b/docs/media/tui-lock-v2/txt/120x40/composer-file-chip.txt new file mode 100644 index 00000000..d5a51e17 --- /dev/null +++ b/docs/media/tui-lock-v2/txt/120x40/composer-file-chip.txt @@ -0,0 +1,40 @@ + 14K / 500K + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @ attaches files · ! enters Bash + + ╭─ Agent ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ > explain @src/cortex-tui/src/composer.rs │ + ╰─────────────────────────────────────────────────────────────────────────────────────────── Cortex Mini 1 (medium) ─╯ + + Enter:send | @:files | !:bash diff --git a/docs/media/tui-lock-v2/txt/120x40/consent-local-tools.txt b/docs/media/tui-lock-v2/txt/120x40/consent-local-tools.txt new file mode 100644 index 00000000..47201c6f --- /dev/null +++ b/docs/media/tui-lock-v2/txt/120x40/consent-local-tools.txt @@ -0,0 +1,40 @@ + 14K / 500K + + > fix the failing test in this repo 10:18 AM + + ♦ Thought for 0.6s + + ● Run tools locally? + Tools run on This PC against the current working directory. Cloud is the default when unset. + > 1 Yes — run tools in this directory + 2 Always allow for this project + 3 No — keep using Cloud + + + + + + + + + + + + + + + + + + + + + + + + + ╭─ Agent ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ > Choose an option above │ + ╰─────────────────────────────────────────────────────────────────────────────────────────── Cortex Mini 1 (medium) ─╯ + + ↑↓:select | Enter:confirm | Esc:cancel diff --git a/docs/media/tui-lock-v2/txt/120x40/undo-sheet.txt b/docs/media/tui-lock-v2/txt/120x40/undo-sheet.txt new file mode 100644 index 00000000..f45f02e4 --- /dev/null +++ b/docs/media/tui-lock-v2/txt/120x40/undo-sheet.txt @@ -0,0 +1,40 @@ + 14K / 500K + + + > hey 12:49 AM + + ♦ Thought for 0.4s + + Hey — what do you want to work on? 12:49 AM + + Worked for 1.8s + + > tell me about yourself 12:49 AM + + ♦ Thought for 0.4s + + I'm Cortex, a coding agent that runs in your terminal. 12:49 AM + + I mostly help you build and debug software: code, architecture, debugging, reviews, docs, and a bit of research. + Here I run in an interactive terminal, so I can read your files, run commands, and change the project. + + In practice: + • I get straight to the point + • I prefer concrete work over long explanations + • I can also discuss, explain, or help you plan + + Tell me what you'd like to do. + + Worked for 4.6s + + Undo + Undo the last turn, redo it, or rewind this Cortex session to a checkpoint. + > 1 Undo last turn + 2 Redo + 3 Rewind to checkpoint + + ╭─ Agent ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ > /undo │ + ╰─────────────────────────────────────────────────────────────────────────────────────────── Cortex Mini 1 (medium) ─╯ + + ↑↓:select | Enter:confirm | Esc:close diff --git a/docs/media/tui-lock-v2/txt/40x12/composer-file-chip.txt b/docs/media/tui-lock-v2/txt/40x12/composer-file-chip.txt new file mode 100644 index 00000000..5c346dcb --- /dev/null +++ b/docs/media/tui-lock-v2/txt/40x12/composer-file-chip.txt @@ -0,0 +1,12 @@ + 14K / 500K + + + + + + + ╭─ Agent ────────────────────────────╮ + │ > explain @src/composer.rs │ + ╰─────────── Cortex Mini 1 (medium) ─╯ + + Enter:send | @:files diff --git a/docs/media/tui-lock-v2/txt/40x12/consent-local-tools.txt b/docs/media/tui-lock-v2/txt/40x12/consent-local-tools.txt new file mode 100644 index 00000000..ddc6e141 --- /dev/null +++ b/docs/media/tui-lock-v2/txt/40x12/consent-local-tools.txt @@ -0,0 +1,12 @@ + 14K / 500K + ● Run tools locally? + This PC · current directory · Clou… + > 1 Yes — run tools here + 2 Always allow project + 3 No — keep using Cloud + + ╭─ Agent ────────────────────────────╮ + │ > Choose an option above │ + ╰─────────── Cortex Mini 1 (medium) ─╯ + + Enter:confirm | Esc:cancel diff --git a/docs/media/tui-lock-v2/txt/40x12/undo-sheet.txt b/docs/media/tui-lock-v2/txt/40x12/undo-sheet.txt new file mode 100644 index 00000000..264811fd --- /dev/null +++ b/docs/media/tui-lock-v2/txt/40x12/undo-sheet.txt @@ -0,0 +1,12 @@ + 14K / 500K + Undo + > 1 Undo last turn + 2 Redo + 3 Rewind… + + + ╭─ Agent ────────────────────────────╮ + │ > /undo │ + ╰─────────── Cortex Mini 1 (medium) ─╯ + + Enter:confirm | Esc:close diff --git a/scripts/render-tui-lock-v2.sh b/scripts/render-tui-lock-v2.sh index 6a13db4f..578c284f 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" 38 -unique_pngs "$output_dir/120x40" 84 +unique_pngs "$output_dir/40x12" 41 +unique_pngs "$output_dir/120x40" 87 python3 - "$output_dir" <<'PY' from pathlib import Path @@ -100,9 +100,10 @@ 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: **84** boards at 120×40 and **38** at 40×12. Each filename is one +SPEC §7: **87** boards at 120×40 and **41** at 40×12. Each filename is one distinct live state — no two PNGs share a sha256. Includes `/goal` composer -chips (`goal-chip-*`) plus distinct `offline` and `rate-limit` diagnostics. +chips (`goal-chip-*`), distinct `offline` and `rate-limit` diagnostics, +local-tools consent, composer `@file` chip, and `/undo` `/redo` `/rewind` sheet. EOF echo "Wrote runtime captures to $output_dir" diff --git a/src/cortex-tui/src/bin/generate_tui_lock_screenshots.rs b/src/cortex-tui/src/bin/generate_tui_lock_screenshots.rs index 74385997..4ddebc7a 100644 --- a/src/cortex-tui/src/bin/generate_tui_lock_screenshots.rs +++ b/src/cortex-tui/src/bin/generate_tui_lock_screenshots.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use std::process; use cortex_tui::lock_proof::write_lock_frames; -use cortex_tui::lock_v2::write_lock_v2_frames; +use cortex_tui::lock_v2::{validate_lock_v2_only_ids, write_lock_v2_frames}; fn print_help() { println!( @@ -25,6 +25,7 @@ OPTIONS: -w, --width Terminal width (default: 120) -h, --height Terminal height (default: 40) --v2 Capture lock v2 scenes (real session chrome) + --only Comma-separated lock v2 scene ids (with --v2) --help Show this help " ); @@ -36,6 +37,8 @@ fn main() { let mut width: u16 = 120; let mut height: u16 = 40; let mut v2 = false; + let mut only_flag = false; + let mut only: Vec = Vec::new(); let mut i = 1; while i < args.len() { @@ -68,6 +71,20 @@ fn main() { "--v2" => { v2 = true; } + "--only" => { + only_flag = true; + i += 1; + let raw = args.get(i).cloned().unwrap_or_else(|| { + eprintln!("Missing value for --only"); + process::exit(1); + }); + only = raw + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + } other => { eprintln!("Unknown argument: {other}"); print_help(); @@ -77,8 +94,22 @@ fn main() { i += 1; } + if only_flag && !v2 { + eprintln!("--only requires --v2"); + process::exit(1); + } + let result = if v2 { - write_lock_v2_frames(width, height, &output) + if only_flag { + let ids: Vec<&str> = only.iter().map(String::as_str).collect(); + if let Err(err) = validate_lock_v2_only_ids(&ids, width) { + eprintln!("{err:#}"); + process::exit(1); + } + cortex_tui::lock_v2::write_lock_v2_id_frames(&ids, width, height, &output) + } else { + write_lock_v2_frames(width, height, &output) + } } else { write_lock_frames(width, height, &output) }; diff --git a/src/cortex-tui/src/lib.rs b/src/cortex-tui/src/lib.rs index 4e90462b..69ae0232 100644 --- a/src/cortex-tui/src/lib.rs +++ b/src/cortex-tui/src/lib.rs @@ -108,6 +108,7 @@ mod lock_v2_boards; mod lock_v2_goal; mod lock_v2_ids; mod lock_v2_network; +mod lock_v2_parity; mod lock_v2_scenes; pub mod readme_hero; pub mod readme_hero_boards; diff --git a/src/cortex-tui/src/lock_v2.rs b/src/cortex-tui/src/lock_v2.rs index 6e659f86..9d5061cf 100644 --- a/src/cortex-tui/src/lock_v2.rs +++ b/src/cortex-tui/src/lock_v2.rs @@ -7,6 +7,7 @@ use anyhow::{Context, Result}; use cortex_tui_capture::{CaptureConfig, MockTerminal, StyleRendering}; use ratatui::widgets::Clear; use serde::Serialize; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use crate::lock_proof::{LOCK_SPLASH_VERSION, LockFrame}; @@ -33,10 +34,39 @@ struct ManifestFrame { } pub fn write_lock_v2_frames(width: u16, height: u16, output_dir: &Path) -> Result { + write_lock_v2_id_frames(lock_v2_scene_ids(width), width, height, output_dir) +} + +/// Reject empty, unknown, or repeated `--only` scene ids before any writes. +pub fn validate_lock_v2_only_ids(ids: &[&str], width: u16) -> Result<()> { + if ids.is_empty() { + anyhow::bail!("--only requires at least one scene id"); + } + let known = lock_v2_scene_ids(width); + let mut seen = HashSet::new(); + for id in ids { + if !known.contains(id) { + anyhow::bail!("unknown lock v2 scene id `{id}`"); + } + if !seen.insert(*id) { + anyhow::bail!("repeated lock v2 scene id `{id}`"); + } + } + Ok(()) +} + +/// Write a subset of lock v2 scenes (used to recapture residual boards). +pub fn write_lock_v2_id_frames( + ids: &[&str], + width: u16, + height: u16, + output_dir: &Path, +) -> Result { + validate_lock_v2_only_ids(ids, width)?; std::fs::create_dir_all(output_dir) .with_context(|| format!("create {}", output_dir.display()))?; let mut manifest_frames = Vec::new(); - for id in lock_v2_scene_ids(width) { + for id in ids { let frame = render_lock_v2_scene(id, width, height)?; let file = format!("{}.ans", id); std::fs::write(output_dir.join(&file), &frame.ansi) @@ -128,8 +158,41 @@ mod tests { #[test] fn lock_v2_wide_count_is_spec() { - assert_eq!(LOCK_V2_WIDE_IDS.len(), 84); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 38); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 87); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 41); + } + + #[test] + fn only_ids_reject_unknown_repeated_and_empty() { + assert!(validate_lock_v2_only_ids(&["welcome-cortex"], 120).is_ok()); + let unknown = validate_lock_v2_only_ids(&["welcome-cortex", "unknown-scene"], 120) + .expect_err("unknown"); + assert!(unknown.to_string().contains("unknown"), "{unknown}"); + let repeated = validate_lock_v2_only_ids(&["welcome-cortex", "welcome-cortex"], 120) + .expect_err("repeated"); + assert!(repeated.to_string().contains("repeated"), "{repeated}"); + let empty = validate_lock_v2_only_ids(&[], 120).expect_err("empty"); + assert!(empty.to_string().contains("at least one"), "{empty}"); + let wide_only_at_narrow = + validate_lock_v2_only_ids(&["session-thought"], 40).expect_err("wide-only at 40"); + assert!( + wide_only_at_narrow.to_string().contains("unknown"), + "{wide_only_at_narrow}" + ); + } + + #[test] + fn write_lock_v2_id_frames_does_not_write_on_invalid_ids() { + let dir = std::env::temp_dir().join(format!("cortex-lock-only-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let err = write_lock_v2_id_frames(&["welcome-cortex", "unknown-scene"], 120, 40, &dir) + .expect_err("invalid ids"); + assert!(err.to_string().contains("unknown"), "{err}"); + assert!( + !dir.join("welcome-cortex.ans").exists(), + "must not write a partial capture set" + ); + assert!(!dir.join("manifest.json").exists()); } #[test] diff --git a/src/cortex-tui/src/lock_v2_boards.rs b/src/cortex-tui/src/lock_v2_boards.rs index f021226a..df07d65f 100644 --- a/src/cortex-tui/src/lock_v2_boards.rs +++ b/src/cortex-tui/src/lock_v2_boards.rs @@ -16,6 +16,7 @@ use crate::interactive::builders::{ use crate::lock_v2::PRODUCT_ERROR; use crate::lock_v2_goal::{apply_goal_chip_scene, show_goal_in_narrow_palette}; 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::modal::mcp_manager::{McpServerInfo, McpStatus}; use crate::session::SessionSummary; @@ -808,6 +809,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_goal_chip_scene(id, &mut state) => {} other => panic!("unknown lock v2 scene {other}"), } diff --git a/src/cortex-tui/src/lock_v2_ids.rs b/src/cortex-tui/src/lock_v2_ids.rs index 8b6274a4..77ffa55d 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 — 38 boards. +/// Narrow (40×12) SPEC §7 set — 41 boards. pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "welcome-cortex", "welcome-agent", @@ -41,9 +41,12 @@ pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "diff-hunk", "login", "shortcuts-overlay", + "consent-local-tools", + "composer-file-chip", + "undo-sheet", ]; -/// Wide (120×40) SPEC §7 set — 84 boards. +/// Wide (120×40) SPEC §7 set — 87 boards. pub const LOCK_V2_WIDE_IDS: &[&str] = &[ "welcome-cortex", "welcome-agent", @@ -129,6 +132,9 @@ pub const LOCK_V2_WIDE_IDS: &[&str] = &[ "sudo", "config-tree", "btw", + "consent-local-tools", + "composer-file-chip", + "undo-sheet", ]; /// Boards captured at both sizes. Narrow (40×12) is a subset. @@ -139,3 +145,27 @@ pub fn lock_v2_scene_ids(width: u16) -> &'static [&'static str] { LOCK_V2_WIDE_IDS } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn lock_v2_id_counts_and_unique() { + assert_eq!(LOCK_V2_WIDE_IDS.len(), 87); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 41); + let mut wide = HashSet::new(); + for id in LOCK_V2_WIDE_IDS { + assert!(wide.insert(*id), "duplicate wide id {id}"); + } + let mut narrow = HashSet::new(); + for id in LOCK_V2_NARROW_IDS { + assert!(narrow.insert(*id), "duplicate narrow id {id}"); + assert!( + wide.contains(id), + "narrow id {id} is not in LOCK_V2_WIDE_IDS" + ); + } + } +} diff --git a/src/cortex-tui/src/lock_v2_parity.rs b/src/cortex-tui/src/lock_v2_parity.rs new file mode 100644 index 00000000..aa534b82 --- /dev/null +++ b/src/cortex-tui/src/lock_v2_parity.rs @@ -0,0 +1,399 @@ +//! Lock v2 residual chrome: local-tools consent, `@file` chip, undo sheet. +//! +//! Split out of [`crate::lock_v2_boards`] so adding COR-222/223/224 scenes +//! does not grow that file past the source-policy line-count baseline. + +use cortex_core::widgets::Message; + +use crate::app::AppState; +use crate::interactive::builders::build_question_prompt; +use crate::lock_v2_scenes::{conversation, radios, resumed}; + +/// Residual parity boards (wide + narrow). +pub const PARITY_IDS: &[&str] = &["consent-local-tools", "composer-file-chip", "undo-sheet"]; + +/// Attached `@file` token shown after the picker closes. +pub const FILE_CHIP_TOKEN: &str = "@src/cortex-tui/src/composer.rs"; + +/// Wide composer prompt with the attached file chip and a trailing space. +pub const FILE_CHIP_PROMPT: &str = "explain @src/cortex-tui/src/composer.rs "; + +/// Apply a residual parity scene. Returns `false` when `id` is not one of ours. +pub fn apply_parity_scene(id: &str, state: &mut AppState, width: u16) -> bool { + if !PARITY_IDS.contains(&id) { + return false; + } + match id { + "consent-local-tools" => { + apply_consent_local_tools(state, width); + true + } + "composer-file-chip" => { + apply_composer_file_chip(state, width); + true + } + "undo-sheet" => { + apply_undo_sheet(state, width); + true + } + _ => false, + } +} + +fn apply_consent_local_tools(state: &mut AppState, width: u16) { + resumed(state); + let narrow = width <= 40; + if !narrow { + state.add_message( + Message::user("fix the failing test in this repo").with_timestamp("10:18 AM"), + ); + state.add_message( + Message::assistant( + "Run tools locally?\nTools run on This PC against the current working directory. Cloud is the default when unset.", + ) + .with_timestamp("10:18 AM") + .with_thought_secs(0.6), + ); + } else { + state.add_message( + Message::assistant( + "Run tools locally?\nThis PC · current directory · Cloud is default", + ) + .with_timestamp("10:18 AM"), + ); + } + let rows: &[(&str, &str, &str)] = if narrow { + &[ + ("yes", "1 Yes — run tools here", ""), + ("always", "2 Always allow project", ""), + ("no", "3 No — keep using Cloud", ""), + ] + } else { + &[ + ( + "yes", + "1 Yes — run tools in this directory", + "This PC · current directory", + ), + ( + "always", + "2 Always allow for this project", + "remember this workspace", + ), + ("no", "3 No — keep using Cloud", "Cloud stays the default"), + ] + }; + state.enter_interactive_mode( + build_question_prompt("Run tools locally?", rows, 0).with_prompt_focus(), + ); +} + +/// Narrow composer cannot hold the full crate path after `> explain `. +pub const FILE_CHIP_PROMPT_NARROW: &str = "explain @src/composer.rs "; + +fn apply_composer_file_chip(state: &mut AppState, width: u16) { + resumed(state); + if width <= 40 { + state.input.set_text(FILE_CHIP_PROMPT_NARROW); + } else { + debug_assert!(FILE_CHIP_PROMPT.contains(FILE_CHIP_TOKEN)); + state.add_message(Message::system("@ attaches files · ! enters Bash")); + state.input.set_text(FILE_CHIP_PROMPT); + } +} + +fn apply_undo_sheet(state: &mut AppState, width: u16) { + if width > 40 { + conversation(state); + state.add_message( + Message::assistant( + "Undo\nUndo the last turn, redo it, or rewind this Cortex session to a checkpoint.", + ) + .with_timestamp("10:22 AM"), + ); + } else { + resumed(state); + state.add_message(Message::assistant("Undo").with_timestamp("10:22 AM")); + } + state.input.set_text("/undo"); + let rows: &[(&str, &str, &str)] = if width <= 40 { + &[ + ("undo", "1 Undo last turn", ""), + ("redo", "2 Redo", ""), + ("rewind", "3 Rewind…", ""), + ] + } else { + &[ + ("undo", "1 Undo last turn", "restore the last turn"), + ("redo", "2 Redo", "re-apply the undone turn"), + ( + "rewind", + "3 Rewind to checkpoint", + "pick a Cortex session checkpoint", + ), + ] + }; + state.enter_interactive_mode(radios("Undo", rows, 0, None)); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lock_v2::{LOCK_V2_NARROW_IDS, LOCK_V2_WIDE_IDS, render_lock_v2_scene}; + use cortex_core::style::{ACCENT, SELECTION_BG}; + use std::collections::HashSet; + + fn banned_competitors(plain: &str) -> bool { + let lower = plain.to_ascii_lowercase(); + [ + "claude", + "openai", + "anthropic", + "cursor", + "codex", + "devin", + "gemini", + "copilot", + ] + .iter() + .any(|n| lower.contains(n)) + } + + #[test] + fn parity_ids_are_registered_at_both_sizes() { + assert_eq!(PARITY_IDS.len(), 3); + for id in PARITY_IDS { + assert!(LOCK_V2_WIDE_IDS.contains(id), "{id} missing from wide list"); + assert!( + LOCK_V2_NARROW_IDS.contains(id), + "{id} missing from narrow list" + ); + } + } + + #[test] + fn consent_local_tools_is_not_permission_or_optin() { + for (width, height) in [(120u16, 40u16), (40u16, 12u16)] { + let frame = + render_lock_v2_scene("consent-local-tools", width, height).expect("consent"); + assert!( + frame.plain.contains("Run tools locally") + || frame.plain.contains("Yes") && frame.plain.contains("Cloud"), + "consent missing local-tools copy at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("This PC") + || frame.plain.contains("directory") + || frame.plain.contains("Cloud"), + "consent missing This PC / directory / Cloud at {width}x{height}:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("$ npm install"), + "consent must not be a permission-prompt command block:\n{}", + frame.plain + ); + assert!( + !frame + .plain + .to_ascii_lowercase() + .contains("retain coding data"), + "consent must not reuse session-optin privacy copy:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("Choose an option above"), + "consent composer must stay held:\n{}", + frame.plain + ); + assert!(!banned_competitors(&frame.plain), "{}", frame.plain); + assert_ne!( + frame.ansi, + render_lock_v2_scene("permission-prompt", width, height) + .expect("permission-prompt") + .ansi, + "consent-local-tools must differ from permission-prompt" + ); + if width >= 80 { + assert_ne!( + frame.ansi, + render_lock_v2_scene("session-optin", width, height) + .expect("session-optin") + .ansi, + "consent-local-tools must differ from session-optin" + ); + } + let mut sel = false; + for y in 0..height { + for x in 0..width { + if frame.buffer[(x, y)].bg == SELECTION_BG { + sel = true; + } + } + } + assert!(sel, "consent focused row must paint SELECTION_BG"); + assert!( + frame.plain.contains("confirm") && frame.plain.contains("cancel"), + "consent footer must be confirm/cancel, not Typed, at {width}x{height}:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("Shift+Tab") && !frame.plain.contains("Alt+Enter"), + "consent footer must not be the Typed composer strip:\n{}", + frame.plain + ); + if width <= 40 { + assert!( + frame.plain.contains("run tools here") + && !frame.plain.contains("in this directory"), + "narrow consent must use shortened option copy:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("current working directory"), + "narrow consent must not use the wide body:\n{}", + frame.plain + ); + } + } + } + + #[test] + fn composer_file_chip_shows_attached_path() { + for (width, height) in [(120u16, 40u16), (40u16, 12u16)] { + let frame = + render_lock_v2_scene("composer-file-chip", width, height).expect("file-chip"); + assert!( + frame.plain.contains('@') + && (frame.plain.contains("composer.rs") + || frame.plain.contains(FILE_CHIP_TOKEN)), + "file-chip must show @ + path at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("explain"), + "file-chip prompt prefix missing at {width}x{height}:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("rateLimits.json") && !frame.plain.contains("Type to search"), + "file-chip must not open the files-picker menu:\n{}", + frame.plain + ); + assert!(!banned_competitors(&frame.plain), "{}", frame.plain); + if width >= 80 { + assert_ne!( + frame.ansi, + render_lock_v2_scene("files-picker", width, height) + .expect("files-picker") + .ansi, + "composer-file-chip must differ from files-picker" + ); + assert_ne!( + frame.ansi, + render_lock_v2_scene("composer-typing", width, height) + .expect("composer-typing") + .ansi, + "composer-file-chip must differ from composer-typing" + ); + } + let mut accent_at = false; + for y in 0..height { + for x in 0..width { + let cell = &frame.buffer[(x, y)]; + if cell.fg == ACCENT && cell.symbol().contains('@') { + accent_at = true; + } + } + } + assert!( + accent_at, + "file-chip should paint accent on the @ token at {width}x{height}" + ); + } + } + + #[test] + fn undo_sheet_lists_undo_redo_rewind() { + for (width, height) in [(120u16, 40u16), (40u16, 12u16)] { + let frame = render_lock_v2_scene("undo-sheet", width, height).expect("undo-sheet"); + assert!( + frame.plain.contains("Undo") || frame.plain.contains("/undo"), + "undo-sheet missing Undo at {width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("Rewind") || frame.plain.contains("Redo"), + "undo-sheet missing Rewind/Redo at {width}x{height}:\n{}", + frame.plain + ); + assert!( + !frame + .plain + .to_ascii_lowercase() + .contains("clear conversation") + && !frame.plain.contains("Clear this conversation"), + "undo-sheet must not reuse clear-confirm copy:\n{}", + frame.plain + ); + assert!(!banned_competitors(&frame.plain), "{}", frame.plain); + if width >= 80 { + assert_ne!( + frame.ansi, + render_lock_v2_scene("clear-confirm", width, height) + .expect("clear-confirm") + .ansi, + "undo-sheet must differ from clear-confirm" + ); + assert_ne!( + frame.ansi, + render_lock_v2_scene("resume-picker", width, height) + .expect("resume-picker") + .ansi, + "undo-sheet must differ from resume-picker" + ); + } + let mut sel = false; + for y in 0..height { + for x in 0..width { + if frame.buffer[(x, y)].bg == SELECTION_BG { + sel = true; + } + } + } + assert!(sel, "undo-sheet focused row must paint SELECTION_BG"); + assert!( + frame.plain.contains("confirm") && frame.plain.contains("cancel"), + "undo-sheet footer must be confirm/cancel, not Typed, at {width}x{height}:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("Alt+Enter"), + "undo-sheet footer must not be the Typed composer strip:\n{}", + frame.plain + ); + if width >= 80 { + assert!( + frame.plain.contains("tell me about yourself") + || frame.plain.contains("Worked for"), + "wide undo-sheet must paint the conversation backdrop:\n{}", + frame.plain + ); + } + } + } + + #[test] + fn parity_frames_are_unique_from_each_other() { + let mut seen = HashSet::new(); + for id in PARITY_IDS { + let frame = render_lock_v2_scene(id, 120, 40).expect(id); + assert!( + seen.insert(frame.ansi.clone()), + "{id} collided with a sibling at 120x40" + ); + } + assert_eq!(seen.len(), PARITY_IDS.len()); + } +} diff --git a/src/cortex-tui/src/ui/chrome.rs b/src/cortex-tui/src/ui/chrome.rs index c5243863..82f7b84e 100644 --- a/src/cortex-tui/src/ui/chrome.rs +++ b/src/cortex-tui/src/ui/chrome.rs @@ -252,6 +252,12 @@ pub enum FooterSet { Unavailable, RateLimit, Palette, + /// Consent / question radios — confirm or cancel (not Typed, not exec Approval). + Prompt, + /// `/undo` `/redo` `/rewind` sheet. + UndoSheet, + /// Composer with an attached `@file` chip. + FileChip, } impl FooterSet { @@ -483,6 +489,88 @@ impl FooterSet { label: "shortcuts", }, ], + Self::Prompt => &[ + FooterHint { + key: "↑↓", + label: "select", + }, + FooterHint { + key: "Enter", + label: "confirm", + }, + FooterHint { + key: "Esc", + label: "cancel", + }, + ], + Self::UndoSheet => &[ + FooterHint { + key: "↑↓", + label: "select", + }, + FooterHint { + key: "Enter", + label: "confirm", + }, + FooterHint { + key: "Esc", + label: "cancel", + }, + ], + Self::FileChip => &[ + FooterHint { + key: "Enter", + label: "send", + }, + FooterHint { + key: "@", + label: "files", + }, + FooterHint { + key: "!", + label: "bash", + }, + ], + } + } + + /// Narrow strips drop ↑↓ / `!` so 40×12 matches designer lock footers. + pub fn hints_for(self, width: u16) -> &'static [FooterHint] { + if width >= 80 { + return self.hints(); + } + match self { + Self::Prompt => &[ + FooterHint { + key: "Enter", + label: "confirm", + }, + FooterHint { + key: "Esc", + label: "cancel", + }, + ], + Self::UndoSheet => &[ + FooterHint { + key: "Enter", + label: "confirm", + }, + FooterHint { + key: "Esc", + label: "cancel", + }, + ], + Self::FileChip => &[ + FooterHint { + key: "Enter", + label: "send", + }, + FooterHint { + key: "@", + label: "files", + }, + ], + _ => self.hints(), } } } @@ -494,7 +582,7 @@ pub fn paint_footer(area: Rect, buf: &mut Buffer, set: FooterSet, hovered: Optio } let wide = area.width >= 80; let sep = if wide { " | " } else { " | " }; - let hints = set.hints(); + let hints = set.hints_for(area.width); let mut x = area.x + 1; let y = area.y; for (i, hint) in hints.iter().enumerate() { @@ -722,4 +810,47 @@ mod tests { "goal chip uses designer accent" ); } + + #[test] + fn consent_and_undo_footers_are_confirm_cancel_not_typed_or_approval() { + for set in [FooterSet::Prompt, FooterSet::UndoSheet] { + for width in [120u16, 40u16] { + let hints = set.hints_for(width); + assert!( + hints + .iter() + .any(|h| h.key == "Enter" && h.label == "confirm"), + "{set:?} at {width} missing Enter:confirm" + ); + assert!( + hints.iter().any(|h| h.key == "Esc" && h.label == "cancel"), + "{set:?} at {width} missing Esc:cancel" + ); + assert!( + !hints.iter().any(|h| h.label == "send" + || h.label == "newline" + || h.label == "edit command" + || h.label == "mode"), + "{set:?} at {width} leaked Typed/Approval hints: {hints:?}" + ); + } + assert_eq!( + set.hints_for(40).len(), + 2, + "narrow {set:?} is Enter+Esc only" + ); + } + } + + #[test] + fn file_chip_footer_is_not_typed_strip() { + let wide = FooterSet::FileChip.hints_for(120); + assert!(wide.iter().any(|h| h.key == "Enter" && h.label == "send")); + assert!(wide.iter().any(|h| h.key == "@" && h.label == "files")); + assert!(wide.iter().any(|h| h.key == "!" && h.label == "bash")); + assert!(!wide.iter().any(|h| h.key == "Alt+Enter")); + let narrow = FooterSet::FileChip.hints_for(40); + assert!(!narrow.iter().any(|h| h.key == "!")); + assert!(!narrow.iter().any(|h| h.key == "Alt+Enter")); + } } diff --git a/src/cortex-tui/src/views/minimal_session/tests.rs b/src/cortex-tui/src/views/minimal_session/tests.rs index 4b6df329..eecb8512 100644 --- a/src/cortex-tui/src/views/minimal_session/tests.rs +++ b/src/cortex-tui/src/views/minimal_session/tests.rs @@ -211,6 +211,57 @@ mod harness_snapshots { assert!(!wide.trim().is_empty()); } + #[test] + fn ordinary_at_text_keeps_typed_footer() { + let mut email = AppState::default(); + email.input.set_text("contact me@example.com"); + let email_text = render(&email, 120, 40); + assert!( + email_text.contains("Alt+Enter") || email_text.contains("newline"), + "email must keep typed composer hints:\n{email_text}" + ); + assert!( + !email_text.contains("@:files") && !email_text.contains("@: files"), + "email must not open file-chip footer:\n{email_text}" + ); + + let mut ada = AppState::default(); + ada.input.set_text("ada@example.com"); + let ada_text = render(&ada, 120, 40); + assert!( + !ada_text.contains("@:files"), + "solo email must not open file-chip footer:\n{ada_text}" + ); + + let mut bare = AppState::default(); + bare.input.set_text("please inspect @"); + let bare_text = render(&bare, 120, 40); + assert!( + bare_text.contains("Alt+Enter") || bare_text.contains("newline"), + "incomplete @ must keep typed composer hints:\n{bare_text}" + ); + assert!( + !bare_text.contains("@:files"), + "incomplete @ must not open file-chip footer:\n{bare_text}" + ); + + let mut unfinished = AppState::default(); + unfinished.input.set_text("please inspect @src/"); + let unfinished_text = render(&unfinished, 120, 40); + assert!( + !unfinished_text.contains("@:files"), + "trailing-slash mention must not open file-chip footer:\n{unfinished_text}" + ); + + let mut chip = AppState::default(); + chip.input.set_text("explain @src/composer.rs "); + let chip_text = render(&chip, 120, 40); + assert!( + chip_text.contains("@:files"), + "completed @path must use file-chip footer:\n{chip_text}" + ); + } + #[test] fn autocomplete_selected_row_is_banner_green_on_the_gray_bar() { use cortex_core::style::{ACCENT, SELECTION_BG, TEXT, TEXT_DIM}; diff --git a/src/cortex-tui/src/views/minimal_session/view.rs b/src/cortex-tui/src/views/minimal_session/view.rs index 4b87fd3b..6096f7e4 100644 --- a/src/cortex-tui/src/views/minimal_session/view.rs +++ b/src/cortex-tui/src/views/minimal_session/view.rs @@ -84,14 +84,13 @@ pub fn paint_composer_contents( let shown = crate::ui::text_utils::first_fitting_line(input, budget.saturating_sub(1)); let chars: Vec = shown.chars().collect(); let caret = caret.min(chars.len()); - let slash_end = if shown.starts_with('/') { - shown.find(' ').unwrap_or(shown.len()) - } else { - 0 - }; let mut col = col0; for (i, ch) in chars.iter().enumerate() { - let fg = if i < slash_end { ACCENT } else { TEXT }; + let fg = if composer_char_accent(&chars, i) { + ACCENT + } else { + TEXT + }; if caret_visible && i == caret { buf.set_string( col, @@ -752,6 +751,12 @@ impl<'a> MinimalSessionView<'a> { if title.contains("plugin") { return FooterSet::Plugins; } + if title.contains("run tools") || title.contains("locally") { + return FooterSet::Prompt; + } + if title == "undo" || title.starts_with("undo") { + return FooterSet::UndoSheet; + } if title.contains("resume") || title.contains("session") { return FooterSet::Resume; } @@ -771,7 +776,11 @@ impl<'a> MinimalSessionView<'a> { if self.app_state.autocomplete.visible { return FooterSet::Palette; } - if !self.app_state.input.text().is_empty() { + let typed = self.app_state.input.text(); + if composer_has_completed_file_chip(&typed) && !self.app_state.is_interactive_mode() { + return FooterSet::FileChip; + } + if !typed.is_empty() { if area_is_narrow(width) { return FooterSet::TypedNarrow; } @@ -804,6 +813,56 @@ fn area_is_narrow(width: u16) -> bool { width < 80 } +/// Accent slash commands (`/undo`) and completed `@path` file chips. +fn composer_char_accent(chars: &[char], index: usize) -> bool { + if chars.first() == Some(&'/') { + let end = chars.iter().position(|&c| c == ' ').unwrap_or(chars.len()); + if index < end { + return true; + } + } + let mut i = 0; + while i < chars.len() { + let at_token = chars[i] == '@' && (i == 0 || chars[i - 1].is_whitespace()); + if at_token { + let start = i; + i += 1; + while i < chars.len() && !chars[i].is_whitespace() { + i += 1; + } + if index >= start && index < i && i > start + 1 { + return true; + } + } else { + i += 1; + } + } + false +} + +/// True when the composer holds a completed `@path` chip, not an email, a +/// bare `@`, or an unfinished mention that still ends in `/`. +fn composer_has_completed_file_chip(text: &str) -> bool { + let chars: Vec = text.chars().collect(); + let mut i = 0; + while i < chars.len() { + let at_token = chars[i] == '@' && (i == 0 || chars[i - 1].is_whitespace()); + if at_token { + let start = i; + i += 1; + while i < chars.len() && !chars[i].is_whitespace() { + i += 1; + } + if i > start + 1 && chars[i - 1] != '/' { + return true; + } + } else { + i += 1; + } + } + false +} + fn composer_display_text(state: &AppState) -> String { if let Some(istate) = state.get_interactive_state() { let title = istate.title.to_ascii_lowercase(); @@ -820,3 +879,29 @@ fn composer_display_text(state: &AppState) -> String { } state.input.text() } + +#[cfg(test)] +mod file_chip_footer_tests { + use super::*; + + #[test] + fn completed_at_path_is_a_file_chip() { + assert!(composer_has_completed_file_chip( + "explain @src/cortex-tui/src/composer.rs " + )); + assert!(composer_has_completed_file_chip( + "explain @src/composer.rs " + )); + assert!(composer_has_completed_file_chip("@src/foo.rs")); + } + + #[test] + fn email_and_bare_at_are_not_file_chips() { + assert!(!composer_has_completed_file_chip("contact me@example.com")); + assert!(!composer_has_completed_file_chip("ada@example.com")); + assert!(!composer_has_completed_file_chip("please inspect @")); + assert!(!composer_has_completed_file_chip("please inspect @src/")); + assert!(!composer_has_completed_file_chip("@")); + assert!(!composer_has_completed_file_chip("hello world")); + } +}