diff --git a/clients/terminal/cli.py b/clients/terminal/cli.py index fb481f0..46bf036 100644 --- a/clients/terminal/cli.py +++ b/clients/terminal/cli.py @@ -21,6 +21,7 @@ @click.option("--new-session", is_flag=True, help="Create a new session even if state exists.") @click.option("--show-thinking", is_flag=True, help="Show model reasoning blocks.") @click.option("--show-events/--no-events", default=True, help="Show tool call events.") +@click.option("--theme", default=None, help="TUI theme name to start with.") @click.option("--raw", is_flag=True, help="Use the plain CLI instead of the TUI.") @click.version_option(version="0.1.0", prog_name="navi-code") def main( @@ -31,6 +32,7 @@ new_session: bool, show_thinking: bool, show_events: bool, + theme: str | None, raw: bool, ) -> None: """Navi Code — terminal client for Navi. @@ -50,7 +52,7 @@ _run_raw(prompt, new_session, profile_id) return - _run_tui(profile_id, new_session) + _run_tui(profile_id, new_session, theme) def _run_raw(prompt: str | None, new_session: bool, profile_id: str | None) -> None: @@ -69,11 +71,11 @@ asyncio.run(_run_interactive(client, state)) -def _run_tui(profile_id: str | None, new_session: bool) -> None: +def _run_tui(profile_id: str | None, new_session: bool, theme: str | None) -> None: from clients.terminal.tui.tui_app import NaviCodeTui - app = NaviCodeTui(profile_id=profile_id, new_session=new_session) - app.run() + app = NaviCodeTui(profile_id=profile_id, new_session=new_session, theme_name=theme) + app.run(mouse=app._mouse_enabled) def _resolve_session_id(state: StateManager, force_new: bool, profile_id: str | None) -> str | None: diff --git a/clients/terminal/tui/commands/builtin.py b/clients/terminal/tui/commands/builtin.py index fd6b776..2c37634 100644 --- a/clients/terminal/tui/commands/builtin.py +++ b/clients/terminal/tui/commands/builtin.py @@ -6,6 +6,7 @@ from clients.terminal.config import settings from clients.terminal.tui.commands.base import BaseCommand, CommandMeta from clients.terminal.tui.context import TuiContext +from clients.terminal.tui.settings import get_tui_settings class HelpCommand(BaseCommand): @@ -170,6 +171,61 @@ ctx.ws_client.enqueue("Please summarize and compact our conversation so far.") +class ThemesCommand(BaseCommand): + meta = CommandMeta( + name="themes", + aliases=(), + description="Open the theme picker.", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + app = ctx.app() + current = getattr(app, "_theme_name", "gnexus-dark") + + def on_picked(theme_name: str | None) -> None: + if theme_name is None: + ctx.chat_panel.handle_ws_event({"type": "status", "content": "Theme selection cancelled"}) + return + app = ctx.app() + app._theme_name = theme_name + app._apply_theme() + tui_settings = ctx.settings or get_tui_settings() + tui_settings.theme = theme_name + tui_settings.save() + ctx.chat_panel.handle_ws_event({"type": "status", "content": f"Theme set to {theme_name}"}) + + from clients.terminal.tui.screens.theme_picker import ThemePickerScreen + + app.push_screen(ThemePickerScreen(current), callback=on_picked) + + +class MouseCommand(BaseCommand): + meta = CommandMeta( + name="mouse", + aliases=(), + description="Toggle mouse support in the TUI (requires restart).", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + tui_settings = ctx.settings or get_tui_settings() + if args.strip().lower() in ("on", "true", "1", "yes"): + tui_settings.mouse = True + elif args.strip().lower() in ("off", "false", "0", "no"): + tui_settings.mouse = False + else: + tui_settings.mouse = not tui_settings.mouse + tui_settings.save() + state = "on" if tui_settings.mouse else "off" + ctx.chat_panel.handle_ws_event( + { + "type": "status", + "content": f"Mouse support set to {state}. Restart navi-code to apply.", + } + ) + + async def _reconnect_ws(ctx: TuiContext) -> None: """Close old WebSocket and open a new one for the current session.""" if ctx.ws_client: diff --git a/clients/terminal/tui/commands/registry.py b/clients/terminal/tui/commands/registry.py index 93f7dd8..248e821 100644 --- a/clients/terminal/tui/commands/registry.py +++ b/clients/terminal/tui/commands/registry.py @@ -56,4 +56,6 @@ registry.register(builtin.QuitCommand()) registry.register(builtin.ThinkingCommand()) registry.register(builtin.CompactCommand()) + registry.register(builtin.ThemesCommand()) + registry.register(builtin.MouseCommand()) return registry diff --git a/clients/terminal/tui/context.py b/clients/terminal/tui/context.py index e149cad..a625abc 100644 --- a/clients/terminal/tui/context.py +++ b/clients/terminal/tui/context.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from clients.terminal.state import StateManager from clients.terminal.tui.chat_model import ChatModel + from clients.terminal.tui.settings import TuiSettings from clients.terminal.tui.widgets.chat_panel import ChatPanel from clients.terminal.tui.widgets.status_panel import StatusPanel from clients.terminal.ws_client import NaviWebSocketClient @@ -21,13 +22,14 @@ profile_id: str | None = None ws_client: "NaviWebSocketClient | None" = None state: "StateManager | None" = None + settings: "TuiSettings | None" = None chat_panel: "ChatPanel | None" = None status_panel: "StatusPanel | None" = None chat_model: "ChatModel | None" = None def app(self): """Return the running TuiApp instance.""" - from textual.app import App + from textual import app as textual_app - # This is a convenience accessor used by commands that need App-level actions. - return App.get_active_app() + # active_app is a ContextVar containing the currently running App. + return textual_app.active_app.get() diff --git a/clients/terminal/tui/screens/theme_picker.py b/clients/terminal/tui/screens/theme_picker.py new file mode 100644 index 0000000..75f6b0a --- /dev/null +++ b/clients/terminal/tui/screens/theme_picker.py @@ -0,0 +1,186 @@ +"""Theme picker modal screen for Navi Code TUI.""" + +from __future__ import annotations + +from textual import events +from textual.app import ComposeResult +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Input, Label, ListItem, ListView, Static + +from clients.terminal.tui.themes import ThemeRegistry, set_active_theme + + +class ThemePickerScreen(ModalScreen[str | None]): + """A palette-like screen for switching themes with live preview. + + Dismisses with the selected theme name or None if cancelled. + """ + + NAME = "ThemePickerScreen" + + DEFAULT_CSS = """ + ThemePickerScreen { + align: center middle; + } + ThemePickerScreen > Container { + width: 60; + height: auto; + max-height: 24; + border: thick $tui-primary; + background: $tui-surface; + padding: 0 0 1 0; + } + ThemePickerScreen .title { + text-style: bold; + color: $tui-primary; + background: $tui-panel; + padding: 1; + height: auto; + text-align: center; + } + ThemePickerScreen Input { + height: auto; + border: none; + border-bottom: solid $tui-border; + background: $tui-background; + color: $tui-text; + padding: 0 1; + margin: 0; + } + ThemePickerScreen ListView { + height: auto; + max-height: 16; + border: none; + background: $tui-surface; + padding: 0; + margin: 0; + } + ThemePickerScreen ListItem { + color: $tui-text; + background: transparent; + height: auto; + padding: 0 1; + } + ThemePickerScreen ListItem.--highlight { + background: $tui-selection; + color: $tui-background; + } + ThemePickerScreen .empty { + color: $tui-text-dim; + text-align: center; + padding: 1; + } + """ + + BINDINGS = [ + ("escape", "dismiss_cancel", "Cancel"), + ] + + def __init__(self, current_theme: str) -> None: + super().__init__() + self._current_theme = current_theme + self._original_theme = current_theme + self._themes = ThemeRegistry.all() + self._filtered = list(self._themes) + self._list_items: list[ListItem] = [] + + def compose(self) -> ComposeResult: + with Container(): + yield Static("Pick a theme (live preview)", classes="title") + yield Input(placeholder="Type to filter themes...", id="theme-input") + yield ListView(id="theme-list") + + def on_mount(self) -> None: + self._render_list() + list_view = self.query_one("#theme-list", ListView) + list_view.focus() + + def _render_list(self) -> None: + list_view = self.query_one("#theme-list", ListView) + list_view.clear() + self._list_items = [] + if not self._filtered: + list_view.append(ListItem(Static("No matching themes", classes="empty"))) + return + selected_index = 0 + for index, name in enumerate(self._filtered): + label = Label(name) + if name == self._current_theme: + label.update(f"[b]* {name}[/b]") + selected_index = index + self._list_items.append(ListItem(label)) + list_view.append(self._list_items[-1]) + list_view.index = selected_index + + def _preview_theme(self, name: str) -> None: + self._current_theme = name + app = self.app + app.theme = name + set_active_theme(name) + if hasattr(app, "_apply_theme"): + app._apply_theme() + self._render_list() + + def _restore_original(self) -> None: + app = self.app + app.theme = self._original_theme + set_active_theme(self._original_theme) + if hasattr(app, "_apply_theme"): + app._apply_theme() + + def _filter(self, query: str) -> None: + query = query.strip().lower() + if not query: + self._filtered = list(self._themes) + else: + self._filtered = [name for name in self._themes if query in name.lower()] + self._render_list() + + def _select_highlighted(self) -> None: + list_view = self.query_one("#theme-list", ListView) + highlighted = list_view.index + if highlighted is not None and 0 <= highlighted < len(self._filtered): + self.dismiss(self._filtered[highlighted]) + elif self._filtered: + self.dismiss(self._filtered[0]) + + def on_input_changed(self, event: Input.Changed) -> None: + self._filter(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + self._select_highlighted() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + index = self._list_items.index(event.item) if event.item in self._list_items else None + if index is not None: + self.dismiss(self._filtered[index]) + + def on_list_view_highlighted(self, event: ListView.Highlighted) -> None: + index = self._list_items.index(event.item) if event.item in self._list_items else None + if index is not None: + self._preview_theme(self._filtered[index]) + + def on_key(self, event: events.Key) -> None: + list_view = self.query_one("#theme-list", ListView) + if event.key == "down": + list_view.action_cursor_down() + event.stop() + event.prevent_default() + elif event.key == "up": + list_view.action_cursor_up() + event.stop() + event.prevent_default() + elif event.key in ("enter", "return"): + self._select_highlighted() + event.stop() + event.prevent_default() + elif event.key == "escape": + self._restore_original() + self.dismiss(None) + event.stop() + event.prevent_default() + + def action_dismiss_cancel(self) -> None: + self._restore_original() + self.dismiss(None) diff --git a/clients/terminal/tui/settings.py b/clients/terminal/tui/settings.py new file mode 100644 index 0000000..c6fdb52 --- /dev/null +++ b/clients/terminal/tui/settings.py @@ -0,0 +1,91 @@ +"""Persistent TUI settings stored in ~/.navi_code/tui.json.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +from clients.terminal.config import settings + + +@dataclass +class TuiSettings: + """User-facing TUI configuration persisted across restarts.""" + + theme: str = "gnexus-dark" + mouse: bool = True + scroll_speed: int = 1 + diff_style: str = "unified" + keybinds: dict[str, str] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "TuiSettings": + field_defaults = {f.name: f.default for f in cls.__dataclass_fields__.values()} + merged = dict(field_defaults) + merged.update(data) + keybinds = merged.get("keybinds", {}) + if keybinds is None or not isinstance(keybinds, dict): + keybinds = {} + return cls( + theme=merged.get("theme", "gnexus-dark"), + mouse=merged.get("mouse", True), + scroll_speed=merged.get("scroll_speed", 1), + diff_style=merged.get("diff_style", "unified"), + keybinds=dict(keybinds), + ) + + @property + def settings_dir(self) -> Path: + return settings.state_dir + + @property + def settings_file(self) -> Path: + return self.settings_dir / "tui.json" + + def load(self) -> "TuiSettings": + if not self.settings_file.exists(): + return self._save_and_return() + try: + with self.settings_file.open("r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return self._save_and_return() + loaded = self.from_dict(data) + loaded._ensure_dir() + return loaded + + def save(self) -> None: + self._ensure_dir() + with self.settings_file.open("w", encoding="utf-8") as f: + json.dump(self.to_dict(), f, indent=2, ensure_ascii=False) + + def _ensure_dir(self) -> None: + self.settings_dir.mkdir(parents=True, exist_ok=True) + + def _save_and_return(self) -> "TuiSettings": + self.save() + return self + + +# Global lazy-loaded instance. +_tui_settings: TuiSettings | None = None + + +def get_tui_settings() -> TuiSettings: + """Return the loaded TUI settings, caching them for the process lifetime.""" + global _tui_settings + if _tui_settings is None: + _tui_settings = TuiSettings().load() + return _tui_settings + + +def reload_tui_settings() -> TuiSettings: + """Force reload from disk and return fresh settings.""" + global _tui_settings + _tui_settings = TuiSettings().load() + return _tui_settings diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 4debd62..795fde0 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -19,6 +19,7 @@ from clients.terminal.tui.file_refs import FileRefResolver from clients.terminal.tui.permissions import PermissionEngine, PermissionRule from clients.terminal.tui.shell_runner import run_shell_command +from clients.terminal.tui.settings import get_tui_settings from clients.terminal.tui.themes import ThemeRegistry, set_active_theme from clients.terminal.tui.commands.registry import get_registry from clients.terminal.tui.screens.command_palette import CommandPaletteScreen @@ -44,9 +45,11 @@ session_id: str | None = None, profile_id: str | None = None, new_session: bool = False, - theme_name: str = "gnexus-dark", + theme_name: str | None = None, ) -> None: - self._theme_name = theme_name + self._tui_settings = get_tui_settings() + self._theme_name = theme_name or self._tui_settings.theme + self._mouse_enabled = self._tui_settings.mouse super().__init__() self._register_textual_themes() self.theme = self._theme_name @@ -59,6 +62,7 @@ state=self._state, chat_panel=self._chat_panel, status_panel=self._status_panel, + settings=self._tui_settings, ) self._bridge: WsBridge | None = None self._permission_engine = PermissionEngine() @@ -88,6 +92,8 @@ """Activate the selected theme and update global active theme state.""" set_active_theme(self._theme_name) self.theme = self._theme_name + if self._status_panel: + self._status_panel.set_theme(self._theme_name) async def _startup(self) -> None: session_id = await self._resolve_session( @@ -141,6 +147,8 @@ self._status_panel.set_session(session_id) self._status_panel.set_profile(self._ctx.profile_id) self._status_panel.set_model(settings.ollama_default_model if hasattr(settings, "ollama_default_model") else "unknown") + self._status_panel.set_backend(settings.base_url) + self._status_panel.set_theme(self._theme_name) if self._bridge: await self._bridge.stop() diff --git a/clients/terminal/tui/widgets/status_panel.py b/clients/terminal/tui/widgets/status_panel.py index 6fad6a5..5a2f438 100644 --- a/clients/terminal/tui/widgets/status_panel.py +++ b/clients/terminal/tui/widgets/status_panel.py @@ -40,6 +40,10 @@ self._session = Static("Session: -") self._model = Static("Model: -") self._connection = Static("Connection: offline", classes="connection") + self._backend = Static("Backend: -") + self._theme = Static("Theme: -") + self._tokens = Static("Tokens: -") + self._iterations = Static("Iter: -") self._hint = Static("Ctrl+P palette | /help commands") def compose(self) -> ComposeResult: @@ -48,6 +52,10 @@ yield self._session yield self._model yield self._connection + yield self._backend + yield self._theme + yield self._tokens + yield self._iterations yield Static("", classes="spacer") yield self._hint @@ -71,3 +79,26 @@ self._connection.update( Text(f"Connection: offline {detail}", style=theme.status_offline.hex) ) + + def set_backend(self, url: str) -> None: + short = url + if len(short) > 30: + short = short[:27] + "..." + self._backend.update(f"Backend: {short}") + + def set_theme(self, theme_name: str) -> None: + self._theme.update(f"Theme: {theme_name}") + + def set_tokens(self, used: int | None, total: int | None = None) -> None: + if used is None: + self._tokens.update("Tokens: -") + return + suffix = f" / {total}" if total is not None else "" + self._tokens.update(f"Tokens: {used}{suffix}") + + def set_iterations(self, current: int | None, budget: int | None = None) -> None: + if current is None: + self._iterations.update("Iter: -") + return + suffix = f" / {budget}" if budget is not None else "" + self._iterations.update(f"Iter: {current}{suffix}") diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index b613e23..1dbfb6a 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -2,11 +2,28 @@ from __future__ import annotations +from pathlib import Path + import pytest from clients.terminal.tui.tui_app import NaviCodeTui +@pytest.fixture(autouse=True) +def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Override the state dir so tests never touch ~/.navi_code.""" + from clients.terminal import config + + original = config.settings.state_dir + config.settings.state_dir = tmp_path + import clients.terminal.tui.settings as settings_module + + settings_module._tui_settings = None + yield tmp_path + config.settings.state_dir = original + settings_module._tui_settings = None + + @pytest.mark.anyio async def test_tui_mounts_widgets() -> None: """The TUI app mounts chat, status, and input widgets.""" @@ -54,3 +71,15 @@ await pilot.pause() chat = pilot.app.query_one("ChatPanel") assert any(item.kind == "error" for item in chat._model.items) + + +@pytest.mark.anyio +async def test_status_panel_shows_backend_and_theme() -> None: + """Status panel displays backend URL and current theme.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + status = pilot.app.query_one("StatusPanel") + backend_text = str(status._backend.render()) + theme_text = str(status._theme.render()) + assert "Backend:" in backend_text + assert "Theme: gnexus-dark" in theme_text diff --git a/tests/clients/test_tui_settings.py b/tests/clients/test_tui_settings.py new file mode 100644 index 0000000..ce4032a --- /dev/null +++ b/tests/clients/test_tui_settings.py @@ -0,0 +1,85 @@ +"""Tests for persistent TUI settings.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from clients.terminal.tui.settings import TuiSettings, get_tui_settings, reload_tui_settings + + +@pytest.fixture +def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Override the state dir so tests never touch ~/.navi_code.""" + from clients.terminal import config + + original = config.settings.state_dir + config.settings.state_dir = tmp_path + yield tmp_path + config.settings.state_dir = original + + +@pytest.fixture(autouse=True) +def reset_cached_settings(monkeypatch: pytest.MonkeyPatch) -> None: + """Clear the module-level cache before each test.""" + import clients.terminal.tui.settings as settings_module + + settings_module._tui_settings = None + + +def test_default_settings() -> None: + s = TuiSettings() + assert s.theme == "gnexus-dark" + assert s.mouse is True + assert s.scroll_speed == 1 + assert s.diff_style == "unified" + assert s.keybinds == {} + + +def test_settings_save_and_load(tmp_state_dir: Path) -> None: + s = TuiSettings() + s.theme = "gnexus-light" + s.mouse = False + s.scroll_speed = 3 + s.save() + + loaded = TuiSettings().load() + assert loaded.theme == "gnexus-light" + assert loaded.mouse is False + assert loaded.scroll_speed == 3 + + +def test_settings_migrates_unknown_fields(tmp_state_dir: Path) -> None: + data: dict[str, Any] = { + "theme": "gnexus-light", + "future_field": "ignored", + } + (tmp_state_dir / "tui.json").write_text(json.dumps(data), encoding="utf-8") + loaded = TuiSettings().load() + assert loaded.theme == "gnexus-light" + assert loaded.mouse is True # default preserved + + +def test_settings_load_creates_default_file(tmp_state_dir: Path) -> None: + loaded = TuiSettings().load() + file_path = tmp_state_dir / "tui.json" + assert file_path.exists() + data = json.loads(file_path.read_text(encoding="utf-8")) + assert data["theme"] == "gnexus-dark" + assert loaded.theme == "gnexus-dark" + + +def test_get_tui_settings_caches(monkeypatch: pytest.MonkeyPatch, tmp_state_dir: Path) -> None: + first = get_tui_settings() + first.theme = "custom-theme" + first.save() + second = get_tui_settings() + assert second is first + assert second.theme == "custom-theme" + + fresh = reload_tui_settings() + assert fresh is not first + assert fresh.theme == "custom-theme" diff --git a/tests/clients/test_tui_themes.py b/tests/clients/test_tui_themes.py new file mode 100644 index 0000000..ae134b7 --- /dev/null +++ b/tests/clients/test_tui_themes.py @@ -0,0 +1,65 @@ +"""Tests for theme picker and /themes command.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from clients.terminal.tui.tui_app import NaviCodeTui + + +@pytest.fixture(autouse=True) +def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Override the state dir so tests never touch ~/.navi_code.""" + from clients.terminal import config + + original = config.settings.state_dir + config.settings.state_dir = tmp_path + import clients.terminal.tui.settings as settings_module + + settings_module._tui_settings = None + yield tmp_path + config.settings.state_dir = original + settings_module._tui_settings = None + + +@pytest.mark.anyio +async def test_themes_command_opens_picker() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + input_box._input.value = "/themes" + await pilot.press("enter") + await pilot.pause() + assert pilot.app.screen.__class__.__name__ == "ThemePickerScreen" + + +@pytest.mark.anyio +async def test_themes_command_switches_theme() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + input_box._input.value = "/themes" + await pilot.press("enter") + await pilot.pause() + assert pilot.app.screen.__class__.__name__ == "ThemePickerScreen" + await pilot.press("down") + await pilot.press("enter") + await pilot.pause() + assert pilot.app._theme_name == "gnexus-light" + + +@pytest.mark.anyio +async def test_mouse_command_toggles_setting() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + from clients.terminal.tui.settings import get_tui_settings + + tui_settings = get_tui_settings() + original = tui_settings.mouse + input_box = pilot.app.query_one("InputBox") + input_box._input.value = "/mouse" + await pilot.press("enter") + await pilot.pause() + assert tui_settings.mouse is not original