@jsonresume/jobs 0.14.1 → 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/formatters.js +54 -2
- package/src/formatters.test.js +107 -0
- package/src/tui/App.js +108 -265
- 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 +35 -302
- package/src/tui/useJobs.js +7 -4
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the AI/dossier hook.
|
|
3
|
+
* Prompt builders, stream-json parsing, enrichment extraction and filename
|
|
4
|
+
* derivation are kept here so they can be unit-tested in isolation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Compact resume text for the full dossier prompt.
|
|
8
|
+
export function buildResumeText(resume) {
|
|
9
|
+
return [
|
|
10
|
+
resume?.basics?.name,
|
|
11
|
+
resume?.basics?.label,
|
|
12
|
+
resume?.basics?.summary,
|
|
13
|
+
...(resume?.skills || []).map(
|
|
14
|
+
(s) => `${s.name}: ${(s.keywords || []).join(', ')}`
|
|
15
|
+
),
|
|
16
|
+
...(resume?.work || [])
|
|
17
|
+
.slice(0, 5)
|
|
18
|
+
.map(
|
|
19
|
+
(w) =>
|
|
20
|
+
`${w.position} at ${w.name}${
|
|
21
|
+
w.startDate ? ` (${w.startDate})` : ''
|
|
22
|
+
}: ${w.summary || ''}`
|
|
23
|
+
),
|
|
24
|
+
...(resume?.projects || [])
|
|
25
|
+
.slice(0, 3)
|
|
26
|
+
.map((p) => `Project: ${p.name} — ${p.description || ''}`),
|
|
27
|
+
]
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Full job text for the dossier prompt (includes the raw original posting).
|
|
33
|
+
export function buildJobText(job, rawContent) {
|
|
34
|
+
return [
|
|
35
|
+
`Title: ${job.title}`,
|
|
36
|
+
`Company: ${job.company}`,
|
|
37
|
+
`Salary: ${job.salary || 'Not listed'}`,
|
|
38
|
+
`Remote: ${job.remote || 'Not specified'}`,
|
|
39
|
+
job.experience ? `Experience: ${job.experience}` : '',
|
|
40
|
+
job.type ? `Type: ${job.type}` : '',
|
|
41
|
+
job.url ? `HN Post: ${job.url}` : '',
|
|
42
|
+
`Description: ${job.description || ''}`,
|
|
43
|
+
`Skills: ${(job.skills || []).map((s) => s.name || s).join(', ')}`,
|
|
44
|
+
...(job.qualifications || []).map((q) => `Qualification: ${q}`),
|
|
45
|
+
...(job.responsibilities || []).map((r) => `Responsibility: ${r}`),
|
|
46
|
+
rawContent ? `\nFull original posting:\n${rawContent}` : '',
|
|
47
|
+
]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Compact resume text used by the quick GPT summary/batch prompts.
|
|
53
|
+
export function buildSummaryResumeText(resume) {
|
|
54
|
+
return [
|
|
55
|
+
resume?.basics?.label,
|
|
56
|
+
resume?.basics?.summary,
|
|
57
|
+
...(resume?.skills || []).map(
|
|
58
|
+
(s) => `${s.name}: ${(s.keywords || []).join(', ')}`
|
|
59
|
+
),
|
|
60
|
+
]
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Compact job text used by the quick GPT summary prompt.
|
|
66
|
+
export function buildSummaryJobText(job) {
|
|
67
|
+
return [
|
|
68
|
+
`Title: ${job.title}`,
|
|
69
|
+
`Company: ${job.company}`,
|
|
70
|
+
`Salary: ${job.salary || 'Not listed'}`,
|
|
71
|
+
`Remote: ${job.remote || 'Not specified'}`,
|
|
72
|
+
`Description: ${job.description || ''}`,
|
|
73
|
+
`Skills: ${(job.skills || []).map((s) => s.name).join(', ')}`,
|
|
74
|
+
].join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Ranked list text used by the batch-review prompt (first 15 jobs).
|
|
78
|
+
export function buildBatchJobsList(jobs) {
|
|
79
|
+
return jobs
|
|
80
|
+
.slice(0, 15)
|
|
81
|
+
.map(
|
|
82
|
+
(j, i) =>
|
|
83
|
+
`${i + 1}. [${j.similarity}] ${j.title} at ${j.company} | ${
|
|
84
|
+
j.salary || 'no salary'
|
|
85
|
+
} | ${j.remote || 'no remote info'}`
|
|
86
|
+
)
|
|
87
|
+
.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The full research-dossier prompt lives in its own module (large text blob).
|
|
91
|
+
export { buildDossierPrompt } from './dossierPrompt.js';
|
|
92
|
+
|
|
93
|
+
// Derive a human-readable status line from a Claude tool_use block.
|
|
94
|
+
export function statusLineForTool(block) {
|
|
95
|
+
const name = block.name || 'tool';
|
|
96
|
+
const input = block.input || {};
|
|
97
|
+
if (name === 'WebSearch' || name === 'WebFetch') {
|
|
98
|
+
return `🔍 ${name}: ${input.query || input.url || ''}`;
|
|
99
|
+
}
|
|
100
|
+
return `⚙ Using ${name}…`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Compose visible dossier text from the running status line and result text.
|
|
104
|
+
export function composeDossierText(statusLine, resultText) {
|
|
105
|
+
if (statusLine && resultText) return `${statusLine}\n\n${resultText}`;
|
|
106
|
+
return resultText || statusLine || '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Pull the enrichment JSON out of a finished dossier, or null if absent/invalid.
|
|
110
|
+
export function extractEnrichment(finalResult) {
|
|
111
|
+
const match = finalResult.match(/```enrichment\s*\n([\s\S]*?)\n```/);
|
|
112
|
+
if (!match) return null;
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(match[1]);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Build the on-disk filename for an exported dossier.
|
|
121
|
+
export function dossierFilename(job) {
|
|
122
|
+
const company = (job?.company || 'unknown')
|
|
123
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
124
|
+
.replace(/-+/g, '-')
|
|
125
|
+
.replace(/^-|-$/g, '')
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.slice(0, 50);
|
|
128
|
+
return `dossier-${company}.md`;
|
|
129
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
buildResumeText,
|
|
4
|
+
buildJobText,
|
|
5
|
+
buildSummaryResumeText,
|
|
6
|
+
buildSummaryJobText,
|
|
7
|
+
buildBatchJobsList,
|
|
8
|
+
buildDossierPrompt,
|
|
9
|
+
statusLineForTool,
|
|
10
|
+
composeDossierText,
|
|
11
|
+
extractEnrichment,
|
|
12
|
+
dossierFilename,
|
|
13
|
+
} from './aiHelpers.js';
|
|
14
|
+
|
|
15
|
+
const resume = {
|
|
16
|
+
basics: { name: 'Ada Lovelace', label: 'Engineer', summary: 'Builds things' },
|
|
17
|
+
skills: [{ name: 'JS', keywords: ['react', 'node'] }, { name: 'Go' }],
|
|
18
|
+
work: [
|
|
19
|
+
{ position: 'Eng', name: 'Acme', startDate: '2020', summary: 'Did work' },
|
|
20
|
+
],
|
|
21
|
+
projects: [{ name: 'Proj', description: 'A project' }],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const job = {
|
|
25
|
+
title: 'Senior Dev',
|
|
26
|
+
company: 'BigCo',
|
|
27
|
+
salary: '$200k',
|
|
28
|
+
remote: 'Full',
|
|
29
|
+
experience: 'Senior',
|
|
30
|
+
type: 'Full-time',
|
|
31
|
+
url: 'https://news.ycombinator.com/item?id=1',
|
|
32
|
+
description: 'Build cool stuff',
|
|
33
|
+
skills: [{ name: 'JS' }, 'Python'],
|
|
34
|
+
qualifications: ['5y exp'],
|
|
35
|
+
responsibilities: ['Ship code'],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('buildResumeText', () => {
|
|
39
|
+
it('includes name, label, summary, skills, work and projects', () => {
|
|
40
|
+
const out = buildResumeText(resume);
|
|
41
|
+
expect(out).toContain('Ada Lovelace');
|
|
42
|
+
expect(out).toContain('Engineer');
|
|
43
|
+
expect(out).toContain('JS: react, node');
|
|
44
|
+
expect(out).toContain('Eng at Acme (2020): Did work');
|
|
45
|
+
expect(out).toContain('Project: Proj — A project');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('skill without keywords renders empty keyword list', () => {
|
|
49
|
+
expect(buildResumeText(resume)).toContain('Go: ');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('handles undefined resume without throwing', () => {
|
|
53
|
+
expect(buildResumeText(undefined)).toBe('');
|
|
54
|
+
expect(buildResumeText(null)).toBe('');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('caps work at 5 and projects at 3', () => {
|
|
58
|
+
const big = {
|
|
59
|
+
work: Array.from({ length: 8 }, (_, i) => ({
|
|
60
|
+
position: `P${i}`,
|
|
61
|
+
name: `C${i}`,
|
|
62
|
+
})),
|
|
63
|
+
projects: Array.from({ length: 6 }, (_, i) => ({ name: `Pj${i}` })),
|
|
64
|
+
};
|
|
65
|
+
const out = buildResumeText(big);
|
|
66
|
+
expect(out).toContain('P4 at C4');
|
|
67
|
+
expect(out).not.toContain('P5 at C5');
|
|
68
|
+
expect(out).toContain('Project: Pj2');
|
|
69
|
+
expect(out).not.toContain('Project: Pj3');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('buildJobText', () => {
|
|
74
|
+
it('includes all listed fields and skills with name-or-string fallback', () => {
|
|
75
|
+
const out = buildJobText(job, '');
|
|
76
|
+
expect(out).toContain('Title: Senior Dev');
|
|
77
|
+
expect(out).toContain('Company: BigCo');
|
|
78
|
+
expect(out).toContain('Salary: $200k');
|
|
79
|
+
expect(out).toContain('Skills: JS, Python');
|
|
80
|
+
expect(out).toContain('Qualification: 5y exp');
|
|
81
|
+
expect(out).toContain('Responsibility: Ship code');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('appends raw content when present', () => {
|
|
85
|
+
const out = buildJobText(job, 'ORIGINAL POSTING BODY');
|
|
86
|
+
expect(out).toContain('Full original posting:\nORIGINAL POSTING BODY');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('omits raw content section when empty', () => {
|
|
90
|
+
expect(buildJobText(job, '')).not.toContain('Full original posting');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('uses fallbacks for missing salary/remote', () => {
|
|
94
|
+
const out = buildJobText({ title: 't', company: 'c' }, '');
|
|
95
|
+
expect(out).toContain('Salary: Not listed');
|
|
96
|
+
expect(out).toContain('Remote: Not specified');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('buildSummaryResumeText / buildSummaryJobText', () => {
|
|
101
|
+
it('summary resume omits name but keeps label/summary/skills', () => {
|
|
102
|
+
const out = buildSummaryResumeText(resume);
|
|
103
|
+
expect(out).not.toContain('Ada Lovelace');
|
|
104
|
+
expect(out).toContain('Engineer');
|
|
105
|
+
expect(out).toContain('JS: react, node');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('summary job uses skill.name only (no string fallback)', () => {
|
|
109
|
+
const out = buildSummaryJobText(job);
|
|
110
|
+
// 'Python' is a bare string → s.name is undefined
|
|
111
|
+
expect(out).toContain('Skills: JS, ');
|
|
112
|
+
expect(out).toContain('Title: Senior Dev');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('buildBatchJobsList', () => {
|
|
117
|
+
it('numbers jobs and caps at 15', () => {
|
|
118
|
+
const jobs = Array.from({ length: 20 }, (_, i) => ({
|
|
119
|
+
similarity: '0.9',
|
|
120
|
+
title: `T${i}`,
|
|
121
|
+
company: `C${i}`,
|
|
122
|
+
}));
|
|
123
|
+
const out = buildBatchJobsList(jobs);
|
|
124
|
+
expect(out).toContain('1. [0.9] T0 at C0 | no salary | no remote info');
|
|
125
|
+
expect(out).toContain('15. [0.9] T14 at C14');
|
|
126
|
+
expect(out).not.toContain('16. [0.9] T15');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('buildDossierPrompt', () => {
|
|
131
|
+
it('embeds resume and job text and the enrichment block', () => {
|
|
132
|
+
const out = buildDossierPrompt('RESUME_HERE', 'JOB_HERE');
|
|
133
|
+
expect(out).toContain('## Candidate Resume\nRESUME_HERE');
|
|
134
|
+
expect(out).toContain('## Job Posting\nJOB_HERE');
|
|
135
|
+
expect(out).toContain('```enrichment');
|
|
136
|
+
expect(out).toContain('AI-Forward / AI-Friendly');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('statusLineForTool', () => {
|
|
141
|
+
it('formats WebSearch with query', () => {
|
|
142
|
+
expect(
|
|
143
|
+
statusLineForTool({ name: 'WebSearch', input: { query: 'foo' } })
|
|
144
|
+
).toBe('🔍 WebSearch: foo');
|
|
145
|
+
});
|
|
146
|
+
it('formats WebFetch with url', () => {
|
|
147
|
+
expect(
|
|
148
|
+
statusLineForTool({ name: 'WebFetch', input: { url: 'http://x' } })
|
|
149
|
+
).toBe('🔍 WebFetch: http://x');
|
|
150
|
+
});
|
|
151
|
+
it('formats other tools generically', () => {
|
|
152
|
+
expect(statusLineForTool({ name: 'Bash' })).toBe('⚙ Using Bash…');
|
|
153
|
+
expect(statusLineForTool({})).toBe('⚙ Using tool…');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('composeDossierText', () => {
|
|
158
|
+
it('joins status and result when both present', () => {
|
|
159
|
+
expect(composeDossierText('STATUS', 'RESULT')).toBe('STATUS\n\nRESULT');
|
|
160
|
+
});
|
|
161
|
+
it('returns result alone when no status', () => {
|
|
162
|
+
expect(composeDossierText('', 'RESULT')).toBe('RESULT');
|
|
163
|
+
});
|
|
164
|
+
it('returns status alone when no result', () => {
|
|
165
|
+
expect(composeDossierText('STATUS', '')).toBe('STATUS');
|
|
166
|
+
});
|
|
167
|
+
it('returns empty string when neither present', () => {
|
|
168
|
+
expect(composeDossierText('', '')).toBe('');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('extractEnrichment', () => {
|
|
173
|
+
it('parses a valid enrichment block', () => {
|
|
174
|
+
const text = 'prose\n```enrichment\n{"salary":"$1"}\n```\nmore';
|
|
175
|
+
expect(extractEnrichment(text)).toEqual({ salary: '$1' });
|
|
176
|
+
});
|
|
177
|
+
it('returns null when no block present', () => {
|
|
178
|
+
expect(extractEnrichment('no block here')).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
it('returns null on invalid JSON', () => {
|
|
181
|
+
expect(extractEnrichment('```enrichment\nnot json\n```')).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('dossierFilename', () => {
|
|
186
|
+
it('slugifies the company name', () => {
|
|
187
|
+
expect(dossierFilename({ company: 'Big Co, Inc.' })).toBe(
|
|
188
|
+
'dossier-big-co-inc.md'
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
it('falls back to unknown', () => {
|
|
192
|
+
expect(dossierFilename(null)).toBe('dossier-unknown.md');
|
|
193
|
+
expect(dossierFilename({})).toBe('dossier-unknown.md');
|
|
194
|
+
});
|
|
195
|
+
it('truncates to 50 chars of slug', () => {
|
|
196
|
+
const long = 'a'.repeat(80);
|
|
197
|
+
const out = dossierFilename({ company: long });
|
|
198
|
+
expect(out).toBe(`dossier-${'a'.repeat(50)}.md`);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard dispatch for the main App view (list / detail / ai).
|
|
3
|
+
* Returns the (input, key) handler passed to Ink's useInput. All state lives in
|
|
4
|
+
* App; this module only routes keypresses to the supplied actions/setters so
|
|
5
|
+
* the routing rules stay in one cohesive, reviewable place.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function createAppKeyHandler(ctx) {
|
|
9
|
+
const {
|
|
10
|
+
view,
|
|
11
|
+
tab,
|
|
12
|
+
jobs,
|
|
13
|
+
cursor,
|
|
14
|
+
selectedJob,
|
|
15
|
+
inlineSearch,
|
|
16
|
+
confirmExit,
|
|
17
|
+
ai,
|
|
18
|
+
exit,
|
|
19
|
+
forceRefresh,
|
|
20
|
+
showToast,
|
|
21
|
+
setView,
|
|
22
|
+
setTab,
|
|
23
|
+
setCursor,
|
|
24
|
+
setSelectedJob,
|
|
25
|
+
setInlineSearch,
|
|
26
|
+
setSearchQuery,
|
|
27
|
+
setConfirmExit,
|
|
28
|
+
handleDossier,
|
|
29
|
+
nextTab,
|
|
30
|
+
} = ctx;
|
|
31
|
+
|
|
32
|
+
return (input, key) => {
|
|
33
|
+
if (view === 'filters' || view === 'searches' || view === 'help') return;
|
|
34
|
+
if (inlineSearch) return;
|
|
35
|
+
|
|
36
|
+
if (input === 'q' && view === 'list') {
|
|
37
|
+
if (ai.hasActiveProcess && !confirmExit) {
|
|
38
|
+
showToast(
|
|
39
|
+
'Claude dossier still running — press q again to quit',
|
|
40
|
+
'warning'
|
|
41
|
+
);
|
|
42
|
+
setConfirmExit(true);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
ai.cancel();
|
|
46
|
+
exit();
|
|
47
|
+
}
|
|
48
|
+
if (input !== 'q') setConfirmExit(false);
|
|
49
|
+
if (input === 'q' && view === 'detail') setView('list');
|
|
50
|
+
if (input === 'R' && (view === 'list' || view === 'detail')) {
|
|
51
|
+
forceRefresh();
|
|
52
|
+
showToast('Refreshing…', 'info');
|
|
53
|
+
}
|
|
54
|
+
if (input === 'f' && (view === 'list' || view === 'detail'))
|
|
55
|
+
setView('filters');
|
|
56
|
+
if (input === '/' && (view === 'list' || view === 'detail'))
|
|
57
|
+
setView('searches');
|
|
58
|
+
if (input === '?' && (view === 'list' || view === 'detail'))
|
|
59
|
+
setView('help');
|
|
60
|
+
if (input === 'n' && view === 'list') {
|
|
61
|
+
setInlineSearch(true);
|
|
62
|
+
setSearchQuery('');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Enter toggles detail panel
|
|
66
|
+
if (key.return && view === 'list' && jobs[cursor]) {
|
|
67
|
+
setSelectedJob(jobs[cursor]);
|
|
68
|
+
setView('detail');
|
|
69
|
+
}
|
|
70
|
+
if (key.escape && view === 'detail') setView('list');
|
|
71
|
+
if (input === 'c' && view === 'detail' && selectedJob) {
|
|
72
|
+
handleDossier(selectedJob);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (key.escape && view === 'ai') {
|
|
76
|
+
// Don't kill running dossier — just hide the panel
|
|
77
|
+
setView(selectedJob ? 'detail' : 'list');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (key.tab && (view === 'list' || view === 'detail')) {
|
|
81
|
+
setTab(nextTab(tab, 1));
|
|
82
|
+
setCursor(0);
|
|
83
|
+
}
|
|
84
|
+
if (key.shift && key.tab && (view === 'list' || view === 'detail')) {
|
|
85
|
+
setTab(nextTab(tab, -1));
|
|
86
|
+
setCursor(0);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createAppKeyHandler } from './appKeyHandler.js';
|
|
3
|
+
import { nextTab } from './jobFilters.js';
|
|
4
|
+
|
|
5
|
+
function makeCtx(overrides = {}) {
|
|
6
|
+
const ctx = {
|
|
7
|
+
view: 'list',
|
|
8
|
+
tab: 'all',
|
|
9
|
+
jobs: [{ id: 1 }, { id: 2 }],
|
|
10
|
+
cursor: 0,
|
|
11
|
+
selectedJob: null,
|
|
12
|
+
inlineSearch: false,
|
|
13
|
+
confirmExit: false,
|
|
14
|
+
ai: { hasActiveProcess: false, cancel: vi.fn() },
|
|
15
|
+
exit: vi.fn(),
|
|
16
|
+
forceRefresh: vi.fn(),
|
|
17
|
+
showToast: vi.fn(),
|
|
18
|
+
setView: vi.fn(),
|
|
19
|
+
setTab: vi.fn(),
|
|
20
|
+
setCursor: vi.fn(),
|
|
21
|
+
setSelectedJob: vi.fn(),
|
|
22
|
+
setInlineSearch: vi.fn(),
|
|
23
|
+
setSearchQuery: vi.fn(),
|
|
24
|
+
setConfirmExit: vi.fn(),
|
|
25
|
+
handleDossier: vi.fn(),
|
|
26
|
+
nextTab,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
return ctx;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const noKey = {};
|
|
33
|
+
|
|
34
|
+
describe('createAppKeyHandler', () => {
|
|
35
|
+
it('ignores all input while in modal views', () => {
|
|
36
|
+
const ctx = makeCtx({ view: 'filters' });
|
|
37
|
+
createAppKeyHandler(ctx)('q', noKey);
|
|
38
|
+
expect(ctx.exit).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('ignores input while inline search is active', () => {
|
|
42
|
+
const ctx = makeCtx({ inlineSearch: true });
|
|
43
|
+
createAppKeyHandler(ctx)('q', noKey);
|
|
44
|
+
expect(ctx.exit).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('quits on q in list view when no active process', () => {
|
|
48
|
+
const ctx = makeCtx();
|
|
49
|
+
createAppKeyHandler(ctx)('q', noKey);
|
|
50
|
+
expect(ctx.ai.cancel).toHaveBeenCalled();
|
|
51
|
+
expect(ctx.exit).toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('warns and arms confirmExit on q while dossier running', () => {
|
|
55
|
+
const ctx = makeCtx({ ai: { hasActiveProcess: true, cancel: vi.fn() } });
|
|
56
|
+
createAppKeyHandler(ctx)('q', noKey);
|
|
57
|
+
expect(ctx.showToast).toHaveBeenCalledWith(
|
|
58
|
+
expect.stringContaining('still running'),
|
|
59
|
+
'warning'
|
|
60
|
+
);
|
|
61
|
+
expect(ctx.setConfirmExit).toHaveBeenCalledWith(true);
|
|
62
|
+
expect(ctx.exit).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('quits on second q after confirm', () => {
|
|
66
|
+
const ctx = makeCtx({
|
|
67
|
+
ai: { hasActiveProcess: true, cancel: vi.fn() },
|
|
68
|
+
confirmExit: true,
|
|
69
|
+
});
|
|
70
|
+
createAppKeyHandler(ctx)('q', noKey);
|
|
71
|
+
expect(ctx.exit).toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('q in detail view returns to list', () => {
|
|
75
|
+
const ctx = makeCtx({ view: 'detail' });
|
|
76
|
+
createAppKeyHandler(ctx)('q', noKey);
|
|
77
|
+
expect(ctx.setView).toHaveBeenCalledWith('list');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('resets confirmExit on any non-q key', () => {
|
|
81
|
+
const ctx = makeCtx();
|
|
82
|
+
createAppKeyHandler(ctx)('R', noKey);
|
|
83
|
+
expect(ctx.setConfirmExit).toHaveBeenCalledWith(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('R refreshes and toasts', () => {
|
|
87
|
+
const ctx = makeCtx();
|
|
88
|
+
createAppKeyHandler(ctx)('R', noKey);
|
|
89
|
+
expect(ctx.forceRefresh).toHaveBeenCalled();
|
|
90
|
+
expect(ctx.showToast).toHaveBeenCalledWith('Refreshing…', 'info');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('f opens filters, / opens searches, ? opens help', () => {
|
|
94
|
+
const ctx = makeCtx();
|
|
95
|
+
const handler = createAppKeyHandler(ctx);
|
|
96
|
+
handler('f', noKey);
|
|
97
|
+
handler('/', noKey);
|
|
98
|
+
handler('?', noKey);
|
|
99
|
+
expect(ctx.setView).toHaveBeenCalledWith('filters');
|
|
100
|
+
expect(ctx.setView).toHaveBeenCalledWith('searches');
|
|
101
|
+
expect(ctx.setView).toHaveBeenCalledWith('help');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('n opens inline search in list view', () => {
|
|
105
|
+
const ctx = makeCtx();
|
|
106
|
+
createAppKeyHandler(ctx)('n', noKey);
|
|
107
|
+
expect(ctx.setInlineSearch).toHaveBeenCalledWith(true);
|
|
108
|
+
expect(ctx.setSearchQuery).toHaveBeenCalledWith('');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('Enter opens detail for the cursored job', () => {
|
|
112
|
+
const ctx = makeCtx();
|
|
113
|
+
createAppKeyHandler(ctx)('', { return: true });
|
|
114
|
+
expect(ctx.setSelectedJob).toHaveBeenCalledWith({ id: 1 });
|
|
115
|
+
expect(ctx.setView).toHaveBeenCalledWith('detail');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('c in detail view triggers a dossier for the selected job', () => {
|
|
119
|
+
const job = { id: 9 };
|
|
120
|
+
const ctx = makeCtx({ view: 'detail', selectedJob: job });
|
|
121
|
+
createAppKeyHandler(ctx)('c', noKey);
|
|
122
|
+
expect(ctx.handleDossier).toHaveBeenCalledWith(job);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('Escape in ai view returns to detail when a job is selected', () => {
|
|
126
|
+
const ctx = makeCtx({ view: 'ai', selectedJob: { id: 1 } });
|
|
127
|
+
createAppKeyHandler(ctx)('', { escape: true });
|
|
128
|
+
expect(ctx.setView).toHaveBeenCalledWith('detail');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('Tab cycles forward and resets cursor', () => {
|
|
132
|
+
const ctx = makeCtx();
|
|
133
|
+
createAppKeyHandler(ctx)('', { tab: true });
|
|
134
|
+
expect(ctx.setTab).toHaveBeenCalledWith('new');
|
|
135
|
+
expect(ctx.setCursor).toHaveBeenCalledWith(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('Shift+Tab cycles backward (last setTab wins)', () => {
|
|
139
|
+
const ctx = makeCtx({ tab: 'new' });
|
|
140
|
+
createAppKeyHandler(ctx)('', { tab: true, shift: true });
|
|
141
|
+
// both branches fire; backward call is last → 'all'
|
|
142
|
+
expect(ctx.setTab).toHaveBeenLastCalledWith('all');
|
|
143
|
+
});
|
|
144
|
+
});
|