@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,208 @@
1
+ ---
2
+ name: jsonresume-hunt
3
+ description: Interactive job hunting assistant. Interviews you about what you want, searches HN "Who is Hiring" jobs matched to your JSON Resume, researches companies, does skill gap analysis, and builds a shortlist of jobs to apply for. Use when looking for a job, exploring opportunities, or reviewing new job postings.
4
+ argument-hint: "[optional: preferences like 'remote react jobs' or 'startups in SF']"
5
+ ---
6
+
7
+ You are an expert job hunting assistant. Guide the user through a complete job search — from understanding what they want, to delivering a final shortlist of jobs ready to apply for.
8
+
9
+ ## Tools
10
+
11
+ You have the `@jsonresume/job-search` CLI. If it's not installed, run:
12
+
13
+ ```bash
14
+ npx @jsonresume/job-search help
15
+ ```
16
+
17
+ If `JSONRESUME_API_KEY` is not set, tell the user to generate one:
18
+
19
+ ```bash
20
+ curl -s -X POST https://jsonresume.org/api/v1/keys \
21
+ -H 'Content-Type: application/json' \
22
+ -d '{"username":"YOUR_GITHUB_USERNAME"}'
23
+ ```
24
+
25
+ CLI commands:
26
+
27
+ ```
28
+ npx @jsonresume/job-search search --json --top 50 --days 30
29
+ npx @jsonresume/job-search search --json --remote --min-salary 150 --search "react"
30
+ npx @jsonresume/job-search detail <id> --json
31
+ npx @jsonresume/job-search mark <id> interested --feedback "reason"
32
+ npx @jsonresume/job-search mark <id> not_interested --feedback "reason"
33
+ npx @jsonresume/job-search me --json
34
+ npx @jsonresume/job-search update <path-to-resume.json>
35
+ ```
36
+
37
+ You also have WebSearch for company research and full file read/write for creating the tracker.
38
+
39
+ ## Phase 1: Discovery
40
+
41
+ Start by pulling their resume:
42
+
43
+ ```bash
44
+ npx @jsonresume/job-search me --json
45
+ ```
46
+
47
+ Read and understand their background. Then interview them — don't dump all questions at once, have a conversation:
48
+
49
+ 1. What kind of role? (same path, pivot, step up, something new?)
50
+ 2. Remote / hybrid / onsite? Location preferences or restrictions?
51
+ 3. Salary floor and ideal?
52
+ 4. Company stage? (early startup, growth, big tech, agency, non-profit)
53
+ 5. Industries to avoid? (gambling, defense, crypto, adtech, etc.)
54
+ 6. What matters most right now? (compensation, mission, tech stack, growth, balance)
55
+ 7. Dream companies?
56
+ 8. Dealbreakers? (on-call, travel, tech you dislike, visa issues)
57
+
58
+ If the user passed $ARGUMENTS, use those as starting preferences and skip questions they already answered.
59
+
60
+ After the interview, summarize their profile and confirm before searching.
61
+
62
+ If you notice their resume is missing skills, has outdated job titles, or could better reflect what they told you, offer to update it:
63
+
64
+ 1. Save a modified `resume.json` with improvements
65
+ 2. Push it: `npx @jsonresume/job-search update resume.json`
66
+ 3. Confirm the update before proceeding
67
+
68
+ This improves match quality for the search phase.
69
+
70
+ ## Phase 2: Search
71
+
72
+ Cast a wide net with multiple searches:
73
+
74
+ ```bash
75
+ npx @jsonresume/job-search search --json --top 50 --days 30
76
+ npx @jsonresume/job-search search --json --top 50 --days 30 --remote
77
+ npx @jsonresume/job-search search --json --top 50 --days 60 --search "<keyword>"
78
+ ```
79
+
80
+ Run different keyword searches based on their stated interests. Deduplicate by job ID.
81
+
82
+ Filter results against Phase 1 preferences. Sort into tiers:
83
+
84
+ **Tier 1 — Strong matches**: Fits most criteria, high similarity score, right level
85
+ **Tier 2 — Worth a look**: Partial fit, interesting company or upside
86
+ **Tier 3 — Long shots**: Stretch roles, imperfect fit but compelling opportunity
87
+
88
+ Present max 10 at a time in a clean table:
89
+
90
+ ```
91
+ | # | Score | Title | Company | Location | Salary | Tier |
92
+ |---|-------|--------------------------|---------------|--------------|---------|------|
93
+ | 1 | 0.60 | Senior Full Stack Eng | Acme Corp | Remote | $180k | T1 |
94
+ ```
95
+
96
+ Include your quick take on each: why it fits, any yellow flags.
97
+
98
+ ## Phase 3: Research
99
+
100
+ For each Tier 1 job and any Tier 2 the user wants to explore:
101
+
102
+ 1. Pull full details:
103
+
104
+ ```bash
105
+ npx @jsonresume/job-search detail <id> --json
106
+ ```
107
+
108
+ 2. Research the company using WebSearch:
109
+
110
+ - What they do, funding stage, headcount, recent news
111
+ - Tech blog / GitHub / open source presence
112
+ - Employee sentiment if findable
113
+ - Who posted on HN (the URL has the poster's username)
114
+
115
+ 3. Skill gap analysis — compare `me --json` against job requirements:
116
+ - **Matching skills**: What they have that the job wants
117
+ - **Missing skills**: Gaps to acknowledge or address
118
+ - **Adjacent skills**: Related experience that transfers
119
+ - **Fit rating**: Strong / Good / Stretch
120
+
121
+ Present research in a clear block per job. Include the HN URL so they can read the original post.
122
+
123
+ ## Phase 4: Decisions
124
+
125
+ After presenting each researched job, ask: **Apply, Maybe, or Pass?**
126
+
127
+ Mark their decision:
128
+
129
+ ```bash
130
+ npx @jsonresume/job-search mark <id> interested --feedback "strong remote role, good tech stack"
131
+ npx @jsonresume/job-search mark <id> not_interested --feedback "salary too low for the location"
132
+ npx @jsonresume/job-search mark <id> maybe --feedback "interesting but want to research more"
133
+ ```
134
+
135
+ Always capture the reason. Notice patterns — if they keep passing on similar jobs, adjust.
136
+
137
+ Keep a running tally:
138
+
139
+ > Reviewed 12 jobs. Shortlisted: 3. Maybe: 2. Passed: 7. Remaining: 5.
140
+
141
+ ## Phase 5: Application Tracker
142
+
143
+ Once they have their shortlist, create a markdown file:
144
+
145
+ **File: `job-hunt-YYYY-MM-DD.md`** in the current working directory.
146
+
147
+ Use this structure:
148
+
149
+ ```markdown
150
+ # Job Hunt — [Date]
151
+
152
+ ## What I'm Looking For
153
+
154
+ [Summary from Phase 1]
155
+
156
+ ## Shortlist
157
+
158
+ ### 1. [Title] at [Company]
159
+
160
+ - **Job ID:** [id]
161
+ - **Match Score:** [similarity]
162
+ - **HN Post:** [url]
163
+ - **Salary:** [salary]
164
+ - **Location:** [location/remote]
165
+ - **Type:** [full-time/contract]
166
+ - **Why this role:** [2-3 sentences on fit]
167
+ - **Skill gaps:** [honest assessment]
168
+ - **Company:** [1-2 sentences]
169
+ - **Next step:** [what to do — apply on site, email poster, etc.]
170
+ - **Status:** Ready to apply
171
+
172
+ ### 2. ...
173
+
174
+ ## Maybe — Revisit Later
175
+
176
+ - [Company] — [Title]: [why maybe]
177
+
178
+ ## Passed
179
+
180
+ - [Company] — [Title]: [reason]
181
+
182
+ ## Patterns & Notes
183
+
184
+ - [Trends noticed across jobs]
185
+ - [Advice for the user]
186
+ ```
187
+
188
+ ## Phase 6: Outreach
189
+
190
+ For each shortlisted job, offer to:
191
+
192
+ 1. **Draft outreach**: Write a personalized message to the HN poster referencing the user's specific experience and any open source work. Keep it short — 4-5 sentences max.
193
+
194
+ 2. **Resume highlights**: Identify which sections of their JSON Resume are most relevant for this role and suggest what to emphasize or tailor.
195
+
196
+ 3. **Company connections**: Search if the user might have connections (shared open source projects, past companies, mutual communities).
197
+
198
+ Create outreach drafts as a separate file if there are 3+ jobs: `outreach-YYYY-MM-DD.md`
199
+
200
+ ## Behavior
201
+
202
+ - Be direct and opinionated. If a job is wrong, say why.
203
+ - Don't present more than 10 jobs at once.
204
+ - Always show the HN URL.
205
+ - If search returns few results, widen `--days` or loosen filters automatically.
206
+ - Help them think through tradeoffs when they're unsure.
207
+ - When done, remind them to re-run in a few days — new HN threads post monthly.
208
+ - If they say "just show me everything", still tier and annotate — don't dump raw results.
package/src/api.js ADDED
@@ -0,0 +1,61 @@
1
+ const DEFAULT_BASE_URL = 'https://registry.jsonresume.org';
2
+
3
+ export function createApiClient({ baseUrl, apiKey }) {
4
+ const base = baseUrl || DEFAULT_BASE_URL;
5
+
6
+ async function request(path, options = {}) {
7
+ const url = `${base}/api/v1${path}`;
8
+ const res = await fetch(url, {
9
+ ...options,
10
+ headers: {
11
+ Authorization: `Bearer ${apiKey}`,
12
+ 'Content-Type': 'application/json',
13
+ ...options.headers,
14
+ },
15
+ });
16
+ const text = await res.text();
17
+ let data;
18
+ try {
19
+ data = JSON.parse(text);
20
+ } catch {
21
+ throw new Error(`Non-JSON response: ${res.status}`);
22
+ }
23
+ if (!res.ok) throw new Error(data.error || res.statusText);
24
+ return data;
25
+ }
26
+
27
+ return {
28
+ fetchJobs: (params = {}) => {
29
+ const qs = new URLSearchParams();
30
+ qs.set('top', String(params.top || 50));
31
+ qs.set('days', String(params.days || 30));
32
+ if (params.remote) qs.set('remote', 'true');
33
+ if (params.minSalary) qs.set('min_salary', String(params.minSalary));
34
+ if (params.search) qs.set('search', params.search);
35
+ if (params.searchId) qs.set('search_id', params.searchId);
36
+ if (params.rerank !== undefined) qs.set('rerank', String(params.rerank));
37
+ return request(`/jobs?${qs}`);
38
+ },
39
+ fetchJobDetail: (id) => request(`/jobs/${id}`),
40
+ markJob: (id, state, feedback) =>
41
+ request(`/jobs/${id}`, {
42
+ method: 'PUT',
43
+ body: JSON.stringify({ state, feedback: feedback || state }),
44
+ }),
45
+ fetchMe: () => request('/me'),
46
+
47
+ // Search profiles
48
+ listSearches: () => request('/searches'),
49
+ createSearch: (name, prompt) =>
50
+ request('/searches', {
51
+ method: 'POST',
52
+ body: JSON.stringify({ name, prompt }),
53
+ }),
54
+ updateSearch: (id, updates) =>
55
+ request(`/searches/${id}`, {
56
+ method: 'PUT',
57
+ body: JSON.stringify(updates),
58
+ }),
59
+ deleteSearch: (id) => request(`/searches/${id}`, { method: 'DELETE' }),
60
+ };
61
+ }
package/src/auth.js ADDED
@@ -0,0 +1,115 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { createInterface } from 'readline';
5
+
6
+ const DIR = join(homedir(), '.jsonresume');
7
+ const CONFIG_FILE = join(DIR, 'config.json');
8
+
9
+ function ensureDir() {
10
+ try {
11
+ mkdirSync(DIR, { recursive: true });
12
+ } catch {}
13
+ }
14
+
15
+ export function loadConfig() {
16
+ try {
17
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
18
+ } catch {
19
+ return {};
20
+ }
21
+ }
22
+
23
+ export function saveConfig(config) {
24
+ ensureDir();
25
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
26
+ }
27
+
28
+ function prompt(question) {
29
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
30
+ return new Promise((resolve) => {
31
+ rl.question(question, (answer) => {
32
+ rl.close();
33
+ resolve(answer.trim());
34
+ });
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Ensures we have a valid API key. Checks in order:
40
+ * 1. JSONRESUME_API_KEY env var
41
+ * 2. Saved config at ~/.jsonresume/config.json
42
+ * 3. Interactive login (prompt for GitHub username → generate key)
43
+ */
44
+ export async function ensureApiKey(baseUrl) {
45
+ // 1. Env var takes priority
46
+ if (process.env.JSONRESUME_API_KEY) {
47
+ return process.env.JSONRESUME_API_KEY;
48
+ }
49
+
50
+ // 2. Saved config
51
+ const config = loadConfig();
52
+ if (config.apiKey) {
53
+ // Verify it still works
54
+ try {
55
+ const res = await fetch(`${baseUrl}/api/v1/me`, {
56
+ headers: { Authorization: `Bearer ${config.apiKey}` },
57
+ });
58
+ if (res.ok) return config.apiKey;
59
+ } catch {}
60
+ // Key is stale — fall through to login
61
+ }
62
+
63
+ // 3. Interactive login
64
+ console.error('\n Welcome to JSON Resume Job Search!\n');
65
+ console.error(
66
+ ' You need a JSON Resume hosted at registry.jsonresume.org to use this tool.'
67
+ );
68
+ console.error(" If you don't have one yet, visit: https://jsonresume.org\n");
69
+
70
+ const username = await prompt(' Enter your GitHub username: ');
71
+
72
+ if (!username) {
73
+ console.error('\n No username provided. Exiting.');
74
+ process.exit(1);
75
+ }
76
+
77
+ console.error(
78
+ `\n Checking for resume at registry.jsonresume.org/${username}...`
79
+ );
80
+
81
+ // Verify resume exists
82
+ const resumeRes = await fetch(`${baseUrl}/${username}.json`);
83
+ if (!resumeRes.ok) {
84
+ console.error(`\n No resume found for "${username}".`);
85
+ console.error(' Create one at https://jsonresume.org and try again.\n');
86
+ process.exit(1);
87
+ }
88
+
89
+ console.error(' Resume found! Generating API key...');
90
+
91
+ // Generate key
92
+ const keyRes = await fetch(`${baseUrl}/api/v1/keys`, {
93
+ method: 'POST',
94
+ headers: { 'Content-Type': 'application/json' },
95
+ body: JSON.stringify({ username }),
96
+ });
97
+
98
+ if (!keyRes.ok) {
99
+ const err = await keyRes.json().catch(() => ({}));
100
+ console.error(
101
+ `\n Failed to generate key: ${err.error || keyRes.statusText}`
102
+ );
103
+ process.exit(1);
104
+ }
105
+
106
+ const { key } = await keyRes.json();
107
+
108
+ // Save it
109
+ saveConfig({ ...config, apiKey: key, username });
110
+
111
+ console.error(` API key saved to ~/.jsonresume/config.json`);
112
+ console.error(` Logged in as: ${username}\n`);
113
+
114
+ return key;
115
+ }
package/src/cache.js ADDED
@@ -0,0 +1,48 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const CACHE_DIR = join(homedir(), '.jsonresume', 'cache');
6
+ const CACHE_TTL = 24 * 60 * 60 * 1000; // 1 day
7
+
8
+ function ensureDir() {
9
+ try {
10
+ mkdirSync(CACHE_DIR, { recursive: true });
11
+ } catch {}
12
+ }
13
+
14
+ function cacheKey(params) {
15
+ const parts = [`jobs_${params.days || 30}_${params.top || 100}`];
16
+ if (params.searchId) parts.push(`s_${params.searchId.slice(0, 8)}`);
17
+ return parts.join('_') + '.json';
18
+ }
19
+
20
+ export function getCached(params) {
21
+ try {
22
+ const file = join(CACHE_DIR, cacheKey(params));
23
+ const raw = readFileSync(file, 'utf-8');
24
+ const { timestamp, data } = JSON.parse(raw);
25
+ if (Date.now() - timestamp < CACHE_TTL) return data;
26
+ } catch {}
27
+ return null;
28
+ }
29
+
30
+ export function setCache(params, data) {
31
+ try {
32
+ ensureDir();
33
+ const file = join(CACHE_DIR, cacheKey(params));
34
+ writeFileSync(file, JSON.stringify({ timestamp: Date.now(), data }));
35
+ } catch {}
36
+ }
37
+
38
+ export function updateCachedJob(params, jobId, updates) {
39
+ try {
40
+ const file = join(CACHE_DIR, cacheKey(params));
41
+ const raw = readFileSync(file, 'utf-8');
42
+ const cache = JSON.parse(raw);
43
+ cache.data = cache.data.map((j) =>
44
+ j.id === jobId ? { ...j, ...updates } : j
45
+ );
46
+ writeFileSync(file, JSON.stringify(cache));
47
+ } catch {}
48
+ }
package/src/export.js ADDED
@@ -0,0 +1,65 @@
1
+ import { writeFileSync } from 'fs';
2
+ import { formatSalary, formatLocation } from './formatters.js';
3
+
4
+ /**
5
+ * Export jobs to a markdown tracker file.
6
+ * Returns the filename written.
7
+ */
8
+ export function exportShortlist(allJobs) {
9
+ const date = new Date().toISOString().slice(0, 10);
10
+ const filename = `job-hunt-${date}.md`;
11
+
12
+ const interested = allJobs.filter((j) => j.state === 'interested');
13
+ const applied = allJobs.filter((j) => j.state === 'applied');
14
+ const maybe = allJobs.filter((j) => j.state === 'maybe');
15
+
16
+ const formatJob = (j, i) => {
17
+ const loc = formatLocation(j.location, j.remote);
18
+ const sal = formatSalary(j.salary, j.salary_usd);
19
+ const skills = j.skills ? j.skills.map((s) => s.name || s).join(', ') : '—';
20
+ return [
21
+ `### ${i + 1}. ${j.title} at ${j.company}`,
22
+ '',
23
+ `- **Job ID:** ${j.id}`,
24
+ `- **Match Score:** ${j.similarity}`,
25
+ j.url ? `- **HN Post:** ${j.url}` : null,
26
+ `- **Salary:** ${sal}`,
27
+ `- **Location:** ${loc}`,
28
+ `- **Skills:** ${skills}`,
29
+ '',
30
+ ]
31
+ .filter((l) => l !== null)
32
+ .join('\n');
33
+ };
34
+
35
+ const sections = [];
36
+
37
+ sections.push(`# Job Hunt — ${date}\n`);
38
+
39
+ if (interested.length > 0) {
40
+ sections.push(`## Shortlist (${interested.length})\n`);
41
+ sections.push(interested.map(formatJob).join('\n'));
42
+ }
43
+
44
+ if (applied.length > 0) {
45
+ sections.push(`## Applied (${applied.length})\n`);
46
+ sections.push(applied.map(formatJob).join('\n'));
47
+ }
48
+
49
+ if (maybe.length > 0) {
50
+ sections.push(`## Maybe — Revisit Later (${maybe.length})\n`);
51
+ sections.push(
52
+ maybe
53
+ .map((j) => `- **${j.company}** — ${j.title} (score: ${j.similarity})`)
54
+ .join('\n')
55
+ );
56
+ }
57
+
58
+ sections.push(
59
+ `\n---\n*Exported from [JSON Resume Job Search](https://github.com/jsonresume/jsonresume.org)*`
60
+ );
61
+
62
+ const content = sections.join('\n\n');
63
+ writeFileSync(filename, content);
64
+ return filename;
65
+ }
package/src/filters.js ADDED
@@ -0,0 +1,59 @@
1
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ const DIR = join(homedir(), '.jsonresume');
6
+ const FILE = join(DIR, 'filters.json');
7
+
8
+ function ensureDir() {
9
+ try {
10
+ mkdirSync(DIR, { recursive: true });
11
+ } catch {}
12
+ }
13
+
14
+ /**
15
+ * Filter state shape:
16
+ * {
17
+ * default: { active: [] }, // filters for default resume search
18
+ * searches: {
19
+ * "<search-id>": { active: [] }, // filters per saved search
20
+ * }
21
+ * }
22
+ */
23
+ export function loadFilters() {
24
+ try {
25
+ const raw = JSON.parse(readFileSync(FILE, 'utf-8'));
26
+ // Migrate old format: { active: [] } → { default: { active: [] }, searches: {} }
27
+ if (Array.isArray(raw.active)) {
28
+ return { default: { active: raw.active }, searches: {} };
29
+ }
30
+ return {
31
+ default: raw.default || { active: [] },
32
+ searches: raw.searches || {},
33
+ };
34
+ } catch {
35
+ return { default: { active: [] }, searches: {} };
36
+ }
37
+ }
38
+
39
+ export function saveFilters(state) {
40
+ ensureDir();
41
+ writeFileSync(FILE, JSON.stringify(state, null, 2));
42
+ }
43
+
44
+ /** Get filters for a specific search (or default) */
45
+ export function getFiltersForSearch(state, searchId) {
46
+ if (!searchId) return state.default?.active || [];
47
+ return state.searches?.[searchId]?.active || [];
48
+ }
49
+
50
+ /** Set filters for a specific search (or default) */
51
+ export function setFiltersForSearch(state, searchId, active) {
52
+ const next = { ...state, searches: { ...state.searches } };
53
+ if (!searchId) {
54
+ next.default = { active };
55
+ } else {
56
+ next.searches[searchId] = { active };
57
+ }
58
+ return next;
59
+ }
@@ -0,0 +1,71 @@
1
+ export function formatSalary(salary, salaryUsd) {
2
+ if (salaryUsd) return `$${Math.round(salaryUsd / 1000)}k`;
3
+ if (salary) return salary;
4
+ return '—';
5
+ }
6
+
7
+ export function formatLocation(loc, remote) {
8
+ const parts = [];
9
+ if (loc?.city) parts.push(loc.city);
10
+ if (loc?.countryCode) parts.push(loc.countryCode);
11
+ if (remote) parts.push(`(${remote})`);
12
+ return parts.join(', ') || '—';
13
+ }
14
+
15
+ export function stateIcon(state) {
16
+ const icons = {
17
+ interested: '⭐',
18
+ applied: '📨',
19
+ not_interested: '✗',
20
+ dismissed: '👁',
21
+ maybe: '?',
22
+ };
23
+ return icons[state] || ' ';
24
+ }
25
+
26
+ export function stateColor(state) {
27
+ const colors = {
28
+ interested: 'green',
29
+ applied: 'blue',
30
+ not_interested: 'red',
31
+ dismissed: 'gray',
32
+ maybe: 'yellow',
33
+ };
34
+ return colors[state] || 'white';
35
+ }
36
+
37
+ export function truncate(str, len) {
38
+ if (!str) return '';
39
+ return str.length > len ? str.slice(0, len - 1) + '…' : str;
40
+ }
41
+
42
+ /**
43
+ * Render a score as a sparkline bar: ████░░░░ 0.72
44
+ * Uses Unicode block characters for sub-character precision.
45
+ */
46
+ const BLOCKS = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
47
+
48
+ export function scoreBar(score, width = 6) {
49
+ if (typeof score !== 'number' || isNaN(score)) return '░'.repeat(width);
50
+ const clamped = Math.max(0, Math.min(1, score));
51
+ const filled = clamped * width;
52
+ const full = Math.floor(filled);
53
+ const frac = Math.round((filled - full) * 8);
54
+ const empty = width - full - (frac > 0 ? 1 : 0);
55
+ return (
56
+ '█'.repeat(full) +
57
+ (frac > 0 ? BLOCKS[frac] : '') +
58
+ '░'.repeat(Math.max(0, empty))
59
+ );
60
+ }
61
+
62
+ export function stateLabel(state) {
63
+ const labels = {
64
+ interested: 'Interested',
65
+ applied: 'Applied',
66
+ not_interested: 'Passed',
67
+ dismissed: 'Dismissed',
68
+ maybe: 'Maybe',
69
+ };
70
+ return labels[state] || '';
71
+ }
@@ -0,0 +1,37 @@
1
+ import { Box, Text, useInput } from 'ink';
2
+ import Spinner from 'ink-spinner';
3
+ import { h } from './h.js';
4
+
5
+ export default function AIPanel({ text, loading, error, onDismiss, isActive }) {
6
+ useInput(
7
+ (_input, key) => {
8
+ if (key.escape) onDismiss();
9
+ },
10
+ { isActive }
11
+ );
12
+
13
+ return h(
14
+ Box,
15
+ {
16
+ flexDirection: 'column',
17
+ borderStyle: 'double',
18
+ borderColor: 'magenta',
19
+ padding: 1,
20
+ marginX: 2,
21
+ },
22
+ h(Text, { bold: true, color: 'magenta' }, '🤖 AI Analysis'),
23
+ h(Text, null, ' '),
24
+ loading
25
+ ? h(
26
+ Box,
27
+ null,
28
+ h(Spinner, { type: 'dots' }),
29
+ h(Text, null, ' Thinking...')
30
+ )
31
+ : null,
32
+ error ? h(Text, { color: 'red' }, `Error: ${error}`) : null,
33
+ text ? h(Text, { wrap: 'wrap' }, text) : null,
34
+ h(Text, null, ' '),
35
+ h(Text, { dimColor: true }, 'Press ESC to dismiss')
36
+ );
37
+ }