zjcode 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. zjcode-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,2564 @@
1
+ """Interactive thread selector screen for `/threads` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import sqlite3
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, ClassVar, cast
11
+
12
+ from rich.cells import cell_len
13
+ from rich.text import Text
14
+ from textual.binding import Binding, BindingType
15
+ from textual.color import Color as TColor
16
+ from textual.containers import Horizontal, Vertical, VerticalScroll
17
+ from textual.content import Content
18
+ from textual.css.query import NoMatches
19
+ from textual.fuzzy import Matcher
20
+ from textual.message import Message
21
+ from textual.screen import ModalScreen
22
+ from textual.style import Style as TStyle
23
+ from textual.widgets import Checkbox, Input, Select, Static
24
+
25
+ # Specialize focused Select overlay key handling; no public re-export available.
26
+ from textual.widgets._select import ( # noqa: PLC2701
27
+ NoSelection,
28
+ Option,
29
+ SelectCurrent,
30
+ SelectOverlay,
31
+ )
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Callable, Mapping
35
+
36
+ from textual.app import ComposeResult
37
+ from textual.events import Click, Key, MouseMove
38
+
39
+ from deepagents_code.sessions import ThreadInfo
40
+
41
+ from deepagents_code import theme
42
+ from deepagents_code.config import (
43
+ build_langsmith_thread_url,
44
+ get_glyphs,
45
+ is_ascii_mode,
46
+ )
47
+ from deepagents_code.tui.widgets._links import open_style_link
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ _URL_FETCH_TIMEOUT = 2.0
52
+ """Seconds to wait for LangSmith thread-URL resolution before giving up."""
53
+
54
+ _column_widths_cache: (
55
+ tuple[
56
+ tuple[tuple[str, str | None], ...], # (thread_id, checkpoint_id) fingerprint
57
+ frozenset[str], # visible column keys
58
+ bool, # relative_time
59
+ dict[str, int | None], # computed widths
60
+ ]
61
+ | None
62
+ ) = None
63
+ """Module-level cache so repeated `/threads` opens skip column-width computation
64
+ when the inputs (thread data + config) haven't changed."""
65
+
66
+ _COL_TID = 10
67
+ _COL_AGENT = 12
68
+ _COL_MSGS = 4
69
+ _COL_BRANCH = 16
70
+ _COL_TIMESTAMP = None
71
+ _MAX_SEARCH_TEXT_LEN = 200
72
+ _COL_PROMPT = None
73
+ _AUTO_WIDTH_COLUMNS = {"agent_name", "created_at", "updated_at", "cwd"}
74
+ _COLUMN_ORDER = (
75
+ "thread_id",
76
+ "agent_name",
77
+ "messages",
78
+ "created_at",
79
+ "updated_at",
80
+ "git_branch",
81
+ "cwd",
82
+ "initial_prompt",
83
+ )
84
+ _COLUMN_WIDTHS: dict[str, int | None] = {
85
+ "thread_id": _COL_TID,
86
+ "agent_name": _COL_AGENT,
87
+ "messages": _COL_MSGS,
88
+ "created_at": _COL_TIMESTAMP,
89
+ "updated_at": _COL_TIMESTAMP,
90
+ "git_branch": _COL_BRANCH,
91
+ "cwd": None,
92
+ "initial_prompt": _COL_PROMPT,
93
+ }
94
+ _COLUMN_LABELS = {
95
+ "thread_id": "Thread ID",
96
+ "agent_name": "Agent",
97
+ "messages": "Msgs",
98
+ "created_at": "Created",
99
+ "updated_at": "Updated",
100
+ "git_branch": "Branch",
101
+ "cwd": "Location",
102
+ "initial_prompt": "Prompt",
103
+ }
104
+ _COLUMN_TOGGLE_LABELS = {
105
+ "thread_id": "Thread ID",
106
+ "agent_name": "Agent Name",
107
+ "messages": "# Messages",
108
+ "created_at": "Created At",
109
+ "updated_at": "Updated At",
110
+ "git_branch": "Git Branch",
111
+ "cwd": "Working Directory",
112
+ "initial_prompt": "Initial Prompt",
113
+ }
114
+ # Reserved for future right-aligned columns (e.g., message counts).
115
+ _RIGHT_ALIGNED_COLUMNS: set[str] = set()
116
+ _SWITCH_ID_PREFIX = "thread-column-"
117
+ _RELATIVE_TIME_SWITCH_ID = "thread-relative-time"
118
+ _SCOPE_SELECT_ID = "thread-scope-select"
119
+ _SCOPE_VALUE_CWD = "cwd"
120
+ _SCOPE_VALUE_ALL = "all"
121
+ _SORT_SELECT_ID = "thread-sort-select"
122
+ _SORT_VALUE_CREATED = "created_at"
123
+ _SORT_VALUE_UPDATED = "updated_at"
124
+ _AGENT_SELECT_ID = "thread-agent-select"
125
+ _AGENT_VALUE_ALL = "__all__"
126
+ _AGENT_VALUE_LOADING = "__loading__"
127
+ # Label shown in the agent dropdown while the disk load is still pending and no
128
+ # agent names are known yet. The options list is derived from visible threads
129
+ # (plus the configured `/agents` list), so before the load completes it would
130
+ # otherwise show only the "All agents" sentinel; "Loading..." signals that more
131
+ # options may appear. Uses a distinct transient value so the final "All agents"
132
+ # label refreshes when the completed load swaps in the real sentinel. Matches
133
+ # the "Loading threads..." empty-state copy.
134
+ _AGENT_LABEL_LOADING = "Loading..."
135
+ # Display label and filter key for threads with no stored `agent_name`. Shared
136
+ # between the Agent column renderer, the agent dropdown options, and the filter
137
+ # predicate so all three read identically. The parentheses distinguish the
138
+ # synthetic "missing agent" bucket from an agent literally named "unknown".
139
+ _UNKNOWN_AGENT_LABEL = "(unknown)"
140
+ _OPTION_SELECT_IDS = (_SCOPE_SELECT_ID, _SORT_SELECT_ID, _AGENT_SELECT_ID)
141
+ _CONTROLS_SCROLL_ID = "thread-controls-scroll"
142
+ _CONTROLS_OVERFLOW_ID = "thread-controls-overflow"
143
+ _CELL_PADDING_RIGHT = 1
144
+
145
+
146
+ class _Sentinel:
147
+ """Type for module-level singleton sentinels."""
148
+
149
+
150
+ _CWD_DEFAULT = _Sentinel()
151
+ """Sentinel for the default `filter_cwd` constructor arg.
152
+
153
+ Distinguishes "caller did not specify" (use the current working directory) from
154
+ an explicit `None` (start with no cwd filter)."""
155
+
156
+
157
+ def _safe_cwd_string() -> str | None:
158
+ """Return `str(Path.cwd())` or `None` if the working directory is unreadable.
159
+
160
+ `Path.cwd()` raises `OSError` (typically `FileNotFoundError`) when the
161
+ process's working directory has been deleted or is otherwise inaccessible.
162
+ Treating that as "no cwd filter" matches the storage convention used by
163
+ `build_run_metadata` and lets the picker degrade to "All directories"
164
+ instead of crashing the Textual event loop.
165
+ """
166
+ try:
167
+ return str(Path.cwd())
168
+ except OSError:
169
+ logger.warning(
170
+ "Could not determine working directory for thread picker; "
171
+ "falling back to All directories",
172
+ exc_info=True,
173
+ )
174
+ return None
175
+
176
+
177
+ _FormatFns = tuple[
178
+ "Callable[[str | None], str]", # format_path
179
+ "Callable[[str | None], str]", # format_relative_timestamp
180
+ "Callable[[str | None], str]", # format_timestamp
181
+ ]
182
+ """Cached `(format_path, format_relative_timestamp, format_timestamp)`.
183
+
184
+ Resolved once on first use via `_get_format_fns()` to avoid the overhead of
185
+ a per-call deferred import inside the hot `_format_column_value` loop.
186
+ """
187
+
188
+ _format_fns_cache: _FormatFns | None = None
189
+ """Cached format functions, populated on first call to `_get_format_fns()`."""
190
+
191
+
192
+ def _get_format_fns() -> _FormatFns:
193
+ """Return cached `(format_path, format_relative_timestamp, format_timestamp)`."""
194
+ global _format_fns_cache # noqa: PLW0603
195
+ if _format_fns_cache is not None:
196
+ return _format_fns_cache
197
+ from deepagents_code.sessions import (
198
+ format_path,
199
+ format_relative_timestamp,
200
+ format_timestamp,
201
+ )
202
+
203
+ _format_fns_cache = (format_path, format_relative_timestamp, format_timestamp)
204
+ return _format_fns_cache
205
+
206
+
207
+ def _apply_column_width(
208
+ cell: Static, key: str, column_widths: Mapping[str, int | None]
209
+ ) -> None:
210
+ """Apply an explicit width to a table cell when one is configured.
211
+
212
+ Args:
213
+ cell: The cell widget to size.
214
+ key: Column key for the cell.
215
+ column_widths: Effective column widths for the current table state.
216
+ """
217
+ width = column_widths.get(key)
218
+ if width is not None:
219
+ cell.styles.width = width
220
+ if key in _AUTO_WIDTH_COLUMNS:
221
+ cell.styles.min_width = width
222
+
223
+
224
+ def _active_sort_key(sort_by_updated: bool) -> str:
225
+ """Return the active timestamp field used for sorting."""
226
+ return "updated_at" if sort_by_updated else "created_at"
227
+
228
+
229
+ def _visible_column_keys(columns: dict[str, bool]) -> list[str]:
230
+ """Return visible columns in the on-screen order.
231
+
232
+ Args:
233
+ columns: Column visibility settings keyed by column name.
234
+
235
+ Returns:
236
+ Visible column keys in display order.
237
+ """
238
+ return [key for key in _COLUMN_ORDER if columns.get(key)]
239
+
240
+
241
+ def _collapse_whitespace(value: str) -> str:
242
+ """Normalize a text value onto a single display line.
243
+
244
+ Args:
245
+ value: Raw text to display in a single cell.
246
+
247
+ Returns:
248
+ The input text collapsed to a single line.
249
+ """
250
+ return " ".join(value.split())
251
+
252
+
253
+ def _truncate_value(value: str, width: int | None) -> str:
254
+ """Trim text to fit a fixed-width column.
255
+
256
+ Args:
257
+ value: Raw cell text.
258
+ width: Maximum column width, or `None` for no truncation.
259
+
260
+ Returns:
261
+ The possibly truncated display string.
262
+ """
263
+ if width is None:
264
+ return value
265
+
266
+ display = _collapse_whitespace(value)
267
+ if len(display) <= width:
268
+ return display
269
+
270
+ glyphs = get_glyphs()
271
+ ellipsis = glyphs.ellipsis
272
+ if width <= len(ellipsis):
273
+ return display[:width]
274
+ return display[: width - len(ellipsis)] + ellipsis
275
+
276
+
277
+ def _format_column_value(
278
+ thread: ThreadInfo, key: str, *, relative_time: bool = False
279
+ ) -> str:
280
+ """Return the display text for one thread column.
281
+
282
+ Args:
283
+ thread: Thread metadata for the row.
284
+ key: Column key to format.
285
+ relative_time: Use relative timestamps instead of absolute.
286
+
287
+ Returns:
288
+ Formatted display text for the column cell.
289
+ """
290
+ format_path, format_relative_ts, format_ts = _get_format_fns()
291
+ fmt = format_relative_ts if relative_time else format_ts
292
+
293
+ value: str
294
+ if key == "thread_id":
295
+ # Strip UUID separators in the compact table preview so truncation
296
+ # never leaves a dangling trailing hyphen in the thread ID column.
297
+ value = thread["thread_id"].replace("-", "")
298
+ elif key == "agent_name":
299
+ value = thread.get("agent_name") or _UNKNOWN_AGENT_LABEL
300
+ elif key == "messages":
301
+ raw_count = thread.get("message_count")
302
+ value = str(raw_count) if raw_count is not None else "..."
303
+ elif key == "created_at":
304
+ value = fmt(thread.get("created_at"))
305
+ elif key == "updated_at":
306
+ value = fmt(thread.get("updated_at"))
307
+ elif key == "git_branch":
308
+ value = thread.get("git_branch") or ""
309
+ elif key == "cwd":
310
+ value = format_path(thread.get("cwd"))
311
+ elif key == "initial_prompt":
312
+ value = _collapse_whitespace(thread.get("initial_prompt") or "")
313
+ else:
314
+ value = ""
315
+
316
+ return _truncate_value(value, _COLUMN_WIDTHS[key])
317
+
318
+
319
+ def _format_header_label(key: str) -> str:
320
+ """Return the rendered header label for a column."""
321
+ return _truncate_value(_COLUMN_LABELS[key], _COLUMN_WIDTHS[key])
322
+
323
+
324
+ def _header_cell_classes(key: str, *, sort_key: str) -> str:
325
+ """Return CSS classes for a header cell.
326
+
327
+ Args:
328
+ key: Column key for the header cell.
329
+ sort_key: Currently active sort column.
330
+
331
+ Returns:
332
+ Space-delimited classes for the header cell widget.
333
+ """
334
+ classes = f"thread-cell thread-cell-{key}"
335
+ if key == sort_key:
336
+ classes += " thread-cell-sorted"
337
+ return classes
338
+
339
+
340
+ class ThreadOption(Horizontal):
341
+ """A clickable thread option in the selector."""
342
+
343
+ def __init__(
344
+ self,
345
+ thread: ThreadInfo,
346
+ index: int,
347
+ *,
348
+ columns: dict[str, bool],
349
+ column_widths: Mapping[str, int | None],
350
+ selected: bool,
351
+ current: bool,
352
+ relative_time: bool = False,
353
+ cell_text: dict[tuple[str, str], str] | None = None,
354
+ classes: str = "",
355
+ ) -> None:
356
+ """Initialize a thread option row.
357
+
358
+ Args:
359
+ thread: Thread metadata for the row.
360
+ index: The index of this option in the filtered list.
361
+ columns: Column visibility settings.
362
+ column_widths: Effective widths for the visible columns.
363
+ selected: Whether the row is highlighted.
364
+ current: Whether the row is the active thread.
365
+ relative_time: Use relative timestamps.
366
+ cell_text: Pre-formatted cell values keyed by `(thread_id, key)`.
367
+ classes: CSS classes for styling.
368
+ """
369
+ super().__init__(classes=classes)
370
+ self.thread = thread
371
+ self.thread_id = thread["thread_id"]
372
+ self.index = index
373
+ self._columns = dict(columns)
374
+ self._column_widths = dict(column_widths)
375
+ self._selected = selected
376
+ self._current = current
377
+ self._relative_time = relative_time
378
+ self._cell_text = cell_text
379
+
380
+ class Clicked(Message):
381
+ """Message sent when a thread option is clicked."""
382
+
383
+ def __init__(self, thread_id: str, index: int) -> None:
384
+ """Initialize the Clicked message.
385
+
386
+ Args:
387
+ thread_id: The thread identifier.
388
+ index: The index of the clicked option.
389
+ """
390
+ super().__init__()
391
+ self.thread_id = thread_id
392
+ self.index = index
393
+
394
+ def compose(self) -> ComposeResult:
395
+ """Compose the row cells.
396
+
397
+ Yields:
398
+ Static cells for each visible column.
399
+ """
400
+ yield Static(
401
+ self._cursor_text(),
402
+ classes="thread-cell thread-cell-cursor",
403
+ markup=False,
404
+ )
405
+ tid = self.thread_id
406
+ for key in _visible_column_keys(self._columns):
407
+ if self._cell_text is not None and (tid, key) in self._cell_text:
408
+ text = self._cell_text[tid, key]
409
+ else:
410
+ text = _format_column_value(
411
+ self.thread, key, relative_time=self._relative_time
412
+ )
413
+ cell = Static(
414
+ text,
415
+ classes=f"thread-cell thread-cell-{key}",
416
+ expand=key == "initial_prompt",
417
+ markup=False,
418
+ )
419
+ _apply_column_width(cell, key, self._column_widths)
420
+ yield cell
421
+
422
+ def _cursor_text(self) -> str:
423
+ """Return the cursor indicator for the row."""
424
+ return get_glyphs().cursor if self._selected else ""
425
+
426
+ def set_selected(self, selected: bool) -> None:
427
+ """Update row selection styling without rebuilding the row.
428
+
429
+ Args:
430
+ selected: Whether the row should be highlighted.
431
+ """
432
+ self._selected = selected
433
+ if selected:
434
+ self.add_class("thread-option-selected")
435
+ else:
436
+ self.remove_class("thread-option-selected")
437
+
438
+ try:
439
+ cursor = self.query_one(".thread-cell-cursor", Static)
440
+ except NoMatches:
441
+ return
442
+ cursor.update(self._cursor_text())
443
+
444
+ def on_click(self, event: Click) -> None:
445
+ """Handle click on this option.
446
+
447
+ Args:
448
+ event: The click event.
449
+ """
450
+ event.stop()
451
+ self.post_message(self.Clicked(self.thread_id, self.index))
452
+
453
+
454
+ class ThreadControlsScroll(VerticalScroll):
455
+ """Emit scroll changes so the parent can refresh the overflow indicator."""
456
+
457
+ class Scrolled(Message):
458
+ """Message sent when the controls pane scroll position changes."""
459
+
460
+ def watch_scroll_y(self, old_value: float, new_value: float) -> None:
461
+ """Notify the selector so it can update the overflow indicator."""
462
+ super().watch_scroll_y(old_value, new_value)
463
+ if self.is_attached:
464
+ self.post_message(self.Scrolled())
465
+
466
+
467
+ class DeleteThreadConfirmScreen(ModalScreen[bool]):
468
+ """Confirmation modal shown before deleting a thread."""
469
+
470
+ BINDINGS: ClassVar[list[BindingType]] = [
471
+ Binding("enter", "confirm", "Confirm", show=False, priority=True),
472
+ Binding("escape", "cancel", "Cancel", show=False, priority=True),
473
+ ]
474
+ """Key bindings for the delete-confirmation overlay.
475
+
476
+ Enter confirms deletion, Esc cancels. Both use `priority=True` so they take
477
+ precedence over bindings on the underlying thread selector.
478
+ """
479
+
480
+ CSS = """
481
+ DeleteThreadConfirmScreen {
482
+ align: center middle;
483
+ }
484
+
485
+ DeleteThreadConfirmScreen > Vertical {
486
+ width: 50;
487
+ height: auto;
488
+ background: $surface;
489
+ border: solid red;
490
+ padding: 1 2;
491
+ }
492
+
493
+ DeleteThreadConfirmScreen .thread-confirm-text {
494
+ text-align: center;
495
+ margin-bottom: 1;
496
+ }
497
+
498
+ DeleteThreadConfirmScreen .thread-confirm-help {
499
+ text-align: center;
500
+ color: $text-muted;
501
+ text-style: italic;
502
+ }
503
+ """
504
+ """Styling for the centered confirmation dialog, red border, and help text."""
505
+
506
+ def __init__(self, thread_id: str) -> None:
507
+ """Initialize the confirmation modal.
508
+
509
+ Args:
510
+ thread_id: Thread ID the user is being asked to delete.
511
+ """
512
+ super().__init__()
513
+ self._delete_thread_id = thread_id
514
+
515
+ def compose(self) -> ComposeResult:
516
+ """Compose the confirmation dialog.
517
+
518
+ Yields:
519
+ Widgets for the delete confirmation prompt.
520
+ """
521
+ with Vertical(id="delete-confirm"):
522
+ yield Static(
523
+ Content.from_markup(
524
+ "Delete thread [bold]$tid[/bold]?",
525
+ tid=self._delete_thread_id,
526
+ ),
527
+ classes="thread-confirm-text",
528
+ )
529
+ yield Static(
530
+ "Enter to confirm, Esc to cancel",
531
+ classes="thread-confirm-help",
532
+ )
533
+
534
+ def action_confirm(self) -> None:
535
+ """Confirm deletion."""
536
+ self.dismiss(True)
537
+
538
+ def action_cancel(self) -> None:
539
+ """Cancel deletion."""
540
+ self.dismiss(False)
541
+
542
+
543
+ _OPTION_OVERLAY_CONSUMED_KEYS = frozenset(
544
+ {"tab", "shift+tab", "up", "down", "pageup", "pagedown", "home", "end"}
545
+ )
546
+
547
+
548
+ class ContainedSelectOverlay(SelectOverlay):
549
+ """Options dropdown overlay that consumes option navigation while focused."""
550
+
551
+ def key_tab(self, event: Key) -> None:
552
+ """Move to the next option without leaving the dropdown."""
553
+ event.prevent_default()
554
+ event.stop()
555
+ self.action_cursor_down()
556
+
557
+ def key_shift_tab(self, event: Key) -> None:
558
+ """Move to the previous option without leaving the dropdown."""
559
+ event.prevent_default()
560
+ event.stop()
561
+ self.action_cursor_up()
562
+
563
+ def check_consume_key(self, key: str, character: str | None = None) -> bool:
564
+ """Report option navigation consumption so ancestor bindings stay hidden.
565
+
566
+ Args:
567
+ key: Pressed key identifier.
568
+ character: Printable character for the key, when present.
569
+
570
+ Returns:
571
+ `True` when the overlay consumes the key.
572
+ """
573
+ if key in _OPTION_OVERLAY_CONSUMED_KEYS:
574
+ return True
575
+ return super().check_consume_key(key, character)
576
+
577
+
578
+ class ContainedSelect(Select[str]):
579
+ """Options dropdown that keeps focus contained while its menu is open."""
580
+
581
+ def action_show_overlay(self) -> None:
582
+ """Show the overlay when it has finished mounting."""
583
+ try:
584
+ select_current = self.query_one(SelectCurrent)
585
+ select_overlay = self.query_one(ContainedSelectOverlay)
586
+ except NoMatches:
587
+ return
588
+
589
+ select_current.has_value = True
590
+ self.expanded = True
591
+ if select_overlay.highlighted is None:
592
+ select_overlay.action_first()
593
+
594
+ def compose(self) -> ComposeResult:
595
+ """Compose the select with a containment-aware overlay.
596
+
597
+ Yields:
598
+ Current value display and dropdown overlay widgets.
599
+ """
600
+ yield SelectCurrent(self.prompt)
601
+ yield ContainedSelectOverlay(type_to_search=self._type_to_search).data_bind(
602
+ compact=Select.compact
603
+ )
604
+
605
+ def _setup_options_renderables(self) -> None:
606
+ """Populate the custom overlay when options change."""
607
+ options = [
608
+ Option(Text(self.prompt, style="dim"))
609
+ if value == self.NULL
610
+ else Option(prompt)
611
+ for prompt, value in self._options
612
+ ]
613
+
614
+ try:
615
+ option_list = self.query_one(ContainedSelectOverlay)
616
+ except NoMatches:
617
+ if self.is_attached:
618
+ self.call_after_refresh(self._setup_options_renderables)
619
+ return
620
+
621
+ option_list.clear_options()
622
+ option_list.add_options(options)
623
+
624
+ def _watch_value(self, value: str | NoSelection) -> None:
625
+ """Update the current value while using the custom overlay widget."""
626
+ self._value = value
627
+ try:
628
+ select_current = self.query_one(SelectCurrent)
629
+ except NoMatches:
630
+ return
631
+
632
+ if value == self.NULL:
633
+ select_current.update(self.NULL)
634
+ else:
635
+ for index, (prompt, option_value) in enumerate(self._options):
636
+ if option_value == value:
637
+ with contextlib.suppress(NoMatches):
638
+ select_overlay = self.query_one(ContainedSelectOverlay)
639
+ select_overlay.highlighted = index
640
+ select_current.update(prompt)
641
+ break
642
+ self.post_message(self.Changed(self, value))
643
+
644
+ def key_tab(self, event: Key) -> None:
645
+ """Prevent focus traversal while the dropdown menu is open."""
646
+ if self.expanded:
647
+ event.prevent_default()
648
+ event.stop()
649
+
650
+ def key_shift_tab(self, event: Key) -> None:
651
+ """Prevent reverse focus traversal while the dropdown menu is open."""
652
+ if self.expanded:
653
+ event.prevent_default()
654
+ event.stop()
655
+
656
+ def check_consume_key(self, key: str, character: str | None) -> bool:
657
+ """Report Tab consumption while expanded so global bindings stay hidden.
658
+
659
+ Args:
660
+ key: Pressed key identifier.
661
+ character: Printable character for the key, when present.
662
+
663
+ Returns:
664
+ `True` when the open dropdown consumes the key.
665
+ """
666
+ if self.expanded and key in {"tab", "shift+tab"}:
667
+ return True
668
+ return super().check_consume_key(key, character)
669
+
670
+
671
+ class ThreadSelectorScreen(ModalScreen[str | None]):
672
+ """Modal dialog for browsing and resuming threads.
673
+
674
+ Displays recent threads with keyboard navigation, fuzzy search,
675
+ configurable columns, and delete support.
676
+
677
+ Returns a `thread_id` string on selection, or `None` on cancel.
678
+ """
679
+
680
+ BINDINGS: ClassVar[list[BindingType]] = [
681
+ Binding("up", "move_up", "Up", show=False, priority=True),
682
+ Binding("down", "move_down", "Down", show=False, priority=True),
683
+ Binding("pageup", "page_up", "Page up", show=False, priority=True),
684
+ Binding("pagedown", "page_down", "Page down", show=False, priority=True),
685
+ Binding("enter", "select", "Select", show=False, priority=True),
686
+ Binding("escape", "cancel", "Cancel", show=False, priority=True),
687
+ Binding("ctrl+d", "delete_thread", "Delete", show=False, priority=True),
688
+ Binding("tab", "focus_next_filter", "Next filter", show=False, priority=True),
689
+ Binding(
690
+ "shift+tab",
691
+ "focus_previous_filter",
692
+ "Previous filter",
693
+ show=False,
694
+ priority=True,
695
+ ),
696
+ ]
697
+ """Key bindings for thread navigation, selection, deletion, and filter focus.
698
+
699
+ Arrows move the cursor, Page Up/Down jump by a visual page, Enter
700
+ selects the highlighted thread, Ctrl+D opens the delete-confirmation
701
+ overlay, Tab/Shift+Tab rotate focus through the filter input, the
702
+ scope/sort/agent dropdowns, and the relative-timestamp and
703
+ column-visibility checkboxes, and Esc dismisses. All bindings use
704
+ `priority=True` so they take precedence over the embedded filter
705
+ `Input`, `Select`, and checkbox widgets; vim-style `j`/`k` bindings are
706
+ deliberately omitted because they would prevent typing those letters
707
+ into the always-focused filter input.
708
+ """
709
+
710
+ CSS = """
711
+ ThreadSelectorScreen {
712
+ align: center middle;
713
+ }
714
+
715
+ ThreadSelectorScreen #thread-selector-shell {
716
+ width: 100%;
717
+ max-width: 98%;
718
+ height: 90%;
719
+ background: $surface;
720
+ border: solid $primary;
721
+ padding: 1 2;
722
+ }
723
+
724
+ ThreadSelectorScreen .thread-selector-title {
725
+ text-style: bold;
726
+ color: $primary;
727
+ text-align: center;
728
+ margin-bottom: 1;
729
+ }
730
+
731
+ ThreadSelectorScreen #thread-filter {
732
+ margin-bottom: 1;
733
+ border: solid $primary-lighten-2;
734
+ }
735
+
736
+ ThreadSelectorScreen #thread-filter:focus {
737
+ border: solid $primary;
738
+ }
739
+
740
+ ThreadSelectorScreen .thread-selector-body {
741
+ height: 1fr;
742
+ }
743
+
744
+ ThreadSelectorScreen .thread-table-pane {
745
+ width: 1fr;
746
+ min-width: 40;
747
+ height: 1fr;
748
+ }
749
+
750
+ ThreadSelectorScreen .thread-controls {
751
+ width: 28;
752
+ min-width: 24;
753
+ height: 1fr;
754
+ margin-left: 1;
755
+ padding-left: 1;
756
+ border-left: solid $primary-lighten-2;
757
+ }
758
+
759
+ ThreadSelectorScreen .thread-controls-scroll {
760
+ height: 1fr;
761
+ min-height: 1;
762
+ scrollbar-gutter: stable;
763
+ }
764
+
765
+ ThreadSelectorScreen .thread-controls-title {
766
+ text-style: bold;
767
+ color: $primary;
768
+ margin-bottom: 1;
769
+ }
770
+
771
+ ThreadSelectorScreen .thread-controls-help {
772
+ color: $text-muted;
773
+ margin-bottom: 1;
774
+ }
775
+
776
+ ThreadSelectorScreen .thread-controls-label {
777
+ color: $text-muted;
778
+ margin-top: 0;
779
+ }
780
+
781
+ ThreadSelectorScreen .thread-option-select,
782
+ ThreadSelectorScreen .thread-agent-select {
783
+ width: 1fr;
784
+ height: auto;
785
+ margin-bottom: 1;
786
+ }
787
+
788
+ ThreadSelectorScreen .thread-column-toggle {
789
+ width: 1fr;
790
+ height: auto;
791
+ }
792
+
793
+ ThreadSelectorScreen .thread-controls-overflow {
794
+ height: 1;
795
+ color: $text-muted;
796
+ text-align: center;
797
+ }
798
+
799
+ ThreadSelectorScreen .thread-list-header {
800
+ height: 1;
801
+ padding: 0 1;
802
+ color: $text-muted;
803
+ text-style: bold;
804
+ width: 100%;
805
+ overflow-x: hidden;
806
+ }
807
+
808
+ ThreadSelectorScreen .thread-list-header .thread-cell-sorted {
809
+ color: $primary;
810
+ }
811
+
812
+ ThreadSelectorScreen .thread-list {
813
+ height: 1fr;
814
+ min-height: 5;
815
+ scrollbar-gutter: stable;
816
+ background: $background;
817
+ }
818
+
819
+ ThreadSelectorScreen .thread-option {
820
+ height: 1;
821
+ width: 100%;
822
+ padding: 0 1;
823
+ overflow-x: hidden;
824
+ }
825
+
826
+ ThreadSelectorScreen .thread-option:hover {
827
+ background: $surface-lighten-1;
828
+ }
829
+
830
+ ThreadSelectorScreen .thread-option-selected {
831
+ background: $primary;
832
+ color: $background;
833
+ text-style: bold;
834
+ }
835
+
836
+ ThreadSelectorScreen .thread-option-selected:hover {
837
+ background: $primary-lighten-1;
838
+ }
839
+
840
+ ThreadSelectorScreen .thread-option-current {
841
+ text-style: italic;
842
+ }
843
+
844
+ ThreadSelectorScreen .thread-cell {
845
+ height: 1;
846
+ padding-right: 1;
847
+ }
848
+
849
+ ThreadSelectorScreen .thread-cell-cursor {
850
+ width: 2;
851
+ color: $primary;
852
+ }
853
+
854
+ ThreadSelectorScreen .thread-cell-thread_id {
855
+ width: 10;
856
+ }
857
+
858
+ ThreadSelectorScreen .thread-cell-agent_name {
859
+ width: auto;
860
+ overflow-x: hidden;
861
+ text-wrap: nowrap;
862
+ text-overflow: ellipsis;
863
+ }
864
+
865
+ ThreadSelectorScreen .thread-cell-messages {
866
+ width: 4;
867
+ }
868
+
869
+ ThreadSelectorScreen .thread-cell-created_at,
870
+ ThreadSelectorScreen .thread-cell-updated_at {
871
+ width: auto;
872
+ }
873
+
874
+ ThreadSelectorScreen .thread-cell-git_branch {
875
+ width: 17;
876
+ overflow-x: hidden;
877
+ text-wrap: nowrap;
878
+ text-overflow: ellipsis;
879
+ }
880
+
881
+ ThreadSelectorScreen .thread-cell-initial_prompt {
882
+ width: 1fr;
883
+ min-width: 1;
884
+ overflow-x: hidden;
885
+ text-wrap: nowrap;
886
+ text-overflow: ellipsis;
887
+ }
888
+
889
+ ThreadSelectorScreen .thread-selector-help {
890
+ height: auto;
891
+ color: $text-muted;
892
+ text-style: italic;
893
+ margin-top: 1;
894
+ text-align: center;
895
+ }
896
+
897
+ ThreadSelectorScreen .thread-empty {
898
+ color: $text-muted;
899
+ text-align: center;
900
+ margin-top: 2;
901
+ }
902
+
903
+ """
904
+ """Styling for the modal shell, filter input, two-pane body (thread table
905
+ and column controls), per-column widths, and help footer."""
906
+
907
+ def __init__(
908
+ self,
909
+ current_thread: str | None = None,
910
+ *,
911
+ thread_limit: int | None = None,
912
+ initial_threads: list[ThreadInfo] | None = None,
913
+ filter_cwd: str | _Sentinel | None = _CWD_DEFAULT,
914
+ ) -> None:
915
+ """Initialize the `ThreadSelectorScreen`.
916
+
917
+ Args:
918
+ current_thread: The currently active thread ID (to highlight).
919
+ thread_limit: Maximum number of rows to fetch when querying DB.
920
+ initial_threads: Optional preloaded rows to render immediately.
921
+ filter_cwd: Working-directory filter for the picker.
922
+
923
+ When the default sentinel, the picker mirrors Claude Code's
924
+ `/resume` and scopes to the current working directory; the
925
+ "Show threads from" select in the Options panel widens the
926
+ view to "all directories". Pass an explicit path to scope to
927
+ that directory, or `None` to start the picker with no cwd
928
+ filter.
929
+ """
930
+ super().__init__()
931
+ self._current_thread = current_thread
932
+ self._thread_limit = thread_limit
933
+
934
+ from deepagents_code.model_config import load_thread_config
935
+
936
+ cfg = load_thread_config()
937
+
938
+ if isinstance(filter_cwd, _Sentinel):
939
+ # Honor the persisted scope: "all" starts with no cwd filter,
940
+ # "cwd" scopes to the current working directory.
941
+ self._filter_cwd: str | None = (
942
+ None if cfg.scope == "all" else _safe_cwd_string()
943
+ )
944
+ else:
945
+ self._filter_cwd = filter_cwd
946
+ initial = list(initial_threads) if initial_threads is not None else []
947
+ # The cached `initial_threads` are unfiltered, so apply the active cwd
948
+ # filter here to avoid showing all threads on first paint when we are
949
+ # about to re-query with the filter active.
950
+ if self._filter_cwd is not None:
951
+ initial = [t for t in initial if t.get("cwd") == self._filter_cwd]
952
+ self._threads: list[ThreadInfo] = initial
953
+ self._filtered_threads: list[ThreadInfo] = list(self._threads)
954
+ self._has_initial_threads = initial_threads is not None
955
+ self._disk_load_complete = False
956
+ self._selected_index = 0
957
+ self._option_widgets: list[ThreadOption] = []
958
+ self._filter_text = ""
959
+ self._filter_agent: str | None = None
960
+ # Configured agent names from `~/.deepagents/` (the `/agents` list),
961
+ # loaded off the event loop in `_load_threads`. Unioned with
962
+ # thread-derived names so the agent filter mirrors `/agents` even for
963
+ # agents that have not produced any threads yet.
964
+ self._available_agent_names: list[str] = []
965
+ self._confirming_delete = False
966
+ self._render_lock = asyncio.Lock()
967
+ self._filter_input: Input | None = None
968
+ self._filter_controls: list[Input | Checkbox | Select[str]] | None = None
969
+ self._cell_text: dict[tuple[str, str], str] = {}
970
+
971
+ self._columns = dict(cfg.columns)
972
+ self._relative_time = cfg.relative_time
973
+ self._sort_by_updated = cfg.sort_order == "updated_at"
974
+
975
+ # Cached threads are pre-sorted by updated_at DESC (the only sort
976
+ # order the cache stores). Skip the O(n log n) re-sort when that
977
+ # matches the user's preference.
978
+ if not (self._has_initial_threads and self._sort_by_updated):
979
+ self._apply_sort()
980
+ self._sync_selected_index()
981
+ self._column_widths = self._compute_column_widths()
982
+
983
+ @staticmethod
984
+ def _switch_id(column_key: str) -> str:
985
+ """Return the DOM id for a column toggle switch."""
986
+ return f"{_SWITCH_ID_PREFIX}{column_key}"
987
+
988
+ @staticmethod
989
+ def _switch_column_key(switch_id: str | None) -> str | None:
990
+ """Extract the column key from a switch id.
991
+
992
+ Args:
993
+ switch_id: Widget id for a switch in the control panel.
994
+
995
+ Returns:
996
+ The corresponding column key, or `None` for unrelated ids.
997
+ """
998
+ if not switch_id or not switch_id.startswith(_SWITCH_ID_PREFIX):
999
+ return None
1000
+ return switch_id.removeprefix(_SWITCH_ID_PREFIX)
1001
+
1002
+ def _sync_selected_index(self) -> None:
1003
+ """Select the current thread when it exists in the loaded rows."""
1004
+ self._selected_index = 0
1005
+ for i, thread in enumerate(self._filtered_threads):
1006
+ if thread["thread_id"] == self._current_thread:
1007
+ self._selected_index = i
1008
+ break
1009
+
1010
+ def _build_title(self, thread_url: str | None = None) -> str | Content:
1011
+ """Build the title, optionally with a clickable thread ID link.
1012
+
1013
+ Args:
1014
+ thread_url: LangSmith thread URL. When provided, the thread ID is
1015
+ rendered as a clickable hyperlink.
1016
+
1017
+ Returns:
1018
+ Plain string or `Content` with an embedded hyperlink.
1019
+ """
1020
+ if not self._current_thread:
1021
+ return "Select Thread"
1022
+ if thread_url:
1023
+ return Content.assemble(
1024
+ "Select Thread (current: ",
1025
+ (
1026
+ self._current_thread,
1027
+ TStyle(
1028
+ foreground=TColor.parse(theme.get_theme_colors(self).primary),
1029
+ link=thread_url,
1030
+ ),
1031
+ ),
1032
+ ")",
1033
+ )
1034
+ return f"Select Thread (current: {self._current_thread})"
1035
+
1036
+ def _build_help_text(self) -> str:
1037
+ """Build the footer help text for the selector.
1038
+
1039
+ Returns:
1040
+ Footer guidance for the active selector bindings.
1041
+ """
1042
+ glyphs = get_glyphs()
1043
+ lines = (
1044
+ f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate"
1045
+ f" {glyphs.bullet} Enter select"
1046
+ f" {glyphs.bullet} Tab/Shift+Tab focus options"
1047
+ f" {glyphs.bullet} Space toggle option"
1048
+ f" {glyphs.bullet} Ctrl+D delete"
1049
+ f" {glyphs.bullet} Esc cancel"
1050
+ )
1051
+ limit = self._effective_thread_limit()
1052
+ if len(self._threads) >= limit:
1053
+ lines += (
1054
+ f"\nShowing last {limit} threads. "
1055
+ "Set DA_CLI_RECENT_THREADS to override."
1056
+ )
1057
+ return lines
1058
+
1059
+ def _effective_thread_limit(self) -> int:
1060
+ """Return the resolved thread limit for display purposes."""
1061
+ if self._thread_limit is not None:
1062
+ return self._thread_limit
1063
+ from deepagents_code.sessions import get_thread_limit
1064
+
1065
+ return get_thread_limit()
1066
+
1067
+ def _get_filter_input(self) -> Input:
1068
+ """Return the cached search input widget."""
1069
+ if self._filter_input is None:
1070
+ self._filter_input = self.query_one("#thread-filter", Input)
1071
+ return self._filter_input
1072
+
1073
+ def _filter_focus_order(self) -> list[Input | Checkbox | Select[str]]:
1074
+ """Return the cached tab order for filter controls in the side panel."""
1075
+ if self._filter_controls is None:
1076
+ filter_input = self._get_filter_input()
1077
+ scope_select = self.query_one(f"#{_SCOPE_SELECT_ID}", Select)
1078
+ sort_select = self.query_one(f"#{_SORT_SELECT_ID}", Select)
1079
+ agent_select = self.query_one(f"#{_AGENT_SELECT_ID}", Select)
1080
+ relative_switch = self.query_one(f"#{_RELATIVE_TIME_SWITCH_ID}", Checkbox)
1081
+ column_switches = [
1082
+ self.query_one(f"#{self._switch_id(key)}", Checkbox)
1083
+ for key in _COLUMN_ORDER
1084
+ ]
1085
+ self._filter_controls = [
1086
+ filter_input,
1087
+ scope_select,
1088
+ sort_select,
1089
+ agent_select,
1090
+ relative_switch,
1091
+ *column_switches,
1092
+ ]
1093
+ return self._filter_controls
1094
+
1095
+ def _collect_agent_options(self) -> list[tuple[str, str]]:
1096
+ """Return Select option tuples for the agent dropdown.
1097
+
1098
+ Unions the configured agents (the `/agents` list, loaded into
1099
+ `self._available_agent_names`) with the agent names found in the
1100
+ currently loaded thread list, then prepends an "All agents" sentinel so
1101
+ the caller can always reset to an unfiltered view.
1102
+
1103
+ Including the configured agents keeps the filter in sync with `/agents`
1104
+ so an agent that has not produced any threads yet is still selectable;
1105
+ including thread-derived names keeps threads from since-deleted agents
1106
+ (and the `_UNKNOWN_AGENT_LABEL` sentinel for threads with no agent name)
1107
+ filterable.
1108
+
1109
+ While the disk load is still pending and no agent names are known yet,
1110
+ returns a single `("Loading...", _AGENT_VALUE_LOADING)` option so the
1111
+ dropdown signals that more options are on the way rather than implying
1112
+ "All agents" is the only choice.
1113
+
1114
+ Returns:
1115
+ List of `(label, value)` pairs; the first entry is always
1116
+ `("All agents", _AGENT_VALUE_ALL)` once the load has completed
1117
+ (or `("Loading...", _AGENT_VALUE_LOADING)` while it is still
1118
+ pending with no known agents).
1119
+ """
1120
+ names = {t.get("agent_name") or _UNKNOWN_AGENT_LABEL for t in self._threads}
1121
+ names.update(self._available_agent_names)
1122
+ if not names and not self._disk_load_complete:
1123
+ return [(_AGENT_LABEL_LOADING, _AGENT_VALUE_LOADING)]
1124
+ ordered = sorted(names, key=str.casefold)
1125
+ return [("All agents", _AGENT_VALUE_ALL), *((n, n) for n in ordered)]
1126
+
1127
+ def _refresh_agent_select_options(self) -> bool:
1128
+ """Repopulate the agent dropdown after threads are reloaded.
1129
+
1130
+ Preserves the current selection when the chosen agent is still a valid
1131
+ option — present in the reloaded threads or the configured `/agents`
1132
+ list (see `_collect_agent_options`); falls back to "All agents"
1133
+ otherwise.
1134
+
1135
+ Returns:
1136
+ `True` when the active agent filter was cleared (selection fell back
1137
+ to "All agents"); `False` when the selection was preserved or
1138
+ the dropdown is not mounted.
1139
+ """
1140
+ try:
1141
+ agent_select = self.query_one(f"#{_AGENT_SELECT_ID}", Select)
1142
+ except NoMatches:
1143
+ return False
1144
+ options = self._collect_agent_options()
1145
+ valid_values = {value for _, value in options}
1146
+ current = agent_select.value
1147
+ # `set_options` resets the value to the first option, posting a
1148
+ # transient `Select.Changed(_AGENT_VALUE_ALL)`. When we then restore
1149
+ # `current` below, a second `Changed(current)` is posted. Both are
1150
+ # queued, so `on_select_changed` runs them after this method returns;
1151
+ # the spurious intermediate rebuild they would trigger is harmless
1152
+ # because `_schedule_filter_and_rebuild` runs exclusively and the later
1153
+ # event's worker supersedes the earlier one before it paints.
1154
+ agent_select.set_options(options)
1155
+ if current in valid_values:
1156
+ agent_select.value = current
1157
+ return False
1158
+ agent_select.value = _AGENT_VALUE_ALL
1159
+ self._filter_agent = None
1160
+ return True
1161
+
1162
+ async def _load_available_agent_names(self) -> None:
1163
+ """Cache the configured agent names (the `/agents` list) off-thread.
1164
+
1165
+ Runs the filesystem scan in a worker thread so the Textual event loop
1166
+ is never blocked on directory I/O. Expected filesystem errors are
1167
+ swallowed by `get_available_agent_names`, which returns an empty list
1168
+ (logging the cause for unexpected `OSError`s, but staying silent when
1169
+ the agents directory simply does not exist yet), so the agent filter
1170
+ degrades to thread-derived names only. The caller in `_load_threads`
1171
+ additionally guards against any unexpected error so the picker is never
1172
+ stranded if the scan fails.
1173
+ """
1174
+ from deepagents_code.agent import get_available_agent_names
1175
+
1176
+ self._available_agent_names = await asyncio.to_thread(get_available_agent_names)
1177
+
1178
+ def _get_select(self, select_id: str) -> Select[str]:
1179
+ """Return an Options panel dropdown widget by ID."""
1180
+ return self.query_one(f"#{select_id}", Select)
1181
+
1182
+ def _expanded_select_id(self) -> str | None:
1183
+ """Return the ID for the currently open Options panel dropdown."""
1184
+ for select_id in _OPTION_SELECT_IDS:
1185
+ try:
1186
+ if self._get_select(select_id).expanded:
1187
+ return select_id
1188
+ except NoMatches:
1189
+ continue
1190
+ return None
1191
+
1192
+ def _is_select_expanded(self) -> bool:
1193
+ """Return whether an Options panel dropdown menu is currently open."""
1194
+ return self._expanded_select_id() is not None
1195
+
1196
+ def _restore_expanded_select_focus(self, select_id: str) -> None:
1197
+ """Return focus to an Options panel dropdown after background rendering."""
1198
+ try:
1199
+ select = self._get_select(select_id)
1200
+ except NoMatches:
1201
+ return
1202
+ select.expanded = True
1203
+ with contextlib.suppress(NoMatches):
1204
+ select.query_one(ContainedSelectOverlay).focus()
1205
+ return
1206
+ select.focus()
1207
+
1208
+ def _close_expanded_select(self) -> None:
1209
+ """Close the open Options panel dropdown and restore focus."""
1210
+ select_id = self._expanded_select_id()
1211
+ if select_id is None:
1212
+ return
1213
+ select = self._get_select(select_id)
1214
+ select.expanded = False
1215
+ select.focus()
1216
+
1217
+ def _select_expanded_highlight(self) -> None:
1218
+ """Select the currently highlighted option in the open dropdown."""
1219
+ action_select = getattr(self.focused, "action_select", None)
1220
+ if callable(action_select):
1221
+ action_select()
1222
+ return
1223
+ self._close_expanded_select()
1224
+
1225
+ def _open_focused_select(self) -> bool:
1226
+ """Open a focused Options panel dropdown.
1227
+
1228
+ Returns:
1229
+ `True` when a dropdown was focused and opened.
1230
+ """
1231
+ try:
1232
+ selects = tuple(
1233
+ self._get_select(select_id) for select_id in _OPTION_SELECT_IDS
1234
+ )
1235
+ except NoMatches:
1236
+ return False
1237
+ for select in selects:
1238
+ if self.focused is select:
1239
+ select.action_show_overlay()
1240
+ return True
1241
+ return False
1242
+
1243
+ def compose(self) -> ComposeResult:
1244
+ """Compose the screen layout.
1245
+
1246
+ Yields:
1247
+ Widgets for the thread selector UI.
1248
+ """
1249
+ with Vertical(id="thread-selector-shell"):
1250
+ yield Static(
1251
+ self._build_title(), classes="thread-selector-title", id="thread-title"
1252
+ )
1253
+
1254
+ yield Input(
1255
+ placeholder="Type to search threads...",
1256
+ select_on_focus=False,
1257
+ id="thread-filter",
1258
+ )
1259
+
1260
+ with Horizontal(classes="thread-selector-body"):
1261
+ with Vertical(classes="thread-table-pane"):
1262
+ with Horizontal(
1263
+ classes="thread-list-header",
1264
+ id="thread-header",
1265
+ ):
1266
+ yield Static("", classes="thread-cell thread-cell-cursor")
1267
+ sort_key = _active_sort_key(self._sort_by_updated)
1268
+ for key in _visible_column_keys(self._columns):
1269
+ cell = Static(
1270
+ _format_header_label(key),
1271
+ classes=_header_cell_classes(key, sort_key=sort_key),
1272
+ expand=key == "initial_prompt",
1273
+ markup=False,
1274
+ )
1275
+ _apply_column_width(cell, key, self._column_widths)
1276
+ yield cell
1277
+
1278
+ with VerticalScroll(classes="thread-list"):
1279
+ if self._has_initial_threads and self._filtered_threads:
1280
+ self._option_widgets, _ = self._create_option_widgets()
1281
+ yield from self._option_widgets
1282
+ else:
1283
+ yield self._build_empty_state()
1284
+
1285
+ with Vertical(classes="thread-controls"):
1286
+ yield Static("Options", classes="thread-controls-title")
1287
+ yield Static(
1288
+ (
1289
+ "Tab through scope, sort, agent, and column options. "
1290
+ "Column visibility persists between sessions."
1291
+ ),
1292
+ classes="thread-controls-help",
1293
+ markup=False,
1294
+ )
1295
+ yield Static(
1296
+ "Show threads from:",
1297
+ classes="thread-controls-label",
1298
+ markup=False,
1299
+ )
1300
+ yield ContainedSelect(
1301
+ [
1302
+ ("Current directory", _SCOPE_VALUE_CWD),
1303
+ ("All directories", _SCOPE_VALUE_ALL),
1304
+ ],
1305
+ value=(
1306
+ _SCOPE_VALUE_CWD
1307
+ if self._filter_cwd is not None
1308
+ else _SCOPE_VALUE_ALL
1309
+ ),
1310
+ allow_blank=False,
1311
+ compact=True,
1312
+ id=_SCOPE_SELECT_ID,
1313
+ classes="thread-option-select",
1314
+ )
1315
+ yield Static(
1316
+ "Sort threads by:",
1317
+ classes="thread-controls-label",
1318
+ markup=False,
1319
+ )
1320
+ yield ContainedSelect(
1321
+ [
1322
+ ("Created At", _SORT_VALUE_CREATED),
1323
+ ("Updated At", _SORT_VALUE_UPDATED),
1324
+ ],
1325
+ value=(
1326
+ _SORT_VALUE_UPDATED
1327
+ if self._sort_by_updated
1328
+ else _SORT_VALUE_CREATED
1329
+ ),
1330
+ allow_blank=False,
1331
+ compact=True,
1332
+ id=_SORT_SELECT_ID,
1333
+ classes="thread-option-select",
1334
+ )
1335
+ yield Static(
1336
+ "Show agent:",
1337
+ classes="thread-controls-label",
1338
+ markup=False,
1339
+ )
1340
+ agent_options = self._collect_agent_options()
1341
+ agent_value = (
1342
+ _AGENT_VALUE_LOADING
1343
+ if agent_options[0][1] == _AGENT_VALUE_LOADING
1344
+ else _AGENT_VALUE_ALL
1345
+ )
1346
+ yield ContainedSelect(
1347
+ agent_options,
1348
+ value=agent_value,
1349
+ allow_blank=False,
1350
+ compact=True,
1351
+ id=_AGENT_SELECT_ID,
1352
+ classes="thread-agent-select",
1353
+ )
1354
+ with ThreadControlsScroll(
1355
+ id=_CONTROLS_SCROLL_ID, classes="thread-controls-scroll"
1356
+ ):
1357
+ yield Checkbox(
1358
+ "Relative Timestamps",
1359
+ self._relative_time,
1360
+ id=_RELATIVE_TIME_SWITCH_ID,
1361
+ classes="thread-column-toggle",
1362
+ compact=True,
1363
+ )
1364
+ for key in _COLUMN_ORDER:
1365
+ yield Checkbox(
1366
+ _COLUMN_TOGGLE_LABELS[key],
1367
+ self._columns.get(key, False),
1368
+ id=self._switch_id(key),
1369
+ classes="thread-column-toggle",
1370
+ compact=True,
1371
+ )
1372
+ yield Static(
1373
+ get_glyphs().ellipsis,
1374
+ id=_CONTROLS_OVERFLOW_ID,
1375
+ classes="thread-controls-overflow",
1376
+ )
1377
+
1378
+ yield Static(
1379
+ self._build_help_text(),
1380
+ classes="thread-selector-help",
1381
+ id="thread-help",
1382
+ )
1383
+
1384
+ async def on_mount(self) -> None:
1385
+ """Fetch threads, configure border for ASCII terminals, and build the list."""
1386
+ if is_ascii_mode():
1387
+ container = self.query_one("#thread-selector-shell", Vertical)
1388
+ colors = theme.get_theme_colors(self)
1389
+ container.styles.border = ("ascii", colors.success)
1390
+
1391
+ filter_input = self._get_filter_input()
1392
+ self._filter_focus_order()
1393
+ filter_input.focus()
1394
+ self.call_after_refresh(self._update_controls_overflow_hint)
1395
+
1396
+ # Resolve the LangSmith thread URL as soon as the modal mounts. The URL
1397
+ # depends on `self._current_thread` (known at construction) plus
1398
+ # env/config, but not on the loaded thread list, so the header hyperlink
1399
+ # should not wait for the DB load to finish.
1400
+ if self._current_thread:
1401
+ self._resolve_thread_url()
1402
+
1403
+ if self._has_initial_threads:
1404
+ self.call_after_refresh(self._scroll_selected_into_view)
1405
+ # Defer by one message cycle so Textual finishes processing
1406
+ # mount messages before we start the DB refresh.
1407
+ self.call_after_refresh(self._start_thread_load)
1408
+ else:
1409
+ # _load_threads replaces self._threads and schedules background
1410
+ # enrichment (message counts, initial prompts) after load
1411
+ # completes. Launch immediately when there are no cached rows
1412
+ # to render.
1413
+ self.run_worker(
1414
+ self._load_threads, exclusive=True, group="thread-selector-load"
1415
+ )
1416
+
1417
+ def on_resize(self) -> None:
1418
+ """Refresh the overflow indicator when the modal is resized."""
1419
+ self.call_after_refresh(self._update_controls_overflow_hint)
1420
+
1421
+ def on_thread_controls_scroll_scrolled(
1422
+ self, event: ThreadControlsScroll.Scrolled
1423
+ ) -> None:
1424
+ """Refresh the overflow indicator after the options pane scrolls."""
1425
+ event.stop()
1426
+ self.call_after_refresh(self._update_controls_overflow_hint)
1427
+
1428
+ def _start_thread_load(self) -> None:
1429
+ """Launch the thread-load worker after the initial layout pass."""
1430
+ if not self.is_attached:
1431
+ return
1432
+ self.run_worker(
1433
+ self._load_threads, exclusive=True, group="thread-selector-load"
1434
+ )
1435
+
1436
+ def on_input_changed(self, event: Input.Changed) -> None:
1437
+ """Filter threads as user types.
1438
+
1439
+ Args:
1440
+ event: The input changed event.
1441
+ """
1442
+ self._filter_text = event.value
1443
+ self._schedule_filter_and_rebuild()
1444
+
1445
+ def on_input_submitted(self, event: Input.Submitted) -> None:
1446
+ """Handle Enter key when filter input is focused.
1447
+
1448
+ Args:
1449
+ event: The input submitted event.
1450
+ """
1451
+ event.stop()
1452
+ self.action_select()
1453
+
1454
+ def on_key(self, event: Key) -> None:
1455
+ """Return focus to search when letters are typed from other controls.
1456
+
1457
+ Args:
1458
+ event: The key event.
1459
+ """
1460
+ if self._confirming_delete:
1461
+ return
1462
+
1463
+ if event.key in {"tab", "shift+tab"} and self._is_select_expanded():
1464
+ event.prevent_default()
1465
+ event.stop()
1466
+ return
1467
+
1468
+ filter_input = self._get_filter_input()
1469
+ if filter_input.has_focus:
1470
+ return
1471
+
1472
+ character = event.character
1473
+ if not character or not character.isalpha():
1474
+ return
1475
+
1476
+ filter_input.focus()
1477
+ filter_input.insert_text_at_cursor(character)
1478
+ self.set_timer(0.01, self._collapse_search_selection)
1479
+ event.stop()
1480
+
1481
+ def _collapse_search_selection(self) -> None:
1482
+ """Place the search cursor at the end without an active selection."""
1483
+ filter_input = self._get_filter_input()
1484
+ filter_input.selection = type(filter_input.selection).cursor(
1485
+ len(filter_input.value)
1486
+ )
1487
+
1488
+ def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
1489
+ """Route relative-time and column-visibility checkbox changes.
1490
+
1491
+ Args:
1492
+ event: The checkbox change event.
1493
+ """
1494
+ if event.checkbox.id == _RELATIVE_TIME_SWITCH_ID:
1495
+ if self._relative_time == event.value:
1496
+ return
1497
+ self._relative_time = event.value
1498
+
1499
+ from deepagents_code.model_config import save_thread_relative_time
1500
+
1501
+ self.run_worker(
1502
+ asyncio.to_thread(save_thread_relative_time, event.value),
1503
+ group="thread-selector-save",
1504
+ )
1505
+ self._schedule_list_rebuild()
1506
+ return
1507
+
1508
+ column_key = self._switch_column_key(event.checkbox.id)
1509
+ if column_key is None or column_key not in self._columns:
1510
+ return
1511
+ if self._columns[column_key] == event.value:
1512
+ return
1513
+
1514
+ self._columns[column_key] = event.value
1515
+ self._apply_sort()
1516
+ self._sync_selected_index()
1517
+ self._update_help_widgets()
1518
+ if event.value and column_key in {"messages", "initial_prompt"}:
1519
+ self._schedule_checkpoint_enrichment()
1520
+
1521
+ from deepagents_code.model_config import save_thread_columns
1522
+
1523
+ snapshot = dict(self._columns)
1524
+ self.run_worker(
1525
+ asyncio.to_thread(save_thread_columns, snapshot),
1526
+ group="thread-selector-save",
1527
+ )
1528
+ self._schedule_list_rebuild()
1529
+
1530
+ def _agent_filtered_threads(self) -> list[ThreadInfo]:
1531
+ """Return `self._threads` narrowed to the active agent filter.
1532
+
1533
+ Returns:
1534
+ All threads when no agent filter is active; otherwise only threads
1535
+ whose `agent_name` (or `_UNKNOWN_AGENT_LABEL` for missing
1536
+ values) matches `self._filter_agent`.
1537
+ """
1538
+ if self._filter_agent is None:
1539
+ return list(self._threads)
1540
+ return [
1541
+ t
1542
+ for t in self._threads
1543
+ if (t.get("agent_name") or _UNKNOWN_AGENT_LABEL) == self._filter_agent
1544
+ ]
1545
+
1546
+ def _update_filtered_list(self) -> None:
1547
+ """Update filtered threads based on search text using fuzzy matching."""
1548
+ base = self._agent_filtered_threads()
1549
+ query = self._filter_text.strip()
1550
+ if not query:
1551
+ self._filtered_threads = base
1552
+ self._apply_sort()
1553
+ self._sync_selected_index()
1554
+ self._column_widths = self._compute_column_widths()
1555
+ return
1556
+
1557
+ tokens = query.split()
1558
+ try:
1559
+ matchers = [Matcher(token, case_sensitive=False) for token in tokens]
1560
+ scored: list[tuple[float, ThreadInfo]] = []
1561
+ for thread in base:
1562
+ search_text = self._get_search_text(thread)
1563
+ scores = [matcher.match(search_text) for matcher in matchers]
1564
+ if all(score > 0 for score in scores):
1565
+ scored.append((min(scores), thread))
1566
+ except Exception:
1567
+ logger.warning(
1568
+ "Fuzzy matcher failed for query %r, falling back to full list",
1569
+ query,
1570
+ exc_info=True,
1571
+ )
1572
+ self._filtered_threads = base
1573
+ self._apply_sort()
1574
+ self._sync_selected_index()
1575
+ self._column_widths = self._compute_column_widths()
1576
+ return
1577
+
1578
+ sort_key = _active_sort_key(self._sort_by_updated)
1579
+ self._filtered_threads = [
1580
+ thread
1581
+ for _, thread in sorted(
1582
+ scored,
1583
+ key=lambda item: (
1584
+ item[0],
1585
+ item[1].get(sort_key) or "",
1586
+ item[1].get("updated_at") or "",
1587
+ item[1]["thread_id"],
1588
+ ),
1589
+ reverse=True,
1590
+ )
1591
+ ]
1592
+ self._selected_index = 0
1593
+ self._column_widths = self._compute_column_widths()
1594
+
1595
+ def _compute_column_widths(self) -> dict[str, int | None]:
1596
+ """Return effective widths for the current table state.
1597
+
1598
+ Textual's `width: auto` computes per-widget widths, so this method
1599
+ derives shared widths from the visible data instead. Also populates
1600
+ `self._cell_text` as a side effect so that `ThreadOption.compose()` can
1601
+ reuse the formatted strings.
1602
+
1603
+ Returns:
1604
+ Dict mapping column keys to their effective cell widths, with
1605
+ `None` for flex columns.
1606
+ """
1607
+ global _column_widths_cache # noqa: PLW0603 # Module-level cache requires global statement
1608
+
1609
+ visible_keys = _visible_column_keys(self._columns)
1610
+ visible = frozenset(visible_keys)
1611
+ fingerprint = tuple(
1612
+ (t["thread_id"], t.get("latest_checkpoint_id"))
1613
+ for t in self._filtered_threads
1614
+ )
1615
+
1616
+ if _column_widths_cache is not None:
1617
+ fp, vis, rel, cached_widths = _column_widths_cache
1618
+ if (
1619
+ fp == fingerprint
1620
+ and vis == visible
1621
+ and rel == self._relative_time
1622
+ and self._cell_text
1623
+ ):
1624
+ return dict(cached_widths)
1625
+
1626
+ # Pre-format every visible cell in one pass.
1627
+ cell_text: dict[tuple[str, str], str] = {}
1628
+ for thread in self._filtered_threads:
1629
+ tid = thread["thread_id"]
1630
+ for key in visible_keys:
1631
+ cell_text[tid, key] = _format_column_value(
1632
+ thread, key, relative_time=self._relative_time
1633
+ )
1634
+ self._cell_text = cell_text
1635
+
1636
+ # Derive auto-widths from the pre-formatted values.
1637
+ widths = dict(_COLUMN_WIDTHS)
1638
+ for key in _AUTO_WIDTH_COLUMNS:
1639
+ if key not in visible:
1640
+ continue
1641
+ header_len = cell_len(_format_header_label(key))
1642
+ max_cell = max(
1643
+ (
1644
+ cell_len(cell_text[t["thread_id"], key])
1645
+ for t in self._filtered_threads
1646
+ ),
1647
+ default=0,
1648
+ )
1649
+ widths[key] = max(header_len, max_cell) + _CELL_PADDING_RIGHT
1650
+
1651
+ _column_widths_cache = (fingerprint, visible, self._relative_time, widths)
1652
+ return widths
1653
+
1654
+ @staticmethod
1655
+ def _get_search_text(thread: ThreadInfo) -> str:
1656
+ """Build searchable text from thread fields.
1657
+
1658
+ The result is capped at `_MAX_SEARCH_TEXT_LEN` characters so that
1659
+ Textual's fuzzy `Matcher` (which uses recursive backtracking) does
1660
+ not hit exponential performance on long initial prompts with
1661
+ repeated characters.
1662
+
1663
+ Args:
1664
+ thread: Thread metadata.
1665
+
1666
+ Returns:
1667
+ Concatenated searchable string, truncated to a safe length.
1668
+ """
1669
+ # Include both the raw cwd and the home-collapsed display form so a
1670
+ # user typing either "/Users/chesar/foo" or "~/foo" matches the row.
1671
+ format_path, _, _ = _get_format_fns()
1672
+ cwd = thread.get("cwd") or ""
1673
+ parts = [
1674
+ thread["thread_id"],
1675
+ thread.get("agent_name") or "",
1676
+ thread.get("git_branch") or "",
1677
+ cwd,
1678
+ format_path(cwd) if cwd else "",
1679
+ thread.get("initial_prompt") or "",
1680
+ ]
1681
+ text = " ".join(parts)
1682
+ return text[:_MAX_SEARCH_TEXT_LEN]
1683
+
1684
+ def _schedule_filter_and_rebuild(self) -> None:
1685
+ """Queue a filter + rebuild, coalescing rapid keystrokes."""
1686
+ self.run_worker(
1687
+ self._filter_and_build,
1688
+ exclusive=True,
1689
+ group="thread-selector-render",
1690
+ )
1691
+
1692
+ async def _filter_and_build(self) -> None:
1693
+ """Run fuzzy filtering in a thread then rebuild the list."""
1694
+ query = self._filter_text.strip()
1695
+ threads = self._agent_filtered_threads()
1696
+ sort_by_updated = self._sort_by_updated
1697
+
1698
+ filtered = await asyncio.to_thread(
1699
+ self._compute_filtered, query, threads, sort_by_updated
1700
+ )
1701
+ self._filtered_threads = filtered
1702
+ if query:
1703
+ self._selected_index = 0
1704
+ else:
1705
+ self._sync_selected_index()
1706
+ self._column_widths = self._compute_column_widths()
1707
+ await self._build_list(recompute_widths=False)
1708
+
1709
+ @staticmethod
1710
+ def _compute_filtered(
1711
+ query: str,
1712
+ threads: list[ThreadInfo],
1713
+ sort_by_updated: bool,
1714
+ ) -> list[ThreadInfo]:
1715
+ """Compute filtered thread list off the main thread.
1716
+
1717
+ Args:
1718
+ query: Current search query text.
1719
+ threads: Agent-pre-filtered thread list snapshot.
1720
+ sort_by_updated: Whether to sort by `updated_at`.
1721
+
1722
+ Returns:
1723
+ Filtered and sorted thread list.
1724
+ """
1725
+ sort_key = _active_sort_key(sort_by_updated)
1726
+
1727
+ if not query:
1728
+ result = list(threads)
1729
+ result.sort(key=lambda t: t.get(sort_key) or "", reverse=True)
1730
+ return result
1731
+
1732
+ tokens = query.split()
1733
+ try:
1734
+ matchers = [Matcher(token, case_sensitive=False) for token in tokens]
1735
+ scored: list[tuple[float, ThreadInfo]] = []
1736
+ for thread in threads:
1737
+ search_text = ThreadSelectorScreen._get_search_text(thread)
1738
+ scores = [matcher.match(search_text) for matcher in matchers]
1739
+ if all(score > 0 for score in scores):
1740
+ scored.append((min(scores), thread))
1741
+ except Exception:
1742
+ logger.warning(
1743
+ "Fuzzy matcher failed for query %r, falling back to full list",
1744
+ query,
1745
+ exc_info=True,
1746
+ )
1747
+ result = list(threads)
1748
+ result.sort(key=lambda t: t.get(sort_key) or "", reverse=True)
1749
+ return result
1750
+
1751
+ return [
1752
+ thread
1753
+ for _, thread in sorted(
1754
+ scored,
1755
+ key=lambda item: (
1756
+ item[0],
1757
+ item[1].get(sort_key) or "",
1758
+ item[1].get("updated_at") or "",
1759
+ item[1]["thread_id"],
1760
+ ),
1761
+ reverse=True,
1762
+ )
1763
+ ]
1764
+
1765
+ def _schedule_list_rebuild(self) -> None:
1766
+ """Queue a list rebuild, coalescing rapid updates."""
1767
+ self.run_worker(
1768
+ self._build_list,
1769
+ exclusive=True,
1770
+ group="thread-selector-render",
1771
+ )
1772
+
1773
+ def _pending_checkpoint_fields(self) -> tuple[bool, bool]:
1774
+ """Return which visible checkpoint-derived fields still need loading."""
1775
+ load_counts = self._columns.get("messages", False) and any(
1776
+ "message_count" not in thread for thread in self._threads
1777
+ )
1778
+ load_prompts = self._columns.get("initial_prompt", False) and any(
1779
+ "initial_prompt" not in thread for thread in self._threads
1780
+ )
1781
+ return load_counts, load_prompts
1782
+
1783
+ async def _populate_visible_checkpoint_details(self) -> tuple[bool, bool]:
1784
+ """Load any still-missing checkpoint-derived fields for visible columns.
1785
+
1786
+ Returns:
1787
+ Tuple indicating whether message counts and prompts were requested.
1788
+ """
1789
+ from deepagents_code.sessions import populate_thread_checkpoint_details
1790
+
1791
+ load_counts, load_prompts = self._pending_checkpoint_fields()
1792
+ if not load_counts and not load_prompts:
1793
+ return False, False
1794
+
1795
+ await populate_thread_checkpoint_details(
1796
+ self._threads,
1797
+ include_message_count=load_counts,
1798
+ include_initial_prompt=load_prompts,
1799
+ )
1800
+ return load_counts, load_prompts
1801
+
1802
+ def _schedule_checkpoint_enrichment(self) -> None:
1803
+ """Schedule one checkpoint-enrichment pass for missing row fields."""
1804
+ has_missing_counts, has_missing_prompts = self._pending_checkpoint_fields()
1805
+ if not has_missing_counts and not has_missing_prompts:
1806
+ return
1807
+ self.run_worker(
1808
+ self._load_checkpoint_details,
1809
+ exclusive=True,
1810
+ group="thread-selector-checkpoints",
1811
+ )
1812
+
1813
+ @staticmethod
1814
+ def _threads_match(old: list[ThreadInfo], new: list[ThreadInfo]) -> bool:
1815
+ """Check whether two thread lists have the same IDs and checkpoints in order.
1816
+
1817
+ Args:
1818
+ old: Previous thread list.
1819
+ new: Fresh thread list.
1820
+
1821
+ Returns:
1822
+ True if both lists have identical thread/checkpoint ID pairs.
1823
+ """
1824
+ if len(old) != len(new):
1825
+ return False
1826
+ for a, b in zip(old, new, strict=True):
1827
+ if a["thread_id"] != b["thread_id"]:
1828
+ return False
1829
+ if a.get("latest_checkpoint_id") != b.get("latest_checkpoint_id"):
1830
+ return False
1831
+ return True
1832
+
1833
+ async def _load_threads(self) -> None:
1834
+ """Load thread rows first, then kick off background enrichment."""
1835
+ from deepagents_code.sessions import (
1836
+ apply_cached_thread_initial_prompts,
1837
+ apply_cached_thread_message_counts,
1838
+ list_threads,
1839
+ )
1840
+
1841
+ old_threads = list(self._threads)
1842
+
1843
+ try:
1844
+ limit = self._thread_limit
1845
+ if limit is None:
1846
+ from deepagents_code.sessions import get_thread_limit
1847
+
1848
+ limit = get_thread_limit()
1849
+ sort_by = "updated" if self._sort_by_updated else "created"
1850
+ self._threads = await list_threads(
1851
+ limit=limit,
1852
+ include_message_count=False,
1853
+ sort_by=sort_by,
1854
+ cwd=self._filter_cwd,
1855
+ )
1856
+ except (OSError, sqlite3.Error) as exc:
1857
+ logger.exception("Failed to load threads for thread selector")
1858
+ self._disk_load_complete = True
1859
+ await self._show_mount_error(str(exc))
1860
+ return
1861
+ except Exception as exc:
1862
+ # An unexpected error (e.g. a malformed row raising ValueError) must
1863
+ # still mark the load complete and surface the failure; otherwise the
1864
+ # picker stays stuck on the "Loading threads..." placeholder forever.
1865
+ # CancelledError is a BaseException and is intentionally not caught
1866
+ # here, so a superseding exclusive worker keeps ownership of the flag.
1867
+ logger.exception("Unexpected error loading threads for thread selector")
1868
+ self._disk_load_complete = True
1869
+ await self._show_mount_error(str(exc))
1870
+ return
1871
+
1872
+ self._disk_load_complete = True
1873
+ apply_cached_thread_message_counts(self._threads)
1874
+ apply_cached_thread_initial_prompts(self._threads)
1875
+ if not self._has_initial_threads:
1876
+ try:
1877
+ await self._populate_visible_checkpoint_details()
1878
+ except (OSError, sqlite3.Error):
1879
+ logger.debug(
1880
+ "Could not preload checkpoint details for thread selector",
1881
+ exc_info=True,
1882
+ )
1883
+ except Exception:
1884
+ logger.warning(
1885
+ "Unexpected error preloading checkpoint details "
1886
+ "for thread selector",
1887
+ exc_info=True,
1888
+ )
1889
+ try:
1890
+ await self._load_available_agent_names()
1891
+ except Exception:
1892
+ # The configured-agent scan is non-essential: a failure here must
1893
+ # not strand the picker by skipping the filter refresh, DOM build,
1894
+ # and checkpoint enrichment below. `get_available_agent_names`
1895
+ # already absorbs expected filesystem errors; this guards anything
1896
+ # unexpected that escapes it.
1897
+ logger.warning(
1898
+ "Could not load configured agent names for thread selector",
1899
+ exc_info=True,
1900
+ )
1901
+ self._update_filtered_list()
1902
+ self._sync_selected_index()
1903
+ if self._refresh_agent_select_options():
1904
+ self._update_filtered_list()
1905
+ self._sync_selected_index()
1906
+
1907
+ # Short-circuit: when the fresh data matches what is already rendered,
1908
+ # update widget references and cell labels without tearing down the DOM.
1909
+ if (
1910
+ self._has_initial_threads
1911
+ and self._option_widgets
1912
+ and self._threads_match(old_threads, self._filtered_threads)
1913
+ ):
1914
+ for widget, thread in zip(
1915
+ self._option_widgets,
1916
+ self._filtered_threads,
1917
+ strict=True,
1918
+ ):
1919
+ widget.thread = thread
1920
+ self._refresh_cell_labels()
1921
+ else:
1922
+ await self._build_list()
1923
+
1924
+ self._schedule_checkpoint_enrichment()
1925
+
1926
+ if self._current_thread:
1927
+ self._resolve_thread_url()
1928
+
1929
+ async def _load_checkpoint_details(self) -> None:
1930
+ """Populate checkpoint-derived thread fields in one background pass."""
1931
+ if not self._threads:
1932
+ return
1933
+
1934
+ try:
1935
+ _, load_prompts = await self._populate_visible_checkpoint_details()
1936
+ except (OSError, sqlite3.Error):
1937
+ logger.debug(
1938
+ "Could not load checkpoint details for thread selector",
1939
+ exc_info=True,
1940
+ )
1941
+ return
1942
+ except Exception:
1943
+ logger.warning(
1944
+ "Unexpected error loading checkpoint details for thread selector",
1945
+ exc_info=True,
1946
+ )
1947
+ return
1948
+
1949
+ if load_prompts and self._filter_text.strip():
1950
+ # Prompts may affect fuzzy match results; rebuild the filtered
1951
+ # list but preserve the user's cursor position.
1952
+ saved_tid = (
1953
+ self._filtered_threads[self._selected_index]["thread_id"]
1954
+ if self._selected_index < len(self._filtered_threads)
1955
+ else None
1956
+ )
1957
+ self._update_filtered_list()
1958
+ if saved_tid is not None:
1959
+ for i, thread in enumerate(self._filtered_threads):
1960
+ if thread["thread_id"] == saved_tid:
1961
+ self._selected_index = i
1962
+ break
1963
+ self._schedule_list_rebuild()
1964
+ else:
1965
+ self._refresh_cell_labels()
1966
+
1967
+ def _refresh_cell_labels(self) -> None:
1968
+ """Update visible cell text in-place without rebuilding the DOM."""
1969
+ visible_keys = _visible_column_keys(self._columns)
1970
+
1971
+ # Recompute because thread data may have changed since
1972
+ # _compute_column_widths populated the cache.
1973
+ cell_text: dict[tuple[str, str], str] = {}
1974
+ for thread in self._filtered_threads:
1975
+ tid = thread["thread_id"]
1976
+ for key in visible_keys:
1977
+ cell_text[tid, key] = _format_column_value(
1978
+ thread, key, relative_time=self._relative_time
1979
+ )
1980
+ self._cell_text = cell_text
1981
+
1982
+ for widget in self._option_widgets:
1983
+ tid = widget.thread_id
1984
+ for key in visible_keys:
1985
+ try:
1986
+ cell = widget.query_one(f".thread-cell-{key}", Static)
1987
+ except NoMatches:
1988
+ continue
1989
+ cell.update(cell_text[tid, key])
1990
+
1991
+ def _resolve_thread_url(self) -> None:
1992
+ """Start exclusive background worker to resolve LangSmith thread URL."""
1993
+ self.run_worker(
1994
+ self._fetch_thread_url, exclusive=True, group="thread-selector-url"
1995
+ )
1996
+
1997
+ async def _fetch_thread_url(self) -> None:
1998
+ """Resolve the LangSmith URL and update the title with a clickable link."""
1999
+ if not self._current_thread:
2000
+ return
2001
+ loop = asyncio.get_running_loop()
2002
+ started = loop.time()
2003
+ try:
2004
+ thread_url = await asyncio.wait_for(
2005
+ asyncio.to_thread(build_langsmith_thread_url, self._current_thread),
2006
+ timeout=_URL_FETCH_TIMEOUT,
2007
+ )
2008
+ except (TimeoutError, OSError):
2009
+ logger.debug(
2010
+ "Could not resolve LangSmith thread URL for '%s'",
2011
+ self._current_thread,
2012
+ exc_info=True,
2013
+ )
2014
+ return
2015
+ except Exception:
2016
+ logger.debug(
2017
+ "Unexpected error resolving LangSmith thread URL for '%s'",
2018
+ self._current_thread,
2019
+ exc_info=True,
2020
+ )
2021
+ return
2022
+ if loop.time() - started >= _URL_FETCH_TIMEOUT:
2023
+ logger.debug(
2024
+ "LangSmith thread URL for '%s' resolved after the timeout deadline",
2025
+ self._current_thread,
2026
+ )
2027
+ return
2028
+ if thread_url:
2029
+ try:
2030
+ title_widget = self.query_one("#thread-title", Static)
2031
+ title_widget.update(self._build_title(thread_url))
2032
+ except NoMatches:
2033
+ logger.debug(
2034
+ "Title widget #thread-title not found; "
2035
+ "thread selector may have been dismissed during URL resolution"
2036
+ )
2037
+
2038
+ async def _show_mount_error(self, detail: str) -> None:
2039
+ """Display an error message inside the thread list and refocus.
2040
+
2041
+ Args:
2042
+ detail: Human-readable error detail to show.
2043
+ """
2044
+ try:
2045
+ async with self._render_lock:
2046
+ scroll = self.query_one(".thread-list", VerticalScroll)
2047
+ await scroll.remove_children()
2048
+ await scroll.mount(
2049
+ Static(
2050
+ Content.from_markup(
2051
+ "[red]Failed to load threads: $detail. "
2052
+ "Press Esc to close.[/red]",
2053
+ detail=detail,
2054
+ ),
2055
+ classes="thread-empty",
2056
+ )
2057
+ )
2058
+ except Exception:
2059
+ logger.warning(
2060
+ "Could not display error message in thread selector UI",
2061
+ exc_info=True,
2062
+ )
2063
+ self.focus()
2064
+
2065
+ def _build_empty_state(self) -> Static:
2066
+ """Build the empty-list placeholder.
2067
+
2068
+ Shows "Loading threads..." while the disk load is still pending so the
2069
+ picker never claims "No threads found" before the query completes.
2070
+
2071
+ Returns:
2072
+ The placeholder `Static` widget to mount in the thread list.
2073
+ """
2074
+ if not self._disk_load_complete:
2075
+ return Static(
2076
+ Content.styled("Loading threads...", "dim"),
2077
+ classes="thread-empty",
2078
+ id="thread-loading",
2079
+ )
2080
+ return Static(
2081
+ Content.styled("No threads found", "dim"),
2082
+ classes="thread-empty",
2083
+ )
2084
+
2085
+ async def _build_list(self, *, recompute_widths: bool = True) -> None:
2086
+ """Build the thread option widgets.
2087
+
2088
+ Args:
2089
+ recompute_widths: Whether to recalculate shared column widths first.
2090
+ """
2091
+ expanded_select_id = self._expanded_select_id()
2092
+ async with self._render_lock:
2093
+ try:
2094
+ scroll = self.query_one(".thread-list", VerticalScroll)
2095
+ except NoMatches:
2096
+ return
2097
+
2098
+ if recompute_widths:
2099
+ self._column_widths = self._compute_column_widths()
2100
+ with self.app.batch_update():
2101
+ await scroll.remove_children()
2102
+ self._update_help_widgets()
2103
+
2104
+ if not self._filtered_threads:
2105
+ self._option_widgets = []
2106
+ await scroll.mount(self._build_empty_state())
2107
+ return
2108
+
2109
+ self._option_widgets, selected_widget = self._create_option_widgets()
2110
+ await scroll.mount(*self._option_widgets)
2111
+
2112
+ if expanded_select_id is not None:
2113
+ self.call_after_refresh(
2114
+ lambda: self._restore_expanded_select_focus(expanded_select_id)
2115
+ )
2116
+ elif selected_widget:
2117
+ self.call_after_refresh(self._scroll_selected_into_view)
2118
+
2119
+ def _create_option_widgets(self) -> tuple[list[ThreadOption], ThreadOption | None]:
2120
+ """Build option widgets from filtered threads without mounting.
2121
+
2122
+ Returns:
2123
+ Tuple of all option widgets and the currently selected widget.
2124
+ """
2125
+ widgets: list[ThreadOption] = []
2126
+ selected_widget: ThreadOption | None = None
2127
+
2128
+ for i, thread in enumerate(self._filtered_threads):
2129
+ is_current = thread["thread_id"] == self._current_thread
2130
+ is_selected = i == self._selected_index
2131
+
2132
+ classes = "thread-option"
2133
+ if is_selected:
2134
+ classes += " thread-option-selected"
2135
+ if is_current:
2136
+ classes += " thread-option-current"
2137
+
2138
+ widget = ThreadOption(
2139
+ thread=thread,
2140
+ index=i,
2141
+ columns=self._columns,
2142
+ column_widths=self._column_widths,
2143
+ selected=is_selected,
2144
+ current=is_current,
2145
+ relative_time=self._relative_time,
2146
+ cell_text=self._cell_text or None,
2147
+ classes=classes,
2148
+ )
2149
+ widgets.append(widget)
2150
+ if is_selected:
2151
+ selected_widget = widget
2152
+
2153
+ return widgets, selected_widget
2154
+
2155
+ def _scroll_selected_into_view(self) -> None:
2156
+ """Scroll selected option into view without animation."""
2157
+ if not self._option_widgets:
2158
+ return
2159
+ if self._selected_index >= len(self._option_widgets):
2160
+ return
2161
+ try:
2162
+ scroll = self.query_one(".thread-list", VerticalScroll)
2163
+ except NoMatches:
2164
+ return
2165
+
2166
+ if self._selected_index == 0:
2167
+ scroll.scroll_home(animate=False)
2168
+ else:
2169
+ self._option_widgets[self._selected_index].scroll_visible(animate=False)
2170
+
2171
+ def _update_help_widgets(self) -> None:
2172
+ """Update visible header and help text after state changes."""
2173
+ self._schedule_header_rebuild()
2174
+ self.call_after_refresh(self._update_controls_overflow_hint)
2175
+
2176
+ try:
2177
+ help_widget = self.query_one("#thread-help", Static)
2178
+ help_widget.update(self._build_help_text())
2179
+ except NoMatches:
2180
+ logger.debug("Help widget #thread-help not found during update")
2181
+
2182
+ def _update_controls_overflow_hint(self) -> None:
2183
+ """Show an ellipsis when the options pane has hidden rows below."""
2184
+ try:
2185
+ scroll = self.query_one(f"#{_CONTROLS_SCROLL_ID}", VerticalScroll)
2186
+ hint = self.query_one(f"#{_CONTROLS_OVERFLOW_ID}", Static)
2187
+ except NoMatches:
2188
+ return
2189
+
2190
+ hint.display = scroll.scroll_y < scroll.max_scroll_y
2191
+
2192
+ def _schedule_header_rebuild(self) -> None:
2193
+ """Queue a header rebuild to reflect column/sort changes."""
2194
+ self.run_worker(
2195
+ self._rebuild_header,
2196
+ exclusive=True,
2197
+ group="thread-selector-header",
2198
+ )
2199
+
2200
+ async def _rebuild_header(self) -> None:
2201
+ """Replace header cells to match current visible columns."""
2202
+ try:
2203
+ header = self.query_one("#thread-header", Horizontal)
2204
+ except NoMatches:
2205
+ return
2206
+ sort_key = _active_sort_key(self._sort_by_updated)
2207
+ self._column_widths = self._compute_column_widths()
2208
+ with self.app.batch_update():
2209
+ await header.remove_children()
2210
+ cells: list[Static] = [Static("", classes="thread-cell thread-cell-cursor")]
2211
+ for key in _visible_column_keys(self._columns):
2212
+ cell = Static(
2213
+ _format_header_label(key),
2214
+ classes=_header_cell_classes(key, sort_key=sort_key),
2215
+ expand=key == "initial_prompt",
2216
+ markup=False,
2217
+ )
2218
+ _apply_column_width(cell, key, self._column_widths)
2219
+ cells.append(cell)
2220
+ await header.mount(*cells)
2221
+
2222
+ def _apply_sort(self) -> None:
2223
+ """Sort filtered threads by the active sort key."""
2224
+ key = _active_sort_key(self._sort_by_updated)
2225
+ self._filtered_threads.sort(
2226
+ key=lambda thread: thread.get(key) or "", reverse=True
2227
+ )
2228
+
2229
+ def _move_selection(self, delta: int) -> None:
2230
+ """Move selection by delta, updating only the affected rows.
2231
+
2232
+ Args:
2233
+ delta: Positions to move (negative for up, positive for down).
2234
+ """
2235
+ if not self._filtered_threads or not self._option_widgets:
2236
+ return
2237
+
2238
+ count = len(self._filtered_threads)
2239
+ old_index = self._selected_index
2240
+ new_index = (old_index + delta) % count
2241
+ self._selected_index = new_index
2242
+
2243
+ self._option_widgets[old_index].set_selected(False)
2244
+ self._option_widgets[new_index].set_selected(True)
2245
+
2246
+ if new_index == 0:
2247
+ scroll = self.query_one(".thread-list", VerticalScroll)
2248
+ scroll.scroll_home(animate=False)
2249
+ else:
2250
+ self._option_widgets[new_index].scroll_visible()
2251
+
2252
+ def action_move_up(self) -> None:
2253
+ """Move selection up."""
2254
+ if self._confirming_delete:
2255
+ return
2256
+ self._move_selection(-1)
2257
+
2258
+ def action_move_down(self) -> None:
2259
+ """Move selection down."""
2260
+ if self._confirming_delete:
2261
+ return
2262
+ self._move_selection(1)
2263
+
2264
+ def _visible_page_size(self) -> int:
2265
+ """Return the number of thread options that fit in one visual page.
2266
+
2267
+ Returns:
2268
+ Number of thread options per page, at least 1.
2269
+ """
2270
+ default_page_size = 10
2271
+ try:
2272
+ scroll = self.query_one(".thread-list", VerticalScroll)
2273
+ height = scroll.size.height
2274
+ except NoMatches:
2275
+ logger.debug(
2276
+ "Thread list widget not found in _visible_page_size; "
2277
+ "using default page size %d",
2278
+ default_page_size,
2279
+ )
2280
+ return default_page_size
2281
+ if height <= 0:
2282
+ return default_page_size
2283
+ return max(1, height)
2284
+
2285
+ def action_page_up(self) -> None:
2286
+ """Move selection up by one visible page."""
2287
+ if self._confirming_delete or not self._filtered_threads:
2288
+ return
2289
+ page = self._visible_page_size()
2290
+ target = max(0, self._selected_index - page)
2291
+ delta = target - self._selected_index
2292
+ if delta != 0:
2293
+ self._move_selection(delta)
2294
+
2295
+ def action_page_down(self) -> None:
2296
+ """Move selection down by one visible page."""
2297
+ if self._confirming_delete or not self._filtered_threads:
2298
+ return
2299
+ count = len(self._filtered_threads)
2300
+ page = self._visible_page_size()
2301
+ target = min(count - 1, self._selected_index + page)
2302
+ delta = target - self._selected_index
2303
+ if delta != 0:
2304
+ self._move_selection(delta)
2305
+
2306
+ def action_select(self) -> None:
2307
+ """Confirm the highlighted thread and dismiss the selector."""
2308
+ if self._confirming_delete:
2309
+ return
2310
+ if self._is_select_expanded():
2311
+ self._select_expanded_highlight()
2312
+ return
2313
+ if self._open_focused_select():
2314
+ return
2315
+ if self._filtered_threads:
2316
+ thread_id = self._filtered_threads[self._selected_index]["thread_id"]
2317
+ self.dismiss(thread_id)
2318
+
2319
+ def action_focus_next_filter(self) -> None:
2320
+ """Move focus through the filter and column-toggle controls."""
2321
+ if self._confirming_delete:
2322
+ return
2323
+ if self._is_select_expanded():
2324
+ return
2325
+ controls = self._filter_focus_order()
2326
+ focused = self.focused
2327
+ if focused not in controls:
2328
+ controls[0].focus()
2329
+ return
2330
+
2331
+ index = controls.index(cast("Input | Checkbox | Select[str]", focused))
2332
+ controls[(index + 1) % len(controls)].focus()
2333
+
2334
+ def action_focus_previous_filter(self) -> None:
2335
+ """Move focus backward through the filter and column-toggle controls."""
2336
+ if self._confirming_delete:
2337
+ return
2338
+ if self._is_select_expanded():
2339
+ return
2340
+ controls = self._filter_focus_order()
2341
+ focused = self.focused
2342
+ if focused not in controls:
2343
+ controls[-1].focus()
2344
+ return
2345
+
2346
+ index = controls.index(cast("Input | Checkbox | Select[str]", focused))
2347
+ controls[(index - 1) % len(controls)].focus()
2348
+
2349
+ def on_select_changed(self, event: Select.Changed) -> None:
2350
+ """Handle the scope, sort, and agent `Select` dropdowns in the Options panel.
2351
+
2352
+ The agent branch updates the in-memory `_filter_agent` and re-filters
2353
+ the loaded threads without re-querying the database; selecting the
2354
+ "All agents" sentinel (`_AGENT_VALUE_ALL`) clears the filter. The scope
2355
+ branch mirrors Claude Code's `/resume`: the picker is scoped to the
2356
+ current working directory by default, and choosing "All directories"
2357
+ widens the view to threads from every cwd (which does re-query).
2358
+
2359
+ Args:
2360
+ event: The `Select.Changed` message; `event.select.id` selects the
2361
+ branch and `event.value` carries the new selection.
2362
+ """
2363
+ if event.select.id == _AGENT_SELECT_ID:
2364
+ if self._confirming_delete:
2365
+ return
2366
+ if event.value == _AGENT_VALUE_LOADING:
2367
+ return
2368
+ new_agent = None if event.value == _AGENT_VALUE_ALL else str(event.value)
2369
+ if new_agent == self._filter_agent:
2370
+ return
2371
+ self._filter_agent = new_agent
2372
+ self._schedule_filter_and_rebuild()
2373
+ return
2374
+
2375
+ if self._confirming_delete:
2376
+ return
2377
+ if event.select.id == _SORT_SELECT_ID:
2378
+ self._apply_sort_selection(event.value == _SORT_VALUE_UPDATED)
2379
+ return
2380
+ if event.select.id != _SCOPE_SELECT_ID:
2381
+ return
2382
+
2383
+ if event.value == _SCOPE_VALUE_CWD:
2384
+ new_cwd = _safe_cwd_string()
2385
+ if new_cwd is None:
2386
+ # Working directory is gone or unreadable. Tell the user
2387
+ # rather than silently re-querying with no filter.
2388
+ self.app.notify(
2389
+ "Could not determine the current working directory; "
2390
+ "showing all directories instead.",
2391
+ severity="warning",
2392
+ markup=False,
2393
+ )
2394
+ else:
2395
+ new_cwd = None
2396
+ self._persist_scope(
2397
+ _SCOPE_VALUE_CWD if event.value == _SCOPE_VALUE_CWD else _SCOPE_VALUE_ALL
2398
+ )
2399
+ if new_cwd == self._filter_cwd:
2400
+ return
2401
+ self._filter_cwd = new_cwd
2402
+ self._update_help_widgets()
2403
+ self.run_worker(
2404
+ self._load_threads, exclusive=True, group="thread-selector-load"
2405
+ )
2406
+
2407
+ def _apply_sort_selection(self, sort_by_updated: bool) -> None:
2408
+ """Re-sort the picker when the sort dropdown selection changes.
2409
+
2410
+ Args:
2411
+ sort_by_updated: Sort by `updated_at` when `True`, else `created_at`.
2412
+ """
2413
+ if self._sort_by_updated == sort_by_updated:
2414
+ return
2415
+ self._sort_by_updated = sort_by_updated
2416
+ self._apply_sort()
2417
+ self._sync_selected_index()
2418
+ self._update_help_widgets()
2419
+ self._schedule_list_rebuild()
2420
+ self._persist_sort_order(
2421
+ _SORT_VALUE_UPDATED if sort_by_updated else _SORT_VALUE_CREATED
2422
+ )
2423
+
2424
+ def _persist_scope(self, scope: str) -> None:
2425
+ """Save directory-scope preference to config, notifying on failure."""
2426
+
2427
+ async def _save() -> None:
2428
+ from deepagents_code.model_config import save_thread_scope
2429
+
2430
+ ok = await asyncio.to_thread(save_thread_scope, scope)
2431
+ if not ok:
2432
+ self.app.notify("Could not save scope preference", severity="warning")
2433
+
2434
+ self.run_worker(_save(), group="thread-selector-save")
2435
+
2436
+ def _persist_sort_order(self, order: str) -> None:
2437
+ """Save sort-order preference to config, notifying on failure."""
2438
+
2439
+ async def _save() -> None:
2440
+ from deepagents_code.model_config import save_thread_sort_order
2441
+
2442
+ ok = await asyncio.to_thread(save_thread_sort_order, order)
2443
+ if not ok:
2444
+ self.app.notify("Could not save sort preference", severity="warning")
2445
+
2446
+ self.run_worker(_save(), group="thread-selector-save")
2447
+
2448
+ def action_delete_thread(self) -> None:
2449
+ """Show delete confirmation for the highlighted thread."""
2450
+ if self._confirming_delete:
2451
+ return
2452
+ if not self._filtered_threads:
2453
+ # Nothing to delete — fall through to quit. Using exit() instead of
2454
+ # dismiss() is intentional: dismiss() would just close the modal
2455
+ # silently, re-swallowing ctrl+d.
2456
+ self.app.exit()
2457
+ return
2458
+ self._confirming_delete = True
2459
+ thread = self._filtered_threads[self._selected_index]
2460
+ tid = thread["thread_id"]
2461
+ self.app.push_screen(
2462
+ DeleteThreadConfirmScreen(tid),
2463
+ lambda confirmed: self._on_delete_confirmed(tid, confirmed),
2464
+ )
2465
+
2466
+ @property
2467
+ def is_delete_confirmation_open(self) -> bool:
2468
+ """Whether the delete confirmation overlay is visible."""
2469
+ return self._confirming_delete
2470
+
2471
+ def _on_delete_confirmed(self, thread_id: str, confirmed: bool | None) -> None:
2472
+ """Handle the result from the delete confirmation modal.
2473
+
2474
+ Args:
2475
+ thread_id: Thread ID that was targeted.
2476
+ confirmed: Whether deletion was confirmed.
2477
+ """
2478
+ self._confirming_delete = False
2479
+ if confirmed:
2480
+ self.run_worker(
2481
+ self._handle_delete_confirm(thread_id),
2482
+ group="thread-delete-execute",
2483
+ )
2484
+ return
2485
+ with contextlib.suppress(NoMatches):
2486
+ self._get_filter_input().focus()
2487
+
2488
+ async def _handle_delete_confirm(self, thread_id: str) -> None:
2489
+ """Execute thread deletion after confirmation.
2490
+
2491
+ Args:
2492
+ thread_id: Thread ID to delete.
2493
+ """
2494
+ from deepagents_code.sessions import delete_thread
2495
+
2496
+ preferred_thread_id: str | None = None
2497
+ if self._selected_index + 1 < len(self._filtered_threads):
2498
+ preferred_thread_id = self._filtered_threads[self._selected_index + 1][
2499
+ "thread_id"
2500
+ ]
2501
+ elif self._selected_index > 0:
2502
+ preferred_thread_id = self._filtered_threads[self._selected_index - 1][
2503
+ "thread_id"
2504
+ ]
2505
+
2506
+ try:
2507
+ await delete_thread(thread_id)
2508
+ except (OSError, sqlite3.Error):
2509
+ logger.warning("Failed to delete thread %s", thread_id, exc_info=True)
2510
+ self.app.notify(
2511
+ f"Failed to delete thread {thread_id[:8]}",
2512
+ severity="error",
2513
+ timeout=3,
2514
+ markup=False,
2515
+ )
2516
+ with contextlib.suppress(NoMatches):
2517
+ self.query_one("#thread-filter", Input).focus()
2518
+ return
2519
+
2520
+ self._threads = [
2521
+ thread for thread in self._threads if thread["thread_id"] != thread_id
2522
+ ]
2523
+ self._update_filtered_list()
2524
+ if preferred_thread_id is not None:
2525
+ for index, thread in enumerate(self._filtered_threads):
2526
+ if thread["thread_id"] == preferred_thread_id:
2527
+ self._selected_index = index
2528
+ break
2529
+ if self._selected_index >= len(self._filtered_threads):
2530
+ self._selected_index = max(0, len(self._filtered_threads) - 1)
2531
+ await self._build_list()
2532
+ with contextlib.suppress(NoMatches):
2533
+ self.query_one("#thread-filter", Input).focus()
2534
+
2535
+ def on_click(self, event: Click) -> None: # noqa: PLR6301 # Textual event handler
2536
+ """Open Rich-style hyperlinks on single click."""
2537
+ open_style_link(event)
2538
+
2539
+ def on_mouse_move(self, event: MouseMove) -> None:
2540
+ """Show a pointer over Rich-style hyperlinks."""
2541
+ self.styles.pointer = "pointer" if event.style.link else "default"
2542
+
2543
+ def on_leave(self) -> None:
2544
+ """Reset the pointer shape when the mouse leaves the selector."""
2545
+ self.styles.pointer = "default"
2546
+
2547
+ def on_thread_option_clicked(self, event: ThreadOption.Clicked) -> None:
2548
+ """Handle click on a thread option.
2549
+
2550
+ Args:
2551
+ event: The clicked message with thread ID and index.
2552
+ """
2553
+ if self._confirming_delete:
2554
+ return
2555
+ if 0 <= event.index < len(self._filtered_threads):
2556
+ self._selected_index = event.index
2557
+ self.dismiss(event.thread_id)
2558
+
2559
+ def action_cancel(self) -> None:
2560
+ """Cancel the selection."""
2561
+ if self._is_select_expanded():
2562
+ self._close_expanded_select()
2563
+ return
2564
+ self.dismiss(None)