diff --git a/.github/assets/badge-pypi-dark.svg b/.github/assets/badge-pypi-dark.svg index 12eab3b..cb4d54b 100644 --- a/.github/assets/badge-pypi-dark.svg +++ b/.github/assets/badge-pypi-dark.svg @@ -5,5 +5,5 @@ v0.0.3 + font-size="13" font-weight="700" fill="#ffffff">v0.0.4 \ No newline at end of file diff --git a/.github/assets/badge-pypi-light.svg b/.github/assets/badge-pypi-light.svg index bb048a2..05252c8 100644 --- a/.github/assets/badge-pypi-light.svg +++ b/.github/assets/badge-pypi-light.svg @@ -5,5 +5,5 @@ v0.0.3 + font-size="13" font-weight="700" fill="#ffffff">v0.0.4 \ No newline at end of file diff --git a/EvoScientist/cli/file_mentions.py b/EvoScientist/cli/file_mentions.py new file mode 100644 index 0000000..5c06d4f --- /dev/null +++ b/EvoScientist/cli/file_mentions.py @@ -0,0 +1,343 @@ +"""@file mention parsing and injection for CLI and TUI input. + +Usage:: + + text, injected = resolve_file_mentions(user_input, workspace_dir) + # text — original input unchanged + # injected — full prompt with file contents appended (or original if no mentions) +""" + +from __future__ import annotations + +import re +from difflib import SequenceMatcher +from pathlib import Path + +# --------------------------------------------------------------------------- +# Patterns +# --------------------------------------------------------------------------- + +_PATH_CHARS = r"A-Za-z0-9._~/\\:-" + +FILE_MENTION_PATTERN = re.compile(r"@(?P(?:\\.|[" + _PATH_CHARS + r"])+)") +"""Matches ``@path/to/file`` in user input. + +Escaped spaces (``@my\\\\ folder/file``) are supported. Bare ``@`` with no +path characters is not matched (uses ``+`` not ``*``). +""" + +_EMAIL_PREFIX = re.compile(r"[a-zA-Z0-9._%+-]$") +"""If the character immediately before ``@`` matches this, it's an email address.""" + +# Files larger than this are referenced by path only (not embedded inline). +_MAX_EMBED_BYTES = 256 * 1024 # 256 KB + +# Fuzzy search thresholds (ported from DeepAgents FuzzyFileController) +_MIN_FUZZY_SCORE = 15 +_MIN_FUZZY_RATIO = 0.4 + +# Max files to index per workspace +_MAX_WORKSPACE_FILES = 1000 + + +# --------------------------------------------------------------------------- +# Module-level file cache +# --------------------------------------------------------------------------- + +_file_cache: dict[str, list[str]] = {} +"""workspace_dir -> sorted list of relative POSIX paths""" + + +def _get_workspace_files(root: Path) -> list[str]: + """Glob workspace files up to 4 levels deep, skipping hidden entries.""" + files: list[str] = [] + for pattern in ["*", "*/*", "*/*/*", "*/*/*/*"]: + for p in root.glob(pattern): + if not p.is_file(): + continue + rel = p.relative_to(root) + # Skip any part that starts with '.' + if any(part.startswith(".") for part in rel.parts): + continue + files.append(rel.as_posix()) + if len(files) >= _MAX_WORKSPACE_FILES: + return files + return files + + +def _get_cached_files(workspace_dir: str) -> list[str]: + """Return cached file list for *workspace_dir*, scanning if necessary.""" + if workspace_dir not in _file_cache: + _file_cache[workspace_dir] = _get_workspace_files(Path(workspace_dir)) + return _file_cache[workspace_dir] + + +def invalidate_file_cache(workspace_dir: str | None = None) -> None: + """Invalidate the workspace file cache. + + Call when the workspace changes (e.g. ``/new``, ``/resume``). + + Args: + workspace_dir: If given, invalidate only that workspace entry. + If ``None``, clear the entire cache. + """ + if workspace_dir: + _file_cache.pop(workspace_dir, None) + else: + _file_cache.clear() + + +# --------------------------------------------------------------------------- +# Fuzzy scoring (ported from DeepAgents FuzzyFileController) +# --------------------------------------------------------------------------- + + +def _fuzzy_score(query: str, candidate: str) -> float: + """Score how well *query* matches *candidate* path. + + Four-level priority (higher = better match): + + 1. Filename starts with query (150 base + length bonus) + 2. Filename contains query as substring (100–120) + 3. Full path contains query as substring (40–80) + 4. SequenceMatcher ratio on filename (15–30) + + Returns 0 when below ``_MIN_FUZZY_SCORE``. + """ + q = query.lower() + c = candidate.lower() + filename = c.split("/")[-1] + + # Level 1: filename starts with query + if filename.startswith(q): + return 150 + len(q) + + # Level 2: filename contains query + if q in filename: + bonus = 20 if filename.startswith(q[:1]) else 0 + return 100 + bonus + + # Level 3: full path contains query + if q in c: + depth_bonus = max(0, 40 - candidate.count("/") * 5) + return 40 + depth_bonus + + # Level 4: SequenceMatcher on filename + ratio = SequenceMatcher(None, q, filename).ratio() + if ratio >= _MIN_FUZZY_RATIO: + return 15 + ratio * 15 + + return 0 + + +def _fuzzy_search( + query: str, + candidates: list[str], + limit: int = 10, +) -> list[str]: + """Return up to *limit* candidates from *candidates* ranked by fuzzy score. + + When *query* is empty, returns the first *limit* candidates sorted by + depth then name (shallowest, alphabetical first). + """ + if not query: + # Tree order: group by top-level component, dir entry before its children, + # root-level files sorted among top-level dirs alphabetically. + def _tree_key(p: str) -> tuple: + top = p.split("/")[0] # first path component (no slash) + is_file_entry = 0 if p.endswith("/") else 1 # dir entry sorts first + return (top.lower(), is_file_entry, p.lower()) + + return sorted(candidates, key=_tree_key)[:limit] + + scored = [ + (score, c) + for c in candidates + if (score := _fuzzy_score(query, c)) >= _MIN_FUZZY_SCORE + ] + return [c for _, c in sorted(scored, key=lambda x: -x[0])[:limit]] + + +# --------------------------------------------------------------------------- +# Core helpers +# --------------------------------------------------------------------------- + + +def _read_file(path: Path) -> str: + """Return a Markdown snippet for embedding the file inline. + + Files larger than ``_MAX_EMBED_BYTES`` get a path-only reference with a + hint to use the ``read_file`` tool instead. + """ + size = path.stat().st_size + if size > _MAX_EMBED_BYTES: + size_kb = size // 1024 + return ( + f"\n### {path.name}\n" + f"Path: `{path}`\n" + f"Size: {size_kb} KB (too large to embed inline — " + "use the read_file tool to view it)" + ) + content = path.read_text(encoding="utf-8", errors="replace") + return f"\n### {path.name}\nPath: `{path}`\n```\n{content}\n```" + + +def parse_file_mentions(text: str, cwd: Path | None = None) -> list[Path]: + """Extract resolved ``@file`` paths from *text*. + + Args: + text: Raw user input that may contain ``@path`` mentions. + cwd: Base directory for resolving relative paths. Defaults to the + process working directory. + + Returns: + List of resolved, existing ``Path`` objects (directories excluded). + Unresolvable or missing paths are skipped with a printed warning. + """ + if cwd is None: + cwd = Path.cwd() + + files: list[Path] = [] + for match in FILE_MENTION_PATTERN.finditer(text): + # Skip email addresses — character immediately before @ is alphanumeric + before = text[: match.start()] + if before and _EMAIL_PREFIX.search(before): + continue + + raw = match.group("path") + clean = raw.replace("\\ ", " ") + + try: + p = Path(clean).expanduser() + if not p.is_absolute(): + p = cwd / p + resolved = p.resolve() + if resolved.exists() and resolved.is_file(): + files.append(resolved) + else: + print(f"[warning] @file not found: {raw}") + except (OSError, RuntimeError) as exc: + print(f"[warning] invalid @file path {raw!r}: {exc}") + + return files + + +def resolve_file_mentions( + text: str, + workspace_dir: str | None = None, +) -> tuple[str, str]: + """Parse ``@file`` mentions and return *(original_text, final_prompt)*. + + *final_prompt* equals *original_text* when no valid mentions are found, + otherwise it appends a ``## Referenced Files`` section with the file + contents embedded as fenced code blocks. + + Args: + text: Raw user input. + workspace_dir: Workspace root used for resolving relative paths. + + Returns: + ``(original_text, final_prompt)`` — the first element is always the + unchanged input; the second is the prompt to send to the agent. + """ + cwd = Path(workspace_dir) if workspace_dir else None + files = parse_file_mentions(text, cwd=cwd) + + if not files: + return text, text + + parts = [text, "\n\n## Referenced Files\n"] + for path in files: + try: + parts.append(_read_file(path)) + except (OSError, UnicodeDecodeError) as exc: + parts.append(f"\n### {path.name}\n[Error reading file: {exc}]") + + return text, "\n".join(parts) + + +# --------------------------------------------------------------------------- +# Autocomplete helpers (used by CLI completer and TUI) +# --------------------------------------------------------------------------- + + +def _type_hint(rel_path: str) -> str: + """Return a short type label for *rel_path* (extension or ``'file'``).""" + suffix = rel_path.rsplit(".", 1)[-1] if "." in rel_path.split("/")[-1] else "" + return suffix or "file" + + +def complete_file_mention( + text: str, + workspace_dir: str | None = None, +) -> list[tuple[str, str]]: + """Return candidate file paths for the ``@`` prefix at the end of *text*. + + Scans the workspace (up to 4 levels deep) and returns fuzzy-matched + file/dir names relative to *workspace_dir* (or cwd). Returns ``[]`` + when *text* does not end with an ``@``-started token. + + Args: + text: Current input text (up to cursor position). + workspace_dir: Root directory to scan for completions. + + Returns: + List of ``(completion_string, type_hint)`` tuples, e.g. + ``[("@results/v2.json", "json"), ("@README.md", "md")]``. + Directories have a trailing ``/`` and type hint ``"dir"``. + """ + # Find the last @token + match = re.search(r"@([^\s]*)$", text) + if not match: + return [] + + partial = match.group(1).replace("\\ ", " ") + base_str = workspace_dir or str(Path.cwd()) + base = Path(base_str) + + # If partial contains a path separator, check for subdirectory listing + if partial.endswith("/"): + # List directory contents + sub = (base / partial.rstrip("/")).resolve() + if not sub.is_dir(): + return [] + candidates_raw: list[str] = [] + try: + for entry in sorted(sub.iterdir()): + if entry.name.startswith("."): + continue + rel = entry.relative_to(base) + suffix = "/" if entry.is_dir() else "" + candidates_raw.append(rel.as_posix() + suffix) + except OSError: + return [] + return [ + (f"@{r}", "dir" if r.endswith("/") else _type_hint(r)) + for r in candidates_raw[:10] + ] + + # Fuzzy search over cached workspace files + all_files = _get_cached_files(base_str) + + # Also add top-level directories (for dir completion) + dir_candidates: list[str] = [] + try: + for entry in sorted(base.iterdir()): + if entry.is_dir() and not entry.name.startswith("."): + dir_candidates.append(entry.name + "/") + except OSError: + pass + + combined = all_files + dir_candidates + + # Determine query: if partial has a slash, search within that subtree + if "/" in partial: + # Filter candidates to those starting with the directory prefix + dir_prefix = partial.rsplit("/", 1)[0] + "/" + file_query = partial.rsplit("/", 1)[1] + subtree = [c for c in combined if c.startswith(dir_prefix)] + results = _fuzzy_search(file_query, subtree) + else: + results = _fuzzy_search(partial, combined) + + return [(f"@{r}", "dir" if r.endswith("/") else _type_hint(r)) for r in results] diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index 0d3f4e5..6aab52c 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -21,7 +21,9 @@ from prompt_toolkit.history import FileHistory # type: ignore[import-untyped] from prompt_toolkit.key_binding import KeyBindings # type: ignore[import-untyped] from prompt_toolkit.shortcuts import CompleteStyle # type: ignore[import-untyped] from prompt_toolkit.styles import Style as PtStyle # type: ignore[import-untyped] +from rich.markdown import Markdown from rich.markup import escape +from rich.panel import Panel from rich.table import Table from rich.text import Text @@ -50,6 +52,7 @@ from .channel import ( _message_queue, _set_channel_response, ) +from .file_mentions import complete_file_mention, resolve_file_mentions from .mcp_ui import _cmd_mcp from .skills_cmd import ( _cmd_install_skill, @@ -169,10 +172,28 @@ _PICKER_STYLE = PtStyle.from_dict( class SlashCommandCompleter(Completer): - """Autocomplete for slash commands — triggers when input starts with '/'.""" + """Autocomplete for slash commands and ``@file`` mentions.""" + + def __init__(self, workspace_dir: str | None = None) -> None: + self._workspace_dir = workspace_dir def get_completions(self, document, complete_event): text = document.text_before_cursor + + # @file mention completion + if "@" in text: + candidates = complete_file_mention(text, self._workspace_dir) + if candidates: + # Replace from the last '@' token + import re as _re + + m = _re.search(r"@[^\s]*$", text) + start = -len(m.group(0)) if m else 0 + for path, type_hint in candidates: + yield Completion(path, start_position=start, display_meta=type_hint) + return + + # Slash command completion if not text.startswith("/"): return for cmd, desc in _SLASH_COMMANDS: @@ -268,7 +289,7 @@ def cmd_interactive( session = PromptSession( history=FileHistory(history_file), auto_suggest=AutoSuggestFromHistory(), - completer=SlashCommandCompleter(), + completer=SlashCommandCompleter(workspace_dir=workspace_dir), complete_style=CompleteStyle.COLUMN, complete_while_typing=True, style=_COMPLETION_STYLE, @@ -342,52 +363,75 @@ def cmd_interactive( console.print() async def _render_history(thread_id: str): - """Display a compact conversation history for a resumed session.""" + """Display conversation history for a resumed session.""" messages = await get_thread_messages(thread_id) if not messages: return - MAX_CONTENT_LEN = 200 # truncate long messages + HISTORY_WINDOW = 50 - def _truncate(text: str) -> str: - text = text.strip() - if len(text) <= MAX_CONTENT_LEN: - return text - return text[:MAX_CONTENT_LEN] + "..." + # Only human and ai messages; skip tool/system + display = [m for m in messages if getattr(m, "type", None) in ("human", "ai")] - console.print("[dim]── Conversation history ──[/dim]") - for msg in messages: + if len(display) > HISTORY_WINDOW: + skipped = len(display) - HISTORY_WINDOW + display = display[-HISTORY_WINDOW:] + console.print(f"[dim]── ... {skipped} earlier messages ──[/dim]") + else: + console.print("[dim]── Conversation history ──[/dim]") + + for msg in display: msg_type = getattr(msg, "type", None) content = getattr(msg, "content", "") or "" - # content can be a list of blocks (multimodal) — extract text - if isinstance(content, list): - parts = [ - b.get("text", "") - for b in content - if isinstance(b, dict) and b.get("type") == "text" - ] - content = " ".join(parts) if parts else "" if msg_type == "human": - console.print( - Text.assemble( - ("\u276f ", "bold blue"), - (_truncate(content), ""), - ) - ) - elif msg_type == "ai": - tool_calls = getattr(msg, "tool_calls", None) or [] + # Extract text from multimodal list + if isinstance(content, list): + parts = [ + b.get("text", "") + for b in content + if isinstance(b, dict) and b.get("type") == "text" + ] + content = " ".join(parts) if parts else "" + content = content.strip() if content: - console.print(Text(_truncate(content), style="dim")) - if tool_calls: - names = [tc.get("name", "?") for tc in tool_calls] console.print( - Text( - f" \u25b6 {', '.join(names)}", - style="dim italic", + Text.assemble(("\u276f ", "bold blue"), (content, "")) + ) + + elif msg_type == "ai": + thinking_text = "" + text_content = "" + + if isinstance(content, list): + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "thinking": + thinking_text += block.get("thinking", "") + elif block.get("type") == "text": + text_content += block.get("text", "") + else: + text_content = content or "" + + text_content = text_content.strip() + + # Thinking panel (only when show_thinking is enabled) + if thinking_text.strip() and show_thinking: + console.print( + Panel( + thinking_text.strip(), + title="[bold blue]\U0001f4ad Thinking[/bold blue]", + border_style="blue", + expand=False, ) ) - # Skip tool messages — they are verbose and not useful in replay + + # AI response — full Markdown rendering + if text_content: + console.print(Markdown(text_content)) + + # Skip tool messages — verbose and not useful in replay console.print("[dim]── End of history ──[/dim]") console.print() @@ -407,27 +451,39 @@ def cmd_interactive( import questionary + from .widgets.thread_selector import _build_items + choices = [] - # Display-width-aware padding (CJK chars take 2 columns) - import unicodedata - - def _display_width(s: str) -> int: - w = 0 - for ch in s: - w += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 - return w - - def _pad_to_width(s: str, target: int) -> str: - pad = target - _display_width(s) - return s + " " * max(pad, 2) - - lefts = [t.get("preview", "") or t["thread_id"] for t in threads] - col_width = max(_display_width(s) for s in lefts) + 4 - for t, left_text in zip(threads, lefts, strict=False): - tid = t["thread_id"] - when = _format_relative_time(t.get("updated_at")) - label = f"{_pad_to_width(left_text, col_width)}({tid} {when})" - choices.append(questionary.Choice(title=label, value=tid)) + items = _build_items(threads) + for item in items: + if item["type"] == "header": + choices.append( + questionary.Separator( + f"\u2500\u2500 \U0001f4c2 {item['label']}" + ) + ) + elif item["type"] == "subheader": + choices.append(questionary.Separator(f" {item['label']}")) + else: + t = item["thread"] + tid = t["thread_id"] + preview = t.get("preview", "") or "" + msgs = t.get("message_count", 0) + model = t.get("model", "") or "" + when = _format_relative_time(t.get("updated_at")) + indent = " " if item.get("indented") else " " + parts = [f"{indent}{tid}"] + if preview: + parts.append( + preview[:40] + "\u2026" if len(preview) > 40 else preview + ) + parts.append(f"({msgs} msgs)") + if model: + parts.append(model) + if when: + parts.append(when) + label = " ".join(parts) + choices.append(questionary.Choice(title=label, value=tid)) from prompt_toolkit.layout.dimension import Dimension from questionary.prompts.common import InquirerControl @@ -865,13 +921,18 @@ def cmd_interactive( console.print(render_compact_result(result)) continue + # Resolve @file mentions — inject file contents inline + _, message_to_send = resolve_file_mentions( + user_input, state["workspace_dir"] + ) + # Stream agent response with metadata for persistence console.print() meta = build_metadata(state["workspace_dir"], model) run_streaming( ui_backend=state["ui_backend"], agent=state["agent"], - message=user_input, + message=message_to_send, thread_id=state["thread_id"], show_thinking=show_thinking, interactive=True, diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index dc5f2f5..50ff647 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -42,6 +42,7 @@ from .channel import ( _message_queue, _set_channel_response, ) +from .file_mentions import complete_file_mention, resolve_file_mentions from .history_suggester import HistorySuggester _channel_logger = logging.getLogger(__name__) @@ -1387,8 +1388,13 @@ def run_textual_interactive( self._render_status() cancelled = False + # Resolve @file mentions — inject file contents before sending to agent + _, message_to_send = await asyncio.to_thread( + resolve_file_mentions, user_text, workspace_dir + ) + try: - await self._stream_with_widgets(user_text) + await self._stream_with_widgets(message_to_send) except asyncio.CancelledError: cancelled = True self._append_system("\nInterrupted by user", style="dim italic #ffe082") @@ -1595,6 +1601,17 @@ def run_textual_interactive( def on_text_area_changed(self, event: ChatTextArea.Changed) -> None: text = event.text_area.text comp_widget = self.query_one("#completions", Static) + + # @file mention completion + if "@" in text: + candidates = complete_file_mention(text, workspace_dir) + if candidates: + self._comp_items = candidates + self._comp_index = -1 + self._render_completions() + comp_widget.display = True + return + if text.startswith("/"): prefix = text.lower() matches = [ @@ -1825,12 +1842,29 @@ def run_textual_interactive( return True def _apply_selected_completion(self) -> None: - """Apply the currently selected completion to the input field.""" + """Apply the currently selected completion to the input field. + + For ``@file`` completions the last ``@token`` is replaced in-place; + for slash-command completions the entire input is replaced. + """ if self._comp_index < 0 or self._comp_index >= len(self._comp_items): return - selected_cmd = self._comp_items[self._comp_index][0] + selected = self._comp_items[self._comp_index][0] prompt = self.query_one("#prompt", ChatTextArea) - prompt.value = selected_cmd + " " + + if selected.startswith("@"): + import re as _re + + current = prompt.value + m = _re.search(r"@[^\s]*$", current) + if m: + new_val = current[: m.start()] + selected + " " + else: + new_val = current + selected + " " + prompt.value = new_val + else: + prompt.value = selected + " " + prompt.cursor_position = len(prompt.value) def _hide_completions(self) -> None: @@ -1844,11 +1878,11 @@ def run_textual_interactive( for i, (cmd, desc) in enumerate(self._comp_items): if i == self._comp_index: comp_text.append("\u25b8 ", style="bold") - comp_text.append(f"{cmd:<22}", style="bold") + comp_text.append(f"{cmd:<30}", style="bold") comp_text.append(desc, style="bold") else: comp_text.append(" ", style="#888888") - comp_text.append(f"{cmd:<22}", style="#888888") + comp_text.append(f"{cmd:<30}", style="#888888") comp_text.append(desc, style="#888888") if i < len(self._comp_items) - 1: comp_text.append("\n") @@ -1874,42 +1908,81 @@ def run_textual_interactive( self._append_system(f"Unknown command: {command}", style="yellow") async def _render_history(self, thread_id_value: str) -> None: - """Render conversation history from a saved thread.""" + """Render conversation history from a saved thread. + + Restores human messages and AI responses (with Markdown and + thinking panels). Tool calls and other intermediate steps are + skipped — they are difficult to faithfully reproduce from + checkpoint data. + """ messages = await get_thread_messages(thread_id_value) if not messages: return + HISTORY_WINDOW = 50 container = self.query_one("#chat", VerticalScroll) - await container.mount( - SystemMessage("── Conversation history ──", msg_style="dim") - ) - for message in messages: + + # Only human and ai messages; skip tool/system/other + display = [ + m for m in messages if getattr(m, "type", None) in ("human", "ai") + ] + + if len(display) > HISTORY_WINDOW: + skipped = len(display) - HISTORY_WINDOW + display = display[-HISTORY_WINDOW:] + await container.mount( + SystemMessage( + f"── ... {skipped} earlier messages ──", msg_style="dim" + ) + ) + else: + await container.mount( + SystemMessage("── Conversation history ──", msg_style="dim") + ) + + for message in display: msg_type = getattr(message, "type", None) content = getattr(message, "content", "") or "" - if isinstance(content, list): - parts = [ - block.get("text", "") - for block in content - if isinstance(block, dict) and block.get("type") == "text" - ] - content = " ".join(parts) if parts else "" - content = content.strip() - if len(content) > 220: - content = content[:220] + "..." if msg_type == "human": - await container.mount(UserMessage(content)) - elif msg_type == "ai": - tool_calls = getattr(message, "tool_calls", None) or [] + if isinstance(content, list): + parts = [ + block.get("text", "") + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ] + content = " ".join(parts) if parts else "" + content = content.strip() if content: - await container.mount(Static(Text(content, style="dim"))) - if tool_calls: - names = [tc.get("name", "?") for tc in tool_calls] - await container.mount( - Static( - Text(f" \u25b6 {', '.join(names)}", style="dim italic") - ) - ) + await container.mount(UserMessage(content)) + + elif msg_type == "ai": + # Extract thinking and text blocks from content list + thinking_text = "" + text_content = "" + if isinstance(content, list): + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") == "thinking": + thinking_text += block.get("thinking", "") + elif block.get("type") == "text": + text_content += block.get("text", "") + else: + text_content = content or "" + text_content = text_content.strip() + + # Render thinking as collapsed panel (click to expand) + if thinking_text.strip() and show_thinking: + w = ThinkingWidget(show_thinking=True) + await container.mount(w) + w.append_text(thinking_text) + w.finalize() + + # Render AI response with full Markdown + if text_content: + await container.mount(AssistantMessage(text_content)) + await container.mount( SystemMessage("── End of history ──", msg_style="dim") ) diff --git a/EvoScientist/cli/widgets/thread_selector.py b/EvoScientist/cli/widgets/thread_selector.py index 80d886e..1d6d386 100644 --- a/EvoScientist/cli/widgets/thread_selector.py +++ b/EvoScientist/cli/widgets/thread_selector.py @@ -3,6 +3,15 @@ Keyboard-driven widget mounted directly into the chat container (like ApprovalWidget). Posts ``ThreadPickerWidget.Picked`` when user selects a thread, or ``ThreadPickerWidget.Cancelled`` on Esc. + +Threads are grouped into a two-level hierarchy: + + L1 header — common ancestor path shared by 2+ workspaces, or the + workspace path itself for standalone workspaces. + L2 subheader — relative sub-path shown only when a group contains + multiple workspaces. Run-mode dirs are marked with 🔁. + thread row — indented under their sub-path (or directly under L1 for + standalone groups). """ from __future__ import annotations @@ -22,20 +31,151 @@ if TYPE_CHECKING: # --------------------------------------------------------------------------- -# Helpers +# Path helpers # --------------------------------------------------------------------------- +def _normalize_path(path: str) -> str: + """Strip trailing slash and replace home directory with ~.""" + import os + + if not path: + return "" + path = path.rstrip("/") + home = os.path.expanduser("~") + if path.startswith(home): + path = "~" + path[len(home) :] + return path + + +def _common_prefix_depth(p1: str, p2: str) -> int: + """Return the number of leading path components shared by *p1* and *p2*.""" + depth = 0 + for a, b in zip(p1.split("/"), p2.split("/"), strict=False): + if a == b: + depth += 1 + else: + break + return depth + + +def _is_run_path(rel: str) -> bool: + """Return True if *rel* (relative to group ancestor) is a run-mode dir.""" + return "runs" in rel.split("/") + + +def _group_by_ancestor(norm_paths: list[str]) -> dict[str, list[str]]: + """Group normalized paths by their deepest common ancestor. + + Two paths are placed in the same group when they share a common prefix + of at least 2 components (e.g. ``~/Projects``). Paths with no such + shared prefix become standalone single-item groups keyed by their own + full path. + + The returned dict is ordered by first appearance in *norm_paths*. + """ + path_to_ancestor: dict[str, str] = {} + for i, p in enumerate(norm_paths): + best = 1 # at minimum depth 1 (~) + for j, other in enumerate(norm_paths): + if i != j: + best = max(best, _common_prefix_depth(p, other)) + # Only group if they truly share a meaningful ancestor (>= 2 levels) + if best >= 2: + ancestor = "/".join(p.split("/")[:best]) + else: + ancestor = p # standalone + path_to_ancestor[p] = ancestor + + groups: dict[str, list[str]] = {} + for p in norm_paths: + anc = path_to_ancestor[p] + if anc not in groups: + groups[anc] = [] + groups[anc].append(p) + return groups + + +# --------------------------------------------------------------------------- +# Item builders +# --------------------------------------------------------------------------- + + +def _build_items(threads: list[dict]) -> list[dict]: + """Build the flat item list rendered by ThreadPickerWidget. + + Returns a list whose elements are one of:: + + {"type": "header", "label": str} + {"type": "subheader", "label": str} + {"type": "thread", "thread": dict, "indented": bool} + + *indented* is True for thread rows that sit under a L2 subheader. + """ + if not threads: + return [] + + # Map normalized path -> list[thread dicts], preserving first-seen order + raw_to_threads: dict[str, list[dict]] = {} + seen_order: list[str] = [] + for t in threads: + raw = t.get("workspace_dir", "") or "" + norm = _normalize_path(raw) or raw + if norm not in raw_to_threads: + raw_to_threads[norm] = [] + seen_order.append(norm) + raw_to_threads[norm].append(t) + + groups = _group_by_ancestor(seen_order) + + items: list[dict] = [] + for ancestor, norm_paths in groups.items(): + multi = len(norm_paths) > 1 + + # L1 header — the common ancestor (or the sole workspace path) + items.append({"type": "header", "label": ancestor or "(no workspace)"}) + + for norm_path in norm_paths: + if multi: + # L2 subheader — relative path from ancestor + rel = norm_path[len(ancestor) :].lstrip("/") + if not rel: + # norm_path IS the ancestor (standalone group of 1 that shares + # an ancestor with others); show just the last path component + rel = norm_path.split("/")[-1] or norm_path + icon = "🔁" if _is_run_path(rel) else "📁" + items.append({"type": "subheader", "label": f"{icon} {rel}"}) + + for t in raw_to_threads[norm_path]: + items.append({"type": "thread", "thread": t, "indented": multi}) + + return items + + +def build_header_text(label: str) -> Text: + """L1 header: ``── 📂