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 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
- - 6 built-in themes (`hn`, `mocha`, `dracula`, `tokyo`, `nord`, `gruvbox`)
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
- Schedule it, e.g. every 30 minutes via crontab:
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>` or the `HN_THEME` env var; not persisted (no config file yet).
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 activeName = resolvePaletteName(program.opts().theme);
51
- console.log(`Active theme: ${activeName}`);
52
- console.log(`Available: ${paletteNames().join(', ')}`);
53
- console.log('Set with `hn --theme <name>` or the HN_THEME environment variable.');
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)')
@@ -43,6 +43,7 @@ export function loadConfig() {
43
43
  enabled: parsed.desktopNotifications.enabled,
44
44
  timeoutSeconds: parsed.desktopNotifications.timeoutSeconds ?? 10,
45
45
  },
46
+ ui: parsed.ui,
46
47
  };
47
48
  }
48
49
  catch (err) {
@@ -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 { theme } from './theme.js';
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('');
@@ -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 { theme } from './theme.js';
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;
@@ -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 { theme } from './theme.js';
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 { theme } from './theme.js';
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 { theme } from './theme.js';
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);
@@ -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 { theme } from './theme.js';
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([]);
@@ -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 { theme } from './theme.js';
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([]);
@@ -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 { theme } from './theme.js';
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 { theme } from './theme.js';
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 { theme } from './theme.js';
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 { theme } from './theme.js';
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 statusLineHeight = deleteConfirm ? 1 : 0;
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
  }
@@ -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 { theme } from './theme.js';
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 { theme } from './theme.js';
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
- return (_jsx(Box, { children: segments.map((segment, index) => (_jsx(Text, { bold: segment.bold, color: segmentColor(segment.color), children: segment.text }, index))) }));
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 candidate = name ?? process.env['HN_THEME'] ?? DEFAULT_PALETTE;
94
- return isPaletteName(candidate) ? candidate : DEFAULT_PALETTE;
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hn-bits",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Terminal-first Hacker News client",
5
5
  "type": "module",
6
6
  "repository": {