From f0e20dc153af6d3a4f7a1ccaba4420541b883ec7 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 20 Jun 2026 17:12:15 -0400 Subject: [PATCH 1/3] feat(ui): prompt to save view preferences on quit --- .changeset/save-view-preferences-on-quit.md | 5 + README.md | 2 + src/core/config.test.ts | 84 +++++++- src/core/config.ts | 129 +++++++++++- src/core/startup.ts | 1 + src/core/types.ts | 2 + src/ui/App.tsx | 204 ++++++++++++++++++- src/ui/AppHost.interactions.test.tsx | 213 +++++++++++++++++++- src/ui/AppHost.tsx | 1 + src/ui/hooks/useAppKeyboardShortcuts.ts | 45 +++++ 10 files changed, 679 insertions(+), 7 deletions(-) create mode 100644 .changeset/save-view-preferences-on-quit.md diff --git a/.changeset/save-view-preferences-on-quit.md b/.changeset/save-view-preferences-on-quit.md new file mode 100644 index 00000000..a4707634 --- /dev/null +++ b/.changeset/save-view-preferences-on-quit.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Offer to save changed view preferences to the user config on quit, including theme, layout, line numbers, wrapping, hunk headers, agent notes, and copy decorations. The prompt also includes a “never ask” option that persists `prompt_save_view_preferences = false`. diff --git a/README.md b/README.md index e153e559..2f033401 100644 --- a/README.md +++ b/README.md @@ -128,12 +128,14 @@ line_numbers = true wrap_lines = false menu_bar = true agent_notes = false +prompt_save_view_preferences = true transparent_background = false ``` `theme = "auto"` and `--theme auto` query the terminal background at startup, choose `github-light-default` for light backgrounds and `github-dark-default` for dark backgrounds, and fall back to `github-dark-default` if the terminal does not answer. Older theme ids such as `graphite` and `paper` remain accepted as compatibility aliases. `exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only. +`prompt_save_view_preferences = false` disables the quit prompt for saving changed view preferences. `transparent_background` can also be written as `transparentBackground`. Custom themes can inherit from any built-in theme and override only the colors you care about: diff --git a/src/core/config.test.ts b/src/core/config.test.ts index ac3a7f9f..13e19849 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -1,9 +1,13 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CliInput } from "./types"; -import { resolveConfiguredCliInput } from "./config"; +import { + resolveConfiguredCliInput, + saveGlobalViewPreferences, + saveViewPreferencesPromptPreference, +} from "./config"; import { loadAppBootstrap } from "./loaders"; const tempDirs: string[] = []; @@ -46,6 +50,78 @@ afterEach(() => { cleanupTempDirs(); }); +describe("config persistence", () => { + test("writes accepted view preferences to user config without disturbing tables", () => { + const home = createTempDir("hunk-save-config-home-"); + const configPath = join(home, ".config", "hunk", "config.toml"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync( + configPath, + [ + "# personal defaults", + 'theme = "github-dark-default"', + "wrap_lines = false", + "", + "[custom_theme]", + 'label = "Keep me"', + ].join("\n"), + ); + + const savedPath = saveGlobalViewPreferences( + { + mode: "split", + theme: "dracula", + showLineNumbers: false, + wrapLines: true, + showHunkHeaders: false, + showMenuBar: false, + showAgentNotes: true, + copyDecorations: true, + }, + { env: { HOME: home } }, + ); + + expect(savedPath).toBe(configPath); + expect(readFileSync(configPath, "utf8")).toBe( + [ + "# personal defaults", + 'theme = "dracula"', + "wrap_lines = true", + 'mode = "split"', + "line_numbers = false", + "hunk_headers = false", + "menu_bar = false", + "agent_notes = true", + "copy_decorations = true", + "", + "[custom_theme]", + 'label = "Keep me"', + "", + ].join("\n"), + ); + }); + + test("writes the view preferences prompt setting without disturbing tables", () => { + const home = createTempDir("hunk-save-config-home-"); + const configPath = join(home, ".config", "hunk", "config.toml"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync(configPath, ["# personal defaults", "", "[custom_theme]"].join("\n")); + + const savedPath = saveViewPreferencesPromptPreference(false, { env: { HOME: home } }); + + expect(savedPath).toBe(configPath); + expect(readFileSync(configPath, "utf8")).toBe( + [ + "# personal defaults", + "prompt_save_view_preferences = false", + "", + "[custom_theme]", + "", + ].join("\n"), + ); + }); +}); + describe("config resolution", () => { test("merges global, repo, pager, command, and CLI overrides in the right order", () => { const home = createTempDir("hunk-config-home-"); @@ -60,6 +136,7 @@ describe("config resolution", () => { "line_numbers = false", "transparentBackground = true", "color_moved = true", + "prompt_save_view_preferences = false", "", "[patch]", 'mode = "split"', @@ -88,6 +165,7 @@ describe("config resolution", () => { }); expect(resolved.repoConfigPath).toBe(join(repo, ".hunk", "config.toml")); + expect(resolved.viewPreferencesConfigPath).toBe(join(repo, ".hunk", "config.toml")); expect(resolved.input.options).toMatchObject({ pager: true, mode: "stack", @@ -97,6 +175,7 @@ describe("config resolution", () => { menuBar: false, hunkHeaders: false, agentNotes: true, + promptSaveViewPreferences: false, transparentBackground: true, colorMoved: true, }); @@ -273,6 +352,7 @@ describe("config resolution", () => { }); expect(resolved.repoConfigPath).toBeUndefined(); + expect(resolved.viewPreferencesConfigPath).toBe(join(home, ".config", "hunk", "config.toml")); expect(resolved.input.options.theme).toBe("github-dark-default"); }); diff --git a/src/core/config.ts b/src/core/config.ts index fa261289..bfd060b1 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes"; import { normalizeBuiltInThemeId } from "../ui/themes"; import { resolveGlobalConfigPath } from "./paths"; @@ -75,6 +75,21 @@ const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = { copyDecorations: false, }; +const VIEW_PREFERENCES_PROMPT_CONFIG_KEY = "prompt_save_view_preferences"; +const PERSISTED_VIEW_PREFERENCE_KEYS: Array<{ + configKey: string; + value: (preferences: PersistedViewPreferences) => string | boolean | undefined; +}> = [ + { configKey: "theme", value: (preferences) => preferences.theme }, + { configKey: "mode", value: (preferences) => preferences.mode }, + { configKey: "line_numbers", value: (preferences) => preferences.showLineNumbers }, + { configKey: "wrap_lines", value: (preferences) => preferences.wrapLines }, + { configKey: "hunk_headers", value: (preferences) => preferences.showHunkHeaders }, + { configKey: "menu_bar", value: (preferences) => preferences.showMenuBar }, + { configKey: "agent_notes", value: (preferences) => preferences.showAgentNotes }, + { configKey: "copy_decorations", value: (preferences) => preferences.copyDecorations }, +]; + interface ConfigResolutionOptions { cwd?: string; env?: NodeJS.ProcessEnv; @@ -85,12 +100,54 @@ interface HunkConfigResolution { customTheme?: CustomThemeConfig; globalConfigPath?: string; repoConfigPath?: string; + viewPreferencesConfigPath?: string; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** Serialize one primitive TOML preference value. */ +function serializeTomlPreferenceValue(value: string | boolean) { + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + + return JSON.stringify(value); +} + +/** Update one top-level TOML key while preserving sections and unrelated comments. */ +function upsertTopLevelTomlValue(source: string, key: string, value: string | boolean) { + const lines = source.length > 0 ? source.split("\n") : []; + const serialized = serializeTomlPreferenceValue(value); + const assignment = `${key} = ${serialized}`; + let firstTableIndex = lines.findIndex((line) => /^\s*\[/.test(line)); + if (firstTableIndex < 0) { + firstTableIndex = lines.length; + } + + const keyPattern = new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=`); + for (let index = 0; index < firstTableIndex; index += 1) { + if (keyPattern.test(lines[index] ?? "")) { + lines[index] = assignment; + return `${lines.join("\n").replace(/\n*$/, "")}\n`; + } + } + + let insertAt = firstTableIndex; + const hasTableSpacer = insertAt > 0 && lines[insertAt - 1] === ""; + if (hasTableSpacer) { + insertAt -= 1; + } + lines.splice( + insertAt, + 0, + assignment, + ...(hasTableSpacer || insertAt === lines.length ? [] : [""]), + ); + return `${lines.join("\n").replace(/\n*$/, "")}\n`; +} + /** Accept only the layout names Hunk already supports. */ function normalizeLayoutMode(value: unknown): LayoutMode | undefined { return value === "auto" || value === "split" || value === "stack" ? value : undefined; @@ -240,6 +297,7 @@ function readConfigPreferences(source: Record): CommonOptions { menuBar: normalizeBoolean(source.menu_bar), agentNotes: normalizeBoolean(source.agent_notes), copyDecorations: normalizeBoolean(source.copy_decorations), + promptSaveViewPreferences: normalizeBoolean(source[VIEW_PREFERENCES_PROMPT_CONFIG_KEY]), transparentBackground: normalizeBoolean(source.transparentBackground) ?? normalizeBoolean(source.transparent_background), @@ -264,6 +322,8 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti menuBar: overrides.menuBar ?? base.menuBar, agentNotes: overrides.agentNotes ?? base.agentNotes, copyDecorations: overrides.copyDecorations ?? base.copyDecorations, + promptSaveViewPreferences: + overrides.promptSaveViewPreferences ?? base.promptSaveViewPreferences, transparentBackground: overrides.transparentBackground ?? base.transparentBackground, colorMoved: overrides.colorMoved ?? base.colorMoved, }; @@ -305,6 +365,67 @@ function readTomlRecord(path: string) { return parsed; } +/** Read a config file if it already exists. */ +function readConfigSource(configPath: string) { + return fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : ""; +} + +/** Resolve the config file path used for interactive persistence. */ +function resolveWritableConfigPath(configuredPath: string | undefined, env: NodeJS.ProcessEnv) { + const configPath = configuredPath ?? resolveGlobalConfigPath(env); + if (!configPath) { + throw new Error("Could not resolve a config path because HOME/XDG_CONFIG_HOME is unset."); + } + + return configPath; +} + +/** Write an updated config source after ensuring the parent directory exists. */ +function writeConfigSource(configPath: string, source: string) { + fs.mkdirSync(dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, source); +} + +/** Persist accepted in-app view preferences to the selected Hunk config file. */ +export function saveGlobalViewPreferences( + preferences: PersistedViewPreferences, + { + configPath: configuredPath, + env = process.env, + }: Pick & { configPath?: string } = {}, +) { + const configPath = resolveWritableConfigPath(configuredPath, env); + let nextSource = readConfigSource(configPath); + for (const key of PERSISTED_VIEW_PREFERENCE_KEYS) { + const value = key.value(preferences); + if (value !== undefined) { + nextSource = upsertTopLevelTomlValue(nextSource, key.configKey, value); + } + } + + writeConfigSource(configPath, nextSource); + return configPath; +} + +/** Persist whether Hunk should prompt before discarding changed view preferences. */ +export function saveViewPreferencesPromptPreference( + promptSaveViewPreferences: boolean, + { + configPath: configuredPath, + env = process.env, + }: Pick & { configPath?: string } = {}, +) { + const configPath = resolveWritableConfigPath(configuredPath, env); + const nextSource = upsertTopLevelTomlValue( + readConfigSource(configPath), + VIEW_PREFERENCES_PROMPT_CONFIG_KEY, + promptSaveViewPreferences, + ); + + writeConfigSource(configPath, nextSource); + return configPath; +} + /** Resolve CLI input against global and repo-local config files. */ export function resolveConfiguredCliInput( input: CliInput, @@ -331,6 +452,7 @@ export function resolveConfiguredCliInput( menuBar: DEFAULT_VIEW_PREFERENCES.showMenuBar, agentNotes: DEFAULT_VIEW_PREFERENCES.showAgentNotes, copyDecorations: DEFAULT_VIEW_PREFERENCES.copyDecorations, + promptSaveViewPreferences: true, transparentBackground: false, }; @@ -362,6 +484,7 @@ export function resolveConfiguredCliInput( menuBar: resolvedOptions.menuBar ?? DEFAULT_VIEW_PREFERENCES.showMenuBar, agentNotes: resolvedOptions.agentNotes ?? DEFAULT_VIEW_PREFERENCES.showAgentNotes, copyDecorations: resolvedOptions.copyDecorations ?? DEFAULT_VIEW_PREFERENCES.copyDecorations, + promptSaveViewPreferences: resolvedOptions.promptSaveViewPreferences ?? true, transparentBackground: resolvedOptions.transparentBackground ?? false, colorMoved: resolvedOptions.colorMoved, }; @@ -378,5 +501,9 @@ export function resolveConfiguredCliInput( customTheme: resolvedCustomTheme, globalConfigPath: userConfigPath, repoConfigPath, + // Persist in the repo config only when the repo already has one; otherwise keep personal view + // choices user-scoped so Hunk does not create project policy files from an interactive prompt. + viewPreferencesConfigPath: + repoConfigPath && fs.existsSync(repoConfigPath) ? repoConfigPath : userConfigPath, }; } diff --git a/src/core/startup.ts b/src/core/startup.ts index da046503..92e70a16 100644 --- a/src/core/startup.ts +++ b/src/core/startup.ts @@ -232,6 +232,7 @@ export async function prepareStartupPlan( } bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode; + bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath; controllingTerminal ??= usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null; diff --git a/src/core/types.ts b/src/core/types.ts index 4c6c5c95..1363e3bc 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -93,6 +93,7 @@ export interface CommonOptions { menuBar?: boolean; agentNotes?: boolean; copyDecorations?: boolean; + promptSaveViewPreferences?: boolean; transparentBackground?: boolean; colorMoved?: boolean; } @@ -369,4 +370,5 @@ export interface AppBootstrap { initialShowMenuBar?: boolean; initialShowAgentNotes?: boolean; initialCopyDecorations?: boolean; + viewPreferencesConfigPath?: string; } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8b5b0985..43707a28 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -5,10 +5,18 @@ import { } from "@opentui/core"; import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, lazy, useCallback, useEffect, useMemo, useState, useRef } from "react"; -import type { AppBootstrap, CliInput, LayoutMode, UserNoteLineTarget } from "../core/types"; +import { saveGlobalViewPreferences, saveViewPreferencesPromptPreference } from "../core/config"; +import type { + AppBootstrap, + CliInput, + LayoutMode, + PersistedViewPreferences, + UserNoteLineTarget, +} from "../core/types"; import { canReloadInput, computeWatchSignature } from "../core/watch"; import type { HunkSessionBrokerClient, ReloadedSessionResult } from "../hunk-session/types"; import { MenuBar } from "./components/chrome/MenuBar"; +import { ModalFrame } from "./components/chrome/ModalFrame"; import { StatusBar } from "./components/chrome/StatusBar"; import { DiffPane } from "./components/panes/DiffPane"; import { SidebarPane } from "./components/panes/SidebarPane"; @@ -146,6 +154,7 @@ export function App({ const [forceSidebarOpen, setForceSidebarOpen] = useState(false); const [showHelp, setShowHelp] = useState(false); const [showAgentSkill, setShowAgentSkill] = useState(false); + const [saveConfigPromptOpen, setSaveConfigPromptOpen] = useState(false); const [focusArea, setFocusArea] = useState("files"); const [activeAddNoteTarget, setActiveAddNoteTarget] = useState(null); const [sidebarWidth, setSidebarWidth] = useState(34); @@ -181,6 +190,79 @@ export function App({ })), [activeTheme.id, themeOptions], ); + const currentViewPreferences = useMemo( + () => ({ + mode: layoutMode, + theme: themeId, + showLineNumbers, + wrapLines, + showHunkHeaders, + showMenuBar, + showAgentNotes, + copyDecorations, + }), + [ + copyDecorations, + layoutMode, + showAgentNotes, + showHunkHeaders, + showLineNumbers, + showMenuBar, + themeId, + wrapLines, + ], + ); + const initialViewPreferencesRef = useRef(currentViewPreferences); + const changedViewPreferenceLines = useMemo(() => { + const initial = initialViewPreferencesRef.current; + const changes: string[] = []; + if (currentViewPreferences.theme !== initial.theme) { + changes.push( + `Theme: ${initial.theme ?? "default"} → ${currentViewPreferences.theme ?? "default"}`, + ); + } + if (currentViewPreferences.mode !== initial.mode) { + changes.push(`Layout: ${initial.mode} → ${currentViewPreferences.mode}`); + } + if (currentViewPreferences.showLineNumbers !== initial.showLineNumbers) { + changes.push( + `Line numbers: ${initial.showLineNumbers ? "on" : "off"} → ${currentViewPreferences.showLineNumbers ? "on" : "off"}`, + ); + } + if (currentViewPreferences.wrapLines !== initial.wrapLines) { + changes.push( + `Line wrapping: ${initial.wrapLines ? "on" : "off"} → ${currentViewPreferences.wrapLines ? "on" : "off"}`, + ); + } + if (currentViewPreferences.showHunkHeaders !== initial.showHunkHeaders) { + changes.push( + `Hunk headers: ${initial.showHunkHeaders ? "shown" : "hidden"} → ${currentViewPreferences.showHunkHeaders ? "shown" : "hidden"}`, + ); + } + if (currentViewPreferences.showMenuBar !== initial.showMenuBar) { + changes.push( + `Menu bar: ${initial.showMenuBar ? "shown" : "hidden"} → ${currentViewPreferences.showMenuBar ? "shown" : "hidden"}`, + ); + } + if (currentViewPreferences.showAgentNotes !== initial.showAgentNotes) { + changes.push( + `Agent notes: ${initial.showAgentNotes ? "shown" : "hidden"} → ${currentViewPreferences.showAgentNotes ? "shown" : "hidden"}`, + ); + } + if (currentViewPreferences.copyDecorations !== initial.copyDecorations) { + changes.push( + `Copy decorations: ${initial.copyDecorations ? "on" : "off"} → ${currentViewPreferences.copyDecorations ? "on" : "off"}`, + ); + } + return changes; + }, [currentViewPreferences]); + const hasUnsavedViewPreferences = changedViewPreferenceLines.length > 0; + const viewPreferencesConfigLabel = useMemo(() => { + const path = bootstrap.viewPreferencesConfigPath ?? "~/.config/hunk/config.toml"; + return process.env.HOME && path.startsWith(process.env.HOME) + ? `~${path.slice(process.env.HOME.length)}` + : path; + }, [bootstrap.viewPreferencesConfigPath]); const review = useReviewController({ files: bootstrap.changeset.files }); const filteredFiles = review.visibleFiles; const selectedFile = review.selectedFile; @@ -644,11 +726,67 @@ export function App({ }; }, [bootstrap.input, refreshCurrentInput, watchEnabled]); - /** Leave the app through the shared shutdown path. */ - const requestQuit = useCallback(() => { + /** Save current view preferences to user config and then leave the app. */ + const saveViewPreferencesAndQuit = useCallback(() => { + try { + const configPath = saveGlobalViewPreferences(currentViewPreferences, { + configPath: bootstrap.viewPreferencesConfigPath, + }); + initialViewPreferencesRef.current = currentViewPreferences; + showSessionNotice(`Saved view preferences to ${configPath}`); + setTimeout(onQuit, 120); + } catch (error) { + showSessionNotice( + error instanceof Error ? error.message : "Failed to save view preferences.", + ); + } + }, [bootstrap.viewPreferencesConfigPath, currentViewPreferences, onQuit, showSessionNotice]); + + /** Leave the app without writing view preference changes. */ + const discardViewPreferencesAndQuit = useCallback(() => { + setSaveConfigPromptOpen(false); onQuit(); }, [onQuit]); + /** Persist the user's choice to stop prompting about view preference changes. */ + const neverAskToSaveViewPreferencesAndQuit = useCallback(() => { + try { + const configPath = saveViewPreferencesPromptPreference(false, { + configPath: bootstrap.viewPreferencesConfigPath, + }); + showSessionNotice(`Won't ask to save view preferences again (${configPath})`); + setTimeout(onQuit, 120); + } catch (error) { + showSessionNotice( + error instanceof Error ? error.message : "Failed to save prompt preference.", + ); + } + }, [bootstrap.viewPreferencesConfigPath, onQuit, showSessionNotice]); + + /** Leave the app through the shared shutdown path, prompting before discarding view changes. */ + const requestQuit = useCallback(() => { + if ( + !pagerMode && + bootstrap.input.options.promptSaveViewPreferences !== false && + hasUnsavedViewPreferences + ) { + setShowHelp(false); + setSaveConfigPromptOpen(true); + return; + } + + onQuit(); + }, [ + bootstrap.input.options.promptSaveViewPreferences, + hasUnsavedViewPreferences, + onQuit, + pagerMode, + ]); + + const closeSaveConfigPrompt = useCallback(() => { + setSaveConfigPromptOpen(false); + }, []); + /** Close the modal keyboard help overlay. */ const closeHelp = useCallback(() => { setShowHelp(false); @@ -838,6 +976,11 @@ export function App({ openThemeSelector, pagerMode, requestQuit, + saveConfigPromptOpen, + saveViewPreferencesAndQuit, + discardViewPreferencesAndQuit, + neverAskToSaveViewPreferencesAndQuit, + closeSaveConfigPrompt, scrollCodeHorizontally, saveDraftNote, scrollDiff, @@ -1114,6 +1257,61 @@ export function App({ ) : null} + {!pagerMode && saveConfigPromptOpen ? ( + + + + Save your local view changes to {viewPreferencesConfigLabel}? + + + + {changedViewPreferenceLines.map((line) => ( + + {line} + + ))} + + + { + event.stopPropagation(); + saveViewPreferencesAndQuit(); + }} + > + [Enter/s] Save + + + { + event.stopPropagation(); + discardViewPreferencesAndQuit(); + }} + > + [d] Discard + + + { + event.stopPropagation(); + neverAskToSaveViewPreferencesAndQuit(); + }} + > + [n] Never ask + + + [Esc] Cancel + + + ) : null} + {!pagerMode && themeSelectorState.open ? ( { } }); + test("quit prompts to save changed view preferences", async () => { + const configHome = mkdtempSync(join(tmpdir(), "hunk-save-view-config-")); + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = configHome; + const quit = mock(() => undefined); + const setup = await testRender( + , + { + width: 240, + height: 24, + }, + ); + + try { + await flush(setup); + await act(async () => { + await setup.mockInput.typeText("t"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme selector")); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("› github-dark-dimmed")); + await act(async () => { + await setup.mockInput.pressEnter(); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme: github-dark-dimmed")); + + await act(async () => { + await setup.mockInput.typeText("q"); + }); + let frame = await waitForFrame(setup, (nextFrame) => + nextFrame.includes("Save view preferences?"), + ); + expect(frame).toContain("Save your local view changes"); + expect(frame).toContain("[n] Never ask"); + expect(frame).toContain("Theme: github-dark-default → github-dark-dimmed"); + expect(frame).not.toContain("Line numbers:"); + expect(frame).not.toContain("Line wrapping:"); + expect(quit).toHaveBeenCalledTimes(0); + + await act(async () => { + await setup.mockInput.typeText("d"); + await setup.renderOnce(); + }); + expect(quit).toHaveBeenCalledTimes(1); + } finally { + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + rmSync(configHome, { recursive: true, force: true }); + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("quit prompt saves changed view preferences", async () => { + const configHome = mkdtempSync(join(tmpdir(), "hunk-save-view-config-")); + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = configHome; + const quit = mock(() => undefined); + const setup = await testRender( + , + { + width: 240, + height: 24, + }, + ); + + try { + await flush(setup); + await act(async () => { + await setup.mockInput.typeText("t"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme selector")); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("› github-dark-dimmed")); + await act(async () => { + await setup.mockInput.pressEnter(); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme: github-dark-dimmed")); + + await act(async () => { + await setup.mockInput.typeText("q"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Save view preferences?")); + await act(async () => { + await setup.mockInput.pressEnter(); + await Bun.sleep(140); + }); + await flush(setup); + + expect(quit).toHaveBeenCalledTimes(1); + expect(readFileSync(join(configHome, "hunk", "config.toml"), "utf8")).toContain( + 'theme = "github-dark-dimmed"', + ); + } finally { + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + rmSync(configHome, { recursive: true, force: true }); + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("quit prompt can disable future view preference prompts", async () => { + const configHome = mkdtempSync(join(tmpdir(), "hunk-save-view-config-")); + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = configHome; + const quit = mock(() => undefined); + const setup = await testRender( + , + { + width: 240, + height: 24, + }, + ); + + try { + await flush(setup); + await act(async () => { + await setup.mockInput.typeText("t"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme selector")); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("› github-dark-dimmed")); + await act(async () => { + await setup.mockInput.pressEnter(); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme: github-dark-dimmed")); + + await act(async () => { + await setup.mockInput.typeText("q"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Save view preferences?")); + await act(async () => { + await setup.mockInput.typeText("n"); + await Bun.sleep(140); + }); + await flush(setup); + + expect(quit).toHaveBeenCalledTimes(1); + expect(readFileSync(join(configHome, "hunk", "config.toml"), "utf8")).toContain( + "prompt_save_view_preferences = false", + ); + } finally { + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + rmSync(configHome, { recursive: true, force: true }); + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("quit skips the save prompt when configured not to ask", async () => { + const quit = mock(() => undefined); + const bootstrap = createSingleFileBootstrap(); + bootstrap.input.options.promptSaveViewPreferences = false; + const setup = await testRender(, { + width: 240, + height: 24, + }); + + try { + await flush(setup); + await act(async () => { + await setup.mockInput.typeText("t"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme selector")); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("› github-dark-dimmed")); + await act(async () => { + await setup.mockInput.pressEnter(); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme: github-dark-dimmed")); + + await act(async () => { + await setup.mockInput.typeText("q"); + }); + await flush(setup); + + expect(quit).toHaveBeenCalledTimes(1); + expect(setup.captureCharFrame()).not.toContain("Save view preferences?"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("quit shortcuts route through the provided onQuit handler in regular and pager modes", async () => { const regularQuit = mock(() => undefined); const regularSetup = await testRender( diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index c415a0ba..8abb26c6 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -55,6 +55,7 @@ export function AppHost({ cwd, customTheme: configured.customTheme, }); + nextBootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath; const nextSnapshot = createInitialSessionSnapshot(nextBootstrap); let sessionId = "local-session"; diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index eb1e19a5..e1e1b379 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -71,6 +71,11 @@ export interface UseAppKeyboardShortcutsOptions { openThemeSelector: () => void; pagerMode: boolean; requestQuit: () => void; + saveConfigPromptOpen: boolean; + saveViewPreferencesAndQuit: () => void; + discardViewPreferencesAndQuit: () => void; + neverAskToSaveViewPreferencesAndQuit: () => void; + closeSaveConfigPrompt: () => void; scrollCodeHorizontally: (delta: number) => void; scrollDiff: (delta: number, unit: ScrollUnit) => void; saveDraftNote: () => void; @@ -115,6 +120,11 @@ export function useAppKeyboardShortcuts({ openThemeSelector, pagerMode, requestQuit, + saveConfigPromptOpen, + saveViewPreferencesAndQuit, + discardViewPreferencesAndQuit, + neverAskToSaveViewPreferencesAndQuit, + closeSaveConfigPrompt, scrollCodeHorizontally, saveDraftNote, scrollDiff, @@ -141,6 +151,7 @@ export function useAppKeyboardShortcuts({ const pagerModeRef = useRef(pagerMode); const showAgentSkillRef = useRef(showAgentSkill); const showHelpRef = useRef(showHelp); + const saveConfigPromptOpenRef = useRef(saveConfigPromptOpen); const themeSelectorOpenRef = useRef(themeSelectorOpen); activeMenuIdRef.current = activeMenuId; @@ -148,6 +159,7 @@ export function useAppKeyboardShortcuts({ pagerModeRef.current = pagerMode; showAgentSkillRef.current = showAgentSkill; showHelpRef.current = showHelp; + saveConfigPromptOpenRef.current = saveConfigPromptOpen; themeSelectorOpenRef.current = themeSelectorOpen; const resolveJumpShortcut = (key: KeyEvent): JumpShortcut | null => { @@ -285,6 +297,35 @@ export function useAppKeyboardShortcuts({ return false; }; + const handleSaveConfigPromptShortcut = (key: KeyEvent) => { + if (!saveConfigPromptOpenRef.current) { + return false; + } + + consumeKey(key); + if (key.name === "return" || key.name === "enter" || key.name === "s" || key.sequence === "s") { + saveViewPreferencesAndQuit(); + return true; + } + + if (key.name === "d" || key.sequence === "d") { + discardViewPreferencesAndQuit(); + return true; + } + + if (key.name === "n" || key.sequence === "n") { + neverAskToSaveViewPreferencesAndQuit(); + return true; + } + + if (isEscapeKey(key)) { + closeSaveConfigPrompt(); + return true; + } + + return true; + }; + const handleThemeSelectorShortcut = (key: KeyEvent) => { if (!themeSelectorOpenRef.current) { return false; @@ -581,6 +622,10 @@ export function useAppKeyboardShortcuts({ }; useKeyboard((key: KeyEvent) => { + if (handleSaveConfigPromptShortcut(key)) { + return; + } + if (handleMenuToggleShortcut(key)) { return; } From c0585b5d0a633998999ebda949cc64675d68ec24 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 2 Jul 2026 17:57:36 -0400 Subject: [PATCH 2/3] feat(ui): render the quit prompt as the config diff it will write Show changed view preferences as -/+ TOML assignment pairs derived from the same key table saveGlobalViewPreferences persists with, so the prompt is honest about what changes on disk. Extract the modal shape into a reusable ConfirmDialog (body rows + clickable key-legend actions with hover highlight) and document it in agent guidance so future confirm prompts reuse it. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 1 + src/core/config.test.ts | 32 +++++ src/core/config.ts | 35 ++++++ src/ui/App.tsx | 134 ++++++++------------- src/ui/AppHost.interactions.test.tsx | 92 +++++++++++++- src/ui/components/chrome/ConfirmDialog.tsx | 106 ++++++++++++++++ test/pty/chrome.test.ts | 48 ++++++++ 7 files changed, 358 insertions(+), 90 deletions(-) create mode 100644 src/ui/components/chrome/ConfirmDialog.tsx diff --git a/AGENTS.md b/AGENTS.md index b56f33d6..5f988cbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ CLI input - `App` should remain the orchestration shell for app state, navigation, layout mode, theme, filtering, and pane coordination. - Pane rendering should live in dedicated components. +- Confirmation prompts with a small set of choices should reuse `ConfirmDialog` (body rows plus a clickable key-legend action row) instead of composing `ModalFrame` with a hand-rolled footer; keyboard handling for its actions stays in `useAppKeyboardShortcuts`. - New UI work should extend existing components or add new ones, not grow `App` back into a monolith. - Shared formatting, ids, and small derivations belong in helper modules, not repeated inline. - Prefer one implementation path per feature instead of separate "old" and "new" codepaths that duplicate behavior. diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 13e19849..4f1eb182 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CliInput } from "./types"; import { + diffPersistedViewPreferences, resolveConfiguredCliInput, saveGlobalViewPreferences, saveViewPreferencesPromptPreference, @@ -120,6 +121,37 @@ describe("config persistence", () => { ].join("\n"), ); }); + + test("diffs view preference snapshots as the TOML assignments a save would rewrite", () => { + const initial = { + mode: "auto", + theme: "github-dark-default", + showLineNumbers: false, + wrapLines: false, + showHunkHeaders: false, + showMenuBar: true, + showAgentNotes: true, + copyDecorations: false, + } as const; + + expect(diffPersistedViewPreferences(initial, { ...initial })).toEqual([]); + expect( + diffPersistedViewPreferences(initial, { + ...initial, + mode: "split", + theme: "github-dark-dimmed", + showLineNumbers: true, + }), + ).toEqual([ + { + configKey: "theme", + previousValue: '"github-dark-default"', + nextValue: '"github-dark-dimmed"', + }, + { configKey: "mode", previousValue: '"auto"', nextValue: '"split"' }, + { configKey: "line_numbers", previousValue: "false", nextValue: "true" }, + ]); + }); }); describe("config resolution", () => { diff --git a/src/core/config.ts b/src/core/config.ts index bfd060b1..f95b2113 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -386,6 +386,41 @@ function writeConfigSource(configPath: string, source: string) { fs.writeFileSync(configPath, source); } +/** One view preference the quit prompt would rewrite, as TOML assignment text. */ +export interface ViewPreferenceChange { + configKey: string; + previousValue: string; + nextValue: string; +} + +/** + * Diff two view-preference snapshots into the TOML assignments + * `saveGlobalViewPreferences` would rewrite, so prompt UI and persistence + * stay derived from the same key table. + */ +export function diffPersistedViewPreferences( + previous: PersistedViewPreferences, + next: PersistedViewPreferences, +): ViewPreferenceChange[] { + const changes: ViewPreferenceChange[] = []; + for (const key of PERSISTED_VIEW_PREFERENCE_KEYS) { + const previousValue = key.value(previous); + const nextValue = key.value(next); + if (previousValue === nextValue) { + continue; + } + + changes.push({ + configKey: key.configKey, + previousValue: + previousValue === undefined ? "unset" : serializeTomlPreferenceValue(previousValue), + nextValue: nextValue === undefined ? "unset" : serializeTomlPreferenceValue(nextValue), + }); + } + + return changes; +} + /** Persist accepted in-app view preferences to the selected Hunk config file. */ export function saveGlobalViewPreferences( preferences: PersistedViewPreferences, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 43707a28..ec48b549 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -5,7 +5,11 @@ import { } from "@opentui/core"; import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, lazy, useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { saveGlobalViewPreferences, saveViewPreferencesPromptPreference } from "../core/config"; +import { + diffPersistedViewPreferences, + saveGlobalViewPreferences, + saveViewPreferencesPromptPreference, +} from "../core/config"; import type { AppBootstrap, CliInput, @@ -16,7 +20,7 @@ import type { import { canReloadInput, computeWatchSignature } from "../core/watch"; import type { HunkSessionBrokerClient, ReloadedSessionResult } from "../hunk-session/types"; import { MenuBar } from "./components/chrome/MenuBar"; -import { ModalFrame } from "./components/chrome/ModalFrame"; +import { ConfirmDialog, confirmDialogHeight } from "./components/chrome/ConfirmDialog"; import { StatusBar } from "./components/chrome/StatusBar"; import { DiffPane } from "./components/panes/DiffPane"; import { SidebarPane } from "./components/panes/SidebarPane"; @@ -213,50 +217,23 @@ export function App({ ], ); const initialViewPreferencesRef = useRef(currentViewPreferences); - const changedViewPreferenceLines = useMemo(() => { - const initial = initialViewPreferencesRef.current; - const changes: string[] = []; - if (currentViewPreferences.theme !== initial.theme) { - changes.push( - `Theme: ${initial.theme ?? "default"} → ${currentViewPreferences.theme ?? "default"}`, - ); - } - if (currentViewPreferences.mode !== initial.mode) { - changes.push(`Layout: ${initial.mode} → ${currentViewPreferences.mode}`); - } - if (currentViewPreferences.showLineNumbers !== initial.showLineNumbers) { - changes.push( - `Line numbers: ${initial.showLineNumbers ? "on" : "off"} → ${currentViewPreferences.showLineNumbers ? "on" : "off"}`, - ); - } - if (currentViewPreferences.wrapLines !== initial.wrapLines) { - changes.push( - `Line wrapping: ${initial.wrapLines ? "on" : "off"} → ${currentViewPreferences.wrapLines ? "on" : "off"}`, - ); - } - if (currentViewPreferences.showHunkHeaders !== initial.showHunkHeaders) { - changes.push( - `Hunk headers: ${initial.showHunkHeaders ? "shown" : "hidden"} → ${currentViewPreferences.showHunkHeaders ? "shown" : "hidden"}`, - ); - } - if (currentViewPreferences.showMenuBar !== initial.showMenuBar) { - changes.push( - `Menu bar: ${initial.showMenuBar ? "shown" : "hidden"} → ${currentViewPreferences.showMenuBar ? "shown" : "hidden"}`, - ); - } - if (currentViewPreferences.showAgentNotes !== initial.showAgentNotes) { - changes.push( - `Agent notes: ${initial.showAgentNotes ? "shown" : "hidden"} → ${currentViewPreferences.showAgentNotes ? "shown" : "hidden"}`, - ); - } - if (currentViewPreferences.copyDecorations !== initial.copyDecorations) { - changes.push( - `Copy decorations: ${initial.copyDecorations ? "on" : "off"} → ${currentViewPreferences.copyDecorations ? "on" : "off"}`, - ); - } - return changes; - }, [currentViewPreferences]); - const hasUnsavedViewPreferences = changedViewPreferenceLines.length > 0; + const changedViewPreferences = useMemo( + () => diffPersistedViewPreferences(initialViewPreferencesRef.current, currentViewPreferences), + [currentViewPreferences], + ); + // Render each change as the -/+ pair of TOML assignments the save would rewrite, + // with the key column aligned across all changed preferences. + const viewPreferenceDiffLines = useMemo(() => { + const keyWidth = changedViewPreferences.reduce( + (width, change) => Math.max(width, change.configKey.length), + 0, + ); + return changedViewPreferences.flatMap((change) => [ + { removed: true, text: `- ${change.configKey.padEnd(keyWidth)} = ${change.previousValue}` }, + { removed: false, text: `+ ${change.configKey.padEnd(keyWidth)} = ${change.nextValue}` }, + ]); + }, [changedViewPreferences]); + const hasUnsavedViewPreferences = changedViewPreferences.length > 0; const viewPreferencesConfigLabel = useMemo(() => { const path = bootstrap.viewPreferencesConfigPath ?? "~/.config/hunk/config.toml"; return process.env.HOME && path.startsWith(process.env.HOME) @@ -1258,8 +1235,14 @@ export function App({ ) : null} {!pagerMode && saveConfigPromptOpen ? ( - - - Save your local view changes to {viewPreferencesConfigLabel}? + + You changed {changedViewPreferences.length} view{" "} + {changedViewPreferences.length === 1 ? "setting" : "settings"} during this review. + + + + + Save {changedViewPreferences.length === 1 ? "it" : "them"} to your config before + quitting? - {changedViewPreferenceLines.map((line) => ( - - {line} + + {viewPreferencesConfigLabel} + + {viewPreferenceDiffLines.map((line) => ( + + + {line.text} + ))} - - - { - event.stopPropagation(); - saveViewPreferencesAndQuit(); - }} - > - [Enter/s] Save - - - { - event.stopPropagation(); - discardViewPreferencesAndQuit(); - }} - > - [d] Discard - - - { - event.stopPropagation(); - neverAskToSaveViewPreferencesAndQuit(); - }} - > - [n] Never ask - - - [Esc] Cancel - - + ) : null} {!pagerMode && themeSelectorState.open ? ( diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index e0a54ad6..fd7e55d8 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -3391,11 +3391,12 @@ describe("App interactions", () => { let frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("Save view preferences?"), ); - expect(frame).toContain("Save your local view changes"); - expect(frame).toContain("[n] Never ask"); - expect(frame).toContain("Theme: github-dark-default → github-dark-dimmed"); - expect(frame).not.toContain("Line numbers:"); - expect(frame).not.toContain("Line wrapping:"); + expect(frame).toContain("You changed 1 view setting during this review."); + expect(frame).toContain("n never ask"); + expect(frame).toContain('- theme = "github-dark-default"'); + expect(frame).toContain('+ theme = "github-dark-dimmed"'); + expect(frame).not.toContain("line_numbers ="); + expect(frame).not.toContain("wrap_lines ="); expect(quit).toHaveBeenCalledTimes(0); await act(async () => { @@ -3528,6 +3529,87 @@ describe("App interactions", () => { } }); + test("quit prompt actions respond to mouse clicks", async () => { + const configHome = mkdtempSync(join(tmpdir(), "hunk-save-view-config-")); + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = configHome; + const quit = mock(() => undefined); + const setup = await testRender( + , + { + width: 240, + height: 24, + }, + ); + + /** Click the first frame cell where the given footer label starts. */ + const clickLabel = async (label: string) => { + const lines = setup.captureCharFrame().split("\n"); + const targetY = lines.findIndex((line) => line.includes(label)); + expect(targetY).toBeGreaterThanOrEqual(0); + const targetX = lines[targetY]!.indexOf(label); + await act(async () => { + await setup.mockMouse.click(targetX, targetY); + }); + }; + + try { + await flush(setup); + await act(async () => { + await setup.mockInput.typeText("t"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme selector")); + + await act(async () => { + await setup.mockInput.pressArrow("down"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("› github-dark-dimmed")); + await act(async () => { + await setup.mockInput.pressEnter(); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Theme: github-dark-dimmed")); + + await act(async () => { + await setup.mockInput.typeText("q"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Save view preferences?")); + + await clickLabel("esc cancel"); + const cancelled = await waitForFrame( + setup, + (nextFrame) => !nextFrame.includes("Save view preferences?"), + ); + expect(cancelled).not.toContain("Save view preferences?"); + expect(quit).toHaveBeenCalledTimes(0); + + await act(async () => { + await setup.mockInput.typeText("q"); + }); + await waitForFrame(setup, (nextFrame) => nextFrame.includes("Save view preferences?")); + + await clickLabel("enter/s save"); + await act(async () => { + await Bun.sleep(140); + }); + await flush(setup); + + expect(quit).toHaveBeenCalledTimes(1); + expect(readFileSync(join(configHome, "hunk", "config.toml"), "utf8")).toContain( + 'theme = "github-dark-dimmed"', + ); + } finally { + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + rmSync(configHome, { recursive: true, force: true }); + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("quit skips the save prompt when configured not to ask", async () => { const quit = mock(() => undefined); const bootstrap = createSingleFileBootstrap(); diff --git a/src/ui/components/chrome/ConfirmDialog.tsx b/src/ui/components/chrome/ConfirmDialog.tsx new file mode 100644 index 00000000..7a525371 --- /dev/null +++ b/src/ui/components/chrome/ConfirmDialog.tsx @@ -0,0 +1,106 @@ +import type { MouseEvent as TuiMouseEvent } from "@opentui/core"; +import type { ReactNode } from "react"; +import { useState } from "react"; +import type { AppTheme } from "../../themes"; +import { ModalFrame } from "./ModalFrame"; + +/** One confirm choice: its footer key legend, label, and the action it runs. */ +export interface ConfirmDialogAction { + /** Keyboard legend shown in the footer, e.g. "enter/s", "d", "esc". */ + keyLabel: string; + /** Short lowercase verb phrase, e.g. "save" or "never ask". */ + label: string; + /** Run the choice. Invoked on mouse click; keyboard routing stays with the caller. */ + run: () => void; +} + +/** Rows ConfirmDialog adds around the body: ModalFrame chrome plus spacer and action row. */ +const CONFIRM_DIALOG_CHROME_ROWS = 7; + +/** Modal height for a ConfirmDialog whose body renders the given number of rows. */ +export function confirmDialogHeight(bodyRows: number) { + return bodyRows + CONFIRM_DIALOG_CHROME_ROWS; +} + +/** + * Hunk's standard confirm modal shape: a ModalFrame with arbitrary body rows and + * one bottom action legend (`key label · key label · …`). + * + * Actions are mouse-clickable and highlight on hover. Keyboard shortcuts for the + * same actions are intentionally not handled here — wire them through + * useAppKeyboardShortcuts so all key handling stays in one place, and keep each + * action's `keyLabel` in sync with the keys that hook accepts. + * + * New confirmation prompts should reuse this component instead of composing + * ModalFrame with a hand-rolled footer. Size it with + * `confirmDialogHeight(bodyRows)` where `bodyRows` counts the 1-row boxes the + * body renders. + */ +export function ConfirmDialog({ + actions, + children, + height, + onClose, + terminalHeight, + terminalWidth, + theme, + title, + width, +}: { + actions: ConfirmDialogAction[]; + children: ReactNode; + height: number; + /** Invoked by the frame's backdrop click and [Esc] affordance. */ + onClose?: () => void; + terminalHeight: number; + terminalWidth: number; + theme: AppTheme; + title: string; + width: number; +}) { + const [hoveredActionKey, setHoveredActionKey] = useState(null); + + return ( + + {children} + + + {actions.map((action, index) => { + const hovered = hoveredActionKey === action.keyLabel; + return ( + + {index > 0 ? · : null} + setHoveredActionKey(action.keyLabel)} + onMouseOut={() => + setHoveredActionKey((current) => (current === action.keyLabel ? null : current)) + } + onMouseUp={(event: TuiMouseEvent) => { + event.stopPropagation(); + action.run(); + }} + > + {action.keyLabel} + {action.label} + + + ); + })} + + + ); +} diff --git a/test/pty/chrome.test.ts b/test/pty/chrome.test.ts index a70d843d..7342867c 100644 --- a/test/pty/chrome.test.ts +++ b/test/pty/chrome.test.ts @@ -1,4 +1,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { createPtyHarness } from "./harness"; const harness = createPtyHarness(); @@ -76,6 +79,51 @@ describe("PTY chrome", () => { } }); + test("quit prompt shows the config diff and saves preferences on mouse click", async () => { + // Own the config home instead of the shared harness one so the test can + // assert what the save action wrote. + const configHome = mkdtempSync(join(tmpdir(), "hunk-tuistory-save-view-")); + const fixture = harness.createMultiHunkFilePair(); + const session = await harness.launchHunk({ + args: ["diff", fixture.before, fixture.after], + cols: 120, + rows: 24, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await session.waitForText(/line60/, { timeout: 15_000 }); + + await session.press("t"); + await session.waitForText(/Theme selector/, { timeout: 5_000 }); + await session.press("down"); + await session.waitForText(/›\s+github-dark-dimmed/, { timeout: 5_000 }); + await session.press("enter"); + await harness.waitForSnapshot(session, (text) => !text.includes("Theme selector"), 5_000); + + await session.press("q"); + const prompt = await session.waitForText(/Save view preferences\?/, { timeout: 5_000 }); + expect(prompt).toContain('- theme = "github-dark-default"'); + expect(prompt).toContain('+ theme = "github-dark-dimmed"'); + expect(prompt).toContain("enter/s save"); + + await session.click(/enter\/s save/); + + // The save handler writes the config and quits shortly after; poll the + // file instead of the (soon dead) PTY session. + const configPath = join(configHome, "hunk", "config.toml"); + const deadline = Date.now() + 5_000; + while (Date.now() < deadline && !existsSync(configPath)) { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + expect(readFileSync(configPath, "utf8")).toContain('theme = "github-dark-dimmed"'); + } finally { + session.close(); + rmSync(configHome, { recursive: true, force: true }); + } + }); + test("filter focus narrows the visible review stream in the live app", async () => { const fixture = harness.createTwoFileRepoFixture(); const session = await harness.launchHunk({ From 9579fc0590fda53a10bdb09cbd89e93496e45391 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 2 Jul 2026 18:17:46 -0400 Subject: [PATCH 3/3] feat(ui): discard from the quit prompt with q instead of d Double-tapping the quit key now always exits, so quitting without saving stays muscle-memory friendly. Co-Authored-By: Claude Fable 5 --- src/ui/App.tsx | 2 +- src/ui/AppHost.interactions.test.tsx | 4 +++- src/ui/hooks/useAppKeyboardShortcuts.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ec48b549..490c300e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1238,7 +1238,7 @@ export function App({ { nextFrame.includes("Save view preferences?"), ); expect(frame).toContain("You changed 1 view setting during this review."); + expect(frame).toContain("q discard"); expect(frame).toContain("n never ask"); expect(frame).toContain('- theme = "github-dark-default"'); expect(frame).toContain('+ theme = "github-dark-dimmed"'); @@ -3399,8 +3400,9 @@ describe("App interactions", () => { expect(frame).not.toContain("wrap_lines ="); expect(quit).toHaveBeenCalledTimes(0); + // A second "q" quits and discards. await act(async () => { - await setup.mockInput.typeText("d"); + await setup.mockInput.typeText("q"); await setup.renderOnce(); }); expect(quit).toHaveBeenCalledTimes(1); diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index e1e1b379..125d7aa2 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -308,7 +308,8 @@ export function useAppKeyboardShortcuts({ return true; } - if (key.name === "d" || key.sequence === "d") { + // "q" again quits and discards, so a double-tap of the quit key always exits. + if (key.name === "q" || key.sequence === "q") { discardViewPreferencesAndQuit(); return true; }