* feat(tui): enhance conversation history rendering and implement two-level thread hierarchy in picker

* feat(tui): improve conversation history display and enhance thread selection UI

* feat(file_mentions): implement @file mention parsing and completion for CLI and TUI

* feat(uv-tool): add compatibility checks and installation helpers for uv tool environments

* feat(dependencies): update package versions in uv.lock for compatibility and improvements

* feat(badges): update PyPI version to v0.0.4 in SVG assets and README files

* feat(tests): format code in TestUvToolCompat for improved readability
This commit is contained in:
Xi Zhang
2026-03-24 19:13:42 +01:00
committed by GitHub
parent cc266dd6b1
commit fab5f85eee
14 changed files with 1300 additions and 172 deletions
+1 -1
View File
@@ -5,5 +5,5 @@
<rect x="54" y="5" width="62" height="24" rx="6" fill="#1565c0"/>
<text x="85" y="22" text-anchor="middle"
font-family="Inter, -apple-system, system-ui, sans-serif"
font-size="13" font-weight="700" fill="#ffffff">v0.0.3</text>
font-size="13" font-weight="700" fill="#ffffff">v0.0.4</text>
</svg>

Before

Width:  |  Height:  |  Size: 555 B

After

Width:  |  Height:  |  Size: 555 B

+1 -1
View File
@@ -5,5 +5,5 @@
<rect x="54" y="5" width="62" height="24" rx="6" fill="#2563eb"/>
<text x="85" y="22" text-anchor="middle"
font-family="Inter, -apple-system, system-ui, sans-serif"
font-size="13" font-weight="700" fill="#ffffff">v0.0.3</text>
font-size="13" font-weight="700" fill="#ffffff">v0.0.4</text>
</svg>

Before

Width:  |  Height:  |  Size: 555 B

After

Width:  |  Height:  |  Size: 555 B

+343
View File
@@ -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>(?:\\.|[" + _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]
+116 -55
View File
@@ -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,
+105 -32
View File
@@ -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")
)
+217 -37
View File
@@ -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: ``── 📂 <label>``."""
t = Text()
t.append("\u2500\u2500 \U0001f4c2 ", style="bold cyan")
t.append(label, style="bold cyan")
return t
def build_subheader_text(label: str) -> Text:
"""L2 subheader: `` <icon> <rel-path>`` (indented, dim)."""
t = Text()
t.append(" ", style="")
t.append(label, style="dim")
return t
def build_row_text(
thread: dict,
*,
selected: bool = False,
current: bool = False,
indented: bool = False,
) -> Text:
"""Build a Rich Text object for a single thread row.
Pure function — no Textual app context required, safe for unit tests.
"""
"""Thread row. *indented* adds extra leading space for L2-grouped rows."""
from ...sessions import _format_relative_time
tid = thread["thread_id"]
@@ -45,16 +185,19 @@ def build_row_text(
when = _format_relative_time(thread.get("updated_at"))
line = Text()
# Extra indent when nested under a subheader
if indented:
line.append(" ", style="")
cursor = "\u25b8 " if selected else " "
line.append(cursor, style="bold cyan" if selected else "dim")
line.append(f"{tid}", style="bold" if selected else "")
line.append(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(display_preview)
line.append(" ")
line.append(f"({msgs} msgs)", style="dim")
if model:
line.append(f" {model}", style="dim italic")
@@ -72,7 +215,7 @@ 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``.
Threads are displayed in a two-level workspace hierarchy.
"""
can_focus = True
@@ -81,7 +224,7 @@ class ThreadPickerWidget(Widget):
DEFAULT_CSS = """
ThreadPickerWidget {
height: auto;
max-height: 22;
max-height: 26;
margin: 1 0;
padding: 0 1;
background: $surface;
@@ -94,9 +237,18 @@ class ThreadPickerWidget(Widget):
}
ThreadPickerWidget .picker-rows {
height: auto;
max-height: 16;
max-height: 20;
overflow-y: auto;
}
ThreadPickerWidget .picker-header {
height: 1;
padding: 0 1;
margin-top: 1;
}
ThreadPickerWidget .picker-subheader {
height: 1;
padding: 0 1;
}
ThreadPickerWidget .picker-row {
height: 1;
padding: 0 1;
@@ -122,8 +274,6 @@ class ThreadPickerWidget(Widget):
]
class Picked(Message):
"""Posted when user picks a thread."""
def __init__(self, thread_id: str) -> None:
super().__init__()
self.thread_id = thread_id
@@ -143,14 +293,38 @@ class ThreadPickerWidget(Widget):
self._threads = threads
self._current_thread = current_thread
self._title = title
self._selected = 0
self._items = _build_items(threads)
self._selected = self._first_thread_index()
self._row_widgets: list[Static] = []
def _first_thread_index(self) -> int:
for i, item in enumerate(self._items):
if item["type"] == "thread":
return i
return 0
def _move(self, direction: int) -> None:
if not self._items:
return
i = (self._selected + direction) % len(self._items)
steps = 0
while self._items[i]["type"] != "thread" and steps < len(self._items):
i = (i + direction) % len(self._items)
steps += 1
if self._items[i]["type"] == "thread":
self._selected = i
self._update_rows()
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")
for item in self._items:
css = {
"header": "picker-header",
"subheader": "picker-subheader",
"thread": "picker-row",
}.get(item["type"], "picker-row")
widget = Static("", classes=css)
self._row_widgets.append(widget)
yield widget
yield Static(
@@ -163,40 +337,46 @@ class ThreadPickerWidget(Widget):
self.call_later(self.focus)
def _update_rows(self) -> None:
for i, (thread, widget) in enumerate(
zip(self._threads, self._row_widgets, strict=False)
for i, (item, widget) in enumerate(
zip(self._items, self._row_widgets, strict=False)
):
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()
if item["type"] == "header":
widget.update(build_header_text(item["label"]))
elif item["type"] == "subheader":
widget.update(build_subheader_text(item["label"]))
else:
thread = item["thread"]
is_selected = i == self._selected
text = build_row_text(
thread,
selected=is_selected,
current=thread["thread_id"] == self._current_thread,
indented=item.get("indented", False),
)
widget.update(text)
if is_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()
self._move(-1)
def action_move_down(self) -> None:
if not self._threads:
return
self._selected = (self._selected + 1) % len(self._threads)
self._update_rows()
self._move(1)
def action_select(self) -> None:
if not self._threads:
if not self._items or self._selected >= len(self._items):
self.post_message(self.Cancelled())
return
self.post_message(self.Picked(self._threads[self._selected]["thread_id"]))
item = self._items[self._selected]
if item["type"] == "thread":
self.post_message(self.Picked(item["thread"]["thread_id"]))
else:
self.post_message(self.Cancelled())
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)
+50 -3
View File
@@ -62,8 +62,23 @@ class MCPServerEntry:
# =============================================================================
def _is_uv_tool_env() -> bool:
"""Return True when running inside a ``uv tool install`` isolated environment.
Detection: ``VIRTUAL_ENV`` is set and its path contains the uv tools
directory segment (``/uv/tools/`` on Unix, ``\\uv\\tools\\`` on Windows).
"""
virtual_env = os.environ.get("VIRTUAL_ENV", "")
if not virtual_env:
return False
normalized = virtual_env.replace("\\", "/")
return "/uv/tools/" in normalized
def pip_install_hint() -> str:
"""Human-readable install command for error messages."""
if _is_uv_tool_env():
return "uv tool install --reinstall evoscientist --with"
if shutil.which("uv"):
return "uv pip install"
return "pip install"
@@ -72,13 +87,18 @@ def pip_install_hint() -> str:
def install_pip_package(package: str) -> bool:
"""Silently install a pip package.
Tries ``uv pip install`` first, then ``python -m pip install``.
When ``uv`` is available, uses ``uv pip install --python sys.executable``
to target the current interpreter directly — this works for uv tool envs,
standard venvs, conda envs, and system Python without needing ``--system``.
Falls back to ``python -m pip install`` when uv is not available.
Returns True if installation succeeded.
"""
commands: list[list[str]] = []
if shutil.which("uv"):
commands.append(["uv", "pip", "install", "-q", package])
commands.append(
["uv", "pip", "install", "--python", sys.executable, "-q", package]
)
commands.append([sys.executable, "-m", "pip", "install", "-q", package])
for cmd in commands:
@@ -94,6 +114,30 @@ def install_pip_package(package: str) -> bool:
return False
def _resolve_command_path(command: str) -> str:
"""Resolve a command to its full path after pip installation.
Checks PATH first (standard case), then the bin directory of the current
Python interpreter — handles uv tool envs where a newly installed binary
is not on PATH but lives alongside the tool's Python executable.
Returns the full absolute path if found, otherwise the original string.
"""
if os.path.isabs(command):
return command
found = shutil.which(command)
if found:
return found
candidate = Path(sys.executable).parent / command
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
if os.name == "nt":
candidate_exe = candidate.with_suffix(".exe")
if candidate_exe.is_file():
return str(candidate_exe)
return command
# =============================================================================
# Marketplace index (YAML files in EvoSkills/mcp/)
# =============================================================================
@@ -257,10 +301,13 @@ def install_mcp_server(
headers=entry.headers,
)
else:
resolved_cmd = (
_resolve_command_path(entry.command) if entry.command else entry.command
)
add_mcp_server(
entry.name,
entry.transport,
command=entry.command,
command=resolved_cmd,
args=entry.args,
env=entry.env,
)
+1 -1
View File
@@ -10,7 +10,7 @@
<a href="https://pypi.org/project/EvoScientist/"><picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-light.svg">
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-dark.svg">
<img alt="PyPI v0.0.3" src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-light.svg" height="28">
<img alt="PyPI v0.0.4" src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-light.svg" height="28">
</picture></a><a href="https://EvoScientist.github.io/"><picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-website-light.svg">
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-website-dark.svg">
+1 -1
View File
@@ -15,7 +15,7 @@
<a href="https://pypi.org/project/EvoScientist/"><picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-light.svg">
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-dark.svg">
<img alt="PyPI v0.0.3" src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-light.svg" height="28">
<img alt="PyPI v0.0.4" src="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-pypi-light.svg" height="28">
</picture></a><a href="https://EvoScientist.github.io/"><picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-website-light.svg">
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/EvoScientist/EvoScientist/main/.github/assets/badge-website-dark.svg">
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "EvoScientist"
version = "0.0.3"
version = "0.0.4"
description = "EvoScientist: Towards Self-Evolving AI Scientists for End-to-End Scientific Discovery"
readme = "README.md"
requires-python = ">=3.11"
+247
View File
@@ -0,0 +1,247 @@
"""Tests for EvoScientist.cli.file_mentions module."""
from __future__ import annotations
from pathlib import Path
from EvoScientist.cli.file_mentions import (
complete_file_mention,
invalidate_file_cache,
parse_file_mentions,
resolve_file_mentions,
)
# ---------------------------------------------------------------------------
# parse_file_mentions
# ---------------------------------------------------------------------------
class TestParseFileMentions:
def test_single_mention(self, tmp_path: Path) -> None:
f = tmp_path / "paper.tex"
f.write_text("hello")
result = parse_file_mentions(f"read @{f}", cwd=tmp_path)
assert result == [f.resolve()]
def test_relative_mention(self, tmp_path: Path) -> None:
f = tmp_path / "notes.md"
f.write_text("world")
result = parse_file_mentions("check @notes.md", cwd=tmp_path)
assert result == [f.resolve()]
def test_multiple_mentions(self, tmp_path: Path) -> None:
a = tmp_path / "a.py"
b = tmp_path / "b.py"
a.write_text("a")
b.write_text("b")
result = parse_file_mentions(f"diff @{a} @{b}", cwd=tmp_path)
assert set(result) == {a.resolve(), b.resolve()}
def test_missing_file_skipped(self, tmp_path: Path) -> None:
result = parse_file_mentions("@nonexistent.txt", cwd=tmp_path)
assert result == []
def test_email_address_ignored(self, tmp_path: Path) -> None:
result = parse_file_mentions("send to user@example.com", cwd=tmp_path)
assert result == []
def test_directory_excluded(self, tmp_path: Path) -> None:
d = tmp_path / "subdir"
d.mkdir()
result = parse_file_mentions(f"@{d}", cwd=tmp_path)
assert result == []
def test_bare_at_sign_ignored(self, tmp_path: Path) -> None:
result = parse_file_mentions("hello @ world", cwd=tmp_path)
assert result == []
def test_tilde_expansion(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
f = tmp_path / "file.txt"
f.write_text("x")
result = parse_file_mentions("@~/file.txt", cwd=tmp_path)
assert result == [f.resolve()]
# ---------------------------------------------------------------------------
# resolve_file_mentions
# ---------------------------------------------------------------------------
class TestResolveFileMentions:
def test_no_mentions_returns_original(self, tmp_path: Path) -> None:
text = "just a normal message"
original, final = resolve_file_mentions(text, str(tmp_path))
assert original == text
assert final == text
def test_mention_appends_content(self, tmp_path: Path) -> None:
f = tmp_path / "README.md"
f.write_text("# Hello")
text = f"summarise @{f}"
original, final = resolve_file_mentions(text, str(tmp_path))
assert original == text
assert "## Referenced Files" in final
assert "README.md" in final
assert "# Hello" in final
def test_large_file_reference_only(self, tmp_path: Path) -> None:
f = tmp_path / "big.bin"
# Write more than 256 KB
f.write_bytes(b"x" * (260 * 1024))
text = f"read @{f}"
_, final = resolve_file_mentions(text, str(tmp_path))
assert "too large to embed" in final
assert "read_file" in final
def test_multiple_files_all_embedded(self, tmp_path: Path) -> None:
a = tmp_path / "a.txt"
b = tmp_path / "b.txt"
a.write_text("AAA")
b.write_text("BBB")
_, final = resolve_file_mentions(f"@{a} and @{b}", str(tmp_path))
assert "AAA" in final
assert "BBB" in final
def test_original_text_always_first(self, tmp_path: Path) -> None:
f = tmp_path / "f.txt"
f.write_text("content")
text = f"look at @{f} please"
_, final = resolve_file_mentions(text, str(tmp_path))
assert final.startswith(text)
def test_none_workspace_uses_cwd(self, tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
f = tmp_path / "local.txt"
f.write_text("local")
_, final = resolve_file_mentions("@local.txt", None)
assert "local" in final
# ---------------------------------------------------------------------------
# complete_file_mention
# ---------------------------------------------------------------------------
class TestCompleteFileMention:
def setup_method(self) -> None:
# Ensure cache is clean between tests
invalidate_file_cache()
def test_no_at_sign_returns_empty(self, tmp_path: Path) -> None:
result = complete_file_mention("hello world", str(tmp_path))
assert result == []
def test_at_sign_alone_lists_files(self, tmp_path: Path) -> None:
(tmp_path / "paper.tex").write_text("")
(tmp_path / "code.py").write_text("")
result = complete_file_mention("read @", str(tmp_path))
paths = [p for p, _ in result]
names = [Path(p.lstrip("@")).name for p in paths]
assert "paper.tex" in names
assert "code.py" in names
def test_partial_prefix_filters(self, tmp_path: Path) -> None:
(tmp_path / "paper.tex").write_text("")
(tmp_path / "data.csv").write_text("")
result = complete_file_mention("@pa", str(tmp_path))
paths = [p for p, _ in result]
assert any("paper" in p for p in paths)
assert not any("data" in p for p in paths)
def test_directory_has_trailing_slash(self, tmp_path: Path) -> None:
(tmp_path / "workspace").mkdir()
result = complete_file_mention("@work", str(tmp_path))
paths = [p for p, _ in result]
assert any(p.endswith("/") for p in paths)
def test_hidden_files_excluded(self, tmp_path: Path) -> None:
(tmp_path / ".hidden").write_text("")
(tmp_path / "visible.txt").write_text("")
result = complete_file_mention("@", str(tmp_path))
paths = [p for p, _ in result]
assert not any(".hidden" in p for p in paths)
assert any("visible" in p for p in paths)
def test_mid_text_at_completes(self, tmp_path: Path) -> None:
(tmp_path / "intro.tex").write_text("")
result = complete_file_mention("please look at @intro", str(tmp_path))
paths = [p for p, _ in result]
assert any("intro.tex" in p for p in paths)
def test_no_at_in_text_returns_empty(self, tmp_path: Path) -> None:
result = complete_file_mention("no mention here", str(tmp_path))
assert result == []
def test_returns_tuples(self, tmp_path: Path) -> None:
(tmp_path / "report.md").write_text("")
result = complete_file_mention("@rep", str(tmp_path))
assert result # at least one result
assert isinstance(result[0], tuple)
assert len(result[0]) == 2
def test_type_hint_extension(self, tmp_path: Path) -> None:
(tmp_path / "script.py").write_text("")
(tmp_path / "notes.md").write_text("")
(tmp_path / "data.csv").write_text("")
result = complete_file_mention("@", str(tmp_path))
hint_map = {Path(p.lstrip("@")).name: h for p, h in result}
assert hint_map.get("script.py") == "py"
assert hint_map.get("notes.md") == "md"
assert hint_map.get("data.csv") == "csv"
def test_type_hint_no_extension(self, tmp_path: Path) -> None:
(tmp_path / "Makefile").write_text("")
result = complete_file_mention("@Make", str(tmp_path))
hint_map = {Path(p.lstrip("@")).name: h for p, h in result}
assert hint_map.get("Makefile") == "file"
def test_directory_type_hint(self, tmp_path: Path) -> None:
(tmp_path / "subdir").mkdir()
result = complete_file_mention("@sub", str(tmp_path))
pairs = dict(result)
# The dir entry ends with /
dir_entries = [(p, h) for p, h in pairs.items() if p.endswith("/")]
assert dir_entries
assert dir_entries[0][1] == "dir"
def test_fuzzy_match_partial_query(self, tmp_path: Path) -> None:
"""'resu' should fuzzy-match 'results.json'."""
(tmp_path / "results.json").write_text("")
(tmp_path / "readme.txt").write_text("")
result = complete_file_mention("@resu", str(tmp_path))
paths = [p for p, _ in result]
assert any("results.json" in p for p in paths)
def test_fuzzy_deep_file(self, tmp_path: Path) -> None:
"""Files nested 2+ levels deep are discovered by fuzzy search."""
nested = tmp_path / "src" / "models"
nested.mkdir(parents=True)
(nested / "base.py").write_text("")
result = complete_file_mention("@base", str(tmp_path))
paths = [p for p, _ in result]
assert any("base.py" in p for p in paths)
def test_cache_reuse(self, tmp_path: Path) -> None:
"""Second call with same workspace does not rescan."""
(tmp_path / "file1.txt").write_text("")
ws = str(tmp_path)
# Warm cache
complete_file_mention("@", ws)
# Add a new file — should NOT appear until cache is invalidated
(tmp_path / "new_file.txt").write_text("")
result = complete_file_mention("@new", ws)
paths = [p for p, _ in result]
assert not any("new_file" in p for p in paths)
def test_invalidate_cache_triggers_rescan(self, tmp_path: Path) -> None:
"""After invalidation, new files appear in results."""
(tmp_path / "file1.txt").write_text("")
ws = str(tmp_path)
complete_file_mention("@", ws)
# Add new file then invalidate
(tmp_path / "new_file.txt").write_text("")
invalidate_file_cache(ws)
result = complete_file_mention("@new", ws)
paths = [p for p, _ in result]
assert any("new_file" in p for p in paths)
+168
View File
@@ -740,3 +740,171 @@ class TestParseMcpEditArgs:
def test_no_fields_raises(self):
with pytest.raises(ValueError, match="No fields"):
parse_mcp_edit_args(["srv"])
# ---- uv tool compatibility ----
class TestUvToolCompat:
"""Tests for uv tool environment detection and compatible install helpers."""
# -- _is_uv_tool_env --
def test_is_uv_tool_env_false_when_no_virtual_env(self, monkeypatch):
from EvoScientist.mcp.registry import _is_uv_tool_env
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
assert _is_uv_tool_env() is False
def test_is_uv_tool_env_false_for_regular_venv(self, monkeypatch):
from EvoScientist.mcp.registry import _is_uv_tool_env
monkeypatch.setenv("VIRTUAL_ENV", "/home/user/projects/myapp/.venv")
assert _is_uv_tool_env() is False
def test_is_uv_tool_env_true_unix(self, monkeypatch):
from EvoScientist.mcp.registry import _is_uv_tool_env
monkeypatch.setenv(
"VIRTUAL_ENV", "/home/user/.local/share/uv/tools/evoscientist"
)
assert _is_uv_tool_env() is True
def test_is_uv_tool_env_true_windows_backslashes(self, monkeypatch):
from EvoScientist.mcp.registry import _is_uv_tool_env
monkeypatch.setenv(
"VIRTUAL_ENV", r"C:\Users\user\AppData\Local\uv\tools\evoscientist"
)
assert _is_uv_tool_env() is True
# -- pip_install_hint --
def test_pip_install_hint_uv_tool(self, monkeypatch):
import EvoScientist.mcp.registry as reg
monkeypatch.setattr(reg, "_is_uv_tool_env", lambda: True)
hint = reg.pip_install_hint()
assert "uv tool install --reinstall evoscientist --with" in hint
def test_pip_install_hint_uv_no_tool(self, monkeypatch):
import EvoScientist.mcp.registry as reg
monkeypatch.setattr(reg, "_is_uv_tool_env", lambda: False)
monkeypatch.setattr(
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
)
assert reg.pip_install_hint() == "uv pip install"
def test_pip_install_hint_plain_pip(self, monkeypatch):
import EvoScientist.mcp.registry as reg
monkeypatch.setattr(reg, "_is_uv_tool_env", lambda: False)
monkeypatch.setattr(reg.shutil, "which", lambda x: None)
assert reg.pip_install_hint() == "pip install"
# -- install_pip_package --
def test_install_pip_package_uses_python_flag_when_uv_available(self, monkeypatch):
import sys
import EvoScientist.mcp.registry as reg
captured: list[list[str]] = []
def fake_run(cmd, **kwargs):
captured.append(cmd)
ns = type("R", (), {"returncode": 0})()
return ns
monkeypatch.setattr(
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
)
monkeypatch.setattr(reg.subprocess, "run", fake_run)
result = reg.install_pip_package("some-package")
assert result is True
assert len(captured) == 1
cmd = captured[0]
assert "uv" in cmd[0]
assert "--python" in cmd
assert sys.executable in cmd
def test_install_pip_package_falls_back_to_pip_when_no_uv(self, monkeypatch):
import sys
import EvoScientist.mcp.registry as reg
captured: list[list[str]] = []
def fake_run(cmd, **kwargs):
captured.append(cmd)
ns = type("R", (), {"returncode": 0})()
return ns
monkeypatch.setattr(reg.shutil, "which", lambda x: None)
monkeypatch.setattr(reg.subprocess, "run", fake_run)
reg.install_pip_package("some-package")
assert len(captured) == 1
assert sys.executable in captured[0]
assert "-m" in captured[0]
assert "pip" in captured[0]
# -- _resolve_command_path --
def test_resolve_command_path_absolute_passthrough(self):
from EvoScientist.mcp.registry import _resolve_command_path
assert _resolve_command_path("/usr/bin/my-tool") == "/usr/bin/my-tool"
def test_resolve_command_path_found_in_bin_dir(self, monkeypatch, tmp_path):
import sys
import EvoScientist.mcp.registry as reg
# Create a fake executable in a temp bin dir
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
fake_exe = bin_dir / "my-mcp-server"
fake_exe.touch()
fake_exe.chmod(0o755)
# Point sys.executable to something in that bin dir
fake_python = bin_dir / "python"
fake_python.touch()
monkeypatch.setattr(sys, "executable", str(fake_python))
# Ensure shutil.which won't find it on PATH
monkeypatch.setattr(reg.shutil, "which", lambda x: None)
result = reg._resolve_command_path("my-mcp-server")
assert result == str(fake_exe)
def test_resolve_command_path_windows_exe_suffix(self, monkeypatch, tmp_path):
import os
import sys
import EvoScientist.mcp.registry as reg
if os.name != "nt":
pytest.skip("Windows-only behaviour")
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
fake_exe = bin_dir / "my-mcp-server.exe"
fake_exe.touch()
monkeypatch.setattr(sys, "executable", str(bin_dir / "python.exe"))
monkeypatch.setattr(reg.shutil, "which", lambda x: None)
result = reg._resolve_command_path("my-mcp-server")
assert result == str(fake_exe)
def test_resolve_command_path_returns_bare_when_not_found(
self, monkeypatch, tmp_path
):
import sys
import EvoScientist.mcp.registry as reg
monkeypatch.setattr(reg.shutil, "which", lambda x: None)
monkeypatch.setattr(sys, "executable", str(tmp_path / "bin" / "python"))
result = reg._resolve_command_path("nonexistent-tool")
assert result == "nonexistent-tool"
+15 -6
View File
@@ -22,6 +22,7 @@ _THREADS = [
"message_count": 12,
"model": "claude-sonnet-4-6",
"updated_at": "2026-03-09T10:00:00+00:00",
"workspace_dir": "/workspace",
},
{
"thread_id": "def67890",
@@ -29,6 +30,7 @@ _THREADS = [
"message_count": 5,
"model": "gpt-4o",
"updated_at": "2026-03-08T08:00:00+00:00",
"workspace_dir": "/workspace",
},
{
"thread_id": "ghi11111",
@@ -36,6 +38,7 @@ _THREADS = [
"message_count": 0,
"model": "",
"updated_at": None,
"workspace_dir": "/workspace",
},
]
@@ -114,7 +117,8 @@ class TestThreadPickerWidget:
picker = ThreadPickerWidget(_THREADS, current_thread="abc12345")
assert picker._threads == _THREADS
assert picker._current_thread == "abc12345"
assert picker._selected == 0
# items = [header, thread0, thread1, thread2] — first thread is at index 1
assert picker._selected == 1
def test_init_empty_threads(self):
picker = ThreadPickerWidget([])
@@ -131,19 +135,23 @@ class TestThreadPickerWidget:
def test_action_move_down_wraps(self):
picker = ThreadPickerWidget(_THREADS)
picker._row_widgets = [mock.MagicMock() for _ in _THREADS]
picker._selected = len(_THREADS) - 1
# items = [header@0, t0@1, t1@2, t2@3]; last thread is at index 3
picker._selected = len(picker._items) - 1
# Mock _update_rows to avoid Textual update calls
picker._update_rows = mock.MagicMock()
picker.action_move_down()
assert picker._selected == 0
# wraps past header back to first thread at index 1
assert picker._selected == 1
def test_action_move_up_wraps(self):
picker = ThreadPickerWidget(_THREADS)
picker._row_widgets = [mock.MagicMock() for _ in _THREADS]
picker._selected = 0
# items = [header@0, t0@1, t1@2, t2@3]; start at first thread
picker._selected = 1
picker._update_rows = mock.MagicMock()
picker.action_move_up()
assert picker._selected == len(_THREADS) - 1
# wraps past header back to last thread at index 3
assert picker._selected == len(picker._items) - 1
def test_action_move_down_increments(self):
picker = ThreadPickerWidget(_THREADS)
@@ -170,7 +178,8 @@ class TestThreadPickerWidget:
def test_action_select_posts_picked(self):
picker = ThreadPickerWidget(_THREADS)
picker._selected = 1
# items = [header@0, t0@1, t1@2, t2@3]; select t1 (def67890) at index 2
picker._selected = 2
picker.post_message = mock.MagicMock()
picker.action_select()
msg = picker.post_message.call_args[0][0]
Generated
+34 -34
View File
@@ -188,15 +188,15 @@ wheels = [
[[package]]
name = "anyio"
version = "4.12.1"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
@@ -350,7 +350,7 @@ wheels = [
[[package]]
name = "ccproxy-api"
version = "0.2.4"
version = "0.2.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -368,9 +368,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uvicorn" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/c6/7287f6f8218b51f1cc45a2b6da77368bb935dc0e77971f7b34dd155f3739/ccproxy_api-0.2.4.tar.gz", hash = "sha256:4bd726383cae177b18bd043b6a0a00839f543dbcd97c28a141c41c7c8dcfe690", size = 673551, upload-time = "2026-02-26T04:42:42.684Z" }
sdist = { url = "https://files.pythonhosted.org/packages/29/ea/3b277688847ed229f24ff32875597cf215176f52a9fcea7a0dafde380ce9/ccproxy_api-0.2.6.tar.gz", hash = "sha256:9fea2c59c632db653b7254ee3c3833e716665598d1f9b1f7d1af3c8c79728648", size = 680952, upload-time = "2026-03-22T20:44:21.05Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/4a/a92bf61dda9759acf43a201935d859a8742cdfee84f2a1916d545ab5b036/ccproxy_api-0.2.4-py3-none-any.whl", hash = "sha256:bb5c0b5dee4f59d501a1742bcddf18219f28cff0104fd337aaccd09575d30def", size = 870054, upload-time = "2026-02-26T04:42:41.1Z" },
{ url = "https://files.pythonhosted.org/packages/de/d3/ff6613b59db9ae1587905d3d631555e5eee116b0cca8ad8cff5058397541/ccproxy_api-0.2.6-py3-none-any.whl", hash = "sha256:4333ae6f2c5b39b8ca15c20624725e34295651ee810d476233b73bf6fba7e49e", size = 877705, upload-time = "2026-03-22T20:44:18.942Z" },
]
[[package]]
@@ -872,7 +872,7 @@ wheels = [
[[package]]
name = "evoscientist"
version = "0.0.3"
version = "0.0.4"
source = { editable = "." }
dependencies = [
{ name = "deepagents" },
@@ -1012,7 +1012,7 @@ dev = [
[[package]]
name = "fastapi"
version = "0.135.1"
version = "0.135.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -1021,9 +1021,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" },
{ url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" },
]
[package.optional-dependencies]
@@ -1828,11 +1828,11 @@ wheels = [
[[package]]
name = "jsonpointer"
version = "3.0.0"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" }
sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" },
{ url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" },
]
[[package]]
@@ -1927,7 +1927,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.2.20"
version = "1.2.21"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
@@ -1939,9 +1939,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/41/6552a419fe549a79601e5a698d1d5ee2ca7fe93bb87fd624a16a8c1bdee3/langchain_core-1.2.20.tar.gz", hash = "sha256:c7ac8b976039b5832abb989fef058b88c270594ba331efc79e835df046e7dc44", size = 838330, upload-time = "2026-03-18T17:34:45.522Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/e4/135ef5bbb5b97bdf15f777b86d7fba2ef8a162723ae96b3c7c1add9891a9/langchain_core-1.2.21.tar.gz", hash = "sha256:1a121d13976dc0908d5a8222262810ea483a4cf2b05006bdba75df5b11b554b3", size = 841063, upload-time = "2026-03-23T18:01:01.674Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/06/08c88ddd4d6766de4e6c43111ae8f3025df383d2a4379cb938fc571b49d4/langchain_core-1.2.20-py3-none-any.whl", hash = "sha256:b65ff678f3c3dc1f1b4d03a3af5ee3b8d51f9be5181d74eb53c6c11cd9dd5e68", size = 504215, upload-time = "2026-03-18T17:34:44.087Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ae/f7591e57d4c6b64b521f9832fc471070902718c59bf135401f3b90a3f7ef/langchain_core-1.2.21-py3-none-any.whl", hash = "sha256:486cb405e2ecb0c407cb5fb5379ed0f919eb4b8a868b60cc8c3c15a3dfb560a7", size = 505756, upload-time = "2026-03-23T18:01:00.176Z" },
]
[[package]]
@@ -2003,16 +2003,16 @@ wheels = [
[[package]]
name = "langchain-openai"
version = "1.1.11"
version = "1.1.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
{ name = "openai" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/cd/439be2b8deb8bd0d4c470c7c7f66698a84d823e583c3d36a322483cb7cab/langchain_openai-1.1.11.tar.gz", hash = "sha256:44b003a2960d1f6699f23721196b3b97d0c420d2e04444950869213214b7a06a", size = 1088560, upload-time = "2026-03-09T23:02:36.894Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/fd/7dee16e882c4c1577d48db174d85aa3a0ee09ba61eb6a5d41650285ca80c/langchain_openai-1.1.12.tar.gz", hash = "sha256:ccf5ef02c896f6807b4d0e51aaf678a72ce81ae41201cae8d65e11eeff9ecb79", size = 1114119, upload-time = "2026-03-23T18:59:19.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/0f/e4cb42848c25f65969adfb500a06dea1a541831604250fd0d8aa6e54fef5/langchain_openai-1.1.11-py3-none-any.whl", hash = "sha256:a03596221405d38d6852fb865467cb0d9ff9e79f335905eb6a576e8c4874ac71", size = 87694, upload-time = "2026-03-09T23:02:35.651Z" },
{ url = "https://files.pythonhosted.org/packages/6e/a6/68fb22e3604015e6f546fa1d3677d24378b482855ae74710cbf4aec44132/langchain_openai-1.1.12-py3-none-any.whl", hash = "sha256:da71ca3f2d18c16f7a2443cc306aa195ad2a07054335ac9b0626dcae02b6a0c5", size = 88487, upload-time = "2026-03-23T18:59:17.978Z" },
]
[[package]]
@@ -2034,7 +2034,7 @@ wheels = [
[[package]]
name = "langgraph-api"
version = "0.7.82"
version = "0.7.86"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cloudpickle" },
@@ -2065,9 +2065,9 @@ dependencies = [
{ name = "uvicorn" },
{ name = "watchfiles" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/8e/e6c51bc0ba532373df091747bd97b4464cfb94e5e97d4648725c1c81a4e5/langgraph_api-0.7.82.tar.gz", hash = "sha256:19280a2f739eb0660b09bc07e0b5c4beba5ae505f0d87c1a5f8762e1e419c8cd", size = 637928, upload-time = "2026-03-20T14:03:00.921Z" }
sdist = { url = "https://files.pythonhosted.org/packages/62/99/889aa81320bbc1d0a6a563def8371aedc6c6f1342899f476224862ade8f2/langgraph_api-0.7.86.tar.gz", hash = "sha256:36b8d665cd1bba3052a01c5438950acfc05d1f9ee32f7c51a0f03f2160d2fa0f", size = 638676, upload-time = "2026-03-23T22:28:54.755Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/4a/7cd7b33fc4cdfb508c641df3df248d65398c2a4c516fbfaad786f1cc64e3/langgraph_api-0.7.82-py3-none-any.whl", hash = "sha256:235311f972a5dc0f8d08d1600018ffd7cbdce8a37013d210cc678b2a95c888ce", size = 533824, upload-time = "2026-03-20T14:02:59.524Z" },
{ url = "https://files.pythonhosted.org/packages/9c/bc/1a3d6536455f9edd8c6cde04738816610158853a4cdcf35ab7c539fe8968/langgraph_api-0.7.86-py3-none-any.whl", hash = "sha256:bd994059d56bd76d18330b662bde70c1706429241e01be55901c52c01b8668c3", size = 534975, upload-time = "2026-03-23T22:28:52.961Z" },
]
[[package]]
@@ -2099,7 +2099,7 @@ wheels = [
[[package]]
name = "langgraph-cli"
version = "0.4.18"
version = "0.4.19"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -2107,9 +2107,9 @@ dependencies = [
{ name = "langgraph-sdk" },
{ name = "python-dotenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/d9/43f4c5083a9be70edf2add6d46c20bf8f6b69fca55d9e61a42ccd846886d/langgraph_cli-0.4.18.tar.gz", hash = "sha256:843f5990c18e425924b7fe34a164a13fb902d29ac92ceda9d21aa4b56057a91e", size = 876731, upload-time = "2026-03-15T23:55:47.4Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/4e/4c2dbc23e63ffbbe4cb5e5df3196bc65fc2f01bf645f09b2e4fbbbc30d94/langgraph_cli-0.4.19.tar.gz", hash = "sha256:99b79568a8fe829c296ed0aaa4a4570dc16e1d328cb2f3c629c9415edd6020b5", size = 879498, upload-time = "2026-03-20T22:12:20.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/04/0e7be7b6a4bcfe5f939495e7f0de2f8d911d481bf75848feb3aff3ac7c83/langgraph_cli-0.4.18-py3-none-any.whl", hash = "sha256:2327eb2b8859d5fd2c44bfc4f73def78b78c0d0bc4593de3b8cf37353d001f9d", size = 55593, upload-time = "2026-03-15T23:55:46.099Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e1/5b06b1f9f49d5d9e4568e3cc4f27f7268f1c89051dacb2f157c2433df7d0/langgraph_cli-0.4.19-py3-none-any.whl", hash = "sha256:ed3df28460dcf0bba12728be23c17a62a9df6fbfeb7a6940936fd5f180695310", size = 56297, upload-time = "2026-03-20T22:12:18.796Z" },
]
[package.optional-dependencies]
@@ -3280,16 +3280,16 @@ wheels = [
[[package]]
name = "pytest-cov"
version = "7.0.0"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage", extra = ["toml"] },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
@@ -3862,15 +3862,15 @@ wheels = [
[[package]]
name = "sentry-sdk"
version = "2.55.0"
version = "2.56.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/b8/285293dc60fc198fffc3fcdbc7c6d4e646e0f74e61461c355d40faa64ceb/sentry_sdk-2.55.0.tar.gz", hash = "sha256:3774c4d8820720ca4101548131b9c162f4c9426eb7f4d24aca453012a7470f69", size = 424505, upload-time = "2026-03-17T14:15:51.707Z" }
sdist = { url = "https://files.pythonhosted.org/packages/de/df/5008954f5466085966468612a7d1638487596ee6d2fd7fb51783a85351bf/sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168", size = 426820, upload-time = "2026-03-24T09:56:36.575Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/66/20465097782d7e1e742d846407ea7262d338c6e876ddddad38ca8907b38f/sentry_sdk-2.55.0-py2.py3-none-any.whl", hash = "sha256:97026981cb15699394474a196b88503a393cbc58d182ece0d3abe12b9bd978d4", size = 449284, upload-time = "2026-03-17T14:15:49.604Z" },
{ url = "https://files.pythonhosted.org/packages/cd/1a/b3a3e9f6520493fed7997af4d2de7965d71549c62f994a8fd15f2ecd519e/sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02", size = 451568, upload-time = "2026-03-24T09:56:34.807Z" },
]
[[package]]
@@ -3963,15 +3963,15 @@ wheels = [
[[package]]
name = "starlette"
version = "0.52.1"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" }
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]