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/dist/ui/App.js CHANGED
@@ -2,37 +2,94 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState } from 'react';
3
3
  import { useApp, useInput } from 'ink';
4
4
  import { loadConfig } from '../lib/config.js';
5
+ import { setConfigValue } from '../lib/configStore.js';
6
+ import { nextTab, previousTab } from '../lib/listNavigation.js';
5
7
  import { AskAI } from './AskAI.js';
6
8
  import { Comments } from './Comments.js';
7
9
  import { HelpOverlay } from './HelpOverlay.js';
8
- import { COMMENTS_KEYS, LIST_KEYS, SEARCH_RESULTS_KEYS, footerHint } from './keymap.js';
10
+ import { COMMENTS_KEYS, LIST_KEYS, SAVED_KEYS, SEARCH_RESULTS_KEYS, SUB_MATCHES_KEYS, SUBS_KEYS, THEME_PICKER_KEYS, footerHint, } from './keymap.js';
9
11
  import { Body, Footer, Header, Screen } from './Layout.js';
12
+ import { SavedList } from './SavedList.js';
10
13
  import { SearchInput } from './SearchInput.js';
11
14
  import { SearchResults } from './SearchResults.js';
12
15
  import { StoryList } from './StoryList.js';
16
+ import { SubscriptionForm } from './SubscriptionForm.js';
17
+ import { SubscriptionMatches } from './SubscriptionMatches.js';
18
+ import { SubscriptionsView } from './SubscriptionsView.js';
13
19
  import { TabBar } from './TabBar.js';
14
- export function App({ initialQuery }) {
20
+ import { ThemePicker } from './ThemePicker.js';
21
+ import { resolvePaletteName, resolveTheme, ThemeContext } from './theme.js';
22
+ export function App({ initialQuery, initialView }) {
15
23
  const [feed, setFeed] = useState('top');
16
- const [view, setView] = useState(initialQuery ? { name: 'search', query: initialQuery, from: 'cli' } : { name: 'list' });
24
+ const [view, setView] = useState(initialQuery
25
+ ? { name: 'search', query: initialQuery, from: 'cli' }
26
+ : initialView === 'saved'
27
+ ? { name: 'saved' }
28
+ : initialView === 'subs'
29
+ ? { name: 'subs' }
30
+ : { name: 'list' });
17
31
  const [helpOpen, setHelpOpen] = useState(false);
18
32
  const [config] = useState(loadConfig);
33
+ const [paletteName, setPaletteName] = useState(() => resolvePaletteName());
34
+ const [themePickerOpen, setThemePickerOpen] = useState(false);
35
+ const activeTheme = resolveTheme(paletteName);
19
36
  const { exit } = useApp();
20
37
  useInput((input) => {
38
+ if (themePickerOpen)
39
+ return;
21
40
  if (helpOpen)
22
41
  return setHelpOpen(false);
23
- if (view.name === 'search-input' || view.name === 'ask')
42
+ if (view.name === 'search-input' || view.name === 'ask' || view.name === 'sub-form')
24
43
  return;
25
44
  if (input === 'q')
26
45
  return exit();
27
46
  if (input === '?')
28
47
  return setHelpOpen(true);
48
+ if (input === 'T')
49
+ return setThemePickerOpen(true);
29
50
  });
30
- const ctx = { feed, config, setFeed, setView, exit };
31
- return (_jsxs(Screen, { children: [_jsx(Header, { children: _jsx(TabBar, { active: feed }) }), _jsx(Body, { children: helpOpen ? _jsx(HelpOverlay, { ...helpFor(view) }) : renderBody(view, ctx) }), _jsx(Footer, { children: renderFooter(view, ctx) })] }));
51
+ function handleThemeSelected(name) {
52
+ setConfigValue('ui.theme', name);
53
+ setPaletteName(name);
54
+ setThemePickerOpen(false);
55
+ }
56
+ function changeFeed(target) {
57
+ setFeed(target);
58
+ setView({ name: 'list' });
59
+ }
60
+ function changeTab(direction) {
61
+ const next = direction === 1 ? nextTab(activeTab) : previousTab(activeTab);
62
+ if (next === 'saved')
63
+ return setView({ name: 'saved' });
64
+ if (next === 'subs')
65
+ return setView({ name: 'subs' });
66
+ changeFeed(next);
67
+ }
68
+ const activeTab = tabForView(view) ?? feed;
69
+ const ctx = { feed, config, setFeed: changeFeed, setTab: changeTab, setView, exit };
70
+ return (_jsx(ThemeContext.Provider, { value: activeTheme, children: _jsxs(Screen, { children: [_jsx(Header, { children: _jsx(TabBar, { active: activeTab }) }), _jsx(Body, { children: themePickerOpen ? (_jsx(ThemePicker, { current: paletteName, onSelect: handleThemeSelected, onCancel: () => setThemePickerOpen(false) })) : helpOpen ? (_jsx(HelpOverlay, { ...helpFor(view) })) : (renderBody(view, ctx)) }), _jsx(Footer, { children: themePickerOpen ? footerHint(THEME_PICKER_KEYS) : renderFooter(view, ctx) })] }) }));
71
+ }
72
+ /** Traces returnTo chains back to the tab a nested view (comments/ask/sub-form) was opened from. */
73
+ function tabForView(view) {
74
+ if (view.name === 'saved' || view.name === 'subs')
75
+ return view.name;
76
+ if (view.name === 'sub-matches')
77
+ return 'subs';
78
+ if (view.name === 'comments' || view.name === 'ask' || view.name === 'sub-form')
79
+ return tabForView(view.returnTo);
80
+ return null;
32
81
  }
33
82
  function renderBody(view, ctx) {
34
83
  if (view.name === 'list')
35
84
  return renderList(ctx);
85
+ if (view.name === 'saved')
86
+ return renderSaved(ctx);
87
+ if (view.name === 'subs')
88
+ return renderSubs(ctx);
89
+ if (view.name === 'sub-matches')
90
+ return renderSubMatches(view, ctx);
91
+ if (view.name === 'sub-form')
92
+ return renderSubForm(view, ctx);
36
93
  if (view.name === 'search')
37
94
  return renderSearch(view, ctx);
38
95
  if (view.name === 'search-input')
@@ -45,10 +102,16 @@ function renderFooter(view, ctx) {
45
102
  if (view.name === 'search-input') {
46
103
  return (_jsx(SearchInput, { onSubmit: (query) => ctx.setView({ name: 'search', query, from: view.from }), onCancel: () => ctx.setView({ name: 'list' }) }));
47
104
  }
48
- if (view.name === 'ask')
105
+ if (view.name === 'ask' || view.name === 'sub-form')
49
106
  return null;
50
107
  if (view.name === 'list')
51
108
  return footerHint(LIST_KEYS);
109
+ if (view.name === 'saved')
110
+ return footerHint(SAVED_KEYS);
111
+ if (view.name === 'subs')
112
+ return footerHint(SUBS_KEYS);
113
+ if (view.name === 'sub-matches')
114
+ return footerHint(SUB_MATCHES_KEYS);
52
115
  if (view.name === 'comments')
53
116
  return footerHint(COMMENTS_KEYS);
54
117
  return footerHint(SEARCH_RESULTS_KEYS);
@@ -58,14 +121,33 @@ function helpFor(view) {
58
121
  return { title: 'comments', keys: COMMENTS_KEYS };
59
122
  if (view.name === 'search')
60
123
  return { title: 'search results', keys: SEARCH_RESULTS_KEYS };
124
+ if (view.name === 'saved')
125
+ return { title: 'saved', keys: SAVED_KEYS };
126
+ if (view.name === 'subs')
127
+ return { title: 'subs', keys: SUBS_KEYS };
128
+ if (view.name === 'sub-matches')
129
+ return { title: 'sub matches', keys: SUB_MATCHES_KEYS };
61
130
  return { title: 'story list', keys: LIST_KEYS };
62
131
  }
63
- function renderList({ feed, config, setFeed, setView }) {
64
- return (_jsx(StoryList, { feed: feed, config: config, onFeedChange: setFeed, onSelectStory: (story) => setView({ name: 'comments', story, returnTo: { name: 'list' } }), onSearchRequested: () => setView({ name: 'search-input', from: 'tui' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo: { name: 'list' } }) }));
132
+ function renderList({ feed, config, setFeed, setTab, setView }) {
133
+ return (_jsx(StoryList, { feed: feed, config: config, onFeedChange: setFeed, onTabChange: setTab, onSelectStory: (story) => setView({ name: 'comments', story, returnTo: { name: 'list' } }), onSearchRequested: () => setView({ name: 'search-input', from: 'tui' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo: { name: 'list' } }) }));
134
+ }
135
+ function renderSaved({ config, setFeed, setTab, setView }) {
136
+ return (_jsx(SavedList, { config: config, onFeedChange: setFeed, onTabChange: setTab, onSelectStory: (story) => setView({ name: 'comments', story, returnTo: { name: 'saved' } }), onSearchRequested: () => setView({ name: 'search-input', from: 'tui' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo: { name: 'saved' } }) }));
137
+ }
138
+ function renderSubs({ setFeed, setTab, setView }) {
139
+ return (_jsx(SubscriptionsView, { onSelectMatches: (subscription) => setView({ name: 'sub-matches', subscription }), onAdd: () => setView({ name: 'sub-form', mode: 'add', returnTo: { name: 'subs' } }), onEdit: (subscription) => setView({ name: 'sub-form', mode: 'edit', subscription, returnTo: { name: 'subs' } }), onFeedChange: setFeed, onTabChange: setTab }));
140
+ }
141
+ function renderSubMatches(view, { config, setView }) {
142
+ const returnTo = { name: 'sub-matches', subscription: view.subscription };
143
+ return (_jsx(SubscriptionMatches, { subscription: view.subscription, config: config, onSelectStory: (story) => setView({ name: 'comments', story, returnTo }), onBack: () => setView({ name: 'subs' }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo }) }));
144
+ }
145
+ function renderSubForm(view, { setView }) {
146
+ return (_jsx(SubscriptionForm, { mode: view.mode, subscription: view.subscription, prefillQuery: view.prefillQuery, onSave: () => setView(view.returnTo), onCancel: () => setView(view.returnTo) }));
65
147
  }
66
148
  function renderSearch(view, { config, setView, exit }) {
67
149
  const returnTo = { name: 'search', query: view.query, from: view.from };
68
- return (_jsx(SearchResults, { query: view.query, config: config, onSelectStory: (story) => setView({ name: 'comments', story, returnTo }), onExit: () => (view.from === 'tui' ? setView({ name: 'list' }) : exit()), onSearchAgain: () => setView({ name: 'search-input', from: view.from }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo }) }));
150
+ return (_jsx(SearchResults, { query: view.query, config: config, onSelectStory: (story) => setView({ name: 'comments', story, returnTo }), onExit: () => (view.from === 'tui' ? setView({ name: 'list' }) : exit()), onSearchAgain: () => setView({ name: 'search-input', from: view.from }), onAskAI: (story) => setView({ name: 'ask', story, comments: null, returnTo }), onSubscribe: () => setView({ name: 'sub-form', mode: 'add', prefillQuery: view.query, returnTo }) }));
69
151
  }
70
152
  function renderComments(view, { config, setView }) {
71
153
  return (_jsx(Comments, { story: view.story, config: config, onBack: () => setView(view.returnTo), onAskAI: (comments) => setView({ name: 'ask', story: view.story, comments, returnTo: view }) }));
package/dist/ui/AskAI.js CHANGED
@@ -10,7 +10,7 @@ import { truncateTitle } from '../lib/format.js';
10
10
  import { htmlToText } from '../lib/html.js';
11
11
  import { wrapPlainText } from '../lib/viewport.js';
12
12
  import { HEADER_ROWS } from './Layout.js';
13
- import { theme } from './theme.js';
13
+ import { useTheme } from './theme.js';
14
14
  async function resolveArticle(story) {
15
15
  if (story.url) {
16
16
  try {
@@ -25,6 +25,7 @@ async function resolveArticle(story) {
25
25
  return { article: null, reason: 'text post' };
26
26
  }
27
27
  export function AskAI({ story, comments, config, onBack }) {
28
+ const theme = useTheme();
28
29
  const { columns, rows } = useWindowSize();
29
30
  const [status, setStatus] = useState(config ? 'preparing' : 'hint');
30
31
  const [unavailableDetail, setUnavailableDetail] = useState('');
@@ -5,6 +5,7 @@ import open from 'open';
5
5
  import { buildCommentsSummaryPrompt } from '../ai/summaryPrompts.js';
6
6
  import { fetchComments } from '../api/algolia.js';
7
7
  import { hnItemUrl } from '../api/firebase.js';
8
+ import { toggleBookmark } from '../db/bookmarks.js';
8
9
  import { collapseAll, flattenTree, headerOnlyAll, rehideRevealed, revealHeaderOnly, toggleFold, } from '../lib/commentTree.js';
9
10
  import { tokenizeContacts } from '../lib/contactHighlight.js';
10
11
  import { formatAge } from '../lib/format.js';
@@ -15,7 +16,8 @@ import { COMMENTS_KEYS, footerRows } from './keymap.js';
15
16
  import { HEADER_ROWS } from './Layout.js';
16
17
  import { LoadingIndicator } from './LoadingIndicator.js';
17
18
  import { SummaryPanel } from './SummaryPanel.js';
18
- import { theme } from './theme.js';
19
+ import { useTheme } from './theme.js';
20
+ import { useFlash } from './useFlash.js';
19
21
  const HEADER_BORDER_LINES = 2;
20
22
  // borderStyle (2 cols) + paddingX (2 cols) eaten from the card's interior width.
21
23
  const HEADER_FRAME_WIDTH = 4;
@@ -30,6 +32,7 @@ export function commentsHeaderLines(story, columns) {
30
32
  return HEADER_BORDER_LINES + titleLines + 1 + urlLines;
31
33
  }
32
34
  export function Comments({ story, config, onBack, onAskAI }) {
35
+ const theme = useTheme();
33
36
  const { columns, rows } = useWindowSize();
34
37
  const headerLines = commentsHeaderLines(story, columns);
35
38
  const viewportLines = Math.max(1, rows - HEADER_ROWS - footerRows(COMMENTS_KEYS, columns) - headerLines);
@@ -43,6 +46,7 @@ export function Comments({ story, config, onBack, onAskAI }) {
43
46
  });
44
47
  const [selected, setSelected] = useState(0);
45
48
  const [summaryOpen, setSummaryOpen] = useState(false);
49
+ const [flashMessage, flash] = useFlash();
46
50
  const pendingTopJump = useRef(false);
47
51
  const topLineRef = useRef(0);
48
52
  const { folded, headerOnly, revealed } = fold;
@@ -64,7 +68,7 @@ export function Comments({ story, config, onBack, onAskAI }) {
64
68
  }
65
69
  }
66
70
  const flat = useMemo(() => flattenTree(comments, folded, headerOnly), [comments, folded, headerOnly]);
67
- const rowsData = useMemo(() => flat.map((entry) => buildRow(entry, columns)), [flat, columns]);
71
+ const rowsData = useMemo(() => flat.map((entry) => buildRow(entry, columns, theme)), [flat, columns, theme]);
68
72
  const clampedSelected = clampSelection(selected, 0, flat.length);
69
73
  function openStory() {
70
74
  void open(story.url ?? hnItemUrl(story.id));
@@ -119,6 +123,8 @@ export function Comments({ story, config, onBack, onAskAI }) {
119
123
  return setSummaryOpen(true);
120
124
  if (input === 'a')
121
125
  return onAskAI(comments);
126
+ if (input === 'B')
127
+ return flash(toggleBookmark(story) ? 'bookmarked ✓' : 'bookmark removed');
122
128
  }
123
129
  useInput(handleInput, { isActive: !summaryOpen });
124
130
  if (status === 'loading')
@@ -126,12 +132,13 @@ export function Comments({ story, config, onBack, onAskAI }) {
126
132
  if (status === 'error')
127
133
  return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
128
134
  const panelHeight = summaryOpen ? Math.max(6, Math.floor(viewportLines / 2)) : 0;
129
- const listViewportLines = Math.max(1, viewportLines - panelHeight);
135
+ const flashLineHeight = flashMessage ? 1 : 0;
136
+ const listViewportLines = Math.max(1, viewportLines - panelHeight - flashLineHeight);
130
137
  const heights = rowsData.map((row) => row.lines.length);
131
138
  const topLine = ensureVisibleLines(heights, clampedSelected, topLineRef.current, listViewportLines);
132
139
  topLineRef.current = topLine;
133
140
  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) => {
141
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(CommentsHeader, { story: story }), flashMessage && _jsx(Text, { dimColor: true, children: flashMessage }), flat.length === 0 && _jsx(Text, { dimColor: true, children: "no comments yet" }), rowsData.slice(first, last + 1).map((row, i) => {
135
142
  const rowIndex = first + i;
136
143
  const start = rowIndex === first ? clipTop : 0;
137
144
  const end = rowIndex === last ? row.lines.length - clipBottom : row.lines.length;
@@ -139,6 +146,7 @@ export function Comments({ story, config, onBack, onAskAI }) {
139
146
  }), summaryOpen && (_jsx(SummaryPanel, { config: config, buildPrompt: () => Promise.resolve(buildCommentsSummaryPrompt(story, comments)), height: panelHeight, width: columns, onClose: () => setSummaryOpen(false) }))] }));
140
147
  }
141
148
  function CommentsHeader({ story }) {
149
+ const theme = useTheme();
142
150
  return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.colors.accent, paddingX: 1, children: [_jsx(Text, { bold: true, color: theme.colors.title, children: story.title }), _jsxs(Text, { dimColor: true, children: [theme.glyphs.upvote, " ", story.score, " points by ", story.by, " ", formatAge(story.time), " | ", story.descendants, ' ', "comments"] }), story.url && _jsx(Text, { color: theme.colors.accent, children: story.url })] }));
143
151
  }
144
152
  function replyBadge(entry) {
@@ -148,7 +156,7 @@ function replyBadge(entry) {
148
156
  return `[${entry.descendantCount} more]`;
149
157
  return entry.descendantCount === 1 ? '1 reply' : `${entry.descendantCount} replies`;
150
158
  }
151
- function buildHeaderSpans(entry) {
159
+ function buildHeaderSpans(entry, theme) {
152
160
  const glyph = entry.headerOnly ? theme.glyphs.foldClosed : theme.glyphs.foldOpen;
153
161
  const badge = replyBadge(entry);
154
162
  const spans = [
@@ -160,24 +168,24 @@ function buildHeaderSpans(entry) {
160
168
  spans.push({ text: ` | ${badge}`, color: theme.colors.muted });
161
169
  return spans;
162
170
  }
163
- function tokenToSpan(token) {
171
+ function tokenToSpan(token, theme) {
164
172
  if (token.kind === 'link')
165
173
  return { text: token.text, color: theme.colors.link, underline: true };
166
174
  if (token.kind === 'email')
167
175
  return { text: token.text, color: theme.colors.email, bold: true };
168
176
  return { text: token.text };
169
177
  }
170
- function buildRow(entry, columns) {
178
+ function buildRow(entry, columns, theme) {
171
179
  // Reserve 2 columns for the selection bar so wrapping doesn't shift when selection moves,
172
180
  // plus 1 trailing column: a line that exactly fills the terminal width triggers a VT100
173
181
  // delayed-wrap that drops the selection bar/stripe from the following line.
174
182
  const width = Math.max(1, columns - 1 - entry.depth * 2 - ROW_BORDER_WIDTH);
175
- const header = { kind: 'header', spans: buildHeaderSpans(entry) };
183
+ const header = { kind: 'header', spans: buildHeaderSpans(entry, theme) };
176
184
  const body = entry.headerOnly
177
185
  ? []
178
186
  : wrapPlainText(htmlToText(entry.node.text), width).map((line) => ({
179
187
  kind: 'body',
180
- spans: tokenizeContacts(line).map(tokenToSpan),
188
+ spans: tokenizeContacts(line).map((token) => tokenToSpan(token, theme)),
181
189
  }));
182
190
  return { id: entry.node.id, depth: entry.depth, width, lines: [header, ...body] };
183
191
  }
@@ -185,6 +193,7 @@ function spanLength(spans) {
185
193
  return spans.reduce((sum, span) => sum + span.text.length, 0);
186
194
  }
187
195
  function CommentRowView({ depth, width, lines, isSelected }) {
196
+ const theme = useTheme();
188
197
  const background = isSelected ? theme.colors.selectionBackground : undefined;
189
198
  const prefix = isSelected ? `${SELECTION_BAR} ` : ' ';
190
199
  const rowWidth = width + ROW_BORDER_WIDTH;
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { GLOBAL_KEYS } from './keymap.js';
4
- import { theme } from './theme.js';
4
+ import { useTheme } from './theme.js';
5
5
  export function HelpOverlay({ title, keys }) {
6
+ const theme = useTheme();
6
7
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: theme.colors.title, children: title }), keys.map((binding) => (_jsx(KeyRow, { binding: binding }, binding.key))), _jsx(Text, { children: " " }), _jsx(Text, { color: theme.colors.title, children: "global" }), GLOBAL_KEYS.map((binding) => (_jsx(KeyRow, { binding: binding }, binding.key))), _jsx(Text, { dimColor: true, children: "any key closes" })] }));
7
8
  }
8
9
  function KeyRow({ binding }) {
package/dist/ui/Layout.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text, useWindowSize } from 'ink';
3
- import { theme } from './theme.js';
3
+ import { useTheme } from './theme.js';
4
4
  const MIN_ROWS = 8;
5
5
  export const HEADER_ROWS = 3;
6
6
  export function Screen({ children }) {
@@ -17,5 +17,6 @@ export function Body({ children }) {
17
17
  return (_jsx(Box, { flexGrow: 1, overflowY: "hidden", flexDirection: "column", children: children }));
18
18
  }
19
19
  export function Footer({ children }) {
20
+ const theme = useTheme();
20
21
  return (_jsx(Box, { flexShrink: 0, children: _jsx(Text, { color: theme.colors.muted, children: children }) }));
21
22
  }
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
3
  import { Text } from 'ink';
4
- import { theme } from './theme.js';
4
+ import { useTheme } from './theme.js';
5
5
  const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
6
6
  const FRAME_INTERVAL_MS = 80;
7
7
  export function LoadingIndicator({ label }) {
8
+ const theme = useTheme();
8
9
  const [frame, setFrame] = useState(0);
9
10
  useEffect(() => {
10
11
  const id = setInterval(() => setFrame((f) => (f + 1) % FRAMES.length), FRAME_INTERVAL_MS);
@@ -0,0 +1,90 @@
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 open from 'open';
5
+ import { buildListSummaryPrompt } from '../ai/summaryPrompts.js';
6
+ import { hnItemUrl } from '../api/firebase.js';
7
+ import { listBookmarks, toggleBookmark } from '../db/bookmarks.js';
8
+ import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
9
+ import { ensureVisibleLines } from '../lib/viewport.js';
10
+ import { footerRows, SAVED_KEYS } from './keymap.js';
11
+ import { HEADER_ROWS } from './Layout.js';
12
+ import { STORY_ROW_HEIGHT } from './StoryRow.js';
13
+ import { StoryListView } from './StoryListView.js';
14
+ import { SummaryPanel } from './SummaryPanel.js';
15
+ import { useFlash } from './useFlash.js';
16
+ export function SavedList({ config, onSelectStory, onFeedChange, onTabChange, onSearchRequested, onAskAI, }) {
17
+ const { columns, rows } = useWindowSize();
18
+ const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SAVED_KEYS, columns));
19
+ const [stories, setStories] = useState(() => listBookmarks());
20
+ const [selected, setSelected] = useState(0);
21
+ const [summaryOpen, setSummaryOpen] = useState(false);
22
+ const [flashMessage, flash] = useFlash();
23
+ const pendingTopJump = useRef(false);
24
+ const topLineRef = useRef(0);
25
+ function reload() {
26
+ const next = listBookmarks();
27
+ setStories(next);
28
+ setSelected((s) => clampSelection(s, 0, next.length));
29
+ }
30
+ function openSelectedStory() {
31
+ const story = stories[selected];
32
+ if (story)
33
+ void open(story.url ?? hnItemUrl(story.id));
34
+ }
35
+ function removeSelectedBookmark() {
36
+ const story = stories[selected];
37
+ if (!story)
38
+ return;
39
+ toggleBookmark(story);
40
+ flash('bookmark removed');
41
+ reload();
42
+ }
43
+ function handleInput(input, key) {
44
+ const isG = input === 'g';
45
+ if (isG && pendingTopJump.current) {
46
+ pendingTopJump.current = false;
47
+ return setSelected(0);
48
+ }
49
+ pendingTopJump.current = isG;
50
+ const targetFeed = mapFeedKey(input);
51
+ if (targetFeed)
52
+ return onFeedChange(targetFeed);
53
+ if (key.leftArrow)
54
+ return onTabChange(-1);
55
+ if (key.rightArrow)
56
+ return onTabChange(1);
57
+ if (input === '/')
58
+ return onSearchRequested();
59
+ if (input === 'j' || key.downArrow)
60
+ return setSelected((s) => clampSelection(s, 1, stories.length));
61
+ if (input === 'k' || key.upArrow)
62
+ return setSelected((s) => clampSelection(s, -1, stories.length));
63
+ if (input === 'G')
64
+ return setSelected(Math.max(0, stories.length - 1));
65
+ if (input === 'o')
66
+ return openSelectedStory();
67
+ if (input === 'r')
68
+ return reload();
69
+ if (input === 'B' && stories[selected])
70
+ return removeSelectedBookmark();
71
+ if (input === 's' && stories[selected])
72
+ return setSummaryOpen(true);
73
+ if (input === 'a' && stories[selected])
74
+ return onAskAI(stories[selected]);
75
+ if (key.return && stories[selected])
76
+ return onSelectStory(stories[selected]);
77
+ }
78
+ useInput(handleInput, { isActive: !summaryOpen });
79
+ const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
80
+ const statusLineHeight = flashMessage ? 1 : 0;
81
+ const listHeight = Math.max(1, bodyHeight - statusLineHeight - panelHeight);
82
+ const selectedStory = stories[selected];
83
+ if (stories.length === 0) {
84
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "no bookmarks yet \u2014 press B on a story" }), flashMessage && _jsx(Text, { dimColor: true, children: flashMessage })] }));
85
+ }
86
+ const heights = stories.map(() => STORY_ROW_HEIGHT);
87
+ const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
88
+ topLineRef.current = topLine;
89
+ return (_jsxs(Box, { flexDirection: "column", children: [_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) }))] }));
90
+ }
@@ -5,6 +5,7 @@ import open from 'open';
5
5
  import { buildListSummaryPrompt } from '../ai/summaryPrompts.js';
6
6
  import { searchStories } from '../api/algolia.js';
7
7
  import { hnItemUrl } from '../api/firebase.js';
8
+ import { toggleBookmark } from '../db/bookmarks.js';
8
9
  import { clampSelection } from '../lib/listNavigation.js';
9
10
  import { ensureVisibleLines, shouldFetchMore } from '../lib/viewport.js';
10
11
  import { footerRows, SEARCH_RESULTS_KEYS } from './keymap.js';
@@ -13,10 +14,12 @@ import { LoadingIndicator } from './LoadingIndicator.js';
13
14
  import { STORY_ROW_HEIGHT } from './StoryRow.js';
14
15
  import { StoryListView } from './StoryListView.js';
15
16
  import { SummaryPanel } from './SummaryPanel.js';
16
- import { theme } from './theme.js';
17
+ import { useTheme } from './theme.js';
18
+ import { useFlash } from './useFlash.js';
17
19
  const FETCH_THRESHOLD = 10;
18
20
  const HEADER_LINES = 1;
19
- export function SearchResults({ query, config, onSelectStory, onExit, onSearchAgain, onAskAI, }) {
21
+ export function SearchResults({ query, config, onSelectStory, onExit, onSearchAgain, onAskAI, onSubscribe, }) {
22
+ const theme = useTheme();
20
23
  const { columns, rows } = useWindowSize();
21
24
  const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(SEARCH_RESULTS_KEYS, columns) - HEADER_LINES);
22
25
  const [stories, setStories] = useState([]);
@@ -27,6 +30,7 @@ export function SearchResults({ query, config, onSelectStory, onExit, onSearchAg
27
30
  const [loadingMore, setLoadingMore] = useState(false);
28
31
  const [error, setError] = useState('');
29
32
  const [summaryOpen, setSummaryOpen] = useState(false);
33
+ const [flashMessage, flash] = useFlash();
30
34
  const token = useRef(0);
31
35
  const nextPage = useRef(0);
32
36
  const topLineRef = useRef(0);
@@ -110,6 +114,10 @@ export function SearchResults({ query, config, onSelectStory, onExit, onSearchAg
110
114
  return setSummaryOpen(true);
111
115
  if (input === 'a' && stories[selected])
112
116
  return onAskAI(stories[selected]);
117
+ if (input === 'B' && stories[selected])
118
+ return flash(toggleBookmark(stories[selected]) ? 'bookmarked ✓' : 'bookmark removed');
119
+ if (input === 'S')
120
+ return onSubscribe();
113
121
  if (key.return && stories[selected])
114
122
  return onSelectStory(stories[selected]);
115
123
  }
@@ -119,10 +127,11 @@ export function SearchResults({ query, config, onSelectStory, onExit, onSearchAg
119
127
  if (status === 'error')
120
128
  return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
121
129
  const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
122
- const listHeight = Math.max(1, (loadingMore ? bodyHeight - 1 : bodyHeight) - panelHeight);
130
+ const statusLineHeight = loadingMore || flashMessage ? 1 : 0;
131
+ const listHeight = Math.max(1, bodyHeight - statusLineHeight - panelHeight);
123
132
  const heights = stories.map(() => STORY_ROW_HEIGHT);
124
133
  const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
125
134
  topLineRef.current = topLine;
126
135
  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) }))] }));
136
+ 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 }), flashMessage ? _jsx(Text, { dimColor: true, children: flashMessage }) : 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
137
  }
@@ -4,7 +4,8 @@ import { Box, Text, useInput, useWindowSize } from 'ink';
4
4
  import open from 'open';
5
5
  import { buildListSummaryPrompt } from '../ai/summaryPrompts.js';
6
6
  import { fetchStories, fetchStoryIds, hnItemUrl } from '../api/firebase.js';
7
- import { clampSelection, mapFeedKey, nextFeed, previousFeed } from '../lib/listNavigation.js';
7
+ import { toggleBookmark } from '../db/bookmarks.js';
8
+ import { clampSelection, mapFeedKey } from '../lib/listNavigation.js';
8
9
  import { ensureVisibleLines, shouldFetchMore } from '../lib/viewport.js';
9
10
  import { footerRows, LIST_KEYS } from './keymap.js';
10
11
  import { HEADER_ROWS } from './Layout.js';
@@ -12,10 +13,12 @@ import { LoadingIndicator } from './LoadingIndicator.js';
12
13
  import { STORY_ROW_HEIGHT } from './StoryRow.js';
13
14
  import { StoryListView } from './StoryListView.js';
14
15
  import { SummaryPanel } from './SummaryPanel.js';
15
- import { theme } from './theme.js';
16
+ import { useTheme } from './theme.js';
17
+ import { useFlash } from './useFlash.js';
16
18
  const BATCH_SIZE = 30;
17
19
  const FETCH_THRESHOLD = 10;
18
- export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchRequested, onAskAI, }) {
20
+ export function StoryList({ feed, config, onFeedChange, onTabChange, onSelectStory, onSearchRequested, onAskAI, }) {
21
+ const theme = useTheme();
19
22
  const { columns, rows } = useWindowSize();
20
23
  const bodyHeight = Math.max(1, rows - HEADER_ROWS - footerRows(LIST_KEYS, columns));
21
24
  const [storyIds, setStoryIds] = useState([]);
@@ -25,6 +28,7 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
25
28
  const [loadingMore, setLoadingMore] = useState(false);
26
29
  const [error, setError] = useState('');
27
30
  const [summaryOpen, setSummaryOpen] = useState(false);
31
+ const [flashMessage, flash] = useFlash();
28
32
  const token = useRef(0);
29
33
  const pendingTopJump = useRef(false);
30
34
  const topLineRef = useRef(0);
@@ -97,9 +101,9 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
97
101
  if (targetFeed)
98
102
  return onFeedChange(targetFeed);
99
103
  if (key.leftArrow)
100
- return onFeedChange(previousFeed(feed));
104
+ return onTabChange(-1);
101
105
  if (key.rightArrow)
102
- return onFeedChange(nextFeed(feed));
106
+ return onTabChange(1);
103
107
  if (input === '/')
104
108
  return onSearchRequested();
105
109
  if (input === 'j' || key.downArrow)
@@ -116,6 +120,8 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
116
120
  return setSummaryOpen(true);
117
121
  if (input === 'a' && stories[selected])
118
122
  return onAskAI(stories[selected]);
123
+ if (input === 'B' && stories[selected])
124
+ return flash(toggleBookmark(stories[selected]) ? 'bookmarked ✓' : 'bookmark removed');
119
125
  if (key.return && stories[selected])
120
126
  return onSelectStory(stories[selected]);
121
127
  }
@@ -125,10 +131,11 @@ export function StoryList({ feed, config, onFeedChange, onSelectStory, onSearchR
125
131
  if (status === 'error')
126
132
  return _jsxs(Text, { color: theme.colors.error, children: [error, " (r to retry)"] });
127
133
  const panelHeight = summaryOpen ? Math.max(6, Math.floor(bodyHeight / 2)) : 0;
128
- const listHeight = Math.max(1, (loadingMore ? bodyHeight - 1 : bodyHeight) - panelHeight);
134
+ const statusLineHeight = loadingMore || flashMessage ? 1 : 0;
135
+ const listHeight = Math.max(1, bodyHeight - statusLineHeight - panelHeight);
129
136
  const heights = stories.map(() => STORY_ROW_HEIGHT);
130
137
  const topLine = ensureVisibleLines(heights, selected, topLineRef.current, listHeight);
131
138
  topLineRef.current = topLine;
132
139
  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) }))] }));
140
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StoryListView, { stories: stories, selected: selected, topLine: topLine, height: listHeight, width: columns }), flashMessage ? _jsx(Text, { dimColor: true, children: flashMessage }) : 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
141
  }
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { isBookmarked } from '../db/bookmarks.js';
3
4
  import { formatAge, truncateTitle } from '../lib/format.js';
4
5
  import { getHostname } from '../lib/url.js';
5
- import { theme } from './theme.js';
6
+ import { useTheme } from './theme.js';
6
7
  export const STORY_ROW_HEIGHT = 3;
7
8
  function fillPad(isSelected, safeWidth, usedLength) {
8
9
  return isSelected ? ' '.repeat(Math.max(0, safeWidth - usedLength)) : '';
@@ -11,6 +12,7 @@ function metaText(story) {
11
12
  return `${story.score} points by ${story.by} ${formatAge(story.time)} ago | ${story.descendants} comments`;
12
13
  }
13
14
  export function StoryRow({ story, rank, rankWidth, isSelected, width, showMeta = true, showSeparator = true, }) {
15
+ const theme = useTheme();
14
16
  // Never let a line's content reach the literal terminal edge: a line that exactly
15
17
  // fills the width triggers a VT100 delayed-wrap that corrupts Ink's cursor position
16
18
  // for the row below it.
@@ -22,6 +24,8 @@ export function StoryRow({ story, rank, rankWidth, isSelected, width, showMeta =
22
24
  const titleWidth = Math.max(1, safeWidth - prefix.length - hostnameSuffix.length);
23
25
  const title = truncateTitle(story.title, titleWidth);
24
26
  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
+ const bookmarked = isBookmarked(story.id);
28
+ const starPrefix = bookmarked ? `${theme.glyphs.bookmark} ` : '';
29
+ const meta = `${starPrefix}${metaText(story)}`;
30
+ 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), bookmarked && _jsx(Text, { color: theme.colors.accent, children: starPrefix }), _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
31
  }