@calesennett/pi-hn 0.1.1 → 0.1.2

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 CHANGED
@@ -7,10 +7,11 @@ A simple Hacker News front-page reader extension for [pi](https://github.com/bad
7
7
  ## Features
8
8
 
9
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
10
  - `j/k` (and arrow keys) navigate the list
12
11
  - `a` or `Enter` opens the article URL
12
+ - `x` fetches and parses the selected article (via Readability) and adds it to session context for follow-up prompts
13
13
  - `c` opens comments in browser
14
+ - 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`)
14
15
 
15
16
  ## Install
16
17
 
package/extensions/hn.ts CHANGED
@@ -1,11 +1,36 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { DynamicBorder } from "@mariozechner/pi-coding-agent";
3
+ import { Readability } from "@mozilla/readability";
3
4
  import { Container, SelectList, Text } from "@mariozechner/pi-tui";
5
+ import { JSDOM, VirtualConsole } from "jsdom";
4
6
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
7
  import { homedir } from "node:os";
6
8
  import { dirname, join } from "node:path";
7
9
 
8
10
  const HN_FRONT_PAGE_API = "https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=30";
11
+ const ARTICLE_CONTEXT_MESSAGE_TYPE = "hn-article-context";
12
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
13
+
14
+ interface ReadableArticle {
15
+ title: string;
16
+ url: string;
17
+ byline: string | null;
18
+ siteName: string | null;
19
+ excerpt: string | null;
20
+ textContent: string;
21
+ length: number;
22
+ }
23
+
24
+ interface ArticleContextDetails {
25
+ hnId: string;
26
+ title: string;
27
+ url: string;
28
+ siteName: string | null;
29
+ byline: string | null;
30
+ length: number;
31
+ charCount: number;
32
+ fetchedAt: string;
33
+ }
9
34
 
10
35
  interface HNHit {
11
36
  title: string | null;
@@ -248,6 +273,86 @@ function commentsUrl(hit: HNHit): string {
248
273
  return `https://news.ycombinator.com/item?id=${encodeURIComponent(hit.objectID)}`;
249
274
  }
250
275
 
276
+ function normalizeArticleText(text: string): string {
277
+ return text
278
+ .replace(/\u00a0/g, " ")
279
+ .replace(/\r/g, "")
280
+ .replace(/[ \t]+\n/g, "\n")
281
+ .replace(/\n{3,}/g, "\n\n")
282
+ .trim();
283
+ }
284
+
285
+ function createArticleDom(html: string, url: string): JSDOM {
286
+ const virtualConsole = new VirtualConsole();
287
+ virtualConsole.on("jsdomError", (error) => {
288
+ if (error instanceof Error && error.message.includes("Could not parse CSS stylesheet")) return;
289
+ console.error(error);
290
+ });
291
+ return new JSDOM(html, { url, virtualConsole });
292
+ }
293
+
294
+ async function fetchReadableArticle(url: string): Promise<ReadableArticle> {
295
+ const response = await fetch(url, {
296
+ headers: {
297
+ accept: "text/html,application/xhtml+xml",
298
+ "user-agent": "pi-hn/0.1",
299
+ },
300
+ });
301
+ if (!response.ok) {
302
+ throw new Error(`Article request returned ${response.status}`);
303
+ }
304
+
305
+ const html = await response.text();
306
+ const resolvedUrl = response.url || url;
307
+ const dom = createArticleDom(html, resolvedUrl);
308
+ const fallbackDom = createArticleDom(html, resolvedUrl);
309
+ fallbackDom.window.document.querySelectorAll("script, style").forEach((node) => node.remove());
310
+ const fallbackText = normalizeArticleText(fallbackDom.window.document.body?.textContent ?? "");
311
+ const parsed = new Readability(dom.window.document).parse();
312
+ const textContent = normalizeArticleText(parsed?.textContent ?? fallbackText);
313
+ if (textContent.length === 0) {
314
+ throw new Error("Article had no readable text content");
315
+ }
316
+
317
+ return {
318
+ title: normalizeTitle(parsed?.title ?? dom.window.document.title),
319
+ url: resolvedUrl,
320
+ byline: parsed?.byline?.trim() || null,
321
+ siteName: parsed?.siteName?.trim() || null,
322
+ excerpt: parsed?.excerpt?.trim() || null,
323
+ textContent,
324
+ length: parsed?.length ?? textContent.length,
325
+ };
326
+ }
327
+
328
+ function buildArticleContext(hit: HNHit, article: ReadableArticle): { content: string; details: ArticleContextDetails } {
329
+ const title = article.title === "(untitled)" ? normalizeTitle(hit.title) : article.title;
330
+ const lines: string[] = [
331
+ `Title: ${title}`,
332
+ `URL: ${article.url}`,
333
+ `HN Comments: ${commentsUrl(hit)}`,
334
+ ];
335
+
336
+ if (article.siteName) lines.push(`Site: ${article.siteName}`);
337
+ if (article.byline) lines.push(`Byline: ${article.byline}`);
338
+ if (article.excerpt) lines.push(`Excerpt: ${article.excerpt}`);
339
+ lines.push("", "Article Text:", article.textContent);
340
+
341
+ return {
342
+ content: lines.join("\n"),
343
+ details: {
344
+ hnId: hit.objectID,
345
+ title,
346
+ url: article.url,
347
+ siteName: article.siteName,
348
+ byline: article.byline,
349
+ length: article.length,
350
+ charCount: article.textContent.length,
351
+ fetchedAt: new Date().toISOString(),
352
+ },
353
+ };
354
+ }
355
+
251
356
  async function openInBrowser(pi: ExtensionAPI, url: string): Promise<{ ok: boolean; error?: string }> {
252
357
  const windowsQuotedUrl = `"${url.replace(/"/g, '""')}"`;
253
358
  const result =
@@ -298,8 +403,28 @@ async function fetchFrontPage(): Promise<HNHit[]> {
298
403
  }
299
404
 
300
405
  export default function hackerNewsExtension(pi: ExtensionAPI) {
406
+ pi.registerMessageRenderer(ARTICLE_CONTEXT_MESSAGE_TYPE, (message, { expanded }, theme) => {
407
+ const details = (message.details ?? {}) as Partial<ArticleContextDetails>;
408
+ const title = details.title ?? "(untitled)";
409
+ const charCount = typeof details.charCount === "number" ? details.charCount : undefined;
410
+ const charLabel =
411
+ typeof charCount === "number" ? theme.fg("dim", ` (${charCount.toLocaleString()} chars)`) : "";
412
+
413
+ let text = `${theme.bold(`[${title}]`)}${charLabel}`;
414
+ if (expanded) {
415
+ if (details.url) text += `\n${theme.fg("muted", `URL: ${details.url}`)}`;
416
+ if (details.siteName) text += `\n${theme.fg("muted", `Site: ${details.siteName}`)}`;
417
+ if (details.byline) text += `\n${theme.fg("muted", `Byline: ${details.byline}`)}`;
418
+ if (details.fetchedAt) {
419
+ text += `\n${theme.fg("dim", `Fetched: ${new Date(details.fetchedAt).toLocaleString()}`)}`;
420
+ }
421
+ }
422
+
423
+ return new Text(text, 0, 0);
424
+ });
425
+
301
426
  pi.registerCommand("hn", {
302
- description: "Browse Hacker News front page in a list (a=article, c=comments)",
427
+ description: "Browse Hacker News front page (a/enter=article, x=add context, c=comments)",
303
428
  handler: async (_args, ctx) => {
304
429
  if (!ctx.hasUI) return;
305
430
 
@@ -345,7 +470,21 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
345
470
 
346
471
  const pendingReadHits = new Map<string, HNHit>();
347
472
  let uiClosed = false;
473
+ const hintText = [
474
+ `${theme.fg("dim", theme.bold("↑↓/j/k"))} ${theme.fg("dim", "navigate")}`,
475
+ `${theme.fg("dim", theme.bold("enter/a"))} ${theme.fg("dim", "article")}`,
476
+ `${theme.fg("dim", theme.bold("x"))} ${theme.fg("dim", "add to context")}`,
477
+ `${theme.fg("dim", theme.bold("c"))} ${theme.fg("dim", "comments")}`,
478
+ `${theme.fg("dim", theme.bold("esc"))} ${theme.fg("dim", "close")}`,
479
+ ].join(theme.fg("dim", " • "));
480
+ const hint = new Text(hintText);
481
+ let contextLoadHit: HNHit | null = null;
482
+ let spinnerFrame = 0;
483
+ let spinnerTimer: ReturnType<typeof setInterval> | undefined;
484
+
348
485
  const flushPendingReads = () => {
486
+ if (pendingReadHits.size === 0) return;
487
+
349
488
  const result = persistReadArticles([...pendingReadHits.values()]);
350
489
  if (!result.ok) {
351
490
  ctx.ui.notify(
@@ -357,9 +496,42 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
357
496
  pendingReadHits.clear();
358
497
  };
359
498
 
499
+ const setHint = (text: string) => {
500
+ hint.setText(text);
501
+ hint.invalidate();
502
+ if (!uiClosed) tui.requestRender();
503
+ };
504
+
505
+ const updateHint = (text: string, color: "dim" | "warning" = "dim") => {
506
+ setHint(theme.fg(color, text));
507
+ };
508
+
509
+ const stopContextSpinner = () => {
510
+ if (spinnerTimer) {
511
+ clearInterval(spinnerTimer);
512
+ spinnerTimer = undefined;
513
+ }
514
+ contextLoadHit = null;
515
+ spinnerFrame = 0;
516
+ setHint(hintText);
517
+ };
518
+
519
+ const startContextSpinner = (hit: HNHit) => {
520
+ contextLoadHit = hit;
521
+ spinnerFrame = 0;
522
+ const animate = () => {
523
+ const frame = SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "*";
524
+ spinnerFrame += 1;
525
+ updateHint(`${frame} fetching article and adding to session context...`, "warning");
526
+ };
527
+ animate();
528
+ spinnerTimer = setInterval(animate, 110);
529
+ };
530
+
360
531
  const closeUi = () => {
361
532
  if (uiClosed) return;
362
533
  uiClosed = true;
534
+ stopContextSpinner();
363
535
  flushPendingReads();
364
536
  done();
365
537
  };
@@ -389,6 +561,26 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
389
561
  return hitsById.get(selected.value);
390
562
  };
391
563
 
564
+ const markHitRead = (hit: HNHit) => {
565
+ pendingReadHits.set(hit.objectID, hit);
566
+ readArticleIds.add(hit.objectID);
567
+
568
+ if (uiClosed) {
569
+ flushPendingReads();
570
+ return;
571
+ }
572
+
573
+ const item = itemsById.get(hit.objectID);
574
+ if (item) {
575
+ item.label = formatListLabel(hit, true);
576
+ selectList.invalidate();
577
+ tui.requestRender();
578
+ }
579
+ if (pendingReadHits.size >= 10) {
580
+ flushPendingReads();
581
+ }
582
+ };
583
+
392
584
  const openSelectedArticle = async () => {
393
585
  const hit = getSelectedHit();
394
586
  if (!hit) return;
@@ -403,22 +595,44 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
403
595
  return;
404
596
  }
405
597
 
406
- pendingReadHits.set(hit.objectID, hit);
407
- readArticleIds.add(hit.objectID);
598
+ markHitRead(hit);
599
+ };
408
600
 
409
- if (uiClosed) {
410
- flushPendingReads();
601
+ const addSelectedArticleToContext = async () => {
602
+ const hit = getSelectedHit();
603
+ if (!hit) return;
604
+ if (!hit.url) {
605
+ ctx.ui.notify("This item has no article URL.", "warning");
411
606
  return;
412
607
  }
413
-
414
- const item = itemsById.get(hit.objectID);
415
- if (item) {
416
- item.label = formatListLabel(hit, true);
417
- selectList.invalidate();
418
- tui.requestRender();
608
+ if (contextLoadHit) {
609
+ ctx.ui.notify(`Already fetching: ${normalizeTitle(contextLoadHit.title)}`, "warning");
610
+ return;
419
611
  }
420
- if (pendingReadHits.size >= 10) {
421
- flushPendingReads();
612
+
613
+ startContextSpinner(hit);
614
+ try {
615
+ const article = await fetchReadableArticle(hit.url);
616
+ if (uiClosed) return;
617
+ const contextMessage = buildArticleContext(hit, article);
618
+ pi.sendMessage(
619
+ {
620
+ customType: ARTICLE_CONTEXT_MESSAGE_TYPE,
621
+ content: contextMessage.content,
622
+ details: contextMessage.details,
623
+ display: true,
624
+ },
625
+ { triggerTurn: false },
626
+ );
627
+ markHitRead(hit);
628
+ const addedTitle = article.title === "(untitled)" ? normalizeTitle(hit.title) : article.title;
629
+ ctx.ui.notify(`Added to session context: ${addedTitle}`, "info");
630
+ } catch (error) {
631
+ if (!uiClosed) {
632
+ ctx.ui.notify(`Could not add article to context: ${getErrorMessage(error)}`, "error");
633
+ }
634
+ } finally {
635
+ stopContextSpinner();
422
636
  }
423
637
  };
424
638
 
@@ -438,9 +652,7 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
438
652
  selectList.onCancel = () => closeUi();
439
653
 
440
654
  container.addChild(selectList);
441
- container.addChild(
442
- new Text(theme.fg("dim", "↑↓/j/k navigate • enter/a article • c comments • esc close")),
443
- );
655
+ container.addChild(hint);
444
656
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
445
657
 
446
658
  return {
@@ -451,6 +663,10 @@ export default function hackerNewsExtension(pi: ExtensionAPI) {
451
663
  container.invalidate();
452
664
  },
453
665
  handleInput(data: string) {
666
+ if (data === "x" || data === "X") {
667
+ void addSelectedArticleToContext();
668
+ return;
669
+ }
454
670
  if (data === "a" || data === "A") {
455
671
  void openSelectedArticle();
456
672
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calesennett/pi-hn",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Hacker News front-page reader extension for pi",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -16,5 +16,9 @@
16
16
  "homepage": "https://github.com/calesennett/pi-hn#readme",
17
17
  "bugs": {
18
18
  "url": "https://github.com/calesennett/pi-hn/issues"
19
+ },
20
+ "dependencies": {
21
+ "@mozilla/readability": "^0.5.0",
22
+ "jsdom": "^24.1.3"
19
23
  }
20
24
  }