feat(slash): /effort slider + 1M-context display fix + DRY modal cancel - #66
Merged
Conversation
Status bar and the slash-popup `/status` row both went through `marketing_or_id`, which dropped the `[1m]` distinction — `claude-opus-4-7[1m]` rendered as `Claude Opus 4.7`, identical to the bare variant. Add `model::display_name` as the canonical "marketing + (1M context)?" rule and route every display-leaning caller through it. Replaces the inline `describe()` helper that lived only in the model picker. `format_config_change` and the `/status` modal still pair the marketing name with the raw `model_id`, so they keep `marketing_or_id` — the parenthesized id already shows `[1m]` there.
Esc and Ctrl+C now cancel any modal at the stack layer instead of each modal re-implementing the gesture. Fixes Ctrl+C inside a modal, which was previously swallowed by the per-modal `_ => Consumed` arm. `ModalStack::handle_key` short-circuits `Esc` and `Ctrl+C` before delegating; `ModelEffortPicker` and `StatusModal` drop their `KeyCode::Esc` arms. Tests move from per-modal Esc coverage to a single ModalStack-level pair pinning both gestures with `ScriptedModal`.
Bare \`/effort\` now opens a Speed↔Intelligence horizontal slider showing the supported effort tiers for the active model; arrow keys walk the ladder, Enter commits the new tier through `UserAction::SwapConfig`. Typed-arg `/effort <level>` keeps direct-switch semantics. Reuses the modal infrastructure shipped in PR #64. The picker errors before opening on no-effort-tier models (e.g. Haiku 4.5) so the user gets the same recovery hint the typed-arg path produces. Resolves the bare-form "errors with usage hint" branch the previous \`/effort\` shipped as a stopgap.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
`caps.effort=true` guarantees Low / Medium / High are accepted, so `supported` is always non-empty and `resolve_effort` always returns a tier inside it. Replace the dead `is_empty` short-circuit, the `unwrap_or(median)` fallback, the `0 =>` arm in `track_width`, and the `if center > 0` guard with `.expect` calls and direct arithmetic. Closes 3 of 4 codecov-flagged uncovered lines on `effort_slider.rs`.
Match the `picker.rs` layout: one inherent impl + one `Modal` impl under a single section header. Drop the trailing `// ── Render helpers ──` divider that split a small file unnecessarily, and inline `step_left` / `step_right` (each was a two-line helper used once). Backtick the `(1M context)` literal in `model::display_name`'s doc for consistency with the rest of the file.
`effort_slider`: - New `unhandled_key_is_consumed_without_state_change` exercises the `_ => Consumed` fallthrough at line 222 (closing the last codecov gap on the file). - `render_runs_at_typical_widths_without_panicking` now also asserts the title text appears in the rendered buffer at every covered width — was previously a panic-only smoke test. `model::display_name`: - Add tests for the `[1m]` suffix-on / suffix-off paths so the only branch in the helper is pinned.
`status_modal.rs` module doc still claimed `Esc / Enter close` even after PR #66's `84008fd` moved Esc / Ctrl+C to `ModalStack`'s universal handler. Match the contract that `picker.rs` and `effort_slider.rs` already follow. Add a parity doc comment to `ModelEffortPicker::new` mirroring `EffortSlider::new`'s `Option`- returning contract.
Connector symbols separating distinct words take spaces around them in prose, same as `/`. Fixes the unspaced occurrence in `effort.rs`'s module doc and the matching crate-tree entry in `CLAUDE.md`.
Three pre-existing instances of unspaced arrows / comparison operators between distinct words: `dark→light` (theme catalogue), `2→3` / `1→2` (diff line-number transitions), `≥4` (assertion message). Apply the same spacing convention used for `/` so prose remains uniformly readable.
Sweep of small fixes from the multi-aspect PR review pass: - `effort_slider.rs` module doc: `Speed ←→` → `Speed ↔` for consistency with the rest of the PR (single bidirectional arrow per the prose-symbol rule). - `picker.rs` parity doc on `ModelEffortPicker::new`: tightened from mechanics-style narration to a one-line contract. - `model.rs`: added `display_name` test for the unknown-plain-id branch so the borrowed `Cow` path is pinned. - `tui/modal.rs`: renamed the stack-routing test to match what it asserts (modifier-less key reaches the modal) and tightened the comment. - `effort_slider.rs` tests: positively-phrased the `model.is_none()` assertion message and tightened the no-effort-model comment.
The dedicated marker row drifted by half a column on even-length labels
("medium", "high") because the label and the `▲` glyph both anchored to
`tier_center` while the label's true visual midpoint sat between cells.
Inline `●` / `○` radio glyphs in the tier-label row solve this structurally
— the glyph is anchored to its label, so the active indicator can never
disagree with the text it points at. The dedicated marker row also goes
away, dropping `BODY_HEIGHT` from 8 to 7.Three knock-on fixes after the floating-marker → inline-glyph swap: - Slot-based layout produced inconsistent visual gaps between tier units (3-3-4-4 cols on the 5-tier ladder) because integer floor in `label_len/2` drifted by half a column on even-length labels. Switched to free-flow layout where each tier unit is separated by exactly `TIER_GAP` cols. - Graded circle glyphs (`○ ◔ ◑ ◕ ●`) render at inconsistent widths across monospace fonts. Replaced with a single `●` / `○` pair (uniform width everywhere) plus per-tier color along a blue → red speed-to-intelligence axis. ANSI-named so the gradient inherits the user's terminal palette. - Picker's effort row now routes through the same `tier_color` mapping so `/model` and `/effort` agree visually on what each tier looks like.
Doc sweep after the slider modal landed: - guide/slash-commands.md: bare `/effort` opens the slider (not errors); table entry updated; mid-turn list adds bare `/effort` to the read-only set; "Switching the Effort" rewritten to describe the slider behavior and the per-tier color/glyph design. - design/slash/commands.md: design-decision 5 and the per-command note reflect the slider modal. Sources entry added for `effort_slider.rs`. - design/slash/modals.md: "two concrete modals" → three; new per-modal section for the slider; design-decision 7 rewritten to motivate why `/model` and `/effort` open separate modals. - roadmap.md: `/effort` slider moves from planned to working today (and the leftover `←→` bullet is removed).
Green and yellow read close on pastel terminal palettes (Catppuccin Mocha fades them both into similar pale hues), so the rainbow ordering left High and Xhigh visually adjacent. Jumping to a different hue family — magenta — buys structural distance that survives any reasonable theme: Green → Magenta → Red still reads as intensifying toward peak, since magenta carries red undertones.
Sweep of comments added during the slider iteration. Targets where the comment restated the test name or the function body without adding context a future reader couldn't recover from the code itself: - effort_slider.rs: trim tier_color doc (5 → 2 lines), slider_width doc (3 → 1 line), TIER_GAP doc (2 → 1 line), drop GLYPH_PREFIX_WIDTH doc and four test comments that restated their test names. - modal.rs: trim the modifier-less-key test comment to the one detail that actually warrants explanation (the `c` vs `x` choice). - model.rs: drop two display_name test comments that restated the test names. - effort.rs: tighten the xhigh-on-Haiku reject comment. Comments retained explain a genuine WHY — invariants, parameterization rationale, or constraints the code can't carry on its own.
Slash-command and modal docs had unbalanced per-item subsections (/clear got 3 paragraphs, /status got 1). Reformat as bullet lists where each item gets ~2-3 sentences. The guide loses its "Switching the Model" / "Switching the Effort" expansions — small features shouldn't outweigh /clear or /init in the same doc; the table plus one shared cross-cutting paragraph covers them.
The "one bullet per command — non-obvious only" lines describe the section's shape, not its content. Readers can see the structure; explaining it inline is the same kind of WHAT-restating we trim from code comments.
Apply the prose convention to four comments touched in this PR: `low / medium / high`, `speed / intel`, `Left / Right`.
Uh oh!
There was an error while loading. Please reload this page.
12 tasks
hakula139 added a commit
that referenced
this pull request
May 6, 2026
## Summary `/theme` opens a list picker that live-previews each candidate as the cursor moves; Esc snaps back, Enter commits for the rest of the session. `/theme <name>` swaps directly to a curated built-in. Restart returns to `[tui.theme] base` in user config. ## Design decisions - **`ModalKey::Preview(action)`.** Live-preview modals need to emit a `UserAction` on each cursor move without closing. Adds the missing third path alongside `Consumed` / `Cancelled` / `Submitted` — composes with PR #66's stack-level Esc / Ctrl+C cancel so individual modals don't reimplement the gesture. - **Snapshot on first preview.** Capturing in `apply_action_locally` (set-once-if-empty) avoids coupling `App` to per-picker logic at modal-open time. Any future live-preview modal participates with no further wiring. - **Curated roster only for typed `/theme <name>`.** Custom file-path themes still work via `~/.config/ox/config.toml`, but the slash form rejects paths since the picker can't list them. - **Synthesized non-`SubmitPrompt` actions go through `dispatch_user_action`.** The original wiring forwarded all slash-synthesized actions straight to the agent, which silently dropped TUI-only ones like `SwapTheme` (so `/theme latte` was a no-op). Routing through the local handler first lets TUI-only actions land while still forwarding agent-bound ones (`Clear`, `SwapConfig`). `ChatView::set_theme` also drops the streaming-prefix cache so an in-flight assistant turn re-renders under the new palette. - **Popup scrolls with a centered cursor.** Adding `/theme` made the registry exceed `MAX_VISIBLE_ROWS`; the previous `... (N more)` truncation hid `/theme` from discovery. Footer dropped; the cursor anchors at the visual middle once it leaves the top half (Claude Code's typeahead convention) and at the bottom near the list end. - **Themes default to terminal background.** Every built-in (mocha / macchiato / frappe / latte / material) sets `surface = { bg = "reset" }` so the chosen palette layers over the user's terminal bg — light themes adapt to pale terminals and Material composes over any dark backdrop. The frame still paints `theme.surface()` over its full area before sub-widgets render so unpainted gaps don't leak the previous frame's contents. - **Modal collapses input + popup.** A modal owns focus and intercepts every key, so the input row + slash popup are unreachable while one is on the stack. `draw_frame` collapses both layout slots so the chat reclaims the vertical space — applied at the layout level so future modals inherit it without per-modal opt-in. ## Changes | File | Description | | ---- | ----------- | | `slash/theme.rs` | New: `ThemeCmd` + `ThemePicker`, curated roster, live-preview key handling. | | `slash.rs`, `slash/registry.rs` | Wire `ThemeCmd` into `BUILT_INS`. | | `tui/modal.rs` | Add `ModalKey::Preview(action)` — emits an action without popping. | | `tui/theme.rs` | Add `load_builtin(name) -> Option<Theme>`; `expect` on vendored TOML parse so a broken built-in surfaces at startup. | | `tui/app.rs` | `preview_theme_snapshot`, `apply_theme` helper, `PreviewTheme` / `SwapTheme` arms; route synthesized non-`SubmitPrompt` actions through `dispatch_user_action`; full-frame surface fill; collapse input + popup while a modal is on the stack. | | `tui/components/input/popup.rs` | Drop `... (N more)` footer; centered-cursor scroll via stateless `scroll_offset`; explicit surface bg. | | `tui/components/{chat,status,input}.rs` | `set_theme` for mid-session repaint; chat invalidates the streaming cache. | | `agent/event.rs` | `UserAction::{PreviewTheme, SwapTheme}` (TUI-only, agent-bypass). | | `config.rs` | `Config.theme_name` + `ConfigSnapshot.theme_name`, `DEFAULT_THEME = "mocha"`. | | `agent.rs`, `main.rs`, `client/anthropic/testing.rs` | Catch new `UserAction` variants; theme-name fixture for test config. | | `themes/{mocha,macchiato,frappe,latte,material}.toml` | All built-ins use `surface = { bg = "reset" }` so the theme adapts to the terminal palette. | | `docs/guide/slash-commands.md`, `docs/roadmap.md`, `CLAUDE.md` | Document `/theme`; move from "current focus" to "working today"; add to crate tree. | ## Test plan - [x] `cargo fmt --all --check` - [x] `cargo clippy --package oxide-code --all-targets -- -D warnings` - [x] `cargo test --package oxide-code` — 1670 passed - [x] `pnpm lint`, `pnpm spellcheck` — clean - [x] Manual: open `ox`, `/theme`, navigate Up / Down, confirm full repaint each row, Esc snaps back, Enter commits - [x] Manual: `/theme latte`, `/theme mocha`; confirm chat shows `Theme set to ...` for each - [x] Manual: `/theme solarized`; confirm error lists the curated built-ins - [x] Manual: `/theme` mid-stream → picker opens (ReadOnly); typed `/theme <name>` mid-stream → refuses (Mutating gate) - [x] Manual: `/theme` swap mid-stream — confirm in-flight assistant tokens repaint under the new palette on the next frame - [x] Manual: 9 commands in popup → /theme appears after scrolling past the centered cursor - [x] Manual: switch to each theme; confirm bg matches the terminal default (latte / material no longer force a fill) - [x] Manual: open `/theme`, `/model`, `/effort`, `/status` — confirm input row collapses while modal is open and reappears on Esc
20 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three small, related changes around the modal slash-command surface that shipped in PR #64. Bare
/effortnow opens a Speed ↔ Intelligence slider (resolving the "errors with usage hint" stopgap), the status bar shows(1M context)for[1m]models, and Esc / Ctrl+C now universally cancel any modal at the stack layer.Design decisions
/model. Bare/effortand bare/modelopen different surfaces — the slider keeps/effortfocused on the single axis without retreading the model picker. Tiers render with a uniform●(active) /○(inactive) glyph plus per-tier ANSI colors along a blue → red axis (Low → blue, Max → red); active is BOLD on top of the tier color. ANSI-named so the gradient inherits the user's terminal palette without coupling to our theme TOML. Layout is free-flow with a fixedTIER_GAPbetween units to dodge the half-column drift a slot-based layout produced on even-length labels.display_name()over inlinedescribe(). The(1M context)rule lived only inside the model picker. Promoting it tomodel::display_name()and routing the status bar / picker through it removes drift;format_config_changeand the/statusmodal still pair the marketing name with the rawmodel_id(where[1m]already shows up), so they keepmarketing_or_id.ModalStack, not per-modal. Esc was duplicated across every modal and Ctrl+C was silently consumed. Intercepting both at the stack layer fixes the Ctrl+C bug for free and prevents future modals from re-implementing the gesture.caps.effortis the slider's only invariant gate. OnceEffortSlider::newconfirmscaps.effort = true, Low / Medium / High are guaranteed accepted andresolve_effortalways returns a tier insupported. Nois_emptyshort-circuit, nounwrap_or(median)fallback, no0 =>arm intrack_width— defensive code for scenarios that can't happen would only mislead readers.Changes
model.rsdisplay_name(model)→Cow<str>returning marketing +(1M context)?; pin behavior with tests.slash/context.rsLiveSessionInfo::display_name()delegating to the new helper.tui/app.rsdisplay_nameonApp::newandConfigChanged.slash/picker.rsdescribe()collapses intodisplay_name; drop redundantEscarm + test; effort-row glyph routes throughtier_colorso/modeland/effortagree visually; parity contract doc onnew.tui/modal.rsModalStack::handle_keyshort-circuits Esc / Ctrl+C with new tests.slash/status_modal.rsEscarm; keep Enter-as-close; refresh module doc to match the centralized cancel.slash/effort_slider.rsEffortSlidermodal — horizontal Speed ↔ Intelligence slider with uniform-gap layout, inline●/○glyphs, and per-tier ANSI color (tier_color) shared with the picker.slash/effort.rs/effortopens the slider; typed-arg path unchanged; tests updated.slash.rseffort_slidermodule.docs/guide/slash-commands.md,docs/design/slash/commands.md,docs/design/slash/modals.md,docs/roadmap.md,CLAUDE.md/effortsurface — bare opens slider; update modal docs; move/effortslider from "planned" to "working today".Test plan
cargo buildcompiles cleanlycargo clippy --all-targets -- -D warnings— zero warningscargo test— 1630 tests passcargo fmt --all --check— cleanpnpm lint,pnpm spellcheck— clean/effortopens the slider; arrow keys walk the ladder; Enter commits; Esc / Ctrl+C cancel cleanly;[1m]model shows(1M context)in the status bar;/efforton Haiku 4.5 errors before opening; per-tier color visible in slider and/model's effort row