diff --git a/clients/terminal/tui/commands/builtin.py b/clients/terminal/tui/commands/builtin.py index 2c37634..591920b 100644 --- a/clients/terminal/tui/commands/builtin.py +++ b/clients/terminal/tui/commands/builtin.py @@ -2,10 +2,16 @@ from __future__ import annotations +import datetime +import os +import subprocess +import sys + from clients.terminal import api 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.events import SessionInfo, SessionListUpdated from clients.terminal.tui.settings import get_tui_settings @@ -41,14 +47,19 @@ try: session = api.create_session(settings.default_profile_id) except Exception as exc: - ctx.chat_panel.handle_ws_event({"type": "error", "message": f"Failed to create session: {exc}"}) + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to create session: {exc}"} + ) return - ctx.session_id = session["id"] + ctx.session_id = session["session_id"] ctx.profile_id = session.get("profile_id") - ctx.state.set_session_id(session["id"]) - ctx.status_panel.set_session(session["id"]) + ctx.state.set_session_id(session["session_id"]) + ctx.status_panel.set_session(session["session_id"]) ctx.status_panel.set_profile(ctx.profile_id or settings.default_profile_id) - ctx.chat_panel.handle_ws_event({"type": "status", "content": f"Created session {session['id'][:8]}"}) + ctx.chat_panel.handle_ws_event( + {"type": "status", "content": f"Created session {session['session_id'][:8]}"} + ) + await _broadcast_session_list(ctx) await _reconnect_ws(ctx) @@ -64,13 +75,18 @@ try: sessions = api.list_sessions() except Exception as exc: - ctx.chat_panel.handle_ws_event({"type": "error", "message": f"Failed to list sessions: {exc}"}) + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to list sessions: {exc}"} + ) return lines = ["[b]Sessions[/b]"] for s in sessions: - marker = "● " if s["id"] == ctx.session_id else " " - lines.append(f"{marker}{s['id'][:8]} {s.get('profile_id', 'unknown')} {s.get('title', '')}") + sid = s.get("session_id", "") + marker = "● " if sid == ctx.session_id else " " + title = s.get("name", "") or s.get("preview", "") + lines.append(f"{marker}{sid[:8]} {s.get('profile_id', 'unknown')} {title}") ctx.chat_panel.handle_ws_event({"type": "status", "content": "\n".join(lines)}) + await _broadcast_session_list(ctx) class SwitchCommand(BaseCommand): @@ -84,27 +100,34 @@ async def execute(self, ctx: TuiContext, args: str) -> None: target = args.strip() if not target: - ctx.chat_panel.handle_ws_event({"type": "error", "message": "Usage: /switch "}) + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": "Usage: /switch "} + ) return try: session = api.get_session(target) except Exception: try: sessions = api.list_sessions() - matches = [s for s in sessions if s["id"].startswith(target)] + matches = [s for s in sessions if s.get("session_id", "").startswith(target)] if len(matches) == 1: session = matches[0] else: raise Exception("no unique match") except Exception as exc: - ctx.chat_panel.handle_ws_event({"type": "error", "message": f"Session not found: {target} ({exc})"}) + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Session not found: {target} ({exc})"} + ) return - ctx.session_id = session["id"] + ctx.session_id = session["session_id"] ctx.profile_id = session.get("profile_id") - ctx.state.set_session_id(session["id"]) - ctx.status_panel.set_session(session["id"]) + ctx.state.set_session_id(session["session_id"]) + ctx.status_panel.set_session(session["session_id"]) ctx.status_panel.set_profile(ctx.profile_id or settings.default_profile_id) - ctx.chat_panel.handle_ws_event({"type": "status", "content": f"Switched to {session['id'][:8]}"}) + ctx.chat_panel.handle_ws_event( + {"type": "status", "content": f"Switched to {session['session_id'][:8]}"} + ) + await _broadcast_session_list(ctx) await _reconnect_ws(ctx) @@ -123,10 +146,15 @@ try: session = api.get_session(ctx.session_id) except Exception as exc: - ctx.chat_panel.handle_ws_event({"type": "error", "message": f"Failed to get session: {exc}"}) + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to get session: {exc}"} + ) return ctx.chat_panel.handle_ws_event( - {"type": "status", "content": f"Profile: {session.get('profile_id')}\nSession: {session['id']}"} + { + "type": "status", + "content": f"Profile: {session.get('profile_id')}\nSession: {session.get('session_id')}", + } ) @@ -154,7 +182,10 @@ async def execute(self, ctx: TuiContext, args: str) -> None: settings.show_thinking = not settings.show_thinking ctx.chat_panel.handle_ws_event( - {"type": "status", "content": f"Thinking blocks: {'on' if settings.show_thinking else 'off'}"} + { + "type": "status", + "content": f"Thinking blocks: {'on' if settings.show_thinking else 'off'}", + } ) @@ -185,7 +216,9 @@ 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"}) + ctx.chat_panel.handle_ws_event( + {"type": "status", "content": "Theme selection cancelled"} + ) return app = ctx.app() app._theme_name = theme_name @@ -193,7 +226,9 @@ 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}"}) + ctx.chat_panel.handle_ws_event( + {"type": "status", "content": f"Theme set to {theme_name}"} + ) from clients.terminal.tui.screens.theme_picker import ThemePickerScreen @@ -226,6 +261,103 @@ ) +class ExportCommand(BaseCommand): + meta = CommandMeta( + name="export", + aliases=("save",), + description="Export current session to markdown and open $EDITOR.", + keybind=None, + ) + + async def execute(self, ctx: TuiContext, args: str) -> None: + if not ctx.session_id: + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": "No active session to export"} + ) + return + try: + session = api.get_session(ctx.session_id) + except Exception as exc: + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to get session: {exc}"} + ) + return + + short_id = ctx.session_id[:8] + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{short_id}_{timestamp}.md" + exports_dir = ctx.state.state_dir / "exports" + exports_dir.mkdir(parents=True, exist_ok=True) + file_path = exports_dir / filename + + try: + file_path.write_text(_render_export_markdown(session), encoding="utf-8") + except OSError as exc: + ctx.chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to write export: {exc}"} + ) + return + + _open_in_editor(str(file_path)) + ctx.chat_panel.handle_ws_event({"type": "status", "content": f"Exported to {file_path}"}) + + +def _render_export_markdown(session: dict) -> str: + lines: list[str] = [] + session_id = session.get("session_id", "unknown") + profile_id = session.get("profile_id", "unknown") + created = session.get("created_at", "") + lines.append(f"# Navi Code Export — {session_id[:8]}") + lines.append("") + lines.append(f"- **Profile:** {profile_id}") + lines.append(f"- **Session:** {session_id}") + if created: + lines.append(f"- **Created:** {created}") + lines.append("") + + messages = session.get("messages", []) + for msg in messages: + role = msg.get("role", "unknown") + content = msg.get("content", "") + if not content: + continue + heading = role.capitalize() + lines.append(f"## {heading}") + lines.append("") + lines.append(content) + lines.append("") + + return "\n".join(lines) + + +def _open_in_editor(path: str) -> None: + editor = os.environ.get("EDITOR") + 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) + + +async def _broadcast_session_list(ctx: TuiContext) -> None: + """Refresh the sessions panel with the latest server state.""" + if not ctx.app: + return + try: + sessions = api.list_sessions() + except Exception: + return + info_list = [ + SessionInfo( + id=s.get("session_id", ""), + profile_id=s.get("profile_id", "unknown"), + title=s.get("name", "") or s.get("preview", ""), + created_at=s.get("created_at", ""), + ) + 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: diff --git a/clients/terminal/tui/commands/registry.py b/clients/terminal/tui/commands/registry.py index 248e821..9357d2e 100644 --- a/clients/terminal/tui/commands/registry.py +++ b/clients/terminal/tui/commands/registry.py @@ -58,4 +58,5 @@ registry.register(builtin.CompactCommand()) registry.register(builtin.ThemesCommand()) registry.register(builtin.MouseCommand()) + registry.register(builtin.ExportCommand()) return registry diff --git a/clients/terminal/tui/context.py b/clients/terminal/tui/context.py index a625abc..418c597 100644 --- a/clients/terminal/tui/context.py +++ b/clients/terminal/tui/context.py @@ -10,6 +10,7 @@ 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.sessions_panel import SessionsPanel from clients.terminal.tui.widgets.status_panel import StatusPanel from clients.terminal.ws_client import NaviWebSocketClient @@ -25,6 +26,7 @@ settings: "TuiSettings | None" = None chat_panel: "ChatPanel | None" = None status_panel: "StatusPanel | None" = None + sessions_panel: "SessionsPanel | None" = None chat_model: "ChatModel | None" = None def app(self): diff --git a/clients/terminal/tui/events.py b/clients/terminal/tui/events.py index b3c72c2..012aaba 100644 --- a/clients/terminal/tui/events.py +++ b/clients/terminal/tui/events.py @@ -66,3 +66,11 @@ self.sessions = sessions self.current_id = current_id super().__init__() + + +class SessionSelected(Message): + """User selected a session from the sessions panel.""" + + def __init__(self, session_id: str) -> None: + self.session_id = session_id + super().__init__() diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 795fde0..c19f7fa 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -3,7 +3,7 @@ from __future__ import annotations from textual.app import App, ComposeResult -from textual.containers import Horizontal +from textual.containers import Horizontal, Vertical from textual.widgets import Footer, Header from clients.terminal import api @@ -13,6 +13,9 @@ from clients.terminal.tui.events import ( ConnectionStatusChanged, PermissionRequest, + SessionInfo, + SessionListUpdated, + SessionSelected, UserSubmitted, WsEvent, ) @@ -24,7 +27,7 @@ from clients.terminal.tui.commands.registry import get_registry from clients.terminal.tui.screens.command_palette import CommandPaletteScreen from clients.terminal.tui.screens.permission_dialog import PermissionDialogScreen -from clients.terminal.tui.widgets import ChatPanel, InputBox, StatusPanel +from clients.terminal.tui.widgets import ChatPanel, InputBox, SessionsPanel, StatusPanel from clients.terminal.tui.ws_bridge import WsBridge @@ -56,12 +59,14 @@ set_active_theme(self._theme_name) self._chat_panel = ChatPanel() self._status_panel = StatusPanel() + self._sessions_panel = SessionsPanel() self._input_box = InputBox() self._state = StateManager() self._ctx = TuiContext( state=self._state, chat_panel=self._chat_panel, status_panel=self._status_panel, + sessions_panel=self._sessions_panel, settings=self._tui_settings, ) self._bridge: WsBridge | None = None @@ -75,7 +80,9 @@ yield Header(show_clock=False) with Horizontal(): yield self._chat_panel - yield self._status_panel + with Vertical(): + yield self._status_panel + yield self._sessions_panel yield self._input_box yield Footer() @@ -131,7 +138,9 @@ try: session = api.create_session(profile) except Exception as exc: - self._chat_panel.handle_ws_event({"type": "error", "message": f"Failed to create session: {exc}"}) + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to create session: {exc}"} + ) return None self._state.set_session_id(session["session_id"]) return session["session_id"] @@ -146,7 +155,11 @@ 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_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) @@ -155,7 +168,44 @@ self._bridge = WsBridge(self, session_id) await self._bridge.start() self._ctx.ws_client = self._bridge.client - self._chat_panel.handle_ws_event({"type": "status", "content": f"Connected to {session_id[:8]}"}) + self._chat_panel.handle_ws_event( + {"type": "status", "content": f"Connected to {session_id[:8]}"} + ) + self.run_worker(self._refresh_sessions(session_id)) + + async def _refresh_sessions(self, current_session_id: str | None = None) -> None: + current = current_session_id or self._ctx.session_id + try: + raw_sessions = api.list_sessions() + except Exception: + return + sessions = [self._session_info_from_api(item) for item in raw_sessions] + self.post_message(SessionListUpdated(sessions, current)) + + @staticmethod + def _session_info_from_api(item: dict) -> SessionInfo: + sid = item.get("session_id", "") + return SessionInfo( + id=sid, + profile_id=item.get("profile_id", "unknown"), + title=item.get("name", "") or item.get("preview", ""), + created_at=item.get("created_at", ""), + ) + + def on_session_selected(self, event: SessionSelected) -> None: + self.run_worker(self._switch_session(event.session_id)) + + 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() + self._chat_panel.handle_ws_event( + {"type": "status", "content": f"Switched to {session_id[:8]}"} + ) + + def on_session_list_updated(self, event: SessionListUpdated) -> None: + self._sessions_panel.on_session_list_updated(event) def on_user_submitted(self, event: UserSubmitted) -> None: text = event.text @@ -170,7 +220,10 @@ resolved = FileRefResolver().resolve(text) self._chat_panel.add_user_message(resolved.prompt) if resolved.attachments: - names = ", ".join(a.display_path + (" (truncated)" if a.truncated else "") for a in resolved.attachments) + names = ", ".join( + a.display_path + (" (truncated)" if a.truncated else "") + for a in resolved.attachments + ) self._chat_panel.handle_ws_event({"type": "status", "content": f"Attached: {names}"}) for err in resolved.errors: self._chat_panel.handle_ws_event({"type": "error", "message": err}) @@ -178,13 +231,17 @@ if self._bridge and self._bridge.connected: self._bridge.client.enqueue(resolved.to_message()) else: - self._chat_panel.handle_ws_event({"type": "error", "message": "Not connected to a session"}) + self._chat_panel.handle_ws_event( + {"type": "error", "message": "Not connected to a session"} + ) def _run_shell_command(self, text: str) -> None: command = text[1:].strip() args = {"action": "run", "command": command} if self._permission_engine.is_always_deny("shell", args): - self._chat_panel.handle_ws_event({"type": "error", "message": f"Shell command denied by policy: {command}"}) + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Shell command denied by policy: {command}"} + ) return if self._permission_engine.check("shell", args) is None: self.run_worker(self._shell_worker(text)) @@ -198,15 +255,25 @@ if choice == "allow_once": self.run_worker(self._shell_worker(text)) elif choice == "allow_always": - self._permission_engine.set_always_allow("shell", {"action": "run", "command": command}) + self._permission_engine.set_always_allow( + "shell", {"action": "run", "command": command} + ) self.run_worker(self._shell_worker(text)) elif choice == "deny_once": - self._chat_panel.handle_ws_event({"type": "error", "message": f"Shell command cancelled: {command}"}) + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Shell command cancelled: {command}"} + ) elif choice == "deny_always": - self._permission_engine.set_always_deny("shell", {"action": "run", "command": command}) - self._chat_panel.handle_ws_event({"type": "error", "message": f"Shell command cancelled: {command}"}) + self._permission_engine.set_always_deny( + "shell", {"action": "run", "command": command} + ) + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Shell command cancelled: {command}"} + ) else: - self._chat_panel.handle_ws_event({"type": "error", "message": f"Shell command cancelled: {command}"}) + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Shell command cancelled: {command}"} + ) self.push_screen( PermissionDialogScreen( @@ -229,7 +296,9 @@ registry = get_registry() cmd = registry.get(name) if cmd is None: - self._chat_panel.handle_ws_event({"type": "error", "message": f"Unknown command: /{name}"}) + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Unknown command: /{name}"} + ) return self.run_worker(self._command_worker(cmd, args)) @@ -301,7 +370,9 @@ try: api.stop_session(session_id) except Exception as exc: - self._chat_panel.handle_ws_event({"type": "error", "message": f"Failed to stop session: {exc}"}) + self._chat_panel.handle_ws_event( + {"type": "error", "message": f"Failed to stop session: {exc}"} + ) if self._bridge: await self._bridge.stop() self._status_panel.set_connection(False, "permission denied") diff --git a/clients/terminal/tui/widgets/__init__.py b/clients/terminal/tui/widgets/__init__.py index 9e46815..6b9fc96 100644 --- a/clients/terminal/tui/widgets/__init__.py +++ b/clients/terminal/tui/widgets/__init__.py @@ -4,6 +4,7 @@ from .chat_panel import ChatPanel from .input_box import InputBox +from .sessions_panel import SessionsPanel from .status_panel import StatusPanel -__all__ = ["ChatPanel", "InputBox", "StatusPanel"] +__all__ = ["ChatPanel", "InputBox", "SessionsPanel", "StatusPanel"] diff --git a/clients/terminal/tui/widgets/sessions_panel.py b/clients/terminal/tui/widgets/sessions_panel.py new file mode 100644 index 0000000..73819e8 --- /dev/null +++ b/clients/terminal/tui/widgets/sessions_panel.py @@ -0,0 +1,126 @@ +"""Sessions panel widget for the TUI.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Vertical +from textual.widgets import DataTable, Static +from textual.widgets.data_table import RowKey + +from clients.terminal.tui.events import SessionListUpdated, SessionSelected, SessionInfo + + +class SessionsPanel(Vertical): + """Right-side panel showing server sessions and allowing quick switching.""" + + DEFAULT_CSS = """ + SessionsPanel { + border: solid $tui-panel; + background: $tui-panel; + color: $tui-text-muted; + padding: 0; + height: 1fr; + width: 1fr; + } + SessionsPanel .title { + text-style: bold; + color: $tui-primary; + padding: 1 1 0 1; + height: auto; + } + SessionsPanel DataTable { + height: 1fr; + border: none; + background: $tui-panel; + color: $tui-text-muted; + } + SessionsPanel DataTable > .datatable--header { + color: $tui-text-dim; + text-style: bold; + background: $tui-surface; + } + SessionsPanel DataTable > .datatable--row { + background: $tui-panel; + } + SessionsPanel DataTable > .datatable--row-sessions-panel-cursor { + background: $tui-selection; + color: $tui-background; + } + SessionsPanel DataTable > .datatable--row-sessions-panel-cursor .datatable--cursor { + color: $tui-background; + } + SessionsPanel .empty { + color: $tui-text-dim; + text-align: center; + padding: 1; + } + """ + + def __init__(self) -> None: + super().__init__() + self._title = Static("Sessions", classes="title") + self._table: DataTable | None = None + self._sessions: list[SessionInfo] = [] + self._current_id: str | None = None + + def compose(self) -> ComposeResult: + yield self._title + yield DataTable(id="sessions-table") + + def on_mount(self) -> None: + self._table = self.query_one("#sessions-table", DataTable) + self._table.cursor_type = "row" + self._table.show_header = True + self._table.zebra_stripes = True + self._table.add_columns("", "ID", "Profile", "Preview") + self._populate_table() + + def on_session_list_updated(self, event: SessionListUpdated) -> None: + self._sessions = event.sessions + self._current_id = event.current_id + self._populate_table() + + def _populate_table(self) -> None: + if self._table is None: + return + self._table.clear() + if not self._sessions: + self._table.add_row("", "—", "", "No sessions") + return + + for session in self._sessions: + marker = "●" if session.id == self._current_id else "" + short_id = "-".join(session.id.split("-")[:2]) + profile = self._truncate(session.profile_id, 12) + preview = self._truncate(session.title, 24) + self._table.add_row(marker, short_id, profile, preview, key=session.id) + + if self._current_id: + try: + self._table.move_cursor(row=RowKey(self._current_id)) + except Exception: + pass + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + session_id = str(event.row_key.value) if event.row_key else "" + 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: + return text + return text[: max_len - 1] + "…" diff --git a/docs/navi_code_cli.md b/docs/navi_code_cli.md index 7a37627..2aec702 100644 --- a/docs/navi_code_cli.md +++ b/docs/navi_code_cli.md @@ -54,9 +54,19 @@ | `/sessions` | Список сессий на сервере. | | `/switch ` | Переключиться на другую сессию (можно по префиксу id). | | `/profile` | Показать текущий профиль и id сессии. | +| `/export [path]` | Экспортировать текущую сессию в Markdown; без пути — во временный файл и `$EDITOR`. | | `/clear` | Очистить локально сохранённый `session_id`. | | `/quit` | Выйти. | +## Интерфейс TUI + +В интерактивном режиме (`navi-code`) экран разделён на две части: + +- **Левая панель (`ChatPanel`)** — история сообщений, поле ввода и текущий статус. +- **Правая панель (`SessionsPanel`)** — список сессий на сервере с колонками ID, профиль и превью. Клик или `Enter` на строке переключает сессию. + +Список сессий обновляется автоматически при запуске и при выполнении `/new`, `/sessions`, `/switch`. + ## Состояние Клиент сохраняет `session_id` в `~/.navi_code/state.json`, чтобы восстановить диалог при следующем запуске. Удалите файл или используйте `/clear`, чтобы начать с чистого листа. @@ -94,5 +104,8 @@ - `render.py` — рендеринг событий в терминал. - `state.py` — сохранение `session_id` в `~/.navi_code/state.json`. - `config.py` — настройки из переменных окружения `NAVI_CODE_*`. +- `tui/tui_app.py` — Textual-приложение. +- `tui/widgets/` — виджеты (`ChatPanel`, `StatusPanel`, `SessionsPanel`). +- `tui/commands/` — slash-команды (`/new`, `/sessions`, `/switch`, `/export`). -Тесты: `tests/clients/test_terminal_client.py` и `tests/clients/test_terminal_ws.py`. +Тесты: `tests/clients/test_terminal_client.py`, `tests/clients/test_terminal_ws.py`, `tests/clients/test_tui_*.py`. diff --git a/tests/clients/test_tui_app.py b/tests/clients/test_tui_app.py index 1dbfb6a..d4f4b0f 100644 --- a/tests/clients/test_tui_app.py +++ b/tests/clients/test_tui_app.py @@ -31,6 +31,7 @@ await pilot.pause() assert pilot.app.query_one("ChatPanel") is not None assert pilot.app.query_one("StatusPanel") is not None + assert pilot.app.query_one("SessionsPanel") is not None assert pilot.app.query_one("InputBox") is not None @@ -44,7 +45,9 @@ await pilot.press("enter") await pilot.pause() chat = pilot.app.query_one("ChatPanel") - assert any(item.kind == "user_message" and item.content == "hello" for item in chat._model.items) + assert any( + item.kind == "user_message" and item.content == "hello" for item in chat._model.items + ) @pytest.mark.anyio diff --git a/tests/clients/test_tui_export.py b/tests/clients/test_tui_export.py new file mode 100644 index 0000000..58d1316 --- /dev/null +++ b/tests/clients/test_tui_export.py @@ -0,0 +1,117 @@ +"""Tests for the /export slash 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.fixture +def fake_session(monkeypatch: pytest.MonkeyPatch) -> dict: + """Return a fake session payload used to test export.""" + session_id = "sess-export-1234" + + def fake_get_session(sid: str) -> dict: + if sid != session_id: + raise Exception("not found") + return { + "session_id": session_id, + "profile_id": "navi_code", + "created_at": "2026-06-23T12:00:00", + "messages": [ + {"role": "user", "content": "Hello Navi"}, + {"role": "assistant", "content": "Hello!"}, + ], + } + + def fake_create_session(profile_id: str | None = None) -> dict: + return { + "session_id": session_id, + "profile_id": profile_id or "navi_code", + } + + def fake_list_sessions() -> list[dict]: + return [fake_get_session(session_id)] + + monkeypatch.setattr("clients.terminal.api.get_session", fake_get_session) + monkeypatch.setattr("clients.terminal.api.create_session", fake_create_session) + monkeypatch.setattr("clients.terminal.api.list_sessions", fake_list_sessions) + + return fake_get_session(session_id) + + +@pytest.mark.anyio +async def test_export_command_writes_markdown( + tmp_state_dir: Path, fake_session: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + """Running /export writes a markdown file and opens the editor.""" + opened_files: list[str] = [] + + def fake_open_in_editor(path: str) -> None: + opened_files.append(path) + + 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() + + export_dir = tmp_state_dir / "exports" + assert export_dir.exists() + files = list(export_dir.glob("*.md")) + assert len(files) == 1 + content = files[0].read_text(encoding="utf-8") + assert "# Navi Code Export" in content + assert "Hello Navi" in content + assert "Hello!" in content + assert "navi_code" in content + assert opened_files == [str(files[0])] + + +@pytest.mark.anyio +async def test_export_without_session_shows_error( + tmp_state_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Running /export when there is no active session shows an error.""" + + def fake_create_session(profile_id: str | None = None) -> dict: + return {"session_id": "", "profile_id": profile_id or "navi_code"} + + def fake_get_session(sid: str) -> dict: + raise Exception("not found") + + monkeypatch.setattr("clients.terminal.api.create_session", fake_create_session) + monkeypatch.setattr("clients.terminal.api.get_session", fake_get_session) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + pilot.app._ctx.session_id = None + 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" for item in chat._model.items) diff --git a/tests/clients/test_tui_sessions_panel.py b/tests/clients/test_tui_sessions_panel.py new file mode 100644 index 0000000..3db0b82 --- /dev/null +++ b/tests/clients/test_tui_sessions_panel.py @@ -0,0 +1,74 @@ +"""Tests for the SessionsPanel widget and session switching.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from clients.terminal.tui.events import SessionInfo, SessionListUpdated +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_sessions_panel_mounts() -> None: + """The TUI layout includes a SessionsPanel on the right.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + assert pilot.app.query_one("SessionsPanel") is not None + + +@pytest.mark.anyio +async def test_session_list_updated_populates_table() -> None: + """Posting SessionListUpdated fills the sessions table.""" + 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 + assert table is not None + assert table.row_count == 2 + row = table.get_row_at(0) + assert "sess-aaaa" in str(row) + assert "First" in str(row) + + +@pytest.mark.anyio +async def test_data_table_row_selects_session() -> None: + """Selecting a row in the sessions panel 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.action_cursor_down() + await pilot.pause() + table.action_select_cursor() + await pilot.pause() + assert pilot.app._ctx.session_id == "sess-bbbb-2222"