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: ``── 📂