diff --git a/.github/workflows/codebuild.yml b/.github/workflows/codebuild.yml index 4da317df..81ae82b3 100644 --- a/.github/workflows/codebuild.yml +++ b/.github/workflows/codebuild.yml @@ -23,7 +23,10 @@ name: CodeBuild CI workflow_dispatch: concurrency: - group: codebuild-${{ github.workflow }}-${{ github.ref }} + # pull_request and pull_request_target share github.ref (the PR). If they + # share a group, one cancels the other and the cancelled job shows up as + # `matrix.context` because the name expression never expands. + group: codebuild-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true permissions: diff --git a/design/cli-lock-board-index.md b/design/cli-lock-board-index.md index d5543e23..18877f75 100644 --- a/design/cli-lock-board-index.md +++ b/design/cli-lock-board-index.md @@ -3,9 +3,9 @@ Generated from the id lists in the tree at `3035361` (`v0.1.10`): `lock_scene_ids()` in `src/cortex-tui/src/lock_proof.rs:53-131`, `is_lock_board()` in `src/cortex-tui/src/lock_boards.rs:44-99`, -`LOCK_V2_WIDE_IDS` / `LOCK_V2_NARROW_IDS` in `src/cortex-tui/src/lock_v2.rs:33-147`. +`LOCK_V2_WIDE_IDS` / `LOCK_V2_NARROW_IDS` in `src/cortex-tui/src/lock_v2_ids.rs`. -Current Designer lock: **v2** (77 wide / 31 narrow), green focus `#1F4945`. +Current Designer lock: **v2** (89 wide / 43 narrow), green focus `#1F4945`. Committed PNGs under `docs/media/tui-lock/` and `docs/media/tui-lock-v2/` still include historical violet `#A78BFA` pixels (see those READMEs). The ids and tests below are the source of truth: @@ -57,7 +57,7 @@ shows those chords; cover those boards with `lock.render`, | `lock_proof::tests::distinct_states_render_distinct_frames` | v1 | only the 4 documented aliases share a frame | | `lock_proof::tests::no_smashed_tokens_anywhere` | v1 | wrapped copy never breaks tokens | | `lock_proof::tests::no_rounded_frame_glyphs_anywhere` | v1 | currently a no-op (no glyph assertions); TUI bleeds to terminal edges — see `docs/media/tui-lock/README.md` | -| `lock_v2::tests::lock_v2_wide_count_is_spec` | v2 | 77 / 31 ids | +| `lock_v2::tests::lock_v2_wide_count_is_spec` | v2 | 89 / 43 ids | | `lock_v2::tests::lock_v2_wide_frames_are_unique`, `lock_v2_narrow_frames_are_unique` | v2 | every id is a distinct frame | | `lock_v2::tests::slash_hover_is_not_banner_green_wash` | v2 | hover `#1A1A1A`; `#221A38` banned | | `style::tests::gray_chrome_palette_is_locked` (`cortex-core`) | palette | `ACCENT == #1F4945`, grays neutral, mint/cyan banned, gold retired | @@ -145,7 +145,7 @@ Kinds: **painted** = `lock_boards.rs` painter (not the runtime view); | `sandbox_deny` | painted | `red_and_amber_stay_on_diagnostics` | flow *Permission* (deny) | | `mcp_drop` | painted | `red_and_amber_stay_on_diagnostics`; runtime path: `runner::event_loop::tests::mcp_disconnect_without_user_stop_is_a_drop` | flow *MCP* (kill peer) | -## Lock v2 — 77 ids (`docs/media/tui-lock-v2/`), 31 also at 40×12 +## Lock v2 — 86 ids (`docs/media/tui-lock-v2/`), 40 also at 40×12 Kinds: **real** = production view/builder with a real `AppState`; **seed** = real view but the runtime-emitted line is inserted as @@ -181,6 +181,11 @@ Every v2 id is covered by `lock_v2_wide_frames_are_unique` (and | `tokens-topright-warn` | — | real | uniqueness | fixture usage ≥ 90 % | | `compact-chat` | yes | real | uniqueness | `tui.type "/compact"` | | `slash-palette` | yes | real | `slash_hover_is_not_banner_green_wash` | flow *Slash* | +| `goal-chip-active` | yes | real | uniqueness | `lock.render` (`/goal` chip) | +| `goal-chip-paused` | yes | real | uniqueness | `lock.render` | +| `goal-chip-done` | yes | real | uniqueness | `lock.render` | +| `goal-chip-budget` | yes | real | uniqueness | `lock.render` | +| `goal-chip-blocked` | yes | real | uniqueness | `lock.render` | | `slash-model-typed` | yes | real | uniqueness | flow *Slash* (`/mod`) | | `model-list` | yes | real (`build_model_selector`) | uniqueness | flow *Model* + `api.models` | | `model-list-hover` | — | real | uniqueness | mouse fixture | @@ -208,6 +213,10 @@ Every v2 id is covered by `lock_v2_wide_frames_are_unique` (and | `sandbox` | — | **synthetic** | uniqueness | `tui.type "/sandbox"` (`build_sandbox_selector`) | | `sandbox-deny` | — | **synthetic** | uniqueness | flow *Permission* (deny) via `CortexError::SandboxDenied` | | `cloud-handoff` | — | seed | uniqueness | `tui.type "& …"` | +| `offline` | yes | seed | `lock_v2_network::tests::*` | Network unreachable — no API-down copy | +| `rate-limit` | yes | seed | `lock_v2_network::tests::*` | HTTP 429 — distinct from quota | +| `computer-disconnected` | yes | seed | `lock_v2_computer::tests::*` | This PC/SSH fail-closed — no substitute runtime | +| `computer-cloud-default` | yes | seed | `lock_v2_computer::tests::*` | Computer · Cloud welcome default | | `diagnostics` | yes | seed | uniqueness | flow *Errors* (diagnostics tool) | | `interrupt-stopped` | yes | seed | runtime path `interrupt_records_stopped_once` | flow *Cancel* | | `error-unavailable` | — | real | uniqueness | flow *Errors* (503) | @@ -241,7 +250,7 @@ Every v2 id is covered by `lock_v2_wide_frames_are_unique` (and | Pack | Ids | Sizes | Frames | Live / real | Painted or synthetic | PNGs in repo (all violet) | |---|---|---|---|---|---|---| | v1 | 72 | 40×12, 120×40 | 144 (+144 macOS composites) | 17 | 51 painted + 4 aliases | 65/72 files carry `#A78BFA` at each size | -| v2 | 77 wide / 31 narrow | 120×40 / 40×12 | 108 | 63 (5 seeded) | 14 synthetic | runtime 73/77 + 30/31; designer boards 68/77 + 29/31 | +| v2 | 89 wide / 43 narrow | 120×40 / 40×12 | 132 | 72 (7 seeded) | 14 synthetic | runtime 89/89 + 43/43; designer boards 89/89 + 43/43 | Regenerate captures: `./scripts/render-tui-lock.sh`, `./scripts/render-tui-lock-v2.sh`, `python3 docs/media/tui-lock-v2/tools/render_lock_v2.py --index`. diff --git a/docs/media/tui-lock-v2/120x40/computer-cloud-default.png b/docs/media/tui-lock-v2/120x40/computer-cloud-default.png new file mode 100644 index 00000000..85c9531d Binary files /dev/null and b/docs/media/tui-lock-v2/120x40/computer-cloud-default.png differ diff --git a/docs/media/tui-lock-v2/120x40/computer-disconnected.png b/docs/media/tui-lock-v2/120x40/computer-disconnected.png new file mode 100644 index 00000000..e35e5e0a Binary files /dev/null and b/docs/media/tui-lock-v2/120x40/computer-disconnected.png differ diff --git a/docs/media/tui-lock-v2/40x12/computer-cloud-default.png b/docs/media/tui-lock-v2/40x12/computer-cloud-default.png new file mode 100644 index 00000000..7ccdcb2d Binary files /dev/null and b/docs/media/tui-lock-v2/40x12/computer-cloud-default.png differ diff --git a/docs/media/tui-lock-v2/40x12/computer-disconnected.png b/docs/media/tui-lock-v2/40x12/computer-disconnected.png new file mode 100644 index 00000000..6c48162d Binary files /dev/null and b/docs/media/tui-lock-v2/40x12/computer-disconnected.png differ diff --git a/docs/media/tui-lock-v2/SPEC.md b/docs/media/tui-lock-v2/SPEC.md index a8ef6a6a..4ed84fea 100644 --- a/docs/media/tui-lock-v2/SPEC.md +++ b/docs/media/tui-lock-v2/SPEC.md @@ -5,9 +5,12 @@ Design-only deliverable for the full TUI redesign. Layout language is taken composer with the model chip in the bottom border, Settings modal with search + categorised rows + tip/nav footer, slash autocomplete above the composer, effort radios under `/model`, token counter top-right, footer shortcut strip) and -re-skinned to the Cortex chrome. **No runtime code changes ship with this pack.** +re-skinned to the Cortex chrome. Designer boards stay design-only; runtime +captures use `MinimalSessionView` plus lock flags (`computer_held`, +`show_computer_default`, `offline_held`, `rate_limit_held`) so MockTerminal +matches live chrome. -- Boards: [`index.md`](index.md) — 87 boards at 120×40, 41 of them also at 40×12 (128 PNGs). +- Boards: [`index.md`](index.md) — 89 boards at 120×40, 43 of them also at 40×12 (132 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 @@ -413,7 +416,7 @@ Narrow: no bars, `used / total pct%`. | 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` | | 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` | +| 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` | | 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` | @@ -426,7 +429,7 @@ Narrow (40×12) set: `welcome-cortex`, `welcome-agent`, `first-run-tips`, `sessi `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`, `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 cff79b57..26edd6b5 100644 --- a/docs/media/tui-lock-v2/index.md +++ b/docs/media/tui-lock-v2/index.md @@ -90,6 +90,8 @@ Regenerate: `python3 tools/render_lock_v2.py --index` (fetches IBM Plex Mono on | `error-unavailable` | API down — product-facing error | [120x40](120x40/error-unavailable.png) | — | | `offline` | Network unreachable — held composer, distinct from API-down | [120x40](120x40/offline.png) | [40x12](40x12/offline.png) | | `rate-limit` | HTTP 429 rate limited — retry-after, distinct from quota | [120x40](120x40/rate-limit.png) | [40x12](40x12/rate-limit.png) | +| `computer-disconnected` | This PC/SSH fail-closed — no connected Code session | [120x40](120x40/computer-disconnected.png) · [runtime](runtime/120x40/computer-disconnected.png) | [40x12](40x12/computer-disconnected.png) · [runtime](runtime/40x12/computer-disconnected.png) | +| `computer-cloud-default` | Computer · Cloud shipped default (welcome info card) | [120x40](120x40/computer-cloud-default.png) · [runtime](runtime/120x40/computer-cloud-default.png) | [40x12](40x12/computer-cloud-default.png) · [runtime](runtime/40x12/computer-cloud-default.png) | | `tool-tiles` | Grouped tool calls expanded — Read / Grep / Shell | [120x40](120x40/tool-tiles.png) | — | | `tool-tiles-collapsed` | Grouped tool calls collapsed | [120x40](120x40/tool-tiles-collapsed.png) | — | | `shell-running` | Live Shell tile with output | [120x40](120x40/shell-running.png) | — | @@ -118,4 +120,6 @@ Regenerate: `python3 tools/render_lock_v2.py --index` (fetches IBM Plex Mono on | `composer-file-chip` | Composer with attached @file chip in prompt | [120x40](120x40/composer-file-chip.png) | [40x12](40x12/composer-file-chip.png) | | `undo-sheet` | /undo /redo /rewind sheet | [120x40](120x40/undo-sheet.png) | [40x12](40x12/undo-sheet.png) | -**87** boards at 120x40 · **41** at 40x12 · 128 PNGs. +**89** boards at 120x40 · **43** at 40x12 · 132 PNGs. + +Runtime MockTerminal pack (same ids): [`runtime/120x40`](runtime/120x40/) (89) · [`runtime/40x12`](runtime/40x12/) (43). diff --git a/docs/media/tui-lock-v2/runtime/120x40/computer-cloud-default.png b/docs/media/tui-lock-v2/runtime/120x40/computer-cloud-default.png new file mode 100644 index 00000000..dab3ebac Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/computer-cloud-default.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/computer-disconnected.png b/docs/media/tui-lock-v2/runtime/120x40/computer-disconnected.png new file mode 100644 index 00000000..1d3e647f Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/computer-disconnected.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/computer-cloud-default.png b/docs/media/tui-lock-v2/runtime/40x12/computer-cloud-default.png new file mode 100644 index 00000000..f2b1ab7f Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/computer-cloud-default.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/computer-disconnected.png b/docs/media/tui-lock-v2/runtime/40x12/computer-disconnected.png new file mode 100644 index 00000000..d8cd2a47 Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/computer-disconnected.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/jobs.png b/docs/media/tui-lock-v2/runtime/40x12/jobs.png deleted file mode 100644 index 39f8c5db..00000000 Binary files a/docs/media/tui-lock-v2/runtime/40x12/jobs.png and /dev/null differ diff --git a/docs/media/tui-lock-v2/runtime/README.md b/docs/media/tui-lock-v2/runtime/README.md index a45e0eeb..19f5575e 100644 --- a/docs/media/tui-lock-v2/runtime/README.md +++ b/docs/media/tui-lock-v2/runtime/README.md @@ -8,7 +8,8 @@ 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: **87** boards at 120×40 and **41** at 40×12. Each filename is one +SPEC §7: **89** boards at 120×40 and **43** 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. diff --git a/docs/media/tui-lock-v2/tools/boards.py b/docs/media/tui-lock-v2/tools/boards.py index 2e03c2c6..9815d9e0 100644 --- a/docs/media/tui-lock-v2/tools/boards.py +++ b/docs/media/tui-lock-v2/tools/boards.py @@ -1412,6 +1412,55 @@ def board_rate_limit(s, c): footer(s, c, [("Enter", "retry"), ("/usage", "details")]) +# Exact engine constant (`src/cortex-engine/src/client/computer.rs`). +DISCONNECTED_RUNTIME = ( + "This PC and SSH Code execution require an already connected Code session. " + "Connect a host and resume that session, or leave CORTEX_COMPUTER unset to use Cloud. " + "No runtime was substituted." +) +DISCONNECTED_RUNTIME_NARROW = ( + "This PC and SSH need a connected Code session. No runtime was substituted." +) + + +def board_computer_disconnected(s, c): + """This PC/SSH fail-closed — no connected Code session; no Cloud substitute.""" + header(s, c) + placeholder = ( + "Connect a host — or unset for Cloud" + if c.narrow + else "Connect a host — or unset CORTEX_COMPUTER for Cloud" + ) + top = composer(s, c, placeholder=placeholder, focused=False, caret=False) + f = Flow(s, c, y=1 if c.narrow else 2, limit=top if c.narrow else top - 1) + f.user("run the local cargo tests" if not c.narrow else "run local tests", "09:18 AM") + f.line([("×", S_ERR), (" This PC disconnected", S_ERR)]) + body = DISCONNECTED_RUNTIME_NARROW if c.narrow else DISCONNECTED_RUNTIME + width = max(8, f.x1 - 1 - f.tx - 2) + for line in textwrap.wrap(body, width=width): + f.dim(line, indent=2) + footer(s, c, [("Enter", "retry"), ("Ctrl+x", "shortcuts")]) + + +def board_computer_cloud_default(s, c): + """Welcome frame with Computer · Cloud as the shipped default (not a handoff).""" + header(s, c, "0 / 500K") + title = [("Welcome to ", S), ("Cortex", S_BOLD)] + ( + [] if c.narrow else [(", the coding agent CLI", S_DIM)] + ) + welcome_lines(s, c, title) + y = 4 + if c.narrow: + s.spans(c.x0 + 2, y, [("Computer", S_DIM), (" ", S), ("Cloud", S)], max_x=c.x1) + else: + s.spans(c.x0 + 2, y, [("Directory", S_DIM), (" ", S), ("~/cortex", S)], max_x=c.x1) + s.spans(c.x0 + 2, y + 1, [("Org", S_DIM), (" ", S), ("Personal", S)], max_x=c.x1) + s.spans(c.x0 + 2, y + 2, [("Plan", S_DIM), (" ", S), ("Pro", S)], max_x=c.x1) + s.spans(c.x0 + 2, y + 3, [("Computer", S_DIM), (" ", S), ("Cloud", S)], max_x=c.x1) + composer(s, c) + footer(s, c) + + def board_tool_tiles(s, c): header(s, c) top = composer(s, c) @@ -1960,6 +2009,8 @@ def board_undo_sheet(s, c): ("error-unavailable", board_error_unavailable, False, "E", "API down — product-facing error"), ("offline", board_offline, True, "E", "Network unreachable — held composer, distinct from API-down"), ("rate-limit", board_rate_limit, True, "E", "HTTP 429 rate limited — retry-after, distinct from quota"), + ("computer-disconnected", board_computer_disconnected, True, "E", "This PC/SSH fail-closed — no connected Code session"), + ("computer-cloud-default", board_computer_cloud_default, True, "E", "Computer · Cloud shipped default (welcome info card)"), ("tool-tiles", board_tool_tiles, False, "E", "Grouped tool calls expanded — Read / Grep / Shell"), ("tool-tiles-collapsed", board_tool_tiles_collapsed, False, "E", "Grouped tool calls collapsed"), ("shell-running", board_shell_running, False, "E", "Live Shell tile with output"), diff --git a/docs/media/tui-lock-v2/txt/120x40/computer-cloud-default.txt b/docs/media/tui-lock-v2/txt/120x40/computer-cloud-default.txt new file mode 100644 index 00000000..9ab91368 --- /dev/null +++ b/docs/media/tui-lock-v2/txt/120x40/computer-cloud-default.txt @@ -0,0 +1,40 @@ + 0 / 500K + + Welcome to Cortex, the coding agent CLI + v0.1.7 · / commands · @ files · ! shell · & cloud + Directory ~/cortex + Org Personal + Plan Pro + Computer Cloud + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭─ Agent ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ > Plan, search, build anything │ + ╰─────────────────────────────────────────────────────────────────────────────────────────── Cortex Mini 1 (medium) ─╯ + + Shift+Tab:mode | Ctrl+x:shortcuts diff --git a/docs/media/tui-lock-v2/txt/120x40/computer-disconnected.txt b/docs/media/tui-lock-v2/txt/120x40/computer-disconnected.txt new file mode 100644 index 00000000..a7cc3adc --- /dev/null +++ b/docs/media/tui-lock-v2/txt/120x40/computer-disconnected.txt @@ -0,0 +1,40 @@ + 14K / 500K + + > run the local cargo tests 09:18 AM + + × This PC disconnected + This PC and SSH Code execution require an already connected Code session. Connect a host and resume that session, + or leave CORTEX_COMPUTER unset to use Cloud. No runtime was substituted. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭─ Agent ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ > Connect a host — or unset CORTEX_COMPUTER for Cloud │ + ╰─────────────────────────────────────────────────────────────────────────────────────────── Cortex Mini 1 (medium) ─╯ + + Enter:retry | Ctrl+x:shortcuts diff --git a/docs/media/tui-lock-v2/txt/40x12/computer-cloud-default.txt b/docs/media/tui-lock-v2/txt/40x12/computer-cloud-default.txt new file mode 100644 index 00000000..b03834bc --- /dev/null +++ b/docs/media/tui-lock-v2/txt/40x12/computer-cloud-default.txt @@ -0,0 +1,12 @@ + 0 / 500K + + Welcome to Cortex + v0.1.7 · / commands · @ files + Computer Cloud + + + ╭─ Agent ────────────────────────────╮ + │ > Plan, search, build anything │ + ╰─────────── Cortex Mini 1 (medium) ─╯ + + Shift+Tab:mode | Ctrl+x:shortcuts diff --git a/docs/media/tui-lock-v2/txt/40x12/computer-disconnected.txt b/docs/media/tui-lock-v2/txt/40x12/computer-disconnected.txt new file mode 100644 index 00000000..f4898f66 --- /dev/null +++ b/docs/media/tui-lock-v2/txt/40x12/computer-disconnected.txt @@ -0,0 +1,12 @@ + 14K / 500K + > run local tests + + × This PC disconnected + This PC and SSH need a connected + Code session. No runtime was + substituted. + ╭─ Agent ────────────────────────────╮ + │ > Connect a host — or unset for C… │ + ╰─────────── Cortex Mini 1 (medium) ─╯ + + Enter:retry | Ctrl+x:shortcuts diff --git a/scripts/readiness/test_codebuild.py b/scripts/readiness/test_codebuild.py index 80798857..b8ed2635 100644 --- a/scripts/readiness/test_codebuild.py +++ b/scripts/readiness/test_codebuild.py @@ -100,6 +100,9 @@ def test_pull_requests_use_trusted_buildspec_and_uncached_projects(self): self.assertIn("github.event_name != 'pull_request'", text) self.assertIn("github.event_name == 'pull_request_target'", text) self.assertIn("github.event_name == 'push'", text) + group = self.workflow["concurrency"]["group"] + self.assertIn("github.event_name", group) + self.assertIn("github.ref", group) self.assertIn("logs-only", text) def test_start_build_is_skipped_without_role_variable(self): diff --git a/scripts/render-tui-lock-v2.sh b/scripts/render-tui-lock-v2.sh index 578c284f..15cbf954 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" 41 -unique_pngs "$output_dir/120x40" 87 +unique_pngs "$output_dir/40x12" 43 +unique_pngs "$output_dir/120x40" 89 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: **87** boards at 120×40 and **41** at 40×12. Each filename is one +SPEC §7: **89** boards at 120×40 and **43** 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. EOF diff --git a/src/cortex-tui/src/app/state.rs b/src/cortex-tui/src/app/state.rs index a9a60d62..6109dd4c 100644 --- a/src/cortex-tui/src/app/state.rs +++ b/src/cortex-tui/src/app/state.rs @@ -206,6 +206,10 @@ pub struct AppState { pub offline_held: bool, /// HTTP 429 rate limit — follow-ups stay in the composer until Retry-After. pub rate_limit_held: bool, + /// This PC / SSH fail-closed — no host session; composer held, no Cloud substitute. + pub computer_held: bool, + /// Welcome info card should lock-paint Computer · Cloud as the shipped default. + pub show_computer_default: bool, /// Launched via the `agent` binary / alias. pub agent_entrypoint: bool, /// Token counter used / window. @@ -348,6 +352,8 @@ impl AppState { quota_held: false, offline_held: false, rate_limit_held: false, + computer_held: false, + show_computer_default: false, agent_entrypoint: false, tokens_used: 0, context_window: 500_000, diff --git a/src/cortex-tui/src/lib.rs b/src/cortex-tui/src/lib.rs index 69ae0232..7027bd1b 100644 --- a/src/cortex-tui/src/lib.rs +++ b/src/cortex-tui/src/lib.rs @@ -105,6 +105,7 @@ pub mod lock_palette; pub mod lock_proof; pub mod lock_v2; mod lock_v2_boards; +mod lock_v2_computer; 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 9d5061cf..9df6dae0 100644 --- a/src/cortex-tui/src/lock_v2.rs +++ b/src/cortex-tui/src/lock_v2.rs @@ -158,8 +158,8 @@ mod tests { #[test] fn lock_v2_wide_count_is_spec() { - assert_eq!(LOCK_V2_WIDE_IDS.len(), 87); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 41); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 89); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 43); } #[test] diff --git a/src/cortex-tui/src/lock_v2_boards.rs b/src/cortex-tui/src/lock_v2_boards.rs index df07d65f..8e616bd8 100644 --- a/src/cortex-tui/src/lock_v2_boards.rs +++ b/src/cortex-tui/src/lock_v2_boards.rs @@ -14,7 +14,9 @@ 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_goal::{apply_goal_chip_scene, show_goal_in_narrow_palette}; +use crate::lock_v2_goal::{ + apply_computer_scene, 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::*; @@ -811,6 +813,12 @@ 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) => {} + "computer-disconnected" | "computer-cloud-default" => { + assert!( + apply_computer_scene(id, &mut state, width), + "computer lock scene {id}" + ); + } other => panic!("unknown lock v2 scene {other}"), } state diff --git a/src/cortex-tui/src/lock_v2_computer.rs b/src/cortex-tui/src/lock_v2_computer.rs new file mode 100644 index 00000000..4c4ca8f0 --- /dev/null +++ b/src/cortex-tui/src/lock_v2_computer.rs @@ -0,0 +1,279 @@ +//! Computer lock v2 scenes — This PC/SSH fail-closed and Cloud default. +//! +//! Split out of [`crate::lock_v2`] so adding these boards does not grow that +//! file past the source-policy line-count baseline. + +use cortex_core::widgets::Message; +use cortex_engine::client::DISCONNECTED_RUNTIME; + +use crate::app::AppState; +use crate::ui::consts::DISCONNECTED_TITLE; + +/// Computer lock boards. Each filename is one live state. +pub const COMPUTER_SCENE_IDS: &[&str] = &["computer-disconnected", "computer-cloud-default"]; + +/// Narrow (40-column) fail-closed body — still names This PC/SSH and refuses +/// a substitute. Wide boards use the engine constant verbatim. +pub const DISCONNECTED_RUNTIME_NARROW: &str = + "This PC and SSH need a connected Code session. No runtime was substituted."; + +/// Apply a Computer lock scene. Returns `false` when `id` is not one of these. +pub fn apply_computer_scene(id: &str, state: &mut AppState, width: u16) -> bool { + if !COMPUTER_SCENE_IDS.contains(&id) { + return false; + } + match id { + "computer-disconnected" => seed_disconnected(state, width), + "computer-cloud-default" => seed_cloud_default(state), + _ => return false, + } + true +} + +fn seed_disconnected(state: &mut AppState, width: u16) { + state.show_launch_splash = false; + state.tokens_used = 14_000; + state.computer_held = true; + state.caret_visible = false; + state.add_message( + Message::user(if width <= 40 { + "run local tests" + } else { + "run the local cargo tests" + }) + .with_timestamp("09:18 AM"), + ); + state.add_message(Message::system(format!("× {DISCONNECTED_TITLE}"))); + state.add_message(Message::system(if width <= 40 { + DISCONNECTED_RUNTIME_NARROW + } else { + DISCONNECTED_RUNTIME + })); +} + +fn seed_cloud_default(state: &mut AppState) { + state.show_launch_splash = true; + state.tokens_used = 0; + state.show_computer_default = true; +} + +/// Squeezed transcript text so wrapped rows still match engine copy. +/// Scrollbar and box-drawing cells become spaces before collapse. +#[cfg(test)] +fn squeezed_plain(plain: &str) -> String { + plain + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c.is_ascii_punctuation() || c.is_whitespace() { + c + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +#[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 crate::ui::consts::PLACEHOLDER_DISCONNECTED; + use cortex_core::style::{ERROR, TEXT_DIM}; + use cortex_engine::client::ComputerKind; + + const SIZES: [(u16, u16); 2] = [(120, 40), (40, 12)]; + + fn has_fg( + frame: &crate::lock_proof::LockFrame, + needle: &str, + color: ratatui::style::Color, + ) -> bool { + let width = frame.buffer.area.width; + let height = frame.buffer.area.height; + for y in 0..height { + let row: String = (0..width) + .map(|x| frame.buffer[(x, y)].symbol().to_string()) + .collect(); + if let Some(at) = row.find(needle) { + let x = at as u16; + if frame.buffer[(x, y)].fg == color { + return true; + } + } + } + false + } + + #[test] + fn computer_scene_ids_are_registered() { + assert_eq!(COMPUTER_SCENE_IDS.len(), 2); + for id in COMPUTER_SCENE_IDS { + 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); + let mut seen = std::collections::HashSet::new(); + for id in LOCK_V2_WIDE_IDS.iter().chain(LOCK_V2_NARROW_IDS) { + seen.insert(*id); + } + assert!(seen.contains("computer-disconnected")); + assert!(seen.contains("computer-cloud-default")); + } + + #[test] + fn disconnected_uses_engine_copy_and_fail_closed() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("computer-disconnected", width, height) + .expect("computer-disconnected"); + let plain = &frame.plain; + let squeezed = squeezed_plain(plain); + assert!( + plain.contains("This PC"), + "missing This PC at {width}x{height}:\n{plain}" + ); + assert!( + plain.contains("SSH"), + "missing SSH at {width}x{height}:\n{plain}" + ); + assert!( + plain.contains("No runtime was substituted"), + "missing refuse at {width}x{height}:\n{plain}" + ); + assert!( + plain.contains(DISCONNECTED_TITLE), + "missing title at {width}x{height}:\n{plain}" + ); + if width >= 120 { + assert!( + squeezed.contains(DISCONNECTED_RUNTIME), + "wide board must carry the engine constant:\n{plain}" + ); + } else { + assert!( + squeezed.contains(DISCONNECTED_RUNTIME_NARROW), + "narrow board must keep the connected-session gist:\n{plain}" + ); + } + assert!( + !plain.contains("temporarily unavailable"), + "must not reuse error-unavailable:\n{plain}" + ); + assert!( + !plain.contains("Handed off"), + "must not look like cloud-handoff:\n{plain}" + ); + assert!( + plain.contains("Enter") && plain.contains("retry"), + "held footer Enter:retry at {width}x{height}:\n{plain}" + ); + assert!( + !plain.contains("Shift+Tab"), + "held board must not show idle Shift+Tab:\n{plain}" + ); + assert!( + plain.contains("Connect a host"), + "held placeholder at {width}x{height}:\n{plain}" + ); + if width >= 120 { + assert!( + plain.contains(PLACEHOLDER_DISCONNECTED) + || squeezed.contains("CORTEX_COMPUTER"), + "wide placeholder:\n{plain}" + ); + } else { + assert!( + squeezed.contains("Connect a host") && squeezed.contains("unset for"), + "narrow placeholder:\n{plain}" + ); + } + } + } + + #[test] + fn disconnected_title_is_error_red_and_body_is_dim() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("computer-disconnected", width, height).expect("disc"); + assert!( + has_fg(&frame, "This PC disconnected", ERROR) || has_fg(&frame, "×", ERROR), + "title must be error-red at {width}x{height}" + ); + assert!( + has_fg(&frame, "No runtime", TEXT_DIM) + || has_fg(&frame, "substituted", TEXT_DIM) + || has_fg(&frame, "connected", TEXT_DIM), + "body must be dim at {width}x{height}:\n{}", + frame.plain + ); + } + } + + #[test] + fn disconnected_is_not_unavailable_or_handoff() { + let disc = render_lock_v2_scene("computer-disconnected", 120, 40).unwrap(); + let unavail = render_lock_v2_scene("error-unavailable", 120, 40).unwrap(); + let handoff = render_lock_v2_scene("cloud-handoff", 120, 40).unwrap(); + let quota = render_lock_v2_scene("quota-exhausted", 120, 40).unwrap(); + assert_ne!(disc.ansi, unavail.ansi); + assert_ne!(disc.ansi, handoff.ansi); + assert_ne!(disc.ansi, quota.ansi); + assert!(unavail.plain.contains("temporarily unavailable")); + assert!(!disc.plain.contains("temporarily unavailable")); + assert!(handoff.plain.contains("Handed off") || handoff.plain.contains("Cortex Cloud")); + assert!(!disc.plain.contains("Handed off")); + } + + #[test] + fn cloud_default_shows_computer_cloud_not_handoff() { + for (width, height) in SIZES { + let frame = render_lock_v2_scene("computer-cloud-default", width, height) + .expect("cloud-default"); + let plain = &frame.plain; + assert!( + plain.contains("Computer"), + "missing Computer at {width}x{height}:\n{plain}" + ); + assert!( + plain.contains(ComputerKind::Cloud.label()), + "missing Cloud label at {width}x{height}:\n{plain}" + ); + assert!( + plain.contains("Welcome") && plain.contains("Cortex"), + "welcome chrome at {width}x{height}:\n{plain}" + ); + assert!( + !plain.contains("Handed off"), + "must not reuse cloud-handoff:\n{plain}" + ); + assert!( + !plain.contains("This PC disconnected"), + "cloud default is not the fail-closed board:\n{plain}" + ); + assert!( + plain.contains("Shift+Tab"), + "idle footer at {width}x{height}:\n{plain}" + ); + } + let cloud = render_lock_v2_scene("computer-cloud-default", 120, 40).unwrap(); + let welcome = render_lock_v2_scene("welcome-cortex", 120, 40).unwrap(); + let handoff = render_lock_v2_scene("cloud-handoff", 120, 40).unwrap(); + assert_ne!(cloud.ansi, welcome.ansi); + assert_ne!(cloud.ansi, handoff.ansi); + assert!(!welcome.plain.contains("Computer")); + assert!(cloud.plain.contains("Directory") || cloud.plain.contains("Computer")); + } + + #[test] + fn computer_kind_labels_match_product() { + assert_eq!(ComputerKind::ThisPc.label(), "This PC"); + assert_eq!(ComputerKind::Cloud.label(), "Cloud"); + assert_eq!(ComputerKind::Ssh.label(), "SSH"); + assert!(DISCONNECTED_RUNTIME.contains("This PC")); + assert!(DISCONNECTED_RUNTIME.contains("SSH")); + assert!(DISCONNECTED_RUNTIME.contains("No runtime was substituted")); + } +} diff --git a/src/cortex-tui/src/lock_v2_goal.rs b/src/cortex-tui/src/lock_v2_goal.rs index 7b0a488c..53b58bd6 100644 --- a/src/cortex-tui/src/lock_v2_goal.rs +++ b/src/cortex-tui/src/lock_v2_goal.rs @@ -8,6 +8,8 @@ use cortex_engine::goal::{Goal, GoalState}; use crate::app::AppState; +pub use crate::lock_v2_computer::apply_computer_scene; + /// Composer chip lock boards. Each filename is one live state. pub const GOAL_CHIP_IDS: &[&str] = &[ "goal-chip-active", diff --git a/src/cortex-tui/src/lock_v2_ids.rs b/src/cortex-tui/src/lock_v2_ids.rs index 77ffa55d..d7e965f9 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 — 41 boards. +/// Narrow (40×12) SPEC §7 set — 43 boards. pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "welcome-cortex", "welcome-agent", @@ -38,6 +38,8 @@ pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "interrupt-stopped", "offline", "rate-limit", + "computer-disconnected", + "computer-cloud-default", "diff-hunk", "login", "shortcuts-overlay", @@ -46,7 +48,7 @@ pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "undo-sheet", ]; -/// Wide (120×40) SPEC §7 set — 87 boards. +/// Wide (120×40) SPEC §7 set — 89 boards. pub const LOCK_V2_WIDE_IDS: &[&str] = &[ "welcome-cortex", "welcome-agent", @@ -108,6 +110,8 @@ pub const LOCK_V2_WIDE_IDS: &[&str] = &[ "error-unavailable", "offline", "rate-limit", + "computer-disconnected", + "computer-cloud-default", "tool-tiles", "tool-tiles-collapsed", "shell-running", @@ -153,8 +157,8 @@ mod tests { #[test] fn lock_v2_id_counts_and_unique() { - assert_eq!(LOCK_V2_WIDE_IDS.len(), 87); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 41); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 89); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 43); let mut wide = HashSet::new(); for id in LOCK_V2_WIDE_IDS { assert!(wide.insert(*id), "duplicate wide id {id}"); @@ -167,5 +171,7 @@ mod tests { "narrow id {id} is not in LOCK_V2_WIDE_IDS" ); } + assert!(wide.contains("computer-disconnected")); + assert!(wide.contains("computer-cloud-default")); } } diff --git a/src/cortex-tui/src/ui/consts.rs b/src/cortex-tui/src/ui/consts.rs index 659f690f..3d98eab0 100644 --- a/src/cortex-tui/src/ui/consts.rs +++ b/src/cortex-tui/src/ui/consts.rs @@ -59,6 +59,13 @@ pub const PLACEHOLDER_RATE_LIMIT: &str = "Add a follow-up — held until the rat /// Narrow (40-column) form of the rate-limit placeholder. pub const PLACEHOLDER_RATE_LIMIT_NARROW: &str = "Held until the rate limit resets"; +/// This PC / SSH fail-closed title painted in error red (`× This PC disconnected`). +pub const DISCONNECTED_TITLE: &str = "This PC disconnected"; +/// Composer placeholder while This PC/SSH is fail-closed (no host session). +pub const PLACEHOLDER_DISCONNECTED: &str = "Connect a host — or unset CORTEX_COMPUTER for Cloud"; +/// Narrow (40-column) form of the disconnected placeholder. +pub const PLACEHOLDER_DISCONNECTED_NARROW: &str = "Connect a host — or unset for Cloud"; + /// Sandbox deny title painted in error red. pub const SANDBOX_DENIED_TITLE: &str = "Sandbox denied"; diff --git a/src/cortex-tui/src/views/minimal_session/rendering.rs b/src/cortex-tui/src/views/minimal_session/rendering.rs index adacb9fb..5c007839 100644 --- a/src/cortex-tui/src/views/minimal_session/rendering.rs +++ b/src/cortex-tui/src/views/minimal_session/rendering.rs @@ -1051,7 +1051,6 @@ pub fn render_update_banner( let x = area.x + 2; let y = area.y; - // Ensure we don't overflow if x + banner_width > area.right() { return; } diff --git a/src/cortex-tui/src/views/minimal_session/text_utils.rs b/src/cortex-tui/src/views/minimal_session/text_utils.rs index de821f16..22c4eafc 100644 --- a/src/cortex-tui/src/views/minimal_session/text_utils.rs +++ b/src/cortex-tui/src/views/minimal_session/text_utils.rs @@ -14,6 +14,7 @@ pub(super) fn system_message_is_error(content: &str) -> bool { "Invalid", "You're offline", "Rate limited", + "This PC disconnected", "context limit", "Stopped", "quota exhausted", @@ -174,6 +175,7 @@ mod tests { fn lock_red_system_copy_includes_offline_and_rate_limit() { assert!(system_message_is_error("× You're offline")); assert!(system_message_is_error("× Rate limited")); + assert!(system_message_is_error("× This PC disconnected")); assert!(system_message_is_error( "The coding service is temporarily unavailable" )); diff --git a/src/cortex-tui/src/views/minimal_session/view.rs b/src/cortex-tui/src/views/minimal_session/view.rs index 6096f7e4..1fa5695f 100644 --- a/src/cortex-tui/src/views/minimal_session/view.rs +++ b/src/cortex-tui/src/views/minimal_session/view.rs @@ -3,7 +3,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Modifier, Style}; -use ratatui::text::Line; +use ratatui::text::{Line, Span}; use ratatui::widgets::{Paragraph, Widget}; use super::rendering::{ @@ -162,6 +162,9 @@ impl<'a> MinimalSessionView<'a> { let mut all_lines: Vec> = Vec::new(); all_lines.extend(generate_welcome_lines(width, &self.colors, self.app_state)); + if self.app_state.show_computer_default { + all_lines.extend(computer_default_info_lines(width, &self.colors)); + } let message_lines = generate_message_lines(width, &self.colors, self.app_state); if !message_lines.is_empty() { @@ -261,7 +264,8 @@ impl<'a> MinimalSessionView<'a> { || self.app_state.has_pending_approval(); let focused = self.app_state.settings_modal.is_none() && !self.app_state.shortcuts_open - && !prompt_owns_focus; + && !prompt_owns_focus + && !self.app_state.computer_held; let effort = self .app_state .thinking_budget @@ -319,6 +323,12 @@ impl<'a> MinimalSessionView<'a> { } } else if self.app_state.quota_held { crate::ui::consts::PLACEHOLDER_QUOTA + } else if self.app_state.computer_held { + if area.width < 80 { + crate::ui::consts::PLACEHOLDER_DISCONNECTED_NARROW + } else { + crate::ui::consts::PLACEHOLDER_DISCONNECTED + } } else if self.is_task_running() { PLACEHOLDER_RUNNING } else if self.app_state.agent_entrypoint { @@ -731,7 +741,8 @@ pub const PALETTE_FOOTER_HINT_SHORT: &str = "Enter:send | Ctrl+x:shortcuts"; impl<'a> MinimalSessionView<'a> { fn footer_set(&self, is_task_running: bool, width: u16) -> FooterSet { - if self.app_state.offline_held || self.app_state.quota_held { + if self.app_state.offline_held || self.app_state.quota_held || self.app_state.computer_held + { return FooterSet::Unavailable; } if self.app_state.rate_limit_held { @@ -863,6 +874,30 @@ fn composer_has_completed_file_chip(text: &str) -> bool { false } +/// Welcome info-card rows that make Computer · Cloud lock-evident. +fn computer_default_info_lines(width: u16, colors: &AdaptiveColors) -> Vec> { + let dim = Style::default().fg(colors.text_dim); + let text = Style::default().fg(colors.text); + let indent = if width < 20 { 0 } else { 3 }; + let pad = " ".repeat(indent as usize); + let row = |label: &str, value: &str| { + Line::from(vec![ + Span::raw(pad.clone()), + Span::styled(format!("{label:<10}"), dim), + Span::styled(value.to_string(), text), + ]) + }; + let cloud = cortex_engine::client::ComputerKind::Cloud.label(); + let mut lines = vec![Line::from("")]; + if width >= 80 { + lines.push(row("Directory", "~/cortex")); + lines.push(row("Org", "Personal")); + lines.push(row("Plan", "Pro")); + } + lines.push(row("Computer", cloud)); + lines +} + fn composer_display_text(state: &AppState) -> String { if let Some(istate) = state.get_interactive_state() { let title = istate.title.to_ascii_lowercase();