diff --git a/design/cli-lock-board-index.md b/design/cli-lock-board-index.md index 18877f75..217aee29 100644 --- a/design/cli-lock-board-index.md +++ b/design/cli-lock-board-index.md @@ -18,13 +18,12 @@ Verifier tools are the hidden `cortex mcp-server --verify` surface `lock.palette_audit`, `lock.diff_txt`, `tui.*`, `login.run`, `api.*`, `mcp.*`, `report.finish`. Guide: [Verification MCP](../docs/guides/development.md). -`tui.key` (`verify_mcp/tui.rs` `apply_key`) only applies composer **Clear**, -**NewLine** (`Shift+Enter` in the production mapper), **Backspace**, and -**single-character insert**. `F2`, `Shift+Tab`, `Alt+Enter`, and `Ctrl+x` -parse as keys and return success but are no-ops: they do not open Settings -or Shortcuts, cycle mode, or insert a newline. Designer lock copy still -shows those chords; cover those boards with `lock.render`, -`cargo test -p cortex-tui`, or `tui.type` / `tui.slash`, not `tui.key`. +`tui.key` (`verify_mcp/tui.rs` `apply_key`) applies composer **Clear**, +**NewLine** (`Shift+Enter` and `Alt+Enter`), **Backspace**, **single-character +insert**, **Ctrl+x** / **Esc** / **Up** / **Down** on the shortcuts sheet, +**F2** (settings), **Shift+Tab** (Agent / Plan / Ask), and slash-palette +**Up** / **Down**. Cover remaining boards with `lock.render` or +`cargo test -p cortex-tui`. **Flow** in the tables is a named interactive scenario on that verify server (or the matching `cargo test -p cortex-tui` case), not a separate spec: diff --git a/docs/media/tui-lock-v2/runtime/120x40/clear-confirm.png b/docs/media/tui-lock-v2/runtime/120x40/clear-confirm.png index d78bad90..d6246c3a 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/clear-confirm.png and b/docs/media/tui-lock-v2/runtime/120x40/clear-confirm.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/cloud-handoff.png b/docs/media/tui-lock-v2/runtime/120x40/cloud-handoff.png index 5f108040..9c761461 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/cloud-handoff.png and b/docs/media/tui-lock-v2/runtime/120x40/cloud-handoff.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/code-fence.png b/docs/media/tui-lock-v2/runtime/120x40/code-fence.png index 832f2f17..35e947d8 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/code-fence.png and b/docs/media/tui-lock-v2/runtime/120x40/code-fence.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/composer-multiline.png b/docs/media/tui-lock-v2/runtime/120x40/composer-multiline.png index 4cc30787..f6365ce0 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/composer-multiline.png and b/docs/media/tui-lock-v2/runtime/120x40/composer-multiline.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/composer-typing-blink.png b/docs/media/tui-lock-v2/runtime/120x40/composer-typing-blink.png index 19ccb969..917b0be1 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/composer-typing-blink.png and b/docs/media/tui-lock-v2/runtime/120x40/composer-typing-blink.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/composer-typing.png b/docs/media/tui-lock-v2/runtime/120x40/composer-typing.png index 22c297f2..e568d26c 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/composer-typing.png and b/docs/media/tui-lock-v2/runtime/120x40/composer-typing.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/diff-hunk.png b/docs/media/tui-lock-v2/runtime/120x40/diff-hunk.png index 1d035994..dd54149e 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/diff-hunk.png and b/docs/media/tui-lock-v2/runtime/120x40/diff-hunk.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/first-run-tips.png b/docs/media/tui-lock-v2/runtime/120x40/first-run-tips.png index a72fd8f4..f5f9de8c 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/first-run-tips.png and b/docs/media/tui-lock-v2/runtime/120x40/first-run-tips.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/goal-chip-active.png b/docs/media/tui-lock-v2/runtime/120x40/goal-chip-active.png index 45693059..734db972 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/goal-chip-active.png and b/docs/media/tui-lock-v2/runtime/120x40/goal-chip-active.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/goal-chip-blocked.png b/docs/media/tui-lock-v2/runtime/120x40/goal-chip-blocked.png index c4b8badd..38f4b3ce 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/goal-chip-blocked.png and b/docs/media/tui-lock-v2/runtime/120x40/goal-chip-blocked.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/goal-chip-budget.png b/docs/media/tui-lock-v2/runtime/120x40/goal-chip-budget.png index 2d7f3528..35a0cb5c 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/goal-chip-budget.png and b/docs/media/tui-lock-v2/runtime/120x40/goal-chip-budget.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/goal-chip-done.png b/docs/media/tui-lock-v2/runtime/120x40/goal-chip-done.png index 3b75ce90..9e636d52 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/goal-chip-done.png and b/docs/media/tui-lock-v2/runtime/120x40/goal-chip-done.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/goal-chip-paused.png b/docs/media/tui-lock-v2/runtime/120x40/goal-chip-paused.png index fa5cf8e7..5b5f8485 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/goal-chip-paused.png and b/docs/media/tui-lock-v2/runtime/120x40/goal-chip-paused.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/interrupt-stopped.png b/docs/media/tui-lock-v2/runtime/120x40/interrupt-stopped.png index f28135d0..38100bfe 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/interrupt-stopped.png and b/docs/media/tui-lock-v2/runtime/120x40/interrupt-stopped.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/mcp-drop.png b/docs/media/tui-lock-v2/runtime/120x40/mcp-drop.png index d70889a6..6db57035 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/mcp-drop.png and b/docs/media/tui-lock-v2/runtime/120x40/mcp-drop.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/mcp-servers.png b/docs/media/tui-lock-v2/runtime/120x40/mcp-servers.png index 032cdfaa..916c79e4 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/mcp-servers.png and b/docs/media/tui-lock-v2/runtime/120x40/mcp-servers.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/md-table.png b/docs/media/tui-lock-v2/runtime/120x40/md-table.png index 04e62057..a35440c5 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/md-table.png and b/docs/media/tui-lock-v2/runtime/120x40/md-table.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/mode-agent.png b/docs/media/tui-lock-v2/runtime/120x40/mode-agent.png index 66637c81..b852edf4 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/mode-agent.png and b/docs/media/tui-lock-v2/runtime/120x40/mode-agent.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/mode-ask.png b/docs/media/tui-lock-v2/runtime/120x40/mode-ask.png index a9bd6d71..992a02e7 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/mode-ask.png and b/docs/media/tui-lock-v2/runtime/120x40/mode-ask.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/mode-plan.png b/docs/media/tui-lock-v2/runtime/120x40/mode-plan.png index 8ab0688f..fd543684 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/mode-plan.png and b/docs/media/tui-lock-v2/runtime/120x40/mode-plan.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/model-effort-high.png b/docs/media/tui-lock-v2/runtime/120x40/model-effort-high.png index 3173d633..46e3945c 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/model-effort-high.png and b/docs/media/tui-lock-v2/runtime/120x40/model-effort-high.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/model-effort-hover.png b/docs/media/tui-lock-v2/runtime/120x40/model-effort-hover.png index f2555bcc..03962333 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/model-effort-hover.png and b/docs/media/tui-lock-v2/runtime/120x40/model-effort-hover.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/model-effort-low.png b/docs/media/tui-lock-v2/runtime/120x40/model-effort-low.png index a5be2593..7e13dee7 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/model-effort-low.png and b/docs/media/tui-lock-v2/runtime/120x40/model-effort-low.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/model-effort-medium.png b/docs/media/tui-lock-v2/runtime/120x40/model-effort-medium.png index aefe5149..6bbb4d06 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/model-effort-medium.png and b/docs/media/tui-lock-v2/runtime/120x40/model-effort-medium.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/model-list-hover.png b/docs/media/tui-lock-v2/runtime/120x40/model-list-hover.png index ae18a5f3..429160f4 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/model-list-hover.png and b/docs/media/tui-lock-v2/runtime/120x40/model-list-hover.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/model-list.png b/docs/media/tui-lock-v2/runtime/120x40/model-list.png index a933271b..49a2fef6 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/model-list.png and b/docs/media/tui-lock-v2/runtime/120x40/model-list.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/permission-prompt-hover.png b/docs/media/tui-lock-v2/runtime/120x40/permission-prompt-hover.png index e4ea44e6..2962c1ea 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/permission-prompt-hover.png and b/docs/media/tui-lock-v2/runtime/120x40/permission-prompt-hover.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/permission-prompt.png b/docs/media/tui-lock-v2/runtime/120x40/permission-prompt.png index bea2d6d6..d73058c9 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/permission-prompt.png and b/docs/media/tui-lock-v2/runtime/120x40/permission-prompt.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/permissions-picker.png b/docs/media/tui-lock-v2/runtime/120x40/permissions-picker.png index 7d988cee..bc321195 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/permissions-picker.png and b/docs/media/tui-lock-v2/runtime/120x40/permissions-picker.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/plan-confirm.png b/docs/media/tui-lock-v2/runtime/120x40/plan-confirm.png index d8032c95..78a13e0d 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/plan-confirm.png and b/docs/media/tui-lock-v2/runtime/120x40/plan-confirm.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/question.png b/docs/media/tui-lock-v2/runtime/120x40/question.png index 049625cc..cb4639e3 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/question.png and b/docs/media/tui-lock-v2/runtime/120x40/question.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/resume-picker.png b/docs/media/tui-lock-v2/runtime/120x40/resume-picker.png index 63e5fc59..13ac91bd 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/resume-picker.png and b/docs/media/tui-lock-v2/runtime/120x40/resume-picker.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/sandbox-deny.png b/docs/media/tui-lock-v2/runtime/120x40/sandbox-deny.png index 76e47c4d..717edc64 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/sandbox-deny.png and b/docs/media/tui-lock-v2/runtime/120x40/sandbox-deny.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/shortcuts-overlay.png b/docs/media/tui-lock-v2/runtime/120x40/shortcuts-overlay.png index 48e6c640..457a56ec 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/shortcuts-overlay.png and b/docs/media/tui-lock-v2/runtime/120x40/shortcuts-overlay.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/slash-palette.png b/docs/media/tui-lock-v2/runtime/120x40/slash-palette.png index 1c15b3fb..0aa4e046 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/slash-palette.png and b/docs/media/tui-lock-v2/runtime/120x40/slash-palette.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/todos.png b/docs/media/tui-lock-v2/runtime/120x40/todos.png index 30956137..1b644aa1 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/todos.png and b/docs/media/tui-lock-v2/runtime/120x40/todos.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/tokens-topright.png b/docs/media/tui-lock-v2/runtime/120x40/tokens-topright.png index 7166c34e..46c125b3 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/tokens-topright.png and b/docs/media/tui-lock-v2/runtime/120x40/tokens-topright.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/tool-tiles-collapsed.png b/docs/media/tui-lock-v2/runtime/120x40/tool-tiles-collapsed.png index 8bdfa2a0..f3c489e8 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/tool-tiles-collapsed.png and b/docs/media/tui-lock-v2/runtime/120x40/tool-tiles-collapsed.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/tool-tiles.png b/docs/media/tui-lock-v2/runtime/120x40/tool-tiles.png index e94f79a0..835f17ea 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/tool-tiles.png and b/docs/media/tui-lock-v2/runtime/120x40/tool-tiles.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/welcome-agent.png b/docs/media/tui-lock-v2/runtime/120x40/welcome-agent.png index 031e007e..efb3147b 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/welcome-agent.png and b/docs/media/tui-lock-v2/runtime/120x40/welcome-agent.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/welcome-cortex.png b/docs/media/tui-lock-v2/runtime/120x40/welcome-cortex.png index e01678b0..94eb6ab0 100644 Binary files a/docs/media/tui-lock-v2/runtime/120x40/welcome-cortex.png and b/docs/media/tui-lock-v2/runtime/120x40/welcome-cortex.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/composer-typing.png b/docs/media/tui-lock-v2/runtime/40x12/composer-typing.png index 3b975e9a..6f453d4c 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/composer-typing.png and b/docs/media/tui-lock-v2/runtime/40x12/composer-typing.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/diff-hunk.png b/docs/media/tui-lock-v2/runtime/40x12/diff-hunk.png index d3868918..7e8015f1 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/diff-hunk.png and b/docs/media/tui-lock-v2/runtime/40x12/diff-hunk.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/goal-chip-active.png b/docs/media/tui-lock-v2/runtime/40x12/goal-chip-active.png index 1424b151..467bc766 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/goal-chip-active.png and b/docs/media/tui-lock-v2/runtime/40x12/goal-chip-active.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/goal-chip-blocked.png b/docs/media/tui-lock-v2/runtime/40x12/goal-chip-blocked.png index 193ba741..bd5df93c 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/goal-chip-blocked.png and b/docs/media/tui-lock-v2/runtime/40x12/goal-chip-blocked.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/goal-chip-budget.png b/docs/media/tui-lock-v2/runtime/40x12/goal-chip-budget.png index ea6abf9d..1a406fb0 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/goal-chip-budget.png and b/docs/media/tui-lock-v2/runtime/40x12/goal-chip-budget.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/goal-chip-done.png b/docs/media/tui-lock-v2/runtime/40x12/goal-chip-done.png index 3d9232b2..fdb52879 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/goal-chip-done.png and b/docs/media/tui-lock-v2/runtime/40x12/goal-chip-done.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/goal-chip-paused.png b/docs/media/tui-lock-v2/runtime/40x12/goal-chip-paused.png index be24d56e..3e539123 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/goal-chip-paused.png and b/docs/media/tui-lock-v2/runtime/40x12/goal-chip-paused.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/interrupt-stopped.png b/docs/media/tui-lock-v2/runtime/40x12/interrupt-stopped.png index 047828d0..cbbf82e9 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/interrupt-stopped.png and b/docs/media/tui-lock-v2/runtime/40x12/interrupt-stopped.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/mcp-servers.png b/docs/media/tui-lock-v2/runtime/40x12/mcp-servers.png index 1ea01e40..7c566e13 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/mcp-servers.png and b/docs/media/tui-lock-v2/runtime/40x12/mcp-servers.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/mode-ask.png b/docs/media/tui-lock-v2/runtime/40x12/mode-ask.png index 912dff41..e37b6456 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/mode-ask.png and b/docs/media/tui-lock-v2/runtime/40x12/mode-ask.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/mode-plan.png b/docs/media/tui-lock-v2/runtime/40x12/mode-plan.png index 3a2e2f73..b99256b8 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/mode-plan.png and b/docs/media/tui-lock-v2/runtime/40x12/mode-plan.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/model-effort-high.png b/docs/media/tui-lock-v2/runtime/40x12/model-effort-high.png index 66ec8f73..92970fe4 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/model-effort-high.png and b/docs/media/tui-lock-v2/runtime/40x12/model-effort-high.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/model-list.png b/docs/media/tui-lock-v2/runtime/40x12/model-list.png index 7fbf4509..2dd2037b 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/model-list.png and b/docs/media/tui-lock-v2/runtime/40x12/model-list.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/permission-prompt.png b/docs/media/tui-lock-v2/runtime/40x12/permission-prompt.png index 4519e44f..db9414da 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/permission-prompt.png and b/docs/media/tui-lock-v2/runtime/40x12/permission-prompt.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/shortcuts-overlay.png b/docs/media/tui-lock-v2/runtime/40x12/shortcuts-overlay.png index c568037e..efa53921 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/shortcuts-overlay.png and b/docs/media/tui-lock-v2/runtime/40x12/shortcuts-overlay.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/slash-palette.png b/docs/media/tui-lock-v2/runtime/40x12/slash-palette.png index 8f1d046b..ca3a92a2 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/slash-palette.png and b/docs/media/tui-lock-v2/runtime/40x12/slash-palette.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/welcome-agent.png b/docs/media/tui-lock-v2/runtime/40x12/welcome-agent.png index 6f369dcc..8bbf2fb7 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/welcome-agent.png and b/docs/media/tui-lock-v2/runtime/40x12/welcome-agent.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/welcome-cortex.png b/docs/media/tui-lock-v2/runtime/40x12/welcome-cortex.png index 08b1ec18..df2bd313 100644 Binary files a/docs/media/tui-lock-v2/runtime/40x12/welcome-cortex.png and b/docs/media/tui-lock-v2/runtime/40x12/welcome-cortex.png differ diff --git a/docs/reference/keyboard.md b/docs/reference/keyboard.md index 47821da5..d2c4fd00 100644 --- a/docs/reference/keyboard.md +++ b/docs/reference/keyboard.md @@ -12,9 +12,10 @@ or `F1` at any time for the in-app version. | `Ctrl+Shift+C` | Copy the selection | | `Ctrl+Shift+V` | Paste | | `?` or `F1` | Help | -| `Esc` | Cancel or close | +| `Esc` | Cancel, close a sheet, or interrupt the running turn | | `Tab` | Focus the next element | -| `Shift+Tab` | Cycle the autonomy level | +| `Shift+Tab` | Cycle Agent / Plan / Ask | +| `F2` | Open settings | | `Ctrl+K` or `Ctrl+P` | Command palette | | `Ctrl+I` | Focus the composer | | `Ctrl+B` | Toggle the sidebar | @@ -29,8 +30,10 @@ or `F1` at any time for the in-app version. | Key | Action | |-----|--------| | `Enter` | Send | -| `Shift+Enter` | Insert a newline | -| `Up` / `Down` | Previous / next prompt in history | +| `Shift+Enter` or `Alt+Enter` | Insert a newline | +| `Ctrl+x` | Open or close the shortcuts sheet | +| `Up` / `Down` | Previous / next prompt in history; move the slash-palette selection | +| `Ctrl+U` or `Ctrl+L` | Clear the composer | | `Ctrl+U` or `Ctrl+L` | Clear the composer | | `Ctrl+V` | Paste | | `Ctrl+A` | Select all | @@ -94,8 +97,9 @@ Shown at startup when there are sessions to resume. | Key | Action | |-----|--------| | `Enter` | Resume the selected session | -| `F` | Fork from it | -| `N` or `Esc` | Start a new session | +| `f` | Favorite it | +| `d` | Delete it | +| `Esc` | Close | ## Modals @@ -113,7 +117,8 @@ reverts to the one you started with. | Key | Action | |-----|--------| | `Esc` | Interrupt the turn | -| `Ctrl+C` | Force quit | +| `Ctrl+C` | Interrupt the turn. When idle: copy the selection, or press twice to quit | +| `Ctrl+x` | Open or close the shortcuts sheet | ## See also diff --git a/docs/reference/slash-commands.md b/docs/reference/slash-commands.md index d7131e7c..d5e55009 100644 --- a/docs/reference/slash-commands.md +++ b/docs/reference/slash-commands.md @@ -44,6 +44,7 @@ ordinary message. | `/install-github-app` | | Install the Cortex GitHub App | | `/bug` | | `/bug [description]` | | `/goal` | | `/goal [objective\|status\|pause\|resume\|clear]` — persisted long-horizon objective | +| `/shortcuts` | `keys` | Open the shortcuts sheet (also `Ctrl+x`) | ## Authentication and billing diff --git a/src/cortex-cli/src/verify_mcp/tui.rs b/src/cortex-cli/src/verify_mcp/tui.rs index 12a7f4a6..5ae07a74 100644 --- a/src/cortex-cli/src/verify_mcp/tui.rs +++ b/src/cortex-cli/src/verify_mcp/tui.rs @@ -307,11 +307,48 @@ pub fn stop(state: &mut VerifyState, args: &Value) -> Result { } fn apply_key(session: &mut TuiSession, name: &str) -> Result<()> { + if name.eq_ignore_ascii_case("Ctrl+x") { + session.app_state.toggle_shortcuts_sheet(); + return Ok(()); + } + if session.app_state.shortcuts_open { + if name.eq_ignore_ascii_case("Esc") || name.eq_ignore_ascii_case("F2") { + session.app_state.close_shortcuts_sheet(); + return Ok(()); + } + if name.eq_ignore_ascii_case("Down") { + session.app_state.shortcuts_move(1); + return Ok(()); + } + if name.eq_ignore_ascii_case("Up") { + session.app_state.shortcuts_move(-1); + return Ok(()); + } + } + if session.app_state.autocomplete.visible { + if name.eq_ignore_ascii_case("Down") { + session.app_state.autocomplete.select_next(); + return Ok(()); + } + if name.eq_ignore_ascii_case("Up") { + session.app_state.autocomplete.select_prev(); + return Ok(()); + } + } let event = parse_key_string(name).ok_or_else(|| anyhow::anyhow!("unknown key {name}"))?; let action = session.mapper.get_action(event, ActionContext::Input); match action { KeyAction::Clear => session.app_state.input.set_text(""), KeyAction::NewLine => session.app_state.input.insert_str("\n"), + KeyAction::CyclePermissionMode => session.app_state.cycle_agent_mode(), + KeyAction::ToggleSettings => session.app_state.open_settings_modal(), + KeyAction::Cancel => { + if session.app_state.streaming.is_streaming { + session.app_state.stop_streaming(); + } else if session.app_state.is_interactive_mode() { + session.app_state.exit_interactive_mode(); + } + } _ if name.eq_ignore_ascii_case("Backspace") => { let text = session.app_state.input.text(); let mut chars: Vec = text.chars().collect(); @@ -430,6 +467,63 @@ mod tests { assert!(state.sessions.is_empty()); } + #[test] + fn keyboard_map_ctrl_x_shift_tab_palette_and_cancel() { + let (mut state, id) = started("cortex"); + key(&mut state, &json!({"session_id": id, "keys": ["Ctrl+x"]})).expect("open sheet"); + assert!(session_ref(&state, &id).unwrap().app_state.shortcuts_open); + key( + &mut state, + &json!({"session_id": id, "keys": ["Down", "Up", "F2"]}), + ) + .expect("sheet nav"); + assert!(!session_ref(&state, &id).unwrap().app_state.shortcuts_open); + + key( + &mut state, + &json!({"session_id": id, "keys": ["Ctrl+x", "Esc"]}), + ) + .expect("esc sheet"); + assert!(!session_ref(&state, &id).unwrap().app_state.shortcuts_open); + + slash(&mut state, &json!({"session_id": id, "query": "/"})).expect("slash"); + assert!( + session_ref(&state, &id) + .unwrap() + .app_state + .autocomplete + .visible + ); + key( + &mut state, + &json!({"session_id": id, "keys": ["Down", "Up"]}), + ) + .expect("palette"); + + key( + &mut state, + &json!({"session_id": id, "keys": ["Shift+Tab", "F2"]}), + ) + .expect("mode and settings"); + assert!( + session_ref(&state, &id) + .unwrap() + .app_state + .settings_modal + .is_some() + ); + + let session = session_mut(&mut state, &id).unwrap(); + session.app_state.start_streaming(None, true); + apply_key(session, "Esc").expect("interrupt"); + assert!(!session.app_state.streaming.is_streaming); + + let picker = cortex_tui::interactive::builders::build_clear_confirm(); + session.app_state.enter_interactive_mode(picker); + apply_key(session, "Esc").expect("close picker"); + assert!(!session.app_state.is_interactive_mode()); + } + #[test] fn agent_entry_and_error_paths() { let (mut state, id) = started("agent"); diff --git a/src/cortex-engine/src/goal/types.rs b/src/cortex-engine/src/goal/types.rs index 0f79d6f6..00710ffb 100644 --- a/src/cortex-engine/src/goal/types.rs +++ b/src/cortex-engine/src/goal/types.rs @@ -254,9 +254,19 @@ impl Goal { ) } - /// Pause / resume / set acknowledgement that repeats the chip. + /// Pause / resume / set acknowledgement that repeats the chip and next step. pub fn action_text(&self, action: &str) -> String { - format!("{action} {}", self.chip()) + format!("{action} {}\n next: {}", self.chip(), self.next_step()) + } + + /// Compact TUI card for `/goal` and `/goal status`. + pub fn status_card(&self) -> String { + format!( + "{}\n {}\n next: {}\n /goal pause · /goal resume · /goal clear", + self.chip(), + self.objective, + self.next_step() + ) } pub fn turns_remaining(&self) -> u32 { @@ -325,6 +335,18 @@ mod tests { assert!(text.starts_with("Goal · 7/8"), "{text}"); assert!(text.contains("wrap-up"), "{text}"); assert!(text.contains("write out.txt"), "{text}"); + let card = goal.status_card(); + assert!(card.contains("Goal · 7/8"), "{card}"); + assert!(card.contains("/goal pause"), "{card}"); + assert!(card.contains("/goal resume"), "{card}"); + assert!(card.contains("/goal clear"), "{card}"); + let paused = { + let mut g = goal.clone(); + g.state = GoalState::Paused; + g.action_text("Goal paused.") + }; + assert!(paused.contains("Goal · paused"), "{paused}"); + assert!(paused.contains("next:"), "{paused}"); } #[test] diff --git a/src/cortex-tui/src/actions/mapper.rs b/src/cortex-tui/src/actions/mapper.rs index 2702d5c1..3cbbeca6 100644 --- a/src/cortex-tui/src/actions/mapper.rs +++ b/src/cortex-tui/src/actions/mapper.rs @@ -69,6 +69,21 @@ impl ActionMapper { KeyAction::CyclePermissionMode, "Cycle Agent / Plan / Ask", ), + KeyBinding::global( + KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT), + KeyAction::CyclePermissionMode, + "Cycle Agent / Plan / Ask", + ), + KeyBinding::global( + KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE), + KeyAction::CyclePermissionMode, + "Cycle Agent / Plan / Ask", + ), + KeyBinding::global( + KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE), + KeyAction::ToggleSettings, + "Open settings", + ), // Sidebar toggle (Ctrl+B only) KeyBinding::global( KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL), @@ -159,12 +174,17 @@ impl ActionMapper { KeyAction::Submit, "Submit message", ), - // New line (Shift+Enter) + // New line (Shift+Enter or Alt+Enter — lock footer) KeyBinding::input( KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT), KeyAction::NewLine, "Insert new line", ), + KeyBinding::input( + KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT), + KeyAction::NewLine, + "Insert new line", + ), // History navigation KeyBinding::input( KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), @@ -552,6 +572,27 @@ mod tests { KeyAction::Submit ); + let alt_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT); + assert_eq!( + mapper.get_action(alt_enter, ActionContext::Input), + KeyAction::NewLine + ); + let shift_enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT); + assert_eq!( + mapper.get_action(shift_enter, ActionContext::Input), + KeyAction::NewLine + ); + let shift_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); + assert_eq!( + mapper.get_action(shift_tab, ActionContext::Global), + KeyAction::CyclePermissionMode + ); + let f2 = KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE); + assert_eq!( + mapper.get_action(f2, ActionContext::Global), + KeyAction::ToggleSettings + ); + // Test global fallback let help_key = KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE); assert_eq!( diff --git a/src/cortex-tui/src/app/checklist.rs b/src/cortex-tui/src/app/checklist.rs new file mode 100644 index 00000000..a3be9e94 --- /dev/null +++ b/src/cortex-tui/src/app/checklist.rs @@ -0,0 +1,72 @@ +//! Session working checklist (lock `todos`). + +use super::subagent::{SubagentTodoItem, SubagentTodoStatus}; + +/// Live turn checklist painted above the composer — distinct from subagent tiles. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkingChecklist { + pub elapsed_secs: u32, + pub tokens: u64, + pub items: Vec, +} + +impl WorkingChecklist { + pub fn new(items: Vec, elapsed_secs: u32, tokens: u64) -> Self { + Self { + elapsed_secs, + tokens, + items, + } + } + + pub fn done(&self) -> usize { + self.items + .iter() + .filter(|t| matches!(t.status, SubagentTodoStatus::Completed)) + .count() + } + + pub fn header(&self) -> String { + format!( + "⠇ Working {}/{} · {}s · {}", + self.done(), + self.items.len(), + self.elapsed_secs, + compact_tokens(self.tokens) + ) + } +} + +fn compact_tokens(n: u64) -> String { + if n >= 1_000 { + let k = n as f64 / 1_000.0; + if (k - k.round()).abs() < 0.05 { + format!("{}k tokens", k as u64) + } else { + format!("{k:.1}k tokens") + } + } else { + format!("{n} tokens") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_matches_lock_copy() { + let board = WorkingChecklist::new( + vec![ + SubagentTodoItem::new("a", SubagentTodoStatus::Completed), + SubagentTodoItem::new("b", SubagentTodoStatus::Completed), + SubagentTodoItem::new("c", SubagentTodoStatus::InProgress), + SubagentTodoItem::new("d", SubagentTodoStatus::Pending), + SubagentTodoItem::new("e", SubagentTodoStatus::Pending), + ], + 38, + 6_100, + ); + assert_eq!(board.header(), "⠇ Working 2/5 · 38s · 6.1k tokens"); + } +} diff --git a/src/cortex-tui/src/app/methods.rs b/src/cortex-tui/src/app/methods.rs index d2f408c3..90e48491 100644 --- a/src/cortex-tui/src/app/methods.rs +++ b/src/cortex-tui/src/app/methods.rs @@ -850,4 +850,15 @@ mod agent_mode_tests { assert!(!state.has_pending_approval()); assert!(!state.is_interactive_mode()); } + + #[test] + fn clear_and_new_session_drop_stopped_placeholder() { + let mut state = AppState::default(); + state.last_turn_stopped = true; + state.clear_messages(); + assert!(!state.last_turn_stopped); + state.last_turn_stopped = true; + state.new_session(); + assert!(!state.last_turn_stopped); + } } diff --git a/src/cortex-tui/src/app/mod.rs b/src/cortex-tui/src/app/mod.rs index afe99f92..16e46f9b 100644 --- a/src/cortex-tui/src/app/mod.rs +++ b/src/cortex-tui/src/app/mod.rs @@ -4,8 +4,10 @@ mod approval; mod autocomplete; +mod checklist; mod methods; mod session; +mod sheet; mod state; mod streaming; mod subagent; @@ -15,6 +17,7 @@ mod update_status; // Re-export all public types pub use approval::{ApprovalState, PendingToolResult}; pub use autocomplete::{AutocompleteItem, AutocompleteState}; +pub use checklist::WorkingChecklist; pub use session::{ActiveModal, SessionSummary}; pub use state::AppState; pub use streaming::StreamingState; diff --git a/src/cortex-tui/src/app/sheet.rs b/src/cortex-tui/src/app/sheet.rs new file mode 100644 index 00000000..183dbd9a --- /dev/null +++ b/src/cortex-tui/src/app/sheet.rs @@ -0,0 +1,97 @@ +//! `/shortcuts` sheet open / navigate / dismiss. + +use ratatui::layout::Rect; + +use crate::widgets::ShortcutsOverlay; + +use super::state::AppState; + +impl AppState { + pub fn open_shortcuts_sheet(&mut self) { + self.settings_modal = None; + self.shortcuts_open = true; + self.shortcuts_selected = 0; + self.shortcuts_hovered = None; + } + + pub fn close_shortcuts_sheet(&mut self) { + self.shortcuts_open = false; + self.shortcuts_hovered = None; + } + + pub fn toggle_shortcuts_sheet(&mut self) { + if self.shortcuts_open { + self.close_shortcuts_sheet(); + } else { + self.open_shortcuts_sheet(); + } + } + + pub fn shortcuts_move(&mut self, delta: i32) { + let (w, h) = self.terminal_size; + let area = Rect::new(0, 0, w.max(40), h.max(12)); + let n = ShortcutsOverlay::row_count(area).max(1) as i32; + let next = (self.shortcuts_selected as i32 + delta).rem_euclid(n); + self.shortcuts_selected = next as usize; + } + + pub fn shortcuts_hover_at(&mut self, x: u16, y: u16) { + let (w, h) = self.terminal_size; + let area = Rect::new(0, 0, w.max(1), h.max(1)); + self.shortcuts_hovered = ShortcutsOverlay::row_at(area, x, y); + } + + pub fn shortcuts_close_hit(&self, x: u16, y: u16) -> bool { + let (w, h) = self.terminal_size; + let area = Rect::new(0, 0, w.max(1), h.max(1)); + ShortcutsOverlay::close_hit(area, x, y) + } + + pub fn shortcuts_select_at(&mut self, x: u16, y: u16) -> bool { + let (w, h) = self.terminal_size; + let area = Rect::new(0, 0, w.max(1), h.max(1)); + if let Some(idx) = ShortcutsOverlay::row_at(area, x, y) { + self.shortcuts_selected = idx; + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_navigate_dismiss() { + let mut state = AppState::default(); + state.terminal_size = (120, 40); + state.toggle_shortcuts_sheet(); + assert!(state.shortcuts_open); + assert_eq!(state.shortcuts_selected, 0); + state.shortcuts_move(1); + assert_eq!(state.shortcuts_selected, 1); + state.shortcuts_move(-1); + assert_eq!(state.shortcuts_selected, 0); + state.toggle_shortcuts_sheet(); + assert!(!state.shortcuts_open); + } + + #[test] + fn compact_navigation_stays_on_painted_rows() { + let mut state = AppState::default(); + state.terminal_size = (40, 12); + state.toggle_shortcuts_sheet(); + let area = Rect::new(0, 0, 40, 12); + let visible = ShortcutsOverlay::row_count(area); + for _ in 0..(visible + 3) { + state.shortcuts_move(1); + } + assert!(state.shortcuts_selected < visible); + assert!( + ShortcutsOverlay::row_at(area, 0, area.y + 2).is_none(), + "margin left of the sheet is not a hit" + ); + } +} diff --git a/src/cortex-tui/src/app/state.rs b/src/cortex-tui/src/app/state.rs index 328422eb..342e5323 100644 --- a/src/cortex-tui/src/app/state.rs +++ b/src/cortex-tui/src/app/state.rs @@ -223,8 +223,16 @@ pub struct AppState { pub footer_hover: Option, /// Settings modal (F2 / `/settings`). pub settings_modal: Option, - /// Ctrl+x shortcuts overlay. + /// Ctrl+x / `/shortcuts` overlay. pub shortcuts_open: bool, + /// Focused binding in the shortcuts sheet. + pub shortcuts_selected: usize, + /// Hovered binding in the shortcuts sheet. + pub shortcuts_hovered: Option, + /// Session working checklist (lock `todos`). + pub working_checklist: Option, + /// Last turn was interrupted — composer uses the stopped placeholder. + pub last_turn_stopped: bool, /// Show the Help improve Cortex banner. pub opt_in_banner: bool, /// Hovered opt-in button: 0 = Opt out, 1 = Opt in. @@ -364,6 +372,10 @@ impl AppState { footer_hover: None, settings_modal: None, shortcuts_open: false, + shortcuts_selected: 0, + shortcuts_hovered: None, + working_checklist: None, + last_turn_stopped: false, opt_in_banner: false, opt_in_hover: None, show_thinking_blocks: true, @@ -526,6 +538,7 @@ impl AppState { pub fn add_message(&mut self, message: Message) { if message.role == cortex_core::widgets::MessageRole::User { self.show_launch_splash = false; + self.last_turn_stopped = false; } let mut message = message; if self.timestamps_enabled && message.timestamp.is_none() { @@ -551,10 +564,10 @@ impl AppState { pub fn clear_messages(&mut self) { self.messages.clear(); self.chat_scroll = 0; - // Reset scroll state to bottom (no "↓ End" hint after clearing) self.chat_scroll_pinned_bottom = true; - // Request clearing terminal scrollback buffer for privacy self.pending_scrollback_clear = true; + self.last_turn_stopped = false; + self.working_checklist = None; } /// Check if scrollback clear is pending and reset the flag @@ -643,7 +656,7 @@ impl AppState { let mut modal = crate::widgets::SettingsModalState::default(); modal.values = self.settings_values(); self.settings_modal = Some(modal); - self.shortcuts_open = false; + self.close_shortcuts_sheet(); } /// Copy modal values back onto the session. diff --git a/src/cortex-tui/src/commands/executor/dispatch.rs b/src/cortex-tui/src/commands/executor/dispatch.rs index b43b2341..4d0498a9 100644 --- a/src/cortex-tui/src/commands/executor/dispatch.rs +++ b/src/cortex-tui/src/commands/executor/dispatch.rs @@ -13,6 +13,7 @@ impl CommandExecutor { "version" | "v" => self.cmd_version(), "upgrade" | "update" => CommandResult::OpenModal(ModalType::Upgrade), "settings" | "prefs" => CommandResult::OpenModal(ModalType::Settings), + "shortcuts" | "keys" => CommandResult::Toggle("shortcuts".to_string()), "reload-config" | "reload" => CommandResult::Async("config:reload".to_string()), "theme" => self.cmd_theme(cmd), "compact" => CommandResult::Toggle("compact".to_string()), @@ -26,7 +27,7 @@ impl CommandExecutor { "plan" => CommandResult::OpenModal(ModalType::Plan), "goal" => self.cmd_goal(cmd), // Effort radios live on `/model` (Tab). `/effort` is an alias. - "effort" => CommandResult::OpenModal(ModalType::ModelPicker), + "effort" => CommandResult::OpenModal(ModalType::Effort), "btw" => CommandResult::Message("Side note captured for this turn.".to_string()), "jobs" | "bg" | "background" | "tasks" => CommandResult::OpenModal(ModalType::Tasks), "skills" | "sk" => CommandResult::OpenModal(ModalType::Skills), diff --git a/src/cortex-tui/src/commands/executor/tests.rs b/src/cortex-tui/src/commands/executor/tests.rs index 4141781d..50182dc4 100644 --- a/src/cortex-tui/src/commands/executor/tests.rs +++ b/src/cortex-tui/src/commands/executor/tests.rs @@ -310,6 +310,19 @@ fn test_goal_commands() { )); } +#[test] +fn test_shortcuts_command() { + let executor = CommandExecutor::new(); + assert!(matches!( + executor.execute_str("/shortcuts"), + CommandResult::Toggle(ref s) if s == "shortcuts" + )); + assert!(matches!( + executor.execute_str("/keys"), + CommandResult::Toggle(ref s) if s == "shortcuts" + )); +} + #[test] fn test_add_command() { let executor = CommandExecutor::new(); @@ -570,13 +583,9 @@ fn effort_opens_model_picker_not_a_star_picker() { let executor = CommandExecutor::new(); let result = executor.execute_str("/effort"); assert!( - matches!(result, CommandResult::OpenModal(ModalType::ModelPicker)), - "expected /effort to open /model radios, got {result:?}" + matches!(result, CommandResult::OpenModal(ModalType::Effort)), + "expected /effort to open /model effort radios, got {result:?}" ); - assert!(!matches!( - result, - CommandResult::OpenModal(ModalType::Effort) - )); } #[test] diff --git a/src/cortex-tui/src/commands/forms.rs b/src/cortex-tui/src/commands/forms.rs index bfcac06f..bb5ac716 100644 --- a/src/cortex-tui/src/commands/forms.rs +++ b/src/cortex-tui/src/commands/forms.rs @@ -241,7 +241,7 @@ impl FormRegistry { vec![ FormField::text("provider", "Provider Name") .required() - .with_placeholder("anthropic, openai, groq..."), + .with_placeholder("cortex, custom..."), ], )), @@ -251,7 +251,7 @@ impl FormRegistry { vec![ FormField::text("model", "Model Name") .required() - .with_placeholder("claude-sonnet-4-20250514, gpt-4..."), + .with_placeholder("Cortex Mini 1, Cortex 1, Cortex Max 1"), ], )), diff --git a/src/cortex-tui/src/commands/registry/builtin.rs b/src/cortex-tui/src/commands/registry/builtin.rs index 43bbeb06..954a3312 100644 --- a/src/cortex-tui/src/commands/registry/builtin.rs +++ b/src/cortex-tui/src/commands/registry/builtin.rs @@ -57,6 +57,15 @@ pub fn register_builtin_commands(registry: &mut CommandRegistry) { false, )); + registry.register(CommandDef::new( + "shortcuts", + &["keys"], + "Open the shortcuts sheet", + "/shortcuts", + CommandCategory::General, + false, + )); + registry.register(CommandDef::new( "mode", &[], diff --git a/src/cortex-tui/src/interactive/builders/approval.rs b/src/cortex-tui/src/interactive/builders/approval.rs index 51e285fc..f2d861f8 100644 --- a/src/cortex-tui/src/interactive/builders/approval.rs +++ b/src/cortex-tui/src/interactive/builders/approval.rs @@ -50,18 +50,25 @@ pub fn build_approval_selector(current: Option<&str>) -> InteractiveState { /// `/permissions` picker — lock `permissions-picker` copy (SPEC §3.9 radios). pub fn build_permissions_picker(current: Option<&str>) -> InteractiveState { let current = current.unwrap_or("smart").to_ascii_lowercase(); + let is_smart = current == "smart" || current == "medium"; + let is_ro = current == "ro" || current == "ask"; + let is_full = current == "full" || current == "auto" || current == "yolo"; let items = vec![ + InteractiveItem::new("smart", "Smart") + .with_description(if is_smart { + "auto-approve safe reads · ask before edits · current" + } else { + "auto-approve safe reads · ask before edits" + }) + .with_current(is_smart) + .with_shortcut('1'), InteractiveItem::new("ro", "Read-only") .with_description("never edit files or run commands") - .with_current(current == "ro" || current == "ask") - .with_shortcut('1'), - InteractiveItem::new("smart", "Smart") - .with_description("ask before leaving the sandbox") - .with_current(current == "smart" || current == "medium") + .with_current(is_ro) .with_shortcut('2'), InteractiveItem::new("full", "Full access") .with_description("only ask when leaving the sandbox") - .with_current(current == "full" || current == "auto" || current == "yolo") + .with_current(is_full) .with_shortcut('3'), ]; InteractiveState::new( @@ -69,6 +76,7 @@ pub fn build_permissions_picker(current: Option<&str>) -> InteractiveState { items, InteractiveAction::Custom("permissions-picker".into()), ) + .with_banner("Permissions · how Cortex asks before acting") } /// Command string shown on the gray `$` row and used for “always allow …”. @@ -143,21 +151,22 @@ pub fn build_permission_prompt(approval: &ApprovalState) -> InteractiveState { .with_prompt_focus() } -/// Sandbox deny radios (lock `sandbox-deny`). +/// Sandbox deny radios (lock `sandbox-deny`). Aligns with permission radios; +/// does not change the exec permission-prompt (COR-8). pub fn build_sandbox_deny_prompt() -> InteractiveState { let items = vec![ - InteractiveItem::new("retry", "1 Retry inside the sandbox") - .with_description("stay in workspace") + InteractiveItem::new("keep", "1 Keep blocked") + .with_description("stay blocked") .with_shortcut('1'), - InteractiveItem::new("allow", "2 Allow this domain") - .with_description("ask next time") + InteractiveItem::new("once", "2 Allow once") + .with_description("this command only") .with_shortcut('2'), - InteractiveItem::new("cancel", "3 Cancel") - .with_description("do not run") + InteractiveItem::new("session", "3 Allow for this session") + .with_description("remember until Cortex exits") .with_shortcut('3'), ]; InteractiveState::new( - "Sandbox blocked", + "Sandbox denied", items, InteractiveAction::Custom("sandbox-deny".into()), ) @@ -194,10 +203,10 @@ pub fn build_question_prompt( /// Plan-mode confirm (lock `plan-confirm`). pub fn build_plan_confirm() -> InteractiveState { let items = vec![ - InteractiveItem::new("yes", "1 Yes, implement") + InteractiveItem::new("yes", "1 Yes, switch to Agent mode and implement") .with_description("switch to Agent and execute") .with_shortcut('1'), - InteractiveItem::new("no", "2 Not yet") + InteractiveItem::new("no", "2 No, keep planning — tell Cortex what to change") .with_description("stay in Plan") .with_shortcut('2'), ]; @@ -206,25 +215,27 @@ pub fn build_plan_confirm() -> InteractiveState { items, InteractiveAction::Custom("plan-confirm".into()), ) + .with_banner("Implement this plan?") .with_prompt_focus() } -/// `/clear` confirm (lock `clear-confirm`). +/// `/clear` confirm (lock `clear-confirm`). Composer keeps `/clear`. pub fn build_clear_confirm() -> InteractiveState { let items = vec![ - InteractiveItem::new("yes", "1 Clear") - .with_description("wipe this thread, keep the workspace") + InteractiveItem::new("yes", "1 Yes, clear") + .with_description("drop the transcript") .with_shortcut('1'), - InteractiveItem::new("no", "2 Keep") + InteractiveItem::new("no", "2 No, keep it") .with_description("leave messages in place") .with_shortcut('2'), ]; InteractiveState::new( - "Clear conversation?", + "Clear this conversation?", items, InteractiveAction::Custom("clear-confirm".into()), ) - .with_prompt_focus() + .with_banner("Clear this conversation?") + .with_banner_sub("The transcript is dropped. Git, files and config stay as they are.") } /// `/handoff` confirm (lock `handoff-confirm`). Chat · Code · Bot; CLI stays. @@ -337,15 +348,29 @@ mod tests { #[test] fn permissions_picker_marks_smart() { let state = build_permissions_picker(Some("smart")); - assert!(state.items[1].is_current); + assert_eq!(state.items[0].id, "smart"); + assert!(state.items[0].is_current); + assert!( + state.items[0] + .description + .as_deref() + .unwrap_or("") + .contains("current") + ); assert!(!state.prompt_owns_focus); + assert_eq!( + state.banner.as_deref(), + Some("Permissions · how Cortex asks before acting") + ); } #[test] fn related_prompts_own_composer() { assert!(build_sandbox_deny_prompt().prompt_owns_focus); + assert_eq!(build_sandbox_deny_prompt().items[0].label, "1 Keep blocked"); assert!(build_plan_confirm().prompt_owns_focus); - assert!(build_clear_confirm().prompt_owns_focus); + assert!(!build_clear_confirm().prompt_owns_focus); + assert_eq!(build_clear_confirm().items[0].label, "1 Yes, clear"); let handoff = build_handoff_confirm(false); assert!(handoff.prompt_owns_focus); assert_eq!(handoff.title, "Handoff"); diff --git a/src/cortex-tui/src/interactive/builders/mcp.rs b/src/cortex-tui/src/interactive/builders/mcp.rs index 9040ac22..bb7af716 100644 --- a/src/cortex-tui/src/interactive/builders/mcp.rs +++ b/src/cortex-tui/src/interactive/builders/mcp.rs @@ -6,74 +6,66 @@ use crate::interactive::state::{ use crate::modal::mcp_manager::{McpServerInfo, McpStatus}; /// Build an interactive state for MCP server management. +/// +/// Lock `/mcp`: servers only, status glyphs, `a` add / `r` reconnect in the +/// footer (not as list rows). pub fn build_mcp_selector(servers: &[McpServerInfo]) -> InteractiveState { - let mut items: Vec = Vec::new(); + let items: Vec = servers.iter().map(mcp_server_item).collect(); - // Add global actions first (separated from servers) - items.push( - InteractiveItem::new("__add__", "Add MCP Server") - .with_description("Configure a new server (stdio, HTTP, or from registry)") - .with_shortcut('a'), - ); - - items.push( - InteractiveItem::new("__tools__", "View All Tools") - .with_description("List tools from all running servers") - .with_shortcut('t'), - ); - - items.push( - InteractiveItem::new("__reload__", "Reload All Servers") - .with_description("Restart all MCP servers") - .with_shortcut('r'), - ); + let connected = servers + .iter() + .filter(|s| matches!(s.status, McpStatus::Running)) + .count(); + let banner = format!("MCP servers · {} of {} connected", connected, servers.len()); - // Add separator if there are servers - if !servers.is_empty() { - items.push( - InteractiveItem::new("__sep_servers__", "--- Configured Servers ---").as_separator(), - ); - } - - // Add server entries - for server in servers { - let status_text = match server.status { - McpStatus::Running => "running", - McpStatus::Starting => "starting", - McpStatus::Stopped => "stopped", - McpStatus::Error => "error", - }; - - let description = format!("{} - {} tools", status_text, server.tool_count); - - let mut item = - InteractiveItem::new(&server.name, &server.name).with_description(description); + InteractiveState::new("MCP Servers", items, InteractiveAction::McpServerAction) + .with_banner(banner) + .with_hints(vec![ + ("Enter".to_string(), "details".to_string()), + ("r".to_string(), "reconnect".to_string()), + ("a".to_string(), "add server".to_string()), + ("Esc".to_string(), "close".to_string()), + ]) +} - if server.requires_auth { - item = item.with_metadata("requires_auth".to_string()); +fn mcp_server_item(server: &McpServerInfo) -> InteractiveItem { + let (icon, description) = match server.status { + McpStatus::Running => ('✓', format!("{} tools · connected", server.tool_count)), + McpStatus::Starting => ( + '⠇', + if server.requires_auth { + "authenticating…".to_string() + } else { + "starting…".to_string() + }, + ), + McpStatus::Error => { + let reason = server + .error + .as_deref() + .map(trim_error) + .filter(|s| !s.is_empty()) + .unwrap_or("connection lost"); + ('×', format!("failed — {reason} · r to reconnect")) } + McpStatus::Stopped => ('○', "stopped · r to reconnect".to_string()), + }; - items.push(item); + let mut item = InteractiveItem::new(&server.name, &server.name) + .with_icon(icon) + .with_description(description); + if server.requires_auth { + item = item.with_metadata("requires_auth".to_string()); } + item +} - let title = if servers.is_empty() { - "MCP Servers".to_string() - } else { - let running = servers - .iter() - .filter(|s| matches!(s.status, McpStatus::Running)) - .count(); - format!("MCP Servers ({}/{})", running, servers.len()) - }; - - InteractiveState::new(title, items, InteractiveAction::McpServerAction) - .with_search() - .with_hints(vec![ - ("Up/Down".to_string(), "navigate".to_string()), - ("Enter".to_string(), "select".to_string()), - ("/".to_string(), "search".to_string()), - ("Esc".to_string(), "close".to_string()), - ]) +fn trim_error(error: &str) -> &str { + error + .strip_prefix("failed — ") + .or_else(|| error.strip_prefix("failed: ")) + .unwrap_or(error) + .trim() } /// Build a selector for choosing MCP server source (Custom or Registry). @@ -227,11 +219,12 @@ mod tests { #[test] fn test_build_mcp_selector_empty() { let state = build_mcp_selector(&[]); - // Should have global actions: Add, Tools, Reload (no separator when empty) - assert_eq!(state.items.len(), 3); - assert_eq!(state.items[0].id, "__add__"); - assert_eq!(state.items[1].id, "__tools__"); - assert_eq!(state.items[2].id, "__reload__"); + assert!(state.items.is_empty()); + assert_eq!( + state.banner.as_deref(), + Some("MCP servers · 0 of 0 connected") + ); + assert!(!state.searchable); } #[test] @@ -241,8 +234,36 @@ mod tests { create_test_server("test2", McpStatus::Stopped), ]; let state = build_mcp_selector(&servers); - // 3 global actions + 1 separator + 2 servers = 6 items - assert_eq!(state.items.len(), 6); + assert_eq!(state.items.len(), 2); + assert_eq!(state.items[0].id, "test1"); + assert_eq!(state.items[0].icon, Some('✓')); + assert!( + state.items[0] + .description + .as_deref() + .unwrap_or("") + .contains("connected") + ); + assert_eq!(state.items[1].icon, Some('○')); + assert_eq!( + state.banner.as_deref(), + Some("MCP servers · 1 of 2 connected") + ); + } + + #[test] + fn error_row_offers_reconnect() { + let mut server = create_test_server("sentry", McpStatus::Error); + server.error = Some("token expired".into()); + let state = build_mcp_selector(&[server]); + assert_eq!(state.items[0].icon, Some('×')); + assert!( + state.items[0] + .description + .as_deref() + .unwrap_or("") + .contains("failed — token expired · r to reconnect") + ); } #[test] diff --git a/src/cortex-tui/src/interactive/builders/model.rs b/src/cortex-tui/src/interactive/builders/model.rs index ecf944ad..3d6969bc 100644 --- a/src/cortex-tui/src/interactive/builders/model.rs +++ b/src/cortex-tui/src/interactive/builders/model.rs @@ -4,11 +4,12 @@ use crate::interactive::state::{ EffortLevel, InteractiveAction, InteractiveItem, InteractiveState, }; use crate::providers::models::ModelInfo; +use crate::ui::text_utils::{contains_foreign_brand, model_display_name}; /// Build an interactive state for model selection. /// Models should be passed from ProviderManager.available_models(). /// -/// Effort is Low / Medium / High radios on this surface. Tab cycles them. +/// Effort is High / Medium / Low radios on this surface. Tab jumps to them. /// There is no separate A★ `/effort` picker. pub fn build_model_selector( models: Vec, @@ -19,26 +20,15 @@ pub fn build_model_selector( .iter() .map(|model| { let is_current = current_model.map(|c| c == model.id).unwrap_or(false); - - let description = format_model_description(model); - - InteractiveItem::new(&model.id, &model.name) + let (label, description) = format_model_row(model, is_current); + InteractiveItem::new(&model.id, label) .with_description(description) .with_current(is_current) .with_metadata(model.id.clone()) }) .collect(); - // Sort: current first, then by name - items.sort_by(|a, b| { - if a.is_current && !b.is_current { - std::cmp::Ordering::Less - } else if !a.is_current && b.is_current { - std::cmp::Ordering::Greater - } else { - a.label.cmp(&b.label) - } - }); + items.sort_by_key(|item| catalog_rank(&item.label)); let title = "Select Model".to_string(); @@ -54,30 +44,112 @@ pub fn build_model_selector( ]) } -/// Format a model description showing context window and other info. -fn format_model_description(model: &ModelInfo) -> String { - let mut parts = Vec::new(); +/// Lock v2 `/model` copy. Custom / third-party ids stay Cortex-safe. +fn format_model_row(model: &ModelInfo, is_current: bool) -> (String, String) { + let label = cortex_safe_label(&model.id, &model.name); + let mut desc = lock_description(&model.id, &label).unwrap_or_else(|| { + let raw = model.description.trim(); + if !raw.is_empty() && !looks_foreign(raw) { + raw.to_string() + } else { + format!("Configured as {}", label) + } + }); + if is_max_label(&label) && !desc.contains("MAX") { + desc.push_str(" · MAX"); + } + if is_current { + desc.push_str(" · current"); + } + (label, desc) +} - // Context window - let ctx = model.context_window; - let ctx_str = if ctx >= 1_000_000 { - format!("{}M ctx", ctx / 1_000_000) - } else if ctx >= 1_000 { - format!("{}K ctx", ctx / 1_000) +fn cortex_safe_label(id: &str, name: &str) -> String { + let name = name.trim(); + if !name.is_empty() + && name != "Custom model" + && !looks_foreign(name) + && !looks_foreign(&model_display_name(name)) + { + return model_display_name(name); + } + if !looks_foreign(id) && !looks_foreign(&model_display_name(id)) { + return model_display_name(id); + } + custom_distinct_label(id) +} + +/// Third-party catalog rows stay distinguishable without naming other vendors. +fn custom_distinct_label(id: &str) -> String { + let rest = remaining_safe_tokens(id); + let tag = short_tag(id); + if rest.is_empty() { + format!("Custom · {tag}") } else { - format!("{} ctx", ctx) - }; - parts.push(ctx_str); + format!("Custom · {rest} · {tag}") + } +} + +fn remaining_safe_tokens(id: &str) -> String { + id.split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|p| !p.is_empty()) + .filter(|p| !looks_foreign(p) && !is_vendor_token(p)) + .map(|p| { + let mut chars = p.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +fn is_vendor_token(token: &str) -> bool { + matches!( + token.to_ascii_lowercase().as_str(), + "gpt" | "claude" | "opus" | "sonnet" | "haiku" | "gemini" | "llama" | "mistral" + ) +} - // Capabilities - if model.vision { - parts.push("vision".to_string()); +fn short_tag(id: &str) -> String { + let mut hash: u32 = 2166136261; + for byte in id.as_bytes() { + hash ^= u32::from(*byte); + hash = hash.wrapping_mul(16777619); } - if model.tools { - parts.push("tools".to_string()); + format!("{hash:04x}")[..4].to_string() +} + +fn lock_description(id: &str, label: &str) -> Option { + let key = id.rsplit('/').next().unwrap_or(id); + let text = if key.eq_ignore_ascii_case("cortex-1-mini") || label == "Cortex Mini 1" { + Some("Fast default for everyday coding") + } else if key.eq_ignore_ascii_case("cortex-1-max") || label == "Cortex Max 1" { + Some("Longest context — bills by token instead of per request") + } else if key.eq_ignore_ascii_case("cortex-1") || label == "Cortex 1" { + Some("Deeper reasoning for hard changes") + } else { + None + }; + text.map(str::to_string) +} + +fn is_max_label(label: &str) -> bool { + label.to_ascii_lowercase().contains("max") +} + +fn catalog_rank(label: &str) -> u8 { + match label { + "Cortex Mini 1" => 0, + "Cortex 1" => 1, + "Cortex Max 1" => 2, + _ => 10, } +} - parts.join(", ") +fn looks_foreign(text: &str) -> bool { + contains_foreign_brand(text) } #[cfg(test)] @@ -87,7 +159,6 @@ mod tests { #[test] fn test_build_model_selector() { let state = build_model_selector(Vec::new(), None, None); - // May be empty if no models configured, but should not panic assert_eq!(state.title, "Select Model"); assert!(state.searchable); assert_eq!(state.effort, Some(EffortLevel::Medium)); @@ -115,4 +186,81 @@ mod tests { assert!(!line.contains('★') && !line.contains("A★"), "{line}"); assert!(line.contains("Low") && line.contains("Medium") && line.contains("High")); } + + #[test] + fn lock_rows_use_catalog_copy_and_hide_foreign_brands() { + let models = vec![ + ModelInfo::new("cortex-1-mini", "Cortex Mini 1", "cortex"), + ModelInfo::new("cortex-1", "Cortex 1", "cortex"), + ModelInfo::new("cortex-1-max", "Cortex Max 1", "cortex"), + ModelInfo::new("acme/other-router", "Custom model", "custom"), + ]; + let state = build_model_selector(models, Some("cortex-1-mini"), Some("medium")); + let labels: Vec<_> = state.items.iter().map(|i| i.label.as_str()).collect(); + assert_eq!(&labels[..3], ["Cortex Mini 1", "Cortex 1", "Cortex Max 1"]); + assert_eq!(labels[3], "Other Router"); + assert!( + state.items[0] + .description + .as_deref() + .unwrap_or("") + .contains("Fast default for everyday coding") + ); + assert!( + state.items[0] + .description + .as_deref() + .unwrap_or("") + .contains("current") + ); + assert!( + state.items[2] + .description + .as_deref() + .unwrap_or("") + .contains("Longest context") + ); + assert!( + state.items[2] + .description + .as_deref() + .unwrap_or("") + .contains("MAX") + ); + let joined = state + .items + .iter() + .map(|i| format!("{} {}", i.label, i.description.clone().unwrap_or_default())) + .collect::>() + .join("\n") + .to_ascii_lowercase(); + assert!(!joined.contains("claude")); + assert!(!joined.contains("anthropic")); + assert!(!joined.contains("openai")); + assert!(!joined.contains("gpt-4")); + } + + #[test] + fn foreign_provider_ids_become_distinct_custom_rows() { + let models = vec![ + ModelInfo::new("anthropic/claude-sonnet-4", "Claude Sonnet 4", "anthropic"), + ModelInfo::new("openai/gpt-4-turbo", "GPT-4 Turbo", "openai"), + ModelInfo::new("vendor-a/router-west", "Custom model", "custom"), + ModelInfo::new("vendor-b/router-east", "Custom model", "custom"), + ]; + let state = build_model_selector(models, None, None); + let labels: Vec<_> = state.items.iter().map(|i| i.label.clone()).collect(); + assert_eq!(labels.len(), 4); + let unique: std::collections::HashSet<_> = labels.iter().cloned().collect(); + assert_eq!(unique.len(), 4, "{labels:?}"); + let blob = labels.join("\n").to_ascii_lowercase(); + assert!(!blob.contains("claude")); + assert!(!blob.contains("anthropic")); + assert!(!blob.contains("openai")); + assert!(!blob.contains("gpt-4")); + assert!(!blob.contains("sonnet")); + assert!(labels.iter().any(|l| l.starts_with("Custom ·"))); + assert!(labels.iter().any(|l| l.contains("Router West"))); + assert!(labels.iter().any(|l| l.contains("Router East"))); + } } diff --git a/src/cortex-tui/src/interactive/builders/resume_picker.rs b/src/cortex-tui/src/interactive/builders/resume_picker.rs index 0b960e34..e0aaf1df 100644 --- a/src/cortex-tui/src/interactive/builders/resume_picker.rs +++ b/src/cortex-tui/src/interactive/builders/resume_picker.rs @@ -1,92 +1,36 @@ -//! Resume Picker - Session selection at startup. -//! -//! Displays a list of recent sessions with preview information, -//! allowing users to Resume, Fork, or start a New session. +//! Resume picker — `/resume` session list (lock `resume-picker`). use crate::interactive::state::{InteractiveAction, InteractiveItem, InteractiveState}; use crate::session::SessionSummary; use chrono::{Duration, Utc}; -/// Build an interactive state for the resume picker (startup session selection). +/// Build the `/resume` list: search chrome, recent sessions, no New Session row. /// -/// This picker is displayed at startup when `--resume` flag is used or -/// when configured to show on launch. -/// -/// Shortcuts: -/// - Enter: Resume selected session -/// - F: Fork selected session (create copy) -/// - N: Start new session -/// - Esc: Start new session (same as N) +/// Shortcuts: Enter resume · `f` favorite · `d` delete · Esc close. pub fn build_resume_picker(sessions: &[SessionSummary], show_archived: bool) -> InteractiveState { - let mut items: Vec = Vec::new(); - - // Add "New Session" option at the top - items.push( - InteractiveItem::new("__new__", "New Session") - .with_icon('+') - .with_description("Start a fresh conversation") - .with_shortcut('n'), - ); - - // Add separator if there are sessions - if !sessions.is_empty() { - items.push( - InteractiveItem::new("__sep_recent__", "─── Recent Sessions ───") - .with_icon(' ') - .as_separator(), - ); - } - - // Filter and format sessions let filtered_sessions: Vec<_> = sessions .iter() .filter(|s| show_archived || !s.archived) - .take(15) // Limit to 15 most recent + .take(15) .collect(); - for session in filtered_sessions { - let time_ago = format_time_ago(session.updated_at); - - // Format title with fallback - let title = if session.title.is_empty() { - "Untitled session" - } else { - &session.title - }; - - // Truncate title if too long - let display_title = if title.len() > 40 { - format!("{}...", &title[..37]) - } else { - title.to_string() - }; - - // Format description with metadata - let description = format!( - "{} • {} messages • {}", - time_ago, session.message_count, session.model - ); - - // Icon based on recency - let icon = if is_recent(&session.updated_at, 2) { - '*' // Very recent (< 2 hours) - } else if is_recent(&session.updated_at, 24) { - '+' // Today - } else if is_recent(&session.updated_at, 168) { - '-' // This week - } else { - '.' // Older - }; - - items.push( - InteractiveItem::new(&session.id, &display_title) - .with_icon(icon) - .with_description(description) - .with_metadata(format!("ID: {}", short_id(&session.id))), - ); - } + let items: Vec = filtered_sessions + .iter() + .map(|session| { + let title = if session.title.is_empty() { + "Untitled session" + } else { + session.title.as_str() + }; + let display_title = if title.chars().count() > 40 { + format!("{}...", title.chars().take(37).collect::()) + } else { + title.to_string() + }; + InteractiveItem::new(&session.id, display_title).with_description(resume_meta(session)) + }) + .collect(); - // Create state with custom hints InteractiveState::new( "Resume Session".to_string(), items, @@ -95,13 +39,26 @@ pub fn build_resume_picker(sessions: &[SessionSummary], show_archived: bool) -> .with_search() .with_max_visible(12) .with_hints(vec![ - ("Enter".to_string(), "Resume".to_string()), - ("f".to_string(), "Fork".to_string()), - ("n".to_string(), "New".to_string()), - ("Esc".to_string(), "Cancel".to_string()), + ("Enter".to_string(), "resume".to_string()), + ("f".to_string(), "favorite".to_string()), + ("d".to_string(), "delete".to_string()), + ("Esc".to_string(), "close".to_string()), ]) } +fn resume_meta(session: &SessionSummary) -> String { + let time_ago = format_time_ago(session.updated_at); + let extra = session.model.trim(); + if extra.contains('/') { + format!( + "{} · {} messages · {extra}", + time_ago, session.message_count + ) + } else { + format!("{} · {} messages", time_ago, session.message_count) + } +} + /// Format a timestamp as "X ago" human-readable string. fn format_time_ago(timestamp: chrono::DateTime) -> String { let now = Utc::now(); @@ -120,7 +77,7 @@ fn format_time_ago(timestamp: chrono::DateTime) -> String { if days == 1 { "yesterday".to_string() } else { - format!("{}d ago", days) + format!("{} days ago", days) } } else if diff < Duration::days(30) { let weeks = diff.num_weeks(); @@ -130,18 +87,6 @@ fn format_time_ago(timestamp: chrono::DateTime) -> String { } } -/// Check if timestamp is within X hours of now. -fn is_recent(timestamp: &chrono::DateTime, hours: i64) -> bool { - let now = Utc::now(); - let diff = now.signed_duration_since(*timestamp); - diff < Duration::hours(hours) -} - -/// Get short ID (first 8 characters). -fn short_id(id: &str) -> &str { - if id.len() >= 8 { &id[..8] } else { id } -} - #[cfg(test)] mod tests { use super::*; @@ -155,17 +100,17 @@ mod tests { created_at: Utc::now() - Duration::hours(hours_ago), updated_at: Utc::now() - Duration::hours(hours_ago), archived: false, - provider: "anthropic".to_string(), - model: "claude-3".to_string(), + provider: "cortex".to_string(), + model: "cortex-1-mini".to_string(), } } #[test] fn test_build_resume_picker_empty() { let state = build_resume_picker(&[], false); - // Should have "New Session" option only - assert_eq!(state.items.len(), 1); - assert_eq!(state.items[0].id, "__new__"); + assert!(state.items.is_empty()); + assert!(state.searchable); + assert!(state.inline_search()); } #[test] @@ -177,11 +122,17 @@ mod tests { ]; let state = build_resume_picker(&sessions, false); - // New Session + separator + 3 sessions = 5 items - assert_eq!(state.items.len(), 5); - assert_eq!(state.items[0].id, "__new__"); - assert!(state.items[1].is_separator); - assert_eq!(state.items[2].id, "abc123"); + assert_eq!(state.items.len(), 3); + assert_eq!(state.items[0].id, "abc123"); + assert!(!state.items.iter().any(|i| i.id == "__new__")); + let lower = format!( + "{}{}", + state.items[0].label, + state.items[0].description.as_deref().unwrap_or("") + ) + .to_ascii_lowercase(); + assert!(!lower.contains("claude")); + assert!(!lower.contains("anthropic")); } #[test] @@ -192,15 +143,15 @@ mod tests { assert_eq!(format_time_ago(now - Duration::minutes(30)), "30m ago"); assert_eq!(format_time_ago(now - Duration::hours(2)), "2h ago"); assert_eq!(format_time_ago(now - Duration::days(1)), "yesterday"); - assert_eq!(format_time_ago(now - Duration::days(3)), "3d ago"); + assert_eq!(format_time_ago(now - Duration::days(3)), "3 days ago"); } #[test] - fn test_is_recent() { - let now = Utc::now(); - - assert!(is_recent(&now, 1)); - assert!(is_recent(&(now - Duration::minutes(30)), 1)); - assert!(!is_recent(&(now - Duration::hours(2)), 1)); + fn branch_slug_appends_when_model_looks_like_a_ref() { + let mut session = create_test_session("s1", "fix login redirect", 2); + session.model = "cortex/fix-login-redirect".into(); + session.message_count = 14; + let meta = resume_meta(&session); + assert!(meta.contains("14 messages · cortex/fix-login-redirect")); } } diff --git a/src/cortex-tui/src/interactive/handlers.rs b/src/cortex-tui/src/interactive/handlers.rs index 7f92d691..c7696259 100644 --- a/src/cortex-tui/src/interactive/handlers.rs +++ b/src/cortex-tui/src/interactive/handlers.rs @@ -8,13 +8,59 @@ use std::collections::HashMap; /// /// Returns an `InteractiveResult` indicating what action to take. pub fn handle_interactive_key(state: &mut InteractiveState, key: KeyEvent) -> InteractiveResult { - // If inline form is active, handle form input if state.is_form_active() { return handle_form_key(state, key); } + if let Some(result) = handle_interactive_nav(state, key) { + return result; + } + match key.code { + KeyCode::Enter => handle_interactive_enter(state), + KeyCode::Char(' ') if state.multi_select => { + state.toggle_check(); + state.select_next(); + InteractiveResult::Continue + } + KeyCode::Esc => InteractiveResult::Cancelled, + KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => { + InteractiveResult::Cancelled + } + KeyCode::Char('q') if key.modifiers == KeyModifiers::CONTROL => { + InteractiveResult::Cancelled + } + KeyCode::Char(c) if state.searchable && key.modifiers.is_empty() => { + if state.search_query.is_empty() { + if let Some(result) = try_resume_picker_key(state, c) { + return result; + } + } + if !state.search_query.is_empty() || !is_shortcut(state, c) { + state.push_search_char(c); + } else if let Some(result) = try_shortcut(state, c) { + return result; + } + InteractiveResult::Continue + } + KeyCode::Char(c) if !state.searchable && key.modifiers.is_empty() => { + handle_non_search_char(state, c) + } + KeyCode::Backspace if state.searchable && !state.search_query.is_empty() => { + state.pop_search_char(); + InteractiveResult::Continue + } + KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL && state.searchable => { + state.update_search(""); + InteractiveResult::Continue + } + _ => InteractiveResult::Continue, + } +} +fn handle_interactive_nav( + state: &mut InteractiveState, + key: KeyEvent, +) -> Option { match key.code { - // Navigation KeyCode::Up | KeyCode::Char('k') if key.modifiers.is_empty() || key.modifiers == KeyModifiers::NONE => { @@ -23,7 +69,7 @@ pub fn handle_interactive_key(state: &mut InteractiveState, key: KeyEvent) -> In } else { state.select_prev(); } - InteractiveResult::Continue + Some(InteractiveResult::Continue) } KeyCode::Down | KeyCode::Char('j') if key.modifiers.is_empty() || key.modifiers == KeyModifiers::NONE => @@ -33,38 +79,32 @@ pub fn handle_interactive_key(state: &mut InteractiveState, key: KeyEvent) -> In } else { state.select_next(); } - InteractiveResult::Continue + Some(InteractiveResult::Continue) } - - // Ctrl+P / Ctrl+N for navigation (like emacs) KeyCode::Char('p') if key.modifiers == KeyModifiers::CONTROL => { state.select_prev(); - InteractiveResult::Continue + Some(InteractiveResult::Continue) } KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => { state.select_next(); - InteractiveResult::Continue + Some(InteractiveResult::Continue) } - - // Page navigation KeyCode::PageUp => { for _ in 0..state.max_visible { state.select_prev(); } - InteractiveResult::Continue + Some(InteractiveResult::Continue) } KeyCode::PageDown => { for _ in 0..state.max_visible { state.select_next(); } - InteractiveResult::Continue + Some(InteractiveResult::Continue) } - - // Home/End KeyCode::Home => { state.selected = 0; state.scroll_offset = 0; - InteractiveResult::Continue + Some(InteractiveResult::Continue) } KeyCode::End => { if !state.filtered_indices.is_empty() { @@ -73,90 +113,45 @@ pub fn handle_interactive_key(state: &mut InteractiveState, key: KeyEvent) -> In state.scroll_offset = state.selected - state.max_visible + 1; } } - InteractiveResult::Continue - } - - // Tab navigation (Left/Right) - KeyCode::Left if !state.tabs.is_empty() => InteractiveResult::SwitchTab { direction: -1 }, - KeyCode::Right if !state.tabs.is_empty() => InteractiveResult::SwitchTab { direction: 1 }, - - // `/model` Effort radios: Tab jumps between the model list and the effort pane. - KeyCode::Tab if state.effort.is_some() => { - state.toggle_effort_focus(); - InteractiveResult::Continue - } - - // Selection - KeyCode::Enter => { - if let Some(item) = state.selected_item() { - if item.disabled { - InteractiveResult::Continue - } else if state.multi_select && !state.checked.is_empty() { - // Return all checked items - let item_ids: Vec = - state.checked_items().iter().map(|i| i.id.clone()).collect(); - InteractiveResult::Selected { - action: state.action.clone(), - item_id: item_ids.first().cloned().unwrap_or_default(), - item_ids, - } - } else { - InteractiveResult::Selected { - action: state.action.clone(), - item_id: item.id.clone(), - item_ids: vec![item.id.clone()], - } - } - } else { - InteractiveResult::Continue - } - } - - // Toggle (multi-select) - KeyCode::Char(' ') if state.multi_select => { - state.toggle_check(); - state.select_next(); // Move to next after toggle - InteractiveResult::Continue - } - - // Cancel (Esc, Ctrl+C, Ctrl+Q) - KeyCode::Esc => InteractiveResult::Cancelled, - KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => { - InteractiveResult::Cancelled - } - KeyCode::Char('q') if key.modifiers == KeyModifiers::CONTROL => { - InteractiveResult::Cancelled - } - - // Search input (when searchable) - KeyCode::Char(c) if state.searchable && key.modifiers.is_empty() => { - // Check for shortcuts first - if !state.search_query.is_empty() || !is_shortcut(state, c) { - state.push_search_char(c); - } else if let Some(result) = try_shortcut(state, c) { - return result; - } - InteractiveResult::Continue + Some(InteractiveResult::Continue) } - - // Shortcuts (when not searching) - KeyCode::Char(c) if !state.searchable && key.modifiers.is_empty() => { - handle_non_search_char(state, c) + KeyCode::Left if !state.tabs.is_empty() => { + Some(InteractiveResult::SwitchTab { direction: -1 }) } - - // Backspace (search) - KeyCode::Backspace if state.searchable && !state.search_query.is_empty() => { - state.pop_search_char(); - InteractiveResult::Continue + KeyCode::Right if !state.tabs.is_empty() => { + Some(InteractiveResult::SwitchTab { direction: 1 }) } - - // Clear search - KeyCode::Char('u') if key.modifiers == KeyModifiers::CONTROL && state.searchable => { - state.update_search(""); - InteractiveResult::Continue + KeyCode::Tab if state.effort.is_some() => { + state.toggle_effort_focus(); + Some(InteractiveResult::Continue) } + _ => None, + } +} - _ => InteractiveResult::Continue, +fn handle_interactive_enter(state: &mut InteractiveState) -> InteractiveResult { + if state.effort.is_some() && !state.effort_focused { + state.effort_focused = true; + return InteractiveResult::Continue; + } + let Some(item) = state.selected_item() else { + return InteractiveResult::Continue; + }; + if item.disabled { + return InteractiveResult::Continue; + } + if state.multi_select && !state.checked.is_empty() { + let item_ids: Vec = state.checked_items().iter().map(|i| i.id.clone()).collect(); + return InteractiveResult::Selected { + action: state.action.clone(), + item_id: item_ids.first().cloned().unwrap_or_default(), + item_ids, + }; + } + InteractiveResult::Selected { + action: state.action.clone(), + item_id: item.id.clone(), + item_ids: vec![item.id.clone()], } } @@ -239,12 +234,57 @@ fn handle_non_search_char(state: &mut InteractiveState, c: char) -> InteractiveR if let Some(result) = try_jobs_picker_key(state, c) { return result; } + if let Some(result) = try_mcp_picker_key(state, c) { + return result; + } if let Some(result) = try_shortcut(state, c) { return result; } InteractiveResult::Continue } +fn try_mcp_picker_key(state: &InteractiveState, c: char) -> Option { + if !matches!(state.action, InteractiveAction::McpServerAction) { + return None; + } + let item_id = match c { + 'a' => "__add__".to_string(), + 'r' => { + let item = state.selected_item()?; + if item.disabled || item.id.starts_with("__") { + return None; + } + format!("__reconnect__:{}", item.id) + } + _ => return None, + }; + Some(InteractiveResult::Selected { + action: state.action.clone(), + item_id: item_id.clone(), + item_ids: vec![item_id], + }) +} + +fn try_resume_picker_key(state: &InteractiveState, c: char) -> Option { + if !matches!(state.action, InteractiveAction::ResumeSession) { + return None; + } + let item = state.selected_item()?; + if item.disabled || item.id.starts_with("__") { + return None; + } + let action_id = match c { + 'f' => "resume-favorite", + 'd' => "resume-delete", + _ => return None, + }; + Some(InteractiveResult::Selected { + action: InteractiveAction::Custom(action_id.into()), + item_id: item.id.clone(), + item_ids: vec![item.id.clone()], + }) +} + fn try_jobs_picker_key(state: &mut InteractiveState, c: char) -> Option { if !matches!( &state.action, @@ -291,20 +331,6 @@ fn try_permission_prompt_edit(state: &mut InteractiveState, c: char) -> Option Option { - // Special handling for Resume Picker: 'f' = Fork current selection - if c == 'f' - && matches!(state.action, InteractiveAction::ResumeSession) - && let Some(item) = state.selected_item() - && !item.disabled - && !item.id.starts_with("__") - { - return Some(InteractiveResult::Selected { - action: InteractiveAction::ForkSession, - item_id: item.id.clone(), - item_ids: vec![item.id.clone()], - }); - } - for (idx, item) in state.items.iter().enumerate() { if item.shortcut == Some(c) && !item.disabled { // Find the filtered index @@ -450,6 +476,24 @@ mod tests { assert_eq!(state.effort, Some(crate::interactive::EffortLevel::Medium)); } + #[test] + fn enter_on_model_list_opens_effort_then_applies() { + let items = vec![InteractiveItem::new("mini", "Cortex Mini 1")]; + let mut state = InteractiveState::new("Model", items, InteractiveAction::SetModel) + .with_effort(crate::interactive::EffortLevel::Medium); + let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); + let first = handle_interactive_key(&mut state, enter); + assert!(matches!(first, InteractiveResult::Continue)); + assert!(state.effort_focused); + state.effort_down(); + let apply = handle_interactive_key(&mut state, enter); + assert!(matches!( + apply, + InteractiveResult::Selected { ref item_id, .. } if item_id == "mini" + )); + assert_eq!(state.effort, Some(crate::interactive::EffortLevel::Low)); + } + #[test] fn tab_is_a_no_op_without_effort_radios() { let mut state = create_test_state(); @@ -485,4 +529,65 @@ mod tests { InteractiveResult::Selected { ref item_id, .. } if item_id == "stop:sess-1" )); } + + #[test] + fn mcp_a_adds_and_r_reconnects_selected() { + let servers = vec![crate::modal::mcp_manager::McpServerInfo { + name: "sentry".into(), + status: crate::modal::mcp_manager::McpStatus::Error, + tool_count: 0, + error: Some("token expired".into()), + requires_auth: true, + }]; + let mut state = crate::interactive::builders::build_mcp_selector(&servers); + let add = handle_interactive_key( + &mut state, + KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), + ); + assert!(matches!( + add, + InteractiveResult::Selected { ref item_id, .. } if item_id == "__add__" + )); + let reconnect = handle_interactive_key( + &mut state, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE), + ); + assert!(matches!( + reconnect, + InteractiveResult::Selected { ref item_id, .. } if item_id == "__reconnect__:sentry" + )); + } + + #[test] + fn resume_f_favorites_and_d_deletes() { + let sessions = vec![crate::session::SessionSummary { + id: "sess-1".into(), + title: "fix login redirect".into(), + model: "cortex/fix-login-redirect".into(), + provider: "cortex".into(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + message_count: 14, + archived: false, + }]; + let mut state = crate::interactive::builders::build_resume_picker(&sessions, false); + let fav = handle_interactive_key( + &mut state, + KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), + ); + assert!(matches!( + fav, + InteractiveResult::Selected { ref item_id, ref action, .. } + if item_id == "sess-1" && matches!(action, InteractiveAction::Custom(id) if id == "resume-favorite") + )); + let del = handle_interactive_key( + &mut state, + KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), + ); + assert!(matches!( + del, + InteractiveResult::Selected { ref item_id, ref action, .. } + if item_id == "sess-1" && matches!(action, InteractiveAction::Custom(id) if id == "resume-delete") + )); + } } diff --git a/src/cortex-tui/src/interactive/mod.rs b/src/cortex-tui/src/interactive/mod.rs index 6d9b0511..7f5d64f1 100644 --- a/src/cortex-tui/src/interactive/mod.rs +++ b/src/cortex-tui/src/interactive/mod.rs @@ -48,6 +48,7 @@ pub mod builders; pub mod handlers; +pub mod picker_layout; pub mod renderer; pub mod state; diff --git a/src/cortex-tui/src/interactive/picker_layout.rs b/src/cortex-tui/src/interactive/picker_layout.rs new file mode 100644 index 00000000..fea644da --- /dev/null +++ b/src/cortex-tui/src/interactive/picker_layout.rs @@ -0,0 +1,251 @@ +//! Inline picker geometry shared by paint and mouse hit-testing. + +use ratatui::layout::Rect; + +use super::state::InteractiveState; + +/// Must match `views::minimal_session::COMPOSER_ROWS` (avoid a crate cycle). +const COMPOSER_ROWS: u16 = 3; + +/// Session inline lists cap at 8 option rows (lock chrome). +pub const INLINE_OPTION_CAP: usize = 8; + +/// Footer + blank line under the composer in [`MinimalSessionView`]. +const FOOTER_ROWS: u16 = 1; +const BLANK_BEFORE_FOOTER: u16 = 1; + +/// Rows the rows-only picker occupies above the composer. +pub fn picker_stack_height(state: &InteractiveState) -> u16 { + if state.effort_focused { + return 3; + } + let n = if state.filtered_indices.is_empty() { + 1 + } else { + state + .filtered_indices + .len() + .min(state.max_visible) + .min(INLINE_OPTION_CAP) + }; + (n as u16).saturating_add(state.inline_chrome_rows()) +} + +/// Opt-in banner height used by the session view. +pub fn session_optin_height(show: bool, terminal_height: u16) -> u16 { + if !show { + 0 + } else if terminal_height >= 20 { + 5 + } else { + 3 + } +} + +/// Update-available banner height used by the session view. +pub fn session_update_height(show: bool) -> u16 { + u16::from(show) +} + +/// Screen rect of the rows-only picker (matches [`crate::views::minimal_session::MinimalSessionView`]). +/// +/// Update and opt-in banners paint *above* the picker. On a short terminal the +/// transcript collapses and the stack starts at the token row, so the picker +/// is not `composer_y - picker_height`. +pub fn session_inline_picker_area( + screen: Rect, + picker_height: u16, + update_height: u16, + optin_height: u16, +) -> Rect { + let footer_y = screen.bottom().saturating_sub(FOOTER_ROWS); + let composer_y = footer_y + .saturating_sub(BLANK_BEFORE_FOOTER) + .saturating_sub(COMPOSER_ROWS); + let stack = picker_height + .saturating_add(optin_height) + .saturating_add(update_height); + let transcript_bottom = composer_y.saturating_sub(stack); + let content_y = screen.y.saturating_add(1); + let content_height = transcript_bottom.saturating_sub(content_y); + let y = content_y + .saturating_add(content_height) + .saturating_add(update_height) + .saturating_add(optin_height); + let height = picker_height.min(screen.bottom().saturating_sub(y)); + Rect::new(screen.x, y, screen.width, height) +} + +/// Click targets for rows-only chrome: banner / subtitle / search skip, then options. +pub fn calculate_inline_click_zones(state: &mut InteractiveState, area: Rect) { + state.click_zones.clear(); + state.tab_click_zones.clear(); + if state.is_form_active() || state.effort_focused { + return; + } + let chrome = state.inline_chrome_rows(); + let items_y = area.y.saturating_add(chrome); + let items_h = area.height.saturating_sub(chrome); + if items_h == 0 { + return; + } + let start = state.scroll_offset; + let visible = state.filtered_indices.len(); + let end = (start + items_h as usize).min(visible); + for i in 0..(end.saturating_sub(start)) { + let y = items_y.saturating_add(i as u16); + if y >= items_y.saturating_add(items_h) { + break; + } + state + .click_zones + .push((Rect::new(area.x, y, area.width, 1), start + i)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::interactive::builders::{ + build_clear_confirm, build_mcp_selector, build_permissions_picker, build_resume_picker, + }; + use crate::modal::mcp_manager::{McpServerInfo, McpStatus}; + use crate::session::SessionSummary; + use chrono::Utc; + + fn hit(state: &InteractiveState, y: u16) -> Option { + state.hit_test(2, y) + } + + #[test] + fn clear_confirm_clicks_map_to_visible_options() { + let mut state = build_clear_confirm(); + assert_eq!(state.inline_chrome_rows(), 2); + let area = Rect::new(0, 10, 80, 4); + calculate_inline_click_zones(&mut state, area); + assert_eq!(hit(&state, 10), None, "banner"); + assert_eq!(hit(&state, 11), None, "subtitle"); + assert_eq!(hit(&state, 12), Some(0), "first option"); + assert_eq!(hit(&state, 13), Some(1), "second option"); + } + + #[test] + fn mcp_clicks_skip_banner() { + let servers = vec![ + McpServerInfo { + name: "git".into(), + status: McpStatus::Running, + tool_count: 3, + error: None, + requires_auth: false, + }, + McpServerInfo { + name: "docs".into(), + status: McpStatus::Error, + tool_count: 0, + error: Some("lost".into()), + requires_auth: false, + }, + ]; + let mut state = build_mcp_selector(&servers); + let area = Rect::new(0, 5, 80, 3); + calculate_inline_click_zones(&mut state, area); + assert_eq!(hit(&state, 5), None); + assert_eq!(hit(&state, 6), Some(0)); + assert_eq!(hit(&state, 7), Some(1)); + } + + #[test] + fn permissions_clicks_skip_banner() { + let mut state = build_permissions_picker(Some("smart")); + let area = Rect::new(0, 0, 80, 4); + calculate_inline_click_zones(&mut state, area); + assert_eq!(hit(&state, 0), None); + assert_eq!(hit(&state, 1), Some(0)); + assert_eq!(hit(&state, 3), Some(2)); + } + + #[test] + fn resume_clicks_skip_search_chrome() { + let sessions = vec![SessionSummary { + id: "a".into(), + title: "one".into(), + created_at: Utc::now(), + updated_at: Utc::now(), + message_count: 2, + model: "cortex-1-mini".into(), + provider: "cortex".into(), + archived: false, + }]; + let mut state = build_resume_picker(&sessions, false); + assert_eq!(state.inline_chrome_rows(), 2); + let area = Rect::new(0, 20, 80, 3); + calculate_inline_click_zones(&mut state, area); + assert_eq!(hit(&state, 20), None); + assert_eq!(hit(&state, 21), None); + assert_eq!(hit(&state, 22), Some(0)); + } + + #[test] + fn session_picker_sits_above_composer() { + let screen = Rect::new(0, 0, 80, 24); + let area = session_inline_picker_area(screen, 4, 0, 0); + assert_eq!(area.height, 4); + assert_eq!(area.y, 24 - 1 - 1 - COMPOSER_ROWS - 4); + } + + #[test] + fn cramped_banners_move_picker_below_optin() { + let screen = Rect::new(0, 0, 80, 10); + let picker = 4; + let update = 1; + let optin = session_optin_height(true, 10); + assert_eq!(optin, 3); + let area = session_inline_picker_area(screen, picker, update, optin); + // content_y=1, stack does not fit above composer_y=5, so picker starts + // after update+optin: 1+1+3 = 5. + assert_eq!(area.y, 5); + let tall = session_inline_picker_area(Rect::new(0, 0, 80, 24), 4, 1, 3); + assert_eq!(tall.y, 24 - 1 - 1 - COMPOSER_ROWS - 4); + } + + #[test] + fn painted_permission_rows_match_click_zones_with_banners() { + use crate::app::{AppState, UpdateStatus}; + use crate::views::minimal_session::MinimalSessionView; + use cortex_core::widgets::Message; + use ratatui::widgets::Widget; + + let mut app = AppState::new(); + app.terminal_size = (80, 10); + app.opt_in_banner = true; + app.update_status = UpdateStatus::Available { + version: "1.0.0".into(), + }; + app.add_message(Message::user("hi")); + app.enter_interactive_mode(build_permissions_picker(Some("smart"))); + let screen = Rect::new(0, 0, 80, 10); + let mut buf = ratatui::buffer::Buffer::empty(screen); + MinimalSessionView::new(&app).render(screen, &mut buf); + + let mut picker = app.get_interactive_state().expect("picker").clone(); + let area = session_inline_picker_area( + screen, + picker_stack_height(&picker), + session_update_height(true), + session_optin_height(true, 10), + ); + calculate_inline_click_zones(&mut picker, area); + + let mut full_y = None; + for y in 0..10u16 { + let row: String = (0..80u16).map(|x| buf[(x, y)].symbol()).collect(); + if row.contains("Full access") { + full_y = Some(y); + break; + } + } + let full_y = full_y.expect("painted Full access"); + assert_eq!(hit(&picker, full_y), Some(2), "click row {full_y}"); + } +} diff --git a/src/cortex-tui/src/interactive/renderer.rs b/src/cortex-tui/src/interactive/renderer.rs index 2e8edfd8..df295eb6 100644 --- a/src/cortex-tui/src/interactive/renderer.rs +++ b/src/cortex-tui/src/interactive/renderer.rs @@ -1,9 +1,11 @@ //! Renderer for interactive selection in the input area. -use super::state::{EffortLevel, InlineFormState, InteractiveItem, InteractiveState}; +use super::state::{ + EffortLevel, InlineFormState, InteractiveAction, InteractiveItem, InteractiveState, +}; use cortex_core::style::{ - ACCENT, BAR_HOVER, BORDER_FOCUS, HAIRLINE, SELECTION_BG, SUCCESS, SURFACE_1, TEXT, TEXT_DIM, - TEXT_MUTED, + ACCENT, BAR_HOVER, BORDER_FOCUS, ERROR, HAIRLINE, SELECTION_BG, SUCCESS, SURFACE_1, TEXT, + TEXT_DIM, TEXT_MUTED, }; use ratatui::{ buffer::Buffer, @@ -54,76 +56,9 @@ impl<'a> InteractiveWidget<'a> { self } - /// Calculate click zones for the interactive list. - /// Call this after rendering to populate state.click_zones. + /// Calculate click zones for the rows-only picker (banner/search skipped). pub fn calculate_click_zones(state: &mut InteractiveState, area: Rect) { - state.click_zones.clear(); - state.tab_click_zones.clear(); - - // Calculate tab click zones (on title line) - if !state.tabs.is_empty() { - let title = format!(" {} ", state.title); - let title_y = area.y + 1; - let tabs_x = area.x + 2 + title.len() as u16 + 2; - let mut x = tabs_x; - for (i, tab) in state.tabs.iter().enumerate() { - let tab_text = format!(" {} ", tab.label); - let tab_width = tab_text.len() as u16; - let tab_rect = Rect::new(x, title_y, tab_width, 1); - state.tab_click_zones.push((tab_rect, i)); - x += tab_width + 2; - } - } - - // If inline form is active, no item click zones - if state.is_form_active() { - return; - } - - // Calculate the inner area (same logic as render) - // Inner area starts after top border (1) + title line (1) = 2 - let inner = Rect::new( - area.x, - area.y + 2, - area.width, - area.height.saturating_sub(2), - ); - - if inner.height < 3 { - return; - } - - // Layout: search (optional, framed by hairlines) + items + hints - let search_height = if state.searchable { - SEARCH_FIELD_ROWS - } else { - 0 - }; - let hints_height = 1; - let effort_height = if state.effort_focused { 3 } else { 0 }; - let items_height = inner - .height - .saturating_sub(search_height + hints_height + effort_height); - - let items_y = inner.y + search_height; - let items_area = Rect::new(inner.x, items_y, inner.width, items_height); - - // Register click zones for visible items - // We need to collect indices first to avoid borrow conflicts - let start = state.scroll_offset; - let visible_count = state.filtered_indices.len(); - let end = (start + items_area.height as usize).min(visible_count); - - for i in 0..(end - start) { - let y = items_area.y + i as u16; - if y >= items_area.y + items_area.height { - break; - } - - let filtered_idx = start + i; - let item_rect = Rect::new(items_area.x, y, items_area.width, 1); - state.click_zones.push((item_rect, filtered_idx)); - } + super::picker_layout::calculate_inline_click_zones(state, area); } /// Calculate the required height for this widget. @@ -154,9 +89,9 @@ impl<'a> InteractiveWidget<'a> { if self.rows_only { if self.state.effort_focused { - return 3; + return 3 + self.state.inline_chrome_rows(); } - return items_count as u16; + return items_count as u16 + self.state.inline_chrome_rows(); } (items_count as u16) + header_height + search_height + hints_height + effort_height @@ -166,10 +101,13 @@ impl<'a> InteractiveWidget<'a> { impl<'a> Widget for InteractiveWidget<'a> { fn render(self, area: Rect, buf: &mut Buffer) { if self.rows_only { + let mut y = area.y; + y += self.paint_inline_chrome(area, buf, y); + let rest = Rect::new(area.x, y, area.width, area.bottom().saturating_sub(y)); if self.state.effort_focused { - self.render_effort_radios(area, buf); + self.render_effort_radios(rest, buf); } else { - self.render_items(area, buf); + self.render_items(rest, buf); } return; } @@ -205,8 +143,6 @@ impl<'a> Widget for InteractiveWidget<'a> { let is_active = i == self.state.active_tab; let is_hovered = self.state.hovered_tab == Some(i); let tab_text = format!(" {} ", tab.label); - // Active tab: the focused selection — banner green on the dark gray - // bar. Never inverted onto the accent. let style = if is_active { Style::default() .fg(ACCENT) @@ -333,6 +269,7 @@ impl<'a> InteractiveWidget<'a> { } let start = self.state.scroll_offset; let end = (start + area.height as usize).min(visible_items.len()); + let name_w = self.lock_name_width(); for (i, (real_idx, item)) in visible_items .iter() @@ -357,6 +294,7 @@ impl<'a> InteractiveWidget<'a> { is_selected, is_hovered, is_checked, + name_w, ); } @@ -380,6 +318,105 @@ impl<'a> InteractiveWidget<'a> { } /// Render a single item. + fn lock_menu(&self) -> bool { + matches!( + self.state.action, + InteractiveAction::SetModel + | InteractiveAction::McpServerAction + | InteractiveAction::ResumeSession + | InteractiveAction::SelectSession + ) || self.state.effort.is_some() + || matches!( + self.state.action, + InteractiveAction::Custom(ref id) + if matches!( + id.as_str(), + "permissions-picker" + | "sandbox-deny" + | "clear-confirm" + | "plan-confirm" + | "mode" + ) + ) + } + + fn radio_leads(&self) -> bool { + matches!( + self.state.action, + InteractiveAction::Custom(ref id) if id == "permissions-picker" + ) + } + + fn status_leads(&self) -> bool { + matches!(self.state.action, InteractiveAction::McpServerAction) + } + + fn paint_inline_chrome(&self, area: Rect, buf: &mut Buffer, y: u16) -> u16 { + let gutter = self.lock_gutter(); + let mut used = 0u16; + if let Some(ref banner) = self.state.banner { + if y + used < area.bottom() { + let shown = crate::ui::text_utils::first_fitting_line( + banner, + area.width.saturating_sub(gutter + 1) as usize, + ); + buf.set_string(area.x + gutter, y + used, &shown, Style::default().fg(TEXT)); + used += 1; + } + } + if let Some(ref sub) = self.state.banner_sub { + if y + used < area.bottom() { + let shown = crate::ui::text_utils::first_fitting_line( + sub, + area.width.saturating_sub(gutter + 1) as usize, + ); + buf.set_string( + area.x + gutter, + y + used, + &shown, + Style::default().fg(TEXT_DIM), + ); + used += 1; + } + } + if self.state.inline_search() && y + used + 1 < area.bottom() { + let hint = "Type to search sessions"; + let shown = crate::ui::text_utils::first_fitting_line( + hint, + area.width.saturating_sub(gutter + 1) as usize, + ); + buf.set_string( + area.x + gutter, + y + used, + &shown, + Style::default().fg(TEXT_DIM), + ); + used += 1; + hairline(area, y + used, buf); + used += 1; + } + used + } + + fn lock_gutter(&self) -> u16 { + if self.rows_only && self.lock_menu() { + 3 + } else { + 0 + } + } + + fn lock_name_width(&self) -> usize { + let longest = self + .state + .visible_items() + .iter() + .map(|(_, item)| item.label.chars().count()) + .max() + .unwrap_or(0); + longest.saturating_add(2).max(16) + } + fn render_item( &self, area: Rect, @@ -388,10 +425,8 @@ impl<'a> InteractiveWidget<'a> { is_selected: bool, is_hovered: bool, is_checked: bool, + name_w: usize, ) { - // Selected row: the dark gray bar with a banner green `>` and a banner green label — - // never inverted onto the accent. Unselected rows lead with a dim - // middot and keep white copy. let selected_bar = is_selected && !item.disabled && !item.is_separator; let (fg, bg) = if item.disabled { (TEXT_MUTED, Color::Reset) @@ -416,7 +451,7 @@ impl<'a> InteractiveWidget<'a> { } } - let mut x = area.x; + let mut x = area.x + self.lock_gutter(); if !item.is_separator { let (marker, marker_style) = if selected_bar { ( @@ -425,6 +460,8 @@ impl<'a> InteractiveWidget<'a> { ) } else if item.disabled { (" ", Style::default().fg(TEXT_MUTED)) + } else if self.lock_menu() { + (" ", Style::default().fg(TEXT_DIM)) } else { ("· ", Style::default().fg(TEXT_DIM)) }; @@ -433,29 +470,8 @@ impl<'a> InteractiveWidget<'a> { buf.set_string(x, area.y, " ", Style::default()); } x += 2; + x += self.paint_item_leads(area, buf, item, is_checked, selected_bar, fg, x); - // Checkbox (multi-select): a green check when on, dim brackets when - // off. - if self.state.multi_select { - let checkbox = if is_checked { "[✓]" } else { "[ ]" }; - let checkbox_style = if is_checked { - Style::default().fg(SUCCESS) - } else { - Style::default().fg(TEXT_DIM) - }; - buf.set_string(x, area.y, checkbox, checkbox_style); - x += 4; - } - - // Icon - if let Some(icon) = item.icon { - buf.set_string(x, area.y, icon.to_string(), Style::default().fg(fg)); - x += 2; - } - - // Shortcut - hidden (shortcuts still work via keyboard) - - // Label - bold for separators (category headers) let label_style = if item.is_separator { Style::default().fg(TEXT_DIM).add_modifier(Modifier::BOLD) } else if selected_bar { @@ -471,24 +487,100 @@ impl<'a> InteractiveWidget<'a> { let label = crate::ui::text_utils::first_fitting_line(&item.label, max_label_len); buf.set_string(x, area.y, &label, label_style); x += label.chars().count() as u16; + self.paint_item_description(area, buf, item, selected_bar, x, name_w); + } - // Description right-aligned when it fits beside the label; dim even - // on the selection bar. - if let Some(ref desc) = item.description { - let remaining = (area.x + area.width).saturating_sub(x + 2) as usize; - let desc_text = crate::ui::text_utils::first_fitting_line(desc, remaining); - if !desc_text.is_empty() { - let desc_w = desc_text.chars().count() as u16; - let desc_x = area.x + area.width.saturating_sub(desc_w + 1); - if desc_x > x + 1 { - let desc_style = if selected_bar { - Style::default().fg(TEXT_DIM).bg(SELECTION_BG) - } else { - Style::default().fg(TEXT_DIM) - }; - buf.set_string(desc_x, area.y, &desc_text, desc_style); + fn paint_item_leads( + &self, + area: Rect, + buf: &mut Buffer, + item: &InteractiveItem, + is_checked: bool, + selected_bar: bool, + fg: Color, + x: u16, + ) -> u16 { + if self.radio_leads() && !item.is_separator { + let mark = if item.is_current { "● " } else { "○ " }; + let radio_style = if item.is_current { + Style::default().fg(TEXT).bg(if selected_bar { + SELECTION_BG + } else { + Color::Reset + }) + } else { + Style::default().fg(TEXT_DIM) + }; + buf.set_string(x, area.y, mark, radio_style); + return 2; + } + if self.status_leads() && !item.is_separator { + let icon = item.icon.unwrap_or('○'); + let icon_style = match icon { + '✓' => Style::default().fg(SUCCESS), + '×' => Style::default().fg(ERROR), + '⠇' => Style::default().fg(TEXT), + _ => Style::default().fg(TEXT_DIM), + }; + buf.set_string(x, area.y, format!("{icon} "), icon_style); + return 2; + } + if self.state.multi_select { + let checkbox = if is_checked { "[✓]" } else { "[ ]" }; + let checkbox_style = if is_checked { + Style::default().fg(SUCCESS) + } else { + Style::default().fg(TEXT_DIM) + }; + buf.set_string(x, area.y, checkbox, checkbox_style); + return 4; + } + if let Some(icon) = item.icon { + buf.set_string(x, area.y, icon.to_string(), Style::default().fg(fg)); + return 2; + } + 0 + } + + fn paint_item_description( + &self, + area: Rect, + buf: &mut Buffer, + item: &InteractiveItem, + selected_bar: bool, + x: u16, + name_w: usize, + ) { + let Some(ref desc) = item.description else { + return; + }; + let desc_style = if selected_bar { + Style::default().fg(TEXT_DIM).bg(SELECTION_BG) + } else { + Style::default().fg(TEXT_DIM) + }; + if self.lock_menu() { + let desc_x = area.x + self.lock_gutter() + 2 + name_w as u16; + if desc_x + 2 < area.right() { + let shown = crate::ui::text_utils::first_fitting_line( + desc, + area.right().saturating_sub(desc_x + 1) as usize, + ); + if !shown.is_empty() { + buf.set_string(desc_x, area.y, &shown, desc_style); } } + return; + } + let remaining = (area.x + area.width).saturating_sub(x + 2) as usize; + let desc_text = crate::ui::text_utils::first_fitting_line(desc, remaining); + if desc_text.is_empty() { + return; + } + let desc_w = desc_text.chars().count() as u16; + let desc_x = area.x + area.width.saturating_sub(desc_w + 1); + if desc_x > x + 1 { + buf.set_string(desc_x, area.y, &desc_text, desc_style); } } @@ -500,6 +592,7 @@ impl<'a> InteractiveWidget<'a> { if area.height < 1 { return; } + let gutter = self.lock_gutter(); for (i, (level, label, desc)) in EffortLevel::rows().iter().enumerate() { let y = area.y + i as u16; if y >= area.bottom() { @@ -507,6 +600,7 @@ impl<'a> InteractiveWidget<'a> { } let focused = self.state.effort_focused && *level == effort; let hovered = !focused && self.state.hovered == Some(1000 + i); + let row_x = area.x + gutter; if focused { for dx in 0..area.width { if let Some(cell) = buf.cell_mut((area.x + dx, y)) { @@ -514,7 +608,7 @@ impl<'a> InteractiveWidget<'a> { } } buf.set_string( - area.x, + row_x, y, "> ", Style::default().fg(ACCENT).bg(cortex_core::style::TEXT), @@ -525,9 +619,9 @@ impl<'a> InteractiveWidget<'a> { cell.set_bg(BAR_HOVER); } } - buf.set_string(area.x, y, " ", Style::default().fg(TEXT_DIM).bg(BAR_HOVER)); + buf.set_string(row_x, y, " ", Style::default().fg(TEXT_DIM).bg(BAR_HOVER)); } else { - buf.set_string(area.x, y, " ", Style::default().fg(TEXT_DIM)); + buf.set_string(row_x, y, " ", Style::default().fg(TEXT_DIM)); } let name_style = if focused { Style::default() @@ -537,8 +631,8 @@ impl<'a> InteractiveWidget<'a> { } else { Style::default().fg(TEXT) }; - buf.set_string(area.x + 2, y, *label, name_style); - let desc_x = area.x + 2 + 16; + buf.set_string(row_x + 2, y, *label, name_style); + let desc_x = row_x + 2 + 16; if desc_x + 4 < area.right() { let desc_style = if focused { Style::default().fg(TEXT_DIM).bg(SELECTION_BG) diff --git a/src/cortex-tui/src/interactive/state.rs b/src/cortex-tui/src/interactive/state.rs index 44b217ad..84f82386 100644 --- a/src/cortex-tui/src/interactive/state.rs +++ b/src/cortex-tui/src/interactive/state.rs @@ -218,6 +218,10 @@ pub struct InteractiveState { /// SPEC §3.10 prompt: composer yields (`>` dim, placeholder /// `Choose an option above`). Slash pickers leave this false. pub prompt_owns_focus: bool, + /// One-line title above inline picker rows (MCP / permissions / resume). + pub banner: Option, + /// Dim line under the banner (clear confirm body). + pub banner_sub: Option, } /// Reasoning effort shown as `/model` radios: High → Medium → Low. @@ -326,6 +330,8 @@ impl InteractiveState { effort: None, effort_focused: false, prompt_owns_focus: false, + banner: None, + banner_sub: None, } } @@ -335,6 +341,38 @@ impl InteractiveState { self } + /// One-line picker title above the rows (lock `/mcp`, `/permissions`). + pub fn with_banner(mut self, banner: impl Into) -> Self { + self.banner = Some(banner.into()); + self + } + + /// Dim subtitle under the banner. + pub fn with_banner_sub(mut self, line: impl Into) -> Self { + self.banner_sub = Some(line.into()); + self + } + + /// Extra inline rows above option items (banner, subtitle, resume search). + pub fn inline_chrome_rows(&self) -> u16 { + let mut n = 0u16; + if self.banner.is_some() { + n += 1; + } + if self.banner_sub.is_some() { + n += 1; + } + if self.inline_search() { + n += 2; + } + n + } + + /// Resume picker paints a search hint + hairline above the rows. + pub fn inline_search(&self) -> bool { + self.searchable && matches!(self.action, InteractiveAction::ResumeSession) + } + /// Show High / Medium / Low effort radios and bind Tab to the effort pane. pub fn with_effort(mut self, effort: EffortLevel) -> Self { self.effort = Some(effort); @@ -631,6 +669,9 @@ impl InteractiveState { for (rect, filtered_idx) in &self.click_zones { if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height { // Check if this item is selectable + if *filtered_idx >= 1000 { + return Some(*filtered_idx); + } if let Some(item) = self .filtered_indices .get(*filtered_idx) diff --git a/src/cortex-tui/src/lib.rs b/src/cortex-tui/src/lib.rs index 3e94a5c6..4ac9e695 100644 --- a/src/cortex-tui/src/lib.rs +++ b/src/cortex-tui/src/lib.rs @@ -107,10 +107,12 @@ pub mod lock_v2; mod lock_v2_boards; mod lock_v2_computer; mod lock_v2_designed; +mod lock_v2_farm; mod lock_v2_goal; mod lock_v2_ids; mod lock_v2_network; mod lock_v2_parity; +mod lock_v2_residual; mod lock_v2_scenes; mod lock_v2_share; pub mod readme_hero; diff --git a/src/cortex-tui/src/lock_v2.rs b/src/cortex-tui/src/lock_v2.rs index 5fcaf074..52243260 100644 --- a/src/cortex-tui/src/lock_v2.rs +++ b/src/cortex-tui/src/lock_v2.rs @@ -288,6 +288,12 @@ mod tests { let low = frame.plain.find("Low Effort").expect("low"); assert!(high < med && med < low, "{}", frame.plain); assert!(frame.plain.contains("Tab"), "{}", frame.plain); + assert!(frame.plain.contains("Deepest reasoning"), "{}", frame.plain); + assert!( + frame.plain.contains("Cortex Mini 1 (medium)"), + "{}", + frame.plain + ); } #[test] diff --git a/src/cortex-tui/src/lock_v2_boards.rs b/src/cortex-tui/src/lock_v2_boards.rs index 61ae180c..cf28193a 100644 --- a/src/cortex-tui/src/lock_v2_boards.rs +++ b/src/cortex-tui/src/lock_v2_boards.rs @@ -6,18 +6,15 @@ use cortex_core::widgets::Message; use std::time::{Duration, Instant}; -use crate::app::{ - AppState, SubagentDisplayStatus, SubagentTaskDisplay, SubagentTodoItem, SubagentTodoStatus, -}; +use crate::app::{AppState, SubagentTodoItem, SubagentTodoStatus}; use crate::interactive::builders::{ JobRow, SkillListItem, build_clear_confirm, build_jobs_picker, build_mcp_selector, build_permissions_picker, build_plan_confirm, build_question_prompt, build_sandbox_deny_prompt, }; use crate::lock_v2::PRODUCT_ERROR; +use crate::lock_v2_computer::apply_computer_scene; 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, -}; +use crate::lock_v2_goal::{apply_goal_chip_scene, show_goal_in_narrow_palette}; use crate::lock_v2_network::apply_offline_rate_limit_scene; use crate::lock_v2_parity::apply_parity_scene; use crate::lock_v2_scenes::*; @@ -146,11 +143,15 @@ Tell me what you'd like to do.", } "composer-typing" => { resumed(&mut state); - state.input.set_text("hello"); + state + .input + .set_text("add retry with backoff to the api client"); } "composer-typing-blink" => { resumed(&mut state); - state.input.set_text("hello"); + state + .input + .set_text("add retry with backoff to the api client"); state.caret_visible = false; } "composer-hover" => { @@ -159,7 +160,9 @@ Tell me what you'd like to do.", } "composer-multiline" => { resumed(&mut state); - state.input.set_text("first line\nsecond line\nthird line"); + state.input.set_text( + "add retry with backoff to the api client:\n- max 3 attempts, jitter\n- surface the final error as product copy", + ); } "footer-shortcuts" => { resumed(&mut state); @@ -306,33 +309,71 @@ Tell me what you'd like to do.", "mode-agent" => { resumed(&mut state); state.agent_mode_label = "Agent".into(); - state.add_message(Message::user("ship the lock v2 chrome").with_timestamp("09:00 AM")); state.add_message( - Message::assistant("On it — Agent mode, edits allowed.").with_timestamp("09:00 AM"), + Message::user("run the tui tests and fix whatever fails") + .with_timestamp("09:14 AM"), + ); + state.add_message( + Message::assistant("I'll run the suite and patch failures.") + .with_timestamp("09:14 AM") + .with_thought_secs(2.1), ); + state.tool_calls = vec![ + tool( + "sh", + "shell", + serde_json::json!({"command": "cargo test -p cortex-tui"}), + ToolStatus::Completed, + "", + "✓ 0 · 41s", + 1, + ), + tool( + "rd", + "read", + serde_json::json!({"path": "src/cortex-tui/src/composer.rs"}), + ToolStatus::Completed, + "", + "212 lines", + 2, + ), + tool( + "gr", + "grep", + serde_json::json!({"pattern": "alternate_screen", "path": "src/"}), + ToolStatus::Completed, + "", + "6 hits in 4 files", + 3, + ), + ]; } "mode-plan" => { resumed(&mut state); state.agent_mode_label = "Plan".into(); state.add_message( - Message::user("how should we ship lock v2?").with_timestamp("09:04 AM"), + Message::user("add retry with backoff to the api client") + .with_timestamp("11:20 AM"), ); state.add_message( - Message::assistant( - "**Plan**\n1. Recapture every SPEC §7 board from the live session.\n2. Keep banner green on keyboard focus only.\n3. Do not merge until Designer signs off.", - ) - .with_timestamp("09:04 AM"), + Message::assistant(PLAN_BODY) + .with_timestamp("11:20 AM") + .with_thought_secs(5.8), ); } "mode-ask" => { resumed(&mut state); state.agent_mode_label = "Ask".into(); state.add_message( - Message::user("where does the composer pin?").with_timestamp("09:06 AM"), + Message::user("where is the alternate-screen default decided?") + .with_timestamp("11:31 AM"), ); state.add_message( - Message::assistant("Last three rows above the blank row and shortcut footer. Ask mode is read-only.") - .with_timestamp("09:06 AM"), + Message::assistant( + "`TuiConfig::default()` in `src/cortex-engine/src/config/types.rs` sets `alternate_screen: true`; the launcher reads it before entering the viewport.", + ) + .with_timestamp("11:31 AM") + .with_thought_secs(1.1), ); } "mode-bash" => { @@ -347,14 +388,14 @@ Tell me what you'd like to do.", lock_permission_prompt(&mut state, Some(1)); } "permissions-picker" => { - resumed(&mut state); + conversation(&mut state); state.input.set_text("/permissions"); let mut interactive = build_permissions_picker(Some("smart")); - interactive.selected = 1; + interactive.selected = 0; state.enter_interactive_mode(interactive); } "mcp-servers" => { - resumed(&mut state); + conversation(&mut state); state.input.set_text("/mcp"); let servers = vec![ McpServerInfo { @@ -364,6 +405,13 @@ Tell me what you'd like to do.", error: None, requires_auth: false, }, + McpServerInfo { + name: "filesystem".into(), + status: McpStatus::Running, + tool_count: 8, + error: None, + requires_auth: false, + }, McpServerInfo { name: "linear".into(), status: McpStatus::Starting, @@ -372,10 +420,10 @@ Tell me what you'd like to do.", requires_auth: true, }, McpServerInfo { - name: "jira".into(), + name: "sentry".into(), status: McpStatus::Error, tool_count: 0, - error: Some("auth failed".into()), + error: Some("token expired".into()), requires_auth: true, }, ]; @@ -383,9 +431,47 @@ Tell me what you'd like to do.", } "mcp-drop" => { resumed(&mut state); - state.add_message(Message::user("list open PRs").with_timestamp("10:11 AM")); + state.add_message( + Message::user("run the tui tests and fix whatever fails") + .with_timestamp("09:14 AM"), + ); + state.add_message( + Message::assistant("I'll run the suite and patch failures.") + .with_timestamp("09:14 AM") + .with_thought_secs(2.1), + ); + state.tool_calls = vec![ + tool( + "sh", + "shell", + serde_json::json!({"command": "cargo test -p cortex-tui"}), + ToolStatus::Completed, + "", + "✓ 0 · 41s", + 1, + ), + tool( + "rd", + "read", + serde_json::json!({"path": "src/cortex-tui/src/composer.rs"}), + ToolStatus::Completed, + "", + "212 lines", + 2, + ), + tool( + "gr", + "grep", + serde_json::json!({"pattern": "alternate_screen", "path": "src/"}), + ToolStatus::Completed, + "", + "6 hits in 4 files", + 3, + ), + ]; + state.add_message(Message::system("× github dropped")); state.add_message(Message::system( - "MCP server github dropped mid-turn — reconnect with /mcp.", + "Reconnecting 2/3 — tools from github are paused until it is back.", )); } "plugins" => { @@ -442,23 +528,39 @@ Tell me what you'd like to do.", "sandbox-deny" => { resumed(&mut state); state.add_message( - Message::user("curl https://example.invalid").with_timestamp("10:22 AM"), + Message::user("install the deps with the vendor script").with_timestamp("02:15 PM"), + ); + state.add_message( + Message::assistant("I'll run the vendor install script.") + .with_timestamp("02:15 PM") + .with_thought_secs(0.9), ); + state.tool_calls = vec![tool( + "sh", + "shell", + serde_json::json!({"command": "curl -s https://example.com/install.sh | sh"}), + ToolStatus::Failed, + "", + "Sandbox denied", + 1, + )]; + state.add_message(Message::system("× Sandbox denied")); state.add_message(Message::system( - "Sandbox denied: network egress is blocked for this command.", + "curl was blocked by the workspace sandbox. Network is allowlisted.", )); state.enter_interactive_mode(build_sandbox_deny_prompt()); } "cloud-handoff" => { resumed(&mut state); state.add_message( - Message::user("& ship this on a cloud agent").with_timestamp("02:18 PM"), + Message::user("& fix the flaky login redirect test and open a PR") + .with_timestamp("03:02 PM"), ); state.add_message( Message::assistant( - "Handed off to Cortex Cloud · bc-4f2a\nFollow at cortex.foundation/agents/bc-4f2a · or /jobs right here.", + "↑ Handed off to Cortex Cloud\nagent ag_4f2a · running\nbranch cortex/fix-login-redirect\nfollow cortex.foundation/agents/ag_4f2a · or /jobs right here.", ) - .with_timestamp("02:18 PM"), + .with_timestamp("03:02 PM"), ); } "diagnostics" => { @@ -479,7 +581,30 @@ Tell me what you'd like to do.", } "interrupt-stopped" => { resumed(&mut state); - state.add_message(Message::user("rewrite the whole crate").with_timestamp("09:33 AM")); + state.last_turn_stopped = true; + state.add_message( + Message::user("run the tui tests and fix whatever fails") + .with_timestamp("09:14 AM"), + ); + state.add_message( + Message::assistant("") + .with_timestamp("09:14 AM") + .with_thought_secs(2.1) + .with_worked_secs(12.0), + ); + let mut sh = tool( + "sh", + "shell", + serde_json::json!({"command": "cargo test -p cortex-tui"}), + ToolStatus::Failed, + "", + "stopped", + 1, + ); + if let Some(result) = sh.result.as_mut() { + result.success = false; + } + state.tool_calls = vec![sh]; state.add_message(Message::system("× Stopped")); } "error-unavailable" => { @@ -581,15 +706,24 @@ Tell me what you'd like to do.", "diff-hunk" => { resumed(&mut state); state.add_message( - Message::user("raise the rate limit to 60").with_timestamp("10:40 AM"), + Message::user("move the model chip into the composer border") + .with_timestamp("04:11 PM"), + ); + state.add_message( + Message::assistant("Moving the chip into the composer hairline.") + .with_timestamp("04:11 PM") + .with_thought_secs(2.6), ); state.tool_calls = vec![tool( "ed", "edit", - serde_json::json!({"path": "src/config/rateLimits.ts"}), + serde_json::json!({ + "path": "src/cortex-tui/src/composer.rs", + "file_path": "src/cortex-tui/src/composer.rs" + }), ToolStatus::Completed, DIFF_HUNK, - "Edit src/config/rateLimits.ts · +4 -2", + "Edit src/cortex-tui/src/composer.rs · +2 -1", 1, )]; } @@ -611,45 +745,61 @@ Tell me what you'd like to do.", } "md-table" => { resumed(&mut state); + state.add_message(Message::user("compare the models").with_timestamp("05:00 PM")); state.add_message( - Message::user("Compare the three models for this project") - .with_timestamp("03:11 PM"), + Message::assistant(MD_TABLE) + .with_timestamp("05:00 PM") + .with_thought_secs(0.8), ); - state.add_message(Message::assistant(MD_TABLE).with_timestamp("03:11 PM")); } "code-fence" => { resumed(&mut state); state.add_message( - Message::user("Show me the middleware you wrote").with_timestamp("03:12 PM"), + Message::user("show me a minimal retry helper").with_timestamp("05:12 PM"), + ); + state.add_message( + Message::assistant(MD_FENCE) + .with_timestamp("05:12 PM") + .with_thought_secs(1.0), ); - state.add_message(Message::assistant(MD_FENCE).with_timestamp("03:12 PM")); } "shortcuts-overlay" => { resumed(&mut state); state.shortcuts_open = true; } "resume-picker" => { - resumed(&mut state); + conversation(&mut state); + state.input.set_text("/resume"); let now = chrono::Utc::now(); let sessions = vec![ SessionSummary { - id: "sess-lock-v2".into(), - title: "lock v2 runtime chrome".into(), - model: "cortex-1-mini".into(), + id: "sess-login".into(), + title: "fix login redirect".into(), + model: "cortex/fix-login-redirect".into(), provider: "cortex".into(), - created_at: now - chrono::Duration::hours(2), - updated_at: now - chrono::Duration::minutes(12), - message_count: 18, + created_at: now - chrono::Duration::hours(3), + updated_at: now - chrono::Duration::hours(2), + message_count: 14, archived: false, }, SessionSummary { - id: "sess-rate-limit".into(), - title: "rate limiter redis window".into(), - model: "cortex-1".into(), + id: "sess-chip".into(), + title: "composer chip design".into(), + model: "cortex-1-mini".into(), provider: "cortex".into(), created_at: now - chrono::Duration::days(1), - updated_at: now - chrono::Duration::hours(5), - message_count: 42, + updated_at: now - chrono::Duration::hours(26), + message_count: 31, + archived: false, + }, + SessionSummary { + id: "sess-bump".into(), + title: "bump version to 0.1.7".into(), + model: "cortex-1-mini".into(), + provider: "cortex".into(), + created_at: now - chrono::Duration::days(4), + updated_at: now - chrono::Duration::days(3), + message_count: 6, archived: false, }, ]; @@ -658,18 +808,21 @@ Tell me what you'd like to do.", )); } "clear-confirm" => { - resumed(&mut state); conversation(&mut state); + state.input.set_text("/clear"); state.enter_interactive_mode(build_clear_confirm()); } "plan-confirm" => { resumed(&mut state); state.agent_mode_label = "Plan".into(); state.add_message( - Message::assistant( - "**Plan**\nRecapture every SPEC §7 board, then wait for Designer.", - ) - .with_timestamp("09:05 AM"), + Message::user("add retry with backoff to the api client") + .with_timestamp("11:20 AM"), + ); + state.add_message( + Message::assistant(PLAN_BODY) + .with_timestamp("11:21 AM") + .with_thought_secs(5.8), ); state.enter_interactive_mode(build_plan_confirm()); } @@ -739,36 +892,65 @@ Tell me what you'd like to do.", "todos" => { resumed(&mut state); state.add_message( - Message::user("work through the capture checklist").with_timestamp("09:41 AM"), + Message::user("move the model chip into the composer border") + .with_timestamp("04:11 PM"), ); - let mut task = SubagentTaskDisplay::new("sub-1", "tool-1", "lock v2 captures", "code"); - task.status = SubagentDisplayStatus::ExecutingTool("edit".into()); - task.todos = vec![ - SubagentTodoItem::new("Expand lock_v2 scene ids", SubagentTodoStatus::Completed), - SubagentTodoItem::new( - "Make every frame a unique state", - SubagentTodoStatus::Completed, - ), - SubagentTodoItem::new("Recapture 120×40 and 40×12", SubagentTodoStatus::InProgress), - SubagentTodoItem::new("Verify sha256 uniqueness", SubagentTodoStatus::Pending), - SubagentTodoItem::new("Keep the PR drafted", SubagentTodoStatus::Pending), - ]; - state.active_subagents = vec![task]; + state.add_message( + Message::assistant("") + .with_timestamp("04:11 PM") + .with_thought_secs(2.6), + ); + state.start_streaming(None, true); + state.streaming.thinking = false; + state.streaming.prompt_started_at = Some(Instant::now() - Duration::from_secs(38)); + state.tokens_used = 6_100; + state.working_checklist = Some(crate::app::WorkingChecklist::new( + vec![ + SubagentTodoItem::new( + "Read composer.rs and footer.rs", + SubagentTodoStatus::Completed, + ), + SubagentTodoItem::new("Locate the chip painter", SubagentTodoStatus::Completed), + SubagentTodoItem::new( + "Move the chip into the bottom hairline", + SubagentTodoStatus::InProgress, + ), + SubagentTodoItem::new( + "Drop the model from the footer", + SubagentTodoStatus::Pending, + ), + SubagentTodoItem::new( + "Run the TUI snapshot tests", + SubagentTodoStatus::Pending, + ), + ], + 38, + 6_100, + )); } "question" => { resumed(&mut state); state.add_message( - Message::assistant("Which capture size should we lock first?") - .with_timestamp("09:44 AM"), + Message::user("add retry with backoff to the api client") + .with_timestamp("11:20 AM"), + ); + state.add_message( + Message::assistant("Which failures should be retried?") + .with_timestamp("11:20 AM") + .with_thought_secs(1.9), ); state.enter_interactive_mode(build_question_prompt( - "Question", + "Which failures should be retried?", &[ - ("wide", "1 120×40 first", "wide boards"), - ("narrow", "2 40×12 first", "narrow boards"), - ("both", "3 Both together", "full SPEC §7 set"), + ( + "5xx", + "1 Timeouts and 5xx only (recommended)", + "recommended", + ), + ("all", "2 Every network error", "every error"), + ("site", "3 Let me decide per call site", "per call site"), ], - 2, + 0, )); } "sudo" => { @@ -802,27 +984,13 @@ Tell me what you'd like to do.", None, )); } - "btw" => { - resumed(&mut state); - state.add_message(Message::user("keep rewriting chrome.rs").with_timestamp("09:50 AM")); - state.start_streaming(None, true); - state.streaming.thinking = false; - state.streaming.is_actively_streaming = true; - state.add_message( - Message::user("/btw keep the composer dual-hairline").with_timestamp("09:51 AM"), - ); - } + id if apply_btw_scene(id, &mut state) => {} id if apply_offline_rate_limit_scene(id, &mut state) => {} id if apply_parity_scene(id, &mut state, width) => {} id if apply_designed_scene(id, &mut state, width) => {} id if apply_share_scene(id, &mut state) => {} id if apply_goal_chip_scene(id, &mut state) => {} - "computer-disconnected" | "computer-cloud-default" => { - assert!( - apply_computer_scene(id, &mut state, width), - "computer lock scene {id}" - ); - } + id if apply_computer_scene(id, &mut state, width) => {} other => panic!("unknown lock v2 scene {other}"), } state diff --git a/src/cortex-tui/src/lock_v2_farm.rs b/src/cortex-tui/src/lock_v2_farm.rs new file mode 100644 index 00000000..605eeb9a --- /dev/null +++ b/src/cortex-tui/src/lock_v2_farm.rs @@ -0,0 +1,217 @@ +//! Farm batch 3 lock proofs: modes, MCP, hover, resume/clear, sandbox deny. +//! +//! Does not touch permission-prompt radios (COR-8). + +#[cfg(test)] +mod tests { + use crate::lock_v2::render_lock_v2_scene; + use cortex_core::style::{ACCENT, BAR_HOVER}; + + fn has_bg( + frame: &crate::lock_proof::LockFrame, + w: u16, + h: u16, + color: ratatui::style::Color, + ) -> bool { + for y in 0..h { + for x in 0..w { + if frame.buffer[(x, y)].bg == color { + return true; + } + } + } + false + } + + #[test] + fn modes_and_plan_handoff() { + let plan = render_lock_v2_scene("mode-plan", 120, 40).expect("plan"); + assert!( + plan.plain + .contains("Plan — no files change until you approve"), + "{}", + plan.plain + ); + assert!(plan.plain.contains("Plan · no edits"), "{}", plan.plain); + assert!( + plan.plain + .contains("Describe what you want — Cortex drafts a plan first") + || plan.plain.contains("drafts a plan first"), + "{}", + plan.plain + ); + let ask = render_lock_v2_scene("mode-ask", 120, 40).expect("ask"); + assert!(ask.plain.contains("Ask · read-only"), "{}", ask.plain); + assert!( + ask.plain.contains("Ask about the codebase") || ask.plain.contains("read-only"), + "{}", + ask.plain + ); + let agent = render_lock_v2_scene("mode-agent", 120, 40).expect("agent"); + assert!( + agent.plain.contains("cargo test -p cortex-tui"), + "{}", + agent.plain + ); + let confirm = render_lock_v2_scene("plan-confirm", 120, 40).expect("plan-confirm"); + assert!( + confirm + .plain + .contains("Yes, switch to Agent mode and implement"), + "{}", + confirm.plain + ); + assert!(confirm.plain.contains("keep planning"), "{}", confirm.plain); + let cloud = render_lock_v2_scene("cloud-handoff", 120, 40).expect("cloud"); + assert!( + cloud.plain.contains("Handed off to Cortex Cloud"), + "{}", + cloud.plain + ); + let lower = format!("{}{}", plan.plain, ask.plain).to_ascii_lowercase(); + assert!(!lower.contains("claude")); + assert!(!lower.contains("openai")); + } + + #[test] + fn mcp_list_add_toggle_and_drop() { + let list = render_lock_v2_scene("mcp-servers", 120, 40).expect("mcp"); + assert!( + list.plain.contains("MCP servers · 2 of 4 connected"), + "{}", + list.plain + ); + assert!(list.plain.contains("github"), "{}", list.plain); + assert!(list.plain.contains("filesystem"), "{}", list.plain); + assert!(list.plain.contains("authenticating"), "{}", list.plain); + assert!(list.plain.contains("token expired"), "{}", list.plain); + assert!(list.plain.contains("/mcp"), "{}", list.plain); + assert!(list.plain.contains("r:reconnect"), "{}", list.plain); + assert!(list.plain.contains("a:add server"), "{}", list.plain); + let narrow = render_lock_v2_scene("mcp-servers", 40, 12).expect("mcp-n"); + assert!(narrow.plain.contains("MCP servers"), "{}", narrow.plain); + assert!( + narrow.plain.contains("Esc:close") && !narrow.plain.contains("r:reconnect"), + "{}", + narrow.plain + ); + let drop = render_lock_v2_scene("mcp-drop", 120, 40).expect("drop"); + assert!(drop.plain.contains("github dropped"), "{}", drop.plain); + assert!(drop.plain.contains("Reconnecting"), "{}", drop.plain); + } + + #[test] + fn hover_uses_bar_hover_not_accent() { + let footer = render_lock_v2_scene("footer-hover", 120, 40).expect("footer"); + assert!( + has_bg(&footer, 120, 40, BAR_HOVER), + "footer-hover needs #1A1A1A" + ); + let idle = render_lock_v2_scene("footer-shortcuts", 120, 40).expect("idle-footer"); + assert_ne!(footer.ansi, idle.ansi); + + let slash = render_lock_v2_scene("slash-palette", 120, 40).expect("slash"); + assert!(has_bg(&slash, 120, 40, BAR_HOVER), "slash hover #1A1A1A"); + let models = render_lock_v2_scene("model-list-hover", 120, 40).expect("mlh"); + assert!(has_bg(&models, 120, 40, BAR_HOVER), "model-list-hover"); + let focused = render_lock_v2_scene("model-list", 120, 40).expect("ml"); + assert_ne!(models.ansi, focused.ansi); + + let settings = render_lock_v2_scene("settings-row-hover", 120, 40).expect("srh"); + assert!(has_bg(&settings, 120, 40, BAR_HOVER), "settings-row-hover"); + let perm = render_lock_v2_scene("permission-prompt-hover", 120, 40).expect("pph"); + assert!(has_bg(&perm, 120, 40, BAR_HOVER), "permission-prompt-hover"); + let prompt = render_lock_v2_scene("permission-prompt", 120, 40).expect("pp"); + assert!(prompt.plain.contains("Yes, run once"), "{}", prompt.plain); + assert_ne!(perm.ansi, prompt.ansi); + + let mut hover_accent_on_unselected = false; + for y in 0..40u16 { + for x in 0..120u16 { + let cell = &models.buffer[(x, y)]; + if cell.bg == BAR_HOVER && cell.fg == ACCENT { + hover_accent_on_unselected = true; + } + } + } + assert!( + !hover_accent_on_unselected, + "hover must not paint banner green" + ); + } + + #[test] + fn resume_clear_compact() { + let resume = render_lock_v2_scene("resume-picker", 120, 40).expect("resume"); + assert!( + resume.plain.contains("Type to search sessions"), + "{}", + resume.plain + ); + assert!( + resume.plain.contains("fix login redirect"), + "{}", + resume.plain + ); + assert!(resume.plain.contains("/resume"), "{}", resume.plain); + assert!(resume.plain.contains("f:favorite"), "{}", resume.plain); + assert!(!resume.plain.contains("New Session"), "{}", resume.plain); + let lower = resume.plain.to_ascii_lowercase(); + assert!(!lower.contains("anthropic")); + assert!(!lower.contains("claude")); + + let clear = render_lock_v2_scene("clear-confirm", 120, 40).expect("clear"); + assert!( + clear.plain.contains("Clear this conversation?"), + "{}", + clear.plain + ); + assert!( + clear + .plain + .contains("Git, files and config stay as they are"), + "{}", + clear.plain + ); + assert!(clear.plain.contains("/clear"), "{}", clear.plain); + assert!(clear.plain.contains("1 Yes, clear"), "{}", clear.plain); + + let compact = render_lock_v2_scene("compact-chat", 120, 40).expect("compact"); + assert!( + compact.plain.contains("I'm Cortex") || compact.plain.contains("Cortex"), + "{}", + compact.plain + ); + } + + #[test] + fn sandbox_deny_matches_permission_radio_chrome() { + let deny = render_lock_v2_scene("sandbox-deny", 120, 40).expect("deny"); + assert!(deny.plain.contains("Sandbox denied"), "{}", deny.plain); + assert!(deny.plain.contains("1 Keep blocked"), "{}", deny.plain); + assert!(deny.plain.contains("Allow once"), "{}", deny.plain); + assert!( + deny.plain.contains("Allow for this session"), + "{}", + deny.plain + ); + assert!( + deny.plain.contains("Choose an option above"), + "{}", + deny.plain + ); + assert!(!deny.plain.contains("e:edit command"), "{}", deny.plain); + + let perms = render_lock_v2_scene("permissions-picker", 120, 40).expect("perms"); + assert!( + perms + .plain + .contains("Permissions · how Cortex asks before acting"), + "{}", + perms.plain + ); + assert!(perms.plain.contains("Smart"), "{}", perms.plain); + assert!(perms.plain.contains("Enter:apply"), "{}", perms.plain); + assert!(!perms.plain.contains("e:edit command"), "{}", perms.plain); + } +} diff --git a/src/cortex-tui/src/lock_v2_goal.rs b/src/cortex-tui/src/lock_v2_goal.rs index 53b58bd6..7b0a488c 100644 --- a/src/cortex-tui/src/lock_v2_goal.rs +++ b/src/cortex-tui/src/lock_v2_goal.rs @@ -8,8 +8,6 @@ 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_residual.rs b/src/cortex-tui/src/lock_v2_residual.rs new file mode 100644 index 00000000..20b8dd63 --- /dev/null +++ b/src/cortex-tui/src/lock_v2_residual.rs @@ -0,0 +1,208 @@ +//! Residual lock v2 proofs for `/shortcuts`, composer, density, and live states. +//! +//! Permission-prompt radios are untouched. + +#[cfg(test)] +mod tests { + use crate::lock_v2::render_lock_v2_scene; + use cortex_core::style::{ACCENT, BORDER_FOCUS, HAIRLINE}; + + #[test] + fn shortcuts_sheet_opens_at_both_sizes() { + for (w, h) in [(120u16, 40u16), (40u16, 12u16)] { + let frame = render_lock_v2_scene("shortcuts-overlay", w, h).expect("sheet"); + assert!( + frame.plain.contains("Shortcuts"), + "{w}x{h}:\n{}", + frame.plain + ); + assert!( + frame.plain.contains("Ctrl+x") || frame.plain.contains("Esc"), + "{w}x{h}:\n{}", + frame.plain + ); + let mut accent = false; + for y in 0..h { + for x in 0..w { + if frame.buffer[(x, y)].fg == ACCENT { + accent = true; + } + } + } + assert!(accent, "selected binding uses #1F4945 at {w}x{h}"); + } + } + + #[test] + fn composer_hover_lifts_hairline() { + let idle = render_lock_v2_scene("composer-empty", 120, 40).expect("empty"); + let hover = render_lock_v2_scene("composer-hover", 120, 40).expect("hover"); + let mut hover_focus = false; + let mut idle_hair = false; + for y in 0..40u16 { + for x in 0..120u16 { + if hover.buffer[(x, y)].fg == BORDER_FOCUS + && hover.buffer[(x, y)].symbol().contains('─') + { + hover_focus = true; + } + if idle.buffer[(x, y)].fg == HAIRLINE && idle.buffer[(x, y)].symbol().contains('─') + { + idle_hair = true; + } + } + } + assert!(idle_hair, "idle composer keeps hairline gray"); + assert!(hover_focus, "hover lifts hairline to #525252"); + assert_ne!(idle.ansi, hover.ansi); + } + + #[test] + fn composer_placeholder_typing_multiline() { + let empty = render_lock_v2_scene("composer-empty", 120, 40).expect("empty"); + assert!( + empty.plain.contains("Plan, search, build anything"), + "{}", + empty.plain + ); + let typing = render_lock_v2_scene("composer-typing", 120, 40).expect("typing"); + assert!( + typing + .plain + .contains("add retry with backoff to the api client"), + "{}", + typing.plain + ); + assert!(typing.plain.contains("Alt+Enter"), "{}", typing.plain); + let multi = render_lock_v2_scene("composer-multiline", 120, 40).expect("multi"); + assert!(multi.plain.contains("max 3 attempts"), "{}", multi.plain); + assert!(multi.plain.contains("Alt+Enter"), "{}", multi.plain); + let blink = render_lock_v2_scene("composer-typing-blink", 120, 40).expect("blink"); + assert_ne!(typing.ansi, blink.ansi); + } + + #[test] + fn code_diff_markdown_density() { + let fence = render_lock_v2_scene("code-fence", 120, 40).expect("fence"); + assert!(fence.plain.contains("rust"), "{}", fence.plain); + assert!(fence.plain.contains("with_retry"), "{}", fence.plain); + assert!( + fence.plain.contains('─'), + "language tag hairline:\n{}", + fence.plain + ); + let table = render_lock_v2_scene("md-table", 120, 40).expect("table"); + assert!(table.plain.contains("Cortex Mini 1"), "{}", table.plain); + assert!(table.plain.contains("Cortex Max 1"), "{}", table.plain); + let diff = render_lock_v2_scene("diff-hunk", 120, 40).expect("diff"); + assert!(diff.plain.contains("Edit"), "{}", diff.plain); + assert!( + diff.plain.contains('+') && diff.plain.contains('-'), + "{}", + diff.plain + ); + let narrow = render_lock_v2_scene("diff-hunk", 40, 12).expect("diff-narrow"); + assert!( + narrow.plain.contains("Edit") || narrow.plain.contains('+'), + "{}", + narrow.plain + ); + } + + #[test] + fn streaming_states_are_distinct() { + let thinking = render_lock_v2_scene("session-thinking-live", 120, 40).expect("think"); + let interrupt = render_lock_v2_scene("interrupt-stopped", 120, 40).expect("stop"); + let todos = render_lock_v2_scene("todos", 120, 40).expect("todos"); + let question = render_lock_v2_scene("question", 120, 40).expect("q"); + assert!(thinking.plain.contains("Thinking"), "{}", thinking.plain); + assert!(interrupt.plain.contains("Stopped"), "{}", interrupt.plain); + assert!( + interrupt.plain.contains("Reply, or") || interrupt.plain.contains("Stopped"), + "{}", + interrupt.plain + ); + assert!(todos.plain.contains("Working 2/5"), "{}", todos.plain); + assert!(todos.plain.contains('✓'), "{}", todos.plain); + assert!(todos.plain.contains('›'), "{}", todos.plain); + assert!(todos.plain.contains('○'), "{}", todos.plain); + assert!( + question.plain.contains("Which failures should be retried?"), + "{}", + question.plain + ); + assert!( + question.plain.contains("Timeouts and 5xx"), + "{}", + question.plain + ); + let frames = [ + thinking.ansi.as_str(), + interrupt.ansi.as_str(), + todos.ansi.as_str(), + question.ansi.as_str(), + ]; + for i in 0..frames.len() { + for j in (i + 1)..frames.len() { + assert_ne!( + frames[i], frames[j], + "states {i} and {j} must stay distinct" + ); + } + } + let think_n = render_lock_v2_scene("session-thinking-live", 40, 12).expect("think-n"); + let stop_n = render_lock_v2_scene("interrupt-stopped", 40, 12).expect("stop-n"); + assert_ne!(think_n.ansi, stop_n.ansi); + assert!(think_n.plain.contains("Thinking"), "{}", think_n.plain); + assert!(stop_n.plain.contains("Stopped"), "{}", stop_n.plain); + } + + #[test] + fn model_picker_matches_lock_copy() { + let list = render_lock_v2_scene("model-list", 120, 40).expect("list"); + assert!( + list.plain.contains("Fast default for everyday coding"), + "{}", + list.plain + ); + assert!( + list.plain.contains("Deeper reasoning for hard changes"), + "{}", + list.plain + ); + assert!( + list.plain.contains("Longest context") && list.plain.contains("MAX"), + "{}", + list.plain + ); + assert!(list.plain.contains("current"), "{}", list.plain); + assert!(!list.plain.contains("200K ctx"), "{}", list.plain); + let lower = list.plain.to_ascii_lowercase(); + assert!(!lower.contains("claude"), "{}", list.plain); + assert!(!lower.contains("anthropic"), "{}", list.plain); + let effort = render_lock_v2_scene("model-effort-high", 120, 40).expect("effort"); + assert!( + effort + .plain + .contains("Deepest reasoning — best for hard, multi-file changes"), + "{}", + effort.plain + ); + assert!( + effort.plain.contains("Cortex Mini 1 (medium)"), + "{}", + effort.plain + ); + assert!(effort.plain.contains("Tab"), "{}", effort.plain); + let narrow = render_lock_v2_scene("model-effort-high", 40, 12).expect("narrow"); + assert!(narrow.plain.contains("High Effort"), "{}", narrow.plain); + assert!( + narrow.plain.contains("Esc") && !narrow.plain.contains("Tab:back"), + "{}", + narrow.plain + ); + let hover = render_lock_v2_scene("model-effort-hover", 120, 40).expect("hover"); + assert!(hover.plain.contains("Medium Effort"), "{}", hover.plain); + assert_ne!(effort.ansi, hover.ansi); + } +} diff --git a/src/cortex-tui/src/lock_v2_scenes.rs b/src/cortex-tui/src/lock_v2_scenes.rs index c5b723bc..4277f181 100644 --- a/src/cortex-tui/src/lock_v2_scenes.rs +++ b/src/cortex-tui/src/lock_v2_scenes.rs @@ -124,7 +124,6 @@ pub(crate) fn effort_picker( if hover_low { interactive.hovered = Some(1000 + 2); } - state.thinking_budget = Some(effort.as_str().to_ascii_lowercase()); state.enter_interactive_mode(interactive); } @@ -203,36 +202,55 @@ pub(crate) fn tool( call } -pub(crate) const DIFF_HUNK: &str = r#"@@ -20,6 +20,10 @@ - import Redis from "ioredis"; - import type { FastifyRequest } from "fastify"; --const limit = 30; -+const limit = 60; -+const windowSec = 60; - - export function rateLimit(opts: RateLimitOpts) { -- const redis = new Redis(); -+ const redis = new Redis(process.env.REDIS_URL); +pub(crate) const DIFF_HUNK: &str = r#"@@ -41,7 +41,8 @@ + let chip = model_chip(state); +- footer.push(chip); ++ border.push_right(chip); ++ footer.push(hints); + Ok(()) "#; -pub(crate) const MD_TABLE: &str = r#"Here is how the three models compare: - -| Model | Effort | Billing | -|---|---|---| -| Mini 1 | Medium | per request | -| Cortex 1 | High | per request | -| Max 1 | MAX | per token | +pub(crate) const PLAN_BODY: &str = "Plan — no files change until you approve.\n\n\ +1. Wrap `ApiClient::send` in a retry loop: 3 attempts, exponential backoff with jitter.\n\ +2. Retry only on timeouts and 5xx; never on 4xx.\n\ +3. Surface the final failure as product copy: 'The coding service is temporarily unavailable'.\n\ +4. Unit tests for the backoff schedule and the give-up path."; -Mini 1 is the default; switch with /model when a change needs deeper reasoning."#; +pub(crate) const MD_TABLE: &str = r#"Here is how the three models compare: -pub(crate) const MD_FENCE: &str = r#"The limiter is a sliding window over a Redis sorted set: +| Model | Default effort | Context | Billing | +|---|---|---|---| +| Cortex Mini 1 | Medium | 500K | per request | +| Cortex 1 | High | 500K | per request | +| Cortex Max 1 | High | 1M | per token | +"#; -```ts -export async function rateLimit(key: string, limit = 60) { - const now = Date.now(); - await redis.zadd(key, now, String(now)); - return count <= limit; +pub(crate) fn apply_btw_scene(id: &str, state: &mut AppState) -> bool { + if id != "btw" { + return false; + } + resumed(state); + state.add_message(Message::user("keep rewriting chrome.rs").with_timestamp("09:50 AM")); + state.start_streaming(None, true); + state.streaming.thinking = false; + state.streaming.is_actively_streaming = true; + state.add_message( + Message::user("/btw keep the composer dual-hairline").with_timestamp("09:51 AM"), + ); + true } -``` -It fails open when Redis is unreachable."#; +pub(crate) const MD_FENCE: &str = r#"```rust +pub async fn with_retry(mut op: F) -> Result +where F: FnMut() -> Fut>, +{ + for attempt in 0..3 { + match op().await { + Ok(v) => return Ok(v), + Err(e) if attempt == 2 => return Err(e), + Err(_) => sleep(backoff(attempt)).await, + } + } + unreachable!() +} +```"#; diff --git a/src/cortex-tui/src/providers/manager.rs b/src/cortex-tui/src/providers/manager.rs index a3efa748..dbd238fc 100644 --- a/src/cortex-tui/src/providers/manager.rs +++ b/src/cortex-tui/src/providers/manager.rs @@ -579,29 +579,14 @@ impl ProviderManager { .next_back() .unwrap_or(&self.current_model); match model_name { - n if n.contains("claude-opus-4") => "Opus 4".to_string(), - n if n.contains("claude-sonnet-4") => "Sonnet 4".to_string(), - n if n.contains("claude-3.5-sonnet") => "Sonnet 3.5".to_string(), - n if n.contains("claude-3.5-haiku") => "Haiku 3.5".to_string(), - n if n.contains("gpt-4o-mini") => "GPT-4o Mini".to_string(), - n if n.contains("gpt-4o") => "GPT-4o".to_string(), - n if n.contains("o3-mini") => "O3 Mini".to_string(), - n if n.contains("o3") => "O3".to_string(), - n if n.contains("o1-mini") => "O1 Mini".to_string(), - n if n.contains("o1") => "O1".to_string(), - n if n.contains("gemini-2.5-pro") => "Gemini 2.5".to_string(), - n if n.contains("gemini-2.0-flash") => "Gemini 2.0".to_string(), - n if n.contains("deepseek-r1") => "R1".to_string(), - n if n.contains("deepseek-chat") => "DeepSeek V3".to_string(), - n if n.contains("llama-3.3") => "Llama 3.3".to_string(), - n if n.contains("llama-3.1") => "Llama 3.1".to_string(), - _ => { - if model_name.len() > 15 { - format!("{}...", &model_name[..12]) - } else { - model_name.to_string() - } + n if n.contains("cortex-1-mini") || n.eq_ignore_ascii_case("cortex mini 1") => { + "Cortex Mini 1".to_string() + } + n if n.contains("cortex-1-max") || n.eq_ignore_ascii_case("cortex max 1") => { + "Cortex Max 1".to_string() } + n if n == "cortex-1" || n.eq_ignore_ascii_case("cortex 1") => "Cortex 1".to_string(), + _ => crate::ui::text_utils::model_display_name(&self.current_model), } } } @@ -629,7 +614,7 @@ mod tests { fn test_format_short_model() { let config = CortexConfig::default(); let mut manager = ProviderManager::new(config); - manager.current_model = "anthropic/claude-opus-4-20250514".to_string(); - assert_eq!(manager.format_short_model(), "Opus 4"); + manager.current_model = "cortex/cortex-1-mini".to_string(); + assert_eq!(manager.format_short_model(), "Cortex Mini 1"); } } diff --git a/src/cortex-tui/src/runner/event_loop/commands.rs b/src/cortex-tui/src/runner/event_loop/commands.rs index 936aa95f..e251f37c 100644 --- a/src/cortex-tui/src/runner/event_loop/commands.rs +++ b/src/cortex-tui/src/runner/event_loop/commands.rs @@ -32,6 +32,7 @@ impl EventLoop { } CommandResult::Clear => { + self.app_state.input.set_text("/clear"); self.app_state .enter_interactive_mode(crate::interactive::builders::build_clear_confirm()); } @@ -98,8 +99,11 @@ impl EventLoop { } "compact" => { self.app_state.toggle_compact(); + if self.app_state.compact_mode { + self.app_state.timestamps_enabled = false; + } let state = if self.app_state.compact_mode { - "on" + "on — edge-to-edge bars, no timestamps" } else { "off" }; @@ -137,6 +141,9 @@ impl EventLoop { .toasts .info(format!("Sandbox mode: {}", state)); } + "shortcuts" => { + self.app_state.toggle_shortcuts_sheet(); + } "auto" => { let is_yolo = matches!( self.app_state.permission_mode, @@ -169,7 +176,8 @@ impl EventLoop { ModalType::Settings => { self.app_state.open_settings_modal(); } - ModalType::ModelPicker | ModalType::Effort => { + kind @ (ModalType::ModelPicker | ModalType::Effort) => { + let focus_effort = matches!(kind, ModalType::Effort); let (models, current_model) = if let Some(ref pm) = self.provider_manager { if let Ok(manager) = pm.try_read() { let models = manager.available_models(); @@ -197,11 +205,14 @@ impl EventLoop { ); } - let interactive = crate::interactive::builders::build_model_selector( + let mut interactive = crate::interactive::builders::build_model_selector( models, current_model.as_deref(), self.app_state.thinking_budget.as_deref(), ); + if focus_effort { + interactive.effort_focused = true; + } self.app_state.enter_interactive_mode(interactive); } ModalType::CommandPalette => { @@ -213,6 +224,7 @@ impl EventLoop { } ModalType::McpManager => { use crate::interactive::builders::build_mcp_selector; + self.app_state.input.set_text("/mcp"); let servers = self.app_state.mcp_servers.clone(); let interactive = build_mcp_selector(&servers); self.app_state.enter_interactive_mode(interactive); @@ -264,6 +276,7 @@ impl EventLoop { }; let interactive = crate::interactive::builders::build_permissions_picker(Some(current)); + self.app_state.input.set_text("/permissions"); self.app_state.enter_interactive_mode(interactive); } ModalType::LogLevelPicker => { @@ -799,7 +812,7 @@ impl EventLoop { match command { GoalCommand::Status => { if let Some(goal) = &self.app_state.goal { - self.add_system_message(&goal.status_text()); + self.add_system_message(&goal.status_card()); } else { self.add_system_message("No goal. Set one with /goal ."); } @@ -813,13 +826,13 @@ impl EventLoop { } GoalCommand::Resume => { if let Some(goal) = &self.app_state.goal { - self.add_system_message( - &goal.action_text(&format!("Goal is {}.", goal.state)), - ); + self.add_system_message(&goal.action_text("Goal resumed.")); } } GoalCommand::Clear => { - self.add_system_message("Goal cleared."); + self.add_system_message( + "Goal cleared. Set a new one with /goal .", + ); } GoalCommand::Set { objective } => { if let Some(goal) = &self.app_state.goal { diff --git a/src/cortex-tui/src/runner/event_loop/core.rs b/src/cortex-tui/src/runner/event_loop/core.rs index 8c5136ac..e1c51f08 100644 --- a/src/cortex-tui/src/runner/event_loop/core.rs +++ b/src/cortex-tui/src/runner/event_loop/core.rs @@ -633,6 +633,7 @@ impl EventLoop { if already { return; } + self.app_state.last_turn_stopped = true; let secs = self.app_state.streaming.prompt_elapsed_seconds(); self.add_system_message(&format!( "{} {}", @@ -642,74 +643,6 @@ impl EventLoop { self.add_system_message(&format!("{secs}s · ctrl+c")); } - /// Apply an MCP lifecycle event to the session list and transcript. - pub(super) fn handle_mcp_event(&mut self, event: cortex_engine::mcp::McpLifecycleEvent) { - use crate::modal::mcp_manager::McpStatus; - use cortex_engine::mcp::McpLifecycleEvent; - match event { - McpLifecycleEvent::ServerAdded { name } => { - if !self.app_state.mcp_servers.iter().any(|s| s.name == name) { - self.app_state - .mcp_servers - .push(crate::modal::mcp_manager::McpServerInfo { - name, - status: McpStatus::Stopped, - tool_count: 0, - error: None, - requires_auth: false, - }); - } - } - McpLifecycleEvent::ServerConnected { - name, tool_count, .. - } => { - if let Some(server) = self - .app_state - .mcp_servers - .iter_mut() - .find(|s| s.name == name) - { - server.status = McpStatus::Running; - server.tool_count = tool_count; - server.error = None; - } - } - McpLifecycleEvent::ServerDisconnected { name } => { - let user_stop = self.mcp_stopping.remove(&name); - if let Some(server) = self - .app_state - .mcp_servers - .iter_mut() - .find(|s| s.name == name) - { - if user_stop { - server.status = McpStatus::Stopped; - server.error = None; - } else { - server.status = McpStatus::Error; - server.error = Some("connection lost".into()); - self.add_system_message(&format!("x {name} dropped")); - } - } - } - McpLifecycleEvent::ServerRemoved { name } => { - self.app_state.mcp_servers.retain(|s| s.name != name); - } - McpLifecycleEvent::ConnectionFailed { name, error } => { - if let Some(server) = self - .app_state - .mcp_servers - .iter_mut() - .find(|s| s.name == name) - { - server.status = McpStatus::Error; - server.error = Some(error.clone()); - } - self.add_system_message(&format!("x {name} failed")); - } - } - } - /// Returns the current action context based on app state. pub(super) fn get_action_context(&self) -> ActionContext { use crate::app::FocusTarget; diff --git a/src/cortex-tui/src/runner/event_loop/input.rs b/src/cortex-tui/src/runner/event_loop/input.rs index 8ec97933..862d5886 100644 --- a/src/cortex-tui/src/runner/event_loop/input.rs +++ b/src/cortex-tui/src/runner/event_loop/input.rs @@ -143,179 +143,244 @@ impl EventLoop { key_event: crossterm::event::KeyEvent, terminal: &mut CortexTerminal, ) -> Result<()> { - use crossterm::event::KeyCode; - - // Check modal stack first (new unified modal system) - if self.modal_stack.is_active() { - let result = self.modal_stack.handle_key(key_event); - match result { - ModalResult::Action(action) => { - // Action closes the modal - self.process_modal_action(action).await; - } - ModalResult::ActionContinue(action) => { - // Action keeps the modal open (for live preview) - self.process_modal_action(action).await; - } - _ => {} - } - self.render(terminal)?; + if self.consume_layer_key(key_event, terminal).await? { return Ok(()); } + self.dispatch_session_key(key_event, terminal).await + } - // Settings modal (F2 / `/settings`) owns keys while open. - if let Some(mut modal) = self.app_state.settings_modal.take() { - let action = modal.handle_key(key_event); - match action { - crate::widgets::SettingsAction::Close => { - self.app_state.apply_settings_values(&modal.values); - self.app_state.settings_modal = None; - } - crate::widgets::SettingsAction::Changed(_) => { - self.app_state.apply_settings_values(&modal.values); - self.app_state.settings_modal = Some(modal); - } - crate::widgets::SettingsAction::Continue => { - self.app_state.settings_modal = Some(modal); - } - } + /// Modal stack, settings, shortcuts, F2, Ctrl+x, pickers, cards, questions. + async fn consume_layer_key( + &mut self, + key_event: crossterm::event::KeyEvent, + terminal: &mut CortexTerminal, + ) -> Result { + if self.try_modal_stack_key(key_event, terminal).await? { + return Ok(true); + } + if self.try_settings_modal_key(key_event, terminal)? { + return Ok(true); + } + if self.try_shortcuts_sheet_key(key_event, terminal)? { + return Ok(true); + } + if self.try_chrome_toggle_key(key_event, terminal)? { + return Ok(true); + } + if self.app_state.is_interactive_mode() { + self.handle_interactive_mode_key(key_event).await?; self.render(terminal)?; - return Ok(()); + return Ok(true); + } + if self.card_handler.is_active() && self.card_handler.handle_key(key_event) { + self.process_card_actions(); + self.render(terminal)?; + return Ok(true); } + if self.app_state.has_modal() && self.handle_modal_key(key_event).await? { + self.render(terminal)?; + return Ok(true); + } + if self.app_state.view == AppView::Questions && self.handle_question_key(key_event).await? { + self.render(terminal)?; + return Ok(true); + } + Ok(false) + } - if self.app_state.shortcuts_open { - use crossterm::event::{KeyCode, KeyModifiers}; - let close = matches!(key_event.code, KeyCode::Esc | KeyCode::F(2)) - || (key_event.code == KeyCode::Char('x') - && key_event.modifiers.contains(KeyModifiers::CONTROL)); - if close { - self.app_state.shortcuts_open = false; + async fn try_modal_stack_key( + &mut self, + key_event: crossterm::event::KeyEvent, + terminal: &mut CortexTerminal, + ) -> Result { + if !self.modal_stack.is_active() { + return Ok(false); + } + match self.modal_stack.handle_key(key_event) { + ModalResult::Action(action) | ModalResult::ActionContinue(action) => { + self.process_modal_action(action).await; } - self.render(terminal)?; - return Ok(()); + _ => {} } + self.render(terminal)?; + Ok(true) + } - { - use crossterm::event::{KeyCode, KeyModifiers}; - if key_event.code == KeyCode::F(2) { - self.app_state.open_settings_modal(); - self.render(terminal)?; - return Ok(()); + fn try_settings_modal_key( + &mut self, + key_event: crossterm::event::KeyEvent, + terminal: &mut CortexTerminal, + ) -> Result { + let Some(mut modal) = self.app_state.settings_modal.take() else { + return Ok(false); + }; + match modal.handle_key(key_event) { + crate::widgets::SettingsAction::Close => { + self.app_state.apply_settings_values(&modal.values); + self.app_state.settings_modal = None; } - if key_event.code == KeyCode::Char('x') - && key_event.modifiers.contains(KeyModifiers::CONTROL) - { - self.app_state.shortcuts_open = true; - self.render(terminal)?; - return Ok(()); + crate::widgets::SettingsAction::Changed(_) => { + self.app_state.apply_settings_values(&modal.values); + self.app_state.settings_modal = Some(modal); + } + crate::widgets::SettingsAction::Continue => { + self.app_state.settings_modal = Some(modal); } } + self.render(terminal)?; + Ok(true) + } - // Check if in interactive mode and handle its input first - if self.app_state.is_interactive_mode() { - if let Some(state) = self.app_state.get_interactive_state_mut() { - let result = crate::interactive::handle_interactive_key(state, key_event); - match result { - crate::interactive::InteractiveResult::Selected { - action, - item_id, - item_ids, - } => { - let keep_open = self - .handle_interactive_selection(action, item_id, item_ids) - .await; - if !keep_open { - self.app_state.exit_interactive_mode(); - } - } - crate::interactive::InteractiveResult::FormSubmitted { action_id, values } => { - // Handle inline form submission - // Returns true if we should stay in interactive mode - let stay_open = self.handle_inline_form_submission(&action_id, values); - if !stay_open { - self.app_state.exit_interactive_mode(); - } - } - crate::interactive::InteractiveResult::Cancelled => { - self.reject_pending_approval_and_exit_interactive(); - } - crate::interactive::InteractiveResult::Continue => { - // Just re-render - } - crate::interactive::InteractiveResult::SwitchTab { direction } => { - // Rebuild settings with new tab - if let Some(state) = self.app_state.get_interactive_state() - && !state.tabs.is_empty() - { - let current_tab = state.active_tab; - let num_tabs = state.tabs.len(); - let new_tab = if direction < 0 { - if current_tab == 0 { - num_tabs - 1 - } else { - current_tab - 1 - } - } else { - (current_tab + 1) % num_tabs - }; - // Rebuild settings with new tab using current snapshot - let snapshot = crate::interactive::builders::SettingsSnapshot { - compact_mode: self.app_state.compact_mode, - sandbox_mode: self.app_state.sandbox_mode, - streaming_enabled: self.app_state.streaming_enabled, - sound: self.app_state.sound_enabled, - thinking_enabled: self.app_state.thinking_budget.is_some(), - debug_mode: self.app_state.debug_mode, - ..Default::default() - }; - let new_state = - crate::interactive::builders::build_settings_selector_with_tab( - snapshot, None, new_tab, - ); - self.app_state.enter_interactive_mode(new_state); - } - } - } - } - self.render(terminal)?; - return Ok(()); + fn try_shortcuts_sheet_key( + &mut self, + key_event: crossterm::event::KeyEvent, + terminal: &mut CortexTerminal, + ) -> Result { + use crossterm::event::{KeyCode, KeyModifiers}; + if !self.app_state.shortcuts_open { + return Ok(false); + } + let close = matches!(key_event.code, KeyCode::Esc | KeyCode::F(2)) + || (key_event.code == KeyCode::Char('x') + && key_event.modifiers.contains(KeyModifiers::CONTROL)); + if close { + self.app_state.close_shortcuts_sheet(); + } else if matches!(key_event.code, KeyCode::Down | KeyCode::Char('j')) { + self.app_state.shortcuts_move(1); + } else if matches!(key_event.code, KeyCode::Up | KeyCode::Char('k')) { + self.app_state.shortcuts_move(-1); } + self.render(terminal)?; + Ok(true) + } - // Check if a card is active and handle its input first - if self.card_handler.is_active() && self.card_handler.handle_key(key_event) { - // Process any pending card actions - self.process_card_actions(); + fn try_chrome_toggle_key( + &mut self, + key_event: crossterm::event::KeyEvent, + terminal: &mut CortexTerminal, + ) -> Result { + use crossterm::event::{KeyCode, KeyModifiers}; + if key_event.code == KeyCode::F(2) { + self.app_state.open_settings_modal(); self.render(terminal)?; - return Ok(()); + return Ok(true); } - - // Check if a modal is open and handle its input first - if self.app_state.has_modal() && self.handle_modal_key(key_event).await? { + if key_event.code == KeyCode::Char('x') + && key_event.modifiers.contains(KeyModifiers::CONTROL) + { + if self.shortcuts_toggle_blocked() { + return Ok(false); + } + self.app_state.toggle_shortcuts_sheet(); self.render(terminal)?; - return Ok(()); + return Ok(true); } + Ok(false) + } - // Handle Questions view input - if self.app_state.view == AppView::Questions && self.handle_question_key(key_event).await? { - self.render(terminal)?; + fn shortcuts_toggle_blocked(&self) -> bool { + self.app_state.is_interactive_mode() + || self.card_handler.is_active() + || self.app_state.has_modal() + || self.modal_stack.is_active() + || self.app_state.view == AppView::Questions + || self.app_state.get_question_state().is_some() + } + + async fn handle_interactive_mode_key( + &mut self, + key_event: crossterm::event::KeyEvent, + ) -> Result<()> { + let Some(state) = self.app_state.get_interactive_state_mut() else { return Ok(()); + }; + let result = crate::interactive::handle_interactive_key(state, key_event); + match result { + crate::interactive::InteractiveResult::Selected { + action, + item_id, + item_ids, + } => { + let keep_open = self + .handle_interactive_selection(action, item_id, item_ids) + .await; + if !keep_open { + self.app_state.exit_interactive_mode(); + } + } + crate::interactive::InteractiveResult::FormSubmitted { action_id, values } => { + let stay_open = self.handle_inline_form_submission(&action_id, values); + if !stay_open { + self.app_state.exit_interactive_mode(); + } + } + crate::interactive::InteractiveResult::Cancelled => { + self.reject_pending_approval_and_exit_interactive(); + } + crate::interactive::InteractiveResult::Continue => {} + crate::interactive::InteractiveResult::SwitchTab { direction } => { + self.switch_settings_tab(direction); + } } + Ok(()) + } + + fn switch_settings_tab(&mut self, direction: i32) { + let Some(state) = self.app_state.get_interactive_state() else { + return; + }; + if state.tabs.is_empty() { + return; + } + let current_tab = state.active_tab; + let num_tabs = state.tabs.len(); + let new_tab = if direction < 0 { + if current_tab == 0 { + num_tabs - 1 + } else { + current_tab - 1 + } + } else { + (current_tab + 1) % num_tabs + }; + let snapshot = crate::interactive::builders::SettingsSnapshot { + compact_mode: self.app_state.compact_mode, + sandbox_mode: self.app_state.sandbox_mode, + streaming_enabled: self.app_state.streaming_enabled, + sound: self.app_state.sound_enabled, + thinking_enabled: self.app_state.thinking_budget.is_some(), + debug_mode: self.app_state.debug_mode, + ..Default::default() + }; + let new_state = + crate::interactive::builders::build_settings_selector_with_tab(snapshot, None, new_tab); + self.app_state.enter_interactive_mode(new_state); + } + + async fn dispatch_session_key( + &mut self, + key_event: crossterm::event::KeyEvent, + terminal: &mut CortexTerminal, + ) -> Result<()> { + use crossterm::event::KeyCode; - // Handle Ctrl+C with contextual behavior if key_event.code == KeyCode::Char('c') && key_event .modifiers .contains(crossterm::event::KeyModifiers::CONTROL) { + if self.app_state.streaming.is_streaming { + self.cancel_streaming(); + self.render(terminal)?; + return Ok(()); + } return self.handle_ctrl_c(terminal); } - // Handle ESC with double-tap to quit when idle if key_event.code == KeyCode::Esc { return self.handle_esc(terminal); } - // Reset Ctrl+C and ESC timers on other key presses if key_event.code != KeyCode::Esc { self.app_state.reset_esc(); } @@ -324,15 +389,12 @@ impl EventLoop { let context = self.get_action_context(); let action = self.action_mapper.get_action(key_event, context); - // Check if autocomplete is visible and handle its navigation if self.app_state.autocomplete.visible && self.handle_autocomplete_key(key_event, terminal).await? { return Ok(()); } - // Handle Copy action specially since it needs terminal access - // This handles both Ctrl+C (when text selected) and Ctrl+Shift+C if action == KeyAction::Copy { if self.app_state.text_selection.has_selection() { self.copy_selection_to_clipboard(terminal)?; @@ -342,14 +404,11 @@ impl EventLoop { return Ok(()); } - // Handle '?' key specially: only show help when input is empty - // Otherwise, type '?' into the input if action == KeyAction::Help && key_event.code == KeyCode::Char('?') && context == ActionContext::Input && !self.app_state.input.is_empty() { - // Type '?' into the input instead of opening help self.app_state.text_selection.clear(); self.app_state.input.handle_key(key_event); self.update_autocomplete(); @@ -357,22 +416,14 @@ impl EventLoop { return Ok(()); } - // Forward key events to input widget when focused and no action mapped - // This allows character input, backspace, delete, etc. to work if context == ActionContext::Input && action == KeyAction::None { - // Clear selection when typing self.app_state.text_selection.clear(); - self.app_state.input.handle_key(key_event); - // Update autocomplete based on new input self.update_autocomplete(); } self.handle_action(action).await?; - - // Always render after key input for responsiveness self.render(terminal)?; - Ok(()) } diff --git a/src/cortex-tui/src/runner/event_loop/mcp_panel.rs b/src/cortex-tui/src/runner/event_loop/mcp_panel.rs new file mode 100644 index 00000000..292a2f68 --- /dev/null +++ b/src/cortex-tui/src/runner/event_loop/mcp_panel.rs @@ -0,0 +1,184 @@ +//! MCP panel: picker actions and lifecycle events. + +use super::core::EventLoop; +use crate::modal::mcp_manager::McpStatus; +use cortex_engine::mcp::McpLifecycleEvent; + +impl EventLoop { + /// Re-opens the MCP panel after add / reconnect / stop. + pub(super) fn reopen_mcp_panel(&mut self) { + use crate::interactive::builders::build_mcp_selector; + let servers = self.app_state.mcp_servers.clone(); + let interactive = build_mcp_selector(&servers); + self.app_state.enter_interactive_mode(interactive); + } + + pub(super) async fn handle_mcp_selector_item(&mut self, item_id: &str) -> bool { + if let Some(name) = item_id.strip_prefix("__reconnect__:") { + return self.handle_mcp_server_action(name, "restart").await; + } + match item_id { + "__add__" => { + let interactive = crate::interactive::builders::build_mcp_source_selector(); + self.app_state.enter_interactive_mode(interactive); + true + } + "__tools__" => { + let manager = self.mcp_manager.clone(); + let tools = manager.list_all_tools().await; + if tools.is_empty() { + self.add_system_message("No MCP tools connected."); + } else { + let mut lines: Vec = tools.keys().cloned().collect(); + lines.sort(); + self.add_system_message(&format!( + "MCP tools ({})\n{}", + lines.len(), + lines.join("\n") + )); + } + false + } + "__reload__" => { + let manager = self.mcp_manager.clone(); + tokio::spawn(async move { + let names = manager.server_names().await; + for name in names { + let _ = manager.disconnect(&name).await; + let _ = manager.connect(&name).await; + } + }); + self.app_state.toasts.info("Reloading MCP servers…"); + self.reopen_mcp_panel(); + true + } + name => { + if let Some(server) = self + .app_state + .mcp_servers + .iter() + .find(|s| s.name == name) + .cloned() + { + let interactive = + crate::interactive::builders::build_mcp_server_actions(&server); + self.app_state.enter_interactive_mode(interactive); + true + } else { + false + } + } + } + } + + pub(super) async fn handle_mcp_server_action(&mut self, server: &str, action: &str) -> bool { + let manager = self.mcp_manager.clone(); + let name = server.to_string(); + match action { + "start" | "restart" => { + if action == "restart" { + self.mcp_stopping.insert(name.clone()); + let _ = manager.disconnect(&name).await; + } + if let Some(s) = self + .app_state + .mcp_servers + .iter_mut() + .find(|s| s.name == name) + { + s.status = McpStatus::Starting; + } + tokio::spawn(async move { + let _ = manager.connect(&name).await; + }); + self.reopen_mcp_panel(); + true + } + "stop" => { + self.mcp_stopping.insert(name.clone()); + tokio::spawn(async move { + let _ = manager.disconnect(&name).await; + }); + self.reopen_mcp_panel(); + true + } + "remove" => { + self.mcp_stopping.insert(name.clone()); + let _ = manager.remove_server(&name).await; + self.app_state.mcp_servers.retain(|s| s.name != name); + self.reopen_mcp_panel(); + true + } + _ => false, + } + } + + /// Apply an MCP lifecycle event to the session list and transcript. + pub(super) fn handle_mcp_event(&mut self, event: McpLifecycleEvent) { + match event { + McpLifecycleEvent::ServerAdded { name } => { + if !self.app_state.mcp_servers.iter().any(|s| s.name == name) { + self.app_state + .mcp_servers + .push(crate::modal::mcp_manager::McpServerInfo { + name, + status: McpStatus::Stopped, + tool_count: 0, + error: None, + requires_auth: false, + }); + } + } + McpLifecycleEvent::ServerConnected { + name, tool_count, .. + } => { + if let Some(server) = self + .app_state + .mcp_servers + .iter_mut() + .find(|s| s.name == name) + { + server.status = McpStatus::Running; + server.tool_count = tool_count; + server.error = None; + } + } + McpLifecycleEvent::ServerDisconnected { name } => { + let user_stop = self.mcp_stopping.remove(&name); + if let Some(server) = self + .app_state + .mcp_servers + .iter_mut() + .find(|s| s.name == name) + { + if user_stop { + server.status = McpStatus::Stopped; + server.error = None; + } else { + server.status = McpStatus::Error; + server.error = Some("connection lost".into()); + self.add_system_message(&format!("× {name} dropped")); + self.add_system_message(&format!( + "Reconnecting — tools from {name} are paused until it is back." + )); + } + } + } + McpLifecycleEvent::ServerRemoved { name } => { + self.app_state.mcp_servers.retain(|s| s.name != name); + } + McpLifecycleEvent::ConnectionFailed { name, error } => { + if let Some(server) = self + .app_state + .mcp_servers + .iter_mut() + .find(|s| s.name == name) + { + server.status = McpStatus::Error; + server.error = Some(error.clone()); + } + self.add_system_message(&format!("× {name} failed")); + } + } + } +} diff --git a/src/cortex-tui/src/runner/event_loop/mod.rs b/src/cortex-tui/src/runner/event_loop/mod.rs index ffedacff..82397d46 100644 --- a/src/cortex-tui/src/runner/event_loop/mod.rs +++ b/src/cortex-tui/src/runner/event_loop/mod.rs @@ -33,6 +33,7 @@ mod core; mod handoff; mod input; mod local_workflows; +mod mcp_panel; mod me; mod modal; mod modal_jobs; diff --git a/src/cortex-tui/src/runner/event_loop/modal.rs b/src/cortex-tui/src/runner/event_loop/modal.rs index 439700e8..cf5c8362 100644 --- a/src/cortex-tui/src/runner/event_loop/modal.rs +++ b/src/cortex-tui/src/runner/event_loop/modal.rs @@ -402,36 +402,9 @@ impl EventLoop { .push(Box::new(ModelsModal::new(models, Some(current)))); } - /// Opens the sessions modal with recent sessions. + /// Opens `/resume` (lock resume picker). pub fn open_sessions_modal(&mut self) { - use crate::modal::{SessionInfo, SessionsModal}; - - match self - .session_storage() - .and_then(|storage| storage.list_recent_sessions(100)) - { - Ok(sessions) => { - let session_infos: Vec = sessions - .into_iter() - .map(|s| SessionInfo { - path: std::path::PathBuf::from(&s.id), - name: if s.title.is_empty() { - "Untitled".to_string() - } else { - s.title - }, - model: s.model, - created_at: s.created_at, - message_count: s.message_count as usize, - }) - .collect(); - self.modal_stack - .push(Box::new(SessionsModal::new(session_infos))); - } - Err(e) => { - self.add_system_message(&format!("Failed to list sessions: {}", e)); - } - } + self.open_resume_picker(); } /// Handles inline form submission from interactive mode. @@ -537,14 +510,6 @@ impl EventLoop { } } - /// Re-opens the MCP panel to show the list of servers after adding a new one. - pub(super) fn reopen_mcp_panel(&mut self) { - use crate::interactive::builders::build_mcp_selector; - let servers = self.app_state.mcp_servers.clone(); - let interactive = build_mcp_selector(&servers); - self.app_state.enter_interactive_mode(interactive); - } - /// Handles interactive selection. pub(super) async fn handle_interactive_selection( &mut self, @@ -600,86 +565,7 @@ impl EventLoop { return false; } InteractiveAction::ToggleSetting => { - if item_id == "__hub__" || item_id.starts_with("__cat_") { - let interactive = crate::interactive::builders::build_settings_hub(Some( - self.app_state.terminal_size.1, - )); - self.app_state.enter_interactive_mode(interactive); - return true; - } - if let Some(section) = item_id.strip_prefix("__section_") { - let snapshot = self.settings_snapshot(); - let interactive = crate::interactive::builders::build_settings_section( - snapshot, - Some(self.app_state.terminal_size.1), - section, - ); - self.app_state.enter_interactive_mode(interactive); - return true; - } - match item_id.as_str() { - "model" => { - self.app_state.exit_interactive_mode(); - self.handle_open_modal(crate::commands::ModalType::ModelPicker) - .await; - return true; - } - "mode" => { - self.app_state.exit_interactive_mode(); - self.handle_open_modal(crate::commands::ModalType::Mode) - .await; - return true; - } - "permissions" => { - self.app_state.exit_interactive_mode(); - self.handle_open_modal(crate::commands::ModalType::Permissions) - .await; - return true; - } - "sandbox" => { - let interactive = crate::interactive::builders::build_sandbox_selector( - self.app_state.sandbox_mode, - ); - self.app_state.enter_interactive_mode(interactive); - return true; - } - "mcp" => { - self.app_state.exit_interactive_mode(); - self.handle_open_modal(crate::commands::ModalType::McpManager) - .await; - return true; - } - "config" => { - self.app_state.exit_interactive_mode(); - let path = dirs::home_dir() - .map(|h| h.join(".cortex").join("config.json")) - .unwrap_or_else(|| std::path::PathBuf::from("~/.cortex/config.json")); - self.add_system_message(&format!("Config: {}", path.display())); - return false; - } - "usage" => { - self.app_state.exit_interactive_mode(); - crate::runner::billing_handlers::spawn_usage_fetch( - self.tool_event_tx.clone(), - None, - None, - ); - self.app_state.toasts.info("Fetching usage…"); - return false; - } - "compact" => { - self.app_state.compact_mode = !self.app_state.compact_mode; - } - "debug" => { - self.app_state.debug_mode = !self.app_state.debug_mode; - } - "sound" => { - self.app_state.sound_enabled = !self.app_state.sound_enabled; - } - _ => {} - }; - self.reopen_settings_menu(); - return true; + return self.handle_toggle_setting_item(item_id).await; } InteractiveAction::SetApprovalMode => { self.handle_set_value("approval", &item_id); @@ -688,129 +574,245 @@ impl EventLoop { InteractiveAction::McpServerAction => { return self.handle_mcp_selector_item(&item_id).await; } - InteractiveAction::Custom(ref custom) => { - if let Some(server) = custom.strip_prefix("mcp:") { - return self.handle_mcp_server_action(server, &item_id).await; + InteractiveAction::ResumeSession => { + let result = self.resume_local_session(&item_id); + self.report_local_result(result, "Session resumed"); + return false; + } + InteractiveAction::Custom(custom) => { + return self.handle_interactive_custom(custom, item_id).await; + } + _ => { + self.add_system_message("This selection is unsupported in the current session. No operation was performed."); + } + } + false + } + + async fn handle_toggle_setting_item(&mut self, item_id: String) -> bool { + if item_id == "__hub__" || item_id.starts_with("__cat_") { + let interactive = crate::interactive::builders::build_settings_hub(Some( + self.app_state.terminal_size.1, + )); + self.app_state.enter_interactive_mode(interactive); + return true; + } + if let Some(section) = item_id.strip_prefix("__section_") { + let snapshot = self.settings_snapshot(); + let interactive = crate::interactive::builders::build_settings_section( + snapshot, + Some(self.app_state.terminal_size.1), + section, + ); + self.app_state.enter_interactive_mode(interactive); + return true; + } + match item_id.as_str() { + "model" => { + self.app_state.exit_interactive_mode(); + self.handle_open_modal(crate::commands::ModalType::ModelPicker) + .await; + return true; + } + "mode" => { + self.app_state.exit_interactive_mode(); + self.handle_open_modal(crate::commands::ModalType::Mode) + .await; + return true; + } + "permissions" => { + self.app_state.exit_interactive_mode(); + self.handle_open_modal(crate::commands::ModalType::Permissions) + .await; + return true; + } + "sandbox" => { + let interactive = crate::interactive::builders::build_sandbox_selector( + self.app_state.sandbox_mode, + ); + self.app_state.enter_interactive_mode(interactive); + return true; + } + "mcp" => { + self.app_state.exit_interactive_mode(); + self.handle_open_modal(crate::commands::ModalType::McpManager) + .await; + return true; + } + "config" => { + self.app_state.exit_interactive_mode(); + let path = dirs::home_dir() + .map(|h| h.join(".cortex").join("config.json")) + .unwrap_or_else(|| std::path::PathBuf::from("~/.cortex/config.json")); + self.add_system_message(&format!("Config: {}", path.display())); + return false; + } + "usage" => { + self.app_state.exit_interactive_mode(); + crate::runner::billing_handlers::spawn_usage_fetch( + self.tool_event_tx.clone(), + None, + None, + ); + self.app_state.toasts.info("Fetching usage…"); + return false; + } + "compact" => { + self.app_state.compact_mode = !self.app_state.compact_mode; + } + "debug" => { + self.app_state.debug_mode = !self.app_state.debug_mode; + } + "sound" => { + self.app_state.sound_enabled = !self.app_state.sound_enabled; + } + _ => {} + } + self.reopen_settings_menu(); + true + } + + async fn handle_interactive_custom(&mut self, custom: String, item_id: String) -> bool { + if let Some(server) = custom.strip_prefix("mcp:") { + return self.handle_mcp_server_action(server, &item_id).await; + } + match custom.as_str() { + "export" => { + if let Some(format) = crate::session::ExportFormat::parse(&item_id) { + let _ = self.handle_export(format).await; } - match custom.as_str() { - "export" => { - if let Some(format) = crate::session::ExportFormat::parse(&item_id) { - let _ = self.handle_export(format).await; - } - return false; - } - "mode" => { - self.app_state.set_agent_mode(&item_id); - self.sync_agent_mode_harness(); - self.app_state - .toasts - .info(format!("Mode: {}", self.app_state.agent_mode_label)); - return false; - } - "effort" => { - self.app_state.set_thinking_budget(Some(item_id.clone())); - self.app_state.toasts.info(format!("Effort: {}", item_id)); - return false; - } - "sandbox" => { - self.app_state.sandbox_mode = item_id == "on"; - let state = if self.app_state.sandbox_mode { - "on" - } else { - "off" - }; - self.app_state - .toasts - .info(format!("Sandbox mode: {}", state)); - return false; - } - "skill-run" => { - self.invoke_skill_command(&format!("skill:invoke:{item_id}")) - .await; - return false; - } - "jobs-picker" => { - return self.handle_jobs_picker_choice(&item_id); - } - "permission-prompt" => { - return self.handle_permission_prompt_choice(&item_id).await; - } - "permissions-picker" => { - self.app_state.permission_mode = match item_id.as_str() { - "ro" => crate::permissions::PermissionMode::High, - "smart" => crate::permissions::PermissionMode::Medium, - "full" => crate::permissions::PermissionMode::Low, - _ => return false, - }; - let label = match item_id.as_str() { - "ro" => "Read-only", - "smart" => "Smart", - "full" => "Full access", - _ => "Permissions", - }; - 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(); - self.add_system_message("Display cleared. Stored conversation context is unchanged; use /new for a fresh conversation."); - } - return false; - } - "plan-confirm" => { - if item_id == "yes" { - self.app_state.set_agent_mode("agent"); - self.sync_agent_mode_harness(); - self.app_state.toasts.info("Mode: Agent"); - } - return false; - } - "sandbox-deny" | "question" => { - return false; - } - "mcp-source" => { - let interactive = match item_id.as_str() { - "custom" => { - crate::interactive::builders::build_mcp_transport_selector() - } - "registry" => { - crate::interactive::builders::build_mcp_registry_browser() - } - _ => return false, - }; - self.app_state.enter_interactive_mode(interactive); - return true; - } - "mcp-transport" => { - let mut interactive = crate::interactive::builders::build_mcp_selector( - &self.app_state.mcp_servers, - ); - match item_id.as_str() { - "stdio" => interactive - .open_form(crate::interactive::builders::build_mcp_stdio_form()), - "http" => interactive - .open_form(crate::interactive::builders::build_mcp_http_form()), - _ => return false, - } - self.app_state.enter_interactive_mode(interactive); - return true; - } - _ => { - self.add_system_message("This selection is unsupported in the current session. No operation was performed."); - } + false + } + "mode" => { + self.app_state.set_agent_mode(&item_id); + self.sync_agent_mode_harness(); + self.app_state + .toasts + .info(format!("Mode: {}", self.app_state.agent_mode_label)); + false + } + "effort" => { + self.app_state.set_thinking_budget(Some(item_id.clone())); + self.app_state.toasts.info(format!("Effort: {}", item_id)); + false + } + "sandbox" => { + self.app_state.sandbox_mode = item_id == "on"; + let state = if self.app_state.sandbox_mode { + "on" + } else { + "off" + }; + self.app_state + .toasts + .info(format!("Sandbox mode: {}", state)); + false + } + "skill-run" => { + self.invoke_skill_command(&format!("skill:invoke:{item_id}")) + .await; + false + } + "jobs-picker" => self.handle_jobs_picker_choice(&item_id), + "resume-favorite" | "resume-delete" => { + self.handle_resume_picker_custom(&custom, &item_id) + } + "permission-prompt" => self.handle_permission_prompt_choice(&item_id).await, + "permissions-picker" => self.apply_permissions_picker(&item_id), + "handoff-confirm" => self.handle_handoff_confirm_choice(&item_id).await, + "clear-confirm" => { + if item_id == "yes" { + self.app_state.clear_messages(); + self.add_system_message("Display cleared. Stored conversation context is unchanged; use /new for a fresh conversation."); + } + false + } + "plan-confirm" => { + if item_id == "yes" { + self.app_state.set_agent_mode("agent"); + self.sync_agent_mode_harness(); + self.app_state.toasts.info("Mode: Agent"); } + false } + "sandbox-deny" | "question" => false, + "mcp-source" | "mcp-transport" => self.handle_mcp_form_custom(&custom, &item_id), _ => { self.add_system_message("This selection is unsupported in the current session. No operation was performed."); + false } } + } + + fn handle_resume_picker_custom(&mut self, custom: &str, item_id: &str) -> bool { + if custom == "resume-favorite" { + match self.toggle_listed_favorite(item_id) { + Ok(true) => { + self.app_state.toasts.info("Favorited"); + } + Ok(false) => { + self.app_state.toasts.info("Removed from favorites"); + } + Err(error) => { + self.add_system_message(&format!("Error: {error}")); + } + } + return true; + } + match self + .session_storage() + .and_then(|storage| storage.delete_session(item_id)) + { + Ok(()) => { + self.app_state.toasts.info("Session deleted"); + self.open_resume_picker(); + true + } + Err(error) => { + self.add_system_message(&format!("Error: {error}")); + false + } + } + } + + fn apply_permissions_picker(&mut self, item_id: &str) -> bool { + self.app_state.permission_mode = match item_id { + "ro" => crate::permissions::PermissionMode::High, + "smart" => crate::permissions::PermissionMode::Medium, + "full" => crate::permissions::PermissionMode::Low, + _ => return false, + }; + let label = match item_id { + "ro" => "Read-only", + "smart" => "Smart", + "full" => "Full access", + _ => "Permissions", + }; + self.app_state.toasts.info(format!("Permissions: {label}")); false } + fn handle_mcp_form_custom(&mut self, custom: &str, item_id: &str) -> bool { + if custom == "mcp-source" { + let interactive = match item_id { + "custom" => crate::interactive::builders::build_mcp_transport_selector(), + "registry" => crate::interactive::builders::build_mcp_registry_browser(), + _ => return false, + }; + self.app_state.enter_interactive_mode(interactive); + return true; + } + let mut interactive = + crate::interactive::builders::build_mcp_selector(&self.app_state.mcp_servers); + match item_id { + "stdio" => interactive.open_form(crate::interactive::builders::build_mcp_stdio_form()), + "http" => interactive.open_form(crate::interactive::builders::build_mcp_http_form()), + _ => return false, + } + self.app_state.enter_interactive_mode(interactive); + true + } + fn settings_snapshot(&self) -> crate::interactive::builders::SettingsSnapshot { crate::interactive::builders::SettingsSnapshot { compact_mode: self.app_state.compact_mode, @@ -872,103 +874,6 @@ impl EventLoop { self.app_state.enter_interactive_mode(interactive); } - async fn handle_mcp_selector_item(&mut self, item_id: &str) -> bool { - match item_id { - "__add__" => { - let interactive = crate::interactive::builders::build_mcp_source_selector(); - self.app_state.enter_interactive_mode(interactive); - true - } - "__tools__" => { - let manager = self.mcp_manager.clone(); - let tools = manager.list_all_tools().await; - if tools.is_empty() { - self.add_system_message("No MCP tools connected."); - } else { - let mut lines: Vec = tools.keys().cloned().collect(); - lines.sort(); - self.add_system_message(&format!( - "MCP tools ({})\n{}", - lines.len(), - lines.join("\n") - )); - } - false - } - "__reload__" => { - let manager = self.mcp_manager.clone(); - tokio::spawn(async move { - let names = manager.server_names().await; - for name in names { - let _ = manager.disconnect(&name).await; - let _ = manager.connect(&name).await; - } - }); - self.app_state.toasts.info("Reloading MCP servers…"); - self.reopen_mcp_panel(); - true - } - name => { - if let Some(server) = self - .app_state - .mcp_servers - .iter() - .find(|s| s.name == name) - .cloned() - { - let interactive = - crate::interactive::builders::build_mcp_server_actions(&server); - self.app_state.enter_interactive_mode(interactive); - true - } else { - false - } - } - } - } - - async fn handle_mcp_server_action(&mut self, server: &str, action: &str) -> bool { - let manager = self.mcp_manager.clone(); - let name = server.to_string(); - match action { - "start" | "restart" => { - if action == "restart" { - self.mcp_stopping.insert(name.clone()); - let _ = manager.disconnect(&name).await; - } - if let Some(s) = self - .app_state - .mcp_servers - .iter_mut() - .find(|s| s.name == name) - { - s.status = crate::modal::mcp_manager::McpStatus::Starting; - } - tokio::spawn(async move { - let _ = manager.connect(&name).await; - }); - self.reopen_mcp_panel(); - true - } - "stop" => { - self.mcp_stopping.insert(name.clone()); - tokio::spawn(async move { - let _ = manager.disconnect(&name).await; - }); - self.reopen_mcp_panel(); - true - } - "remove" => { - self.mcp_stopping.insert(name.clone()); - let _ = manager.remove_server(&name).await; - self.app_state.mcp_servers.retain(|s| s.name != name); - self.reopen_mcp_panel(); - true - } - _ => false, - } - } - /// Apply a SPEC §3.10 permission-prompt choice. async fn handle_permission_prompt_choice(&mut self, item_id: &str) -> bool { match item_id { diff --git a/src/cortex-tui/src/runner/event_loop/mouse.rs b/src/cortex-tui/src/runner/event_loop/mouse.rs index 3c9d2ede..4b0be58c 100644 --- a/src/cortex-tui/src/runner/event_loop/mouse.rs +++ b/src/cortex-tui/src/runner/event_loop/mouse.rs @@ -19,38 +19,7 @@ impl EventLoop { ) -> Result<()> { match action { MouseAction::Click { x, y, button } => { - // Handle interactive mode clicks - if self.app_state.is_interactive_mode() - && let Some((action, item_id, item_ids)) = self.handle_interactive_click(x, y) - { - let keep_open = self - .handle_interactive_selection(action, item_id, item_ids) - .await; - if !keep_open { - self.app_state.exit_interactive_mode(); - } - self.render(terminal)?; - return Ok(()); - } - - // Handle question view clicks - if self.app_state.view == crate::app::AppView::Questions - && self.handle_question_click(x, y) - { - self.render(terminal)?; - return Ok(()); - } - - // Find clicked zone - if let Some(zone_id) = self.click_zones.find(x, y) { - let should_copy = self.handle_click(zone_id, button)?; - if should_copy { - self.copy_selection_to_clipboard(terminal)?; - self.app_state.text_selection.clear(); - } - } - - self.render(terminal)?; + self.handle_mouse_click(x, y, button, terminal).await?; } MouseAction::DoubleClick { x, y } => { @@ -98,59 +67,115 @@ impl EventLoop { } MouseAction::Move { x, y } => { - // Handle hover effects for interactive mode - if self.app_state.is_interactive_mode() - && let Some(state) = self.app_state.get_interactive_state_mut() - { - if let Some(idx) = state.hit_test(x, y) { - if state.hovered != Some(idx) { - state.hovered = Some(idx); - } - } else if state.hovered.is_some() { - state.hovered = None; - } - } + self.handle_mouse_move(x, y, terminal)?; + } + } - if self.app_state.autocomplete.visible { - let (w, h) = self.app_state.terminal_size; - let footer_y = h.saturating_sub(1); - let composer_y = footer_y.saturating_sub(1 + 3); - let palette_h = if h >= 20 { 8 } else { 3 }; - let palette_y = composer_y.saturating_sub(palette_h); - if y >= palette_y && y < composer_y { - let row = - (y - palette_y) as usize + self.app_state.autocomplete.scroll_offset; - self.app_state.autocomplete.hovered = Some(row); - } else { - self.app_state.autocomplete.hovered = None; - } - let _ = w; - } + Ok(()) + } - if let Some(ref mut modal) = self.app_state.settings_modal { - modal.hover_at(y); - } + async fn handle_mouse_click( + &mut self, + x: u16, + y: u16, + button: MouseButton, + terminal: &mut CortexTerminal, + ) -> Result<()> { + if self.app_state.shortcuts_open { + if self.app_state.shortcuts_close_hit(x, y) { + self.app_state.close_shortcuts_sheet(); + } else { + let _ = self.app_state.shortcuts_select_at(x, y); + } + self.render(terminal)?; + return Ok(()); + } - let (_, h) = self.app_state.terminal_size; - let footer_y = h.saturating_sub(1); - let composer_y = footer_y.saturating_sub(1 + 3); - self.app_state.composer_hovered = y >= composer_y && y < footer_y.saturating_sub(1); - if y == footer_y { - self.app_state.footer_hover = Some(0); - } else { - self.app_state.footer_hover = None; - } - let _ = x; + if self.app_state.is_interactive_mode() + && let Some((action, item_id, item_ids)) = self.handle_interactive_click(x, y) + { + let keep_open = self + .handle_interactive_selection(action, item_id, item_ids) + .await; + if !keep_open { + self.app_state.exit_interactive_mode(); + } + self.render(terminal)?; + return Ok(()); + } - // Handle question view hover - if self.app_state.view == crate::app::AppView::Questions { - self.handle_question_hover(x, y); - } + if self.app_state.view == crate::app::AppView::Questions && self.handle_question_click(x, y) + { + self.render(terminal)?; + return Ok(()); + } - self.render(terminal)?; + if let Some(zone_id) = self.click_zones.find(x, y) { + let should_copy = self.handle_click(zone_id, button)?; + if should_copy { + self.copy_selection_to_clipboard(terminal)?; + self.app_state.text_selection.clear(); + } + } + + self.render(terminal)?; + Ok(()) + } + + fn handle_mouse_move(&mut self, x: u16, y: u16, terminal: &mut CortexTerminal) -> Result<()> { + if self.app_state.shortcuts_open { + self.app_state.shortcuts_hover_at(x, y); + self.render(terminal)?; + return Ok(()); + } + + if self.app_state.is_interactive_mode() + && let Some(state) = self.app_state.get_interactive_state_mut() + { + if let Some(idx) = state.hit_test(x, y) { + if state.hovered != Some(idx) { + state.hovered = Some(idx); + } + } else if state.hovered.is_some() { + state.hovered = None; } } + if self.app_state.autocomplete.visible { + let (w, h) = self.app_state.terminal_size; + let footer_y = h.saturating_sub(1); + let composer_y = footer_y.saturating_sub(1 + 3); + let palette_h = if h >= 20 { 8 } else { 3 }; + let palette_y = composer_y.saturating_sub(palette_h); + if y >= palette_y && y < composer_y { + let row = (y - palette_y) as usize + self.app_state.autocomplete.scroll_offset; + self.app_state.autocomplete.hovered = Some(row); + } else { + self.app_state.autocomplete.hovered = None; + } + let _ = w; + } + + if let Some(ref mut modal) = self.app_state.settings_modal { + modal.hover_at(y); + } + + let (_, h) = self.app_state.terminal_size; + let footer_y = h.saturating_sub(1); + let composer_y = footer_y.saturating_sub(1 + 3); + self.app_state.composer_hovered = y >= composer_y && y < footer_y.saturating_sub(1); + if y == footer_y { + self.app_state.footer_hover = Some(0); + } else { + self.app_state.footer_hover = None; + } + let _ = x; + + if self.app_state.view == crate::app::AppView::Questions { + self.handle_question_hover(x, y); + } + + self.render(terminal)?; Ok(()) } @@ -239,15 +264,23 @@ impl EventLoop { // Hit test using stored click zones let idx = state.hit_test(x, y)?; - // Move selection to clicked item - state.selected = idx; - state.hovered = Some(idx); + if idx >= 1000 { + let i = idx - 1000; + if let Some((level, _, _)) = crate::interactive::EffortLevel::rows().get(i) { + state.effort = Some(*level); + state.effort_focused = true; + } + } else { + state.selected = idx; + state.hovered = Some(idx); + } - // Get the selected item to check if it's selectable - let _item = match state.selected_item() { - Some(item) if !item.disabled && !item.is_separator => item, - _ => return None, - }; + if idx < 1000 { + let _item = match state.selected_item() { + Some(item) if !item.disabled && !item.is_separator => item, + _ => return None, + }; + } let is_multi = state.multi_select; diff --git a/src/cortex-tui/src/runner/event_loop/rendering.rs b/src/cortex-tui/src/runner/event_loop/rendering.rs index 442dcc1d..4553795d 100644 --- a/src/cortex-tui/src/runner/event_loop/rendering.rs +++ b/src/cortex-tui/src/runner/event_loop/rendering.rs @@ -198,35 +198,30 @@ impl EventLoop { self.click_zones .register(ClickZoneId::InputField, input_area); - // Calculate click zones for interactive mode: the panel sits - // directly above the footer and is `required_height` tall - // (hairline, title, optional framed search, rows, hints). - if self.app_state.is_interactive_mode() - && let Some(state) = self.app_state.get_interactive_state_mut() - { - let items_count = if state.filtered_indices.is_empty() { - 2 - } else { - state.filtered_indices.len().min(state.max_visible) - }; - let search_rows: u16 = if state.searchable { - crate::interactive::renderer::SEARCH_FIELD_ROWS - } else { - 0 - }; - let required_height = (items_count as u16) + 3 + search_rows; - let max_height = area.height.saturating_sub(footer_height).max(3); - let widget_height = required_height.min(max_height); - let interactive_y = area - .bottom() - .saturating_sub(footer_height) - .saturating_sub(widget_height); - let interactive_area = - Rect::new(area.x, interactive_y, area.width, widget_height); - crate::interactive::InteractiveWidget::calculate_click_zones( - state, - interactive_area, + // Rows-only picker: same origin as MinimalSessionView (chrome then options). + if self.app_state.is_interactive_mode() { + let update_h = crate::interactive::picker_layout::session_update_height( + self.app_state.should_show_update_banner(), + ); + let optin_h = crate::interactive::picker_layout::session_optin_height( + self.app_state.opt_in_banner && !self.app_state.messages.is_empty(), + height, ); + if let Some(state) = self.app_state.get_interactive_state_mut() { + let picker_height = + crate::interactive::picker_layout::picker_stack_height(state); + let interactive_area = + crate::interactive::picker_layout::session_inline_picker_area( + area, + picker_height, + update_h, + optin_h, + ); + crate::interactive::InteractiveWidget::calculate_click_zones( + state, + interactive_area, + ); + } } } } diff --git a/src/cortex-tui/src/runner/event_loop/sessions.rs b/src/cortex-tui/src/runner/event_loop/sessions.rs index 749b1a3b..a4068f8d 100644 --- a/src/cortex-tui/src/runner/event_loop/sessions.rs +++ b/src/cortex-tui/src/runner/event_loop/sessions.rs @@ -60,6 +60,33 @@ impl EventLoop { Ok(()) } + pub(super) fn toggle_listed_favorite(&mut self, id: &str) -> Result { + let storage = self.session_storage()?; + let mut session = CortexSession::load_with_storage(id, storage)?; + let favorite = !session.meta.favorite; + let mut meta = session.meta.clone(); + meta.favorite = favorite; + session.persist_metadata(meta)?; + Ok(favorite) + } + + pub(super) fn open_resume_picker(&mut self) { + match self + .session_storage() + .and_then(|storage| storage.list_recent_sessions(15)) + { + Ok(sessions) => { + self.app_state.input.set_text("/resume"); + let interactive = + crate::interactive::builders::build_resume_picker(&sessions, false); + self.app_state.enter_interactive_mode(interactive); + } + Err(e) => { + self.add_system_message(&format!("Failed to list sessions: {e}")); + } + } + } + pub(super) fn new_local_session(&mut self) -> Result<()> { self.ensure_session_idle()?; self.flush_session_before_switch()?; diff --git a/src/cortex-tui/src/runner/event_loop/tools.rs b/src/cortex-tui/src/runner/event_loop/tools.rs index 4243c825..1c36ae11 100644 --- a/src/cortex-tui/src/runner/event_loop/tools.rs +++ b/src/cortex-tui/src/runner/event_loop/tools.rs @@ -35,6 +35,11 @@ impl EventLoop { let tool_tx = self.tool_event_tx.clone(); let id = tool_call_id.clone(); let name = tool_name.clone(); + if matches!(tool_name.as_str(), "TodoWrite" | "todo_write") + && let Some(todos) = todo_pairs_from_write_args(&args) + { + self.handle_todo_updated(String::new(), todos); + } let mcp_manager = self.mcp_manager.clone(); // Every caller reaches here only after the permission manager passed // the call or the user approved it in the modal. @@ -232,6 +237,9 @@ impl EventLoop { success, duration, } => { + if success { + self.apply_todo_write_output(&name, &output); + } self.handle_tool_completed(id, name, output, success, duration) .await; } @@ -500,6 +508,14 @@ impl EventLoop { } } + fn apply_todo_write_output(&mut self, name: &str, output: &str) { + if matches!(name, "TodoWrite" | "todo_write") + && let Some(todos) = todo_pairs_from_write_output(output) + { + self.handle_todo_updated(String::new(), todos); + } + } + /// Handle todo updated event fn handle_todo_updated(&mut self, session_id: String, todos: Vec<(String, String)>) { use crate::app::{SubagentTodoItem, SubagentTodoStatus}; @@ -510,31 +526,45 @@ impl EventLoop { todos.len() ); - self.app_state.update_subagent(&session_id, |task| { - task.todos = todos - .iter() - .map(|(content, status)| { - let status = match status.as_str() { - "in_progress" => SubagentTodoStatus::InProgress, - "completed" => SubagentTodoStatus::Completed, - _ => SubagentTodoStatus::Pending, - }; - SubagentTodoItem { - content: content.clone(), - status, - } - }) - .collect(); + let items: Vec = todos + .iter() + .map(|(content, status)| { + let status = match status.as_str() { + "in_progress" => SubagentTodoStatus::InProgress, + "completed" => SubagentTodoStatus::Completed, + _ => SubagentTodoStatus::Pending, + }; + SubagentTodoItem { + content: content.clone(), + status, + } + }) + .collect(); - // Update activity based on in-progress item - if let Some(in_progress) = task - .todos - .iter() - .find(|t| matches!(t.status, SubagentTodoStatus::InProgress)) - { - task.current_activity = in_progress.content.clone(); - } - }); + let matched = self + .app_state + .active_subagents + .iter() + .any(|t| t.session_id == session_id); + if matched { + self.app_state.update_subagent(&session_id, |task| { + task.todos = items.clone(); + if let Some(in_progress) = task + .todos + .iter() + .find(|t| matches!(t.status, SubagentTodoStatus::InProgress)) + { + task.current_activity = in_progress.content.clone(); + } + }); + } else { + let elapsed = self.app_state.streaming.prompt_elapsed_seconds().max(1) as u32; + self.app_state.working_checklist = Some(crate::app::WorkingChecklist::new( + items, + elapsed, + self.app_state.tokens_used, + )); + } } /// Checks for crashed background tool tasks (panics or cancelled). @@ -667,6 +697,77 @@ where futures::future::join_all(futures).await } +fn todo_pairs_from_write_args(args: &serde_json::Value) -> Option> { + let todos = args.get("todos")?.as_array()?; + let pairs: Vec<(String, String)> = todos + .iter() + .filter_map(|item| { + let content = item.get("content")?.as_str()?.to_string(); + let status = item + .get("status")? + .as_str() + .unwrap_or("pending") + .to_string(); + Some((content, status)) + }) + .collect(); + (!pairs.is_empty()).then_some(pairs) +} + +fn todo_pairs_from_write_output(output: &str) -> Option> { + let mut pairs = Vec::new(); + for line in output.lines() { + let line = line.trim(); + let (status, rest) = if let Some(rest) = line.strip_prefix("[x] ") { + ("completed", rest) + } else if let Some(rest) = line.strip_prefix("[~] ") { + ("in_progress", rest) + } else if let Some(rest) = line.strip_prefix("[ ] ") { + ("pending", rest) + } else { + continue; + }; + let content = rest.split_once(": ").map(|(_, c)| c).unwrap_or(rest); + if !content.is_empty() { + pairs.push((content.to_string(), status.to_string())); + } + } + (!pairs.is_empty()).then_some(pairs) +} + +#[cfg(test)] +mod todo_write_live_tests { + use super::{todo_pairs_from_write_args, todo_pairs_from_write_output}; + + #[test] + fn todo_write_args_become_checklist_pairs() { + let args = serde_json::json!({ + "todos": [ + {"id": "1", "content": "Read composer.rs", "status": "completed"}, + {"id": "2", "content": "Move the chip", "status": "in_progress"}, + ] + }); + let pairs = todo_pairs_from_write_args(&args).expect("pairs"); + assert_eq!(pairs.len(), 2); + assert_eq!(pairs[0].1, "completed"); + assert_eq!(pairs[1].0, "Move the chip"); + } + + #[test] + fn todo_write_completion_output_restores_checklist() { + let output = "TODO List Updated\n\n[x] !! 1: Read composer.rs\n[~] !! 2: Move the chip\n[ ] ! 3: Run tests\n"; + let pairs = todo_pairs_from_write_output(output).expect("pairs"); + assert_eq!( + pairs, + vec![ + ("Read composer.rs".into(), "completed".into()), + ("Move the chip".into(), "in_progress".into()), + ("Run tests".into(), "pending".into()), + ] + ); + } +} + #[cfg(test)] #[path = "runtime_contract_tools_tests.rs"] mod runtime_contract_tests; 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 c81d973c..949eb419 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 @@ -263,25 +263,22 @@ async fn ux_contract_diff_runs_real_git_without_mutating_user_work() { #[tokio::test] async fn ux_contract_picker_export_selector_and_limitations_render() { let (_temp, mut runner) = fixture(); + runner + .change_session_metadata("session_name", "picker-session") + .unwrap(); runner .change_session_metadata("session_name", "picker-session") .unwrap(); runner .process_modal_action(ModalAction::ExecuteCommand("sessions".into())) .await; - assert_eq!(runner.modal_stack.len(), 1); - for (width, height) in [(40, 12), (120, 40)] { - let area = Rect::new(0, 0, width, height); - let mut buffer = Buffer::empty(area); - runner - .modal_stack - .current() - .unwrap() - .render(area, &mut buffer); - let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect(); - assert!(text.contains("picker-session"), "{text}"); - } - runner.modal_stack.clear(); + assert!( + runner.app_state.get_interactive_state().is_some(), + "sessions opens the inline resume picker" + ); + assert_eq!(runner.modal_stack.len(), 0); + assert_views(&mut runner, "picker-session"); + runner.app_state.exit_interactive_mode(); runner.handle_open_modal(ModalType::Export(None)).await; assert!(runner.app_state.get_interactive_state().is_some()); runner diff --git a/src/cortex-tui/src/ui/chrome.rs b/src/cortex-tui/src/ui/chrome.rs index 7e1338fb..24a9b74b 100644 --- a/src/cortex-tui/src/ui/chrome.rs +++ b/src/cortex-tui/src/ui/chrome.rs @@ -266,9 +266,16 @@ pub enum FooterSet { Running, Queue, ModelList, + ModelListNarrow, Effort, + EffortNarrow, Approval, + SelectConfirm, + Confirm, + PlanKeep, + PermissionsApply, Mcp, + McpNarrow, Plugins, Resume, Bash, @@ -374,6 +381,20 @@ impl FooterSet { label: "close", }, ], + Self::ModelListNarrow => &[ + FooterHint { + key: "Enter", + label: "choose", + }, + FooterHint { + key: "Tab", + label: "effort", + }, + FooterHint { + key: "Esc", + label: "close", + }, + ], Self::Effort => &[ FooterHint { key: "Enter", @@ -388,6 +409,16 @@ impl FooterSet { label: "close", }, ], + Self::EffortNarrow => &[ + FooterHint { + key: "Enter", + label: "apply", + }, + FooterHint { + key: "Esc", + label: "close", + }, + ], Self::Approval => &[ FooterHint { key: "↑↓", @@ -406,6 +437,50 @@ impl FooterSet { label: "cancel", }, ], + Self::SelectConfirm => &[ + FooterHint { + key: "↑↓", + label: "select", + }, + FooterHint { + key: "Enter", + label: "confirm", + }, + FooterHint { + key: "Esc", + label: "cancel", + }, + ], + Self::Confirm => &[ + FooterHint { + key: "Enter", + label: "confirm", + }, + FooterHint { + key: "Esc", + label: "cancel", + }, + ], + Self::PlanKeep => &[ + FooterHint { + key: "Enter", + label: "confirm", + }, + FooterHint { + key: "Esc", + label: "keep planning", + }, + ], + Self::PermissionsApply => &[ + FooterHint { + key: "Enter", + label: "apply", + }, + FooterHint { + key: "Esc", + label: "close", + }, + ], Self::Mcp => &[ FooterHint { key: "Enter", @@ -424,6 +499,16 @@ impl FooterSet { label: "close", }, ], + Self::McpNarrow => &[ + FooterHint { + key: "Enter", + label: "details", + }, + FooterHint { + key: "Esc", + label: "close", + }, + ], Self::Plugins => &[ FooterHint { key: "Enter", @@ -807,6 +892,19 @@ mod tests { assert_eq!(buf[(1, 0)].style().fg, Some(HAIRLINE)); } + #[test] + fn composer_hover_uses_focus_hairline() { + let area = Rect::new(0, 0, 40, 3); + let mut idle = Buffer::empty(area); + fill_inky(area, &mut idle); + paint_composer_box(area, &mut idle, "Agent", "Cortex Mini 1", false, true, None); + let mut hover = Buffer::empty(area); + fill_inky(area, &mut hover); + paint_composer_box(area, &mut hover, "Agent", "Cortex Mini 1", true, true, None); + assert_eq!(idle[(1, 0)].style().fg, Some(HAIRLINE)); + assert_eq!(hover[(1, 0)].style().fg, Some(BORDER_FOCUS)); + } + #[test] fn composer_box_paints_goal_chip_in_accent() { let area = Rect::new(0, 0, 48, 3); diff --git a/src/cortex-tui/src/ui/text_utils.rs b/src/cortex-tui/src/ui/text_utils.rs index f911cc8a..215de598 100644 --- a/src/cortex-tui/src/ui/text_utils.rs +++ b/src/cortex-tui/src/ui/text_utils.rs @@ -428,6 +428,9 @@ pub fn model_display_name(id: &str) -> String { let slug = id.rsplit('/').next().unwrap_or(id).trim(); if slug.is_empty() || !slug.contains('-') { + if contains_foreign_brand(id) || contains_foreign_brand(slug) { + return "Custom model".to_string(); + } return slug.to_string(); } let parts: Vec<&str> = slug.split('-').filter(|p| !p.is_empty()).collect(); @@ -443,11 +446,37 @@ pub fn model_display_name(id: &str) -> String { format!("Cortex {} {generation}", variants.join(" ")) }; } - parts + let pretty = parts .iter() .map(|p| capitalize(p)) .collect::>() - .join(" ") + .join(" "); + if contains_foreign_brand(id) || contains_foreign_brand(&pretty) { + "Custom model".to_string() + } else { + pretty + } +} + +/// True when copy names a third-party catalog we do not show in Cortex chrome. +pub fn contains_foreign_brand(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + [ + "anthropic", + "openai", + "claude", + "gpt-4", + "gpt-3", + "gpt4", + "gemini", + "llama", + "mistral", + "deepseek", + "sonnet", + "haiku", + ] + .iter() + .any(|needle| lower.contains(needle)) } #[cfg(test)] @@ -822,5 +851,9 @@ mod tests { // No hyphenated slug ever reaches the screen. assert_eq!(model_display_name("some-other-model"), "Some Other Model"); assert!(!model_display_name("cortex-1-mini").contains('-')); + assert_eq!( + model_display_name("anthropic/claude-opus-4"), + "Custom model" + ); } } diff --git a/src/cortex-tui/src/views/minimal_session/checklist.rs b/src/cortex-tui/src/views/minimal_session/checklist.rs new file mode 100644 index 00000000..3777baea --- /dev/null +++ b/src/cortex-tui/src/views/minimal_session/checklist.rs @@ -0,0 +1,83 @@ +//! Working checklist rows for the live session (lock `todos`). + +use ratatui::style::Style; +use ratatui::text::{Line, Span}; + +use crate::app::{AppState, SubagentTodoStatus}; +use crate::ui::colors::AdaptiveColors; +use crate::ui::text_utils::wrap_or_drop; + +/// Paint `⠇ Working n/m` plus ✓ / › / ○ rows. Empty when no session checklist. +pub fn render_working_checklist( + app_state: &AppState, + width: u16, + colors: &AdaptiveColors, +) -> Vec> { + let Some(board) = app_state.working_checklist.as_ref() else { + return Vec::new(); + }; + if board.items.is_empty() { + return Vec::new(); + } + let indent = if app_state.compact_mode { 0 } else { 3 }; + let inner = (width as usize).saturating_sub(indent + 2).max(8); + let mut lines = Vec::new(); + let header = format!("{}{}", " ".repeat(indent), board.header()); + lines.push(Line::from(Span::styled( + header, + Style::default().fg(colors.text_dim), + ))); + for todo in &board.items { + let (mark, color) = match todo.status { + SubagentTodoStatus::Completed => ("✓", colors.success), + SubagentTodoStatus::InProgress => ("›", colors.text), + SubagentTodoStatus::Pending => ("○", colors.text_muted), + }; + let prefix = format!("{} {} ", " ".repeat(indent), mark); + let room = inner.saturating_sub(mark.len() + 2); + let body = wrap_or_drop(&todo.content, room) + .into_iter() + .next() + .unwrap_or_default(); + lines.push(Line::from(vec![ + Span::styled(prefix, Style::default().fg(color)), + Span::styled(body, Style::default().fg(colors.text_dim)), + ])); + } + lines.push(Line::from("")); + lines +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{SubagentTodoItem, WorkingChecklist}; + + #[test] + fn paints_marks_and_counts() { + let mut state = AppState::default(); + state.working_checklist = Some(WorkingChecklist::new( + vec![ + SubagentTodoItem::new( + "Read composer.rs and footer.rs", + SubagentTodoStatus::Completed, + ), + SubagentTodoItem::new( + "Move the chip into the bottom hairline", + SubagentTodoStatus::InProgress, + ), + SubagentTodoItem::new("Run the TUI snapshot tests", SubagentTodoStatus::Pending), + ], + 38, + 6_100, + )); + let colors = AdaptiveColors::default_dark(); + let lines = render_working_checklist(&state, 80, &colors); + let text: String = lines.iter().map(|l| l.to_string()).collect(); + assert!(text.contains("Working 1/3"), "{text}"); + assert!(text.contains("✓"), "{text}"); + assert!(text.contains("›"), "{text}"); + assert!(text.contains("○"), "{text}"); + assert!(text.contains("Read composer.rs"), "{text}"); + } +} diff --git a/src/cortex-tui/src/views/minimal_session/mod.rs b/src/cortex-tui/src/views/minimal_session/mod.rs index 848d0e13..af1aa464 100644 --- a/src/cortex-tui/src/views/minimal_session/mod.rs +++ b/src/cortex-tui/src/views/minimal_session/mod.rs @@ -7,8 +7,11 @@ //! - Simple input line with prompt //! - Contextual key hints at the bottom +mod checklist; mod layout; mod rendering; +mod rendering_banner; +mod rendering_user; mod text_utils; mod view; @@ -19,7 +22,8 @@ mod tests; pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION"); // Re-export main types for backwards compatibility -pub use rendering::{EMPTY_SESSION_HINTS, user_turn_lines}; +pub use rendering::EMPTY_SESSION_HINTS; +pub use rendering_user::user_turn_lines; pub use view::{ BLOCK_CURSOR, COMPOSER_ROWS, ChatMessage, MinimalSessionView, PALETTE_FOOTER_HINT, PALETTE_FOOTER_HINT_SHORT, PLACEHOLDER_IDLE, PLACEHOLDER_RUNNING, paint_composer_contents, diff --git a/src/cortex-tui/src/views/minimal_session/rendering.rs b/src/cortex-tui/src/views/minimal_session/rendering.rs index 5c007839..72634594 100644 --- a/src/cortex-tui/src/views/minimal_session/rendering.rs +++ b/src/cortex-tui/src/views/minimal_session/rendering.rs @@ -20,17 +20,10 @@ use crate::ui::text_utils::wrap_or_drop; use crate::views::tool_call::{ContentSegment, ToolCallDisplay, ToolStatus}; use super::VERSION; +use super::checklist::render_working_checklist; +use super::rendering_user::user_turn_lines; use super::text_utils::wrap_text; -/// Renders the "← Back to main conversation" hint when viewing a subagent. -/// Displays in the top-left area of the screen. -pub fn render_back_to_main_hint(area: Rect, buf: &mut Buffer, colors: &AdaptiveColors) { - let hint = "← Back to main (Esc)"; - let style = Style::default().fg(colors.text_dim); - // Render at the start of the area with 1 character padding - buf.set_string(area.x + 1, area.y, hint, style); -} - /// Renders a single message to lines with optional markdown theme. pub fn render_message_with_theme( msg: &Message, @@ -175,64 +168,6 @@ pub fn render_message_with_theme( lines } -/// A past user turn as full-width bar rows: `> text` on the first row, the -/// wrapped continuation indented under the copy, every row padded to `width` -/// so the gray bar spans the terminal. Timestamp is 12h on the first row when -/// the viewport is wide enough. -pub fn user_turn_lines( - content: &str, - width: u16, - colors: &AdaptiveColors, - timestamp: Option<&str>, - compact: bool, -) -> Vec> { - let bar = Style::default().fg(colors.text).bg(colors.user_bg); - let gutter = if compact { 0 } else { 1 }; - let indent = if compact { 0 } else { 2 }; // spaces inside the bar before `>` - let width = width.max(3) as usize; - let ts = timestamp.filter(|_| width >= 80 && !compact).unwrap_or(""); - let ts_w = if ts.is_empty() { - 0 - } else { - ts.chars().count() + 2 - }; - let text_width = width - .saturating_sub(gutter + 1 + indent + 2) - .saturating_sub(ts_w) - .max(8); - let mut lines = Vec::new(); - let wrapped = wrap_text(content, text_width); - let rows: Vec = if wrapped.is_empty() { - vec![String::new()] - } else { - wrapped - }; - for (i, row) in rows.iter().enumerate() { - let prefix = if i == 0 { "> " } else { " " }; - let mut spans = Vec::new(); - if gutter > 0 { - spans.push(Span::raw(" ".repeat(gutter))); - } - let mut text = format!("{}{}{row}", " ".repeat(indent), prefix); - let used = unicode_width::UnicodeWidthStr::width(text.as_str()); - let pad_target = if i == 0 && !ts.is_empty() { - width.saturating_sub(gutter + ts.chars().count()) - } else { - width.saturating_sub(gutter) - }; - text.push_str(&" ".repeat(pad_target.saturating_sub(used))); - spans.push(Span::styled(text, bar)); - if i == 0 && !ts.is_empty() { - spans.push(Span::styled( - ts.to_string(), - Style::default().fg(colors.text_dim).bg(colors.user_bg), - )); - } - lines.push(Line::from(spans)); - } - lines -} - /// Renders a single tool call as one card: name + path + body. pub fn render_tool_call( call: &ToolCallDisplay, @@ -747,6 +682,9 @@ pub fn generate_message_lines( )); } + // Session working checklist (lock `todos`) — distinct from subagent tiles. + all_lines.extend(render_working_checklist(app_state, width, colors)); + // Render active subagents for task in &app_state.active_subagents { all_lines.extend(render_subagent(task, width, colors)); @@ -1006,58 +944,4 @@ pub fn _render_welcome_text_centered( paragraph.render(text_area, buf); } -/// Renders an update notification banner above the input box. -/// Shows different states: Available, Downloading (with progress), ReadyToRestart -pub fn render_update_banner( - area: Rect, - buf: &mut Buffer, - colors: &AdaptiveColors, - update_status: &crate::app::UpdateStatus, -) { - use crate::app::UpdateStatus; - - if area.is_empty() || area.height < 1 { - return; - } - - // Gray copy throughout; only the `✓` of a finished download is green. - let (icon, icon_style, text) = match update_status { - UpdateStatus::Available { version } => ( - "↑", - Style::default().fg(colors.text), - format!(" A new version ({}) is available ", version), - ), - UpdateStatus::Downloading { - version: _, - progress, - } => ( - "⟳", - Style::default().fg(colors.text_dim), - format!(" Downloading update... {}% ", progress), - ), - UpdateStatus::ReadyToRestart { version: _ } => ( - "✓", - Style::default().fg(colors.success), - " You must restart to run the latest version ".to_string(), - ), - _ => return, // Don't render for other states - }; - let text_style = Style::default().fg(colors.text); - - // Calculate banner width - let banner_width = (icon.chars().count() + text.len() + 2) as u16; // +2 for spacing - - // Position at left side of the area with some padding - let x = area.x + 2; - let y = area.y; - - if x + banner_width > area.right() { - return; - } - - // Render icon - buf.set_string(x, y, icon, icon_style); - - // Render text - buf.set_string(x + icon.chars().count() as u16 + 1, y, &text, text_style); -} +pub use super::rendering_banner::{render_back_to_main_hint, render_update_banner}; diff --git a/src/cortex-tui/src/views/minimal_session/rendering_banner.rs b/src/cortex-tui/src/views/minimal_session/rendering_banner.rs new file mode 100644 index 00000000..0ee5055e --- /dev/null +++ b/src/cortex-tui/src/views/minimal_session/rendering_banner.rs @@ -0,0 +1,59 @@ +//! Session chrome banners (update + back-to-main). + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; + +use crate::ui::colors::AdaptiveColors; + +/// Renders the "← Back to main conversation" hint when viewing a subagent. +pub fn render_back_to_main_hint(area: Rect, buf: &mut Buffer, colors: &AdaptiveColors) { + let hint = "← Back to main (Esc)"; + let style = Style::default().fg(colors.text_dim); + buf.set_string(area.x + 1, area.y, hint, style); +} + +/// Renders an update notification banner above the input box. +pub fn render_update_banner( + area: Rect, + buf: &mut Buffer, + colors: &AdaptiveColors, + update_status: &crate::app::UpdateStatus, +) { + use crate::app::UpdateStatus; + + if area.is_empty() || area.height < 1 { + return; + } + + let (icon, icon_style, text) = match update_status { + UpdateStatus::Available { version } => ( + "↑", + Style::default().fg(colors.text), + format!(" A new version ({}) is available ", version), + ), + UpdateStatus::Downloading { + version: _, + progress, + } => ( + "⟳", + Style::default().fg(colors.text_dim), + format!(" Downloading update... {}% ", progress), + ), + UpdateStatus::ReadyToRestart { version: _ } => ( + "✓", + Style::default().fg(colors.success), + " You must restart to run the latest version ".to_string(), + ), + _ => return, + }; + let text_style = Style::default().fg(colors.text); + let banner_width = (icon.chars().count() + text.len() + 2) as u16; + let x = area.x + 2; + let y = area.y; + if x + banner_width > area.right() { + return; + } + buf.set_string(x, y, icon, icon_style); + buf.set_string(x + icon.chars().count() as u16 + 1, y, &text, text_style); +} diff --git a/src/cortex-tui/src/views/minimal_session/rendering_user.rs b/src/cortex-tui/src/views/minimal_session/rendering_user.rs new file mode 100644 index 00000000..f9fe40cd --- /dev/null +++ b/src/cortex-tui/src/views/minimal_session/rendering_user.rs @@ -0,0 +1,66 @@ +//! User-turn bars for the session transcript. + +use ratatui::style::Style; +use ratatui::text::{Line, Span}; + +use crate::ui::colors::AdaptiveColors; + +use super::text_utils::wrap_text; + +/// A past user turn as full-width bar rows: `> text` on the first row, the +/// wrapped continuation indented under the copy, every row padded to `width` +/// so the gray bar spans the terminal. Timestamp is 12h on the first row when +/// the viewport is wide enough. +pub fn user_turn_lines( + content: &str, + width: u16, + colors: &AdaptiveColors, + timestamp: Option<&str>, + compact: bool, +) -> Vec> { + let bar = Style::default().fg(colors.text).bg(colors.user_bg); + let gutter = if compact { 0 } else { 1 }; + let indent = if compact { 0 } else { 2 }; + let width = width.max(3) as usize; + let ts = timestamp.filter(|_| width >= 80 && !compact).unwrap_or(""); + let ts_w = if ts.is_empty() { + 0 + } else { + ts.chars().count() + 2 + }; + let text_width = width + .saturating_sub(gutter + 1 + indent + 2) + .saturating_sub(ts_w) + .max(8); + let mut lines = Vec::new(); + let wrapped = wrap_text(content, text_width); + let rows: Vec = if wrapped.is_empty() { + vec![String::new()] + } else { + wrapped + }; + for (i, row) in rows.iter().enumerate() { + let prefix = if i == 0 { "> " } else { " " }; + let mut spans = Vec::new(); + if gutter > 0 { + spans.push(Span::raw(" ".repeat(gutter))); + } + let mut text = format!("{}{}{row}", " ".repeat(indent), prefix); + let used = unicode_width::UnicodeWidthStr::width(text.as_str()); + let pad_target = if i == 0 && !ts.is_empty() { + width.saturating_sub(gutter + ts.chars().count()) + } else { + width.saturating_sub(gutter) + }; + text.push_str(&" ".repeat(pad_target.saturating_sub(used))); + spans.push(Span::styled(text, bar)); + if i == 0 && !ts.is_empty() { + spans.push(Span::styled( + ts.to_string(), + Style::default().fg(colors.text_dim).bg(colors.user_bg), + )); + } + lines.push(Line::from(spans)); + } + lines +} diff --git a/src/cortex-tui/src/views/minimal_session/view.rs b/src/cortex-tui/src/views/minimal_session/view.rs index 03bcf703..9c80f061 100644 --- a/src/cortex-tui/src/views/minimal_session/view.rs +++ b/src/cortex-tui/src/views/minimal_session/view.rs @@ -11,6 +11,8 @@ use super::rendering::{ }; use crate::app::AppState; use crate::commands::PALETTE_HOME_LIMIT; +use crate::interactive::InteractiveAction; +use crate::interactive::builders::PERMISSION_PROMPT_ACTION; use crate::ui::chrome::{ FooterSet, composer_caret_style, composer_inner, fill_inky, format_token_counter, model_chip, paint_composer_box, paint_footer, paint_status_marker, paint_token_counter, @@ -32,8 +34,9 @@ pub const PLACEHOLDER_IDLE: &str = "Plan, search, build anything"; /// Composer placeholder while a run is live — stdin stays alive and a /// submitted follow-up is queued. pub const PLACEHOLDER_RUNNING: &str = "Add a follow-up — Enter to queue"; -/// Composer placeholder while a SPEC §3.10 prompt owns focus. pub const PLACEHOLDER_PROMPT: &str = crate::interactive::builders::PERMISSION_PROMPT_PLACEHOLDER; +/// Composer placeholder after Esc / Ctrl+c interrupt. +pub const PLACEHOLDER_STOPPED: &str = "Reply, or ↑ to edit your last message"; /// Paint the composer input row (after `> `) to the lock: /// empty = block cursor at input col 0, dim placeholder after that cell; @@ -329,6 +332,8 @@ impl<'a> MinimalSessionView<'a> { } else { crate::ui::consts::PLACEHOLDER_DISCONNECTED } + } else if self.app_state.last_turn_stopped { + PLACEHOLDER_STOPPED } else if self.is_task_running() { PLACEHOLDER_RUNNING } else if self.app_state.agent_entrypoint { @@ -652,23 +657,9 @@ impl<'a> Widget for MinimalSessionView<'a> { }; let interactive = self.app_state.is_interactive_mode(); - let effort_focused = self - .app_state - .get_interactive_state() - .map(|s| s.effort_focused) - .unwrap_or(false); let picker_height: u16 = if interactive { if let Some(state) = self.app_state.get_interactive_state() { - if effort_focused { - 3 - } else { - let n = if state.filtered_indices.is_empty() { - 1 - } else { - state.filtered_indices.len().min(state.max_visible).min(8) - }; - n as u16 - } + crate::interactive::picker_layout::picker_stack_height(state) } else { 0 } @@ -679,13 +670,11 @@ impl<'a> Widget for MinimalSessionView<'a> { }; let show_optin = self.app_state.opt_in_banner && !self.app_state.messages.is_empty(); - let optin_height: u16 = if show_optin { - if area.height >= 20 { 5 } else { 3 } - } else { - 0 - }; + let optin_height = + crate::interactive::picker_layout::session_optin_height(show_optin, area.height); let show_update_banner = self.app_state.should_show_update_banner(); - let update_banner_height: u16 = if show_update_banner { 1 } else { 0 }; + let update_banner_height = + crate::interactive::picker_layout::session_update_height(show_update_banner); let stack_below_transcript = picker_height + optin_height + update_banner_height; let transcript_bottom = composer_y.saturating_sub(stack_below_transcript); @@ -719,7 +708,12 @@ impl<'a> Widget for MinimalSessionView<'a> { } if picker_height > 0 { - let picker_area = Rect::new(area.x, next_y, area.width, picker_height); + let picker_area = crate::interactive::picker_layout::session_inline_picker_area( + area, + picker_height, + update_banner_height, + optin_height, + ); if interactive { if let Some(state) = self.app_state.get_interactive_state() { crate::interactive::InteractiveWidget::new(state) @@ -739,6 +733,10 @@ impl<'a> Widget for MinimalSessionView<'a> { } if self.app_state.shortcuts_open { crate::widgets::ShortcutsOverlay::new(self.app_state.cli_version.clone()) + .with_selection( + self.app_state.shortcuts_selected, + self.app_state.shortcuts_hovered, + ) .render(area, buf); } @@ -762,15 +760,36 @@ impl<'a> MinimalSessionView<'a> { } if let Some(state) = self.app_state.get_interactive_state() { if state.effort_focused { - return FooterSet::Effort; - } - let title = state.title.to_ascii_lowercase(); - if title.contains("model") { - return FooterSet::ModelList; + return if area_is_narrow(width) { + FooterSet::EffortNarrow + } else { + FooterSet::Effort + }; } - if title.contains("mcp") { - return FooterSet::Mcp; + match &state.action { + InteractiveAction::SetModel => { + return if area_is_narrow(width) { + FooterSet::ModelListNarrow + } else { + FooterSet::ModelList + }; + } + InteractiveAction::McpServerAction => { + return if area_is_narrow(width) { + FooterSet::McpNarrow + } else { + FooterSet::Mcp + }; + } + InteractiveAction::ResumeSession => return FooterSet::Resume, + InteractiveAction::Custom(id) => { + if let Some(set) = custom_interactive_footer(id) { + return set; + } + } + _ => {} } + let title = state.title.to_ascii_lowercase(); if title.contains("plugin") { return FooterSet::Plugins; } @@ -780,12 +799,6 @@ impl<'a> MinimalSessionView<'a> { if title == "undo" || title.starts_with("undo") { return FooterSet::UndoSheet; } - if title.contains("resume") || title.contains("session") { - return FooterSet::Resume; - } - if title.contains("permission") || title.contains("approv") { - return FooterSet::Approval; - } } if self.app_state.agent_mode_label == "Bash" { return FooterSet::Bash; @@ -836,6 +849,18 @@ fn area_is_narrow(width: u16) -> bool { width < 80 } +fn custom_interactive_footer(id: &str) -> Option { + Some(match id { + PERMISSION_PROMPT_ACTION => FooterSet::Approval, + "sandbox-deny" => FooterSet::SelectConfirm, + "plan-confirm" => FooterSet::PlanKeep, + "permissions-picker" => FooterSet::PermissionsApply, + "clear-confirm" => FooterSet::Confirm, + "plugins" => FooterSet::Plugins, + _ => return None, + }) +} + /// Accent slash commands (`/undo`) and completed `@path` file chips. fn composer_char_accent(chars: &[char], index: usize) -> bool { if chars.first() == Some(&'/') { diff --git a/src/cortex-tui/src/views/tool_call.rs b/src/cortex-tui/src/views/tool_call.rs index 4bf1386c..4e54af3e 100644 --- a/src/cortex-tui/src/views/tool_call.rs +++ b/src/cortex-tui/src/views/tool_call.rs @@ -158,7 +158,10 @@ pub fn tool_tile_label(name: &str) -> String { pub fn format_tool_summary(name: &str, args: &Value) -> String { match name.to_lowercase().as_str() { "read" | "edit" | "write" | "delete" => { - if let Some(path) = args.get("file_path").or_else(|| args.get("filePath")) + if let Some(path) = args + .get("file_path") + .or_else(|| args.get("filePath")) + .or_else(|| args.get("path")) && let Some(path_str) = path.as_str() { return shorten_path(path_str); diff --git a/src/cortex-tui/src/widgets/shortcuts_overlay.rs b/src/cortex-tui/src/widgets/shortcuts_overlay.rs index 1e13b44f..148eba4a 100644 --- a/src/cortex-tui/src/widgets/shortcuts_overlay.rs +++ b/src/cortex-tui/src/widgets/shortcuts_overlay.rs @@ -1,6 +1,9 @@ -//! Ctrl+x shortcuts overlay — lock v2 §3.12. +//! `/shortcuts` interactive sheet — lock v2 Ctrl+x overlay. +//! +//! Open, navigate (↑↓), dismiss (Esc / Ctrl+x / `[x]`). Selected key uses +//! banner green `#1F4945`. Chrome hairlines stay inky gray. -use cortex_core::style::{HAIRLINE, TEXT, TEXT_DIM, TEXT_MUTED, VOID}; +use cortex_core::style::{ACCENT, HAIRLINE, TEXT, TEXT_DIM, TEXT_MUTED, VOID}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Style; @@ -30,15 +33,29 @@ const RIGHT: &[(&str, &str)] = &[ ("Ctrl+x", "this overlay"), ]; +const NARROW: &[(&str, &str)] = &[ + ("Shift+Tab", "cycle Agent / Plan / Ask"), + ("@", "mention files"), + ("!", "bash mode"), + ("&", "hand off to Cortex Cloud"), + ("Ctrl+c", "stop"), + ("F2", "settings"), + ("Esc", "interrupt / close"), +]; + /// Two-column shortcuts overlay. pub struct ShortcutsOverlay { pub version: String, + pub selected: usize, + pub hovered: Option, } impl Default for ShortcutsOverlay { fn default() -> Self { Self { version: VERSION.to_string(), + selected: 0, + hovered: None, } } } @@ -47,16 +64,108 @@ impl ShortcutsOverlay { pub fn new(version: impl Into) -> Self { Self { version: version.into(), + selected: 0, + hovered: None, + } + } + + pub fn with_selection(mut self, selected: usize, hovered: Option) -> Self { + self.selected = selected; + self.hovered = hovered; + self + } + + pub fn catalog_len(area: Rect) -> usize { + if compact(area) { + NARROW.len() + } else { + LEFT.len() + RIGHT.len() + } + } + + /// Rows that actually paint inside `area` (compact terminals clip the list). + pub fn row_count(area: Rect) -> usize { + let modal = Self::modal_rect(area); + let inner_h = modal.height.saturating_sub(4); + if compact(area) { + let visible = inner_h.saturating_sub(2) as usize; + NARROW.len().min(visible.max(1)) + } else { + let per = inner_h.saturating_sub(3) as usize; + LEFT.len().min(per) + RIGHT.len().min(per) } + .max(1) + .min(Self::catalog_len(area)) } - fn modal_rect(area: Rect) -> Rect { - let w = 84u16.min(area.width.saturating_sub(4)).max(40); - let h = 14u16.min(area.height.saturating_sub(2)).max(8); + pub fn wrap_index(area: Rect, index: usize) -> usize { + let n = Self::row_count(area).max(1); + index % n + } + + pub fn modal_rect(area: Rect) -> Rect { + let w = 84u16 + .min(area.width.saturating_sub(4)) + .max(40.min(area.width)); + let h = 14u16 + .min(area.height.saturating_sub(2)) + .max(8.min(area.height)); let x = area.x + (area.width.saturating_sub(w)) / 2; let y = area.y + (area.height.saturating_sub(h)) / 2; Rect::new(x, y, w, h) } + + /// True when `(x, y)` is on the `[x]` close control. + pub fn close_hit(area: Rect, x: u16, y: u16) -> bool { + let modal = Self::modal_rect(area); + if modal.width < 6 || y != modal.y { + return false; + } + let close_x = modal.right().saturating_sub(6); + x >= close_x && x < close_x.saturating_add(3) + } + + /// Binding under `(x, y)`, if any. Pointers outside the sheet are ignored. + pub fn row_at(area: Rect, x: u16, y: u16) -> Option { + let modal = Self::modal_rect(area); + if x < modal.x || x >= modal.right() || y < modal.y || y >= modal.bottom() { + return None; + } + let inner = Rect::new( + modal.x + 2, + modal.y + 2, + modal.width.saturating_sub(4), + modal.height.saturating_sub(4), + ); + if y < inner.y { + return None; + } + let idx = (y - inner.y) as usize; + if compact(area) { + let stop = inner.bottom().saturating_sub(2); + if y >= stop { + return None; + } + return (idx < NARROW.len()).then_some(idx); + } + let stop = inner.bottom().saturating_sub(3); + if y >= stop { + return None; + } + let col2 = inner.x + inner.width / 2; + if x >= col2 { + let i = LEFT.len() + idx; + (i < LEFT.len() + RIGHT.len()).then_some(i) + } else if idx < LEFT.len() { + Some(idx) + } else { + None + } + } +} + +fn compact(area: Rect) -> bool { + area.width < 80 || area.height < 16 } impl Widget for ShortcutsOverlay { @@ -100,54 +209,113 @@ impl Widget for ShortcutsOverlay { modal.width.saturating_sub(4), modal.height.saturating_sub(4), ); - let col2 = inner.x + inner.width / 2; - for (i, (key, label)) in LEFT.iter().enumerate() { - let y = inner.y + i as u16; - if y >= inner.bottom().saturating_sub(3) { - break; - } - buf.set_string(inner.x, y, format!("{key:<12}"), Style::default().fg(TEXT)); + if compact(area) { + paint_column( + buf, + inner.x, + inner.y, + inner.width, + inner.bottom().saturating_sub(2), + NARROW, + self.selected, + self.hovered, + 0, + ); + } else { + let col2 = inner.x + inner.width / 2; + let stop = inner.bottom().saturating_sub(3); + paint_column( + buf, + inner.x, + inner.y, + col2.saturating_sub(inner.x), + stop, + LEFT, + self.selected, + self.hovered, + 0, + ); + paint_column( + buf, + col2, + inner.y, + inner.right().saturating_sub(col2), + stop, + RIGHT, + self.selected, + self.hovered, + LEFT.len(), + ); + let hair_y = inner.bottom().saturating_sub(3); + buf.set_string(inner.x, hair_y, "─".repeat(inner.width as usize), rule); + let docs = format!( + "Docs & guides: cortex.foundation/docs · Cortex CLI v{}", + self.version + ); buf.set_string( - inner.x + 13, - y, - first_fitting_line(label, (col2.saturating_sub(inner.x + 14)) as usize), - Style::default().fg(TEXT_DIM), + inner.x, + hair_y + 1, + first_fitting_line(&docs, inner.width as usize), + Style::default().fg(TEXT_MUTED), ); + let close = "Ctrl+x/Esc close"; + let cx = inner.x + (inner.width.saturating_sub(close.len() as u16)) / 2; + buf.set_string(cx, hair_y + 2, close, Style::default().fg(TEXT_DIM)); + return; } - for (i, (key, label)) in RIGHT.iter().enumerate() { - let y = inner.y + i as u16; - if y >= inner.bottom().saturating_sub(3) { - break; - } - buf.set_string(col2, y, format!("{key:<12}"), Style::default().fg(TEXT)); + let close = "Ctrl+x/Esc close"; + let cy = modal.bottom().saturating_sub(2); + let cx = inner.x + (inner.width.saturating_sub(close.len() as u16)) / 2; + buf.set_string(cx, cy, close, Style::default().fg(TEXT_DIM)); + } +} + +fn paint_column( + buf: &mut Buffer, + x: u16, + y0: u16, + width: u16, + stop_y: u16, + rows: &[(&str, &str)], + selected: usize, + hovered: Option, + base: usize, +) { + for (i, (key, label)) in rows.iter().enumerate() { + let y = y0 + i as u16; + if y >= stop_y { + break; + } + let idx = base + i; + let active = idx == selected || hovered == Some(idx); + let key_style = if active { + Style::default().fg(ACCENT) + } else { + Style::default().fg(TEXT) + }; + let key_w = 12u16.min(width); + buf.set_string(x, y, format!("{key:<12}"), key_style); + if width > key_w + 1 { buf.set_string( - col2 + 13, + x + key_w, y, - first_fitting_line(label, inner.right().saturating_sub(col2 + 13) as usize), + first_fitting_line(label, width.saturating_sub(key_w) as usize), Style::default().fg(TEXT_DIM), ); } - let hair_y = inner.bottom().saturating_sub(3); - buf.set_string(inner.x, hair_y, "─".repeat(inner.width as usize), rule); - let docs = format!( - "Docs & guides: cortex.foundation/docs · Cortex CLI v{}", - self.version - ); - buf.set_string( - inner.x, - hair_y + 1, - first_fitting_line(&docs, inner.width as usize), - Style::default().fg(TEXT_MUTED), - ); - let close = "Ctrl+x/Esc close"; - let cx = inner.x + (inner.width.saturating_sub(close.len() as u16)) / 2; - buf.set_string(cx, hair_y + 2, close, Style::default().fg(TEXT_DIM)); } } #[cfg(test)] mod tests { use super::*; + use cortex_core::style::ACCENT; + + fn line(buf: &Buffer, y: u16, width: u16) -> String { + (0..width) + .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' ')) + .collect() + } #[test] fn overlay_paints_title_and_docs() { @@ -157,17 +325,85 @@ mod tests { let mut found_title = false; let mut found_docs = false; for y in 0..20 { - let line: String = (0..120) - .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' ')) - .collect(); - if line.contains("Shortcuts") { + let row = line(&buf, y, 120); + if row.contains("Shortcuts") { found_title = true; } - if line.contains("cortex.foundation/docs") { + if row.contains("cortex.foundation/docs") { found_docs = true; } } assert!(found_title); assert!(found_docs); } + + #[test] + fn selected_key_uses_banner_green() { + let area = Rect::new(0, 0, 120, 24); + let mut buf = Buffer::empty(area); + ShortcutsOverlay::new("0.1.7") + .with_selection(0, None) + .render(area, &mut buf); + let mut accent = false; + for y in 0..24 { + for x in 0..120 { + if buf[(x, y)].fg == ACCENT && buf[(x, y)].symbol().contains('S') { + accent = true; + } + } + } + assert!(accent, "selected Shift+Tab must use #1F4945"); + } + + #[test] + fn close_hit_and_row_at() { + let area = Rect::new(0, 0, 120, 40); + let modal = ShortcutsOverlay::modal_rect(area); + let close_x = modal.right().saturating_sub(6); + assert!(ShortcutsOverlay::close_hit(area, close_x, modal.y)); + assert!(!ShortcutsOverlay::close_hit(area, modal.x, modal.y)); + assert_eq!( + ShortcutsOverlay::row_at(area, modal.x + 3, modal.y + 2), + Some(0) + ); + assert_eq!( + ShortcutsOverlay::row_at(area, modal.x.saturating_sub(1), modal.y + 2), + None + ); + assert_eq!( + ShortcutsOverlay::row_at(area, modal.right(), modal.y + 2), + None + ); + assert_eq!( + ShortcutsOverlay::catalog_len(area), + LEFT.len() + RIGHT.len() + ); + } + + #[test] + fn narrow_sheet_fits_40x12() { + let area = Rect::new(0, 0, 40, 12); + let mut buf = Buffer::empty(area); + ShortcutsOverlay::new("0.1.7").render(area, &mut buf); + let mut found = false; + for y in 0..12 { + if line(&buf, y, 40).contains("Shortcuts") { + found = true; + } + } + assert!(found); + let visible = ShortcutsOverlay::row_count(area); + assert!( + visible <= 4, + "40×12 paints at most 4 bindings, got {visible}" + ); + assert_eq!(ShortcutsOverlay::catalog_len(area), NARROW.len()); + assert_eq!(ShortcutsOverlay::wrap_index(area, visible), 0); + let modal = ShortcutsOverlay::modal_rect(area); + let inner_y = modal.y + 2; + assert_eq!( + ShortcutsOverlay::row_at(area, modal.x + 2, inner_y + visible as u16), + None + ); + } }