@jsonresume/jobs 0.10.0 → 0.11.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.
- package/README.md +11 -3
- package/package.json +1 -1
- package/src/api.js +8 -0
- package/src/cache.js +2 -1
- package/src/localApi.js +5 -0
- package/src/tui/AIPanel.js +113 -15
- package/src/tui/App.js +132 -9
- package/src/tui/HelpModal.js +1 -0
- package/src/tui/JobDetail.js +2 -0
- package/src/tui/JobList.js +52 -5
- package/src/tui/StatusBar.js +9 -1
- package/src/tui/useAI.js +381 -2
- package/src/tui/useJobs.js +2 -1
package/README.md
CHANGED
|
@@ -118,11 +118,12 @@ The TUI has three main regions:
|
|
|
118
118
|
- **Export** — press `e` to export your shortlist, applied, and maybe lists to a `job-hunt-YYYY-MM-DD.md` markdown file in the current directory
|
|
119
119
|
- **Toast notifications** — instant feedback on every action (marking, exporting, refreshing)
|
|
120
120
|
- **Help modal** — press `?` for a full keyboard reference organized by section
|
|
121
|
+
- **Dossier research** — press `c` to spawn a Claude Code CLI session that researches the company, role, and generates a comprehensive dossier with fit assessment, talking points, interview prep, and compensation context. Results stream live and are cached server-side so you can revisit them anytime. Supports switching between multiple dossiers without restarting. Requires [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed
|
|
121
122
|
- **AI summaries** — press `Space` for a per-job AI summary, or `S` for a batch review of all visible jobs (requires `OPENAI_API_KEY`)
|
|
122
123
|
- **Vim-style navigation** — `j`/`k` to move, `g`/`G` to jump to first/last, `Ctrl+U`/`Ctrl+D` to page up/down
|
|
123
124
|
- **Responsive columns** — job list columns (score, title, company, location, salary) adapt to terminal width on resize
|
|
124
125
|
- **Smart filtering** — passed and dismissed jobs are excluded server-side with 5x over-fetch, so you always get a full set of fresh results
|
|
125
|
-
- **Cached results** — job data is cached locally for
|
|
126
|
+
- **Cached results** — job data is cached locally for 2 hours to minimize API calls; press `R` to force a fresh fetch
|
|
126
127
|
|
|
127
128
|
### Keyboard Shortcuts
|
|
128
129
|
|
|
@@ -145,6 +146,7 @@ The TUI has three main regions:
|
|
|
145
146
|
| `f` | Manage filters |
|
|
146
147
|
| `/` | Search profiles |
|
|
147
148
|
| `Space` | AI summary for current job |
|
|
149
|
+
| `c` | Research dossier via Claude Code CLI |
|
|
148
150
|
| `S` | AI batch review of visible jobs |
|
|
149
151
|
| `e` | Export shortlist to markdown |
|
|
150
152
|
| `R` | Force refresh (bypass cache) |
|
|
@@ -160,6 +162,7 @@ The TUI has three main regions:
|
|
|
160
162
|
| `o` | Open HN post in browser |
|
|
161
163
|
| `i` / `x` / `m` / `p` | Mark job state |
|
|
162
164
|
| `Space` | AI summary |
|
|
165
|
+
| `c` | Research dossier |
|
|
163
166
|
| `Esc` / `q` | Back to full list |
|
|
164
167
|
|
|
165
168
|
#### Filters Panel
|
|
@@ -262,6 +265,10 @@ npx @jsonresume/jobs help # All options
|
|
|
262
265
|
| `not_interested` | ✗ | Not for you (hidden from future searches) |
|
|
263
266
|
| `dismissed` | 👁 | Hide from results (hidden from future searches) |
|
|
264
267
|
|
|
268
|
+
## Architecture
|
|
269
|
+
|
|
270
|
+

|
|
271
|
+
|
|
265
272
|
## How Ranking Works
|
|
266
273
|
|
|
267
274
|
The system uses a five-stage pipeline to match and rank jobs against your resume.
|
|
@@ -335,7 +342,7 @@ All local data is stored under `~/.jsonresume/`:
|
|
|
335
342
|
|------|----------|
|
|
336
343
|
| `~/.jsonresume/config.json` | API key and username (registry mode) |
|
|
337
344
|
| `~/.jsonresume/filters.json` | Saved filter presets per search profile |
|
|
338
|
-
| `~/.jsonresume/cache/` | Cached job results (auto-expires after
|
|
345
|
+
| `~/.jsonresume/cache/` | Cached job results (auto-expires after 2 hours) |
|
|
339
346
|
| `~/.jsonresume/local-marks.json` | Job marks in local mode |
|
|
340
347
|
|
|
341
348
|
The export command writes `job-hunt-YYYY-MM-DD.md` to your current working directory.
|
|
@@ -363,7 +370,8 @@ The TUI is built with [React Ink](https://github.com/vadimdemedes/ink) v6 using
|
|
|
363
370
|
| `src/tui/JobDetail.js` | Full job detail view (standalone and split-pane) |
|
|
364
371
|
| `src/tui/StatusBar.js` | Key hints, loading state, toasts |
|
|
365
372
|
| `src/tui/useJobs.js` | Job fetching, caching, tab filtering |
|
|
366
|
-
| `src/tui/useAI.js` | AI summary
|
|
373
|
+
| `src/tui/useAI.js` | AI summary, dossier research (Claude CLI), batch review |
|
|
374
|
+
| `src/tui/AIPanel.js` | Dossier/AI split-pane panel with scroll and export |
|
|
367
375
|
| `src/tui/useSearches.js` | Search profile CRUD |
|
|
368
376
|
| `src/filters.js` | Persistent filter storage per search profile |
|
|
369
377
|
| `src/export.js` | Markdown export |
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -57,5 +57,13 @@ export function createApiClient({ baseUrl, apiKey }) {
|
|
|
57
57
|
body: JSON.stringify(updates),
|
|
58
58
|
}),
|
|
59
59
|
deleteSearch: (id) => request(`/searches/${id}`, { method: 'DELETE' }),
|
|
60
|
+
|
|
61
|
+
// Dossiers
|
|
62
|
+
fetchDossier: (id) => request(`/jobs/${id}/dossier`),
|
|
63
|
+
saveDossier: (id, content) =>
|
|
64
|
+
request(`/jobs/${id}/dossier`, {
|
|
65
|
+
method: 'PUT',
|
|
66
|
+
body: JSON.stringify({ content }),
|
|
67
|
+
}),
|
|
60
68
|
};
|
|
61
69
|
}
|
package/src/cache.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
import { homedir } from 'os';
|
|
4
4
|
|
|
5
5
|
const CACHE_DIR = join(homedir(), '.jsonresume', 'cache');
|
|
6
|
-
const CACHE_TTL =
|
|
6
|
+
const CACHE_TTL = 2 * 60 * 60 * 1000; // 2 hours
|
|
7
7
|
|
|
8
8
|
function ensureDir() {
|
|
9
9
|
try {
|
|
@@ -14,6 +14,7 @@ function ensureDir() {
|
|
|
14
14
|
function cacheKey(params) {
|
|
15
15
|
const parts = [`jobs_${params.days || 30}_${params.top || 100}`];
|
|
16
16
|
if (params.searchId) parts.push(`s_${params.searchId.slice(0, 8)}`);
|
|
17
|
+
if (params.mode) parts.push(params.mode);
|
|
17
18
|
return parts.join('_') + '.json';
|
|
18
19
|
}
|
|
19
20
|
|
package/src/localApi.js
CHANGED
|
@@ -10,6 +10,7 @@ export function createLocalApiClient({ baseUrl, resume }) {
|
|
|
10
10
|
const base = baseUrl || DEFAULT_BASE_URL;
|
|
11
11
|
|
|
12
12
|
return {
|
|
13
|
+
mode: 'local',
|
|
13
14
|
fetchJobs: async (params = {}) => {
|
|
14
15
|
const body = {
|
|
15
16
|
resume,
|
|
@@ -68,5 +69,9 @@ export function createLocalApiClient({ baseUrl, resume }) {
|
|
|
68
69
|
deleteSearch: async () => {
|
|
69
70
|
throw new Error('Search profiles require a registry account');
|
|
70
71
|
},
|
|
72
|
+
|
|
73
|
+
// Dossiers — local mode stores in memory only
|
|
74
|
+
fetchDossier: async () => ({ content: null }),
|
|
75
|
+
saveDossier: async () => ({ saved: true }),
|
|
71
76
|
};
|
|
72
77
|
}
|
package/src/tui/AIPanel.js
CHANGED
|
@@ -1,37 +1,135 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
1
2
|
import { Box, Text, useInput } from 'ink';
|
|
2
3
|
import Spinner from 'ink-spinner';
|
|
3
4
|
import { h } from './h.js';
|
|
4
5
|
|
|
5
|
-
export default function AIPanel({
|
|
6
|
+
export default function AIPanel({
|
|
7
|
+
text,
|
|
8
|
+
loading,
|
|
9
|
+
error,
|
|
10
|
+
onDismiss,
|
|
11
|
+
onExport,
|
|
12
|
+
onMark,
|
|
13
|
+
job,
|
|
14
|
+
isActive,
|
|
15
|
+
mode,
|
|
16
|
+
}) {
|
|
17
|
+
const [scroll, setScroll] = useState(0);
|
|
18
|
+
const maxRows = Math.max((process.stdout.rows || 30) - 8, 10);
|
|
19
|
+
const exportMsg = useRef(null);
|
|
20
|
+
|
|
21
|
+
// Enable mouse reporting for scroll wheel
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!process.stdout.isTTY) return;
|
|
24
|
+
// Enable mouse wheel reporting (SGR mode)
|
|
25
|
+
process.stdout.write('\x1b[?1000h\x1b[?1006h');
|
|
26
|
+
const onData = (data) => {
|
|
27
|
+
const str = data.toString();
|
|
28
|
+
// SGR mouse: \x1b[<button;col;row(M|m)
|
|
29
|
+
const match = str.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
|
30
|
+
if (match) {
|
|
31
|
+
const btn = parseInt(match[1], 10);
|
|
32
|
+
if (btn === 64) setScroll((s) => Math.max(0, s - 3));
|
|
33
|
+
if (btn === 65) setScroll((s) => s + 3);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
process.stdin.on('data', onData);
|
|
37
|
+
return () => {
|
|
38
|
+
process.stdin.removeListener('data', onData);
|
|
39
|
+
if (process.stdout.isTTY) {
|
|
40
|
+
process.stdout.write('\x1b[?1000l\x1b[?1006l');
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
6
45
|
useInput(
|
|
7
|
-
(
|
|
46
|
+
(input, key) => {
|
|
8
47
|
if (key.escape) onDismiss();
|
|
48
|
+
if (input === 'e' && text && onExport) {
|
|
49
|
+
const filename = onExport();
|
|
50
|
+
if (filename) {
|
|
51
|
+
exportMsg.current = filename;
|
|
52
|
+
setTimeout(() => {
|
|
53
|
+
exportMsg.current = null;
|
|
54
|
+
}, 3000);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Mark keys
|
|
58
|
+
if (job && onMark) {
|
|
59
|
+
if (input === 'i') onMark(job.id, 'interested');
|
|
60
|
+
if (input === 'x') onMark(job.id, 'applied');
|
|
61
|
+
if (input === 'm') onMark(job.id, 'maybe');
|
|
62
|
+
if (input === 'p') onMark(job.id, 'not_interested');
|
|
63
|
+
}
|
|
64
|
+
if (key.upArrow || input === 'k') setScroll((s) => Math.max(0, s - 1));
|
|
65
|
+
if (key.downArrow || input === 'j') setScroll((s) => s + 1);
|
|
66
|
+
if (key.pageUp || (key.ctrl && input === 'u'))
|
|
67
|
+
setScroll((s) => Math.max(0, s - 20));
|
|
68
|
+
if (key.pageDown || (key.ctrl && input === 'd')) setScroll((s) => s + 20);
|
|
69
|
+
if (input === 'g') setScroll(0);
|
|
70
|
+
if (input === 'G' && text) {
|
|
71
|
+
const lines = text.split('\n');
|
|
72
|
+
setScroll(Math.max(0, lines.length - maxRows));
|
|
73
|
+
}
|
|
9
74
|
},
|
|
10
75
|
{ isActive }
|
|
11
76
|
);
|
|
12
77
|
|
|
78
|
+
const isCover = mode === 'cover';
|
|
79
|
+
const title = isCover ? '📋 Job Dossier (via Claude Code)' : '🤖 AI Analysis';
|
|
80
|
+
const color = isCover ? 'green' : 'magenta';
|
|
81
|
+
const loadingMsg = isCover
|
|
82
|
+
? ' Claude is researching… (streaming live)'
|
|
83
|
+
: ' Thinking...';
|
|
84
|
+
|
|
85
|
+
let displayText = text;
|
|
86
|
+
let totalLines = 0;
|
|
87
|
+
if (text) {
|
|
88
|
+
const lines = text.split('\n');
|
|
89
|
+
totalLines = lines.length;
|
|
90
|
+
if (loading) {
|
|
91
|
+
// While streaming, auto-scroll to bottom
|
|
92
|
+
displayText = lines.slice(Math.max(0, lines.length - maxRows)).join('\n');
|
|
93
|
+
} else {
|
|
94
|
+
// When done, allow manual scroll
|
|
95
|
+
const start = Math.min(scroll, Math.max(0, lines.length - maxRows));
|
|
96
|
+
displayText = lines.slice(start, start + maxRows).join('\n');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const scrollHint =
|
|
101
|
+
!loading && totalLines > maxRows
|
|
102
|
+
? `j/k scroll · g/G top/bottom · ${scroll + 1}–${Math.min(
|
|
103
|
+
scroll + maxRows,
|
|
104
|
+
totalLines
|
|
105
|
+
)}/${totalLines}`
|
|
106
|
+
: '';
|
|
107
|
+
|
|
108
|
+
const exportHint = text ? ' · e export' : '';
|
|
109
|
+
const statusLine =
|
|
110
|
+
loading && text
|
|
111
|
+
? 'Streaming… ESC to cancel'
|
|
112
|
+
: loading
|
|
113
|
+
? 'ESC to cancel'
|
|
114
|
+
: scrollHint
|
|
115
|
+
? `${scrollHint}${exportHint} · ESC to dismiss`
|
|
116
|
+
: `ESC to dismiss${exportHint}`;
|
|
117
|
+
|
|
13
118
|
return h(
|
|
14
119
|
Box,
|
|
15
120
|
{
|
|
16
121
|
flexDirection: 'column',
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
padding: 1,
|
|
20
|
-
marginX: 2,
|
|
122
|
+
flexGrow: 1,
|
|
123
|
+
paddingX: 1,
|
|
21
124
|
},
|
|
22
|
-
h(Text, { bold: true, color
|
|
125
|
+
h(Text, { bold: true, color }, title),
|
|
23
126
|
h(Text, null, ' '),
|
|
24
127
|
loading
|
|
25
|
-
? h(
|
|
26
|
-
Box,
|
|
27
|
-
null,
|
|
28
|
-
h(Spinner, { type: 'dots' }),
|
|
29
|
-
h(Text, null, ' Thinking...')
|
|
30
|
-
)
|
|
128
|
+
? h(Box, null, h(Spinner, { type: 'dots' }), h(Text, null, loadingMsg))
|
|
31
129
|
: null,
|
|
32
130
|
error ? h(Text, { color: 'red' }, `Error: ${error}`) : null,
|
|
33
|
-
|
|
131
|
+
displayText ? h(Text, { wrap: 'wrap' }, displayText) : null,
|
|
34
132
|
h(Text, null, ' '),
|
|
35
|
-
h(Text, { dimColor: true },
|
|
133
|
+
h(Text, { dimColor: true }, statusLine)
|
|
36
134
|
);
|
|
37
135
|
}
|
package/src/tui/App.js
CHANGED
|
@@ -65,7 +65,9 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
65
65
|
// Active search profile
|
|
66
66
|
const [activeSearchId, setActiveSearchId] = useState(null);
|
|
67
67
|
|
|
68
|
-
|
|
68
|
+
const searchesHook = useSearches(api);
|
|
69
|
+
|
|
70
|
+
// Persistent filters (local + server sync for search profiles)
|
|
69
71
|
const [filterStore, setFilterStore] = useState(() => loadFilters());
|
|
70
72
|
const activeFilters = useMemo(
|
|
71
73
|
() => getFiltersForSearch(filterStore, activeSearchId),
|
|
@@ -78,8 +80,12 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
78
80
|
saveFilters(next);
|
|
79
81
|
return next;
|
|
80
82
|
});
|
|
83
|
+
// Sync to server for search profiles
|
|
84
|
+
if (activeSearchId) {
|
|
85
|
+
searchesHook.updateFilters(activeSearchId, newActive);
|
|
86
|
+
}
|
|
81
87
|
},
|
|
82
|
-
[activeSearchId]
|
|
88
|
+
[activeSearchId, searchesHook]
|
|
83
89
|
);
|
|
84
90
|
const filterState = useMemo(
|
|
85
91
|
() => ({ active: activeFilters }),
|
|
@@ -90,6 +96,21 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
90
96
|
[updateFilters]
|
|
91
97
|
);
|
|
92
98
|
|
|
99
|
+
// Sync server-side filters into local store when search profiles load
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!searchesHook.searches.length) return;
|
|
102
|
+
setFilterStore((prev) => {
|
|
103
|
+
let updated = prev;
|
|
104
|
+
for (const s of searchesHook.searches) {
|
|
105
|
+
if (s.filters?.length && !prev.searches?.[s.id]) {
|
|
106
|
+
updated = setFiltersForSearch(updated, s.id, s.filters);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (updated !== prev) saveFilters(updated);
|
|
110
|
+
return updated;
|
|
111
|
+
});
|
|
112
|
+
}, [searchesHook.searches]);
|
|
113
|
+
|
|
93
114
|
const {
|
|
94
115
|
jobs: rawJobs,
|
|
95
116
|
allJobs,
|
|
@@ -100,8 +121,19 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
100
121
|
forceRefresh,
|
|
101
122
|
} = useJobs(api, activeFilters, tab, activeSearchId);
|
|
102
123
|
const ai = useAI(resume);
|
|
103
|
-
const searchesHook = useSearches(api);
|
|
104
124
|
const { toast, show: showToast } = useToast();
|
|
125
|
+
const [confirmExit, setConfirmExit] = useState(false);
|
|
126
|
+
|
|
127
|
+
// Kill claude processes on exit (Ctrl+C)
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const cleanup = () => ai.cancel();
|
|
130
|
+
process.on('SIGINT', cleanup);
|
|
131
|
+
process.on('SIGTERM', cleanup);
|
|
132
|
+
return () => {
|
|
133
|
+
process.removeListener('SIGINT', cleanup);
|
|
134
|
+
process.removeListener('SIGTERM', cleanup);
|
|
135
|
+
};
|
|
136
|
+
}, [ai]);
|
|
105
137
|
|
|
106
138
|
// Apply inline search filter
|
|
107
139
|
const jobs = useMemo(() => {
|
|
@@ -128,12 +160,12 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
128
160
|
.catch(() => {});
|
|
129
161
|
}, [api]);
|
|
130
162
|
|
|
131
|
-
// Update selectedJob when cursor moves in detail view
|
|
163
|
+
// Update selectedJob when cursor moves or jobs list changes in detail view
|
|
132
164
|
useEffect(() => {
|
|
133
165
|
if (view === 'detail' && jobs[cursor]) {
|
|
134
166
|
setSelectedJob(jobs[cursor]);
|
|
135
167
|
}
|
|
136
|
-
}, [cursor, view]);
|
|
168
|
+
}, [cursor, view, jobs]);
|
|
137
169
|
|
|
138
170
|
// Inline search escape handler
|
|
139
171
|
useInput(
|
|
@@ -153,7 +185,19 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
153
185
|
if (view === 'filters' || view === 'searches' || view === 'help') return;
|
|
154
186
|
if (inlineSearch) return;
|
|
155
187
|
|
|
156
|
-
if (input === 'q' && view === 'list')
|
|
188
|
+
if (input === 'q' && view === 'list') {
|
|
189
|
+
if (ai.hasActiveProcess && !confirmExit) {
|
|
190
|
+
showToast(
|
|
191
|
+
'Claude dossier still running — press q again to quit',
|
|
192
|
+
'warning'
|
|
193
|
+
);
|
|
194
|
+
setConfirmExit(true);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
ai.cancel();
|
|
198
|
+
exit();
|
|
199
|
+
}
|
|
200
|
+
if (input !== 'q') setConfirmExit(false);
|
|
157
201
|
if (input === 'q' && view === 'detail') setView('list');
|
|
158
202
|
if (input === 'R' && (view === 'list' || view === 'detail')) {
|
|
159
203
|
forceRefresh();
|
|
@@ -176,9 +220,12 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
176
220
|
setView('detail');
|
|
177
221
|
}
|
|
178
222
|
if (key.escape && view === 'detail') setView('list');
|
|
223
|
+
if (input === 'c' && view === 'detail' && selectedJob) {
|
|
224
|
+
handleDossier(selectedJob);
|
|
225
|
+
}
|
|
179
226
|
|
|
180
227
|
if (key.escape && view === 'ai') {
|
|
181
|
-
|
|
228
|
+
// Don't kill running dossier — just hide the panel
|
|
182
229
|
setView(selectedJob ? 'detail' : 'list');
|
|
183
230
|
}
|
|
184
231
|
|
|
@@ -219,6 +266,11 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
219
266
|
setView('ai');
|
|
220
267
|
ai.summarizeJob(job);
|
|
221
268
|
};
|
|
269
|
+
const handleDossier = (job) => {
|
|
270
|
+
setSelectedJob(job);
|
|
271
|
+
setView('ai');
|
|
272
|
+
ai.dossier(job, api);
|
|
273
|
+
};
|
|
222
274
|
const handleAIBatch = (visibleJobs) => {
|
|
223
275
|
setView('ai');
|
|
224
276
|
ai.batchReview(visibleJobs);
|
|
@@ -312,6 +364,9 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
312
364
|
onCursorChange: setCursor,
|
|
313
365
|
onSelect: handleSelect,
|
|
314
366
|
onMark: handleMark,
|
|
367
|
+
onAISummary: handleAISummary,
|
|
368
|
+
onDossier: handleDossier,
|
|
369
|
+
getDossierStatus: ai.getDossierStatus,
|
|
315
370
|
isActive: true,
|
|
316
371
|
compact: true,
|
|
317
372
|
reservedRows: 8,
|
|
@@ -327,6 +382,8 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
327
382
|
onBack: handleBack,
|
|
328
383
|
onMark: handleMark,
|
|
329
384
|
onAISummary: handleAISummary,
|
|
385
|
+
onDossier: handleDossier,
|
|
386
|
+
getDossierStatus: ai.getDossierStatus,
|
|
330
387
|
isActive: false,
|
|
331
388
|
isPanel: true,
|
|
332
389
|
})
|
|
@@ -336,6 +393,70 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
336
393
|
);
|
|
337
394
|
}
|
|
338
395
|
|
|
396
|
+
// Split-pane: compact list on left, AI/dossier on right
|
|
397
|
+
if (view === 'ai' && selectedJob) {
|
|
398
|
+
return h(
|
|
399
|
+
Box,
|
|
400
|
+
{ flexDirection: 'column', height: process.stdout.rows || 40 },
|
|
401
|
+
header,
|
|
402
|
+
h(
|
|
403
|
+
Box,
|
|
404
|
+
{ flexGrow: 1, flexDirection: 'row' },
|
|
405
|
+
// Left pane: compact job list
|
|
406
|
+
h(
|
|
407
|
+
Box,
|
|
408
|
+
{
|
|
409
|
+
flexDirection: 'column',
|
|
410
|
+
width: '40%',
|
|
411
|
+
borderStyle: 'single',
|
|
412
|
+
borderColor: 'gray',
|
|
413
|
+
borderRight: true,
|
|
414
|
+
borderLeft: false,
|
|
415
|
+
borderTop: false,
|
|
416
|
+
borderBottom: false,
|
|
417
|
+
},
|
|
418
|
+
h(JobList, {
|
|
419
|
+
jobs,
|
|
420
|
+
cursor,
|
|
421
|
+
tab,
|
|
422
|
+
onCursorChange: setCursor,
|
|
423
|
+
onSelect: handleSelect,
|
|
424
|
+
onMark: handleMark,
|
|
425
|
+
onAISummary: handleAISummary,
|
|
426
|
+
onDossier: handleDossier,
|
|
427
|
+
getDossierStatus: ai.getDossierStatus,
|
|
428
|
+
isActive: false,
|
|
429
|
+
compact: true,
|
|
430
|
+
reservedRows: 8,
|
|
431
|
+
})
|
|
432
|
+
),
|
|
433
|
+
// Right pane: AI/dossier panel
|
|
434
|
+
h(
|
|
435
|
+
Box,
|
|
436
|
+
{ flexDirection: 'column', width: '60%' },
|
|
437
|
+
h(AIPanel, {
|
|
438
|
+
text: ai.text,
|
|
439
|
+
loading: ai.loading,
|
|
440
|
+
error: ai.error,
|
|
441
|
+
mode: ai.mode,
|
|
442
|
+
job: selectedJob,
|
|
443
|
+
onMark: handleMark,
|
|
444
|
+
onDismiss: () => {
|
|
445
|
+
setView(selectedJob ? 'detail' : 'list');
|
|
446
|
+
},
|
|
447
|
+
onExport: () => {
|
|
448
|
+
const f = ai.exportDossier(selectedJob);
|
|
449
|
+
if (f) showToast(`Saved ./${f}`, 'export');
|
|
450
|
+
return f;
|
|
451
|
+
},
|
|
452
|
+
isActive: true,
|
|
453
|
+
})
|
|
454
|
+
)
|
|
455
|
+
),
|
|
456
|
+
statusBar
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
339
460
|
// Full-width list view
|
|
340
461
|
return h(
|
|
341
462
|
Box,
|
|
@@ -350,6 +471,7 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
350
471
|
onSelect: handleSelect,
|
|
351
472
|
onMark: handleMark,
|
|
352
473
|
onAISummary: handleAISummary,
|
|
474
|
+
onDossier: handleDossier,
|
|
353
475
|
onAIBatch: handleAIBatch,
|
|
354
476
|
onExport: handleExport,
|
|
355
477
|
isActive: !inlineSearch,
|
|
@@ -384,14 +506,15 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
384
506
|
onClose: () => setView('list'),
|
|
385
507
|
})
|
|
386
508
|
: null,
|
|
387
|
-
view === 'ai'
|
|
509
|
+
view === 'ai' && !selectedJob
|
|
388
510
|
? h(AIPanel, {
|
|
389
511
|
text: ai.text,
|
|
390
512
|
loading: ai.loading,
|
|
391
513
|
error: ai.error,
|
|
514
|
+
mode: ai.mode,
|
|
392
515
|
onDismiss: () => {
|
|
393
516
|
ai.clear();
|
|
394
|
-
setView(
|
|
517
|
+
setView('list');
|
|
395
518
|
},
|
|
396
519
|
isActive: true,
|
|
397
520
|
})
|
package/src/tui/HelpModal.js
CHANGED
package/src/tui/JobDetail.js
CHANGED
|
@@ -10,6 +10,7 @@ export default function JobDetail({
|
|
|
10
10
|
onBack,
|
|
11
11
|
onMark,
|
|
12
12
|
onAISummary,
|
|
13
|
+
onDossier,
|
|
13
14
|
isActive,
|
|
14
15
|
isPanel,
|
|
15
16
|
}) {
|
|
@@ -45,6 +46,7 @@ export default function JobDetail({
|
|
|
45
46
|
if (input === 'm') onMark(job.id, 'maybe');
|
|
46
47
|
if (input === 'p') onMark(job.id, 'not_interested');
|
|
47
48
|
if (input === ' ') onAISummary(job);
|
|
49
|
+
if (input === 'c' && onDossier) onDossier(job);
|
|
48
50
|
if (input === 'o' && (detail?.url || job.url)) {
|
|
49
51
|
import('child_process').then(({ exec }) => {
|
|
50
52
|
const url = detail?.url || job.url;
|
package/src/tui/JobList.js
CHANGED
|
@@ -16,13 +16,26 @@ function useColumns(hasRerank, compact) {
|
|
|
16
16
|
const cols = stdout?.columns || 120;
|
|
17
17
|
const available = compact ? Math.floor(cols * 0.4) : cols;
|
|
18
18
|
|
|
19
|
+
const dossierW = 2;
|
|
20
|
+
|
|
19
21
|
if (compact) {
|
|
20
|
-
// Compact mode: just score, title, status
|
|
22
|
+
// Compact mode: just score, title, dossier, status
|
|
21
23
|
const scoreW = 5;
|
|
22
24
|
const statusW = 2;
|
|
23
25
|
const gaps = GAP * 2;
|
|
24
|
-
const titleW = Math.max(
|
|
25
|
-
|
|
26
|
+
const titleW = Math.max(
|
|
27
|
+
10,
|
|
28
|
+
available - scoreW - dossierW - statusW - gaps - 2
|
|
29
|
+
);
|
|
30
|
+
return {
|
|
31
|
+
cols: available,
|
|
32
|
+
titleW,
|
|
33
|
+
compW: 0,
|
|
34
|
+
locW: 0,
|
|
35
|
+
scoreW,
|
|
36
|
+
statusW,
|
|
37
|
+
dossierW,
|
|
38
|
+
};
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
const scoreW = 5;
|
|
@@ -30,8 +43,9 @@ function useColumns(hasRerank, compact) {
|
|
|
30
43
|
const salaryW = 12;
|
|
31
44
|
const statusW = 2;
|
|
32
45
|
const cursorW = 2;
|
|
33
|
-
const gaps = GAP * (hasRerank ?
|
|
34
|
-
const fixed =
|
|
46
|
+
const gaps = GAP * (hasRerank ? 7 : 6);
|
|
47
|
+
const fixed =
|
|
48
|
+
cursorW + scoreW + aiW + salaryW + dossierW + statusW + gaps + 2;
|
|
35
49
|
const flex = Math.max(30, available - fixed);
|
|
36
50
|
const titleW = Math.max(12, Math.floor(flex * 0.35));
|
|
37
51
|
const compW = Math.max(10, Math.floor(flex * 0.3));
|
|
@@ -44,6 +58,7 @@ function useColumns(hasRerank, compact) {
|
|
|
44
58
|
scoreW,
|
|
45
59
|
salaryW,
|
|
46
60
|
statusW,
|
|
61
|
+
dossierW,
|
|
47
62
|
aiW,
|
|
48
63
|
};
|
|
49
64
|
}
|
|
@@ -99,6 +114,11 @@ function HeaderRow({ hasRerank, titleW, compW, locW, compact }) {
|
|
|
99
114
|
{ width: 12, marginRight: GAP },
|
|
100
115
|
h(Text, { bold: true, dimColor: true }, 'Salary')
|
|
101
116
|
),
|
|
117
|
+
h(
|
|
118
|
+
Box,
|
|
119
|
+
{ width: 2, marginRight: GAP },
|
|
120
|
+
h(Text, { bold: true, dimColor: true }, '📋')
|
|
121
|
+
),
|
|
102
122
|
h(Box, { width: 2 }, h(Text, { bold: true, dimColor: true }, ' '))
|
|
103
123
|
);
|
|
104
124
|
}
|
|
@@ -112,12 +132,15 @@ function JobRow({
|
|
|
112
132
|
locW,
|
|
113
133
|
marked,
|
|
114
134
|
compact,
|
|
135
|
+
dossierStatus,
|
|
115
136
|
}) {
|
|
116
137
|
const loc = formatLocation(job.location, job.remote);
|
|
117
138
|
const sal = formatSalary(job.salary, job.salary_usd);
|
|
118
139
|
const score =
|
|
119
140
|
typeof job.similarity === 'number' ? job.similarity.toFixed(2) : '—';
|
|
120
141
|
const icon = stateIcon(job.state);
|
|
142
|
+
const dossierIcon =
|
|
143
|
+
dossierStatus === 'generating' ? '◌' : dossierStatus === 'done' ? '📋' : '';
|
|
121
144
|
|
|
122
145
|
const stColor =
|
|
123
146
|
job.state === 'interested'
|
|
@@ -145,6 +168,8 @@ function JobRow({
|
|
|
145
168
|
backgroundColor: bg,
|
|
146
169
|
};
|
|
147
170
|
|
|
171
|
+
const dossierColor = dossierStatus === 'generating' ? 'yellow' : 'green';
|
|
172
|
+
|
|
148
173
|
if (compact) {
|
|
149
174
|
return h(
|
|
150
175
|
Box,
|
|
@@ -160,6 +185,15 @@ function JobRow({
|
|
|
160
185
|
{ flexGrow: 1 },
|
|
161
186
|
h(Text, props, truncate(job.title || '—', titleW))
|
|
162
187
|
),
|
|
188
|
+
h(
|
|
189
|
+
Box,
|
|
190
|
+
{ width: 2 },
|
|
191
|
+
h(
|
|
192
|
+
Text,
|
|
193
|
+
{ ...props, color: dossierIcon ? dossierColor : undefined },
|
|
194
|
+
dossierIcon || ' '
|
|
195
|
+
)
|
|
196
|
+
),
|
|
163
197
|
h(Box, { width: 2 }, h(Text, props, icon))
|
|
164
198
|
);
|
|
165
199
|
}
|
|
@@ -196,6 +230,15 @@ function JobRow({
|
|
|
196
230
|
h(Text, props, truncate(loc, locW - 1))
|
|
197
231
|
),
|
|
198
232
|
h(Box, { width: 12, marginRight: GAP }, h(Text, props, truncate(sal, 11))),
|
|
233
|
+
h(
|
|
234
|
+
Box,
|
|
235
|
+
{ width: 2, marginRight: GAP },
|
|
236
|
+
h(
|
|
237
|
+
Text,
|
|
238
|
+
{ ...props, color: dossierIcon ? dossierColor : undefined },
|
|
239
|
+
dossierIcon || ' '
|
|
240
|
+
)
|
|
241
|
+
),
|
|
199
242
|
h(Box, { width: 2 }, h(Text, props, icon))
|
|
200
243
|
);
|
|
201
244
|
}
|
|
@@ -248,8 +291,10 @@ export default function JobList({
|
|
|
248
291
|
onSelect,
|
|
249
292
|
onMark,
|
|
250
293
|
onAISummary,
|
|
294
|
+
onDossier,
|
|
251
295
|
onAIBatch,
|
|
252
296
|
onExport,
|
|
297
|
+
getDossierStatus,
|
|
253
298
|
isActive,
|
|
254
299
|
tab,
|
|
255
300
|
compact,
|
|
@@ -321,6 +366,7 @@ export default function JobList({
|
|
|
321
366
|
if (input === 'p' && jobs[cursor])
|
|
322
367
|
onMark(jobs[cursor].id, 'not_interested');
|
|
323
368
|
if (input === ' ' && jobs[cursor]) onAISummary(jobs[cursor]);
|
|
369
|
+
if (input === 'c' && jobs[cursor] && onDossier) onDossier(jobs[cursor]);
|
|
324
370
|
if (input === 'S' && onAIBatch) onAIBatch(jobs);
|
|
325
371
|
if (input === 'e' && onExport) onExport();
|
|
326
372
|
},
|
|
@@ -344,6 +390,7 @@ export default function JobList({
|
|
|
344
390
|
compW,
|
|
345
391
|
locW,
|
|
346
392
|
compact,
|
|
393
|
+
dossierStatus: getDossierStatus ? getDossierStatus(job.id) : null,
|
|
347
394
|
})
|
|
348
395
|
);
|
|
349
396
|
|
package/src/tui/StatusBar.js
CHANGED
|
@@ -12,6 +12,7 @@ const KEYS = {
|
|
|
12
12
|
['v', 'select'],
|
|
13
13
|
['n', 'find'],
|
|
14
14
|
['space', 'AI'],
|
|
15
|
+
['c', 'dossier'],
|
|
15
16
|
],
|
|
16
17
|
detail: [
|
|
17
18
|
['j/k', 'nav jobs'],
|
|
@@ -19,6 +20,7 @@ const KEYS = {
|
|
|
19
20
|
['i/x/m/p', 'mark'],
|
|
20
21
|
['o', 'open URL'],
|
|
21
22
|
['space', 'AI'],
|
|
23
|
+
['c', 'dossier'],
|
|
22
24
|
['esc', 'back'],
|
|
23
25
|
],
|
|
24
26
|
search: [
|
|
@@ -39,7 +41,13 @@ const KEYS = {
|
|
|
39
41
|
['d', 'delete'],
|
|
40
42
|
['esc', 'close'],
|
|
41
43
|
],
|
|
42
|
-
ai: [
|
|
44
|
+
ai: [
|
|
45
|
+
['j/k', 'scroll'],
|
|
46
|
+
['g/G', 'top/bottom'],
|
|
47
|
+
['i/x/m/p', 'mark'],
|
|
48
|
+
['e', 'export'],
|
|
49
|
+
['esc', 'back'],
|
|
50
|
+
],
|
|
43
51
|
help: [['?/esc', 'close']],
|
|
44
52
|
};
|
|
45
53
|
|
package/src/tui/useAI.js
CHANGED
|
@@ -1,12 +1,64 @@
|
|
|
1
|
-
import { useState, useCallback } from 'react';
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
import { writeFileSync } from 'fs';
|
|
2
3
|
import { generateText } from 'ai';
|
|
3
4
|
import { openai } from '@ai-sdk/openai';
|
|
4
5
|
|
|
6
|
+
function buildResumeText(resume) {
|
|
7
|
+
return [
|
|
8
|
+
resume?.basics?.name,
|
|
9
|
+
resume?.basics?.label,
|
|
10
|
+
resume?.basics?.summary,
|
|
11
|
+
...(resume?.skills || []).map(
|
|
12
|
+
(s) => `${s.name}: ${(s.keywords || []).join(', ')}`
|
|
13
|
+
),
|
|
14
|
+
...(resume?.work || [])
|
|
15
|
+
.slice(0, 5)
|
|
16
|
+
.map(
|
|
17
|
+
(w) =>
|
|
18
|
+
`${w.position} at ${w.name}${
|
|
19
|
+
w.startDate ? ` (${w.startDate})` : ''
|
|
20
|
+
}: ${w.summary || ''}`
|
|
21
|
+
),
|
|
22
|
+
...(resume?.projects || [])
|
|
23
|
+
.slice(0, 3)
|
|
24
|
+
.map((p) => `Project: ${p.name} — ${p.description || ''}`),
|
|
25
|
+
]
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.join('\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildJobText(job, rawContent) {
|
|
31
|
+
return [
|
|
32
|
+
`Title: ${job.title}`,
|
|
33
|
+
`Company: ${job.company}`,
|
|
34
|
+
`Salary: ${job.salary || 'Not listed'}`,
|
|
35
|
+
`Remote: ${job.remote || 'Not specified'}`,
|
|
36
|
+
job.experience ? `Experience: ${job.experience}` : '',
|
|
37
|
+
job.type ? `Type: ${job.type}` : '',
|
|
38
|
+
job.url ? `HN Post: ${job.url}` : '',
|
|
39
|
+
`Description: ${job.description || ''}`,
|
|
40
|
+
`Skills: ${(job.skills || []).map((s) => s.name || s).join(', ')}`,
|
|
41
|
+
...(job.qualifications || []).map((q) => `Qualification: ${q}`),
|
|
42
|
+
...(job.responsibilities || []).map((r) => `Responsibility: ${r}`),
|
|
43
|
+
rawContent ? `\nFull original posting:\n${rawContent}` : '',
|
|
44
|
+
]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
5
49
|
export function useAI(resume) {
|
|
6
50
|
const [text, setText] = useState('');
|
|
7
51
|
const [loading, setLoading] = useState(false);
|
|
8
52
|
const [error, setError] = useState(null);
|
|
53
|
+
const [mode, setMode] = useState('ai'); // 'ai' | 'cover'
|
|
9
54
|
const hasKey = Boolean(process.env.OPENAI_API_KEY);
|
|
55
|
+
const childRef = useRef(null);
|
|
56
|
+
const dossierJobId = useRef(null);
|
|
57
|
+
// Local cache: jobId → { text, done, loading }
|
|
58
|
+
const dossierCache = useRef(new Map());
|
|
59
|
+
// Bump to trigger re-renders when dossier status changes
|
|
60
|
+
const [, setDossierTick] = useState(0);
|
|
61
|
+
const bumpTick = () => setDossierTick((t) => t + 1);
|
|
10
62
|
|
|
11
63
|
const summarizeJob = useCallback(
|
|
12
64
|
async (job) => {
|
|
@@ -16,6 +68,7 @@ export function useAI(resume) {
|
|
|
16
68
|
);
|
|
17
69
|
return;
|
|
18
70
|
}
|
|
71
|
+
setMode('ai');
|
|
19
72
|
setLoading(true);
|
|
20
73
|
setError(null);
|
|
21
74
|
setText('');
|
|
@@ -56,6 +109,283 @@ export function useAI(resume) {
|
|
|
56
109
|
[resume, hasKey]
|
|
57
110
|
);
|
|
58
111
|
|
|
112
|
+
const dossier = useCallback(
|
|
113
|
+
async (job, api) => {
|
|
114
|
+
const cached = dossierCache.current.get(job.id);
|
|
115
|
+
|
|
116
|
+
// If dossier is already loading or loaded for this job, just show it
|
|
117
|
+
if (cached && (cached.loading || cached.done)) {
|
|
118
|
+
setMode('cover');
|
|
119
|
+
setText(cached.text || '');
|
|
120
|
+
setLoading(cached.loading || false);
|
|
121
|
+
setError(null);
|
|
122
|
+
dossierJobId.current = job.id;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
setMode('cover');
|
|
127
|
+
setError(null);
|
|
128
|
+
setText('');
|
|
129
|
+
dossierJobId.current = job.id;
|
|
130
|
+
dossierCache.current.set(job.id, {
|
|
131
|
+
text: '',
|
|
132
|
+
done: false,
|
|
133
|
+
loading: true,
|
|
134
|
+
});
|
|
135
|
+
bumpTick();
|
|
136
|
+
|
|
137
|
+
// Helper to update both state and cache
|
|
138
|
+
// Only updates visible state if this job is still the active dossier
|
|
139
|
+
const updateText = (val) => {
|
|
140
|
+
const entry = dossierCache.current.get(job.id);
|
|
141
|
+
if (entry) entry.text = val;
|
|
142
|
+
if (dossierJobId.current === job.id) setText(val);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Check server for existing dossier
|
|
146
|
+
try {
|
|
147
|
+
const { content } = await api.fetchDossier(job.id);
|
|
148
|
+
if (content) {
|
|
149
|
+
updateText(content);
|
|
150
|
+
dossierCache.current.set(job.id, {
|
|
151
|
+
text: content,
|
|
152
|
+
done: true,
|
|
153
|
+
loading: false,
|
|
154
|
+
});
|
|
155
|
+
bumpTick();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
/* no cached dossier */
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check if claude CLI is available
|
|
163
|
+
const { execSync } = await import('child_process');
|
|
164
|
+
let claudePath;
|
|
165
|
+
try {
|
|
166
|
+
claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
|
|
167
|
+
} catch {
|
|
168
|
+
setError(
|
|
169
|
+
'Claude Code CLI not found. Install it: npm install -g @anthropic-ai/claude-code'
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Kill any existing claude process
|
|
175
|
+
if (childRef.current) {
|
|
176
|
+
try {
|
|
177
|
+
childRef.current.kill();
|
|
178
|
+
} catch {}
|
|
179
|
+
childRef.current = null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setLoading(true);
|
|
183
|
+
|
|
184
|
+
// Fetch resume if not available yet
|
|
185
|
+
let currentResume = resume;
|
|
186
|
+
if (!currentResume) {
|
|
187
|
+
try {
|
|
188
|
+
const me = await api.fetchMe();
|
|
189
|
+
currentResume = me.resume;
|
|
190
|
+
} catch {
|
|
191
|
+
/* proceed without resume */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Fetch full job detail for raw content
|
|
196
|
+
let rawContent = '';
|
|
197
|
+
try {
|
|
198
|
+
const detail = await api.fetchJobDetail(job.id);
|
|
199
|
+
rawContent = detail.raw_content || detail.full_description || '';
|
|
200
|
+
} catch {}
|
|
201
|
+
|
|
202
|
+
const resumeText = buildResumeText(currentResume);
|
|
203
|
+
const jobText = buildJobText(job, rawContent);
|
|
204
|
+
|
|
205
|
+
const prompt = `You are a job search research assistant. A candidate is considering applying to this role. Do thorough research and produce a comprehensive dossier.
|
|
206
|
+
|
|
207
|
+
## Candidate Resume
|
|
208
|
+
${resumeText}
|
|
209
|
+
|
|
210
|
+
## Job Posting
|
|
211
|
+
${jobText}
|
|
212
|
+
|
|
213
|
+
## Your Task
|
|
214
|
+
|
|
215
|
+
Research everything you can and produce a complete dossier covering:
|
|
216
|
+
|
|
217
|
+
### 1. Company Deep Dive
|
|
218
|
+
- What the company does, their products/services
|
|
219
|
+
- Funding stage, size, recent news
|
|
220
|
+
- Tech stack and engineering culture
|
|
221
|
+
- Leadership team
|
|
222
|
+
- Employee sentiment and Glassdoor/Blind highlights
|
|
223
|
+
- Any red flags or concerns
|
|
224
|
+
|
|
225
|
+
### 2. Role Analysis
|
|
226
|
+
- What you'd actually be doing day-to-day
|
|
227
|
+
- Team context — who you'd work with
|
|
228
|
+
- Growth trajectory for this role
|
|
229
|
+
- How this role fits into the company's current priorities
|
|
230
|
+
|
|
231
|
+
### 3. Fit Assessment
|
|
232
|
+
- Matching skills and experience (be specific, reference the candidate's actual background)
|
|
233
|
+
- Skill gaps to acknowledge or address
|
|
234
|
+
- Adjacent experience that transfers
|
|
235
|
+
- Overall fit rating: Strong / Good / Stretch
|
|
236
|
+
|
|
237
|
+
### 4. Cover Letter Talking Points
|
|
238
|
+
- 5-7 specific bullet points to mention, each referencing the candidate's real experience
|
|
239
|
+
- What angle to take (technical depth? leadership? domain expertise?)
|
|
240
|
+
- What to emphasize vs. downplay
|
|
241
|
+
|
|
242
|
+
### 5. Contact Info
|
|
243
|
+
- Email addresses from the posting
|
|
244
|
+
- Who posted (HN username from the URL if available)
|
|
245
|
+
- Best way to reach out
|
|
246
|
+
|
|
247
|
+
### 6. Interview Prep
|
|
248
|
+
- Questions they'll likely ask for this role
|
|
249
|
+
- Questions the candidate should ask
|
|
250
|
+
- Topics to research before interviewing
|
|
251
|
+
|
|
252
|
+
### 7. Compensation Context
|
|
253
|
+
- Market rate for this role/level/location
|
|
254
|
+
- How the listed salary compares
|
|
255
|
+
- Negotiation leverage points
|
|
256
|
+
|
|
257
|
+
Be thorough, specific, and opinionated. Reference the candidate's actual experience when making recommendations.`;
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const { spawn } = await import('child_process');
|
|
261
|
+
// Remove CLAUDECODE env var to allow nested claude sessions
|
|
262
|
+
const env = { ...process.env };
|
|
263
|
+
delete env.CLAUDECODE;
|
|
264
|
+
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
265
|
+
delete env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
266
|
+
|
|
267
|
+
const child = spawn(
|
|
268
|
+
claudePath,
|
|
269
|
+
[
|
|
270
|
+
'--print',
|
|
271
|
+
'--output-format',
|
|
272
|
+
'stream-json',
|
|
273
|
+
'--verbose',
|
|
274
|
+
'--allowedTools',
|
|
275
|
+
'WebSearch',
|
|
276
|
+
'WebFetch',
|
|
277
|
+
'--',
|
|
278
|
+
prompt,
|
|
279
|
+
],
|
|
280
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], env }
|
|
281
|
+
);
|
|
282
|
+
childRef.current = child;
|
|
283
|
+
|
|
284
|
+
let finalResult = '';
|
|
285
|
+
let buffer = '';
|
|
286
|
+
let statusLine = '';
|
|
287
|
+
|
|
288
|
+
function processLine(line) {
|
|
289
|
+
if (!line.trim()) return;
|
|
290
|
+
try {
|
|
291
|
+
const event = JSON.parse(line);
|
|
292
|
+
|
|
293
|
+
if (event.type === 'assistant') {
|
|
294
|
+
// Extract text from assistant message content
|
|
295
|
+
const content = event.message?.content || [];
|
|
296
|
+
for (const block of content) {
|
|
297
|
+
if (block.type === 'text' && block.text) {
|
|
298
|
+
finalResult = block.text;
|
|
299
|
+
updateText(
|
|
300
|
+
statusLine ? `${statusLine}\n\n${finalResult}` : finalResult
|
|
301
|
+
);
|
|
302
|
+
} else if (block.type === 'tool_use') {
|
|
303
|
+
// Show what Claude is doing (e.g. WebSearch, WebFetch)
|
|
304
|
+
const name = block.name || 'tool';
|
|
305
|
+
const input = block.input || {};
|
|
306
|
+
if (name === 'WebSearch' || name === 'WebFetch') {
|
|
307
|
+
statusLine = `🔍 ${name}: ${
|
|
308
|
+
input.query || input.url || ''
|
|
309
|
+
}`;
|
|
310
|
+
} else {
|
|
311
|
+
statusLine = `⚙ Using ${name}…`;
|
|
312
|
+
}
|
|
313
|
+
updateText(
|
|
314
|
+
finalResult ? `${statusLine}\n\n${finalResult}` : statusLine
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (event.type === 'result' && event.result) {
|
|
321
|
+
finalResult = event.result;
|
|
322
|
+
updateText(finalResult);
|
|
323
|
+
}
|
|
324
|
+
} catch {
|
|
325
|
+
// Not valid JSON, skip
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
child.stdout.on('data', (chunk) => {
|
|
330
|
+
buffer += chunk.toString();
|
|
331
|
+
// Process complete JSON lines
|
|
332
|
+
const lines = buffer.split('\n');
|
|
333
|
+
buffer = lines.pop() || '';
|
|
334
|
+
for (const line of lines) {
|
|
335
|
+
processLine(line);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
child.stderr.on('data', () => {});
|
|
340
|
+
|
|
341
|
+
await new Promise((resolve, reject) => {
|
|
342
|
+
child.on('close', (code) => {
|
|
343
|
+
// Only clear childRef if it's still our process
|
|
344
|
+
if (childRef.current === child) childRef.current = null;
|
|
345
|
+
// Process any remaining buffer
|
|
346
|
+
if (buffer.trim()) processLine(buffer);
|
|
347
|
+
if (code === 0) {
|
|
348
|
+
resolve();
|
|
349
|
+
} else if (code !== null) {
|
|
350
|
+
reject(new Error(`Claude exited with code ${code}`));
|
|
351
|
+
} else {
|
|
352
|
+
resolve(); // killed
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
child.on('error', (err) => {
|
|
356
|
+
if (childRef.current === child) childRef.current = null;
|
|
357
|
+
reject(err);
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// Save to server if we got output
|
|
362
|
+
if (finalResult.trim()) {
|
|
363
|
+
try {
|
|
364
|
+
await api.saveDossier(job.id, finalResult);
|
|
365
|
+
} catch {}
|
|
366
|
+
}
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (err.message?.includes('exited with code')) {
|
|
369
|
+
setError(`Claude failed — ${err.message}`);
|
|
370
|
+
} else if (err.message !== 'killed') {
|
|
371
|
+
setError(err.message);
|
|
372
|
+
}
|
|
373
|
+
} finally {
|
|
374
|
+
const entry = dossierCache.current.get(job.id);
|
|
375
|
+
if (entry) {
|
|
376
|
+
entry.done = true;
|
|
377
|
+
entry.loading = false;
|
|
378
|
+
}
|
|
379
|
+
bumpTick();
|
|
380
|
+
if (dossierJobId.current === job.id) {
|
|
381
|
+
childRef.current = null;
|
|
382
|
+
setLoading(false);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
[resume]
|
|
387
|
+
);
|
|
388
|
+
|
|
59
389
|
const batchReview = useCallback(
|
|
60
390
|
async (jobs) => {
|
|
61
391
|
if (!hasKey) {
|
|
@@ -64,6 +394,7 @@ export function useAI(resume) {
|
|
|
64
394
|
);
|
|
65
395
|
return;
|
|
66
396
|
}
|
|
397
|
+
setMode('ai');
|
|
67
398
|
setLoading(true);
|
|
68
399
|
setError(null);
|
|
69
400
|
setText('');
|
|
@@ -99,13 +430,61 @@ export function useAI(resume) {
|
|
|
99
430
|
[resume, hasKey]
|
|
100
431
|
);
|
|
101
432
|
|
|
433
|
+
const cancel = useCallback(() => {
|
|
434
|
+
if (childRef.current) {
|
|
435
|
+
try {
|
|
436
|
+
childRef.current.kill();
|
|
437
|
+
} catch {}
|
|
438
|
+
childRef.current = null;
|
|
439
|
+
}
|
|
440
|
+
}, []);
|
|
441
|
+
|
|
442
|
+
const textRef = useRef('');
|
|
443
|
+
textRef.current = text;
|
|
444
|
+
|
|
445
|
+
const exportDossier = useCallback((job) => {
|
|
446
|
+
// Use visible text state, or fall back to cache
|
|
447
|
+
const content =
|
|
448
|
+
textRef.current || dossierCache.current.get(job?.id)?.text || '';
|
|
449
|
+
if (!content) return null;
|
|
450
|
+
const company = (job?.company || 'unknown')
|
|
451
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
452
|
+
.replace(/-+/g, '-')
|
|
453
|
+
.replace(/^-|-$/g, '')
|
|
454
|
+
.toLowerCase()
|
|
455
|
+
.slice(0, 50);
|
|
456
|
+
const filename = `dossier-${company}.md`;
|
|
457
|
+
writeFileSync(filename, content, 'utf-8');
|
|
458
|
+
return filename;
|
|
459
|
+
}, []);
|
|
460
|
+
|
|
461
|
+
// Expose dossier status for job list icons
|
|
462
|
+
// Returns: 'generating' | 'done' | null
|
|
463
|
+
const getDossierStatus = useCallback((jobId) => {
|
|
464
|
+
const entry = dossierCache.current.get(jobId);
|
|
465
|
+
if (!entry) return null;
|
|
466
|
+
if (entry.loading) return 'generating';
|
|
467
|
+
if (entry.done) return 'done';
|
|
468
|
+
return null;
|
|
469
|
+
}, []);
|
|
470
|
+
|
|
102
471
|
return {
|
|
103
472
|
text,
|
|
104
473
|
loading,
|
|
105
474
|
error,
|
|
106
475
|
hasKey,
|
|
476
|
+
mode,
|
|
477
|
+
hasActiveProcess: Boolean(childRef.current),
|
|
478
|
+
getDossierStatus,
|
|
107
479
|
summarizeJob,
|
|
480
|
+
dossier,
|
|
108
481
|
batchReview,
|
|
109
|
-
|
|
482
|
+
exportDossier,
|
|
483
|
+
cancel,
|
|
484
|
+
clear: () => {
|
|
485
|
+
cancel();
|
|
486
|
+
setText('');
|
|
487
|
+
dossierJobId.current = null;
|
|
488
|
+
},
|
|
110
489
|
};
|
|
111
490
|
}
|
package/src/tui/useJobs.js
CHANGED
|
@@ -12,6 +12,7 @@ export function useJobs(api, activeFilters, tab, searchId) {
|
|
|
12
12
|
const params = useMemo(() => {
|
|
13
13
|
const p = { top: 100, days: 30 };
|
|
14
14
|
if (searchId) p.searchId = searchId;
|
|
15
|
+
if (api.mode) p.mode = api.mode;
|
|
15
16
|
for (const f of activeFilters || []) {
|
|
16
17
|
if (f.type === 'days') p.days = Number(f.value) || 30;
|
|
17
18
|
if (f.type === 'remote') p.remote = true;
|
|
@@ -19,7 +20,7 @@ export function useJobs(api, activeFilters, tab, searchId) {
|
|
|
19
20
|
if (f.type === 'search') p.search = f.value || '';
|
|
20
21
|
}
|
|
21
22
|
return p;
|
|
22
|
-
}, [activeFilters, searchId]);
|
|
23
|
+
}, [activeFilters, searchId, api.mode]);
|
|
23
24
|
|
|
24
25
|
const paramsKey = JSON.stringify(params);
|
|
25
26
|
|