v0.0.4 (#93)
* 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:
@@ -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 |
@@ -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 |
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user