diff --git a/.cursor/skills/verify-anyr/SKILL.md b/.cursor/skills/verify-anyr/SKILL.md index 8b6fb60..300916d 100644 --- a/.cursor/skills/verify-anyr/SKILL.md +++ b/.cursor/skills/verify-anyr/SKILL.md @@ -104,7 +104,7 @@ Stable handles. Match these strings, not column layout or ANSI color. | Whoami | `cli -- whoami` | `active account` `default`; full fixture key absent | | Config path | `cli -- config path` | isolated `config.yaml` | | Account switch | `cli -- account use work` | stdout contains `work`; follow with `whoami` | -| Menu dump | `cli -- menu --dump-tui` | ANSI-free frame with `LAUNCH`, `claude`, `❯`, box corners `╭`/`╯`; secret absent | +| Menu dump | `cli -- menu --dump-tui` | ANSI-free frame with `LAUNCH`, `claude`, `CONFIGURE`, `for claude`, `❯`, box corners `╭`/`╯`; secret absent | | Config dump | `cli -- config --dump-tui` | sections `ACCOUNT`, `MODEL`, `AGENT`, `GENERAL` | | Agent dry-run | `cli -- claude --dry-run --yes --key sk-ar-v1-fixture-key-0001` | `command:`, `ANTHROPIC_BASE_URL`, key redacted. **Does not spawn Claude** | | Upgrade check | `ANYR_RELEASES_JSON=$(control-anyr path RELEASES_FIXTURE)` then `cli -- upgrade --check --dry-run` | no install; no live GitHub required | diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/PROOF.md b/.cursor/skills/verify-anyr/artifacts/tui-launcher/PROOF.md new file mode 100644 index 0000000..d8d2c7c --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/PROOF.md @@ -0,0 +1,43 @@ +# Proof: tui-launcher (#36 pick-agent-then-settings) + +Feature: `tui-launcher` +Entry points driven: `anyr menu --dump-tui`, empty-agents dump, `anyr config --dump-tui`, `anyr claude --dry-run --yes`, PTY `anyr menu` +Harness: `.cursor/skills/verify-anyr/control-anyr` (`launch`, `doctor`, `cli`, `pty`, `cleanup`) +Binary: `target/debug/anyr` from this checkout (`0.1.11`) +Isolated home: `/tmp/anyr-verify-20260903-060828-7216/home` (removed by cleanup) + +## Commands + +```bash +.cursor/skills/verify-anyr/control-anyr launch +.cursor/skills/verify-anyr/control-anyr doctor +.cursor/skills/verify-anyr/control-anyr cli --out artifacts/tui-launcher/menu-dump.txt -- menu --dump-tui +# empty-agents: helper always exports ANYR_AGENTS=claude,codex; drive the +# isolated binary directly with ANYR_AGENTS=none +ANYR_AGENTS=none ANYROUTER_HOME=… "$BIN" menu --dump-tui > artifacts/tui-launcher/menu-empty.txt +.cursor/skills/verify-anyr/control-anyr cli --out artifacts/tui-launcher/config-dump.txt -- config --dump-tui +.cursor/skills/verify-anyr/control-anyr cli --out artifacts/tui-launcher/claude-dry-run.txt -- claude --dry-run --yes --key sk-ar-v1-fixture-key-0001 +.cursor/skills/verify-anyr/control-anyr pty start -- menu +.cursor/skills/verify-anyr/control-anyr pty capture --path artifacts/tui-launcher/pane.txt +.cursor/skills/verify-anyr/control-anyr pty send Down +.cursor/skills/verify-anyr/control-anyr pty capture --path artifacts/tui-launcher/pane-focus-codex.txt +.cursor/skills/verify-anyr/control-anyr pty send Escape +.cursor/skills/verify-anyr/control-anyr cleanup +``` + +## Results + +| Artifact | Exit | Observable | +| --- | --- | --- | +| `menu-dump.txt` | 0 | Compact 3-row mark (`▄▄ ▄▄▄` / `▄█▀▀█▄▄█▀`). `LAUNCH` and `CONFIGURE · CLAUDE` on the same frame. Claude row: `stealth/ox-alpha · default ·` masked key. Codex row: `auto`. `model…` / `account…` / `key…` say `for claude`. `MORE` holds install/config/quit. No `verify-fixture-not-a-real-key`. | +| `menu-empty.txt` | 0 | `install an agent…`, `none detected`; launch list still present (not replaced by configure). | +| `config-dump.txt` | 0 | `ACCOUNT`, `MODEL`, `AGENT`, `GENERAL`, `auto-update`, `update channel`. | +| `claude-dry-run.txt` | 0 | `command: claude`, `ANTHROPIC_MODEL=stealth/ox-alpha[1m]`, key redacted. Did not spawn Claude. | +| `pane.txt` | PTY | Live home: `LAUNCH` + `CONFIGURE · CLAUDE` together. Highlight on claude. | +| `pane-focus-codex.txt` | PTY | Down moves highlight to codex. `CONFIGURE · CODEX` / `for codex`. Launch list still visible. Claude still shows `stealth/ox-alpha`. | + +No `anyr claude` without `--dry-run`. No paid tokens. Model catalog / `anyrouter/auto` most-used dump not exercised (#37). + +## Cleanup + +`control-anyr cleanup` removed the isolated workdir and left this `artifacts/tui-launcher/` tree in place. diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/claude-dry-run.txt b/.cursor/skills/verify-anyr/artifacts/tui-launcher/claude-dry-run.txt new file mode 100644 index 0000000..be1235d --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/claude-dry-run.txt @@ -0,0 +1,20 @@ +command: claude +args: [] +env: +ANTHROPIC_AUTH_TOKEN=sk-ar-...0001 +ANTHROPIC_BASE_URL=https://anyrouter.dev/api +ANTHROPIC_DEFAULT_FABLE_MODEL=stealth/ox-alpha[1m] +ANTHROPIC_DEFAULT_FABLE_MODEL_DESCRIPTION=Fable alias + fallback +ANTHROPIC_DEFAULT_HAIKU_MODEL=stealth/ox-alpha[1m] +ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION=Background & subagents +ANTHROPIC_DEFAULT_OPUS_MODEL=stealth/ox-alpha[1m] +ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION=Opus alias +ANTHROPIC_DEFAULT_SONNET_MODEL=stealth/ox-alpha[1m] +ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION=Sonnet alias +ANTHROPIC_MODEL=stealth/ox-alpha[1m] +ANYROUTER_MODEL_MODE=concrete +ANYROUTER_PINNED_PRESET=@preset/coding-stack +API_TIMEOUT_MS=3000000 +CLAUDE_CODE_AUTO_COMPACT_WINDOW=1000000 +CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 +CLAUDE_CODE_SUBAGENT_MODEL=stealth/ox-alpha[1m] diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/claude-dry-run.txt.err b/.cursor/skills/verify-anyr/artifacts/tui-launcher/claude-dry-run.txt.err new file mode 100644 index 0000000..e69de29 diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/claude-dry-run.txt.exit b/.cursor/skills/verify-anyr/artifacts/tui-launcher/claude-dry-run.txt.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/claude-dry-run.txt.exit @@ -0,0 +1 @@ +0 diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-after.txt b/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-after.txt new file mode 100644 index 0000000..bb0a597 --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-after.txt @@ -0,0 +1 @@ +/tmp/anyr-verify-20260903-060828-7216/home/config.yaml diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-after.txt.err b/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-after.txt.err new file mode 100644 index 0000000..e69de29 diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-after.txt.exit b/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-after.txt.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-after.txt.exit @@ -0,0 +1 @@ +0 diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-dump.txt b/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-dump.txt new file mode 100644 index 0000000..82a70a5 --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-dump.txt @@ -0,0 +1,26 @@ + ╭ ▲ Config ────────────────────────────────────────────────────────╮ + │account default │ + │credits - │ + │file /tmp/anyr-verify-20260903-060828-7216/home/config.yaml │ + │ │ + │[general] claude codex grok opencode pi pool │ + │ │ + ├──────────────────────────────────────────────────────────────────┤ + │ │ + │ ACCOUNT │ + │◆ account default │ + │ api key sk-ar-v1-verif…-key │ + │ │ + │ MODEL │ + │ default auto · most used │ + │ │ + │ AGENT │ + │ coding agent claude │ + │ on PATH claude, codex │ + │ │ + │ GENERAL │ + │ auto-update disabled │ + │ update channel stable │ + ├──────────────────────────────────────────────────────────────────┤ + │tab agent ↑↓ move ↵ edit x reset q close │ + ╰──────────────────────────────────────────────────────────────────╯ diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-dump.txt.err b/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-dump.txt.err new file mode 100644 index 0000000..e69de29 diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-dump.txt.exit b/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-dump.txt.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/config-dump.txt.exit @@ -0,0 +1 @@ +0 diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-dump.txt b/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-dump.txt new file mode 100644 index 0000000..ad068f9 --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-dump.txt @@ -0,0 +1,23 @@ + ╭ anyr ────────────────────────────────────────────────────────────────╮ + │ ▄▄ ▄▄▄ account default │ + │ ▄█▀▀█▄▄█▀ credits - │ + │ ▀▀ ▀▀▀▀ │ + ├──────────────────────────────────────────────────────────────────────┤ + │❯ █ │ + ├──────────────────────────────────────────────────────────────────────┤ + │ LAUNCH │ + │◆ ⚡ claude stealth/ox-alpha · default · sk-ar-v1-verif…-key │ + │ ⚡ codex auto · most used · default · sk-ar-v1-verif…-key │ + │ │ + │ CONFIGURE · CLAUDE │ + │ ◆ model… for claude │ + │ ⇄ account… for claude │ + │ 🔑 key… for claude │ + │ │ + │ MORE │ + │ ⬇ install… install a coding agent │ + │ ⚙ config… accounts · keys · agent │ + │ ✕ quit esc works too │ + ├──────────────────────────────────────────────────────────────────────┤ + │type to filter ↑↓ pick agent ↵ launch / switch esc quit │ + ╰──────────────────────────────────────────────────────────────────────╯ diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-dump.txt.err b/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-dump.txt.err new file mode 100644 index 0000000..e69de29 diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-dump.txt.exit b/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-dump.txt.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-dump.txt.exit @@ -0,0 +1 @@ +0 diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-empty.txt b/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-empty.txt new file mode 100644 index 0000000..2665eb3 --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-empty.txt @@ -0,0 +1,22 @@ + ╭ anyr ────────────────────────────────────────────────────────────────╮ + │ ▄▄ ▄▄▄ account default │ + │ ▄█▀▀█▄▄█▀ credits - │ + │ ▀▀ ▀▀▀▀ │ + ├──────────────────────────────────────────────────────────────────────┤ + │❯ █ │ + ├──────────────────────────────────────────────────────────────────────┤ + │ LAUNCH │ + │◆ ⬇ install an agent… none detected on PATH │ + │ │ + │ CONFIGURE · CLAUDE │ + │ ◆ model… for claude │ + │ ⇄ account… for claude │ + │ 🔑 key… for claude │ + │ │ + │ MORE │ + │ ⬇ install… install a coding agent │ + │ ⚙ config… accounts · keys · agent │ + │ ✕ quit esc works too │ + ├──────────────────────────────────────────────────────────────────────┤ + │type to filter ↑↓ pick agent ↵ launch / switch esc quit │ + ╰──────────────────────────────────────────────────────────────────────╯ diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-empty.txt.err b/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-empty.txt.err new file mode 100644 index 0000000..e69de29 diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-empty.txt.exit b/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-empty.txt.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/menu-empty.txt.exit @@ -0,0 +1 @@ +0 diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/pane-focus-codex.txt b/.cursor/skills/verify-anyr/artifacts/tui-launcher/pane-focus-codex.txt new file mode 100644 index 0000000..869beec --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/pane-focus-codex.txt @@ -0,0 +1,24 @@ + ┌ anyr ────────────────────────────────────────────────────────────────┐ + │ │ + │ ▄▄ ▄▄▄ account default │ + │ ▄█▀▀█▄▄█▀ credits (unknown) │ + │ ▀▀ ▀▀▀▀ │ + │ ──────────────────────────────────────────────────────────────────── │ + │ ❯ █ │ + │ │ + │ LAUNCH │ + │ ⚡ claude stealth/ox-alpha · default · sk-ar-v1-verif…-ke │ + │ ❯ ⚡ codex auto · most used · default · sk-ar-v1-verif…-ke │ + │ │ + │ CONFIGURE · CODEX │ + │ ◆ model… for codex │ + │ ⇄ account… for codex │ + │ 🔑 key… for code │ + │ │ + │ MORE │ + │ ⬇ install… install a coding agent │ + │ ⚙ config… accounts · keys · agent │ + │ │ + │ type to filter ↑↓ pick agent ↵ launch / switch esc quit │ + │ │ + └──────────────────────────────────────────────────────────────────────┘ diff --git a/.cursor/skills/verify-anyr/artifacts/tui-launcher/pane.txt b/.cursor/skills/verify-anyr/artifacts/tui-launcher/pane.txt new file mode 100644 index 0000000..c14c839 --- /dev/null +++ b/.cursor/skills/verify-anyr/artifacts/tui-launcher/pane.txt @@ -0,0 +1,24 @@ + ┌ anyr ────────────────────────────────────────────────────────────────┐ + │ │ + │ ▄▄ ▄▄▄ account default │ + │ ▄█▀▀█▄▄█▀ credits (unknown) │ + │ ▀▀ ▀▀▀▀ │ + │ ──────────────────────────────────────────────────────────────────── │ + │ ❯ █ │ + │ │ + │ LAUNCH │ + │ ❯ ⚡ claude stealth/ox-alpha · default · sk-ar-v1-verif…-ke │ + │ ⚡ codex auto · most used · default · sk-ar-v1-verif…-ke │ + │ │ + │ CONFIGURE · CLAUDE │ + │ ◆ model… for claude │ + │ ⇄ account… for claude │ + │ 🔑 key… for claud │ + │ │ + │ MORE │ + │ ⬇ install… install a coding agent │ + │ ⚙ config… accounts · keys · agent │ + │ │ + │ type to filter ↑↓ pick agent ↵ launch / switch esc quit │ + │ │ + └──────────────────────────────────────────────────────────────────────┘ diff --git a/.cursor/skills/verify-anyr/features/tui-launcher.md b/.cursor/skills/verify-anyr/features/tui-launcher.md index 6e9b5a2..9c91447 100644 --- a/.cursor/skills/verify-anyr/features/tui-launcher.md +++ b/.cursor/skills/verify-anyr/features/tui-launcher.md @@ -1,20 +1,21 @@ # TUI launcher -The TUI launcher lets a user pick an agent, inspect account/model/credits, and open grouped settings from one palette. `--dump-tui` prints a single ANSI-free frame for tests and pipes. +The TUI launcher lets a user pick a coding agent first, then change **that** agent's model / account / key on the same home screen. Launch rows stay visible (configure does not replace them). `--dump-tui` prints a single ANSI-free frame for tests and pipes. ## Sub-features -- `menu-dump` prints one launcher/palette frame with `LAUNCH` rows and a `❯` input line. +- `menu-dump` prints one launcher/palette frame with `LAUNCH` rows (bindings beside each agent), `CONFIGURE` for the highlighted agent, and a `❯` input line. - `menu-dump-empty` shows `install an agent…` when no agents are detected. - `config-dump` prints grouped settings (`ACCOUNT`, `MODEL`, `AGENT`, `GENERAL`). -- `menu-quit` (PTY) leaves the interactive launcher with `q` without launching an agent. +- `menu-quit` (PTY) leaves the interactive launcher with Escape without launching an agent. ## How to get to it (user POV) - Run `anyr` or `anyr menu` on a TTY. - Run `anyr menu --dump-tui` (or `ANYR_TUI_DUMP=1`) to print one frame and exit. - Run `anyr config` on a TTY, or `anyr config --dump-tui`. -- From the palette, type to filter (`cla`, `codex`, `model`), ↑↓ to move, ↵ to run, esc / `q` to quit. +- Highlight an agent, then use `model…` / `account…` / `key…` on the same screen. Those rows apply to the highlighted agent. +- Type to filter (`cla`, `codex`, `model`), ↑↓ to move, ↵ to launch or switch, esc to quit. ## Driving it with control-anyr @@ -24,16 +25,17 @@ Preconditions: - Isolated config contains the fixture `default` profile. - Helper sets `ANYR_AGENTS=claude,codex` unless a recipe overrides it. -- **Palette dump.** Run `control-anyr cli --out artifacts/tui-launcher/menu-dump.txt -- menu --dump-tui`. Exit code `0`. Stdout has no ESC (`\x1b`). Stdout contains `LAUNCH`, `claude`, `CONFIGURE`, `config…`, `❯`, `╭`, `╯`, and either `⚡` or `◆`. Stdout does not contain `verify-fixture-not-a-real-key`. +- **Palette dump.** Run `control-anyr cli --out artifacts/tui-launcher/menu-dump.txt -- menu --dump-tui`. Exit code `0`. Stdout has no ESC (`\x1b`). Stdout contains `LAUNCH`, `claude`, `CONFIGURE`, `for claude` (or `for codex`), `model…`, `account…`, `key…`, `MORE`, `config…`, `❯`, `╭`, `╯`, and either `⚡` or `◆`. Agent rows include ` · `. Stdout does not contain `verify-fixture-not-a-real-key`. `LAUNCH` and `CONFIGURE` both appear (configure does not hide launch). - **Empty agents.** Run `ANYR_AGENTS=none control-anyr cli --out artifacts/tui-launcher/menu-empty.txt -- menu --dump-tui` only if you temporarily export `ANYR_AGENTS=none` for that one command. Exit code `0`. Stdout contains `install an agent…` and `none detected`. Restore `ANYR_AGENTS=claude,codex` afterward (a new `doctor` / next `cli` uses the helper default). - **Settings dump.** Run `control-anyr cli --out artifacts/tui-launcher/config-dump.txt -- config --dump-tui`. Exit code `0`. Stdout contains `ACCOUNT`, `MODEL`, `AGENT`, `GENERAL`, `auto-update`, and `update channel`. Secret substring `config-dump` / `verify-fixture-not-a-real-key` is absent. -- **Interactive quit (optional).** Run `control-anyr pty start -- menu`, then `control-anyr pty capture --path artifacts/tui-launcher/pane.txt`. The pane shows `LAUNCH` or the AR mark. Send `q`, then `control-anyr pty stop`. Do not press Enter on a launch row. +- **Interactive pick-agent flow (optional).** Run `control-anyr pty start -- menu`, then `control-anyr pty capture --path artifacts/tui-launcher/pane.txt`. The pane shows `LAUNCH` and `CONFIGURE` together. Send Down to highlight the second agent if present; capture again as `pane-focus.txt` if you need proof configure retargets. Send Escape, then `control-anyr pty stop`. Do not press Enter on a launch row. - **Proof.** Keep `menu-dump.txt` and `config-dump.txt`. They identify this binary's launcher and settings without attaching a fullscreen TTY. ## Gotchas - `ANYR_AGENTS` overrides PATH detection. Leave it at `claude,codex` for the default dump; `none` is only for the empty-agents row. -- Dump mode is not a TTY session. Do not claim keybindings work from `--dump-tui` alone; use `pty` for `q` / filter typing. -- Palette treats every printable character as filter input (`q` quits the older full-pane launcher, not necessarily the type-first palette — send Escape if `q` types into the query). Prefer `--dump-tui` unless the claim is interactive. +- Dump mode is not a TTY session. Do not claim keybindings work from `--dump-tui` alone; use `pty` for filter typing / focus. +- Palette treats every printable character as filter input. Send Escape to quit (not `q`, which types into the query). Prefer `--dump-tui` unless the claim is interactive. - Credits rows may show a placeholder when `ANYR_NO_CATALOG=1`. That is expected isolation, not a product outage. - Do not select `claude` in the PTY. That path launches an agent. +- Do not dump the live model catalog from this flow. `anyrouter/auto` / most-used models are a separate job. diff --git a/.cursor/skills/verify-anyr/seed-config.yaml b/.cursor/skills/verify-anyr/seed-config.yaml index 995cb2f..d560be7 100644 --- a/.cursor/skills/verify-anyr/seed-config.yaml +++ b/.cursor/skills/verify-anyr/seed-config.yaml @@ -10,3 +10,8 @@ profiles: work: api_key: sk-ar-v1-verify-work-fixture-not-a-real-key default_model: anthropic/claude-sonnet-4.6 +agents: + claude: + default_model: stealth/ox-alpha + codex: + default_model: auto diff --git a/src/commands.rs b/src/commands.rs index 3c53881..3b924ca 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -988,29 +988,7 @@ fn save_agent_key(path: &PathBuf, agent: &str, key: &str) -> Result } fn palette_bind_detail(agent: &str) -> String { - format!("per agent · {agent}") -} - -fn launch_detail_for( - cfg: &crate::config::Config, - id: &str, - product_label: &str, - session_model: &str, - is_last: bool, -) -> String { - if let Some(m) = cfg - .agent_binding(id) - .and_then(|b| b.default_model.as_deref()) - .map(str::trim) - .filter(|s| !s.is_empty() && !is_auto_model(s)) - { - return session_model_label(m); - } - if is_last { - session_model.to_string() - } else { - product_label.to_string() - } + format!("for {agent}") } fn run_models(parsed: &ParsedArgs, env: &BTreeMap) -> Result { @@ -2933,8 +2911,38 @@ fn stored_api_key( resolve_api_key(&parsed.flags, env, profile) } -/// Palette entries mirroring the launcher's action set: launch rows first -/// (with the pinned model / agent list as detail), then configure rows. +fn agent_binding_detail(cfg: &crate::config::Config, id: &str, signed_in: bool) -> String { + let profile = cfg.profiles.get(&cfg.active_profile); + let binding = cfg.agent_binding(id); + let model = binding + .and_then(|b| b.default_model.as_deref()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(session_model_label) + .unwrap_or_else(|| session_model_label(profile.map(|p| p.default_model()).unwrap_or("auto"))); + let account = binding + .and_then(|b| b.profile.as_deref()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(cfg.active_profile.as_str()); + let key = if signed_in { + if let Some(k) = binding + .and_then(|b| b.api_key.as_deref()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + mask_api_key(Some(k)) + } else { + mask_api_key(profile.and_then(|p| p.api_key.as_deref())) + } + } else { + "(not signed in)".into() + }; + format!("{model} · {account} · {key}") +} + +/// Palette entries: launch rows first (bindings visible per agent), then +/// model/account/key for the highlighted agent on the same screen, then more. /// `signed_in` gates the launch group exactly like the old dialog did. #[cfg(feature = "native")] fn launcher_palette( @@ -2959,11 +2967,8 @@ fn launcher_palette( .or_else(|| get_string_flag(&parsed.flags, "tool")) .or_else(|| present.first().map(|(id, _)| (*id).to_string())) .unwrap_or_else(|| "claude".into()); - let model_line = session_model_label(profile.map(|p| p.default_model()).unwrap_or("auto")); - // Status header — local data only. Credits/identity fill in from the - // background fetch via the palette idle tick (do not block first paint). - let key = resolve_api_key(&parsed.flags, env, profile); + // Compact status: bindings live on each agent row, not a 5-line header. let dump_or_pipe = tui_wants_dump(parsed, env) || !term::is_interactive(); let credits_line = format!("credits {}", credits.peek_credits()); let account_line = if dump_or_pipe { @@ -2973,27 +2978,15 @@ fn launcher_palette( } else { format!("account {}", cfg.active_profile) }; - let key_line = format!( - "key {}", - if signed_in { - mask_api_key(key.as_deref()) - } else { - "(not signed in)".into() - } - ); - let header = vec![ - account_line, - key_line, - format!("model {model_line}"), - format!("agent {last}"), - credits_line, - ]; + let header = vec![account_line, credits_line]; #[cfg(feature = "native")] use crate::tui::PaletteEntry; let mut entries = Vec::new(); if signed_in { - push_launch_entries(&mut entries, &cfg, &present, &last, &model_line); + push_launch_entries(&mut entries, &present, &last, |id| { + agent_binding_detail(&cfg, id, signed_in) + }); } else { entries.push(PaletteEntry::new( "login", @@ -3002,44 +2995,28 @@ fn launcher_palette( "Login / sign in", )); } - let bind = palette_bind_detail(&last); - entries.push(PaletteEntry::new( - "model…", - bind.clone(), - "configure", - "Switch model", - )); - entries.push(PaletteEntry::new( - "agent…", - "switch default agent", - "configure", - "Switch agent", - )); - entries.push(PaletteEntry::new( - "account…", - bind.clone(), - "configure", - "Switch account", - )); - entries.push(PaletteEntry::new("key…", bind, "configure", "Switch key")); + let hub = if present.iter().any(|(id, _)| *id == last.as_str()) { + last.clone() + } else { + present + .first() + .map(|(id, _)| (*id).to_string()) + .unwrap_or(last.clone()) + }; + push_agent_configure_entries(&mut entries, &hub); entries.push(PaletteEntry::new( "install…", "install a coding agent", - "configure", + "more", "Install agent", )); entries.push(PaletteEntry::new( "config…", "accounts · keys · agent", - "configure", + "more", "Config", )); - entries.push(PaletteEntry::new( - "quit", - "esc works too", - "configure", - "Quit", - )); + entries.push(PaletteEntry::new("quit", "esc works too", "more", "Quit")); (header, entries) } @@ -3055,24 +3032,13 @@ fn launcher_palette( let profile = cfg.profiles.get(&cfg.active_profile); let signed_in = launcher_signed_in(path, parsed, env); let last = launcher_last_tool(path, parsed, env); - let model_line = session_model_label(profile.map(|p| p.default_model()).unwrap_or("auto")); let header = vec![ format!("account {}", cfg.active_profile), - format!( - "key {}", - if signed_in { - mask_api_key(profile.and_then(|p| p.api_key.as_deref())) - } else { - "(not signed in)".into() - } - ), - format!("model {model_line}"), - format!("agent {last}"), "credits -".to_string(), ]; let mut entries = Vec::new(); + let present = available_agents(env, |id| tool_command_for(path, id)); if signed_in { - let present = available_agents(env, |id| tool_command_for(path, id)); if present.is_empty() { entries.push(InlineEntry::new( "install an agent…", @@ -3086,20 +3052,26 @@ fn launcher_palette( } else { present[0].0.to_string() }; + let detail = agent_binding_detail(&cfg, &last, signed_in); entries.push(InlineEntry::new( last.clone(), - launch_detail_for(&cfg, &last, last.as_str(), &model_line, true), + detail, "launch", format!("Launch {last}"), )); - for (id, label) in present.into_iter().filter(|(id, _)| *id != last.as_str()) { + for (id, _) in present + .iter() + .copied() + .filter(|(id, _)| *id != last.as_str()) + { entries.push(InlineEntry::new( id, - launch_detail_for(&cfg, id, label, &model_line, false), + agent_binding_detail(&cfg, id, signed_in), "launch", format!("Launch {id}"), )); } + push_inline_configure(&mut entries, &last); } } else { entries.push(InlineEntry::new( @@ -3108,46 +3080,44 @@ fn launcher_palette( "account", "Login / sign in", )); + push_inline_configure(&mut entries, &last); } - let bind = palette_bind_detail(&last); - entries.push(InlineEntry::new( - "model…", - bind.clone(), - "configure", - "Switch model", - )); - entries.push(InlineEntry::new( - "agent…", - "switch default agent", - "configure", - "Switch agent", - )); - entries.push(InlineEntry::new( - "account…", - bind.clone(), - "configure", - "Switch account", - )); - entries.push(InlineEntry::new("key…", bind, "configure", "Switch key")); entries.push(InlineEntry::new( "install…", "install a coding agent", - "configure", + "more", "Install agent", )); entries.push(InlineEntry::new( "config…", "accounts · keys · agent", - "configure", + "more", "Config", )); + entries.push(InlineEntry::new("quit", "esc works too", "more", "Quit")); + (header, entries) +} + +#[cfg(not(feature = "native"))] +fn push_inline_configure(entries: &mut Vec, agent: &str) { + entries.push(InlineEntry::new( + "model…", + palette_bind_detail(agent), + format!("configure · {agent}"), + format!("Switch model {agent}"), + )); + entries.push(InlineEntry::new( + "account…", + palette_bind_detail(agent), + format!("configure · {agent}"), + format!("Switch account {agent}"), + )); entries.push(InlineEntry::new( - "quit", - "esc works too", - "configure", - "Quit", + "key…", + palette_bind_detail(agent), + format!("configure · {agent}"), + format!("Switch key {agent}"), )); - (header, entries) } fn run_menu(parsed: &ParsedArgs, env: &BTreeMap) -> Result { @@ -3230,10 +3200,9 @@ enum LauncherNext { #[cfg(feature = "native")] fn push_launch_entries( entries: &mut Vec, - cfg: &crate::config::Config, present: &[(&'static str, &'static str)], last: &str, - model_line: &str, + detail_for: impl Fn(&str) -> String, ) { use crate::tui::PaletteEntry; if present.is_empty() { @@ -3252,24 +3221,48 @@ fn push_launch_entries( }; entries.push(PaletteEntry::new( last.clone(), - launch_detail_for(cfg, &last, last.as_str(), model_line, true), + detail_for(&last), "launch", format!("Launch {last}"), )); - for (id, label) in present + for (id, _) in present .iter() .copied() .filter(|(id, _)| *id != last.as_str()) { entries.push(PaletteEntry::new( id, - launch_detail_for(cfg, id, label, model_line, false), + detail_for(id), "launch", format!("Launch {id}"), )); } } +#[cfg(feature = "native")] +fn push_agent_configure_entries(entries: &mut Vec, agent: &str) { + use crate::tui::PaletteEntry; + let group = format!("configure · {agent}"); + entries.push(PaletteEntry::new( + "model…", + palette_bind_detail(agent), + group.clone(), + format!("Switch model {agent}"), + )); + entries.push(PaletteEntry::new( + "account…", + palette_bind_detail(agent), + group.clone(), + format!("Switch account {agent}"), + )); + entries.push(PaletteEntry::new( + "key…", + palette_bind_detail(agent), + group, + format!("Switch key {agent}"), + )); +} + fn install_agent_dialog(path: &PathBuf, env: &BTreeMap) -> Result { let missing = missing_agents(env, |id| tool_command_for(path, id)); if missing.is_empty() { @@ -3496,6 +3489,9 @@ fn launcher_dispatch( } return Ok(LauncherNext::Continue); } + if let Some(agent) = action.strip_prefix("Switch model ") { + return switch_agent_model(parsed, env, path, agent.trim()); + } if action == "Switch agent" { match config_edit_row(parsed, env, path, SettingKind::Agent) { Ok(_) => {} @@ -3515,6 +3511,9 @@ fn launcher_dispatch( } return Ok(LauncherNext::Continue); } + if let Some(agent) = action.strip_prefix("Switch account ") { + return switch_agent_account(parsed, env, path, agent.trim()); + } if action == "Switch key" { if !launcher_signed_in(path, parsed, env) { eprintln!("{}", term::err("Sign in first (Login / sign in).")); @@ -3532,6 +3531,9 @@ fn launcher_dispatch( } return Ok(LauncherNext::Continue); } + if let Some(agent) = action.strip_prefix("Switch key ") { + return switch_agent_key(parsed, env, path, agent.trim()); + } if action == "Credits" { if let Err(err) = run_usage(parsed, env) { eprintln!("{}", term::err(&err)); @@ -3553,6 +3555,162 @@ fn launcher_dispatch( Ok(LauncherNext::Continue) } +fn switch_agent_model( + parsed: &ParsedArgs, + env: &BTreeMap, + path: &PathBuf, + agent: &str, +) -> Result { + if agent.is_empty() { + return Ok(LauncherNext::Continue); + } + if !launcher_signed_in(path, parsed, env) { + eprintln!("{}", term::err("Sign in first (Login / sign in).")); + return Ok(LauncherNext::Continue); + } + match bind_agent_model(parsed, env, path, agent) { + Ok(_) => {} + Err(err) if err == "Cancelled." => {} + Err(err) => eprintln!("{}", term::err(&err)), + } + Ok(LauncherNext::Continue) +} + +fn bind_agent_model( + parsed: &ParsedArgs, + env: &BTreeMap, + path: &PathBuf, + agent: &str, +) -> Result { + let existing = load_config_if_present(path); + let profile = existing + .as_ref() + .and_then(|c| c.profiles.get(&c.active_profile)); + let key = resolve_api_key(&parsed.flags, env, profile); + let base = resolve_base_url(&parsed.flags, profile); + let models = fetch_models(&base, key.as_deref())?; + let current = existing + .as_ref() + .and_then(|c| c.agent_binding(agent)) + .and_then(|b| b.default_model.clone()) + .or_else(|| profile.map(|p| p.default_model().to_string())); + let id = pick_model(&models, current.as_deref(), &format!("{agent} model"))?; + save_agent_model(path, agent, &id) +} + +fn switch_agent_account( + _parsed: &ParsedArgs, + _env: &BTreeMap, + path: &PathBuf, + agent: &str, +) -> Result { + if agent.is_empty() { + return Ok(LauncherNext::Continue); + } + match bind_agent_account(path, agent) { + Ok(_) => {} + Err(err) if err == "Cancelled." => {} + Err(err) => eprintln!("{}", term::err(&err)), + } + Ok(LauncherNext::Continue) +} + +fn bind_agent_account(path: &PathBuf, agent: &str) -> Result { + let cfg = load_config_if_present(path).unwrap_or_default(); + let mut names: Vec = cfg.profiles.keys().cloned().collect(); + names.sort(); + if names.is_empty() { + return Err(no_key_error()); + } + let current_name = cfg + .agent_binding(agent) + .and_then(|b| b.profile.clone()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| cfg.active_profile.clone()); + let labels: Vec = names + .iter() + .map(|n| { + let p = cfg.profiles.get(n); + let key = mask_api_key(p.and_then(|p| p.api_key.as_deref())); + let mark = if n == ¤t_name { " ●" } else { "" }; + format!("{n} · {key}{mark}") + }) + .collect(); + let current = names.iter().position(|n| n == ¤t_name); + let idx = pick_list( + &format!("Account for {agent}"), + &["applies only to this agent · launch list stays put".into()], + &labels, + current, + )?; + let chosen = names[idx].clone(); + save_agent_account(path, agent, &chosen) +} + +fn switch_agent_key( + parsed: &ParsedArgs, + env: &BTreeMap, + path: &PathBuf, + agent: &str, +) -> Result { + if agent.is_empty() { + return Ok(LauncherNext::Continue); + } + if !launcher_signed_in(path, parsed, env) { + eprintln!("{}", term::err("Sign in first (Login / sign in).")); + return Ok(LauncherNext::Continue); + } + match bind_agent_key(parsed, env, path, agent) { + Ok(_) => {} + Err(err) if err == "Cancelled." => {} + Err(err) => eprintln!("{}", term::err(&err)), + } + Ok(LauncherNext::Continue) +} + +fn bind_agent_key( + parsed: &ParsedArgs, + env: &BTreeMap, + path: &PathBuf, + agent: &str, +) -> Result { + let (_keys_path, cfg, base, api_key) = keys_credential(parsed, env)?; + let rows = crate::http::keys_newest_first( + fetch_keys(&base, &api_key)? + .into_iter() + .filter(|r| r.active) + .collect(), + ); + if rows.is_empty() { + return Err(hint("No active keys. Create one: {bin} keys create")); + } + let current_key = cfg + .agent_binding(agent) + .and_then(|b| b.api_key.as_deref()) + .map(str::trim) + .filter(|s| !s.is_empty()); + let current = rows + .iter() + .position(|r| is_active_key_row(&r.masked, current_key)) + .or(Some(0)); + let labels: Vec = rows + .iter() + .enumerate() + .map(|(i, r)| key_pick_label(r, current == Some(i))) + .collect(); + let idx = pick_list( + &format!("API key for {agent}"), + &[ + "applies only to this agent · newest first".into(), + format!("current {}", mask_api_key(current_key)), + ], + &labels, + current, + )?; + let revealed = reveal_key(&base, &api_key, &rows[idx].hash)?; + save_agent_key(path, agent, &revealed) +} + fn launch_agent_picker( parsed: &ParsedArgs, env: &BTreeMap, diff --git a/src/config.rs b/src/config.rs index 957f801..44c7e2e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -888,4 +888,31 @@ agents: assert_eq!(entries, vec!["config.yaml".to_string()], "{entries:?}"); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn agent_binding_mut_does_not_clobber_sibling() { + let mut cfg = parse_config( + "\ +active_profile: default +profiles: + default: + api_key: sk-ar-v1-test + default_model: auto +", + ); + cfg.agent_binding_mut("claude").default_model = Some("stealth/ox-alpha".into()); + cfg.agent_binding_mut("grok").default_model = Some("grok-4".into()); + cfg.agent_binding_mut("claude").default_model = + Some("anthropic/claude-sonnet-4.6".into()); + assert_eq!( + cfg.agent_binding("claude") + .and_then(|b| b.default_model.as_deref()), + Some("anthropic/claude-sonnet-4.6") + ); + assert_eq!( + cfg.agent_binding("grok") + .and_then(|b| b.default_model.as_deref()), + Some("grok-4") + ); + } } diff --git a/src/help.rs b/src/help.rs index b585d5e..e0a213e 100644 --- a/src/help.rs +++ b/src/help.rs @@ -196,7 +196,7 @@ pub fn command_help(command: &str) -> Option { ), "menu" => fill( &bin, - "{bin} menu — open the command-palette launcher (default on a TTY)\n\nUsage:\n {bin} Same as `{bin} menu` on a TTY\n {bin} menu [--dump-tui]\n\nOne input, fuzzy everything: type to filter — \"cla\", \"codex\",\n\"model\" — with account / model / agent / credits status up top and\nright-aligned detail per row. On terminals that can't run the\nfullscreen TUI (dumb TERM, restricted hosts) the same entries fall\nback to an inline numbered prompt.\n\nKeys: type to filter · ↑↓ move · ↵ run · esc quit\n\nmodel… / account… / key… bind the current coding agent (the header\n`agent` / last launch) without extra screens. Switch agent first, then\nchange that agent's model, account, or key. Launch uses those bindings;\na per-agent key does not fall back to the default profile key.\nConfig opens the grouped settings screen (account, keys, model slots,\nagent, auto-update) with current values on every row.\n`--dump-tui` / ANYR_TUI_DUMP=1 prints one plain frame (for tests and pipes).\n", + "{bin} menu — open the command-palette launcher (default on a TTY)\n\nUsage:\n {bin} Same as `{bin} menu` on a TTY\n {bin} menu [--dump-tui]\n\nPick a coding agent first. Model / account / key on the home TUI\napply to that highlighted agent and stay on the same screen — they\ndo not replace the launch list. Each agent row shows its bound\nmodel · account · key. Launch uses those bindings; a per-agent key\ndoes not fall back to the default profile key.\n\nType to filter — \"cla\", \"codex\", \"model\". On terminals that can't\nrun the fullscreen TUI (dumb TERM, restricted hosts) the same\nentries fall back to an inline numbered prompt.\n\nKeys: type to filter · ↑↓ pick agent · ↵ launch / switch · esc quit\n\nConfig opens the grouped settings screen (account, keys, model slots,\nagent, auto-update) with current values on every row.\n`--dump-tui` / ANYR_TUI_DUMP=1 prints one plain frame (for tests and pipes).\n", ), "prompt" => fill( &bin, diff --git a/src/term.rs b/src/term.rs index 028776f..14b6a5f 100644 --- a/src/term.rs +++ b/src/term.rs @@ -71,6 +71,10 @@ pub const MARK_LINES: [&str; 5] = [ "▄███▀ ▀██▄▀███▄", ]; +/// Compact 3-row AR mark for TUI chrome. Recovered from the original +/// half-block logo (`b7a313b`); not the later 5-line ligature. +pub const TUI_MARK_LINES: [&str; 3] = [" ▄▄ ▄▄▄ ", " ▄█▀▀█▄▄█▀", " ▀▀ ▀▀▀▀"]; + const MARK_PNG: &[u8] = include_bytes!("../assets/mark.png"); const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; diff --git a/src/tui/keys.rs b/src/tui/keys.rs index aaf65e0..9c5a2e9 100644 --- a/src/tui/keys.rs +++ b/src/tui/keys.rs @@ -86,7 +86,7 @@ pub fn hint_line(surface: Surface) -> &'static str { Surface::Launcher => "↑↓ move ↵ select q/esc quit", Surface::Settings => "tab agent ↑↓ move ↵ edit x reset q close", Surface::Picker => "type to search ↑↓ move ↵ select esc cancel", - Surface::Palette => "type to filter ↑↓ move ↵ run esc quit", + Surface::Palette => "type to filter ↑↓ pick agent ↵ launch / switch esc quit", } } diff --git a/src/tui/live.rs b/src/tui/live.rs index 4d56672..7786a63 100644 --- a/src/tui/live.rs +++ b/src/tui/live.rs @@ -256,7 +256,7 @@ pub fn run_palette_live_with( return Ok(outcome); } } else { - state.cursor = i; + state.set_cursor(i); } } } diff --git a/src/tui/state.rs b/src/tui/state.rs index cff983e..298bf79 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -352,6 +352,10 @@ impl PaletteEntry { /// Command palette state: one input, fuzzy filter over every entry, /// groups rendered only where they change. Same Outcome contract as Menu. +/// +/// `focused_agent` is the hub for inline model/account/key rows: moving onto +/// a launch row selects that agent; moving onto configure rows keeps it so +/// LAUNCH stays visible while settings apply to the highlighted agent. #[derive(Debug, Clone)] pub struct PaletteState { pub header: Vec, @@ -359,16 +363,21 @@ pub struct PaletteState { pub query: String, /// Index into the filtered list. pub cursor: usize, + /// Coding-agent id whose model/account/key configure rows target. + pub focused_agent: Option, } impl PaletteState { pub fn new(header: Vec, entries: Vec) -> Self { - Self { + let mut state = Self { header, entries, query: String::new(), cursor: 0, - } + focused_agent: None, + }; + state.sync_focus(); + state } pub fn surface(&self) -> Surface { @@ -392,10 +401,54 @@ impl PaletteState { .collect() } + /// Launch-row agent under the cursor, if any. + pub fn cursor_launch_agent(&self) -> Option { + let filtered = self.filtered(); + if filtered.is_empty() { + return None; + } + let idx = filtered[self.cursor.min(filtered.len() - 1)]; + launch_id_from_action(&self.entries[idx].action) + } + + pub fn set_cursor(&mut self, cursor: usize) { + self.cursor = cursor; + self.sync_focus(); + } + + /// Follow the cursor onto a launch row; retarget configure rows in place + /// so model/account/key never replace the launch list. + pub fn sync_focus(&mut self) { + if let Some(agent) = self.cursor_launch_agent() { + self.focused_agent = Some(agent); + } else if self.focused_agent.is_none() { + self.focused_agent = self + .entries + .iter() + .find_map(|e| launch_id_from_action(&e.action)); + } + self.retarget_configure(); + } + + fn retarget_configure(&mut self) { + let Some(agent) = self.focused_agent.clone() else { + return; + }; + let group = format!("configure · {agent}"); + for entry in &mut self.entries { + let kind = configure_kind(&entry.action, &entry.label); + let Some(kind) = kind else { + continue; + }; + entry.group = group.clone(); + entry.action = format!("Switch {kind} {agent}"); + entry.detail = format!("for {agent}"); + } + } + pub fn apply(&mut self, action: Action) -> Outcome { match action { - Action::Quit => Outcome::Quit, - Action::Esc => Outcome::Quit, + Action::Quit | Action::Esc => Outcome::Quit, Action::Resize => Outcome::Continue, Action::Enter => { let filtered = self.filtered(); @@ -413,6 +466,7 @@ impl PaletteState { } else { self.cursor - 1 }; + self.sync_focus(); } Outcome::Continue } @@ -420,12 +474,14 @@ impl PaletteState { let n = self.filtered().len(); if n > 0 { self.cursor = (self.cursor + 1) % n; + self.sync_focus(); } Outcome::Continue } Action::Backspace => { self.query.pop(); self.cursor = 0; + self.sync_focus(); Outcome::Continue } Action::Char(c) => { @@ -434,6 +490,7 @@ impl PaletteState { } self.query.push(c); self.cursor = 0; + self.sync_focus(); Outcome::Continue } Action::Unset | Action::NextTab | Action::PrevTab => Outcome::Continue, @@ -445,6 +502,29 @@ impl PaletteState { } } +fn launch_id_from_action(action: &str) -> Option { + let rest = action.strip_prefix("Launch ")?.trim(); + if rest.is_empty() || rest == "coding agent…" { + return None; + } + Some(rest.to_string()) +} + +/// model / account / key — the inline home-TUI switches (not install/config/quit). +fn configure_kind(action: &str, label: &str) -> Option<&'static str> { + let a = action.to_ascii_lowercase(); + let l = label.to_ascii_lowercase(); + if a.starts_with("switch model") || l.starts_with("model") { + Some("model") + } else if a.starts_with("switch account") || l.starts_with("account") { + Some("account") + } else if a.starts_with("switch key") || l.starts_with("key") { + Some("key") + } else { + None + } +} + /// Drive palette state with a scripted key sequence (unit / e2e, no TTY). pub fn drive_palette(state: &mut PaletteState, actions: &[Action]) -> Outcome { for action in actions { @@ -606,18 +686,20 @@ mod tests { for c in "cod".chars() { s.apply(Action::Char(c)); } - assert_eq!(s.filtered(), vec![1]); + // Codex ranks first. model… may also match once focus retargets + // its detail to "for codex". + assert_eq!(s.filtered()[0], 1); assert_eq!(s.apply(Action::Enter), Outcome::Selected(1)); } #[test] fn palette_query_matches_detail_column_too() { let mut s = sample_palette(); - for c in "sonnet".chars() { + for c in "ox-alpha".chars() { s.apply(Action::Char(c)); } - // "sonnet" appears only in model…'s detail line — still findable. - assert_eq!(s.filtered(), vec![2]); + // Model id lives on the launch row detail — still findable. + assert_eq!(s.filtered(), vec![0]); } #[test] @@ -636,7 +718,7 @@ mod tests { for c in "cod".chars() { s.apply(Action::Char(c)); } - assert_eq!(s.filtered(), vec![1]); + assert_eq!(s.filtered()[0], 1); s.apply(Action::Backspace); assert_eq!(s.query, "co"); assert_eq!(s.cursor, 0); @@ -656,4 +738,37 @@ mod tests { ); assert_eq!(out, Outcome::Selected(1)); } + + #[test] + fn palette_focus_starts_on_first_launch_agent() { + let s = sample_palette(); + assert_eq!(s.focused_agent.as_deref(), Some("claude")); + assert_eq!(s.entries[2].action, "Switch model claude"); + assert_eq!(s.entries[2].group, "configure · claude"); + assert_eq!(s.entries[2].detail, "for claude"); + // Launch list is still present — configure does not replace it. + assert_eq!(s.entries[0].group, "launch"); + assert_eq!(s.entries[1].group, "launch"); + } + + #[test] + fn palette_moving_to_grok_retargets_model_without_dropping_launch() { + let mut s = sample_palette(); + s.apply(Action::Down); + assert_eq!(s.focused_agent.as_deref(), Some("codex")); + assert_eq!(s.entries[2].action, "Switch model codex"); + assert!(s.entries.iter().any(|e| e.action == "Launch claude")); + assert!(s.entries.iter().any(|e| e.action == "Launch codex")); + assert_eq!(s.entries[0].group, "launch"); + } + + #[test] + fn palette_configure_row_keeps_highlighted_agent() { + let mut s = sample_palette(); + s.apply(Action::Down); // codex + s.apply(Action::Down); // model… + assert_eq!(s.focused_agent.as_deref(), Some("codex")); + assert_eq!(s.apply(Action::Enter), Outcome::Selected(2)); + assert_eq!(s.entries[2].action, "Switch model codex"); + } } diff --git a/src/tui/view.rs b/src/tui/view.rs index 7fb4aa8..f83c44c 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -707,7 +707,7 @@ pub fn item_icon(label: &str) -> &'static str { } fn mark_line_width() -> usize { - crate::term::MARK_LINES + crate::term::TUI_MARK_LINES .iter() .map(|s| s.chars().count()) .max() @@ -719,13 +719,13 @@ fn palette_shows_mark(inner_w: usize) -> bool { } fn padded_mark_line(i: usize) -> String { - let raw = crate::term::MARK_LINES.get(i).copied().unwrap_or(""); + let raw = crate::term::TUI_MARK_LINES.get(i).copied().unwrap_or(""); let w = mark_line_width(); format!("{raw}{}", " ".repeat(w.saturating_sub(raw.chars().count()))) } fn palette_header_height(header: &[String]) -> u16 { - crate::term::MARK_LINES.len().max(header.len()).max(1) as u16 + crate::term::TUI_MARK_LINES.len().max(header.len()).max(1) as u16 } fn status_caption_spans(line: &str) -> Vec> { @@ -742,7 +742,7 @@ fn status_caption_spans(line: &str) -> Vec> { fn palette_header_lines(header: &[String], inner_w: usize) -> Vec> { let with_mark = palette_shows_mark(inner_w); let n = if with_mark { - crate::term::MARK_LINES.len().max(header.len()).max(1) + crate::term::TUI_MARK_LINES.len().max(header.len()).max(1) } else { header.len().max(1) }; @@ -884,7 +884,10 @@ pub fn plain_palette_lines(state: &PaletteState, cols: usize) -> Vec { let with_mark = palette_shows_mark(content_w); let header_n = if with_mark { - crate::term::MARK_LINES.len().max(state.header.len()).max(1) + crate::term::TUI_MARK_LINES + .len() + .max(state.header.len()) + .max(1) } else { state.header.len().max(1) }; @@ -1146,7 +1149,7 @@ mod tests { assert!(!frame.contains('\u{1b}'), "must be ANSI-free: {frame}"); assert!(frame.contains(" anyr "), "{frame}"); assert!( - frame.contains(crate::term::MARK_LINES[0].trim()), + frame.contains(crate::term::TUI_MARK_LINES[0].trim()), "AR mark missing:\n{frame}" ); for line in [ diff --git a/tests/cli.rs b/tests/cli.rs index ee6b9d7..38af50c 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1306,6 +1306,11 @@ profiles: default: api_key: sk-ar-v1-menu-dump-secret-value-abcdef default_model: auto +agents: + claude: + default_model: stealth/ox-alpha + codex: + default_model: auto ", ) .unwrap(); @@ -1324,8 +1329,8 @@ profiles: // Palette frame: AR mark, auth/defaults, input, grouped rows. assert!(stdout.contains("anyr"), "{stdout}"); assert!( - stdout.contains("▄█▄") || stdout.contains("████"), - "AR mark missing:\n{stdout}" + stdout.contains("▄▄") || stdout.contains("▄█▀") || stdout.contains("████"), + "small AR mark missing:\n{stdout}" ); assert!(stdout.contains("account"), "{stdout}"); assert!(stdout.contains("key"), "{stdout}"); @@ -1335,13 +1340,21 @@ profiles: assert!(stdout.contains("LAUNCH"), "{stdout}"); assert!(stdout.contains("claude"), "{stdout}"); assert!(stdout.contains("CONFIGURE"), "{stdout}"); + assert!(stdout.contains("MORE"), "{stdout}"); assert!(stdout.contains("config…"), "{stdout}"); assert!(stdout.contains("account…"), "{stdout}"); assert!(stdout.contains("key…"), "{stdout}"); assert!(stdout.contains("model…"), "{stdout}"); - assert!(stdout.contains("agent…"), "{stdout}"); assert!(stdout.contains("install…"), "{stdout}"); assert!(stdout.contains("quit"), "{stdout}"); + assert!( + stdout.contains("for claude") || stdout.contains("for codex"), + "configure rows must target a highlighted agent:\n{stdout}" + ); + assert!( + stdout.contains(" · "), + "agent rows must show model · account · key:\n{stdout}" + ); assert!( stdout.contains("⚡") || stdout.contains("◆"), "row icons missing:\n{stdout}" @@ -1354,6 +1367,14 @@ profiles: stdout.contains('╭') && stdout.contains('╯'), "dump should look like a dialog card: {stdout}" ); + assert!( + stdout.contains("stealth/ox-alpha"), + "claude's bound model should be visible:\n{stdout}" + ); + assert!( + stdout.contains("LAUNCH") && stdout.contains("CONFIGURE"), + "configure must not replace launch:\n{stdout}" + ); assert!( !stdout.contains("menu-dump-secret-value"), "dump must not leak full secret: {stdout}" @@ -1361,6 +1382,46 @@ profiles: let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn dry_run_uses_per_agent_model_not_sibling_binding() { + let dir = temp_home(); + std::fs::write( + dir.join("config.yaml"), + "\ +active_profile: default +profiles: + default: + api_key: sk-ar-v1-fixture-key-0001 + default_model: auto +agents: + claude: + default_model: stealth/ox-alpha + grok: + default_model: auto +", + ) + .unwrap(); + let claude = anyr() + .args(["claude", "--dry-run", "--yes"]) + .env("ANYROUTER_HOME", &dir) + .env_remove("ANYROUTER_API_KEY") + .output() + .expect("claude dry-run"); + let stdout = String::from_utf8_lossy(&claude.stdout); + let stderr = String::from_utf8_lossy(&claude.stderr); + assert_eq!(claude.status.code().unwrap_or(1), 0, "{stdout}{stderr}"); + assert!( + stdout.contains("ANTHROPIC_MODEL=stealth/ox-alpha"), + "claude should use its own binding:\n{stdout}" + ); + let cfg = std::fs::read_to_string(dir.join("config.yaml")).expect("config"); + assert!(cfg.contains("stealth/ox-alpha"), "{cfg}"); + assert!( + cfg.contains("grok:") && cfg.contains("default_model: auto"), + "grok binding must stay auto:\n{cfg}" + ); +} + #[test] fn menu_dump_tui_empty_agents_shows_install() { let dir = std::env::temp_dir().join(format!("anyr-cli-menu-empty-{}", std::process::id())); @@ -1739,7 +1800,7 @@ agents: assert_eq!(out.status.code().unwrap_or(1), 0, "{stdout}{stderr}"); assert!(stdout.contains("LAUNCH"), "{stdout}"); assert!(stdout.contains("CONFIGURE"), "{stdout}"); - assert!(stdout.contains("per agent · claude"), "{stdout}"); + assert!(stdout.contains("for claude") || stdout.contains("configure · claude"), "{stdout}"); assert!(stdout.contains("stealth/ox-alpha"), "{stdout}"); assert!(stdout.contains("grok-4"), "{stdout}"); assert!(