@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 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 24 hours to minimize API calls; press `R` to force a fresh fetch
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
+ ![architecture](./architecture.svg)
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 24 hours) |
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 and batch review integration |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonresume/jobs",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "Search Hacker News jobs matched against your JSON Resume",
6
6
  "bin": {
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 = 24 * 60 * 60 * 1000; // 1 day
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
  }
@@ -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({ text, loading, error, onDismiss, isActive }) {
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
- (_input, key) => {
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
- borderStyle: 'double',
18
- borderColor: 'magenta',
19
- padding: 1,
20
- marginX: 2,
122
+ flexGrow: 1,
123
+ paddingX: 1,
21
124
  },
22
- h(Text, { bold: true, color: 'magenta' }, '🤖 AI Analysis'),
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
- text ? h(Text, { wrap: 'wrap' }, text) : null,
131
+ displayText ? h(Text, { wrap: 'wrap' }, displayText) : null,
34
132
  h(Text, null, ' '),
35
- h(Text, { dimColor: true }, 'Press ESC to dismiss')
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
- // Persistent filters
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') exit();
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
- ai.clear();
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(selectedJob ? 'detail' : 'list');
517
+ setView('list');
395
518
  },
396
519
  isActive: true,
397
520
  })
@@ -46,6 +46,7 @@ const SECTIONS = [
46
46
  title: 'AI Features (requires OPENAI_API_KEY)',
47
47
  keys: [
48
48
  ['Space', 'AI summary of current job'],
49
+ ['c', 'Research dossier (uses Claude Code CLI)'],
49
50
  ['S', 'AI batch review of visible jobs'],
50
51
  ],
51
52
  },
@@ -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;
@@ -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(10, available - scoreW - statusW - gaps - 2);
25
- return { cols: available, titleW, compW: 0, locW: 0, scoreW, statusW };
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 ? 6 : 5);
34
- const fixed = cursorW + scoreW + aiW + salaryW + statusW + gaps + 2; // +2 paddingX
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
 
@@ -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: [['esc', 'dismiss']],
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
- clear: () => setText(''),
482
+ exportDossier,
483
+ cancel,
484
+ clear: () => {
485
+ cancel();
486
+ setText('');
487
+ dossierJobId.current = null;
488
+ },
110
489
  };
111
490
  }
@@ -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