hn-bits 0.3.0 → 0.5.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 +15 -4
- package/dist/index.js +61 -6
- package/dist/lib/config.js +1 -0
- package/dist/lib/configKeys.js +9 -0
- package/dist/lib/schedule.js +53 -0
- package/dist/ui/App.js +17 -2
- package/dist/ui/AskAI.js +2 -1
- package/dist/ui/Comments.js +10 -7
- package/dist/ui/HelpOverlay.js +2 -1
- package/dist/ui/Layout.js +2 -1
- package/dist/ui/LoadingIndicator.js +2 -1
- package/dist/ui/SearchResults.js +2 -1
- package/dist/ui/StoryList.js +2 -1
- package/dist/ui/StoryRow.js +2 -1
- package/dist/ui/SubscriptionForm.js +29 -3
- package/dist/ui/SubscriptionMatches.js +2 -1
- package/dist/ui/SubscriptionsView.js +30 -4
- package/dist/ui/SummaryPanel.js +2 -1
- package/dist/ui/TabBar.js +4 -3
- package/dist/ui/ThemePicker.js +28 -0
- package/dist/ui/keymap.js +7 -0
- package/dist/ui/theme.js +37 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ A fullscreen terminal client for Hacker News, built with TypeScript + Ink. Optio
|
|
|
19
19
|
- Fullscreen TUI: top/new/best/ask/show/saved/subs tabs, vim-style navigation
|
|
20
20
|
- Threaded comments view with fold/collapse
|
|
21
21
|
- Search across stories
|
|
22
|
-
-
|
|
22
|
+
- 7 built-in themes (`hn`, `mocha`, `dracula`, `tokyo`, `nord`, `gruvbox`, `concord`)
|
|
23
23
|
- Local AI article/thread summaries via Ollama, no cloud calls
|
|
24
24
|
- Ask AI: multi-turn Q&A grounded in the current story
|
|
25
25
|
- Subscriptions + `hn watch --once` radar with Telegram notifications
|
|
@@ -53,7 +53,7 @@ hn # fullscreen shell: top/new/best/ask/show/saved/subs tabs
|
|
|
53
53
|
hn search <query...> # search results
|
|
54
54
|
hn bookmarks # opens straight into the saved tab
|
|
55
55
|
hn subs # opens straight into the subs tab
|
|
56
|
-
hn --theme dracula # themes: hn (default), mocha, dracula, tokyo, nord, gruvbox
|
|
56
|
+
hn --theme dracula # themes: hn (default), mocha, dracula, tokyo, nord, gruvbox, concord
|
|
57
57
|
```
|
|
58
58
|
|
|
59
59
|
Press `?` from any view for the full keybinding help overlay.
|
|
@@ -107,7 +107,15 @@ All three keys are required, `telegram.enabled` alone is not enough. With nothin
|
|
|
107
107
|
hn watch --once # one pass: query subscriptions, notify new matches, exit
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
|
|
110
|
+
The first `hn sub add` offers to install a cron job for you (every 30 minutes). Manage it anytime via the CLI, or `c` on the Subs tab (which also shows a live installed/not-installed status line):
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
hn schedule status # installed / not installed
|
|
114
|
+
hn schedule install # install now (no-op if already installed)
|
|
115
|
+
hn schedule remove # uninstall
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Or set it up by hand:
|
|
111
119
|
|
|
112
120
|
```
|
|
113
121
|
*/30 * * * * cd /path/to/hn-bits && hn watch --once
|
|
@@ -128,7 +136,7 @@ Sensitive values (e.g. `telegram.botToken`) print masked in `list` but raw from
|
|
|
128
136
|
|
|
129
137
|
### Themes
|
|
130
138
|
|
|
131
|
-
`hn`, `mocha`, `dracula`, `tokyo`, `nord`, `gruvbox` (default: `hn`, HN-orange). Pick with `--theme <name
|
|
139
|
+
`hn`, `mocha`, `dracula`, `tokyo`, `nord`, `gruvbox`, `concord` (default: `hn`, HN-orange). Pick with `--theme <name>`, the `HN_THEME` env var, `hn config set ui.theme <name>`, or press `T` in the TUI to switch live and persist the choice. Precedence: flag > env > config > default. `hn theme` shows the active theme and where it came from.
|
|
132
140
|
|
|
133
141
|
### Keybindings
|
|
134
142
|
|
|
@@ -188,6 +196,9 @@ Same as story list, minus search; `B` removes a bookmark instead of adding one.
|
|
|
188
196
|
| `a` | add subscription |
|
|
189
197
|
| `e` | edit subscription |
|
|
190
198
|
| `d` | delete subscription |
|
|
199
|
+
| `c` | toggle watch cron schedule (install/remove) |
|
|
200
|
+
|
|
201
|
+
Shows a `schedule: installed (every 30 min)` / `schedule: not installed` status line, so you don't need to drop to `hn schedule status` to check.
|
|
191
202
|
|
|
192
203
|
**Sub matches** (opened from Subs via `enter`)
|
|
193
204
|
|
package/dist/index.js
CHANGED
|
@@ -46,11 +46,16 @@ program
|
|
|
46
46
|
.command('theme')
|
|
47
47
|
.description('Show the active color theme and available palettes')
|
|
48
48
|
.action(async () => {
|
|
49
|
-
const { paletteNames, resolvePaletteName } = await import('./ui/theme.js');
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
const { paletteNames, resolvePaletteName, resolvePaletteSource } = await import('./ui/theme.js');
|
|
50
|
+
const flag = program.opts().theme;
|
|
51
|
+
const activeName = resolvePaletteName(flag);
|
|
52
|
+
const source = resolvePaletteSource(flag);
|
|
53
|
+
const sourceLabel = source === 'default' ? '(default)' : `(from ${source})`;
|
|
54
|
+
console.log(`Active theme: ${activeName} ${sourceLabel}`);
|
|
55
|
+
console.log(`Available: ${paletteNames()
|
|
56
|
+
.map((n) => (n === 'hn' ? `${n} (default)` : n))
|
|
57
|
+
.join(', ')}`);
|
|
58
|
+
console.log('Set with `hn --theme <name>`, the HN_THEME environment variable, or `hn config set ui.theme <name>`.');
|
|
54
59
|
});
|
|
55
60
|
const config = program.command('config').description('Get or set hn-bits config values');
|
|
56
61
|
config
|
|
@@ -107,6 +112,24 @@ config
|
|
|
107
112
|
}
|
|
108
113
|
});
|
|
109
114
|
const sub = program.command('sub').description('Manage topic subscriptions');
|
|
115
|
+
async function promptInstallSchedule() {
|
|
116
|
+
const readline = await import('node:readline/promises');
|
|
117
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
118
|
+
const answer = await rl.question('No watch schedule installed yet. Install one now (every 30 min)? [y/N] ');
|
|
119
|
+
rl.close();
|
|
120
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
121
|
+
console.log("skipped. Run 'hn schedule install' anytime, or add the cron line yourself (see README).");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const { installScheduledJob } = await import('./lib/schedule.js');
|
|
125
|
+
try {
|
|
126
|
+
const line = installScheduledJob();
|
|
127
|
+
console.log(`installed: ${line}`);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
console.log("couldn't find 'hn' on PATH — add the cron line yourself (see README) or run 'hn schedule install' later.");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
110
133
|
sub
|
|
111
134
|
.command('add <name> <query...>')
|
|
112
135
|
.description('Add a subscription')
|
|
@@ -117,7 +140,7 @@ sub
|
|
|
117
140
|
console.error(`--min-points must be a non-negative integer, got '${options.minPoints}'`);
|
|
118
141
|
process.exit(1);
|
|
119
142
|
}
|
|
120
|
-
const { addSubscription } = await import('./db/subscriptions.js');
|
|
143
|
+
const { addSubscription, listSubscriptions } = await import('./db/subscriptions.js');
|
|
121
144
|
try {
|
|
122
145
|
addSubscription(name, queryParts.join(' '), minPoints);
|
|
123
146
|
console.log(`added '${name}'`);
|
|
@@ -126,6 +149,10 @@ sub
|
|
|
126
149
|
console.error(err.message);
|
|
127
150
|
process.exit(1);
|
|
128
151
|
}
|
|
152
|
+
const { hasScheduledJob } = await import('./lib/schedule.js');
|
|
153
|
+
if (listSubscriptions().length === 1 && !hasScheduledJob()) {
|
|
154
|
+
await promptInstallSchedule();
|
|
155
|
+
}
|
|
129
156
|
});
|
|
130
157
|
sub
|
|
131
158
|
.command('list')
|
|
@@ -156,6 +183,34 @@ sub
|
|
|
156
183
|
}
|
|
157
184
|
console.log(`removed '${name}'`);
|
|
158
185
|
});
|
|
186
|
+
const schedule = program.command('schedule').description('Manage the cron job that runs `hn watch --once`');
|
|
187
|
+
schedule
|
|
188
|
+
.command('status')
|
|
189
|
+
.description('Show whether the watch cron job is installed')
|
|
190
|
+
.action(async () => {
|
|
191
|
+
const { hasScheduledJob } = await import('./lib/schedule.js');
|
|
192
|
+
console.log(hasScheduledJob() ? 'installed' : 'not installed');
|
|
193
|
+
});
|
|
194
|
+
schedule
|
|
195
|
+
.command('install')
|
|
196
|
+
.description('Install the watch cron job (no-op if already installed)')
|
|
197
|
+
.action(async () => {
|
|
198
|
+
const { installScheduledJob } = await import('./lib/schedule.js');
|
|
199
|
+
try {
|
|
200
|
+
console.log(`installed: ${installScheduledJob()}`);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
console.error("couldn't find 'hn' on PATH; add the cron line yourself (see README)");
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
schedule
|
|
208
|
+
.command('remove')
|
|
209
|
+
.description('Remove the watch cron job')
|
|
210
|
+
.action(async () => {
|
|
211
|
+
const { removeScheduledJob } = await import('./lib/schedule.js');
|
|
212
|
+
console.log(removeScheduledJob() ? 'removed' : 'not installed');
|
|
213
|
+
});
|
|
159
214
|
program
|
|
160
215
|
.command('watch')
|
|
161
216
|
.description('Check subscriptions for new matches and notify (one-shot; cron owns scheduling)')
|
package/dist/lib/config.js
CHANGED
package/dist/lib/configKeys.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { paletteNames } from '../ui/theme.js';
|
|
2
|
+
function validateTheme(raw) {
|
|
3
|
+
const names = paletteNames();
|
|
4
|
+
if (!names.includes(raw)) {
|
|
5
|
+
throw new Error(`unknown theme '${raw}' (valid: ${names.join(', ')})`);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
1
8
|
export const CONFIG_KEYS = {
|
|
2
9
|
'ollama.host': { path: ['ollama', 'host'], type: 'string' },
|
|
3
10
|
'ollama.model': { path: ['ollama', 'model'], type: 'string' },
|
|
@@ -6,9 +13,11 @@ export const CONFIG_KEYS = {
|
|
|
6
13
|
'telegram.chatId': { path: ['telegram', 'chatId'], type: 'string' },
|
|
7
14
|
'desktopNotifications.enabled': { path: ['desktopNotifications', 'enabled'], type: 'boolean' },
|
|
8
15
|
'desktopNotifications.timeoutSeconds': { path: ['desktopNotifications', 'timeoutSeconds'], type: 'number' },
|
|
16
|
+
'ui.theme': { path: ['ui', 'theme'], type: 'string', validate: validateTheme },
|
|
9
17
|
};
|
|
10
18
|
/** Coerces a CLI string argument to the key's declared type. Throws on invalid input. */
|
|
11
19
|
export function parseValue(def, raw) {
|
|
20
|
+
def.validate?.(raw);
|
|
12
21
|
if (def.type === 'boolean') {
|
|
13
22
|
if (raw !== 'true' && raw !== 'false')
|
|
14
23
|
throw new Error(`expected 'true' or 'false', got '${raw}'`);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const MARKER = '# hn-bits watch';
|
|
5
|
+
function watchLogPath() {
|
|
6
|
+
return join(homedir(), '.local', 'share', 'hn-bits', 'watch.log');
|
|
7
|
+
}
|
|
8
|
+
function readCrontab() {
|
|
9
|
+
try {
|
|
10
|
+
return execSync('crontab -l', { encoding: 'utf-8' });
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function writeCrontab(content) {
|
|
17
|
+
execSync('crontab -', { input: content, encoding: 'utf-8' });
|
|
18
|
+
}
|
|
19
|
+
function resolveHnPath() {
|
|
20
|
+
return execSync('which hn', { encoding: 'utf-8' }).trim();
|
|
21
|
+
}
|
|
22
|
+
function resolveNodePath() {
|
|
23
|
+
return execSync('which node', { encoding: 'utf-8' }).trim();
|
|
24
|
+
}
|
|
25
|
+
/** Invokes node directly rather than relying on hn's `#!/usr/bin/env node` shebang, since cron's
|
|
26
|
+
* minimal PATH (unlike an interactive shell) usually can't resolve a version-manager-installed node. */
|
|
27
|
+
function buildCronLine(nodePath, hnPath) {
|
|
28
|
+
return `*/30 * * * * ${nodePath} ${hnPath} watch --once >> ${watchLogPath()} 2>&1 ${MARKER}`;
|
|
29
|
+
}
|
|
30
|
+
export function hasScheduledJob() {
|
|
31
|
+
return readCrontab()
|
|
32
|
+
.split('\n')
|
|
33
|
+
.some((line) => line.includes(MARKER));
|
|
34
|
+
}
|
|
35
|
+
/** Idempotent: no-ops if a job is already installed. Throws if `hn` isn't on PATH. */
|
|
36
|
+
export function installScheduledJob() {
|
|
37
|
+
const current = readCrontab();
|
|
38
|
+
const existing = current.split('\n').find((line) => line.includes(MARKER));
|
|
39
|
+
if (existing)
|
|
40
|
+
return existing;
|
|
41
|
+
const line = buildCronLine(resolveNodePath(), resolveHnPath());
|
|
42
|
+
const body = current.trim().length > 0 ? `${current.replace(/\n+$/, '')}\n${line}\n` : `${line}\n`;
|
|
43
|
+
writeCrontab(body);
|
|
44
|
+
return line;
|
|
45
|
+
}
|
|
46
|
+
export function removeScheduledJob() {
|
|
47
|
+
const lines = readCrontab().split('\n');
|
|
48
|
+
const filtered = lines.filter((line) => !line.includes(MARKER));
|
|
49
|
+
if (filtered.length === lines.length)
|
|
50
|
+
return false;
|
|
51
|
+
writeCrontab(filtered.join('\n'));
|
|
52
|
+
return true;
|
|
53
|
+
}
|
package/dist/ui/App.js
CHANGED
|
@@ -2,11 +2,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { useApp, useInput } from 'ink';
|
|
4
4
|
import { loadConfig } from '../lib/config.js';
|
|
5
|
+
import { setConfigValue } from '../lib/configStore.js';
|
|
5
6
|
import { nextTab, previousTab } from '../lib/listNavigation.js';
|
|
6
7
|
import { AskAI } from './AskAI.js';
|
|
7
8
|
import { Comments } from './Comments.js';
|
|
8
9
|
import { HelpOverlay } from './HelpOverlay.js';
|
|
9
|
-
import { COMMENTS_KEYS, LIST_KEYS, SAVED_KEYS, SEARCH_RESULTS_KEYS, SUB_MATCHES_KEYS, SUBS_KEYS, footerHint, } from './keymap.js';
|
|
10
|
+
import { COMMENTS_KEYS, LIST_KEYS, SAVED_KEYS, SEARCH_RESULTS_KEYS, SUB_MATCHES_KEYS, SUBS_KEYS, THEME_PICKER_KEYS, footerHint, } from './keymap.js';
|
|
10
11
|
import { Body, Footer, Header, Screen } from './Layout.js';
|
|
11
12
|
import { SavedList } from './SavedList.js';
|
|
12
13
|
import { SearchInput } from './SearchInput.js';
|
|
@@ -16,6 +17,8 @@ import { SubscriptionForm } from './SubscriptionForm.js';
|
|
|
16
17
|
import { SubscriptionMatches } from './SubscriptionMatches.js';
|
|
17
18
|
import { SubscriptionsView } from './SubscriptionsView.js';
|
|
18
19
|
import { TabBar } from './TabBar.js';
|
|
20
|
+
import { ThemePicker } from './ThemePicker.js';
|
|
21
|
+
import { resolvePaletteName, resolveTheme, ThemeContext } from './theme.js';
|
|
19
22
|
export function App({ initialQuery, initialView }) {
|
|
20
23
|
const [feed, setFeed] = useState('top');
|
|
21
24
|
const [view, setView] = useState(initialQuery
|
|
@@ -27,8 +30,13 @@ export function App({ initialQuery, initialView }) {
|
|
|
27
30
|
: { name: 'list' });
|
|
28
31
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
29
32
|
const [config] = useState(loadConfig);
|
|
33
|
+
const [paletteName, setPaletteName] = useState(() => resolvePaletteName());
|
|
34
|
+
const [themePickerOpen, setThemePickerOpen] = useState(false);
|
|
35
|
+
const activeTheme = resolveTheme(paletteName);
|
|
30
36
|
const { exit } = useApp();
|
|
31
37
|
useInput((input) => {
|
|
38
|
+
if (themePickerOpen)
|
|
39
|
+
return;
|
|
32
40
|
if (helpOpen)
|
|
33
41
|
return setHelpOpen(false);
|
|
34
42
|
if (view.name === 'search-input' || view.name === 'ask' || view.name === 'sub-form')
|
|
@@ -37,7 +45,14 @@ export function App({ initialQuery, initialView }) {
|
|
|
37
45
|
return exit();
|
|
38
46
|
if (input === '?')
|
|
39
47
|
return setHelpOpen(true);
|
|
48
|
+
if (input === 'T')
|
|
49
|
+
return setThemePickerOpen(true);
|
|
40
50
|
});
|
|
51
|
+
function handleThemeSelected(name) {
|
|
52
|
+
setConfigValue('ui.theme', name);
|
|
53
|
+
setPaletteName(name);
|
|
54
|
+
setThemePickerOpen(false);
|
|
55
|
+
}
|
|
41
56
|
function changeFeed(target) {
|
|
42
57
|
setFeed(target);
|
|
43
58
|
setView({ name: 'list' });
|
|
@@ -52,7 +67,7 @@ export function App({ initialQuery, initialView }) {
|
|
|
52
67
|
}
|
|
53
68
|
const activeTab = tabForView(view) ?? feed;
|
|
54
69
|
const ctx = { feed, config, setFeed: changeFeed, setTab: changeTab, setView, exit };
|
|
55
|
-
return (_jsxs(Screen, { children: [_jsx(Header, { children: _jsx(TabBar, { active: activeTab }) }), _jsx(Body, { children: helpOpen ? _jsx(HelpOverlay, { ...helpFor(view) }) : renderBody(view, ctx) }), _jsx(Footer, { children: renderFooter(view, ctx) })] }));
|
|
70
|
+
return (_jsx(ThemeContext.Provider, { value: activeTheme, children: _jsxs(Screen, { children: [_jsx(Header, { children: _jsx(TabBar, { active: activeTab }) }), _jsx(Body, { children: themePickerOpen ? (_jsx(ThemePicker, { current: paletteName, onSelect: handleThemeSelected, onCancel: () => setThemePickerOpen(false) })) : helpOpen ? (_jsx(HelpOverlay, { ...helpFor(view) })) : (renderBody(view, ctx)) }), _jsx(Footer, { children: themePickerOpen ? footerHint(THEME_PICKER_KEYS) : renderFooter(view, ctx) })] }) }));
|
|
56
71
|
}
|
|
57
72
|
/** Traces returnTo chains back to the tab a nested view (comments/ask/sub-form) was opened from. */
|
|
58
73
|
function tabForView(view) {
|
package/dist/ui/AskAI.js
CHANGED
|
@@ -10,7 +10,7 @@ import { truncateTitle } from '../lib/format.js';
|
|
|
10
10
|
import { htmlToText } from '../lib/html.js';
|
|
11
11
|
import { wrapPlainText } from '../lib/viewport.js';
|
|
12
12
|
import { HEADER_ROWS } from './Layout.js';
|
|
13
|
-
import {
|
|
13
|
+
import { useTheme } from './theme.js';
|
|
14
14
|
async function resolveArticle(story) {
|
|
15
15
|
if (story.url) {
|
|
16
16
|
try {
|
|
@@ -25,6 +25,7 @@ async function resolveArticle(story) {
|
|
|
25
25
|
return { article: null, reason: 'text post' };
|
|
26
26
|
}
|
|
27
27
|
export function AskAI({ story, comments, config, onBack }) {
|
|
28
|
+
const theme = useTheme();
|
|
28
29
|
const { columns, rows } = useWindowSize();
|
|
29
30
|
const [status, setStatus] = useState(config ? 'preparing' : 'hint');
|
|
30
31
|
const [unavailableDetail, setUnavailableDetail] = useState('');
|
package/dist/ui/Comments.js
CHANGED
|
@@ -16,7 +16,7 @@ import { COMMENTS_KEYS, footerRows } from './keymap.js';
|
|
|
16
16
|
import { HEADER_ROWS } from './Layout.js';
|
|
17
17
|
import { LoadingIndicator } from './LoadingIndicator.js';
|
|
18
18
|
import { SummaryPanel } from './SummaryPanel.js';
|
|
19
|
-
import {
|
|
19
|
+
import { useTheme } from './theme.js';
|
|
20
20
|
import { useFlash } from './useFlash.js';
|
|
21
21
|
const HEADER_BORDER_LINES = 2;
|
|
22
22
|
// borderStyle (2 cols) + paddingX (2 cols) eaten from the card's interior width.
|
|
@@ -32,6 +32,7 @@ export function commentsHeaderLines(story, columns) {
|
|
|
32
32
|
return HEADER_BORDER_LINES + titleLines + 1 + urlLines;
|
|
33
33
|
}
|
|
34
34
|
export function Comments({ story, config, onBack, onAskAI }) {
|
|
35
|
+
const theme = useTheme();
|
|
35
36
|
const { columns, rows } = useWindowSize();
|
|
36
37
|
const headerLines = commentsHeaderLines(story, columns);
|
|
37
38
|
const viewportLines = Math.max(1, rows - HEADER_ROWS - footerRows(COMMENTS_KEYS, columns) - headerLines);
|
|
@@ -67,7 +68,7 @@ export function Comments({ story, config, onBack, onAskAI }) {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
const flat = useMemo(() => flattenTree(comments, folded, headerOnly), [comments, folded, headerOnly]);
|
|
70
|
-
const rowsData = useMemo(() => flat.map((entry) => buildRow(entry, columns)), [flat, columns]);
|
|
71
|
+
const rowsData = useMemo(() => flat.map((entry) => buildRow(entry, columns, theme)), [flat, columns, theme]);
|
|
71
72
|
const clampedSelected = clampSelection(selected, 0, flat.length);
|
|
72
73
|
function openStory() {
|
|
73
74
|
void open(story.url ?? hnItemUrl(story.id));
|
|
@@ -145,6 +146,7 @@ export function Comments({ story, config, onBack, onAskAI }) {
|
|
|
145
146
|
}), summaryOpen && (_jsx(SummaryPanel, { config: config, buildPrompt: () => Promise.resolve(buildCommentsSummaryPrompt(story, comments)), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
146
147
|
}
|
|
147
148
|
function CommentsHeader({ story }) {
|
|
149
|
+
const theme = useTheme();
|
|
148
150
|
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.colors.accent, paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.colors.title, children: story.title }), _jsxs(Text, { dimColor: true, children: [theme.glyphs.upvote, " ", story.score, " points by ", story.by, " ", formatAge(story.time), " | ", story.descendants, ' ', "comments"] }), story.url && _jsx(Text, { color: theme.colors.accent, children: story.url })] }));
|
|
149
151
|
}
|
|
150
152
|
function replyBadge(entry) {
|
|
@@ -154,7 +156,7 @@ function replyBadge(entry) {
|
|
|
154
156
|
return `[${entry.descendantCount} more]`;
|
|
155
157
|
return entry.descendantCount === 1 ? '1 reply' : `${entry.descendantCount} replies`;
|
|
156
158
|
}
|
|
157
|
-
function buildHeaderSpans(entry) {
|
|
159
|
+
function buildHeaderSpans(entry, theme) {
|
|
158
160
|
const glyph = entry.headerOnly ? theme.glyphs.foldClosed : theme.glyphs.foldOpen;
|
|
159
161
|
const badge = replyBadge(entry);
|
|
160
162
|
const spans = [
|
|
@@ -166,24 +168,24 @@ function buildHeaderSpans(entry) {
|
|
|
166
168
|
spans.push({ text: ` | ${badge}`, color: theme.colors.muted });
|
|
167
169
|
return spans;
|
|
168
170
|
}
|
|
169
|
-
function tokenToSpan(token) {
|
|
171
|
+
function tokenToSpan(token, theme) {
|
|
170
172
|
if (token.kind === 'link')
|
|
171
173
|
return { text: token.text, color: theme.colors.link, underline: true };
|
|
172
174
|
if (token.kind === 'email')
|
|
173
175
|
return { text: token.text, color: theme.colors.email, bold: true };
|
|
174
176
|
return { text: token.text };
|
|
175
177
|
}
|
|
176
|
-
function buildRow(entry, columns) {
|
|
178
|
+
function buildRow(entry, columns, theme) {
|
|
177
179
|
// Reserve 2 columns for the selection bar so wrapping doesn't shift when selection moves,
|
|
178
180
|
// plus 1 trailing column: a line that exactly fills the terminal width triggers a VT100
|
|
179
181
|
// delayed-wrap that drops the selection bar/stripe from the following line.
|
|
180
182
|
const width = Math.max(1, columns - 1 - entry.depth * 2 - ROW_BORDER_WIDTH);
|
|
181
|
-
const header = { kind: 'header', spans: buildHeaderSpans(entry) };
|
|
183
|
+
const header = { kind: 'header', spans: buildHeaderSpans(entry, theme) };
|
|
182
184
|
const body = entry.headerOnly
|
|
183
185
|
? []
|
|
184
186
|
: wrapPlainText(htmlToText(entry.node.text), width).map((line) => ({
|
|
185
187
|
kind: 'body',
|
|
186
|
-
spans: tokenizeContacts(line).map(tokenToSpan),
|
|
188
|
+
spans: tokenizeContacts(line).map((token) => tokenToSpan(token, theme)),
|
|
187
189
|
}));
|
|
188
190
|
return { id: entry.node.id, depth: entry.depth, width, lines: [header, ...body] };
|
|
189
191
|
}
|
|
@@ -191,6 +193,7 @@ function spanLength(spans) {
|
|
|
191
193
|
return spans.reduce((sum, span) => sum + span.text.length, 0);
|
|
192
194
|
}
|
|
193
195
|
function CommentRowView({ depth, width, lines, isSelected }) {
|
|
196
|
+
const theme = useTheme();
|
|
194
197
|
const background = isSelected ? theme.colors.selectionBackground : undefined;
|
|
195
198
|
const prefix = isSelected ? `${SELECTION_BAR} ` : ' ';
|
|
196
199
|
const rowWidth = width + ROW_BORDER_WIDTH;
|
package/dist/ui/HelpOverlay.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { GLOBAL_KEYS } from './keymap.js';
|
|
4
|
-
import {
|
|
4
|
+
import { useTheme } from './theme.js';
|
|
5
5
|
export function HelpOverlay({ title, keys }) {
|
|
6
|
+
const theme = useTheme();
|
|
6
7
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: title }), keys.map((binding) => (_jsx(KeyRow, { binding: binding }, binding.key))), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.title, children: "global" }), GLOBAL_KEYS.map((binding) => (_jsx(KeyRow, { binding: binding }, binding.key))), _jsx(Text, { dimColor: true, children: "any key closes" })] }));
|
|
7
8
|
}
|
|
8
9
|
function KeyRow({ binding }) {
|
package/dist/ui/Layout.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useWindowSize } from 'ink';
|
|
3
|
-
import {
|
|
3
|
+
import { useTheme } from './theme.js';
|
|
4
4
|
const MIN_ROWS = 8;
|
|
5
5
|
export const HEADER_ROWS = 3;
|
|
6
6
|
export function Screen({ children }) {
|
|
@@ -17,5 +17,6 @@ export function Body({ children }) {
|
|
|
17
17
|
return (_jsx(Box, { flexGrow: 1, overflowY: "hidden", flexDirection: "column", children: children }));
|
|
18
18
|
}
|
|
19
19
|
export function Footer({ children }) {
|
|
20
|
+
const theme = useTheme();
|
|
20
21
|
return (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: theme.colors.muted, children: children }) }));
|
|
21
22
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { Text } from 'ink';
|
|
4
|
-
import {
|
|
4
|
+
import { useTheme } from './theme.js';
|
|
5
5
|
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
6
6
|
const FRAME_INTERVAL_MS = 80;
|
|
7
7
|
export function LoadingIndicator({ label }) {
|
|
8
|
+
const theme = useTheme();
|
|
8
9
|
const [frame, setFrame] = useState(0);
|
|
9
10
|
useEffect(() => {
|
|
10
11
|
const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), FRAME_INTERVAL_MS);
|
package/dist/ui/SearchResults.js
CHANGED
|
@@ -14,11 +14,12 @@ import { LoadingIndicator } from './LoadingIndicator.js';
|
|
|
14
14
|
import { STORY_ROW_HEIGHT } from './StoryRow.js';
|
|
15
15
|
import { StoryListView } from './StoryListView.js';
|
|
16
16
|
import { SummaryPanel } from './SummaryPanel.js';
|
|
17
|
-
import {
|
|
17
|
+
import { useTheme } from './theme.js';
|
|
18
18
|
import { useFlash } from './useFlash.js';
|
|
19
19
|
const FETCH_THRESHOLD = 10;
|
|
20
20
|
const HEADER_LINES = 1;
|
|
21
21
|
export function SearchResults({ query, config, onSelectStory, onExit, onSearchAgain, onAskAI, onSubscribe, }) {
|
|
22
|
+
const theme = useTheme();
|
|
22
23
|
const { columns, rows } = useWindowSize();
|
|
23
24
|
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SEARCH_RESULTS_KEYS, columns) - HEADER_LINES);
|
|
24
25
|
const [stories, setStories] = useState([]);
|
package/dist/ui/StoryList.js
CHANGED
|
@@ -13,11 +13,12 @@ import { LoadingIndicator } from './LoadingIndicator.js';
|
|
|
13
13
|
import { STORY_ROW_HEIGHT } from './StoryRow.js';
|
|
14
14
|
import { StoryListView } from './StoryListView.js';
|
|
15
15
|
import { SummaryPanel } from './SummaryPanel.js';
|
|
16
|
-
import {
|
|
16
|
+
import { useTheme } from './theme.js';
|
|
17
17
|
import { useFlash } from './useFlash.js';
|
|
18
18
|
const BATCH_SIZE = 30;
|
|
19
19
|
const FETCH_THRESHOLD = 10;
|
|
20
20
|
export function StoryList({ feed, config, onFeedChange, onTabChange, onSelectStory, onSearchRequested, onAskAI, }) {
|
|
21
|
+
const theme = useTheme();
|
|
21
22
|
const { columns, rows } = useWindowSize();
|
|
22
23
|
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(LIST_KEYS, columns));
|
|
23
24
|
const [storyIds, setStoryIds] = useState([]);
|
package/dist/ui/StoryRow.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Box, Text } from 'ink';
|
|
|
3
3
|
import { isBookmarked } from '../db/bookmarks.js';
|
|
4
4
|
import { formatAge, truncateTitle } from '../lib/format.js';
|
|
5
5
|
import { getHostname } from '../lib/url.js';
|
|
6
|
-
import {
|
|
6
|
+
import { useTheme } from './theme.js';
|
|
7
7
|
export const STORY_ROW_HEIGHT = 3;
|
|
8
8
|
function fillPad(isSelected, safeWidth, usedLength) {
|
|
9
9
|
return isSelected ? ' '.repeat(Math.max(0, safeWidth - usedLength)) : '';
|
|
@@ -12,6 +12,7 @@ function metaText(story) {
|
|
|
12
12
|
return `${story.score} points by ${story.by} ${formatAge(story.time)} ago | ${story.descendants} comments`;
|
|
13
13
|
}
|
|
14
14
|
export function StoryRow({ story, rank, rankWidth, isSelected, width, showMeta = true, showSeparator = true, }) {
|
|
15
|
+
const theme = useTheme();
|
|
15
16
|
// Never let a line's content reach the literal terminal edge: a line that exactly
|
|
16
17
|
// fills the width triggers a VT100 delayed-wrap that corrupts Ink's cursor position
|
|
17
18
|
// for the row below it.
|
|
@@ -2,9 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
4
4
|
import { searchRecent } from '../api/algolia.js';
|
|
5
|
-
import { addSubscription, updateSubscription } from '../db/subscriptions.js';
|
|
5
|
+
import { addSubscription, listSubscriptions, updateSubscription } from '../db/subscriptions.js';
|
|
6
6
|
import { truncateTitle } from '../lib/format.js';
|
|
7
|
-
import {
|
|
7
|
+
import { hasScheduledJob, installScheduledJob } from '../lib/schedule.js';
|
|
8
|
+
import { useTheme } from './theme.js';
|
|
8
9
|
const PREVIEW_DEBOUNCE_MS = 300;
|
|
9
10
|
const PREVIEW_WINDOW_DAYS = 7;
|
|
10
11
|
const PREVIEW_LIMIT = 5;
|
|
@@ -13,6 +14,7 @@ function windowStart() {
|
|
|
13
14
|
return Math.floor(Date.now() / 1000) - PREVIEW_WINDOW_DAYS * 24 * 60 * 60;
|
|
14
15
|
}
|
|
15
16
|
export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onCancel, }) {
|
|
17
|
+
const theme = useTheme();
|
|
16
18
|
const { columns } = useWindowSize();
|
|
17
19
|
const [name, setName] = useState(subscription?.name ?? '');
|
|
18
20
|
const [query, setQuery] = useState(subscription?.query ?? prefillQuery ?? '');
|
|
@@ -20,6 +22,7 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
20
22
|
const [field, setField] = useState('name');
|
|
21
23
|
const [error, setError] = useState('');
|
|
22
24
|
const [preview, setPreview] = useState([]);
|
|
25
|
+
const [scheduleConfirm, setScheduleConfirm] = useState(false);
|
|
23
26
|
const debounceRef = useRef(null);
|
|
24
27
|
const previewToken = useRef(0);
|
|
25
28
|
useEffect(() => {
|
|
@@ -62,13 +65,36 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
62
65
|
addSubscription(trimmedName, trimmedQuery, points);
|
|
63
66
|
else
|
|
64
67
|
updateSubscription(subscription.id, { name: trimmedName, query: trimmedQuery, minPoints: points });
|
|
68
|
+
if (mode === 'add' && listSubscriptions().length === 1 && !hasScheduledJob()) {
|
|
69
|
+
setScheduleConfirm(true);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
65
72
|
onSave();
|
|
66
73
|
}
|
|
67
74
|
catch (err) {
|
|
68
75
|
setError(err.message);
|
|
69
76
|
}
|
|
70
77
|
}
|
|
78
|
+
function resolveScheduleConfirm(install) {
|
|
79
|
+
if (install) {
|
|
80
|
+
try {
|
|
81
|
+
installScheduledJob();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// hn not on PATH; user can run `hn schedule install` later
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
setScheduleConfirm(false);
|
|
88
|
+
onSave();
|
|
89
|
+
}
|
|
71
90
|
function handleInput(input, key) {
|
|
91
|
+
if (scheduleConfirm) {
|
|
92
|
+
if (input === 'y')
|
|
93
|
+
return resolveScheduleConfirm(true);
|
|
94
|
+
if (input === 'n' || key.escape)
|
|
95
|
+
return resolveScheduleConfirm(false);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
72
98
|
if (key.escape)
|
|
73
99
|
return onCancel();
|
|
74
100
|
if (key.tab) {
|
|
@@ -99,5 +125,5 @@ export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onC
|
|
|
99
125
|
useInput(handleInput);
|
|
100
126
|
const cursor = (f) => (field === f ? '▏' : '');
|
|
101
127
|
const previewLabel = Number(minPoints) > 0 ? `≥${minPoints} pts` : 'any points';
|
|
102
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: mode === 'add' ? 'New subscription' : 'Edit subscription' }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["name: ", name, cursor('name')] }), _jsxs(Text, { children: ["query: ", query, cursor('query')] }), _jsxs(Text, { children: ["min points: ", minPoints, cursor('minPoints')] }), _jsx(Text, { children: " " }), query.trim() && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["preview (last 7 days, ", previewLabel, "):"] }), preview.length === 0 && _jsx(Text, { dimColor: true, children: " no matches" }), preview.map((story, i) => (_jsxs(Text, { children: [' ', i + 1, ". ", truncateTitle(story.title, Math.max(1, columns - 24)), " ", story.score, " pts"] }, story.id)))] })), error && _jsx(Text, { color: theme.colors.error, children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "tab next field \u00B7 enter save \u00B7 esc cancel" })] }));
|
|
128
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: mode === 'add' ? 'New subscription' : 'Edit subscription' }), _jsx(Text, { children: " " }), _jsxs(Text, { children: ["name: ", name, cursor('name')] }), _jsxs(Text, { children: ["query: ", query, cursor('query')] }), _jsxs(Text, { children: ["min points: ", minPoints, cursor('minPoints')] }), _jsx(Text, { children: " " }), query.trim() && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["preview (last 7 days, ", previewLabel, "):"] }), preview.length === 0 && _jsx(Text, { dimColor: true, children: " no matches" }), preview.map((story, i) => (_jsxs(Text, { children: [' ', i + 1, ". ", truncateTitle(story.title, Math.max(1, columns - 24)), " ", story.score, " pts"] }, story.id)))] })), error && _jsx(Text, { color: theme.colors.error, children: error }), _jsx(Text, { children: " " }), scheduleConfirm ? (_jsx(Text, { dimColor: true, children: "no watch schedule installed yet - install one now (every 30 min)? y/n" })) : (_jsx(Text, { dimColor: true, children: "tab next field \u00B7 enter save \u00B7 esc cancel" }))] }));
|
|
103
129
|
}
|
|
@@ -14,7 +14,7 @@ import { LoadingIndicator } from './LoadingIndicator.js';
|
|
|
14
14
|
import { STORY_ROW_HEIGHT } from './StoryRow.js';
|
|
15
15
|
import { StoryListView } from './StoryListView.js';
|
|
16
16
|
import { SummaryPanel } from './SummaryPanel.js';
|
|
17
|
-
import {
|
|
17
|
+
import { useTheme } from './theme.js';
|
|
18
18
|
import { useFlash } from './useFlash.js';
|
|
19
19
|
const WINDOW_DAYS = 7;
|
|
20
20
|
const HEADER_LINES = 1;
|
|
@@ -22,6 +22,7 @@ function windowStart() {
|
|
|
22
22
|
return Math.floor(Date.now() / 1000) - WINDOW_DAYS * 24 * 60 * 60;
|
|
23
23
|
}
|
|
24
24
|
export function SubscriptionMatches({ subscription, config, onSelectStory, onBack, onAskAI, }) {
|
|
25
|
+
const theme = useTheme();
|
|
25
26
|
const { columns, rows } = useWindowSize();
|
|
26
27
|
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SUB_MATCHES_KEYS, columns) - HEADER_LINES);
|
|
27
28
|
const [stories, setStories] = useState([]);
|
|
@@ -4,19 +4,24 @@ import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
|
4
4
|
import { listSubscriptions, removeSubscription } from '../db/subscriptions.js';
|
|
5
5
|
import { formatAge } from '../lib/format.js';
|
|
6
6
|
import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
|
|
7
|
+
import { hasScheduledJob, installScheduledJob, removeScheduledJob } from '../lib/schedule.js';
|
|
7
8
|
import { ensureVisibleLines, sliceByLines } from '../lib/viewport.js';
|
|
8
9
|
import { footerRows, SUBS_KEYS } from './keymap.js';
|
|
9
10
|
import { HEADER_ROWS } from './Layout.js';
|
|
10
|
-
import {
|
|
11
|
+
import { useTheme } from './theme.js';
|
|
12
|
+
import { useFlash } from './useFlash.js';
|
|
11
13
|
function pointsLabel(minPoints) {
|
|
12
14
|
return minPoints === 0 ? 'any' : `≥${minPoints} pts`;
|
|
13
15
|
}
|
|
14
16
|
export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange, onTabChange, }) {
|
|
17
|
+
const theme = useTheme();
|
|
15
18
|
const { columns, rows } = useWindowSize();
|
|
16
19
|
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SUBS_KEYS, columns));
|
|
17
20
|
const [subs, setSubs] = useState(() => listSubscriptions());
|
|
18
21
|
const [selected, setSelected] = useState(0);
|
|
19
22
|
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
|
23
|
+
const [scheduleInstalled, setScheduleInstalled] = useState(() => hasScheduledJob());
|
|
24
|
+
const [scheduleFlash, flashSchedule] = useFlash();
|
|
20
25
|
const pendingTopJump = useRef(false);
|
|
21
26
|
const topLineRef = useRef(0);
|
|
22
27
|
function reload() {
|
|
@@ -24,6 +29,22 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
|
|
|
24
29
|
setSubs(next);
|
|
25
30
|
setSelected((s) => clampSelection(s, 0, next.length));
|
|
26
31
|
}
|
|
32
|
+
function toggleSchedule() {
|
|
33
|
+
if (scheduleInstalled) {
|
|
34
|
+
removeScheduledJob();
|
|
35
|
+
setScheduleInstalled(false);
|
|
36
|
+
flashSchedule('schedule removed');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
installScheduledJob();
|
|
41
|
+
setScheduleInstalled(true);
|
|
42
|
+
flashSchedule('schedule installed');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
flashSchedule("couldn't find 'hn' on PATH");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
27
48
|
function handleInput(input, key) {
|
|
28
49
|
if (deleteConfirm) {
|
|
29
50
|
if (input === 'y') {
|
|
@@ -60,12 +81,17 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
|
|
|
60
81
|
return onEdit(subs[selected]);
|
|
61
82
|
if (input === 'd' && subs[selected])
|
|
62
83
|
return setDeleteConfirm(subs[selected].name);
|
|
84
|
+
if (input === 'c')
|
|
85
|
+
return toggleSchedule();
|
|
63
86
|
}
|
|
64
87
|
useInput(handleInput);
|
|
88
|
+
const scheduleStatusText = scheduleInstalled ? 'schedule: installed (every 30 min)' : 'schedule: not installed';
|
|
65
89
|
if (subs.length === 0) {
|
|
66
|
-
return _jsx(Text, { dimColor: true, children: "no subscriptions yet - press a to add" });
|
|
90
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "no subscriptions yet - press a to add" }), _jsx(Text, { dimColor: true, children: scheduleStatusText }), scheduleFlash && _jsx(Text, { dimColor: true, children: scheduleFlash })] }));
|
|
67
91
|
}
|
|
68
|
-
const
|
|
92
|
+
const deleteLineHeight = deleteConfirm ? 1 : 0;
|
|
93
|
+
const scheduleFlashLineHeight = scheduleFlash ? 1 : 0;
|
|
94
|
+
const statusLineHeight = 1 + deleteLineHeight + scheduleFlashLineHeight;
|
|
69
95
|
const listHeight = Math.max(1, bodyHeight - statusLineHeight);
|
|
70
96
|
const heights = subs.map(() => 1);
|
|
71
97
|
const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
|
|
@@ -78,5 +104,5 @@ export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange
|
|
|
78
104
|
const marker = isSelected ? theme.glyphs.selection : ' ';
|
|
79
105
|
const background = isSelected ? theme.colors.selectionBackground : undefined;
|
|
80
106
|
return (_jsxs(Text, { backgroundColor: background, children: [marker, " ", sub.name.padEnd(nameWidth), " \"", sub.query, "\" ", pointsLabel(sub.minPoints).padEnd(9), " added", ' ', formatAge(sub.createdAt), " ago"] }, sub.id));
|
|
81
|
-
}), deleteConfirm && _jsxs(Text, { dimColor: true, children: ["delete '", deleteConfirm, "'? y/n"] })] }));
|
|
107
|
+
}), deleteConfirm && _jsxs(Text, { dimColor: true, children: ["delete '", deleteConfirm, "'? y/n"] }), _jsx(Text, { dimColor: true, children: scheduleStatusText }), scheduleFlash && _jsx(Text, { dimColor: true, children: scheduleFlash })] }));
|
|
82
108
|
}
|
package/dist/ui/SummaryPanel.js
CHANGED
|
@@ -4,9 +4,10 @@ import { Box, Text, useInput } from 'ink';
|
|
|
4
4
|
import { chatStream, describeError } from '../ai/ollama.js';
|
|
5
5
|
import { AI_SETUP_HINT_LINES } from '../lib/config.js';
|
|
6
6
|
import { wrapPlainText } from '../lib/viewport.js';
|
|
7
|
-
import {
|
|
7
|
+
import { useTheme } from './theme.js';
|
|
8
8
|
const SYSTEM_PROMPT = 'You are a concise assistant summarizing Hacker News content. Plain text only, no markdown headers. Max ~150 words.';
|
|
9
9
|
export function SummaryPanel({ config, buildPrompt, height, width, onClose }) {
|
|
10
|
+
const theme = useTheme();
|
|
10
11
|
const [status, setStatus] = useState(config ? 'preparing' : 'setup-hint');
|
|
11
12
|
const [content, setContent] = useState('');
|
|
12
13
|
const [notice, setNotice] = useState('');
|
package/dist/ui/TabBar.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useWindowSize } from 'ink';
|
|
3
|
-
import {
|
|
3
|
+
import { useTheme } from './theme.js';
|
|
4
4
|
const TABS = [
|
|
5
5
|
{ id: 'top', label: 'Top' },
|
|
6
6
|
{ id: 'new', label: 'New' },
|
|
@@ -71,11 +71,12 @@ function buildBottomRuleSegments(columns, wallColumn, innerWidth) {
|
|
|
71
71
|
const line = base.slice(0, wallColumn) + notch + base.slice(wallColumn + notch.length);
|
|
72
72
|
return [{ text: line, color: 'accent' }];
|
|
73
73
|
}
|
|
74
|
-
function segmentColor(color) {
|
|
74
|
+
function segmentColor(color, theme) {
|
|
75
75
|
return color === 'accent' ? theme.colors.accent : theme.colors.muted;
|
|
76
76
|
}
|
|
77
77
|
function SegmentRow({ segments }) {
|
|
78
|
-
|
|
78
|
+
const theme = useTheme();
|
|
79
|
+
return (_jsx(Box, { children: segments.map((segment, index) => (_jsx(Text, { bold: segment.bold, color: segmentColor(segment.color, theme), children: segment.text }, index))) }));
|
|
79
80
|
}
|
|
80
81
|
export function TabBar({ active }) {
|
|
81
82
|
const { columns } = useWindowSize();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { clampSelection } from '../lib/listNavigation.js';
|
|
5
|
+
import { paletteNames, useTheme } from './theme.js';
|
|
6
|
+
export function ThemePicker({ current, onSelect, onCancel }) {
|
|
7
|
+
const theme = useTheme();
|
|
8
|
+
const names = paletteNames();
|
|
9
|
+
const [selected, setSelected] = useState(() => Math.max(0, names.indexOf(current)));
|
|
10
|
+
function handleInput(input, key) {
|
|
11
|
+
if (key.escape)
|
|
12
|
+
return onCancel();
|
|
13
|
+
if (input === 'j' || key.downArrow)
|
|
14
|
+
return setSelected((s) => clampSelection(s, 1, names.length));
|
|
15
|
+
if (input === 'k' || key.upArrow)
|
|
16
|
+
return setSelected((s) => clampSelection(s, -1, names.length));
|
|
17
|
+
if (key.return)
|
|
18
|
+
return onSelect(names[selected]);
|
|
19
|
+
}
|
|
20
|
+
useInput(handleInput);
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: "theme" }), names.map((name, index) => {
|
|
22
|
+
const isSelected = index === selected;
|
|
23
|
+
const marker = isSelected ? theme.glyphs.selection : ' ';
|
|
24
|
+
const background = isSelected ? theme.colors.selectionBackground : undefined;
|
|
25
|
+
const suffix = name === current ? ' (current)' : '';
|
|
26
|
+
return (_jsxs(Text, { backgroundColor: background, children: [marker, " ", name, suffix] }, name));
|
|
27
|
+
})] }));
|
|
28
|
+
}
|
package/dist/ui/keymap.js
CHANGED
|
@@ -2,6 +2,7 @@ import { wrapPlainText } from '../lib/viewport.js';
|
|
|
2
2
|
export const GLOBAL_KEYS = [
|
|
3
3
|
{ key: 'q', label: 'quit' },
|
|
4
4
|
{ key: '?', label: 'help' },
|
|
5
|
+
{ key: 'T', label: 'theme' },
|
|
5
6
|
];
|
|
6
7
|
export const LIST_KEYS = [
|
|
7
8
|
{ key: 'j/k', label: 'move' },
|
|
@@ -48,6 +49,7 @@ export const SUBS_KEYS = [
|
|
|
48
49
|
{ key: 'a', label: 'add' },
|
|
49
50
|
{ key: 'e', label: 'edit' },
|
|
50
51
|
{ key: 'd', label: 'delete' },
|
|
52
|
+
{ key: 'c', label: 'schedule' },
|
|
51
53
|
];
|
|
52
54
|
export const SUB_MATCHES_KEYS = [
|
|
53
55
|
{ key: 'j/k', label: 'move' },
|
|
@@ -65,6 +67,11 @@ export const SUB_FORM_KEYS = [
|
|
|
65
67
|
{ key: 'enter', label: 'save' },
|
|
66
68
|
{ key: 'esc', label: 'cancel' },
|
|
67
69
|
];
|
|
70
|
+
export const THEME_PICKER_KEYS = [
|
|
71
|
+
{ key: 'j/k', label: 'move' },
|
|
72
|
+
{ key: 'enter', label: 'apply' },
|
|
73
|
+
{ key: 'esc', label: 'cancel' },
|
|
74
|
+
];
|
|
68
75
|
export const SAVED_KEYS = [
|
|
69
76
|
{ key: 'j/k', label: 'move' },
|
|
70
77
|
{ key: '←/→', label: 'tab' },
|
package/dist/ui/theme.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import { loadConfig } from '../lib/config.js';
|
|
1
3
|
const glyphs = {
|
|
2
4
|
selection: '❯',
|
|
3
5
|
foldOpen: '▾',
|
|
@@ -81,6 +83,18 @@ const palettes = {
|
|
|
81
83
|
link: LINK,
|
|
82
84
|
email: EMAIL,
|
|
83
85
|
},
|
|
86
|
+
// Ported from chojs23/concord's ratatui color constants (src/tui/ui/types.rs, message/format.rs).
|
|
87
|
+
concord: {
|
|
88
|
+
accent: ansi256(44),
|
|
89
|
+
title: ansi256(255),
|
|
90
|
+
muted: ansi256(243),
|
|
91
|
+
error: ansi256(196),
|
|
92
|
+
score: ansi256(208),
|
|
93
|
+
comment: ansi256(80),
|
|
94
|
+
selectionBackground: SELECTION_BACKGROUND,
|
|
95
|
+
link: LINK,
|
|
96
|
+
email: EMAIL,
|
|
97
|
+
},
|
|
84
98
|
};
|
|
85
99
|
const DEFAULT_PALETTE = 'hn';
|
|
86
100
|
export function paletteNames() {
|
|
@@ -89,11 +103,32 @@ export function paletteNames() {
|
|
|
89
103
|
function isPaletteName(name) {
|
|
90
104
|
return name in palettes;
|
|
91
105
|
}
|
|
106
|
+
/** First defined candidate across flag > HN_THEME env > ui.theme config, with where it came from. */
|
|
107
|
+
function candidatePaletteName(name) {
|
|
108
|
+
if (name !== undefined)
|
|
109
|
+
return { raw: name, source: 'flag' };
|
|
110
|
+
const env = process.env['HN_THEME'];
|
|
111
|
+
if (env !== undefined)
|
|
112
|
+
return { raw: env, source: 'env' };
|
|
113
|
+
const configured = loadConfig()?.ui?.theme;
|
|
114
|
+
if (configured !== undefined)
|
|
115
|
+
return { raw: configured, source: 'config' };
|
|
116
|
+
return { source: 'default' };
|
|
117
|
+
}
|
|
92
118
|
export function resolvePaletteName(name) {
|
|
93
|
-
const
|
|
94
|
-
return isPaletteName(
|
|
119
|
+
const { raw } = candidatePaletteName(name);
|
|
120
|
+
return raw !== undefined && isPaletteName(raw) ? raw : DEFAULT_PALETTE;
|
|
121
|
+
}
|
|
122
|
+
/** Where the active theme choice came from, for `hn theme`'s status line. */
|
|
123
|
+
export function resolvePaletteSource(name) {
|
|
124
|
+
const { raw, source } = candidatePaletteName(name);
|
|
125
|
+
return raw !== undefined && isPaletteName(raw) ? source : 'default';
|
|
95
126
|
}
|
|
96
127
|
export function resolveTheme(name) {
|
|
97
128
|
return { colors: palettes[resolvePaletteName(name)], glyphs };
|
|
98
129
|
}
|
|
99
130
|
export const theme = resolveTheme();
|
|
131
|
+
export const ThemeContext = createContext(theme);
|
|
132
|
+
export function useTheme() {
|
|
133
|
+
return useContext(ThemeContext);
|
|
134
|
+
}
|