diff --git a/Cargo.lock b/Cargo.lock index c878d48f..15e2d3e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1709,6 +1709,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "serial_test", "similar", "tempfile", "thiserror 2.0.20", @@ -1722,6 +1723,7 @@ dependencies = [ "uuid", "walkdir", "which", + "wiremock", ] [[package]] diff --git a/docs/media/tui-lock-v2/120x40/hooks-lifecycle.png b/docs/media/tui-lock-v2/120x40/hooks-lifecycle.png new file mode 100644 index 00000000..a0fbc693 Binary files /dev/null and b/docs/media/tui-lock-v2/120x40/hooks-lifecycle.png differ diff --git a/docs/media/tui-lock-v2/120x40/init-agents.png b/docs/media/tui-lock-v2/120x40/init-agents.png new file mode 100644 index 00000000..b3aa24b6 Binary files /dev/null and b/docs/media/tui-lock-v2/120x40/init-agents.png differ diff --git a/docs/media/tui-lock-v2/40x12/hooks-lifecycle.png b/docs/media/tui-lock-v2/40x12/hooks-lifecycle.png new file mode 100644 index 00000000..6c3d19ac Binary files /dev/null and b/docs/media/tui-lock-v2/40x12/hooks-lifecycle.png differ diff --git a/docs/media/tui-lock-v2/40x12/init-agents.png b/docs/media/tui-lock-v2/40x12/init-agents.png new file mode 100644 index 00000000..b49761c7 Binary files /dev/null and b/docs/media/tui-lock-v2/40x12/init-agents.png differ diff --git a/docs/media/tui-lock-v2/SPEC.md b/docs/media/tui-lock-v2/SPEC.md index 4ed84fea..fd93beb0 100644 --- a/docs/media/tui-lock-v2/SPEC.md +++ b/docs/media/tui-lock-v2/SPEC.md @@ -10,7 +10,7 @@ captures use `MinimalSessionView` plus lock flags (`computer_held`, `show_computer_default`, `offline_held`, `rate_limit_held`) so MockTerminal matches live chrome. -- Boards: [`index.md`](index.md) — 89 boards at 120×40, 43 of them also at 40×12 (132 PNGs). +- 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`. - 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 @@ -414,9 +414,10 @@ Narrow: no bars, `used / total pct%`. | slash-palette / slash-model-typed | `slash-palette` (`/goal` after `/plan`), `slash-model-typed` | | goal-chip (active / paused / done / budget / blocked) | `goal-chip-active`, `goal-chip-paused`, `goal-chip-done`, `goal-chip-budget`, `goal-chip-blocked` | | model-effort-low·medium·high / model-list-hover | `model-list`, `model-list-hover`, `model-effort-high`, `model-effort-medium`, `model-effort-low`, `model-effort-hover` | -| settings-appearance / mouse / row-hover / search / theme-submenu | `settings-appearance`, `settings-mouse`, `settings-row-hover`, `settings-search`, `settings-theme-submenu` | +| settings-appearance / mouse / row-hover / search / theme-submenu | `settings-appearance`, `settings-mouse`, `settings-row-hover`, `settings-search`, `settings-theme-submenu`, `theme-picker` | +| theme / handoff / fork / init / commands / hooks | `theme-picker`, `handoff-confirm`, `session-fork`, `init-agents`, `custom-commands`, `hooks-lifecycle` | | mode chips (Shift+Tab) | `mode-agent`, `mode-plan`, `mode-ask`, `mode-bash` | -| permissions / mcp / plugins / usage-quota / sandbox / cloud | `permission-prompt`, `permission-prompt-hover`, `permissions-picker`, `mcp-servers`, `mcp-drop`, `plugins`, `usage`, `quota-exhausted`, `sandbox`, `sandbox-deny`, `cloud-handoff`, `computer-disconnected`, `computer-cloud-default` | +| permissions / mcp / plugins / usage-quota / sandbox / cloud | `permission-prompt`, `permission-prompt-hover`, `permissions-picker`, `mcp-servers`, `mcp-drop`, `plugins`, `usage`, `quota-exhausted`, `sandbox`, `sandbox-deny`, `cloud-handoff`, `handoff-confirm`, `computer-disconnected`, `computer-cloud-default` | | diagnostics red/amber · interrupt/stopped | `diagnostics`, `interrupt-stopped`, `error-unavailable`, `offline`, `rate-limit` | | markdown table · diff hunk · code fence | `md-table`, `diff-hunk`, `edit-collapsed`, `code-fence`, `tool-tiles`, `tool-tiles-collapsed`, `shell-running` | | login / first-run | `login`, `login-waiting`, `login-success`, `login-error`, `first-run-tips` | @@ -428,7 +429,7 @@ Narrow (40×12) set: `welcome-cortex`, `welcome-agent`, `first-run-tips`, `sessi `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`, +`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`, `permission-prompt`, `mcp-servers`, `usage`, `diagnostics`, `interrupt-stopped`, `offline`, `rate-limit`, `computer-disconnected`, `computer-cloud-default`, `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 26edd6b5..32cab5c1 100644 --- a/docs/media/tui-lock-v2/index.md +++ b/docs/media/tui-lock-v2/index.md @@ -49,6 +49,11 @@ Regenerate: `python3 tools/render_lock_v2.py --index` (fetches IBM Plex Mono on | `goal-chip-budget` | Composer goal chip — `Goal · budget` (budget_limited), text-only | [120x40](120x40/goal-chip-budget.png) | [40x12](40x12/goal-chip-budget.png) | | `goal-chip-blocked` | Composer goal chip — `Goal · blocked`, text-only | [120x40](120x40/goal-chip-blocked.png) | [40x12](40x12/goal-chip-blocked.png) | | `slash-model-typed` | `/mod` typed — banner green matched chars, ghost completion | [120x40](120x40/slash-model-typed.png) | [40x12](40x12/slash-model-typed.png) | +| `theme-picker` | `/theme` — Cortex Night / Cortex Day / Ocean Dark / Monokai | [runtime 120x40](runtime/120x40/theme-picker.png) | [runtime 40x12](runtime/40x12/theme-picker.png) | +| `session-fork` | `/fork` — fork this session or stay | [runtime 120x40](runtime/120x40/session-fork.png) | [runtime 40x12](runtime/40x12/session-fork.png) | +| `init-agents` | `/init` — AGENTS.md from the built-in project template | [120x40](120x40/init-agents.png) · [runtime](runtime/120x40/init-agents.png) | [40x12](40x12/init-agents.png) · [runtime](runtime/40x12/init-agents.png) | +| `custom-commands` | `.cortex/commands` + repo `SKILL.md` | [runtime 120x40](runtime/120x40/custom-commands.png) | [runtime 40x12](runtime/40x12/custom-commands.png) | +| `hooks-lifecycle` | `/hooks` — pre_tool_use / post_tool_use / stop / session_end | [120x40](120x40/hooks-lifecycle.png) · [runtime](runtime/120x40/hooks-lifecycle.png) | [40x12](40x12/hooks-lifecycle.png) · [runtime](runtime/40x12/hooks-lifecycle.png) | | `model-list` | `/model` — Cortex Mini 1 · Cortex 1 · Cortex Max 1 | [120x40](120x40/model-list.png) | [40x12](40x12/model-list.png) | | `model-list-hover` | Model list with mouse over row 3 | [120x40](120x40/model-list-hover.png) | — | | `model-effort-high` | Effort radios — High focused | [120x40](120x40/model-effort-high.png) | [40x12](40x12/model-effort-high.png) | @@ -85,6 +90,7 @@ Regenerate: `python3 tools/render_lock_v2.py --index` (fetches IBM Plex Mono on | `sandbox` | `/sandbox` — filesystem / network / escalation | [120x40](120x40/sandbox.png) | — | | `sandbox-deny` | Sandbox blocked a command — red title, radios | [120x40](120x40/sandbox-deny.png) | — | | `cloud-handoff` | `&` handoff to Cortex Cloud | [120x40](120x40/cloud-handoff.png) | — | +| `handoff-confirm` | `/handoff` — Cortex Cloud vs stay on this CLI (Chat · Code · Bot) | [runtime 120x40](runtime/120x40/handoff-confirm.png) | [runtime 40x12](runtime/40x12/handoff-confirm.png) | | `diagnostics` | Diagnostics tile — error red, warn amber | [120x40](120x40/diagnostics.png) | [40x12](40x12/diagnostics.png) | | `interrupt-stopped` | Esc / Ctrl+c — `× Stopped` | [120x40](120x40/interrupt-stopped.png) | [40x12](40x12/interrupt-stopped.png) | | `error-unavailable` | API down — product-facing error | [120x40](120x40/error-unavailable.png) | — | @@ -120,6 +126,4 @@ 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) | -**89** boards at 120x40 · **43** at 40x12 · 132 PNGs. - -Runtime MockTerminal pack (same ids): [`runtime/120x40`](runtime/120x40/) (89) · [`runtime/40x12`](runtime/40x12/) (43). +**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. diff --git a/docs/media/tui-lock-v2/runtime/120x40/custom-commands.png b/docs/media/tui-lock-v2/runtime/120x40/custom-commands.png new file mode 100644 index 00000000..c087f57b Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/custom-commands.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/handoff-confirm.png b/docs/media/tui-lock-v2/runtime/120x40/handoff-confirm.png new file mode 100644 index 00000000..ba1ff49f Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/handoff-confirm.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/hooks-lifecycle.png b/docs/media/tui-lock-v2/runtime/120x40/hooks-lifecycle.png new file mode 100644 index 00000000..dde9de2e Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/hooks-lifecycle.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/init-agents.png b/docs/media/tui-lock-v2/runtime/120x40/init-agents.png new file mode 100644 index 00000000..5acf2f71 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/init-agents.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/session-fork.png b/docs/media/tui-lock-v2/runtime/120x40/session-fork.png new file mode 100644 index 00000000..0993fe68 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/session-fork.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/theme-picker.png b/docs/media/tui-lock-v2/runtime/120x40/theme-picker.png new file mode 100644 index 00000000..60ff6535 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/theme-picker.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/custom-commands.png b/docs/media/tui-lock-v2/runtime/40x12/custom-commands.png new file mode 100644 index 00000000..1cc0e810 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/custom-commands.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/handoff-confirm.png b/docs/media/tui-lock-v2/runtime/40x12/handoff-confirm.png new file mode 100644 index 00000000..865bca02 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/handoff-confirm.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/hooks-lifecycle.png b/docs/media/tui-lock-v2/runtime/40x12/hooks-lifecycle.png new file mode 100644 index 00000000..edf0853c Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/hooks-lifecycle.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/init-agents.png b/docs/media/tui-lock-v2/runtime/40x12/init-agents.png new file mode 100644 index 00000000..f93a1bd8 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/init-agents.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/session-fork.png b/docs/media/tui-lock-v2/runtime/40x12/session-fork.png new file mode 100644 index 00000000..7ef1c4fe Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/session-fork.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/theme-picker.png b/docs/media/tui-lock-v2/runtime/40x12/theme-picker.png new file mode 100644 index 00000000..6b806e8f Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/theme-picker.png differ diff --git a/docs/media/tui-lock-v2/runtime/README.md b/docs/media/tui-lock-v2/runtime/README.md index 19f5575e..d26c662d 100644 --- a/docs/media/tui-lock-v2/runtime/README.md +++ b/docs/media/tui-lock-v2/runtime/README.md @@ -8,8 +8,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: **89** boards at 120×40 and **43** at 40×12. Each filename is one +SPEC §7: **95** boards at 120×40 and **49** 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, and `/undo` `/redo` `/rewind` sheet. +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`). diff --git a/docs/media/tui-lock-v2/tools/boards.py b/docs/media/tui-lock-v2/tools/boards.py index 9815d9e0..8c2603b2 100644 --- a/docs/media/tui-lock-v2/tools/boards.py +++ b/docs/media/tui-lock-v2/tools/boards.py @@ -929,6 +929,64 @@ def board_slash_model_typed(s, c): footer(s, c, FOOTER_TYPED_NARROW if c.narrow else FOOTER_TYPED) +def board_init_agents(s, c): + header(s, c) + top = composer(s, c, focused=False, caret=False) + 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("/init", "09:12 AM") + f.reply( + [ + "Set up AGENTS.md from the built-in project template. Review the diff, then write it to the repo." + ], + "09:12 AM", + ) + f.tile("Edit", "AGENTS.md", meta_parts=[("+12", S_OK)]) + if c.narrow: + f.line([("+ # Cortex CLI", S_OK)], indent=2) + f.line([("+ Chat | Code | Bot", S_OK)], indent=2) + f.options(["Write AGENTS.md built-in template", "Cancel leave the file untouched"], focused=0) + else: + f.line([("+ # Cortex CLI / Cortex Code", S_OK)], indent=2) + f.line([("+ Chat | Code | Bot in this repository.", S_OK)], indent=2) + f.options(["Write AGENTS.md to the repo", "Cancel"], focused=0) + footer( + s, + c, + [("Enter", "confirm"), ("Esc", "cancel")] if c.narrow else [("↑↓", "select"), ("Enter", "confirm"), ("Esc", "cancel")], + ) + + +def board_hooks_lifecycle(s, c): + header(s, c) + top = composer(s, c, content=[("/hooks", S_ACC), (" ", S)]) + if c.narrow: + rows = [ + ([("pre_tool_use", S)], "before a local tool"), + ([("post_tool_use", S)], "after a local tool"), + ([("stop", S)], "when the turn stops"), + ([("session_start", S)], "plugin event"), + ([("session_end", S)], "never consent"), + ] + else: + rows = [ + ([("pre_tool_use", S)], "before a local tool"), + ([("post_tool_use", S)], "after a local tool"), + ([("stop", S)], "when the turn stops"), + ([("session_start", S)], "plugin · terminal notification"), + ([("session_end", S)], "lifecycle event — never consent"), + ] + menu_top = menu(s, c, top, rows, focused=0, name_w=16) + banner = ( + "A hook is never consent. Output is capped and redacted." + if c.narrow + else "`.cortex/hooks.json` — a hook is never consent. Output is capped and redacted." + ) + s.put(c.x0 + 2, max(1, menu_top - 2), clip(banner, c.inner_w - 2), S_DIM) + backdrop_tail(s, c, menu_top - 2) + footer(s, c, [("Enter", "select"), ("Esc", "close")] if c.narrow else [("↑↓", "select"), ("Enter", "select"), ("Esc", "close")]) + + def model_rows(): rows = [] for name, desc, meta in MODELS: @@ -1976,6 +2034,8 @@ def board_undo_sheet(s, c): ("goal-chip-budget", board_goal_chip_budget, True, "C", "Composer goal chip — `Goal · budget` (budget_limited), text-only"), ("goal-chip-blocked", board_goal_chip_blocked, True, "C", "Composer goal chip — `Goal · blocked`, text-only"), ("slash-model-typed", board_slash_model_typed, True, "C", "`/mod` typed — banner green matched chars, ghost completion"), + ("init-agents", board_init_agents, True, "C", "`/init` — AGENTS.md from the built-in project template"), + ("hooks-lifecycle", board_hooks_lifecycle, True, "C", "`/hooks` — pre_tool_use / post_tool_use / stop / session_end"), ("model-list", board_model_list, True, "C", "`/model` — Cortex Mini 1 · Cortex 1 · Cortex Max 1"), ("model-list-hover", board_model_list_hover, False, "C", "Model list with mouse over row 3"), ("model-effort-high", board_model_effort_high, True, "C", "Effort radios — High focused"), diff --git a/docs/media/tui-lock-v2/txt/120x40/hooks-lifecycle.txt b/docs/media/tui-lock-v2/txt/120x40/hooks-lifecycle.txt new file mode 100644 index 00000000..a1af4a94 --- /dev/null +++ b/docs/media/tui-lock-v2/txt/120x40/hooks-lifecycle.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 + + `.cortex/hooks.json` — a hook is never consent. Output is capped and redacted. + + > pre_tool_use before a local tool + post_tool_use after a local tool + stop when the turn stops + session_start plugin · terminal notification + session_end lifecycle event — never consent + ╭─ Agent ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ > /hooks │ + ╰─────────────────────────────────────────────────────────────────────────────────────────── Cortex Mini 1 (medium) ─╯ + + ↑↓:select | Enter:select | Esc:close diff --git a/docs/media/tui-lock-v2/txt/120x40/init-agents.txt b/docs/media/tui-lock-v2/txt/120x40/init-agents.txt new file mode 100644 index 00000000..364f2454 --- /dev/null +++ b/docs/media/tui-lock-v2/txt/120x40/init-agents.txt @@ -0,0 +1,40 @@ + 14K / 500K + + > /init 09:12 AM + + Set up AGENTS.md from the built-in project template. Review the diff, then write it to the repo. 09:12 AM + + ● Edit AGENTS.md · +12 + + # Cortex CLI / Cortex Code + + Chat | Code | Bot in this repository. + > 1 Write AGENTS.md to the repo + 2 Cancel + + + + + + + + + + + + + + + + + + + + + + + + + ╭─ Agent ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ > Plan, search, build anything │ + ╰─────────────────────────────────────────────────────────────────────────────────────────── Cortex Mini 1 (medium) ─╯ + + ↑↓:select | Enter:confirm | Esc:cancel diff --git a/docs/media/tui-lock-v2/txt/40x12/hooks-lifecycle.txt b/docs/media/tui-lock-v2/txt/40x12/hooks-lifecycle.txt new file mode 100644 index 00000000..c225d6d2 --- /dev/null +++ b/docs/media/tui-lock-v2/txt/40x12/hooks-lifecycle.txt @@ -0,0 +1,12 @@ + 14K / 500K + A hook is never consent. Output is … + > pre_tool_use before a local t… + post_tool_use after a local to… + stop when the turn st… + session_start plugin event + session_end never consent + ╭─ Agent ────────────────────────────╮ + │ > /hooks │ + ╰─────────── Cortex Mini 1 (medium) ─╯ + + Enter:select | Esc:close diff --git a/docs/media/tui-lock-v2/txt/40x12/init-agents.txt b/docs/media/tui-lock-v2/txt/40x12/init-agents.txt new file mode 100644 index 00000000..b9722f49 --- /dev/null +++ b/docs/media/tui-lock-v2/txt/40x12/init-agents.txt @@ -0,0 +1,12 @@ + 14K / 500K + ● Edit AGENTS.md · +12 + + # Cortex CLI + + Chat | Code | Bot + > 1 Write AGENTS.md built-in tem… + 2 Cancel leave the file untouc… + + ╭─ Agent ────────────────────────────╮ + │ > Plan, search, build anything │ + ╰─────────── Cortex Mini 1 (medium) ─╯ + + Enter:confirm | Esc:cancel diff --git a/scripts/render-tui-lock-v2.sh b/scripts/render-tui-lock-v2.sh index 15cbf954..552319d6 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" 43 -unique_pngs "$output_dir/120x40" 89 +unique_pngs "$output_dir/40x12" 49 +unique_pngs "$output_dir/120x40" 95 python3 - "$output_dir" <<'PY' from pathlib import Path @@ -100,11 +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: **89** boards at 120×40 and **43** at 40×12. Each filename is one +SPEC §7: **95** boards at 120×40 and **49** 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, and `/undo` `/redo` `/rewind` sheet. +local-tools consent, composer `@file` chip, `/undo` `/redo` `/rewind` sheet, +and runtime-only COR-18/225/227/228 scenes. + EOF echo "Wrote runtime captures to $output_dir" diff --git a/src/cortex-tui/Cargo.toml b/src/cortex-tui/Cargo.toml index b1ddea15..d1af674c 100644 --- a/src/cortex-tui/Cargo.toml +++ b/src/cortex-tui/Cargo.toml @@ -85,7 +85,9 @@ rodio = { workspace = true, optional = true } libc = { workspace = true } [dev-dependencies] +serial_test = { workspace = true } tempfile = { workspace = true } +wiremock = { workspace = true } [lints] workspace = true 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 4ddebc7a..00cf799a 100644 --- a/src/cortex-tui/src/bin/generate_tui_lock_screenshots.rs +++ b/src/cortex-tui/src/bin/generate_tui_lock_screenshots.rs @@ -111,6 +111,10 @@ fn main() { write_lock_v2_frames(width, height, &output) } } else { + if only_flag { + eprintln!("--only requires --v2"); + process::exit(1); + } write_lock_frames(width, height, &output) }; match result { diff --git a/src/cortex-tui/src/commands/executor/dispatch.rs b/src/cortex-tui/src/commands/executor/dispatch.rs index 857f1945..b43b2341 100644 --- a/src/cortex-tui/src/commands/executor/dispatch.rs +++ b/src/cortex-tui/src/commands/executor/dispatch.rs @@ -59,6 +59,7 @@ impl CommandExecutor { CommandResult::Async("sessions:list".to_string()) } "fork" | "branch" => self.cmd_fork(cmd), + "handoff" => self.cmd_handoff(), "rename" | "mv" => self.cmd_rename(cmd), "favorite" | "fav" | "star" => CommandResult::Toggle("favorite".to_string()), "unfavorite" | "unfav" | "unstar" => { diff --git a/src/cortex-tui/src/commands/executor/session.rs b/src/cortex-tui/src/commands/executor/session.rs index 633f4832..5d065629 100644 --- a/src/cortex-tui/src/commands/executor/session.rs +++ b/src/cortex-tui/src/commands/executor/session.rs @@ -16,6 +16,10 @@ impl CommandExecutor { CommandResult::OpenModal(ModalType::Fork) } + pub(super) fn cmd_handoff(&self) -> CommandResult { + CommandResult::Async("handoff".to_string()) + } + pub(super) fn cmd_rename(&self, cmd: &ParsedCommand) -> CommandResult { match cmd.first_arg() { Some(name) => CommandResult::SetValue("session_name".to_string(), name.to_string()), diff --git a/src/cortex-tui/src/commands/executor/tests.rs b/src/cortex-tui/src/commands/executor/tests.rs index 0dd64e11..4141781d 100644 --- a/src/cortex-tui/src/commands/executor/tests.rs +++ b/src/cortex-tui/src/commands/executor/tests.rs @@ -532,6 +532,16 @@ fn test_agents_command() { )); } +#[test] +fn test_handoff_command() { + let executor = CommandExecutor::new(); + let result = executor.execute_str("/handoff"); + assert!(matches!( + result, + CommandResult::Async(ref s) if s == "handoff" + )); +} + #[test] fn test_share_command() { let executor = CommandExecutor::new(); diff --git a/src/cortex-tui/src/commands/registry/builtin.rs b/src/cortex-tui/src/commands/registry/builtin.rs index e07d6831..43bbeb06 100644 --- a/src/cortex-tui/src/commands/registry/builtin.rs +++ b/src/cortex-tui/src/commands/registry/builtin.rs @@ -367,6 +367,15 @@ pub fn register_builtin_commands(registry: &mut CommandRegistry) { true, )); + registry.register(CommandDef::new( + "handoff", + &[], + "Hand off to Cortex Cloud", + "/handoff", + CommandCategory::Session, + false, + )); + registry.register(CommandDef::new( "rename", &["mv"], diff --git a/src/cortex-tui/src/commands/registry/mod.rs b/src/cortex-tui/src/commands/registry/mod.rs index 1eaeb2ce..65df4993 100644 --- a/src/cortex-tui/src/commands/registry/mod.rs +++ b/src/cortex-tui/src/commands/registry/mod.rs @@ -163,6 +163,7 @@ mod tests { assert!(registry.exists("resume")); assert!(registry.exists("sessions")); assert!(registry.exists("fork")); + assert!(registry.exists("handoff")); assert!(registry.exists("rename")); assert!(registry.exists("favorite")); assert!(registry.exists("unfavorite")); diff --git a/src/cortex-tui/src/interactive/builders/approval.rs b/src/cortex-tui/src/interactive/builders/approval.rs index ce83321e..51e285fc 100644 --- a/src/cortex-tui/src/interactive/builders/approval.rs +++ b/src/cortex-tui/src/interactive/builders/approval.rs @@ -227,6 +227,35 @@ pub fn build_clear_confirm() -> InteractiveState { .with_prompt_focus() } +/// `/handoff` confirm (lock `handoff-confirm`). Chat · Code · Bot; CLI stays. +pub fn build_handoff_confirm(narrow: bool) -> InteractiveState { + let items = vec![ + InteractiveItem::new("cloud", "1 Cortex Cloud") + .with_description(if narrow { + "Chat · Code · Bot" + } else { + "Chat · Code · Bot — this CLI session stays here" + }) + .with_shortcut('1'), + InteractiveItem::new( + "stay", + if narrow { + "2 Stay on CLI" + } else { + "2 Stay on this CLI session" + }, + ) + .with_description("keep running locally") + .with_shortcut('2'), + ]; + InteractiveState::new( + "Handoff", + items, + InteractiveAction::Custom("handoff-confirm".into()), + ) + .with_prompt_focus() +} + /// Build an interactive state for log level selection. pub fn build_log_level_selector(current: Option<&str>) -> InteractiveState { let items = vec![ @@ -317,6 +346,11 @@ mod tests { assert!(build_sandbox_deny_prompt().prompt_owns_focus); assert!(build_plan_confirm().prompt_owns_focus); assert!(build_clear_confirm().prompt_owns_focus); + let handoff = build_handoff_confirm(false); + assert!(handoff.prompt_owns_focus); + assert_eq!(handoff.title, "Handoff"); + assert_eq!(handoff.items[0].id, "cloud"); + assert_eq!(handoff.items[1].id, "stay"); let q = build_question_prompt( "Question", &[ diff --git a/src/cortex-tui/src/interactive/builders/jobs.rs b/src/cortex-tui/src/interactive/builders/jobs.rs index bbadbfe3..fbdb94bb 100644 --- a/src/cortex-tui/src/interactive/builders/jobs.rs +++ b/src/cortex-tui/src/interactive/builders/jobs.rs @@ -1,5 +1,7 @@ //! `/jobs` picker — background agents and subagents (lock `jobs`). +use cortex_engine::client::CodeSession; + use crate::app::SubagentTaskDisplay; use crate::interactive::state::{InteractiveAction, InteractiveItem, InteractiveState}; @@ -25,6 +27,30 @@ impl JobRow { status: task.status.description(), } } + + pub fn from_code_session(session: &CodeSession) -> Self { + let title = if session.title.is_empty() { + session.id.clone() + } else { + session.title.clone() + }; + let kind = if session.runtime.is_empty() { + "cloud".into() + } else { + session.runtime.clone() + }; + let status = if session.state.is_empty() { + "live".into() + } else { + session.state.clone() + }; + Self { + id: session.id.clone(), + kind, + title, + status, + } + } } /// Build `/jobs` interactive picker. Numbered `>` / `·` rows — not radios. @@ -93,6 +119,28 @@ mod tests { )); } + #[test] + fn jobs_from_code_sessions_keep_api_ids() { + let session = CodeSession { + id: "sess-lock-1".into(), + runtime: "cloud".into(), + kind: String::new(), + host_status: String::new(), + state: String::new(), + model_slug: String::new(), + model_ref: String::new(), + title: "rate limiter".into(), + created_at: String::new(), + host_id: String::new(), + stream: None, + }; + let row = JobRow::from_code_session(&session); + assert_eq!(row.id, "sess-lock-1"); + assert_eq!(row.kind, "cloud"); + assert_eq!(row.title, "rate limiter"); + assert_eq!(row.status, "live"); + } + #[test] fn jobs_from_subagents_keep_ids() { let mut task = SubagentTaskDisplay::new("sub-1", "tool-1", "rate-limiter", "subagent"); diff --git a/src/cortex-tui/src/interactive/builders/mod.rs b/src/cortex-tui/src/interactive/builders/mod.rs index e5c1d94b..1904cb8b 100644 --- a/src/cortex-tui/src/interactive/builders/mod.rs +++ b/src/cortex-tui/src/interactive/builders/mod.rs @@ -31,7 +31,7 @@ pub use agents::{ pub use approval::{ PERMISSION_EDIT_LABEL, PERMISSION_NO_LABEL, PERMISSION_ONCE_LABEL, PERMISSION_PROMPT_ACTION, PERMISSION_PROMPT_PLACEHOLDER, PERMISSION_PROMPT_TITLE, always_allow_snippet, - build_approval_selector, build_clear_confirm, build_log_level_selector, + build_approval_selector, build_clear_confirm, build_handoff_confirm, build_log_level_selector, build_permission_prompt, build_permissions_picker, build_plan_confirm, build_question_prompt, build_sandbox_deny_prompt, permission_always_label, permission_command_line, }; diff --git a/src/cortex-tui/src/interactive/builders/theme.rs b/src/cortex-tui/src/interactive/builders/theme.rs index 2eb18905..d4ab9d38 100644 --- a/src/cortex-tui/src/interactive/builders/theme.rs +++ b/src/cortex-tui/src/interactive/builders/theme.rs @@ -10,30 +10,40 @@ struct ThemeDef { const THEMES: &[ThemeDef] = &[ ThemeDef { - id: "ocean", - label: "Ocean", - description: "Deep blue theme - default", + id: "dark", + label: "Cortex Night", + description: "Default inky chrome · banner green on focus only", }, ThemeDef { - id: "midnight", - label: "Midnight", - description: "Dark purple theme", + id: "light", + label: "Cortex Day", + description: "Light chrome for bright rooms", }, ThemeDef { - id: "forest", - label: "Forest", - description: "Dark green theme", + id: "ocean_dark", + label: "Ocean Dark", + description: "Deep blue and cyan accents", }, ThemeDef { - id: "monochrome", - label: "Monochrome", - description: "Black and white", + id: "monokai", + label: "Monokai", + description: "Classic code-editor colors", }, ]; +fn canonical_theme_id(current: Option<&str>) -> &'static str { + match current.unwrap_or("dark") { + "dark" | "cortex-night" | "cortex_night" => "dark", + "light" | "cortex-day" | "cortex_day" => "light", + "ocean_dark" | "ocean" => "ocean_dark", + "monokai" => "monokai", + _ => "dark", + } +} + /// Build an interactive state for theme selection. pub fn build_theme_selector(current: Option<&str>) -> InteractiveState { - let current_theme = current.unwrap_or("ocean"); + let current_theme = canonical_theme_id(current); let items: Vec = THEMES .iter() @@ -61,6 +71,8 @@ mod tests { let current = state.items.iter().find(|i| i.is_current); assert!(current.is_some()); - assert_eq!(current.unwrap().id, "ocean"); + assert_eq!(current.unwrap().id, "ocean_dark"); + assert_eq!(state.items[0].label, "Cortex Night"); + assert_eq!(state.items[1].label, "Cortex Day"); } } diff --git a/src/cortex-tui/src/lib.rs b/src/cortex-tui/src/lib.rs index 7027bd1b..af0ce279 100644 --- a/src/cortex-tui/src/lib.rs +++ b/src/cortex-tui/src/lib.rs @@ -106,6 +106,7 @@ pub mod lock_proof; pub mod lock_v2; mod lock_v2_boards; mod lock_v2_computer; +mod lock_v2_designed; mod lock_v2_goal; mod lock_v2_ids; mod lock_v2_network; diff --git a/src/cortex-tui/src/lock_v2.rs b/src/cortex-tui/src/lock_v2.rs index 9df6dae0..2cec5e16 100644 --- a/src/cortex-tui/src/lock_v2.rs +++ b/src/cortex-tui/src/lock_v2.rs @@ -46,7 +46,7 @@ pub fn validate_lock_v2_only_ids(ids: &[&str], width: u16) -> Result<()> { let mut seen = HashSet::new(); for id in ids { if !known.contains(id) { - anyhow::bail!("unknown lock v2 scene id `{id}`"); + anyhow::bail!("unknown lock v2 scene id `{id}` at width {width}"); } if !seen.insert(*id) { anyhow::bail!("repeated lock v2 scene id `{id}`"); @@ -68,12 +68,12 @@ pub fn write_lock_v2_id_frames( let mut manifest_frames = Vec::new(); for id in ids { let frame = render_lock_v2_scene(id, width, height)?; - let file = format!("{}.ans", id); + let file = format!("{id}.ans"); std::fs::write(output_dir.join(&file), &frame.ansi) .with_context(|| format!("write {file}"))?; manifest_frames.push(ManifestFrame { file, - label: id.to_string(), + label: (*id).to_string(), hold: 1, }); } @@ -158,8 +158,8 @@ mod tests { #[test] fn lock_v2_wide_count_is_spec() { - assert_eq!(LOCK_V2_WIDE_IDS.len(), 89); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 43); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 95); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 49); } #[test] diff --git a/src/cortex-tui/src/lock_v2_boards.rs b/src/cortex-tui/src/lock_v2_boards.rs index 8e616bd8..d2ddf81b 100644 --- a/src/cortex-tui/src/lock_v2_boards.rs +++ b/src/cortex-tui/src/lock_v2_boards.rs @@ -14,6 +14,7 @@ use crate::interactive::builders::{ build_permissions_picker, build_plan_confirm, build_question_prompt, build_sandbox_deny_prompt, }; use crate::lock_v2::PRODUCT_ERROR; +use crate::lock_v2_designed::apply_designed_scene; use crate::lock_v2_goal::{ apply_computer_scene, apply_goal_chip_scene, show_goal_in_narrow_palette, }; @@ -812,6 +813,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_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 4c4ca8f0..9dfe1019 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(), 89); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 43); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 95); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 49); 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 new file mode 100644 index 00000000..35f0fc15 --- /dev/null +++ b/src/cortex-tui/src/lock_v2_designed.rs @@ -0,0 +1,394 @@ +//! Lock v2 scenes for COR-18 / COR-225 / COR-227 / COR-228. +//! +//! Runtime MockTerminal boards only — Designer PNGs on other PRs stay untouched. +//! `/share` / `/unshare` (COR-226) is not a scene here. + +use cortex_core::widgets::Message; + +use crate::app::AppState; +use crate::interactive::builders::build_theme_selector; +use crate::lock_v2_scenes::*; + +/// Designed boards (all sizes). `handoff-confirm` is also at 40×12. +pub const DESIGNED_NARROW_IDS: &[&str] = &[ + "theme-picker", + "session-fork", + "init-agents", + "custom-commands", + "hooks-lifecycle", + "handoff-confirm", +]; + +/// Apply a designed lock scene. Returns `false` when `id` is not one of these. +pub fn apply_designed_scene(id: &str, state: &mut AppState, width: u16) -> bool { + if !DESIGNED_NARROW_IDS.contains(&id) { + return false; + } + let narrow = width <= 40; + match id { + "theme-picker" => { + resumed(state); + state.input.set_text("/theme"); + let mut interactive = build_theme_selector(Some("dark")); + interactive.hovered = Some(1); + state.enter_interactive_mode(interactive); + } + "handoff-confirm" => { + resumed(state); + if !narrow { + state.add_message( + Message::user("& ship the lock boards on Cortex Cloud") + .with_timestamp("02:40 PM"), + ); + } + state.input.set_text("/handoff"); + let mut interactive = radios( + "Handoff", + &[ + ( + "cloud", + "1 Cortex Cloud", + if narrow { + "Chat · Code · Bot" + } else { + "Chat · Code · Bot — this CLI session stays here" + }, + ), + ( + "stay", + if narrow { + "2 Stay on CLI" + } else { + "2 Stay on this CLI session" + }, + "keep running locally", + ), + ], + 0, + None, + ); + interactive.hovered = Some(0); + state.enter_interactive_mode(interactive); + } + "session-fork" => { + resumed(state); + state.add_message( + Message::user("fork from this reply and keep going").with_timestamp("11:08 AM"), + ); + state.input.set_text("/fork"); + let desc_keep = if narrow { + "this thread unchanged" + } else { + "original session unchanged" + }; + state.enter_interactive_mode(radios( + "Fork session", + &[ + ( + "fork", + if narrow { + "1 Fork now" + } else { + "1 Fork this session" + }, + if narrow { + "new branch · optional name" + } else { + "new session · optional name" + }, + ), + ("keep", "2 Stay here", desc_keep), + ], + 0, + None, + )); + } + "init-agents" => { + resumed(state); + state.add_message(Message::user("/init").with_timestamp("09:12 AM")); + state.add_message( + Message::assistant(if narrow { + "Set up AGENTS.md from the built-in template — accept the diff." + } else { + "Set up AGENTS.md from the built-in project template. Review the diff, then write it to the repo." + }) + .with_timestamp("09:12 AM"), + ); + state.tool_calls = vec![tool( + "init-agents", + "edit", + serde_json::json!({"path": "AGENTS.md"}), + crate::views::tool_call::ToolStatus::Completed, + if narrow { + "@@ AGENTS.md\n+# Cortex CLI\n+Chat | Code | Bot" + } else { + "@@ AGENTS.md\n+# Cortex CLI / Cortex Code\n+\n+Chat | Code | Bot in this repository.\n+Prefer `cargo test` and lock_v2 scenes." + }, + "+12", + 1, + )]; + state.enter_interactive_mode(radios( + "Write AGENTS.md?", + &[ + ( + "write", + if narrow { + "1 Write AGENTS.md" + } else { + "1 Write AGENTS.md to the repo" + }, + if narrow { + "built-in template" + } else { + "accept the diff" + }, + ), + ("cancel", "2 Cancel", "leave the file untouched"), + ], + 0, + None, + )); + } + "custom-commands" => { + resumed(state); + state.input.set_text("/commands"); + state.enter_interactive_mode(radios( + "Custom commands", + &[ + ( + "pr", + "/pr", + if narrow { + ".cortex/commands · $ARGUMENTS" + } else { + ".cortex/commands/pr.md · $ARGUMENTS" + }, + ), + ( + "ship", + "/ship", + if narrow { + ".cortex/commands" + } else { + ".cortex/commands/ship.md · prompt template" + }, + ), + ( + "review", + "/review-lock", + if narrow { + "repo SKILL.md" + } else { + ".cortex/skills/review/SKILL.md" + }, + ), + ], + 0, + Some(0), + )); + } + "hooks-lifecycle" => { + resumed(state); + state.input.set_text("/hooks"); + state.add_message(Message::system(if narrow { + "A hook is never consent. Output is capped and redacted." + } else { + "`.cortex/hooks.json` — a hook is never consent. Output is capped and redacted." + })); + state.enter_interactive_mode(radios( + "Hooks", + &[ + ("pre", "pre_tool_use", "before a local tool"), + ("post", "post_tool_use", "after a local tool"), + ("stop", "stop", "when the turn stops"), + ( + "start", + "session_start", + if narrow { + "plugin event" + } else { + "plugin · terminal notification" + }, + ), + ( + "end", + "session_end", + if narrow { + "never consent" + } else { + "lifecycle event — never consent" + }, + ), + ], + 0, + None, + )); + } + _ => return false, + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lock_v2::render_lock_v2_scene; + use crate::lock_v2_ids::{LOCK_V2_NARROW_IDS, LOCK_V2_WIDE_IDS}; + use cortex_core::style::ACCENT; + + const SIZES: [(u16, u16); 2] = [(120, 40), (40, 12)]; + + #[test] + fn designed_scene_ids_are_registered() { + for id in DESIGNED_NARROW_IDS { + assert!(LOCK_V2_WIDE_IDS.contains(id), "{id} missing from wide"); + assert!(LOCK_V2_NARROW_IDS.contains(id), "{id} missing from narrow"); + } + 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); + } + + #[test] + fn theme_picker_is_cortex_branded() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("theme-picker", width, height).expect("theme"); + let plain = &frame.plain; + assert!(plain.contains("Cortex Night"), "{plain}"); + assert!(plain.contains("Cortex Day"), "{plain}"); + assert!(plain.contains("Ocean Dark"), "{plain}"); + assert!(plain.contains("Monokai"), "{plain}"); + assert!(!plain.contains("Midnight"), "{plain}"); + assert!(!plain.contains("Monochrome"), "{plain}"); + assert!(!plain.contains("Grok"), "{plain}"); + let mut accent = false; + for y in 0..height { + for x in 0..width { + if frame.buffer[(x, y)].fg == ACCENT { + accent = true; + } + } + } + assert!( + accent, + "theme-picker focus uses #1F4945 at {width}x{height}" + ); + } + } + + #[test] + fn handoff_slash_command_is_registered() { + let registry = crate::commands::CommandRegistry::default(); + assert!( + registry.exists("handoff"), + "/handoff must be a registered command" + ); + } + + #[test] + fn handoff_confirm_is_cli_products_only() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("handoff-confirm", width, height).expect("handoff"); + assert!( + frame.plain.contains("Cortex Cloud"), + "{width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("Chat"), + "{width}x{height}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("Stay on") && frame.plain.contains("CLI"), + "{width}x{height}:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("Handed off to Cortex Cloud"), + "{width}x{height}:\n{}", + frame.plain + ); + assert!(!frame.plain.contains("ag_4f2a"), "{}", frame.plain); + assert!(!frame.plain.contains("Arcade"), "{}", frame.plain); + } + } + + #[test] + fn session_fork_is_distinct_from_resume() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("session-fork", width, height).expect("fork"); + assert!( + frame.plain.contains("Fork"), + "fork copy at {width}x{height}:\n{}", + frame.plain + ); + assert!( + !frame.plain.contains("Resume"), + "must not look like resume-picker:\n{}", + frame.plain + ); + } + } + + #[test] + fn init_agents_shows_diff_to_accept() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("init-agents", width, height).expect("init"); + assert!(frame.plain.contains("AGENTS.md"), "{}", frame.plain); + assert!( + frame.plain.contains("Write AGENTS.md") || frame.plain.contains("1 Write"), + "{}", + frame.plain + ); + assert!(frame.plain.contains("Cancel"), "{}", frame.plain); + assert!(!frame.plain.contains("share"), "{}", frame.plain); + assert!( + !frame.plain.contains("bundled skill"), + "init copy must not claim a bundled skill:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("built-in"), + "init copy must name the built-in template at {width}x{height}:\n{}", + frame.plain + ); + } + } + + #[test] + fn custom_commands_come_from_cortex_dir() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("custom-commands", width, height).expect("cmds"); + assert!(frame.plain.contains("/pr"), "{}", frame.plain); + assert!( + frame.plain.contains(".cortex/commands") || frame.plain.contains("$ARGUMENTS"), + "{}", + frame.plain + ); + assert!( + frame.plain.contains("SKILL.md") || frame.plain.contains("/review-lock"), + "{}", + frame.plain + ); + } + } + + #[test] + fn hooks_lifecycle_is_not_consent() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("hooks-lifecycle", width, height).expect("hooks"); + assert!(frame.plain.contains("pre_tool_use"), "{}", frame.plain); + assert!(frame.plain.contains("post_tool_use"), "{}", frame.plain); + assert!(frame.plain.contains("session_start"), "{}", frame.plain); + assert!( + frame.plain.contains("never consent") || frame.plain.contains("not consent"), + "{}", + frame.plain + ); + assert!(!frame.plain.contains("cert"), "{}", frame.plain); + assert!(!frame.plain.contains("badge"), "{}", frame.plain); + } + } +} diff --git a/src/cortex-tui/src/lock_v2_ids.rs b/src/cortex-tui/src/lock_v2_ids.rs index d7e965f9..beb017d3 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 — 43 boards. +/// Narrow (40×12) SPEC §7 set — 49 boards. pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "welcome-cortex", "welcome-agent", @@ -29,6 +29,12 @@ pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "settings-mouse", "settings-row-hover", "settings-theme-submenu", + "theme-picker", + "session-fork", + "init-agents", + "custom-commands", + "hooks-lifecycle", + "handoff-confirm", "mode-plan", "mode-ask", "permission-prompt", @@ -48,7 +54,7 @@ pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "undo-sheet", ]; -/// Wide (120×40) SPEC §7 set — 89 boards. +/// Wide (120×40) SPEC §7 set — 95 boards. pub const LOCK_V2_WIDE_IDS: &[&str] = &[ "welcome-cortex", "welcome-agent", @@ -105,6 +111,12 @@ pub const LOCK_V2_WIDE_IDS: &[&str] = &[ "sandbox", "sandbox-deny", "cloud-handoff", + "handoff-confirm", + "theme-picker", + "session-fork", + "init-agents", + "custom-commands", + "hooks-lifecycle", "diagnostics", "interrupt-stopped", "error-unavailable", @@ -157,8 +169,8 @@ mod tests { #[test] fn lock_v2_id_counts_and_unique() { - assert_eq!(LOCK_V2_WIDE_IDS.len(), 89); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 43); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 95); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 49); 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/modal/theme.rs b/src/cortex-tui/src/modal/theme.rs index e56df9ff..967b7b63 100644 --- a/src/cortex-tui/src/modal/theme.rs +++ b/src/cortex-tui/src/modal/theme.rs @@ -32,23 +32,23 @@ struct ThemeDef { const THEMES: &[ThemeDef] = &[ ThemeDef { id: "dark", - label: "Dark", - description: "Default dark theme with green accents", + label: "Cortex Night", + description: "Default inky chrome · banner green on focus only", }, ThemeDef { id: "light", - label: "Light", - description: "Light theme with dark text", + label: "Cortex Day", + description: "Light chrome for bright rooms", }, ThemeDef { id: "ocean_dark", label: "Ocean Dark", - description: "Deep blue/cyan aesthetic", + description: "Deep blue and cyan accents", }, ThemeDef { id: "monokai", label: "Monokai", - description: "Classic code editor colors", + description: "Classic code-editor colors", }, ]; diff --git a/src/cortex-tui/src/runner/event_loop/commands.rs b/src/cortex-tui/src/runner/event_loop/commands.rs index 9e0354a4..936aa95f 100644 --- a/src/cortex-tui/src/runner/event_loop/commands.rs +++ b/src/cortex-tui/src/runner/event_loop/commands.rs @@ -370,6 +370,12 @@ impl EventLoop { match cmd { "sessions:list" => self.open_sessions_modal(), + "handoff" => { + let narrow = self.app_state.terminal_size.0 <= 40; + self.app_state.enter_interactive_mode( + crate::interactive::builders::build_handoff_confirm(narrow), + ); + } "undo" => { let result = self.rewind_conversation(1); self.report_local_result(result, ""); diff --git a/src/cortex-tui/src/runner/event_loop/handoff.rs b/src/cortex-tui/src/runner/event_loop/handoff.rs new file mode 100644 index 00000000..4b1f1e9e --- /dev/null +++ b/src/cortex-tui/src/runner/event_loop/handoff.rs @@ -0,0 +1,280 @@ +//! `/handoff` confirm: stay on this CLI, or follow live Cortex Cloud agents. + +use cortex_engine::client::{AUTH_REQUIRED, CodeAgentClient, CodeSession}; + +use super::core::EventLoop; +use crate::interactive::builders::JobRow; + +impl EventLoop { + /// Stay closes the picker. Cloud lists (or creates) Code sessions — never + /// the empty local `/jobs` picker. + pub(super) async fn handle_handoff_confirm_choice(&mut self, item_id: &str) -> bool { + if item_id != "cloud" { + return false; + } + self.add_system_message("This CLI session stays here (Chat · Code · Bot)."); + match self.load_cloud_job_rows().await { + Ok(rows) => { + let interactive = crate::interactive::builders::build_jobs_picker(&rows); + self.app_state.enter_interactive_mode(interactive); + true + } + Err(message) => { + self.app_state.exit_interactive_mode(); + self.add_system_message(&message); + false + } + } + } + + async fn load_cloud_job_rows(&self) -> Result, String> { + let client = self.code_agent_client()?; + let sessions = list_or_create_cloud_sessions(&client, self.handoff_session_title()).await?; + if sessions.iter().any(|session| session.id.is_empty()) { + return Err("The coding service is temporarily unavailable".into()); + } + Ok(sessions.iter().map(JobRow::from_code_session).collect()) + } + + fn handoff_session_title(&self) -> Option { + self.cortex_session + .as_ref() + .map(crate::session::CortexSession::title) + .filter(|title| !title.is_empty()) + } + + fn code_agent_client(&self) -> Result { + let token = cloud_handoff_token().ok_or_else(|| AUTH_REQUIRED.to_string())?; + Ok(CodeAgentClient::new(self.code_agent_api_url(), Some(token))) + } + + fn code_agent_api_url(&self) -> Option { + if let Some(pm) = &self.provider_manager + && let Ok(manager) = pm.try_read() + { + let url = manager.api_url(); + if !url.is_empty() { + return Some(url.to_string()); + } + } + std::env::var("CORTEX_API_URL") + .ok() + .filter(|url| !url.is_empty()) + } +} + +fn cloud_handoff_token() -> Option { + std::env::var("CORTEX_AUTH_TOKEN") + .ok() + .filter(|token| !token.is_empty()) + .or_else(|| { + std::env::var("CORTEX_API_KEY") + .ok() + .filter(|token| !token.is_empty()) + }) + .or_else(cortex_login::get_auth_token) +} + +async fn list_or_create_cloud_sessions( + client: &CodeAgentClient, + title: Option, +) -> Result, String> { + let sessions = client + .list_sessions() + .await + .map_err(|err| err.user_friendly_message())?; + if !sessions.is_empty() { + return Ok(sessions); + } + let created = client + .create_session(title.as_deref()) + .await + .map_err(|err| err.user_friendly_message())?; + Ok(vec![created]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::AppState; + use crate::session::{CortexSession, SessionStorage}; + use serial_test::serial; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn isolate_auth_env() { + unsafe { + std::env::remove_var("CORTEX_AUTH_TOKEN"); + std::env::remove_var("CORTEX_API_KEY"); + } + } + + fn fixture() -> (tempfile::TempDir, EventLoop) { + let temp = tempfile::tempdir().unwrap(); + let store = SessionStorage::with_dir(temp.path().join("sessions")); + let session = CortexSession::with_storage("cortex", "test", store).unwrap(); + let event_loop = EventLoop::new(AppState::new()).with_cortex_session(session); + (temp, event_loop) + } + + #[tokio::test] + #[serial] + async fn cloud_without_auth_does_not_open_local_jobs() { + isolate_auth_env(); + unsafe { + std::env::set_var("CORTEX_API_URL", "http://127.0.0.1:9"); + } + let (_temp, mut runner) = fixture(); + let keep = runner.handle_handoff_confirm_choice("cloud").await; + unsafe { + std::env::remove_var("CORTEX_API_URL"); + } + assert!(!keep); + assert!(runner.app_state.get_interactive_state().is_none()); + let last = runner + .app_state + .messages + .last() + .expect("auth copy") + .content + .clone(); + assert!( + last.contains("Not signed in") || last.contains("temporarily unavailable"), + "{last}" + ); + assert!(!last.to_ascii_lowercase().contains("reqwest"), "{last}"); + } + + #[tokio::test] + #[serial] + async fn stay_does_not_open_jobs() { + let (_temp, mut runner) = fixture(); + assert!(!runner.handle_handoff_confirm_choice("stay").await); + assert!(runner.app_state.get_interactive_state().is_none()); + } + + #[tokio::test] + #[serial] + async fn cloud_lists_live_code_sessions() { + isolate_auth_env(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/code/sessions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "items": [{ + "id": "sess-live-1", + "runtime": "cloud", + "state": "running", + "title": "lock boards" + }] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/code/sessions")) + .respond_with(ResponseTemplate::new(599)) + .expect(0) + .mount(&server) + .await; + + unsafe { + std::env::set_var("CORTEX_API_KEY", "handoff-fixture-token"); + std::env::set_var("CORTEX_API_URL", server.uri()); + } + let (_temp, mut runner) = fixture(); + let keep = runner.handle_handoff_confirm_choice("cloud").await; + unsafe { + std::env::remove_var("CORTEX_API_KEY"); + std::env::remove_var("CORTEX_API_URL"); + } + assert!(keep); + let jobs = runner + .app_state + .get_interactive_state() + .expect("cloud rows open the jobs picker"); + assert_eq!(jobs.items[0].id, "sess-live-1"); + assert!(!jobs.items[0].disabled); + assert!(!jobs.title.contains("none running"), "{}", jobs.title); + assert_eq!(runner.app_state.active_subagents.len(), 0); + } + + #[tokio::test] + #[serial] + async fn cloud_creates_a_session_when_the_list_is_empty() { + isolate_auth_env(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/code/sessions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "items": [] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/code/sessions")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "sess-created-9", + "runtime": "cloud", + "title": "test" + }))) + .expect(1) + .mount(&server) + .await; + + unsafe { + std::env::set_var("CORTEX_API_KEY", "handoff-fixture-token"); + std::env::set_var("CORTEX_API_URL", server.uri()); + } + let (_temp, mut runner) = fixture(); + let keep = runner.handle_handoff_confirm_choice("cloud").await; + unsafe { + std::env::remove_var("CORTEX_API_KEY"); + std::env::remove_var("CORTEX_API_URL"); + } + assert!(keep); + let jobs = runner + .app_state + .get_interactive_state() + .expect("created session is listed"); + assert_eq!(jobs.items[0].id, "sess-created-9"); + } + + #[tokio::test] + #[serial] + async fn cloud_api_outage_is_product_copy_and_not_local_jobs() { + isolate_auth_env(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/code/sessions")) + .respond_with(ResponseTemplate::new(503)) + .expect(1) + .mount(&server) + .await; + + unsafe { + std::env::set_var("CORTEX_API_KEY", "handoff-fixture-token"); + std::env::set_var("CORTEX_API_URL", server.uri()); + } + let (_temp, mut runner) = fixture(); + let keep = runner.handle_handoff_confirm_choice("cloud").await; + unsafe { + std::env::remove_var("CORTEX_API_KEY"); + std::env::remove_var("CORTEX_API_URL"); + } + assert!(!keep); + assert!(runner.app_state.get_interactive_state().is_none()); + let last = runner + .app_state + .messages + .last() + .expect("outage copy") + .content + .clone(); + assert!( + last.contains("The coding service is temporarily unavailable"), + "{last}" + ); + } +} diff --git a/src/cortex-tui/src/runner/event_loop/mod.rs b/src/cortex-tui/src/runner/event_loop/mod.rs index 305b68ca..ffedacff 100644 --- a/src/cortex-tui/src/runner/event_loop/mod.rs +++ b/src/cortex-tui/src/runner/event_loop/mod.rs @@ -30,6 +30,7 @@ mod actions; mod auth; mod commands; mod core; +mod handoff; mod input; mod local_workflows; mod me; diff --git a/src/cortex-tui/src/runner/event_loop/modal.rs b/src/cortex-tui/src/runner/event_loop/modal.rs index 07ff39fb..439700e8 100644 --- a/src/cortex-tui/src/runner/event_loop/modal.rs +++ b/src/cortex-tui/src/runner/event_loop/modal.rs @@ -751,6 +751,9 @@ impl EventLoop { self.app_state.toasts.info(format!("Permissions: {label}")); return false; } + "handoff-confirm" => { + return self.handle_handoff_confirm_choice(&item_id).await; + } "clear-confirm" => { if item_id == "yes" { self.app_state.clear_messages(); diff --git a/src/cortex-tui/src/runner/event_loop/ux_contract_tests.rs b/src/cortex-tui/src/runner/event_loop/ux_contract_tests.rs index 2ec21ca3..c81d973c 100644 --- a/src/cortex-tui/src/runner/event_loop/ux_contract_tests.rs +++ b/src/cortex-tui/src/runner/event_loop/ux_contract_tests.rs @@ -6,6 +6,7 @@ use crate::modal::ModalAction; use crate::session::{CortexSession, SessionStorage, StoredMessage}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget}; +use serial_test::serial; fn fixture() -> (tempfile::TempDir, EventLoop) { let temp = tempfile::tempdir().unwrap(); @@ -514,3 +515,87 @@ async fn ux_contract_permission_prompt_numbered_radios() { runner.app_state.reject(); assert!(!runner.app_state.has_pending_approval()); } + +#[tokio::test] +#[serial] +async fn ux_contract_handoff_opens_confirm_instead_of_unsupported() { + let previous_url = std::env::var("CORTEX_API_URL").ok(); + unsafe { + std::env::remove_var("CORTEX_AUTH_TOKEN"); + std::env::remove_var("CORTEX_API_KEY"); + std::env::set_var("CORTEX_API_URL", "http://127.0.0.1:9"); + } + let (_temp, mut runner) = fixture(); + runner + .handle_command_result(CommandResult::Async("handoff".into())) + .await + .unwrap(); + let state = runner + .app_state + .get_interactive_state() + .expect("/handoff must open the confirm picker"); + assert_eq!(state.title, "Handoff"); + assert!( + state.items.iter().any(|i| i.id == "cloud"), + "missing Cortex Cloud option" + ); + assert!( + state.items.iter().any(|i| i.id == "stay"), + "missing stay option" + ); + assert_views(&mut runner, "Cortex Cloud"); + runner + .handle_interactive_selection( + crate::interactive::InteractiveAction::Custom("handoff-confirm".into()), + "stay".into(), + vec![], + ) + .await; + runner.app_state.exit_interactive_mode(); + runner + .handle_command_result(CommandResult::Async("handoff".into())) + .await + .unwrap(); + runner + .handle_interactive_selection( + crate::interactive::InteractiveAction::Custom("handoff-confirm".into()), + "cloud".into(), + vec![], + ) + .await; + assert_views(&mut runner, "Chat"); + if let Some(jobs) = runner.app_state.get_interactive_state() { + assert!( + !jobs.title.contains("none running"), + "Cloud must not open the empty local jobs picker: {}", + jobs.title + ); + assert!( + jobs.items + .iter() + .all(|item| item.id != "empty" && !item.disabled), + "Cloud rows must be live Code session ids, got {:?}", + jobs.items.iter().map(|i| &i.id).collect::>() + ); + assert_eq!(runner.app_state.active_subagents.len(), 0); + } else { + let last = runner + .app_state + .messages + .last() + .expect("Cloud without sessions must explain the failure") + .content + .clone(); + assert!( + last.contains("Not signed in") + || last.contains("temporarily unavailable") + || last.contains("cortex login"), + "{last}" + ); + assert!(!last.to_ascii_lowercase().contains("reqwest"), "{last}"); + } + match previous_url { + Some(url) => unsafe { std::env::set_var("CORTEX_API_URL", url) }, + None => unsafe { std::env::remove_var("CORTEX_API_URL") }, + } +}