chameleon-cv 0.1.0

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.
Files changed (151) hide show
  1. package/AGENTS.md +186 -0
  2. package/LICENSE +21 -0
  3. package/Makefile +136 -0
  4. package/README.md +356 -0
  5. package/bin/chameleon.js +136 -0
  6. package/chameleon.bat +286 -0
  7. package/chameleon.sh +353 -0
  8. package/package.json +39 -0
  9. package/profiles/github_repos.json +1266 -0
  10. package/requirements.txt +5 -0
  11. package/scan.sh +12 -0
  12. package/score.sh +12 -0
  13. package/scripts/__init__.py +1 -0
  14. package/scripts/__main_tui__.py +11 -0
  15. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  16. package/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  17. package/scripts/__pycache__/__main_tui__.cpython-313.pyc +0 -0
  18. package/scripts/__pycache__/ats_ghost.cpython-313.pyc +0 -0
  19. package/scripts/__pycache__/cache.cpython-313.pyc +0 -0
  20. package/scripts/__pycache__/config.cpython-312.pyc +0 -0
  21. package/scripts/__pycache__/config.cpython-313.pyc +0 -0
  22. package/scripts/__pycache__/cover_letter.cpython-313.pyc +0 -0
  23. package/scripts/__pycache__/db.cpython-312.pyc +0 -0
  24. package/scripts/__pycache__/db.cpython-313.pyc +0 -0
  25. package/scripts/__pycache__/fonts.cpython-312.pyc +0 -0
  26. package/scripts/__pycache__/fonts.cpython-313.pyc +0 -0
  27. package/scripts/__pycache__/ghost.cpython-313.pyc +0 -0
  28. package/scripts/__pycache__/job_matcher.cpython-312.pyc +0 -0
  29. package/scripts/__pycache__/job_matcher.cpython-313.pyc +0 -0
  30. package/scripts/__pycache__/question.cpython-313.pyc +0 -0
  31. package/scripts/__pycache__/render.cpython-313.pyc +0 -0
  32. package/scripts/__pycache__/review.cpython-313.pyc +0 -0
  33. package/scripts/__pycache__/setup.cpython-312.pyc +0 -0
  34. package/scripts/__pycache__/tailor_brief.cpython-312.pyc +0 -0
  35. package/scripts/__pycache__/tailor_brief.cpython-313.pyc +0 -0
  36. package/scripts/__pycache__/tailor_cv.cpython-312.pyc +0 -0
  37. package/scripts/__pycache__/tailor_cv.cpython-313.pyc +0 -0
  38. package/scripts/__pycache__/tailor_prompts.cpython-312.pyc +0 -0
  39. package/scripts/__pycache__/tailor_prompts.cpython-313.pyc +0 -0
  40. package/scripts/__pycache__/tui_app.cpython-312.pyc +0 -0
  41. package/scripts/__pycache__/tui_app.cpython-313.pyc +0 -0
  42. package/scripts/ats_ghost.py +170 -0
  43. package/scripts/cache.py +63 -0
  44. package/scripts/cli.py +109 -0
  45. package/scripts/config.py +376 -0
  46. package/scripts/cover_letter.py +105 -0
  47. package/scripts/db.py +331 -0
  48. package/scripts/fonts.py +186 -0
  49. package/scripts/ghost.py +88 -0
  50. package/scripts/job_matcher.py +636 -0
  51. package/scripts/job_scanner/__init__.py +8 -0
  52. package/scripts/job_scanner/__main__.py +6 -0
  53. package/scripts/job_scanner/__pycache__/__init__.cpython-312.pyc +0 -0
  54. package/scripts/job_scanner/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/scripts/job_scanner/__pycache__/__main__.cpython-312.pyc +0 -0
  56. package/scripts/job_scanner/__pycache__/__main__.cpython-313.pyc +0 -0
  57. package/scripts/job_scanner/__pycache__/base.cpython-312.pyc +0 -0
  58. package/scripts/job_scanner/__pycache__/base.cpython-313.pyc +0 -0
  59. package/scripts/job_scanner/__pycache__/browser.cpython-312.pyc +0 -0
  60. package/scripts/job_scanner/__pycache__/browser.cpython-313.pyc +0 -0
  61. package/scripts/job_scanner/__pycache__/cli.cpython-313.pyc +0 -0
  62. package/scripts/job_scanner/__pycache__/registry.cpython-312.pyc +0 -0
  63. package/scripts/job_scanner/__pycache__/registry.cpython-313.pyc +0 -0
  64. package/scripts/job_scanner/__pycache__/scanner.cpython-312.pyc +0 -0
  65. package/scripts/job_scanner/__pycache__/scanner.cpython-313.pyc +0 -0
  66. package/scripts/job_scanner/base.py +197 -0
  67. package/scripts/job_scanner/browser.py +244 -0
  68. package/scripts/job_scanner/cli.py +6 -0
  69. package/scripts/job_scanner/parsers/__init__.py +24 -0
  70. package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-312.pyc +0 -0
  71. package/scripts/job_scanner/parsers/__pycache__/__init__.cpython-313.pyc +0 -0
  72. package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-312.pyc +0 -0
  73. package/scripts/job_scanner/parsers/__pycache__/adzuna.cpython-313.pyc +0 -0
  74. package/scripts/job_scanner/parsers/__pycache__/arc.cpython-312.pyc +0 -0
  75. package/scripts/job_scanner/parsers/__pycache__/arc.cpython-313.pyc +0 -0
  76. package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-312.pyc +0 -0
  77. package/scripts/job_scanner/parsers/__pycache__/crypto_careers.cpython-313.pyc +0 -0
  78. package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-312.pyc +0 -0
  79. package/scripts/job_scanner/parsers/__pycache__/dailyremote.cpython-313.pyc +0 -0
  80. package/scripts/job_scanner/parsers/__pycache__/flexjobs.cpython-313.pyc +0 -0
  81. package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-312.pyc +0 -0
  82. package/scripts/job_scanner/parsers/__pycache__/himalayas.cpython-313.pyc +0 -0
  83. package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-312.pyc +0 -0
  84. package/scripts/job_scanner/parsers/__pycache__/hn_hiring.cpython-313.pyc +0 -0
  85. package/scripts/job_scanner/parsers/__pycache__/indeed.cpython-313.pyc +0 -0
  86. package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-312.pyc +0 -0
  87. package/scripts/job_scanner/parsers/__pycache__/jobicy.cpython-313.pyc +0 -0
  88. package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-312.pyc +0 -0
  89. package/scripts/job_scanner/parsers/__pycache__/jooble.cpython-313.pyc +0 -0
  90. package/scripts/job_scanner/parsers/__pycache__/linkedin.cpython-313.pyc +0 -0
  91. package/scripts/job_scanner/parsers/__pycache__/opentoworkremote.cpython-313.pyc +0 -0
  92. package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-312.pyc +0 -0
  93. package/scripts/job_scanner/parsers/__pycache__/relocate.cpython-313.pyc +0 -0
  94. package/scripts/job_scanner/parsers/__pycache__/remote_rocketship.cpython-313.pyc +0 -0
  95. package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-312.pyc +0 -0
  96. package/scripts/job_scanner/parsers/__pycache__/remoteok.cpython-313.pyc +0 -0
  97. package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-312.pyc +0 -0
  98. package/scripts/job_scanner/parsers/__pycache__/remotive.cpython-313.pyc +0 -0
  99. package/scripts/job_scanner/parsers/__pycache__/web3_career.cpython-313.pyc +0 -0
  100. package/scripts/job_scanner/parsers/__pycache__/wellfound.cpython-313.pyc +0 -0
  101. package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-312.pyc +0 -0
  102. package/scripts/job_scanner/parsers/__pycache__/weworkremotely.cpython-313.pyc +0 -0
  103. package/scripts/job_scanner/parsers/__pycache__/ycomb.cpython-313.pyc +0 -0
  104. package/scripts/job_scanner/parsers/adzuna.py +81 -0
  105. package/scripts/job_scanner/parsers/arc.py +65 -0
  106. package/scripts/job_scanner/parsers/crypto_careers.py +88 -0
  107. package/scripts/job_scanner/parsers/dailyremote.py +91 -0
  108. package/scripts/job_scanner/parsers/flexjobs.py +91 -0
  109. package/scripts/job_scanner/parsers/himalayas.py +47 -0
  110. package/scripts/job_scanner/parsers/hn_hiring.py +94 -0
  111. package/scripts/job_scanner/parsers/indeed.py +98 -0
  112. package/scripts/job_scanner/parsers/jobicy.py +60 -0
  113. package/scripts/job_scanner/parsers/jooble.py +68 -0
  114. package/scripts/job_scanner/parsers/linkedin.py +140 -0
  115. package/scripts/job_scanner/parsers/opentoworkremote.py +53 -0
  116. package/scripts/job_scanner/parsers/relocate.py +95 -0
  117. package/scripts/job_scanner/parsers/remote_rocketship.py +90 -0
  118. package/scripts/job_scanner/parsers/remoteok.py +63 -0
  119. package/scripts/job_scanner/parsers/remotive.py +57 -0
  120. package/scripts/job_scanner/parsers/web3_career.py +92 -0
  121. package/scripts/job_scanner/parsers/wellfound.py +94 -0
  122. package/scripts/job_scanner/parsers/weworkremotely.py +60 -0
  123. package/scripts/job_scanner/parsers/ycomb.py +83 -0
  124. package/scripts/job_scanner/registry.py +65 -0
  125. package/scripts/job_scanner/scanner.py +162 -0
  126. package/scripts/question.py +107 -0
  127. package/scripts/render.py +110 -0
  128. package/scripts/review.py +89 -0
  129. package/scripts/setup.py +139 -0
  130. package/scripts/tailor_brief.py +379 -0
  131. package/scripts/tailor_cv.py +746 -0
  132. package/scripts/tailor_prompts.py +101 -0
  133. package/scripts/tui/__init__.py +1 -0
  134. package/scripts/tui/__pycache__/__init__.cpython-312.pyc +0 -0
  135. package/scripts/tui/__pycache__/__init__.cpython-313.pyc +0 -0
  136. package/scripts/tui/__pycache__/app.cpython-312.pyc +0 -0
  137. package/scripts/tui/__pycache__/app.cpython-313.pyc +0 -0
  138. package/scripts/tui/__pycache__/helpers.cpython-312.pyc +0 -0
  139. package/scripts/tui/__pycache__/helpers.cpython-313.pyc +0 -0
  140. package/scripts/tui/__pycache__/screens.cpython-312.pyc +0 -0
  141. package/scripts/tui/__pycache__/screens.cpython-313.pyc +0 -0
  142. package/scripts/tui/__pycache__/widgets.cpython-312.pyc +0 -0
  143. package/scripts/tui/__pycache__/widgets.cpython-313.pyc +0 -0
  144. package/scripts/tui/app.py +1547 -0
  145. package/scripts/tui/helpers.py +65 -0
  146. package/scripts/tui/screens.py +642 -0
  147. package/scripts/tui/widgets.py +276 -0
  148. package/scripts/tui_app.py +25 -0
  149. package/scripts/wizard.py +109 -0
  150. package/tui.bat +50 -0
  151. package/tui.sh +59 -0
@@ -0,0 +1,1547 @@
1
+ """Chameleon TUI — main application class.
2
+
3
+ Scan, score, search, edit, tailor, diff, settings.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import atexit
8
+ import json
9
+ import signal
10
+ import subprocess
11
+ import sys
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import TextIO
15
+
16
+ from textual import on, work
17
+ from textual.app import App, ComposeResult
18
+ from textual.binding import Binding
19
+ from textual.containers import Horizontal, Vertical, VerticalScroll
20
+ from textual.widgets import (
21
+ DataTable, Footer, Header, Input, RichLog, Static, TabbedContent, TabPane, Tree,
22
+ )
23
+
24
+ # ── Project root on sys.path (needed for scripts.* imports) ───────────────
25
+ _CHAMELEON = Path(__file__).resolve().parent.parent.parent
26
+ if str(_CHAMELEON) not in sys.path:
27
+ sys.path.insert(0, str(_CHAMELEON))
28
+
29
+ from scripts.config import config
30
+ from scripts.db import (
31
+ compute_job_id, delete_job, get_all_jobs, get_tailored_cvs,
32
+ save_jobs, save_tailored_cv, update_job_score,
33
+ )
34
+ from scripts.job_matcher import load_profile, score_job, extract_job_signals, find_matching_repos
35
+ from scripts.tailor_cv import kill_all_processes as kill_tailor_processes, tailor_and_render
36
+ from scripts.tailor_brief import (
37
+ analyze_seniority, analyze_experience_relevance, analyze_repos_for_jd,
38
+ find_gaps, load_cv_yaml, generate_brief, render_brief_text,
39
+ )
40
+
41
+ from scripts.tui.helpers import make_diff, safe_str, score_badge, score_bar, score_summary, truncate
42
+ from scripts.tui.screens import (
43
+ ConfirmQuitScreen, CustomScanScreen, DeleteConfirmScreen, HelpScreen, PasteJobScreen,
44
+ SetupWizardScreen,
45
+ )
46
+ from scripts.tui.widgets import NanoEditor, SettingsPane
47
+
48
+ # ── Paths (from config, not hardcoded) ─────────────────────────────────────
49
+ PROFILE_PATH = config.profile_path()
50
+ CV_PATH = config.master_cv_path()
51
+ SCANS_DIR = config.scans_dir()
52
+ ANALYSES_DIR = config.analyses_dir()
53
+ TEMPLATES_DIR = config.templates_dir()
54
+ VENV_PYTHON = config.venv_python()
55
+
56
+
57
+ # ═══════════════════════════════════════════════════════════════════════════
58
+ # TeeStream — duplicate stdout to terminal AND TUI log in real-time
59
+ # ═══════════════════════════════════════════════════════════════════════════
60
+ class TeeStream:
61
+ """Writes to both the real stdout and a TUI log callback, line by line."""
62
+
63
+ def __init__(self, real_stdout: TextIO, log_cb) -> None:
64
+ self._real = real_stdout
65
+ self._log = log_cb
66
+ self._buf = ""
67
+
68
+ def _real_open(self) -> bool:
69
+ """Check whether the real stream accepts writes (handles missing .closed)."""
70
+ real = self._real
71
+ if real is None:
72
+ return False
73
+ try:
74
+ return not real.closed # type: ignore[union-attr]
75
+ except AttributeError:
76
+ return True # no .closed attr => assume open (e.g. Textual's _PrintCapture)
77
+
78
+ def write(self, text: str) -> None:
79
+ if self._real_open():
80
+ try:
81
+ self._real.write(text)
82
+ self._real.flush()
83
+ except (BrokenPipeError, OSError, AttributeError):
84
+ pass
85
+ self._buf += text
86
+ while "\n" in self._buf:
87
+ line, self._buf = self._buf.split("\n", 1)
88
+ if line.strip():
89
+ try:
90
+ self._log(f"[dim]tailor|[/dim] {line}")
91
+ except Exception:
92
+ pass
93
+
94
+ def flush(self) -> None:
95
+ if self._real_open():
96
+ try:
97
+ self._real.flush()
98
+ except (BrokenPipeError, OSError, AttributeError):
99
+ pass
100
+ if self._buf.strip():
101
+ try:
102
+ self._log(f"[dim]tailor|[/dim] {self._buf.strip()}")
103
+ except Exception:
104
+ pass
105
+ self._buf = ""
106
+
107
+ def fileno(self) -> int:
108
+ return self._real.fileno()
109
+
110
+ def close(self) -> None:
111
+ pass # don't close the real stdout — we don't own it
112
+
113
+ def isatty(self) -> bool:
114
+ return False
115
+
116
+
117
+ # ═══════════════════════════════════════════════════════════════════════════
118
+ # MAIN APP
119
+ # ═══════════════════════════════════════════════════════════════════════════
120
+ class ChameleonTUI(App):
121
+ """Chameleon Job TUI — scan, score, search, edit, tailor, diff, settings."""
122
+
123
+ TITLE = "Chameleon \u2014 Job Matcher TUI"
124
+ SUB_TITLE = "Scan \u00b7 Score \u00b7 Search \u00b7 Tailor \u00b7 Diff \u00b7 Settings"
125
+
126
+ CSS = """
127
+ Screen { background: $surface; }
128
+
129
+ /* ── Search bar ── */
130
+ #search_bar { dock: top; height: 3; background: $surface-darken-1; padding: 0 1; layout: horizontal; border-bottom: solid $primary-darken-2; }
131
+ #search_bar Input { width: 1fr; }
132
+ #search_count { width: auto; content-align: right middle; padding: 0 1 0 0; color: $text-muted; min-width: 8; }
133
+
134
+ /* ── Status + hints ── */
135
+ #status_line { dock: bottom; height: 1; background: $secondary-darken-2; color: $text-muted; padding: 0 1; }
136
+ #key_hints { dock: bottom; height: 1; background: $primary-darken-1; color: $text; padding: 0 1; text-style: dim; }
137
+
138
+ /* ── Tabs ── */
139
+ #main_tabs { height: 1fr; }
140
+ TabbedContent > TabPane { padding: 0; }
141
+
142
+ /* ── Jobs tab ── */
143
+ #job_table { height: 1fr; }
144
+ #detail_pane { width: 1fr; height: 1fr; border: tall $primary-darken-1; background: $surface-darken-1; }
145
+
146
+ /* ── Editor tab ── */
147
+ #editor_container { height: 1fr; }
148
+ #editor_placeholder { width: 100%; height: 100%; content-align: center middle; text-align: center; color: $text-muted; padding: 2 4; }
149
+
150
+ /* ── Profile tab ── */
151
+ #profile_tree { height: 1fr; }
152
+
153
+ /* ── Scanner tab ── */
154
+ #scan_log { height: 1fr; }
155
+
156
+ /* ── Tailored tab ── */
157
+ #tailored_table { height: 1fr; }
158
+ #diff_view { width: 1fr; height: 1fr; border: tall $success-darken-1; background: $surface-darken-1; }
159
+ #diff_stats { dock: top; height: 3; background: $success-darken-2; color: $text; padding: 0 1; text-style: bold; }
160
+ #diff_stats Static { width: auto; }
161
+
162
+ /* ── Empty states ── */
163
+ .empty-state {
164
+ width: 100%; height: 100%;
165
+ content-align: center middle; text-align: center;
166
+ color: $text-muted; padding: 2 4;
167
+ }
168
+ .empty-state .title { text-style: bold; color: $text; margin-bottom: 1; }
169
+ .empty-state .hint { color: $text-muted; margin-top: 0; }
170
+ """
171
+
172
+ BINDINGS = [
173
+ Binding("ctrl+q", "quit", "Quit"),
174
+ Binding("ctrl+s", "scan_jobs", "Scan"),
175
+ Binding("ctrl+r", "rescore", "Score"),
176
+ Binding("ctrl+e", "edit_job", "Edit"),
177
+ Binding("ctrl+t", "tailor_cv", "Tailor"),
178
+ Binding("ctrl+n", "paste_job", "Paste"),
179
+ Binding("ctrl+d", "delete_job", "Delete"),
180
+ Binding("ctrl+o", "open_url", "URL"),
181
+ Binding("f1", "help_screen", "Help"),
182
+ Binding("f2", "scan_with_options", "Scan+"),
183
+ Binding("f3", "setup_wizard", "Setup"),
184
+ Binding("f5", "refresh", "Refresh"),
185
+ Binding("slash", "focus_search", "Search"),
186
+ ]
187
+
188
+ def __init__(self) -> None:
189
+ super().__init__()
190
+ try:
191
+ self.profile = load_profile(PROFILE_PATH)
192
+ except Exception:
193
+ self.profile = {
194
+ "repos": [],
195
+ "skills_summary": {"languages": {}, "tech_stack": [], "domains": [], "infrastructure": []},
196
+ }
197
+ self.jobs: list[dict] = []
198
+ self.scored_jobs: list[dict] = []
199
+ self.filtered_jobs: list[dict] = []
200
+ self.current_job_idx: int | None = None
201
+ self._scanning = False
202
+ self._rescoreing = False
203
+ self._tailor_running = False
204
+ self._scan_count = 0
205
+ self._scan_total = 0
206
+ self._search_query = ""
207
+ self.tailored_cvs: list[dict] = []
208
+ self.current_tailored_idx: int | None = None
209
+ self._setup_status = self._check_setup()
210
+
211
+ # ── Subprocess kill registry (scanner-level) ─────
212
+ self._subprocs: list[subprocess.Popen] = []
213
+ self._subprocs_lock = threading.Lock()
214
+ self._shutting_down = False
215
+
216
+ # ── Kill all child processes on exit ─────────────
217
+ atexit.register(self._emergency_cleanup)
218
+
219
+ # ── Trap SIGINT (Ctrl+C) to clean up before die ──
220
+ signal.signal(signal.SIGINT, self._handle_sigint)
221
+
222
+ # ── SIGPIPE protection (common in piped CLI usage) ──
223
+ try:
224
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
225
+ except AttributeError:
226
+ pass # SIGPIPE not available on Windows
227
+
228
+ # ── Crash-safe subprocess tracking ─────────────────────────────────
229
+ def _track_proc(self, proc: subprocess.Popen) -> None:
230
+ with self._subprocs_lock:
231
+ self._subprocs.append(proc)
232
+
233
+ def _untrack_proc(self, proc: subprocess.Popen) -> None:
234
+ with self._subprocs_lock:
235
+ try:
236
+ self._subprocs.remove(proc)
237
+ except ValueError:
238
+ pass
239
+
240
+ def _cleanup_subprocesses(self) -> None:
241
+ """Kill all TUI-tracked subprocesses (scanner, etc.) plus tailor processes.
242
+ Safe to call during atexit / early init — uses no TUI widgets."""
243
+ lock = getattr(self, "_subprocs_lock", None)
244
+ if lock is not None:
245
+ with lock:
246
+ for proc in getattr(self, "_subprocs", []):
247
+ try:
248
+ proc.kill()
249
+ proc.wait(timeout=3)
250
+ except Exception:
251
+ pass
252
+ try:
253
+ self._subprocs.clear()
254
+ except Exception:
255
+ pass
256
+ kill_tailor_processes()
257
+
258
+ def _emergency_cleanup(self) -> None:
259
+ """Called by atexit and Ctrl+C — kills subprocesses only. No TUI widget access."""
260
+ self._shutting_down = True
261
+ self._cleanup_subprocesses()
262
+
263
+ def _handle_sigint(self, signum: int, frame) -> None:
264
+ """Traps Ctrl+C so we can kill subprocesses before Python exits."""
265
+ self._emergency_cleanup()
266
+ # Restore default handler and re-raise so the process stops
267
+ signal.signal(signal.SIGINT, signal.default_int_handler)
268
+ raise KeyboardInterrupt()
269
+
270
+ # ── Crash-safe notifications ────────────────────────────────────────
271
+ def _crash_notify(self, title: str, message: str = "", severity: str = "error") -> None:
272
+ """Show a crash/debug toast with optional log entry. Never throws."""
273
+ textual_sev = {"error": "error", "warning": "warning", "info": "information"}.get(severity, "error")
274
+ try:
275
+ tag = {"error": "[red]\u26a0 CRASH[/red]",
276
+ "warning": "[yellow]\u26a0 WARNING[/yellow]",
277
+ "info": "[cyan]\u2139[/cyan]"}.get(severity, "[red]\u26a0[/red]")
278
+ line = f"{tag} {title}"
279
+ if message:
280
+ line += f" [dim]\u2014[/dim] {message}"
281
+ self._log(line)
282
+ self.notify(f"{title} {message}", timeout=8, severity=textual_sev)
283
+ except Exception:
284
+ pass # last resort — if notify itself crashes, do nothing
285
+
286
+ def compose(self) -> ComposeResult:
287
+ yield Header()
288
+ with Horizontal(id="search_bar"):
289
+ yield Input(placeholder=" / Search by title, company, tag...", id="search_input")
290
+ yield Static("", id="search_count")
291
+ yield Static("", id="status_line")
292
+ with TabbedContent(id="main_tabs"):
293
+ with TabPane("Jobs", id="tab_jobs"):
294
+ with Horizontal():
295
+ yield DataTable(id="job_table")
296
+ with VerticalScroll(id="detail_pane"):
297
+ yield Static(self._welcome_text(), id="empty_state", classes="empty-state")
298
+ yield RichLog(id="score_detail", auto_scroll=False, markup=True)
299
+ with TabPane("Editor", id="tab_editor"):
300
+ yield Static(self._editor_placeholder_text(), id="editor_placeholder", classes="empty-state")
301
+ yield NanoEditor(id="editor_container")
302
+ with TabPane("Profile", id="tab_profile"):
303
+ yield Tree("GitHub Profile", id="profile_tree")
304
+ with TabPane("Scanner", id="tab_scanner"):
305
+ yield RichLog(id="scan_log", markup=True)
306
+ with TabPane("Tailored", id="tab_tailored"):
307
+ with Horizontal():
308
+ yield DataTable(id="tailored_table")
309
+ with VerticalScroll(id="diff_view"):
310
+ yield Static(self._tailored_placeholder_text(), id="tailored_empty", classes="empty-state")
311
+ yield RichLog(id="diff_log", auto_scroll=False, markup=True)
312
+ with TabPane("Settings", id="tab_settings"):
313
+ yield SettingsPane(id="settings_pane")
314
+ yield Static(self._key_hints_for_tab("tab_jobs"), id="key_hints")
315
+ yield Footer()
316
+
317
+ def _welcome_text(self) -> str:
318
+ s = self._setup_status if hasattr(self, "_setup_status") else {}
319
+ lines = ["[bold]\u2593 Chameleon Job TUI[/bold]", ""]
320
+
321
+ if s:
322
+ if not s["cv_exists"] or not s["profile_exists"]:
323
+ lines.append("[bold yellow]\u26a0 Setup Required[/bold yellow]")
324
+ if not s["cv_exists"]:
325
+ lines.append(f" [red]\u2717[/red] CV: [dim]{s['cv_path']}[/dim] [red]not found[/red]")
326
+ else:
327
+ lines.append(f" [green]\u2713[/green] CV: [dim]{s['cv_name']}[/dim]")
328
+ if not s["profile_exists"]:
329
+ lines.append(f" [red]\u2717[/red] Profile: [dim]{s['profile_path']}[/dim] [red]not found[/red]")
330
+ else:
331
+ lines.append(f" [green]\u2713[/green] Profile: [dim]{s['profile_repo_count']} repos[/dim]")
332
+ lines.append("")
333
+ lines.append("[bold]Press F3 to launch the Setup Wizard[/bold]")
334
+ lines.append("")
335
+ else:
336
+ lines.append(f"[green]\u2713[/green] CV: [dim]{s['cv_name']}[/dim] | [green]\u2713[/green] Profile: [dim]{s['profile_repo_count']} repos[/dim]")
337
+ lines.append("")
338
+
339
+ lines.append("[dim]No jobs loaded yet.[/dim]")
340
+ lines.append("")
341
+ lines.append("[bold]Ctrl+S[/bold] Scan job platforms")
342
+ lines.append("[bold]Ctrl+N[/bold] Paste a job description")
343
+ lines.append("[bold]/[/bold] Search & filter")
344
+ lines.append("")
345
+ lines.append("[dim]Tip: Tailored CVs appear in the Tailored tab with diffs[/dim]")
346
+ return "\n".join(lines)
347
+
348
+ def _editor_placeholder_text(self) -> str:
349
+ return (
350
+ "[dim]\u270e Editor[/dim]\n\n"
351
+ "Select a job and press [bold]Ctrl+E[/bold] to edit its description.\n"
352
+ "Changes are saved with [bold]Ctrl+X[/bold] and trigger a re-score."
353
+ )
354
+
355
+ def _tailored_placeholder_text(self) -> str:
356
+ return (
357
+ "[dim]\u2702 Tailored CVs[/dim]\n\n"
358
+ "Select a job and press [bold]Ctrl+T[/bold] to tailor your CV.\n"
359
+ "Tailored CVs appear here with diffs showing changes."
360
+ )
361
+
362
+ _TAB_HINTS: dict[str, str] = {
363
+ "tab_jobs": " \u2191\u2193 Navigate Enter Select Ctrl+S Scan Ctrl+E Edit Ctrl+T Tailor Ctrl+D Delete / Search F3 Setup",
364
+ "tab_editor": " Ctrl+X Save&Exit Ctrl+G GoTo Ctrl+S Save Esc Back",
365
+ "tab_profile": " \u2191\u2193 Navigate \u2195 Expand/Collapse F3 Setup",
366
+ "tab_scanner": " (background) Ctrl+S Scan F2 Custom Scan F3 Setup",
367
+ "tab_tailored": " \u2191\u2193 Select Ctrl+T Tailor new Ctrl+R Rescore F3 Setup",
368
+ "tab_settings": " \u2191\u2193 Edit Enter Confirm Tab Next field F3 Setup Wizard",
369
+ }
370
+
371
+ def _key_hints_for_tab(self, tab_id: str) -> str:
372
+ hints = self._TAB_HINTS.get(tab_id, "")
373
+ return f"[bold]F1[/bold] Help [bold]F3[/bold] Setup {hints} [bold]Ctrl+Q[/bold] Quit"
374
+
375
+ # ── Mount ────────────────────────────────────────────────────────────
376
+ def on_mount(self) -> None:
377
+ self._setup_job_table()
378
+ self._setup_tailored_table()
379
+ self._load_profile_tree()
380
+ self._update_status()
381
+ self._load_saved_scans_worker()
382
+ self._active_tab = "tab_jobs"
383
+
384
+ # Launch setup wizard if CV or profile is missing (small delay for mount to finish)
385
+ s = self._setup_status
386
+ if not s.get("cv_exists") or not s.get("profile_exists"):
387
+ self.set_timer(0.3, self._show_setup_wizard)
388
+ elif not s.get("has_repos"):
389
+ self.notify("GitHub profile has no repos. Scoring will be limited. Press F3 for Setup Wizard.", timeout=6)
390
+ elif not s.get("browser_available"):
391
+ self.set_timer(1.0, self._auto_install_playwright)
392
+
393
+ def _show_setup_wizard(self) -> None:
394
+ def _on_result(result: bool | None) -> None:
395
+ if result:
396
+ self._setup_status = self._check_setup()
397
+ self._load_profile_tree()
398
+ self._update_status()
399
+ config.set("setup_wizard_completed", True)
400
+ self.notify("[bold green]\u2714 Setup complete![/bold green]", timeout=4)
401
+ try:
402
+ self.query_one("#empty_state", Static).update(self._welcome_text())
403
+ except Exception:
404
+ pass
405
+ else:
406
+ # Wizard skipped/cancelled — re-check status for stale warnings
407
+ self._setup_status = self._check_setup()
408
+ self._update_status()
409
+
410
+ self.push_screen(SetupWizardScreen(), _on_result)
411
+
412
+ def _auto_install_playwright(self) -> None:
413
+ """Auto-install Playwright + Chromium in background when missing."""
414
+ import threading
415
+ self.notify("Installing Playwright + Chromium for headless browser automation...", timeout=10)
416
+
417
+ def _install():
418
+ try:
419
+ from scripts.job_scanner.browser import install_playwright
420
+ def _log(msg):
421
+ self.call_from_thread(self.notify, f"[Playwright] {msg}", timeout=6)
422
+ install_playwright(progress_callback=_log)
423
+ self._setup_status = self._check_setup()
424
+ self.call_from_thread(self._update_status)
425
+ except Exception as e:
426
+ self.call_from_thread(self.notify, f"[Playwright] Install failed: {e}", timeout=8)
427
+
428
+ threading.Thread(target=_install, daemon=True).start()
429
+
430
+ @on(TabbedContent.TabActivated, "#main_tabs")
431
+ def on_tab_changed(self, event: TabbedContent.TabActivated) -> None:
432
+ tab_id = event.pane.id or "tab_jobs"
433
+ self._active_tab = tab_id
434
+ try:
435
+ self.query_one("#key_hints", Static).update(self._key_hints_for_tab(tab_id))
436
+ except Exception:
437
+ pass
438
+
439
+ # ── Helpers ──────────────────────────────────────────────────────────
440
+ def _get_selected_job(self) -> dict | None:
441
+ if self.current_job_idx is not None and 0 <= self.current_job_idx < len(self.filtered_jobs):
442
+ return self.filtered_jobs[self.current_job_idx]
443
+ return None
444
+
445
+ def _get_selected_tailored(self) -> dict | None:
446
+ if self.current_tailored_idx is not None and 0 <= self.current_tailored_idx < len(self.tailored_cvs):
447
+ return self.tailored_cvs[self.current_tailored_idx]
448
+ return None
449
+
450
+ def _log(self, message: str) -> None:
451
+ try:
452
+ self.query_one("#scan_log", RichLog).write(message)
453
+ except Exception:
454
+ pass
455
+
456
+ def _check_setup(self) -> dict[str, bool | str]:
457
+ """Check which data sources are available. Returns status dict."""
458
+ cv_ok = CV_PATH.exists()
459
+ cv_name = CV_PATH.name if cv_ok else ""
460
+ profile_ok = PROFILE_PATH.exists()
461
+ has_repos = len(self.profile.get("repos", [])) > 0 if profile_ok else False
462
+ profile_repo_count = len(self.profile.get("repos", [])) if profile_ok else 0
463
+ # Check browser automation availability
464
+ browser_ok = False
465
+ try:
466
+ from scripts.job_scanner.browser import is_playwright_available
467
+ browser_ok = is_playwright_available()
468
+ except Exception:
469
+ pass
470
+ return {
471
+ "cv_exists": cv_ok,
472
+ "cv_path": str(CV_PATH),
473
+ "cv_name": cv_name,
474
+ "profile_exists": profile_ok,
475
+ "profile_path": str(PROFILE_PATH),
476
+ "has_repos": has_repos,
477
+ "profile_repo_count": profile_repo_count,
478
+ "browser_available": browser_ok,
479
+ }
480
+
481
+ def _update_status(self) -> None:
482
+ try:
483
+ s = self._setup_status
484
+ n = len(self.filtered_jobs)
485
+ total = len(self.scored_jobs)
486
+
487
+ # ── Setup warnings ──
488
+ setup_warn = ""
489
+ if not s["cv_exists"] and not s["profile_exists"]:
490
+ setup_warn = " \u26a0 [bold red]SETUP NEEDED:[/bold red] Missing CV + Profile "
491
+ elif not s["cv_exists"]:
492
+ setup_warn = f" \u26a0 [bold yellow]SETUP:[/bold yellow] CV not found ({s['cv_path']}) "
493
+ elif not s["profile_exists"]:
494
+ setup_warn = " \u26a0 [bold yellow]SETUP:[/bold yellow] GitHub profile missing "
495
+ elif s["profile_exists"] and not s["has_repos"]:
496
+ setup_warn = " \u26a0 [bold yellow]SETUP:[/bold yellow] Profile has no repos "
497
+
498
+ # Browser status
499
+ browser_status = ""
500
+ if s.get("browser_available"):
501
+ browser_status = " \U0001f310 [green]Browser[/green] "
502
+ else:
503
+ browser_status = " \U0001f310 [dim]No browser[/dim] "
504
+ setup_warn += browser_status
505
+
506
+ # Job count with filtering info
507
+ if self._search_query:
508
+ count_str = f" Jobs: [bold]{n}[/bold]/{total}"
509
+ else:
510
+ count_str = f" Jobs: {total}"
511
+
512
+ # Selected job
513
+ sel = ""
514
+ job = self._get_selected_job()
515
+ if job:
516
+ score = job.get("score", 0)
517
+ sel = f" \u2502 {truncate(job['company'], 20)} \u2014 {truncate(job['title'], 30)} {score_badge(score)}"
518
+
519
+ # Activity indicators
520
+ activity = ""
521
+ if self._scanning:
522
+ pct = int((self._scan_count / max(self._scan_total, 1)) * 100)
523
+ activity = f" \u2502 [bold cyan]\u25b6 Scanning[/bold cyan] {self._scan_count}/{self._scan_total} ({pct}%)"
524
+ elif self._tailor_running:
525
+ activity = " \u2502 [bold green]\u2702 Tailoring...[/bold green]"
526
+ elif self._rescoreing:
527
+ activity = " \u2502 [bold yellow]\u25b6 Scoring...[/bold yellow]"
528
+
529
+ # Search info
530
+ search = ""
531
+ if self._search_query:
532
+ search = f' \u2502 [dim]filter: "{self._search_query}"[/dim]'
533
+ try:
534
+ self.query_one("#search_count", Static).update(f"[dim]{n} match{'es' if n != 1 else ''}[/dim]")
535
+ except Exception:
536
+ pass
537
+ else:
538
+ try:
539
+ self.query_one("#search_count", Static).update("")
540
+ except Exception:
541
+ pass
542
+
543
+ # Tailored count
544
+ tailored = ""
545
+ if self.tailored_cvs:
546
+ tailored = f" \u2502 [green]\u2702 {len(self.tailored_cvs)} tailored[/green]"
547
+
548
+ self.query_one("#status_line", Static).update(
549
+ f"{setup_warn}{count_str}{sel}{activity}{search}{tailored}"
550
+ )
551
+ except Exception:
552
+ pass
553
+
554
+ # ── Job table ────────────────────────────────────────────────────────
555
+ def _setup_job_table(self) -> None:
556
+ try:
557
+ table = self.query_one("#job_table", DataTable)
558
+ table.add_columns("Score", "Company", "Role", "Remote", "Source")
559
+ table.cursor_type = "row"
560
+ except Exception:
561
+ pass
562
+
563
+ def _refresh_job_table(self) -> None:
564
+ try:
565
+ table = self.query_one("#job_table", DataTable)
566
+ table.clear()
567
+ if self._search_query:
568
+ q = self._search_query.lower()
569
+ self.filtered_jobs = [
570
+ j for j in self.scored_jobs
571
+ if q in j["title"].lower() or q in j["company"].lower()
572
+ or q in " ".join(j.get("tags", [])).lower()
573
+ or q in j.get("source", "").lower()
574
+ or q in j.get("description", "").lower()
575
+ ]
576
+ else:
577
+ self.filtered_jobs = list(self.scored_jobs)
578
+ for job in self.filtered_jobs:
579
+ badge = score_badge(job["score"])
580
+ remote = "[green]Y[/green]" if job.get("remote") else "[dim]N[/dim]"
581
+ table.add_row(
582
+ badge,
583
+ truncate(job["company"], 22),
584
+ truncate(job["title"], 38),
585
+ remote,
586
+ job.get("source", "?")[:12],
587
+ )
588
+ try:
589
+ empty = self.query_one("#empty_state", Static)
590
+ empty.display = not bool(self.scored_jobs)
591
+ except Exception:
592
+ pass
593
+ self._update_status()
594
+ except Exception:
595
+ pass
596
+
597
+ # ── Tailored table ──────────────────────────────────────────────────
598
+ def _setup_tailored_table(self) -> None:
599
+ try:
600
+ table = self.query_one("#tailored_table", DataTable)
601
+ table.add_columns("Company", "Role", "Score", "PDF")
602
+ table.cursor_type = "row"
603
+ except Exception:
604
+ pass
605
+
606
+ def _refresh_tailored_table(self) -> None:
607
+ try:
608
+ table = self.query_one("#tailored_table", DataTable)
609
+ table.clear()
610
+ for cv in self.tailored_cvs:
611
+ score = cv.get("score", 0)
612
+ badge = score_badge(score)
613
+ pdf_name = Path(cv.get("pdf_path", "")).name if cv.get("pdf_path") else "-"
614
+ table.add_row(
615
+ truncate(cv.get("company", "?"), 22),
616
+ truncate(cv.get("title", "?"), 30),
617
+ badge,
618
+ truncate(pdf_name, 30),
619
+ )
620
+ try:
621
+ empty = self.query_one("#tailored_empty", Static)
622
+ empty.display = not bool(self.tailored_cvs)
623
+ except Exception:
624
+ pass
625
+ self._update_status()
626
+ except Exception:
627
+ pass
628
+
629
+ def _show_tailored_diff(self, idx: int) -> None:
630
+ if idx < 0 or idx >= len(self.tailored_cvs):
631
+ return
632
+ self.current_tailored_idx = idx
633
+ cv = self.tailored_cvs[idx]
634
+ try:
635
+ self.query_one("#tailored_empty", Static).display = False
636
+ except Exception:
637
+ pass
638
+ try:
639
+ diff_log = self.query_one("#diff_log", RichLog)
640
+ diff_log.clear()
641
+
642
+ # ── Header card ──
643
+ score = cv.get("score", 0)
644
+ diff_log.write(f"[bold]{'━' * 60}[/bold]")
645
+ diff_log.write(f"[bold green]\u2702 {cv.get('company', '?')} \u2014 {cv.get('title', '?')}[/bold green]")
646
+ diff_log.write(f" {score_summary(score)} {score_bar(score, width=20)}")
647
+ pdf_name = Path(cv.get("pdf_path", "")).name if cv.get("pdf_path") else "-"
648
+ yaml_name = Path(cv.get("yaml_path", "")).name if cv.get("yaml_path") else "-"
649
+ diff_log.write(f" [dim]PDF:[/dim] {pdf_name}")
650
+ diff_log.write(f" [dim]YAML:[/dim] {yaml_name}")
651
+ diff_log.write(f"[bold]{'━' * 60}[/bold]")
652
+ diff_log.write("")
653
+
654
+ # ── Diff ──
655
+ original_path = config.master_cv_path()
656
+ tailored_path = Path(cv.get("yaml_path", ""))
657
+ if not tailored_path.exists():
658
+ diff_log.write("[red]\u2717 Tailored YAML not found on disk[/red]")
659
+ return
660
+ original_text = original_path.read_text() if original_path.exists() else "(original not found)"
661
+ tailored_text = tailored_path.read_text()
662
+ diff_lines = make_diff(original_text, tailored_text, original_path.stem + ".yaml")
663
+ if not diff_lines:
664
+ diff_log.write("[dim]\u2714 No differences (identical to original)[/dim]")
665
+ return
666
+
667
+ added = sum(1 for l in diff_lines if l.startswith("+") and not l.startswith("+++"))
668
+ removed = sum(1 for l in diff_lines if l.startswith("-") and not l.startswith("---"))
669
+ total = added + removed
670
+ diff_log.write(f"[bold] Diff:[/bold] [green]+{added}[/green] added [red]-{removed}[/red] removed [dim]({total} changes)[/dim]\n")
671
+
672
+ for line in diff_lines:
673
+ if line.startswith("+++") or line.startswith("---"):
674
+ diff_log.write(f"[bold]{line}[/bold]")
675
+ elif line.startswith("@@"):
676
+ diff_log.write(f"[bold cyan]{line}[/bold cyan]")
677
+ elif line.startswith("+"):
678
+ diff_log.write(f"[green]{line}[/green]")
679
+ elif line.startswith("-"):
680
+ diff_log.write(f"[red]{line}[/red]")
681
+ else:
682
+ diff_log.write(line)
683
+ except Exception as e:
684
+ try:
685
+ self.query_one("#diff_log", RichLog).write(f"[red]Error reading diff: {e}[/red]")
686
+ except Exception:
687
+ pass
688
+
689
+ # ── Profile tree ─────────────────────────────────────────────────────
690
+ def _load_profile_tree(self) -> None:
691
+ try:
692
+ tree = self.query_one("#profile_tree", Tree)
693
+ tree.root.expand()
694
+ s = self._setup_status
695
+
696
+ if not s["profile_exists"]:
697
+ tree.root.label = "[red]\u26a0 Profile Not Found[/red]"
698
+ tree.root.add_leaf(f"File: {s['profile_path']}")
699
+ tree.root.add_leaf("[dim]Place a GitHub profile JSON at the path above[/dim]")
700
+ tree.root.add_leaf("[dim]Run a scan in the Scanner tab or use Settings to change path[/dim]")
701
+ return
702
+
703
+ user = self.profile.get("user", "github_user")
704
+ repos = self.profile.get("repos", [])
705
+ tree.root.label = f"{user} \u2014 GitHub Profile ({len(repos)} repos)"
706
+
707
+ if not repos and not any([
708
+ self.profile.get("skills_summary", {}).get("languages", {}),
709
+ self.profile.get("skills_summary", {}).get("tech_stack", []),
710
+ ]):
711
+ tree.root.add_leaf("[yellow]\u26a0 No repo data or skills found[/yellow]")
712
+ tree.root.add_leaf("[dim]The profile file exists but contains no repos[/dim]")
713
+ tree.root.add_leaf("[dim]Re-generate your GitHub profile or check the file format[/dim]")
714
+ return
715
+
716
+ langs = self.profile.get("skills_summary", {}).get("languages", {})
717
+ if langs:
718
+ lang_branch = tree.root.add(f"Languages ({len(langs)})")
719
+ for lang, count in sorted(langs.items(), key=lambda x: -x[1])[:10]:
720
+ lang_branch.add_leaf(f"{lang} ({count} repos)")
721
+ if len(langs) > 10:
722
+ lang_branch.add_leaf(f"[dim]... {len(langs) - 10} more[/dim]")
723
+ techs = self.profile.get("skills_summary", {}).get("tech_stack", [])
724
+ if techs:
725
+ tech_branch = tree.root.add(f"Tech Stack ({len(techs)})")
726
+ for tech in sorted(techs)[:15]:
727
+ tech_branch.add_leaf(tech)
728
+ if len(techs) > 15:
729
+ tech_branch.add_leaf(f"[dim]... {len(techs) - 15} more[/dim]")
730
+ doms = self.profile.get("skills_summary", {}).get("domains", [])
731
+ if doms:
732
+ dom_branch = tree.root.add(f"Domains ({len(doms)})")
733
+ for dom in doms:
734
+ dom_branch.add_leaf(dom)
735
+ infra = self.profile.get("skills_summary", {}).get("infrastructure", [])
736
+ if infra:
737
+ infra_branch = tree.root.add(f"Infrastructure ({len(infra)})")
738
+ for i in infra:
739
+ infra_branch.add_leaf(i)
740
+ if repos:
741
+ rbranch = tree.root.add(f"All Repos ({len(repos)})")
742
+ for repo in repos:
743
+ lang = repo.get("language") or "?"
744
+ desc = truncate(repo.get("summary") or "", 60)
745
+ rbranch.add_leaf(f"[{lang}] {repo['name']} \u2014 {desc}")
746
+ except Exception:
747
+ pass
748
+
749
+ # ── Load saved scans + tailored CVs from SQLite (background) ───────
750
+ @work(thread=True, exclusive=True, group="load_scans")
751
+ def _load_saved_scans_worker(self) -> None:
752
+ job_count = 0
753
+ scan_count = 0
754
+ analysis_count = 0
755
+ tailored_count = 0
756
+
757
+ db_jobs = get_all_jobs(limit=500)
758
+ for job in db_jobs:
759
+ self.call_from_thread(self._add_job_from_data, job, source=job.get("source", "db"), persist=False)
760
+ job_count += 1
761
+
762
+ if SCANS_DIR.exists():
763
+ for f in sorted(SCANS_DIR.glob("*.json"))[-5:]:
764
+ try:
765
+ data = json.loads(f.read_text())
766
+ items = data if isinstance(data, list) else data.get("jobs", [])
767
+ for job in items:
768
+ if not any(j.get("title", "").lower() == job.get("title", "").lower()
769
+ and j.get("company", "").lower() == job.get("company", "").lower()
770
+ for j in db_jobs):
771
+ self.call_from_thread(self._add_job_from_data, job, source=f.stem)
772
+ scan_count += 1
773
+ except Exception as e:
774
+ self.call_from_thread(self._log, f"[red]Skip {f.name}: {e}[/red]")
775
+
776
+ if ANALYSES_DIR.exists():
777
+ for f in sorted(ANALYSES_DIR.glob("*.json"))[-10:]:
778
+ try:
779
+ data = json.loads(f.read_text())
780
+ if not any(j.get("title", "").lower() == data.get("role_title", "").lower()
781
+ and j.get("company", "").lower() == data.get("company_name", "").lower()
782
+ for j in db_jobs):
783
+ self.call_from_thread(self._add_job_from_data, data, source="analysis")
784
+ analysis_count += 1
785
+ except Exception:
786
+ pass
787
+
788
+ db_tailored = get_tailored_cvs(limit=50)
789
+ for tc in db_tailored:
790
+ self.call_from_thread(self._add_tailored_cv_from_db, tc)
791
+ tailored_count += 1
792
+
793
+ if db_tailored:
794
+ self.call_from_thread(self._refresh_tailored_table)
795
+
796
+ total = job_count + scan_count + analysis_count + tailored_count
797
+ if total == 0:
798
+ self.call_from_thread(self._log, "[dim]No saved jobs. Press [bold]Ctrl+S[/bold] to scan or [bold]Ctrl+N[/bold] to paste a job.[/dim]")
799
+ else:
800
+ parts = []
801
+ if job_count:
802
+ parts.append(f"{job_count} jobs")
803
+ if scan_count:
804
+ parts.append(f"{scan_count} from scans")
805
+ if analysis_count:
806
+ parts.append(f"{analysis_count} from analyses")
807
+ if tailored_count:
808
+ parts.append(f"{tailored_count} tailored CVs")
809
+ self.call_from_thread(self._log, f"[green]Loaded {', '.join(parts)}.[/green]")
810
+ self.call_from_thread(self._show_loaded_jobs)
811
+ self.call_from_thread(self._rescore_all)
812
+
813
+ # ── Add job (memory + SQLite) ───────────────────────────────────────
814
+ def _add_job_from_data(self, data: dict, source: str = "scan", persist: bool = True) -> None:
815
+ if not isinstance(data, dict):
816
+ return
817
+ title = safe_str(data.get("title") or data.get("role_title") or data.get("job_title"), "Unknown")
818
+ company = safe_str(data.get("company") or data.get("company_name"), "Unknown")
819
+ key = f"{title.lower().strip()}|{company.lower().strip()}"
820
+ for existing in self.jobs:
821
+ ek = f"{existing['title'].lower().strip()}|{existing['company'].lower().strip()}"
822
+ if ek == key:
823
+ return
824
+ tags = data.get("tags", [])
825
+ if isinstance(tags, str):
826
+ tags = [t.strip() for t in tags.split(",") if t.strip()]
827
+ job = {
828
+ "title": title,
829
+ "company": company,
830
+ "url": data.get("url") or data.get("apply_url") or "",
831
+ "location": data.get("location") or "Remote",
832
+ "description": data.get("description") or data.get("jd_text") or data.get("raw_text") or "",
833
+ "salary": data.get("salary") or data.get("salary_range") or "",
834
+ "source": source,
835
+ "remote": bool(data.get("remote", False)),
836
+ "tags": tags if isinstance(tags, list) else [],
837
+ "score": data.get("score", 0),
838
+ "score_detail": data.get("score_detail") or {},
839
+ }
840
+ self.jobs.append(job)
841
+ if persist:
842
+ try:
843
+ save_jobs([job])
844
+ except Exception:
845
+ pass
846
+
847
+ # ── Display loaded jobs immediately (before background scoring) ────
848
+ def _show_loaded_jobs(self) -> None:
849
+ self.scored_jobs = sorted(self.jobs, key=lambda j: -j.get("score", 0))
850
+ self._refresh_job_table()
851
+
852
+ # ── Scoring (background) ────────────────────────────────────────────
853
+ def _rescore_all(self) -> None:
854
+ if self._rescoreing:
855
+ return
856
+ self._rescoreing = True
857
+ self._update_status()
858
+ self._rescore_all_worker()
859
+
860
+ @work(thread=True, exclusive=True, group="rescore")
861
+ def _rescore_all_worker(self) -> None:
862
+ for job in self.jobs:
863
+ try:
864
+ text = job.get("description") or f"{job.get('title', '')} {job.get('company', '')}"
865
+ result = score_job(text, self.profile, CV_PATH)
866
+ job["score"] = result.get("total_score", 0)
867
+ job["score_detail"] = result
868
+ except Exception:
869
+ job["score"] = 0
870
+ job["score_detail"] = {}
871
+ try:
872
+ job_id = compute_job_id(job.get("title", ""), job.get("company", ""), job.get("url", ""), job.get("source", ""))
873
+ update_job_score(job_id, job["score"], job["score_detail"])
874
+ except Exception:
875
+ pass
876
+
877
+ def _finish() -> None:
878
+ self.scored_jobs = sorted(self.jobs, key=lambda j: -j.get("score", 0))
879
+ self._rescoreing = False
880
+ self._refresh_job_table()
881
+ self.notify(f"Scored {len(self.jobs)} jobs", timeout=2)
882
+ self.call_from_thread(_finish)
883
+
884
+ # ── Job detail ───────────────────────────────────────────────────────
885
+ def _enrich_job(self, job: dict) -> dict:
886
+ """Compute enriched analysis for a job (gaps, experience fit, repo angles, seniority).
887
+ Caches the result on the job dict so subsequent views are instant.
888
+ Handles missing CV/profile gracefully with clear placeholders.
889
+ """
890
+ if job.get("_enriched"):
891
+ return job
892
+ try:
893
+ s = self._setup_status
894
+ desc = job.get("description", "")
895
+ if not desc:
896
+ job["_enriched"] = True
897
+ job["_gaps"] = []
898
+ job["_exp_fit"] = []
899
+ job["_repo_angles"] = []
900
+ job["_seniority_detail"] = {}
901
+ job["_setup_notes"] = []
902
+ return job
903
+
904
+ setup_notes: list[str] = []
905
+ if not s["cv_exists"]:
906
+ setup_notes.append(f"No master CV at {s['cv_path']} \u2014 scoring limited to profile match only")
907
+ if s["profile_exists"] and not s["has_repos"]:
908
+ setup_notes.append("GitHub profile has no repos \u2014 repo matching unavailable")
909
+
910
+ signals = extract_job_signals(desc, self.profile)
911
+ cv = load_cv_yaml(CV_PATH) if s["cv_exists"] else {}
912
+ job["_seniority_detail"] = analyze_seniority(desc)
913
+ job["_exp_fit"] = analyze_experience_relevance(cv, signals, desc) if cv else []
914
+ job["_gaps"] = find_gaps(signals, cv) if cv else []
915
+ job["_repo_angles"] = analyze_repos_for_jd(self.profile, signals) if s["has_repos"] else []
916
+ job["_setup_notes"] = setup_notes
917
+ job["_enriched"] = True
918
+ except Exception:
919
+ job["_enriched"] = True
920
+ for k in ("_gaps", "_exp_fit", "_repo_angles", "_seniority_detail", "_setup_notes"):
921
+ job.setdefault(k, [] if k != "_seniority_detail" else {})
922
+ return job
923
+
924
+ def _show_job_detail(self, idx: int) -> None:
925
+ if idx < 0 or idx >= len(self.filtered_jobs):
926
+ return
927
+ self.current_job_idx = idx
928
+ job = self.filtered_jobs[idx]
929
+ try:
930
+ self.query_one("#empty_state", Static).display = False
931
+ except Exception:
932
+ pass
933
+ try:
934
+ detail = self.query_one("#score_detail", RichLog)
935
+ detail.clear()
936
+ job = self._enrich_job(job)
937
+
938
+ total = job.get("score", 0)
939
+ sd = job.get("score_detail", {})
940
+ bd = sd.get("breakdown", {})
941
+ gaps = job.get("_gaps", [])
942
+ exp_fit = job.get("_exp_fit", [])
943
+ repo_angles = job.get("_repo_angles", [])
944
+ sen_detail = job.get("_seniority_detail", {})
945
+
946
+ def _bar(val: int, mx: int = 25, label: str = "") -> str:
947
+ filled = int((val / mx) * 12)
948
+ return f" {label:<13} {'█' * filled}{'░' * (12 - filled)} {val}/{mx}"
949
+
950
+ def _indicator(val: int, mx: int = 25) -> str:
951
+ ratio = val / max(mx, 1)
952
+ if ratio >= 0.8:
953
+ return "[green]\u2713[/green]"
954
+ elif ratio >= 0.4:
955
+ return "[yellow]\u25b3[/yellow]"
956
+ return "[red]\u2717[/red]"
957
+
958
+ # ═══════════════════════════════════════════════
959
+ # CARD: Job Header
960
+ # ═══════════════════════════════════════════════
961
+ detail.write(f"[bold]{'━' * 52}[/bold]")
962
+ detail.write(f"[bold blue]{job.get('title', 'Unknown')}[/bold blue]")
963
+ detail.write(f"[bold]{job.get('company', 'Unknown')}[/bold] [dim]\u2014[/dim] {job.get('location', 'Unknown')}")
964
+ if job.get("salary"):
965
+ detail.write(f"[dim]\u25c8[/dim] Salary: {job['salary']}")
966
+ remote_tag = "[green]\u2611 Remote[/green]" if job.get("remote") else "[dim]\u2610 On-site[/dim]"
967
+ sen_tag = f"[bold cyan]{sen_detail.get('level', '?').upper()}[/bold cyan]" if sen_detail else ""
968
+ detail.write(f"[dim]\u25c8[/dim] {remote_tag} [dim]\u25c8[/dim] {sen_tag} [dim]\u25c8[/dim] Source: {job.get('source', '?')}")
969
+ if job.get("url"):
970
+ detail.write(f"[dim]\u25c8[/dim] URL: [link={job['url']}]{truncate(job['url'], 50)}[/link]")
971
+ if job.get("tags"):
972
+ detail.write(f"[dim]\u25c8[/dim] Tags: [dim]{', '.join(job['tags'][:8])}[/dim]")
973
+ detail.write(f"[bold]{'━' * 52}[/bold]")
974
+
975
+ # ═══════════════════════════════════════════════
976
+ # CARD: Setup Notes (missing CV/profile)
977
+ # ═══════════════════════════════════════════════
978
+ setup_notes = job.get("_setup_notes", [])
979
+ if setup_notes:
980
+ detail.write("")
981
+ for note in setup_notes:
982
+ detail.write(f" [yellow]\u26a0 {note}[/yellow]")
983
+
984
+ # ═══════════════════════════════════════════════
985
+ # CARD: Overall Score
986
+ # ═══════════════════════════════════════════════
987
+ detail.write("")
988
+ detail.write(f" {score_summary(total)}")
989
+ detail.write(f" {score_bar(total, width=28)}")
990
+ detail.write("")
991
+
992
+ # ═══════════════════════════════════════════════
993
+ # CARD: Score Breakdown
994
+ # ═══════════════════════════════════════════════
995
+ detail.write("[bold] Score Breakdown:[/bold]")
996
+ cats = [("Languages", bd.get("languages", 0)),
997
+ ("Frameworks", bd.get("frameworks", 0)),
998
+ ("Domains", bd.get("domains", 0)),
999
+ ("CV Alignment", bd.get("cv_alignment", 0))]
1000
+ for label, val in cats:
1001
+ detail.write(f" {_bar(val, label=label)} {_indicator(val)}")
1002
+
1003
+ # ═══════════════════════════════════════════════
1004
+ # CARD: Matched Signals
1005
+ # ═══════════════════════════════════════════════
1006
+ detail.write("")
1007
+ detail.write("[bold] Matched Signals:[/bold]")
1008
+ matched_langs = sd.get("matched_languages", [])
1009
+ matched_fws = sd.get("matched_frameworks", [])
1010
+ matched_doms = sd.get("matched_domains", [])
1011
+ if matched_langs:
1012
+ detail.write(f" [green]\u2713[/green] Languages: {', '.join(matched_langs[:6])}")
1013
+ if matched_fws:
1014
+ detail.write(f" [green]\u2713[/green] Frameworks: {', '.join(matched_fws[:6])}")
1015
+ if matched_doms:
1016
+ detail.write(f" [yellow]\u25b3[/yellow] Domains: {', '.join(matched_doms[:4])}")
1017
+
1018
+ max_kw = config.get("max_cv_keywords_in_detail", 5)
1019
+ matched_cv = sd.get("matched_cv_keywords", [])[:max_kw]
1020
+ if matched_cv:
1021
+ detail.write(f" [green]\u2713[/green] CV Match: {', '.join(matched_cv)}")
1022
+
1023
+ repos = sd.get("matching_repos", [])
1024
+ if repos:
1025
+ detail.write(f" [green]\u2713[/green] Repos: {', '.join(repos[:4])}")
1026
+
1027
+ # ═══════════════════════════════════════════════
1028
+ # CARD: Gap Analysis
1029
+ # ═══════════════════════════════════════════════
1030
+ if gaps:
1031
+ detail.write("")
1032
+ detail.write(f"[bold] Missing Gaps ({len(gaps)}):[/bold]")
1033
+ high_gaps = [g for g in gaps if g.get("severity") == "high"]
1034
+ med_gaps = [g for g in gaps if g.get("severity") != "high"]
1035
+ for g in (high_gaps + med_gaps)[:8]:
1036
+ sev = "[red]\u2717[/red]" if g.get("severity") == "high" else "[yellow]\u25b3[/yellow]"
1037
+ cat = g.get("category", "?")[:8]
1038
+ mit = g.get("mitigation", "")[:30]
1039
+ detail.write(f" {sev} [bold]{g['term']}[/bold] [dim]({cat})[/dim] [dim]\u2014[/dim] [italic]{mit}[/italic]")
1040
+ if len(gaps) > 8:
1041
+ detail.write(f" [dim]... and {len(gaps) - 8} more gaps[/dim]")
1042
+ else:
1043
+ detail.write("")
1044
+ detail.write(" [bold] Missing Gaps:[/bold] [green]\u2714 None detected[/green]")
1045
+
1046
+ # ═══════════════════════════════════════════════
1047
+ # CARD: Experience Fit
1048
+ # ═══════════════════════════════════════════════
1049
+ if exp_fit:
1050
+ detail.write("")
1051
+ detail.write(f"[bold] Experience Fit ({len(exp_fit)} entries):[/bold]")
1052
+ for exp in exp_fit[:5]:
1053
+ score = exp.get("relevance_score", 0)
1054
+ col = "green" if score >= 60 else "yellow" if score >= 30 else "dim"
1055
+ kws = ", ".join(exp["keyword_matches"][:4]) if exp.get("keyword_matches") else ""
1056
+ detail.write(f" [{col}]\u2605[/{col}] [bold]{exp['company']}[/bold] \u2014 {exp['position']}")
1057
+ detail.write(f" Fit: [bold]{score}[/bold]/100 Keywords: [dim]{kws}[/dim]")
1058
+
1059
+ # ═══════════════════════════════════════════════
1060
+ # CARD: Repo Angles (CV suggestions)
1061
+ # ═══════════════════════════════════════════════
1062
+ if repo_angles:
1063
+ detail.write("")
1064
+ detail.write(f"[bold] Repo Matches ({len(repo_angles)}):[/bold]")
1065
+ for ra in repo_angles[:4]:
1066
+ detail.write(f" [green]\u2605[/green] [bold]{ra['repo_name']}[/bold] [dim]({ra.get('language', '?')})[/dim]")
1067
+ matched = ", ".join(ra.get("matched_terms", [])[:4])
1068
+ if matched:
1069
+ detail.write(f" [dim]{matched}[/dim]")
1070
+ if ra.get("suggested_highlight_for_cv"):
1071
+ detail.write(f" [italic]{ra['suggested_highlight_for_cv'][:80]}[/italic]")
1072
+
1073
+ # ═══════════════════════════════════════════════
1074
+ # CARD: Seniority & Context
1075
+ # ═══════════════════════════════════════════════
1076
+ if sen_detail:
1077
+ detail.write("")
1078
+ detail.write("[bold] Role Context:[/bold]")
1079
+ yrs = sen_detail.get("years_required", 0)
1080
+ level = sen_detail.get("level", "?").title()
1081
+ leadership = sen_detail.get("leadership", False)
1082
+ mentorship = sen_detail.get("mentorship", False)
1083
+ autonomy = sen_detail.get("autonomy", False)
1084
+ detail.write(f" Level: [bold]{level}[/bold] Years req: [bold]{yrs}[/bold]")
1085
+ extras = []
1086
+ if leadership:
1087
+ extras.append("[green]\u2713[/green] Leadership")
1088
+ if mentorship:
1089
+ extras.append("[green]\u2713[/green] Mentorship")
1090
+ if autonomy:
1091
+ extras.append("[yellow]\u25b3[/yellow] Autonomy")
1092
+ if extras:
1093
+ detail.write(f" {', '.join(extras)}")
1094
+
1095
+ # ═══════════════════════════════════════════════
1096
+ # CARD: Tailoring Recommendation
1097
+ # ═══════════════════════════════════════════════
1098
+ rec = sd.get("recommendation", "")
1099
+ if rec:
1100
+ detail.write("")
1101
+ detail.write(" [bold green]\u25b6[/bold green] [bold]Tailoring Rec:[/bold]")
1102
+ detail.write(f" [italic]{rec}[/italic]")
1103
+
1104
+ # ═══════════════════════════════════════════════
1105
+ # CARD: Quick Tailor Action Summary
1106
+ # ═══════════════════════════════════════════════
1107
+ actions: list[str] = []
1108
+ if gaps:
1109
+ n_high = len([g for g in gaps if g.get("severity") == "high"])
1110
+ actions.append(f"Address {n_high} high-severity gap{'s' if n_high != 1 else ''} (omit/reframe)")
1111
+ if exp_fit and exp_fit[0].get("suggested_order_priority", 0) > 1:
1112
+ actions.append(f"Reorder experience: lead with {exp_fit[0]['company']}")
1113
+ if repo_angles:
1114
+ actions.append(f"Incorporate {repo_angles[0]['repo_name']} as project evidence")
1115
+ if sd.get("matched_cv_keywords"):
1116
+ matched_set = set(sd.get("matched_cv_keywords", []))
1117
+ jd_lower = (job.get("description", "") or "").lower()
1118
+ missing_ats = [kw for kw in (sd.get("matched_languages", []) + sd.get("matched_frameworks", []))
1119
+ if kw not in matched_set and kw in jd_lower]
1120
+ if missing_ats:
1121
+ actions.append(f"Add {missing_ats[0]} to CV keywords for ATS")
1122
+ if actions:
1123
+ detail.write("")
1124
+ detail.write(" [bold]Actions:[/bold]")
1125
+ for a in actions[:4]:
1126
+ detail.write(f" [dim]\u2192[/dim] [italic]{a}[/italic]")
1127
+
1128
+ # ═══════════════════════════════════════════════
1129
+ # CARD: Description (collapsed)
1130
+ # ═══════════════════════════════════════════════
1131
+ desc = job.get("description", "")
1132
+ if desc:
1133
+ detail.write("")
1134
+ detail.write(f"[dim]Raw JD ({len(desc)} chars):[/dim]")
1135
+ detail.write(f"[dim]{desc[:1200]}[/dim]")
1136
+ except Exception:
1137
+ pass
1138
+ self._update_status()
1139
+
1140
+ # ── Search ───────────────────────────────────────────────────────────
1141
+ @on(Input.Changed, "#search_input")
1142
+ def on_search_changed(self, event: Input.Changed) -> None:
1143
+ self._search_query = event.value.strip()
1144
+ self.current_job_idx = None
1145
+ self._refresh_job_table()
1146
+ try:
1147
+ self.query_one("#score_detail", RichLog).clear()
1148
+ except Exception:
1149
+ pass
1150
+
1151
+ @on(Input.Submitted, "#search_input")
1152
+ def on_search_submitted(self, event: Input.Submitted) -> None:
1153
+ if self.filtered_jobs:
1154
+ self.current_job_idx = 0
1155
+ self._show_job_detail(0)
1156
+ try:
1157
+ self.query_one("#job_table", DataTable).focus()
1158
+ except Exception:
1159
+ pass
1160
+
1161
+ def action_focus_search(self) -> None:
1162
+ try:
1163
+ self.query_one("#main_tabs", TabbedContent).active = "tab_jobs"
1164
+ self.query_one("#search_input", Input).focus()
1165
+ except Exception:
1166
+ pass
1167
+
1168
+ # ── Events: table ────────────────────────────────────────────────────
1169
+ @on(DataTable.RowSelected, "#job_table")
1170
+ def on_row_selected(self, event: DataTable.RowSelected) -> None:
1171
+ if event.cursor_row is not None:
1172
+ self._show_job_detail(event.cursor_row)
1173
+
1174
+ @on(DataTable.RowSelected, "#tailored_table")
1175
+ def on_tailored_row_selected(self, event: DataTable.RowSelected) -> None:
1176
+ if event.cursor_row is not None:
1177
+ self._show_tailored_diff(event.cursor_row)
1178
+
1179
+ # ── Actions ──────────────────────────────────────────────────────────
1180
+ def action_quit(self) -> None:
1181
+ self.push_screen(ConfirmQuitScreen(), self._on_quit_result)
1182
+
1183
+ def _on_quit_result(self, result: bool | None) -> None:
1184
+ if result is True:
1185
+ self._emergency_cleanup()
1186
+ self.exit()
1187
+
1188
+ def action_scan_jobs(self) -> None:
1189
+ if self._scanning:
1190
+ self.notify("Scan already in progress", timeout=2)
1191
+ return
1192
+ try:
1193
+ self.query_one("#main_tabs", TabbedContent).active = "tab_scanner"
1194
+ except Exception:
1195
+ pass
1196
+ self._scanning = True
1197
+ queries = config.scan_queries()
1198
+ platforms = config.scan_platforms()
1199
+ self._scan_count = 0
1200
+ self._scan_total = len(queries)
1201
+ self._update_status()
1202
+ self._log(f"[bold]{'━' * 50}[/bold]")
1203
+ self._log(f"[bold cyan]\u25b6 Starting scan[/bold cyan]")
1204
+ self._log(f" Platforms: [bold]{platforms}[/bold]")
1205
+ self._log(f" Queries: {', '.join(f'[{q}]' for q in queries)}")
1206
+ self._log(f"[bold]{'━' * 50}[/bold]")
1207
+ self._start_scan_worker(queries, platforms)
1208
+
1209
+ @work(thread=True, exclusive=True, group="scanner")
1210
+ def _start_scan_worker(self, queries: list[str], platforms: str) -> None:
1211
+ limit = config.scan_limit()
1212
+ timeout = config.scan_timeout()
1213
+ try:
1214
+ for query in queries:
1215
+ if self._shutting_down:
1216
+ self.call_from_thread(self._log, "[red] Aborting scan — app is shutting down[/red]")
1217
+ break
1218
+ self.call_from_thread(self._scan_count_increment, query)
1219
+ try:
1220
+ proc = subprocess.Popen(
1221
+ [
1222
+ str(VENV_PYTHON), "-m", "scripts.job_scanner.scanner",
1223
+ "--query", query,
1224
+ "--platforms", platforms,
1225
+ "--limit", str(limit),
1226
+ "--json",
1227
+ ],
1228
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
1229
+ text=True, cwd=str(_CHAMELEON),
1230
+ )
1231
+ self._track_proc(proc)
1232
+ try:
1233
+ stdout, stderr = proc.communicate(timeout=timeout)
1234
+ except subprocess.TimeoutExpired:
1235
+ proc.kill()
1236
+ proc.wait(timeout=5)
1237
+ self.call_from_thread(self._log, f"[red] X {query}: timeout ({timeout}s)[/red]")
1238
+ continue
1239
+ finally:
1240
+ self._untrack_proc(proc)
1241
+
1242
+ if proc.returncode != 0:
1243
+ err = (stderr or "").strip()[:200]
1244
+ self.call_from_thread(self._log, f"[red] X {query}: {err or 'exit code ' + str(proc.returncode)}[/red]")
1245
+ continue
1246
+ stdout = (stdout or "").strip()
1247
+ if stdout:
1248
+ data = json.loads(stdout)
1249
+ jobs = data if isinstance(data, list) else data.get("jobs", [])
1250
+ for job in jobs:
1251
+ self.call_from_thread(self._add_job_from_data, job, source=f"scan:{query}")
1252
+ self.call_from_thread(self._log, f"[green] OK {query}: {len(jobs)} found[/green]")
1253
+ else:
1254
+ self.call_from_thread(self._log, f"[yellow] -> {query}: no results[/yellow]")
1255
+ except json.JSONDecodeError as e:
1256
+ self.call_from_thread(self._log, f"[red] X {query}: bad JSON: {e}[/red]")
1257
+ except Exception as e:
1258
+ self.call_from_thread(self._log, f"[red] X {query}: {e}[/red]")
1259
+ finally:
1260
+ self._scanning = False
1261
+ self.call_from_thread(self._update_status)
1262
+
1263
+ def _finish() -> None:
1264
+ self._show_loaded_jobs()
1265
+ self._rescore_all()
1266
+ self._log(f"[bold]{'━' * 50}[/bold]")
1267
+ self._log("[bold green]\u2714 Scan complete[/bold green]")
1268
+ self._log(f"[bold]{'━' * 50}[/bold]")
1269
+ self.notify("Scan complete", timeout=3)
1270
+ self.call_from_thread(_finish)
1271
+
1272
+ def _scan_count_increment(self, query: str) -> None:
1273
+ self._scan_count += 1
1274
+ self._update_status()
1275
+ self._log(f"[dim] -> {query}...[/dim]")
1276
+
1277
+ def action_rescore(self) -> None:
1278
+ self._rescore_all()
1279
+
1280
+ def action_edit_job(self) -> None:
1281
+ self._open_in_editor()
1282
+
1283
+ def action_tailor_cv(self) -> None:
1284
+ self._tailor_current()
1285
+
1286
+ def action_paste_job(self) -> None:
1287
+ self.push_screen(PasteJobScreen(), self._on_paste_result)
1288
+
1289
+ def action_delete_job(self) -> None:
1290
+ self._delete_current()
1291
+
1292
+ def action_open_url(self) -> None:
1293
+ self._open_url()
1294
+
1295
+ # ── Shutdown lifecycle ──────────────────────────────────────────────
1296
+ def on_unmount(self) -> None:
1297
+ """Textual lifecycle hook — fires when the app is being torn down."""
1298
+ self._emergency_cleanup()
1299
+
1300
+ def action_help_screen(self) -> None:
1301
+ self.push_screen(HelpScreen())
1302
+
1303
+ def action_setup_wizard(self) -> None:
1304
+ """Re-open the setup wizard (F3)."""
1305
+ from scripts.tui.screens import SetupWizardScreen
1306
+
1307
+ def _on_result(result: bool | None) -> None:
1308
+ if result:
1309
+ self._setup_status = self._check_setup()
1310
+ self._load_profile_tree()
1311
+ self._update_status()
1312
+ config.set("setup_wizard_completed", True)
1313
+ self.notify("[bold green]\u2714 Setup complete![/bold green]", timeout=4)
1314
+ try:
1315
+ self.query_one("#empty_state", Static).update(self._welcome_text())
1316
+ except Exception:
1317
+ pass
1318
+ else:
1319
+ self._setup_status = self._check_setup()
1320
+ self._update_status()
1321
+
1322
+ self.push_screen(SetupWizardScreen(), _on_result)
1323
+
1324
+ def action_refresh(self) -> None:
1325
+ self._rescore_all()
1326
+
1327
+ def action_scan_with_options(self) -> None:
1328
+ self.push_screen(CustomScanScreen())
1329
+
1330
+ # ── Editor ───────────────────────────────────────────────────────────
1331
+ def _open_in_editor(self) -> None:
1332
+ job = self._get_selected_job()
1333
+ if not job:
1334
+ self.notify("No job selected", timeout=2)
1335
+ return
1336
+ try:
1337
+ self.query_one("#main_tabs", TabbedContent).active = "tab_editor"
1338
+ try:
1339
+ self.query_one("#editor_placeholder", Static).display = False
1340
+ except Exception:
1341
+ pass
1342
+ editor = self.query_one("#editor_container", NanoEditor)
1343
+ text = job.get("description") or f"{job.get('title', '')} at {job.get('company', '')}\n\n{job.get('url', '')}"
1344
+ safe_co = job.get("company", "Unknown").replace(" ", "_").replace("/", "_")[:20]
1345
+ safe_title = job.get("title", "Unknown").replace(" ", "_").replace("/", "_")[:30]
1346
+ editor.load_text(text, filename=f"{safe_co}_{safe_title}.txt")
1347
+ except Exception:
1348
+ pass
1349
+
1350
+ def _save_editor_content(self) -> None:
1351
+ job = self._get_selected_job()
1352
+ if not job:
1353
+ return
1354
+ try:
1355
+ editor = self.query_one("#editor_container", NanoEditor)
1356
+ text = editor.get_text()
1357
+ job["description"] = text
1358
+ self._rescore_all()
1359
+ self.notify(f"Saved & re-scoring: {job.get('title', '?')}", timeout=2)
1360
+ except Exception as e:
1361
+ self.notify(f"Save failed: {e}", timeout=3)
1362
+
1363
+ # ── Tailor (background) ──────────────────────────────────────────────
1364
+ def _tailor_current(self) -> None:
1365
+ job = self._get_selected_job()
1366
+ if not job:
1367
+ self.notify("No job selected", timeout=2)
1368
+ return
1369
+ if self._tailor_running:
1370
+ self.notify("[yellow]Tailor already in progress — wait for it to finish[/yellow]", timeout=4)
1371
+ return
1372
+ self._tailor_running = True
1373
+ try:
1374
+ self.query_one("#main_tabs", TabbedContent).active = "tab_scanner"
1375
+ except Exception:
1376
+ pass
1377
+ try:
1378
+ self.query_one("#scan_log", RichLog).clear()
1379
+ except Exception:
1380
+ pass
1381
+ self._log(f"[bold]{'━' * 50}[/bold]")
1382
+ self._log(f"[bold cyan]\u2702 Tailoring CV:[/bold cyan] {job.get('title', '?')} at {job.get('company', '?')}")
1383
+ self._log(f" [bold]Score:[/bold] {job.get('score', 0)}/100")
1384
+ self._log(f"[bold]{'━' * 50}[/bold]")
1385
+ self._log("[yellow]Starting tailor pipeline — progress will appear below in real-time...[/yellow]")
1386
+ self.notify("[bold]Tailoring CV...[/bold] check Scanner tab for progress", timeout=5)
1387
+ self._run_tailor_worker(job)
1388
+
1389
+ @work(thread=True, exclusive=True, group="tailor")
1390
+ def _run_tailor_worker(self, job: dict) -> None:
1391
+ # Tee stdout so print(flush=True) from tailor_and_render appears in TUI log
1392
+ old_stdout = sys.stdout
1393
+ tee = TeeStream(sys.stdout, lambda line: None if self._shutting_down else self.call_from_thread(self._log, line))
1394
+ sys.stdout = tee
1395
+ try:
1396
+ ok, msg, path = tailor_and_render(
1397
+ job.get("description", ""),
1398
+ company=job.get("company", ""),
1399
+ title=job.get("title", ""),
1400
+ )
1401
+ if ok:
1402
+ yaml_path = ""
1403
+ pdf_path = ""
1404
+ for line in msg.split("\n"):
1405
+ trimmed = line.strip()
1406
+ if trimmed.startswith("YAML:"):
1407
+ yaml_path = trimmed.split(":", 1)[1].strip()
1408
+ elif trimmed.startswith("PDF:"):
1409
+ pdf_path = trimmed.split(":", 1)[1].strip()
1410
+ self.call_from_thread(self._add_tailored_cv, job, yaml_path, pdf_path)
1411
+ self.call_from_thread(self._log, "[bold green]\u2714 CV tailored successfully![/bold green]")
1412
+ self.call_from_thread(self._log, f"[green]{msg}[/green]")
1413
+ self.call_from_thread(self._crash_notify, "Tailored", "Check Tailored tab", severity="info")
1414
+ else:
1415
+ self.call_from_thread(self._crash_notify, "Tailor failed", msg[:200], severity="warning")
1416
+ except Exception as e:
1417
+ import traceback
1418
+ tb = traceback.format_exc()
1419
+ self.call_from_thread(self._crash_notify, "Tailor crashed", str(e)[:120], severity="error")
1420
+ self.call_from_thread(self._log, f"[red]{tb}[/red]")
1421
+ finally:
1422
+ sys.stdout = old_stdout
1423
+ self._tailor_running = False
1424
+ self.call_from_thread(self._update_status)
1425
+
1426
+ def _add_tailored_cv_from_db(self, tc: dict) -> None:
1427
+ key = f"{tc.get('company', '').lower().strip()}|{tc.get('title', '').lower().strip()}"
1428
+ for existing in self.tailored_cvs:
1429
+ ek = f"{existing.get('company', '').lower().strip()}|{existing.get('title', '').lower().strip()}"
1430
+ if ek == key:
1431
+ return
1432
+ self.tailored_cvs.append(tc)
1433
+
1434
+ def _add_tailored_cv(self, job: dict, yaml_path: str, pdf_path: str) -> None:
1435
+ key = f"{job.get('company', '').lower().strip()}|{job.get('title', '').lower().strip()}"
1436
+ for existing in self.tailored_cvs:
1437
+ ek = f"{existing.get('company', '').lower().strip()}|{existing.get('title', '').lower().strip()}"
1438
+ if ek == key:
1439
+ existing["yaml_path"] = yaml_path
1440
+ existing["pdf_path"] = pdf_path
1441
+ existing["score"] = job.get("score", 0)
1442
+ self._refresh_tailored_table()
1443
+ self._switch_to_tailored()
1444
+ return
1445
+ tc_entry = {
1446
+ "title": job.get("title", "Unknown"), "company": job.get("company", "Unknown"),
1447
+ "score": job.get("score", 0), "yaml_path": yaml_path, "pdf_path": pdf_path,
1448
+ "description": job.get("description", ""), "url": job.get("url", ""),
1449
+ "location": job.get("location", "Remote"), "remote": job.get("remote", False),
1450
+ "salary": job.get("salary", ""), "tags": job.get("tags", []),
1451
+ "source": job.get("source", "tailored"),
1452
+ }
1453
+ self.tailored_cvs.append(tc_entry)
1454
+ job_id = compute_job_id(job.get("title", ""), job.get("company", ""), job.get("url", ""), job.get("source", ""))
1455
+ try:
1456
+ save_tailored_cv(
1457
+ job_id=job_id,
1458
+ title=tc_entry["title"],
1459
+ company=tc_entry["company"],
1460
+ score=tc_entry["score"],
1461
+ yaml_path=str(yaml_path),
1462
+ pdf_path=str(pdf_path),
1463
+ description=tc_entry["description"],
1464
+ url=tc_entry["url"],
1465
+ location=tc_entry["location"],
1466
+ remote=tc_entry["remote"],
1467
+ salary=tc_entry["salary"],
1468
+ tags=tc_entry["tags"],
1469
+ source=tc_entry["source"],
1470
+ )
1471
+ except Exception as exc:
1472
+ self._log(f"[yellow]DB save warning: {exc}[/yellow]")
1473
+ self._refresh_tailored_table()
1474
+ self._switch_to_tailored()
1475
+
1476
+ def _switch_to_tailored(self) -> None:
1477
+ try:
1478
+ self.query_one("#main_tabs", TabbedContent).active = "tab_tailored"
1479
+ self.notify("[bold green]\u2702 Switched to Tailored tab[/bold green]", timeout=3)
1480
+ except Exception as exc:
1481
+ self._log(f"[red]Error switching to Tailored tab: {exc}[/red]")
1482
+ return
1483
+ if self.tailored_cvs:
1484
+ try:
1485
+ table = self.query_one("#tailored_table", DataTable)
1486
+ last = len(self.tailored_cvs) - 1
1487
+ table.move_cursor(row=last, column=0)
1488
+ self._show_tailored_diff(last)
1489
+ except Exception as exc:
1490
+ self._log(f"[red]Error selecting tailored row: {exc}[/red]")
1491
+
1492
+ # ── Delete (memory + SQLite) ────────────────────────────────────────
1493
+ def _delete_current(self) -> None:
1494
+ job = self._get_selected_job()
1495
+ if not job:
1496
+ self.notify("No job selected", timeout=2)
1497
+ return
1498
+
1499
+ def on_confirm(confirmed: bool | None) -> None:
1500
+ if not confirmed:
1501
+ return
1502
+ self.jobs = [j for j in self.jobs if j is not job]
1503
+ self.scored_jobs = [j for j in self.scored_jobs if j is not job]
1504
+ self.filtered_jobs = [j for j in self.filtered_jobs if j is not job]
1505
+ job_id = compute_job_id(job.get("title", ""), job.get("company", ""), job.get("url", ""), job.get("source", ""))
1506
+ try:
1507
+ delete_job(job_id)
1508
+ except Exception:
1509
+ pass
1510
+ if self.current_job_idx is not None:
1511
+ if self.current_job_idx >= len(self.filtered_jobs):
1512
+ self.current_job_idx = len(self.filtered_jobs) - 1 if self.filtered_jobs else None
1513
+ self._refresh_job_table()
1514
+ try:
1515
+ self.query_one("#score_detail", RichLog).clear()
1516
+ except Exception:
1517
+ pass
1518
+ self.notify(f"Deleted: {job.get('title', '?')}", timeout=2)
1519
+
1520
+ self.push_screen(
1521
+ DeleteConfirmScreen(job.get("title", "?"), job.get("company", "?")),
1522
+ on_confirm,
1523
+ )
1524
+
1525
+ # ── Open URL ─────────────────────────────────────────────────────────
1526
+ def _open_url(self) -> None:
1527
+ job = self._get_selected_job()
1528
+ if not job:
1529
+ self.notify("No job selected", timeout=2)
1530
+ return
1531
+ url = job.get("url")
1532
+ if not url:
1533
+ self.notify("No URL for this job", timeout=2)
1534
+ return
1535
+ try:
1536
+ import webbrowser
1537
+ webbrowser.open(url)
1538
+ self.notify(f"Opened: {truncate(url, 40)}", timeout=2)
1539
+ except Exception as e:
1540
+ self.notify(f"Failed: {e}", timeout=3)
1541
+
1542
+ # ── Paste ────────────────────────────────────────────────────────────
1543
+ def _on_paste_result(self, result: dict | None) -> None:
1544
+ if result:
1545
+ self._add_job_from_data(result, source="pasted")
1546
+ self._rescore_all()
1547
+ self.notify(f"Added: {result.get('title', 'Unknown')}", timeout=3)