@jsonresume/jobs 0.9.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/bin/cli.js +6 -4
- package/package.json +1 -1
- package/src/api.js +9 -1
- package/src/cache.js +2 -1
- package/src/formatters.js +0 -20
- package/src/localApi.js +14 -3
- package/src/tui/AIPanel.js +113 -15
- package/src/tui/App.js +149 -14
- package/src/tui/FilterManager.js +3 -7
- package/src/tui/HelpModal.js +17 -11
- package/src/tui/JobDetail.js +2 -0
- package/src/tui/JobList.js +52 -5
- package/src/tui/SearchManager.js +3 -3
- package/src/tui/StatusBar.js +27 -8
- package/src/tui/useAI.js +387 -4
- package/src/tui/useJobs.js +2 -1
- package/src/tui/PreviewPane.js +0 -68
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/bin/cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Just run: npx @jsonresume/jobs
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const VERSION = '0.
|
|
9
|
+
const VERSION = '0.10.0';
|
|
10
10
|
|
|
11
11
|
const BASE_URL =
|
|
12
12
|
getArg('--base-url') ||
|
|
@@ -118,10 +118,12 @@ function stateIcon(state) {
|
|
|
118
118
|
|
|
119
119
|
async function cmdSearch() {
|
|
120
120
|
const params = new URLSearchParams();
|
|
121
|
-
|
|
122
|
-
params.set('
|
|
121
|
+
const topArg = parseInt(getArg('--top')) || 20;
|
|
122
|
+
params.set('top', String(Math.min(Math.max(1, topArg), 100)));
|
|
123
|
+
params.set('days', String(parseInt(getArg('--days')) || 30));
|
|
123
124
|
if (hasFlag('--remote')) params.set('remote', 'true');
|
|
124
|
-
|
|
125
|
+
const minSalary = parseInt(getArg('--min-salary'));
|
|
126
|
+
if (minSalary > 0) params.set('min_salary', String(minSalary));
|
|
125
127
|
if (getArg('--search')) params.set('search', getArg('--search'));
|
|
126
128
|
|
|
127
129
|
const { jobs } = await api(`/jobs?${params}`);
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -40,7 +40,7 @@ export function createApiClient({ baseUrl, apiKey }) {
|
|
|
40
40
|
markJob: (id, state, feedback) =>
|
|
41
41
|
request(`/jobs/${id}`, {
|
|
42
42
|
method: 'PUT',
|
|
43
|
-
body: JSON.stringify({ state, feedback: feedback ||
|
|
43
|
+
body: JSON.stringify({ state, feedback: feedback || undefined }),
|
|
44
44
|
}),
|
|
45
45
|
fetchMe: () => request('/me'),
|
|
46
46
|
|
|
@@ -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/formatters.js
CHANGED
|
@@ -39,26 +39,6 @@ export function truncate(str, len) {
|
|
|
39
39
|
return str.length > len ? str.slice(0, len - 1) + '…' : str;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
* Render a score as a sparkline bar: ████░░░░ 0.72
|
|
44
|
-
* Uses Unicode block characters for sub-character precision.
|
|
45
|
-
*/
|
|
46
|
-
const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
|
|
47
|
-
|
|
48
|
-
export function scoreBar(score, width = 6) {
|
|
49
|
-
if (typeof score !== 'number' || isNaN(score)) return '░'.repeat(width);
|
|
50
|
-
const clamped = Math.max(0, Math.min(1, score));
|
|
51
|
-
const filled = clamped * width;
|
|
52
|
-
const full = Math.floor(filled);
|
|
53
|
-
const frac = Math.round((filled - full) * 8);
|
|
54
|
-
const empty = width - full - (frac > 0 ? 1 : 0);
|
|
55
|
-
return (
|
|
56
|
-
'█'.repeat(full) +
|
|
57
|
-
(frac > 0 ? BLOCKS[frac] : '') +
|
|
58
|
-
'░'.repeat(Math.max(0, empty))
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
42
|
export function stateLabel(state) {
|
|
63
43
|
const labels = {
|
|
64
44
|
interested: 'Interested',
|
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,
|
|
@@ -25,7 +26,13 @@ export function createLocalApiClient({ baseUrl, resume }) {
|
|
|
25
26
|
headers: { 'Content-Type': 'application/json' },
|
|
26
27
|
body: JSON.stringify(body),
|
|
27
28
|
});
|
|
28
|
-
const
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
let data;
|
|
31
|
+
try {
|
|
32
|
+
data = JSON.parse(text);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(`Non-JSON response: ${res.status}`);
|
|
35
|
+
}
|
|
29
36
|
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
30
37
|
|
|
31
38
|
// Overlay local marks onto results
|
|
@@ -44,9 +51,9 @@ export function createLocalApiClient({ baseUrl, resume }) {
|
|
|
44
51
|
return { id };
|
|
45
52
|
},
|
|
46
53
|
|
|
47
|
-
markJob: async (id, state) => {
|
|
54
|
+
markJob: async (id, state, feedback) => {
|
|
48
55
|
setMark(id, state);
|
|
49
|
-
return { id, state };
|
|
56
|
+
return { id, state, feedback };
|
|
50
57
|
},
|
|
51
58
|
|
|
52
59
|
fetchMe: async () => ({ resume, username: 'local' }),
|
|
@@ -62,5 +69,9 @@ export function createLocalApiClient({ baseUrl, resume }) {
|
|
|
62
69
|
deleteSearch: async () => {
|
|
63
70
|
throw new Error('Search profiles require a registry account');
|
|
64
71
|
},
|
|
72
|
+
|
|
73
|
+
// Dossiers — local mode stores in memory only
|
|
74
|
+
fetchDossier: async () => ({ content: null }),
|
|
75
|
+
saveDossier: async () => ({ saved: true }),
|
|
65
76
|
};
|
|
66
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
|
@@ -36,9 +36,10 @@ const TAB_LABELS = {
|
|
|
36
36
|
function InlineSearch({ query, onChange, onSubmit }) {
|
|
37
37
|
return h(
|
|
38
38
|
Box,
|
|
39
|
-
{ paddingX: 1 },
|
|
40
|
-
h(Text, { color: 'yellow', bold: true }, 'Find:
|
|
41
|
-
h(TextInput, { value: query, onChange, onSubmit })
|
|
39
|
+
{ paddingX: 1, gap: 1 },
|
|
40
|
+
h(Text, { color: 'yellow', bold: true }, 'Find:'),
|
|
41
|
+
h(TextInput, { value: query, onChange, onSubmit }),
|
|
42
|
+
h(Text, { dimColor: true }, ' Enter to apply, Esc to clear')
|
|
42
43
|
);
|
|
43
44
|
}
|
|
44
45
|
|
|
@@ -64,7 +65,9 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
64
65
|
// Active search profile
|
|
65
66
|
const [activeSearchId, setActiveSearchId] = useState(null);
|
|
66
67
|
|
|
67
|
-
|
|
68
|
+
const searchesHook = useSearches(api);
|
|
69
|
+
|
|
70
|
+
// Persistent filters (local + server sync for search profiles)
|
|
68
71
|
const [filterStore, setFilterStore] = useState(() => loadFilters());
|
|
69
72
|
const activeFilters = useMemo(
|
|
70
73
|
() => getFiltersForSearch(filterStore, activeSearchId),
|
|
@@ -77,8 +80,12 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
77
80
|
saveFilters(next);
|
|
78
81
|
return next;
|
|
79
82
|
});
|
|
83
|
+
// Sync to server for search profiles
|
|
84
|
+
if (activeSearchId) {
|
|
85
|
+
searchesHook.updateFilters(activeSearchId, newActive);
|
|
86
|
+
}
|
|
80
87
|
},
|
|
81
|
-
[activeSearchId]
|
|
88
|
+
[activeSearchId, searchesHook]
|
|
82
89
|
);
|
|
83
90
|
const filterState = useMemo(
|
|
84
91
|
() => ({ active: activeFilters }),
|
|
@@ -89,6 +96,21 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
89
96
|
[updateFilters]
|
|
90
97
|
);
|
|
91
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
|
+
|
|
92
114
|
const {
|
|
93
115
|
jobs: rawJobs,
|
|
94
116
|
allJobs,
|
|
@@ -99,14 +121,36 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
99
121
|
forceRefresh,
|
|
100
122
|
} = useJobs(api, activeFilters, tab, activeSearchId);
|
|
101
123
|
const ai = useAI(resume);
|
|
102
|
-
const searchesHook = useSearches(api);
|
|
103
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]);
|
|
104
137
|
|
|
105
138
|
// Apply inline search filter
|
|
106
139
|
const jobs = useMemo(() => {
|
|
107
140
|
if (!appliedQuery) return rawJobs;
|
|
108
141
|
const q = appliedQuery.toLowerCase();
|
|
109
|
-
return rawJobs.filter((j) =>
|
|
142
|
+
return rawJobs.filter((j) => {
|
|
143
|
+
const fields = [
|
|
144
|
+
j.title,
|
|
145
|
+
j.company,
|
|
146
|
+
j.description,
|
|
147
|
+
j.remote,
|
|
148
|
+
j.location?.city,
|
|
149
|
+
j.location?.countryCode,
|
|
150
|
+
...(j.skills || []).map((s) => s.name || s),
|
|
151
|
+
];
|
|
152
|
+
return fields.some((f) => f && String(f).toLowerCase().includes(q));
|
|
153
|
+
});
|
|
110
154
|
}, [rawJobs, appliedQuery]);
|
|
111
155
|
|
|
112
156
|
useEffect(() => {
|
|
@@ -116,12 +160,12 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
116
160
|
.catch(() => {});
|
|
117
161
|
}, [api]);
|
|
118
162
|
|
|
119
|
-
// Update selectedJob when cursor moves in detail view
|
|
163
|
+
// Update selectedJob when cursor moves or jobs list changes in detail view
|
|
120
164
|
useEffect(() => {
|
|
121
165
|
if (view === 'detail' && jobs[cursor]) {
|
|
122
166
|
setSelectedJob(jobs[cursor]);
|
|
123
167
|
}
|
|
124
|
-
}, [cursor, view]);
|
|
168
|
+
}, [cursor, view, jobs]);
|
|
125
169
|
|
|
126
170
|
// Inline search escape handler
|
|
127
171
|
useInput(
|
|
@@ -141,7 +185,19 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
141
185
|
if (view === 'filters' || view === 'searches' || view === 'help') return;
|
|
142
186
|
if (inlineSearch) return;
|
|
143
187
|
|
|
144
|
-
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);
|
|
145
201
|
if (input === 'q' && view === 'detail') setView('list');
|
|
146
202
|
if (input === 'R' && (view === 'list' || view === 'detail')) {
|
|
147
203
|
forceRefresh();
|
|
@@ -164,9 +220,12 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
164
220
|
setView('detail');
|
|
165
221
|
}
|
|
166
222
|
if (key.escape && view === 'detail') setView('list');
|
|
223
|
+
if (input === 'c' && view === 'detail' && selectedJob) {
|
|
224
|
+
handleDossier(selectedJob);
|
|
225
|
+
}
|
|
167
226
|
|
|
168
227
|
if (key.escape && view === 'ai') {
|
|
169
|
-
|
|
228
|
+
// Don't kill running dossier — just hide the panel
|
|
170
229
|
setView(selectedJob ? 'detail' : 'list');
|
|
171
230
|
}
|
|
172
231
|
|
|
@@ -207,6 +266,11 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
207
266
|
setView('ai');
|
|
208
267
|
ai.summarizeJob(job);
|
|
209
268
|
};
|
|
269
|
+
const handleDossier = (job) => {
|
|
270
|
+
setSelectedJob(job);
|
|
271
|
+
setView('ai');
|
|
272
|
+
ai.dossier(job, api);
|
|
273
|
+
};
|
|
210
274
|
const handleAIBatch = (visibleJobs) => {
|
|
211
275
|
setView('ai');
|
|
212
276
|
ai.batchReview(visibleJobs);
|
|
@@ -215,7 +279,7 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
215
279
|
const handleExport = () => {
|
|
216
280
|
try {
|
|
217
281
|
const filename = exportShortlist(allJobs);
|
|
218
|
-
showToast(`
|
|
282
|
+
showToast(`Saved ./${filename}`, 'export');
|
|
219
283
|
} catch (err) {
|
|
220
284
|
showToast(`Export failed: ${err.message}`, 'error');
|
|
221
285
|
}
|
|
@@ -300,6 +364,9 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
300
364
|
onCursorChange: setCursor,
|
|
301
365
|
onSelect: handleSelect,
|
|
302
366
|
onMark: handleMark,
|
|
367
|
+
onAISummary: handleAISummary,
|
|
368
|
+
onDossier: handleDossier,
|
|
369
|
+
getDossierStatus: ai.getDossierStatus,
|
|
303
370
|
isActive: true,
|
|
304
371
|
compact: true,
|
|
305
372
|
reservedRows: 8,
|
|
@@ -315,6 +382,8 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
315
382
|
onBack: handleBack,
|
|
316
383
|
onMark: handleMark,
|
|
317
384
|
onAISummary: handleAISummary,
|
|
385
|
+
onDossier: handleDossier,
|
|
386
|
+
getDossierStatus: ai.getDossierStatus,
|
|
318
387
|
isActive: false,
|
|
319
388
|
isPanel: true,
|
|
320
389
|
})
|
|
@@ -324,6 +393,70 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
324
393
|
);
|
|
325
394
|
}
|
|
326
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
|
+
|
|
327
460
|
// Full-width list view
|
|
328
461
|
return h(
|
|
329
462
|
Box,
|
|
@@ -338,6 +471,7 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
338
471
|
onSelect: handleSelect,
|
|
339
472
|
onMark: handleMark,
|
|
340
473
|
onAISummary: handleAISummary,
|
|
474
|
+
onDossier: handleDossier,
|
|
341
475
|
onAIBatch: handleAIBatch,
|
|
342
476
|
onExport: handleExport,
|
|
343
477
|
isActive: !inlineSearch,
|
|
@@ -372,14 +506,15 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
372
506
|
onClose: () => setView('list'),
|
|
373
507
|
})
|
|
374
508
|
: null,
|
|
375
|
-
view === 'ai'
|
|
509
|
+
view === 'ai' && !selectedJob
|
|
376
510
|
? h(AIPanel, {
|
|
377
511
|
text: ai.text,
|
|
378
512
|
loading: ai.loading,
|
|
379
513
|
error: ai.error,
|
|
514
|
+
mode: ai.mode,
|
|
380
515
|
onDismiss: () => {
|
|
381
516
|
ai.clear();
|
|
382
|
-
setView(
|
|
517
|
+
setView('list');
|
|
383
518
|
},
|
|
384
519
|
isActive: true,
|
|
385
520
|
})
|
package/src/tui/FilterManager.js
CHANGED
|
@@ -124,7 +124,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
|
|
|
124
124
|
...next[editIdx],
|
|
125
125
|
value:
|
|
126
126
|
filter.type === 'minSalary' || filter.type === 'days'
|
|
127
|
-
? parseInt(val) ||
|
|
127
|
+
? parseInt(val) || 0
|
|
128
128
|
: val,
|
|
129
129
|
};
|
|
130
130
|
onUpdate({ ...filterState, active: next });
|
|
@@ -132,11 +132,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
|
|
|
132
132
|
},
|
|
133
133
|
})
|
|
134
134
|
),
|
|
135
|
-
h(
|
|
136
|
-
Text,
|
|
137
|
-
{ dimColor: true, marginTop: 1 },
|
|
138
|
-
'Enter to save, Ctrl+C to cancel'
|
|
139
|
-
)
|
|
135
|
+
h(Text, { dimColor: true, marginTop: 1 }, 'Enter to save, Esc to cancel')
|
|
140
136
|
);
|
|
141
137
|
}
|
|
142
138
|
|
|
@@ -165,7 +161,7 @@ export default function FilterManager({ filterState, onUpdate, onClose }) {
|
|
|
165
161
|
if (val.trim()) {
|
|
166
162
|
const value =
|
|
167
163
|
ft.type === 'minSalary' || ft.type === 'days'
|
|
168
|
-
? parseInt(val) ||
|
|
164
|
+
? parseInt(val) || 0
|
|
169
165
|
: val;
|
|
170
166
|
onUpdate({
|
|
171
167
|
...filterState,
|