fast-resume 1.12.8__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.
fast_resume/tui/app.py ADDED
@@ -0,0 +1,629 @@
1
+ """Main TUI application for fast-resume."""
2
+
3
+ import logging
4
+ import os
5
+ import shlex
6
+ import time
7
+ from collections.abc import Callable
8
+
9
+ from textual import on, work
10
+ from textual.app import App, ComposeResult
11
+ from textual.css.query import NoMatches
12
+ from textual.binding import Binding
13
+ from textual.containers import Horizontal, Vertical, VerticalScroll
14
+ from textual.reactive import reactive
15
+ from textual.timer import Timer
16
+ from textual.widgets import Footer, Input, Label
17
+
18
+ from .. import __version__
19
+ from ..adapters.base import ParseError, Session
20
+ from ..config import LOG_FILE
21
+ from ..search import SessionSearch
22
+ from .filter_bar import FILTER_KEYS, FilterBar
23
+ from .modal import YoloModeModal
24
+ from .preview import SessionPreview
25
+ from .query import extract_agent_from_query, update_agent_in_query
26
+ from .results_table import ResultsTable
27
+ from .search_input import KeywordHighlighter, KeywordSuggester
28
+ from .styles import APP_CSS
29
+ from .utils import copy_to_clipboard
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class FastResumeApp(App):
35
+ """Main TUI application for fast-resume."""
36
+
37
+ ENABLE_COMMAND_PALETTE = True
38
+ TITLE = "fast-resume"
39
+ SUB_TITLE = "Session manager"
40
+
41
+ CSS = APP_CSS
42
+
43
+ BINDINGS = [
44
+ Binding("escape", "quit", "Quit", priority=True),
45
+ Binding("q", "quit", "Quit", show=False),
46
+ Binding("ctrl+c", "quit", "Quit", show=False),
47
+ Binding("/", "focus_search", "Search", priority=True),
48
+ Binding("enter", "resume_session", "Resume"),
49
+ Binding("c", "copy_path", "Copy resume command", priority=True),
50
+ Binding("ctrl+grave_accent", "toggle_preview", "Preview", priority=True),
51
+ Binding("tab", "accept_suggestion", "Accept", show=False, priority=True),
52
+ Binding("j", "cursor_down", "Down", show=False),
53
+ Binding("k", "cursor_up", "Up", show=False),
54
+ Binding("down", "cursor_down", "Down", show=False),
55
+ Binding("up", "cursor_up", "Up", show=False),
56
+ Binding("pagedown", "page_down", "Page Down", show=False),
57
+ Binding("pageup", "page_up", "Page Up", show=False),
58
+ Binding("plus", "increase_preview", "+Preview", show=False),
59
+ Binding("equals", "increase_preview", "+Preview", show=False),
60
+ Binding("minus", "decrease_preview", "-Preview", show=False),
61
+ Binding("ctrl+p", "command_palette", "Commands"),
62
+ ]
63
+
64
+ show_preview: reactive[bool] = reactive(True)
65
+ selected_session: reactive[Session | None] = reactive(None)
66
+ active_filter: reactive[str | None] = reactive(None)
67
+ is_loading: reactive[bool] = reactive(True)
68
+ preview_height: reactive[int] = reactive(12)
69
+ search_query: reactive[str] = reactive("", init=False)
70
+ query_time_ms: reactive[float | None] = reactive(None)
71
+ _spinner_frame: int = 0
72
+ _spinner_chars: str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
73
+
74
+ def __init__(
75
+ self,
76
+ initial_query: str = "",
77
+ agent_filter: str | None = None,
78
+ yolo: bool = False,
79
+ no_version_check: bool = False,
80
+ ):
81
+ super().__init__()
82
+ self.search_engine = SessionSearch()
83
+ self.initial_query = initial_query
84
+ self.agent_filter = agent_filter
85
+ self.yolo = yolo
86
+ self.no_version_check = no_version_check
87
+ self.sessions: list[Session] = []
88
+ self._resume_command: list[str] | None = None
89
+ self._resume_directory: str | None = None
90
+ self._current_query: str = ""
91
+ self._total_loaded: int = 0
92
+ self._search_timer: Timer | None = None
93
+ self._available_update: str | None = None
94
+ self._syncing_filter: bool = False # Prevent infinite loops during sync
95
+
96
+ def compose(self) -> ComposeResult:
97
+ """Create child widgets."""
98
+ with Vertical():
99
+ # Title bar: app name + version + session count
100
+ with Horizontal(id="title-bar"):
101
+ yield Label(f"fast-resume v{__version__}", id="app-title")
102
+ yield Label("", id="session-count")
103
+
104
+ # Search row with boxed input
105
+ with Horizontal(id="search-row"):
106
+ with Horizontal(id="search-box"):
107
+ yield Label("🔍", id="search-icon")
108
+ yield Input(
109
+ placeholder="Search titles & messages. Try agent:claude or date:today",
110
+ id="search-input",
111
+ value=self.initial_query,
112
+ highlighter=KeywordHighlighter(),
113
+ suggester=KeywordSuggester(),
114
+ )
115
+ yield Label("", id="query-time")
116
+
117
+ # Agent filter buttons
118
+ yield FilterBar(initial_filter=self.agent_filter, id="filter-container")
119
+
120
+ # Main content area
121
+ with Vertical(id="main-container"):
122
+ with Vertical(id="results-container"):
123
+ yield ResultsTable(id="results-table")
124
+ with VerticalScroll(id="preview-container"):
125
+ yield SessionPreview()
126
+ yield Footer()
127
+
128
+ def on_mount(self) -> None:
129
+ """Set up the app when mounted."""
130
+ # Set initial filter state from agent_filter parameter
131
+ self.active_filter = self.agent_filter
132
+
133
+ # Focus search input
134
+ self.query_one("#search-input", Input).focus()
135
+
136
+ # Start spinner animation
137
+ self._spinner_timer = self.set_interval(0.08, self._update_spinner)
138
+
139
+ # Try fast sync load first (index hit), fall back to async
140
+ self._initial_load()
141
+
142
+ # Check for updates asynchronously (unless disabled)
143
+ if not self.no_version_check:
144
+ self._check_for_updates()
145
+
146
+ # -------------------------------------------------------------------------
147
+ # Loading logic
148
+ # -------------------------------------------------------------------------
149
+
150
+ def _initial_load(self) -> None:
151
+ """Load sessions - sync if index is current, async with streaming otherwise."""
152
+ # Try to get sessions directly from index (fast path)
153
+ sessions = self.search_engine._load_from_index()
154
+ table = self.query_one(ResultsTable)
155
+ if sessions is not None:
156
+ # Index is current - load synchronously, no flicker
157
+ self.search_engine._sessions = sessions
158
+ self._total_loaded = len(sessions)
159
+ start_time = time.perf_counter()
160
+ self.sessions = self.search_engine.search(
161
+ self.initial_query, agent_filter=self.active_filter, limit=100
162
+ )
163
+ self.query_time_ms = (time.perf_counter() - start_time) * 1000
164
+ self._finish_loading()
165
+ self.selected_session = table.update_sessions(
166
+ self.sessions, self._current_query
167
+ )
168
+ else:
169
+ # Index needs update - show loading and fetch with streaming
170
+ table.update_sessions([], self._current_query)
171
+ self._update_session_count()
172
+ self._do_streaming_load()
173
+
174
+ def _update_spinner(self) -> None:
175
+ """Advance spinner animation in search icon."""
176
+ search_icon = self.query_one("#search-icon", Label)
177
+ if self.is_loading:
178
+ self._spinner_frame = (self._spinner_frame + 1) % len(self._spinner_chars)
179
+ search_icon.update(self._spinner_chars[self._spinner_frame])
180
+ else:
181
+ search_icon.update("🔍")
182
+
183
+ def _update_session_count(self) -> None:
184
+ """Update the session count display."""
185
+ count_label = self.query_one("#session-count", Label)
186
+ time_label = self.query_one("#query-time", Label)
187
+ if self.is_loading:
188
+ count_label.update(f"{self._total_loaded} sessions loaded")
189
+ time_label.update("")
190
+ else:
191
+ shown = len(self.sessions)
192
+ # Get total for current filter (or all if no filter)
193
+ total = self.search_engine.get_session_count(self.active_filter)
194
+ if shown < total:
195
+ count_label.update(f"{shown}/{total} sessions")
196
+ else:
197
+ count_label.update(f"{total} sessions")
198
+ # Update query time in search box
199
+ if self.query_time_ms is not None:
200
+ time_label.update(f"{self.query_time_ms:.1f}ms")
201
+ else:
202
+ time_label.update("")
203
+
204
+ @work(exclusive=True, thread=True)
205
+ def _do_streaming_load(self) -> None:
206
+ """Load sessions with progressive updates as each adapter completes."""
207
+ # Collect parse errors (thread-safe list)
208
+ parse_errors: list[ParseError] = []
209
+
210
+ def on_progress():
211
+ # Use Tantivy search with initial_query
212
+ query = self.initial_query
213
+ start_time = time.perf_counter()
214
+ sessions = self.search_engine.search(
215
+ query, agent_filter=self.active_filter, limit=100
216
+ )
217
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
218
+ total = self.search_engine.get_session_count()
219
+ self.call_from_thread(
220
+ self._update_results_streaming, sessions, total, elapsed_ms
221
+ )
222
+
223
+ def on_error(error: ParseError):
224
+ parse_errors.append(error)
225
+
226
+ _, new, updated, deleted = self.search_engine.get_sessions_streaming(
227
+ on_progress, on_error=on_error
228
+ )
229
+ # Mark loading complete and show toast if there were changes
230
+ self.call_from_thread(
231
+ self._finish_loading, new, updated, deleted, len(parse_errors)
232
+ )
233
+
234
+ def _update_results_streaming(
235
+ self, sessions: list, total: int, elapsed_ms: float | None = None
236
+ ) -> None:
237
+ """Update UI with streaming results (keeps loading state)."""
238
+ self.sessions = sessions
239
+ self._total_loaded = total
240
+ if elapsed_ms is not None:
241
+ self.query_time_ms = elapsed_ms
242
+ try:
243
+ table = self.query_one(ResultsTable)
244
+ except NoMatches:
245
+ return # Widget not mounted yet
246
+ self.selected_session = table.update_sessions(sessions, self._current_query)
247
+ self._update_session_count()
248
+
249
+ def _finish_loading(
250
+ self, new: int = 0, updated: int = 0, deleted: int = 0, errors: int = 0
251
+ ) -> None:
252
+ """Mark loading as complete and show toast if there were changes."""
253
+ self.is_loading = False
254
+ if hasattr(self, "_spinner_timer"):
255
+ self._spinner_timer.stop()
256
+ self._update_spinner()
257
+ self._update_session_count()
258
+
259
+ # Update filter bar to only show agents with sessions
260
+ agents_with_sessions = self.search_engine.get_agents_with_sessions()
261
+ self.query_one(FilterBar).update_agents_with_sessions(agents_with_sessions)
262
+
263
+ # Show toast if there were changes
264
+ if new or updated or deleted:
265
+ parts = []
266
+ # Put "session(s)" on the first item only
267
+ if new:
268
+ parts.append(f"{new} new session{'s' if new != 1 else ''}")
269
+ if updated:
270
+ if not parts: # First item
271
+ parts.append(
272
+ f"{updated} session{'s' if updated != 1 else ''} updated"
273
+ )
274
+ else:
275
+ parts.append(f"{updated} updated")
276
+ if deleted:
277
+ if not parts: # First item
278
+ parts.append(
279
+ f"{deleted} session{'s' if deleted != 1 else ''} deleted"
280
+ )
281
+ else:
282
+ parts.append(f"{deleted} deleted")
283
+ self.notify(", ".join(parts), title="Index updated")
284
+
285
+ # Show warning toast for parse errors
286
+ if errors:
287
+ home = os.path.expanduser("~")
288
+ log_path = str(LOG_FILE)
289
+ if log_path.startswith(home):
290
+ log_path = "~" + log_path[len(home) :]
291
+ self.notify(
292
+ f"{errors} session{'s' if errors != 1 else ''} failed to parse. "
293
+ f"See {log_path}",
294
+ severity="warning",
295
+ timeout=5,
296
+ )
297
+
298
+ @work(thread=True)
299
+ def _check_for_updates(self) -> None:
300
+ """Check PyPI for newer version and notify if available."""
301
+ import json
302
+ import urllib.request
303
+
304
+ from .. import __version__
305
+
306
+ try:
307
+ url = "https://pypi.org/pypi/fast-resume/json"
308
+ with urllib.request.urlopen(url, timeout=3) as response:
309
+ data = json.load(response)
310
+ latest = data["info"]["version"]
311
+
312
+ if latest != __version__:
313
+ self._available_update = latest
314
+ self.call_from_thread(
315
+ self.notify,
316
+ f"{__version__} → {latest}\nRun [bold]uv tool upgrade fast-resume[/bold] to update",
317
+ title="Update available",
318
+ timeout=5,
319
+ )
320
+ except Exception:
321
+ pass # Silently ignore update check failures
322
+
323
+ # -------------------------------------------------------------------------
324
+ # Search logic
325
+ # -------------------------------------------------------------------------
326
+
327
+ @work(exclusive=True, thread=True)
328
+ def _do_search(self, query: str) -> None:
329
+ """Perform search and update results in background thread."""
330
+ self._current_query = query
331
+ start_time = time.perf_counter()
332
+ sessions = self.search_engine.search(
333
+ query, agent_filter=self.active_filter, limit=100
334
+ )
335
+ elapsed_ms = (time.perf_counter() - start_time) * 1000
336
+ # Update UI from worker thread via call_from_thread
337
+ self.call_from_thread(self._update_results, sessions, elapsed_ms)
338
+
339
+ def _update_results(
340
+ self, sessions: list[Session], elapsed_ms: float | None = None
341
+ ) -> None:
342
+ """Update the UI with search results (called from main thread)."""
343
+ self.sessions = sessions
344
+ if elapsed_ms is not None:
345
+ self.query_time_ms = elapsed_ms
346
+ # Only stop loading spinner if streaming indexing is also done
347
+ if not self.search_engine._streaming_in_progress:
348
+ self.is_loading = False
349
+ try:
350
+ table = self.query_one(ResultsTable)
351
+ except NoMatches:
352
+ return # Widget not mounted yet
353
+ self.selected_session = table.update_sessions(sessions, self._current_query)
354
+ self._update_session_count()
355
+
356
+ def _update_selected_session(self) -> None:
357
+ """Update the selected session based on cursor position."""
358
+ try:
359
+ table = self.query_one(ResultsTable)
360
+ except NoMatches:
361
+ return # Widget not mounted yet
362
+ session = table.get_selected_session()
363
+ if session:
364
+ self.selected_session = session
365
+ preview = self.query_one(SessionPreview)
366
+ preview.update_preview(session, self._current_query)
367
+
368
+ @on(ResultsTable.Selected)
369
+ def on_results_table_selected(self, event: ResultsTable.Selected) -> None:
370
+ """Handle session selection in results table."""
371
+ if event.session:
372
+ self.selected_session = event.session
373
+ preview = self.query_one(SessionPreview)
374
+ preview.update_preview(event.session, self._current_query)
375
+
376
+ @on(Input.Changed, "#search-input")
377
+ def on_search_changed(self, event: Input.Changed) -> None:
378
+ """Handle search input changes with debouncing."""
379
+ # Cancel previous timer if still pending
380
+ if self._search_timer:
381
+ self._search_timer.stop()
382
+ self.is_loading = True
383
+
384
+ # Sync filter buttons with agent keyword in query (if not already syncing)
385
+ if not self._syncing_filter:
386
+ agent_in_query = extract_agent_from_query(event.value)
387
+ # Only sync if the extracted agent is different from current filter
388
+ if agent_in_query != self.active_filter:
389
+ # Check if this is a valid agent
390
+ if agent_in_query is None or agent_in_query in FILTER_KEYS:
391
+ self._syncing_filter = True
392
+ self.active_filter = agent_in_query
393
+ self.query_one(FilterBar).set_active(agent_in_query)
394
+ self._syncing_filter = False
395
+
396
+ # Debounce: wait 50ms before triggering search
397
+ value = event.value
398
+ self._search_timer = self.set_timer(
399
+ 0.05, lambda: setattr(self, "search_query", value)
400
+ )
401
+
402
+ def watch_search_query(self, query: str) -> None:
403
+ """React to search query changes."""
404
+ self._do_search(query)
405
+
406
+ @on(Input.Submitted, "#search-input")
407
+ def on_search_submitted(self, event: Input.Submitted) -> None:
408
+ """Handle search submission - resume selected session."""
409
+ self.action_resume_session()
410
+
411
+ # -------------------------------------------------------------------------
412
+ # Resume logic
413
+ # -------------------------------------------------------------------------
414
+
415
+ def _resolve_yolo_mode(
416
+ self,
417
+ action: Callable[[bool], None],
418
+ modal_callback: Callable[[bool | None], None],
419
+ ) -> None:
420
+ """Resolve yolo mode and call the action with the result.
421
+
422
+ Determines whether to use yolo mode based on CLI flag, session state,
423
+ or user selection via modal. Then calls `action(yolo_value)`.
424
+ """
425
+ assert self.selected_session is not None
426
+ adapter = self.search_engine.get_adapter_for_session(self.selected_session)
427
+
428
+ # If CLI --yolo flag is set, always use yolo
429
+ if self.yolo:
430
+ action(True)
431
+ return
432
+
433
+ # If session has stored yolo mode, use it directly
434
+ if self.selected_session.yolo:
435
+ action(True)
436
+ return
437
+
438
+ # If adapter supports yolo but session doesn't have stored value, show modal
439
+ if adapter and adapter.supports_yolo:
440
+ self.push_screen(YoloModeModal(), modal_callback)
441
+ return
442
+
443
+ # Otherwise proceed without yolo
444
+ action(False)
445
+
446
+ def action_copy_path(self) -> None:
447
+ """Copy the full resume command (cd + agent resume) to clipboard."""
448
+ if not self.selected_session:
449
+ return
450
+ self._resolve_yolo_mode(self._do_copy_command, self._on_copy_yolo_modal_result)
451
+
452
+ def _do_copy_command(self, yolo: bool) -> None:
453
+ """Execute the copy command with specified yolo mode."""
454
+ assert self.selected_session is not None
455
+ resume_cmd = self.search_engine.get_resume_command(
456
+ self.selected_session, yolo=yolo
457
+ )
458
+ if not resume_cmd:
459
+ self.notify("No resume command available", severity="warning", timeout=2)
460
+ return
461
+
462
+ directory = self.selected_session.directory
463
+ cmd_str = shlex.join(resume_cmd)
464
+ full_cmd = f"cd {shlex.quote(directory)} && {cmd_str}"
465
+
466
+ if copy_to_clipboard(full_cmd):
467
+ self.notify(f"Copied: {full_cmd}", timeout=3)
468
+ else:
469
+ self.notify(full_cmd, title="Clipboard unavailable", timeout=5)
470
+
471
+ def _on_copy_yolo_modal_result(self, result: bool | None) -> None:
472
+ """Handle result from yolo mode modal for copy action."""
473
+ if result is not None:
474
+ self._do_copy_command(yolo=result)
475
+
476
+ def action_resume_session(self) -> None:
477
+ """Resume the selected session."""
478
+ if not self.selected_session:
479
+ return
480
+
481
+ # Crush doesn't support CLI resume - show a toast instead
482
+ if self.selected_session.agent == "crush":
483
+ self.notify(
484
+ f"Crush doesn't support CLI resume. Open crush in: [bold]{self.selected_session.directory}[/bold] and use ctrl+s to find your session",
485
+ title="Cannot resume",
486
+ severity="warning",
487
+ timeout=5,
488
+ )
489
+ return
490
+
491
+ self._resolve_yolo_mode(self._do_resume, self._on_yolo_modal_result)
492
+
493
+ def _do_resume(self, yolo: bool) -> None:
494
+ """Execute the resume with specified yolo mode."""
495
+ assert self.selected_session is not None
496
+ self._resume_command = self.search_engine.get_resume_command(
497
+ self.selected_session, yolo=yolo
498
+ )
499
+ self._resume_directory = self.selected_session.directory
500
+ self.exit()
501
+
502
+ def _on_yolo_modal_result(self, result: bool | None) -> None:
503
+ """Handle result from yolo mode modal."""
504
+ if result is not None:
505
+ self._do_resume(yolo=result)
506
+
507
+ # -------------------------------------------------------------------------
508
+ # UI actions
509
+ # -------------------------------------------------------------------------
510
+
511
+ def action_focus_search(self) -> None:
512
+ """Focus the search input."""
513
+ self.query_one("#search-input", Input).focus()
514
+
515
+ def action_toggle_preview(self) -> None:
516
+ """Toggle the preview pane."""
517
+ self.show_preview = not self.show_preview
518
+ preview_container = self.query_one("#preview-container")
519
+ if self.show_preview:
520
+ preview_container.remove_class("hidden")
521
+ else:
522
+ preview_container.add_class("hidden")
523
+
524
+ def action_cursor_down(self) -> None:
525
+ """Move cursor down in results."""
526
+ table = self.query_one(ResultsTable)
527
+ table.action_cursor_down()
528
+
529
+ def action_cursor_up(self) -> None:
530
+ """Move cursor up in results."""
531
+ table = self.query_one(ResultsTable)
532
+ table.action_cursor_up()
533
+
534
+ def action_page_down(self) -> None:
535
+ """Move cursor down by a page."""
536
+ table = self.query_one(ResultsTable)
537
+ # Move down by ~10 rows (approximate page)
538
+ for _ in range(10):
539
+ table.action_cursor_down()
540
+
541
+ def action_page_up(self) -> None:
542
+ """Move cursor up by a page."""
543
+ table = self.query_one(ResultsTable)
544
+ # Move up by ~10 rows (approximate page)
545
+ for _ in range(10):
546
+ table.action_cursor_up()
547
+
548
+ def action_increase_preview(self) -> None:
549
+ """Increase preview pane height."""
550
+ if self.preview_height < 30:
551
+ self.preview_height += 3
552
+ self._apply_preview_height()
553
+
554
+ def action_decrease_preview(self) -> None:
555
+ """Decrease preview pane height."""
556
+ if self.preview_height > 6:
557
+ self.preview_height -= 3
558
+ self._apply_preview_height()
559
+
560
+ def _apply_preview_height(self) -> None:
561
+ """Apply the current preview height to the container."""
562
+ preview_container = self.query_one("#preview-container")
563
+ preview_container.styles.height = self.preview_height
564
+
565
+ def _set_filter(self, agent: str | None) -> None:
566
+ """Set the agent filter and refresh results, syncing query string."""
567
+ self.active_filter = agent
568
+ self.query_one(FilterBar).set_active(agent)
569
+
570
+ # Update search input to reflect the new filter (if not already syncing)
571
+ if not self._syncing_filter:
572
+ self._syncing_filter = True
573
+ search_input = self.query_one("#search-input", Input)
574
+ new_query = update_agent_in_query(search_input.value, agent)
575
+ if new_query != search_input.value:
576
+ search_input.value = new_query
577
+ self._current_query = new_query
578
+ self._syncing_filter = False
579
+
580
+ self._do_search(self._current_query)
581
+
582
+ def action_accept_suggestion(self) -> None:
583
+ """Accept autocomplete suggestion in search input."""
584
+ # If a modal is open, let it handle tab for focus switching
585
+ if isinstance(self.screen, YoloModeModal):
586
+ self.screen.action_toggle_focus()
587
+ return
588
+ search_input = self.query_one("#search-input", Input)
589
+ if search_input._suggestion:
590
+ search_input.action_cursor_right()
591
+
592
+ def action_cycle_filter(self) -> None:
593
+ """Cycle to the next agent filter."""
594
+ try:
595
+ current_index = FILTER_KEYS.index(self.active_filter)
596
+ next_index = (current_index + 1) % len(FILTER_KEYS)
597
+ except ValueError:
598
+ next_index = 0
599
+ self._set_filter(FILTER_KEYS[next_index])
600
+
601
+ async def action_quit(self) -> None:
602
+ """Quit the app, or dismiss modal if one is open."""
603
+ if len(self.screen_stack) > 1:
604
+ top_screen = self.screen_stack[-1]
605
+ if isinstance(top_screen, YoloModeModal):
606
+ top_screen.dismiss(None)
607
+ return
608
+ self.exit()
609
+
610
+ @on(FilterBar.Changed)
611
+ def on_filter_bar_changed(self, event: FilterBar.Changed) -> None:
612
+ """Handle filter bar selection change."""
613
+ self._set_filter(event.filter_key)
614
+
615
+ def get_resume_command(self) -> list[str] | None:
616
+ """Get the resume command to execute after exit."""
617
+ return self._resume_command
618
+
619
+ def get_resume_directory(self) -> str | None:
620
+ """Get the directory to change to before running the resume command."""
621
+ return self._resume_directory
622
+
623
+ @property
624
+ def _displayed_sessions(self) -> list[Session]:
625
+ """Get currently displayed sessions (for backward compatibility)."""
626
+ try:
627
+ return self.query_one(ResultsTable).displayed_sessions
628
+ except Exception:
629
+ return []