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/utils.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Utility functions for the TUI."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import humanize
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from rich.columns import Columns
|
|
13
|
+
from rich.console import RenderableType
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from textual_image.renderable import Image as ImageRenderable
|
|
16
|
+
|
|
17
|
+
from ..config import AGENTS
|
|
18
|
+
|
|
19
|
+
# Asset paths for agent icons
|
|
20
|
+
ASSETS_DIR = Path(__file__).parent.parent / "assets"
|
|
21
|
+
|
|
22
|
+
# Cache for agent icon renderables (textual_image has incomplete type stubs)
|
|
23
|
+
_icon_cache: dict[str, Any] = {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_agent_icon(agent: str) -> RenderableType:
|
|
27
|
+
"""Get the icon + name renderable for an agent."""
|
|
28
|
+
agent_config = AGENTS.get(agent, {"color": "white", "badge": agent})
|
|
29
|
+
|
|
30
|
+
if agent not in _icon_cache:
|
|
31
|
+
icon_path = ASSETS_DIR / f"{agent}.png"
|
|
32
|
+
if icon_path.exists():
|
|
33
|
+
try:
|
|
34
|
+
_icon_cache[agent] = ImageRenderable(icon_path, width=2, height=1)
|
|
35
|
+
except Exception:
|
|
36
|
+
_icon_cache[agent] = None
|
|
37
|
+
else:
|
|
38
|
+
_icon_cache[agent] = None
|
|
39
|
+
|
|
40
|
+
icon = _icon_cache[agent]
|
|
41
|
+
name = Text(agent_config["badge"])
|
|
42
|
+
name.stylize(agent_config["color"])
|
|
43
|
+
|
|
44
|
+
if icon is not None:
|
|
45
|
+
# Combine icon and name horizontally
|
|
46
|
+
return Columns([icon, name], padding=(0, 1), expand=False)
|
|
47
|
+
|
|
48
|
+
# Fallback to just colored name with a dot
|
|
49
|
+
badge = Text(f"● {agent_config['badge']}")
|
|
50
|
+
badge.stylize(agent_config["color"], 0, 1) # Color just the dot
|
|
51
|
+
return badge
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_time_ago(dt: datetime) -> str:
|
|
55
|
+
"""Format a datetime as a human-readable time ago string."""
|
|
56
|
+
return humanize.naturaltime(dt)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_directory(path: str) -> str:
|
|
60
|
+
"""Format directory path, replacing home with ~."""
|
|
61
|
+
if not path:
|
|
62
|
+
return "n/a"
|
|
63
|
+
home = os.path.expanduser("~")
|
|
64
|
+
if path.startswith(home):
|
|
65
|
+
return "~" + path[len(home) :]
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def highlight_matches(
|
|
70
|
+
text: str, query: str, max_len: int | None = None, style: str = "bold reverse"
|
|
71
|
+
) -> Text:
|
|
72
|
+
"""Highlight matching portions of text based on query terms.
|
|
73
|
+
|
|
74
|
+
Returns a Rich Text object with matches highlighted.
|
|
75
|
+
"""
|
|
76
|
+
if max_len and len(text) > max_len:
|
|
77
|
+
text = text[: max_len - 3] + "..."
|
|
78
|
+
|
|
79
|
+
if not query:
|
|
80
|
+
return Text(text)
|
|
81
|
+
|
|
82
|
+
result = Text(text)
|
|
83
|
+
query_lower = query.lower()
|
|
84
|
+
text_lower = text.lower()
|
|
85
|
+
|
|
86
|
+
# Split query into terms and highlight each
|
|
87
|
+
terms = query_lower.split()
|
|
88
|
+
for term in terms:
|
|
89
|
+
if not term:
|
|
90
|
+
continue
|
|
91
|
+
start = 0
|
|
92
|
+
while True:
|
|
93
|
+
idx = text_lower.find(term, start)
|
|
94
|
+
if idx == -1:
|
|
95
|
+
break
|
|
96
|
+
result.stylize(style, idx, idx + len(term))
|
|
97
|
+
start = idx + 1
|
|
98
|
+
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_age_color(age_hours: float) -> str:
|
|
103
|
+
"""Return a hex color based on session age using exponential decay gradient.
|
|
104
|
+
|
|
105
|
+
Colors transition: Green (0h) → Yellow (24h) → Orange (~2.5d) → Dim gray (7d+)
|
|
106
|
+
"""
|
|
107
|
+
# Anchor: 24 hours should hit the green→yellow transition (t=0.3)
|
|
108
|
+
decay_rate = -math.log(1 - 0.3) / 24 # ≈ 0.0149
|
|
109
|
+
t = 1 - math.exp(-decay_rate * age_hours) # 0 at 0h, approaches 1 asymptotically
|
|
110
|
+
|
|
111
|
+
# Interpolate through color stops: green → yellow → orange → gray
|
|
112
|
+
if t < 0.3:
|
|
113
|
+
# Muted green to yellow
|
|
114
|
+
s = t / 0.3
|
|
115
|
+
r = int(100 + s * 100) # 100 → 200
|
|
116
|
+
g = int(200 - s * 20) # 200 → 180
|
|
117
|
+
b = int(50 - s * 50) # 50 → 0
|
|
118
|
+
elif t < 0.6:
|
|
119
|
+
# Yellow to muted orange
|
|
120
|
+
s = (t - 0.3) / 0.3
|
|
121
|
+
r = 200
|
|
122
|
+
g = int(180 - s * 80) # 180 → 100
|
|
123
|
+
b = int(0 + s * 50) # 0 → 50
|
|
124
|
+
else:
|
|
125
|
+
# Muted orange to dim gray
|
|
126
|
+
s = (t - 0.6) / 0.4
|
|
127
|
+
r = int(200 - s * 100) # 200 → 100
|
|
128
|
+
g = 100
|
|
129
|
+
b = int(50 + s * 50) # 50 → 100
|
|
130
|
+
|
|
131
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def copy_to_clipboard(text: str) -> bool:
|
|
135
|
+
"""Copy text to system clipboard.
|
|
136
|
+
|
|
137
|
+
Returns True on success, False on failure.
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
if sys.platform == "darwin":
|
|
141
|
+
subprocess.run(["pbcopy"], input=text.encode(), check=True)
|
|
142
|
+
elif sys.platform == "win32":
|
|
143
|
+
subprocess.run(["clip"], input=text.encode(), check=True)
|
|
144
|
+
else:
|
|
145
|
+
# Linux - try xclip or xsel
|
|
146
|
+
try:
|
|
147
|
+
subprocess.run(
|
|
148
|
+
["xclip", "-selection", "clipboard"],
|
|
149
|
+
input=text.encode(),
|
|
150
|
+
check=True,
|
|
151
|
+
)
|
|
152
|
+
except FileNotFoundError:
|
|
153
|
+
subprocess.run(
|
|
154
|
+
["xsel", "--clipboard", "--input"],
|
|
155
|
+
input=text.encode(),
|
|
156
|
+
check=True,
|
|
157
|
+
)
|
|
158
|
+
return True
|
|
159
|
+
except Exception:
|
|
160
|
+
return False
|