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/cli.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""CLI entry point for fast-resume."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import humanize
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from .config import AGENTS, INDEX_DIR
|
|
11
|
+
from .index import TantivyIndex
|
|
12
|
+
from .logging_config import setup_logging
|
|
13
|
+
from .search import SessionSearch
|
|
14
|
+
from .tui import run_tui
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command()
|
|
18
|
+
@click.argument("query", required=False, default="")
|
|
19
|
+
@click.option(
|
|
20
|
+
"-a",
|
|
21
|
+
"--agent",
|
|
22
|
+
type=click.Choice(
|
|
23
|
+
[
|
|
24
|
+
"claude",
|
|
25
|
+
"codex",
|
|
26
|
+
"copilot-cli",
|
|
27
|
+
"copilot-vscode",
|
|
28
|
+
"crush",
|
|
29
|
+
"opencode",
|
|
30
|
+
"vibe",
|
|
31
|
+
]
|
|
32
|
+
),
|
|
33
|
+
help="Filter by agent",
|
|
34
|
+
)
|
|
35
|
+
@click.option("-d", "--directory", help="Filter by directory (substring match)")
|
|
36
|
+
@click.option("--no-tui", is_flag=True, help="Output list to stdout instead of TUI")
|
|
37
|
+
@click.option(
|
|
38
|
+
"--list", "list_only", is_flag=True, help="Just list sessions, don't resume"
|
|
39
|
+
)
|
|
40
|
+
@click.option("--rebuild", is_flag=True, help="Force rebuild the session index")
|
|
41
|
+
@click.option("--stats", is_flag=True, help="Show index statistics")
|
|
42
|
+
@click.option(
|
|
43
|
+
"--yolo",
|
|
44
|
+
is_flag=True,
|
|
45
|
+
help="Resume sessions with auto-approve/skip-permissions flags",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--no-version-check",
|
|
49
|
+
is_flag=True,
|
|
50
|
+
help="Disable checking for new versions",
|
|
51
|
+
)
|
|
52
|
+
@click.version_option()
|
|
53
|
+
def main(
|
|
54
|
+
query: str,
|
|
55
|
+
agent: str | None,
|
|
56
|
+
directory: str | None,
|
|
57
|
+
no_tui: bool,
|
|
58
|
+
list_only: bool,
|
|
59
|
+
rebuild: bool,
|
|
60
|
+
stats: bool,
|
|
61
|
+
yolo: bool,
|
|
62
|
+
no_version_check: bool,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Fast fuzzy finder for coding agent session history.
|
|
65
|
+
|
|
66
|
+
Search across Claude Code, Codex CLI, Copilot CLI, Crush, OpenCode, and Vibe sessions.
|
|
67
|
+
Select a session to resume it with the appropriate agent.
|
|
68
|
+
|
|
69
|
+
Supports keyword search syntax:
|
|
70
|
+
|
|
71
|
+
agent:NAME Filter by agent (e.g., agent:claude)
|
|
72
|
+
|
|
73
|
+
agent:A,B Multiple values with OR (e.g., agent:claude,codex)
|
|
74
|
+
|
|
75
|
+
-agent:NAME Exclude agent (or agent:!NAME)
|
|
76
|
+
|
|
77
|
+
dir:PATH Filter by directory substring
|
|
78
|
+
|
|
79
|
+
date:VALUE Filter by date/time (today, <1h, >1d, etc.)
|
|
80
|
+
|
|
81
|
+
Examples:
|
|
82
|
+
|
|
83
|
+
fr agent:claude,codex api # Claude OR Codex sessions
|
|
84
|
+
|
|
85
|
+
fr -agent:vibe # Exclude Vibe sessions
|
|
86
|
+
|
|
87
|
+
fr date:<1d -agent:claude # Last 24h, not Claude
|
|
88
|
+
|
|
89
|
+
fr dir:project date:today # Today's sessions in project
|
|
90
|
+
"""
|
|
91
|
+
# Initialize logging for parse errors
|
|
92
|
+
setup_logging()
|
|
93
|
+
|
|
94
|
+
if stats:
|
|
95
|
+
# Sync before showing stats to ensure accurate data
|
|
96
|
+
search = SessionSearch()
|
|
97
|
+
search.get_all_sessions()
|
|
98
|
+
_show_stats()
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if rebuild:
|
|
102
|
+
# Force rebuild index
|
|
103
|
+
search = SessionSearch()
|
|
104
|
+
search.get_all_sessions(force_refresh=True)
|
|
105
|
+
click.echo("Index rebuilt.")
|
|
106
|
+
if not (no_tui or list_only or query):
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
if no_tui or list_only:
|
|
110
|
+
_list_sessions(query, agent, directory)
|
|
111
|
+
else:
|
|
112
|
+
resume_cmd, resume_dir = run_tui(
|
|
113
|
+
query=query,
|
|
114
|
+
agent_filter=agent,
|
|
115
|
+
yolo=yolo,
|
|
116
|
+
no_version_check=no_version_check,
|
|
117
|
+
)
|
|
118
|
+
if resume_cmd:
|
|
119
|
+
# Change to session directory before running command
|
|
120
|
+
if resume_dir:
|
|
121
|
+
os.chdir(resume_dir)
|
|
122
|
+
# Execute the resume command
|
|
123
|
+
os.execvp(resume_cmd[0], resume_cmd)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _show_stats() -> None:
|
|
127
|
+
"""Display index statistics."""
|
|
128
|
+
console = Console()
|
|
129
|
+
index = TantivyIndex()
|
|
130
|
+
stats = index.get_stats()
|
|
131
|
+
|
|
132
|
+
if stats.total_sessions == 0:
|
|
133
|
+
console.print(
|
|
134
|
+
"[dim]No sessions indexed yet. Run [bold]fr[/bold] to index sessions.[/dim]"
|
|
135
|
+
)
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Header
|
|
139
|
+
console.print("\n[bold]Index Statistics[/bold]\n")
|
|
140
|
+
|
|
141
|
+
# Overview table
|
|
142
|
+
overview = Table(show_header=False, box=None, padding=(0, 2))
|
|
143
|
+
overview.add_column("Label", style="dim")
|
|
144
|
+
overview.add_column("Value")
|
|
145
|
+
|
|
146
|
+
overview.add_row("Total sessions", f"[bold]{stats.total_sessions}[/bold]")
|
|
147
|
+
overview.add_row("Total messages", f"{stats.total_messages:,}")
|
|
148
|
+
overview.add_row("Avg messages/session", f"{stats.avg_messages_per_session:.1f}")
|
|
149
|
+
overview.add_row("Index size", humanize.naturalsize(stats.index_size_bytes))
|
|
150
|
+
overview.add_row("Index location", str(INDEX_DIR))
|
|
151
|
+
|
|
152
|
+
if stats.oldest_session and stats.newest_session:
|
|
153
|
+
date_range = (
|
|
154
|
+
f"{stats.oldest_session:%Y-%m-%d} to {stats.newest_session:%Y-%m-%d}"
|
|
155
|
+
)
|
|
156
|
+
overview.add_row("Date range", date_range)
|
|
157
|
+
|
|
158
|
+
console.print(overview)
|
|
159
|
+
|
|
160
|
+
# Data by agent (raw + indexed)
|
|
161
|
+
console.print("\n[bold]Data by Agent[/bold]\n")
|
|
162
|
+
search = SessionSearch()
|
|
163
|
+
agent_table = Table(show_header=True, header_style="bold")
|
|
164
|
+
agent_table.add_column("Agent", no_wrap=True)
|
|
165
|
+
agent_table.add_column("Files", justify="right")
|
|
166
|
+
agent_table.add_column("Disk", justify="right")
|
|
167
|
+
agent_table.add_column("Sessions", justify="right")
|
|
168
|
+
agent_table.add_column("Messages", justify="right")
|
|
169
|
+
agent_table.add_column("Content", justify="right")
|
|
170
|
+
agent_table.add_column("Data Directory")
|
|
171
|
+
|
|
172
|
+
messages_by_agent = stats.messages_by_agent or {}
|
|
173
|
+
content_chars_by_agent = stats.content_chars_by_agent or {}
|
|
174
|
+
|
|
175
|
+
# Collect data for all agents and sort by indexed session count
|
|
176
|
+
agent_data = []
|
|
177
|
+
for adapter in search.adapters:
|
|
178
|
+
raw_stats = adapter.get_raw_stats()
|
|
179
|
+
sessions = stats.sessions_by_agent.get(adapter.name, 0)
|
|
180
|
+
agent_data.append((adapter.name, raw_stats, sessions))
|
|
181
|
+
|
|
182
|
+
# Sort by session count descending
|
|
183
|
+
agent_data.sort(key=lambda x: -x[2])
|
|
184
|
+
|
|
185
|
+
for agent_name, raw_stats, sessions in agent_data:
|
|
186
|
+
agent_config = AGENTS.get(agent_name, {"color": "white"})
|
|
187
|
+
color = agent_config["color"]
|
|
188
|
+
messages = messages_by_agent.get(agent_name, 0)
|
|
189
|
+
content_size = content_chars_by_agent.get(agent_name, 0)
|
|
190
|
+
|
|
191
|
+
if raw_stats.available:
|
|
192
|
+
# Shorten home directory in path
|
|
193
|
+
data_dir = raw_stats.data_dir
|
|
194
|
+
home = os.path.expanduser("~")
|
|
195
|
+
if data_dir.startswith(home):
|
|
196
|
+
data_dir = "~" + data_dir[len(home) :]
|
|
197
|
+
|
|
198
|
+
agent_table.add_row(
|
|
199
|
+
f"[{color}]{agent_name}[/{color}]",
|
|
200
|
+
str(raw_stats.file_count),
|
|
201
|
+
humanize.naturalsize(raw_stats.total_bytes),
|
|
202
|
+
str(sessions) if sessions > 0 else "[dim]0[/dim]",
|
|
203
|
+
f"{messages:,}" if messages > 0 else "[dim]0[/dim]",
|
|
204
|
+
humanize.naturalsize(content_size)
|
|
205
|
+
if content_size > 0
|
|
206
|
+
else "[dim]-[/dim]",
|
|
207
|
+
f"[dim]{data_dir}[/dim]",
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
agent_table.add_row(
|
|
211
|
+
f"[{color}]{agent_name}[/{color}]",
|
|
212
|
+
"[dim]-[/dim]",
|
|
213
|
+
"[dim]-[/dim]",
|
|
214
|
+
"[dim]-[/dim]",
|
|
215
|
+
"[dim]-[/dim]",
|
|
216
|
+
"[dim]-[/dim]",
|
|
217
|
+
"[dim]not found[/dim]",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
console.print(agent_table)
|
|
221
|
+
|
|
222
|
+
# Activity by day of week
|
|
223
|
+
if stats.sessions_by_weekday:
|
|
224
|
+
console.print("\n[bold]Activity by Day[/bold]\n")
|
|
225
|
+
day_table = Table(show_header=False, box=None, padding=(0, 1))
|
|
226
|
+
day_table.add_column("Day", style="dim", width=4)
|
|
227
|
+
day_table.add_column("Bar")
|
|
228
|
+
day_table.add_column("Count", justify="right", width=4)
|
|
229
|
+
|
|
230
|
+
max_day = (
|
|
231
|
+
max(stats.sessions_by_weekday.values()) if stats.sessions_by_weekday else 1
|
|
232
|
+
)
|
|
233
|
+
for day in ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]:
|
|
234
|
+
count = stats.sessions_by_weekday.get(day, 0)
|
|
235
|
+
bar_width = int((count / max_day) * 20) if max_day else 0
|
|
236
|
+
bar = "[green]" + "█" * bar_width + "[/green]"
|
|
237
|
+
day_table.add_row(day, bar, str(count))
|
|
238
|
+
|
|
239
|
+
console.print(day_table)
|
|
240
|
+
|
|
241
|
+
# Activity by hour
|
|
242
|
+
if stats.sessions_by_hour:
|
|
243
|
+
console.print("\n[bold]Activity by Hour[/bold]\n")
|
|
244
|
+
# Show as a compact sparkline-style display
|
|
245
|
+
max_hour = max(stats.sessions_by_hour.values()) if stats.sessions_by_hour else 1
|
|
246
|
+
blocks = " ▁▂▃▄▅▆▇█"
|
|
247
|
+
|
|
248
|
+
hour_line = ""
|
|
249
|
+
for h in range(24):
|
|
250
|
+
count = stats.sessions_by_hour.get(h, 0)
|
|
251
|
+
idx = int((count / max_hour) * 8) if max_hour else 0
|
|
252
|
+
hour_line += blocks[idx]
|
|
253
|
+
|
|
254
|
+
console.print(f" [dim]0h[/dim] [yellow]{hour_line}[/yellow] [dim]23h[/dim]")
|
|
255
|
+
|
|
256
|
+
# Find peak hours
|
|
257
|
+
sorted_hours = sorted(stats.sessions_by_hour.items(), key=lambda x: -x[1])
|
|
258
|
+
if sorted_hours:
|
|
259
|
+
top_hours = sorted_hours[:3]
|
|
260
|
+
peak_str = ", ".join(f"{h}:00 ({c})" for h, c in top_hours)
|
|
261
|
+
console.print(f" [dim]Peak hours: {peak_str}[/dim]")
|
|
262
|
+
|
|
263
|
+
# Top directories
|
|
264
|
+
if stats.top_directories:
|
|
265
|
+
console.print("\n[bold]Top Directories[/bold]\n")
|
|
266
|
+
dir_table = Table(show_header=True, header_style="bold")
|
|
267
|
+
dir_table.add_column("Directory")
|
|
268
|
+
dir_table.add_column("Sessions", justify="right")
|
|
269
|
+
dir_table.add_column("Messages", justify="right")
|
|
270
|
+
|
|
271
|
+
home = os.path.expanduser("~")
|
|
272
|
+
for directory, sessions, messages in stats.top_directories[:10]:
|
|
273
|
+
display_dir = directory
|
|
274
|
+
if display_dir.startswith(home):
|
|
275
|
+
display_dir = "~" + display_dir[len(home) :]
|
|
276
|
+
dir_table.add_row(display_dir, str(sessions), f"{messages:,}")
|
|
277
|
+
|
|
278
|
+
console.print(dir_table)
|
|
279
|
+
|
|
280
|
+
console.print()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _list_sessions(query: str, agent: str | None, directory: str | None) -> None:
|
|
284
|
+
"""List sessions in terminal without TUI."""
|
|
285
|
+
console = Console()
|
|
286
|
+
search = SessionSearch()
|
|
287
|
+
|
|
288
|
+
sessions = search.search(query, agent_filter=agent, directory_filter=directory)
|
|
289
|
+
|
|
290
|
+
if not sessions:
|
|
291
|
+
console.print("[dim]No sessions found.[/dim]")
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
table = Table(show_header=True, header_style="bold")
|
|
295
|
+
table.add_column("Agent", style="bold")
|
|
296
|
+
table.add_column("Title")
|
|
297
|
+
table.add_column("Directory", style="dim")
|
|
298
|
+
table.add_column("ID", style="dim")
|
|
299
|
+
|
|
300
|
+
for session in sessions[:50]: # Limit output
|
|
301
|
+
agent_config = AGENTS.get(session.agent, {"color": "white"})
|
|
302
|
+
agent_style = agent_config["color"]
|
|
303
|
+
|
|
304
|
+
# Truncate fields
|
|
305
|
+
title = session.title[:50] + "..." if len(session.title) > 50 else session.title
|
|
306
|
+
directory_display = session.directory
|
|
307
|
+
home = os.path.expanduser("~")
|
|
308
|
+
if directory_display.startswith(home):
|
|
309
|
+
directory_display = "~" + directory_display[len(home) :]
|
|
310
|
+
if len(directory_display) > 35:
|
|
311
|
+
directory_display = "..." + directory_display[-32:]
|
|
312
|
+
|
|
313
|
+
table.add_row(
|
|
314
|
+
f"[{agent_style}]{session.agent}[/{agent_style}]",
|
|
315
|
+
title,
|
|
316
|
+
directory_display,
|
|
317
|
+
session.id[:20] + "..." if len(session.id) > 20 else session.id,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
console.print(table)
|
|
321
|
+
console.print(
|
|
322
|
+
f"\n[dim]Showing {min(len(sessions), 50)} of {len(sessions)} sessions[/dim]"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
main()
|
fast_resume/config.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Configuration and constants for fast-resume."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Agent colors and badges (badge is the display name shown in UI)
|
|
6
|
+
AGENTS = {
|
|
7
|
+
"claude": {"color": "#E87B35", "badge": "claude"},
|
|
8
|
+
"codex": {"color": "#00A67E", "badge": "codex"},
|
|
9
|
+
"opencode": {"color": "#CFCECD", "badge": "opencode"},
|
|
10
|
+
"vibe": {"color": "#FF6B35", "badge": "vibe"},
|
|
11
|
+
"crush": {"color": "#6B51FF", "badge": "crush"},
|
|
12
|
+
"copilot-cli": {"color": "#9CA3AF", "badge": "copilot"},
|
|
13
|
+
"copilot-vscode": {"color": "#007ACC", "badge": "vscode"},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# Storage paths
|
|
17
|
+
CLAUDE_DIR = Path.home() / ".claude" / "projects"
|
|
18
|
+
CODEX_DIR = Path.home() / ".codex" / "sessions"
|
|
19
|
+
OPENCODE_DIR = Path.home() / ".local" / "share" / "opencode" / "storage"
|
|
20
|
+
VIBE_DIR = Path.home() / ".vibe" / "logs" / "session"
|
|
21
|
+
CRUSH_PROJECTS_FILE = Path.home() / ".local" / "share" / "crush" / "projects.json"
|
|
22
|
+
COPILOT_DIR = Path.home() / ".copilot" / "session-state"
|
|
23
|
+
|
|
24
|
+
# Storage location
|
|
25
|
+
CACHE_DIR = Path.home() / ".cache" / "fast-resume"
|
|
26
|
+
INDEX_DIR = CACHE_DIR / "tantivy_index"
|
|
27
|
+
LOG_FILE = CACHE_DIR / "parse-errors.log"
|
|
28
|
+
SCHEMA_VERSION = (
|
|
29
|
+
19 # Bump when schema changes (19: indexed timestamp for range queries)
|
|
30
|
+
)
|