hn-bits 0.3.0 → 0.4.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.
@@ -128,7 +128,7 @@ Sensitive values (e.g. `telegram.botToken`) print masked in `list` but raw from
128
128
 
129
129
  ### Themes
130
130
 
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).
131
+ `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
132
 
133
133
  ### Keybindings
134
134
 
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
@@ -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}'`);
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.
@@ -4,7 +4,7 @@ import { Box, Text, useInput, useWindowSize } from 'ink';
4
4
  import { searchRecent } from '../api/algolia.js';
5
5
  import { addSubscription, updateSubscription } from '../db/subscriptions.js';
6
6
  import { truncateTitle } from '../lib/format.js';
7
- import { theme } from './theme.js';
7
+ import { useTheme } from './theme.js';
8
8
  const PREVIEW_DEBOUNCE_MS = 300;
9
9
  const PREVIEW_WINDOW_DAYS = 7;
10
10
  const PREVIEW_LIMIT = 5;
@@ -13,6 +13,7 @@ function windowStart() {
13
13
  return Math.floor(Date.now() / 1000) - PREVIEW_WINDOW_DAYS * 24 * 60 * 60;
14
14
  }
15
15
  export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onCancel, }) {
16
+ const theme = useTheme();
16
17
  const { columns } = useWindowSize();
17
18
  const [name, setName] = useState(subscription?.name ?? '');
18
19
  const [query, setQuery] = useState(subscription?.query ?? prefillQuery ?? '');
@@ -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([]);
@@ -7,11 +7,12 @@ import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
7
7
  import { ensureVisibleLines, sliceByLines } from '../lib/viewport.js';
8
8
  import { footerRows, SUBS_KEYS } from './keymap.js';
9
9
  import { HEADER_ROWS } from './Layout.js';
10
- import { theme } from './theme.js';
10
+ import { useTheme } from './theme.js';
11
11
  function pointsLabel(minPoints) {
12
12
  return minPoints === 0 ? 'any' : `≥${minPoints} pts`;
13
13
  }
14
14
  export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange, onTabChange, }) {
15
+ const theme = useTheme();
15
16
  const { columns, rows } = useWindowSize();
16
17
  const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SUBS_KEYS, columns));
17
18
  const [subs, setSubs] = useState(() => listSubscriptions());
@@ -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' },
@@ -65,6 +66,11 @@ export const SUB_FORM_KEYS = [
65
66
  { key: 'enter', label: 'save' },
66
67
  { key: 'esc', label: 'cancel' },
67
68
  ];
69
+ export const THEME_PICKER_KEYS = [
70
+ { key: 'j/k', label: 'move' },
71
+ { key: 'enter', label: 'apply' },
72
+ { key: 'esc', label: 'cancel' },
73
+ ];
68
74
  export const SAVED_KEYS = [
69
75
  { key: 'j/k', label: 'move' },
70
76
  { 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.4.0",
4
4
  "description": "Terminal-first Hacker News client",
5
5
  "type": "module",
6
6
  "repository": {