@jsonresume/jobs 0.7.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.
@@ -0,0 +1,382 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { Box, Text, useInput, useStdout } from 'ink';
3
+ import { h } from './h.js';
4
+ import {
5
+ stateIcon,
6
+ truncate,
7
+ formatSalary,
8
+ formatLocation,
9
+ } from '../formatters.js';
10
+
11
+ // Column gap between each column
12
+ const GAP = 2;
13
+
14
+ function useColumns(hasRerank, compact) {
15
+ const { stdout } = useStdout();
16
+ const cols = stdout?.columns || 120;
17
+ const available = compact ? Math.floor(cols * 0.4) : cols;
18
+
19
+ if (compact) {
20
+ // Compact mode: just score, title, status
21
+ const scoreW = 5;
22
+ const statusW = 2;
23
+ 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
+ }
27
+
28
+ const scoreW = 5;
29
+ const aiW = hasRerank ? 3 : 0;
30
+ const salaryW = 12;
31
+ const statusW = 2;
32
+ const cursorW = 2;
33
+ const gaps = GAP * (hasRerank ? 6 : 5);
34
+ const fixed = cursorW + scoreW + aiW + salaryW + statusW + gaps + 2; // +2 paddingX
35
+ const flex = Math.max(30, available - fixed);
36
+ const titleW = Math.max(12, Math.floor(flex * 0.35));
37
+ const compW = Math.max(10, Math.floor(flex * 0.3));
38
+ const locW = Math.max(8, flex - titleW - compW);
39
+ return {
40
+ cols: available,
41
+ titleW,
42
+ compW,
43
+ locW,
44
+ scoreW,
45
+ salaryW,
46
+ statusW,
47
+ aiW,
48
+ };
49
+ }
50
+
51
+ function HeaderRow({ hasRerank, titleW, compW, locW, compact }) {
52
+ if (compact) {
53
+ return h(
54
+ Box,
55
+ { paddingX: 1 },
56
+ h(Box, { width: 2 }),
57
+ h(
58
+ Box,
59
+ { width: 5, marginRight: GAP },
60
+ h(Text, { bold: true, dimColor: true }, 'Score')
61
+ ),
62
+ h(Box, { flexGrow: 1 }, h(Text, { bold: true, dimColor: true }, 'Title'))
63
+ );
64
+ }
65
+
66
+ return h(
67
+ Box,
68
+ { paddingX: 1 },
69
+ h(Box, { width: 2 }),
70
+ h(
71
+ Box,
72
+ { width: 5, marginRight: GAP },
73
+ h(Text, { bold: true, dimColor: true }, 'Score')
74
+ ),
75
+ hasRerank
76
+ ? h(
77
+ Box,
78
+ { width: 3, marginRight: GAP },
79
+ h(Text, { bold: true, dimColor: true }, 'AI')
80
+ )
81
+ : null,
82
+ h(
83
+ Box,
84
+ { width: titleW, marginRight: GAP },
85
+ h(Text, { bold: true, dimColor: true }, 'Title')
86
+ ),
87
+ h(
88
+ Box,
89
+ { width: compW, marginRight: GAP },
90
+ h(Text, { bold: true, dimColor: true }, 'Company')
91
+ ),
92
+ h(
93
+ Box,
94
+ { width: locW, marginRight: GAP },
95
+ h(Text, { bold: true, dimColor: true }, 'Location')
96
+ ),
97
+ h(
98
+ Box,
99
+ { width: 12, marginRight: GAP },
100
+ h(Text, { bold: true, dimColor: true }, 'Salary')
101
+ ),
102
+ h(Box, { width: 2 }, h(Text, { bold: true, dimColor: true }, ' '))
103
+ );
104
+ }
105
+
106
+ function JobRow({
107
+ job,
108
+ selected,
109
+ hasRerank,
110
+ titleW,
111
+ compW,
112
+ locW,
113
+ marked,
114
+ compact,
115
+ }) {
116
+ const loc = formatLocation(job.location, job.remote);
117
+ const sal = formatSalary(job.salary, job.salary_usd);
118
+ const score =
119
+ typeof job.similarity === 'number' ? job.similarity.toFixed(2) : '—';
120
+ const icon = stateIcon(job.state);
121
+
122
+ const stColor =
123
+ job.state === 'interested'
124
+ ? 'green'
125
+ : job.state === 'applied'
126
+ ? 'cyan'
127
+ : job.state === 'maybe'
128
+ ? 'yellow'
129
+ : job.state === 'not_interested'
130
+ ? 'red'
131
+ : undefined;
132
+
133
+ const color = selected ? 'white' : stColor;
134
+ const bg = selected ? 'blue' : undefined;
135
+ const cursorStr = marked ? '● ' : selected ? '▸ ' : ' ';
136
+ const props = {
137
+ inverse: selected,
138
+ color,
139
+ backgroundColor: bg,
140
+ wrap: 'truncate',
141
+ };
142
+ const markerProps = {
143
+ inverse: selected,
144
+ color: marked ? 'magenta' : color,
145
+ backgroundColor: bg,
146
+ };
147
+
148
+ if (compact) {
149
+ return h(
150
+ Box,
151
+ { paddingX: 1 },
152
+ h(Box, { width: 2 }, h(Text, markerProps, cursorStr)),
153
+ h(
154
+ Box,
155
+ { width: 5, marginRight: GAP },
156
+ h(Text, { ...props, dimColor: !selected }, score)
157
+ ),
158
+ h(
159
+ Box,
160
+ { flexGrow: 1 },
161
+ h(Text, props, truncate(job.title || '—', titleW))
162
+ ),
163
+ h(Box, { width: 2 }, h(Text, props, icon))
164
+ );
165
+ }
166
+
167
+ return h(
168
+ Box,
169
+ { paddingX: 1 },
170
+ h(Box, { width: 2 }, h(Text, markerProps, cursorStr)),
171
+ h(
172
+ Box,
173
+ { width: 5, marginRight: GAP },
174
+ h(Text, { ...props, dimColor: !selected }, score)
175
+ ),
176
+ hasRerank
177
+ ? h(
178
+ Box,
179
+ { width: 3, marginRight: GAP },
180
+ h(Text, props, job.rerank_score ? String(job.rerank_score) : '—')
181
+ )
182
+ : null,
183
+ h(
184
+ Box,
185
+ { width: titleW, marginRight: GAP },
186
+ h(Text, props, truncate(job.title || '—', titleW - 1))
187
+ ),
188
+ h(
189
+ Box,
190
+ { width: compW, marginRight: GAP },
191
+ h(Text, props, truncate(job.company || '—', compW - 1))
192
+ ),
193
+ h(
194
+ Box,
195
+ { width: locW, marginRight: GAP },
196
+ h(Text, props, truncate(loc, locW - 1))
197
+ ),
198
+ h(Box, { width: 12, marginRight: GAP }, h(Text, props, truncate(sal, 11))),
199
+ h(Box, { width: 2 }, h(Text, props, icon))
200
+ );
201
+ }
202
+
203
+ function EmptyState({ tab }) {
204
+ const messages = {
205
+ all: [
206
+ '',
207
+ ' No matching jobs found.',
208
+ '',
209
+ ' Try:',
210
+ ' f Increase date range or remove filters',
211
+ ' / Try a different search profile',
212
+ ' R Refresh results',
213
+ ],
214
+ interested: [
215
+ '',
216
+ ' No jobs marked as interested yet.',
217
+ '',
218
+ ' Press i on jobs you like.',
219
+ ],
220
+ applied: [
221
+ '',
222
+ ' No applications tracked yet.',
223
+ '',
224
+ ' Press x to mark jobs as applied.',
225
+ ],
226
+ maybe: ['', ' No maybes yet.', '', ' Press m on jobs to revisit later.'],
227
+ passed: [
228
+ '',
229
+ ' No passed jobs.',
230
+ '',
231
+ " Press p on jobs that aren't a fit.",
232
+ ],
233
+ };
234
+
235
+ return h(
236
+ Box,
237
+ { flexDirection: 'column', paddingX: 2, paddingY: 1 },
238
+ ...(messages[tab] || messages.all).map((line, i) =>
239
+ h(Text, { key: i, dimColor: true }, line)
240
+ )
241
+ );
242
+ }
243
+
244
+ export default function JobList({
245
+ jobs,
246
+ cursor,
247
+ onCursorChange,
248
+ onSelect,
249
+ onMark,
250
+ onAISummary,
251
+ onAIBatch,
252
+ onExport,
253
+ isActive,
254
+ tab,
255
+ compact,
256
+ reservedRows,
257
+ }) {
258
+ const [scroll, setScroll] = useState(0);
259
+ const [selected, setSelected] = useState(new Set());
260
+ const reserved = reservedRows || (compact ? 6 : 10);
261
+ const visibleRows = Math.max((process.stdout.rows || 30) - reserved, 5);
262
+
263
+ useEffect(() => {
264
+ if (cursor >= jobs.length && jobs.length > 0) {
265
+ onCursorChange(jobs.length - 1);
266
+ }
267
+ }, [jobs.length]);
268
+
269
+ useEffect(() => {
270
+ if (cursor < scroll) setScroll(cursor);
271
+ if (cursor >= scroll + visibleRows) setScroll(cursor - visibleRows + 1);
272
+ }, [cursor, visibleRows]);
273
+
274
+ useEffect(() => {
275
+ setSelected(new Set());
276
+ }, [jobs.length, tab]);
277
+
278
+ useInput(
279
+ (input, key) => {
280
+ if (key.upArrow || input === 'k') {
281
+ onCursorChange(Math.max(0, cursor - 1));
282
+ }
283
+ if (key.downArrow || input === 'j') {
284
+ onCursorChange(Math.min(jobs.length - 1, cursor + 1));
285
+ }
286
+ if (key.pageUp || (key.ctrl && input === 'u')) {
287
+ onCursorChange(Math.max(0, cursor - visibleRows));
288
+ }
289
+ if (key.pageDown || (key.ctrl && input === 'd')) {
290
+ onCursorChange(Math.min(jobs.length - 1, cursor + visibleRows));
291
+ }
292
+ if (key.home || input === 'g') onCursorChange(0);
293
+ if (input === 'G') onCursorChange(Math.max(0, jobs.length - 1));
294
+
295
+ if (key.return && jobs[cursor]) onSelect(jobs[cursor]);
296
+ if (input === 'v' && jobs[cursor]) {
297
+ setSelected((prev) => {
298
+ const next = new Set(prev);
299
+ next.has(jobs[cursor].id)
300
+ ? next.delete(jobs[cursor].id)
301
+ : next.add(jobs[cursor].id);
302
+ return next;
303
+ });
304
+ }
305
+ if (selected.size > 0 && 'ixmp'.includes(input)) {
306
+ const states = {
307
+ i: 'interested',
308
+ x: 'applied',
309
+ m: 'maybe',
310
+ p: 'not_interested',
311
+ };
312
+ if (states[input]) {
313
+ for (const id of selected) onMark(id, states[input]);
314
+ setSelected(new Set());
315
+ return;
316
+ }
317
+ }
318
+ if (input === 'i' && jobs[cursor]) onMark(jobs[cursor].id, 'interested');
319
+ if (input === 'x' && jobs[cursor]) onMark(jobs[cursor].id, 'applied');
320
+ if (input === 'm' && jobs[cursor]) onMark(jobs[cursor].id, 'maybe');
321
+ if (input === 'p' && jobs[cursor])
322
+ onMark(jobs[cursor].id, 'not_interested');
323
+ if (input === ' ' && jobs[cursor]) onAISummary(jobs[cursor]);
324
+ if (input === 'S' && onAIBatch) onAIBatch(jobs);
325
+ if (input === 'e' && onExport) onExport();
326
+ },
327
+ { isActive }
328
+ );
329
+
330
+ if (jobs.length === 0) return h(EmptyState, { tab });
331
+
332
+ const visible = jobs.slice(scroll, scroll + visibleRows);
333
+ const hasRerank = visible.some((j) => j.rerank_score);
334
+ const { cols, titleW, compW, locW } = useColumns(hasRerank, compact);
335
+
336
+ const rows = visible.map((job, i) =>
337
+ h(JobRow, {
338
+ key: job.id,
339
+ job,
340
+ selected: scroll + i === cursor,
341
+ marked: selected.has(job.id),
342
+ hasRerank,
343
+ titleW,
344
+ compW,
345
+ locW,
346
+ compact,
347
+ })
348
+ );
349
+
350
+ const info = [];
351
+ info.push(
352
+ `${scroll + 1}–${Math.min(scroll + visibleRows, jobs.length)} of ${
353
+ jobs.length
354
+ }`
355
+ );
356
+ if (selected.size > 0) info.push(`${selected.size} selected`);
357
+
358
+ return h(
359
+ Box,
360
+ { flexDirection: 'column' },
361
+ h(HeaderRow, { hasRerank, titleW, compW, locW, compact }),
362
+ h(
363
+ Box,
364
+ { paddingX: 1 },
365
+ h(Text, { dimColor: true }, '─'.repeat(Math.max(10, cols - 2)))
366
+ ),
367
+ ...rows,
368
+ h(
369
+ Box,
370
+ { paddingX: 1, justifyContent: 'space-between' },
371
+ h(Text, { dimColor: true }, info.join(' · ')),
372
+ h(
373
+ Box,
374
+ { gap: 1 },
375
+ scroll > 0 ? h(Text, { dimColor: true }, '↑') : null,
376
+ scroll + visibleRows < jobs.length
377
+ ? h(Text, { dimColor: true }, '↓')
378
+ : null
379
+ )
380
+ )
381
+ );
382
+ }
@@ -0,0 +1,68 @@
1
+ import { Box, Text } from 'ink';
2
+ import { h } from './h.js';
3
+ import { formatSalary, formatLocation, stateIcon } from '../formatters.js';
4
+
5
+ export default function PreviewPane({ job }) {
6
+ if (!job) return null;
7
+
8
+ const cols = process.stdout.columns || 80;
9
+ const loc = formatLocation(job.location, job.remote);
10
+ const sal = formatSalary(job.salary, job.salary_usd);
11
+ const icon = stateIcon(job.state);
12
+
13
+ const skills = job.skills
14
+ ? job.skills
15
+ .slice(0, 8)
16
+ .map((s) => s.name || s)
17
+ .join(' · ')
18
+ : null;
19
+
20
+ const desc = job.description
21
+ ? job.description.replace(/\n/g, ' ').slice(0, cols * 2)
22
+ : null;
23
+
24
+ return h(
25
+ Box,
26
+ {
27
+ flexDirection: 'column',
28
+ borderStyle: 'single',
29
+ borderColor: 'gray',
30
+ borderTop: true,
31
+ borderBottom: false,
32
+ borderLeft: false,
33
+ borderRight: false,
34
+ paddingX: 1,
35
+ },
36
+ // Title line
37
+ h(
38
+ Box,
39
+ { gap: 1 },
40
+ h(Text, { bold: true, color: 'white' }, job.title || '—'),
41
+ h(Text, { dimColor: true }, 'at'),
42
+ h(Text, { bold: true, color: 'cyan' }, job.company || '—'),
43
+ job.state ? h(Text, null, ` ${icon}`) : null
44
+ ),
45
+ // Meta line
46
+ h(
47
+ Box,
48
+ { gap: 2 },
49
+ h(Text, { dimColor: true }, `📍 ${loc}`),
50
+ h(Text, { dimColor: true }, `💰 ${sal}`),
51
+ job.experience
52
+ ? h(Text, { dimColor: true }, `📊 ${job.experience}`)
53
+ : null,
54
+ job.url ? h(Text, { color: 'blue', dimColor: true }, 'o:open') : null
55
+ ),
56
+ // Skills
57
+ skills
58
+ ? h(
59
+ Box,
60
+ null,
61
+ h(Text, { color: 'yellow' }, 'Skills: '),
62
+ h(Text, { dimColor: true }, skills)
63
+ )
64
+ : null,
65
+ // Description snippet
66
+ desc ? h(Text, { dimColor: true, wrap: 'truncate-end' }, desc) : null
67
+ );
68
+ }
@@ -0,0 +1,207 @@
1
+ import { useState } from 'react';
2
+ import { Box, Text, useInput } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ import Spinner from 'ink-spinner';
5
+ import { h } from './h.js';
6
+
7
+ export default function SearchManager({
8
+ searches,
9
+ activeSearchId,
10
+ onSwitch,
11
+ onCreate,
12
+ onDelete,
13
+ onClose,
14
+ }) {
15
+ const [mode, setMode] = useState('list'); // list | create-name | create-prompt
16
+ const [cursor, setCursor] = useState(0);
17
+ const [name, setName] = useState('');
18
+ const [prompt, setPrompt] = useState('');
19
+ const [creating, setCreating] = useState(false);
20
+
21
+ // All options: default + saved searches
22
+ const options = [
23
+ { id: null, name: 'Default (my resume)', prompt: null },
24
+ ...searches,
25
+ ];
26
+
27
+ useInput(
28
+ (input, key) => {
29
+ if (mode !== 'list') return;
30
+ if (key.escape) {
31
+ onClose();
32
+ return;
33
+ }
34
+ if (key.upArrow || input === 'k') setCursor((c) => Math.max(0, c - 1));
35
+ if (key.downArrow || input === 'j')
36
+ setCursor((c) => Math.min(options.length - 1, c + 1));
37
+ if (key.return) {
38
+ const opt = options[cursor];
39
+ onSwitch(opt.id);
40
+ onClose();
41
+ return;
42
+ }
43
+ if (input === 'n') {
44
+ setName('');
45
+ setPrompt('');
46
+ setMode('create-name');
47
+ return;
48
+ }
49
+ if (input === 'd' && cursor > 0) {
50
+ const opt = options[cursor];
51
+ onDelete(opt.id);
52
+ setCursor((c) => Math.min(c, options.length - 2));
53
+ return;
54
+ }
55
+ },
56
+ { isActive: mode === 'list' }
57
+ );
58
+
59
+ if (creating) {
60
+ return h(
61
+ Box,
62
+ {
63
+ flexDirection: 'column',
64
+ borderStyle: 'double',
65
+ borderColor: 'magenta',
66
+ padding: 1,
67
+ marginX: 2,
68
+ },
69
+ h(
70
+ Box,
71
+ null,
72
+ h(Spinner, { type: 'dots' }),
73
+ h(
74
+ Text,
75
+ { color: 'magenta' },
76
+ ' Creating search profile... (AI is blending your resume with the prompt)'
77
+ )
78
+ )
79
+ );
80
+ }
81
+
82
+ if (mode === 'create-name') {
83
+ return h(
84
+ Box,
85
+ {
86
+ flexDirection: 'column',
87
+ borderStyle: 'double',
88
+ borderColor: 'green',
89
+ padding: 1,
90
+ marginX: 2,
91
+ },
92
+ h(Text, { bold: true, color: 'green' }, 'New Search Profile'),
93
+ h(Text, { dimColor: true, marginTop: 1 }, 'Give it a short name:'),
94
+ h(
95
+ Box,
96
+ { marginTop: 1 },
97
+ h(Text, null, 'Name: '),
98
+ h(TextInput, {
99
+ value: name,
100
+ placeholder: 'e.g. Rockets in Texas',
101
+ onChange: setName,
102
+ onSubmit: (val) => {
103
+ if (val.trim()) {
104
+ setName(val.trim());
105
+ setMode('create-prompt');
106
+ }
107
+ },
108
+ })
109
+ ),
110
+ h(
111
+ Text,
112
+ { dimColor: true, marginTop: 1 },
113
+ 'Enter to continue, Ctrl+C to cancel'
114
+ )
115
+ );
116
+ }
117
+
118
+ if (mode === 'create-prompt') {
119
+ return h(
120
+ Box,
121
+ {
122
+ flexDirection: 'column',
123
+ borderStyle: 'double',
124
+ borderColor: 'green',
125
+ padding: 1,
126
+ marginX: 2,
127
+ },
128
+ h(Text, { bold: true, color: 'green' }, `New Search: ${name}`),
129
+ h(
130
+ Text,
131
+ { dimColor: true, marginTop: 1 },
132
+ "Describe what you're looking for. AI will blend this with your resume:"
133
+ ),
134
+ h(
135
+ Box,
136
+ { marginTop: 1 },
137
+ h(Text, null, 'Search: '),
138
+ h(TextInput, {
139
+ value: prompt,
140
+ placeholder:
141
+ 'e.g. I want to work on rockets or space tech, preferably in Texas',
142
+ onChange: setPrompt,
143
+ onSubmit: async (val) => {
144
+ if (val.trim()) {
145
+ setCreating(true);
146
+ await onCreate(name, val.trim());
147
+ setCreating(false);
148
+ setMode('list');
149
+ }
150
+ },
151
+ })
152
+ ),
153
+ h(
154
+ Text,
155
+ { dimColor: true, marginTop: 1 },
156
+ 'Enter to create, Ctrl+C to cancel'
157
+ )
158
+ );
159
+ }
160
+
161
+ // List mode
162
+ return h(
163
+ Box,
164
+ {
165
+ flexDirection: 'column',
166
+ borderStyle: 'double',
167
+ borderColor: 'magenta',
168
+ padding: 1,
169
+ marginX: 2,
170
+ },
171
+ h(Text, { bold: true, color: 'magenta' }, '🔍 Search Profiles'),
172
+ h(
173
+ Text,
174
+ { dimColor: true },
175
+ 'Each profile uses a different embedding to rank jobs differently'
176
+ ),
177
+ h(Text, null, ''),
178
+ ...options.map((opt, i) => {
179
+ const selected = i === cursor;
180
+ const isActive = opt.id === activeSearchId;
181
+ const label = opt.id
182
+ ? `${opt.name} ${isActive ? '◀ active' : ''}`
183
+ : `${opt.name} ${isActive ? '◀ active' : ''}`;
184
+ const detail = opt.prompt ? ` "${opt.prompt}"` : '';
185
+
186
+ return h(
187
+ Box,
188
+ { key: opt.id || 'default', flexDirection: 'column' },
189
+ h(
190
+ Text,
191
+ {
192
+ inverse: selected,
193
+ color: isActive ? 'green' : selected ? 'white' : undefined,
194
+ },
195
+ `${selected ? '▸ ' : ' '}${label}`
196
+ ),
197
+ detail && !selected ? h(Text, { dimColor: true }, detail) : null
198
+ );
199
+ }),
200
+ h(Text, null, ''),
201
+ h(
202
+ Text,
203
+ { dimColor: true },
204
+ 'enter:switch n:new search d:delete esc:close'
205
+ )
206
+ );
207
+ }