Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

Run `ccswap` on its own (or `ccswap tui`) for the full-screen dashboard: Claude Code and Codex appear together in labelled sections, with live usage, provider-correct switching, and auto-switching, all keyboard-driven. Arrow-key and Vim-style menu navigation wraps at both ends. The menu's **Settings…** screen cycles the theme, dashboard view, auto-switch threshold, and strategy, and toggles **Mask account info**. Masking hides email addresses and organization names on the dashboard, the `w` watch page, and the auto-switch view — including its candidate list and event log — while slot numbers and aliases stay visible so you can still switch. `m` toggles it from any screen, and the footer shows `Mask on` or `Mask off`. The same switch is `ccswap config set ui.mask on` (off by default). The dashboard view can show both providers or only Claude Code / Codex without stopping background updates for the hidden provider. `ccswap watch` opens straight into the live monitor using the selected dashboard view. Works on macOS, Linux, and Windows.

Hover over a menu option for a short explanation. With the keyboard, highlight
a row and press `?` to read the same help; Esc closes it without activating the option.

## Menu bar (macOS)

Needs the `menubar` extra (macOS only):
Expand Down
12 changes: 11 additions & 1 deletion src/claude_swap/tui/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
iter_accounts,
present_tag,
)
from claude_swap.tui.option_help import option_help
from claude_swap.tui.modals import OutputModal
from claude_swap.tui.widgets import (
AccountItem,
AccountsPanel,
Expand Down Expand Up @@ -89,6 +91,7 @@ class DashboardScreen(Screen):
Binding("w", "app.open_watch", "Watch"),
Binding("escape,left", "menu_back", "Back", show=False),
Binding("q", "app.quit", "Quit"),
Binding("question_mark", "option_help", "Option help"),
# Power shortcuts; the menu is the discoverable path.
Binding("g", "app.open_auto", "Auto view", show=False),
Binding("f", "app.refresh_full", "Refresh usage", show=False),
Expand Down Expand Up @@ -296,11 +299,18 @@ async def _render_menu(self) -> None:
menu = self.query_one("#menu", ListView)
await menu.clear()
await menu.extend(
MenuItem(label, action_id, muted=(action_id == "back"))
MenuItem(label, action_id, muted=(action_id == "back"),
help_text=option_help(action_id))
for label, action_id in entries
)
menu.index = 0

def action_option_help(self) -> None:
menu = self.query_one("#menu", ListView)
item = menu.highlighted_child
if isinstance(item, MenuItem) and (help_text := option_help(item.action_id)):
self.app.push_screen(OutputModal("Option help", help_text))

async def on_list_view_selected(self, event: ListView.Selected) -> None:
item = event.item
if isinstance(item, MenuItem):
Expand Down
31 changes: 31 additions & 0 deletions src/claude_swap/tui/option_help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Explanations shared by menu tooltips and keyboard help."""

OPTION_HELP = {
"switch": "Choose a saved account to use. Codex must be restarted after switching to load the selected login.",
"watch": "Monitor account quotas without switching. Press s in Watch to enable account selection.",
"auto": "Open the auto-switch view for a provider. It starts as a dry-run preview; press l and confirm to switch live. Closing the view stops its engine.",
"add-menu": "Save your current login, or add a Claude setup token or API key, as a managed account.",
"disable-menu": "Exclude an account from automatic selection without deleting its saved login. You can enable it again here.",
"remove-menu": "Choose an account whose saved credentials and configuration backup will be deleted. You will be asked to confirm.",
"settings-menu": "Change appearance, account masking, switching policy and Claude Code integrations. Settings are saved for later runs.",
"quit": "Close this app. Any auto-switch engine started by this app stops; separately running auto processes are not stopped.",
"back": "Return to the previous menu without changing a setting.",
"setting:theme": "Cycle dark, light and auto. Auto follows the terminal background.",
"setting:view": "Cycle both providers, Claude only and Codex only. This filters the dashboard, Switch and Watch lists; administrative menus still show all accounts.",
"setting:mask": "Hide account emails and organization names in supported displays. This does not remove credentials or change the active login.",
"setting:threshold": "Cycle the default utilization threshold for automatic switching. More specific gates override it; weekly gates may inherit weeklyThreshold first. Disabled windows do not trigger switching.",
"setting:threshold5h": "Cycle the 5-hour gate, then inherit. Automatic switching can trigger when any configured gate reaches its threshold.",
"setting:threshold7d": "Cycle the weekly gate, then inherit. Applies to Claude's 7-day window and Codex's weekly window.",
"setting:fable": "Cycle the Claude-only Fable weekly-model gate, then inherit. Other model thresholds are preserved. Exact values and additional models are available through ccswap config set.",
"setting:strategy": "Cycle best and consume-first. Best favors quota headroom; consume-first proactively uses available quota whose weekly window resets sooner, even below the threshold. This does not redeem banked resets.",
"claude:claude.statusline": "Install or remove ccswap's Claude Code statusline. Turning it off restores the statusline configuration backed up during installation.",
"claude:claude.mod": "Enable or disable the ccswap Claude Code plugin. This may invoke Claude's plugin manager; it does not change your account.",
"add-token": "Add a Claude setup token or API key. API-key requests are billed by usage; a setup token uses its account subscription.",
"add-login": "Save the provider's current login as a managed account. An already managed login is refreshed in place.",
"remove": "Remove this account's saved credentials and configuration backup after confirmation. Removal cannot be undone from this menu.",
"disable": "Toggle whether this account may be selected automatically. Its saved login remains stored.",
}


def option_help(action_id: str) -> str | None:
return OPTION_HELP.get(action_id) or OPTION_HELP.get(action_id.split(":", 1)[0])
5 changes: 4 additions & 1 deletion src/claude_swap/tui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,9 +630,12 @@ def action_cursor_down(self) -> None:
class MenuItem(ListItem):
"""One menu row: a label plus an action id the screen dispatches on."""

def __init__(self, label: str, action_id: str, *, muted: bool = False) -> None:
def __init__(
self, label: str, action_id: str, *, muted: bool = False, help_text: str | None = None
) -> None:
item = Static(label, markup=False)
if muted:
item.add_class("menu-item-muted")
super().__init__(item)
self.action_id = action_id
self.tooltip = Text(help_text) if help_text else None
30 changes: 30 additions & 0 deletions tests/test_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -2883,3 +2883,33 @@ async def test_theme_menu_marks_current_and_applies(self, tmp_path):
assert app._theme_name == "light"
assert app.theme == "cswap-light"
assert json.loads((tmp_path / "settings.json").read_text())["ui"]["theme"] == "light"


@pytest.mark.asyncio
async def test_menu_hover_and_keyboard_help_do_not_activate_option(tmp_path):
from textual.widgets import ListView, Static, Tooltip
from claude_swap.tui.widgets import MenuItem
from claude_swap.tui.modals import OutputModal
fake = FakeSwitcher([make_account(1, active=True)], tmp_path)
app = make_app(fake)
app.TOOLTIP_DELAY = 0.01
async with app.run_test(size=(110, 40), tooltips=True) as pilot:
await settle(pilot)
await menu_select(pilot, "settings-menu")
menu = app.screen.query_one("#menu", ListView)
item = next(i for i in menu.query(MenuItem) if i.action_id == "setting:threshold")
menu.index = list(menu.children).index(item)
assert all(i.tooltip for i in menu.query(MenuItem))
before = app.threshold_pct
await pilot.hover(item.query_one(Static))
await pilot.pause(0.1)
assert "utilization threshold" in str(app.screen.query_one(Tooltip).render())
await pilot.press("question_mark")
await pilot.pause()
assert isinstance(app.screen, OutputModal)
assert "utilization threshold" in app.screen._output
assert app.threshold_pct == before
await pilot.press("escape")
await pilot.pause()
assert menu.highlighted_child is item
assert not (tmp_path / "settings.json").exists()
Loading