From 7b4e60b50b942d3ec89f1f63018b92f7968e3468 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 17:08:23 +0000 Subject: [PATCH 1/3] Fix cross-platform URL opening without shell interpolation Open story URLs with execFile on darwin, win32, and linux so Windows uses cmd /c start and URLs are passed as arguments, not interpolated into a shell string. Also point the OpenTUI credit at anomalyco/opentui. Co-authored-by: Brian Lovin --- README.md | 2 +- src/index.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b1e5336..3299b3f 100644 --- a/README.md +++ b/README.md @@ -210,4 +210,4 @@ This permanently disables telemetry. Your preference is stored locally at `~/.co ## Credits -Built with [OpenTUI](https://github.com/anthropics/opentui) +Built with [OpenTUI](https://github.com/anomalyco/opentui) diff --git a/src/index.ts b/src/index.ts index 6c3f47d..57534ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun import { createCliRenderer } from "@opentui/core"; -import { exec } from "child_process"; +import { execFile } from "node:child_process"; import { HackerNewsApp } from "./app"; import { checkForUpdates, currentVersion } from "./version"; import { setTelemetryEnabled } from "./config"; @@ -10,6 +10,16 @@ const COLORS = { bg: "#1a1a1a", }; +function openUrl(url: string): void { + if (process.platform === "darwin") { + execFile("open", [url]); + } else if (process.platform === "win32") { + execFile("cmd", ["/c", "start", "", url]); + } else { + execFile("xdg-open", [url]); + } +} + function parseArgs(): { storyId?: number } { const args = process.argv.slice(2); let storyId: number | undefined; @@ -64,9 +74,7 @@ async function main() { }); const app = new HackerNewsApp(renderer, { - onOpenUrl: (url) => { - exec(`open "${url}"`); - }, + onOpenUrl: openUrl, onExit: async () => { await telemetry.flushSync(); renderer.destroy(); From f22107a0d5dd968e41c653f08a1d4a9b9beecac4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 17:15:16 +0000 Subject: [PATCH 2/3] Scroll story list into view without waiting on item fetch scrollToStory ran after await getPostById, so a slow or hung HNPWA request left selectedIndex updated and scrollTop at 0. Scroll immediately from the last laid-out frame, ignore zero-size layout metrics, and lock the regression with a hanging-fetch test. Co-authored-by: Brian Lovin --- src/app.ts | 5 ++++- src/components/StoryList.ts | 4 ++++ src/test/app.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index 464f53a..96887cc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -624,6 +624,10 @@ export class HackerNewsApp { this.rootCommentIndex = 0; updateStorySelection(this.storyListState, this.posts, previousIndex, index); + // Scroll immediately from the last laid-out frame. Detail fetching is + // independent — waiting on getPostById left the list unmoved when the + // network was slow or hung (selectedIndex updated, scrollTop stayed 0). + scrollToStory(this.storyListState, index); const post = this.posts[index]; if (!post) return; @@ -673,7 +677,6 @@ export class HackerNewsApp { } if (this.renderer.isDestroyed) return; - scrollToStory(this.storyListState, index); this.saveToCache(); } diff --git a/src/components/StoryList.ts b/src/components/StoryList.ts index 12bfde9..ea569b2 100644 --- a/src/components/StoryList.ts +++ b/src/components/StoryList.ts @@ -174,6 +174,10 @@ export function scrollToStory( const viewportHeight = state.scroll.viewport.height; const currentScroll = state.scroll.scrollTop; + // Layout has not run yet — do not treat a zero-size item as visible, + // and never reset an existing scroll position from stale 0,0 metrics. + if (viewportHeight <= 0 || item.height <= 0) return; + // Only scroll if the item is outside the visible viewport if (itemTop < currentScroll) { // Item is above viewport - scroll up to show it at top diff --git a/src/test/app.test.ts b/src/test/app.test.ts index ba211ca..c767eff 100644 --- a/src/test/app.test.ts +++ b/src/test/app.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { createAppTestContext, cleanupTestContext, type TestContext } from "./test-utils"; import { createMockPosts, createMockPostWithComments } from "./fixtures"; +import { scrollToStory } from "../components/StoryList"; describe("HackerNewsApp", () => { let ctx: TestContext; @@ -115,7 +116,23 @@ describe("HackerNewsApp", () => { }); describe("Story List Scroll", () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + // List scroll must not depend on HN item fetches (CI cannot reach / hangs on hnpwa). + globalThis.fetch = (async () => + new Response(null, { status: 404 })) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + it("should scroll down to show off-screen selected story", async () => { + // Hang the item fetch: scroll-into-view must not wait on getPostById. + globalThis.fetch = (() => new Promise(() => {})) as typeof fetch; + // Create more stories than can fit in viewport const posts = createMockPosts(20); ctx.app.setPostsForTesting(posts); @@ -139,6 +156,19 @@ describe("HackerNewsApp", () => { expect(storyListState.scroll.scrollTop).toBeGreaterThan(0); }); + it("scrollToStory moves an off-screen item into view from current layout", async () => { + const posts = createMockPosts(20); + ctx.app.setPostsForTesting(posts); + await ctx.renderOnce(); + + const storyListState = (ctx.app as any).storyListState; + expect(storyListState.scroll.scrollTop).toBe(0); + + scrollToStory(storyListState, 14); + + expect(storyListState.scroll.scrollTop).toBeGreaterThan(0); + }); + it("should scroll up to show off-screen selected story", async () => { // Create more stories than can fit in viewport const posts = createMockPosts(20); From 2dc00f329f4d45fc981086a4519c6ff873a86e30 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 17:15:41 +0000 Subject: [PATCH 3/3] Fix fetch mock types in story list scroll tests Attach preconnect so the hanging/404 fetch stubs satisfy typeof fetch. Co-authored-by: Brian Lovin --- src/test/app.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/test/app.test.ts b/src/test/app.test.ts index c767eff..5c27f91 100644 --- a/src/test/app.test.ts +++ b/src/test/app.test.ts @@ -121,8 +121,9 @@ describe("HackerNewsApp", () => { beforeEach(() => { originalFetch = globalThis.fetch; // List scroll must not depend on HN item fetches (CI cannot reach / hangs on hnpwa). - globalThis.fetch = (async () => - new Response(null, { status: 404 })) as typeof fetch; + const mockFetch = async () => new Response(null, { status: 404 }); + mockFetch.preconnect = originalFetch.preconnect; + globalThis.fetch = mockFetch as typeof fetch; }); afterEach(() => { @@ -131,7 +132,9 @@ describe("HackerNewsApp", () => { it("should scroll down to show off-screen selected story", async () => { // Hang the item fetch: scroll-into-view must not wait on getPostById. - globalThis.fetch = (() => new Promise(() => {})) as typeof fetch; + const hangingFetch = () => new Promise(() => {}); + hangingFetch.preconnect = originalFetch.preconnect; + globalThis.fetch = hangingFetch as typeof fetch; // Create more stories than can fit in viewport const posts = createMockPosts(20);