hn-bits 0.2.1 → 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 +93 -13
- package/dist/api/algolia.js +31 -11
- package/dist/db/bookmarks.js +37 -0
- package/dist/db/db.js +64 -0
- package/dist/db/seen.js +12 -0
- package/dist/db/subscriptions.js +44 -0
- package/dist/index.js +85 -5
- package/dist/lib/config.js +1 -0
- package/dist/lib/configKeys.js +9 -0
- package/dist/lib/listNavigation.js +7 -7
- package/dist/notify/notifier.js +1 -0
- package/dist/notify/telegram.js +58 -0
- package/dist/ui/App.js +92 -10
- package/dist/ui/AskAI.js +2 -1
- package/dist/ui/Comments.js +18 -9
- package/dist/ui/HelpOverlay.js +2 -1
- package/dist/ui/Layout.js +2 -1
- package/dist/ui/LoadingIndicator.js +2 -1
- package/dist/ui/SavedList.js +90 -0
- package/dist/ui/SearchResults.js +13 -4
- package/dist/ui/StoryList.js +14 -7
- package/dist/ui/StoryRow.js +7 -3
- package/dist/ui/SubscriptionForm.js +104 -0
- package/dist/ui/SubscriptionMatches.js +103 -0
- package/dist/ui/SubscriptionsView.js +83 -0
- package/dist/ui/SummaryPanel.js +2 -1
- package/dist/ui/TabBar.js +13 -10
- package/dist/ui/ThemePicker.js +28 -0
- package/dist/ui/keymap.js +48 -0
- package/dist/ui/theme.js +38 -2
- package/dist/ui/useFlash.js +14 -0
- package/dist/watch.js +96 -0
- package/package.json +3 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
4
|
+
import { searchRecent } from '../api/algolia.js';
|
|
5
|
+
import { addSubscription, updateSubscription } from '../db/subscriptions.js';
|
|
6
|
+
import { truncateTitle } from '../lib/format.js';
|
|
7
|
+
import { useTheme } from './theme.js';
|
|
8
|
+
const PREVIEW_DEBOUNCE_MS = 300;
|
|
9
|
+
const PREVIEW_WINDOW_DAYS = 7;
|
|
10
|
+
const PREVIEW_LIMIT = 5;
|
|
11
|
+
const FIELD_ORDER = ['name', 'query', 'minPoints'];
|
|
12
|
+
function windowStart() {
|
|
13
|
+
return Math.floor(Date.now() / 1000) - PREVIEW_WINDOW_DAYS * 24 * 60 * 60;
|
|
14
|
+
}
|
|
15
|
+
export function SubscriptionForm({ mode, subscription, prefillQuery, onSave, onCancel, }) {
|
|
16
|
+
const theme = useTheme();
|
|
17
|
+
const { columns } = useWindowSize();
|
|
18
|
+
const [name, setName] = useState(subscription?.name ?? '');
|
|
19
|
+
const [query, setQuery] = useState(subscription?.query ?? prefillQuery ?? '');
|
|
20
|
+
const [minPoints, setMinPoints] = useState(String(subscription?.minPoints ?? 0));
|
|
21
|
+
const [field, setField] = useState('name');
|
|
22
|
+
const [error, setError] = useState('');
|
|
23
|
+
const [preview, setPreview] = useState([]);
|
|
24
|
+
const debounceRef = useRef(null);
|
|
25
|
+
const previewToken = useRef(0);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (debounceRef.current)
|
|
28
|
+
clearTimeout(debounceRef.current);
|
|
29
|
+
if (!query.trim()) {
|
|
30
|
+
setPreview([]);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
debounceRef.current = setTimeout(() => void loadPreview(), PREVIEW_DEBOUNCE_MS);
|
|
34
|
+
return () => {
|
|
35
|
+
if (debounceRef.current)
|
|
36
|
+
clearTimeout(debounceRef.current);
|
|
37
|
+
};
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
}, [query, minPoints]);
|
|
40
|
+
async function loadPreview() {
|
|
41
|
+
const myToken = ++previewToken.current;
|
|
42
|
+
const points = Number(minPoints) || 0;
|
|
43
|
+
try {
|
|
44
|
+
const results = await searchRecent(query, { createdAfter: windowStart(), minPoints: points, hitsPerPage: PREVIEW_LIMIT });
|
|
45
|
+
if (myToken === previewToken.current)
|
|
46
|
+
setPreview(results);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
if (myToken === previewToken.current)
|
|
50
|
+
setPreview([]);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function save() {
|
|
54
|
+
const trimmedName = name.trim();
|
|
55
|
+
const trimmedQuery = query.trim();
|
|
56
|
+
if (!trimmedName)
|
|
57
|
+
return setError('name is required');
|
|
58
|
+
if (!trimmedQuery)
|
|
59
|
+
return setError('query is required');
|
|
60
|
+
const points = Number(minPoints) || 0;
|
|
61
|
+
try {
|
|
62
|
+
if (mode === 'add')
|
|
63
|
+
addSubscription(trimmedName, trimmedQuery, points);
|
|
64
|
+
else
|
|
65
|
+
updateSubscription(subscription.id, { name: trimmedName, query: trimmedQuery, minPoints: points });
|
|
66
|
+
onSave();
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
setError(err.message);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function handleInput(input, key) {
|
|
73
|
+
if (key.escape)
|
|
74
|
+
return onCancel();
|
|
75
|
+
if (key.tab) {
|
|
76
|
+
const index = FIELD_ORDER.indexOf(field);
|
|
77
|
+
return setField(FIELD_ORDER[(index + 1) % FIELD_ORDER.length]);
|
|
78
|
+
}
|
|
79
|
+
if (key.return)
|
|
80
|
+
return save();
|
|
81
|
+
if (key.backspace || key.delete) {
|
|
82
|
+
if (field === 'name')
|
|
83
|
+
setName((v) => v.slice(0, -1));
|
|
84
|
+
if (field === 'query')
|
|
85
|
+
setQuery((v) => v.slice(0, -1));
|
|
86
|
+
if (field === 'minPoints')
|
|
87
|
+
setMinPoints((v) => v.slice(0, -1));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (input && !key.ctrl && !key.meta) {
|
|
91
|
+
if (field === 'name')
|
|
92
|
+
setName((v) => v + input);
|
|
93
|
+
if (field === 'query')
|
|
94
|
+
setQuery((v) => v + input);
|
|
95
|
+
if (field === 'minPoints' && /^\d+$/.test(input)) {
|
|
96
|
+
setMinPoints((v) => (v === '0' ? input : v + input));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
useInput(handleInput);
|
|
101
|
+
const cursor = (f) => (field === f ? '▏' : '');
|
|
102
|
+
const previewLabel = Number(minPoints) > 0 ? `≥${minPoints} pts` : 'any points';
|
|
103
|
+
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" })] }));
|
|
104
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
4
|
+
import open from 'open';
|
|
5
|
+
import { buildListSummaryPrompt } from '../ai/summaryPrompts.js';
|
|
6
|
+
import { searchRecent } from '../api/algolia.js';
|
|
7
|
+
import { hnItemUrl } from '../api/firebase.js';
|
|
8
|
+
import { toggleBookmark } from '../db/bookmarks.js';
|
|
9
|
+
import { clampSelection } from '../lib/listNavigation.js';
|
|
10
|
+
import { ensureVisibleLines } from '../lib/viewport.js';
|
|
11
|
+
import { footerRows, SUB_MATCHES_KEYS } from './keymap.js';
|
|
12
|
+
import { HEADER_ROWS } from './Layout.js';
|
|
13
|
+
import { LoadingIndicator } from './LoadingIndicator.js';
|
|
14
|
+
import { STORY_ROW_HEIGHT } from './StoryRow.js';
|
|
15
|
+
import { StoryListView } from './StoryListView.js';
|
|
16
|
+
import { SummaryPanel } from './SummaryPanel.js';
|
|
17
|
+
import { useTheme } from './theme.js';
|
|
18
|
+
import { useFlash } from './useFlash.js';
|
|
19
|
+
const WINDOW_DAYS = 7;
|
|
20
|
+
const HEADER_LINES = 1;
|
|
21
|
+
function windowStart() {
|
|
22
|
+
return Math.floor(Date.now() / 1000) - WINDOW_DAYS * 24 * 60 * 60;
|
|
23
|
+
}
|
|
24
|
+
export function SubscriptionMatches({ subscription, config, onSelectStory, onBack, onAskAI, }) {
|
|
25
|
+
const theme = useTheme();
|
|
26
|
+
const { columns, rows } = useWindowSize();
|
|
27
|
+
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SUB_MATCHES_KEYS, columns) - HEADER_LINES);
|
|
28
|
+
const [stories, setStories] = useState([]);
|
|
29
|
+
const [selected, setSelected] = useState(0);
|
|
30
|
+
const [status, setStatus] = useState('loading');
|
|
31
|
+
const [error, setError] = useState('');
|
|
32
|
+
const [summaryOpen, setSummaryOpen] = useState(false);
|
|
33
|
+
const [flashMessage, flash] = useFlash();
|
|
34
|
+
const pendingTopJump = useRef(false);
|
|
35
|
+
const topLineRef = useRef(0);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
void load();
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
}, [subscription.id]);
|
|
40
|
+
async function load() {
|
|
41
|
+
setStatus('loading');
|
|
42
|
+
setSelected(0);
|
|
43
|
+
try {
|
|
44
|
+
const results = await searchRecent(subscription.query, {
|
|
45
|
+
createdAfter: windowStart(),
|
|
46
|
+
minPoints: subscription.minPoints,
|
|
47
|
+
});
|
|
48
|
+
setStories(results);
|
|
49
|
+
setStatus('ready');
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
setStatus('error');
|
|
53
|
+
setError(err.message);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function openSelectedStory() {
|
|
57
|
+
const story = stories[selected];
|
|
58
|
+
if (story)
|
|
59
|
+
void open(story.url ?? hnItemUrl(story.id));
|
|
60
|
+
}
|
|
61
|
+
function handleInput(input, key) {
|
|
62
|
+
const isG = input === 'g';
|
|
63
|
+
if (isG && pendingTopJump.current) {
|
|
64
|
+
pendingTopJump.current = false;
|
|
65
|
+
return setSelected(0);
|
|
66
|
+
}
|
|
67
|
+
pendingTopJump.current = isG;
|
|
68
|
+
if (key.escape)
|
|
69
|
+
return onBack();
|
|
70
|
+
if (input === 'j' || key.downArrow)
|
|
71
|
+
return setSelected((s) => clampSelection(s, 1, stories.length));
|
|
72
|
+
if (input === 'k' || key.upArrow)
|
|
73
|
+
return setSelected((s) => clampSelection(s, -1, stories.length));
|
|
74
|
+
if (input === 'G')
|
|
75
|
+
return setSelected(Math.max(0, stories.length - 1));
|
|
76
|
+
if (input === 'o')
|
|
77
|
+
return openSelectedStory();
|
|
78
|
+
if (input === 'r')
|
|
79
|
+
return void load();
|
|
80
|
+
if (input === 's' && stories[selected])
|
|
81
|
+
return setSummaryOpen(true);
|
|
82
|
+
if (input === 'a' && stories[selected])
|
|
83
|
+
return onAskAI(stories[selected]);
|
|
84
|
+
if (input === 'B' && stories[selected]) {
|
|
85
|
+
return flash(toggleBookmark(stories[selected]) ? 'bookmarked ✓' : 'bookmark removed');
|
|
86
|
+
}
|
|
87
|
+
if (key.return && stories[selected])
|
|
88
|
+
return onSelectStory(stories[selected]);
|
|
89
|
+
}
|
|
90
|
+
useInput(handleInput, { isActive: !summaryOpen });
|
|
91
|
+
if (status === 'loading')
|
|
92
|
+
return _jsx(LoadingIndicator, { label: "Loading matches..." });
|
|
93
|
+
if (status === 'error')
|
|
94
|
+
return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
|
|
95
|
+
const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
|
|
96
|
+
const statusLineHeight = flashMessage ? 1 : 0;
|
|
97
|
+
const listHeight = Math.max(1, bodyHeight - statusLineHeight - panelHeight);
|
|
98
|
+
const heights = stories.map(() => STORY_ROW_HEIGHT);
|
|
99
|
+
const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
|
|
100
|
+
topLineRef.current = topLine;
|
|
101
|
+
const selectedStory = stories[selected];
|
|
102
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["sub: ", subscription.name, " \"", subscription.query, "\""] }), stories.length === 0 ? (_jsx(Text, { dimColor: true, children: "no matches in the last 7 days" })) : (_jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns })), flashMessage && _jsx(Text, { dimColor: true, children: flashMessage }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
|
|
103
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput, useWindowSize } from 'ink';
|
|
4
|
+
import { listSubscriptions, removeSubscription } from '../db/subscriptions.js';
|
|
5
|
+
import { formatAge } from '../lib/format.js';
|
|
6
|
+
import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
|
|
7
|
+
import { ensureVisibleLines, sliceByLines } from '../lib/viewport.js';
|
|
8
|
+
import { footerRows, SUBS_KEYS } from './keymap.js';
|
|
9
|
+
import { HEADER_ROWS } from './Layout.js';
|
|
10
|
+
import { useTheme } from './theme.js';
|
|
11
|
+
function pointsLabel(minPoints) {
|
|
12
|
+
return minPoints === 0 ? 'any' : `≥${minPoints} pts`;
|
|
13
|
+
}
|
|
14
|
+
export function SubscriptionsView({ onSelectMatches, onAdd, onEdit, onFeedChange, onTabChange, }) {
|
|
15
|
+
const theme = useTheme();
|
|
16
|
+
const { columns, rows } = useWindowSize();
|
|
17
|
+
const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SUBS_KEYS, columns));
|
|
18
|
+
const [subs, setSubs] = useState(() => listSubscriptions());
|
|
19
|
+
const [selected, setSelected] = useState(0);
|
|
20
|
+
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
|
21
|
+
const pendingTopJump = useRef(false);
|
|
22
|
+
const topLineRef = useRef(0);
|
|
23
|
+
function reload() {
|
|
24
|
+
const next = listSubscriptions();
|
|
25
|
+
setSubs(next);
|
|
26
|
+
setSelected((s) => clampSelection(s, 0, next.length));
|
|
27
|
+
}
|
|
28
|
+
function handleInput(input, key) {
|
|
29
|
+
if (deleteConfirm) {
|
|
30
|
+
if (input === 'y') {
|
|
31
|
+
removeSubscription(deleteConfirm);
|
|
32
|
+
reload();
|
|
33
|
+
}
|
|
34
|
+
setDeleteConfirm(null);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const isG = input === 'g';
|
|
38
|
+
if (isG && pendingTopJump.current) {
|
|
39
|
+
pendingTopJump.current = false;
|
|
40
|
+
return setSelected(0);
|
|
41
|
+
}
|
|
42
|
+
pendingTopJump.current = isG;
|
|
43
|
+
const targetFeed = mapFeedKey(input);
|
|
44
|
+
if (targetFeed)
|
|
45
|
+
return onFeedChange(targetFeed);
|
|
46
|
+
if (key.leftArrow)
|
|
47
|
+
return onTabChange(-1);
|
|
48
|
+
if (key.rightArrow)
|
|
49
|
+
return onTabChange(1);
|
|
50
|
+
if (input === 'j' || key.downArrow)
|
|
51
|
+
return setSelected((s) => clampSelection(s, 1, subs.length));
|
|
52
|
+
if (input === 'k' || key.upArrow)
|
|
53
|
+
return setSelected((s) => clampSelection(s, -1, subs.length));
|
|
54
|
+
if (input === 'G')
|
|
55
|
+
return setSelected(Math.max(0, subs.length - 1));
|
|
56
|
+
if (key.return && subs[selected])
|
|
57
|
+
return onSelectMatches(subs[selected]);
|
|
58
|
+
if (input === 'a')
|
|
59
|
+
return onAdd();
|
|
60
|
+
if (input === 'e' && subs[selected])
|
|
61
|
+
return onEdit(subs[selected]);
|
|
62
|
+
if (input === 'd' && subs[selected])
|
|
63
|
+
return setDeleteConfirm(subs[selected].name);
|
|
64
|
+
}
|
|
65
|
+
useInput(handleInput);
|
|
66
|
+
if (subs.length === 0) {
|
|
67
|
+
return _jsx(Text, { dimColor: true, children: "no subscriptions yet - press a to add" });
|
|
68
|
+
}
|
|
69
|
+
const statusLineHeight = deleteConfirm ? 1 : 0;
|
|
70
|
+
const listHeight = Math.max(1, bodyHeight - statusLineHeight);
|
|
71
|
+
const heights = subs.map(() => 1);
|
|
72
|
+
const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
|
|
73
|
+
topLineRef.current = topLine;
|
|
74
|
+
const { first, last } = sliceByLines(heights, topLine, listHeight);
|
|
75
|
+
const nameWidth = Math.max(...subs.map((s) => s.name.length));
|
|
76
|
+
return (_jsxs(Box, { flexDirection: "column", children: [subs.slice(first, last + 1).map((sub, i) => {
|
|
77
|
+
const index = first + i;
|
|
78
|
+
const isSelected = index === selected;
|
|
79
|
+
const marker = isSelected ? theme.glyphs.selection : ' ';
|
|
80
|
+
const background = isSelected ? theme.colors.selectionBackground : undefined;
|
|
81
|
+
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));
|
|
82
|
+
}), deleteConfirm && _jsxs(Text, { dimColor: true, children: ["delete '", deleteConfirm, "'? y/n"] })] }));
|
|
83
|
+
}
|
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,18 +1,20 @@
|
|
|
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
|
-
{
|
|
6
|
-
{
|
|
7
|
-
{
|
|
8
|
-
{
|
|
9
|
-
{
|
|
5
|
+
{ id: 'top', label: 'Top' },
|
|
6
|
+
{ id: 'new', label: 'New' },
|
|
7
|
+
{ id: 'best', label: 'Best' },
|
|
8
|
+
{ id: 'ask', label: 'Ask' },
|
|
9
|
+
{ id: 'show', label: 'Show' },
|
|
10
|
+
{ id: 'saved', label: 'Saved' },
|
|
11
|
+
{ id: 'subs', label: 'Subs' },
|
|
10
12
|
];
|
|
11
13
|
const BRAND = 'Hacker News';
|
|
12
14
|
const HELP_HINT = '? help';
|
|
13
15
|
const TAB_GAP = ' ';
|
|
14
16
|
function tabSegment(tab, active) {
|
|
15
|
-
if (tab.
|
|
17
|
+
if (tab.id !== active)
|
|
16
18
|
return { segment: { text: tab.label, color: 'muted' }, width: tab.label.length };
|
|
17
19
|
const inner = ` ${tab.label} `;
|
|
18
20
|
return { segment: { text: `│${inner}│`, color: 'accent', bold: true }, width: inner.length };
|
|
@@ -29,7 +31,7 @@ function buildTabSegments(active) {
|
|
|
29
31
|
}
|
|
30
32
|
const { segment, width } = tabSegment(tab, active);
|
|
31
33
|
segments.push(segment);
|
|
32
|
-
if (tab.
|
|
34
|
+
if (tab.id === active) {
|
|
33
35
|
activeWallColumn = column;
|
|
34
36
|
activeInnerWidth = width;
|
|
35
37
|
}
|
|
@@ -69,11 +71,12 @@ function buildBottomRuleSegments(columns, wallColumn, innerWidth) {
|
|
|
69
71
|
const line = base.slice(0, wallColumn) + notch + base.slice(wallColumn + notch.length);
|
|
70
72
|
return [{ text: line, color: 'accent' }];
|
|
71
73
|
}
|
|
72
|
-
function segmentColor(color) {
|
|
74
|
+
function segmentColor(color, theme) {
|
|
73
75
|
return color === 'accent' ? theme.colors.accent : theme.colors.muted;
|
|
74
76
|
}
|
|
75
77
|
function SegmentRow({ segments }) {
|
|
76
|
-
|
|
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))) }));
|
|
77
80
|
}
|
|
78
81
|
export function TabBar({ active }) {
|
|
79
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' },
|
|
@@ -14,6 +15,7 @@ export const LIST_KEYS = [
|
|
|
14
15
|
{ key: '/', label: 'search' },
|
|
15
16
|
{ key: 's', label: 'summary' },
|
|
16
17
|
{ key: 'a', label: 'ask ai' },
|
|
18
|
+
{ key: 'B', label: 'bookmark' },
|
|
17
19
|
];
|
|
18
20
|
export const COMMENTS_KEYS = [
|
|
19
21
|
{ key: 'j/k', label: 'move' },
|
|
@@ -24,6 +26,7 @@ export const COMMENTS_KEYS = [
|
|
|
24
26
|
{ key: 'r', label: 'reload' },
|
|
25
27
|
{ key: 's', label: 'summary' },
|
|
26
28
|
{ key: 'a', label: 'ask ai' },
|
|
29
|
+
{ key: 'B', label: 'bookmark' },
|
|
27
30
|
{ key: 'esc', label: 'back' },
|
|
28
31
|
];
|
|
29
32
|
export const SEARCH_RESULTS_KEYS = [
|
|
@@ -34,8 +37,53 @@ export const SEARCH_RESULTS_KEYS = [
|
|
|
34
37
|
{ key: '/', label: 'new search' },
|
|
35
38
|
{ key: 's', label: 'summary' },
|
|
36
39
|
{ key: 'a', label: 'ask ai' },
|
|
40
|
+
{ key: 'B', label: 'bookmark' },
|
|
41
|
+
{ key: 'S', label: 'subscribe' },
|
|
37
42
|
{ key: 'esc', label: 'back/quit' },
|
|
38
43
|
];
|
|
44
|
+
export const SUBS_KEYS = [
|
|
45
|
+
{ key: 'j/k', label: 'move' },
|
|
46
|
+
{ key: '←/→', label: 'tab' },
|
|
47
|
+
{ key: 't/n/b', label: 'feed' },
|
|
48
|
+
{ key: 'enter', label: 'matches' },
|
|
49
|
+
{ key: 'a', label: 'add' },
|
|
50
|
+
{ key: 'e', label: 'edit' },
|
|
51
|
+
{ key: 'd', label: 'delete' },
|
|
52
|
+
];
|
|
53
|
+
export const SUB_MATCHES_KEYS = [
|
|
54
|
+
{ key: 'j/k', label: 'move' },
|
|
55
|
+
{ key: 'gg/G', label: 'top/bottom' },
|
|
56
|
+
{ key: 'enter', label: 'comments' },
|
|
57
|
+
{ key: 'o', label: 'browser' },
|
|
58
|
+
{ key: 'B', label: 'bookmark' },
|
|
59
|
+
{ key: 's', label: 'summary' },
|
|
60
|
+
{ key: 'a', label: 'ask ai' },
|
|
61
|
+
{ key: 'r', label: 'refetch' },
|
|
62
|
+
{ key: 'esc', label: 'back' },
|
|
63
|
+
];
|
|
64
|
+
export const SUB_FORM_KEYS = [
|
|
65
|
+
{ key: 'tab', label: 'next field' },
|
|
66
|
+
{ key: 'enter', label: 'save' },
|
|
67
|
+
{ key: 'esc', label: 'cancel' },
|
|
68
|
+
];
|
|
69
|
+
export const THEME_PICKER_KEYS = [
|
|
70
|
+
{ key: 'j/k', label: 'move' },
|
|
71
|
+
{ key: 'enter', label: 'apply' },
|
|
72
|
+
{ key: 'esc', label: 'cancel' },
|
|
73
|
+
];
|
|
74
|
+
export const SAVED_KEYS = [
|
|
75
|
+
{ key: 'j/k', label: 'move' },
|
|
76
|
+
{ key: '←/→', label: 'tab' },
|
|
77
|
+
{ key: 't/n/b', label: 'feed' },
|
|
78
|
+
{ key: 'gg/G', label: 'top/bottom' },
|
|
79
|
+
{ key: 'enter', label: 'comments' },
|
|
80
|
+
{ key: 'o', label: 'browser' },
|
|
81
|
+
{ key: 'r', label: 'reload' },
|
|
82
|
+
{ key: '/', label: 'search' },
|
|
83
|
+
{ key: 's', label: 'summary' },
|
|
84
|
+
{ key: 'a', label: 'ask ai' },
|
|
85
|
+
{ key: 'B', label: 'remove' },
|
|
86
|
+
];
|
|
39
87
|
/** Footer hint string: view keys followed by the global keys. */
|
|
40
88
|
export function footerHint(viewKeys) {
|
|
41
89
|
return [...viewKeys, ...GLOBAL_KEYS].map((binding) => `${binding.key} ${binding.label}`).join(' · ');
|
package/dist/ui/theme.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
import { loadConfig } from '../lib/config.js';
|
|
1
3
|
const glyphs = {
|
|
2
4
|
selection: '❯',
|
|
3
5
|
foldOpen: '▾',
|
|
4
6
|
foldClosed: '▸',
|
|
5
7
|
upvote: '▲',
|
|
8
|
+
bookmark: '★',
|
|
6
9
|
};
|
|
7
10
|
function ansi256(code) {
|
|
8
11
|
return `ansi256(${code})`;
|
|
@@ -80,6 +83,18 @@ const palettes = {
|
|
|
80
83
|
link: LINK,
|
|
81
84
|
email: EMAIL,
|
|
82
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
|
+
},
|
|
83
98
|
};
|
|
84
99
|
const DEFAULT_PALETTE = 'hn';
|
|
85
100
|
export function paletteNames() {
|
|
@@ -88,11 +103,32 @@ export function paletteNames() {
|
|
|
88
103
|
function isPaletteName(name) {
|
|
89
104
|
return name in palettes;
|
|
90
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
|
+
}
|
|
91
118
|
export function resolvePaletteName(name) {
|
|
92
|
-
const
|
|
93
|
-
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';
|
|
94
126
|
}
|
|
95
127
|
export function resolveTheme(name) {
|
|
96
128
|
return { colors: palettes[resolvePaletteName(name)], glyphs };
|
|
97
129
|
}
|
|
98
130
|
export const theme = resolveTheme();
|
|
131
|
+
export const ThemeContext = createContext(theme);
|
|
132
|
+
export function useTheme() {
|
|
133
|
+
return useContext(ThemeContext);
|
|
134
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
const FLASH_MS = 1500;
|
|
3
|
+
/** Transient status text (e.g. "bookmarked ✓") that self-clears after FLASH_MS. */
|
|
4
|
+
export function useFlash() {
|
|
5
|
+
const [message, setMessage] = useState(null);
|
|
6
|
+
const timer = useRef(null);
|
|
7
|
+
function flash(text) {
|
|
8
|
+
if (timer.current)
|
|
9
|
+
clearTimeout(timer.current);
|
|
10
|
+
setMessage(text);
|
|
11
|
+
timer.current = setTimeout(() => setMessage(null), FLASH_MS);
|
|
12
|
+
}
|
|
13
|
+
return [message, flash];
|
|
14
|
+
}
|
package/dist/watch.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { searchRecent } from './api/algolia.js';
|
|
2
|
+
import { isSeen, markSeen } from './db/seen.js';
|
|
3
|
+
import { listSubscriptions, touchLastRun } from './db/subscriptions.js';
|
|
4
|
+
import { loadConfig } from './lib/config.js';
|
|
5
|
+
import { createTelegramNotifier } from './notify/telegram.js';
|
|
6
|
+
const SIX_HOURS = 6 * 60 * 60;
|
|
7
|
+
const TWENTY_FOUR_HOURS = 24 * 60 * 60;
|
|
8
|
+
function log(message) {
|
|
9
|
+
console.log(`[${new Date().toISOString()}] ${message}`);
|
|
10
|
+
}
|
|
11
|
+
function windowStart(lastRunAt, now) {
|
|
12
|
+
if (lastRunAt == null)
|
|
13
|
+
return now - TWENTY_FOUR_HOURS;
|
|
14
|
+
return Math.max(lastRunAt - SIX_HOURS, 0);
|
|
15
|
+
}
|
|
16
|
+
function buildNotifiers() {
|
|
17
|
+
const config = loadConfig();
|
|
18
|
+
const telegram = config?.telegram;
|
|
19
|
+
if (telegram?.enabled && telegram.botToken && telegram.chatId) {
|
|
20
|
+
return [createTelegramNotifier({ botToken: telegram.botToken, chatId: telegram.chatId })];
|
|
21
|
+
}
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
async function notifyStory(sub, story, notifiers) {
|
|
25
|
+
try {
|
|
26
|
+
for (const notifier of notifiers)
|
|
27
|
+
await notifier.send({ subscription: sub, story });
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
log(`${sub.name}: notify failed for ${story.id} - ${err.message}`);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function processSubscription(sub, notifiers, now, dryRun) {
|
|
36
|
+
let stories;
|
|
37
|
+
try {
|
|
38
|
+
stories = await searchRecent(sub.query, { createdAfter: windowStart(sub.lastRunAt, now), minPoints: sub.minPoints });
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
log(`${sub.name}: query failed - ${err.message}`);
|
|
42
|
+
return { notified: 0, hadFailure: true };
|
|
43
|
+
}
|
|
44
|
+
const matches = stories.filter((story) => !isSeen(story.id, sub.id)).sort((a, b) => a.time - b.time);
|
|
45
|
+
if (matches.length === 0) {
|
|
46
|
+
log(`${sub.name}: no new matches`);
|
|
47
|
+
if (!dryRun)
|
|
48
|
+
touchLastRun(sub.id, now);
|
|
49
|
+
return { notified: 0, hadFailure: false };
|
|
50
|
+
}
|
|
51
|
+
log(`${sub.name}: ${matches.length} new matches`);
|
|
52
|
+
let notified = 0;
|
|
53
|
+
let hadFailure = false;
|
|
54
|
+
for (const story of matches) {
|
|
55
|
+
if (dryRun) {
|
|
56
|
+
log(`would notify: [${sub.name}] ${story.title} (${story.score} pts)`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const sent = await notifyStory(sub, story, notifiers);
|
|
60
|
+
if (!sent) {
|
|
61
|
+
hadFailure = true;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
markSeen(story.id, sub.id, now);
|
|
65
|
+
log(`${sub.name}: notified ${story.id} "${story.title}" (${story.score} pts)`);
|
|
66
|
+
notified++;
|
|
67
|
+
}
|
|
68
|
+
if (!dryRun)
|
|
69
|
+
touchLastRun(sub.id, now);
|
|
70
|
+
return { notified, hadFailure };
|
|
71
|
+
}
|
|
72
|
+
/** hn watch --once: one-shot pass over all subscriptions. Returns the process exit code. */
|
|
73
|
+
export async function runWatch(options) {
|
|
74
|
+
const notifiers = buildNotifiers();
|
|
75
|
+
if (notifiers.length === 0) {
|
|
76
|
+
console.error('no notifier configured: hn config set telegram.enabled true (and botToken/chatId)');
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
const subs = listSubscriptions();
|
|
80
|
+
if (subs.length === 0) {
|
|
81
|
+
log('no subscriptions');
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
log(`watch: ${subs.length} subscriptions`);
|
|
85
|
+
const now = Math.floor(Date.now() / 1000);
|
|
86
|
+
let totalNotified = 0;
|
|
87
|
+
let failedSubs = 0;
|
|
88
|
+
for (const sub of subs) {
|
|
89
|
+
const result = await processSubscription(sub, notifiers, now, options.dryRun);
|
|
90
|
+
totalNotified += result.notified;
|
|
91
|
+
if (result.hadFailure)
|
|
92
|
+
failedSubs++;
|
|
93
|
+
}
|
|
94
|
+
log(`done: ${totalNotified} notified, ${failedSubs} failed`);
|
|
95
|
+
return failedSubs > 0 ? 1 : 0;
|
|
96
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hn-bits",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Terminal-first Hacker News client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"license": "MIT",
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@mozilla/readability": "^0.6.0",
|
|
29
|
+
"better-sqlite3": "^12.11.1",
|
|
29
30
|
"commander": "^15.0.0",
|
|
30
31
|
"ink": "^7.1.0",
|
|
31
32
|
"jsdom": "^29.1.1",
|
|
@@ -33,6 +34,7 @@
|
|
|
33
34
|
"react": "^19.2.7"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
37
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
36
38
|
"@types/jsdom": "^28.0.3",
|
|
37
39
|
"@types/node": "^26.1.0",
|
|
38
40
|
"@types/react": "^19.2.17",
|