@calesennett/pi-hn 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 +27 -0
- package/assets/pi-hn.png +0 -0
- package/extensions/hn.ts +482 -0
- package/package.json +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# pi-hn
|
|
2
|
+
|
|
3
|
+
A simple Hacker News front-page reader extension for [pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent).
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- `/hn` command opens a selectable front-page list
|
|
10
|
+
- Read tracking is stored in a local JSON file at `~/.pi/agent/data/pi-hn/db.json` (or `$PI_CODING_AGENT_DIR/data/pi-hn/db.json`)
|
|
11
|
+
- `j/k` (and arrow keys) navigate the list
|
|
12
|
+
- `a` or `Enter` opens the article URL
|
|
13
|
+
- `c` opens comments in browser
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:@calesennett/pi-hn
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Local development
|
|
22
|
+
|
|
23
|
+
In this repo, load the extension directly:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi -e ./extensions/hn.ts
|
|
27
|
+
```
|
package/assets/pi-hn.png
ADDED
|
Binary file
|
package/extensions/hn.ts
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { Container, SelectList, Text } from "@mariozechner/pi-tui";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
const HN_FRONT_PAGE_API = "https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=30";
|
|
9
|
+
|
|
10
|
+
interface HNHit {
|
|
11
|
+
title: string | null;
|
|
12
|
+
points: number | null;
|
|
13
|
+
num_comments: number | null;
|
|
14
|
+
url: string | null;
|
|
15
|
+
objectID: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface HNRawHit {
|
|
19
|
+
title?: string | null;
|
|
20
|
+
points?: number | null;
|
|
21
|
+
num_comments?: number | null;
|
|
22
|
+
url?: string | null;
|
|
23
|
+
objectID?: string | null;
|
|
24
|
+
objectId?: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface HNResponse {
|
|
28
|
+
hits: HNRawHit[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PersistResult {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ReadLookupResult {
|
|
37
|
+
ok: boolean;
|
|
38
|
+
readIds: Set<string>;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface StoredArticle {
|
|
43
|
+
id: number;
|
|
44
|
+
hn_id: string;
|
|
45
|
+
title: string | null;
|
|
46
|
+
url: string | null;
|
|
47
|
+
read_at: number | null;
|
|
48
|
+
first_seen_at: number;
|
|
49
|
+
last_seen_at: number;
|
|
50
|
+
created_at: number;
|
|
51
|
+
updated_at: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ArticleStore {
|
|
55
|
+
next_id: number;
|
|
56
|
+
articles: StoredArticle[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getPiAgentDir(): string {
|
|
60
|
+
const configured = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
61
|
+
if (configured && configured.length > 0) return configured;
|
|
62
|
+
return join(homedir(), ".pi", "agent");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const HN_STORE_PATH = join(getPiAgentDir(), "data", "pi-hn", "db.json");
|
|
66
|
+
let isStoreInitialized = false;
|
|
67
|
+
let storeInitError: string | null = null;
|
|
68
|
+
|
|
69
|
+
function nowUnix(): number {
|
|
70
|
+
return Math.floor(Date.now() / 1000);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function emptyStore(): ArticleStore {
|
|
74
|
+
return {
|
|
75
|
+
next_id: 1,
|
|
76
|
+
articles: [],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getErrorMessage(error: unknown): string {
|
|
81
|
+
if (error instanceof Error && error.message) return error.message;
|
|
82
|
+
return "Unknown error";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readStore(): ArticleStore {
|
|
86
|
+
if (!existsSync(HN_STORE_PATH)) {
|
|
87
|
+
return emptyStore();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const raw = readFileSync(HN_STORE_PATH, "utf8").trim();
|
|
91
|
+
if (raw.length === 0) return emptyStore();
|
|
92
|
+
|
|
93
|
+
const parsed = JSON.parse(raw) as Partial<ArticleStore>;
|
|
94
|
+
if (!Array.isArray(parsed.articles) || typeof parsed.next_id !== "number") {
|
|
95
|
+
throw new Error("Invalid JSON store format");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
next_id: parsed.next_id,
|
|
100
|
+
articles: parsed.articles,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function writeStore(store: ArticleStore): void {
|
|
105
|
+
mkdirSync(dirname(HN_STORE_PATH), { recursive: true });
|
|
106
|
+
const tmpPath = `${HN_STORE_PATH}.tmp`;
|
|
107
|
+
writeFileSync(tmpPath, `${JSON.stringify(store, null, 2)}\n`, "utf8");
|
|
108
|
+
renameSync(tmpPath, HN_STORE_PATH);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ensureStore(): PersistResult {
|
|
112
|
+
if (isStoreInitialized) return { ok: true };
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
mkdirSync(dirname(HN_STORE_PATH), { recursive: true });
|
|
116
|
+
if (!existsSync(HN_STORE_PATH)) {
|
|
117
|
+
writeStore(emptyStore());
|
|
118
|
+
} else {
|
|
119
|
+
void readStore();
|
|
120
|
+
}
|
|
121
|
+
isStoreInitialized = true;
|
|
122
|
+
storeInitError = null;
|
|
123
|
+
return { ok: true };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
storeInitError = getErrorMessage(error);
|
|
126
|
+
return { ok: false, error: storeInitError };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function persistFetchedArticles(hits: HNHit[]): PersistResult {
|
|
131
|
+
if (hits.length === 0) return { ok: true };
|
|
132
|
+
|
|
133
|
+
const ready = ensureStore();
|
|
134
|
+
if (!ready.ok) return ready;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const store = readStore();
|
|
138
|
+
const now = nowUnix();
|
|
139
|
+
const existingByHnId = new Map(store.articles.map((article) => [article.hn_id, article]));
|
|
140
|
+
|
|
141
|
+
for (const hit of hits) {
|
|
142
|
+
const existing = existingByHnId.get(hit.objectID);
|
|
143
|
+
if (existing) {
|
|
144
|
+
existing.title = hit.title ?? null;
|
|
145
|
+
existing.url = hit.url ?? null;
|
|
146
|
+
existing.last_seen_at = now;
|
|
147
|
+
existing.updated_at = now;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
store.articles.push({
|
|
152
|
+
id: store.next_id,
|
|
153
|
+
hn_id: hit.objectID,
|
|
154
|
+
title: hit.title ?? null,
|
|
155
|
+
url: hit.url ?? null,
|
|
156
|
+
read_at: null,
|
|
157
|
+
first_seen_at: now,
|
|
158
|
+
last_seen_at: now,
|
|
159
|
+
created_at: now,
|
|
160
|
+
updated_at: now,
|
|
161
|
+
});
|
|
162
|
+
store.next_id += 1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
writeStore(store);
|
|
166
|
+
return { ok: true };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { ok: false, error: getErrorMessage(error) };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function persistReadArticles(hits: HNHit[]): PersistResult {
|
|
173
|
+
if (hits.length === 0) return { ok: true };
|
|
174
|
+
|
|
175
|
+
const ready = ensureStore();
|
|
176
|
+
if (!ready.ok) return ready;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const store = readStore();
|
|
180
|
+
const now = nowUnix();
|
|
181
|
+
const existingByHnId = new Map(store.articles.map((article) => [article.hn_id, article]));
|
|
182
|
+
|
|
183
|
+
for (const hit of hits) {
|
|
184
|
+
const existing = existingByHnId.get(hit.objectID);
|
|
185
|
+
if (existing) {
|
|
186
|
+
existing.title = hit.title ?? null;
|
|
187
|
+
existing.url = hit.url ?? null;
|
|
188
|
+
existing.read_at = existing.read_at ?? now;
|
|
189
|
+
existing.last_seen_at = now;
|
|
190
|
+
existing.updated_at = now;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
store.articles.push({
|
|
195
|
+
id: store.next_id,
|
|
196
|
+
hn_id: hit.objectID,
|
|
197
|
+
title: hit.title ?? null,
|
|
198
|
+
url: hit.url ?? null,
|
|
199
|
+
read_at: now,
|
|
200
|
+
first_seen_at: now,
|
|
201
|
+
last_seen_at: now,
|
|
202
|
+
created_at: now,
|
|
203
|
+
updated_at: now,
|
|
204
|
+
});
|
|
205
|
+
store.next_id += 1;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
writeStore(store);
|
|
209
|
+
return { ok: true };
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return { ok: false, error: getErrorMessage(error) };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function loadReadArticleIds(hits: HNHit[]): ReadLookupResult {
|
|
216
|
+
if (hits.length === 0) return { ok: true, readIds: new Set<string>() };
|
|
217
|
+
|
|
218
|
+
const ready = ensureStore();
|
|
219
|
+
if (!ready.ok) return { ok: false, readIds: new Set<string>(), error: ready.error };
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const store = readStore();
|
|
223
|
+
const currentHitIds = new Set(hits.map((hit) => hit.objectID));
|
|
224
|
+
const readIds = new Set(
|
|
225
|
+
store.articles
|
|
226
|
+
.filter((article) => article.read_at !== null && currentHitIds.has(article.hn_id))
|
|
227
|
+
.map((article) => article.hn_id),
|
|
228
|
+
);
|
|
229
|
+
return { ok: true, readIds };
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return { ok: false, readIds: new Set<string>(), error: getErrorMessage(error) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function normalizeTitle(title: string | null | undefined): string {
|
|
236
|
+
const normalized = (title ?? "").replace(/\s+/g, " ").trim();
|
|
237
|
+
return normalized.length > 0 ? normalized : "(untitled)";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function formatListLabel(hit: HNHit, isRead: boolean): string {
|
|
241
|
+
const points = typeof hit.points === "number" ? hit.points : 0;
|
|
242
|
+
const comments = typeof hit.num_comments === "number" ? hit.num_comments : 0;
|
|
243
|
+
const marker = isRead ? "✓ " : " ";
|
|
244
|
+
return `${marker}${normalizeTitle(hit.title)} (${points} points, ${comments} comments)`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function commentsUrl(hit: HNHit): string {
|
|
248
|
+
return `https://news.ycombinator.com/item?id=${encodeURIComponent(hit.objectID)}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function openInBrowser(pi: ExtensionAPI, url: string): Promise<{ ok: boolean; error?: string }> {
|
|
252
|
+
const windowsQuotedUrl = `"${url.replace(/"/g, '""')}"`;
|
|
253
|
+
const result =
|
|
254
|
+
process.platform === "darwin"
|
|
255
|
+
? await pi.exec("open", [url])
|
|
256
|
+
: process.platform === "win32"
|
|
257
|
+
? await pi.exec("cmd", ["/c", "start", "", windowsQuotedUrl])
|
|
258
|
+
: await pi.exec("xdg-open", [url]);
|
|
259
|
+
|
|
260
|
+
if (result.code !== 0) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
error: result.stderr.trim() || `Failed to open URL (exit code ${result.code})`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { ok: true };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function fetchFrontPage(): Promise<HNHit[]> {
|
|
271
|
+
const response = await fetch(HN_FRONT_PAGE_API);
|
|
272
|
+
if (!response.ok) {
|
|
273
|
+
throw new Error(`Hacker News API returned ${response.status}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const data = (await response.json()) as Partial<HNResponse>;
|
|
277
|
+
if (!Array.isArray(data.hits)) return [];
|
|
278
|
+
|
|
279
|
+
return data.hits
|
|
280
|
+
.map((hit) => {
|
|
281
|
+
const objectID = hit.objectID ?? hit.objectId;
|
|
282
|
+
if (!objectID) return undefined;
|
|
283
|
+
return {
|
|
284
|
+
title: hit.title ?? null,
|
|
285
|
+
points: hit.points ?? 0,
|
|
286
|
+
num_comments: hit.num_comments ?? 0,
|
|
287
|
+
url: hit.url ?? null,
|
|
288
|
+
objectID,
|
|
289
|
+
} as HNHit;
|
|
290
|
+
})
|
|
291
|
+
.filter((hit): hit is HNHit => Boolean(hit))
|
|
292
|
+
.sort((a, b) => {
|
|
293
|
+
const pointsDiff = (b.points ?? 0) - (a.points ?? 0);
|
|
294
|
+
if (pointsDiff !== 0) return pointsDiff;
|
|
295
|
+
|
|
296
|
+
return (b.num_comments ?? 0) - (a.num_comments ?? 0);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export default function hackerNewsExtension(pi: ExtensionAPI) {
|
|
301
|
+
pi.registerCommand("hn", {
|
|
302
|
+
description: "Browse Hacker News front page in a list (a=article, c=comments)",
|
|
303
|
+
handler: async (_args, ctx) => {
|
|
304
|
+
if (!ctx.hasUI) return;
|
|
305
|
+
|
|
306
|
+
let hits: HNHit[];
|
|
307
|
+
try {
|
|
308
|
+
hits = await fetchFrontPage();
|
|
309
|
+
} catch (error) {
|
|
310
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
311
|
+
ctx.ui.notify(`Failed to load Hacker News front page: ${message}`, "error");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (hits.length === 0) {
|
|
316
|
+
ctx.ui.notify("Hacker News front page returned no items.", "warning");
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const persistedFetches = persistFetchedArticles(hits);
|
|
321
|
+
if (!persistedFetches.ok) {
|
|
322
|
+
ctx.ui.notify(
|
|
323
|
+
`Could not persist fetched articles to ${HN_STORE_PATH}: ${persistedFetches.error ?? "Unknown error"}`,
|
|
324
|
+
"warning",
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const readLookup = loadReadArticleIds(hits);
|
|
329
|
+
if (!readLookup.ok) {
|
|
330
|
+
ctx.ui.notify(
|
|
331
|
+
`Could not load read-article state from ${HN_STORE_PATH}: ${readLookup.error ?? "Unknown error"}`,
|
|
332
|
+
"warning",
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
const readArticleIds = readLookup.readIds;
|
|
336
|
+
|
|
337
|
+
const hitsById = new Map(hits.map((hit) => [hit.objectID, hit]));
|
|
338
|
+
|
|
339
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
340
|
+
const items = hits.map((hit) => ({
|
|
341
|
+
value: hit.objectID,
|
|
342
|
+
label: formatListLabel(hit, readArticleIds.has(hit.objectID)),
|
|
343
|
+
}));
|
|
344
|
+
const itemsById = new Map(items.map((item) => [item.value, item]));
|
|
345
|
+
|
|
346
|
+
const pendingReadHits = new Map<string, HNHit>();
|
|
347
|
+
let uiClosed = false;
|
|
348
|
+
const flushPendingReads = () => {
|
|
349
|
+
const result = persistReadArticles([...pendingReadHits.values()]);
|
|
350
|
+
if (!result.ok) {
|
|
351
|
+
ctx.ui.notify(
|
|
352
|
+
`Could not persist read articles to ${HN_STORE_PATH}: ${result.error ?? "Unknown error"}`,
|
|
353
|
+
"warning",
|
|
354
|
+
);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
pendingReadHits.clear();
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const closeUi = () => {
|
|
361
|
+
if (uiClosed) return;
|
|
362
|
+
uiClosed = true;
|
|
363
|
+
flushPendingReads();
|
|
364
|
+
done();
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const container = new Container();
|
|
368
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
369
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Hacker News"))));
|
|
370
|
+
|
|
371
|
+
const selectList = new SelectList(items, Math.min(items.length, 15), {
|
|
372
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
373
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
374
|
+
description: (text) => theme.fg("muted", text),
|
|
375
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
376
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
377
|
+
});
|
|
378
|
+
let selectedIndex = 0;
|
|
379
|
+
selectList.onSelectionChange = (item) => {
|
|
380
|
+
selectedIndex = Math.max(
|
|
381
|
+
0,
|
|
382
|
+
items.findIndex((candidate) => candidate.value === item.value),
|
|
383
|
+
);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const getSelectedHit = (): HNHit | undefined => {
|
|
387
|
+
const selected = selectList.getSelectedItem();
|
|
388
|
+
if (!selected) return undefined;
|
|
389
|
+
return hitsById.get(selected.value);
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
const openSelectedArticle = async () => {
|
|
393
|
+
const hit = getSelectedHit();
|
|
394
|
+
if (!hit) return;
|
|
395
|
+
if (!hit.url) {
|
|
396
|
+
ctx.ui.notify("This item has no article URL.", "warning");
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const result = await openInBrowser(pi, hit.url);
|
|
401
|
+
if (!result.ok) {
|
|
402
|
+
ctx.ui.notify(`Could not open article: ${result.error ?? "Unknown error"}`, "error");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
pendingReadHits.set(hit.objectID, hit);
|
|
407
|
+
readArticleIds.add(hit.objectID);
|
|
408
|
+
|
|
409
|
+
if (uiClosed) {
|
|
410
|
+
flushPendingReads();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const item = itemsById.get(hit.objectID);
|
|
415
|
+
if (item) {
|
|
416
|
+
item.label = formatListLabel(hit, true);
|
|
417
|
+
selectList.invalidate();
|
|
418
|
+
tui.requestRender();
|
|
419
|
+
}
|
|
420
|
+
if (pendingReadHits.size >= 10) {
|
|
421
|
+
flushPendingReads();
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const openSelectedComments = async () => {
|
|
426
|
+
const hit = getSelectedHit();
|
|
427
|
+
if (!hit) return;
|
|
428
|
+
|
|
429
|
+
const result = await openInBrowser(pi, commentsUrl(hit));
|
|
430
|
+
if (!result.ok) {
|
|
431
|
+
ctx.ui.notify(`Could not open comments: ${result.error ?? "Unknown error"}`, "error");
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
selectList.onSelect = () => {
|
|
436
|
+
void openSelectedArticle();
|
|
437
|
+
};
|
|
438
|
+
selectList.onCancel = () => closeUi();
|
|
439
|
+
|
|
440
|
+
container.addChild(selectList);
|
|
441
|
+
container.addChild(
|
|
442
|
+
new Text(theme.fg("dim", "↑↓/j/k navigate • enter/a article • c comments • esc close")),
|
|
443
|
+
);
|
|
444
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
render(width: number) {
|
|
448
|
+
return container.render(width);
|
|
449
|
+
},
|
|
450
|
+
invalidate() {
|
|
451
|
+
container.invalidate();
|
|
452
|
+
},
|
|
453
|
+
handleInput(data: string) {
|
|
454
|
+
if (data === "a" || data === "A") {
|
|
455
|
+
void openSelectedArticle();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (data === "c" || data === "C") {
|
|
459
|
+
void openSelectedComments();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (data === "j" || data === "J") {
|
|
463
|
+
selectedIndex = (selectedIndex + 1) % items.length;
|
|
464
|
+
selectList.setSelectedIndex(selectedIndex);
|
|
465
|
+
tui.requestRender();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (data === "k" || data === "K") {
|
|
469
|
+
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
|
|
470
|
+
selectList.setSelectedIndex(selectedIndex);
|
|
471
|
+
tui.requestRender();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
selectList.handleInput(data);
|
|
476
|
+
tui.requestRender();
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
});
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@calesennett/pi-hn",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Hacker News front-page reader extension for pi",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"hacker-news",
|
|
9
|
+
"hn"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/calesennett/pi-hn.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/calesennett/pi-hn#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/calesennett/pi-hn/issues"
|
|
19
|
+
}
|
|
20
|
+
}
|