From 9732f8f2696a4036aae8d1a28fd42580520a715f Mon Sep 17 00:00:00 2001 From: Chukuwebuka-2003 Date: Thu, 9 Jul 2026 13:40:58 +0100 Subject: [PATCH] feat(tui): command palette, diff viewer modals with dedicated patch slot - Add CommandPaletteScreen modal with search filtering over all registered SlashCommand objects, replacing the old /-insertion approach - Add DiffViewerScreen modal with syntax-highlighted diff rendering via render_diff_content() - Add last_diff slot to TuiState that stores the full patch at record time via _result_patch, replacing heuristic text-scanning - Extract render_diff_content() from _render_patch_body for reuse - Swap defaults: command_palette to ctrl+p, model_cycle to ctrl+k - Add diff_viewer keybinding (ctrl+l) to TuiKeybindings with serialization in to_json() --- src/tau_coding/commands.py | 5 +- src/tau_coding/tui/__init__.py | 4 + src/tau_coding/tui/app.py | 303 ++++++++++++++++++++++++++++++++- src/tau_coding/tui/config.py | 6 +- src/tau_coding/tui/state.py | 4 + src/tau_coding/tui/widgets.py | 17 ++ tests/test_tui_app.py | 31 ++-- 7 files changed, 344 insertions(+), 26 deletions(-) diff --git a/src/tau_coding/commands.py b/src/tau_coding/commands.py index 59397ec84..4851b1465 100644 --- a/src/tau_coding/commands.py +++ b/src/tau_coding/commands.py @@ -318,7 +318,7 @@ def create_default_command_registry() -> CommandRegistry: SlashCommand( name="scoped-models", usage="/scoped-models", - description="Choose models available to quick-cycle with Ctrl+P.", + description="Choose models available to quick-cycle with Ctrl+K.", handler=_scoped_models_command, search_terms=("scope", "quick", "cycle", "ctrl+p"), ) @@ -430,7 +430,8 @@ def _hotkeys_command(context: CommandContext) -> CommandResult: "- Shift+Enter: insert newline", "- Alt+Enter: queue follow-up while running", "- Esc: cancel active run", - "- Ctrl+K: open slash-command completions", + "- Ctrl+P: open command palette", + "- Ctrl+K: cycle scoped model", "- Ctrl+R: open session picker", "- Shift+Tab: cycle thinking mode", "- Ctrl+T: toggle thinking tokens", diff --git a/src/tau_coding/tui/__init__.py b/src/tau_coding/tui/__init__.py index d37e23b56..201e63f94 100644 --- a/src/tau_coding/tui/__init__.py +++ b/src/tau_coding/tui/__init__.py @@ -30,6 +30,8 @@ TranscriptView, render_chat_item, render_compact_session_info, + render_completion_suggestions, + render_diff_content, render_session_sidebar, transcript_item_selection_text, ) @@ -59,6 +61,8 @@ "load_tui_settings", "render_chat_item", "render_compact_session_info", + "render_completion_suggestions", + "render_diff_content", "render_session_sidebar", "run_tui_app", "save_tui_settings", diff --git a/src/tau_coding/tui/app.py b/src/tau_coding/tui/app.py index 4ba902c63..dd1bf567f 100644 --- a/src/tau_coding/tui/app.py +++ b/src/tau_coding/tui/app.py @@ -31,6 +31,7 @@ Label, ListItem, ListView, + RichLog, Static, TextArea, ) @@ -56,7 +57,7 @@ from tau_ai import ProviderErrorEvent, ProviderEvent from tau_ai.provider import CancellationToken from tau_coding.catalog_loader import save_user_catalog_entries -from tau_coding.commands import CommandRegistry, create_default_command_registry +from tau_coding.commands import CommandRegistry, SlashCommand, create_default_command_registry from tau_coding.credentials import FileCredentialStore, OAuthCredential from tau_coding.oauth import OAuthAuthInfo, OAuthPrompt, login_openai_codex from tau_coding.provider_catalog import ( @@ -114,6 +115,7 @@ SessionSidebar, TranscriptView, render_completion_suggestions, + render_diff_content, ) type BindingEntry = Binding | tuple[str, str] | tuple[str, str, str] @@ -838,6 +840,195 @@ def action_scroll_down(self) -> None: self.query_one("#command-output-scroll", CommandOutputScroll).action_scroll_down() +class CommandPaletteScreen(ModalScreen[str | None]): + """Searchable command palette showing all registered slash commands.""" + + BINDINGS: ClassVar[list[BindingEntry]] = [ + Binding("escape", "cancel", "Cancel"), + Binding("up", "cursor_up", "Up", show=False), + Binding("down", "cursor_down", "Down", show=False), + Binding("enter", "select_cursor", "Run", show=False), + Binding("tab", "select_cursor", "Run", show=False), + ] + + def __init__( + self, + commands: Sequence[SlashCommand], + *, + theme: TuiTheme, + ) -> None: + super().__init__() + self.all_commands = tuple(commands) + self.theme = theme + self.search_value = "" + + def compose(self) -> ComposeResult: + """Compose the command palette.""" + with Vertical(id="command-palette"): + yield Static("Command Palette", id="command-palette-title") + yield Input(placeholder="Search commands…", id="command-palette-search") + yield ListView(id="command-palette-list") + yield Static("Enter runs command - Escape closes", id="command-palette-help") + + def on_mount(self) -> None: + """Focus the search field.""" + self.query_one("#command-palette-search", Input).focus() + self._refresh_command_list() + + def on_input_changed(self, event: Input.Changed) -> None: + """Filter commands as the search value changes.""" + if event.input.id != "command-palette-search": + return + event.stop() + self.search_value = event.value + self._refresh_command_list() + + def on_input_submitted(self, event: Input.Submitted) -> None: + """Select the highlighted command from the search field.""" + if event.input.id != "command-palette-search": + return + event.stop() + self._select_visible_choice() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + """Handle the selected row.""" + event.stop() + self._select_visible_choice() + + def on_key(self, event: Key) -> None: + """Route palette keys to the list.""" + if event.key == "up": + event.stop() + self.action_cursor_up() + elif event.key == "down": + event.stop() + self.action_cursor_down() + elif event.key in {"enter", "tab"}: + event.stop() + self.action_select_cursor() + + def action_cursor_up(self) -> None: + """Move to the previous command.""" + self.query_one("#command-palette-list", ListView).action_cursor_up() + + def action_cursor_down(self) -> None: + """Move to the next command.""" + self.query_one("#command-palette-list", ListView).action_cursor_down() + + def action_select_cursor(self) -> None: + """Select the highlighted command and dismiss.""" + self._select_visible_choice() + + def action_cancel(self) -> None: + """Close without selecting a command.""" + self.dismiss(None) + + def _select_visible_choice(self) -> None: + command_list = self.query_one("#command-palette-list", ListView) + index = command_list.index + if index is None: + return + command = self.visible_commands[index] + self.dismiss(f"/{command.name} ") + + @property + def visible_commands(self) -> tuple[SlashCommand, ...]: + normalized = self.search_value.strip().lower() + if not normalized: + return self.all_commands + return tuple( + command + for command in self.all_commands + if normalized in command.name.lower() + or normalized in command.description.lower() + or any(normalized in alias.lower() for alias in command.aliases) + or any(normalized in term.lower() for term in command.search_terms) + ) + + def _refresh_command_list(self) -> None: + command_list = self.query_one("#command-palette-list", ListView) + command_list.clear() + command_list.extend( + [ + ListItem(Label(self._command_label(command), markup=False)) + for command in self.visible_commands + ] + ) + if self.visible_commands: + command_list.index = 0 + + def _command_label(self, command: SlashCommand) -> str: + return f"/{command.name} {command.description}" + + +class DiffViewerScreen(ModalScreen[None]): + """Modal viewer for syntax-highlighted diffs.""" + + BINDINGS: ClassVar[list[BindingEntry]] = [ + Binding("escape", "close", "Close"), + Binding("enter", "close", "Close"), + Binding("q", "close", "Close"), + Binding("up", "scroll_up", "Scroll up", show=False, priority=True), + Binding("down", "scroll_down", "Scroll down", show=False, priority=True), + ] + + def __init__( + self, + diff_text: str, + *, + title: str = "Diff", + theme: TuiTheme, + ) -> None: + super().__init__() + self.diff_text = diff_text + self.title_text = title + self.theme = theme + + def compose(self) -> ComposeResult: + """Compose the diff viewer.""" + with Vertical(id="diff-viewer"): + yield Static(self.title_text, id="diff-viewer-title") + with DiffViewerScroll(id="diff-viewer-scroll"): + yield RichLog(id="diff-viewer-content", highlight=True, markup=False) + yield Static("q/Enter/Escape closes — Arrow keys scroll", id="diff-viewer-help") + + def on_mount(self) -> None: + """Render the diff content and focus the scroll area.""" + content = self.query_one("#diff-viewer-content", RichLog) + rendered = render_diff_content( + self.diff_text, + syntax_theme=self.theme.syntax_theme, + code_block_background=self.theme.markdown_code_block_background, + ) + content.write(rendered) + self.query_one("#diff-viewer-scroll", DiffViewerScroll).focus() + + def on_key(self, event: Key) -> None: + """Route arrow keys to the scroll area.""" + if event.key == "up": + event.stop() + self.action_scroll_up() + elif event.key == "down": + event.stop() + self.action_scroll_down() + + def action_close(self) -> None: + """Close the diff viewer.""" + self.dismiss(None) + + def action_scroll_up(self) -> None: + """Scroll diff content up.""" + self.query_one("#diff-viewer-scroll", DiffViewerScroll).action_scroll_up() + + def action_scroll_down(self) -> None: + """Scroll diff content down.""" + self.query_one("#diff-viewer-scroll", DiffViewerScroll).action_scroll_down() + + +class DiffViewerScroll(VerticalScroll): + """Scrollable container for diff viewer content.""" + + class LoginProviderPickerScreen(ModalScreen[str | None]): """Provider picker for the TUI login flow.""" @@ -1790,10 +1981,66 @@ class TauTuiApp(App[None]): SessionPickerScreen, TreePickerScreen, - CommandOutputScreen { + CommandPaletteScreen, + CommandOutputScreen, + DiffViewerScreen { align: center middle; } + #command-palette, + #diff-viewer { + width: 76; + max-width: 90%; + height: auto; + max-height: 70%; + padding: 1 2; + background: $tau-chrome-background; + color: $tau-chrome-text; + border: tall $tau-border; + } + + #command-palette-title, + #diff-viewer-title { + height: 1; + color: $tau-chrome-text; + text-style: bold; + margin-bottom: 1; + } + + #command-palette-search { + height: 3; + margin-bottom: 1; + background: $tau-prompt-background; + color: $tau-prompt-text; + border: tall $tau-prompt-border; + } + + #command-palette-list { + height: auto; + max-height: 16; + background: $tau-transcript-background; + color: $tau-screen-text; + border: tall $tau-border; + } + + #command-palette-list ListItem Label { + color: $tau-screen-text; + } + + #command-palette-help, + #diff-viewer-help { + height: 1; + margin-top: 1; + color: $tau-muted-text; + } + + #diff-viewer-scroll { + height: auto; + max-height: 20; + background: $tau-transcript-background; + border: tall $tau-border; + } + #session-picker, #tree-picker { width: 76; @@ -2801,15 +3048,59 @@ def _pop_latest_queued_message(self) -> str | None: return None + @property + def _command_registry(self) -> CommandRegistry: + """Return the session command registry.""" + return _session_command_registry(self.session) + def action_open_command_palette(self) -> None: - """Open the slash-command palette in the prompt.""" + """Open the command palette modal.""" + registry = self._command_registry + commands = registry.list_commands() + if not commands: + self._notify("No commands available.", severity="warning") + return + self.push_screen( + CommandPaletteScreen( + commands, + theme=self.tui_settings.resolved_theme, + ), + callback=self._handle_command_palette_result, + ) + + def _handle_command_palette_result(self, result: str | None) -> None: + """Insert the selected command into the prompt.""" + if result is None: + return prompt = self.query_one("#prompt", PromptInput) prompt.focus() - prompt.text = "/" - prompt.move_cursor((0, 1)) + prompt.text = result + prompt.move_cursor((0, len(result))) self._completion_state = self._build_completion_state(prompt.text) self._refresh_completions() + def action_open_diff_viewer(self) -> None: + """Open the diff viewer with the most recent patch content.""" + diff_text = self.state.last_diff + title = "Last Patch" + if diff_text: + self._open_diff_viewer(diff_text, title=title) + else: + self._notify("No patch diff available.", severity="warning") + + def _open_diff_viewer(self, diff_text: str, title: str = "Diff") -> None: + """Open the syntax-highlighted diff viewer modal.""" + if not diff_text.strip(): + self._notify("No diff content to display.", severity="warning") + return + self.push_screen( + DiffViewerScreen( + diff_text, + title=title, + theme=self.tui_settings.resolved_theme, + ), + ) + def action_open_session_picker(self) -> None: """Open the indexed session picker.""" if self.state.running: @@ -4054,6 +4345,7 @@ def _app_bindings(keybindings: TuiKeybindings) -> list[Binding]: Binding(keybindings.toggle_thinking, "toggle_thinking", "Thinking tokens"), Binding(keybindings.copy_message, "clear_prompt", "Clear input"), Binding(keybindings.quit, "quit", "Quit"), + Binding(keybindings.diff_viewer, "open_diff_viewer", "Diff"), ] @@ -4140,6 +4432,7 @@ def _hidden_prompt_bindings( (keybindings.completion_next, "completion_next"), (keybindings.completion_previous, "completion_previous"), (keybindings.quit, "quit"), + (keybindings.diff_viewer, "open_diff_viewer"), ) return [ Binding(key, action, show=False, priority=True) diff --git a/src/tau_coding/tui/config.py b/src/tau_coding/tui/config.py index 6f854f7fd..f9b70f3c6 100644 --- a/src/tau_coding/tui/config.py +++ b/src/tau_coding/tui/config.py @@ -19,14 +19,15 @@ class TuiKeybindings: """Configurable keys for Tau's built-in Textual frontend.""" cancel: str = "escape" - command_palette: str = "ctrl+k" + command_palette: str = "ctrl+p" session_picker: str = "ctrl+r" + diff_viewer: str = "ctrl+l" queue_follow_up: str = "alt+enter" accept_completion: str = "tab" completion_next: str = "down" completion_previous: str = "up" thinking_cycle: str = "shift+tab" - model_cycle: str = "ctrl+p" + model_cycle: str = "ctrl+k" toggle_thinking: str = "ctrl+t" toggle_tool_results: str = "ctrl+o" copy_message: str = "ctrl+c" @@ -38,6 +39,7 @@ def to_json(self) -> dict[str, str]: "cancel": self.cancel, "command_palette": self.command_palette, "session_picker": self.session_picker, + "diff_viewer": self.diff_viewer, "queue_follow_up": self.queue_follow_up, "accept_completion": self.accept_completion, "completion_next": self.completion_next, diff --git a/src/tau_coding/tui/state.py b/src/tau_coding/tui/state.py index 4d1a98981..ded6665b5 100644 --- a/src/tau_coding/tui/state.py +++ b/src/tau_coding/tui/state.py @@ -53,6 +53,7 @@ class TuiState: queued_steering: tuple[str, ...] = () queued_follow_up: tuple[str, ...] = () skills: tuple[Skill, ...] = () + last_diff: str | None = None def add_item( self, @@ -133,6 +134,9 @@ def record_tool_result(self, result: AgentToolResult) -> None: content=result.content, data=result.data, ) + patch = _result_patch(name=result.name, ok=result.ok, data=result.data) + if patch: + self.last_diff = patch for item in reversed(self.items): if item.role in {"tool", "skill"} and item.tool_call_id == result.tool_call_id: item.tool_result_text = result_text diff --git a/src/tau_coding/tui/widgets.py b/src/tau_coding/tui/widgets.py index 08ee02b78..3417f9f7b 100644 --- a/src/tau_coding/tui/widgets.py +++ b/src/tau_coding/tui/widgets.py @@ -1482,6 +1482,23 @@ def _syntax_language(raw: str) -> str: return language +def render_diff_content( + diff_text: str, + *, + syntax_theme: str = "ansi_dark", + code_block_background: str = "default", +) -> RenderableType: + """Render a unified diff patch with syntax highlighting.""" + return Syntax( + diff_text, + "diff", + theme=syntax_theme, + word_wrap=True, + padding=(1, 2), + background_color=code_block_background, + ) + + def render_completion_suggestions( state: CompletionState, *, diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 3c06b5c6a..c50a6fd73 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -61,6 +61,7 @@ COMPLETION_MAX_VISIBLE_LINES, PASTE_DISPLAY_THRESHOLD, CommandOutputScreen, + CommandPaletteScreen, CustomProviderLoginResult, CustomProviderLoginScreen, LoginMethodPickerScreen, @@ -1864,12 +1865,12 @@ async def test_tui_app_uses_textual_footer_for_shortcut_hints() -> None: assert _visible_footer_bindings(app) == { "Quit": "ctrl+d", "Clear": "ctrl+c", - "Commands": "ctrl+k", + "Commands": "ctrl+p", "Submit": "enter", "Newline": "shift+enter", "Sessions": "ctrl+r", "Thinking": "shift+tab", - "Model": "ctrl+p", + "Model": "ctrl+k", "Cancel": "escape", } @@ -3474,13 +3475,11 @@ async def test_tui_app_opens_command_palette_from_keybinding() -> None: app = TauTuiApp(FakeSession()) async with app.run_test() as pilot: - prompt = app.query_one("#prompt") - await pilot.press("ctrl+k") + await pilot.press("ctrl+p") + await pilot.pause() - assert prompt.value == "/" - assert app._completion_state.items - assert any(item.display == "/session" for item in app._completion_state.items) - assert app.query_one("#autocomplete").display is True + assert app.screen is not None + assert isinstance(app.screen, CommandPaletteScreen) def test_tui_model_picker_guides_setup_when_no_provider_is_usable() -> None: @@ -3843,17 +3842,15 @@ async def test_tui_app_uses_configured_command_palette_keybinding() -> None: ) async with app.run_test() as pilot: - prompt = app.query_one("#prompt") - await pilot.press("ctrl+k") + await pilot.press("ctrl+p") + await pilot.pause() - assert prompt.value == "" - assert app._completion_state.items == () + assert not isinstance(app.screen, CommandPaletteScreen) await pilot.press("ctrl+j") + await pilot.pause() - assert prompt.value == "/" - assert app._completion_state.items - assert any(item.display == "/session" for item in app._completion_state.items) + assert isinstance(app.screen, CommandPaletteScreen) @pytest.mark.anyio @@ -5028,7 +5025,7 @@ def fake_notify(message: str, **kwargs: object) -> None: app._notify = fake_notify # type: ignore[method-assign] async with app.run_test() as pilot: - await pilot.press("ctrl+p") + await pilot.press("ctrl+k") await pilot.pause() assert session.provider_name == "openai" @@ -5057,7 +5054,7 @@ def fake_update_from_state(*args: object, **kwargs: object) -> None: transcript_refreshes += 1 transcript.update_from_state = fake_update_from_state # type: ignore[method-assign] - await pilot.press("ctrl+p") + await pilot.press("ctrl+k") await pilot.pause() assert session.provider_name == "openai"