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.
@@ -0,0 +1,200 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Box, Text, useInput, useWindowSize } from 'ink';
4
+ import open from 'open';
5
+ import { buildCommentsSummaryPrompt } from '../ai/summaryPrompts.js';
6
+ import { fetchComments } from '../api/algolia.js';
7
+ import { hnItemUrl } from '../api/firebase.js';
8
+ import { collapseAll, flattenTree, headerOnlyAll, rehideRevealed, revealHeaderOnly, toggleFold, } from '../lib/commentTree.js';
9
+ import { tokenizeContacts } from '../lib/contactHighlight.js';
10
+ import { formatAge } from '../lib/format.js';
11
+ import { htmlToText } from '../lib/html.js';
12
+ import { clampSelection } from '../lib/listNavigation.js';
13
+ import { ensureVisibleLines, sliceByLines, wrapPlainText } from '../lib/viewport.js';
14
+ import { COMMENTS_KEYS, footerRows } from './keymap.js';
15
+ import { HEADER_ROWS } from './Layout.js';
16
+ import { LoadingIndicator } from './LoadingIndicator.js';
17
+ import { SummaryPanel } from './SummaryPanel.js';
18
+ import { theme } from './theme.js';
19
+ const HEADER_BORDER_LINES = 2;
20
+ // borderStyle (2 cols) + paddingX (2 cols) eaten from the card's interior width.
21
+ const HEADER_FRAME_WIDTH = 4;
22
+ const ROW_BORDER_WIDTH = 2;
23
+ const SELECTION_BAR = '│';
24
+ // Long titles/URLs word-wrap inside the bordered card; undercounting that height
25
+ // shrinks the comment viewport too little and overflows the terminal (corrupts the frame).
26
+ export function commentsHeaderLines(story, columns) {
27
+ const width = Math.max(1, columns - HEADER_FRAME_WIDTH);
28
+ const titleLines = wrapPlainText(story.title, width).length;
29
+ const urlLines = story.url ? wrapPlainText(story.url, width).length : 0;
30
+ return HEADER_BORDER_LINES + titleLines + 1 + urlLines;
31
+ }
32
+ export function Comments({ story, config, onBack, onAskAI }) {
33
+ const { columns, rows } = useWindowSize();
34
+ const headerLines = commentsHeaderLines(story, columns);
35
+ const viewportLines = Math.max(1, rows - HEADER_ROWS - footerRows(COMMENTS_KEYS, columns) - headerLines);
36
+ const [status, setStatus] = useState('loading');
37
+ const [error, setError] = useState('');
38
+ const [comments, setComments] = useState([]);
39
+ const [fold, setFold] = useState({
40
+ folded: new Set(),
41
+ headerOnly: new Set(),
42
+ revealed: new Set(),
43
+ });
44
+ const [selected, setSelected] = useState(0);
45
+ const [summaryOpen, setSummaryOpen] = useState(false);
46
+ const pendingTopJump = useRef(false);
47
+ const topLineRef = useRef(0);
48
+ const { folded, headerOnly, revealed } = fold;
49
+ useEffect(() => {
50
+ void load();
51
+ }, []);
52
+ async function load() {
53
+ setStatus('loading');
54
+ try {
55
+ const tree = await fetchComments(story.id);
56
+ setComments(tree);
57
+ setFold({ folded: collapseAll(tree), headerOnly: new Set(), revealed: new Set() });
58
+ setSelected(0);
59
+ setStatus('ready');
60
+ }
61
+ catch (err) {
62
+ setStatus('error');
63
+ setError(err.message);
64
+ }
65
+ }
66
+ const flat = useMemo(() => flattenTree(comments, folded, headerOnly), [comments, folded, headerOnly]);
67
+ const rowsData = useMemo(() => flat.map((entry) => buildRow(entry, columns)), [flat, columns]);
68
+ const clampedSelected = clampSelection(selected, 0, flat.length);
69
+ function openStory() {
70
+ void open(story.url ?? hnItemUrl(story.id));
71
+ }
72
+ function toggleSelected() {
73
+ const row = flat[clampedSelected];
74
+ if (!row)
75
+ return;
76
+ const id = row.node.id;
77
+ if (row.headerOnly) {
78
+ return setFold((f) => ({ ...f, ...revealHeaderOnly(f, id) }));
79
+ }
80
+ if (row.node.children.length === 0) {
81
+ if (revealed.has(id))
82
+ setFold((f) => ({ ...f, ...rehideRevealed(f, id) }));
83
+ return;
84
+ }
85
+ setFold((f) => ({ ...f, folded: toggleFold(f.folded, id) }));
86
+ }
87
+ function collapseEverything() {
88
+ setFold((f) => ({ ...f, headerOnly: headerOnlyAll(comments), revealed: new Set() }));
89
+ }
90
+ function resetFold() {
91
+ setFold({ folded: collapseAll(comments), headerOnly: new Set(), revealed: new Set() });
92
+ }
93
+ function handleInput(input, key) {
94
+ const isG = input === 'g';
95
+ if (isG && pendingTopJump.current) {
96
+ pendingTopJump.current = false;
97
+ return setSelected(0);
98
+ }
99
+ pendingTopJump.current = isG;
100
+ if (key.escape || input === 'b')
101
+ return onBack();
102
+ if (input === 'j' || key.downArrow)
103
+ return setSelected(clampSelection(clampedSelected, 1, flat.length));
104
+ if (input === 'k' || key.upArrow)
105
+ return setSelected(clampSelection(clampedSelected, -1, flat.length));
106
+ if (input === 'G')
107
+ return setSelected(Math.max(0, flat.length - 1));
108
+ if (input === ' ' || key.return)
109
+ return toggleSelected();
110
+ if (input === 'C')
111
+ return collapseEverything();
112
+ if (input === 'E')
113
+ return resetFold();
114
+ if (input === 'o')
115
+ return openStory();
116
+ if (input === 'r')
117
+ return void load();
118
+ if (input === 's')
119
+ return setSummaryOpen(true);
120
+ if (input === 'a')
121
+ return onAskAI(comments);
122
+ }
123
+ useInput(handleInput, { isActive: !summaryOpen });
124
+ if (status === 'loading')
125
+ return _jsx(LoadingIndicator, { label: "Loading comments..." });
126
+ if (status === 'error')
127
+ return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
128
+ const panelHeight = summaryOpen ? Math.max(6, Math.floor(viewportLines / 2)) : 0;
129
+ const listViewportLines = Math.max(1, viewportLines - panelHeight);
130
+ const heights = rowsData.map((row) => row.lines.length);
131
+ const topLine = ensureVisibleLines(heights, clampedSelected, topLineRef.current, listViewportLines);
132
+ topLineRef.current = topLine;
133
+ const { first, last, clipTop, clipBottom } = sliceByLines(heights, topLine, listViewportLines);
134
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(CommentsHeader, { story: story }), flat.length === 0 && _jsx(Text, { dimColor: true, children: "no comments yet" }), rowsData.slice(first, last + 1).map((row, i) => {
135
+ const rowIndex = first + i;
136
+ const start = rowIndex === first ? clipTop : 0;
137
+ const end = rowIndex === last ? row.lines.length - clipBottom : row.lines.length;
138
+ return (_jsx(CommentRowView, { depth: row.depth, width: row.width, lines: row.lines.slice(start, end), isSelected: rowIndex === clampedSelected }, row.id));
139
+ }), summaryOpen && (_jsx(SummaryPanel, { config: config, buildPrompt: () => Promise.resolve(buildCommentsSummaryPrompt(story, comments)), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
140
+ }
141
+ function CommentsHeader({ story }) {
142
+ 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 })] }));
143
+ }
144
+ function replyBadge(entry) {
145
+ if (entry.descendantCount === 0)
146
+ return '';
147
+ if (entry.headerOnly)
148
+ return `[${entry.descendantCount} more]`;
149
+ return entry.descendantCount === 1 ? '1 reply' : `${entry.descendantCount} replies`;
150
+ }
151
+ function buildHeaderSpans(entry) {
152
+ const glyph = entry.headerOnly ? theme.glyphs.foldClosed : theme.glyphs.foldOpen;
153
+ const badge = replyBadge(entry);
154
+ const spans = [
155
+ { text: `${glyph} `, color: theme.colors.accent },
156
+ { text: entry.node.author, color: theme.colors.accent, bold: true },
157
+ { text: ` · ${formatAge(entry.node.time)}`, color: theme.colors.muted },
158
+ ];
159
+ if (badge)
160
+ spans.push({ text: ` | ${badge}`, color: theme.colors.muted });
161
+ return spans;
162
+ }
163
+ function tokenToSpan(token) {
164
+ if (token.kind === 'link')
165
+ return { text: token.text, color: theme.colors.link, underline: true };
166
+ if (token.kind === 'email')
167
+ return { text: token.text, color: theme.colors.email, bold: true };
168
+ return { text: token.text };
169
+ }
170
+ function buildRow(entry, columns) {
171
+ // Reserve 2 columns for the selection bar so wrapping doesn't shift when selection moves,
172
+ // plus 1 trailing column: a line that exactly fills the terminal width triggers a VT100
173
+ // delayed-wrap that drops the selection bar/stripe from the following line.
174
+ const width = Math.max(1, columns - 1 - entry.depth * 2 - ROW_BORDER_WIDTH);
175
+ const header = { kind: 'header', spans: buildHeaderSpans(entry) };
176
+ const body = entry.headerOnly
177
+ ? []
178
+ : wrapPlainText(htmlToText(entry.node.text), width).map((line) => ({
179
+ kind: 'body',
180
+ spans: tokenizeContacts(line).map(tokenToSpan),
181
+ }));
182
+ return { id: entry.node.id, depth: entry.depth, width, lines: [header, ...body] };
183
+ }
184
+ function spanLength(spans) {
185
+ return spans.reduce((sum, span) => sum + span.text.length, 0);
186
+ }
187
+ function CommentRowView({ depth, width, lines, isSelected }) {
188
+ const background = isSelected ? theme.colors.selectionBackground : undefined;
189
+ const prefix = isSelected ? `${SELECTION_BAR} ` : ' ';
190
+ const rowWidth = width + ROW_BORDER_WIDTH;
191
+ return (_jsx(Box, { marginLeft: depth * 2, flexDirection: "column", children: lines.map((line, i) => {
192
+ const spans = [
193
+ { text: prefix, color: isSelected ? theme.colors.accent : undefined },
194
+ ...line.spans,
195
+ ];
196
+ if (isSelected)
197
+ spans.push({ text: ' '.repeat(Math.max(0, rowWidth - spanLength(spans))) });
198
+ return (_jsx(Text, { backgroundColor: background, wrap: "truncate-end", children: spans.map((span, j) => (_jsx(Text, { color: span.color, bold: span.bold, underline: span.underline, children: span.text }, j))) }, i));
199
+ }) }));
200
+ }
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { GLOBAL_KEYS } from './keymap.js';
4
+ import { theme } from './theme.js';
5
+ export function HelpOverlay({ title, keys }) {
6
+ 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
+ function KeyRow({ binding }) {
9
+ return (_jsxs(Text, { children: [' ', binding.key.padEnd(8), " ", binding.label] }));
10
+ }
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text, useWindowSize } from 'ink';
3
+ import { theme } from './theme.js';
4
+ const MIN_ROWS = 8;
5
+ export const HEADER_ROWS = 3;
6
+ export function Screen({ children }) {
7
+ const { columns, rows } = useWindowSize();
8
+ if (rows < MIN_ROWS) {
9
+ return _jsx(Text, { children: "Terminal too small \u2014 resize to continue." });
10
+ }
11
+ return (_jsx(Box, { flexDirection: "column", width: columns, height: rows, children: children }));
12
+ }
13
+ export function Header({ children }) {
14
+ return _jsx(Box, { flexShrink: 0, children: children });
15
+ }
16
+ export function Body({ children }) {
17
+ return (_jsx(Box, { flexGrow: 1, overflowY: "hidden", flexDirection: "column", children: children }));
18
+ }
19
+ export function Footer({ children }) {
20
+ return (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: theme.colors.muted, children: children }) }));
21
+ }
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Text } from 'ink';
4
+ import { theme } from './theme.js';
5
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
6
+ const FRAME_INTERVAL_MS = 80;
7
+ export function LoadingIndicator({ label }) {
8
+ const [frame, setFrame] = useState(0);
9
+ useEffect(() => {
10
+ const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), FRAME_INTERVAL_MS);
11
+ return () => clearInterval(id);
12
+ }, []);
13
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.colors.accent, children: FRAMES[frame] }), " ", _jsx(Text, { color: theme.colors.title, children: label })] }));
14
+ }
@@ -0,0 +1,21 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Text, useInput } from 'ink';
4
+ export function SearchInput({ onSubmit, onCancel }) {
5
+ const [query, setQuery] = useState('');
6
+ useInput((input, key) => {
7
+ if (key.escape)
8
+ return onCancel();
9
+ if (key.return)
10
+ return submit();
11
+ if (key.backspace || key.delete)
12
+ return setQuery((q) => q.slice(0, -1));
13
+ if (input && !key.ctrl && !key.meta)
14
+ setQuery((q) => q + input);
15
+ });
16
+ function submit() {
17
+ if (query.trim())
18
+ onSubmit(query.trim());
19
+ }
20
+ return _jsxs(Text, { children: ["/ ", query, "\u258A"] });
21
+ }
@@ -0,0 +1,128 @@
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 { searchStories } from '../api/algolia.js';
7
+ import { hnItemUrl } from '../api/firebase.js';
8
+ import { clampSelection } from '../lib/listNavigation.js';
9
+ import { ensureVisibleLines, shouldFetchMore } from '../lib/viewport.js';
10
+ import { footerRows, SEARCH_RESULTS_KEYS } from './keymap.js';
11
+ import { HEADER_ROWS } from './Layout.js';
12
+ import { LoadingIndicator } from './LoadingIndicator.js';
13
+ import { STORY_ROW_HEIGHT } from './StoryRow.js';
14
+ import { StoryListView } from './StoryListView.js';
15
+ import { SummaryPanel } from './SummaryPanel.js';
16
+ import { theme } from './theme.js';
17
+ const FETCH_THRESHOLD = 10;
18
+ const HEADER_LINES = 1;
19
+ export function SearchResults({ query, config, onSelectStory, onExit, onSearchAgain, onAskAI, }) {
20
+ const { columns, rows } = useWindowSize();
21
+ const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SEARCH_RESULTS_KEYS, columns) - HEADER_LINES);
22
+ const [stories, setStories] = useState([]);
23
+ const [hasMore, setHasMore] = useState(false);
24
+ const [totalHits, setTotalHits] = useState(null);
25
+ const [selected, setSelected] = useState(0);
26
+ const [status, setStatus] = useState('loading');
27
+ const [loadingMore, setLoadingMore] = useState(false);
28
+ const [error, setError] = useState('');
29
+ const [summaryOpen, setSummaryOpen] = useState(false);
30
+ const token = useRef(0);
31
+ const nextPage = useRef(0);
32
+ const topLineRef = useRef(0);
33
+ useEffect(() => {
34
+ void loadFirstPage();
35
+ }, [query]);
36
+ useEffect(() => {
37
+ const totalCount = hasMore ? Infinity : stories.length;
38
+ if (status === 'ready' && !loadingMore && shouldFetchMore(selected, stories.length, totalCount, FETCH_THRESHOLD)) {
39
+ void loadMore();
40
+ }
41
+ }, [selected, stories.length, hasMore, status, loadingMore]);
42
+ async function loadFirstPage() {
43
+ const myToken = ++token.current;
44
+ nextPage.current = 0;
45
+ setStatus('loading');
46
+ setSelected(0);
47
+ setStories([]);
48
+ setTotalHits(null);
49
+ try {
50
+ const result = await searchStories(query, 0);
51
+ if (myToken !== token.current)
52
+ return;
53
+ nextPage.current = 1;
54
+ setStories(result.stories);
55
+ setHasMore(result.hasMore);
56
+ setTotalHits(result.totalHits);
57
+ setStatus('ready');
58
+ }
59
+ catch (err) {
60
+ if (myToken === token.current)
61
+ failWith(err);
62
+ }
63
+ }
64
+ async function loadMore() {
65
+ const myToken = token.current;
66
+ const page = nextPage.current;
67
+ setLoadingMore(true);
68
+ try {
69
+ const result = await searchStories(query, page);
70
+ if (myToken !== token.current)
71
+ return;
72
+ nextPage.current = page + 1;
73
+ setStories((prev) => [...prev, ...result.stories]);
74
+ setHasMore(result.hasMore);
75
+ }
76
+ catch (err) {
77
+ if (myToken === token.current)
78
+ failWith(err);
79
+ }
80
+ finally {
81
+ if (myToken === token.current)
82
+ setLoadingMore(false);
83
+ }
84
+ }
85
+ function failWith(err) {
86
+ setStatus('error');
87
+ setError(err.message);
88
+ }
89
+ function openSelectedStory() {
90
+ const story = stories[selected];
91
+ if (story)
92
+ void open(story.url ?? hnItemUrl(story.id));
93
+ }
94
+ function handleInput(input, key) {
95
+ if (input === '/')
96
+ return onSearchAgain();
97
+ if (key.escape)
98
+ return onExit();
99
+ if (input === 'j' || key.downArrow)
100
+ return setSelected((s) => clampSelection(s, 1, stories.length));
101
+ if (input === 'k' || key.upArrow)
102
+ return setSelected((s) => clampSelection(s, -1, stories.length));
103
+ if (input === 'G')
104
+ return setSelected(Math.max(0, stories.length - 1));
105
+ if (input === 'o')
106
+ return openSelectedStory();
107
+ if (input === 'r' && status === 'error')
108
+ return void loadFirstPage();
109
+ if (input === 's' && stories[selected])
110
+ return setSummaryOpen(true);
111
+ if (input === 'a' && stories[selected])
112
+ return onAskAI(stories[selected]);
113
+ if (key.return && stories[selected])
114
+ return onSelectStory(stories[selected]);
115
+ }
116
+ useInput(handleInput, { isActive: !summaryOpen });
117
+ if (status === 'loading')
118
+ return _jsx(LoadingIndicator, { label: "Searching..." });
119
+ if (status === 'error')
120
+ return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
121
+ const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
122
+ const listHeight = Math.max(1, (loadingMore ? bodyHeight - 1 : bodyHeight) - panelHeight);
123
+ const heights = stories.map(() => STORY_ROW_HEIGHT);
124
+ const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
125
+ topLineRef.current = topLine;
126
+ const selectedStory = stories[selected];
127
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["search: ", query, totalHits !== null ? ` ${totalHits} results` : ''] }), _jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), loadingMore && _jsx(Text, { dimColor: true, children: "loading more\u2026" }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
128
+ }
@@ -0,0 +1,134 @@
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 { fetchStories, fetchStoryIds, hnItemUrl } from '../api/firebase.js';
7
+ import { clampSelection, mapFeedKey, nextFeed, previousFeed } from '../lib/listNavigation.js';
8
+ import { ensureVisibleLines, shouldFetchMore } from '../lib/viewport.js';
9
+ import { footerRows, LIST_KEYS } from './keymap.js';
10
+ import { HEADER_ROWS } from './Layout.js';
11
+ import { LoadingIndicator } from './LoadingIndicator.js';
12
+ import { STORY_ROW_HEIGHT } from './StoryRow.js';
13
+ import { StoryListView } from './StoryListView.js';
14
+ import { SummaryPanel } from './SummaryPanel.js';
15
+ import { theme } from './theme.js';
16
+ const BATCH_SIZE = 30;
17
+ const FETCH_THRESHOLD = 10;
18
+ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchRequested, onAskAI, }) {
19
+ const { columns, rows } = useWindowSize();
20
+ const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(LIST_KEYS, columns));
21
+ const [storyIds, setStoryIds] = useState([]);
22
+ const [stories, setStories] = useState([]);
23
+ const [selected, setSelected] = useState(0);
24
+ const [status, setStatus] = useState('loading');
25
+ const [loadingMore, setLoadingMore] = useState(false);
26
+ const [error, setError] = useState('');
27
+ const [summaryOpen, setSummaryOpen] = useState(false);
28
+ const token = useRef(0);
29
+ const pendingTopJump = useRef(false);
30
+ const topLineRef = useRef(0);
31
+ useEffect(() => {
32
+ void loadFeed();
33
+ }, [feed]);
34
+ useEffect(() => {
35
+ if (status === 'ready' && !loadingMore && shouldFetchMore(selected, stories.length, storyIds.length, FETCH_THRESHOLD)) {
36
+ void loadMore();
37
+ }
38
+ }, [selected, stories.length, storyIds.length, status, loadingMore]);
39
+ async function loadFeed() {
40
+ const myToken = ++token.current;
41
+ setStatus('loading');
42
+ setSelected(0);
43
+ setStories([]);
44
+ try {
45
+ const ids = await fetchStoryIds(feed);
46
+ if (myToken !== token.current)
47
+ return;
48
+ setStoryIds(ids);
49
+ const firstBatch = await fetchStories(ids.slice(0, BATCH_SIZE));
50
+ if (myToken !== token.current)
51
+ return;
52
+ setStories(firstBatch);
53
+ setStatus('ready');
54
+ }
55
+ catch (err) {
56
+ if (myToken === token.current)
57
+ failWith(err);
58
+ }
59
+ }
60
+ async function loadMore() {
61
+ const myToken = token.current;
62
+ const alreadyFetched = stories.length;
63
+ setLoadingMore(true);
64
+ try {
65
+ const nextIds = storyIds.slice(alreadyFetched, alreadyFetched + BATCH_SIZE);
66
+ const more = await fetchStories(nextIds);
67
+ if (myToken !== token.current)
68
+ return;
69
+ setStories((prev) => [...prev, ...more]);
70
+ }
71
+ catch (err) {
72
+ if (myToken === token.current)
73
+ failWith(err);
74
+ }
75
+ finally {
76
+ if (myToken === token.current)
77
+ setLoadingMore(false);
78
+ }
79
+ }
80
+ function failWith(err) {
81
+ setStatus('error');
82
+ setError(err.message);
83
+ }
84
+ function openSelectedStory() {
85
+ const story = stories[selected];
86
+ if (story)
87
+ void open(story.url ?? hnItemUrl(story.id));
88
+ }
89
+ function handleInput(input, key) {
90
+ const isG = input === 'g';
91
+ if (isG && pendingTopJump.current) {
92
+ pendingTopJump.current = false;
93
+ return setSelected(0);
94
+ }
95
+ pendingTopJump.current = isG;
96
+ const targetFeed = mapFeedKey(input);
97
+ if (targetFeed)
98
+ return onFeedChange(targetFeed);
99
+ if (key.leftArrow)
100
+ return onFeedChange(previousFeed(feed));
101
+ if (key.rightArrow)
102
+ return onFeedChange(nextFeed(feed));
103
+ if (input === '/')
104
+ return onSearchRequested();
105
+ if (input === 'j' || key.downArrow)
106
+ return setSelected((s) => clampSelection(s, 1, stories.length));
107
+ if (input === 'k' || key.upArrow)
108
+ return setSelected((s) => clampSelection(s, -1, stories.length));
109
+ if (input === 'G')
110
+ return setSelected(Math.max(0, stories.length - 1));
111
+ if (input === 'o')
112
+ return openSelectedStory();
113
+ if (input === 'r')
114
+ return void loadFeed();
115
+ if (input === 's' && stories[selected])
116
+ return setSummaryOpen(true);
117
+ if (input === 'a' && stories[selected])
118
+ return onAskAI(stories[selected]);
119
+ if (key.return && stories[selected])
120
+ return onSelectStory(stories[selected]);
121
+ }
122
+ useInput(handleInput, { isActive: !summaryOpen });
123
+ if (status === 'loading')
124
+ return _jsx(LoadingIndicator, { label: "Loading stories..." });
125
+ if (status === 'error')
126
+ return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
127
+ const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
128
+ const listHeight = Math.max(1, (loadingMore ? bodyHeight - 1 : bodyHeight) - panelHeight);
129
+ const heights = stories.map(() => STORY_ROW_HEIGHT);
130
+ const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
131
+ topLineRef.current = topLine;
132
+ const selectedStory = stories[selected];
133
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), loadingMore && _jsx(Text, { dimColor: true, children: "loading more\u2026" }), summaryOpen && selectedStory && (_jsx(SummaryPanel, { config: config, buildPrompt: (signal) => buildListSummaryPrompt(selectedStory, signal), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
134
+ }
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box } from 'ink';
3
+ import { sliceByLines } from '../lib/viewport.js';
4
+ import { STORY_ROW_HEIGHT, StoryRow } from './StoryRow.js';
5
+ export function StoryListView({ stories, selected, topLine, height, width }) {
6
+ const heights = stories.map(() => STORY_ROW_HEIGHT);
7
+ const { first, last, clipTop, clipBottom } = sliceByLines(heights, topLine, height);
8
+ const rankWidth = String(stories.length).length;
9
+ return (_jsx(Box, { flexDirection: "column", children: stories.slice(first, last + 1).map((story, i) => {
10
+ const index = first + i;
11
+ const clip = (index === first ? clipTop : 0) + (index === last ? clipBottom : 0);
12
+ // Edge clipping order: separator drops first, then meta, then title — title is always last standing.
13
+ const visibleLines = STORY_ROW_HEIGHT - clip;
14
+ return (_jsx(StoryRow, { story: story, rank: index + 1, rankWidth: rankWidth, isSelected: index === selected, width: width, showMeta: visibleLines >= 2, showSeparator: visibleLines >= 3 }, story.id));
15
+ }) }));
16
+ }
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { formatAge, truncateTitle } from '../lib/format.js';
4
+ import { getHostname } from '../lib/url.js';
5
+ import { theme } from './theme.js';
6
+ export const STORY_ROW_HEIGHT = 3;
7
+ function fillPad(isSelected, safeWidth, usedLength) {
8
+ return isSelected ? ' '.repeat(Math.max(0, safeWidth - usedLength)) : '';
9
+ }
10
+ function metaText(story) {
11
+ return `${story.score} points by ${story.by} ${formatAge(story.time)} ago | ${story.descendants} comments`;
12
+ }
13
+ export function StoryRow({ story, rank, rankWidth, isSelected, width, showMeta = true, showSeparator = true, }) {
14
+ // Never let a line's content reach the literal terminal edge: a line that exactly
15
+ // fills the width triggers a VT100 delayed-wrap that corrupts Ink's cursor position
16
+ // for the row below it.
17
+ const safeWidth = Math.max(1, width - 1);
18
+ const marker = isSelected ? theme.glyphs.selection : ' ';
19
+ const prefix = `${marker} ${String(rank).padStart(rankWidth)}. `;
20
+ const hostname = getHostname(story.url);
21
+ const hostnameSuffix = hostname ? ` (${hostname})` : '';
22
+ const titleWidth = Math.max(1, safeWidth - prefix.length - hostnameSuffix.length);
23
+ const title = truncateTitle(story.title, titleWidth);
24
+ const background = isSelected ? theme.colors.selectionBackground : undefined;
25
+ const meta = metaText(story);
26
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { backgroundColor: background, children: [prefix, _jsx(Text, { bold: true, color: theme.colors.title, children: title }), hostname && _jsx(Text, { color: theme.colors.muted, children: hostnameSuffix }), fillPad(isSelected, safeWidth, prefix.length + title.length + hostnameSuffix.length)] }), showMeta && (_jsxs(Text, { backgroundColor: background, children: [' '.repeat(prefix.length), _jsxs(Text, { color: theme.colors.score, children: [story.score, " points"] }), _jsxs(Text, { color: theme.colors.muted, children: [' ', "by ", story.by, " ", formatAge(story.time), " ago | ", story.descendants, " comments"] }), fillPad(isSelected, safeWidth, prefix.length + meta.length)] })), showSeparator && _jsx(Text, { children: " " })] }));
27
+ }