hackernews-tui 0.1.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 ADDED
@@ -0,0 +1,72 @@
1
+ # Hacker News TUI
2
+
3
+ ![Hacker News TUI screenshot](./readme.png)
4
+
5
+ A full terminal UI for browsing Hacker News using the official [Hacker News API](https://github.com/HackerNews/API) and OpenTUI.
6
+
7
+ ## Features
8
+
9
+ - Browse feeds: Top, New, Best, Ask, Show, Jobs
10
+ - Search loaded stories by title, author, URL, and text
11
+ - Filter stories by post class and minimum points
12
+ - Sort stories by rank, newest, points, or comment count
13
+ - Open article URLs and HN discussion pages from the terminal
14
+ - Deep post view with recursively loaded nested comments
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ bun install
20
+ ```
21
+
22
+ ## Run
23
+
24
+ ```bash
25
+ bun dev
26
+ ```
27
+
28
+ Run from npm with Bun:
29
+
30
+ ```bash
31
+ bunx hackernews-tui
32
+ ```
33
+
34
+ Watch mode (auto-restart on file changes):
35
+
36
+ ```bash
37
+ bun run dev:watch
38
+ ```
39
+
40
+ ## Quality Checks
41
+
42
+ ```bash
43
+ bun run typecheck
44
+ bun run lint
45
+ bun run format
46
+ bun run test
47
+ ```
48
+
49
+ ## Publish
50
+
51
+ ```bash
52
+ npm login
53
+ npm publish --access public
54
+ ```
55
+
56
+ ## Keybindings
57
+
58
+ - `Tab`: cycle focus (`list -> detail -> search`)
59
+ - `/`: focus search box
60
+ - `Esc` / `Enter` (from search): return to story list
61
+ - `1-6`: switch feed (`Top, New, Best, Ask, Show, Jobs`)
62
+ - `s`: cycle sort mode
63
+ - `t`: cycle post type filter
64
+ - `p`: cycle minimum points filter
65
+ - `j` / `k` or `Down` / `Up`: move through stories
66
+ - `Enter` / `l` / `Right`: move focus to detail pane
67
+ - `h` / `Left`: move focus back to story list
68
+ - `n`: load more stories from current feed
69
+ - `r`: refresh current feed
70
+ - `o`: open selected story URL (or HN link fallback)
71
+ - `i`: open selected HN discussion page
72
+ - `q`: quit
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/index.tsx";
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "hackernews-tui",
3
+ "version": "0.1.0",
4
+ "description": "A terminal UI for browsing Hacker News with search, filters, and deep threads.",
5
+ "module": "src/index.tsx",
6
+ "type": "module",
7
+ "private": false,
8
+ "bin": {
9
+ "hackernews-tui": "bin/hackernews-tui"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src",
14
+ "README.md",
15
+ "readme.png"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "keywords": [
21
+ "hackernews",
22
+ "hn",
23
+ "tui",
24
+ "terminal",
25
+ "opentui",
26
+ "bun"
27
+ ],
28
+ "scripts": {
29
+ "dev": "bun run src/index.tsx",
30
+ "dev:watch": "bun run --watch src/index.tsx",
31
+ "test": "bun test",
32
+ "typecheck": "tsc --noEmit",
33
+ "lint": "biome lint .",
34
+ "format": "biome format --write .",
35
+ "check": "bun run lint && bun run typecheck && bun run test",
36
+ "prepublishOnly": "bun run check"
37
+ },
38
+ "devDependencies": {
39
+ "@biomejs/biome": "^2.4.5",
40
+ "@types/bun": "latest",
41
+ "typescript": "^5.9.3"
42
+ },
43
+ "dependencies": {
44
+ "@opentui/core": "^0.1.84",
45
+ "@opentui/react": "^0.1.84",
46
+ "react": "^19.2.4"
47
+ }
48
+ }
package/readme.png ADDED
Binary file
@@ -0,0 +1,58 @@
1
+ export const STORY_FETCH_STEP = 120;
2
+ export const STORY_FETCH_CONCURRENCY = 16;
3
+ export const COMMENT_INITIAL_BATCH = 40;
4
+ export const COMMENT_BATCH_SIZE = 30;
5
+ export const COMMENT_NEAR_BOTTOM_THRESHOLD = 4;
6
+
7
+ export const FEED_OPTIONS = [
8
+ { value: "topstories", label: "Top" },
9
+ { value: "newstories", label: "New" },
10
+ { value: "beststories", label: "Best" },
11
+ { value: "askstories", label: "Ask" },
12
+ { value: "showstories", label: "Show" },
13
+ { value: "jobstories", label: "Jobs" },
14
+ ] as const;
15
+
16
+ export const SORT_OPTIONS = ["rank", "newest", "points", "comments"] as const;
17
+ export const SORT_LABELS: Record<(typeof SORT_OPTIONS)[number], string> = {
18
+ rank: "Rank",
19
+ newest: "Newest",
20
+ points: "Points",
21
+ comments: "Comments",
22
+ };
23
+
24
+ export const TYPE_FILTER_OPTIONS = ["all", "story", "ask", "show", "job"] as const;
25
+ export const TYPE_FILTER_LABELS: Record<(typeof TYPE_FILTER_OPTIONS)[number], string> = {
26
+ all: "All",
27
+ story: "Stories",
28
+ ask: "Ask",
29
+ show: "Show",
30
+ job: "Jobs",
31
+ };
32
+
33
+ export const SCORE_FILTER_OPTIONS = [0, 10, 50, 100, 250] as const;
34
+
35
+ export const UI = {
36
+ appBackground: "#111722",
37
+ panelBackground: "#16202d",
38
+ border: "#9aa8be",
39
+ text: "#f4f7ff",
40
+ muted: "#cad2df",
41
+ accent: "#8dd6ff",
42
+ accentWarm: "#ffd58a",
43
+ success: "#b4ef8b",
44
+ link: "#9fc4ff",
45
+ listBackground: "#141d2a",
46
+ listFocusedBackground: "#1b2738",
47
+ listSelectedBackground: "#355984",
48
+ listSelectedText: "#fff6cc",
49
+ inputBackground: "#0f1622",
50
+ inputText: "#f4f7ff",
51
+ inputPlaceholder: "#98a8be",
52
+ danger: "#ff879a",
53
+ } as const;
54
+
55
+ export type FeedKey = (typeof FEED_OPTIONS)[number]["value"];
56
+ export type SortMode = (typeof SORT_OPTIONS)[number];
57
+ export type StoryClass = (typeof TYPE_FILTER_OPTIONS)[number];
58
+ export type FocusZone = "list" | "detail" | "search";
@@ -0,0 +1,337 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ COMMENT_BATCH_SIZE,
4
+ COMMENT_INITIAL_BATCH,
5
+ STORY_FETCH_CONCURRENCY,
6
+ STORY_FETCH_STEP,
7
+ type FeedKey,
8
+ type SortMode,
9
+ type StoryClass,
10
+ } from "../app/constants";
11
+ import {
12
+ createCommentStack,
13
+ loadNextCommentBatch,
14
+ type CommentStackNode,
15
+ type FlatComment,
16
+ } from "../lib/comment-loader";
17
+ import { getFeedIds, getItemCached, mapWithConcurrency } from "../lib/hn-api";
18
+ import { classifyStory, getErrorMessage, htmlToText, type HNItem } from "../lib/hn-utils";
19
+
20
+ interface CommentCursorState {
21
+ storyId: number;
22
+ stack: CommentStackNode[];
23
+ }
24
+
25
+ export function useHnBrowserState() {
26
+ const commentCursorRef = useRef<CommentCursorState | null>(null);
27
+ const commentLoadInFlightRef = useRef(false);
28
+
29
+ const [feed, setFeed] = useState<FeedKey>("topstories");
30
+ const [refreshTick, setRefreshTick] = useState(0);
31
+ const [storyLimit, setStoryLimit] = useState(STORY_FETCH_STEP);
32
+ const [feedIds, setFeedIds] = useState<number[]>([]);
33
+ const [stories, setStories] = useState<HNItem[]>([]);
34
+ const [selectedStoryIndex, setSelectedStoryIndex] = useState(0);
35
+
36
+ const [feedLoading, setFeedLoading] = useState(false);
37
+ const [storyLoading, setStoryLoading] = useState(false);
38
+ const [detailLoading, setDetailLoading] = useState(false);
39
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
40
+
41
+ const [searchQuery, setSearchQuery] = useState("");
42
+ const [sortMode, setSortMode] = useState<SortMode>("rank");
43
+ const [storyClass, setStoryClass] = useState<StoryClass>("all");
44
+ const [minScore, setMinScore] = useState(0);
45
+
46
+ const [detailItem, setDetailItem] = useState<HNItem | null>(null);
47
+ const [detailComments, setDetailComments] = useState<FlatComment[]>([]);
48
+ const [hasMoreComments, setHasMoreComments] = useState(false);
49
+ const [loadingMoreComments, setLoadingMoreComments] = useState(false);
50
+
51
+ useEffect(() => {
52
+ let cancelled = false;
53
+
54
+ setFeedLoading(true);
55
+ setStoryLoading(false);
56
+ setDetailLoading(false);
57
+ setErrorMessage(null);
58
+ setFeedIds([]);
59
+ setStories([]);
60
+ setStoryLimit(STORY_FETCH_STEP);
61
+ setSelectedStoryIndex(0);
62
+ setDetailItem(null);
63
+ setDetailComments([]);
64
+ setHasMoreComments(false);
65
+ setLoadingMoreComments(false);
66
+ commentCursorRef.current = null;
67
+ commentLoadInFlightRef.current = false;
68
+
69
+ void (async () => {
70
+ try {
71
+ const ids = await getFeedIds(feed, refreshTick);
72
+ if (cancelled) {
73
+ return;
74
+ }
75
+ setFeedIds(ids);
76
+ } catch (error) {
77
+ if (cancelled) {
78
+ return;
79
+ }
80
+ setErrorMessage(`Failed to load feed IDs: ${getErrorMessage(error)}`);
81
+ } finally {
82
+ if (!cancelled) {
83
+ setFeedLoading(false);
84
+ }
85
+ }
86
+ })();
87
+
88
+ return () => {
89
+ cancelled = true;
90
+ };
91
+ }, [feed, refreshTick]);
92
+
93
+ useEffect(() => {
94
+ if (feedIds.length === 0) {
95
+ return;
96
+ }
97
+
98
+ let cancelled = false;
99
+ const idsToLoad = feedIds.slice(0, storyLimit);
100
+
101
+ setStoryLoading(true);
102
+ setErrorMessage(null);
103
+
104
+ void (async () => {
105
+ try {
106
+ const items = await mapWithConcurrency(idsToLoad, STORY_FETCH_CONCURRENCY, getItemCached);
107
+ if (cancelled) {
108
+ return;
109
+ }
110
+
111
+ const filtered = items.filter((item): item is HNItem => {
112
+ if (!item) {
113
+ return false;
114
+ }
115
+ return item.type === "story" || item.type === "job" || item.type === "poll";
116
+ });
117
+
118
+ setStories(filtered);
119
+ } catch (error) {
120
+ if (cancelled) {
121
+ return;
122
+ }
123
+ setErrorMessage(`Failed to load stories: ${getErrorMessage(error)}`);
124
+ } finally {
125
+ if (!cancelled) {
126
+ setStoryLoading(false);
127
+ }
128
+ }
129
+ })();
130
+
131
+ return () => {
132
+ cancelled = true;
133
+ };
134
+ }, [feedIds, storyLimit]);
135
+
136
+ const feedOrder = useMemo(() => {
137
+ const order = new Map<number, number>();
138
+ feedIds.forEach((id, index) => {
139
+ order.set(id, index);
140
+ });
141
+ return order;
142
+ }, [feedIds]);
143
+
144
+ const normalizedSearch = searchQuery.trim().toLowerCase();
145
+ const filteredStories = useMemo(() => {
146
+ const matches = stories.filter((story) => {
147
+ if ((story.score ?? 0) < minScore) {
148
+ return false;
149
+ }
150
+ if (storyClass !== "all" && classifyStory(story) !== storyClass) {
151
+ return false;
152
+ }
153
+ if (!normalizedSearch) {
154
+ return true;
155
+ }
156
+
157
+ const haystack = [story.title ?? "", story.by ?? "", story.url ?? "", htmlToText(story.text)]
158
+ .join(" ")
159
+ .toLowerCase();
160
+ return haystack.includes(normalizedSearch);
161
+ });
162
+
163
+ const sorted = [...matches];
164
+ sorted.sort((a, b) => {
165
+ switch (sortMode) {
166
+ case "newest":
167
+ return (b.time ?? 0) - (a.time ?? 0);
168
+ case "points":
169
+ return (b.score ?? 0) - (a.score ?? 0);
170
+ case "comments":
171
+ return (b.descendants ?? 0) - (a.descendants ?? 0);
172
+ default:
173
+ return (
174
+ (feedOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
175
+ (feedOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER)
176
+ );
177
+ }
178
+ });
179
+ return sorted;
180
+ }, [stories, minScore, storyClass, normalizedSearch, sortMode, feedOrder]);
181
+
182
+ useEffect(() => {
183
+ if (filteredStories.length === 0 && selectedStoryIndex !== 0) {
184
+ setSelectedStoryIndex(0);
185
+ return;
186
+ }
187
+
188
+ if (filteredStories.length > 0 && selectedStoryIndex >= filteredStories.length) {
189
+ setSelectedStoryIndex(filteredStories.length - 1);
190
+ }
191
+ }, [filteredStories.length, selectedStoryIndex]);
192
+
193
+ const selectedStory = filteredStories[selectedStoryIndex] ?? null;
194
+ const selectedStoryId = selectedStory?.id ?? null;
195
+
196
+ const loadMoreComments = useCallback(async (storyId: number, batchSize: number = COMMENT_BATCH_SIZE) => {
197
+ const cursor = commentCursorRef.current;
198
+ if (!cursor || cursor.storyId !== storyId || commentLoadInFlightRef.current) {
199
+ return;
200
+ }
201
+ if (cursor.stack.length === 0) {
202
+ setHasMoreComments(false);
203
+ return;
204
+ }
205
+
206
+ commentLoadInFlightRef.current = true;
207
+ setLoadingMoreComments(true);
208
+
209
+ try {
210
+ const loadedBatch = await loadNextCommentBatch(cursor.stack, batchSize, getItemCached);
211
+
212
+ if (commentCursorRef.current?.storyId !== storyId) {
213
+ return;
214
+ }
215
+ if (loadedBatch.length > 0) {
216
+ setDetailComments((previous) => [...previous, ...loadedBatch]);
217
+ }
218
+ setHasMoreComments(cursor.stack.length > 0);
219
+ } catch (error) {
220
+ if (commentCursorRef.current?.storyId !== storyId) {
221
+ return;
222
+ }
223
+ setErrorMessage(`Failed to load comments: ${getErrorMessage(error)}`);
224
+ } finally {
225
+ if (commentCursorRef.current?.storyId === storyId) {
226
+ setLoadingMoreComments(false);
227
+ }
228
+ commentLoadInFlightRef.current = false;
229
+ }
230
+ }, []);
231
+
232
+ useEffect(() => {
233
+ let cancelled = false;
234
+
235
+ if (!selectedStoryId) {
236
+ setDetailLoading(false);
237
+ setDetailItem(null);
238
+ setDetailComments([]);
239
+ setHasMoreComments(false);
240
+ setLoadingMoreComments(false);
241
+ commentCursorRef.current = null;
242
+ commentLoadInFlightRef.current = false;
243
+ return;
244
+ }
245
+
246
+ setDetailItem(selectedStory);
247
+ setDetailComments([]);
248
+ setHasMoreComments(false);
249
+ setLoadingMoreComments(false);
250
+ commentCursorRef.current = null;
251
+ commentLoadInFlightRef.current = false;
252
+ setDetailLoading(true);
253
+ setErrorMessage(null);
254
+
255
+ void (async () => {
256
+ try {
257
+ const item = await getItemCached(selectedStoryId);
258
+ if (!item) {
259
+ throw new Error(`Story ${selectedStoryId} not found`);
260
+ }
261
+ if (cancelled) {
262
+ return;
263
+ }
264
+ setDetailItem(item);
265
+ setDetailComments([]);
266
+ const stack = createCommentStack(item.kids);
267
+
268
+ commentCursorRef.current = {
269
+ storyId: selectedStoryId,
270
+ stack,
271
+ };
272
+ setHasMoreComments(stack.length > 0);
273
+
274
+ if (stack.length > 0) {
275
+ await loadMoreComments(selectedStoryId, COMMENT_INITIAL_BATCH);
276
+ }
277
+ } catch (error) {
278
+ if (cancelled) {
279
+ return;
280
+ }
281
+ setDetailItem(selectedStory ?? null);
282
+ setDetailComments([]);
283
+ setHasMoreComments(false);
284
+ setLoadingMoreComments(false);
285
+ commentCursorRef.current = null;
286
+ commentLoadInFlightRef.current = false;
287
+ setErrorMessage(`Failed to load post details: ${getErrorMessage(error)}`);
288
+ } finally {
289
+ if (!cancelled) {
290
+ setDetailLoading(false);
291
+ }
292
+ }
293
+ })();
294
+
295
+ return () => {
296
+ cancelled = true;
297
+ };
298
+ }, [selectedStoryId, selectedStory, loadMoreComments]);
299
+
300
+ const refreshFeed = () => {
301
+ setRefreshTick((current) => current + 1);
302
+ };
303
+
304
+ return {
305
+ feed,
306
+ setFeed,
307
+ storyLimit,
308
+ setStoryLimit,
309
+ feedIds,
310
+ stories,
311
+ selectedStoryIndex,
312
+ setSelectedStoryIndex,
313
+ feedLoading,
314
+ storyLoading,
315
+ detailLoading,
316
+ errorMessage,
317
+ searchQuery,
318
+ setSearchQuery,
319
+ sortMode,
320
+ setSortMode,
321
+ storyClass,
322
+ setStoryClass,
323
+ minScore,
324
+ setMinScore,
325
+ detailItem,
326
+ detailComments,
327
+ hasMoreComments,
328
+ loadingMoreComments,
329
+ feedOrder,
330
+ filteredStories,
331
+ selectedStory,
332
+ selectedStoryId,
333
+ loadMoreComments,
334
+ refreshFeed,
335
+ isCommentLoadInFlight: commentLoadInFlightRef,
336
+ };
337
+ }