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:
Cesar Garcia
2026-05-11 00:35:34 -03:00
committed by GitHub
parent 0904974d52
commit 07a973ae5b
12 changed files with 1019 additions and 43 deletions
+6 -3
View File
@@ -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
+13 -4
View File
@@ -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/"
+74 -11
View File
@@ -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,
+12
View File
@@ -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.
+42 -3
View File
@@ -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"
+2
View File
@@ -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)
+11
View File
@@ -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()
+122 -2
View File
@@ -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."""
+128
View File
@@ -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()