diff --git a/clients/terminal/tui/renderers/markdown_content.py b/clients/terminal/tui/renderers/markdown_content.py index 7c37fad..32f5af4 100644 --- a/clients/terminal/tui/renderers/markdown_content.py +++ b/clients/terminal/tui/renderers/markdown_content.py @@ -2,12 +2,49 @@ from __future__ import annotations -from rich.console import RenderableType +from rich.console import Console, RenderableType, ConsoleOptions, RenderResult from rich.markdown import Markdown +from rich.segment import Segment +from rich.style import Style +from rich.theme import Theme as RichTheme + +from clients.terminal.tui.themes import get_active_theme from .base import ContentRenderer +def _theme_aware_code_theme(theme_name: str) -> str: + """Pick a Pygments code theme that matches the Navi theme brightness.""" + return "dracula" if theme_name == "gnexus-dark" else "github-light" + + +class ThemedMarkdownRenderable: + """Wrap Markdown and render it with Navi theme colors applied.""" + + def __init__(self, markdown: Markdown, theme_name: str) -> None: + self._markdown = markdown + self._theme_name = theme_name + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + theme = get_active_theme() + themed_console = Console( + width=console.width, + color_system=console.color_system, + theme=RichTheme(theme.rich_theme_styles()), + force_terminal=True, + ) + segments = list(themed_console.render(self._markdown)) + link_color = Style.parse(theme.link.hex).color + for segment in segments: + style = segment.style + if style and style.link and link_color is not None: + segment = Segment( + segment.text, + Style(color=link_color, underline=True, link=style.link), + ) + yield segment + + class MarkdownRenderer(ContentRenderer): """Render markdown text with syntax highlighting.""" @@ -15,5 +52,12 @@ return msg.get("type") == "markdown" def render(self, msg: dict) -> RenderableType: + theme = get_active_theme() text = msg.get("content", "") - return Markdown(text, code_theme="monokai") + code_theme = _theme_aware_code_theme(theme.name) + md = Markdown( + text, + code_theme=code_theme, + inline_code_theme=code_theme, + ) + return ThemedMarkdownRenderable(md, theme.name) diff --git a/clients/terminal/tui/screens/__init__.py b/clients/terminal/tui/screens/__init__.py new file mode 100644 index 0000000..5c9e4f2 --- /dev/null +++ b/clients/terminal/tui/screens/__init__.py @@ -0,0 +1,5 @@ +"""Textual screens for the Navi Code TUI.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/clients/terminal/tui/screens/command_palette.py b/clients/terminal/tui/screens/command_palette.py new file mode 100644 index 0000000..163d95f --- /dev/null +++ b/clients/terminal/tui/screens/command_palette.py @@ -0,0 +1,204 @@ +"""Command palette 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.commands.base import BaseCommand, CommandMeta + + +class CommandPaletteScreen(ModalScreen[BaseCommand | None]): + """A centered command palette with fuzzy filtering. + + Dismisses with the selected command (or None if cancelled). The caller is + responsible for executing the selected command with the TUI context. + """ + + DEFAULT_CSS = """ + CommandPaletteScreen { + align: center middle; + } + CommandPaletteScreen > Container { + width: 70; + height: auto; + max-height: 24; + border: thick $tui-primary; + background: $tui-surface; + padding: 0 0 1 0; + } + CommandPaletteScreen .title { + text-style: bold; + color: $tui-primary; + background: $tui-panel; + padding: 1; + height: auto; + text-align: center; + } + CommandPaletteScreen Input { + height: auto; + border: none; + border-bottom: solid $tui-border; + background: $tui-background; + color: $tui-text; + padding: 0 1; + margin: 0; + } + CommandPaletteScreen ListView { + height: auto; + max-height: 16; + border: none; + background: $tui-surface; + padding: 0; + margin: 0; + } + CommandPaletteScreen ListItem { + color: $tui-text; + background: transparent; + height: auto; + padding: 0 1; + } + CommandPaletteScreen ListItem.--highlight { + background: $tui-selection; + color: $tui-background; + } + CommandPaletteScreen ListItem.--highlight .cmd-description { + color: $tui-background; + } + CommandPaletteScreen ListItem.--highlight .cmd-keybind { + color: $tui-background; + } + CommandPaletteScreen .cmd-line { + height: auto; + width: 100%; + } + CommandPaletteScreen .cmd-name { + text-style: bold; + width: auto; + } + CommandPaletteScreen .cmd-description { + color: $tui-text-muted; + } + CommandPaletteScreen .cmd-keybind { + color: $tui-accent; + text-align: right; + dock: right; + width: auto; + } + CommandPaletteScreen .empty { + color: $tui-text-dim; + text-align: center; + padding: 1; + } + """ + + BINDINGS = [ + ("escape", "dismiss_cancel", "Cancel"), + ("ctrl+p", "dismiss_cancel", "Cancel"), + ] + + def __init__(self, commands: list[BaseCommand]) -> None: + super().__init__() + self._commands = commands + self._filtered = list(commands) + self._list_items: list[ListItem] = [] + + def compose(self) -> ComposeResult: + with Container(): + yield Static("Command palette", classes="title") + yield Input(placeholder="Type to filter commands...", id="palette-input") + yield ListView(id="palette-list") + + def on_mount(self) -> None: + self._render_list() + self.query_one("#palette-input", Input).focus() + + def _render_list(self) -> None: + list_view = self.query_one("#palette-list", ListView) + list_view.clear() + self._list_items = [] + if not self._filtered: + list_view.append(ListItem(Static("No matching commands", classes="empty"))) + return + for index, cmd in enumerate(self._filtered): + item = self._build_item(index, cmd.meta) + self._list_items.append(item) + list_view.append(item) + + def _build_item(self, index: int, meta: CommandMeta) -> ListItem: + keybind_text = f" {meta.keybind}" if meta.keybind else "" + aliases_text = f" ({', '.join(meta.aliases)})" if meta.aliases else "" + name_line = f"/{meta.name}{aliases_text}" + line = Label(f"{name_line}{keybind_text}", classes="cmd-line") + line.renderable = self._render_rich_line(name_line, meta.description, keybind_text) + return ListItem(line, id=f"palette-item-{index}-{meta.name}") + + def _render_rich_line(self, name: str, description: str, keybind: str): + from rich.text import Text + + parts: list[Text] = [] + parts.append(Text(name, style="bold")) + if keybind: + parts.append(Text(keybind, style="dim")) + parts.append(Text(f" — {description}", style="dim")) + return Text.assemble(*parts) + + def _filter(self, query: str) -> None: + query = query.strip().lower() + if not query: + self._filtered = list(self._commands) + else: + self._filtered = [ + cmd + for cmd in self._commands + if self._matches(cmd, query) + ] + self._render_list() + + def _matches(self, cmd: BaseCommand, query: str) -> bool: + meta = cmd.meta + haystack = " ".join( + [meta.name, " ".join(meta.aliases), meta.description, meta.keybind or ""] + ).lower() + return all(part in haystack for part in query.split()) + + def on_input_changed(self, event: Input.Changed) -> None: + self._filter(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + if self._filtered: + self.dismiss(self._filtered[0]) + + 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_key(self, event: events.Key) -> None: + list_view = self.query_one("#palette-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"): + highlighted = list_view.index + if 0 <= highlighted < len(self._filtered): + self.dismiss(self._filtered[highlighted]) + elif self._filtered: + self.dismiss(self._filtered[0]) + event.stop() + event.prevent_default() + elif event.key in ("escape", "ctrl+p"): + self.dismiss(None) + event.stop() + event.prevent_default() + + def action_dismiss_cancel(self) -> None: + self.dismiss(None) diff --git a/clients/terminal/tui/themes.py b/clients/terminal/tui/themes.py index 706c7e4..d823a45 100644 --- a/clients/terminal/tui/themes.py +++ b/clients/terminal/tui/themes.py @@ -45,6 +45,20 @@ selection: Color link: Color + def rich_theme_styles(self) -> dict[str, str]: + """Return Rich console theme entries for markdown rendering.""" + return { + "markdown.h1": f"bold {self.accent.hex}", + "markdown.h2": f"bold {self.accent.hex}", + "markdown.h3": f"bold {self.primary.hex}", + "markdown.h4": f"bold {self.primary.hex}", + "markdown.h5": f"{self.primary.hex}", + "markdown.h6": f"{self.text_muted.hex}", + "markdown.code": f"{self.accent.hex} on {self.surface.hex}", + "markdown.link": f"{self.link.hex} underline", + "markdown.block_quote": f"{self.text_muted.hex} italic", + } + def css(self) -> dict[str, str]: """Return CSS variable dictionary for Textual.""" return { diff --git a/clients/terminal/tui/tui_app.py b/clients/terminal/tui/tui_app.py index 568b0cf..90b6188 100644 --- a/clients/terminal/tui/tui_app.py +++ b/clients/terminal/tui/tui_app.py @@ -9,7 +9,6 @@ from clients.terminal import api from clients.terminal.config import settings from clients.terminal.state import StateManager -from clients.terminal.tui.commands.registry import get_registry from clients.terminal.tui.context import TuiContext from clients.terminal.tui.events import ( ConnectionStatusChanged, @@ -19,6 +18,8 @@ ) from clients.terminal.tui.permissions import PermissionEngine 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 from clients.terminal.tui.widgets import ChatPanel, InputBox, StatusPanel from clients.terminal.tui.ws_bridge import WsBridge @@ -188,8 +189,13 @@ ) def action_command_palette(self) -> None: - # Phase 4: implement command palette screen. - self._chat_panel.handle_ws_event({"type": "status", "content": "Command palette: /help, /new, /sessions, /switch, /profile, /thinking, /compact, /quit"}) + registry = get_registry() + + def on_select(cmd) -> None: + if cmd is not None: + self.run_worker(self._command_worker(cmd, "")) + + self.push_screen(CommandPaletteScreen(registry.all()), callback=on_select) def action_new_session(self) -> None: self._run_command("/new")