mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(cli): filter threads in switcher by directory (#3290)
Closes #1990. ## What this does The `/threads` picker (and `deepagents threads list`) now scopes to the current working directory by default, so you only see conversations that started in this folder. The Options side-panel exposes a `Show threads from:` dropdown to widen the view to every project. ## How to use it **Inside a session:** - Open the picker with `/threads`. - You'll see only threads from the current cwd. In the right-hand **Options** panel, the `Show threads from:` `Select` shows the active scope. - Open the dropdown and choose **`All directories`** to see threads from any cwd. Choose **`Current directory`** to scope back. **From the shell:** - `deepagents threads list` → unchanged, lists everything (kept for script compatibility). - `deepagents threads list --cwd` → only threads from the current directory. - `deepagents threads list --cwd /some/path` → only threads from that path. ## Tests All existing tests pass. Test fixtures that pass `initial_threads` opt out of the default cwd scoping with `filter_cwd=None`, since their mock rows don't carry a matching `cwd`. The Tab-focus tests were updated to account for the new `Select` sitting between the search input and the sort checkbox. ## Docs Couldn't find any user-facing docs file that lists `/threads` controls or `deepagents threads list` flags beyond the in-CLI help text (which I did update in `ui.py`). If there's an external docs page that should mention the new dropdown/flag, happy to update it — just point me to it. --------- Co-authored-by: Mason Daugherty <mason@langchain.dev> Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
@@ -8485,13 +8485,16 @@ class DeepAgentsApp(App):
|
||||
return False
|
||||
|
||||
from deepagents_cli.config import _get_default_model_spec
|
||||
from deepagents_cli.model_config import ModelConfigError
|
||||
from deepagents_cli.model_config import (
|
||||
ModelConfigError,
|
||||
NoCredentialsConfiguredError,
|
||||
)
|
||||
|
||||
try:
|
||||
model_spec = _get_default_model_spec()
|
||||
except NoCredentialsConfiguredError:
|
||||
return False
|
||||
except ModelConfigError as exc:
|
||||
if str(exc).startswith("No credentials configured"):
|
||||
return False
|
||||
await self._mount_message(ErrorMessage(str(exc)))
|
||||
return False
|
||||
|
||||
|
||||
@@ -1847,14 +1847,16 @@ def _get_default_model_spec() -> str:
|
||||
3. Auto-detection based on available API credentials.
|
||||
|
||||
Returns:
|
||||
Model specification in provider:model format.
|
||||
Model specification in `provider:model` format.
|
||||
|
||||
Raises:
|
||||
ModelConfigError: If no credentials are configured.
|
||||
NoCredentialsConfiguredError: If no credentials are configured for any
|
||||
of the auto-detectable providers. Callers may catch this to defer
|
||||
startup and prompt for credentials interactively.
|
||||
"""
|
||||
from deepagents_cli.model_config import (
|
||||
ModelConfig,
|
||||
ModelConfigError,
|
||||
NoCredentialsConfiguredError,
|
||||
get_provider_auth_status,
|
||||
)
|
||||
|
||||
@@ -1865,6 +1867,13 @@ def _get_default_model_spec() -> str:
|
||||
if config.recent_model:
|
||||
return config.recent_model
|
||||
|
||||
# `is True` deliberately excludes `ProviderAuthState.UNKNOWN` (which maps
|
||||
# to `as_legacy_bool() -> None`). For the three explicit-credential
|
||||
# providers below, an UNKNOWN result means we cannot prove auth works, so
|
||||
# we fall through rather than pick an unverifiable default. If an
|
||||
# implicit-auth provider (e.g., Vertex ADC) is added to this fallback
|
||||
# list, switch to checking `state` against the relevant
|
||||
# `ProviderAuthState` members directly.
|
||||
if get_provider_auth_status("openai").as_legacy_bool() is True:
|
||||
return "openai:gpt-5.5"
|
||||
if get_provider_auth_status("anthropic").as_legacy_bool() is True:
|
||||
@@ -1876,7 +1885,7 @@ def _get_default_model_spec() -> str:
|
||||
"No credentials configured. Please set one of: "
|
||||
"ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY"
|
||||
)
|
||||
raise ModelConfigError(msg)
|
||||
raise NoCredentialsConfiguredError(msg)
|
||||
|
||||
|
||||
_OPENROUTER_APP_URL = "https://pypi.org/project/deepagents-cli/"
|
||||
|
||||
@@ -81,6 +81,38 @@ def _resolve_agent_arg(args: argparse.Namespace) -> str:
|
||||
return DEFAULT_AGENT_NAME
|
||||
|
||||
|
||||
def _normalize_cwd_filter(cwd: str | None) -> str | None:
|
||||
"""Normalize the `threads list --cwd` filter for metadata matching.
|
||||
|
||||
Storage uses `str(Path.cwd())` (absolute, no symlink resolution — see
|
||||
`build_run_metadata`). We mirror that here with lexical normalization
|
||||
rather than `.resolve()` so a user invoking from a symlinked path doesn't
|
||||
get an empty result set due to symlink normalization.
|
||||
|
||||
Args:
|
||||
cwd: Parsed `--cwd` value. An empty string means the flag was passed
|
||||
without a value and should use the current working directory.
|
||||
|
||||
Returns:
|
||||
Absolute path string for filtering, or `None` when no filter was
|
||||
requested. Returns `None` if the current working directory cannot
|
||||
be determined (bare `--cwd` only).
|
||||
"""
|
||||
if cwd is None:
|
||||
return None
|
||||
if cwd == "": # noqa: PLC1901
|
||||
try:
|
||||
return str(Path.cwd())
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Could not determine working directory for --cwd; "
|
||||
"no cwd filter will be applied",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
return os.path.normpath(str(Path(cwd).expanduser().absolute()))
|
||||
|
||||
|
||||
def _recent_agent_is_valid(name: str) -> bool:
|
||||
"""Return `True` when `~/.deepagents/<name>/` still exists on disk.
|
||||
|
||||
@@ -627,6 +659,16 @@ def parse_args() -> argparse.Namespace:
|
||||
default=None,
|
||||
help="Filter by git branch name",
|
||||
)
|
||||
threads_list.add_argument(
|
||||
"--cwd",
|
||||
nargs="?",
|
||||
const="",
|
||||
default=None,
|
||||
help=(
|
||||
"Filter by working directory. With no value, uses the current "
|
||||
"directory; pass a path to filter by that directory instead."
|
||||
),
|
||||
)
|
||||
threads_list.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
@@ -994,7 +1036,11 @@ async def run_textual_cli_async(
|
||||
detect_provider,
|
||||
settings,
|
||||
)
|
||||
from deepagents_cli.model_config import ModelConfigError, ModelSpec
|
||||
from deepagents_cli.model_config import (
|
||||
ModelConfigError,
|
||||
ModelSpec,
|
||||
NoCredentialsConfiguredError,
|
||||
)
|
||||
from deepagents_cli.onboarding import should_run_onboarding
|
||||
|
||||
# Resolve display-name cheaply (<1ms, no langchain) so the status
|
||||
@@ -1004,18 +1050,16 @@ async def run_textual_cli_async(
|
||||
defer_server_start = False
|
||||
try:
|
||||
resolved_spec = model_name or _get_default_model_spec()
|
||||
except ModelConfigError as e:
|
||||
if not str(e).startswith("No credentials configured"):
|
||||
from rich.markup import escape
|
||||
|
||||
from deepagents_cli.config import console
|
||||
|
||||
console.print(
|
||||
f"[bold red]Error:[/bold red] {escape(str(e))}", highlight=False
|
||||
)
|
||||
return AppResult(return_code=1, thread_id=None)
|
||||
except NoCredentialsConfiguredError:
|
||||
resolved_spec = ""
|
||||
defer_server_start = True
|
||||
except ModelConfigError as e:
|
||||
from rich.markup import escape
|
||||
|
||||
from deepagents_cli.config import console
|
||||
|
||||
console.print(f"[bold red]Error:[/bold red] {escape(str(e))}", highlight=False)
|
||||
return AppResult(return_code=1, thread_id=None)
|
||||
|
||||
if resolved_spec:
|
||||
parsed = ModelSpec.try_parse(resolved_spec)
|
||||
@@ -1926,12 +1970,31 @@ def cli_main() -> None:
|
||||
# "ls" is an argparse alias for "list" — argparse stores the
|
||||
# alias as-is in the namespace, so we must match both values.
|
||||
if args.threads_command in {"list", "ls"}:
|
||||
raw_cwd = getattr(args, "cwd", None)
|
||||
cwd_filter = _normalize_cwd_filter(raw_cwd)
|
||||
# Warn (but still query) when the user passed an explicit
|
||||
# `--cwd <path>` that does not exist on disk — otherwise a
|
||||
# typo would silently return "No threads found" with no hint.
|
||||
# Skip the check for the bare-flag (empty string) form, where
|
||||
# the path was generated from `Path.cwd()` and is known to
|
||||
# exist.
|
||||
if (
|
||||
raw_cwd not in {None, ""}
|
||||
and cwd_filter is not None
|
||||
and not Path(cwd_filter).exists()
|
||||
):
|
||||
print( # noqa: T201
|
||||
f"Warning: --cwd path {cwd_filter!r} does not exist; "
|
||||
"filtering by stored metadata anyway.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
asyncio.run(
|
||||
list_threads_command(
|
||||
agent_name=getattr(args, "agent", None),
|
||||
limit=getattr(args, "limit", None),
|
||||
sort_by=getattr(args, "sort", None),
|
||||
branch=getattr(args, "branch", None),
|
||||
cwd=cwd_filter,
|
||||
verbose=getattr(args, "verbose", False),
|
||||
relative=getattr(args, "relative", None),
|
||||
output_format=output_format,
|
||||
|
||||
@@ -107,6 +107,18 @@ class ModelConfigError(Exception):
|
||||
"""Raised when model configuration or creation fails."""
|
||||
|
||||
|
||||
class NoCredentialsConfiguredError(ModelConfigError):
|
||||
"""Raised when no credentials are configured for any default-resolvable provider.
|
||||
|
||||
Distinct from `MissingCredentialsError` (which targets a specific provider
|
||||
the user has selected): this fires from `_get_default_model_spec()` when
|
||||
auto-detection finds no usable credentials at all. Callers (the deferred-
|
||||
start path in the TUI and CLI) `isinstance`-check this type to recover by
|
||||
launching the TUI with model creation deferred, rather than string-matching
|
||||
the formatted message.
|
||||
"""
|
||||
|
||||
|
||||
class UnknownProviderError(ModelConfigError):
|
||||
"""Raised when neither the CLI nor `init_chat_model` can infer a provider.
|
||||
|
||||
|
||||
@@ -271,6 +271,7 @@ async def list_threads(
|
||||
include_message_count: bool = False,
|
||||
sort_by: str = "updated",
|
||||
branch: str | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> list[ThreadInfo]:
|
||||
"""List threads from checkpoints table.
|
||||
|
||||
@@ -280,6 +281,11 @@ async def list_threads(
|
||||
include_message_count: Whether to include message counts.
|
||||
sort_by: Sort field — `"updated"` or `"created"`.
|
||||
branch: Optional filter by git branch name.
|
||||
cwd: Optional filter by working directory. Only threads whose stored
|
||||
`cwd` metadata equals this path are returned. Matching is an
|
||||
exact string comparison — no path normalization, symlink
|
||||
resolution, or prefix matching. Threads without a stored `cwd`
|
||||
(older rows) are excluded.
|
||||
|
||||
Returns:
|
||||
List of `ThreadInfo` dicts with `thread_id`, `agent_name`,
|
||||
@@ -307,6 +313,9 @@ async def list_threads(
|
||||
if branch:
|
||||
where_clauses.append("json_extract(metadata, '$.git_branch') = ?")
|
||||
params_list.append(branch)
|
||||
if cwd:
|
||||
where_clauses.append("json_extract(metadata, '$.cwd') = ?")
|
||||
params_list.append(cwd)
|
||||
|
||||
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
||||
|
||||
@@ -346,8 +355,8 @@ async def list_threads(
|
||||
await _populate_message_counts(conn, threads)
|
||||
|
||||
# Only cache unfiltered results so the thread selector modal
|
||||
# doesn't receive branch-filtered or differently-sorted data.
|
||||
if sort_by == "updated" and branch is None:
|
||||
# doesn't receive branch-/cwd-filtered or differently-sorted data.
|
||||
if sort_by == "updated" and branch is None and cwd is None:
|
||||
_cache_recent_threads(agent_name, limit, threads)
|
||||
return threads
|
||||
|
||||
@@ -1065,6 +1074,7 @@ async def list_threads_command(
|
||||
limit: int | None = None,
|
||||
sort_by: str | None = None,
|
||||
branch: str | None = None,
|
||||
cwd: str | None = None,
|
||||
verbose: bool = False,
|
||||
relative: bool | None = None,
|
||||
*,
|
||||
@@ -1073,7 +1083,7 @@ async def list_threads_command(
|
||||
"""CLI handler for `deepagents threads list`.
|
||||
|
||||
Fetches and displays a table of recent conversation threads, optionally
|
||||
filtered by agent name or git branch.
|
||||
filtered by agent name, git branch, or working directory.
|
||||
|
||||
Args:
|
||||
agent_name: Only show threads belonging to this agent.
|
||||
@@ -1087,6 +1097,10 @@ async def list_threads_command(
|
||||
|
||||
When `None`, reads from config (`~/.deepagents/config.toml`).
|
||||
branch: Only show threads from this git branch.
|
||||
cwd: Only show threads whose stored `cwd` metadata equals this path
|
||||
(exact string match — no normalization or prefix matching). When
|
||||
`None`, no cwd filter is applied. Threads without a stored `cwd`
|
||||
(older rows) are excluded when this is set.
|
||||
verbose: When `True`, show all columns (branch, created, prompt).
|
||||
relative: Show timestamps as relative time (e.g., '5m ago').
|
||||
|
||||
@@ -1114,6 +1128,7 @@ async def list_threads_command(
|
||||
include_message_count=True,
|
||||
sort_by=sort_by,
|
||||
branch=branch,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
if verbose and threads:
|
||||
@@ -1139,12 +1154,34 @@ async def list_threads_command(
|
||||
filters.append(f"agent '{escape_markup(agent_name)}'")
|
||||
if branch:
|
||||
filters.append(f"branch '{escape_markup(branch)}'")
|
||||
if cwd:
|
||||
filters.append(f"cwd '{escape_markup(cwd)}'")
|
||||
if filters:
|
||||
console.print(
|
||||
f"[yellow]No threads found for {' and '.join(filters)}.[/yellow]"
|
||||
)
|
||||
else:
|
||||
console.print("[yellow]No threads found.[/yellow]")
|
||||
if cwd:
|
||||
# Older threads predating cwd-metadata storage are silently
|
||||
# filtered out by the `json_extract` equality match. Tell the
|
||||
# user explicitly when unfiltered rows exist so they don't think
|
||||
# their history is gone.
|
||||
unfiltered = await list_threads(
|
||||
agent_name,
|
||||
limit=limit,
|
||||
include_message_count=False,
|
||||
sort_by=sort_by,
|
||||
branch=branch,
|
||||
)
|
||||
legacy_count = sum(1 for t in unfiltered if not t.get("cwd"))
|
||||
if legacy_count:
|
||||
console.print(
|
||||
f"[dim]{legacy_count} older thread"
|
||||
f"{'s have' if legacy_count != 1 else ' has'} "
|
||||
"no cwd metadata; run without --cwd to see "
|
||||
f"{'them' if legacy_count != 1 else 'it'}.[/dim]"
|
||||
)
|
||||
console.print("[dim]Start a conversation with: deepagents[/dim]")
|
||||
return
|
||||
|
||||
@@ -1153,6 +1190,8 @@ async def list_threads_command(
|
||||
title_parts.append(f"agent '{escape_markup(agent_name)}'")
|
||||
if branch:
|
||||
title_parts.append(f"branch '{escape_markup(branch)}'")
|
||||
if cwd:
|
||||
title_parts.append(f"cwd '{escape_markup(cwd)}'")
|
||||
|
||||
title_filter = f" for {' and '.join(title_parts)}" if title_parts else ""
|
||||
sort_label = "created" if sort_by == "created" else "updated"
|
||||
|
||||
@@ -527,6 +527,7 @@ def show_threads_list_help() -> None:
|
||||
_print_option_section(
|
||||
" --agent NAME Filter by agent name",
|
||||
" --branch TEXT Filter by git branch name",
|
||||
" --cwd [PATH] Filter by working directory (no value = current)",
|
||||
" --sort {created,updated} Sort order (default: from config, or updated)",
|
||||
" -n, --limit N Maximum threads to display (default: 20)",
|
||||
" -v, --verbose Show all columns (branch, created, prompt)",
|
||||
@@ -539,6 +540,7 @@ def show_threads_list_help() -> None:
|
||||
console.print(" deepagents threads list -n 10")
|
||||
console.print(" deepagents threads list --agent mybot")
|
||||
console.print(" deepagents threads list --branch main -v")
|
||||
console.print(" deepagents threads list --cwd")
|
||||
console.print(" deepagents threads list --sort created --limit 50")
|
||||
console.print(" deepagents threads list -r")
|
||||
console.print()
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, ClassVar, cast
|
||||
|
||||
from rich.cells import cell_len
|
||||
@@ -18,7 +19,11 @@ from textual.fuzzy import Matcher
|
||||
from textual.message import Message
|
||||
from textual.screen import ModalScreen
|
||||
from textual.style import Style as TStyle
|
||||
from textual.widgets import Checkbox, Input, Static
|
||||
from textual.widgets import Checkbox, Input, Select, Static
|
||||
from textual.widgets._select import (
|
||||
SelectCurrent, # noqa: PLC2701 # needed to specialize focused Select overlay key handling
|
||||
SelectOverlay, # noqa: PLC2701 # needed to specialize focused Select overlay key handling
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Mapping
|
||||
@@ -106,10 +111,45 @@ _RIGHT_ALIGNED_COLUMNS: set[str] = set()
|
||||
_SWITCH_ID_PREFIX = "thread-column-"
|
||||
_SORT_SWITCH_ID = "thread-sort-toggle"
|
||||
_RELATIVE_TIME_SWITCH_ID = "thread-relative-time"
|
||||
_SCOPE_SELECT_ID = "thread-scope-select"
|
||||
_SCOPE_VALUE_CWD = "cwd"
|
||||
_SCOPE_VALUE_ALL = "all"
|
||||
_CONTROLS_SCROLL_ID = "thread-controls-scroll"
|
||||
_CONTROLS_OVERFLOW_ID = "thread-controls-overflow"
|
||||
_CELL_PADDING_RIGHT = 1
|
||||
|
||||
|
||||
class _Sentinel:
|
||||
"""Type for module-level singleton sentinels."""
|
||||
|
||||
|
||||
_CWD_DEFAULT = _Sentinel()
|
||||
"""Sentinel for the default `filter_cwd` constructor arg.
|
||||
|
||||
Distinguishes "caller did not specify" (use the current working directory) from
|
||||
an explicit `None` (start with no cwd filter)."""
|
||||
|
||||
|
||||
def _safe_cwd_string() -> str | None:
|
||||
"""Return `str(Path.cwd())` or `None` if the working directory is unreadable.
|
||||
|
||||
`Path.cwd()` raises `OSError` (typically `FileNotFoundError`) when the
|
||||
process's working directory has been deleted or is otherwise inaccessible.
|
||||
Treating that as "no cwd filter" matches the storage convention used by
|
||||
`build_run_metadata` and lets the picker degrade to "All directories"
|
||||
instead of crashing the Textual event loop.
|
||||
"""
|
||||
try:
|
||||
return str(Path.cwd())
|
||||
except OSError:
|
||||
logger.warning(
|
||||
"Could not determine working directory for thread picker; "
|
||||
"falling back to All directories",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
_FormatFns = tuple[
|
||||
"Callable[[str | None], str]", # format_path
|
||||
"Callable[[str | None], str]", # format_relative_timestamp
|
||||
@@ -476,6 +516,82 @@ class DeleteThreadConfirmScreen(ModalScreen[bool]):
|
||||
self.dismiss(False)
|
||||
|
||||
|
||||
_SCOPE_OVERLAY_CONSUMED_KEYS = frozenset(
|
||||
{"tab", "shift+tab", "up", "down", "pageup", "pagedown", "home", "end"}
|
||||
)
|
||||
|
||||
|
||||
class ThreadScopeSelectOverlay(SelectOverlay):
|
||||
"""Scope dropdown overlay that consumes option navigation while focused."""
|
||||
|
||||
def key_tab(self, event: Key) -> None:
|
||||
"""Move to the next option without leaving the dropdown."""
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
self.action_cursor_down()
|
||||
|
||||
def key_shift_tab(self, event: Key) -> None:
|
||||
"""Move to the previous option without leaving the dropdown."""
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
self.action_cursor_up()
|
||||
|
||||
def check_consume_key(self, key: str, character: str | None = None) -> bool:
|
||||
"""Report option navigation consumption so ancestor bindings stay hidden.
|
||||
|
||||
Args:
|
||||
key: Pressed key identifier.
|
||||
character: Printable character for the key, when present.
|
||||
|
||||
Returns:
|
||||
`True` when the overlay consumes the key.
|
||||
"""
|
||||
if key in _SCOPE_OVERLAY_CONSUMED_KEYS:
|
||||
return True
|
||||
return super().check_consume_key(key, character)
|
||||
|
||||
|
||||
class ThreadScopeSelect(Select[str]):
|
||||
"""Scope dropdown that keeps focus contained while its menu is open."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the select with a scope-specific overlay.
|
||||
|
||||
Yields:
|
||||
Current value display and dropdown overlay widgets.
|
||||
"""
|
||||
yield SelectCurrent(self.prompt)
|
||||
yield ThreadScopeSelectOverlay(type_to_search=self._type_to_search).data_bind(
|
||||
compact=Select.compact
|
||||
)
|
||||
|
||||
def key_tab(self, event: Key) -> None:
|
||||
"""Prevent focus traversal while the dropdown menu is open."""
|
||||
if self.expanded:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
def key_shift_tab(self, event: Key) -> None:
|
||||
"""Prevent reverse focus traversal while the dropdown menu is open."""
|
||||
if self.expanded:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
|
||||
def check_consume_key(self, key: str, character: str | None) -> bool:
|
||||
"""Report Tab consumption while expanded so global bindings stay hidden.
|
||||
|
||||
Args:
|
||||
key: Pressed key identifier.
|
||||
character: Printable character for the key, when present.
|
||||
|
||||
Returns:
|
||||
`True` when the open dropdown consumes the key.
|
||||
"""
|
||||
if self.expanded and key in {"tab", "shift+tab"}:
|
||||
return True
|
||||
return super().check_consume_key(key, character)
|
||||
|
||||
|
||||
class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
"""Modal dialog for browsing and resuming threads.
|
||||
|
||||
@@ -580,6 +696,17 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
ThreadSelectorScreen .thread-controls-label {
|
||||
color: $text-muted;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
ThreadSelectorScreen .thread-scope-select {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
ThreadSelectorScreen .thread-column-toggle {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
@@ -705,6 +832,7 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
*,
|
||||
thread_limit: int | None = None,
|
||||
initial_threads: list[ThreadInfo] | None = None,
|
||||
filter_cwd: str | _Sentinel | None = _CWD_DEFAULT,
|
||||
) -> None:
|
||||
"""Initialize the `ThreadSelectorScreen`.
|
||||
|
||||
@@ -712,13 +840,29 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
current_thread: The currently active thread ID (to highlight).
|
||||
thread_limit: Maximum number of rows to fetch when querying DB.
|
||||
initial_threads: Optional preloaded rows to render immediately.
|
||||
filter_cwd: Working-directory filter for the picker.
|
||||
|
||||
When the default sentinel, the picker mirrors Claude Code's
|
||||
`/resume` and scopes to the current working directory; the
|
||||
"Show threads from" select in the Options panel widens the
|
||||
view to "all directories". Pass an explicit path to scope to
|
||||
that directory, or `None` to start the picker with no cwd
|
||||
filter.
|
||||
"""
|
||||
super().__init__()
|
||||
self._current_thread = current_thread
|
||||
self._thread_limit = thread_limit
|
||||
self._threads: list[ThreadInfo] = (
|
||||
list(initial_threads) if initial_threads is not None else []
|
||||
)
|
||||
if isinstance(filter_cwd, _Sentinel):
|
||||
self._filter_cwd: str | None = _safe_cwd_string()
|
||||
else:
|
||||
self._filter_cwd = filter_cwd
|
||||
initial = list(initial_threads) if initial_threads is not None else []
|
||||
# The cached `initial_threads` are unfiltered, so apply the active cwd
|
||||
# filter here to avoid showing all threads on first paint when we are
|
||||
# about to re-query with the filter active.
|
||||
if self._filter_cwd is not None:
|
||||
initial = [t for t in initial if t.get("cwd") == self._filter_cwd]
|
||||
self._threads: list[ThreadInfo] = initial
|
||||
self._filtered_threads: list[ThreadInfo] = list(self._threads)
|
||||
self._has_initial_threads = initial_threads is not None
|
||||
self._selected_index = 0
|
||||
@@ -727,7 +871,7 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
self._confirming_delete = False
|
||||
self._render_lock = asyncio.Lock()
|
||||
self._filter_input: Input | None = None
|
||||
self._filter_controls: list[Input | Checkbox] | None = None
|
||||
self._filter_controls: list[Input | Checkbox | Select[str]] | None = None
|
||||
self._cell_text: dict[tuple[str, str], str] = {}
|
||||
|
||||
from deepagents_cli.model_config import load_thread_config
|
||||
@@ -840,10 +984,11 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
self._filter_input = self.query_one("#thread-filter", Input)
|
||||
return self._filter_input
|
||||
|
||||
def _filter_focus_order(self) -> list[Input | Checkbox]:
|
||||
def _filter_focus_order(self) -> list[Input | Checkbox | Select[str]]:
|
||||
"""Return the cached tab order for filter controls in the side panel."""
|
||||
if self._filter_controls is None:
|
||||
filter_input = self._get_filter_input()
|
||||
scope_select = self.query_one(f"#{_SCOPE_SELECT_ID}", Select)
|
||||
sort_switch = self.query_one(f"#{_SORT_SWITCH_ID}", Checkbox)
|
||||
relative_switch = self.query_one(f"#{_RELATIVE_TIME_SWITCH_ID}", Checkbox)
|
||||
column_switches = [
|
||||
@@ -852,12 +997,38 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
]
|
||||
self._filter_controls = [
|
||||
filter_input,
|
||||
scope_select,
|
||||
sort_switch,
|
||||
relative_switch,
|
||||
*column_switches,
|
||||
]
|
||||
return self._filter_controls
|
||||
|
||||
def _get_scope_select(self) -> Select[str]:
|
||||
"""Return the scope dropdown widget."""
|
||||
return self.query_one(f"#{_SCOPE_SELECT_ID}", Select)
|
||||
|
||||
def _is_scope_select_expanded(self) -> bool:
|
||||
"""Return whether the scope dropdown menu is currently open."""
|
||||
try:
|
||||
return self._get_scope_select().expanded
|
||||
except NoMatches:
|
||||
return False
|
||||
|
||||
def _close_scope_select(self) -> None:
|
||||
"""Close the scope dropdown and restore focus to the select control."""
|
||||
scope_select = self._get_scope_select()
|
||||
scope_select.expanded = False
|
||||
scope_select.focus()
|
||||
|
||||
def _select_scope_highlight(self) -> None:
|
||||
"""Select the currently highlighted option in the open scope dropdown."""
|
||||
action_select = getattr(self.focused, "action_select", None)
|
||||
if callable(action_select):
|
||||
action_select()
|
||||
return
|
||||
self._close_scope_select()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the screen layout.
|
||||
|
||||
@@ -920,6 +1091,26 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
classes="thread-controls-help",
|
||||
markup=False,
|
||||
)
|
||||
yield Static(
|
||||
"Show threads from:",
|
||||
classes="thread-controls-label",
|
||||
markup=False,
|
||||
)
|
||||
yield ThreadScopeSelect(
|
||||
[
|
||||
("Current directory", _SCOPE_VALUE_CWD),
|
||||
("All directories", _SCOPE_VALUE_ALL),
|
||||
],
|
||||
value=(
|
||||
_SCOPE_VALUE_CWD
|
||||
if self._filter_cwd is not None
|
||||
else _SCOPE_VALUE_ALL
|
||||
),
|
||||
allow_blank=False,
|
||||
compact=True,
|
||||
id=_SCOPE_SELECT_ID,
|
||||
classes="thread-scope-select",
|
||||
)
|
||||
with ThreadControlsScroll(
|
||||
id=_CONTROLS_SCROLL_ID, classes="thread-controls-scroll"
|
||||
):
|
||||
@@ -1033,6 +1224,11 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
if self._confirming_delete:
|
||||
return
|
||||
|
||||
if event.key in {"tab", "shift+tab"} and self._is_scope_select_expanded():
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
return
|
||||
|
||||
filter_input = self._get_filter_input()
|
||||
if filter_input.has_focus:
|
||||
return
|
||||
@@ -1229,10 +1425,16 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
Returns:
|
||||
Concatenated searchable string, truncated to a safe length.
|
||||
"""
|
||||
# Include both the raw cwd and the home-collapsed display form so a
|
||||
# user typing either "/Users/chesar/foo" or "~/foo" matches the row.
|
||||
format_path, _, _ = _get_format_fns()
|
||||
cwd = thread.get("cwd") or ""
|
||||
parts = [
|
||||
thread["thread_id"],
|
||||
thread.get("agent_name") or "",
|
||||
thread.get("git_branch") or "",
|
||||
cwd,
|
||||
format_path(cwd) if cwd else "",
|
||||
thread.get("initial_prompt") or "",
|
||||
]
|
||||
text = " ".join(parts)
|
||||
@@ -1405,16 +1607,15 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
limit = get_thread_limit()
|
||||
sort_by = "updated" if self._sort_by_updated else "created"
|
||||
self._threads = await list_threads(
|
||||
limit=limit, include_message_count=False, sort_by=sort_by
|
||||
limit=limit,
|
||||
include_message_count=False,
|
||||
sort_by=sort_by,
|
||||
cwd=self._filter_cwd,
|
||||
)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
logger.exception("Failed to load threads for thread selector")
|
||||
await self._show_mount_error(str(exc))
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error loading threads for thread selector")
|
||||
await self._show_mount_error(str(exc))
|
||||
return
|
||||
|
||||
apply_cached_thread_message_counts(self._threads)
|
||||
apply_cached_thread_initial_prompts(self._threads)
|
||||
@@ -1816,6 +2017,13 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
"""Confirm the highlighted thread and dismiss the selector."""
|
||||
if self._confirming_delete:
|
||||
return
|
||||
if self._is_scope_select_expanded():
|
||||
self._select_scope_highlight()
|
||||
return
|
||||
scope_select = self._get_scope_select()
|
||||
if self.focused is scope_select:
|
||||
scope_select.action_show_overlay()
|
||||
return
|
||||
if self._filtered_threads:
|
||||
thread_id = self._filtered_threads[self._selected_index]["thread_id"]
|
||||
self.dismiss(thread_id)
|
||||
@@ -1824,26 +2032,30 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
"""Move focus through the filter and column-toggle controls."""
|
||||
if self._confirming_delete:
|
||||
return
|
||||
if self._is_scope_select_expanded():
|
||||
return
|
||||
controls = self._filter_focus_order()
|
||||
focused = self.focused
|
||||
if focused not in controls:
|
||||
controls[0].focus()
|
||||
return
|
||||
|
||||
index = controls.index(cast("Input | Checkbox", focused))
|
||||
index = controls.index(cast("Input | Checkbox | Select[str]", focused))
|
||||
controls[(index + 1) % len(controls)].focus()
|
||||
|
||||
def action_focus_previous_filter(self) -> None:
|
||||
"""Move focus backward through the filter and column-toggle controls."""
|
||||
if self._confirming_delete:
|
||||
return
|
||||
if self._is_scope_select_expanded():
|
||||
return
|
||||
controls = self._filter_focus_order()
|
||||
focused = self.focused
|
||||
if focused not in controls:
|
||||
controls[-1].focus()
|
||||
return
|
||||
|
||||
index = controls.index(cast("Input | Checkbox", focused))
|
||||
index = controls.index(cast("Input | Checkbox | Select[str]", focused))
|
||||
controls[(index - 1) % len(controls)].focus()
|
||||
|
||||
def action_toggle_sort(self) -> None:
|
||||
@@ -1860,6 +2072,39 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
"updated_at" if self._sort_by_updated else "created_at"
|
||||
)
|
||||
|
||||
def on_select_changed(self, event: Select.Changed) -> None:
|
||||
"""Handle the scope `Select` dropdown in the Options panel.
|
||||
|
||||
Mirrors Claude Code's `/resume`: the picker is scoped to the current
|
||||
working directory by default; choosing "All directories" widens the
|
||||
view to threads from every cwd.
|
||||
"""
|
||||
if event.select.id != _SCOPE_SELECT_ID:
|
||||
return
|
||||
if self._confirming_delete:
|
||||
return
|
||||
|
||||
if event.value == _SCOPE_VALUE_CWD:
|
||||
new_cwd = _safe_cwd_string()
|
||||
if new_cwd is None:
|
||||
# Working directory is gone or unreadable. Tell the user
|
||||
# rather than silently re-querying with no filter.
|
||||
self.app.notify(
|
||||
"Could not determine the current working directory; "
|
||||
"showing all directories instead.",
|
||||
severity="warning",
|
||||
markup=False,
|
||||
)
|
||||
else:
|
||||
new_cwd = None
|
||||
if new_cwd == self._filter_cwd:
|
||||
return
|
||||
self._filter_cwd = new_cwd
|
||||
self._update_help_widgets()
|
||||
self.run_worker(
|
||||
self._load_threads, exclusive=True, group="thread-selector-load"
|
||||
)
|
||||
|
||||
def _persist_sort_order(self, order: str) -> None:
|
||||
"""Save sort-order preference to config, notifying on failure."""
|
||||
|
||||
@@ -1977,4 +2222,7 @@ class ThreadSelectorScreen(ModalScreen[str | None]):
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Cancel the selection."""
|
||||
if self._is_scope_select_expanded():
|
||||
self._close_scope_select()
|
||||
return
|
||||
self.dismiss(None)
|
||||
|
||||
@@ -1469,6 +1469,8 @@ class TestModalScreenShiftTabHandling:
|
||||
|
||||
async def test_shift_tab_moves_backward_in_thread_selector(self) -> None:
|
||||
"""Shift+Tab should move backward in the thread selector controls."""
|
||||
from textual.widgets import Select
|
||||
|
||||
from deepagents_cli.widgets.thread_selector import ThreadSelectorScreen
|
||||
|
||||
app = DeepAgentsApp()
|
||||
@@ -1492,12 +1494,21 @@ class TestModalScreenShiftTabHandling:
|
||||
|
||||
assert app._auto_approve is False
|
||||
filter_input = screen.query_one("#thread-filter", Input)
|
||||
scope_select = screen.query_one("#thread-scope-select", Select)
|
||||
sort_switch = screen.query_one("#thread-sort-toggle", Checkbox)
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert scope_select.has_focus
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert sort_switch.has_focus
|
||||
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
assert scope_select.has_focus
|
||||
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
|
||||
|
||||
@@ -747,7 +747,7 @@ class TestRunTextualCliAsyncModelConfigError:
|
||||
|
||||
async def test_launches_tui_on_no_credentials(self) -> None:
|
||||
"""Missing default credentials should be recoverable inside the TUI."""
|
||||
from deepagents_cli.model_config import ModelConfigError
|
||||
from deepagents_cli.model_config import NoCredentialsConfiguredError
|
||||
|
||||
app_result = AppResult(return_code=0, thread_id="t-1")
|
||||
captured_kwargs: dict[str, Any] = {}
|
||||
@@ -760,7 +760,7 @@ class TestRunTextualCliAsyncModelConfigError:
|
||||
with (
|
||||
patch(
|
||||
"deepagents_cli.config._get_default_model_spec",
|
||||
side_effect=ModelConfigError("No credentials configured"),
|
||||
side_effect=NoCredentialsConfiguredError("No credentials configured"),
|
||||
),
|
||||
patch("deepagents_cli.app.run_textual_app", new=_stub),
|
||||
):
|
||||
@@ -771,6 +771,36 @@ class TestRunTextualCliAsyncModelConfigError:
|
||||
assert captured_kwargs["model_kwargs"] is None
|
||||
assert captured_kwargs["server_kwargs"]["model_name"] is None
|
||||
|
||||
async def test_recovery_does_not_rely_on_message_text(self) -> None:
|
||||
"""`NoCredentialsConfiguredError` triggers deferred start.
|
||||
|
||||
Regardless of the exception message text.
|
||||
"""
|
||||
from deepagents_cli.model_config import NoCredentialsConfiguredError
|
||||
|
||||
app_result = AppResult(return_code=0, thread_id="t-2")
|
||||
captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
async def _stub(**kwargs: Any) -> AppResult:
|
||||
captured_kwargs.update(kwargs)
|
||||
await asyncio.sleep(0)
|
||||
return app_result
|
||||
|
||||
# Reword the message to prove we no longer string-match on prefix.
|
||||
with (
|
||||
patch(
|
||||
"deepagents_cli.config._get_default_model_spec",
|
||||
side_effect=NoCredentialsConfiguredError(
|
||||
"Setup required: please run /model"
|
||||
),
|
||||
),
|
||||
patch("deepagents_cli.app.run_textual_app", new=_stub),
|
||||
):
|
||||
result = await run_textual_cli_async("agent")
|
||||
|
||||
assert result == app_result
|
||||
assert captured_kwargs["defer_server_start"] is True
|
||||
|
||||
async def test_returns_error_code_on_other_model_config_error(self) -> None:
|
||||
"""Non-recoverable default model errors should still block startup."""
|
||||
from deepagents_cli.model_config import ModelConfigError
|
||||
@@ -801,3 +831,93 @@ class TestRunTextualCliAsyncModelConfigError:
|
||||
result = await run_textual_cli_async("agent", model_name="openai:gpt-4o")
|
||||
|
||||
assert result.return_code == 0
|
||||
|
||||
|
||||
class TestNormalizeCwdFilter:
|
||||
"""Tests for `_normalize_cwd_filter`."""
|
||||
|
||||
def test_none_returns_none(self) -> None:
|
||||
"""No flag → no filter."""
|
||||
from deepagents_cli.main import _normalize_cwd_filter
|
||||
|
||||
assert _normalize_cwd_filter(None) is None
|
||||
|
||||
def test_empty_string_uses_current_cwd(self) -> None:
|
||||
"""Bare `--cwd` (empty-string sentinel) resolves to current working dir."""
|
||||
from deepagents_cli.main import _normalize_cwd_filter
|
||||
|
||||
assert _normalize_cwd_filter("") == str(Path.cwd())
|
||||
|
||||
def test_explicit_path_is_made_absolute(self) -> None:
|
||||
"""A user-supplied path is expanduser'd and made absolute."""
|
||||
from deepagents_cli.main import _normalize_cwd_filter
|
||||
|
||||
result = _normalize_cwd_filter("~/foo/bar")
|
||||
assert result is not None
|
||||
assert result == str(Path("~/foo/bar").expanduser().absolute())
|
||||
assert Path(result).is_absolute()
|
||||
|
||||
def test_explicit_relative_parent_path_is_normalized(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Explicit relative paths collapse `..` without resolving symlinks."""
|
||||
from deepagents_cli.main import _normalize_cwd_filter
|
||||
|
||||
project = tmp_path / "project"
|
||||
subdir = project / "subdir"
|
||||
subdir.mkdir(parents=True)
|
||||
monkeypatch.chdir(subdir)
|
||||
|
||||
assert _normalize_cwd_filter("..") == str(project)
|
||||
|
||||
def test_explicit_path_does_not_resolve_symlinks(self, tmp_path: Path) -> None:
|
||||
"""Lexical normalization (not `.resolve()`) matches storage convention."""
|
||||
from deepagents_cli.main import _normalize_cwd_filter
|
||||
|
||||
real = tmp_path / "real"
|
||||
real.mkdir()
|
||||
link = tmp_path / "via_link"
|
||||
try:
|
||||
link.symlink_to(real)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks unsupported on this platform")
|
||||
|
||||
result = _normalize_cwd_filter(str(link))
|
||||
assert result == str(link.absolute())
|
||||
# Sanity: resolve() would have collapsed the symlink to `real`.
|
||||
assert result != str(link.resolve())
|
||||
|
||||
def test_cwd_unreadable_returns_none(self) -> None:
|
||||
"""A deleted/unreadable cwd degrades to no filter rather than crashing."""
|
||||
from deepagents_cli.main import _normalize_cwd_filter
|
||||
|
||||
with patch(
|
||||
"deepagents_cli.main.Path.cwd",
|
||||
side_effect=FileNotFoundError("gone"),
|
||||
):
|
||||
assert _normalize_cwd_filter("") is None
|
||||
|
||||
|
||||
class TestThreadsListCwdArgparse:
|
||||
"""Tests for `--cwd` argparse semantics on `deepagents threads list`."""
|
||||
|
||||
def _parse(self, argv: list[str]) -> Any: # noqa: ANN401
|
||||
from deepagents_cli.main import parse_args
|
||||
|
||||
with patch("sys.argv", ["deepagents", *argv]):
|
||||
return parse_args()
|
||||
|
||||
def test_cwd_omitted_yields_none(self) -> None:
|
||||
"""Omitting --cwd leaves the namespace value at `None`."""
|
||||
ns = self._parse(["threads", "list"])
|
||||
assert getattr(ns, "cwd", "MISSING") is None
|
||||
|
||||
def test_cwd_alone_yields_empty_string_const(self) -> None:
|
||||
"""Bare `--cwd` stores the `const=""` sentinel for downstream resolution."""
|
||||
ns = self._parse(["threads", "list", "--cwd"])
|
||||
assert ns.cwd == ""
|
||||
|
||||
def test_cwd_with_value_stores_value(self) -> None:
|
||||
"""`--cwd /some/path` stores the literal value as-is."""
|
||||
ns = self._parse(["threads", "list", "--cwd", "/some/path"])
|
||||
assert ns.cwd == "/some/path"
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractContextManager
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -689,6 +690,64 @@ class TestAgentResolutionScope:
|
||||
valid_recent.assert_not_called()
|
||||
|
||||
|
||||
class TestThreadsListCwdFilter:
|
||||
"""Tests for `deepagents threads list --cwd` path normalization."""
|
||||
|
||||
@staticmethod
|
||||
def _run_threads_list(*args: str) -> AsyncMock:
|
||||
from deepagents_cli.main import cli_main
|
||||
|
||||
mock_stdin = MagicMock()
|
||||
mock_stdin.isatty.return_value = True
|
||||
|
||||
with (
|
||||
patch.object(sys, "argv", ["deepagents", "threads", "list", *args]),
|
||||
patch.object(sys, "stdin", mock_stdin),
|
||||
patch("deepagents_cli.main.check_cli_dependencies"),
|
||||
patch(
|
||||
"deepagents_cli.sessions.list_threads_command",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_list,
|
||||
):
|
||||
cli_main()
|
||||
|
||||
return mock_list
|
||||
|
||||
def test_no_value_cwd_uses_current_directory(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Bare `--cwd` filters by the current working directory."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
mock_list = self._run_threads_list("--cwd")
|
||||
|
||||
assert mock_list.await_args.kwargs["cwd"] == str(Path.cwd()) # type: ignore[union-attr]
|
||||
|
||||
def test_explicit_relative_cwd_is_normalized(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Explicit relative paths match absolute stored cwd metadata."""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
monkeypatch.chdir(project)
|
||||
|
||||
mock_list = self._run_threads_list("--cwd", ".")
|
||||
|
||||
assert mock_list.await_args.kwargs["cwd"] == str(project.resolve()) # type: ignore[union-attr]
|
||||
|
||||
def test_explicit_home_cwd_is_expanded(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Explicit `~` paths match absolute stored cwd metadata."""
|
||||
project = tmp_path / "repo"
|
||||
project.mkdir()
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
|
||||
mock_list = self._run_threads_list("--cwd", "~/repo")
|
||||
|
||||
assert mock_list.await_args.kwargs["cwd"] == str(project.resolve()) # type: ignore[union-attr]
|
||||
|
||||
|
||||
class TestResolveAgentArg:
|
||||
"""Resolution order: explicit > -r fallback > recent > default."""
|
||||
|
||||
|
||||
@@ -1319,6 +1319,134 @@ class TestListThreadsSortAndBranch:
|
||||
assert threads[0]["thread_id"] == "thread_a"
|
||||
|
||||
|
||||
class TestListThreadsCwdFilter:
|
||||
"""Tests for the `cwd` filter on `list_threads`."""
|
||||
|
||||
@pytest.fixture
|
||||
def db_with_cwds(self, tmp_path: Path) -> Path:
|
||||
"""Database with threads stored under distinct cwd values.
|
||||
|
||||
thread_a: cwd=/home/user/project-a, branch=main, agent=bot
|
||||
thread_b: cwd=/tmp/workspace, branch=feat, agent=bot
|
||||
thread_c: no cwd metadata (legacy), branch=main, agent=bot
|
||||
"""
|
||||
db_path = tmp_path / "cwds.db"
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
conn.execute("""
|
||||
CREATE TABLE checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
checkpoint_ns TEXT NOT NULL DEFAULT '',
|
||||
checkpoint_id TEXT NOT NULL,
|
||||
metadata BLOB,
|
||||
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
|
||||
)
|
||||
""")
|
||||
ins = (
|
||||
"INSERT INTO checkpoints"
|
||||
" (thread_id, checkpoint_ns, checkpoint_id, metadata)"
|
||||
" VALUES (?, '', ?, ?)"
|
||||
)
|
||||
conn.execute(
|
||||
ins,
|
||||
(
|
||||
"thread_a",
|
||||
"cp_a",
|
||||
json.dumps(
|
||||
{
|
||||
"agent_name": "bot",
|
||||
"updated_at": "2025-06-01T12:00:00+00:00",
|
||||
"git_branch": "main",
|
||||
"cwd": "/home/user/project-a",
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
ins,
|
||||
(
|
||||
"thread_b",
|
||||
"cp_b",
|
||||
json.dumps(
|
||||
{
|
||||
"agent_name": "bot",
|
||||
"updated_at": "2025-05-15T12:00:00+00:00",
|
||||
"git_branch": "feat",
|
||||
"cwd": "/tmp/workspace",
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
ins,
|
||||
(
|
||||
"thread_c",
|
||||
"cp_c",
|
||||
json.dumps(
|
||||
{
|
||||
"agent_name": "bot",
|
||||
"updated_at": "2025-04-01T12:00:00+00:00",
|
||||
"git_branch": "main",
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return db_path
|
||||
|
||||
def test_filter_by_cwd(self, db_with_cwds: Path) -> None:
|
||||
"""Only threads with exactly the supplied cwd are returned."""
|
||||
with patch.object(sessions, "get_db_path", return_value=db_with_cwds):
|
||||
threads = asyncio.run(sessions.list_threads(cwd="/home/user/project-a"))
|
||||
assert [t["thread_id"] for t in threads] == ["thread_a"]
|
||||
assert threads[0]["cwd"] == "/home/user/project-a"
|
||||
|
||||
def test_filter_by_cwd_excludes_legacy_rows(self, db_with_cwds: Path) -> None:
|
||||
"""Threads without stored cwd metadata are dropped by the filter."""
|
||||
with patch.object(sessions, "get_db_path", return_value=db_with_cwds):
|
||||
threads = asyncio.run(sessions.list_threads(cwd="/tmp/workspace"))
|
||||
ids = {t["thread_id"] for t in threads}
|
||||
# thread_c (no cwd) must not leak through; only thread_b matches.
|
||||
assert ids == {"thread_b"}
|
||||
|
||||
def test_filter_by_cwd_no_match(self, db_with_cwds: Path) -> None:
|
||||
"""Unknown cwd returns empty list."""
|
||||
with patch.object(sessions, "get_db_path", return_value=db_with_cwds):
|
||||
threads = asyncio.run(sessions.list_threads(cwd="/no/such/dir"))
|
||||
assert threads == []
|
||||
|
||||
def test_filter_by_cwd_combined_with_branch(self, db_with_cwds: Path) -> None:
|
||||
"""`cwd` + branch combine with AND."""
|
||||
with patch.object(sessions, "get_db_path", return_value=db_with_cwds):
|
||||
threads = asyncio.run(
|
||||
sessions.list_threads(cwd="/home/user/project-a", branch="main")
|
||||
)
|
||||
assert [t["thread_id"] for t in threads] == ["thread_a"]
|
||||
# Mismatched branch yields empty even when cwd matches.
|
||||
threads = asyncio.run(
|
||||
sessions.list_threads(cwd="/home/user/project-a", branch="feat")
|
||||
)
|
||||
assert threads == []
|
||||
|
||||
def test_cwd_filter_skips_recent_threads_cache(self, db_with_cwds: Path) -> None:
|
||||
"""Filtered queries must not poison the unfiltered picker cache."""
|
||||
sessions._recent_threads_cache.clear()
|
||||
with patch.object(sessions, "get_db_path", return_value=db_with_cwds):
|
||||
asyncio.run(
|
||||
sessions.list_threads(
|
||||
cwd="/home/user/project-a", sort_by="updated", limit=10
|
||||
)
|
||||
)
|
||||
assert sessions._recent_threads_cache == {}
|
||||
|
||||
def test_unfiltered_query_populates_cache(self, db_with_cwds: Path) -> None:
|
||||
"""Sanity: cache still fills when cwd is None (regression guard)."""
|
||||
sessions._recent_threads_cache.clear()
|
||||
with patch.object(sessions, "get_db_path", return_value=db_with_cwds):
|
||||
asyncio.run(sessions.list_threads(sort_by="updated", limit=10))
|
||||
assert (None, 10) in sessions._recent_threads_cache
|
||||
|
||||
|
||||
class TestListThreadsCommandConfigDefaults:
|
||||
"""Tests for list_threads_command reading config defaults."""
|
||||
|
||||
|
||||
@@ -13,12 +13,13 @@ from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
||||
from textual.css.query import NoMatches
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Checkbox, Input, Static
|
||||
from textual.widgets import Checkbox, Input, Select, Static
|
||||
|
||||
from deepagents_cli.app import DeepAgentsApp
|
||||
from deepagents_cli.sessions import ThreadInfo
|
||||
from deepagents_cli.widgets.thread_selector import (
|
||||
DeleteThreadConfirmScreen,
|
||||
ThreadScopeSelectOverlay,
|
||||
ThreadSelectorScreen,
|
||||
)
|
||||
|
||||
@@ -136,7 +137,11 @@ class ThreadSelectorTestApp(App):
|
||||
self.result = result
|
||||
self.dismissed = True
|
||||
|
||||
screen = ThreadSelectorScreen(current_thread=self._current_thread)
|
||||
# Disable the default cwd filter so tests don't need to populate the
|
||||
# `cwd` field on every mock thread fixture.
|
||||
screen = ThreadSelectorScreen(
|
||||
current_thread=self._current_thread, filter_cwd=None
|
||||
)
|
||||
self.push_screen(screen, handle_result)
|
||||
|
||||
|
||||
@@ -170,7 +175,7 @@ class AppWithEscapeBinding(App):
|
||||
self.result = result
|
||||
self.dismissed = True
|
||||
|
||||
screen = ThreadSelectorScreen(current_thread="abc12345")
|
||||
screen = ThreadSelectorScreen(current_thread="abc12345", filter_cwd=None)
|
||||
self.push_screen(screen, handle_result)
|
||||
|
||||
|
||||
@@ -442,6 +447,7 @@ class TestThreadSelectorTabSort:
|
||||
assert isinstance(screen, ThreadSelectorScreen)
|
||||
|
||||
filter_input = screen.query_one("#thread-filter", Input)
|
||||
scope_select = screen.query_one("#thread-scope-select", Select)
|
||||
sort_switch = screen.query_one("#thread-sort-toggle", Checkbox)
|
||||
thread_id_switch = screen.query_one(
|
||||
f"#{ThreadSelectorScreen._switch_id('thread_id')}",
|
||||
@@ -458,6 +464,10 @@ class TestThreadSelectorTabSort:
|
||||
|
||||
assert filter_input.has_focus
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert scope_select.has_focus
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert sort_switch.has_focus
|
||||
@@ -493,13 +503,22 @@ class TestThreadSelectorTabSort:
|
||||
assert isinstance(screen, ThreadSelectorScreen)
|
||||
|
||||
filter_input = screen.query_one("#thread-filter", Input)
|
||||
scope_select = screen.query_one("#thread-scope-select", Select)
|
||||
sort_switch = screen.query_one("#thread-sort-toggle", Checkbox)
|
||||
assert filter_input.has_focus
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert scope_select.has_focus
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert sort_switch.has_focus
|
||||
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
assert scope_select.has_focus
|
||||
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
assert filter_input.has_focus
|
||||
@@ -516,6 +535,7 @@ class TestThreadSelectorTabSort:
|
||||
assert isinstance(screen, ThreadSelectorScreen)
|
||||
|
||||
filter_input = screen.query_one("#thread-filter", Input)
|
||||
scope_select = screen.query_one("#thread-scope-select", Select)
|
||||
sort_switch = screen.query_one("#thread-sort-toggle", Checkbox)
|
||||
controls = screen._filter_focus_order()
|
||||
event = MagicMock()
|
||||
@@ -534,7 +554,8 @@ class TestThreadSelectorTabSort:
|
||||
):
|
||||
assert screen._filter_focus_order() == controls
|
||||
assert controls[0] is filter_input
|
||||
assert controls[1] is sort_switch
|
||||
assert controls[1] is scope_select
|
||||
assert controls[2] is sort_switch
|
||||
|
||||
screen.on_key(event)
|
||||
|
||||
@@ -556,6 +577,7 @@ class TestThreadSelectorTabSort:
|
||||
sort_switch = screen.query_one("#thread-sort-toggle", Checkbox)
|
||||
filter_input = screen.query_one("#thread-filter", Input)
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert sort_switch.has_focus
|
||||
@@ -580,6 +602,7 @@ class TestThreadSelectorTabSort:
|
||||
filter_input = screen.query_one("#thread-filter", Input)
|
||||
sort_switch = screen.query_one("#thread-sort-toggle", Checkbox)
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert sort_switch.has_focus
|
||||
@@ -607,6 +630,7 @@ class TestThreadSelectorTabSort:
|
||||
filter_input = screen.query_one("#thread-filter", Input)
|
||||
sort_switch = screen.query_one("#thread-sort-toggle", Checkbox)
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert sort_switch.has_focus
|
||||
@@ -636,6 +660,7 @@ class TestThreadSelectorTabSort:
|
||||
filter_input = screen.query_one("#thread-filter", Input)
|
||||
sort_switch = screen.query_one("#thread-sort-toggle", Checkbox)
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert sort_switch.has_focus
|
||||
@@ -714,6 +739,250 @@ class TestThreadSelectorPageNavigation:
|
||||
assert screen._selected_index == 0
|
||||
|
||||
|
||||
class _ThreadSelectorScopedTestApp(App):
|
||||
"""Test app that mounts the picker with an explicit `filter_cwd`."""
|
||||
|
||||
def __init__(self, filter_cwd: str | None) -> None:
|
||||
super().__init__()
|
||||
self._filter_cwd = filter_cwd
|
||||
self.result: str | None = None
|
||||
self.dismissed = False
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Container(id="main")
|
||||
|
||||
def show_selector(self) -> None:
|
||||
"""Mount the selector with a caller-supplied cwd filter."""
|
||||
|
||||
def handle_result(result: str | None) -> None:
|
||||
self.result = result
|
||||
self.dismissed = True
|
||||
|
||||
screen = ThreadSelectorScreen(current_thread=None, filter_cwd=self._filter_cwd)
|
||||
self.push_screen(screen, handle_result)
|
||||
|
||||
|
||||
class TestThreadSelectorScopeSelect:
|
||||
"""Tests for the cwd scope `Select` in the Options panel."""
|
||||
|
||||
async def test_enter_opens_scope_select_without_resuming_thread(self) -> None:
|
||||
"""Enter on the focused scope control should open its dropdown."""
|
||||
with _patch_list_threads(), _patch_columns():
|
||||
app = ThreadSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_selector()
|
||||
await pilot.pause()
|
||||
|
||||
screen = app.screen
|
||||
assert isinstance(screen, ThreadSelectorScreen)
|
||||
scope_select = screen.query_one("#thread-scope-select", Select)
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert scope_select.has_focus
|
||||
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert scope_select.expanded
|
||||
assert not app.dismissed
|
||||
|
||||
async def test_tab_keys_move_open_scope_select_highlight(self) -> None:
|
||||
"""Tab and Shift+Tab should move the dropdown highlight while open."""
|
||||
with _patch_list_threads(), _patch_columns():
|
||||
app = ThreadSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_selector()
|
||||
await pilot.pause()
|
||||
|
||||
screen = app.screen
|
||||
assert isinstance(screen, ThreadSelectorScreen)
|
||||
filter_input = screen.query_one("#thread-filter", Input)
|
||||
scope_select = screen.query_one("#thread-scope-select", Select)
|
||||
sort_switch = screen.query_one("#thread-sort-toggle", Checkbox)
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert scope_select.expanded
|
||||
overlay = scope_select.query_one(ThreadScopeSelectOverlay)
|
||||
assert overlay.highlighted == 1
|
||||
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
assert scope_select.expanded
|
||||
assert overlay.highlighted == 0
|
||||
assert not filter_input.has_focus
|
||||
assert not app.dismissed
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert scope_select.expanded
|
||||
assert overlay.highlighted == 1
|
||||
assert not sort_switch.has_focus
|
||||
assert not app.dismissed
|
||||
|
||||
async def test_arrow_keys_move_open_scope_select_not_thread_list(self) -> None:
|
||||
"""Normal dropdown navigation should not move the thread highlight."""
|
||||
with _patch_list_threads(), _patch_columns():
|
||||
app = ThreadSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_selector()
|
||||
await pilot.pause()
|
||||
|
||||
screen = app.screen
|
||||
assert isinstance(screen, ThreadSelectorScreen)
|
||||
scope_select = screen.query_one("#thread-scope-select", Select)
|
||||
|
||||
screen._selected_index = 1
|
||||
await pilot.press("tab")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert scope_select.expanded
|
||||
overlay = scope_select.query_one(ThreadScopeSelectOverlay)
|
||||
assert overlay.highlighted == 1
|
||||
|
||||
await pilot.press("up")
|
||||
await pilot.pause()
|
||||
assert overlay.highlighted == 0
|
||||
assert screen._selected_index == 1
|
||||
|
||||
await pilot.press("down")
|
||||
await pilot.pause()
|
||||
assert overlay.highlighted == 1
|
||||
assert screen._selected_index == 1
|
||||
|
||||
await pilot.press("pageup")
|
||||
await pilot.pause()
|
||||
assert overlay.highlighted == 0
|
||||
assert screen._selected_index == 1
|
||||
|
||||
await pilot.press("pagedown")
|
||||
await pilot.pause()
|
||||
assert overlay.highlighted == 1
|
||||
assert screen._selected_index == 1
|
||||
assert not app.dismissed
|
||||
|
||||
async def test_escape_closes_open_scope_select_without_dismissing(self) -> None:
|
||||
"""Esc should close the dropdown before it cancels the selector."""
|
||||
with _patch_list_threads(), _patch_columns():
|
||||
app = ThreadSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_selector()
|
||||
await pilot.pause()
|
||||
|
||||
screen = app.screen
|
||||
assert isinstance(screen, ThreadSelectorScreen)
|
||||
scope_select = screen.query_one("#thread-scope-select", Select)
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert scope_select.expanded
|
||||
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
|
||||
assert not scope_select.expanded
|
||||
assert scope_select.has_focus
|
||||
assert not app.dismissed
|
||||
|
||||
async def test_enter_selects_open_scope_select_without_resuming(self) -> None:
|
||||
"""Enter should choose the highlighted dropdown option while open."""
|
||||
with _patch_list_threads(), _patch_columns():
|
||||
app = ThreadSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.show_selector()
|
||||
await pilot.pause()
|
||||
|
||||
screen = app.screen
|
||||
assert isinstance(screen, ThreadSelectorScreen)
|
||||
scope_select = screen.query_one("#thread-scope-select", Select)
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert scope_select.expanded
|
||||
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert not scope_select.expanded
|
||||
assert scope_select.has_focus
|
||||
assert not app.dismissed
|
||||
|
||||
async def test_select_toggle_requeries_with_new_cwd(self) -> None:
|
||||
"""Switching the scope dropdown reloads threads with the new cwd kwarg."""
|
||||
starting_cwd = "/home/user/project-a"
|
||||
mock_list = AsyncMock(return_value=MOCK_THREADS)
|
||||
|
||||
with (
|
||||
patch("deepagents_cli.sessions.list_threads", mock_list),
|
||||
_patch_columns(),
|
||||
patch(
|
||||
"deepagents_cli.widgets.thread_selector._safe_cwd_string",
|
||||
return_value=starting_cwd,
|
||||
),
|
||||
):
|
||||
app = _ThreadSelectorScopedTestApp(filter_cwd=starting_cwd)
|
||||
async with app.run_test() as pilot:
|
||||
app.show_selector()
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
screen = app.screen
|
||||
assert isinstance(screen, ThreadSelectorScreen)
|
||||
assert screen._filter_cwd == starting_cwd
|
||||
|
||||
scope_select = screen.query_one("#thread-scope-select", Select)
|
||||
# Sanity: initial query carried the cwd filter.
|
||||
assert mock_list.await_count >= 1
|
||||
initial_kwargs = [c.kwargs for c in mock_list.await_args_list]
|
||||
assert any(kw.get("cwd") == starting_cwd for kw in initial_kwargs), (
|
||||
f"expected starting cwd, got {initial_kwargs}"
|
||||
)
|
||||
|
||||
mock_list.reset_mock()
|
||||
scope_select.value = "all"
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
assert screen._filter_cwd is None
|
||||
toggled_kwargs = [c.kwargs for c in mock_list.await_args_list]
|
||||
assert any(kw.get("cwd") is None for kw in toggled_kwargs), (
|
||||
f"expected re-query with cwd=None, got {toggled_kwargs}"
|
||||
)
|
||||
|
||||
mock_list.reset_mock()
|
||||
scope_select.value = "cwd"
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
assert screen._filter_cwd == starting_cwd
|
||||
back_kwargs = [c.kwargs for c in mock_list.await_args_list]
|
||||
assert any(kw.get("cwd") == starting_cwd for kw in back_kwargs), (
|
||||
f"expected re-query with cwd={starting_cwd!r}, got {back_kwargs}"
|
||||
)
|
||||
|
||||
async def test_select_same_value_does_not_requery(self) -> None:
|
||||
"""Setting the dropdown to its current value is a no-op."""
|
||||
mock_list = AsyncMock(return_value=MOCK_THREADS)
|
||||
with (
|
||||
patch("deepagents_cli.sessions.list_threads", mock_list),
|
||||
_patch_columns(),
|
||||
):
|
||||
app = _ThreadSelectorScopedTestApp(filter_cwd=None)
|
||||
async with app.run_test() as pilot:
|
||||
app.show_selector()
|
||||
await pilot.pause()
|
||||
screen = app.screen
|
||||
assert isinstance(screen, ThreadSelectorScreen)
|
||||
scope_select = screen.query_one("#thread-scope-select", Select)
|
||||
mock_list.reset_mock()
|
||||
# Re-setting to "all" (the active value) must not re-query.
|
||||
scope_select.value = "all"
|
||||
await pilot.pause()
|
||||
mock_list.assert_not_awaited()
|
||||
|
||||
|
||||
class TestThreadSelectorClickHandling:
|
||||
"""Tests for mouse click handling."""
|
||||
|
||||
@@ -1278,7 +1547,10 @@ class TestThreadSelectorLimit:
|
||||
await pilot.pause(0.05)
|
||||
|
||||
mock_lt.assert_awaited_once_with(
|
||||
limit=20, include_message_count=False, sort_by="updated"
|
||||
limit=20,
|
||||
include_message_count=False,
|
||||
sort_by="updated",
|
||||
cwd=None,
|
||||
)
|
||||
mock_populate.assert_awaited_once()
|
||||
|
||||
@@ -1345,7 +1617,8 @@ class TestThreadSelectorCheckpointDetailErrors:
|
||||
"agent_name": "my-agent",
|
||||
"updated_at": "2025-01-15T10:30:00",
|
||||
}
|
||||
]
|
||||
],
|
||||
filter_cwd=None,
|
||||
)
|
||||
|
||||
with (
|
||||
@@ -1409,6 +1682,7 @@ class TestThreadSelectorPrefetchedRows:
|
||||
current_thread="abc12345",
|
||||
thread_limit=20,
|
||||
initial_threads=prefetched,
|
||||
filter_cwd=None,
|
||||
)
|
||||
)
|
||||
await pilot.pause()
|
||||
@@ -1472,6 +1746,7 @@ class TestThreadSelectorPrefetchedRows:
|
||||
current_thread="abc12345",
|
||||
thread_limit=20,
|
||||
initial_threads=prefetched,
|
||||
filter_cwd=None,
|
||||
)
|
||||
)
|
||||
await pilot.pause()
|
||||
@@ -1505,6 +1780,7 @@ class TestThreadSelectorPrefetchedRows:
|
||||
current_thread="abc12345",
|
||||
thread_limit=20,
|
||||
initial_threads=[],
|
||||
filter_cwd=None,
|
||||
)
|
||||
)
|
||||
await pilot.pause()
|
||||
@@ -1597,6 +1873,7 @@ class TestThreadSelectorInitialSortOrder:
|
||||
current_thread=None,
|
||||
thread_limit=20,
|
||||
initial_threads=prefetched,
|
||||
filter_cwd=None,
|
||||
)
|
||||
)
|
||||
await pilot.pause()
|
||||
@@ -1638,6 +1915,7 @@ class TestThreadSelectorSearch:
|
||||
screen = ThreadSelectorScreen(
|
||||
current_thread=None,
|
||||
initial_threads=MOCK_THREADS,
|
||||
filter_cwd=None,
|
||||
)
|
||||
screen._filter_text = ""
|
||||
screen._update_filtered_list()
|
||||
@@ -1648,6 +1926,7 @@ class TestThreadSelectorSearch:
|
||||
screen = ThreadSelectorScreen(
|
||||
current_thread=None,
|
||||
initial_threads=MOCK_THREADS,
|
||||
filter_cwd=None,
|
||||
)
|
||||
screen._filter_text = "def678"
|
||||
screen._update_filtered_list()
|
||||
@@ -1690,7 +1969,9 @@ class TestThreadSelectorSearch:
|
||||
"initial_prompt": "prompt two",
|
||||
},
|
||||
]
|
||||
screen = ThreadSelectorScreen(current_thread=None, initial_threads=threads)
|
||||
screen = ThreadSelectorScreen(
|
||||
current_thread=None, initial_threads=threads, filter_cwd=None
|
||||
)
|
||||
|
||||
screen._filter_text = "p"
|
||||
screen._update_filtered_list()
|
||||
@@ -3075,6 +3356,7 @@ class TestThreadSelectorDomSkip:
|
||||
current_thread="abc12345",
|
||||
thread_limit=20,
|
||||
initial_threads=prefetched,
|
||||
filter_cwd=None,
|
||||
)
|
||||
)
|
||||
await pilot.pause()
|
||||
|
||||
Reference in New Issue
Block a user