Skip to content

feat(slash): /effort slider + 1M-context display fix + DRY modal cancel - #66

Merged
hakula139 merged 18 commits into
mainfrom
feat/effort-slider-and-display-fixes
May 6, 2026
Merged

feat(slash): /effort slider + 1M-context display fix + DRY modal cancel#66
hakula139 merged 18 commits into
mainfrom
feat/effort-slider-and-display-fixes

Conversation

@hakula139

@hakula139hakula139 commented May 5, 2026

Copy link
Copy Markdown
Owner

Summary

Three small, related changes around the modal slash-command surface that shipped in PR #64. Bare /effort now 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

  • Slider is its own modal, not folded into /model. Bare /effort and bare /model open different surfaces — the slider keeps /effort focused 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 fixed TIER_GAP between units to dodge the half-column drift a slot-based layout produced on even-length labels.
  • display_name() over inline describe(). The (1M context) rule lived only inside the model picker. Promoting it to model::display_name() and routing the status bar / picker through it removes drift; format_config_change and the /status modal still pair the marketing name with the raw model_id (where [1m] already shows up), so they keep marketing_or_id.
  • DRY cancel at 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.effort is the slider's only invariant gate. Once EffortSlider::new confirms caps.effort = true, Low / Medium / High are guaranteed accepted and resolve_effort always returns a tier in supported. No is_empty short-circuit, no unwrap_or(median) fallback, no 0 => arm in track_width — defensive code for scenarios that can't happen would only mislead readers.

Changes

FileDescription
model.rsAdd display_name(model)Cow<str> returning marketing + (1M context)?; pin behavior with tests.
slash/context.rsAdd LiveSessionInfo::display_name() delegating to the new helper.
tui/app.rsStatus-bar label flows through display_name on App::new and ConfigChanged.
slash/picker.rsdescribe() collapses into display_name; drop redundant Esc arm + test; effort-row glyph routes through tier_color so /model and /effort agree visually; parity contract doc on new.
tui/modal.rsModalStack::handle_key short-circuits Esc / Ctrl+C with new tests.
slash/status_modal.rsDrop redundant Esc arm; keep Enter-as-close; refresh module doc to match the centralized cancel.
slash/effort_slider.rsNew EffortSlider modal — horizontal Speed ↔ Intelligence slider with uniform-gap layout, inline / glyphs, and per-tier ANSI color (tier_color) shared with the picker.
slash/effort.rsBare /effort opens the slider; typed-arg path unchanged; tests updated.
slash.rsRegister effort_slider module.
docs/guide/slash-commands.md, docs/design/slash/commands.md, docs/design/slash/modals.md, docs/roadmap.md, CLAUDE.mdRefresh /effort surface — bare opens slider; update modal docs; move /effort slider from "planned" to "working today".

Test plan

  • cargo build compiles cleanly
  • cargo clippy --all-targets -- -D warnings — zero warnings
  • cargo test — 1630 tests pass
  • cargo fmt --all --check — clean
  • pnpm lint, pnpm spellcheck — clean
  • Manual TUI: /effort opens the slider; arrow keys walk the ladder; Enter commits; Esc / Ctrl+C cancel cleanly; [1m] model shows (1M context) in the status bar; /effort on Haiku 4.5 errors before opening; per-tier color visible in slider and /model's effort row

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.
@hakula139hakula139 added the enhancement New feature or request label May 5, 2026
@hakula139hakula139 self-assigned this May 5, 2026
@codecov

codecovBot commented May 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.69789% with 1 line in your changes missing coverage. Please review.

Files with missing linesPatch %Lines
crates/oxide-code/src/slash/effort_slider.rs99.56%1 Missing ⚠️

📢 Thoughts on this report? Let us know!

hakula139 added 15 commits May 6, 2026 01:27
`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`.
@hakula139
hakula139 merged commit 93eaf9a into mainMay 6, 2026
4 checks passed
@hakula139
hakula139 deleted the feat/effort-slider-and-display-fixes branch May 6, 2026 09:58
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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancementNew feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@hakula139