From b25dca79a94fd593c4fd155c9a77df135a3ce57d Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Tue, 7 Apr 2026 15:26:54 -0400 Subject: [PATCH 1/6] Decouple app mouse support from pager mode --- src/core/terminal.test.ts | 30 ++++++++++++++++++++++++++++++ src/core/terminal.ts | 13 +++++++++++++ src/main.tsx | 7 +++++-- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/core/terminal.test.ts b/src/core/terminal.test.ts index bc5033f6..7b5cad08 100644 --- a/src/core/terminal.test.ts +++ b/src/core/terminal.test.ts @@ -3,6 +3,7 @@ import type { CliInput } from "./types"; import { openControllingTerminal, resolveRuntimeCliInput, + shouldUseMouseForApp, shouldUsePagerMode, usesPipedPatchInput, } from "./terminal"; @@ -41,6 +42,35 @@ describe("terminal runtime defaults", () => { }); }); +describe("app mouse support", () => { + test("enables mouse for interactive stdin", () => { + expect( + shouldUseMouseForApp({ + stdinIsTTY: true, + hasControllingTerminal: false, + }), + ).toBe(true); + }); + + test("enables mouse when a controlling terminal is attached", () => { + expect( + shouldUseMouseForApp({ + stdinIsTTY: false, + hasControllingTerminal: true, + }), + ).toBe(true); + }); + + test("disables mouse when no interactive terminal is available", () => { + expect( + shouldUseMouseForApp({ + stdinIsTTY: false, + hasControllingTerminal: false, + }), + ).toBe(false); + }); +}); + describe("controlling terminal attachment", () => { test("opens /dev/tty for read and write and closes both streams", () => { const calls: Array<[string, string]> = []; diff --git a/src/core/terminal.ts b/src/core/terminal.ts index 8e4da0d8..50034701 100644 --- a/src/core/terminal.ts +++ b/src/core/terminal.ts @@ -2,6 +2,11 @@ import fs from "node:fs"; import tty from "node:tty"; import type { CliInput } from "./types"; +export interface AppMouseOptions { + stdinIsTTY?: boolean; + hasControllingTerminal?: boolean; +} + /** Detect the stdin-pipe patch workflow used by `git diff` pagers. */ export function usesPipedPatchInput(input: CliInput, stdinIsTTY = Boolean(process.stdin.isTTY)) { return input.kind === "patch" && (!input.file || input.file === "-") && !stdinIsTTY; @@ -26,6 +31,14 @@ export function resolveRuntimeCliInput( } as CliInput; } +/** Keep mouse support tied to terminal interactivity instead of pager chrome mode. */ +export function shouldUseMouseForApp({ + stdinIsTTY = Boolean(process.stdin.isTTY), + hasControllingTerminal = false, +}: AppMouseOptions = {}) { + return stdinIsTTY || hasControllingTerminal; +} + export interface ControllingTerminal { stdin: tty.ReadStream; stdout: tty.WriteStream; diff --git a/src/main.tsx b/src/main.tsx index ce010d4f..9aeeb2e4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import { formatCliError } from "./core/errors"; import { pagePlainText } from "./core/pager"; import { shutdownSession } from "./core/shutdown"; import { prepareStartupPlan } from "./core/startup"; +import { shouldUseMouseForApp } from "./core/terminal"; import { resolveStartupUpdateNotice } from "./core/updateNotice"; import { AppHost } from "./ui/AppHost"; import { SessionBrokerClient } from "./session-broker/brokerClient"; @@ -50,7 +51,7 @@ async function main() { throw new Error("Unreachable startup plan."); } - const { bootstrap, cliInput, controllingTerminal } = startupPlan; + const { bootstrap, controllingTerminal } = startupPlan; const hostClient = new SessionBrokerClient< HunkSessionInfo, HunkSessionState, @@ -62,7 +63,9 @@ async function main() { const renderer = await createCliRenderer({ stdin: controllingTerminal?.stdin, stdout: controllingTerminal?.stdout, - useMouse: !cliInput.options.pager, + useMouse: shouldUseMouseForApp({ + hasControllingTerminal: Boolean(controllingTerminal), + }), useAlternateScreen: true, exitOnCtrlC: true, openConsoleOnError: true, From 286c0023d6b6f833e37b63a87bef3b2ee35bfa1f Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Tue, 7 Apr 2026 15:32:16 -0400 Subject: [PATCH 2/6] Add PTY coverage for pager-mode mouse scrolling --- test/pty/harness.ts | 44 ++++++++++++ test/pty/ui-integration.test.ts | 123 ++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/test/pty/harness.ts b/test/pty/harness.ts index 37491419..d820dbfa 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -64,6 +64,11 @@ function writeText(path: string, content: string) { writeFileSync(path, content); } +/** Quote shell arguments so PTY helpers can safely launch piped commands through Bash. */ +function shellQuote(value: string) { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + /** Build numbered export lines so PTY fixtures can assert on stable visible content. */ function createNumberedExportLines(start: number, count: number, valueOffset = 0) { return Array.from({ length: count }, (_, index) => { @@ -357,6 +362,17 @@ export function createPtyHarness() { return { dir, patchFile }; } + /** Build the source-run Hunk command so PTY tests can reuse it inside shell pipelines. */ + function buildHunkCommand(args: string[]) { + return [ + shellQuote(bunExecutable), + "run", + shellQuote(sourceEntrypoint), + "--", + ...args.map(shellQuote), + ].join(" "); + } + async function launchHunk(options: { args: string[]; cwd?: string; @@ -381,6 +397,31 @@ export function createPtyHarness() { }); } + /** Launch an arbitrary shell command inside the PTY for pipeline-style integration tests. */ + async function launchShellCommand(options: { + command: string; + cwd?: string; + cols?: number; + rows?: number; + env?: Record; + }) { + const { launchTerminal } = await loadTuistory(); + + return launchTerminal({ + command: "/bin/bash", + args: ["-lc", options.command], + cwd: options.cwd ?? repoRoot, + cols: options.cols ?? 140, + rows: options.rows ?? 24, + env: { + ...process.env, + HUNK_MCP_DISABLE: "1", + HUNK_DISABLE_UPDATE_NOTICE: "1", + ...options.env, + }, + }); + } + async function waitForSnapshot( session: Session, predicate: (text: string) => boolean, @@ -422,6 +463,9 @@ export function createPtyHarness() { createSidebarJumpRepoFixture, createTwoFileRepoFixture, launchHunk, + launchShellCommand, + buildHunkCommand, + shellQuote, waitForSnapshot, }; } diff --git a/test/pty/ui-integration.test.ts b/test/pty/ui-integration.test.ts index ba8b72bc..1b068644 100644 --- a/test/pty/ui-integration.test.ts +++ b/test/pty/ui-integration.test.ts @@ -591,6 +591,129 @@ describe("live UI integration", () => { } }); + test("stdin patch mode enables mouse wheel scrolling in pager UI", async () => { + const fixture = harness.createPagerPatchFixture(60); + const session = await harness.launchShellCommand({ + command: `cat ${harness.shellQuote(fixture.patchFile)} | ${harness.buildHunkCommand(["patch", "-"])}`, + cols: 120, + rows: 12, + }); + + try { + const initial = await session.waitForText(/scroll\.ts/, { timeout: 15_000 }); + + expect(initial).not.toContain("View Navigate Theme Agent Help"); + expect(initial).toContain("before_01"); + expect(initial).not.toContain("before_12"); + + await session.waitIdle({ timeout: 200 }); + await session.scrollDown(10); + const scrolled = await harness.waitForSnapshot( + session, + (text) => !text.includes("before_01") && text.includes("before_12"), + 5_000, + ); + + expect(scrolled).not.toContain("View Navigate Theme Agent Help"); + expect(scrolled).not.toContain("before_01"); + expect(scrolled).toContain("before_12"); + + await session.scrollUp(10); + const restored = await harness.waitForSnapshot( + session, + (text) => text.includes("before_01") && !text.includes("before_12"), + 5_000, + ); + + expect(restored).toContain("before_01"); + expect(restored).not.toContain("before_12"); + } finally { + session.close(); + } + }); + + test("general pager mode enables mouse wheel scrolling for diff-like stdin", async () => { + const fixture = harness.createPagerPatchFixture(60); + const session = await harness.launchShellCommand({ + command: `cat ${harness.shellQuote(fixture.patchFile)} | ${harness.buildHunkCommand(["pager"])}`, + cols: 120, + rows: 12, + }); + + try { + const initial = await session.waitForText(/scroll\.ts/, { timeout: 15_000 }); + + expect(initial).not.toContain("View Navigate Theme Agent Help"); + expect(initial).toContain("before_01"); + expect(initial).not.toContain("before_12"); + + await session.waitIdle({ timeout: 200 }); + await session.scrollDown(10); + const scrolled = await harness.waitForSnapshot( + session, + (text) => !text.includes("before_01") && text.includes("before_12"), + 5_000, + ); + + expect(scrolled).not.toContain("View Navigate Theme Agent Help"); + expect(scrolled).not.toContain("before_01"); + expect(scrolled).toContain("before_12"); + + await session.scrollUp(10); + const restored = await harness.waitForSnapshot( + session, + (text) => text.includes("before_01") && !text.includes("before_12"), + 5_000, + ); + + expect(restored).toContain("before_01"); + expect(restored).not.toContain("before_12"); + } finally { + session.close(); + } + }); + + test("explicit pager mode still supports mouse wheel scrolling on a TTY", async () => { + const fixture = harness.createPagerPatchFixture(60); + const session = await harness.launchHunk({ + args: ["patch", fixture.patchFile, "--pager"], + cols: 120, + rows: 12, + }); + + try { + const initial = await session.waitForText(/scroll\.ts/, { timeout: 15_000 }); + + expect(initial).not.toContain("View Navigate Theme Agent Help"); + expect(initial).toContain("before_01"); + expect(initial).not.toContain("before_12"); + + await session.waitIdle({ timeout: 200 }); + await session.scrollDown(10); + const scrolled = await harness.waitForSnapshot( + session, + (text) => !text.includes("before_01") && text.includes("before_12"), + 5_000, + ); + + expect(scrolled).not.toContain("View Navigate Theme Agent Help"); + expect(scrolled).not.toContain("before_01"); + expect(scrolled).toContain("before_12"); + + await session.scrollUp(10); + const restored = await harness.waitForSnapshot( + session, + (text) => text.includes("before_01") && !text.includes("before_12"), + 5_000, + ); + + expect(restored).toContain("before_01"); + expect(restored).not.toContain("before_12"); + } finally { + session.close(); + } + }); + test("keyboard help can open with ? in a real PTY", async () => { const fixture = harness.createTwoFileRepoFixture(); const session = await harness.launchHunk({ From 649a0a8911ec96dccad3299513b49724ee1eb6b3 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Tue, 7 Apr 2026 17:04:29 -0400 Subject: [PATCH 3/6] Attach piped pager input through /dev/tty only --- src/core/terminal.test.ts | 24 +++--------------------- src/core/terminal.ts | 12 ++++-------- src/main.tsx | 2 +- 3 files changed, 8 insertions(+), 30 deletions(-) diff --git a/src/core/terminal.test.ts b/src/core/terminal.test.ts index 7b5cad08..67a3e728 100644 --- a/src/core/terminal.test.ts +++ b/src/core/terminal.test.ts @@ -72,48 +72,33 @@ describe("app mouse support", () => { }); describe("controlling terminal attachment", () => { - test("opens /dev/tty for read and write and closes both streams", () => { + test("opens /dev/tty for read and closes the input stream", () => { const calls: Array<[string, string]> = []; let stdinDestroyed = false; - let stdoutDestroyed = false; const stdin = { destroy() { stdinDestroyed = true; }, } as never; - const stdout = { - destroy() { - stdoutDestroyed = true; - }, - } as never; const controllingTerminal = openControllingTerminal({ openSync(path, flags) { calls.push([String(path), String(flags)]); - return flags === "r" ? 11 : 12; + return 11; }, createReadStream(fd) { expect(fd).toBe(11); return stdin; }, - createWriteStream(fd) { - expect(fd).toBe(12); - return stdout; - }, }); expect(controllingTerminal).not.toBeNull(); - expect(calls).toEqual([ - ["/dev/tty", "r"], - ["/dev/tty", "w"], - ]); + expect(calls).toEqual([["/dev/tty", "r"]]); expect(controllingTerminal?.stdin).toBe(stdin); - expect(controllingTerminal?.stdout).toBe(stdout); controllingTerminal?.close(); expect(stdinDestroyed).toBe(true); - expect(stdoutDestroyed).toBe(true); }); test("returns null when the controlling terminal cannot be opened", () => { @@ -124,9 +109,6 @@ describe("controlling terminal attachment", () => { createReadStream() { throw new Error("unreachable"); }, - createWriteStream() { - throw new Error("unreachable"); - }, }); expect(controllingTerminal).toBeNull(); diff --git a/src/core/terminal.ts b/src/core/terminal.ts index 50034701..ccfd28d3 100644 --- a/src/core/terminal.ts +++ b/src/core/terminal.ts @@ -41,7 +41,6 @@ export function shouldUseMouseForApp({ export interface ControllingTerminal { stdin: tty.ReadStream; - stdout: tty.WriteStream; close: () => void; } @@ -49,29 +48,26 @@ export interface ControllingTerminal { export interface ControllingTerminalDeps { openSync: typeof fs.openSync; createReadStream: (fd: number) => tty.ReadStream; - createWriteStream: (fd: number) => tty.WriteStream; } -/** Open the controlling terminal so the UI can stay interactive while stdin carries patch data. */ +/** + * Open the controlling terminal for input so the UI can stay interactive while stdin carries patch + * data. Rendering can continue through the existing stdout stream. + */ export function openControllingTerminal( deps: ControllingTerminalDeps = { openSync: fs.openSync, createReadStream: (fd) => new tty.ReadStream(fd), - createWriteStream: (fd) => new tty.WriteStream(fd), }, ): ControllingTerminal | null { try { const stdinFd = deps.openSync("/dev/tty", "r"); - const stdoutFd = deps.openSync("/dev/tty", "w"); const stdin = deps.createReadStream(stdinFd); - const stdout = deps.createWriteStream(stdoutFd); return { stdin, - stdout, close: () => { stdin.destroy(); - stdout.destroy(); }, }; } catch { diff --git a/src/main.tsx b/src/main.tsx index 9aeeb2e4..b7f88fa5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -62,7 +62,7 @@ async function main() { const renderer = await createCliRenderer({ stdin: controllingTerminal?.stdin, - stdout: controllingTerminal?.stdout, + stdout: process.stdout, useMouse: shouldUseMouseForApp({ hasControllingTerminal: Boolean(controllingTerminal), }), From 15c1a69bea3cefcf86168049ce0d3c9491551e07 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Tue, 7 Apr 2026 17:04:42 -0400 Subject: [PATCH 4/6] Launch piped PTY tests with bash exec stdin redirect The piped-pager integration tests need a non-TTY stdin while keeping the PTY on stdout. Use bash `exec cmd < file` in the harness to swap fd 0 to a fixture file without introducing extra dependencies. --- test/pty/harness.ts | 23 +++++++++++++++++++++++ test/pty/ui-integration.test.ts | 10 ++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/test/pty/harness.ts b/test/pty/harness.ts index d820dbfa..9d31b694 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -422,6 +422,28 @@ export function createPtyHarness() { }); } + /** + * Launch Hunk with a file-backed stdin while keeping stdout/stderr attached to the PTY. + * Uses `exec cmd < file` so bash replaces itself with Hunk, preserving the PTY on stdout/stderr + * and the controlling terminal while giving the child a non-TTY stdin. + */ + async function launchHunkWithFileBackedStdin(options: { + stdinFile: string; + args: string[]; + cwd?: string; + cols?: number; + rows?: number; + env?: Record; + }) { + return launchShellCommand({ + command: `exec ${buildHunkCommand(options.args)} < ${shellQuote(options.stdinFile)}`, + cwd: options.cwd, + cols: options.cols, + rows: options.rows, + env: options.env, + }); + } + async function waitForSnapshot( session: Session, predicate: (text: string) => boolean, @@ -463,6 +485,7 @@ export function createPtyHarness() { createSidebarJumpRepoFixture, createTwoFileRepoFixture, launchHunk, + launchHunkWithFileBackedStdin, launchShellCommand, buildHunkCommand, shellQuote, diff --git a/test/pty/ui-integration.test.ts b/test/pty/ui-integration.test.ts index 1b068644..8caf2f2d 100644 --- a/test/pty/ui-integration.test.ts +++ b/test/pty/ui-integration.test.ts @@ -593,8 +593,9 @@ describe("live UI integration", () => { test("stdin patch mode enables mouse wheel scrolling in pager UI", async () => { const fixture = harness.createPagerPatchFixture(60); - const session = await harness.launchShellCommand({ - command: `cat ${harness.shellQuote(fixture.patchFile)} | ${harness.buildHunkCommand(["patch", "-"])}`, + const session = await harness.launchHunkWithFileBackedStdin({ + stdinFile: fixture.patchFile, + args: ["patch", "-"], cols: 120, rows: 12, }); @@ -634,8 +635,9 @@ describe("live UI integration", () => { test("general pager mode enables mouse wheel scrolling for diff-like stdin", async () => { const fixture = harness.createPagerPatchFixture(60); - const session = await harness.launchShellCommand({ - command: `cat ${harness.shellQuote(fixture.patchFile)} | ${harness.buildHunkCommand(["pager"])}`, + const session = await harness.launchHunkWithFileBackedStdin({ + stdinFile: fixture.patchFile, + args: ["pager"], cols: 120, rows: 12, }); From 19937a014c6b10dcabcbc1e7ce49e10b20f386dc Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Sun, 12 Apr 2026 19:07:33 -0400 Subject: [PATCH 5/6] fix(test): drop unnecessary login shell flag from PTY harness --- test/pty/harness.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/pty/harness.ts b/test/pty/harness.ts index 9d31b694..307a4015 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -409,7 +409,7 @@ export function createPtyHarness() { return launchTerminal({ command: "/bin/bash", - args: ["-lc", options.command], + args: ["-c", options.command], cwd: options.cwd ?? repoRoot, cols: options.cols ?? 140, rows: options.rows ?? 24, From f46d50c0ed874c85f8429658a110f577d4976453 Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Mon, 13 Apr 2026 16:29:58 -0400 Subject: [PATCH 6/6] fix(theme): make graphite the explicit default across startup modes (#200) --- src/core/config.test.ts | 27 ++++++++++++++++++++++++++- src/core/config.ts | 4 +++- src/ui/lib/ui-lib.test.ts | 4 ++-- src/ui/themes.ts | 8 ++------ 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 355b0bf2..97c248a1 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -86,7 +86,7 @@ describe("config resolution", () => { }); }); - test("falls back to the global config path outside a repo", () => { + test("defaults unspecified themes to graphite, including piped pager-style patch input", () => { const home = createTempDir("hunk-config-home-"); const cwd = createTempDir("hunk-config-cwd-"); @@ -96,6 +96,7 @@ describe("config resolution", () => { }); expect(resolved.repoConfigPath).toBeUndefined(); + expect(resolved.input.options.theme).toBe("graphite"); }); test("command-specific config sections also apply to show mode", () => { @@ -196,4 +197,28 @@ describe("config resolution", () => { expect(bootstrap.initialShowHunkHeaders).toBe(false); expect(bootstrap.initialShowAgentNotes).toBe(true); }); + + test("loadAppBootstrap exposes graphite when no theme is configured", async () => { + const home = createTempDir("hunk-config-home-"); + const repo = createTempDir("hunk-config-repo-"); + createRepo(repo); + + const before = join(repo, "before.ts"); + const after = join(repo, "after.ts"); + writeFileSync(before, "export const alpha = 1;\n"); + writeFileSync(after, "export const alpha = 2;\n"); + + const resolved = resolveConfiguredCliInput( + { + kind: "diff", + left: before, + right: after, + options: {}, + }, + { cwd: repo, env: { HOME: home } }, + ); + const bootstrap = await loadAppBootstrap(resolved.input); + + expect(bootstrap.initialTheme).toBe("graphite"); + }); }); diff --git a/src/core/config.ts b/src/core/config.ts index 6e8466c4..67dcb392 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -131,7 +131,9 @@ export function resolveConfiguredCliInput( let resolvedOptions: CommonOptions = { mode: DEFAULT_VIEW_PREFERENCES.mode, - theme: undefined, + // Keep the built-in theme default explicit so stdin-backed startup paths do not depend on + // renderer theme-mode detection for their initial palette. + theme: "graphite", agentContext: input.options.agentContext, pager: input.options.pager ?? false, watch: input.options.watch ?? false, diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 55837c78..2d8aa402 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -359,13 +359,13 @@ describe("ui helpers", () => { ).toBe(16); }); - test("resolveTheme falls back by requested id and renderer mode while lazily exposing syntax styles", () => { + test("resolveTheme falls back by requested id to graphite while lazily exposing syntax styles", () => { const midnight = resolveTheme("midnight", null); const missingLight = resolveTheme("missing", "light"); const missingDark = resolveTheme("missing", "dark"); expect(midnight.id).toBe("midnight"); - expect(missingLight.id).toBe("paper"); + expect(missingLight.id).toBe("graphite"); expect(missingDark.id).toBe("graphite"); expect(resolveTheme("ember", null).syntaxStyle).toBeDefined(); }); diff --git a/src/ui/themes.ts b/src/ui/themes.ts index 9e13288c..8bf95c3d 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -286,16 +286,12 @@ export const THEMES: AppTheme[] = [ ), ]; -/** Resolve a named theme or fall back to a theme that matches the renderer mode. */ -export function resolveTheme(requested: string | undefined, themeMode: ThemeMode | null) { +/** Resolve a named theme or fall back to Hunk's explicit built-in default. */ +export function resolveTheme(requested: string | undefined, _themeMode: ThemeMode | null) { const exact = THEMES.find((theme) => theme.id === requested); if (exact) { return exact; } - if (themeMode === "light") { - return THEMES.find((theme) => theme.id === "paper") ?? THEMES[0]!; - } - return THEMES.find((theme) => theme.id === "graphite") ?? THEMES[0]!; }