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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Filter bar widget for agent selection."""
|
|
2
|
+
|
|
3
|
+
from textual.containers import Horizontal
|
|
4
|
+
from textual.events import Click
|
|
5
|
+
from textual.message import Message
|
|
6
|
+
from textual.reactive import reactive
|
|
7
|
+
from textual.widgets import Label
|
|
8
|
+
from textual_image.widget import Image as ImageWidget
|
|
9
|
+
|
|
10
|
+
from ..config import AGENTS
|
|
11
|
+
from .utils import ASSETS_DIR
|
|
12
|
+
|
|
13
|
+
# Filter keys in display order (None = "All")
|
|
14
|
+
FILTER_KEYS: list[str | None] = [
|
|
15
|
+
None,
|
|
16
|
+
"claude",
|
|
17
|
+
"codex",
|
|
18
|
+
"copilot-cli",
|
|
19
|
+
"copilot-vscode",
|
|
20
|
+
"crush",
|
|
21
|
+
"opencode",
|
|
22
|
+
"vibe",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# Map button IDs to filter values
|
|
26
|
+
_FILTER_ID_MAP: dict[str, str | None] = {
|
|
27
|
+
f"filter-{key or 'all'}": key for key in FILTER_KEYS
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FilterBar(Horizontal):
|
|
32
|
+
"""A horizontal bar of filter buttons for agent selection.
|
|
33
|
+
|
|
34
|
+
Emits FilterBar.Changed when filter selection changes.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
class Changed(Message):
|
|
38
|
+
"""Posted when the active filter changes."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, filter_key: str | None) -> None:
|
|
41
|
+
self.filter_key = filter_key
|
|
42
|
+
super().__init__()
|
|
43
|
+
|
|
44
|
+
active_filter: reactive[str | None] = reactive(None)
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
initial_filter: str | None = None,
|
|
49
|
+
id: str | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
super().__init__(id=id)
|
|
52
|
+
self._initial_filter = initial_filter
|
|
53
|
+
self._filter_buttons: dict[str | None, Horizontal] = {}
|
|
54
|
+
|
|
55
|
+
def compose(self):
|
|
56
|
+
"""Create filter buttons."""
|
|
57
|
+
for filter_key in FILTER_KEYS:
|
|
58
|
+
filter_label = AGENTS[filter_key]["badge"] if filter_key else "All"
|
|
59
|
+
btn_id = f"filter-{filter_key or 'all'}"
|
|
60
|
+
with Horizontal(id=btn_id, classes="filter-btn") as btn_container:
|
|
61
|
+
if filter_key:
|
|
62
|
+
icon_path = ASSETS_DIR / f"{filter_key}.png"
|
|
63
|
+
if icon_path.exists():
|
|
64
|
+
yield ImageWidget(icon_path, classes="filter-icon")
|
|
65
|
+
yield Label(
|
|
66
|
+
filter_label, classes=f"filter-label agent-{filter_key}"
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
yield Label(filter_label, classes="filter-label")
|
|
70
|
+
self._filter_buttons[filter_key] = btn_container
|
|
71
|
+
|
|
72
|
+
def on_mount(self) -> None:
|
|
73
|
+
"""Initialize active filter state."""
|
|
74
|
+
self.active_filter = self._initial_filter
|
|
75
|
+
self._update_button_styles()
|
|
76
|
+
|
|
77
|
+
def watch_active_filter(self, value: str | None) -> None:
|
|
78
|
+
"""Update button styles when active filter changes."""
|
|
79
|
+
self._update_button_styles()
|
|
80
|
+
|
|
81
|
+
def _update_button_styles(self) -> None:
|
|
82
|
+
"""Update filter button active states."""
|
|
83
|
+
for filter_key, btn in self._filter_buttons.items():
|
|
84
|
+
if filter_key == self.active_filter:
|
|
85
|
+
btn.add_class("-active")
|
|
86
|
+
else:
|
|
87
|
+
btn.remove_class("-active")
|
|
88
|
+
|
|
89
|
+
def set_active(self, filter_key: str | None, notify: bool = False) -> None:
|
|
90
|
+
"""Set the active filter programmatically.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
filter_key: The filter to activate, or None for "All".
|
|
94
|
+
notify: If True, emit a Changed message.
|
|
95
|
+
"""
|
|
96
|
+
if filter_key != self.active_filter:
|
|
97
|
+
self.active_filter = filter_key
|
|
98
|
+
if notify:
|
|
99
|
+
self.post_message(self.Changed(filter_key))
|
|
100
|
+
|
|
101
|
+
def on_click(self, event: Click) -> None:
|
|
102
|
+
"""Handle click on filter buttons."""
|
|
103
|
+
# Walk up to find the filter-btn container (click might be on child widget)
|
|
104
|
+
widget = event.widget
|
|
105
|
+
while widget and widget is not self:
|
|
106
|
+
if hasattr(widget, "classes") and "filter-btn" in widget.classes:
|
|
107
|
+
if widget.id in _FILTER_ID_MAP:
|
|
108
|
+
new_filter = _FILTER_ID_MAP[widget.id]
|
|
109
|
+
if new_filter != self.active_filter:
|
|
110
|
+
self.active_filter = new_filter
|
|
111
|
+
self.post_message(self.Changed(new_filter))
|
|
112
|
+
return
|
|
113
|
+
widget = widget.parent
|
|
114
|
+
|
|
115
|
+
def update_agents_with_sessions(self, agents: set[str]) -> None:
|
|
116
|
+
"""Show only agents that have sessions.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
agents: Set of agent names that have at least one session.
|
|
120
|
+
"""
|
|
121
|
+
for filter_key, btn in self._filter_buttons.items():
|
|
122
|
+
if filter_key is None:
|
|
123
|
+
# "All" button is always visible
|
|
124
|
+
btn.display = True
|
|
125
|
+
elif filter_key in agents:
|
|
126
|
+
btn.display = True
|
|
127
|
+
else:
|
|
128
|
+
btn.display = False
|
fast_resume/tui/modal.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Modal dialogs for the TUI."""
|
|
2
|
+
|
|
3
|
+
from textual import on
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.binding import Binding
|
|
6
|
+
from textual.containers import Horizontal, Vertical
|
|
7
|
+
from textual.screen import ModalScreen
|
|
8
|
+
from textual.widgets import Button, Label
|
|
9
|
+
|
|
10
|
+
from .styles import YOLO_MODAL_CSS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class YoloModeModal(ModalScreen[bool]):
|
|
14
|
+
"""Modal to choose yolo mode for resume."""
|
|
15
|
+
|
|
16
|
+
BINDINGS = [
|
|
17
|
+
Binding("y", "select_yolo", "Yolo Mode", show=False),
|
|
18
|
+
Binding("n", "select_normal", "Normal", show=False),
|
|
19
|
+
Binding("escape", "dismiss", "Cancel", show=False),
|
|
20
|
+
Binding("enter", "select_focused", "Select", show=False),
|
|
21
|
+
Binding("left", "focus_normal", "Left", show=False),
|
|
22
|
+
Binding("right", "focus_yolo", "Right", show=False),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
CSS = YOLO_MODAL_CSS
|
|
26
|
+
|
|
27
|
+
def compose(self) -> ComposeResult:
|
|
28
|
+
with Vertical():
|
|
29
|
+
yield Label("Resume with yolo mode?", id="title")
|
|
30
|
+
with Horizontal(id="buttons"):
|
|
31
|
+
yield Button("No", id="normal-btn")
|
|
32
|
+
yield Button("Yolo", id="yolo-btn")
|
|
33
|
+
|
|
34
|
+
def on_mount(self) -> None:
|
|
35
|
+
"""Focus the first button when modal opens."""
|
|
36
|
+
self.query_one("#normal-btn", Button).focus()
|
|
37
|
+
|
|
38
|
+
def action_toggle_focus(self) -> None:
|
|
39
|
+
"""Toggle focus between the two buttons."""
|
|
40
|
+
if self.focused and self.focused.id == "yolo-btn":
|
|
41
|
+
self.query_one("#normal-btn", Button).focus()
|
|
42
|
+
else:
|
|
43
|
+
self.query_one("#yolo-btn", Button).focus()
|
|
44
|
+
|
|
45
|
+
def action_focus_normal(self) -> None:
|
|
46
|
+
"""Focus the normal button."""
|
|
47
|
+
self.query_one("#normal-btn", Button).focus()
|
|
48
|
+
|
|
49
|
+
def action_focus_yolo(self) -> None:
|
|
50
|
+
"""Focus the yolo button."""
|
|
51
|
+
self.query_one("#yolo-btn", Button).focus()
|
|
52
|
+
|
|
53
|
+
def action_select_focused(self) -> None:
|
|
54
|
+
"""Select whichever button is currently focused."""
|
|
55
|
+
focused = self.focused
|
|
56
|
+
if focused and focused.id == "yolo-btn":
|
|
57
|
+
self.dismiss(True)
|
|
58
|
+
else:
|
|
59
|
+
self.dismiss(False)
|
|
60
|
+
|
|
61
|
+
def action_select_yolo(self) -> None:
|
|
62
|
+
self.dismiss(True)
|
|
63
|
+
|
|
64
|
+
def action_select_normal(self) -> None:
|
|
65
|
+
self.dismiss(False)
|
|
66
|
+
|
|
67
|
+
@on(Button.Pressed, "#yolo-btn")
|
|
68
|
+
def on_yolo_pressed(self) -> None:
|
|
69
|
+
self.dismiss(True)
|
|
70
|
+
|
|
71
|
+
@on(Button.Pressed, "#normal-btn")
|
|
72
|
+
def on_normal_pressed(self) -> None:
|
|
73
|
+
self.dismiss(False)
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
"""Session preview widget for the TUI."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from io import StringIO
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.columns import Columns
|
|
9
|
+
from rich.console import Console, Group, RenderableType
|
|
10
|
+
from rich.markup import escape as escape_markup
|
|
11
|
+
from rich.syntax import Syntax
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
from textual.widgets import Static
|
|
14
|
+
from textual_image.renderable import Image as ImageRenderable
|
|
15
|
+
|
|
16
|
+
from ..adapters.base import Session
|
|
17
|
+
from ..config import AGENTS
|
|
18
|
+
from .utils import highlight_matches
|
|
19
|
+
|
|
20
|
+
# Asset paths for agent icons
|
|
21
|
+
ASSETS_DIR = Path(__file__).parent.parent / "assets"
|
|
22
|
+
|
|
23
|
+
# Cache for agent icon renderables
|
|
24
|
+
_preview_icon_cache: dict[str, Any] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_agent_icon(agent: str) -> RenderableType | None:
|
|
28
|
+
"""Get the icon renderable for an agent."""
|
|
29
|
+
if agent not in _preview_icon_cache:
|
|
30
|
+
icon_path = ASSETS_DIR / f"{agent}.png"
|
|
31
|
+
if icon_path.exists():
|
|
32
|
+
try:
|
|
33
|
+
_preview_icon_cache[agent] = ImageRenderable(
|
|
34
|
+
icon_path, width=2, height=1
|
|
35
|
+
)
|
|
36
|
+
except Exception:
|
|
37
|
+
_preview_icon_cache[agent] = None
|
|
38
|
+
else:
|
|
39
|
+
_preview_icon_cache[agent] = None
|
|
40
|
+
return _preview_icon_cache[agent]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SessionPreview(Static):
|
|
44
|
+
"""Preview pane showing session content."""
|
|
45
|
+
|
|
46
|
+
# Highlight style for matches in preview
|
|
47
|
+
MATCH_STYLE = "bold reverse"
|
|
48
|
+
# Max lines to show for a single assistant message (None = no limit)
|
|
49
|
+
MAX_ASSISTANT_LINES: int | None = None
|
|
50
|
+
|
|
51
|
+
# Pattern to match code blocks with optional language
|
|
52
|
+
CODE_BLOCK_PATTERN = re.compile(r"```(\w*)")
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
super().__init__("", id="preview")
|
|
56
|
+
|
|
57
|
+
def update_preview(self, session: Session | None, query: str = "") -> None:
|
|
58
|
+
"""Update the preview with session content, highlighting matches."""
|
|
59
|
+
if session is None:
|
|
60
|
+
self.update("")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
result = self._build_preview_renderable(session, query)
|
|
64
|
+
self.update(result)
|
|
65
|
+
|
|
66
|
+
def _build_preview_renderable(
|
|
67
|
+
self, session: Session, query: str = ""
|
|
68
|
+
) -> RenderableType:
|
|
69
|
+
"""Build the preview renderable with icons. Returns a Group of renderables."""
|
|
70
|
+
content = session.content
|
|
71
|
+
preview_text = ""
|
|
72
|
+
|
|
73
|
+
# If there's a query, try to show the part containing the match
|
|
74
|
+
if query:
|
|
75
|
+
query_lower = query.lower()
|
|
76
|
+
content_lower = content.lower()
|
|
77
|
+
terms = query_lower.split()
|
|
78
|
+
|
|
79
|
+
# Find the first matching term
|
|
80
|
+
best_pos = -1
|
|
81
|
+
for term in terms:
|
|
82
|
+
if term:
|
|
83
|
+
pos = content_lower.find(term)
|
|
84
|
+
if pos != -1 and (best_pos == -1 or pos < best_pos):
|
|
85
|
+
best_pos = pos
|
|
86
|
+
|
|
87
|
+
if best_pos != -1:
|
|
88
|
+
# Show context around the match (start 200 chars before, up to 5000 chars)
|
|
89
|
+
start = max(0, best_pos - 200)
|
|
90
|
+
end = min(len(content), start + 5000)
|
|
91
|
+
preview_text = content[start:end]
|
|
92
|
+
if start > 0:
|
|
93
|
+
preview_text = "..." + preview_text
|
|
94
|
+
if end < len(content):
|
|
95
|
+
preview_text = preview_text + "..."
|
|
96
|
+
|
|
97
|
+
# Fall back to full content (limited) if no match found
|
|
98
|
+
if not preview_text:
|
|
99
|
+
preview_text = content[:5000]
|
|
100
|
+
if len(content) > 5000:
|
|
101
|
+
preview_text += "..."
|
|
102
|
+
|
|
103
|
+
# Get agent config and icon
|
|
104
|
+
agent_config = AGENTS.get(
|
|
105
|
+
session.agent, {"color": "white", "badge": session.agent}
|
|
106
|
+
)
|
|
107
|
+
agent_icon = _get_agent_icon(session.agent)
|
|
108
|
+
|
|
109
|
+
# Build list of renderables
|
|
110
|
+
renderables: list[RenderableType] = []
|
|
111
|
+
|
|
112
|
+
# Split by double newlines to get individual messages
|
|
113
|
+
messages = preview_text.split("\n\n")
|
|
114
|
+
|
|
115
|
+
for i, msg in enumerate(messages):
|
|
116
|
+
msg = msg.rstrip()
|
|
117
|
+
if not msg.strip():
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Detect if this is a user message
|
|
121
|
+
is_user = msg.startswith("» ")
|
|
122
|
+
|
|
123
|
+
if is_user:
|
|
124
|
+
# User message - render as text
|
|
125
|
+
text = Text()
|
|
126
|
+
self._render_message(text, msg, query, is_user, agent_config)
|
|
127
|
+
renderables.append(text)
|
|
128
|
+
else:
|
|
129
|
+
# Assistant message - add icon + text
|
|
130
|
+
if agent_icon is not None:
|
|
131
|
+
# Create icon with text on same line using Columns
|
|
132
|
+
text = Text()
|
|
133
|
+
self._render_message_content(text, msg, query, agent_config)
|
|
134
|
+
renderables.append(Columns([agent_icon, text], padding=(0, 1)))
|
|
135
|
+
else:
|
|
136
|
+
# Fallback to badge
|
|
137
|
+
text = Text()
|
|
138
|
+
self._render_message(text, msg, query, is_user, agent_config)
|
|
139
|
+
renderables.append(text)
|
|
140
|
+
|
|
141
|
+
return Group(*renderables)
|
|
142
|
+
|
|
143
|
+
def build_preview_text(self, session: Session, query: str = "") -> Text:
|
|
144
|
+
"""Build the preview text for a session. Returns a Rich Text object."""
|
|
145
|
+
content = session.content
|
|
146
|
+
preview_text = ""
|
|
147
|
+
|
|
148
|
+
# If there's a query, try to show the part containing the match
|
|
149
|
+
if query:
|
|
150
|
+
query_lower = query.lower()
|
|
151
|
+
content_lower = content.lower()
|
|
152
|
+
terms = query_lower.split()
|
|
153
|
+
|
|
154
|
+
# Find the first matching term
|
|
155
|
+
best_pos = -1
|
|
156
|
+
for term in terms:
|
|
157
|
+
if term:
|
|
158
|
+
pos = content_lower.find(term)
|
|
159
|
+
if pos != -1 and (best_pos == -1 or pos < best_pos):
|
|
160
|
+
best_pos = pos
|
|
161
|
+
|
|
162
|
+
if best_pos != -1:
|
|
163
|
+
# Show context around the match (start 200 chars before, up to 5000 chars)
|
|
164
|
+
start = max(0, best_pos - 200)
|
|
165
|
+
end = min(len(content), start + 5000)
|
|
166
|
+
preview_text = content[start:end]
|
|
167
|
+
if start > 0:
|
|
168
|
+
preview_text = "..." + preview_text
|
|
169
|
+
if end < len(content):
|
|
170
|
+
preview_text = preview_text + "..."
|
|
171
|
+
|
|
172
|
+
# Fall back to full content (limited) if no match found
|
|
173
|
+
if not preview_text:
|
|
174
|
+
preview_text = content[:5000]
|
|
175
|
+
if len(content) > 5000:
|
|
176
|
+
preview_text += "..."
|
|
177
|
+
|
|
178
|
+
# Build rich text with role-based styling
|
|
179
|
+
result = Text()
|
|
180
|
+
|
|
181
|
+
# Get agent config for styling
|
|
182
|
+
agent_config = AGENTS.get(
|
|
183
|
+
session.agent, {"color": "white", "badge": session.agent}
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Split by double newlines to get individual messages
|
|
187
|
+
messages = preview_text.split("\n\n")
|
|
188
|
+
|
|
189
|
+
for i, msg in enumerate(messages):
|
|
190
|
+
msg = (
|
|
191
|
+
msg.rstrip()
|
|
192
|
+
) # Only strip trailing whitespace, preserve leading indent
|
|
193
|
+
if not msg.strip(): # Skip if empty after stripping
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# Detect if this is a user message
|
|
197
|
+
is_user = msg.startswith("» ")
|
|
198
|
+
|
|
199
|
+
# Process message with code block handling
|
|
200
|
+
self._render_message(result, msg, query, is_user, agent_config)
|
|
201
|
+
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
def _render_message(
|
|
205
|
+
self,
|
|
206
|
+
result: Text,
|
|
207
|
+
msg: str,
|
|
208
|
+
query: str,
|
|
209
|
+
is_user: bool,
|
|
210
|
+
agent_config: dict[str, str],
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Render a single message with code block syntax highlighting."""
|
|
213
|
+
lines = msg.split("\n")
|
|
214
|
+
|
|
215
|
+
# Truncate assistant messages if limit is set
|
|
216
|
+
if (
|
|
217
|
+
not is_user
|
|
218
|
+
and self.MAX_ASSISTANT_LINES is not None
|
|
219
|
+
and len(lines) > self.MAX_ASSISTANT_LINES
|
|
220
|
+
):
|
|
221
|
+
lines = lines[: self.MAX_ASSISTANT_LINES]
|
|
222
|
+
lines.append("...")
|
|
223
|
+
|
|
224
|
+
# Add role prefix on first line
|
|
225
|
+
first_line = True
|
|
226
|
+
|
|
227
|
+
i = 0
|
|
228
|
+
while i < len(lines):
|
|
229
|
+
line = lines[i]
|
|
230
|
+
|
|
231
|
+
# Check for code block start
|
|
232
|
+
match = self.CODE_BLOCK_PATTERN.match(line)
|
|
233
|
+
if match:
|
|
234
|
+
language = match.group(1) or ""
|
|
235
|
+
# Collect code block content
|
|
236
|
+
code_lines = []
|
|
237
|
+
i += 1
|
|
238
|
+
while i < len(lines) and not lines[i].startswith("```"):
|
|
239
|
+
code_lines.append(lines[i])
|
|
240
|
+
i += 1
|
|
241
|
+
|
|
242
|
+
# Render code block with syntax highlighting
|
|
243
|
+
if code_lines:
|
|
244
|
+
code = "\n".join(code_lines)
|
|
245
|
+
self._render_code_with_highlighting(result, code, language)
|
|
246
|
+
|
|
247
|
+
# Skip closing ```
|
|
248
|
+
if i < len(lines) and lines[i].startswith("```"):
|
|
249
|
+
i += 1
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
# Handle user prompt
|
|
253
|
+
if line.startswith("» "):
|
|
254
|
+
result.append("» ", style="bold cyan")
|
|
255
|
+
content_part = escape_markup(line[2:])
|
|
256
|
+
if len(content_part) > 200:
|
|
257
|
+
content_part = content_part[:200].rsplit(" ", 1)[0] + " ..."
|
|
258
|
+
highlighted = highlight_matches(
|
|
259
|
+
content_part, query, style=self.MATCH_STYLE
|
|
260
|
+
)
|
|
261
|
+
result.append_text(highlighted)
|
|
262
|
+
result.append("\n")
|
|
263
|
+
first_line = False
|
|
264
|
+
elif line == "...":
|
|
265
|
+
result.append(" ⋯\n", style="dim")
|
|
266
|
+
elif line.startswith("..."):
|
|
267
|
+
# Truncation indicator from context
|
|
268
|
+
result.append(escape_markup(line) + "\n", style="dim")
|
|
269
|
+
elif line.startswith(" "):
|
|
270
|
+
# Assistant response (indented) - add agent prefix on first line
|
|
271
|
+
if first_line:
|
|
272
|
+
agent_color = agent_config["color"]
|
|
273
|
+
result.append("● ", style=agent_color)
|
|
274
|
+
result.append(agent_config["badge"], style=f"bold {agent_color}")
|
|
275
|
+
result.append(" ")
|
|
276
|
+
# Remove leading indent for first line since we added prefix
|
|
277
|
+
content = line.lstrip()
|
|
278
|
+
first_line = False
|
|
279
|
+
else:
|
|
280
|
+
content = line
|
|
281
|
+
highlighted = highlight_matches(
|
|
282
|
+
escape_markup(content), query, style=self.MATCH_STYLE
|
|
283
|
+
)
|
|
284
|
+
result.append_text(highlighted)
|
|
285
|
+
result.append("\n")
|
|
286
|
+
else:
|
|
287
|
+
# Other content
|
|
288
|
+
highlighted = highlight_matches(
|
|
289
|
+
escape_markup(line), query, style=self.MATCH_STYLE
|
|
290
|
+
)
|
|
291
|
+
result.append_text(highlighted)
|
|
292
|
+
result.append("\n")
|
|
293
|
+
|
|
294
|
+
i += 1
|
|
295
|
+
|
|
296
|
+
def _render_message_content(
|
|
297
|
+
self,
|
|
298
|
+
result: Text,
|
|
299
|
+
msg: str,
|
|
300
|
+
query: str,
|
|
301
|
+
agent_config: dict[str, str],
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Render assistant message content without badge (icon shown separately)."""
|
|
304
|
+
lines = msg.split("\n")
|
|
305
|
+
|
|
306
|
+
# Truncate assistant messages if limit is set
|
|
307
|
+
if (
|
|
308
|
+
self.MAX_ASSISTANT_LINES is not None
|
|
309
|
+
and len(lines) > self.MAX_ASSISTANT_LINES
|
|
310
|
+
):
|
|
311
|
+
lines = lines[: self.MAX_ASSISTANT_LINES]
|
|
312
|
+
lines.append("...")
|
|
313
|
+
|
|
314
|
+
i = 0
|
|
315
|
+
while i < len(lines):
|
|
316
|
+
line = lines[i]
|
|
317
|
+
|
|
318
|
+
# Check for code block start
|
|
319
|
+
match = self.CODE_BLOCK_PATTERN.match(line)
|
|
320
|
+
if match:
|
|
321
|
+
language = match.group(1) or ""
|
|
322
|
+
code_lines = []
|
|
323
|
+
i += 1
|
|
324
|
+
while i < len(lines) and not lines[i].startswith("```"):
|
|
325
|
+
code_lines.append(lines[i])
|
|
326
|
+
i += 1
|
|
327
|
+
|
|
328
|
+
if code_lines:
|
|
329
|
+
code = "\n".join(code_lines)
|
|
330
|
+
self._render_code_with_highlighting(result, code, language)
|
|
331
|
+
|
|
332
|
+
if i < len(lines) and lines[i].startswith("```"):
|
|
333
|
+
i += 1
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
if line == "...":
|
|
337
|
+
result.append("⋯\n", style="dim")
|
|
338
|
+
elif line.startswith("..."):
|
|
339
|
+
result.append(escape_markup(line) + "\n", style="dim")
|
|
340
|
+
elif line.startswith(" "):
|
|
341
|
+
# Assistant response - strip leading indent
|
|
342
|
+
content = line.lstrip()
|
|
343
|
+
highlighted = highlight_matches(
|
|
344
|
+
escape_markup(content), query, style=self.MATCH_STYLE
|
|
345
|
+
)
|
|
346
|
+
result.append_text(highlighted)
|
|
347
|
+
result.append("\n")
|
|
348
|
+
else:
|
|
349
|
+
highlighted = highlight_matches(
|
|
350
|
+
escape_markup(line), query, style=self.MATCH_STYLE
|
|
351
|
+
)
|
|
352
|
+
result.append_text(highlighted)
|
|
353
|
+
result.append("\n")
|
|
354
|
+
|
|
355
|
+
i += 1
|
|
356
|
+
|
|
357
|
+
def _render_code_with_highlighting(
|
|
358
|
+
self, result: Text, code: str, language: str
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Render code with syntax highlighting."""
|
|
361
|
+
# Map common language aliases
|
|
362
|
+
lang_map = {
|
|
363
|
+
"js": "javascript",
|
|
364
|
+
"ts": "typescript",
|
|
365
|
+
"py": "python",
|
|
366
|
+
"rb": "ruby",
|
|
367
|
+
"sh": "bash",
|
|
368
|
+
"yml": "yaml",
|
|
369
|
+
"": "text",
|
|
370
|
+
}
|
|
371
|
+
language = lang_map.get(language, language) or "text"
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
syntax = Syntax(
|
|
375
|
+
code,
|
|
376
|
+
language,
|
|
377
|
+
theme="ansi_dark",
|
|
378
|
+
line_numbers=False,
|
|
379
|
+
word_wrap=True,
|
|
380
|
+
background_color="default",
|
|
381
|
+
)
|
|
382
|
+
# Rich Syntax objects can be converted to Text
|
|
383
|
+
string_io = StringIO()
|
|
384
|
+
console = Console(file=string_io, force_terminal=True, width=200)
|
|
385
|
+
console.print(syntax, end="")
|
|
386
|
+
highlighted_code = string_io.getvalue()
|
|
387
|
+
|
|
388
|
+
# Add with indentation
|
|
389
|
+
for line in highlighted_code.rstrip().split("\n"):
|
|
390
|
+
result.append(" ")
|
|
391
|
+
result.append_text(Text.from_ansi(line))
|
|
392
|
+
result.append("\n")
|
|
393
|
+
except Exception:
|
|
394
|
+
# Fall back to plain dim text
|
|
395
|
+
for line in code.split("\n"):
|
|
396
|
+
result.append(" " + escape_markup(line) + "\n", style="dim")
|
fast_resume/tui/query.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Query parsing utilities for search keyword syntax."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ..config import AGENTS
|
|
6
|
+
|
|
7
|
+
# Pattern to match keyword:value syntax in search queries (with optional - prefix)
|
|
8
|
+
KEYWORD_PATTERN = re.compile(r"(-?)(agent:|dir:|date:)(\S+)")
|
|
9
|
+
|
|
10
|
+
# Valid date keywords and pattern
|
|
11
|
+
VALID_DATE_KEYWORDS = {"today", "yesterday", "week", "month"}
|
|
12
|
+
DATE_PATTERN = re.compile(r"^([<>])?(\d+)(m|h|d|w|mo|y)$")
|
|
13
|
+
|
|
14
|
+
# Pattern to match agent: keyword specifically (for extraction/replacement)
|
|
15
|
+
AGENT_KEYWORD_PATTERN = re.compile(r"-?agent:(\S+)")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_valid_filter_value(keyword: str, value: str) -> bool:
|
|
19
|
+
"""Check if a filter value is valid for the given keyword."""
|
|
20
|
+
check_value = value.lstrip("!")
|
|
21
|
+
values = [v.strip().lstrip("!") for v in check_value.split(",") if v.strip()]
|
|
22
|
+
|
|
23
|
+
if keyword == "agent:":
|
|
24
|
+
return all(v.lower() in AGENTS for v in values)
|
|
25
|
+
elif keyword == "date:":
|
|
26
|
+
for v in values:
|
|
27
|
+
v_lower = v.lower()
|
|
28
|
+
if v_lower not in VALID_DATE_KEYWORDS and not DATE_PATTERN.match(v_lower):
|
|
29
|
+
return False
|
|
30
|
+
return True
|
|
31
|
+
elif keyword == "dir:":
|
|
32
|
+
return True # Any value is valid for directory
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_agent_from_query(query: str) -> str | None:
|
|
37
|
+
"""Extract agent value from query string if present.
|
|
38
|
+
|
|
39
|
+
Returns the first non-negated agent value, or None if no agent keyword.
|
|
40
|
+
For mixed filters like agent:claude,!codex, returns 'claude'.
|
|
41
|
+
"""
|
|
42
|
+
match = AGENT_KEYWORD_PATTERN.search(query)
|
|
43
|
+
if not match:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
# Check if the whole keyword is negated with - prefix
|
|
47
|
+
full_match = match.group(0)
|
|
48
|
+
if full_match.startswith("-"):
|
|
49
|
+
return None # Negated filter, don't sync to buttons
|
|
50
|
+
|
|
51
|
+
value = match.group(1)
|
|
52
|
+
# Handle ! prefix on value
|
|
53
|
+
if value.startswith("!"):
|
|
54
|
+
return None # Negated, don't sync
|
|
55
|
+
|
|
56
|
+
# Get first non-negated value from comma-separated list
|
|
57
|
+
for v in value.split(","):
|
|
58
|
+
v = v.strip()
|
|
59
|
+
if v and not v.startswith("!"):
|
|
60
|
+
return v
|
|
61
|
+
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def update_agent_in_query(query: str, agent: str | None) -> str:
|
|
66
|
+
"""Update or remove agent keyword in query string.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
query: Current query string
|
|
70
|
+
agent: Agent to set, or None to remove agent keyword
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Updated query string with agent keyword added/updated/removed.
|
|
74
|
+
"""
|
|
75
|
+
# Remove existing agent keyword(s)
|
|
76
|
+
query_without_agent = AGENT_KEYWORD_PATTERN.sub("", query).strip()
|
|
77
|
+
# Clean up extra whitespace
|
|
78
|
+
query_without_agent = " ".join(query_without_agent.split())
|
|
79
|
+
|
|
80
|
+
if agent is None:
|
|
81
|
+
return query_without_agent
|
|
82
|
+
|
|
83
|
+
# Append agent keyword at the end
|
|
84
|
+
if query_without_agent:
|
|
85
|
+
return f"{query_without_agent} agent:{agent}"
|
|
86
|
+
return f"agent:{agent}"
|