finch-cli 0.1.1__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.
- finch_cli/__init__.py +3 -0
- finch_cli/__main__.py +4 -0
- finch_cli/_data/base_resume.md +32 -0
- finch_cli/_data/job_post.txt +25 -0
- finch_cli/cli.py +155 -0
- finch_cli/fetch.py +131 -0
- finch_cli/jobs.py +166 -0
- finch_cli/output.py +20 -0
- finch_cli/score.py +166 -0
- finch_cli/storage.py +81 -0
- finch_cli/tailor.py +150 -0
- finch_cli/tui.py +782 -0
- finch_cli-0.1.1.dist-info/METADATA +140 -0
- finch_cli-0.1.1.dist-info/RECORD +17 -0
- finch_cli-0.1.1.dist-info/WHEEL +4 -0
- finch_cli-0.1.1.dist-info/entry_points.txt +2 -0
- finch_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
finch_cli/tui.py
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
"""Finch TUI - jobs / library / tailor in one terminal app."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from textual import work
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Container, Horizontal, Vertical
|
|
13
|
+
from textual.reactive import reactive
|
|
14
|
+
from textual.screen import ModalScreen
|
|
15
|
+
from textual.widgets import (
|
|
16
|
+
Button,
|
|
17
|
+
DataTable,
|
|
18
|
+
Footer,
|
|
19
|
+
Header,
|
|
20
|
+
Input,
|
|
21
|
+
Label,
|
|
22
|
+
Markdown,
|
|
23
|
+
Static,
|
|
24
|
+
TabbedContent,
|
|
25
|
+
TabPane,
|
|
26
|
+
TextArea,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from .fetch import FetchError, fetch_job
|
|
30
|
+
from .jobs import Job, filter_jobs, load_jobs
|
|
31
|
+
from .score import MatchResult, bar, score_label, score_match
|
|
32
|
+
from .storage import SavedResume, list_tailored, save_tailored
|
|
33
|
+
from .tailor import DEFAULT_MODEL, TailorError, detected_key_env, tailor_resume
|
|
34
|
+
|
|
35
|
+
# In a pip-installed wheel the example files live alongside the package at
|
|
36
|
+
# finch_cli/_data/. In a dev checkout they're at <repo>/examples/. Try both,
|
|
37
|
+
# in installed-wheel order, so neither path is "the special one".
|
|
38
|
+
_HERE = Path(__file__).resolve().parent
|
|
39
|
+
_DATA_CANDIDATES = (_HERE / "_data", _HERE.parent / "examples")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _resource(name: str) -> Path | None:
|
|
43
|
+
for base in _DATA_CANDIDATES:
|
|
44
|
+
p = base / name
|
|
45
|
+
if p.exists():
|
|
46
|
+
return p
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
EXAMPLES = next((p for p in _DATA_CANDIDATES if p.exists()), _DATA_CANDIDATES[0])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
54
|
+
# helpers
|
|
55
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _job_to_markdown(job: Job, *, match: MatchResult | None = None) -> str:
|
|
59
|
+
locs = ", ".join(job.locations) if job.locations else "-"
|
|
60
|
+
terms = ", ".join(job.terms) if job.terms else "-"
|
|
61
|
+
degrees = ", ".join(job.degrees) if job.degrees else "-"
|
|
62
|
+
spon = job.sponsorship or "-"
|
|
63
|
+
body = (
|
|
64
|
+
f"# {job.title}\n\n"
|
|
65
|
+
f"**{job.company}** · {locs}\n\n"
|
|
66
|
+
f"`{job.source}` · posted {job.age_label()} ago · terms: {terms}\n\n"
|
|
67
|
+
f"degrees: {degrees} · sponsorship: {spon}\n\n"
|
|
68
|
+
)
|
|
69
|
+
if match is not None and match.total > 0:
|
|
70
|
+
lbl, _ = score_label(match.score)
|
|
71
|
+
body += (
|
|
72
|
+
f"---\n\n"
|
|
73
|
+
f"### your match: **{match.score:.0f}%** ({lbl})\n\n"
|
|
74
|
+
f"`{bar(match.score, 20)}` {match.matched_count} of {match.total} keywords\n\n"
|
|
75
|
+
)
|
|
76
|
+
if match.matched:
|
|
77
|
+
body += "**matched:** " + ", ".join(match.matched[:15])
|
|
78
|
+
if len(match.matched) > 15:
|
|
79
|
+
body += f", +{len(match.matched) - 15} more"
|
|
80
|
+
body += "\n\n"
|
|
81
|
+
if match.missing:
|
|
82
|
+
body += "**missing:** " + ", ".join(match.missing[:10])
|
|
83
|
+
if len(match.missing) > 10:
|
|
84
|
+
body += f", +{len(match.missing) - 10} more"
|
|
85
|
+
body += "\n\n"
|
|
86
|
+
body += f"---\n\nApply: {job.url or '(no link)'}\n"
|
|
87
|
+
return body
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
91
|
+
# modals
|
|
92
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class FilePathScreen(ModalScreen[str | None]):
|
|
96
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
97
|
+
|
|
98
|
+
def __init__(self, prompt: str, default: str = "") -> None:
|
|
99
|
+
super().__init__()
|
|
100
|
+
self.prompt = prompt
|
|
101
|
+
self.default = default
|
|
102
|
+
|
|
103
|
+
def compose(self) -> ComposeResult:
|
|
104
|
+
with Vertical(id="modal_box"):
|
|
105
|
+
yield Label(self.prompt)
|
|
106
|
+
yield Input(value=self.default, id="path_input")
|
|
107
|
+
with Horizontal(id="modal_buttons"):
|
|
108
|
+
yield Button("OK", variant="primary", id="ok")
|
|
109
|
+
yield Button("Cancel", id="cancel")
|
|
110
|
+
|
|
111
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
112
|
+
if event.button.id == "ok":
|
|
113
|
+
self.dismiss(self.query_one("#path_input", Input).value or None)
|
|
114
|
+
else:
|
|
115
|
+
self.dismiss(None)
|
|
116
|
+
|
|
117
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
118
|
+
self.dismiss(event.value or None)
|
|
119
|
+
|
|
120
|
+
def action_cancel(self) -> None:
|
|
121
|
+
self.dismiss(None)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class URLInputScreen(ModalScreen[str | None]):
|
|
125
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel")]
|
|
126
|
+
|
|
127
|
+
def compose(self) -> ComposeResult:
|
|
128
|
+
with Vertical(id="modal_box"):
|
|
129
|
+
yield Label("Paste a job posting URL")
|
|
130
|
+
yield Input(placeholder="https://...", id="url_input")
|
|
131
|
+
with Horizontal(id="modal_buttons"):
|
|
132
|
+
yield Button("Fetch", variant="primary", id="ok")
|
|
133
|
+
yield Button("Cancel", id="cancel")
|
|
134
|
+
|
|
135
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
136
|
+
if event.button.id == "ok":
|
|
137
|
+
self.dismiss(self.query_one("#url_input", Input).value or None)
|
|
138
|
+
else:
|
|
139
|
+
self.dismiss(None)
|
|
140
|
+
|
|
141
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
142
|
+
self.dismiss(event.value or None)
|
|
143
|
+
|
|
144
|
+
def action_cancel(self) -> None:
|
|
145
|
+
self.dismiss(None)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
149
|
+
# panes
|
|
150
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class JobsPane(Container):
|
|
154
|
+
"""Job browser: filter + DataTable + detail preview."""
|
|
155
|
+
|
|
156
|
+
DEFAULT_CSS = """
|
|
157
|
+
JobsPane { layout: vertical; }
|
|
158
|
+
#jobs_filter_row { height: 3; padding: 1 1 0 1; }
|
|
159
|
+
#jobs_filter_row Input { width: 1fr; margin: 0 1 0 0; }
|
|
160
|
+
#jobs_filter_row Input.narrow { width: 18; }
|
|
161
|
+
#jobs_filter_row Input.tiny { width: 14; }
|
|
162
|
+
#jobs_body { height: 1fr; padding: 1 1 0 1; }
|
|
163
|
+
#jobs_table_wrap {
|
|
164
|
+
width: 2fr;
|
|
165
|
+
border: round #2a2d34;
|
|
166
|
+
padding: 0;
|
|
167
|
+
margin: 0 1 0 0;
|
|
168
|
+
min-width: 50;
|
|
169
|
+
}
|
|
170
|
+
#jobs_detail {
|
|
171
|
+
width: 1fr;
|
|
172
|
+
border: round #f15a29 30%;
|
|
173
|
+
padding: 1 2;
|
|
174
|
+
min-width: 35;
|
|
175
|
+
}
|
|
176
|
+
DataTable { background: $surface; height: 1fr; padding: 0; }
|
|
177
|
+
#jobs_stats { dock: bottom; height: 1; color: $text-muted; padding: 0 2; }
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
jobs_all: reactive[list[Job]] = reactive(list, recompose=False)
|
|
181
|
+
selected_job_id: reactive[str] = reactive("")
|
|
182
|
+
base_resume_text: reactive[str] = reactive("")
|
|
183
|
+
|
|
184
|
+
def __init__(self) -> None:
|
|
185
|
+
super().__init__()
|
|
186
|
+
self._displayed: list[Job] = []
|
|
187
|
+
|
|
188
|
+
def compose(self) -> ComposeResult:
|
|
189
|
+
with Horizontal(id="jobs_filter_row"):
|
|
190
|
+
yield Input(placeholder="search title / company / location...", id="jobs_filter")
|
|
191
|
+
yield Input(placeholder="source", classes="narrow", id="jobs_source")
|
|
192
|
+
yield Input(placeholder="term", classes="tiny", id="jobs_term")
|
|
193
|
+
with Horizontal(id="jobs_body"):
|
|
194
|
+
with Vertical(id="jobs_table_wrap"):
|
|
195
|
+
yield DataTable(id="jobs_table", cursor_type="row", zebra_stripes=True)
|
|
196
|
+
with Vertical(id="jobs_detail"):
|
|
197
|
+
yield Markdown("Pick a job on the left to see details.\n\nLoad a base resume (`Ctrl+O`) to see your match score per job.", id="jobs_detail_md")
|
|
198
|
+
yield Static("loading job feeds...", id="jobs_stats")
|
|
199
|
+
|
|
200
|
+
def on_mount(self) -> None:
|
|
201
|
+
table = self.query_one("#jobs_table", DataTable)
|
|
202
|
+
table.add_columns("Title", "Company", "Loc", "Term", "Sponsorship", "Posted")
|
|
203
|
+
self._load_jobs_worker()
|
|
204
|
+
|
|
205
|
+
@work(thread=True, exclusive=True)
|
|
206
|
+
def _load_jobs_worker(self, *, force: bool = False) -> None:
|
|
207
|
+
try:
|
|
208
|
+
jobs = load_jobs(force_refresh=force)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
self.app.call_from_thread(self._set_stats, f"fetch failed: {e}")
|
|
211
|
+
return
|
|
212
|
+
self.app.call_from_thread(self._apply_jobs, jobs)
|
|
213
|
+
|
|
214
|
+
def _apply_jobs(self, jobs: list[Job]) -> None:
|
|
215
|
+
self.jobs_all = jobs
|
|
216
|
+
self._rebuild_table()
|
|
217
|
+
|
|
218
|
+
def _set_stats(self, text: str) -> None:
|
|
219
|
+
try:
|
|
220
|
+
self.query_one("#jobs_stats", Static).update(text)
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
def _current_filter(self) -> tuple[str, str | None, str | None]:
|
|
225
|
+
q = self.query_one("#jobs_filter", Input).value or ""
|
|
226
|
+
src = (self.query_one("#jobs_source", Input).value or "").strip().lower() or None
|
|
227
|
+
if src not in (None, "intern", "newgrad"):
|
|
228
|
+
src = None
|
|
229
|
+
term = (self.query_one("#jobs_term", Input).value or "").strip() or None
|
|
230
|
+
return q, src, term
|
|
231
|
+
|
|
232
|
+
def _rebuild_table(self) -> None:
|
|
233
|
+
q, src, term = self._current_filter()
|
|
234
|
+
filtered = filter_jobs(self.jobs_all, query=q, source=src, term_substring=term)
|
|
235
|
+
self._displayed = filtered
|
|
236
|
+
table = self.query_one("#jobs_table", DataTable)
|
|
237
|
+
table.clear()
|
|
238
|
+
for j in filtered[:1500]:
|
|
239
|
+
table.add_row(
|
|
240
|
+
j.title[:50],
|
|
241
|
+
j.company[:24],
|
|
242
|
+
j.loc_label()[:20],
|
|
243
|
+
j.term_label()[:14],
|
|
244
|
+
j.sponsorship_label()[:10],
|
|
245
|
+
j.age_label(),
|
|
246
|
+
key=j.id,
|
|
247
|
+
)
|
|
248
|
+
intern = sum(1 for j in self.jobs_all if j.source == "intern")
|
|
249
|
+
newgrad = sum(1 for j in self.jobs_all if j.source == "newgrad")
|
|
250
|
+
shown = len(filtered)
|
|
251
|
+
match_hint = (
|
|
252
|
+
"" if not self.base_resume_text.strip() else " · base resume loaded, match scores visible"
|
|
253
|
+
)
|
|
254
|
+
self._set_stats(
|
|
255
|
+
f"{shown:,} shown · {len(self.jobs_all):,} active "
|
|
256
|
+
f"({intern:,} intern + {newgrad:,} new-grad) · "
|
|
257
|
+
f"refreshed {datetime.now().strftime('%H:%M:%S')}{match_hint}"
|
|
258
|
+
)
|
|
259
|
+
if filtered:
|
|
260
|
+
table.move_cursor(row=0)
|
|
261
|
+
self._update_detail(filtered[0])
|
|
262
|
+
|
|
263
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
264
|
+
if event.input.id in ("jobs_filter", "jobs_source", "jobs_term"):
|
|
265
|
+
self._rebuild_table()
|
|
266
|
+
|
|
267
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
268
|
+
if event.row_key is None:
|
|
269
|
+
return
|
|
270
|
+
job_id = str(event.row_key.value)
|
|
271
|
+
self.selected_job_id = job_id
|
|
272
|
+
job = self._find_job(job_id)
|
|
273
|
+
if job:
|
|
274
|
+
self._update_detail(job)
|
|
275
|
+
|
|
276
|
+
def _find_job(self, job_id: str) -> Job | None:
|
|
277
|
+
for j in self._displayed:
|
|
278
|
+
if j.id == job_id:
|
|
279
|
+
return j
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
def _update_detail(self, job: Job) -> None:
|
|
283
|
+
match: MatchResult | None = None
|
|
284
|
+
if self.base_resume_text.strip():
|
|
285
|
+
match = score_match(self.base_resume_text, job.title + " " + (job.category or ""))
|
|
286
|
+
try:
|
|
287
|
+
self.query_one("#jobs_detail_md", Markdown).update(
|
|
288
|
+
_job_to_markdown(job, match=match)
|
|
289
|
+
)
|
|
290
|
+
except Exception:
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
def current_selection(self) -> Job | None:
|
|
294
|
+
return self._find_job(self.selected_job_id)
|
|
295
|
+
|
|
296
|
+
def refresh_feed(self) -> None:
|
|
297
|
+
self._set_stats("refetching feeds...")
|
|
298
|
+
self._load_jobs_worker(force=True)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class LibraryPane(Container):
|
|
302
|
+
"""Saved tailored resumes."""
|
|
303
|
+
|
|
304
|
+
DEFAULT_CSS = """
|
|
305
|
+
LibraryPane { layout: vertical; }
|
|
306
|
+
#lib_body { height: 1fr; padding: 1 1 0 1; }
|
|
307
|
+
#lib_table_wrap { width: 2fr; border: round #2a2d34; margin: 0 1 0 0; min-width: 50; }
|
|
308
|
+
#lib_preview_wrap { width: 1fr; border: round #f15a29 30%; padding: 1 2; min-width: 35; }
|
|
309
|
+
#lib_stats { dock: bottom; height: 1; color: $text-muted; padding: 0 2; }
|
|
310
|
+
DataTable { background: $surface; height: 1fr; }
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
items: reactive[list[SavedResume]] = reactive(list, recompose=False)
|
|
314
|
+
|
|
315
|
+
def compose(self) -> ComposeResult:
|
|
316
|
+
with Horizontal(id="lib_body"):
|
|
317
|
+
with Vertical(id="lib_table_wrap"):
|
|
318
|
+
yield DataTable(id="lib_table", cursor_type="row", zebra_stripes=True)
|
|
319
|
+
with Vertical(id="lib_preview_wrap"):
|
|
320
|
+
yield Markdown(
|
|
321
|
+
"No tailored resumes yet.\n\nGo to the Tailor tab, run `Ctrl+T`, then `Ctrl+L` to save it here.",
|
|
322
|
+
id="lib_preview",
|
|
323
|
+
)
|
|
324
|
+
yield Static("", id="lib_stats")
|
|
325
|
+
|
|
326
|
+
def on_mount(self) -> None:
|
|
327
|
+
table = self.query_one("#lib_table", DataTable)
|
|
328
|
+
table.add_columns("Date", "Company", "Title", "Size")
|
|
329
|
+
self.reload()
|
|
330
|
+
|
|
331
|
+
def reload(self) -> None:
|
|
332
|
+
items = list_tailored()
|
|
333
|
+
self.items = items
|
|
334
|
+
table = self.query_one("#lib_table", DataTable)
|
|
335
|
+
table.clear()
|
|
336
|
+
for r in items:
|
|
337
|
+
table.add_row(
|
|
338
|
+
r.timestamp.strftime("%Y-%m-%d %H:%M"),
|
|
339
|
+
r.company[:24],
|
|
340
|
+
r.title[:40],
|
|
341
|
+
f"{r.size_bytes:,}",
|
|
342
|
+
key=str(r.path),
|
|
343
|
+
)
|
|
344
|
+
self.query_one("#lib_stats", Static).update(
|
|
345
|
+
f"{len(items)} saved tailored resume(s) · ~/.local/share/finch-cli/resumes/"
|
|
346
|
+
)
|
|
347
|
+
if items:
|
|
348
|
+
table.move_cursor(row=0)
|
|
349
|
+
self._show(items[0])
|
|
350
|
+
|
|
351
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
352
|
+
if event.row_key is None:
|
|
353
|
+
return
|
|
354
|
+
path = Path(str(event.row_key.value))
|
|
355
|
+
item = next((r for r in self.items if r.path == path), None)
|
|
356
|
+
if item:
|
|
357
|
+
self._show(item)
|
|
358
|
+
|
|
359
|
+
def _show(self, item: SavedResume) -> None:
|
|
360
|
+
try:
|
|
361
|
+
text = item.path.read_text(encoding="utf-8")
|
|
362
|
+
self.query_one("#lib_preview", Markdown).update(text)
|
|
363
|
+
except Exception as e:
|
|
364
|
+
self.query_one("#lib_preview", Markdown).update(f"Could not read {item.path}: {e}")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class MatchPanel(Container):
|
|
368
|
+
"""Bottom-of-tailor panel: ATS-style score + matched / missing keywords."""
|
|
369
|
+
|
|
370
|
+
DEFAULT_CSS = """
|
|
371
|
+
MatchPanel {
|
|
372
|
+
height: 9;
|
|
373
|
+
border: round #f15a29 50%;
|
|
374
|
+
padding: 0 2;
|
|
375
|
+
margin: 1 1 0 1;
|
|
376
|
+
}
|
|
377
|
+
#match_title { color: $accent; text-style: bold; height: 1; }
|
|
378
|
+
#match_score_row { height: 1; }
|
|
379
|
+
#match_score_row > Static { height: 1; }
|
|
380
|
+
#match_score_label { width: auto; padding: 0 2 0 0; }
|
|
381
|
+
#match_score_bar { width: 1fr; color: #f4a261; }
|
|
382
|
+
#match_score_delta { width: auto; padding: 0 0 0 2; color: $text-muted; }
|
|
383
|
+
#match_matched { color: #a8d2a0; height: 2; }
|
|
384
|
+
#match_missing { color: #f4a261; height: 2; }
|
|
385
|
+
"""
|
|
386
|
+
|
|
387
|
+
def compose(self) -> ComposeResult:
|
|
388
|
+
yield Static("Match analysis", id="match_title")
|
|
389
|
+
with Horizontal(id="match_score_row"):
|
|
390
|
+
yield Static("no resume + job loaded", id="match_score_label")
|
|
391
|
+
yield Static("", id="match_score_bar")
|
|
392
|
+
yield Static("", id="match_score_delta")
|
|
393
|
+
yield Static("matched: -", id="match_matched")
|
|
394
|
+
yield Static("missing: -", id="match_missing")
|
|
395
|
+
|
|
396
|
+
def update_match(
|
|
397
|
+
self,
|
|
398
|
+
*,
|
|
399
|
+
base: MatchResult | None,
|
|
400
|
+
tailored: MatchResult | None,
|
|
401
|
+
) -> None:
|
|
402
|
+
focus = tailored if (tailored and tailored.total > 0) else base
|
|
403
|
+
if not focus or focus.total == 0:
|
|
404
|
+
self.query_one("#match_score_label", Static).update("no resume + job loaded")
|
|
405
|
+
self.query_one("#match_score_bar", Static).update("")
|
|
406
|
+
self.query_one("#match_score_delta", Static).update("")
|
|
407
|
+
self.query_one("#match_matched", Static).update("matched: -")
|
|
408
|
+
self.query_one("#match_missing", Static).update("missing: -")
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
lbl, color = score_label(focus.score)
|
|
412
|
+
self.query_one("#match_score_label", Static).update(
|
|
413
|
+
f"[{color}]{focus.score:>5.1f}% {lbl}[/] · {focus.matched_count} of {focus.total} keywords"
|
|
414
|
+
)
|
|
415
|
+
self.query_one("#match_score_bar", Static).update(bar(focus.score, 30))
|
|
416
|
+
if base and tailored and base.total > 0:
|
|
417
|
+
delta = tailored.score - base.score
|
|
418
|
+
sign = "+" if delta >= 0 else ""
|
|
419
|
+
color_d = "green" if delta > 0 else ("red" if delta < 0 else "white")
|
|
420
|
+
self.query_one("#match_score_delta", Static).update(
|
|
421
|
+
f"[dim]base[/] {base.score:.0f}% → [{color_d}]{sign}{delta:.0f}[/]"
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
self.query_one("#match_score_delta", Static).update("")
|
|
425
|
+
|
|
426
|
+
matched_str = ", ".join(focus.matched[:18])
|
|
427
|
+
if len(focus.matched) > 18:
|
|
428
|
+
matched_str += f", +{len(focus.matched) - 18} more"
|
|
429
|
+
self.query_one("#match_matched", Static).update(
|
|
430
|
+
f"[green]✓ matched ({focus.matched_count}):[/] {matched_str or '-'}"
|
|
431
|
+
)
|
|
432
|
+
missing_str = ", ".join(focus.missing[:14])
|
|
433
|
+
if len(focus.missing) > 14:
|
|
434
|
+
missing_str += f", +{len(focus.missing) - 14} more"
|
|
435
|
+
self.query_one("#match_missing", Static).update(
|
|
436
|
+
f"[#f4a261]✗ missing ({len(focus.missing)}):[/] {missing_str or '-'}"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
class TailorPane(Container):
|
|
441
|
+
"""Three-pane editor + match-analysis panel."""
|
|
442
|
+
|
|
443
|
+
DEFAULT_CSS = """
|
|
444
|
+
TailorPane { layout: vertical; }
|
|
445
|
+
#tailor_row { height: 1fr; padding: 1 1 0 1; }
|
|
446
|
+
.tailor_col {
|
|
447
|
+
width: 1fr;
|
|
448
|
+
border: round #2a2d34;
|
|
449
|
+
margin: 0 1 0 0;
|
|
450
|
+
min-width: 30;
|
|
451
|
+
}
|
|
452
|
+
.tailor_col:last-of-type { margin-right: 0; }
|
|
453
|
+
.tailor_col Label.pane_label {
|
|
454
|
+
height: 1; background: #f15a29 25%; color: #fff1e1;
|
|
455
|
+
padding: 0 1; text-style: bold;
|
|
456
|
+
}
|
|
457
|
+
TextArea { height: 1fr; border: none; padding: 0 1; }
|
|
458
|
+
#tailor_status { dock: bottom; height: 1; color: $text-muted; padding: 0 2; }
|
|
459
|
+
"""
|
|
460
|
+
|
|
461
|
+
busy: reactive[bool] = reactive(False)
|
|
462
|
+
|
|
463
|
+
def compose(self) -> ComposeResult:
|
|
464
|
+
with Horizontal(id="tailor_row"):
|
|
465
|
+
with Vertical(classes="tailor_col"):
|
|
466
|
+
yield Label("Base resume (markdown)", classes="pane_label")
|
|
467
|
+
yield TextArea.code_editor("", language="markdown", id="resume_ta")
|
|
468
|
+
with Vertical(classes="tailor_col"):
|
|
469
|
+
yield Label("Job posting", classes="pane_label")
|
|
470
|
+
yield TextArea("", id="job_ta")
|
|
471
|
+
with Vertical(classes="tailor_col"):
|
|
472
|
+
yield Label("Tailored resume", classes="pane_label")
|
|
473
|
+
yield TextArea.code_editor("", language="markdown", read_only=True, id="out_ta")
|
|
474
|
+
yield MatchPanel(id="match_panel")
|
|
475
|
+
yield Static(
|
|
476
|
+
"Ctrl+T tailor · Ctrl+U paste URL · Ctrl+O open resume · Ctrl+L save to library · Ctrl+S save to file",
|
|
477
|
+
id="tailor_status",
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def set_resume(self, text: str) -> None:
|
|
481
|
+
self.query_one("#resume_ta", TextArea).text = text
|
|
482
|
+
self.recompute_match()
|
|
483
|
+
|
|
484
|
+
def set_job(self, text: str) -> None:
|
|
485
|
+
self.query_one("#job_ta", TextArea).text = text
|
|
486
|
+
self.recompute_match()
|
|
487
|
+
|
|
488
|
+
def load_demo(self) -> None:
|
|
489
|
+
rp = EXAMPLES / "base_resume.md"
|
|
490
|
+
jp = EXAMPLES / "job_post.txt"
|
|
491
|
+
if rp.exists():
|
|
492
|
+
self.query_one("#resume_ta", TextArea).text = rp.read_text(encoding="utf-8")
|
|
493
|
+
if jp.exists():
|
|
494
|
+
self.query_one("#job_ta", TextArea).text = jp.read_text(encoding="utf-8")
|
|
495
|
+
self.query_one("#tailor_status", Static).update("demo loaded · Ctrl+T to tailor")
|
|
496
|
+
self.recompute_match()
|
|
497
|
+
|
|
498
|
+
def get_resume(self) -> str:
|
|
499
|
+
return self.query_one("#resume_ta", TextArea).text
|
|
500
|
+
|
|
501
|
+
def get_job(self) -> str:
|
|
502
|
+
return self.query_one("#job_ta", TextArea).text
|
|
503
|
+
|
|
504
|
+
def get_output(self) -> str:
|
|
505
|
+
return self.query_one("#out_ta", TextArea).text
|
|
506
|
+
|
|
507
|
+
def set_output(self, text: str) -> None:
|
|
508
|
+
self.query_one("#out_ta", TextArea).text = text
|
|
509
|
+
self.recompute_match()
|
|
510
|
+
|
|
511
|
+
def set_status(self, text: str) -> None:
|
|
512
|
+
self.query_one("#tailor_status", Static).update(text)
|
|
513
|
+
|
|
514
|
+
def recompute_match(self) -> None:
|
|
515
|
+
resume = self.get_resume()
|
|
516
|
+
job = self.get_job()
|
|
517
|
+
out = self.get_output()
|
|
518
|
+
base = score_match(resume, job)
|
|
519
|
+
tailored = score_match(out, job) if out.strip() else None
|
|
520
|
+
try:
|
|
521
|
+
self.query_one(MatchPanel).update_match(base=base, tailored=tailored)
|
|
522
|
+
except Exception:
|
|
523
|
+
pass
|
|
524
|
+
|
|
525
|
+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
526
|
+
# Debounced match recompute would be nicer; keystroke recompute is
|
|
527
|
+
# cheap enough at this corpus size.
|
|
528
|
+
if event.text_area.id in ("resume_ta", "job_ta", "out_ta"):
|
|
529
|
+
self.recompute_match()
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
533
|
+
# app
|
|
534
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class FinchTUI(App):
|
|
538
|
+
"""Finch terminal client."""
|
|
539
|
+
|
|
540
|
+
CSS = """
|
|
541
|
+
Screen { layout: vertical; background: #16181d; }
|
|
542
|
+
Header { background: #f15a29 20%; color: #f7d4a4; }
|
|
543
|
+
#branding {
|
|
544
|
+
dock: top; height: 1; background: #f15a29 30%;
|
|
545
|
+
color: #fff1e1; padding: 0 2; layout: horizontal;
|
|
546
|
+
}
|
|
547
|
+
#branding > Label { width: 1fr; }
|
|
548
|
+
#branding > .right { width: auto; color: #fcc28b; text-align: right; }
|
|
549
|
+
TabbedContent { height: 1fr; }
|
|
550
|
+
TabPane { padding: 0; }
|
|
551
|
+
Tabs { background: #1d2026; }
|
|
552
|
+
Tabs > Tab { color: #d0d4dc; padding: 0 2; }
|
|
553
|
+
Tabs > Tab:focus { color: #ffb781; }
|
|
554
|
+
Tabs > Tab.-active { background: #f15a29 25%; color: #fff1e1; text-style: bold; }
|
|
555
|
+
DataTable { background: #16181d; color: #d0d4dc; }
|
|
556
|
+
DataTable > .datatable--header { background: #2a2d34; color: #f4a261; text-style: bold; }
|
|
557
|
+
DataTable > .datatable--cursor { background: #f15a29 25%; }
|
|
558
|
+
DataTable > .datatable--odd-row { background: #1a1d23; }
|
|
559
|
+
Markdown { background: #16181d; color: #d0d4dc; padding: 0; }
|
|
560
|
+
Markdown > MarkdownH1 { color: #f4a261; text-style: bold; }
|
|
561
|
+
Markdown > MarkdownH3 { color: #f4a261; text-style: bold; }
|
|
562
|
+
Markdown > MarkdownFence { background: #1d2026; }
|
|
563
|
+
Input { background: #1d2026; color: #d0d4dc; border: round #2a2d34; }
|
|
564
|
+
Input:focus { border: round #f15a29; }
|
|
565
|
+
TextArea { background: #16181d; color: #d0d4dc; }
|
|
566
|
+
#modal_box {
|
|
567
|
+
background: #1d2026; border: thick #f15a29; padding: 1 2;
|
|
568
|
+
width: 70; max-width: 90%; height: auto; offset: 50% 30%;
|
|
569
|
+
}
|
|
570
|
+
#modal_buttons { height: 3; padding-top: 1; align: center middle; }
|
|
571
|
+
Button { margin: 0 1; }
|
|
572
|
+
Footer { background: #1d2026; color: #d0d4dc; }
|
|
573
|
+
Footer > .footer--highlight { background: #f15a29 50%; color: #fff1e1; }
|
|
574
|
+
"""
|
|
575
|
+
|
|
576
|
+
BINDINGS = [
|
|
577
|
+
Binding("ctrl+t", "tailor_action", "Tailor", priority=True),
|
|
578
|
+
Binding("ctrl+u", "paste_url", "Paste URL"),
|
|
579
|
+
Binding("ctrl+o", "open_resume", "Open resume"),
|
|
580
|
+
Binding("ctrl+s", "save_file", "Save to file"),
|
|
581
|
+
Binding("ctrl+l", "save_library", "Save to library"),
|
|
582
|
+
Binding("ctrl+r", "refresh_jobs", "Refresh jobs"),
|
|
583
|
+
Binding("ctrl+d", "load_demo", "Demo"),
|
|
584
|
+
Binding("ctrl+q", "quit", "Quit", priority=True),
|
|
585
|
+
Binding("1", "show_jobs", "Jobs", show=False),
|
|
586
|
+
Binding("2", "show_library", "Library", show=False),
|
|
587
|
+
Binding("3", "show_tailor", "Tailor", show=False),
|
|
588
|
+
]
|
|
589
|
+
|
|
590
|
+
TITLE = "finch · applyfinch.com"
|
|
591
|
+
|
|
592
|
+
def __init__(self, *, demo: bool = False, model: str = DEFAULT_MODEL) -> None:
|
|
593
|
+
super().__init__()
|
|
594
|
+
self.demo = demo
|
|
595
|
+
self.model = model
|
|
596
|
+
|
|
597
|
+
def compose(self) -> ComposeResult:
|
|
598
|
+
yield Header(show_clock=False)
|
|
599
|
+
with Horizontal(id="branding"):
|
|
600
|
+
yield Label(
|
|
601
|
+
"Finch · tailor resumes for any job, from your terminal",
|
|
602
|
+
id="brand_left",
|
|
603
|
+
)
|
|
604
|
+
key_env = detected_key_env()
|
|
605
|
+
key_state = key_env if key_env else "unset"
|
|
606
|
+
yield Label(
|
|
607
|
+
f"model: {self.model} · key: {key_state}",
|
|
608
|
+
classes="right",
|
|
609
|
+
)
|
|
610
|
+
with TabbedContent(initial="tab_jobs", id="tabs"):
|
|
611
|
+
with TabPane("Jobs", id="tab_jobs"):
|
|
612
|
+
yield JobsPane()
|
|
613
|
+
with TabPane("Library", id="tab_library"):
|
|
614
|
+
yield LibraryPane()
|
|
615
|
+
with TabPane("Tailor", id="tab_tailor"):
|
|
616
|
+
yield TailorPane()
|
|
617
|
+
yield Footer()
|
|
618
|
+
|
|
619
|
+
def on_mount(self) -> None:
|
|
620
|
+
if self.demo:
|
|
621
|
+
self.query_one(TailorPane).load_demo()
|
|
622
|
+
# Also push the demo resume into JobsPane so per-job scores show
|
|
623
|
+
self.query_one(JobsPane).base_resume_text = self.query_one(TailorPane).get_resume()
|
|
624
|
+
|
|
625
|
+
# ── tab nav ──
|
|
626
|
+
def action_show_jobs(self) -> None:
|
|
627
|
+
self.query_one(TabbedContent).active = "tab_jobs"
|
|
628
|
+
|
|
629
|
+
def action_show_library(self) -> None:
|
|
630
|
+
self.query_one(TabbedContent).active = "tab_library"
|
|
631
|
+
self.query_one(LibraryPane).reload()
|
|
632
|
+
|
|
633
|
+
def action_show_tailor(self) -> None:
|
|
634
|
+
self.query_one(TabbedContent).active = "tab_tailor"
|
|
635
|
+
self.query_one(TailorPane).recompute_match()
|
|
636
|
+
|
|
637
|
+
# ── actions ──
|
|
638
|
+
def action_refresh_jobs(self) -> None:
|
|
639
|
+
self.query_one(JobsPane).refresh_feed()
|
|
640
|
+
|
|
641
|
+
def action_load_demo(self) -> None:
|
|
642
|
+
self.query_one(TailorPane).load_demo()
|
|
643
|
+
self.query_one(JobsPane).base_resume_text = self.query_one(TailorPane).get_resume()
|
|
644
|
+
self.action_show_tailor()
|
|
645
|
+
|
|
646
|
+
def action_paste_url(self) -> None:
|
|
647
|
+
self.push_screen(URLInputScreen(), self._on_url_entered)
|
|
648
|
+
|
|
649
|
+
def _on_url_entered(self, url: str | None) -> None:
|
|
650
|
+
if not url:
|
|
651
|
+
return
|
|
652
|
+
tailor = self.query_one(TailorPane)
|
|
653
|
+
tailor.set_status(f"fetching {url}...")
|
|
654
|
+
self.action_show_tailor()
|
|
655
|
+
self._fetch_job_url(url)
|
|
656
|
+
|
|
657
|
+
@work(thread=True)
|
|
658
|
+
def _fetch_job_url(self, url: str) -> None:
|
|
659
|
+
try:
|
|
660
|
+
text = fetch_job(url)
|
|
661
|
+
except FetchError as e:
|
|
662
|
+
self.call_from_thread(self.query_one(TailorPane).set_status, f"fetch failed: {e}")
|
|
663
|
+
return
|
|
664
|
+
except Exception as e:
|
|
665
|
+
self.call_from_thread(
|
|
666
|
+
self.query_one(TailorPane).set_status, f"{type(e).__name__}: {e}"
|
|
667
|
+
)
|
|
668
|
+
return
|
|
669
|
+
|
|
670
|
+
def apply() -> None:
|
|
671
|
+
tailor = self.query_one(TailorPane)
|
|
672
|
+
tailor.set_job(text)
|
|
673
|
+
tailor.set_status(f"fetched {len(text):,} chars from {url}")
|
|
674
|
+
|
|
675
|
+
self.call_from_thread(apply)
|
|
676
|
+
|
|
677
|
+
def action_open_resume(self) -> None:
|
|
678
|
+
default = str(EXAMPLES / "base_resume.md")
|
|
679
|
+
self.push_screen(FilePathScreen("Open base resume", default), self._on_resume_chosen)
|
|
680
|
+
|
|
681
|
+
def _on_resume_chosen(self, path: str | None) -> None:
|
|
682
|
+
if not path:
|
|
683
|
+
return
|
|
684
|
+
p = Path(path).expanduser()
|
|
685
|
+
if not p.exists():
|
|
686
|
+
self.query_one(TailorPane).set_status(f"file not found: {p}")
|
|
687
|
+
return
|
|
688
|
+
try:
|
|
689
|
+
text = p.read_text(encoding="utf-8")
|
|
690
|
+
self.query_one(TailorPane).set_resume(text)
|
|
691
|
+
self.query_one(JobsPane).base_resume_text = text
|
|
692
|
+
self.query_one(TailorPane).set_status(f"loaded {p}")
|
|
693
|
+
self.action_show_tailor()
|
|
694
|
+
except Exception as e:
|
|
695
|
+
self.query_one(TailorPane).set_status(f"open failed: {e}")
|
|
696
|
+
|
|
697
|
+
def action_save_file(self) -> None:
|
|
698
|
+
self.push_screen(FilePathScreen("Save tailored resume to file", "tailored.md"), self._on_save_chosen)
|
|
699
|
+
|
|
700
|
+
def _on_save_chosen(self, path: str | None) -> None:
|
|
701
|
+
if not path:
|
|
702
|
+
return
|
|
703
|
+
p = Path(path).expanduser()
|
|
704
|
+
try:
|
|
705
|
+
p.write_text(self.query_one(TailorPane).get_output(), encoding="utf-8")
|
|
706
|
+
self.query_one(TailorPane).set_status(f"saved to {p}")
|
|
707
|
+
except Exception as e:
|
|
708
|
+
self.query_one(TailorPane).set_status(f"save failed: {e}")
|
|
709
|
+
|
|
710
|
+
def action_save_library(self) -> None:
|
|
711
|
+
out = self.query_one(TailorPane).get_output().strip()
|
|
712
|
+
if not out:
|
|
713
|
+
self.query_one(TailorPane).set_status("nothing to save (output is empty)")
|
|
714
|
+
return
|
|
715
|
+
job = self.query_one(JobsPane).current_selection()
|
|
716
|
+
company = job.company if job else "manual"
|
|
717
|
+
title = job.title if job else "tailored"
|
|
718
|
+
p = save_tailored(out, company=company, title=title)
|
|
719
|
+
self.query_one(TailorPane).set_status(f"saved to library: {p}")
|
|
720
|
+
self.query_one(LibraryPane).reload()
|
|
721
|
+
|
|
722
|
+
def action_tailor_action(self) -> None:
|
|
723
|
+
tabs = self.query_one(TabbedContent)
|
|
724
|
+
if tabs.active == "tab_jobs":
|
|
725
|
+
job = self.query_one(JobsPane).current_selection()
|
|
726
|
+
if job:
|
|
727
|
+
self.query_one(TailorPane).set_job(_job_to_markdown(job))
|
|
728
|
+
self.action_show_tailor()
|
|
729
|
+
if self.query_one(TailorPane).busy:
|
|
730
|
+
return
|
|
731
|
+
self._run_tailor()
|
|
732
|
+
|
|
733
|
+
def _run_tailor(self) -> None:
|
|
734
|
+
tailor = self.query_one(TailorPane)
|
|
735
|
+
resume = tailor.get_resume().strip()
|
|
736
|
+
job = tailor.get_job().strip()
|
|
737
|
+
if not resume:
|
|
738
|
+
tailor.set_status("base resume is empty - load one with Ctrl+O")
|
|
739
|
+
self.action_show_tailor()
|
|
740
|
+
return
|
|
741
|
+
if not job:
|
|
742
|
+
tailor.set_status("job posting is empty - pick one from Jobs tab or paste with Ctrl+U")
|
|
743
|
+
self.action_show_tailor()
|
|
744
|
+
return
|
|
745
|
+
if not detected_key_env():
|
|
746
|
+
tailor.set_status(
|
|
747
|
+
"no API key set - export DEEPSEEK_API_KEY (or FINCH_API_KEY, or OPENAI_API_KEY) and relaunch"
|
|
748
|
+
)
|
|
749
|
+
self.action_show_tailor()
|
|
750
|
+
return
|
|
751
|
+
tailor.busy = True
|
|
752
|
+
tailor.set_status("tailoring against the job...")
|
|
753
|
+
tailor.set_output("")
|
|
754
|
+
self.action_show_tailor()
|
|
755
|
+
self._tailor_worker(resume, job)
|
|
756
|
+
|
|
757
|
+
@work(thread=True)
|
|
758
|
+
def _tailor_worker(self, resume: str, job: str) -> None:
|
|
759
|
+
try:
|
|
760
|
+
out = tailor_resume(resume, job, model=self.model)
|
|
761
|
+
except TailorError as e:
|
|
762
|
+
self.call_from_thread(self._finish_tailor, None, str(e))
|
|
763
|
+
return
|
|
764
|
+
except Exception as e:
|
|
765
|
+
self.call_from_thread(self._finish_tailor, None, f"{type(e).__name__}: {e}")
|
|
766
|
+
return
|
|
767
|
+
self.call_from_thread(self._finish_tailor, out, None)
|
|
768
|
+
|
|
769
|
+
def _finish_tailor(self, out: str | None, err: str | None) -> None:
|
|
770
|
+
tailor = self.query_one(TailorPane)
|
|
771
|
+
tailor.busy = False
|
|
772
|
+
if err:
|
|
773
|
+
tailor.set_status(f"tailoring failed: {err}")
|
|
774
|
+
return
|
|
775
|
+
tailor.set_output(out or "")
|
|
776
|
+
tailor.set_status(
|
|
777
|
+
f"done · {len(out or ''):,} chars · Ctrl+L save to library, Ctrl+S save to file"
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def run(*, demo: bool = False, model: str = DEFAULT_MODEL) -> None:
|
|
782
|
+
FinchTUI(demo=demo, model=model).run()
|