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 +3 -3
- package/dist/index.js +10 -5
- package/dist/lib/config.js +1 -0
- package/dist/lib/configKeys.js +9 -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 +2 -1
- package/dist/ui/SubscriptionMatches.js +2 -1
- package/dist/ui/SubscriptionsView.js +2 -1
- 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 +6 -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.
|
|
@@ -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
|
|
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
|
|
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
|
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}'`);
|
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.
|
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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());
|
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' },
|
|
@@ -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
|
|
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
|
+
}
|