diff --git a/docs/dashboard.md b/docs/dashboard.md index ac9a4c0..e193c8a 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -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): diff --git a/src/claude_swap/tui/dashboard.py b/src/claude_swap/tui/dashboard.py index ac81efa..1f99c09 100644 --- a/src/claude_swap/tui/dashboard.py +++ b/src/claude_swap/tui/dashboard.py @@ -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, @@ -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), @@ -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): diff --git a/src/claude_swap/tui/option_help.py b/src/claude_swap/tui/option_help.py new file mode 100644 index 0000000..c85a5ad --- /dev/null +++ b/src/claude_swap/tui/option_help.py @@ -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]) diff --git a/src/claude_swap/tui/widgets.py b/src/claude_swap/tui/widgets.py index b4c1713..59d6ca0 100644 --- a/src/claude_swap/tui/widgets.py +++ b/src/claude_swap/tui/widgets.py @@ -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 diff --git a/tests/test_tui.py b/tests/test_tui.py index 75dee21..9d18760 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -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()