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.
@@ -0,0 +1,178 @@
1
+ """Results table widget for displaying sessions."""
2
+
3
+ from datetime import datetime
4
+
5
+ from rich.text import Text
6
+ from textual.message import Message
7
+ from textual.widgets import DataTable
8
+
9
+ from ..adapters.base import Session
10
+ from .utils import (
11
+ format_directory,
12
+ format_time_ago,
13
+ get_age_color,
14
+ get_agent_icon,
15
+ highlight_matches,
16
+ )
17
+
18
+
19
+ # Column width breakpoints: (min_width, agent, dir, msgs, date)
20
+ _COL_WIDTHS = [
21
+ (120, 12, 30, 6, 18), # Wide
22
+ (90, 12, 22, 5, 15), # Medium
23
+ (60, 12, 16, 5, 12), # Narrow
24
+ (0, 11, 0, 4, 10), # Very narrow (hide directory)
25
+ ]
26
+
27
+
28
+ class ResultsTable(DataTable):
29
+ """A data table for displaying session results.
30
+
31
+ Handles responsive column sizing and session rendering.
32
+ Emits ResultsTable.Selected when a row is highlighted.
33
+ """
34
+
35
+ class Selected(Message):
36
+ """Posted when a session is selected."""
37
+
38
+ def __init__(self, session: Session | None) -> None:
39
+ self.session = session
40
+ super().__init__()
41
+
42
+ def __init__(self, id: str | None = None) -> None:
43
+ super().__init__(
44
+ id=id,
45
+ cursor_type="row",
46
+ cursor_background_priority="renderable",
47
+ cursor_foreground_priority="renderable",
48
+ )
49
+ self._displayed_sessions: list[Session] = []
50
+ self._title_width: int = 60
51
+ self._dir_width: int = 22
52
+ self._current_query: str = ""
53
+
54
+ def on_mount(self) -> None:
55
+ """Set up table columns on mount."""
56
+ (
57
+ self._col_agent,
58
+ self._col_title,
59
+ self._col_dir,
60
+ self._col_msgs,
61
+ self._col_date,
62
+ ) = self.add_columns("Agent", "Title", "Directory", "Turns", "Date")
63
+ self._update_responsive_widths()
64
+
65
+ def on_resize(self) -> None:
66
+ """Handle resize events."""
67
+ if hasattr(self, "_col_agent"):
68
+ self._update_responsive_widths()
69
+ if self._displayed_sessions:
70
+ self._render_sessions()
71
+
72
+ def _update_responsive_widths(self) -> None:
73
+ """Update column widths based on container size."""
74
+ width = self.size.width
75
+ if width == 0:
76
+ # Not yet laid out, use reasonable defaults
77
+ width = 120
78
+
79
+ # Find appropriate breakpoint
80
+ agent_w, dir_w, msgs_w, date_w = next(
81
+ (a, d, m, t) for min_w, a, d, m, t in _COL_WIDTHS if width >= min_w
82
+ )
83
+ title_w = max(15, width - agent_w - dir_w - msgs_w - date_w - 8)
84
+
85
+ for col in self.columns.values():
86
+ col.auto_width = False
87
+ self.columns[self._col_agent].width = agent_w
88
+ self.columns[self._col_title].width = title_w
89
+ self.columns[self._col_dir].width = dir_w
90
+ self.columns[self._col_msgs].width = msgs_w
91
+ self.columns[self._col_date].width = date_w
92
+
93
+ self._title_width, self._dir_width = title_w, dir_w
94
+ self.refresh()
95
+
96
+ def update_sessions(
97
+ self, sessions: list[Session], query: str = ""
98
+ ) -> Session | None:
99
+ """Update the table with new sessions.
100
+
101
+ Args:
102
+ sessions: List of sessions to display.
103
+ query: Current search query for highlighting matches.
104
+
105
+ Returns:
106
+ The selected session (first one), or None if no sessions.
107
+ """
108
+ self._displayed_sessions = sessions
109
+ self._current_query = query
110
+ self._render_sessions()
111
+
112
+ if sessions:
113
+ self.move_cursor(row=0)
114
+ return sessions[0]
115
+ return None
116
+
117
+ def _render_sessions(self) -> None:
118
+ """Render sessions to the table."""
119
+ self.clear()
120
+
121
+ if not self._displayed_sessions:
122
+ self.add_row(
123
+ "",
124
+ Text("No sessions found", style="dim italic"),
125
+ "",
126
+ "",
127
+ "",
128
+ )
129
+ return
130
+
131
+ for session in self._displayed_sessions:
132
+ # Get agent icon (image or text fallback)
133
+ icon = get_agent_icon(session.agent)
134
+
135
+ # Title - truncate and highlight matches
136
+ title = highlight_matches(
137
+ session.title, self._current_query, max_len=self._title_width
138
+ )
139
+
140
+ # Format directory - truncate based on column width
141
+ dir_w = self._dir_width
142
+ directory = format_directory(session.directory)
143
+ if dir_w > 0 and len(directory) > dir_w:
144
+ directory = "..." + directory[-(dir_w - 3) :]
145
+ dir_text = (
146
+ highlight_matches(directory, self._current_query)
147
+ if dir_w > 0
148
+ else Text("")
149
+ )
150
+
151
+ # Format message count
152
+ msgs_text = str(session.message_count) if session.message_count > 0 else "-"
153
+
154
+ # Format time with age-based gradient coloring
155
+ time_ago = format_time_ago(session.timestamp)
156
+ time_text = Text(time_ago.rjust(8))
157
+ age_hours = (datetime.now() - session.timestamp).total_seconds() / 3600
158
+ time_text.stylize(get_age_color(age_hours))
159
+
160
+ self.add_row(icon, title, dir_text, msgs_text, time_text)
161
+
162
+ def get_selected_session(self) -> Session | None:
163
+ """Get the currently selected session."""
164
+ if self.cursor_row is not None and self.cursor_row < len(
165
+ self._displayed_sessions
166
+ ):
167
+ return self._displayed_sessions[self.cursor_row]
168
+ return None
169
+
170
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
171
+ """Handle row highlight and emit Selected message."""
172
+ session = self.get_selected_session()
173
+ self.post_message(self.Selected(session))
174
+
175
+ @property
176
+ def displayed_sessions(self) -> list[Session]:
177
+ """Get the list of displayed sessions."""
178
+ return self._displayed_sessions
@@ -0,0 +1,117 @@
1
+ """Search input components: highlighter and suggester for keyword syntax."""
2
+
3
+ import re
4
+
5
+ from rich.highlighter import Highlighter
6
+ from rich.text import Text
7
+ from textual.suggester import Suggester
8
+
9
+ from ..config import AGENTS
10
+ from .query import KEYWORD_PATTERN, is_valid_filter_value
11
+
12
+
13
+ class KeywordHighlighter(Highlighter):
14
+ """Highlighter for search keyword syntax (agent:, dir:, date:).
15
+
16
+ Applies Rich styles directly to keyword prefixes and their values.
17
+ Supports negation with - prefix or ! in value.
18
+ Invalid values are shown in red with strikethrough.
19
+ """
20
+
21
+ def highlight(self, text: Text) -> None:
22
+ """Apply highlighting to keyword syntax in the text."""
23
+ plain = text.plain
24
+ for match in KEYWORD_PATTERN.finditer(plain):
25
+ neg_prefix = match.group(1)
26
+ keyword = match.group(2)
27
+ value = match.group(3)
28
+
29
+ is_valid = is_valid_filter_value(keyword, value)
30
+
31
+ # Style the negation prefix in red
32
+ if neg_prefix:
33
+ text.stylize("bold red", match.start(1), match.end(1))
34
+
35
+ # Style the keyword prefix
36
+ if is_valid:
37
+ text.stylize("bold cyan", match.start(2), match.end(2))
38
+ else:
39
+ text.stylize("bold red", match.start(2), match.end(2))
40
+
41
+ # Style the value
42
+ if not is_valid:
43
+ text.stylize("red strike", match.start(3), match.end(3))
44
+ elif value.startswith("!"):
45
+ text.stylize("bold red", match.start(3), match.start(3) + 1)
46
+ text.stylize("green", match.start(3) + 1, match.end(3))
47
+ else:
48
+ text.stylize("green", match.start(3), match.end(3))
49
+
50
+
51
+ # Pattern to match partial keyword at end of input for autocomplete
52
+ _PARTIAL_KEYWORD_PATTERN = re.compile(
53
+ r"(-?)(agent:|dir:|date:)([^\s]*)$" # Keyword at end, possibly partial value
54
+ )
55
+
56
+ # Known values for each keyword type (agent values derived from AGENTS config)
57
+ _KEYWORD_VALUES = {
58
+ "agent:": list(AGENTS.keys()),
59
+ "date:": ["today", "yesterday", "week", "month"],
60
+ # dir: has no predefined values (user-specific paths)
61
+ }
62
+
63
+
64
+ class KeywordSuggester(Suggester):
65
+ """Suggester for keyword value autocomplete.
66
+
67
+ Provides completions for:
68
+ - agent: values (claude, codex, etc.)
69
+ - date: values (today, yesterday, week, month)
70
+ """
71
+
72
+ def __init__(self) -> None:
73
+ super().__init__(use_cache=True, case_sensitive=False)
74
+
75
+ async def get_suggestion(self, value: str) -> str | None:
76
+ """Get completion suggestion for the current input.
77
+
78
+ Args:
79
+ value: Current input text (casefolded if case_sensitive=False)
80
+
81
+ Returns:
82
+ Complete input with suggested value, or None if no suggestion.
83
+ """
84
+ # Find partial keyword at end of input
85
+ match = _PARTIAL_KEYWORD_PATTERN.search(value)
86
+ if not match:
87
+ return None
88
+
89
+ keyword = match.group(2) # agent:, dir:, date:
90
+ partial = match.group(3) # Partial value typed so far
91
+
92
+ # Get known values for this keyword
93
+ known_values = _KEYWORD_VALUES.get(keyword)
94
+ if not known_values:
95
+ return None
96
+
97
+ # Don't suggest if value is empty (user just typed "agent:")
98
+ if not partial:
99
+ return None
100
+
101
+ # Handle ! prefix on partial value
102
+ negated_value = partial.startswith("!")
103
+ search_partial = partial[1:] if negated_value else partial
104
+
105
+ # Find first matching value (but not exact match - already complete)
106
+ for candidate in known_values:
107
+ if candidate.lower().startswith(search_partial.lower()):
108
+ # Skip if already complete (no suggestion needed)
109
+ if candidate.lower() == search_partial.lower():
110
+ continue
111
+ # Build the suggestion
112
+ suggested_value = f"!{candidate}" if negated_value else candidate
113
+ # Replace partial with full value
114
+ suggestion = value[: match.start(3)] + suggested_value
115
+ return suggestion
116
+
117
+ return None
@@ -0,0 +1,302 @@
1
+ """CSS styles for the TUI components."""
2
+
3
+ # CSS for the YoloModeModal
4
+ YOLO_MODAL_CSS = """
5
+ YoloModeModal {
6
+ align: center middle;
7
+ background: rgba(0, 0, 0, 0.6);
8
+ }
9
+
10
+ YoloModeModal > Vertical {
11
+ width: 36;
12
+ height: auto;
13
+ background: $surface;
14
+ border: thick $primary 80%;
15
+ padding: 1 2;
16
+ }
17
+
18
+ YoloModeModal #title {
19
+ text-align: center;
20
+ text-style: bold;
21
+ width: 100%;
22
+ }
23
+
24
+ YoloModeModal #buttons {
25
+ width: 100%;
26
+ height: auto;
27
+ align: center middle;
28
+ margin-top: 1;
29
+ }
30
+
31
+ YoloModeModal Button {
32
+ margin: 0 1;
33
+ min-width: 10;
34
+ }
35
+
36
+ YoloModeModal Button:focus {
37
+ background: $warning;
38
+ }
39
+ """
40
+
41
+ # CSS for the main FastResumeApp
42
+ APP_CSS = """
43
+ Screen {
44
+ layout: vertical;
45
+ width: 100%;
46
+ background: $surface;
47
+ }
48
+
49
+ /* Title bar - branding + session count */
50
+ #title-bar {
51
+ height: 1;
52
+ width: 100%;
53
+ padding: 0 1;
54
+ background: $surface;
55
+ }
56
+
57
+ #app-title {
58
+ width: 1fr;
59
+ color: $text;
60
+ text-style: bold;
61
+ }
62
+
63
+ #session-count {
64
+ dock: right;
65
+ color: $text-muted;
66
+ width: auto;
67
+ }
68
+
69
+ /* Search row */
70
+ #search-row {
71
+ height: 3;
72
+ width: 100%;
73
+ padding: 0 1;
74
+ }
75
+
76
+ #search-box {
77
+ width: 100%;
78
+ height: 3;
79
+ border: solid $primary-background-lighten-2;
80
+ background: $surface;
81
+ padding: 0 1;
82
+ }
83
+
84
+ #search-box:focus-within {
85
+ border: solid $accent;
86
+ }
87
+
88
+ #search-icon {
89
+ width: 3;
90
+ color: $text-muted;
91
+ content-align: center middle;
92
+ }
93
+
94
+ #search-input {
95
+ width: 1fr;
96
+ border: none;
97
+ background: transparent;
98
+ }
99
+
100
+ #search-input:focus {
101
+ border: none;
102
+ }
103
+
104
+ /* Agent filter tabs - pill style */
105
+ #filter-container {
106
+ height: 1;
107
+ width: 100%;
108
+ padding: 0 1;
109
+ margin-bottom: 1;
110
+ }
111
+
112
+ .filter-btn {
113
+ width: auto;
114
+ height: 1;
115
+ margin: 0 1 0 0;
116
+ padding: 0 1;
117
+ border: none;
118
+ background: transparent;
119
+ color: $text-muted;
120
+ }
121
+
122
+ .filter-btn:hover {
123
+ color: $text;
124
+ }
125
+
126
+ .filter-btn:focus {
127
+ text-style: none;
128
+ }
129
+
130
+ .filter-btn.-active {
131
+ background: $accent 20%;
132
+ color: $accent;
133
+ }
134
+
135
+ .filter-icon {
136
+ width: 2;
137
+ height: 1;
138
+ margin-right: 1;
139
+ }
140
+
141
+ .filter-label {
142
+ height: 1;
143
+ }
144
+
145
+ .filter-btn.-active .filter-label {
146
+ text-style: bold;
147
+ }
148
+
149
+ /* Agent-specific filter colors */
150
+ #filter-claude {
151
+ color: #E87B35;
152
+ }
153
+ #filter-claude.-active {
154
+ background: #E87B35 20%;
155
+ color: #E87B35;
156
+ }
157
+
158
+ #filter-codex {
159
+ color: #00A67E;
160
+ }
161
+ #filter-codex.-active {
162
+ background: #00A67E 20%;
163
+ color: #00A67E;
164
+ }
165
+
166
+ #filter-copilot-cli {
167
+ color: #9CA3AF;
168
+ }
169
+ #filter-copilot-cli.-active {
170
+ background: #9CA3AF 20%;
171
+ color: #9CA3AF;
172
+ }
173
+
174
+ #filter-copilot-vscode {
175
+ color: #007ACC;
176
+ }
177
+ #filter-copilot-vscode.-active {
178
+ background: #007ACC 20%;
179
+ color: #007ACC;
180
+ }
181
+
182
+ #filter-crush {
183
+ color: #FF5F87;
184
+ }
185
+ #filter-crush.-active {
186
+ background: #FF5F87 20%;
187
+ color: #FF5F87;
188
+ }
189
+
190
+ #filter-opencode {
191
+ color: #6366F1;
192
+ }
193
+ #filter-opencode.-active {
194
+ background: #6366F1 20%;
195
+ color: #6366F1;
196
+ }
197
+
198
+ #filter-vibe {
199
+ color: #FF6B35;
200
+ }
201
+ #filter-vibe.-active {
202
+ background: #FF6B35 20%;
203
+ color: #FF6B35;
204
+ }
205
+
206
+ /* Main content area */
207
+ #main-container {
208
+ height: 1fr;
209
+ width: 100%;
210
+ }
211
+
212
+ #results-container {
213
+ height: 1fr;
214
+ width: 100%;
215
+ overflow-x: hidden;
216
+ }
217
+
218
+ #results-table {
219
+ height: 100%;
220
+ width: 100%;
221
+ overflow-x: hidden;
222
+ }
223
+
224
+ DataTable {
225
+ background: transparent;
226
+ overflow-x: hidden;
227
+ }
228
+
229
+ DataTable > .datatable--header {
230
+ text-style: bold;
231
+ color: $text;
232
+ }
233
+
234
+ DataTable > .datatable--cursor {
235
+ background: $accent 30%;
236
+ }
237
+
238
+ DataTable > .datatable--hover {
239
+ background: $surface-lighten-1;
240
+ }
241
+
242
+ /* Preview pane - expanded */
243
+ #preview-container {
244
+ height: 12;
245
+ border-top: solid $accent 50%;
246
+ background: $surface;
247
+ padding: 0 1;
248
+ }
249
+
250
+ #preview-container.hidden {
251
+ display: none;
252
+ }
253
+
254
+ #preview {
255
+ height: auto;
256
+ }
257
+
258
+ /* Agent colors */
259
+ .agent-claude {
260
+ color: #E87B35;
261
+ }
262
+
263
+ .agent-codex {
264
+ color: #00A67E;
265
+ }
266
+
267
+ .agent-copilot {
268
+ color: #9CA3AF;
269
+ }
270
+
271
+ .agent-opencode {
272
+ color: #6366F1;
273
+ }
274
+
275
+ .agent-vibe {
276
+ color: #FF6B35;
277
+ }
278
+
279
+ .agent-crush {
280
+ color: #FF5F87;
281
+ }
282
+
283
+ /* Footer styling */
284
+ Footer {
285
+ background: $primary-background;
286
+ }
287
+
288
+ Footer > .footer--key {
289
+ background: $surface;
290
+ color: $text;
291
+ }
292
+
293
+ Footer > .footer--description {
294
+ color: $text-muted;
295
+ }
296
+
297
+ #query-time {
298
+ width: auto;
299
+ padding: 0 1;
300
+ color: $text-muted;
301
+ }
302
+ """