diff --git a/clients/terminal/cli.py b/clients/terminal/cli.py index 46bf036..151a95b 100644 --- a/clients/terminal/cli.py +++ b/clients/terminal/cli.py @@ -22,6 +22,7 @@ @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("--mouse/--no-mouse", default=None, help="Override mouse support in the TUI.") @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( @@ -33,6 +34,7 @@ show_thinking: bool, show_events: bool, theme: str | None, + mouse: bool | None, raw: bool, ) -> None: """Navi Code — terminal client for Navi. @@ -52,7 +54,7 @@ _run_raw(prompt, new_session, profile_id) return - _run_tui(profile_id, new_session, theme) + _run_tui(profile_id, new_session, theme, mouse) def _run_raw(prompt: str | None, new_session: bool, profile_id: str | None) -> None: @@ -71,10 +73,14 @@ asyncio.run(_run_interactive(client, state)) -def _run_tui(profile_id: str | None, new_session: bool, theme: str | None) -> None: +def _run_tui( + profile_id: str | None, new_session: bool, theme: str | None, mouse: bool | None +) -> None: from clients.terminal.tui.tui_app import NaviCodeTui app = NaviCodeTui(profile_id=profile_id, new_session=new_session, theme_name=theme) + if mouse is not None: + app._mouse_enabled = mouse app.run(mouse=app._mouse_enabled) @@ -85,10 +91,10 @@ try: session = api.get_session(saved) click.secho( - f"Resumed session {session['id'][:8]} (profile {session['profile_id']})", + f"Resumed session {session['session_id'][:8]} (profile {session['profile_id']})", fg="bright_black", ) - return session["id"] + return session["session_id"] except Exception: state.clear_session_id() @@ -99,12 +105,12 @@ click.secho(f"Failed to create session: {exc}", fg="red", err=True) return None - state.set_session_id(session["id"]) + state.set_session_id(session["session_id"]) click.secho( - f"Created session {session['id'][:8]} (profile {session['profile_id']})", + f"Created session {session['session_id'][:8]} (profile {session['profile_id']})", fg="green", ) - return session["id"] + return session["session_id"] async def _run_one_shot(client: NaviWebSocketClient, prompt: str) -> None: @@ -121,7 +127,8 @@ while True: try: user_input = await asyncio.get_event_loop().run_in_executor( - None, lambda: input(click.style("You: ", fg="blue", bold=True)), + None, + lambda: input(click.style("You: ", fg="blue", bold=True)), ) except EOFError: break @@ -169,9 +176,9 @@ except Exception as exc: click.secho(f"Failed to create session: {exc}", fg="red", err=True) return True - state.set_session_id(session["id"]) + state.set_session_id(session["session_id"]) click.secho( - f"Switched to new session {session['id'][:8]} (profile {session['profile_id']})", + f"Switched to new session {session['session_id'][:8]} (profile {session['profile_id']})", fg="green", ) return True @@ -183,7 +190,8 @@ click.secho(f"Failed to list sessions: {exc}", fg="red", err=True) return True for s in sessions: - click.echo(f" {s['id'][:8]} {s.get('profile_id', 'unknown')} {s.get('title', '')}") + title = s.get("name", "") or s.get("preview", "") + click.echo(f" {s['session_id'][:8]} {s.get('profile_id', 'unknown')} {title}") return True if head == "/switch" and len(parts) == 2: @@ -194,7 +202,7 @@ # Try to find by prefix try: sessions = api.list_sessions() - matches = [s for s in sessions if s["id"].startswith(target)] + matches = [s for s in sessions if s["session_id"].startswith(target)] if len(matches) == 1: session = matches[0] else: @@ -202,9 +210,9 @@ except Exception: click.secho(f"Session not found: {target}", fg="red", err=True) return True - state.set_session_id(session["id"]) + state.set_session_id(session["session_id"]) click.secho( - f"Switched to session {session['id'][:8]} (profile {session['profile_id']})", + f"Switched to session {session['session_id'][:8]} (profile {session['profile_id']})", fg="green", ) return True diff --git a/clients/terminal/tui/commands/builtin.py b/clients/terminal/tui/commands/builtin.py index 591920b..684a0e6 100644 --- a/clients/terminal/tui/commands/builtin.py +++ b/clients/terminal/tui/commands/builtin.py @@ -59,8 +59,10 @@ ctx.chat_panel.handle_ws_event( {"type": "status", "content": f"Created session {session['session_id'][:8]}"} ) + app = ctx.app() + await app.attach_session(session["session_id"]) + ctx.chat_panel.clear() await _broadcast_session_list(ctx) - await _reconnect_ws(ctx) class SessionsCommand(BaseCommand): @@ -127,8 +129,10 @@ ctx.chat_panel.handle_ws_event( {"type": "status", "content": f"Switched to {session['session_id'][:8]}"} ) + app = ctx.app() + await app.attach_session(session["session_id"]) + ctx.chat_panel.clear() await _broadcast_session_list(ctx) - await _reconnect_ws(ctx) class ProfileCommand(BaseCommand): @@ -222,7 +226,7 @@ return app = ctx.app() app._theme_name = theme_name - app._apply_theme() + app.apply_theme() tui_settings = ctx.settings or get_tui_settings() tui_settings.theme = theme_name tui_settings.save() @@ -298,7 +302,16 @@ ) return - _open_in_editor(str(file_path)) + try: + _open_in_editor(str(file_path)) + except OSError as exc: + ctx.chat_panel.handle_ws_event( + { + "type": "error", + "message": f"Failed to open editor for {file_path}: {exc}", + } + ) + return ctx.chat_panel.handle_ws_event({"type": "status", "content": f"Exported to {file_path}"}) @@ -335,7 +348,10 @@ if not editor: editor = "notepad" if sys.platform == "win32" else "vi" # Detach so the TUI is not blocked while the editor runs. - subprocess.Popen([editor, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + subprocess.Popen([editor, path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError as exc: + raise OSError(f"cannot start editor '{editor}': {exc}") from exc async def _broadcast_session_list(ctx: TuiContext) -> None: @@ -356,25 +372,3 @@ for s in sessions ] ctx.app().post_message(SessionListUpdated(info_list, ctx.session_id)) - - -async def _reconnect_ws(ctx: TuiContext) -> None: - """Close old WebSocket and open a new one for the current session.""" - if ctx.ws_client: - await ctx.ws_client.close() - if not ctx.session_id: - return - from clients.terminal.tui.events import ConnectionStatusChanged - from clients.terminal.ws_client import NaviWebSocketClient - - new_client = NaviWebSocketClient(ctx.session_id) - ctx.ws_client = new_client - try: - await new_client.connect() - app = ctx.app() - app.run_worker(new_client.receive_loop) - ctx.status_panel.set_connection(True, "") - app.post_message(ConnectionStatusChanged(True, "")) - except Exception as exc: - ctx.status_panel.set_connection(False, str(exc)) - ctx.app().post_message(ConnectionStatusChanged(False, str(exc))) diff --git a/clients/terminal/tui/screens/theme_picker.py b/clients/terminal/tui/screens/theme_picker.py index 75f6b0a..28cc1ba 100644 --- a/clients/terminal/tui/screens/theme_picker.py +++ b/clients/terminal/tui/screens/theme_picker.py @@ -118,16 +118,14 @@ app = self.app app.theme = name set_active_theme(name) - if hasattr(app, "_apply_theme"): - 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() + app.apply_theme() def _filter(self, query: str) -> None: query = query.strip().lower() diff --git a/clients/terminal/tui/settings.py b/clients/terminal/tui/settings.py index c6fdb52..a7e4e7c 100644 --- a/clients/terminal/tui/settings.py +++ b/clients/terminal/tui/settings.py @@ -32,10 +32,10 @@ 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"), + theme=_coerce_str(merged.get("theme"), field_defaults["theme"]), + mouse=_coerce_bool(merged.get("mouse"), field_defaults["mouse"]), + scroll_speed=_coerce_int(merged.get("scroll_speed"), field_defaults["scroll_speed"]), + diff_style=_coerce_str(merged.get("diff_style"), field_defaults["diff_style"]), keybinds=dict(keybinds), ) @@ -89,3 +89,35 @@ global _tui_settings _tui_settings = TuiSettings().load() return _tui_settings + + +def _coerce_bool(value: Any, default: bool) -> bool: + """Return a boolean from bool or common string representations.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in ("true", "yes", "1", "on"): + return True + if lowered in ("false", "no", "0", "off"): + return False + return default + + +def _coerce_int(value: Any, default: int) -> int: + """Return an int from int or numeric string, falling back to default.""" + if isinstance(value, int) and not isinstance(value, bool): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError: + pass + return default + + +def _coerce_str(value: Any, default: str) -> str: + """Return a string value if it is a non-empty string, else default.""" + if isinstance(value, str) and value: + return value + return default diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index c19f7fa..fb5563a 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -87,7 +87,7 @@ yield Footer() def on_mount(self) -> None: - self._apply_theme() + self.apply_theme() self.run_worker(self._startup) def _register_textual_themes(self) -> None: @@ -95,7 +95,7 @@ for name in ThemeRegistry.all(): self.register_theme(ThemeRegistry.get(name).to_textual_theme()) - def _apply_theme(self) -> None: + def apply_theme(self) -> None: """Activate the selected theme and update global active theme state.""" set_active_theme(self._theme_name) self.theme = self._theme_name @@ -109,7 +109,7 @@ self._force_new_session, ) if session_id: - await self._attach_session(session_id) + await self.attach_session(session_id) self._input_box.focus_input() async def _resolve_session( @@ -145,7 +145,7 @@ self._state.set_session_id(session["session_id"]) return session["session_id"] - async def _attach_session(self, session_id: str) -> None: + async def attach_session(self, session_id: str) -> None: self._ctx.session_id = session_id try: session = api.get_session(session_id) @@ -197,9 +197,8 @@ async def _switch_session(self, session_id: str) -> None: self._state.set_session_id(session_id) - await self._attach_session(session_id) - self._chat_panel._model.items.clear() - self._chat_panel._refresh() + await self.attach_session(session_id) + self._chat_panel.clear() self._chat_panel.handle_ws_event( {"type": "status", "content": f"Switched to {session_id[:8]}"} ) diff --git a/clients/terminal/tui/widgets/chat_panel.py b/clients/terminal/tui/widgets/chat_panel.py index 78c2776..b38e341 100644 --- a/clients/terminal/tui/widgets/chat_panel.py +++ b/clients/terminal/tui/widgets/chat_panel.py @@ -48,23 +48,40 @@ self._model.handle_ws_event(msg) self._refresh() + def clear(self) -> None: + """Reset the chat model and redraw an empty conversation.""" + self._model.items.clear() + self._model._current_assistant = None + self._model._current_thinking = None + self._refresh() + def _refresh(self) -> None: renderables = [] for item in self._model.items: if item.kind == "user_message": - renderables.append(self._registry.render({"type": "user_message", "content": item.content})) + renderables.append( + self._registry.render({"type": "user_message", "content": item.content}) + ) elif item.kind == "assistant_message": - renderables.append(self._registry.render({"type": "assistant_message", "content": item.content})) + renderables.append( + self._registry.render({"type": "assistant_message", "content": item.content}) + ) elif item.kind == "thinking_block": - renderables.append(self._registry.render({"type": "thinking_block", "content": item.content})) + renderables.append( + self._registry.render({"type": "thinking_block", "content": item.content}) + ) elif item.kind == "tool_started": renderables.append(self._registry.render({"type": "tool_started", **item.meta})) elif item.kind == "tool_call": renderables.append(self._registry.render({"type": "tool_call", **item.meta})) elif item.kind == "error": - renderables.append(self._registry.render({"type": "error", "message": item.content})) + renderables.append( + self._registry.render({"type": "error", "message": item.content}) + ) else: - renderables.append(self._registry.render({"type": "plain", "content": item.content})) + renderables.append( + self._registry.render({"type": "plain", "content": item.content}) + ) self._items_container.update(Group(*renderables)) self.scroll_end(animate=False) diff --git a/clients/terminal/tui/widgets/sessions_panel.py b/clients/terminal/tui/widgets/sessions_panel.py index 73819e8..9184efc 100644 --- a/clients/terminal/tui/widgets/sessions_panel.py +++ b/clients/terminal/tui/widgets/sessions_panel.py @@ -106,19 +106,6 @@ if session_id: self.post_message(SessionSelected(session_id)) - def on_key(self, event) -> None: - if self._table is None: - return - if hasattr(event, "key") and event.key in ("enter", "return"): - cursor = self._table.cursor_coordinate - if cursor: - row_key = self._table.get_row_at(cursor.row) - session_id = str(row_key) if row_key else "" - if session_id: - self.post_message(SessionSelected(session_id)) - event.stop() - event.prevent_default() - @staticmethod def _truncate(text: str, max_len: int) -> str: if len(text) <= max_len: diff --git a/docs/navi_code_cli.md b/docs/navi_code_cli.md index 2aec702..2c2ff7f 100644 --- a/docs/navi_code_cli.md +++ b/docs/navi_code_cli.md @@ -43,6 +43,8 @@ | `--new-session` | Создать новую сессию, даже если сохранена старая. | | `--show-thinking` | Показывать блоки рассуждений модели. | | `--no-events` | Скрывать события `tool_started` / `tool_call`. | +| `--mouse` / `--no-mouse` | Включить/выключить мышь в TUI на один запуск (перекрывает `tui.json`). | +| `--theme NAME` | Запустить TUI с указанной темой. | | `--version` | Версия клиента. | ## Команды в интерактивном режиме @@ -54,8 +56,10 @@ | `/sessions` | Список сессий на сервере. | | `/switch ` | Переключиться на другую сессию (можно по префиксу id). | | `/profile` | Показать текущий профиль и id сессии. | -| `/export [path]` | Экспортировать текущую сессию в Markdown; без пути — во временный файл и `$EDITOR`. | -| `/clear` | Очистить локально сохранённый `session_id`. | +| `/export [path]` | Экспортировать текущую сессию в Markdown; без пути — во временный файл и `$EDITOR`. Если редактор не запускается, ошибка выводится в чат. | +| `/themes` | Открыть выбор темы с live-preview. | +| `/mouse on|off` | Включить/выключить поддержку мыши (требует перезапуска). | +| `/clear` | Очистить локально сохранённый `session_id`. | `/quit` | Выйти. | ## Интерфейс TUI @@ -106,6 +110,7 @@ - `config.py` — настройки из переменных окружения `NAVI_CODE_*`. - `tui/tui_app.py` — Textual-приложение. - `tui/widgets/` — виджеты (`ChatPanel`, `StatusPanel`, `SessionsPanel`). -- `tui/commands/` — slash-команды (`/new`, `/sessions`, `/switch`, `/export`). +- `tui/commands/` — slash-команды (`/new`, `/sessions`, `/switch`, `/export`, `/themes`, `/mouse`). +- `tui/settings.py` — persisted TUI config (`~/.navi_code/tui.json`). Тесты: `tests/clients/test_terminal_client.py`, `tests/clients/test_terminal_ws.py`, `tests/clients/test_tui_*.py`. diff --git a/tests/clients/test_terminal_client.py b/tests/clients/test_terminal_client.py index b1074a1..0ffcdd0 100644 --- a/tests/clients/test_terminal_client.py +++ b/tests/clients/test_terminal_client.py @@ -5,6 +5,7 @@ import json from pathlib import Path +import pytest from click.testing import CliRunner from clients.terminal.cli import main @@ -86,3 +87,48 @@ result = runner.invoke(main, ["--version"]) assert result.exit_code == 0 assert "0.1.0" in result.output + + def test_raw_mode_uses_session_id_and_name_fields( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Raw CLI must read session_id/name/preview from the server API.""" + + class FakeWsClient: + def __init__(self, session_id: str, renderer=None) -> None: + self.session_id = session_id + + async def run_one_shot(self, prompt: str) -> None: + pass + + monkeypatch.setattr("clients.terminal.cli.NaviWebSocketClient", FakeWsClient) + + from clients.terminal import config + + original_state_dir = config.settings.state_dir + config.settings.state_dir = tmp_path + + def fake_create_session(profile_id: str | None = None) -> dict: + return { + "session_id": "sess-raw-1234", + "profile_id": profile_id or "navi_code", + } + + def fake_get_session(session_id: str) -> dict: + return { + "session_id": session_id, + "profile_id": "navi_code", + "name": "Raw session", + } + + monkeypatch.setattr("clients.terminal.api.create_session", fake_create_session) + monkeypatch.setattr("clients.terminal.api.get_session", fake_get_session) + + state = StateManager(tmp_path) + state.set_session_id("sess-raw-1234") + runner = CliRunner() + try: + result = runner.invoke(main, ["--raw", "--base-url", "http://localhost:8000", "hello"]) + assert result.exit_code == 0 + assert "Resumed session sess-raw" in result.output + finally: + config.settings.state_dir = original_state_dir diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index d4f4b0f..21f4a40 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -77,6 +77,21 @@ @pytest.mark.anyio +async def test_chat_panel_clear_resets_conversation() -> None: + """ChatPanel.clear() removes all items and current assistant state.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + chat.handle_ws_event({"type": "stream_start"}) + chat.handle_ws_event({"type": "stream_delta", "delta": "hi"}) + await pilot.pause() + assert chat._model.items + chat.clear() + assert not chat._model.items + assert chat._model._current_assistant is None + + +@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: diff --git a/tests/clients/test_tui_export.py b/tests/clients/test_tui_export.py index 58d1316..6f9eac6 100644 --- a/tests/clients/test_tui_export.py +++ b/tests/clients/test_tui_export.py @@ -115,3 +115,28 @@ 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_export_editor_failure_shows_error( + tmp_state_dir: Path, fake_session: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + """If the editor cannot be launched, /export reports the error.""" + + def fake_open_in_editor(path: str) -> None: + raise OSError("no such editor") + + monkeypatch.setattr( + "clients.terminal.tui.commands.builtin._open_in_editor", fake_open_in_editor + ) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + input_box._input.value = "/export" + await pilot.press("enter") + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + assert any( + item.kind == "error" and "no such editor" in item.content for item in chat._model.items + ) diff --git a/tests/clients/test_tui_sessions_panel.py b/tests/clients/test_tui_sessions_panel.py index 3db0b82..110055d 100644 --- a/tests/clients/test_tui_sessions_panel.py +++ b/tests/clients/test_tui_sessions_panel.py @@ -72,3 +72,25 @@ table.action_select_cursor() await pilot.pause() assert pilot.app._ctx.session_id == "sess-bbbb-2222" + + +@pytest.mark.anyio +async def test_enter_key_selects_session() -> None: + """Pressing Enter on a focused sessions table switches the active session.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + panel = pilot.app.query_one("SessionsPanel") + sessions = [ + SessionInfo(id="sess-aaaa-1111", profile_id="navi_code", title="First", created_at=""), + SessionInfo(id="sess-bbbb-2222", profile_id="dev", title="Second", created_at=""), + ] + pilot.app.post_message(SessionListUpdated(sessions, "sess-aaaa-1111")) + await pilot.pause() + + table = panel._table + table.focus() + table.action_cursor_down() + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert pilot.app._ctx.session_id == "sess-bbbb-2222" diff --git a/tests/clients/test_tui_settings.py b/tests/clients/test_tui_settings.py index ce4032a..457bbfc 100644 --- a/tests/clients/test_tui_settings.py +++ b/tests/clients/test_tui_settings.py @@ -83,3 +83,32 @@ fresh = reload_tui_settings() assert fresh is not first assert fresh.theme == "custom-theme" + + +def test_settings_coerces_string_booleans(tmp_state_dir: Path) -> None: + data = {"mouse": "yes"} + (tmp_state_dir / "tui.json").write_text(json.dumps(data), encoding="utf-8") + loaded = TuiSettings().load() + assert loaded.mouse is True + + +def test_settings_coerces_numeric_scroll_speed(tmp_state_dir: Path) -> None: + data = {"scroll_speed": "5"} + (tmp_state_dir / "tui.json").write_text(json.dumps(data), encoding="utf-8") + loaded = TuiSettings().load() + assert loaded.scroll_speed == 5 + + +def test_settings_falls_back_on_bad_values(tmp_state_dir: Path) -> None: + data = { + "mouse": "maybe", + "scroll_speed": "fast", + "theme": "", + "diff_style": 123, + } + (tmp_state_dir / "tui.json").write_text(json.dumps(data), encoding="utf-8") + loaded = TuiSettings().load() + assert loaded.mouse is True + assert loaded.scroll_speed == 1 + assert loaded.theme == "gnexus-dark" + assert loaded.diff_style == "unified"