@jsonresume/jobs 0.7.0 → 0.9.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 +147 -39
- package/bin/cli.js +58 -3
- package/package.json +1 -1
- package/src/localApi.js +66 -0
- package/src/localState.js +37 -0
- package/src/tui/App.js +10 -11
- package/src/tui/Header.js +74 -54
- package/src/tui/JobDetail.js +2 -1
- package/src/tui/StatusBar.js +52 -61
package/README.md
CHANGED
|
@@ -1,23 +1,35 @@
|
|
|
1
|
-
# @jsonresume/
|
|
1
|
+
# @jsonresume/jobs
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@jsonresume/jobs)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
6
|
|
|
7
7
|
Search Hacker News "Who is Hiring" jobs matched against your [JSON Resume](https://jsonresume.org). Jobs are semantically ranked using AI embeddings — your resume is compared against hundreds of monthly job postings to surface the best fits.
|
|
8
8
|
|
|
9
|
+

|
|
10
|
+
|
|
9
11
|
## Quick Start
|
|
10
12
|
|
|
13
|
+
**With a registry account:**
|
|
14
|
+
|
|
11
15
|
```bash
|
|
12
16
|
npx @jsonresume/jobs
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
The CLI walks you through login on first run — all you need is a resume hosted at [registry.jsonresume.org](https://registry.jsonresume.org).
|
|
20
|
+
|
|
21
|
+
**With a local resume file (no account needed):**
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @jsonresume/jobs --resume ./resume.json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or just drop a `resume.json` in your current directory and run `npx @jsonresume/jobs` — it's auto-detected.
|
|
16
28
|
|
|
17
29
|
## Prerequisites
|
|
18
30
|
|
|
19
31
|
- **Node.js** >= 18
|
|
20
|
-
- A JSON Resume on the [
|
|
32
|
+
- A [JSON Resume](https://jsonresume.org/schema/) — either hosted on the [registry](https://registry.jsonresume.org) or as a local `resume.json` file
|
|
21
33
|
- *(Optional)* `OPENAI_API_KEY` — enables AI summaries and batch ranking in the TUI
|
|
22
34
|
|
|
23
35
|
## Installation
|
|
@@ -31,7 +43,7 @@ npx @jsonresume/jobs
|
|
|
31
43
|
Or install globally:
|
|
32
44
|
|
|
33
45
|
```bash
|
|
34
|
-
npm install -g @jsonresume/
|
|
46
|
+
npm install -g @jsonresume/jobs
|
|
35
47
|
jsonresume-jobs
|
|
36
48
|
```
|
|
37
49
|
|
|
@@ -57,6 +69,27 @@ To clear saved credentials:
|
|
|
57
69
|
npx @jsonresume/jobs logout
|
|
58
70
|
```
|
|
59
71
|
|
|
72
|
+
## Local Mode (No Account)
|
|
73
|
+
|
|
74
|
+
You don't need a registry account to use the TUI. If you have a `resume.json` file following the [JSON Resume schema](https://jsonresume.org/schema/), you can use it directly:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Explicit path
|
|
78
|
+
npx @jsonresume/jobs --resume ./my-resume.json
|
|
79
|
+
|
|
80
|
+
# Auto-detect (looks for resume.json in current directory)
|
|
81
|
+
npx @jsonresume/jobs
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
In local mode:
|
|
85
|
+
|
|
86
|
+
- Job matching works the same way (your resume is sent to the server for embedding and vector search)
|
|
87
|
+
- **Marks are saved locally** to `~/.jsonresume/local-marks.json` instead of the server
|
|
88
|
+
- Filters and export work identically
|
|
89
|
+
- Custom search profiles are not available (they require a registry account for server-side storage)
|
|
90
|
+
|
|
91
|
+
This is useful if you want to try the tool without setting up a registry account, or if you prefer to keep your resume as a local file.
|
|
92
|
+
|
|
60
93
|
## Interactive TUI
|
|
61
94
|
|
|
62
95
|
The default command launches a full terminal interface for browsing and managing jobs.
|
|
@@ -65,20 +98,31 @@ The default command launches a full terminal interface for browsing and managing
|
|
|
65
98
|
npx @jsonresume/jobs
|
|
66
99
|
```
|
|
67
100
|
|
|
101
|
+
### Layout
|
|
102
|
+
|
|
103
|
+
The TUI has three main regions:
|
|
104
|
+
|
|
105
|
+
- **Header** — shows the app title, active search profile name, tab bar (All / Interested / Applied / Maybe / Passed) with live counts, and any active filter pills
|
|
106
|
+
- **Content area** — job list in list view, or a split-pane layout (40% compact list + 60% job detail) in detail view
|
|
107
|
+
- **Status bar** — context-sensitive keyboard hints, loading/reranking indicators, and toast notifications
|
|
108
|
+
|
|
68
109
|
### Features
|
|
69
110
|
|
|
70
|
-
- **Split-pane detail view** — press Enter to see a compact job list on the left and full details on the right
|
|
71
|
-
- **Tab-based views** — All / Interested / Applied / Maybe / Passed with live counts
|
|
72
|
-
- **Persistent filters** — remote, salary, keyword,
|
|
73
|
-
- **Custom search profiles** — targeted searches like "remote React jobs in climate tech" with AI-powered reranking
|
|
111
|
+
- **Split-pane detail view** — press `Enter` to see a compact job list on the left and full details on the right; navigate jobs with `j`/`k` without leaving the detail panel
|
|
112
|
+
- **Tab-based views** — All / Interested / Applied / Maybe / Passed — always visible with live counts, cycle with `Tab`/`Shift+Tab`
|
|
113
|
+
- **Persistent filters** — remote, salary, keyword, date range — saved to disk per search profile and restored when you switch profiles
|
|
114
|
+
- **Custom search profiles** — targeted searches like "remote React jobs in climate tech" with AI-powered reranking via HyDE embeddings
|
|
74
115
|
- **Two-pass loading** — results appear instantly from vector search, then reshuffle when LLM reranking completes in the background
|
|
75
|
-
- **Batch operations** — select multiple jobs with `v`, then bulk-mark them all at once
|
|
76
|
-
- **Inline search** — press `n` to quickly filter visible jobs by keyword
|
|
77
|
-
- **Export** — press `e` to export your shortlist to a markdown file
|
|
78
|
-
- **Toast notifications** — instant feedback on every action
|
|
79
|
-
- **Help modal** — press `?` for a full keyboard reference
|
|
80
|
-
- **AI summaries** — per-job
|
|
81
|
-
- **Vim-style navigation** — `j`/`k
|
|
116
|
+
- **Batch operations** — select multiple jobs with `v`, then bulk-mark them all at once with `i`/`x`/`m`/`p`
|
|
117
|
+
- **Inline search** — press `n` to quickly filter visible jobs by keyword; press `Esc` to clear
|
|
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
|
+
- **Toast notifications** — instant feedback on every action (marking, exporting, refreshing)
|
|
120
|
+
- **Help modal** — press `?` for a full keyboard reference organized by section
|
|
121
|
+
- **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
|
+
- **Vim-style navigation** — `j`/`k` to move, `g`/`G` to jump to first/last, `Ctrl+U`/`Ctrl+D` to page up/down
|
|
123
|
+
- **Responsive columns** — job list columns (score, title, company, location, salary) adapt to terminal width on resize
|
|
124
|
+
- **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
|
|
82
126
|
|
|
83
127
|
### Keyboard Shortcuts
|
|
84
128
|
|
|
@@ -95,15 +139,15 @@ npx @jsonresume/jobs
|
|
|
95
139
|
| `x` | Mark applied |
|
|
96
140
|
| `m` | Mark maybe |
|
|
97
141
|
| `p` | Mark passed |
|
|
98
|
-
| `v` | Toggle batch selection |
|
|
142
|
+
| `v` | Toggle batch selection (selected jobs shown with `●`) |
|
|
99
143
|
| `Tab` / `Shift+Tab` | Next / previous tab |
|
|
100
144
|
| `n` | Inline keyword search |
|
|
101
145
|
| `f` | Manage filters |
|
|
102
146
|
| `/` | Search profiles |
|
|
103
|
-
| `Space` | AI summary |
|
|
104
|
-
| `S` | AI batch review |
|
|
147
|
+
| `Space` | AI summary for current job |
|
|
148
|
+
| `S` | AI batch review of visible jobs |
|
|
105
149
|
| `e` | Export shortlist to markdown |
|
|
106
|
-
| `R` | Force refresh |
|
|
150
|
+
| `R` | Force refresh (bypass cache) |
|
|
107
151
|
| `?` | Help modal |
|
|
108
152
|
| `q` | Quit |
|
|
109
153
|
|
|
@@ -111,22 +155,60 @@ npx @jsonresume/jobs
|
|
|
111
155
|
|
|
112
156
|
| Key | Action |
|
|
113
157
|
|-----|--------|
|
|
114
|
-
| `j` / `k` | Navigate jobs (updates detail pane) |
|
|
115
|
-
| `J` / `K` | Scroll detail content |
|
|
158
|
+
| `j` / `k` | Navigate between jobs (updates detail pane) |
|
|
159
|
+
| `J` / `K` | Scroll detail content up / down |
|
|
116
160
|
| `o` | Open HN post in browser |
|
|
117
161
|
| `i` / `x` / `m` / `p` | Mark job state |
|
|
118
162
|
| `Space` | AI summary |
|
|
119
163
|
| `Esc` / `q` | Back to full list |
|
|
120
164
|
|
|
121
|
-
#### Filters
|
|
165
|
+
#### Filters Panel
|
|
122
166
|
|
|
123
167
|
| Key | Action |
|
|
124
168
|
|-----|--------|
|
|
125
|
-
| `
|
|
126
|
-
| `
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
129
|
-
| `Esc` | Close |
|
|
169
|
+
| `j` / `k` | Navigate filters |
|
|
170
|
+
| `Enter` | Edit filter value |
|
|
171
|
+
| `a` | Add filter (remote, salary, keyword, days) |
|
|
172
|
+
| `d` | Delete selected filter |
|
|
173
|
+
| `Esc` | Close panel |
|
|
174
|
+
|
|
175
|
+
#### Search Profiles Panel
|
|
176
|
+
|
|
177
|
+
| Key | Action |
|
|
178
|
+
|-----|--------|
|
|
179
|
+
| `j` / `k` | Navigate profiles |
|
|
180
|
+
| `Enter` | Switch to selected profile |
|
|
181
|
+
| `n` | Create new search profile |
|
|
182
|
+
| `d` | Delete selected profile |
|
|
183
|
+
| `Esc` | Close panel |
|
|
184
|
+
|
|
185
|
+
### Job List Columns
|
|
186
|
+
|
|
187
|
+
In full-width list view, columns are:
|
|
188
|
+
|
|
189
|
+
| Column | Description |
|
|
190
|
+
|--------|-------------|
|
|
191
|
+
| Score | Cosine similarity (0.00–1.00) between your resume and the job |
|
|
192
|
+
| AI | LLM rerank score (1–10), shown only when a search profile triggers reranking |
|
|
193
|
+
| Title | Job title |
|
|
194
|
+
| Company | Company name |
|
|
195
|
+
| Location | City, country code, remote status |
|
|
196
|
+
| Salary | Parsed salary or `—` if not listed |
|
|
197
|
+
| Status | State icon: ⭐ interested, 📨 applied, ? maybe, ✗ passed |
|
|
198
|
+
|
|
199
|
+
In split-pane detail view, the left pane shows a compact list with just score, title, and status.
|
|
200
|
+
|
|
201
|
+
### Tab Views
|
|
202
|
+
|
|
203
|
+
| Tab | Shows |
|
|
204
|
+
|-----|-------|
|
|
205
|
+
| All | All jobs from the current search (excludes passed/dismissed) |
|
|
206
|
+
| Interested | Jobs you marked with `i` |
|
|
207
|
+
| Applied | Jobs you marked with `x` |
|
|
208
|
+
| Maybe | Jobs you marked with `m` |
|
|
209
|
+
| Passed | Jobs you marked with `p` |
|
|
210
|
+
|
|
211
|
+
All tabs always appear in the header with their current count. Marking a job moves it between tabs instantly.
|
|
130
212
|
|
|
131
213
|
## CLI Commands
|
|
132
214
|
|
|
@@ -140,6 +222,7 @@ npx @jsonresume/jobs detail 181420 # Full job details
|
|
|
140
222
|
npx @jsonresume/jobs mark 181420 interested # Mark a job
|
|
141
223
|
npx @jsonresume/jobs me # Your resume summary
|
|
142
224
|
npx @jsonresume/jobs update ./resume.json # Update your resume
|
|
225
|
+
npx @jsonresume/jobs logout # Remove saved API key
|
|
143
226
|
npx @jsonresume/jobs help # All options
|
|
144
227
|
```
|
|
145
228
|
|
|
@@ -150,7 +233,7 @@ npx @jsonresume/jobs help # All options
|
|
|
150
233
|
| *(default)* | Launch interactive TUI |
|
|
151
234
|
| `search` | Find matching jobs (table output) |
|
|
152
235
|
| `detail <id>` | Show full details for a job |
|
|
153
|
-
| `mark <id> <state>` | Set job state
|
|
236
|
+
| `mark <id> <state>` | Set job state (see states below) |
|
|
154
237
|
| `me` | Show your resume summary |
|
|
155
238
|
| `update <file>` | Upload a new version of your resume |
|
|
156
239
|
| `logout` | Remove saved API key |
|
|
@@ -176,8 +259,8 @@ npx @jsonresume/jobs help # All options
|
|
|
176
259
|
| `interested` | ⭐ | You want this job |
|
|
177
260
|
| `applied` | 📨 | You've applied |
|
|
178
261
|
| `maybe` | ? | Considering it |
|
|
179
|
-
| `not_interested` | ✗ | Not for you |
|
|
180
|
-
| `dismissed` | 👁 | Hide from results |
|
|
262
|
+
| `not_interested` | ✗ | Not for you (hidden from future searches) |
|
|
263
|
+
| `dismissed` | 👁 | Hide from results (hidden from future searches) |
|
|
181
264
|
|
|
182
265
|
## How Ranking Works
|
|
183
266
|
|
|
@@ -191,7 +274,7 @@ Job postings from HN's monthly "Who is Hiring?" threads are parsed by GPT into s
|
|
|
191
274
|
|
|
192
275
|
### Stage 2: Vector Similarity Search
|
|
193
276
|
|
|
194
|
-
Your resume embedding is compared against all job embeddings using cosine similarity via [pgvector](https://github.com/pgvector/pgvector). The top ~500 candidates are retrieved in ~200ms. This is purely semantic — it finds jobs that "sound like" your resume. Jobs you've already passed on are excluded server-side so you always get fresh results.
|
|
277
|
+
Your resume embedding is compared against all job embeddings using cosine similarity via [pgvector](https://github.com/pgvector/pgvector). The top ~500 candidates are retrieved in ~200ms. This is purely semantic — it finds jobs that "sound like" your resume. Jobs you've already passed on or dismissed are excluded server-side (with 5x over-fetch to compensate) so you always get fresh results.
|
|
195
278
|
|
|
196
279
|
### Stage 3: Custom Search Profiles
|
|
197
280
|
|
|
@@ -207,11 +290,11 @@ For custom searches, the top 30 vector results are re-scored by `gpt-4.1-mini`.
|
|
|
207
290
|
|
|
208
291
|
The final score blends both signals: `0.4 × vector_score + 0.6 × llm_score`. This lets the LLM override semantic similarity — a job that's a great vector match but contradicts your preferences gets pushed down.
|
|
209
292
|
|
|
210
|
-
In the TUI, this runs as a two-pass load: jobs appear instantly from vector search, then reshuffle when reranking finishes in the background.
|
|
293
|
+
In the TUI, this runs as a two-pass load: jobs appear instantly from vector search, then reshuffle when reranking finishes in the background. The status bar shows "reranking..." while this is in progress.
|
|
211
294
|
|
|
212
295
|
### Stage 5: Client-side Filtering
|
|
213
296
|
|
|
214
|
-
After server-side ranking, the TUI applies local filters (remote only, minimum salary, keyword, days). Filters are persisted per search profile
|
|
297
|
+
After server-side ranking, the TUI applies local filters (remote only, minimum salary, keyword, days). Filters are persisted per search profile to `~/.jsonresume/filters.json`, so switching profiles restores each one's filters automatically.
|
|
215
298
|
|
|
216
299
|
## Claude Code Skill
|
|
217
300
|
|
|
@@ -221,7 +304,7 @@ This package includes a [Claude Code skill](https://docs.anthropic.com/en/docs/c
|
|
|
221
304
|
|
|
222
305
|
```bash
|
|
223
306
|
mkdir -p ~/.claude/skills/jsonresume-hunt
|
|
224
|
-
cp node_modules/@jsonresume/
|
|
307
|
+
cp node_modules/@jsonresume/jobs/skills/jsonresume-hunt/SKILL.md \
|
|
225
308
|
~/.claude/skills/jsonresume-hunt/SKILL.md
|
|
226
309
|
```
|
|
227
310
|
|
|
@@ -246,11 +329,16 @@ The skill interviews you about what you're looking for, runs multiple searches,
|
|
|
246
329
|
|
|
247
330
|
## Data Storage
|
|
248
331
|
|
|
332
|
+
All local data is stored under `~/.jsonresume/`:
|
|
333
|
+
|
|
249
334
|
| Path | Contents |
|
|
250
335
|
|------|----------|
|
|
251
|
-
| `~/.jsonresume/config.json` | API key and username |
|
|
336
|
+
| `~/.jsonresume/config.json` | API key and username (registry mode) |
|
|
252
337
|
| `~/.jsonresume/filters.json` | Saved filter presets per search profile |
|
|
253
|
-
| `~/.jsonresume/cache/` | Cached job results (auto-expires) |
|
|
338
|
+
| `~/.jsonresume/cache/` | Cached job results (auto-expires after 24 hours) |
|
|
339
|
+
| `~/.jsonresume/local-marks.json` | Job marks in local mode |
|
|
340
|
+
|
|
341
|
+
The export command writes `job-hunt-YYYY-MM-DD.md` to your current working directory.
|
|
254
342
|
|
|
255
343
|
## Contributing
|
|
256
344
|
|
|
@@ -263,6 +351,26 @@ pnpm install
|
|
|
263
351
|
node packages/job-search/bin/cli.js help
|
|
264
352
|
```
|
|
265
353
|
|
|
354
|
+
The TUI is built with [React Ink](https://github.com/vadimdemedes/ink) v6 using a `h()` helper (no JSX). Key source files:
|
|
355
|
+
|
|
356
|
+
| File | Purpose |
|
|
357
|
+
|------|---------|
|
|
358
|
+
| `bin/cli.js` | Entry point, CLI command routing |
|
|
359
|
+
| `src/auth.js` | Interactive login flow, API key management |
|
|
360
|
+
| `src/tui/App.js` | Main TUI orchestrator, view state, layout |
|
|
361
|
+
| `src/tui/Header.js` | Title bar, tab bar, filter pills |
|
|
362
|
+
| `src/tui/JobList.js` | Responsive job table with flexbox columns |
|
|
363
|
+
| `src/tui/JobDetail.js` | Full job detail view (standalone and split-pane) |
|
|
364
|
+
| `src/tui/StatusBar.js` | Key hints, loading state, toasts |
|
|
365
|
+
| `src/tui/useJobs.js` | Job fetching, caching, tab filtering |
|
|
366
|
+
| `src/tui/useAI.js` | AI summary and batch review integration |
|
|
367
|
+
| `src/tui/useSearches.js` | Search profile CRUD |
|
|
368
|
+
| `src/filters.js` | Persistent filter storage per search profile |
|
|
369
|
+
| `src/export.js` | Markdown export |
|
|
370
|
+
| `src/localApi.js` | API client for local mode (no registry account) |
|
|
371
|
+
| `src/localState.js` | Local mark storage for local mode |
|
|
372
|
+
| `src/cache.js` | Local result caching with TTL |
|
|
373
|
+
|
|
266
374
|
See the repo root [CLAUDE.md](../../CLAUDE.md) for code standards and contribution guidelines.
|
|
267
375
|
|
|
268
376
|
## License
|
package/bin/cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Just run: npx @jsonresume/jobs
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
const VERSION = '0.
|
|
9
|
+
const VERSION = '0.9.0';
|
|
10
10
|
|
|
11
11
|
const BASE_URL =
|
|
12
12
|
getArg('--base-url') ||
|
|
@@ -25,6 +25,37 @@ function hasFlag(name) {
|
|
|
25
25
|
return process.argv.includes(name);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
// ── Local resume detection ────────────────────────────────
|
|
29
|
+
|
|
30
|
+
async function findLocalResume() {
|
|
31
|
+
const explicitPath = getArg('--resume');
|
|
32
|
+
if (explicitPath) {
|
|
33
|
+
const fs = await import('node:fs');
|
|
34
|
+
const path = await import('node:path');
|
|
35
|
+
const resolved = path.resolve(explicitPath);
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(fs.readFileSync(resolved, 'utf-8'));
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error(`Error reading resume at ${resolved}: ${err.message}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Auto-detect resume.json in CWD
|
|
45
|
+
const fs = await import('node:fs');
|
|
46
|
+
const path = await import('node:path');
|
|
47
|
+
const candidates = ['resume.json', 'Resume.json'];
|
|
48
|
+
for (const name of candidates) {
|
|
49
|
+
const filePath = path.resolve(name);
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
52
|
+
const resume = JSON.parse(raw);
|
|
53
|
+
if (resume.basics) return resume;
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
28
59
|
// ── API client ─────────────────────────────────────────────
|
|
29
60
|
|
|
30
61
|
let _apiKey;
|
|
@@ -288,6 +319,12 @@ jsonresume-jobs v${VERSION} — Search HN "Who is Hiring" jobs matched to your J
|
|
|
288
319
|
|
|
289
320
|
QUICK START
|
|
290
321
|
npx @jsonresume/jobs Launch the interactive TUI (logs in automatically)
|
|
322
|
+
npx @jsonresume/jobs --resume resume.json Use a local resume (no account needed)
|
|
323
|
+
|
|
324
|
+
LOCAL MODE
|
|
325
|
+
If a resume.json file exists in the current directory, or you pass --resume,
|
|
326
|
+
the TUI launches without requiring a registry account. Job matches are fetched
|
|
327
|
+
from the server using your resume, and marks are saved locally.
|
|
291
328
|
|
|
292
329
|
COMMANDS
|
|
293
330
|
(default) Interactive TUI with AI features
|
|
@@ -316,6 +353,7 @@ MARK STATES
|
|
|
316
353
|
dismissed Hide from results
|
|
317
354
|
|
|
318
355
|
GLOBAL OPTIONS
|
|
356
|
+
--resume <path> Use a local resume file (skips registry auth)
|
|
319
357
|
--json Output raw JSON (for piping / Claude Code)
|
|
320
358
|
--base-url URL API base URL (default: https://registry.jsonresume.org)
|
|
321
359
|
--feedback "reason" Add a reason when marking (with mark command)
|
|
@@ -326,7 +364,8 @@ ENVIRONMENT
|
|
|
326
364
|
JSONRESUME_BASE_URL API base URL override
|
|
327
365
|
|
|
328
366
|
EXAMPLES
|
|
329
|
-
npx @jsonresume/jobs # TUI
|
|
367
|
+
npx @jsonresume/jobs # TUI (registry)
|
|
368
|
+
npx @jsonresume/jobs --resume ./resume.json # TUI (local)
|
|
330
369
|
npx @jsonresume/jobs search --remote --min-salary 150 # CLI search
|
|
331
370
|
npx @jsonresume/jobs detail 181420 # Job details
|
|
332
371
|
npx @jsonresume/jobs mark 181420 interested --feedback "great" # Mark job
|
|
@@ -366,7 +405,23 @@ async function main() {
|
|
|
366
405
|
case 'tui':
|
|
367
406
|
case '':
|
|
368
407
|
default: {
|
|
369
|
-
//
|
|
408
|
+
// Check for local resume first
|
|
409
|
+
const localResume = await findLocalResume();
|
|
410
|
+
if (localResume) {
|
|
411
|
+
console.error(
|
|
412
|
+
`\n Using local resume: ${localResume.basics?.name || 'Unknown'}`
|
|
413
|
+
);
|
|
414
|
+
console.error(' Marks will be saved locally.\n');
|
|
415
|
+
const { createLocalApiClient } = await import('../src/localApi.js');
|
|
416
|
+
const localApi = createLocalApiClient({
|
|
417
|
+
baseUrl: BASE_URL,
|
|
418
|
+
resume: localResume,
|
|
419
|
+
});
|
|
420
|
+
const { default: runTUI } = await import('../src/tui/App.js');
|
|
421
|
+
return runTUI({ baseUrl: BASE_URL, apiClient: localApi });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Registry mode
|
|
370
425
|
const apiKey = await getApiKey();
|
|
371
426
|
const { default: runTUI } = await import('../src/tui/App.js');
|
|
372
427
|
return runTUI({ baseUrl: BASE_URL, apiKey });
|
package/package.json
CHANGED
package/src/localApi.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getMarks, setMark } from './localState.js';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE_URL = 'https://registry.jsonresume.org';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates an API client for local mode — uses a resume JSON object
|
|
7
|
+
* instead of registry auth. Marks are stored locally.
|
|
8
|
+
*/
|
|
9
|
+
export function createLocalApiClient({ baseUrl, resume }) {
|
|
10
|
+
const base = baseUrl || DEFAULT_BASE_URL;
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
fetchJobs: async (params = {}) => {
|
|
14
|
+
const body = {
|
|
15
|
+
resume,
|
|
16
|
+
top: params.top || 50,
|
|
17
|
+
days: params.days || 30,
|
|
18
|
+
};
|
|
19
|
+
if (params.remote) body.remote = true;
|
|
20
|
+
if (params.minSalary) body.min_salary = params.minSalary;
|
|
21
|
+
if (params.search) body.search = params.search;
|
|
22
|
+
|
|
23
|
+
const res = await fetch(`${base}/api/v1/jobs`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
const data = await res.json();
|
|
29
|
+
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
30
|
+
|
|
31
|
+
// Overlay local marks onto results
|
|
32
|
+
const marks = getMarks();
|
|
33
|
+
const jobs = (data.jobs || []).map((j) => ({
|
|
34
|
+
...j,
|
|
35
|
+
state: marks[String(j.id)] || j.state || null,
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
return { jobs };
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
fetchJobDetail: async (id) => {
|
|
42
|
+
// In local mode we don't have a detail endpoint — return
|
|
43
|
+
// a stub so JobDetail falls back to the job data it already has.
|
|
44
|
+
return { id };
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
markJob: async (id, state) => {
|
|
48
|
+
setMark(id, state);
|
|
49
|
+
return { id, state };
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
fetchMe: async () => ({ resume, username: 'local' }),
|
|
53
|
+
|
|
54
|
+
// Search profiles not supported in local mode
|
|
55
|
+
listSearches: async () => ({ searches: [] }),
|
|
56
|
+
createSearch: async () => {
|
|
57
|
+
throw new Error('Search profiles require a registry account');
|
|
58
|
+
},
|
|
59
|
+
updateSearch: async () => {
|
|
60
|
+
throw new Error('Search profiles require a registry account');
|
|
61
|
+
},
|
|
62
|
+
deleteSearch: async () => {
|
|
63
|
+
throw new Error('Search profiles require a registry account');
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
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, 'local-marks.json');
|
|
7
|
+
|
|
8
|
+
function ensureDir() {
|
|
9
|
+
try {
|
|
10
|
+
mkdirSync(DIR, { recursive: true });
|
|
11
|
+
} catch {}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function load() {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(FILE, 'utf-8'));
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function save(data) {
|
|
23
|
+
ensureDir();
|
|
24
|
+
writeFileSync(FILE, JSON.stringify(data, null, 2));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Get all marks as { jobId: state } */
|
|
28
|
+
export function getMarks() {
|
|
29
|
+
return load();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Set a mark for a job */
|
|
33
|
+
export function setMark(jobId, state) {
|
|
34
|
+
const marks = load();
|
|
35
|
+
marks[String(jobId)] = state;
|
|
36
|
+
save(marks);
|
|
37
|
+
}
|
package/src/tui/App.js
CHANGED
|
@@ -26,11 +26,11 @@ import HelpModal from './HelpModal.js';
|
|
|
26
26
|
|
|
27
27
|
const TABS = ['all', 'interested', 'applied', 'maybe', 'passed'];
|
|
28
28
|
const TAB_LABELS = {
|
|
29
|
-
all: 'All
|
|
30
|
-
interested: '
|
|
31
|
-
applied: '
|
|
32
|
-
maybe: '
|
|
33
|
-
passed: '
|
|
29
|
+
all: 'All',
|
|
30
|
+
interested: 'Interested',
|
|
31
|
+
applied: 'Applied',
|
|
32
|
+
maybe: 'Maybe',
|
|
33
|
+
passed: 'Passed',
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
function InlineSearch({ query, onChange, onSubmit }) {
|
|
@@ -42,11 +42,11 @@ function InlineSearch({ query, onChange, onSubmit }) {
|
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function App({ baseUrl, apiKey }) {
|
|
45
|
+
function App({ baseUrl, apiKey, apiClient }) {
|
|
46
46
|
const { exit } = useApp();
|
|
47
47
|
const api = useMemo(
|
|
48
|
-
() => createApiClient({ baseUrl, apiKey }),
|
|
49
|
-
[baseUrl, apiKey]
|
|
48
|
+
() => apiClient || createApiClient({ baseUrl, apiKey }),
|
|
49
|
+
[baseUrl, apiKey, apiClient]
|
|
50
50
|
);
|
|
51
51
|
|
|
52
52
|
// View: 'list' | 'detail' | 'filters' | 'searches' | 'ai' | 'help'
|
|
@@ -268,7 +268,6 @@ function App({ baseUrl, apiKey }) {
|
|
|
268
268
|
reranking,
|
|
269
269
|
error,
|
|
270
270
|
aiEnabled: ai.hasKey,
|
|
271
|
-
searchName: activeSearch?.name || null,
|
|
272
271
|
toast: toastEl,
|
|
273
272
|
});
|
|
274
273
|
|
|
@@ -390,6 +389,6 @@ function App({ baseUrl, apiKey }) {
|
|
|
390
389
|
);
|
|
391
390
|
}
|
|
392
391
|
|
|
393
|
-
export default function runTUI({ baseUrl, apiKey }) {
|
|
394
|
-
render(h(App, { baseUrl, apiKey }));
|
|
392
|
+
export default function runTUI({ baseUrl, apiKey, apiClient }) {
|
|
393
|
+
render(h(App, { baseUrl, apiKey, apiClient }));
|
|
395
394
|
}
|
package/src/tui/Header.js
CHANGED
|
@@ -2,10 +2,10 @@ import { Box, Text } from 'ink';
|
|
|
2
2
|
import { h } from './h.js';
|
|
3
3
|
|
|
4
4
|
const FILTER_LABELS = {
|
|
5
|
-
remote: (
|
|
6
|
-
search: (f) => `
|
|
7
|
-
minSalary: (f) =>
|
|
8
|
-
days: (f) =>
|
|
5
|
+
remote: () => 'Remote',
|
|
6
|
+
search: (f) => `"${f.value}"`,
|
|
7
|
+
minSalary: (f) => `≥$${f.value}k`,
|
|
8
|
+
days: (f) => `${f.value}d`,
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
export default function Header({
|
|
@@ -17,71 +17,91 @@ export default function Header({
|
|
|
17
17
|
searchName,
|
|
18
18
|
appliedQuery,
|
|
19
19
|
}) {
|
|
20
|
+
const cols = process.stdout.columns || 80;
|
|
21
|
+
|
|
22
|
+
// ── Title row ─────────────────────────────────────
|
|
23
|
+
const titleRow = h(
|
|
24
|
+
Box,
|
|
25
|
+
{ paddingX: 1, justifyContent: 'space-between' },
|
|
26
|
+
h(
|
|
27
|
+
Box,
|
|
28
|
+
{ gap: 1 },
|
|
29
|
+
h(
|
|
30
|
+
Text,
|
|
31
|
+
{ bold: true, color: 'black', backgroundColor: 'cyan' },
|
|
32
|
+
' jsonresume-jobs '
|
|
33
|
+
),
|
|
34
|
+
searchName
|
|
35
|
+
? h(Text, { color: 'magenta', bold: true }, ` ${searchName}`)
|
|
36
|
+
: null
|
|
37
|
+
),
|
|
38
|
+
h(Text, { dimColor: true }, '?:help /:profiles f:filters q:quit')
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// ── Tab row ───────────────────────────────────────
|
|
20
42
|
const tabElements = tabs.map((t) => {
|
|
21
43
|
const active = t === tab;
|
|
22
44
|
const count = counts[t] || 0;
|
|
23
|
-
const label = `${tabLabels[t]}
|
|
45
|
+
const label = `${tabLabels[t]} ${count}`;
|
|
46
|
+
|
|
47
|
+
if (active) {
|
|
48
|
+
return h(
|
|
49
|
+
Box,
|
|
50
|
+
{ key: t, marginRight: 1 },
|
|
51
|
+
h(
|
|
52
|
+
Text,
|
|
53
|
+
{ bold: true, color: 'black', backgroundColor: 'white' },
|
|
54
|
+
` ${label} `
|
|
55
|
+
)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
24
58
|
return h(
|
|
25
59
|
Box,
|
|
26
60
|
{ key: t, marginRight: 1 },
|
|
61
|
+
h(Text, { dimColor: true }, ` ${label} `)
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const tabRow = h(Box, { paddingX: 1 }, ...tabElements);
|
|
66
|
+
|
|
67
|
+
// ── Filter pills (only if active) ────────────────
|
|
68
|
+
const tags = [];
|
|
69
|
+
for (const f of filters || []) {
|
|
70
|
+
const label = FILTER_LABELS[f.type]?.(f) || f.value;
|
|
71
|
+
tags.push(
|
|
27
72
|
h(
|
|
28
73
|
Text,
|
|
29
|
-
{
|
|
30
|
-
bold: active,
|
|
31
|
-
color: active ? 'cyan' : 'gray',
|
|
32
|
-
underline: active,
|
|
33
|
-
},
|
|
74
|
+
{ key: f.type, color: 'black', backgroundColor: 'yellow' },
|
|
34
75
|
` ${label} `
|
|
35
76
|
)
|
|
36
77
|
);
|
|
37
|
-
}
|
|
78
|
+
}
|
|
79
|
+
if (appliedQuery) {
|
|
80
|
+
tags.push(
|
|
81
|
+
h(
|
|
82
|
+
Text,
|
|
83
|
+
{ key: 'find', color: 'black', backgroundColor: 'green' },
|
|
84
|
+
` find:${appliedQuery} `
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
38
88
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
return h(Text, { key: i, color: 'yellow' }, ` [${label}] `);
|
|
42
|
-
});
|
|
89
|
+
const filterRow =
|
|
90
|
+
tags.length > 0 ? h(Box, { paddingX: 1, gap: 1 }, ...tags) : null;
|
|
43
91
|
|
|
44
|
-
|
|
92
|
+
// ── Divider ───────────────────────────────────────
|
|
93
|
+
const divider = h(
|
|
94
|
+
Box,
|
|
95
|
+
{ paddingX: 1 },
|
|
96
|
+
h(Text, { dimColor: true }, '─'.repeat(Math.max(10, cols - 2)))
|
|
97
|
+
);
|
|
45
98
|
|
|
46
99
|
return h(
|
|
47
100
|
Box,
|
|
48
|
-
{ flexDirection: 'column'
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
borderStyle: 'single',
|
|
54
|
-
borderColor: 'cyan',
|
|
55
|
-
borderBottom: false,
|
|
56
|
-
},
|
|
57
|
-
h(Text, { bold: true, color: 'cyan' }, '⚡ '),
|
|
58
|
-
h(Text, { bold: true, color: 'white' }, 'JSON Resume Job Search'),
|
|
59
|
-
searchName ? h(Text, { color: 'magenta' }, ` 🔍 ${searchName}`) : null,
|
|
60
|
-
h(Text, { color: 'gray' }, ' '),
|
|
61
|
-
h(Text, { dimColor: true }, 'tab:sections /:searches ?:help')
|
|
62
|
-
),
|
|
63
|
-
h(Box, { paddingX: 1, gap: 0 }, ...tabElements),
|
|
64
|
-
hasFilters
|
|
65
|
-
? h(
|
|
66
|
-
Box,
|
|
67
|
-
{ paddingX: 1 },
|
|
68
|
-
filterTags.length > 0
|
|
69
|
-
? h(Text, { dimColor: true }, 'Filters:')
|
|
70
|
-
: null,
|
|
71
|
-
...filterTags,
|
|
72
|
-
appliedQuery
|
|
73
|
-
? h(Text, { color: 'yellow' }, ` [Find: "${appliedQuery}"] `)
|
|
74
|
-
: null,
|
|
75
|
-
h(Text, { dimColor: true }, ' f:manage')
|
|
76
|
-
)
|
|
77
|
-
: h(
|
|
78
|
-
Box,
|
|
79
|
-
{ paddingX: 1 },
|
|
80
|
-
h(
|
|
81
|
-
Text,
|
|
82
|
-
{ dimColor: true },
|
|
83
|
-
'No filters active f:add n:quick search'
|
|
84
|
-
)
|
|
85
|
-
)
|
|
101
|
+
{ flexDirection: 'column' },
|
|
102
|
+
titleRow,
|
|
103
|
+
tabRow,
|
|
104
|
+
filterRow,
|
|
105
|
+
divider
|
|
86
106
|
);
|
|
87
107
|
}
|
package/src/tui/JobDetail.js
CHANGED
|
@@ -23,7 +23,8 @@ export default function JobDetail({
|
|
|
23
23
|
api
|
|
24
24
|
.fetchJobDetail(job.id)
|
|
25
25
|
.then((d) => {
|
|
26
|
-
|
|
26
|
+
// If the detail response only has an id (local mode), use the job data we already have
|
|
27
|
+
setDetail(d?.title ? d : null);
|
|
27
28
|
setLoading(false);
|
|
28
29
|
})
|
|
29
30
|
.catch(() => setLoading(false));
|
package/src/tui/StatusBar.js
CHANGED
|
@@ -3,41 +3,33 @@ import { h } from './h.js';
|
|
|
3
3
|
|
|
4
4
|
const KEYS = {
|
|
5
5
|
list: [
|
|
6
|
-
['
|
|
7
|
-
['enter', '
|
|
8
|
-
['i', '
|
|
9
|
-
['x', '
|
|
10
|
-
['m', '
|
|
11
|
-
['p', '
|
|
6
|
+
['j/k', 'nav'],
|
|
7
|
+
['enter', 'detail'],
|
|
8
|
+
['i', 'interested'],
|
|
9
|
+
['x', 'applied'],
|
|
10
|
+
['m', 'maybe'],
|
|
11
|
+
['p', 'pass'],
|
|
12
12
|
['v', 'select'],
|
|
13
|
+
['n', 'find'],
|
|
13
14
|
['space', 'AI'],
|
|
14
|
-
['f', 'filter'],
|
|
15
|
-
['/', 'search'],
|
|
16
|
-
['e', 'export'],
|
|
17
|
-
['?', 'help'],
|
|
18
|
-
['q', 'quit'],
|
|
19
15
|
],
|
|
20
16
|
detail: [
|
|
21
|
-
['
|
|
22
|
-
['
|
|
23
|
-
['i', '
|
|
24
|
-
['
|
|
25
|
-
['m', '?'],
|
|
26
|
-
['p', '✗'],
|
|
27
|
-
['o', 'open'],
|
|
17
|
+
['j/k', 'nav jobs'],
|
|
18
|
+
['J/K', 'scroll'],
|
|
19
|
+
['i/x/m/p', 'mark'],
|
|
20
|
+
['o', 'open URL'],
|
|
28
21
|
['space', 'AI'],
|
|
29
22
|
['esc', 'back'],
|
|
30
|
-
['q', 'close'],
|
|
31
23
|
],
|
|
32
24
|
filters: [
|
|
33
|
-
['
|
|
25
|
+
['j/k', 'nav'],
|
|
34
26
|
['enter', 'edit'],
|
|
35
27
|
['a', 'add'],
|
|
36
28
|
['d', 'delete'],
|
|
37
29
|
['esc', 'close'],
|
|
38
30
|
],
|
|
39
31
|
searches: [
|
|
40
|
-
['
|
|
32
|
+
['j/k', 'nav'],
|
|
41
33
|
['enter', 'switch'],
|
|
42
34
|
['n', 'new'],
|
|
43
35
|
['d', 'delete'],
|
|
@@ -47,6 +39,15 @@ const KEYS = {
|
|
|
47
39
|
help: [['?/esc', 'close']],
|
|
48
40
|
};
|
|
49
41
|
|
|
42
|
+
function KeyHint({ k, label }) {
|
|
43
|
+
return h(
|
|
44
|
+
Box,
|
|
45
|
+
{ marginRight: 1 },
|
|
46
|
+
h(Text, { color: 'cyan' }, k),
|
|
47
|
+
h(Text, { dimColor: true }, ` ${label}`)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
50
51
|
export default function StatusBar({
|
|
51
52
|
view,
|
|
52
53
|
jobCount,
|
|
@@ -55,54 +56,44 @@ export default function StatusBar({
|
|
|
55
56
|
reranking,
|
|
56
57
|
error,
|
|
57
58
|
aiEnabled,
|
|
58
|
-
searchName,
|
|
59
59
|
toast,
|
|
60
60
|
}) {
|
|
61
|
+
const cols = process.stdout.columns || 80;
|
|
61
62
|
const keys = KEYS[view] || KEYS.list;
|
|
62
63
|
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
h(Text, { bold: true, color: 'cyan' }, key),
|
|
68
|
-
h(Text, { dimColor: true }, `:${label}`)
|
|
69
|
-
)
|
|
64
|
+
const divider = h(
|
|
65
|
+
Box,
|
|
66
|
+
{ paddingX: 1 },
|
|
67
|
+
h(Text, { dimColor: true }, '─'.repeat(Math.max(10, cols - 2)))
|
|
70
68
|
);
|
|
71
69
|
|
|
72
|
-
|
|
70
|
+
const rightInfo = h(
|
|
73
71
|
Box,
|
|
74
|
-
{
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
Box,
|
|
88
|
-
{ gap: 1 },
|
|
89
|
-
h(Text, { dimColor: true }, `${jobCount}/${totalCount} jobs`)
|
|
90
|
-
)
|
|
91
|
-
)
|
|
92
|
-
: h(
|
|
72
|
+
{ gap: 1 },
|
|
73
|
+
loading ? h(Text, { color: 'yellow' }, 'loading…') : null,
|
|
74
|
+
reranking ? h(Text, { color: 'magenta' }, 'reranking…') : null,
|
|
75
|
+
h(Text, { dimColor: true }, `${jobCount}/${totalCount}`),
|
|
76
|
+
aiEnabled ? null : h(Text, { dimColor: true }, 'no-AI')
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const content = toast
|
|
80
|
+
? h(Box, { paddingX: 1, justifyContent: 'space-between' }, toast, rightInfo)
|
|
81
|
+
: h(
|
|
82
|
+
Box,
|
|
83
|
+
{ paddingX: 1, justifyContent: 'space-between' },
|
|
84
|
+
h(
|
|
93
85
|
Box,
|
|
94
|
-
{
|
|
95
|
-
h(
|
|
96
|
-
h(
|
|
97
|
-
Box,
|
|
98
|
-
{ gap: 1 },
|
|
99
|
-
loading ? h(Text, { color: 'yellow' }, '⏳') : null,
|
|
100
|
-
reranking ? h(Text, { color: 'magenta' }, '🧠 reranking…') : null,
|
|
101
|
-
h(Text, { dimColor: true }, `${jobCount}/${totalCount} jobs`),
|
|
102
|
-
searchName ? h(Text, { color: 'magenta' }, '🔍') : null,
|
|
103
|
-
aiEnabled ? null : h(Text, { color: 'gray' }, '(no AI)')
|
|
104
|
-
)
|
|
86
|
+
{ flexWrap: 'wrap' },
|
|
87
|
+
...keys.map(([k, label], i) => h(KeyHint, { key: i, k, label }))
|
|
105
88
|
),
|
|
106
|
-
|
|
89
|
+
rightInfo
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return h(
|
|
93
|
+
Box,
|
|
94
|
+
{ flexDirection: 'column' },
|
|
95
|
+
divider,
|
|
96
|
+
content,
|
|
97
|
+
error ? h(Box, { paddingX: 1 }, h(Text, { color: 'red' }, error)) : null
|
|
107
98
|
);
|
|
108
99
|
}
|