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/__init__.py +5 -0
- fast_resume/adapters/__init__.py +25 -0
- fast_resume/adapters/base.py +263 -0
- fast_resume/adapters/claude.py +209 -0
- fast_resume/adapters/codex.py +216 -0
- fast_resume/adapters/copilot.py +176 -0
- fast_resume/adapters/copilot_vscode.py +326 -0
- fast_resume/adapters/crush.py +341 -0
- fast_resume/adapters/opencode.py +333 -0
- fast_resume/adapters/vibe.py +188 -0
- fast_resume/assets/claude.png +0 -0
- fast_resume/assets/codex.png +0 -0
- fast_resume/assets/copilot-cli.png +0 -0
- fast_resume/assets/copilot-vscode.png +0 -0
- fast_resume/assets/crush.png +0 -0
- fast_resume/assets/opencode.png +0 -0
- fast_resume/assets/vibe.png +0 -0
- fast_resume/cli.py +327 -0
- fast_resume/config.py +30 -0
- fast_resume/index.py +758 -0
- fast_resume/logging_config.py +57 -0
- fast_resume/query.py +264 -0
- fast_resume/search.py +281 -0
- fast_resume/tui/__init__.py +58 -0
- fast_resume/tui/app.py +629 -0
- fast_resume/tui/filter_bar.py +128 -0
- fast_resume/tui/modal.py +73 -0
- fast_resume/tui/preview.py +396 -0
- fast_resume/tui/query.py +86 -0
- fast_resume/tui/results_table.py +178 -0
- fast_resume/tui/search_input.py +117 -0
- fast_resume/tui/styles.py +302 -0
- fast_resume/tui/utils.py +160 -0
- fast_resume-1.12.8.dist-info/METADATA +545 -0
- fast_resume-1.12.8.dist-info/RECORD +38 -0
- fast_resume-1.12.8.dist-info/WHEEL +4 -0
- fast_resume-1.12.8.dist-info/entry_points.txt +3 -0
- fast_resume-1.12.8.dist-info/licenses/LICENSE +21 -0
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 []
|