diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index 76924cd..bcfb921 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -275,6 +275,7 @@ def run_textual_interactive( BINDINGS = [ Binding("ctrl+c", "request_quit", "Quit", show=False), Binding("up", "edit_queued", show=False, priority=True), + Binding("down", "down_delegate", show=False, priority=True), Binding("escape", "cancel_queued", show=False, priority=True), ] @@ -306,6 +307,7 @@ def run_textual_interactive( self._comp_index: int = -1 self._hitl_auto_approve: bool = False self._approval_future: asyncio.Future | None = None + self._picker_future: asyncio.Future | None = None self._history_suggester = HistorySuggester(get_config_dir() / "history") # ── Layout ───────────────────────────────────────────── @@ -416,6 +418,33 @@ def run_textual_interactive( if self._approval_future and not self._approval_future.done(): self._approval_future.set_result(event) + async def _wait_for_thread_pick(self, picker_widget) -> str | None: + """Wait for user to pick a thread from ThreadPickerWidget. + + Returns the selected thread_id, or ``None`` on cancel/timeout. + """ + self._picker_future = asyncio.get_event_loop().create_future() + try: + return await asyncio.wait_for(self._picker_future, timeout=120) + except (asyncio.TimeoutError, asyncio.CancelledError): + return None + finally: + self._picker_future = None + try: + picker_widget.remove() + except Exception: + pass + + def on_thread_picker_widget_picked(self, event) -> None: # type: ignore[override] + """Handle ThreadPickerWidget.Picked message.""" + if self._picker_future and not self._picker_future.done(): + self._picker_future.set_result(event.thread_id) + + def on_thread_picker_widget_cancelled(self, event) -> None: # type: ignore[override] + """Handle ThreadPickerWidget.Cancelled message.""" + if self._picker_future and not self._picker_future.done(): + self._picker_future.set_result(None) + # ── Streaming core ───────────────────────────────────── async def _stream_with_widgets( @@ -1138,7 +1167,10 @@ def run_textual_interactive( if text.startswith("/"): self._hide_completions() - await self._handle_command(text) + # Launch as independent task to free the message pump. + # Commands like /resume mount interactive widgets that need + # the pump to process key events and message bubbling. + asyncio.ensure_future(self._handle_command(text)) return self._history_suggester.append_entry(text) @@ -1183,26 +1215,34 @@ def run_textual_interactive( def action_cancel_queued(self) -> None: """Cancel the last queued message on Esc.""" - # Delegate to ApprovalWidget if it has focus + # Delegate to ApprovalWidget or ThreadPickerWidget if focused focused = self.focused if focused is not None: from .widgets.approval_widget import ApprovalWidget + from .widgets.thread_selector import ThreadPickerWidget if isinstance(focused, ApprovalWidget): focused.action_select_reject() return + if isinstance(focused, ThreadPickerWidget): + focused.action_cancel() + return if self._queued_messages: self._queued_messages.pop() self._render_queue_indicator() def action_edit_queued(self) -> None: """Pop the last queued message back into input for editing.""" - # Skip if an ApprovalWidget has focus — let it handle up/down + # Skip if an ApprovalWidget or ThreadPickerWidget has focus focused = self.focused if focused is not None: from .widgets.approval_widget import ApprovalWidget + from .widgets.thread_selector import ThreadPickerWidget if isinstance(focused, ApprovalWidget): focused.action_move_up() return + if isinstance(focused, ThreadPickerWidget): + focused.action_move_up() + return if self._queued_messages: last = self._queued_messages.pop() prompt = self.query_one("#prompt", Input) @@ -1211,6 +1251,19 @@ def run_textual_interactive( prompt.focus() self._render_queue_indicator() + def action_down_delegate(self) -> None: + """Delegate down key to focused ApprovalWidget or ThreadPickerWidget.""" + focused = self.focused + if focused is not None: + from .widgets.approval_widget import ApprovalWidget + from .widgets.thread_selector import ThreadPickerWidget + if isinstance(focused, ApprovalWidget): + focused.action_move_down() + return + if isinstance(focused, ThreadPickerWidget): + focused.action_move_down() + return + def on_key(self, event: Any) -> None: comp_widget = self.query_one("#completions", Static) if not (comp_widget.display and self._comp_items): @@ -1446,9 +1499,32 @@ def run_textual_interactive( async def _cmd_resume(self, arg: str) -> None: if not arg: - self._append_system("Usage: /resume ", style="yellow") - await self._cmd_threads() - return + # Show inline thread picker + threads = await list_threads( + limit=0, + include_message_count=True, + include_preview=True, + ) + if not threads: + self._append_system("No sessions to resume.", style="yellow") + return + + from .widgets.thread_selector import ThreadPickerWidget + + container = self.query_one("#chat", VerticalScroll) + picker = ThreadPickerWidget( + threads, + current_thread=self._conversation_tid, + title=">>> Select session to resume <<<", + ) + await container.mount(picker) + container.scroll_end(animate=False) + picker.focus() + + selected = await self._wait_for_thread_pick(picker) + if selected is None: + return + arg = selected resolved = await self._resolve_thread_id(arg) if not resolved: @@ -1474,8 +1550,32 @@ def run_textual_interactive( async def _cmd_delete(self, arg: str) -> None: if not arg: - self._append_system("Usage: /delete ", style="yellow") - return + # Show inline thread picker for deletion + threads = await list_threads( + limit=0, + include_message_count=True, + include_preview=True, + ) + if not threads: + self._append_system("No sessions to delete.", style="yellow") + return + + from .widgets.thread_selector import ThreadPickerWidget + + container = self.query_one("#chat", VerticalScroll) + picker = ThreadPickerWidget( + threads, + current_thread=self._conversation_tid, + title=">>> Select session to delete <<<", + ) + await container.mount(picker) + container.scroll_end(animate=False) + picker.focus() + + selected = await self._wait_for_thread_pick(picker) + if selected is None: + return + arg = selected resolved = await self._resolve_thread_id(arg) if not resolved: diff --git a/EvoScientist/cli/widgets/__init__.py b/EvoScientist/cli/widgets/__init__.py index 76e18aa..e9a6c33 100644 --- a/EvoScientist/cli/widgets/__init__.py +++ b/EvoScientist/cli/widgets/__init__.py @@ -10,6 +10,7 @@ from .user_message import UserMessage from .system_message import SystemMessage from .usage_widget import UsageWidget from .approval_widget import ApprovalWidget +from .thread_selector import ThreadPickerWidget __all__ = [ "LoadingWidget", @@ -22,4 +23,5 @@ __all__ = [ "SystemMessage", "UsageWidget", "ApprovalWidget", + "ThreadPickerWidget", ] diff --git a/EvoScientist/cli/widgets/thread_selector.py b/EvoScientist/cli/widgets/thread_selector.py new file mode 100644 index 0000000..609095f --- /dev/null +++ b/EvoScientist/cli/widgets/thread_selector.py @@ -0,0 +1,197 @@ +"""Inline thread picker widget for /resume and /delete in TUI. + +Keyboard-driven widget mounted directly into the chat container (like +ApprovalWidget). Posts ``ThreadPickerWidget.Picked`` when user selects +a thread, or ``ThreadPickerWidget.Cancelled`` on Esc. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar + +from rich.text import Text + +from textual.binding import Binding, BindingType +from textual.containers import Container +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Static + +if TYPE_CHECKING: + from textual import events + from textual.app import ComposeResult + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def build_row_text( + thread: dict, + *, + selected: bool = False, + current: bool = False, +) -> Text: + """Build a Rich Text object for a single thread row. + + Pure function — no Textual app context required, safe for unit tests. + """ + from ...sessions import _format_relative_time + + tid = thread["thread_id"] + preview = thread.get("preview", "") or "" + msgs = thread.get("message_count", 0) + model = thread.get("model", "") or "" + when = _format_relative_time(thread.get("updated_at")) + + line = Text() + cursor = "\u25b8 " if selected else " " + line.append(cursor, style="bold cyan" if selected else "dim") + line.append(f"{tid}", style="bold" if selected else "") + if current: + line.append(" *", style="bold green") + line.append(" ") + if preview: + display_preview = preview[:40] + "\u2026" if len(preview) > 40 else preview + line.append(display_preview, style="") + line.append(" ", style="") + line.append(f"({msgs} msgs)", style="dim") + if model: + line.append(f" {model}", style="dim italic") + if when: + line.append(f" {when}", style="dim") + return line + + +# --------------------------------------------------------------------------- +# Widget +# --------------------------------------------------------------------------- + +class ThreadPickerWidget(Widget): + """Inline thread picker — mounts in chat, keyboard-driven. + + Posts ``Picked(thread_id)`` on Enter, ``Cancelled()`` on Esc. + Follows the same pattern as ``ApprovalWidget``. + """ + + can_focus = True + can_focus_children = False + + DEFAULT_CSS = """ + ThreadPickerWidget { + height: auto; + max-height: 22; + margin: 1 0; + padding: 0 1; + background: $surface; + border: solid $primary; + } + ThreadPickerWidget .picker-title { + height: 1; + text-style: bold; + color: $primary; + } + ThreadPickerWidget .picker-rows { + height: auto; + max-height: 16; + overflow-y: auto; + } + ThreadPickerWidget .picker-row { + height: 1; + padding: 0 1; + } + ThreadPickerWidget .picker-row-selected { + background: $primary; + text-style: bold; + } + ThreadPickerWidget .picker-help { + height: 1; + color: $text-muted; + text-style: italic; + } + """ + + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("up", "move_up", "Up", show=False), + Binding("k", "move_up", "Up", show=False), + Binding("down", "move_down", "Down", show=False), + Binding("j", "move_down", "Down", show=False), + Binding("enter", "select", "Select", show=False), + Binding("escape", "cancel", "Cancel", show=False), + ] + + class Picked(Message): + """Posted when user picks a thread.""" + + def __init__(self, thread_id: str) -> None: + super().__init__() + self.thread_id = thread_id + + class Cancelled(Message): + """Posted when user cancels selection.""" + + def __init__( + self, + threads: list[dict], + *, + current_thread: str | None = None, + title: str = "Select a session", + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._threads = threads + self._current_thread = current_thread + self._title = title + self._selected = 0 + self._row_widgets: list[Static] = [] + + def compose(self) -> ComposeResult: + yield Static(self._title, classes="picker-title") + with Container(classes="picker-rows"): + for _ in self._threads: + widget = Static("", classes="picker-row") + self._row_widgets.append(widget) + yield widget + yield Static( + "\u2191/\u2193 navigate \u00b7 Enter select \u00b7 Esc cancel", + classes="picker-help", + ) + + def on_mount(self) -> None: + self._update_rows() + self.call_later(self.focus) + + def _update_rows(self) -> None: + for i, (thread, widget) in enumerate(zip(self._threads, self._row_widgets)): + is_current = thread["thread_id"] == self._current_thread + text = build_row_text(thread, selected=(i == self._selected), current=is_current) + widget.update(text) + widget.remove_class("picker-row-selected") + if i == self._selected: + widget.add_class("picker-row-selected") + widget.scroll_visible() + + def action_move_up(self) -> None: + if not self._threads: + return + self._selected = (self._selected - 1) % len(self._threads) + self._update_rows() + + def action_move_down(self) -> None: + if not self._threads: + return + self._selected = (self._selected + 1) % len(self._threads) + self._update_rows() + + def action_select(self) -> None: + if not self._threads: + self.post_message(self.Cancelled()) + return + self.post_message(self.Picked(self._threads[self._selected]["thread_id"])) + + def action_cancel(self) -> None: + self.post_message(self.Cancelled()) + + def on_blur(self, event: events.Blur) -> None: + """Re-focus to keep focus trapped until decision is made.""" + self.call_after_refresh(self.focus) diff --git a/tests/test_thread_selector.py b/tests/test_thread_selector.py new file mode 100644 index 0000000..902d21b --- /dev/null +++ b/tests/test_thread_selector.py @@ -0,0 +1,204 @@ +"""Tests for EvoScientist.cli.widgets.thread_selector module.""" + +from __future__ import annotations + +from unittest import mock + +from rich.text import Text + +from EvoScientist.cli.widgets.thread_selector import ( + ThreadPickerWidget, + build_row_text, +) + + +# --------------------------------------------------------------------------- +# Sample data +# --------------------------------------------------------------------------- + +_THREADS = [ + { + "thread_id": "abc12345", + "preview": "Help me write a paper", + "message_count": 12, + "model": "claude-sonnet-4-6", + "updated_at": "2026-03-09T10:00:00+00:00", + }, + { + "thread_id": "def67890", + "preview": "Run experiment pipeline", + "message_count": 5, + "model": "gpt-4o", + "updated_at": "2026-03-08T08:00:00+00:00", + }, + { + "thread_id": "ghi11111", + "preview": "", + "message_count": 0, + "model": "", + "updated_at": None, + }, +] + + +# --------------------------------------------------------------------------- +# build_row_text unit tests (pure function, no Textual app needed) +# --------------------------------------------------------------------------- + +class TestBuildRowText: + def test_selected_has_cursor(self): + text = build_row_text(_THREADS[0], selected=True) + assert isinstance(text, Text) + assert "\u25b8" in text.plain + + def test_not_selected_no_cursor(self): + text = build_row_text(_THREADS[0], selected=False) + assert "\u25b8" not in text.plain + + def test_thread_id_shown(self): + text = build_row_text(_THREADS[0]) + assert "abc12345" in text.plain + + def test_current_marker(self): + text = build_row_text(_THREADS[0], current=True) + assert "*" in text.plain + + def test_no_current_marker(self): + text = build_row_text(_THREADS[0], current=False) + assert " *" not in text.plain + + def test_preview_shown(self): + text = build_row_text(_THREADS[0]) + assert "Help me write a paper" in text.plain + + def test_no_preview(self): + text = build_row_text(_THREADS[2]) + assert "ghi11111" in text.plain + assert "(0 msgs)" in text.plain + + def test_message_count(self): + text = build_row_text(_THREADS[0]) + assert "(12 msgs)" in text.plain + + def test_model_shown(self): + text = build_row_text(_THREADS[0]) + assert "claude-sonnet-4-6" in text.plain + + def test_empty_model_omitted(self): + text = build_row_text(_THREADS[2]) + plain = text.plain + assert "ghi11111" in plain + + def test_long_preview_truncated(self): + long_thread = {**_THREADS[0], "preview": "A" * 60} + text = build_row_text(long_thread) + assert "\u2026" in text.plain + assert "A" * 60 not in text.plain + + def test_relative_time_shown(self): + text = build_row_text(_THREADS[0]) + assert "abc12345" in text.plain + + def test_no_time_for_none_updated_at(self): + text = build_row_text(_THREADS[2]) + assert "ghi11111" in text.plain + + +# --------------------------------------------------------------------------- +# ThreadPickerWidget unit tests +# --------------------------------------------------------------------------- + +class TestThreadPickerWidget: + def test_init_stores_threads(self): + picker = ThreadPickerWidget(_THREADS, current_thread="abc12345") + assert picker._threads == _THREADS + assert picker._current_thread == "abc12345" + assert picker._selected == 0 + + def test_init_empty_threads(self): + picker = ThreadPickerWidget([]) + assert picker._threads == [] + + def test_custom_title(self): + picker = ThreadPickerWidget(_THREADS, title="Pick one") + assert picker._title == "Pick one" + + def test_default_title(self): + picker = ThreadPickerWidget(_THREADS) + assert picker._title == "Select a session" + + def test_action_move_down_wraps(self): + picker = ThreadPickerWidget(_THREADS) + picker._row_widgets = [mock.MagicMock() for _ in _THREADS] + picker._selected = len(_THREADS) - 1 + # Mock _update_rows to avoid Textual update calls + picker._update_rows = mock.MagicMock() + picker.action_move_down() + assert picker._selected == 0 + + def test_action_move_up_wraps(self): + picker = ThreadPickerWidget(_THREADS) + picker._row_widgets = [mock.MagicMock() for _ in _THREADS] + picker._selected = 0 + picker._update_rows = mock.MagicMock() + picker.action_move_up() + assert picker._selected == len(_THREADS) - 1 + + def test_action_move_down_increments(self): + picker = ThreadPickerWidget(_THREADS) + picker._row_widgets = [mock.MagicMock() for _ in _THREADS] + picker._selected = 0 + picker._update_rows = mock.MagicMock() + picker.action_move_down() + assert picker._selected == 1 + + def test_action_move_up_decrements(self): + picker = ThreadPickerWidget(_THREADS) + picker._row_widgets = [mock.MagicMock() for _ in _THREADS] + picker._selected = 2 + picker._update_rows = mock.MagicMock() + picker.action_move_up() + assert picker._selected == 1 + + def test_action_move_empty_noop(self): + picker = ThreadPickerWidget([]) + picker.action_move_down() + assert picker._selected == 0 + picker.action_move_up() + assert picker._selected == 0 + + def test_action_select_posts_picked(self): + picker = ThreadPickerWidget(_THREADS) + picker._selected = 1 + picker.post_message = mock.MagicMock() + picker.action_select() + msg = picker.post_message.call_args[0][0] + assert isinstance(msg, ThreadPickerWidget.Picked) + assert msg.thread_id == "def67890" + + def test_action_cancel_posts_cancelled(self): + picker = ThreadPickerWidget(_THREADS) + picker.post_message = mock.MagicMock() + picker.action_cancel() + msg = picker.post_message.call_args[0][0] + assert isinstance(msg, ThreadPickerWidget.Cancelled) + + def test_action_select_empty_posts_cancelled(self): + picker = ThreadPickerWidget([]) + picker.post_message = mock.MagicMock() + picker.action_select() + msg = picker.post_message.call_args[0][0] + assert isinstance(msg, ThreadPickerWidget.Cancelled) + + def test_bindings_include_navigation(self): + bindings = {b.key for b in ThreadPickerWidget.BINDINGS} + assert "up" in bindings + assert "down" in bindings + assert "enter" in bindings + assert "escape" in bindings + assert "k" in bindings + assert "j" in bindings + + def test_can_focus(self): + assert ThreadPickerWidget.can_focus is True + assert ThreadPickerWidget.can_focus_children is False