hn-bits 0.2.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 +122 -0
- package/dist/ai/context.js +50 -0
- package/dist/ai/ollama.js +127 -0
- package/dist/ai/summaryPrompts.js +49 -0
- package/dist/api/algolia.js +51 -0
- package/dist/api/firebase.js +35 -0
- package/dist/index.js +93 -0
- package/dist/lib/article.js +71 -0
- package/dist/lib/commentTree.js +60 -0
- package/dist/lib/comments.js +3 -0
- package/dist/lib/config.js +52 -0
- package/dist/lib/configKeys.js +31 -0
- package/dist/lib/configStore.js +64 -0
- package/dist/lib/contactHighlight.js +18 -0
- package/dist/lib/format.js +20 -0
- package/dist/lib/html.js +37 -0
- package/dist/lib/listNavigation.js +18 -0
- package/dist/lib/url.js +10 -0
- package/dist/lib/viewport.js +81 -0
- package/dist/ui/App.js +75 -0
- package/dist/ui/AskAI.js +186 -0
- package/dist/ui/Comments.js +200 -0
- package/dist/ui/HelpOverlay.js +10 -0
- package/dist/ui/Layout.js +21 -0
- package/dist/ui/LoadingIndicator.js +14 -0
- package/dist/ui/SearchInput.js +21 -0
- package/dist/ui/SearchResults.js +128 -0
- package/dist/ui/StoryList.js +134 -0
- package/dist/ui/StoryListView.js +16 -0
- package/dist/ui/StoryRow.js +27 -0
- package/dist/ui/SummaryPanel.js +101 -0
- package/dist/ui/TabBar.js +82 -0
- package/dist/ui/keymap.js +46 -0
- package/dist/ui/theme.js +98 -0
- package/package.json +39 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import { chatStream, describeError } from '../ai/ollama.js';
|
|
5
|
+
import { AI_SETUP_HINT_LINES } from '../lib/config.js';
|
|
6
|
+
import { wrapPlainText } from '../lib/viewport.js';
|
|
7
|
+
import { theme } from './theme.js';
|
|
8
|
+
const SYSTEM_PROMPT = 'You are a concise assistant summarizing Hacker News content. Plain text only, no markdown headers. Max ~150 words.';
|
|
9
|
+
export function SummaryPanel({ config, buildPrompt, height, width, onClose }) {
|
|
10
|
+
const [status, setStatus] = useState(config ? 'preparing' : 'setup-hint');
|
|
11
|
+
const [content, setContent] = useState('');
|
|
12
|
+
const [notice, setNotice] = useState('');
|
|
13
|
+
const [error, setError] = useState('');
|
|
14
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
15
|
+
const token = useRef(0);
|
|
16
|
+
const controllerRef = useRef(null);
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (config)
|
|
19
|
+
void generate();
|
|
20
|
+
return () => controllerRef.current?.abort();
|
|
21
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
22
|
+
}, []);
|
|
23
|
+
async function generate() {
|
|
24
|
+
if (!config)
|
|
25
|
+
return;
|
|
26
|
+
controllerRef.current?.abort();
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
controllerRef.current = controller;
|
|
29
|
+
const myToken = ++token.current;
|
|
30
|
+
setStatus('preparing');
|
|
31
|
+
setContent('');
|
|
32
|
+
setNotice('');
|
|
33
|
+
setError('');
|
|
34
|
+
setScrollOffset(0);
|
|
35
|
+
let prompt;
|
|
36
|
+
try {
|
|
37
|
+
const result = await buildPrompt(controller.signal);
|
|
38
|
+
if (myToken !== token.current)
|
|
39
|
+
return;
|
|
40
|
+
prompt = result.prompt;
|
|
41
|
+
if (result.notice)
|
|
42
|
+
setNotice(result.notice);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (myToken !== token.current)
|
|
46
|
+
return;
|
|
47
|
+
setStatus('error');
|
|
48
|
+
setError(describeError(err));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
setStatus('thinking');
|
|
52
|
+
const messages = [
|
|
53
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
54
|
+
{ role: 'user', content: prompt },
|
|
55
|
+
];
|
|
56
|
+
try {
|
|
57
|
+
for await (const delta of chatStream(config.ollama, messages, controller.signal)) {
|
|
58
|
+
if (myToken !== token.current)
|
|
59
|
+
return;
|
|
60
|
+
setStatus('streaming');
|
|
61
|
+
setContent((prev) => prev + delta);
|
|
62
|
+
}
|
|
63
|
+
if (myToken === token.current)
|
|
64
|
+
setStatus('done');
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (myToken !== token.current)
|
|
68
|
+
return;
|
|
69
|
+
setStatus('error');
|
|
70
|
+
setError(describeError(err));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
useInput((input, key) => {
|
|
74
|
+
if (key.escape) {
|
|
75
|
+
controllerRef.current?.abort();
|
|
76
|
+
return onClose();
|
|
77
|
+
}
|
|
78
|
+
if (input === 's')
|
|
79
|
+
return void generate();
|
|
80
|
+
if (input === 'j')
|
|
81
|
+
return setScrollOffset((s) => s + 1);
|
|
82
|
+
if (input === 'k')
|
|
83
|
+
return setScrollOffset((s) => Math.max(0, s - 1));
|
|
84
|
+
});
|
|
85
|
+
const innerWidth = Math.max(1, width - 4);
|
|
86
|
+
const innerHeight = Math.max(1, height - 2);
|
|
87
|
+
const live = status === 'preparing' || status === 'thinking' || status === 'streaming';
|
|
88
|
+
const chromeRows = 2 + (notice ? 1 : 0);
|
|
89
|
+
const contentRows = Math.max(1, innerHeight - chromeRows);
|
|
90
|
+
const wrapped = wrapPlainText(content, innerWidth);
|
|
91
|
+
const maxScroll = Math.max(0, wrapped.length - contentRows);
|
|
92
|
+
const effectiveScroll = live ? maxScroll : Math.min(scrollOffset, maxScroll);
|
|
93
|
+
const visibleLines = wrapped.slice(effectiveScroll, effectiveScroll + contentRows);
|
|
94
|
+
const thinkingSuffix = status === 'preparing' || status === 'thinking' ? ' · thinking…' : '';
|
|
95
|
+
const headerLabel = config ? `summary · ${config.ollama.model}${thinkingSuffix}` : 'summary';
|
|
96
|
+
const footerHint = status === 'setup-hint' ? 'esc close' : 'esc close · s regenerate · j/k scroll';
|
|
97
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.colors.accent, height: height, width: width, paddingX: 1, children: [_jsx(Text, { color: theme.colors.title, children: headerLabel }), status === 'setup-hint' &&
|
|
98
|
+
AI_SETUP_HINT_LINES.map((line) => (_jsx(Text, { dimColor: true, children: line }, line))), status === 'error' && _jsx(Text, { color: theme.colors.error, children: error }), status !== 'setup-hint' &&
|
|
99
|
+
status !== 'error' &&
|
|
100
|
+
visibleLines.map((line, i) => (_jsxs(Text, { children: [line, live && i === visibleLines.length - 1 ? '▍' : ''] }, i))), notice && status !== 'setup-hint' && _jsx(Text, { dimColor: true, children: notice }), _jsx(Text, { dimColor: true, children: footerHint })] }));
|
|
101
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useWindowSize } from 'ink';
|
|
3
|
+
import { theme } from './theme.js';
|
|
4
|
+
const TABS = [
|
|
5
|
+
{ feed: 'top', label: 'Top' },
|
|
6
|
+
{ feed: 'new', label: 'New' },
|
|
7
|
+
{ feed: 'best', label: 'Best' },
|
|
8
|
+
{ feed: 'ask', label: 'Ask' },
|
|
9
|
+
{ feed: 'show', label: 'Show' },
|
|
10
|
+
];
|
|
11
|
+
const BRAND = 'Hacker News';
|
|
12
|
+
const HELP_HINT = '? help';
|
|
13
|
+
const TAB_GAP = ' ';
|
|
14
|
+
function tabSegment(tab, active) {
|
|
15
|
+
if (tab.feed !== active)
|
|
16
|
+
return { segment: { text: tab.label, color: 'muted' }, width: tab.label.length };
|
|
17
|
+
const inner = ` ${tab.label} `;
|
|
18
|
+
return { segment: { text: `│${inner}│`, color: 'accent', bold: true }, width: inner.length };
|
|
19
|
+
}
|
|
20
|
+
function buildTabSegments(active) {
|
|
21
|
+
const segments = [{ text: ` ${BRAND}${TAB_GAP}`, color: 'accent', bold: true }];
|
|
22
|
+
let column = segments[0].text.length;
|
|
23
|
+
let activeWallColumn = 0;
|
|
24
|
+
let activeInnerWidth = 0;
|
|
25
|
+
TABS.forEach((tab, index) => {
|
|
26
|
+
if (index > 0) {
|
|
27
|
+
segments.push({ text: TAB_GAP, color: 'muted' });
|
|
28
|
+
column += TAB_GAP.length;
|
|
29
|
+
}
|
|
30
|
+
const { segment, width } = tabSegment(tab, active);
|
|
31
|
+
segments.push(segment);
|
|
32
|
+
if (tab.feed === active) {
|
|
33
|
+
activeWallColumn = column;
|
|
34
|
+
activeInnerWidth = width;
|
|
35
|
+
}
|
|
36
|
+
column += segment.text.length;
|
|
37
|
+
});
|
|
38
|
+
return { segments, activeWallColumn, activeInnerWidth };
|
|
39
|
+
}
|
|
40
|
+
function appendHint(segments, usedColumns, columns) {
|
|
41
|
+
const padding = Math.max(1, columns - usedColumns - HELP_HINT.length);
|
|
42
|
+
segments.push({ text: ' '.repeat(padding), color: 'muted' }, { text: HELP_HINT, color: 'muted' });
|
|
43
|
+
}
|
|
44
|
+
function buildLine(active, columns) {
|
|
45
|
+
const line = buildTabSegments(active);
|
|
46
|
+
const usedColumns = line.segments.reduce((sum, segment) => sum + segment.text.length, 0);
|
|
47
|
+
appendHint(line.segments, usedColumns, columns);
|
|
48
|
+
return line;
|
|
49
|
+
}
|
|
50
|
+
/** Splits `base` into dim/accent segments, with `replacement` overlaid as accent at `start`. */
|
|
51
|
+
function splitAccent(base, start, replacement) {
|
|
52
|
+
const before = base.slice(0, start);
|
|
53
|
+
const after = base.slice(start + replacement.length);
|
|
54
|
+
const segments = [];
|
|
55
|
+
if (before)
|
|
56
|
+
segments.push({ text: before, color: 'muted' });
|
|
57
|
+
segments.push({ text: replacement, color: 'accent' });
|
|
58
|
+
if (after)
|
|
59
|
+
segments.push({ text: after, color: 'muted' });
|
|
60
|
+
return segments;
|
|
61
|
+
}
|
|
62
|
+
function buildTopBorderSegments(columns, wallColumn, innerWidth) {
|
|
63
|
+
const box = `╭${'─'.repeat(innerWidth)}╮`;
|
|
64
|
+
return splitAccent(' '.repeat(columns), wallColumn, box);
|
|
65
|
+
}
|
|
66
|
+
function buildBottomRuleSegments(columns, wallColumn, innerWidth) {
|
|
67
|
+
const notch = `╯${' '.repeat(innerWidth)}╰`;
|
|
68
|
+
const base = '─'.repeat(columns);
|
|
69
|
+
const line = base.slice(0, wallColumn) + notch + base.slice(wallColumn + notch.length);
|
|
70
|
+
return [{ text: line, color: 'accent' }];
|
|
71
|
+
}
|
|
72
|
+
function segmentColor(color) {
|
|
73
|
+
return color === 'accent' ? theme.colors.accent : theme.colors.muted;
|
|
74
|
+
}
|
|
75
|
+
function SegmentRow({ segments }) {
|
|
76
|
+
return (_jsx(Box, { children: segments.map((segment, index) => (_jsx(Text, { bold: segment.bold, color: segmentColor(segment.color), children: segment.text }, index))) }));
|
|
77
|
+
}
|
|
78
|
+
export function TabBar({ active }) {
|
|
79
|
+
const { columns } = useWindowSize();
|
|
80
|
+
const line = buildLine(active, columns);
|
|
81
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(SegmentRow, { segments: buildTopBorderSegments(columns, line.activeWallColumn, line.activeInnerWidth) }), _jsx(SegmentRow, { segments: line.segments }), _jsx(SegmentRow, { segments: buildBottomRuleSegments(columns, line.activeWallColumn, line.activeInnerWidth) })] }));
|
|
82
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { wrapPlainText } from '../lib/viewport.js';
|
|
2
|
+
export const GLOBAL_KEYS = [
|
|
3
|
+
{ key: 'q', label: 'quit' },
|
|
4
|
+
{ key: '?', label: 'help' },
|
|
5
|
+
];
|
|
6
|
+
export const LIST_KEYS = [
|
|
7
|
+
{ key: 'j/k', label: 'move' },
|
|
8
|
+
{ key: '←/→', label: 'tab' },
|
|
9
|
+
{ key: 't/n/b', label: 'feed' },
|
|
10
|
+
{ key: 'gg/G', label: 'top/bottom' },
|
|
11
|
+
{ key: 'enter', label: 'comments' },
|
|
12
|
+
{ key: 'o', label: 'browser' },
|
|
13
|
+
{ key: 'r', label: 'refresh' },
|
|
14
|
+
{ key: '/', label: 'search' },
|
|
15
|
+
{ key: 's', label: 'summary' },
|
|
16
|
+
{ key: 'a', label: 'ask ai' },
|
|
17
|
+
];
|
|
18
|
+
export const COMMENTS_KEYS = [
|
|
19
|
+
{ key: 'j/k', label: 'move' },
|
|
20
|
+
{ key: 'space', label: 'fold' },
|
|
21
|
+
{ key: 'C/E', label: 'collapse/expand' },
|
|
22
|
+
{ key: 'gg/G', label: 'top/bottom' },
|
|
23
|
+
{ key: 'o', label: 'browser' },
|
|
24
|
+
{ key: 'r', label: 'reload' },
|
|
25
|
+
{ key: 's', label: 'summary' },
|
|
26
|
+
{ key: 'a', label: 'ask ai' },
|
|
27
|
+
{ key: 'esc', label: 'back' },
|
|
28
|
+
];
|
|
29
|
+
export const SEARCH_RESULTS_KEYS = [
|
|
30
|
+
{ key: 'j/k', label: 'move' },
|
|
31
|
+
{ key: 'gg/G', label: 'top/bottom' },
|
|
32
|
+
{ key: 'enter', label: 'comments' },
|
|
33
|
+
{ key: 'o', label: 'browser' },
|
|
34
|
+
{ key: '/', label: 'new search' },
|
|
35
|
+
{ key: 's', label: 'summary' },
|
|
36
|
+
{ key: 'a', label: 'ask ai' },
|
|
37
|
+
{ key: 'esc', label: 'back/quit' },
|
|
38
|
+
];
|
|
39
|
+
/** Footer hint string: view keys followed by the global keys. */
|
|
40
|
+
export function footerHint(viewKeys) {
|
|
41
|
+
return [...viewKeys, ...GLOBAL_KEYS].map((binding) => `${binding.key} ${binding.label}`).join(' · ');
|
|
42
|
+
}
|
|
43
|
+
/** How many terminal rows the footer hint wraps to at the given width. */
|
|
44
|
+
export function footerRows(viewKeys, width) {
|
|
45
|
+
return wrapPlainText(footerHint(viewKeys), width).length;
|
|
46
|
+
}
|
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const glyphs = {
|
|
2
|
+
selection: '❯',
|
|
3
|
+
foldOpen: '▾',
|
|
4
|
+
foldClosed: '▸',
|
|
5
|
+
upvote: '▲',
|
|
6
|
+
};
|
|
7
|
+
function ansi256(code) {
|
|
8
|
+
return `ansi256(${code})`;
|
|
9
|
+
}
|
|
10
|
+
// Shared across every palette — selection is a UI affordance, not a theme accent.
|
|
11
|
+
const SELECTION_BACKGROUND = ansi256(238);
|
|
12
|
+
// Shared across every palette — contact highlighting is an affordance, not a theme accent.
|
|
13
|
+
const LINK = ansi256(81);
|
|
14
|
+
const EMAIL = ansi256(114);
|
|
15
|
+
// Ported from heartleo/hn-cli's internal/cli/colors.go (subset of roles this app uses).
|
|
16
|
+
const palettes = {
|
|
17
|
+
hn: {
|
|
18
|
+
accent: ansi256(208),
|
|
19
|
+
title: ansi256(255),
|
|
20
|
+
muted: ansi256(243),
|
|
21
|
+
error: ansi256(204),
|
|
22
|
+
score: ansi256(208),
|
|
23
|
+
comment: ansi256(243),
|
|
24
|
+
selectionBackground: SELECTION_BACKGROUND,
|
|
25
|
+
link: LINK,
|
|
26
|
+
email: EMAIL,
|
|
27
|
+
},
|
|
28
|
+
mocha: {
|
|
29
|
+
accent: ansi256(183),
|
|
30
|
+
title: ansi256(189),
|
|
31
|
+
muted: ansi256(243),
|
|
32
|
+
error: ansi256(204),
|
|
33
|
+
score: ansi256(208),
|
|
34
|
+
comment: ansi256(109),
|
|
35
|
+
selectionBackground: SELECTION_BACKGROUND,
|
|
36
|
+
link: LINK,
|
|
37
|
+
email: EMAIL,
|
|
38
|
+
},
|
|
39
|
+
dracula: {
|
|
40
|
+
accent: ansi256(141),
|
|
41
|
+
title: ansi256(231),
|
|
42
|
+
muted: ansi256(61),
|
|
43
|
+
error: ansi256(210),
|
|
44
|
+
score: ansi256(208),
|
|
45
|
+
comment: ansi256(117),
|
|
46
|
+
selectionBackground: SELECTION_BACKGROUND,
|
|
47
|
+
link: LINK,
|
|
48
|
+
email: EMAIL,
|
|
49
|
+
},
|
|
50
|
+
tokyo: {
|
|
51
|
+
accent: ansi256(75),
|
|
52
|
+
title: ansi256(189),
|
|
53
|
+
muted: ansi256(59),
|
|
54
|
+
error: ansi256(203),
|
|
55
|
+
score: ansi256(208),
|
|
56
|
+
comment: ansi256(73),
|
|
57
|
+
selectionBackground: SELECTION_BACKGROUND,
|
|
58
|
+
link: LINK,
|
|
59
|
+
email: EMAIL,
|
|
60
|
+
},
|
|
61
|
+
nord: {
|
|
62
|
+
accent: ansi256(110),
|
|
63
|
+
title: ansi256(253),
|
|
64
|
+
muted: ansi256(60),
|
|
65
|
+
error: ansi256(174),
|
|
66
|
+
score: ansi256(208),
|
|
67
|
+
comment: ansi256(73),
|
|
68
|
+
selectionBackground: SELECTION_BACKGROUND,
|
|
69
|
+
link: LINK,
|
|
70
|
+
email: EMAIL,
|
|
71
|
+
},
|
|
72
|
+
gruvbox: {
|
|
73
|
+
accent: ansi256(208),
|
|
74
|
+
title: ansi256(223),
|
|
75
|
+
muted: ansi256(245),
|
|
76
|
+
error: ansi256(167),
|
|
77
|
+
score: ansi256(208),
|
|
78
|
+
comment: ansi256(108),
|
|
79
|
+
selectionBackground: SELECTION_BACKGROUND,
|
|
80
|
+
link: LINK,
|
|
81
|
+
email: EMAIL,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const DEFAULT_PALETTE = 'hn';
|
|
85
|
+
export function paletteNames() {
|
|
86
|
+
return Object.keys(palettes);
|
|
87
|
+
}
|
|
88
|
+
function isPaletteName(name) {
|
|
89
|
+
return name in palettes;
|
|
90
|
+
}
|
|
91
|
+
export function resolvePaletteName(name) {
|
|
92
|
+
const candidate = name ?? process.env['HN_THEME'] ?? DEFAULT_PALETTE;
|
|
93
|
+
return isPaletteName(candidate) ? candidate : DEFAULT_PALETTE;
|
|
94
|
+
}
|
|
95
|
+
export function resolveTheme(name) {
|
|
96
|
+
return { colors: palettes[resolvePaletteName(name)], glyphs };
|
|
97
|
+
}
|
|
98
|
+
export const theme = resolveTheme();
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hn-bits",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Terminal-first Hacker News client",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hn": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=22"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "tsx src/index.tsx",
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"start": "node dist/index.js",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@mozilla/readability": "^0.6.0",
|
|
25
|
+
"commander": "^15.0.0",
|
|
26
|
+
"ink": "^7.1.0",
|
|
27
|
+
"jsdom": "^29.1.1",
|
|
28
|
+
"open": "^11.0.0",
|
|
29
|
+
"react": "^19.2.7"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/jsdom": "^28.0.3",
|
|
33
|
+
"@types/node": "^26.1.0",
|
|
34
|
+
"@types/react": "^19.2.17",
|
|
35
|
+
"tsx": "^4.23.0",
|
|
36
|
+
"typescript": "^6.0.3",
|
|
37
|
+
"vitest": "^4.1.10"
|
|
38
|
+
}
|
|
39
|
+
}
|