@jsonresume/jobs 0.14.0 โ 0.14.2
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/LICENSE +21 -0
- package/bin/cli.js +2 -2
- package/package.json +9 -2
- package/src/api.js +7 -0
- package/src/formatters.js +66 -2
- package/src/formatters.test.js +107 -0
- package/src/tui/App.js +108 -265
- package/src/tui/JobDetail.js +25 -3
- package/src/tui/JobList.js +3 -1
- package/src/tui/SplitPane.js +58 -0
- package/src/tui/aiHelpers.js +129 -0
- package/src/tui/aiHelpers.test.js +200 -0
- package/src/tui/appKeyHandler.js +89 -0
- package/src/tui/appKeyHandler.test.js +144 -0
- package/src/tui/claudeDossier.js +97 -0
- package/src/tui/claudeDossier.test.js +40 -0
- package/src/tui/dossierCache.js +26 -0
- package/src/tui/dossierCache.test.js +40 -0
- package/src/tui/dossierPrompt.js +80 -0
- package/src/tui/dossierStream.js +48 -0
- package/src/tui/dossierStream.test.js +81 -0
- package/src/tui/gptReview.js +47 -0
- package/src/tui/jobFilters.js +69 -0
- package/src/tui/jobFilters.test.js +80 -0
- package/src/tui/useAI.js +37 -282
- package/src/tui/useJobs.js +7 -4
package/src/tui/App.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
|
-
import { render, Box,
|
|
3
|
-
import TextInput from 'ink-text-input';
|
|
2
|
+
import { render, Box, useInput, useApp } from 'ink';
|
|
4
3
|
import { h } from './h.js';
|
|
5
4
|
import { createApiClient } from '../api.js';
|
|
6
5
|
import {
|
|
@@ -23,35 +22,15 @@ import SearchManager from './SearchManager.js';
|
|
|
23
22
|
import StatusBar from './StatusBar.js';
|
|
24
23
|
import AIPanel from './AIPanel.js';
|
|
25
24
|
import HelpModal from './HelpModal.js';
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
];
|
|
36
|
-
const TAB_LABELS = {
|
|
37
|
-
all: 'All',
|
|
38
|
-
new: 'New',
|
|
39
|
-
reviewed: 'Reviewed',
|
|
40
|
-
interested: 'Interested',
|
|
41
|
-
applied: 'Applied',
|
|
42
|
-
maybe: 'Maybe',
|
|
43
|
-
passed: 'Passed',
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
function InlineSearch({ query, onChange, onSubmit }) {
|
|
47
|
-
return h(
|
|
48
|
-
Box,
|
|
49
|
-
{ paddingX: 1, gap: 1 },
|
|
50
|
-
h(Text, { color: 'yellow', bold: true }, 'Find:'),
|
|
51
|
-
h(TextInput, { value: query, onChange, onSubmit }),
|
|
52
|
-
h(Text, { dimColor: true }, ' Enter to apply, Esc to clear')
|
|
53
|
-
);
|
|
54
|
-
}
|
|
25
|
+
import { InlineSearch, SplitPane } from './SplitPane.js';
|
|
26
|
+
import {
|
|
27
|
+
TABS,
|
|
28
|
+
TAB_LABELS,
|
|
29
|
+
nextTab,
|
|
30
|
+
filterJobsByQuery,
|
|
31
|
+
computeCounts,
|
|
32
|
+
} from './jobFilters.js';
|
|
33
|
+
import { createAppKeyHandler } from './appKeyHandler.js';
|
|
55
34
|
|
|
56
35
|
function App({ baseUrl, apiKey, apiClient }) {
|
|
57
36
|
const { exit } = useApp();
|
|
@@ -151,22 +130,10 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
151
130
|
}, [ai]);
|
|
152
131
|
|
|
153
132
|
// Apply inline search filter
|
|
154
|
-
const jobs = useMemo(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const fields = [
|
|
159
|
-
j.title,
|
|
160
|
-
j.company,
|
|
161
|
-
j.description,
|
|
162
|
-
j.remote,
|
|
163
|
-
j.location?.city,
|
|
164
|
-
j.location?.countryCode,
|
|
165
|
-
...(j.skills || []).map((s) => s.name || s),
|
|
166
|
-
];
|
|
167
|
-
return fields.some((f) => f && String(f).toLowerCase().includes(q));
|
|
168
|
-
});
|
|
169
|
-
}, [rawJobs, appliedQuery]);
|
|
133
|
+
const jobs = useMemo(
|
|
134
|
+
() => filterJobsByQuery(rawJobs, appliedQuery),
|
|
135
|
+
[rawJobs, appliedQuery]
|
|
136
|
+
);
|
|
170
137
|
|
|
171
138
|
useEffect(() => {
|
|
172
139
|
api
|
|
@@ -182,82 +149,6 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
182
149
|
}
|
|
183
150
|
}, [cursor, view, jobs]);
|
|
184
151
|
|
|
185
|
-
// Inline search escape handler
|
|
186
|
-
useInput(
|
|
187
|
-
(input, key) => {
|
|
188
|
-
if (key.escape) {
|
|
189
|
-
setInlineSearch(false);
|
|
190
|
-
setSearchQuery('');
|
|
191
|
-
setAppliedQuery('');
|
|
192
|
-
}
|
|
193
|
-
},
|
|
194
|
-
{ isActive: inlineSearch }
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
// Main input handler
|
|
198
|
-
useInput(
|
|
199
|
-
(input, key) => {
|
|
200
|
-
if (view === 'filters' || view === 'searches' || view === 'help') return;
|
|
201
|
-
if (inlineSearch) return;
|
|
202
|
-
|
|
203
|
-
if (input === 'q' && view === 'list') {
|
|
204
|
-
if (ai.hasActiveProcess && !confirmExit) {
|
|
205
|
-
showToast(
|
|
206
|
-
'Claude dossier still running โ press q again to quit',
|
|
207
|
-
'warning'
|
|
208
|
-
);
|
|
209
|
-
setConfirmExit(true);
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
ai.cancel();
|
|
213
|
-
exit();
|
|
214
|
-
}
|
|
215
|
-
if (input !== 'q') setConfirmExit(false);
|
|
216
|
-
if (input === 'q' && view === 'detail') setView('list');
|
|
217
|
-
if (input === 'R' && (view === 'list' || view === 'detail')) {
|
|
218
|
-
forceRefresh();
|
|
219
|
-
showToast('Refreshingโฆ', 'info');
|
|
220
|
-
}
|
|
221
|
-
if (input === 'f' && (view === 'list' || view === 'detail'))
|
|
222
|
-
setView('filters');
|
|
223
|
-
if (input === '/' && (view === 'list' || view === 'detail'))
|
|
224
|
-
setView('searches');
|
|
225
|
-
if (input === '?' && (view === 'list' || view === 'detail'))
|
|
226
|
-
setView('help');
|
|
227
|
-
if (input === 'n' && view === 'list') {
|
|
228
|
-
setInlineSearch(true);
|
|
229
|
-
setSearchQuery('');
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Enter toggles detail panel
|
|
233
|
-
if (key.return && view === 'list' && jobs[cursor]) {
|
|
234
|
-
setSelectedJob(jobs[cursor]);
|
|
235
|
-
setView('detail');
|
|
236
|
-
}
|
|
237
|
-
if (key.escape && view === 'detail') setView('list');
|
|
238
|
-
if (input === 'c' && view === 'detail' && selectedJob) {
|
|
239
|
-
handleDossier(selectedJob);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (key.escape && view === 'ai') {
|
|
243
|
-
// Don't kill running dossier โ just hide the panel
|
|
244
|
-
setView(selectedJob ? 'detail' : 'list');
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (key.tab && (view === 'list' || view === 'detail')) {
|
|
248
|
-
const idx = TABS.indexOf(tab);
|
|
249
|
-
setTab(TABS[(idx + 1) % TABS.length]);
|
|
250
|
-
setCursor(0);
|
|
251
|
-
}
|
|
252
|
-
if (key.shift && key.tab && (view === 'list' || view === 'detail')) {
|
|
253
|
-
const idx = TABS.indexOf(tab);
|
|
254
|
-
setTab(TABS[(idx - 1 + TABS.length) % TABS.length]);
|
|
255
|
-
setCursor(0);
|
|
256
|
-
}
|
|
257
|
-
},
|
|
258
|
-
{ isActive: view !== 'filters' && view !== 'searches' && view !== 'help' }
|
|
259
|
-
);
|
|
260
|
-
|
|
261
152
|
const handleSelect = (job) => {
|
|
262
153
|
setSelectedJob(job);
|
|
263
154
|
setView('detail');
|
|
@@ -313,28 +204,50 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
313
204
|
setCursor(0);
|
|
314
205
|
};
|
|
315
206
|
|
|
207
|
+
// Inline search escape handler
|
|
208
|
+
useInput(
|
|
209
|
+
(input, key) => {
|
|
210
|
+
if (key.escape) {
|
|
211
|
+
setInlineSearch(false);
|
|
212
|
+
setSearchQuery('');
|
|
213
|
+
setAppliedQuery('');
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
{ isActive: inlineSearch }
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// Main input handler
|
|
220
|
+
useInput(
|
|
221
|
+
createAppKeyHandler({
|
|
222
|
+
view,
|
|
223
|
+
tab,
|
|
224
|
+
jobs,
|
|
225
|
+
cursor,
|
|
226
|
+
selectedJob,
|
|
227
|
+
inlineSearch,
|
|
228
|
+
confirmExit,
|
|
229
|
+
ai,
|
|
230
|
+
exit,
|
|
231
|
+
forceRefresh,
|
|
232
|
+
showToast,
|
|
233
|
+
setView,
|
|
234
|
+
setTab,
|
|
235
|
+
setCursor,
|
|
236
|
+
setSelectedJob,
|
|
237
|
+
setInlineSearch,
|
|
238
|
+
setSearchQuery,
|
|
239
|
+
setConfirmExit,
|
|
240
|
+
handleDossier,
|
|
241
|
+
nextTab,
|
|
242
|
+
}),
|
|
243
|
+
{ isActive: view !== 'filters' && view !== 'searches' && view !== 'help' }
|
|
244
|
+
);
|
|
245
|
+
|
|
316
246
|
const activeSearch = activeSearchId
|
|
317
247
|
? searchesHook.searches.find((s) => s.id === activeSearchId)
|
|
318
248
|
: null;
|
|
319
249
|
|
|
320
|
-
const counts =
|
|
321
|
-
all: allJobs.length,
|
|
322
|
-
new: allJobs.filter(
|
|
323
|
-
(j) =>
|
|
324
|
-
!j.state &&
|
|
325
|
-
!j.has_dossier &&
|
|
326
|
-
ai.getDossierStatus(j.id) !== 'done' &&
|
|
327
|
-
ai.getDossierStatus(j.id) !== 'generating'
|
|
328
|
-
).length,
|
|
329
|
-
reviewed: allJobs.filter(
|
|
330
|
-
(j) => (j.has_dossier || ai.getDossierStatus(j.id) === 'done') && !j.state
|
|
331
|
-
).length,
|
|
332
|
-
interested: allJobs.filter((j) => j.state === 'interested').length,
|
|
333
|
-
applied: allJobs.filter((j) => j.state === 'applied').length,
|
|
334
|
-
maybe: allJobs.filter((j) => j.state === 'maybe').length,
|
|
335
|
-
passed: allJobs.filter((j) => j.state === 'not_interested').length,
|
|
336
|
-
};
|
|
337
|
-
|
|
250
|
+
const counts = computeCounts(allJobs, ai.getDossierStatus);
|
|
338
251
|
const toastEl = toast ? h(Toast, { toast }) : null;
|
|
339
252
|
|
|
340
253
|
// โโ Layout โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
@@ -360,130 +273,67 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
360
273
|
toast: toastEl,
|
|
361
274
|
});
|
|
362
275
|
|
|
276
|
+
// Shared left-pane list props for split-pane views.
|
|
277
|
+
const baseListProps = {
|
|
278
|
+
jobs,
|
|
279
|
+
cursor,
|
|
280
|
+
tab,
|
|
281
|
+
onCursorChange: setCursor,
|
|
282
|
+
onSelect: handleSelect,
|
|
283
|
+
onMark: handleMark,
|
|
284
|
+
onAISummary: handleAISummary,
|
|
285
|
+
onDossier: handleDossier,
|
|
286
|
+
getDossierStatus: ai.getDossierStatus,
|
|
287
|
+
};
|
|
288
|
+
|
|
363
289
|
// Split-pane: compact list on left, detail on right
|
|
364
290
|
if (view === 'detail' && selectedJob) {
|
|
365
|
-
return h(
|
|
366
|
-
Box,
|
|
367
|
-
{ flexDirection: 'column', height: process.stdout.rows || 40 },
|
|
291
|
+
return h(SplitPane, {
|
|
368
292
|
header,
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
borderBottom: false,
|
|
384
|
-
},
|
|
385
|
-
h(JobList, {
|
|
386
|
-
jobs,
|
|
387
|
-
cursor,
|
|
388
|
-
tab,
|
|
389
|
-
onCursorChange: setCursor,
|
|
390
|
-
onSelect: handleSelect,
|
|
391
|
-
onMark: handleMark,
|
|
392
|
-
onAISummary: handleAISummary,
|
|
393
|
-
onDossier: handleDossier,
|
|
394
|
-
getDossierStatus: ai.getDossierStatus,
|
|
395
|
-
isActive: true,
|
|
396
|
-
compact: true,
|
|
397
|
-
reservedRows: 8,
|
|
398
|
-
})
|
|
399
|
-
),
|
|
400
|
-
// Right pane: job detail
|
|
401
|
-
h(
|
|
402
|
-
Box,
|
|
403
|
-
{ flexDirection: 'column', width: '60%' },
|
|
404
|
-
h(JobDetail, {
|
|
405
|
-
job: selectedJob,
|
|
406
|
-
api,
|
|
407
|
-
onBack: handleBack,
|
|
408
|
-
onMark: handleMark,
|
|
409
|
-
onAISummary: handleAISummary,
|
|
410
|
-
onDossier: handleDossier,
|
|
411
|
-
getDossierStatus: ai.getDossierStatus,
|
|
412
|
-
isActive: false,
|
|
413
|
-
isPanel: true,
|
|
414
|
-
})
|
|
415
|
-
)
|
|
416
|
-
),
|
|
417
|
-
statusBar
|
|
418
|
-
);
|
|
293
|
+
statusBar,
|
|
294
|
+
listProps: { ...baseListProps, isActive: true },
|
|
295
|
+
right: h(JobDetail, {
|
|
296
|
+
job: selectedJob,
|
|
297
|
+
api,
|
|
298
|
+
onBack: handleBack,
|
|
299
|
+
onMark: handleMark,
|
|
300
|
+
onAISummary: handleAISummary,
|
|
301
|
+
onDossier: handleDossier,
|
|
302
|
+
getDossierStatus: ai.getDossierStatus,
|
|
303
|
+
isActive: false,
|
|
304
|
+
isPanel: true,
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
419
307
|
}
|
|
420
308
|
|
|
421
309
|
// Split-pane: compact list on left, AI/dossier on right
|
|
422
310
|
if (view === 'ai' && selectedJob) {
|
|
423
|
-
return h(
|
|
424
|
-
Box,
|
|
425
|
-
{ flexDirection: 'column', height: process.stdout.rows || 40 },
|
|
311
|
+
return h(SplitPane, {
|
|
426
312
|
header,
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
onDossier: handleDossier,
|
|
452
|
-
getDossierStatus: ai.getDossierStatus,
|
|
453
|
-
isActive: false,
|
|
454
|
-
compact: true,
|
|
455
|
-
reservedRows: 8,
|
|
456
|
-
})
|
|
457
|
-
),
|
|
458
|
-
// Right pane: AI/dossier panel
|
|
459
|
-
h(
|
|
460
|
-
Box,
|
|
461
|
-
{ flexDirection: 'column', width: '60%' },
|
|
462
|
-
h(AIPanel, {
|
|
463
|
-
text: ai.text,
|
|
464
|
-
loading: ai.loading,
|
|
465
|
-
error: ai.error,
|
|
466
|
-
mode: ai.mode,
|
|
467
|
-
job: selectedJob,
|
|
468
|
-
onMark: handleMark,
|
|
469
|
-
onDismiss: () => {
|
|
470
|
-
setView(selectedJob ? 'detail' : 'list');
|
|
471
|
-
},
|
|
472
|
-
onExport: () => {
|
|
473
|
-
const f = ai.exportDossier(selectedJob);
|
|
474
|
-
if (f) showToast(`Saved ./${f}`, 'export');
|
|
475
|
-
return f;
|
|
476
|
-
},
|
|
477
|
-
onRegenerate: (job) => {
|
|
478
|
-
ai.regenerateDossier(job, api);
|
|
479
|
-
showToast('Regenerating dossierโฆ', 'info');
|
|
480
|
-
},
|
|
481
|
-
isActive: true,
|
|
482
|
-
})
|
|
483
|
-
)
|
|
484
|
-
),
|
|
485
|
-
statusBar
|
|
486
|
-
);
|
|
313
|
+
statusBar,
|
|
314
|
+
listProps: { ...baseListProps, isActive: false },
|
|
315
|
+
right: h(AIPanel, {
|
|
316
|
+
text: ai.text,
|
|
317
|
+
loading: ai.loading,
|
|
318
|
+
error: ai.error,
|
|
319
|
+
mode: ai.mode,
|
|
320
|
+
job: selectedJob,
|
|
321
|
+
onMark: handleMark,
|
|
322
|
+
onDismiss: () => {
|
|
323
|
+
setView(selectedJob ? 'detail' : 'list');
|
|
324
|
+
},
|
|
325
|
+
onExport: () => {
|
|
326
|
+
const f = ai.exportDossier(selectedJob);
|
|
327
|
+
if (f) showToast(`Saved ./${f}`, 'export');
|
|
328
|
+
return f;
|
|
329
|
+
},
|
|
330
|
+
onRegenerate: (job) => {
|
|
331
|
+
ai.regenerateDossier(job, api);
|
|
332
|
+
showToast('Regenerating dossierโฆ', 'info');
|
|
333
|
+
},
|
|
334
|
+
isActive: true,
|
|
335
|
+
}),
|
|
336
|
+
});
|
|
487
337
|
}
|
|
488
338
|
|
|
489
339
|
// Full-width list view
|
|
@@ -493,14 +343,7 @@ function App({ baseUrl, apiKey, apiClient }) {
|
|
|
493
343
|
header,
|
|
494
344
|
view === 'list'
|
|
495
345
|
? h(JobList, {
|
|
496
|
-
|
|
497
|
-
cursor,
|
|
498
|
-
tab,
|
|
499
|
-
onCursorChange: setCursor,
|
|
500
|
-
onSelect: handleSelect,
|
|
501
|
-
onMark: handleMark,
|
|
502
|
-
onAISummary: handleAISummary,
|
|
503
|
-
onDossier: handleDossier,
|
|
346
|
+
...baseListProps,
|
|
504
347
|
onAIBatch: handleAIBatch,
|
|
505
348
|
onExport: handleExport,
|
|
506
349
|
isActive: !inlineSearch,
|
package/src/tui/JobDetail.js
CHANGED
|
@@ -2,7 +2,12 @@ import { useState, useEffect } from 'react';
|
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import Spinner from 'ink-spinner';
|
|
4
4
|
import { h } from './h.js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
stateIcon,
|
|
7
|
+
formatSalary,
|
|
8
|
+
formatLocation,
|
|
9
|
+
formatAge,
|
|
10
|
+
} from '../formatters.js';
|
|
6
11
|
|
|
7
12
|
export default function JobDetail({
|
|
8
13
|
job,
|
|
@@ -79,17 +84,25 @@ export default function JobDetail({
|
|
|
79
84
|
lines.push({ text: '' });
|
|
80
85
|
|
|
81
86
|
// Meta as key-value pairs
|
|
87
|
+
const age = formatAge(d.posted_at || job.posted_at);
|
|
88
|
+
const postedStr = d.posted_at
|
|
89
|
+
? `${d.posted_at.slice(0, 10)}${age ? ` (${age})` : ''}`
|
|
90
|
+
: 'โ';
|
|
91
|
+
|
|
82
92
|
const meta = [
|
|
83
93
|
['๐ Location', loc],
|
|
84
94
|
['๐ฐ Salary', sal],
|
|
85
95
|
['๐ Type', d.type || 'โ'],
|
|
86
96
|
['๐ Experience', d.experience || 'โ'],
|
|
87
|
-
['๐
Posted',
|
|
97
|
+
['๐
Posted', postedStr],
|
|
88
98
|
[
|
|
89
99
|
'๐ฏ Match',
|
|
90
100
|
typeof d.similarity === 'number' ? `${d.similarity.toFixed(3)}` : 'โ',
|
|
91
101
|
],
|
|
92
102
|
];
|
|
103
|
+
if (d.decayed_similarity && d.decayed_similarity !== d.similarity) {
|
|
104
|
+
meta.push(['โณ Recency', d.decayed_similarity.toFixed(3)]);
|
|
105
|
+
}
|
|
93
106
|
if (d.rerank_score) meta.push(['๐ง AI Score', `${d.rerank_score}/10`]);
|
|
94
107
|
if (d.combined_score) meta.push(['๐ Combined', d.combined_score.toFixed(3)]);
|
|
95
108
|
meta.push(['๐ Status', state ? `${stateIcon(state)} ${state}` : 'unmarked']);
|
|
@@ -104,11 +117,20 @@ export default function JobDetail({
|
|
|
104
117
|
lines.push({ text: '' });
|
|
105
118
|
}
|
|
106
119
|
|
|
107
|
-
// Skills
|
|
120
|
+
// Skills with match indicators
|
|
108
121
|
if (d.skills?.length) {
|
|
109
122
|
lines.push({ text: 'Skills', bold: true, color: 'yellow' });
|
|
110
123
|
const skillNames = d.skills.map((s) => s.name || s).join(' ยท ');
|
|
111
124
|
lines.push({ text: ` ${skillNames}` });
|
|
125
|
+
// Show keywords for each skill
|
|
126
|
+
for (const sk of d.skills) {
|
|
127
|
+
if (sk.keywords?.length) {
|
|
128
|
+
lines.push({
|
|
129
|
+
text: ` ${sk.name}: ${sk.keywords.join(', ')}`,
|
|
130
|
+
dimColor: true,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
112
134
|
lines.push({ text: '' });
|
|
113
135
|
}
|
|
114
136
|
|
package/src/tui/JobList.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
truncate,
|
|
7
7
|
formatSalary,
|
|
8
8
|
formatLocation,
|
|
9
|
+
formatAge,
|
|
9
10
|
} from '../formatters.js';
|
|
10
11
|
|
|
11
12
|
// Column gap between each column
|
|
@@ -138,6 +139,7 @@ function JobRow({
|
|
|
138
139
|
const sal = formatSalary(job.salary, job.salary_usd);
|
|
139
140
|
const score =
|
|
140
141
|
typeof job.similarity === 'number' ? job.similarity.toFixed(2) : 'โ';
|
|
142
|
+
const age = formatAge(job.posted_at);
|
|
141
143
|
const icon = stateIcon(job.state);
|
|
142
144
|
const dossierIcon =
|
|
143
145
|
dossierStatus === 'generating' ? 'โ' : dossierStatus === 'done' ? '๐' : '';
|
|
@@ -227,7 +229,7 @@ function JobRow({
|
|
|
227
229
|
h(
|
|
228
230
|
Box,
|
|
229
231
|
{ width: locW, marginRight: GAP },
|
|
230
|
-
h(Text, props, truncate(loc, locW - 1))
|
|
232
|
+
h(Text, props, truncate(age ? `${loc} ยท ${age}` : loc, locW - 1))
|
|
231
233
|
),
|
|
232
234
|
h(Box, { width: 12, marginRight: GAP }, h(Text, props, truncate(sal, 11))),
|
|
233
235
|
h(
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import TextInput from 'ink-text-input';
|
|
3
|
+
import { h } from './h.js';
|
|
4
|
+
import JobList from './JobList.js';
|
|
5
|
+
|
|
6
|
+
// Inline find-as-you-type bar shown under the list view.
|
|
7
|
+
export function InlineSearch({ query, onChange, onSubmit }) {
|
|
8
|
+
return h(
|
|
9
|
+
Box,
|
|
10
|
+
{ paddingX: 1, gap: 1 },
|
|
11
|
+
h(Text, { color: 'yellow', bold: true }, 'Find:'),
|
|
12
|
+
h(TextInput, { value: query, onChange, onSubmit }),
|
|
13
|
+
h(Text, { dimColor: true }, ' Enter to apply, Esc to clear')
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Compact job list used as the left pane of the split-pane views.
|
|
18
|
+
function leftPane(listProps) {
|
|
19
|
+
return h(
|
|
20
|
+
Box,
|
|
21
|
+
{
|
|
22
|
+
flexDirection: 'column',
|
|
23
|
+
width: '40%',
|
|
24
|
+
borderStyle: 'single',
|
|
25
|
+
borderColor: 'gray',
|
|
26
|
+
borderRight: true,
|
|
27
|
+
borderLeft: false,
|
|
28
|
+
borderTop: false,
|
|
29
|
+
borderBottom: false,
|
|
30
|
+
},
|
|
31
|
+
h(JobList, { ...listProps, compact: true, reservedRows: 8 })
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Two-column layout: compact job list on the left, an arbitrary right pane.
|
|
37
|
+
* Used by both the detail and AI/dossier views.
|
|
38
|
+
*
|
|
39
|
+
* @param {object} p
|
|
40
|
+
* @param {any} p.header rendered header element
|
|
41
|
+
* @param {any} p.statusBar rendered status bar element
|
|
42
|
+
* @param {object} p.listProps props forwarded to the left-pane JobList
|
|
43
|
+
* @param {any} p.right rendered right-pane element
|
|
44
|
+
*/
|
|
45
|
+
export function SplitPane({ header, statusBar, listProps, right }) {
|
|
46
|
+
return h(
|
|
47
|
+
Box,
|
|
48
|
+
{ flexDirection: 'column', height: process.stdout.rows || 40 },
|
|
49
|
+
header,
|
|
50
|
+
h(
|
|
51
|
+
Box,
|
|
52
|
+
{ flexGrow: 1, flexDirection: 'row' },
|
|
53
|
+
leftPane(listProps),
|
|
54
|
+
h(Box, { flexDirection: 'column', width: '60%' }, right)
|
|
55
|
+
),
|
|
56
|
+
statusBar
|
|
57
|
+
);
|
|
58
|
+
}
|