From 0980100a6a62516ba7ec1321406a98079d3736ae Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Fri, 4 Sep 2026 13:40:37 -0400 Subject: [PATCH 1/4] refactor(ui): share desktop chrome with history --- .changeset/shared-log-chrome.md | 5 + docs/keybindings.md | 7 +- skills/hunk-extensions/SKILL.md | 2 +- src/app/historyBootstrap.ts | 55 +- src/core/vcs/index.ts | 4 +- src/core/vcs/types.ts | 3 + src/extension-api/index.ts | 1 + .../default/vcs/git/history.test.ts | 15 + src/extensions/default/vcs/git/index.ts | 11 +- .../default/vcs/jujutsu/history.test.ts | 13 +- src/extensions/default/vcs/jujutsu/index.ts | 14 +- src/extensions/runExtension.test.ts | 20 +- src/extensions/runExtension.ts | 19 +- src/extensions/types.ts | 1 + src/ui/components/chrome/HelpDialog.tsx | 24 +- src/ui/components/chrome/MenuBar.tsx | 8 +- src/ui/components/chrome/MenuDropdown.tsx | 50 +- .../components/chrome/ThemeSelectorDialog.tsx | 21 +- src/ui/components/chrome/menu.ts | 7 +- src/ui/components/ui-components.test.tsx | 82 +++ src/ui/history/runInteractiveHistory.test.ts | 80 +-- src/ui/history/runInteractiveHistory.ts | 367 +----------- src/ui/history/runStaticHistory.test.ts | 3 + src/ui/history/terminalInput.test.ts | 53 -- src/ui/history/terminalInput.ts | 193 ------- src/ui/history/types.ts | 8 +- src/ui/hooks/useMenuController.test.tsx | 54 ++ src/ui/hooks/useMenuController.ts | 37 +- .../hooks/useThemeSelectorController.test.tsx | 3 + src/ui/hooks/useThemeSelectorController.ts | 6 +- src/ui/lib/appMenus.ts | 2 +- src/ui/lib/ui-lib.test.ts | 4 +- src/ui/log/LogApp.tsx | 528 ++++++++++++++++++ src/ui/log/ParentSelectorDialog.tsx | 72 +++ src/ui/log/commands.test.ts | 68 +++ src/ui/log/commands.ts | 224 ++++++++ src/ui/log/controller.test.ts | 161 ++++++ src/ui/log/controller.ts | 346 ++++++++++++ src/ui/log/logHelp.ts | 4 + src/ui/log/reviewLaunch.ts | 41 ++ src/ui/log/runInteractiveLog.test.ts | 10 + src/ui/log/runInteractiveLog.tsx | 128 +++++ test/pty/log-integration.test.ts | 90 ++- 43 files changed, 2064 insertions(+), 780 deletions(-) create mode 100644 .changeset/shared-log-chrome.md delete mode 100644 src/ui/history/terminalInput.test.ts delete mode 100644 src/ui/history/terminalInput.ts create mode 100644 src/ui/log/LogApp.tsx create mode 100644 src/ui/log/ParentSelectorDialog.tsx create mode 100644 src/ui/log/commands.test.ts create mode 100644 src/ui/log/commands.ts create mode 100644 src/ui/log/controller.test.ts create mode 100644 src/ui/log/controller.ts create mode 100644 src/ui/log/logHelp.ts create mode 100644 src/ui/log/reviewLaunch.ts create mode 100644 src/ui/log/runInteractiveLog.test.ts create mode 100644 src/ui/log/runInteractiveLog.tsx diff --git a/.changeset/shared-log-chrome.md b/.changeset/shared-log-chrome.md new file mode 100644 index 000000000..5bd4ad805 --- /dev/null +++ b/.changeset/shared-log-chrome.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add Hunk's desktop menu chrome, live theme picker, and provider-owned merge-parent selection to the interactive repository history browser. diff --git a/docs/keybindings.md b/docs/keybindings.md index 3d8291273..47ce3d6d0 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -46,9 +46,10 @@ deleted until its replies are removed. The built-in commands and the keys they ship with: `hunk log --interactive` is a separate, fixed read-only history entry point rather than part of -the configurable review command table. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`, -`g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `y` to copy the full commit id, -`Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit +the configurable review command table. `F10` opens its File, View, Navigate, Commit, and Help menus; +View includes Hunk's shared theme selector. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`, +`g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `r` to refresh, `y` to copy +the full commit id, `Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit id to open it immediately, click elsewhere on a row to select it, or double-click a row to open it. Quitting the opened review returns to the retained history selection and viewport. diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index 363a36ec7..f4c8ae713 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -111,7 +111,7 @@ bad or duplicate id is skipped with a startup notice. | Reload after an external agent changes reviewed inputs | `ctx.review.requestReload()` in an event | | Read user-supplied settings | `hunk.config` (`[extension.]` table) | | Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command | -| Branch on the API generation (currently `17`) | `hunk.apiVersion` | +| Branch on the API generation (currently `18`) | `hunk.apiVersion` | Registration is only valid while the factory runs — Hunk seals the API object afterwards. diff --git a/src/app/historyBootstrap.ts b/src/app/historyBootstrap.ts index a60d57be0..bd4372416 100644 --- a/src/app/historyBootstrap.ts +++ b/src/app/historyBootstrap.ts @@ -3,6 +3,7 @@ import { collectSessionCustomThemes } from "../core/theme/customThemes"; import type { ExtensionVcsHistoryCommit, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, NamedCustomThemeConfig, } from "../extension-api/types"; import { sanitizeTerminalLine } from "../lib/terminalText"; @@ -31,7 +32,11 @@ export interface HistoryBootstrap { extensions: ExtensionLoadResult; notices: readonly string[]; customThemes: readonly NamedCustomThemeConfig[]; - planReview(commit: ExtensionVcsHistoryCommit): Promise; + planReview( + commit: ExtensionVcsHistoryCommit, + options?: ExtensionVcsHistoryReviewOptions, + ): Promise; + reopenSource(signal?: AbortSignal): Promise; close(): Promise; } @@ -95,24 +100,22 @@ export async function loadHistoryBootstrap({ } const repoRoot = selectedDetection?.repoRoot ?? cwd; + const historyInput = { + ...(input.revision ? { revision: input.revision } : {}), + ...(input.all ? { all: true } : {}), + ...(input.firstParent ? { firstParent: true } : {}), + ...(input.maxCount !== undefined ? { maxCount: input.maxCount } : {}), + ...(input.author !== undefined ? { author: input.author } : {}), + ...(input.grep !== undefined ? { grep: input.grep } : {}), + ...(input.since !== undefined ? { since: input.since } : {}), + ...(input.until !== undefined ? { until: input.until } : {}), + ...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}), + }; + const openSource = (signal?: AbortSignal) => + openVcsHistory(adapter, historyInput, { cwd: repoRoot, signal }, catalog); let source: VcsHistorySource; try { - source = await openVcsHistory( - adapter, - { - ...(input.revision ? { revision: input.revision } : {}), - ...(input.all ? { all: true } : {}), - ...(input.firstParent ? { firstParent: true } : {}), - ...(input.maxCount !== undefined ? { maxCount: input.maxCount } : {}), - ...(input.author !== undefined ? { author: input.author } : {}), - ...(input.grep !== undefined ? { grep: input.grep } : {}), - ...(input.since !== undefined ? { since: input.since } : {}), - ...(input.until !== undefined ? { until: input.until } : {}), - ...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}), - }, - { cwd: repoRoot }, - catalog, - ); + source = await openSource(); emitExtensionEvent(resolved.extensions, "startup", { cwd }); } catch (error) { await retireExtensionLoadResult(resolved.extensions); @@ -141,8 +144,22 @@ export async function loadHistoryBootstrap({ ] : []), ], - planReview(commit) { - return planVcsHistoryReview(adapter, commit, { cwd: repoRoot }); + planReview(commit, options) { + return planVcsHistoryReview(adapter, commit, { cwd: repoRoot }, options); + }, + async reopenSource(signal) { + if (closed) throw new Error("History session is closed."); + signal?.throwIfAborted(); + const previous = source; + const replacement = await openSource(signal); + if (closed || source !== previous || signal?.aborted) { + await replacement.close(); + signal?.throwIfAborted(); + throw new Error("History session changed while refreshing."); + } + source = replacement; + await previous.close(); + return replacement; }, async close() { if (closed) return; diff --git a/src/core/vcs/index.ts b/src/core/vcs/index.ts index 2a1e072c2..386b320f3 100644 --- a/src/core/vcs/index.ts +++ b/src/core/vcs/index.ts @@ -5,6 +5,7 @@ import type { ExtensionVcsHistoryCommit, ExtensionVcsHistoryInput, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, } from "../../extension-api/types"; import type { CliInput } from "../run/commandInputs"; import type { @@ -182,13 +183,14 @@ export async function planVcsHistoryReview( adapter: VcsAdapter, commit: ExtensionVcsHistoryCommit, context: VcsLoadContext, + options?: ExtensionVcsHistoryReviewOptions, ): Promise { if (!adapter.history) { throw new HunkUserError(`\`hunk log\` is not supported by ${adapter.name}.`, [ "Use a VCS adapter that implements history browsing.", ]); } - return await adapter.history.planReview(commit, context); + return await adapter.history.planReview(commit, context, options); } /** Build an adapter event plan, falling back to signature polling. */ diff --git a/src/core/vcs/types.ts b/src/core/vcs/types.ts index 062676429..7a110e22a 100644 --- a/src/core/vcs/types.ts +++ b/src/core/vcs/types.ts @@ -3,6 +3,7 @@ import type { ExtensionVcsHistoryInput, ExtensionVcsHistoryPage, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, ExtensionVcsWatchPlan, } from "../../extension-api/types"; import type { DiffFile } from "../changeset/model"; @@ -22,6 +23,7 @@ export interface VcsDetection { export interface VcsLoadContext { cwd: string; + signal?: AbortSignal; } export type VcsReviewInput = VcsDiffCommandInput | VcsShowCommandInput | VcsStashShowCommandInput; @@ -57,6 +59,7 @@ export interface VcsHistoryCapability { planReview( commit: ExtensionVcsHistoryCommit, context: VcsLoadContext, + options?: ExtensionVcsHistoryReviewOptions, ): Promise; } diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index f2445ad17..225ee7261 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -154,6 +154,7 @@ export type { ExtensionVcsHistoryInput, ExtensionVcsHistoryPage, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, ExtensionVcsHistorySource, ExtensionVcsLoadContext, ExtensionVcsOperation, diff --git a/src/extensions/default/vcs/git/history.test.ts b/src/extensions/default/vcs/git/history.test.ts index a5826c30e..e18710b06 100644 --- a/src/extensions/default/vcs/git/history.test.ts +++ b/src/extensions/default/vcs/git/history.test.ts @@ -142,6 +142,21 @@ describe("Git history production", () => { fromRevisionId: "c".repeat(40), toRevisionId: "b".repeat(40), }); + expect( + await history.planReview( + { + ...root, + revisionId: "b".repeat(40), + parentRevisionIds: ["c".repeat(40), "d".repeat(40)], + }, + undefined, + { parentRevisionId: "d".repeat(40) }, + ), + ).toEqual({ + kind: "revision-range", + fromRevisionId: "d".repeat(40), + toRevisionId: "b".repeat(40), + }); }); test("rejects truncated records and invalid SHA object ids", () => { diff --git a/src/extensions/default/vcs/git/index.ts b/src/extensions/default/vcs/git/index.ts index c92204ed6..9f1cda3b4 100644 --- a/src/extensions/default/vcs/git/index.ts +++ b/src/extensions/default/vcs/git/index.ts @@ -284,12 +284,15 @@ export function createGitVcsAdapter({ open(input, { cwd }) { return openGitHistory(input, { cwd, gitExecutable }); }, - planReview(commit) { - const firstParent = commit.parentRevisionIds[0]; - return firstParent + planReview(commit, _context?: unknown, options?: { parentRevisionId?: string }) { + const parent = options?.parentRevisionId ?? commit.parentRevisionIds[0]; + if (parent && !commit.parentRevisionIds.includes(parent)) { + throw new Error("The selected revision is not a parent of this Git commit."); + } + return parent ? { kind: "revision-range" as const, - fromRevisionId: firstParent, + fromRevisionId: parent, toRevisionId: commit.revisionId, } : { kind: "revision-show" as const, revisionId: commit.revisionId }; diff --git a/src/extensions/default/vcs/jujutsu/history.test.ts b/src/extensions/default/vcs/jujutsu/history.test.ts index fb4fb0e32..4c35bb363 100644 --- a/src/extensions/default/vcs/jujutsu/history.test.ts +++ b/src/extensions/default/vcs/jujutsu/history.test.ts @@ -171,7 +171,7 @@ describe("Jujutsu history production", () => { expect(parseJjHistory(raw, true)[0]!.parentRevisionIds).toEqual(["b".repeat(40)]); }); - test("owns ordinary, merge, and root review semantics with revision-show", async () => { + test("owns default and explicitly selected parent review semantics", async () => { const history = createJjVcsAdapter().history!; for (const parentRevisionIds of [[], ["b".repeat(40)], ["b".repeat(40), "c".repeat(40)]]) { const commit = { @@ -188,6 +188,17 @@ describe("Jujutsu history production", () => { kind: "revision-show", revisionId: commit.revisionId, }); + if (parentRevisionIds[0]) { + expect( + await history.planReview(commit, undefined, { + parentRevisionId: parentRevisionIds[0], + }), + ).toEqual({ + kind: "revision-range", + fromRevisionId: parentRevisionIds[0], + toRevisionId: commit.revisionId, + }); + } } }); diff --git a/src/extensions/default/vcs/jujutsu/index.ts b/src/extensions/default/vcs/jujutsu/index.ts index f39cc9f3e..0e0d7e9c7 100644 --- a/src/extensions/default/vcs/jujutsu/index.ts +++ b/src/extensions/default/vcs/jujutsu/index.ts @@ -138,8 +138,18 @@ export function createJjVcsAdapter({ jjExecutable = "jj" }: Readonly { subject: "Child", }; let page = 0; + let plannedParent: string | undefined; const adapter = toInternalVcsAdapter({ id: "opaque", name: "Opaque VCS", @@ -1205,14 +1206,17 @@ describe("toInternalVcsAdapter history boundary", () => { page++ === 0 ? { commits: [child], done: false } : { commits: [root], done: true }, close() {}, }), - planReview: (selected) => - selected.parentRevisionIds.length + planReview: (selected, _context, options) => { + plannedParent = options?.parentRevisionId; + return selected.parentRevisionIds.length ? { kind: "revision-range", - fromRevisionId: `opaque:base-for/${selected.revisionId}`, + fromRevisionId: + options?.parentRevisionId ?? `opaque:base-for/${selected.revisionId}`, toRevisionId: selected.revisionId, } - : { kind: "revision-show", revisionId: `opaque:root-view/${selected.revisionId}` }, + : { kind: "revision-show", revisionId: `opaque:root-view/${selected.revisionId}` }; + }, }, }); @@ -1228,6 +1232,14 @@ describe("toInternalVcsAdapter history boundary", () => { fromRevisionId: `opaque:base-for/${child.revisionId}`, toRevisionId: child.revisionId, }); + await expect( + adapter.history!.planReview(child, { cwd: "/repo" }, { parentRevisionId: root.revisionId }), + ).resolves.toEqual({ + kind: "revision-range", + fromRevisionId: root.revisionId, + toRevisionId: child.revisionId, + }); + expect(plannedParent).toBe(root.revisionId); await expect(adapter.history!.planReview(root, { cwd: "/repo" })).resolves.toEqual({ kind: "revision-show", revisionId: `opaque:root-view/${root.revisionId}`, diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index d1619d4ca..8f69b6f7e 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -562,10 +562,25 @@ export function toInternalVcsAdapter( throw toUserFacingError(error); } }, - async planReview(commit, context) { + async planReview(commit, context, options) { try { + const normalizedCommit = normalizeHistoryCommit(commit); + const parentRevisionId = options?.parentRevisionId; + if ( + parentRevisionId !== undefined && + !normalizedCommit.parentRevisionIds.includes(parentRevisionId) + ) { + throw new Error( + "VCS history parent selection must name one of the commit's parents.", + ); + } return normalizeHistoryReviewAction( - await planHistoryReview.call(history, normalizeHistoryCommit(commit), context), + await planHistoryReview.call( + history, + normalizedCommit, + context, + parentRevisionId === undefined ? undefined : { parentRevisionId }, + ), ); } catch (error) { throw toUserFacingError(error); diff --git a/src/extensions/types.ts b/src/extensions/types.ts index 7d281f052..4bace99a9 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -82,6 +82,7 @@ export type { ExtensionVcsHistoryInput, ExtensionVcsHistoryPage, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, ExtensionVcsHistorySource, ExtensionWorkspace, ExtensionWorkspaceWriteRequest, diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx index 3dcfc9808..b025d5ffa 100644 --- a/src/ui/components/chrome/HelpDialog.tsx +++ b/src/ui/components/chrome/HelpDialog.tsx @@ -1,5 +1,5 @@ import type { AppCommand } from "../../lib/appCommands"; -import { buildHelpSections } from "../../lib/helpContent"; +import { buildHelpSections, type HelpSection } from "../../lib/helpContent"; import { fitText, padText } from "../../lib/text"; import type { AppTheme } from "../../themes"; import { ModalFrame } from "./ModalFrame"; @@ -12,20 +12,22 @@ import { ModalFrame } from "./ModalFrame"; */ export function HelpDialog({ commands, + sections: suppliedSections, terminalHeight, terminalWidth, theme, onClose, }: { - commands: readonly AppCommand[]; + commands?: readonly AppCommand[]; + sections?: readonly HelpSection[]; terminalHeight: number; terminalWidth: number; theme: AppTheme; onClose: () => void; }) { - const sections = buildHelpSections(commands); + const sections = suppliedSections ? [...suppliedSections] : buildHelpSections(commands ?? []); - const width = Math.min(74, Math.max(56, terminalWidth - 8)); + const width = Math.max(1, Math.min(74, Math.max(56, terminalWidth - 8), terminalWidth - 2)); const bodyWidth = Math.max(1, width - 4); const rows = sections.flatMap((section) => section.rows); const longestKeys = Math.max(0, ...rows.map((row) => row.keys.length)); @@ -33,8 +35,14 @@ export function HelpDialog({ // Key text is user-controlled once bindings are, so the column is measured // rather than guessed — but descriptions are given the room they need first, // since a truncated key is still recognizable and a truncated sentence is not. - const keyWidth = Math.max(12, Math.min(longestKeys + 1, bodyWidth - longestDescription)); - const descriptionWidth = Math.max(1, bodyWidth - keyWidth); + const keyWidth = Math.max( + 1, + Math.min( + bodyWidth, + Math.max(Math.min(12, bodyWidth), Math.min(longestKeys + 1, bodyWidth - longestDescription)), + ), + ); + const descriptionWidth = Math.max(0, bodyWidth - keyWidth); const sectionSpacerRowCount = Math.max(0, sections.length - 1); const contentRowCount = sections.reduce((rowCount, section) => rowCount + 1 + section.rows.length, 0) + @@ -42,7 +50,7 @@ export function HelpDialog({ // ModalFrame contributes the border rows, title row, padding, and one blank spacer row. const modalFrameChromeRowCount = 6; const requiredModalHeight = contentRowCount + modalFrameChromeRowCount; - const modalHeight = Math.min(requiredModalHeight, Math.max(8, terminalHeight - 2)); + const modalHeight = Math.max(1, Math.min(requiredModalHeight, terminalHeight - 2)); const shouldScroll = modalHeight < requiredModalHeight; const content = ( @@ -77,7 +85,7 @@ export function HelpDialog({ onClose={onClose} > {shouldScroll ? ( - + {content} ) : ( diff --git a/src/ui/components/chrome/MenuBar.tsx b/src/ui/components/chrome/MenuBar.tsx index 9857feaad..653e1cb3a 100644 --- a/src/ui/components/chrome/MenuBar.tsx +++ b/src/ui/components/chrome/MenuBar.tsx @@ -20,6 +20,10 @@ export function MenuBar({ onHoverMenu: (menuId: MenuId) => void; onToggleMenu: (menuId: MenuId) => void; }) { + const visibleMenuSpecs = menuSpecs.filter( + (menu) => menu.left + menu.width <= Math.max(1, terminalWidth - 1), + ); + const title = visibleMenuSpecs.length === 0 ? "F10 menu" : topTitle; return ( // The outer row paints the app background so the bar keeps the same // one-column gutter the body panes have; only the inner band is chrome. @@ -42,7 +46,7 @@ export function MenuBar({ alignItems: "center", }} > - {menuSpecs.map((menu) => { + {visibleMenuSpecs.map((menu) => { const active = activeMenuId === menu.id; return ( {` ${fitText(topTitle, menuBarTitleWidth(menuSpecs, terminalWidth))}`} + >{` ${fitText(title, menuBarTitleWidth(visibleMenuSpecs, terminalWidth))}`} diff --git a/src/ui/components/chrome/MenuDropdown.tsx b/src/ui/components/chrome/MenuDropdown.tsx index 53f1d0cf1..5a883b56c 100644 --- a/src/ui/components/chrome/MenuDropdown.tsx +++ b/src/ui/components/chrome/MenuDropdown.tsx @@ -23,7 +23,7 @@ function renderMenuLine( style={{ width: "100%", height: 1, flexDirection: "row", justifyContent: "space-between" }} > - {padText(text, leftWidth)} + {padText(text, leftWidth)} {hint ? ( @@ -42,6 +42,7 @@ export function MenuDropdown({ activeMenuSpec, activeMenuWidth, top = 1, + terminalHeight = Number.MAX_SAFE_INTEGER, terminalWidth, theme, onHoverItem, @@ -53,13 +54,23 @@ export function MenuDropdown({ activeMenuSpec: MenuSpec; activeMenuWidth: number; top?: number; + terminalHeight?: number; terminalWidth: number; theme: AppTheme; onHoverItem: (index: number) => void; onSelectItem: (entry: Extract) => void; }) { - const clampedWidth = Math.min(activeMenuWidth, Math.max(22, terminalWidth - 2)); - const clampedLeft = Math.max(1, Math.min(activeMenuSpec.left, terminalWidth - clampedWidth - 1)); + const clampedWidth = Math.max(1, Math.min(activeMenuWidth, terminalWidth - 2)); + const clampedLeft = Math.max(0, Math.min(activeMenuSpec.left, terminalWidth - clampedWidth)); + const visibleRowCount = Math.max(1, terminalHeight - top - 2); + const windowStart = Math.max( + 0, + Math.min( + Math.max(0, activeMenuEntries.length - visibleRowCount), + activeMenuItemIndex - Math.floor(visibleRowCount / 2), + ), + ); + const visibleEntries = activeMenuEntries.slice(windowStart, windowStart + visibleRowCount); return ( - {activeMenuEntries.map((entry, index) => - entry.kind === "separator" ? ( + {visibleEntries.map((entry, offset) => { + const index = windowStart + offset; + return entry.kind === "separator" ? ( - {padText("-".repeat(clampedWidth - 4), clampedWidth - 2)} + + {padText("-".repeat(Math.max(0, clampedWidth - 4)), Math.max(0, clampedWidth - 2))} + ) : ( { + if (!entry.disabled) onHoverItem(index); + }} + onMouseUp={() => { + if (!entry.disabled) onSelectItem(entry); }} - onMouseOver={() => onHoverItem(index)} - onMouseUp={() => onSelectItem(entry)} > - {renderMenuLine(entry, clampedWidth - 2, theme, activeMenuItemIndex === index)} + {renderMenuLine( + entry, + Math.max(1, clampedWidth - 2), + theme, + activeMenuItemIndex === index && !entry.disabled, + )} - ), - )} + ); + })} ); } diff --git a/src/ui/components/chrome/ThemeSelectorDialog.tsx b/src/ui/components/chrome/ThemeSelectorDialog.tsx index f88131502..7d6495456 100644 --- a/src/ui/components/chrome/ThemeSelectorDialog.tsx +++ b/src/ui/components/chrome/ThemeSelectorDialog.tsx @@ -68,11 +68,17 @@ export function ThemeSelectorDialog({ onClose: () => void; onPreviewItem: (index: number) => void; }) { - const width = Math.min(82, Math.max(56, terminalWidth - 8)); - const modalHeight = Math.min(Math.max(12, terminalHeight - 4), 28); + const width = Math.max(1, Math.min(82, Math.max(56, terminalWidth - 8), terminalWidth - 2)); + const modalHeight = Math.max( + 1, + Math.min(28, Math.max(12, terminalHeight - 4), terminalHeight - 2), + ); const bodyWidth = Math.max(1, width - 4); // ModalFrame contributes border/title/padding; reserve help/footer rows inside the body. - const visibleRows = Math.max(4, modalHeight - 7); + const visibleRows = Math.max( + 1, + Math.min(Math.max(4, modalHeight - 7), Math.max(1, modalHeight - 5)), + ); const [windowState, setWindowState] = useState(() => ({ itemCount: items.length, selectedIndex, @@ -123,9 +129,12 @@ export function ThemeSelectorDialog({ ); const visibleItems = items.slice(windowStart, windowStart + visibleRows); - const markerWidth = 3; - const descriptionWidth = 12; - const labelWidth = Math.max(8, bodyWidth - markerWidth - descriptionWidth - 2); + const markerWidth = Math.min(3, bodyWidth); + const descriptionWidth = bodyWidth >= 28 ? 12 : 0; + const labelWidth = Math.max( + 0, + bodyWidth - markerWidth - descriptionWidth - (descriptionWidth ? 2 : 0), + ); return ( void; } | { @@ -36,6 +38,7 @@ const MENU_LABELS: Record = { file: "File", view: "View", navigate: "Navigate", + commit: "Commit", agent: "Agent", extensions: "Extensions", help: "Help", @@ -84,7 +87,7 @@ export function nextMenuItemIndex(entries: MenuEntry[], currentIndex: number, de for (let remaining = entries.length; remaining > 0; remaining -= 1) { candidate = (candidate + delta + entries.length) % entries.length; const entry = entries[candidate]; - if (entry?.kind === "item") { + if (entry?.kind === "item" && !entry.disabled) { return candidate; } } diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 74d9acd5c..553abd1ea 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -27,6 +27,7 @@ const { AppHost } = await import("../AppHost"); const { toReadOnlyFileViews } = await import("../../extensions/events"); const { FlexFileSidebar } = await import("../../extensions/default/ui/sidebar"); const { HelpDialog } = await import("./chrome/HelpDialog"); +const { ThemeSelectorDialog } = await import("./chrome/ThemeSelectorDialog"); const { AgentCard } = await import("./panes/AgentCard"); const { AgentInlineNote, measureAgentInlineNoteHeight } = await import("./panes/AgentInlineNote"); const { DiffPane, storedReviewNoteActions } = await import("./panes/DiffPane"); @@ -3533,6 +3534,33 @@ describe("UI components", () => { expect(frame).toContain("┘"); }); + test("MenuDropdown windows the active item inside a narrow terminal", async () => { + const theme = resolveTheme("github-dark-default", null); + const entries = Array.from({ length: 8 }, (_, index) => ({ + kind: "item" as const, + label: `Item ${index}`, + action: () => {}, + })); + const frame = await captureFrame( + {}} + onSelectItem={() => {}} + />, + 16, + 6, + ); + expect(frame).toContain("Item 7"); + expect(frame.split("\n").every((line) => line.length <= 16)).toBe(true); + }); + test("StatusBar renders filter mode affordance", async () => { const theme = resolveTheme("github-dark-default", null); const frame = await captureFrame( @@ -3751,6 +3779,60 @@ describe("UI components", () => { expect(frame).not.toContain("linese/Awrapt/smetadata"); }); + test("HelpDialog renders surface-supplied sections without review controls", async () => { + const theme = resolveTheme("github-dark-default", null); + const frame = await captureFrame( + {}} + />, + 60, + 16, + ); + + expect(frame).toContain("Commit"); + expect(frame).toContain("Enter"); + expect(frame).toContain("open commit"); + expect(frame).not.toContain("Review"); + }); + + test("shared help and theme dialogs clamp into narrow terminals", async () => { + const theme = resolveTheme("github-dark-default", null); + const help = await captureFrame( + {}} + />, + 20, + 8, + ); + const selector = await captureFrame( + {}} + onClose={() => {}} + onPreviewItem={() => {}} + />, + 20, + 8, + ); + expect(help).toContain("Commit"); + expect(selector).toContain("Dark"); + expect([...help.split("\n"), ...selector.split("\n")].every((line) => line.length <= 20)).toBe( + true, + ); + }); + test("HelpDialog shows the keys a remapped command actually answers to", async () => { const theme = resolveTheme("github-dark-default", null); const { keys } = resolveCommandKeys({ diff --git a/src/ui/history/runInteractiveHistory.test.ts b/src/ui/history/runInteractiveHistory.test.ts index 8d29616d4..9d91e6cad 100644 --- a/src/ui/history/runInteractiveHistory.test.ts +++ b/src/ui/history/runInteractiveHistory.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { PassThrough } from "node:stream"; -import type { HistoryRuntime } from "./types"; -import { historyReviewArgs, runInteractiveHistory } from "./runInteractiveHistory"; +import { historyReviewArgs } from "./runInteractiveHistory"; describe("history review child arguments", () => { test("encodes provider-owned opaque actions without exposing ids to CLI option parsing", () => { @@ -25,79 +23,3 @@ describe("history review child arguments", () => { }); }); }); - -describe("interactive history loading", () => { - test("q interrupts an exhaustive read and closes the provider", async () => { - const stdin = new PassThrough() as unknown as NodeJS.ReadStream; - const stdout = new PassThrough() as unknown as NodeJS.WriteStream; - Object.assign(stdin, { isTTY: true, setRawMode() {} }); - Object.assign(stdout, { isTTY: true, columns: 80, rows: 12 }); - let reads = 0; - let readAborted = false; - let closed = false; - const runtime: HistoryRuntime = { - input: { - kind: "history", - color: "never", - format: "compact", - ascii: false, - interactive: true, - extensionsEnabled: false, - extensionPaths: [], - }, - source: { - async read({ signal }) { - reads += 1; - if (reads === 1) { - return { - commits: [ - { - revisionId: "a".repeat(40), - displayId: "aaaaaaaa", - parentRevisionIds: [], - subject: "First", - authorName: "Ada", - authoredAt: "2026-01-01T00:00:00Z", - decorations: [], - }, - ], - done: false, - }; - } - return await new Promise((_, reject) => { - signal?.addEventListener( - "abort", - () => { - readAborted = true; - reject(signal.reason); - }, - { once: true }, - ); - }); - }, - async close() {}, - }, - providerId: "test", - providerName: "Test", - repoRoot: "/repo", - notices: [], - customThemes: [], - async planReview() { - return { kind: "revision-show", revisionId: "a".repeat(40) }; - }, - async close() { - closed = true; - }, - }; - - const running = runInteractiveHistory(runtime, { stdin, stdout }); - while (reads < 1) await Bun.sleep(1); - // Terminals may coalesce the exhaustive-navigation key and quit. - stdin.write("Gq"); - await running; - expect(reads).toBe(2); - - expect(readAborted).toBe(true); - expect(closed).toBe(true); - }); -}); diff --git a/src/ui/history/runInteractiveHistory.ts b/src/ui/history/runInteractiveHistory.ts index ca46ad514..bf698746d 100644 --- a/src/ui/history/runInteractiveHistory.ts +++ b/src/ui/history/runInteractiveHistory.ts @@ -1,364 +1,3 @@ -import { spawn } from "node:child_process"; -import { resolve } from "node:path"; -import { createHistoryLaneCheckpoint, planHistoryPage } from "../../core/history/lanePlanner"; -import type { HistoryGraphRow, HistoryLaneCheckpoint } from "../../core/history/types"; -import { resolveCurrentHunkCommand } from "../../core/process/relaunch"; -import { HunkUserError } from "../../core/run/errors"; -import { sanitizeTerminalLine } from "../../lib/terminalText"; -import { fitText } from "../lib/text"; -import { - background, - foreground, - getHistoryCommitIdBounds, - projectHistoryRow, - resolveHistoryColor, - resolveHistoryTheme, -} from "./staticProjection"; -import { TerminalInputReader } from "./terminalInput"; -import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; -import type { HistoryRuntime } from "./types"; - -const ENTER_ALT = "\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h"; -const LEAVE_ALT = "\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"; - -/** Convert a provider-owned review declaration into one child Hunk invocation. */ -export function historyReviewArgs(action: ExtensionVcsHistoryReviewAction) { - const payload = Buffer.from(JSON.stringify(action), "utf8").toString("base64url"); - return [action.kind === "revision-range" ? "diff" : "show", "--history-review", payload]; -} - -/** Run one provider-planned child Hunk review after yielding terminal ownership. */ -async function openCommitReview( - bootstrap: HistoryRuntime, - action: ExtensionVcsHistoryReviewAction, -) { - const current = resolveCurrentHunkCommand(); - const extensionArgs = bootstrap.input.extensionPaths.flatMap((path) => [ - "--extension", - resolve(path), - ]); - const reviewArgs = historyReviewArgs(action); - const args = [ - ...current.args, - ...reviewArgs, - "--vcs", - bootstrap.providerId, - ...(bootstrap.input.extensionsEnabled ? extensionArgs : ["--no-extensions"]), - ]; - const child = spawn(current.command, args, { - cwd: bootstrap.repoRoot, - env: { ...process.env, HUNK_RETURN_TO_HISTORY: "1" }, - stdio: "inherit", - }); - return await new Promise((resolveExit, reject) => { - child.once("error", reject); - child.once("exit", (code, signal) => resolveExit(signal ? 1 : (code ?? 1))); - }); -} - -/** Browse history as one minimal graph list and open immutable commits in ordinary Hunk review. */ -export async function runInteractiveHistory( - bootstrap: HistoryRuntime, - { - stdin = process.stdin, - stdout = process.stdout, - }: { - stdin?: NodeJS.ReadStream; - stdout?: NodeJS.WriteStream; - } = {}, -) { - if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") { - await bootstrap.close(); - throw new HunkUserError("`hunk log --interactive` requires a terminal.", [ - "Use plain `hunk log` for pipes and redirected output.", - ]); - } - - const input = new TerminalInputReader(stdin); - const abort = new AbortController(); - const rows: HistoryGraphRow[] = []; - let checkpoint: HistoryLaneCheckpoint = createHistoryLaneCheckpoint(); - let historyDone = false; - let selected = 0; - let top = 0; - let search = ""; - let notice = bootstrap.notices[0] ? sanitizeTerminalLine(bootstrap.notices[0]) : ""; - let lastClick = { index: -1, at: 0 }; - let active = false; - let stopped = false; - let loading = false; - const theme = resolveHistoryTheme(bootstrap.input.theme, bootstrap.customThemes); - - /** Read one page while letting quit interrupt an exhaustive traversal. */ - const readInterruptibly = async () => { - const pending = bootstrap.source.read({ limit: 256, signal: abort.signal }); - const settled = pending.then( - (page) => ({ kind: "page" as const, page }), - (error: unknown) => ({ kind: "error" as const, error }), - ); - const deferredKeys: string[] = []; - for (;;) { - const keyWait = new AbortController(); - // Put input first so a queued `q` wins when a fast provider page and - // coalesced `Gq` input are both already ready. - const result = await Promise.race([ - input.next(keyWait.signal).then((key) => ({ kind: "key" as const, key })), - settled, - ]); - if (result.kind === "page") { - keyWait.abort(); - input.prepend(deferredKeys); - return result.page; - } - if (result.kind === "error") { - keyWait.abort(); - input.prepend(deferredKeys); - throw result.error; - } - if (result.key === "q" || result.key === "\x03") { - cleanup(); - await settled; - return undefined; - } - deferredKeys.push(result.key); - } - }; - - /** Fetch one bounded continuation page and preserve graph state across it. */ - const loadMore = async (interruptible = false) => { - if (historyDone || loading || stopped) return; - loading = true; - try { - const page = interruptible - ? await readInterruptibly() - : await bootstrap.source.read({ limit: 256, signal: abort.signal }); - if (!page) return; - if (!page.done && page.commits.length === 0) - throw new Error("VCS history returned an empty page before EOF."); - const planned = planHistoryPage(page.commits, checkpoint); - rows.push(...planned.rows); - checkpoint = planned.checkpoint; - historyDone = page.done; - if (rows.length === 0 && historyDone) notice = "No commits found."; - } finally { - loading = false; - } - }; - const loadAll = async () => { - while (!historyDone && !stopped) await loadMore(true); - }; - - const terminalWidth = () => (stdout.columns && stdout.columns > 0 ? stdout.columns : 80); - const terminalHeight = () => (stdout.rows && stdout.rows > 0 ? stdout.rows : 24); - const enterTerminal = () => { - stdin.setRawMode?.(true); - input.resume(); - stdout.write(ENTER_ALT); - active = true; - }; - const leaveTerminal = () => { - if (!active) return; - active = false; - input.pause(); - stdout.write(LEAVE_ALT); - stdin.setRawMode?.(false); - }; - const clampViewport = () => { - selected = Math.max(0, Math.min(Math.max(0, rows.length - 1), selected)); - const height = Math.max(1, terminalHeight() - 1); - if (selected < top) top = selected; - if (selected >= top + height) top = selected - height + 1; - top = Math.max(0, Math.min(top, Math.max(0, rows.length - height))); - }; - const render = () => { - if (!active) return; - clampViewport(); - const width = Math.max(1, terminalWidth()); - const height = Math.max(1, terminalHeight() - 1); - const visible = rows.slice(top, top + height); - const color = resolveHistoryColor({ - mode: bootstrap.input.color, - stdoutIsTTY: true, - env: process.env, - }); - const lines = visible.map((row, offset) => { - const isSelected = top + offset === selected; - const text = projectHistoryRow(row, { - ascii: bootstrap.input.ascii || process.env.TERM === "dumb", - color: color && !isSelected, - theme, - width, - }); - return isSelected && color - ? `${background(theme.selectedHunk)}${foreground(theme.text)}${text}\x1b[0m` - : isSelected - ? `\x1b[7m${text}\x1b[0m` - : text; - }); - while (lines.length < height) lines.push(""); - const footer = search - ? `/${search}` - : notice || - `↑↓/jk move / search n/N match y copy enter open q quit${historyDone ? "" : " ↓ load more"}`; - const footerText = fitText(footer, width, "…"); - const styledFooter = color - ? `${background(theme.panelAlt)}${foreground(theme.muted)}${footerText}\x1b[0m` - : `\x1b[7m${footerText}\x1b[0m`; - stdout.write(`\x1b[H\x1b[2J${lines.join("\n")}\n${styledFooter}`); - }; - const findMatch = async (direction: 1 | -1) => { - if (!search || rows.length === 0) return; - await loadAll(); - const needle = search.toLocaleLowerCase(); - for (let step = 1; step <= rows.length; step += 1) { - const index = (selected + direction * step + rows.length) % rows.length; - const commit = rows[index]!.commit; - const haystack = [ - commit.revisionId, - commit.displayId, - commit.subject, - commit.body ?? "", - commit.authorName, - commit.authorEmail ?? "", - ...commit.decorations.map((entry) => entry.label), - ] - .join(" ") - .toLocaleLowerCase(); - if (haystack.includes(needle)) { - selected = index; - notice = ""; - return; - } - } - notice = `No match for ${sanitizeTerminalLine(search)}`; - }; - const editSearch = async () => { - let draft = search; - for (;;) { - search = draft; - render(); - const key = await input.next(); - if (key === "\r" || key === "\n") { - search = draft; - await findMatch(1); - return; - } - if (key === "\x1b") return; - if (key === "\x03") { - cleanup(); - return; - } - if (key === "\x7f") draft = Array.from(draft).slice(0, -1).join(""); - else if (/^[^\x00-\x1f\x7f]+$/u.test(key)) draft += key; - } - }; - /** Open one provider-planned review while yielding terminal ownership. */ - const openRowReview = async (row: HistoryGraphRow) => { - const reviewAction = await bootstrap.planReview(row.commit); - input.discardPending(); - leaveTerminal(); - const code = await openCommitReview(bootstrap, reviewAction); - if (!stopped) enterTerminal(); - notice = code === 0 ? "" : `Could not open ${row.commit.displayId}`; - }; - const cleanup = () => { - if (stopped) return; - stopped = true; - abort.abort(new Error("History browser stopped.")); - leaveTerminal(); - }; - const onResize = () => { - if (active) render(); - }; - const stopForSignal = (exitCode: number) => { - cleanup(); - process.exitCode = exitCode; - input.close(); - }; - const onInterrupt = () => stopForSignal(130); - const onHangup = () => stopForSignal(129); - const onTerminate = () => stopForSignal(143); - - process.once("SIGINT", onInterrupt); - process.once("SIGHUP", onHangup); - process.once("SIGTERM", onTerminate); - stdout.on("resize", onResize); - try { - await loadMore(); - if (stopped) return; - enterTerminal(); - render(); - while (!stopped) { - const key = await input.next(); - const height = Math.max(1, terminalHeight() - 1); - if (key === "q" || key === "\x03") break; - if (key === "\x1b[B" || key === "j") { - if (selected + 1 >= rows.length && !historyDone) await loadMore(); - selected += 1; - } else if (key === "\x1b[A" || key === "k") selected -= 1; - else if (key === "\x1b[6~") { - while (selected + height >= rows.length && !historyDone) await loadMore(); - selected += height; - } else if (key === "\x1b[5~") selected -= height; - else if (["\x1b[H", "\x1b[1~", "\x1bOH", "g"].includes(key)) selected = 0; - else if (["\x1b[F", "\x1b[4~", "\x1bOF", "G"].includes(key)) { - await loadAll(); - selected = rows.length - 1; - } else if (key === "/") await editSearch(); - else if (key === "n") await findMatch(1); - else if (key === "N") await findMatch(-1); - else if (key === "y" && rows[selected]) { - stdout.write( - `\x1b]52;c;${Buffer.from(rows[selected]!.commit.revisionId).toString("base64")}\x07`, - ); - notice = `Copied ${rows[selected]!.commit.displayId}`; - } else if ((key === "\r" || key === "\n") && rows[selected]) { - await openRowReview(rows[selected]!); - if (stopped) break; - } else { - const mouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(key); - if (mouse) { - const button = Number(mouse[1]); - const screenColumn = Number(mouse[2]) - 1; - const screenRow = Number(mouse[3]) - 1; - const visibleCount = Math.min(height, rows.length - top); - if (button === 64) selected -= 3; - else if (button === 65) { - if (selected + 3 >= rows.length && !historyDone) await loadMore(); - selected += 3; - } else if ( - button === 0 && - mouse[4] === "M" && - screenRow >= 0 && - screenRow < visibleCount - ) { - const index = top + screenRow; - const row = rows[index]!; - selected = index; - const idBounds = getHistoryCommitIdBounds( - row, - bootstrap.input.ascii || process.env.TERM === "dumb", - ); - const clickedCommitId = screenColumn >= idBounds.start && screenColumn < idBounds.end; - const now = Date.now(); - if (clickedCommitId || (lastClick.index === index && now - lastClick.at < 400)) { - await openRowReview(row); - } - lastClick = { index, at: now }; - } - } - } - render(); - } - } catch (error) { - if (!stopped) throw error; - } finally { - cleanup(); - input.close(); - stdout.off("resize", onResize); - process.off("SIGINT", onInterrupt); - process.off("SIGHUP", onHangup); - process.off("SIGTERM", onTerminate); - await bootstrap.close(); - } -} +/** Preserve the original import seam while interactive history moves into the log feature folder. */ +export { historyReviewArgs } from "../log/reviewLaunch"; +export { runInteractiveLog as runInteractiveHistory } from "../log/runInteractiveLog"; diff --git a/src/ui/history/runStaticHistory.test.ts b/src/ui/history/runStaticHistory.test.ts index d2e5a6f41..cd2b53214 100644 --- a/src/ui/history/runStaticHistory.test.ts +++ b/src/ui/history/runStaticHistory.test.ts @@ -28,6 +28,9 @@ function runtime(commits: HistoryCommit[], maxCount?: number) { async planReview(commit) { return { kind: "revision-show", revisionId: commit.revisionId }; }, + async reopenSource() { + return value.source; + }, source: { async read({ limit }) { reads += 1; diff --git a/src/ui/history/terminalInput.test.ts b/src/ui/history/terminalInput.test.ts deleted file mode 100644 index 0d18822e4..000000000 --- a/src/ui/history/terminalInput.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { PassThrough } from "node:stream"; -import { TerminalInputReader, TerminalInputTokenizer } from "./terminalInput"; - -describe("TerminalInputTokenizer", () => { - test("queues multiple navigation and action keys from one chunk", () => { - const tokenizer = new TerminalInputTokenizer(); - - expect(tokenizer.push("\x1b[B\r")).toEqual(["\x1b[B", "\r"]); - }); - - test("retains split CSI and mouse sequences until they are complete", () => { - const tokenizer = new TerminalInputTokenizer(); - - expect(tokenizer.push("\x1b[")).toEqual([]); - expect(tokenizer.push("B/fi")).toEqual(["\x1b[B", "/", "f", "i"]); - expect(tokenizer.push("\x1b[<0;12;")).toEqual([]); - expect(tokenizer.push("4Mq")).toEqual(["\x1b[<0;12;4M", "q"]); - }); - - test("preserves UTF-8 characters split across byte chunks", () => { - const tokenizer = new TerminalInputTokenizer(); - const bytes = Buffer.from("猫"); - - expect(tokenizer.push(bytes.subarray(0, 2))).toEqual([]); - expect(tokenizer.push(bytes.subarray(2))).toEqual(["猫"]); - }); - - test("flushes a standalone escape without consuming the next action", () => { - const tokenizer = new TerminalInputTokenizer(); - - expect(tokenizer.push("\x1b")).toEqual([]); - expect(tokenizer.hasStandaloneEscape()).toBe(true); - expect(tokenizer.flushStandaloneEscape()).toEqual(["\x1b"]); - expect(tokenizer.push("q")).toEqual(["q"]); - }); -}); - -describe("TerminalInputReader", () => { - test("cancels a temporary wait without losing restored input", async () => { - const stream = new PassThrough() as unknown as NodeJS.ReadStream; - const reader = new TerminalInputReader(stream); - const abort = new AbortController(); - const waiting = reader.next(abort.signal); - - abort.abort(new Error("stop waiting")); - await expect(waiting).rejects.toThrow("stop waiting"); - reader.prepend(["j", "q"]); - expect(await reader.next()).toBe("j"); - expect(await reader.next()).toBe("q"); - reader.close(); - }); -}); diff --git a/src/ui/history/terminalInput.ts b/src/ui/history/terminalInput.ts deleted file mode 100644 index 52c5b395c..000000000 --- a/src/ui/history/terminalInput.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { StringDecoder } from "node:string_decoder"; - -const ESCAPE = "\x1b"; - -/** Splits raw terminal bytes into complete key and mouse tokens across arbitrary chunks. */ -export class TerminalInputTokenizer { - private readonly decoder = new StringDecoder("utf8"); - private buffered = ""; - - /** Add one raw input chunk and return every complete token now available. */ - push(chunk: Buffer | string) { - this.buffered += typeof chunk === "string" ? chunk : this.decoder.write(chunk); - return this.takeCompleteTokens(); - } - - /** Return whether a lone Escape is waiting for a possible sequence suffix. */ - hasStandaloneEscape() { - return this.buffered === ESCAPE; - } - - /** Resolve a lone buffered Escape after the terminal's sequence grace period. */ - flushStandaloneEscape() { - if (!this.hasStandaloneEscape()) return []; - this.buffered = ""; - return [ESCAPE]; - } - - /** Flush decoder state and expose any remaining input when the stream closes. */ - finish() { - this.buffered += this.decoder.end(); - const tokens = this.takeCompleteTokens(); - if (this.buffered) { - tokens.push(...Array.from(this.buffered)); - this.buffered = ""; - } - return tokens; - } - - /** Consume complete characters, CSI sequences, and SS3 sequences from the buffer. */ - private takeCompleteTokens() { - const tokens: string[] = []; - while (this.buffered) { - if (!this.buffered.startsWith(ESCAPE)) { - const token = String.fromCodePoint(this.buffered.codePointAt(0)!); - tokens.push(token); - this.buffered = this.buffered.slice(token.length); - continue; - } - - if (this.buffered.length === 1) break; - const prefix = this.buffered[1]; - if (prefix === "[") { - let finalIndex = -1; - for (let index = 2; index < this.buffered.length; index += 1) { - const code = this.buffered.charCodeAt(index); - if (code >= 0x40 && code <= 0x7e) { - finalIndex = index; - break; - } - } - if (finalIndex < 0) break; - tokens.push(this.buffered.slice(0, finalIndex + 1)); - this.buffered = this.buffered.slice(finalIndex + 1); - continue; - } - - if (prefix === "O") { - if (this.buffered.length < 3) break; - tokens.push(this.buffered.slice(0, 3)); - this.buffered = this.buffered.slice(3); - continue; - } - - // Hunk has no Alt-key bindings here, so preserve Escape as its own action. - tokens.push(ESCAPE); - this.buffered = this.buffered.slice(1); - } - return tokens; - } -} - -/** Queues tokenized terminal input while allowing terminal ownership to pause for child review. */ -export class TerminalInputReader { - private readonly tokenizer = new TerminalInputTokenizer(); - private readonly queued: string[] = []; - private readonly waiting: Array<{ - resolve: (token: string) => void; - reject: (error: Error) => void; - }> = []; - private escapeTimer: ReturnType | undefined; - private endedError: Error | undefined; - - constructor(private readonly stream: NodeJS.ReadStream) { - stream.on("data", this.onData); - stream.on("end", this.onEnd); - stream.on("error", this.onError); - } - - /** Resume delivery from the caller-owned terminal stream. */ - resume() { - this.stream.resume(); - } - - /** Pause delivery while another process owns the terminal. */ - pause() { - this.clearEscapeTimer(); - this.stream.pause(); - } - - /** Return the next complete token, allowing a temporary caller to cancel its wait. */ - next(signal?: AbortSignal) { - const token = this.queued.shift(); - if (token !== undefined) return Promise.resolve(token); - if (this.endedError) return Promise.reject(this.endedError); - if (signal?.aborted) { - return Promise.reject(signal.reason ?? new Error("Terminal input wait aborted.")); - } - return new Promise((resolve, reject) => { - const waiter = { - resolve: (value: string) => { - signal?.removeEventListener("abort", onAbort); - resolve(value); - }, - reject: (error: Error) => { - signal?.removeEventListener("abort", onAbort); - reject(error); - }, - }; - const onAbort = () => { - const index = this.waiting.indexOf(waiter); - if (index >= 0) this.waiting.splice(index, 1); - waiter.reject(signal?.reason ?? new Error("Terminal input wait aborted.")); - }; - this.waiting.push(waiter); - signal?.addEventListener("abort", onAbort, { once: true }); - }); - } - - /** Restore temporarily consumed tokens to the front of the input queue. */ - prepend(tokens: readonly string[]) { - this.queued.unshift(...tokens); - } - - /** Drop typeahead before transferring terminal ownership to a child process. */ - discardPending() { - this.queued.length = 0; - } - - /** Detach listeners and reject any pending read. */ - close(error = new Error("Terminal input closed.")) { - this.finish(error, false); - } - - private readonly onData = (chunk: Buffer | string) => { - this.clearEscapeTimer(); - this.enqueue(this.tokenizer.push(chunk)); - if (this.tokenizer.hasStandaloneEscape()) { - this.escapeTimer = setTimeout(() => { - this.escapeTimer = undefined; - this.enqueue(this.tokenizer.flushStandaloneEscape()); - }, 25); - this.escapeTimer.unref?.(); - } - }; - - private readonly onEnd = () => this.finish(new Error("Terminal input closed."), true); - private readonly onError = (error: Error) => this.finish(error, true); - - private enqueue(tokens: string[]) { - for (const token of tokens) { - const waiter = this.waiting.shift(); - if (waiter) waiter.resolve(token); - else this.queued.push(token); - } - } - - private finish(error: Error, flush: boolean) { - if (this.endedError) return; - this.clearEscapeTimer(); - if (flush) this.enqueue(this.tokenizer.finish()); - this.endedError = error; - this.stream.off("data", this.onData); - this.stream.off("end", this.onEnd); - this.stream.off("error", this.onError); - this.stream.pause(); - for (const waiter of this.waiting.splice(0)) waiter.reject(error); - } - - private clearEscapeTimer() { - if (this.escapeTimer) clearTimeout(this.escapeTimer); - this.escapeTimer = undefined; - } -} diff --git a/src/ui/history/types.ts b/src/ui/history/types.ts index 5f63a1f98..fa808bae7 100644 --- a/src/ui/history/types.ts +++ b/src/ui/history/types.ts @@ -3,6 +3,7 @@ import type { VcsHistorySource } from "../../core/vcs/types"; import type { ExtensionVcsHistoryCommit, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, NamedCustomThemeConfig, } from "../../extension-api/types"; @@ -15,6 +16,11 @@ export interface HistoryRuntime { repoRoot: string; notices: readonly string[]; customThemes: readonly NamedCustomThemeConfig[]; - planReview(commit: ExtensionVcsHistoryCommit): Promise; + planReview( + commit: ExtensionVcsHistoryCommit, + options?: ExtensionVcsHistoryReviewOptions, + ): Promise; + /** Replace the current provider cursor for an explicit interactive refresh. */ + reopenSource(signal?: AbortSignal): Promise; close(): Promise; } diff --git a/src/ui/hooks/useMenuController.test.tsx b/src/ui/hooks/useMenuController.test.tsx index 3d78b15cc..d5ab11cbf 100644 --- a/src/ui/hooks/useMenuController.test.tsx +++ b/src/ui/hooks/useMenuController.test.tsx @@ -159,6 +159,60 @@ describe("useMenuController", () => { } }); + test("skips disabled entries and refuses direct activation", async () => { + let controller!: ReturnType; + const ran: string[] = []; + const menus: AppMenus = { + commit: [ + { kind: "item", label: "Root parent", disabled: true, action: () => ran.push("disabled") }, + { kind: "item", label: "Open diff", action: () => ran.push("open") }, + ], + }; + + function Probe() { + controller = useMenuController(menus); + return null; + } + + const setup = await testRender(, { width: 80, height: 24 }); + try { + await act(async () => { + await setup.renderOnce(); + controller.openMenu("commit"); + }); + expect(controller.activeMenuItemIndex).toBe(1); + await act(async () => controller.activateCurrentMenuItem()); + expect(ran).toEqual(["open"]); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("keeps rapid menu switch and activation sequential before a rerender", async () => { + let controller!: ReturnType; + const ran: string[] = []; + const menus: AppMenus = { + file: [{ kind: "item", label: "Open", action: () => ran.push("file") }], + view: [{ kind: "item", label: "Theme", action: () => ran.push("view") }], + }; + function Probe() { + controller = useMenuController(menus); + return null; + } + const setup = await testRender(, { width: 80, height: 24 }); + try { + await act(async () => setup.renderOnce()); + await act(async () => { + controller.openMenu("file"); + controller.switchMenu(1); + controller.activateCurrentMenuItem(); + }); + expect(ran).toEqual(["view"]); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + test("cycling skips menus the session does not show", async () => { let controller!: ReturnType; diff --git a/src/ui/hooks/useMenuController.ts b/src/ui/hooks/useMenuController.ts index c1c949422..b5e874d7a 100644 --- a/src/ui/hooks/useMenuController.ts +++ b/src/ui/hooks/useMenuController.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { buildMenuSpecs, menuEntries, @@ -12,6 +12,14 @@ import { export function useMenuController(menus: AppMenus) { const [activeMenuId, setActiveMenuId] = useState(null); const [activeMenuItemIndex, setActiveMenuItemIndex] = useState(0); + // OpenTUI can deliver several decoded keys before React commits a render. + // These mirrors preserve sequential menu semantics within that input burst. + const liveMenuId = useRef(activeMenuId); + const liveItemIndex = useRef(activeMenuItemIndex); + const liveMenus = useRef(menus); + liveMenuId.current = activeMenuId; + liveItemIndex.current = activeMenuItemIndex; + liveMenus.current = menus; // Plain derivation, not a memo: `menus` is rebuilt every render on purpose // (hints and checked marks must stay live), so a memo keyed on it never hits. @@ -35,12 +43,16 @@ export function useMenuController(menus: AppMenus) { }, [activeMenuId, openMenuId]); const closeMenu = () => { + liveMenuId.current = null; setActiveMenuId(null); }; const openMenu = (menuId: MenuId) => { + const firstIndex = nextMenuItemIndex(menuEntries(liveMenus.current, menuId), -1, 1); + liveMenuId.current = menuId; + liveItemIndex.current = firstIndex; setActiveMenuId(menuId); - setActiveMenuItemIndex(nextMenuItemIndex(menuEntries(menus, menuId), -1, 1)); + setActiveMenuItemIndex(firstIndex); }; const toggleMenu = (menuId: MenuId) => { @@ -59,9 +71,10 @@ export function useMenuController(menus: AppMenus) { return; } + const currentMenuId = liveMenuId.current; const currentIndex = Math.max( 0, - openMenuId ? menuSpecs.findIndex((menu) => menu.id === openMenuId) : 0, + currentMenuId ? menuSpecs.findIndex((menu) => menu.id === currentMenuId) : 0, ); const nextIndex = (currentIndex + delta + menuSpecs.length) % menuSpecs.length; openMenu(menuSpecs[nextIndex]!.id); @@ -76,7 +89,7 @@ export function useMenuController(menus: AppMenus) { // so the visible highlight and Enter always agree on a real entry. const resolveItemIndex = (index: number) => { const entry = activeMenuEntries[index]; - return entry?.kind === "item" + return entry?.kind === "item" && !entry.disabled ? index : nextMenuItemIndex(activeMenuEntries, Math.min(index, activeMenuEntries.length) - 1, 1); }; @@ -87,19 +100,24 @@ export function useMenuController(menus: AppMenus) { // highlight visibly is, not from the stale position. useEffect(() => { if (openMenuId !== null && openMenuItemIndex !== activeMenuItemIndex) { + liveItemIndex.current = openMenuItemIndex; setActiveMenuItemIndex(openMenuItemIndex); } }, [openMenuId, openMenuItemIndex, activeMenuItemIndex]); const moveMenuItem = (delta: number) => { - setActiveMenuItemIndex((current) => - nextMenuItemIndex(activeMenuEntries, resolveItemIndex(current), delta), - ); + const menuId = liveMenuId.current; + const entries = menuId ? menuEntries(liveMenus.current, menuId) : []; + const next = nextMenuItemIndex(entries, liveItemIndex.current, delta); + liveItemIndex.current = next; + setActiveMenuItemIndex(next); }; const activateCurrentMenuItem = () => { - const entry = activeMenuEntries[openMenuItemIndex]; - if (!entry || entry.kind !== "item") { + const menuId = liveMenuId.current; + const entries = menuId ? menuEntries(liveMenus.current, menuId) : []; + const entry = entries[liveItemIndex.current]; + if (!entry || entry.kind !== "item" || entry.disabled) { return; } @@ -118,6 +136,7 @@ export function useMenuController(menus: AppMenus) { activeMenuWidth, activateCurrentMenuItem, closeMenu, + getActiveMenuId: () => liveMenuId.current, menuSpecs, moveMenuItem, openMenu, diff --git a/src/ui/hooks/useThemeSelectorController.test.tsx b/src/ui/hooks/useThemeSelectorController.test.tsx index f69dc8e3e..7a0253242 100644 --- a/src/ui/hooks/useThemeSelectorController.test.tsx +++ b/src/ui/hooks/useThemeSelectorController.test.tsx @@ -149,8 +149,10 @@ describe("useThemeSelectorController", () => { test("pointer and keyboard acceptance commit atomically and preserve notices", async () => { const notices: string[] = []; + const committed: string[] = []; const harness = await renderThemeSelectorController({ initialTheme: "github-dark-default", + onThemeCommitted: (themeId) => committed.push(themeId), onTransientNotice: (notice) => notices.push(notice), transparentBackground: false, }); @@ -175,6 +177,7 @@ describe("useThemeSelectorController", () => { expect(harness.controller.themeId).toBe(keyboardItem.id); expect(harness.controller.baseTheme.id).toBe(keyboardItem.id); expect(notices.at(-1)).toBe(`Theme: ${keyboardItem.label}`); + expect(committed).toEqual([pointerItem.id, keyboardItem.id]); } finally { await destroyController(harness.setup); } diff --git a/src/ui/hooks/useThemeSelectorController.ts b/src/ui/hooks/useThemeSelectorController.ts index 7db6223da..a41d6ed5e 100644 --- a/src/ui/hooks/useThemeSelectorController.ts +++ b/src/ui/hooks/useThemeSelectorController.ts @@ -16,6 +16,8 @@ export interface UseThemeSelectorControllerOptions { initialTheme?: string; initialThemeMode?: TerminalThemeMode | null; onTransientNotice: (text: string) => void; + /** Observe committed choices so a remounting surface can retain them. */ + onThemeCommitted?: (themeId: string) => void; transparentBackground: boolean; } @@ -25,6 +27,7 @@ export function useThemeSelectorController({ initialTheme, initialThemeMode, onTransientNotice, + onThemeCommitted, transparentBackground, }: UseThemeSelectorControllerOptions) { // Startup detection is launch state. Soft bootstrap reloads may replace the @@ -177,9 +180,10 @@ export function useThemeSelectorController({ previewThemeId: null, selectedThemeId: item.id, })); + onThemeCommitted?.(item.id); onTransientNotice(`Theme: ${item.label}`); }, - [onTransientNotice], + [onThemeCommitted, onTransientNotice], ); /** Commit one pointer-selected item when its current catalog entry is valid. */ diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index 0614b6c00..6a48df299 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -137,7 +137,7 @@ export function buildAppMenus({ showMenuBar, wrapLines, }: BuildAppMenusOptions): AppMenus { - const specs: Record, MenuEntrySpec[]> = { + const specs: Record, MenuEntrySpec[]> = { file: [ { commandId: "hunk.app.toggleFocusArea", label: "Toggle files/filter focus" }, { commandId: "hunk.review.focusFilter", label: "Focus filter" }, diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 1292f255d..0a2151f6f 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -124,11 +124,11 @@ describe("ui helpers", () => { ]); }); - test("nextMenuItemIndex skips separators in both directions", () => { + test("nextMenuItemIndex skips separators and disabled items in both directions", () => { const entries: MenuEntry[] = [ { kind: "separator" }, { kind: "item", label: "One", action: () => {} }, - { kind: "separator" }, + { kind: "item", label: "Unavailable", disabled: true, action: () => {} }, { kind: "item", label: "Two", action: () => {} }, ]; diff --git a/src/ui/log/LogApp.tsx b/src/ui/log/LogApp.tsx new file mode 100644 index 000000000..43c275810 --- /dev/null +++ b/src/ui/log/LogApp.tsx @@ -0,0 +1,528 @@ +import type { KeyEvent, MouseEvent as TuiMouseEvent } from "@opentui/core"; +import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; +import { basename } from "node:path"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { HelpDialog } from "../components/chrome/HelpDialog"; +import { MenuBar } from "../components/chrome/MenuBar"; +import { MenuDropdown } from "../components/chrome/MenuDropdown"; +import type { AppMenus, MenuEntry } from "../components/chrome/menu"; +import { ThemeSelectorDialog } from "../components/chrome/ThemeSelectorDialog"; +import { useMenuController } from "../hooks/useMenuController"; +import { useThemeSelectorController } from "../hooks/useThemeSelectorController"; +import { fitText } from "../lib/text"; +import { formatHistoryDecorations, renderHistoryGraph } from "../history/staticProjection"; +import type { HistoryRuntime } from "../history/types"; +import type { LogController } from "./controller"; +import { LOG_HELP_SECTIONS } from "./logHelp"; +import { + isLogCommandEnabled, + logCommand, + logCommandHint, + matchLogCommand, + type LogCommandId, +} from "./commands"; +import { ParentSelectorDialog } from "./ParentSelectorDialog"; + +export type LogAppOutcome = + | { kind: "quit"; exitCode?: number } + | { kind: "open-review"; action: ExtensionVcsHistoryReviewAction; themeId: string }; + +/** Render the bounded history list inside Hunk's shared desktop chrome. */ +export function LogApp({ + controller, + runtime, + onOutcome, +}: { + controller: LogController; + runtime: HistoryRuntime; + onOutcome: (outcome: LogAppOutcome) => void; +}) { + const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot); + const terminal = useTerminalDimensions(); + const renderer = useRenderer(); + const [showHelp, setShowHelp] = useState(false); + const [parentSelectorIndex, setParentSelectorIndex] = useState(null); + const [transientNotice, setTransientNotice] = useState(""); + const lastClick = useRef({ index: -1, at: 0 }); + const themeController = useThemeSelectorController({ + customThemes: runtime.customThemes, + initialTheme: snapshot.themeId, + initialThemeMode: renderer.themeMode, + onTransientNotice: setTransientNotice, + onThemeCommitted: (id) => controller.setTheme(id), + transparentBackground: false, + }); + const theme = themeController.activeTheme; + const selectedRow = snapshot.rows[snapshot.selected]; + const detailHeight = + snapshot.presentation.format === "medium" && selectedRow && terminal.height >= 9 ? 5 : 0; + const viewportHeight = Math.max(1, terminal.height - 2 - detailHeight); + + const copySelected = () => { + const currentRow = controller.getSelectedRow(); + if (!currentRow) return; + if (renderer.isOsc52Supported?.() && typeof renderer.copyToClipboardOSC52 === "function") { + renderer.copyToClipboardOSC52(currentRow.commit.revisionId); + setTransientNotice(`Copied ${currentRow.commit.displayId}`); + } else { + setTransientNotice("Clipboard is unavailable in this terminal."); + } + }; + const openSelected = async (parentRevisionId?: string) => { + const planned = controller.planSelectedReview(parentRevisionId); + if (!planned) return; + try { + onOutcome({ + kind: "open-review", + action: await planned, + themeId: themeController.themeId, + }); + } catch (error) { + controller.setNotice(error instanceof Error ? error.message : String(error)); + } + }; + + useEffect(() => { + if (!transientNotice) return; + const timeout = setTimeout(() => setTransientNotice(""), 2500); + return () => clearTimeout(timeout); + }, [transientNotice]); + + const clearTransientNotice = () => setTransientNotice(""); + const executeCommand = (id: LogCommandId, exitCode?: number) => { + clearTransientNotice(); + if (!isLogCommandEnabled(id, controller.getSnapshot())) return; + switch (id) { + case "open": + void openSelected(); + break; + case "copy": + copySelected(); + break; + case "refresh": + void controller.refresh(); + break; + case "quit": + onOutcome({ kind: "quit", ...(exitCode === undefined ? {} : { exitCode }) }); + break; + case "theme": + themeController.openThemeSelector(); + break; + case "format-medium": + controller.setFormat("medium"); + break; + case "format-compact": + controller.setFormat("compact"); + break; + case "toggle-graph": + controller.togglePresentation("graph"); + break; + case "toggle-unicode": + controller.togglePresentation("unicode"); + break; + case "toggle-author": + controller.togglePresentation("author"); + break; + case "toggle-date": + controller.togglePresentation("date"); + break; + case "toggle-decorations": + controller.togglePresentation("decorations"); + break; + case "previous": + void controller.move(-1, viewportHeight); + break; + case "next": + void controller.move(1, viewportHeight); + break; + case "page-up": + void controller.page(-1, viewportHeight); + break; + case "page-down": + void controller.page(1, viewportHeight); + break; + case "first": + void controller.first(viewportHeight); + break; + case "last": + void controller.last(viewportHeight); + break; + case "search": + controller.beginSearch(); + break; + case "next-match": + void controller.findMatch(1, viewportHeight); + break; + case "previous-match": + void controller.findMatch(-1, viewportHeight); + break; + case "open-first-parent": { + const parent = controller.getSelectedRow()?.commit.parentRevisionIds[0]; + if (parent) void openSelected(parent); + break; + } + case "open-parent": + setParentSelectorIndex(0); + break; + case "help": + setShowHelp(true); + break; + case "about": + setTransientNotice("Hunk · terminal-native code review"); + break; + } + }; + const commandItem = ( + id: LogCommandId, + options: Pick, "checked"> = {}, + ): Extract => { + const definition = logCommand(id); + return { + kind: "item", + commandId: `hunk.log.${id}`, + label: definition.label, + ...(logCommandHint(id) ? { hint: logCommandHint(id) } : {}), + disabled: !isLogCommandEnabled(id, snapshot), + action: () => executeCommand(id), + ...options, + }; + }; + const menus: AppMenus = { + file: [ + commandItem("open"), + commandItem("copy"), + commandItem("refresh"), + { kind: "separator" }, + commandItem("quit"), + ], + view: [ + commandItem("theme"), + { kind: "separator" }, + commandItem("format-medium", { checked: snapshot.presentation.format === "medium" }), + commandItem("format-compact", { checked: snapshot.presentation.format === "compact" }), + { kind: "separator" }, + commandItem("toggle-graph", { checked: snapshot.presentation.graph }), + commandItem("toggle-unicode", { checked: snapshot.presentation.unicode }), + commandItem("toggle-author", { checked: snapshot.presentation.author }), + commandItem("toggle-date", { checked: snapshot.presentation.date }), + commandItem("toggle-decorations", { checked: snapshot.presentation.decorations }), + ], + navigate: [ + commandItem("previous"), + commandItem("next"), + commandItem("page-up"), + commandItem("page-down"), + commandItem("first"), + commandItem("last"), + { kind: "separator" }, + commandItem("search"), + commandItem("next-match"), + commandItem("previous-match"), + ], + commit: [ + commandItem("open"), + commandItem("copy"), + { kind: "separator" }, + commandItem("open-first-parent"), + commandItem("open-parent"), + ], + help: [commandItem("help"), commandItem("about")], + }; + const menu = useMenuController(menus); + + useEffect(() => { + void controller.loadMore(); + }, [controller]); + useEffect(() => { + controller.clampViewport(viewportHeight); + if (snapshot.top + viewportHeight + 8 >= snapshot.rows.length && !snapshot.historyDone) { + void controller.loadMore(); + } + }, [controller, snapshot.historyDone, snapshot.rows.length, snapshot.top, viewportHeight]); + + useKeyboard((key: KeyEvent) => { + clearTransientNotice(); + const consume = () => { + key.preventDefault(); + key.stopPropagation(); + }; + const name = key.name; + const sequence = key.sequence ?? ""; + if (parentSelectorIndex !== null) { + const parents = controller.getSelectedRow()?.commit.parentRevisionIds ?? []; + if (name === "escape") setParentSelectorIndex(null); + else if (name === "up") + setParentSelectorIndex((parentSelectorIndex - 1 + parents.length) % parents.length); + else if (name === "down" || name === "tab") + setParentSelectorIndex( + (parentSelectorIndex + (key.shift ? -1 : 1) + parents.length) % parents.length, + ); + else if (name === "return" || name === "enter") { + const parent = parents[parentSelectorIndex]; + setParentSelectorIndex(null); + if (parent) void openSelected(parent); + } else return; + consume(); + return; + } + if (themeController.themeSelectorOpen) { + if (name === "escape") themeController.closeThemeSelector(); + else if (name === "up") themeController.moveThemeSelector(-1); + else if (name === "down" || name === "tab") + themeController.moveThemeSelector(key.shift ? -1 : 1); + else if (name === "return" || name === "enter") themeController.acceptThemeSelector(); + else return; + consume(); + return; + } + if (showHelp) { + if (name === "escape" || name === "q" || sequence === "q") setShowHelp(false); + else return; + consume(); + return; + } + if (menu.getActiveMenuId()) { + if (name === "escape") menu.closeMenu(); + else if (name === "left") menu.switchMenu(-1); + else if (name === "right" || name === "tab") menu.switchMenu(1); + else if (name === "up") menu.moveMenuItem(-1); + else if (name === "down") menu.moveMenuItem(1); + else if (name === "return" || name === "enter") menu.activateCurrentMenuItem(); + else { + const command = matchLogCommand(key); + if (!command) return; + menu.closeMenu(); + executeCommand(command, key.ctrl && key.name === "c" ? 130 : undefined); + } + consume(); + return; + } + if (snapshot.searchEditing) { + if ((key.ctrl && name === "c") || name === "escape") controller.cancelSearch(); + else if (name === "return" || name === "enter") + void controller.finishSearch(1, viewportHeight); + else if (name === "backspace") controller.backspaceSearch(); + else if (/^[^\x00-\x1f\x7f]+$/u.test(sequence)) controller.appendSearch(sequence); + else return; + consume(); + return; + } + if (name === "f10") { + menu.openMenu("file"); + consume(); + return; + } + const command = matchLogCommand(key); + if (!command) return; + executeCommand(command, key.ctrl && key.name === "c" ? 130 : undefined); + consume(); + }); + + const visible = snapshot.rows.slice(snapshot.top, snapshot.top + viewportHeight); + const rowWidth = Math.max(1, terminal.width - 2); + const statusHint = terminal.width >= 48 ? "↑↓ move · Enter open · / search · F10 menu" : ""; + const statusTextWidth = Math.max(1, terminal.width - (statusHint ? 42 : 2)); + return ( + + { + if (menu.activeMenuId) menu.openMenu(id); + }} + onToggleMenu={menu.toggleMenu} + /> + { + const direction = event.scroll?.direction; + if (direction === "up") controller.move(-3, viewportHeight); + else if (direction === "down") controller.move(3, viewportHeight); + }} + > + {visible.map((row, offset) => { + const index = snapshot.top + offset; + const selected = index === snapshot.selected; + const graph = snapshot.presentation.graph + ? `${renderHistoryGraph(row, !snapshot.presentation.unicode)} ` + : ""; + const decorations = snapshot.presentation.decorations + ? formatHistoryDecorations(row) + : ""; + const author = snapshot.presentation.author + ? ` ${sanitizeTerminalLine(row.commit.authorName)}` + : ""; + const date = snapshot.presentation.date + ? ` ${sanitizeTerminalLine(row.commit.authoredAt).slice(0, 10)}` + : ""; + const subject = fitText( + `${sanitizeTerminalLine(row.commit.subject)}${decorations}${author}${date}`, + Math.max(1, rowWidth - graph.length - row.commit.displayId.length - 2), + ); + return ( + { + clearTransientNotice(); + const now = Date.now(); + const shouldOpen = + lastClick.current.index === index && now - lastClick.current.at < 400; + void controller.select(index, viewportHeight).then(() => { + if (shouldOpen) void openSelected(); + }); + lastClick.current = { index, at: now }; + }} + > + {graph ? {graph} : null} + { + event.stopPropagation(); + clearTransientNotice(); + void controller.select(index, viewportHeight).then(() => openSelected()); + }} + > + {row.commit.displayId} + + {` ${subject}`} + + ); + })} + + {detailHeight && selectedRow ? ( + + {fitText(selectedRow.commit.subject, rowWidth)} + + {fitText( + sanitizeTerminalLine( + `${selectedRow.commit.authorName}${selectedRow.commit.authorEmail ? ` <${selectedRow.commit.authorEmail}>` : ""}`, + ), + rowWidth, + )} + + + {fitText(sanitizeTerminalLine(selectedRow.commit.authoredAt), rowWidth)} + + + {fitText( + sanitizeTerminalLine((selectedRow.commit.body ?? "").replaceAll("\n", " ")), + rowWidth, + )} + + + {fitText(sanitizeTerminalLine(selectedRow.commit.revisionId), rowWidth)} + + + ) : null} + + + {fitText( + snapshot.searchEditing + ? `/${snapshot.search}` + : transientNotice || + snapshot.notice || + `${runtime.providerName} · ${snapshot.rows.length}${snapshot.historyDone ? " commits" : "+ commits"}`, + statusTextWidth, + )} + + {statusHint ? {statusHint} : null} + + {menu.activeMenuId && menu.activeMenuSpec ? ( + ) => { + if (!entry.disabled) entry.action(); + menu.closeMenu(); + }} + /> + ) : null} + {parentSelectorIndex !== null && selectedRow ? ( + { + const parent = selectedRow.commit.parentRevisionIds[index]; + setParentSelectorIndex(null); + if (parent) void openSelected(parent); + }} + onClose={() => setParentSelectorIndex(null)} + onSelect={setParentSelectorIndex} + /> + ) : null} + {themeController.themeSelectorOpen ? ( + + ) : null} + {showHelp ? ( + setShowHelp(false)} + /> + ) : null} + + ); +} diff --git a/src/ui/log/ParentSelectorDialog.tsx b/src/ui/log/ParentSelectorDialog.tsx new file mode 100644 index 000000000..27d2b546b --- /dev/null +++ b/src/ui/log/ParentSelectorDialog.tsx @@ -0,0 +1,72 @@ +import type { MouseEvent as TuiMouseEvent } from "@opentui/core"; +import { fitText } from "../lib/text"; +import type { AppTheme } from "../themes"; +import { ModalFrame } from "../components/chrome/ModalFrame"; + +/** Let a caller choose one ordered opaque parent without interpreting provider syntax. */ +export function ParentSelectorDialog({ + parentRevisionIds, + selectedIndex, + terminalHeight, + terminalWidth, + theme, + onAccept, + onClose, + onSelect, +}: { + parentRevisionIds: readonly string[]; + selectedIndex: number; + terminalHeight: number; + terminalWidth: number; + theme: AppTheme; + onAccept: (index: number) => void; + onClose: () => void; + onSelect: (index: number) => void; +}) { + const width = Math.max(1, Math.min(70, terminalWidth - 2)); + const height = Math.max(1, Math.min(parentRevisionIds.length + 5, terminalHeight - 2)); + const bodyWidth = Math.max(1, width - 4); + const visibleRows = Math.max(1, height - 6); + const start = Math.max( + 0, + Math.min(parentRevisionIds.length - visibleRows, selectedIndex - Math.floor(visibleRows / 2)), + ); + return ( + + + {fitText("Enter/click open · Esc cancel", bodyWidth)} + + {parentRevisionIds.slice(start, start + visibleRows).map((parentId, offset) => { + const index = start + offset; + const selected = index === selectedIndex; + return ( + onSelect(index)} + onMouseUp={(event: TuiMouseEvent) => { + event.stopPropagation(); + onAccept(index); + }} + > + + {fitText(`${index + 1}. ${parentId}`, bodyWidth)} + + + ); + })} + + ); +} diff --git a/src/ui/log/commands.test.ts b/src/ui/log/commands.test.ts new file mode 100644 index 000000000..fc7cdb5cb --- /dev/null +++ b/src/ui/log/commands.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import type { KeyEvent } from "@opentui/core"; +import type { LogSnapshot } from "./controller"; +import { + buildLogHelpSections, + isLogCommandEnabled, + logCommandHint, + matchLogCommand, +} from "./commands"; + +const key = (name: string, sequence = name, ctrl = false) => ({ name, sequence, ctrl }) as KeyEvent; + +const snapshot = (parents: string[] = []): LogSnapshot => ({ + rows: [ + { + commit: { + revisionId: "commit", + displayId: "commit", + parentRevisionIds: parents, + subject: "subject", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }, + lane: 0, + lanesBefore: [], + lanesAfter: [], + cells: [], + parentLanes: [], + convergences: [], + }, + ], + selected: 0, + top: 0, + search: "", + searchEditing: false, + historyDone: true, + loading: false, + notice: "", + presentation: { + format: "compact", + graph: true, + unicode: true, + author: true, + date: true, + decorations: true, + }, +}); + +describe("log command authority", () => { + test("drives keyboard dispatch, menu hints, and help from one definition", () => { + expect(matchLogCommand(key("down", ""))).toBe("next"); + expect(matchLogCommand(key("x", "j"))).toBe("next"); + expect(matchLogCommand(key("c", "\x03", true))).toBe("quit"); + expect(logCommandHint("next")).toBe("↓ / j"); + expect(buildLogHelpSections().flatMap((section) => section.rows)).toContainEqual({ + keys: "↓ / j", + description: "next commit", + }); + }); + + test("derives parent and search enabled state from current snapshot", () => { + expect(isLogCommandEnabled("open-first-parent", snapshot())).toBe(false); + expect(isLogCommandEnabled("open-first-parent", snapshot(["p1", "p2"]))).toBe(true); + expect(isLogCommandEnabled("open-parent", snapshot(["p1", "p2"]))).toBe(true); + expect(isLogCommandEnabled("next-match", snapshot())).toBe(false); + }); +}); diff --git a/src/ui/log/commands.ts b/src/ui/log/commands.ts new file mode 100644 index 000000000..001bc3bba --- /dev/null +++ b/src/ui/log/commands.ts @@ -0,0 +1,224 @@ +import type { KeyEvent } from "@opentui/core"; +import type { HelpSection } from "../lib/helpContent"; +import type { MenuId } from "../components/chrome/menu"; +import type { LogSnapshot } from "./controller"; + +export type LogCommandId = + | "open" + | "copy" + | "refresh" + | "quit" + | "theme" + | "format-medium" + | "format-compact" + | "toggle-graph" + | "toggle-unicode" + | "toggle-author" + | "toggle-date" + | "toggle-decorations" + | "previous" + | "next" + | "page-up" + | "page-down" + | "first" + | "last" + | "search" + | "next-match" + | "previous-match" + | "open-first-parent" + | "open-parent" + | "help" + | "about"; + +type Shortcut = { token: string; display: string }; + +export interface LogCommandDefinition { + id: LogCommandId; + label: string; + menu: MenuId; + shortcuts?: readonly Shortcut[]; + helpSection?: "Navigation" | "Commit" | "Application"; +} + +/** Define log labels and bindings once for keyboard dispatch, menus, and help. */ +export const LOG_COMMANDS: readonly LogCommandDefinition[] = [ + { + id: "open", + label: "Open selected commit", + menu: "file", + shortcuts: [{ token: "name:enter", display: "Enter" }], + helpSection: "Commit", + }, + { + id: "copy", + label: "Copy commit ID", + menu: "file", + shortcuts: [{ token: "sequence:y", display: "y" }], + helpSection: "Commit", + }, + { + id: "refresh", + label: "Refresh history", + menu: "file", + shortcuts: [{ token: "sequence:r", display: "r" }], + helpSection: "Application", + }, + { + id: "quit", + label: "Quit", + menu: "file", + shortcuts: [ + { token: "sequence:q", display: "q" }, + { token: "ctrl:c", display: "Ctrl-C" }, + ], + helpSection: "Application", + }, + { id: "theme", label: "Theme…", menu: "view" }, + { id: "format-medium", label: "Medium format", menu: "view" }, + { id: "format-compact", label: "Compact format", menu: "view" }, + { id: "toggle-graph", label: "Graph", menu: "view" }, + { id: "toggle-unicode", label: "Unicode graph", menu: "view" }, + { id: "toggle-author", label: "Show author", menu: "view" }, + { id: "toggle-date", label: "Show date", menu: "view" }, + { id: "toggle-decorations", label: "Show decorations", menu: "view" }, + { + id: "previous", + label: "Previous commit", + menu: "navigate", + shortcuts: [ + { token: "name:up", display: "↑ / k" }, + { token: "sequence:k", display: "↑ / k" }, + ], + helpSection: "Navigation", + }, + { + id: "next", + label: "Next commit", + menu: "navigate", + shortcuts: [ + { token: "name:down", display: "↓ / j" }, + { token: "sequence:j", display: "↓ / j" }, + ], + helpSection: "Navigation", + }, + { + id: "page-up", + label: "Page up", + menu: "navigate", + shortcuts: [{ token: "name:pageup", display: "PgUp" }], + helpSection: "Navigation", + }, + { + id: "page-down", + label: "Page down", + menu: "navigate", + shortcuts: [{ token: "name:pagedown", display: "PgDn" }], + helpSection: "Navigation", + }, + { + id: "first", + label: "First commit", + menu: "navigate", + shortcuts: [ + { token: "name:home", display: "Home / g" }, + { token: "sequence:g", display: "Home / g" }, + ], + helpSection: "Navigation", + }, + { + id: "last", + label: "Last commit", + menu: "navigate", + shortcuts: [ + { token: "name:end", display: "End / G" }, + { token: "sequence:G", display: "End / G" }, + ], + helpSection: "Navigation", + }, + { + id: "search", + label: "Search…", + menu: "navigate", + shortcuts: [{ token: "sequence:/", display: "/" }], + helpSection: "Navigation", + }, + { + id: "next-match", + label: "Next match", + menu: "navigate", + shortcuts: [{ token: "sequence:n", display: "n" }], + helpSection: "Navigation", + }, + { + id: "previous-match", + label: "Previous match", + menu: "navigate", + shortcuts: [{ token: "sequence:N", display: "N" }], + helpSection: "Navigation", + }, + { id: "open-first-parent", label: "Open first parent", menu: "commit" }, + { id: "open-parent", label: "Open parent…", menu: "commit" }, + { + id: "help", + label: "Keyboard shortcuts", + menu: "help", + shortcuts: [{ token: "sequence:?", display: "?" }], + helpSection: "Application", + }, + { id: "about", label: "About Hunk", menu: "help" }, +]; + +const BY_ID = new Map(LOG_COMMANDS.map((command) => [command.id, command])); + +/** Return the canonical definition for one command. */ +export function logCommand(id: LogCommandId) { + return BY_ID.get(id)!; +} + +/** Derive one menu/help hint from the same shortcuts used for dispatch. */ +export function logCommandHint(id: LogCommandId) { + return logCommand(id).shortcuts?.[0]?.display; +} + +/** Resolve a keyboard event to the canonical log command. */ +export function matchLogCommand(key: KeyEvent): LogCommandId | null { + const tokens = [ + key.ctrl ? `ctrl:${key.name}` : "", + `name:${key.name === "return" ? "enter" : key.name}`, + key.sequence ? `sequence:${key.sequence}` : "", + ]; + return ( + LOG_COMMANDS.find((command) => + command.shortcuts?.some((shortcut) => tokens.includes(shortcut.token)), + )?.id ?? null + ); +} + +/** Apply context-sensitive availability consistently to keyboard and menus. */ +export function isLogCommandEnabled(id: LogCommandId, snapshot: LogSnapshot) { + const selected = snapshot.rows[snapshot.selected]; + if (["open", "copy"].includes(id)) return Boolean(selected); + if (id === "previous" || id === "page-up" || id === "first") return snapshot.selected > 0; + if (id === "next" || id === "page-down" || id === "last") + return !(snapshot.historyDone && snapshot.selected >= snapshot.rows.length - 1); + if (id === "next-match" || id === "previous-match") return Boolean(snapshot.search); + if (id === "open-first-parent") return Boolean(selected?.commit.parentRevisionIds.length); + if (id === "open-parent") return (selected?.commit.parentRevisionIds.length ?? 0) > 1; + return true; +} + +/** Build log help from the same labels and bindings used by dispatch and menus. */ +export function buildLogHelpSections(): readonly HelpSection[] { + const order: NonNullable[] = [ + "Navigation", + "Commit", + "Application", + ]; + return order.map((title) => ({ + title, + rows: LOG_COMMANDS.filter((command) => command.helpSection === title).map((command) => ({ + keys: command.shortcuts?.[0]?.display ?? "", + description: command.label.toLocaleLowerCase(), + })), + })); +} diff --git a/src/ui/log/controller.test.ts b/src/ui/log/controller.test.ts new file mode 100644 index 000000000..00748a073 --- /dev/null +++ b/src/ui/log/controller.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import type { HistoryRuntime } from "../history/types"; +import { LogController } from "./controller"; + +function createRuntime(subjects = ["first", "second", "third"]) { + let cursor = 0; + let closeCount = 0; + const makeSource = () => ({ + async read({ limit }: { limit: number; signal?: AbortSignal }) { + const selected = subjects.slice(cursor, cursor + Math.min(limit, 2)); + cursor += selected.length; + return { + commits: selected.map((subject) => ({ + revisionId: subject, + displayId: subject.slice(0, 8), + parentRevisionIds: [], + subject, + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + })), + done: cursor >= subjects.length, + }; + }, + async close() {}, + }); + let source = makeSource(); + const runtime: HistoryRuntime = { + input: { + kind: "history", + color: "never", + format: "compact", + ascii: false, + interactive: true, + extensionsEnabled: false, + extensionPaths: [], + }, + source, + providerId: "test", + providerName: "Test", + repoRoot: "/repo", + notices: [], + customThemes: [], + async planReview(commit) { + return { kind: "revision-show", revisionId: commit.revisionId }; + }, + async reopenSource() { + cursor = 0; + source = makeSource(); + return source; + }, + async close() { + closeCount += 1; + }, + }; + return { runtime, closeCount: () => closeCount }; +} + +describe("LogController", () => { + test("loads bounded pages and retains navigation/search state", async () => { + const { runtime } = createRuntime(); + const controller = new LogController(runtime); + await controller.loadMore(); + expect(controller.getSnapshot().rows.map((row) => row.commit.subject)).toEqual([ + "first", + "second", + ]); + controller.move(1, 1); + expect(controller.getSnapshot().selected).toBe(1); + controller.setSearch(""); + controller.appendSearch("th"); + controller.appendSearch("ird"); + expect(controller.getSnapshot().search).toBe("third"); + controller.backspaceSearch(); + controller.appendSearch("d"); + await controller.findMatch(1); + expect(controller.getSnapshot().rows).toHaveLength(3); + expect(controller.getSnapshot().selected).toBe(2); + await controller.close(); + }); + + test("initializes format from CLI input and loads enough pages for navigation", async () => { + const { runtime } = createRuntime(["one", "two", "three", "four", "five"]); + runtime.input.format = "medium"; + const controller = new LogController(runtime); + expect(controller.getSnapshot().presentation.format).toBe("medium"); + await controller.loadMore(); + await controller.page(1, 4); + expect(controller.getSnapshot().selected).toBe(4); + expect(controller.getSnapshot().historyDone).toBe(true); + await controller.close(); + }); + + test("preserves rapid navigation targets while bounded continuation is loading", async () => { + const { runtime } = createRuntime(["one", "two", "three", "four"]); + const controller = new LogController(runtime); + await controller.loadMore(); + await Promise.all([controller.move(1, 1), controller.move(1, 1), controller.move(1, 1)]); + expect(controller.getSnapshot().selected).toBe(3); + await controller.close(); + }); + + test("search reveals its match and refresh preserves immutable selection viewport offset", async () => { + const { runtime } = createRuntime(["one", "two", "three", "four"]); + const controller = new LogController(runtime); + await controller.loadMore(); + await controller.select(2, 2); + expect(controller.getSnapshot().top).toBe(1); + controller.setSearch("four"); + await controller.findMatch(1, 2); + expect(controller.getSnapshot()).toMatchObject({ selected: 3, top: 2 }); + await controller.refresh(); + expect( + controller.getSnapshot().rows[controller.getSnapshot().selected]?.commit.revisionId, + ).toBe("four"); + expect(controller.getSnapshot().selected - controller.getSnapshot().top).toBe(1); + await controller.close(); + }); + + test("closes a replacement cursor when quit wins a refresh race", async () => { + let resolveReplacement!: (source: HistoryRuntime["source"]) => void; + let replacementCloseCount = 0; + let reopenSignal: AbortSignal | undefined; + const { runtime } = createRuntime(["one"]); + runtime.reopenSource = (signal) => { + reopenSignal = signal; + return new Promise((resolve) => { + resolveReplacement = resolve; + }); + }; + const controller = new LogController(runtime); + await controller.loadMore(); + const refresh = controller.refresh(); + await Promise.resolve(); + const close = controller.close(); + resolveReplacement({ + async read() { + return { commits: [], done: true }; + }, + async close() { + replacementCloseCount += 1; + }, + }); + await Promise.all([refresh, close]); + expect(reopenSignal?.aborted).toBe(true); + expect(replacementCloseCount).toBe(1); + }); + + test("refreshes through the provider-owned cursor factory and closes once", async () => { + const { runtime, closeCount } = createRuntime(["first"]); + const controller = new LogController(runtime); + await controller.loadMore(); + controller.setTheme("github-dark"); + await controller.refresh(); + expect(controller.getSnapshot().rows).toHaveLength(1); + expect(controller.getSnapshot().themeId).toBe("github-dark"); + await controller.close(); + await controller.close(); + expect(closeCount()).toBe(1); + }); +}); diff --git a/src/ui/log/controller.ts b/src/ui/log/controller.ts new file mode 100644 index 000000000..d6c55b25d --- /dev/null +++ b/src/ui/log/controller.ts @@ -0,0 +1,346 @@ +import { createHistoryLaneCheckpoint, planHistoryPage } from "../../core/history/lanePlanner"; +import type { HistoryGraphRow, HistoryLaneCheckpoint } from "../../core/history/types"; +import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import type { HistoryRuntime } from "../history/types"; + +export interface LogPresentation { + format: "compact" | "medium"; + graph: boolean; + unicode: boolean; + author: boolean; + date: boolean; + decorations: boolean; +} + +export interface LogSnapshot { + rows: readonly HistoryGraphRow[]; + selected: number; + top: number; + search: string; + searchEditing: boolean; + historyDone: boolean; + loading: boolean; + notice: string; + themeId?: string; + presentation: LogPresentation; +} + +/** Retain interactive log state independently of renderer mount/unmount cycles. */ +export class LogController { + private source: HistoryRuntime["source"]; + private checkpoint: HistoryLaneCheckpoint = createHistoryLaneCheckpoint(); + private listeners = new Set<() => void>(); + private generation = 0; + private abort = new AbortController(); + private loadingPromise: Promise | null = null; + private refreshPromise: Promise | null = null; + private navigationTarget: number | null = null; + private closed = false; + private viewportHeight = 1; + private noticeTimer: ReturnType | null = null; + private snapshot: LogSnapshot; + + constructor(private readonly runtime: HistoryRuntime) { + this.source = runtime.source; + this.snapshot = { + rows: [], + selected: 0, + top: 0, + search: "", + searchEditing: false, + historyDone: false, + loading: false, + notice: runtime.notices[0] ?? "", + themeId: runtime.input.theme, + presentation: { + format: runtime.input.format, + graph: true, + unicode: !runtime.input.ascii, + author: true, + date: true, + decorations: true, + }, + }; + } + + /** Return the immutable render snapshot. */ + getSnapshot = () => this.snapshot; + + /** Subscribe one mounted surface to controller changes. */ + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + private publish(patch: Partial) { + this.snapshot = { ...this.snapshot, ...patch }; + for (const listener of this.listeners) listener(); + } + + /** Load one bounded page, preserving symbolic graph state across page boundaries. */ + async loadMore() { + if (this.loadingPromise) return this.loadingPromise; + if (this.closed || this.snapshot.historyDone) return; + const generation = this.generation; + this.publish({ loading: true }); + const loading = (async () => { + try { + const page = await this.source.read({ limit: 256, signal: this.abort.signal }); + if (generation !== this.generation || this.closed) return; + if (!page.done && page.commits.length === 0) + throw new Error("VCS history returned an empty page before EOF."); + const planned = planHistoryPage(page.commits, this.checkpoint); + this.checkpoint = planned.checkpoint; + const rows = [...this.snapshot.rows, ...planned.rows]; + this.publish({ + rows, + historyDone: page.done, + notice: rows.length === 0 && page.done ? "No commits found." : this.snapshot.notice, + }); + } catch (error) { + if (!this.abort.signal.aborted && generation === this.generation) { + this.publish({ + historyDone: true, + notice: sanitizeTerminalLine(error instanceof Error ? error.message : String(error)), + }); + } + } finally { + if (generation === this.generation && !this.closed) this.publish({ loading: false }); + } + })(); + this.loadingPromise = loading; + try { + await loading; + } finally { + if (this.loadingPromise === loading) this.loadingPromise = null; + } + } + + /** Keep the selected row visible in a viewport of fixed-height compact rows. */ + clampViewport(height: number) { + const safeHeight = Math.max(1, height); + this.viewportHeight = safeHeight; + const selected = Math.max( + 0, + Math.min(Math.max(0, this.snapshot.rows.length - 1), this.snapshot.selected), + ); + let top = this.snapshot.top; + if (selected < top) top = selected; + if (selected >= top + safeHeight) top = selected - safeHeight + 1; + top = Math.max(0, Math.min(top, Math.max(0, this.snapshot.rows.length - safeHeight))); + if (selected !== this.snapshot.selected || top !== this.snapshot.top) + this.publish({ selected, top }); + } + + /** Select a target, loading bounded continuation pages until it exists or EOF is known. */ + async select(index: number, viewportHeight: number) { + this.clearNotice(); + const target = Math.max(0, index); + this.navigationTarget = target; + while (target >= this.snapshot.rows.length && !this.snapshot.historyDone && !this.closed) { + await this.loadMore(); + } + if (this.closed) return; + this.publish({ selected: Math.max(0, Math.min(this.snapshot.rows.length - 1, target)) }); + this.clampViewport(viewportHeight); + if (this.navigationTarget === target) this.navigationTarget = null; + if (target + viewportHeight >= this.snapshot.rows.length && !this.snapshot.historyDone) + void this.loadMore(); + } + + move(delta: number, viewportHeight: number) { + return this.select((this.navigationTarget ?? this.snapshot.selected) + delta, viewportHeight); + } + + page(delta: number, viewportHeight: number) { + return this.move(delta * Math.max(1, viewportHeight), viewportHeight); + } + + first(viewportHeight: number) { + return this.select(0, viewportHeight); + } + + async last(viewportHeight: number) { + while (!this.snapshot.historyDone && !this.closed) await this.loadMore(); + await this.select(this.snapshot.rows.length - 1, viewportHeight); + } + + /** Enter or update the focused search editor without filtering topology. */ + setSearch(search: string, editing = this.snapshot.searchEditing) { + this.publish({ search, searchEditing: editing }); + } + + appendSearch(text: string) { + this.publish({ search: this.snapshot.search + text }); + } + + backspaceSearch() { + this.publish({ search: Array.from(this.snapshot.search).slice(0, -1).join("") }); + } + + beginSearch() { + this.clearNotice(); + this.publish({ searchEditing: true }); + } + + cancelSearch() { + this.publish({ searchEditing: false }); + } + + async finishSearch(direction: 1 | -1 = 1, viewportHeight = this.viewportHeight) { + this.publish({ searchEditing: false }); + await this.findMatch(direction, viewportHeight); + } + + async findMatch(direction: 1 | -1, viewportHeight = this.viewportHeight) { + const needle = this.snapshot.search.toLocaleLowerCase(); + if (!needle) return; + while (!this.snapshot.historyDone && !this.closed) await this.loadMore(); + const rows = this.snapshot.rows; + for (let step = 1; step <= rows.length; step += 1) { + const index = (this.snapshot.selected + direction * step + rows.length) % rows.length; + const commit = rows[index]!.commit; + const haystack = [ + commit.revisionId, + commit.displayId, + commit.subject, + commit.body ?? "", + commit.authorName, + commit.authorEmail ?? "", + ...commit.decorations.map((entry) => entry.label), + ] + .join(" ") + .toLocaleLowerCase(); + if (haystack.includes(needle)) { + this.publish({ selected: index, notice: "" }); + this.clampViewport(viewportHeight); + return; + } + } + this.setNotice(`No match for ${this.snapshot.search}`); + } + + private clearNotice() { + if (this.noticeTimer) clearTimeout(this.noticeTimer); + this.noticeTimer = null; + if (this.snapshot.notice) this.publish({ notice: "" }); + } + + setNotice(notice: string) { + if (this.noticeTimer) clearTimeout(this.noticeTimer); + const safe = sanitizeTerminalLine(notice); + this.publish({ notice: safe }); + if (safe) { + this.noticeTimer = setTimeout(() => { + this.noticeTimer = null; + if (!this.closed && this.snapshot.notice === safe) this.publish({ notice: "" }); + }, 2500); + this.noticeTimer.unref?.(); + } + } + + setTheme(themeId: string) { + this.publish({ themeId }); + } + + togglePresentation(key: keyof Omit) { + this.publish({ + presentation: { ...this.snapshot.presentation, [key]: !this.snapshot.presentation[key] }, + }); + } + + setFormat(format: LogPresentation["format"]) { + this.publish({ presentation: { ...this.snapshot.presentation, format } }); + } + + /** Refresh the provider cursor while reconciling selection by immutable revision id. */ + async refresh() { + if (this.refreshPromise) return this.refreshPromise; + const refreshing = this.performRefresh(); + this.refreshPromise = refreshing; + try { + await refreshing; + } finally { + if (this.refreshPromise === refreshing) this.refreshPromise = null; + } + } + + private async performRefresh() { + if (this.closed) return; + const selectedId = this.snapshot.rows[this.snapshot.selected]?.commit.revisionId; + const viewportOffset = this.snapshot.selected - this.snapshot.top; + this.generation += 1; + const generation = this.generation; + this.abort.abort(); + await this.loadingPromise; + this.loadingPromise = null; + this.abort = new AbortController(); + let replacement: HistoryRuntime["source"]; + try { + replacement = await this.runtime.reopenSource(this.abort.signal); + } catch (error) { + if (!this.closed && generation === this.generation) { + this.setNotice(error instanceof Error ? error.message : String(error)); + } + return; + } + if (this.closed || generation !== this.generation) { + await replacement.close(); + return; + } + this.source = replacement; + this.checkpoint = createHistoryLaneCheckpoint(); + this.publish({ + rows: [], + selected: 0, + top: 0, + historyDone: false, + loading: false, + notice: "", + }); + await this.loadMore(); + if (selectedId) { + while ( + !this.snapshot.historyDone && + !this.snapshot.rows.some((row) => row.commit.revisionId === selectedId) + ) { + await this.loadMore(); + } + const index = this.snapshot.rows.findIndex((row) => row.commit.revisionId === selectedId); + if (index >= 0) { + const maxTop = Math.max(0, this.snapshot.rows.length - this.viewportHeight); + this.publish({ + selected: index, + top: Math.min(maxTop, Math.max(0, index - viewportOffset)), + }); + } + } + this.setNotice("History refreshed."); + } + + /** Ask the selected provider to describe the review without interpreting revision syntax. */ + getSelectedRow() { + return this.snapshot.rows[this.snapshot.selected]; + } + + planSelectedReview(parentRevisionId?: string): Promise | null { + const commit = this.getSelectedRow()?.commit; + return commit + ? this.runtime.planReview( + commit, + parentRevisionId === undefined ? undefined : { parentRevisionId }, + ) + : null; + } + + async close() { + if (this.closed) return; + this.closed = true; + this.generation += 1; + this.abort.abort(); + if (this.noticeTimer) clearTimeout(this.noticeTimer); + this.noticeTimer = null; + await this.runtime.close(); + } +} diff --git a/src/ui/log/logHelp.ts b/src/ui/log/logHelp.ts new file mode 100644 index 000000000..e950ef872 --- /dev/null +++ b/src/ui/log/logHelp.ts @@ -0,0 +1,4 @@ +import { buildLogHelpSections } from "./commands"; + +/** Describe only the canonical controls owned by the interactive log surface. */ +export const LOG_HELP_SECTIONS = buildLogHelpSections(); diff --git a/src/ui/log/reviewLaunch.ts b/src/ui/log/reviewLaunch.ts new file mode 100644 index 000000000..d0d60a39e --- /dev/null +++ b/src/ui/log/reviewLaunch.ts @@ -0,0 +1,41 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { resolveCurrentHunkCommand } from "../../core/process/relaunch"; +import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; +import type { HistoryRuntime } from "../history/types"; + +/** Convert a provider-owned review declaration into one option-safe child invocation. */ +export function historyReviewArgs(action: ExtensionVcsHistoryReviewAction) { + const payload = Buffer.from(JSON.stringify(action), "utf8").toString("base64url"); + return [action.kind === "revision-range" ? "diff" : "show", "--history-review", payload]; +} + +/** Run one provider-planned child Hunk review after the log renderer yields the terminal. */ +export async function launchHistoryReview( + runtime: HistoryRuntime, + action: ExtensionVcsHistoryReviewAction, + themeId?: string, +) { + const current = resolveCurrentHunkCommand(); + const extensionArgs = runtime.input.extensionPaths.flatMap((path) => [ + "--extension", + resolve(path), + ]); + const args = [ + ...current.args, + ...historyReviewArgs(action), + "--vcs", + runtime.providerId, + ...(themeId ? ["--theme", themeId] : []), + ...(runtime.input.extensionsEnabled ? extensionArgs : ["--no-extensions"]), + ]; + const child = spawn(current.command, args, { + cwd: runtime.repoRoot, + env: { ...process.env, HUNK_RETURN_TO_HISTORY: "1" }, + stdio: "inherit", + }); + return await new Promise((resolveExit, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolveExit(signal ? 1 : (code ?? 1))); + }); +} diff --git a/src/ui/log/runInteractiveLog.test.ts b/src/ui/log/runInteractiveLog.test.ts new file mode 100644 index 000000000..10159d1fe --- /dev/null +++ b/src/ui/log/runInteractiveLog.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import { logSignalExitCode } from "./runInteractiveLog"; + +describe("interactive log lifecycle", () => { + test("preserves conventional signal exit codes after cleanup", () => { + expect(logSignalExitCode("SIGINT")).toBe(130); + expect(logSignalExitCode("SIGHUP")).toBe(129); + expect(logSignalExitCode("SIGTERM")).toBe(143); + }); +}); diff --git a/src/ui/log/runInteractiveLog.tsx b/src/ui/log/runInteractiveLog.tsx new file mode 100644 index 000000000..e70f7b2dc --- /dev/null +++ b/src/ui/log/runInteractiveLog.tsx @@ -0,0 +1,128 @@ +import { createCliRenderer } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import { + installJobControlInterruptSupport, + installJobControlSuspendSupport, + type JobControlInterruptSupport, + type JobControlSuspendSupport, +} from "../../core/process/jobControl"; +import { shutdownSession } from "../../core/process/shutdown"; +import { HunkUserError } from "../../core/run/errors"; +import { + installTerminalDisconnectSupport, + type TerminalDisconnectSupport, +} from "../../core/process/terminal"; +import { LogApp, type LogAppOutcome } from "./LogApp"; +import { LogController } from "./controller"; +import { launchHistoryReview } from "./reviewLaunch"; +import type { HistoryRuntime } from "../history/types"; + +const LOG_SHUTDOWN_SIGNALS: NodeJS.Signals[] = + process.platform === "win32" + ? ["SIGINT", "SIGTERM", "SIGBREAK"] + : ["SIGINT", "SIGTERM", "SIGHUP"]; + +/** Translate terminal shutdown signals to conventional shell exit codes. */ +export function logSignalExitCode(signal: NodeJS.Signals) { + return signal === "SIGINT" ? 130 : signal === "SIGHUP" ? 129 : 143; +} + +/** Mount one OpenTUI log surface and resolve only after it requests quit or review. */ +async function mountLogSurface( + controller: LogController, + runtime: HistoryRuntime, + stdin: NodeJS.ReadStream, + stdout: NodeJS.WriteStream, +) { + const renderer = await createCliRenderer({ + stdin, + stdout, + useMouse: true, + screenMode: "alternate-screen", + exitOnCtrlC: false, + exitSignals: [], + openConsoleOnError: true, + }); + let root: ReturnType; + try { + root = createRoot(renderer); + } catch (error) { + renderer.destroy(); + throw error; + } + let settled = false; + let settle!: (outcome: LogAppOutcome) => void; + const outcome = new Promise((resolve) => { + settle = resolve; + }); + const finish = (value: LogAppOutcome) => { + if (settled) return; + settled = true; + settle(value); + }; + const requestQuit = () => finish({ kind: "quit" }); + const requestInterrupt = () => finish({ kind: "quit", exitCode: 130 }); + const signalHandlers = new Map void>( + LOG_SHUTDOWN_SIGNALS.map((signal) => [ + signal, + () => + finish({ + kind: "quit", + exitCode: logSignalExitCode(signal), + }), + ]), + ); + for (const [signal, handler] of signalHandlers) process.once(signal, handler); + let interrupt: JobControlInterruptSupport = { dispose: () => undefined }; + let suspend: JobControlSuspendSupport = { dispose: () => undefined }; + let disconnect: TerminalDisconnectSupport = { dispose: () => undefined }; + try { + interrupt = installJobControlInterruptSupport(renderer, requestInterrupt); + suspend = installJobControlSuspendSupport(renderer); + disconnect = installTerminalDisconnectSupport(stdin, requestQuit); + root.render(); + return await outcome; + } finally { + for (const [signal, handler] of signalHandlers) process.off(signal, handler); + interrupt.dispose(); + suspend.dispose(); + disconnect.dispose(); + shutdownSession({ root, renderer, exit: () => undefined }); + } +} + +/** Browse history in shared desktop chrome, yielding fully to each child review. */ +export async function runInteractiveLog( + runtime: HistoryRuntime, + { + stdin = process.stdin, + stdout = process.stdout, + }: { stdin?: NodeJS.ReadStream; stdout?: NodeJS.WriteStream } = {}, +) { + if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") { + await runtime.close(); + throw new HunkUserError("`hunk log --interactive` requires a terminal.", [ + "Use plain `hunk log` for pipes and redirected output.", + ]); + } + + const controller = new LogController(runtime); + try { + await controller.loadMore(); + for (;;) { + const outcome = await mountLogSurface(controller, runtime, stdin, stdout); + if (outcome.kind === "quit") { + if (outcome.exitCode !== undefined) process.exitCode = outcome.exitCode; + return; + } + try { + const code = await launchHistoryReview(runtime, outcome.action, outcome.themeId); + controller.setNotice(code === 0 ? "" : "Could not open the selected commit."); + } catch (error) { + controller.setNotice(error instanceof Error ? error.message : String(error)); + } + } + } finally { + await controller.close(); + } +} diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index 56b3e956b..c51de6834 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -39,6 +39,26 @@ function createHistoryRepo() { return cwd; } +/** Create a merge whose second-parent comparison exposes only the main-side file. */ +function createMergeHistoryRepo() { + const cwd = mkdtempSync(join(tmpdir(), "hunk-log-merge-pty-")); + tempDirs.push(cwd); + git(cwd, ["init", "-q"]); + writeFileSync(join(cwd, "base.ts"), "export const base = true;\n"); + git(cwd, ["add", "base.ts"]); + git(cwd, ["commit", "-qm", "Root"]); + git(cwd, ["checkout", "-qb", "side"]); + writeFileSync(join(cwd, "side.ts"), "export const side = true;\n"); + git(cwd, ["add", "side.ts"]); + git(cwd, ["commit", "-qm", "Side"]); + git(cwd, ["checkout", "-q", "master"]); + writeFileSync(join(cwd, "main.ts"), "export const main = true;\n"); + git(cwd, ["add", "main.ts"]); + git(cwd, ["commit", "-qm", "Main"]); + git(cwd, ["merge", "--no-ff", "-qm", "Merge side", "side"]); + return cwd; +} + afterEach(() => { harness.cleanup(); for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true }); @@ -59,11 +79,22 @@ describe("interactive hunk log", () => { timeout: 15_000, }); expect(history).toContain("First history commit"); - expect(history).toContain("enter open"); + expect(history).toContain("File View Navigate Commit Help"); + expect(history).toContain("Enter open"); + + // Mouse and keyboard share the same menu model and actions. + session.writeRaw("\x1b[<0;2;1M\x1b[<0;2;1m"); + await session.waitForText(/Open selected commit/, { timeout: 5_000 }); + await session.press("right"); + await session.press("enter"); + await session.waitForText(/Theme selector/, { timeout: 5_000 }); + await session.press("down"); + await session.press("enter"); + await session.waitForText(/Second history commit/, { timeout: 5_000 }); - // The first compact row starts with one graph cell plus two spaces, so x=4 - // lands inside its visible commit id. One press opens without a double-click. - session.writeRaw("\x1b[<0;5;1M"); + // The menu occupies row one; x=5 on the first history row lands inside + // its commit id and opens immediately without a double-click. + session.writeRaw("\x1b[<0;5;2M\x1b[<0;5;2m"); const review = await session.waitForText(/historyValue = 'second'/, { timeout: 15_000, }); @@ -73,10 +104,11 @@ describe("interactive hunk log", () => { const returned = await session.waitForText(/Second history commit/, { timeout: 15_000, }); - expect(returned).toContain("enter open"); + expect(returned).toContain("Enter open"); - // Terminals may coalesce rapid navigation and activation into one stdin chunk. - session.writeRaw("\x1b[B\r"); + // Clicking outside the id selects the second row without opening it. + session.writeRaw("\x1b[<0;50;3M\x1b[<0;50;3m"); + await session.press("enter"); const rootReview = await session.waitForText(/historyValue = 'first'/, { timeout: 15_000, }); @@ -84,11 +116,51 @@ describe("interactive hunk log", () => { await session.press("q"); await session.waitForText(/First history commit/, { timeout: 15_000 }); + // A command key closes an open menu and falls through to canonical dispatch. + await session.press("f10"); + await session.waitForText(/Open selected commit/, { timeout: 5_000 }); + session.writeRaw("k\r"); + await session.waitForText(/historyValue = 'second'/, { timeout: 15_000 }); + await session.press("q"); + await session.waitForText(/Second history commit/, { timeout: 15_000 }); + // Opening again without moving proves return restored the immutable-id selection. await session.press("enter"); - await session.waitForText(/historyValue = 'first'/, { timeout: 15_000 }); + await session.waitForText(/historyValue = 'second'/, { timeout: 15_000 }); await session.press("q"); - await session.waitForText(/First history commit/, { timeout: 15_000 }); + await session.waitForText(/Second history commit/, { timeout: 15_000 }); + await session.press("q"); + } finally { + session.close(); + } + }); + + test("opens a merge against the provider-selected parent", async () => { + const cwd = createMergeHistoryRepo(); + const session = await harness.launchHunk({ + args: ["log", "--interactive", "--color", "never", "--no-extensions"], + cwd, + cols: 100, + rows: 20, + }); + try { + await session.waitForText(/Merge side/, { timeout: 15_000 }); + await session.press("f10"); + await session.press("right"); + await session.press("right"); + await session.press("right"); + await session.waitForText(/Open parent/, { timeout: 5_000 }); + await session.press("down"); + await session.press("down"); + await session.press("down"); + await session.press("enter"); + await session.waitForText(/Open parent/, { timeout: 5_000 }); + await session.press("down"); + await session.press("enter"); + const review = await session.waitForText(/main\.ts/, { timeout: 15_000 }); + expect(review).not.toContain("side.ts"); + await session.press("q"); + await session.waitForText(/Merge side/, { timeout: 15_000 }); await session.press("q"); } finally { session.close(); From cae96cbf52d32849c39d24c06d9409edb102e801 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Fri, 4 Sep 2026 15:48:38 -0400 Subject: [PATCH 2/4] fix(ui): harden interactive history chrome --- docs/keybindings.md | 4 +- src/app/historyBootstrap.test.ts | 81 ++++++++++++++++++++++++ src/ui/App.tsx | 1 + src/ui/AppHost.file-views.test.tsx | 6 +- src/ui/components/chrome/MenuBar.tsx | 45 +++++++++++-- src/ui/components/chrome/menu.ts | 20 ++++++ src/ui/components/ui-components.test.tsx | 65 +++++++++++++++++++ src/ui/lib/ui-lib.test.ts | 16 +++++ src/ui/log/LogApp.tsx | 31 +++++++-- src/ui/log/ParentSelectorDialog.test.tsx | 40 ++++++++++++ src/ui/log/ParentSelectorDialog.tsx | 11 +++- src/ui/log/colorPolicy.test.ts | 22 +++++++ src/ui/log/colorPolicy.ts | 37 +++++++++++ src/ui/log/commands.test.ts | 3 + src/ui/log/commands.ts | 8 ++- src/ui/log/controller.test.ts | 14 ++++ src/ui/log/controller.ts | 2 +- src/ui/log/runInteractiveLog.tsx | 10 ++- test/pty/log-integration.test.ts | 70 ++++++++++++++++++-- 19 files changed, 462 insertions(+), 24 deletions(-) create mode 100644 src/app/historyBootstrap.test.ts create mode 100644 src/ui/log/ParentSelectorDialog.test.tsx create mode 100644 src/ui/log/colorPolicy.test.ts create mode 100644 src/ui/log/colorPolicy.ts diff --git a/docs/keybindings.md b/docs/keybindings.md index 47ce3d6d0..39e8e57e8 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -51,7 +51,9 @@ View includes Hunk's shared theme selector. It uses `Up`/`Down` or `j`/`k` to mo `g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `r` to refresh, `y` to copy the full commit id, `Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit id to open it immediately, click elsewhere on a row to select it, or double-click a row to open it. -Quitting the opened review returns to the retained history selection and viewport. +Quitting the opened review returns to the retained history selection and viewport. The Commit menu's +**Compare with first parent** and **Compare with parent…** actions compare the selected commit against +an ordered provider-owned parent; they do not navigate the history selection to that parent. | Command id | Does | Default keys | | ---------------------------------------------- | ---------------------------------------------- | ---------------------------- | diff --git a/src/app/historyBootstrap.test.ts b/src/app/historyBootstrap.test.ts new file mode 100644 index 000000000..976168daa --- /dev/null +++ b/src/app/historyBootstrap.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { HistoryCommandInput } from "../core/run/commandInputs"; +import type { VcsAdapter, VcsCatalog, VcsHistorySource } from "../core/vcs/types"; +import { loadHistoryBootstrap } from "./historyBootstrap"; + +const input: HistoryCommandInput = { + kind: "history", + color: "never", + format: "compact", + ascii: false, + interactive: true, + vcs: "test", + extensionsEnabled: false, + extensionPaths: [], +}; + +describe("history bootstrap cursor ownership", () => { + test("cancels refresh before opening and closes each active provider cursor once", async () => { + const cwd = mkdtempSync(join(tmpdir(), "hunk-history-bootstrap-")); + const configHome = mkdtempSync(join(tmpdir(), "hunk-history-config-")); + const closeCounts: number[] = []; + let opens = 0; + const makeSource = (): VcsHistorySource => { + const index = opens++; + closeCounts[index] = 0; + return { + async read() { + return { commits: [], done: true }; + }, + async close() { + closeCounts[index]! += 1; + }, + }; + }; + const adapter: VcsAdapter = { + id: "test", + name: "Test", + detect: () => ({ id: "test", repoRoot: cwd }), + operations: {}, + history: { + async open() { + return makeSource(); + }, + async planReview(commit) { + return { kind: "revision-show", revisionId: commit.revisionId }; + }, + }, + }; + const catalog: VcsCatalog = { + adapters: [adapter], + defaultAdapterId: "test", + reservedIds: new Set(["test"]), + }; + + try { + const bootstrap = await loadHistoryBootstrap({ + input, + cwd, + env: { ...process.env, XDG_CONFIG_HOME: configHome }, + baseVcsCatalog: catalog, + }); + const cancelled = new AbortController(); + cancelled.abort(); + await expect(bootstrap.reopenSource(cancelled.signal)).rejects.toThrow(); + expect(opens).toBe(1); + + await bootstrap.reopenSource(); + expect(opens).toBe(2); + expect(closeCounts).toEqual([1, 0]); + await bootstrap.close(); + await bootstrap.close(); + expect(closeCounts).toEqual([1, 1]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); + } + }); +}); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8cb01ce8d..c845e48a1 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1448,6 +1448,7 @@ export function App({ activeMenuSpec={activeMenuSpec} activeMenuWidth={activeMenuWidth} top={showMenuBar ? 1 : 0} + terminalHeight={terminal.height} terminalWidth={terminal.width} theme={baseTheme} onHoverItem={setActiveMenuItemIndex} diff --git a/src/ui/AppHost.file-views.test.tsx b/src/ui/AppHost.file-views.test.tsx index 2e032db09..266e6d016 100644 --- a/src/ui/AppHost.file-views.test.tsx +++ b/src/ui/AppHost.file-views.test.tsx @@ -429,7 +429,11 @@ describe("AppHost file views", () => { await act(async () => setup.mockInput.pressKey("F10")); await waitForFrame(setup, (frame) => frame.includes("Toggle files/filter focus")); - await act(async () => setup.mockInput.pressArrow("right")); + await act(async () => { + await setup.mockInput.pressArrow("right"); + // Height-clamped dropdowns window around the active item; wrap to the final action. + await setup.mockInput.pressArrow("up"); + }); const menu = await waitForFrame(setup, (frame) => frame.includes("Apply “Bulk preview” to all matching files"), ); diff --git a/src/ui/components/chrome/MenuBar.tsx b/src/ui/components/chrome/MenuBar.tsx index 653e1cb3a..fed171f30 100644 --- a/src/ui/components/chrome/MenuBar.tsx +++ b/src/ui/components/chrome/MenuBar.tsx @@ -1,6 +1,6 @@ import type { AppTheme } from "../../themes"; import { fitText } from "../../lib/text"; -import { menuBarTitleWidth, type MenuId, type MenuSpec } from "./menu"; +import { menuBarTitleWidth, responsiveMenuSpecs, type MenuId, type MenuSpec } from "./menu"; /** Render the top menu bar and the current changeset title. */ export function MenuBar({ @@ -20,9 +20,24 @@ export function MenuBar({ onHoverMenu: (menuId: MenuId) => void; onToggleMenu: (menuId: MenuId) => void; }) { - const visibleMenuSpecs = menuSpecs.filter( - (menu) => menu.left + menu.width <= Math.max(1, terminalWidth - 1), - ); + const responsive = responsiveMenuSpecs(menuSpecs, terminalWidth); + const visibleMenuSpecs = responsive.visible; + const hiddenMenuIds = new Set(responsive.hidden.map((menu) => menu.id)); + const activeHiddenIndex = responsive.hidden.findIndex((menu) => menu.id === activeMenuId); + const activeHiddenMenu = + activeHiddenIndex >= 0 ? responsive.hidden[activeHiddenIndex] : undefined; + const overflowTarget = + activeHiddenIndex >= 0 + ? responsive.hidden[(activeHiddenIndex + 1) % responsive.hidden.length] + : responsive.hidden[0]; + const overflowHoverTarget = activeHiddenMenu ?? responsive.hidden[0]; + const titleSpecs = + responsive.overflowLeft === null + ? visibleMenuSpecs + : [ + ...visibleMenuSpecs, + { id: "help" as const, left: responsive.overflowLeft, width: 3, label: "…" }, + ]; const title = visibleMenuSpecs.length === 0 ? "F10 menu" : topTitle; return ( // The outer row paints the app background so the bar keeps the same @@ -63,11 +78,31 @@ export function MenuBar({ ); })} + {overflowTarget && responsive.overflowLeft !== null ? ( + onToggleMenu(overflowTarget.id)} + onMouseOver={() => { + if (activeMenuId && overflowHoverTarget) onHoverMenu(overflowHoverTarget.id); + }} + > + + {" … "} + + + ) : null} {` ${fitText(title, menuBarTitleWidth(visibleMenuSpecs, terminalWidth))}`} + >{` ${fitText(title, menuBarTitleWidth(titleSpecs, terminalWidth))}`} diff --git a/src/ui/components/chrome/menu.ts b/src/ui/components/chrome/menu.ts index 63ba4579d..3191fa429 100644 --- a/src/ui/components/chrome/menu.ts +++ b/src/ui/components/chrome/menu.ts @@ -77,6 +77,26 @@ export function buildMenuSpecs(menus: AppMenus) { ); } +/** Fit a shared ordered menu model into one bar and retain hidden menus behind overflow. */ +export function responsiveMenuSpecs(menuSpecs: readonly MenuSpec[], terminalWidth: number) { + const rightEdge = Math.max(1, terminalWidth - 1); + const allVisible = menuSpecs.filter((menu) => menu.left + menu.width <= rightEdge); + if (allVisible.length === menuSpecs.length) { + return { visible: allVisible, hidden: [] as MenuSpec[], overflowLeft: null }; + } + + const overflowWidth = 3; + const visible = menuSpecs.filter((menu) => menu.left + menu.width + overflowWidth <= rightEdge); + const visibleIds = new Set(visible.map((menu) => menu.id)); + const hidden = menuSpecs.filter((menu) => !visibleIds.has(menu.id)); + const previous = visible.at(-1); + return { + visible, + hidden, + overflowLeft: previous ? previous.left + previous.width : 1, + }; +} + /** Find the next selectable menu item, skipping separators. */ export function nextMenuItemIndex(entries: MenuEntry[], currentIndex: number, delta: number) { if (entries.length === 0) { diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 553abd1ea..a26214f2c 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -3,6 +3,7 @@ import type { ScrollBoxRenderable } from "@opentui/core"; import { MouseButtons } from "@opentui/core/testing"; import { testRender } from "@opentui/react/test-utils"; import { act, createRef, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import type { MenuId } from "./chrome/menu"; import type { AppBootstrap } from "../../core/bootstrap"; import type { DiffFile } from "../../core/changeset/model"; import { createTestVcsAppBootstrap } from "../../../test/helpers/app-bootstrap"; @@ -31,6 +32,7 @@ const { ThemeSelectorDialog } = await import("./chrome/ThemeSelectorDialog"); const { AgentCard } = await import("./panes/AgentCard"); const { AgentInlineNote, measureAgentInlineNoteHeight } = await import("./panes/AgentInlineNote"); const { DiffPane, storedReviewNoteActions } = await import("./panes/DiffPane"); +const { MenuBar } = await import("./chrome/MenuBar"); const { MenuDropdown } = await import("./chrome/MenuDropdown"); const { StatusBar } = await import("./chrome/StatusBar"); const { DiffFileHeaderRow } = await import("./panes/DiffFileHeaderRow"); @@ -3471,6 +3473,69 @@ describe("UI components", () => { expect(frame).toContain("Second rationale."); }); + test("MenuBar renders a responsive overflow for hidden top menus", async () => { + const theme = resolveTheme("github-dark-default", null); + const frame = await captureFrame( + {}} + onToggleMenu={() => {}} + />, + 20, + 2, + ); + expect(frame).toContain("File"); + expect(frame).toContain("…"); + expect(frame).not.toContain("Navigate"); + }); + + test("MenuBar overflow cycles through every hidden menu by mouse", async () => { + const theme = resolveTheme("github-dark-default", null); + const toggled: string[] = []; + const clickOverflow = async (activeMenuId: MenuId | null) => { + const setup = await testRender( + {}} + onToggleMenu={(id) => toggled.push(id)} + />, + { width: 20, height: 2 }, + ); + try { + await act(async () => { + await setup.renderOnce(); + await setup.mockMouse.click(14, 0); + }); + } finally { + setup.renderer.destroy(); + } + }; + await clickOverflow(null); + await clickOverflow("navigate"); + await clickOverflow("commit"); + expect(toggled).toEqual(["navigate", "commit", "help"]); + }); + test("MenuDropdown renders checked items and key hints", async () => { const theme = resolveTheme("github-dark-default", null); const frame = await captureFrame( diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 0a2151f6f..79be9ed18 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -9,6 +9,7 @@ import { menuBoxHeight, menuWidth, nextMenuItemIndex, + responsiveMenuSpecs, type MenuEntry, } from "../components/chrome/menu"; import { createVisibleAgentNote } from "./agentAnnotations"; @@ -171,6 +172,21 @@ describe("ui helpers", () => { expect(menuWidth(wide)).toBe(menuWidth(ascii) + 10); }); + test("responsive menus keep hidden commands reachable behind a visible overflow", () => { + const item: MenuEntry = { kind: "item", label: "One", action: () => {} }; + const specs = buildMenuSpecs({ + file: [item], + view: [item], + navigate: [item], + commit: [item], + help: [item], + }); + const layout = responsiveMenuSpecs(specs, 20); + expect(layout.visible.map((spec) => spec.id)).toEqual(["file", "view"]); + expect(layout.hidden.map((spec) => spec.id)).toEqual(["navigate", "commit", "help"]); + expect(layout.overflowLeft).toBe(13); + }); + test("menuBarTitleWidth cedes title space to the menus the bar shows", () => { const item: MenuEntry = { kind: "item", label: "One", action: () => {} }; const base = { file: [item], view: [item], navigate: [item], agent: [item], help: [item] }; diff --git a/src/ui/log/LogApp.tsx b/src/ui/log/LogApp.tsx index 43c275810..4b8d0a0dd 100644 --- a/src/ui/log/LogApp.tsx +++ b/src/ui/log/LogApp.tsx @@ -24,6 +24,7 @@ import { type LogCommandId, } from "./commands"; import { ParentSelectorDialog } from "./ParentSelectorDialog"; +import { monochromeLogTheme } from "./colorPolicy"; export type LogAppOutcome = | { kind: "quit"; exitCode?: number } @@ -34,10 +35,12 @@ export function LogApp({ controller, runtime, onOutcome, + useColor, }: { controller: LogController; runtime: HistoryRuntime; onOutcome: (outcome: LogAppOutcome) => void; + useColor: boolean; }) { const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot); const terminal = useTerminalDimensions(); @@ -46,6 +49,9 @@ export function LogApp({ const [parentSelectorIndex, setParentSelectorIndex] = useState(null); const [transientNotice, setTransientNotice] = useState(""); const lastClick = useRef({ index: -1, at: 0 }); + // Lock synchronously before awaiting provider planning so coalesced Enter+q input cannot + // quit the log or leak the trailing command into the child review. + const reviewPending = useRef(false); const themeController = useThemeSelectorController({ customThemes: runtime.customThemes, initialTheme: snapshot.themeId, @@ -54,7 +60,13 @@ export function LogApp({ onThemeCommitted: (id) => controller.setTheme(id), transparentBackground: false, }); - const theme = themeController.activeTheme; + const terminalThemeMode = renderer.themeMode ?? "dark"; + const theme = useColor + ? themeController.activeTheme + : monochromeLogTheme(themeController.activeTheme, terminalThemeMode); + const chromeTheme = useColor + ? themeController.baseTheme + : monochromeLogTheme(themeController.baseTheme, terminalThemeMode); const selectedRow = snapshot.rows[snapshot.selected]; const detailHeight = snapshot.presentation.format === "medium" && selectedRow && terminal.height >= 9 ? 5 : 0; @@ -71,8 +83,10 @@ export function LogApp({ } }; const openSelected = async (parentRevisionId?: string) => { + if (reviewPending.current) return; const planned = controller.planSelectedReview(parentRevisionId); if (!planned) return; + reviewPending.current = true; try { onOutcome({ kind: "open-review", @@ -80,6 +94,7 @@ export function LogApp({ themeId: themeController.themeId, }); } catch (error) { + reviewPending.current = false; controller.setNotice(error instanceof Error ? error.message : String(error)); } }; @@ -248,6 +263,10 @@ export function LogApp({ key.preventDefault(); key.stopPropagation(); }; + if (reviewPending.current) { + consume(); + return; + } const name = key.name; const sequence = key.sequence ?? ""; if (parentSelectorIndex !== null) { @@ -352,7 +371,9 @@ export function LogApp({ paddingLeft: 1, paddingRight: 1, }} + onMouseUp={() => menu.closeMenu()} onMouseScroll={(event: TuiMouseEvent) => { + menu.closeMenu(); const direction = event.scroll?.direction; if (direction === "up") controller.move(-3, viewportHeight); else if (direction === "down") controller.move(3, viewportHeight); @@ -478,7 +499,7 @@ export function LogApp({ activeMenuWidth={menu.activeMenuWidth} terminalHeight={terminal.height} terminalWidth={terminal.width} - theme={themeController.baseTheme} + theme={chromeTheme} onHoverItem={menu.setActiveMenuItemIndex} onSelectItem={(entry: Extract) => { if (!entry.disabled) entry.action(); @@ -492,7 +513,7 @@ export function LogApp({ selectedIndex={parentSelectorIndex} terminalHeight={terminal.height} terminalWidth={terminal.width} - theme={themeController.baseTheme} + theme={chromeTheme} onAccept={(index) => { const parent = selectedRow.commit.parentRevisionIds[index]; setParentSelectorIndex(null); @@ -508,7 +529,7 @@ export function LogApp({ selectedIndex={themeController.themeSelectorSelectedIndex} terminalHeight={terminal.height} terminalWidth={terminal.width} - theme={themeController.baseTheme} + theme={chromeTheme} onAcceptItem={themeController.acceptThemeSelectorItem} onClose={themeController.closeThemeSelector} onPreviewItem={themeController.previewThemeSelectorItem} @@ -519,7 +540,7 @@ export function LogApp({ sections={LOG_HELP_SECTIONS} terminalHeight={terminal.height} terminalWidth={terminal.width} - theme={themeController.baseTheme} + theme={chromeTheme} onClose={() => setShowHelp(false)} /> ) : null} diff --git a/src/ui/log/ParentSelectorDialog.test.tsx b/src/ui/log/ParentSelectorDialog.test.tsx new file mode 100644 index 000000000..b306c9df1 --- /dev/null +++ b/src/ui/log/ParentSelectorDialog.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { resolveTheme } from "../themes"; +import { ParentSelectorDialog } from "./ParentSelectorDialog"; + +describe("ParentSelectorDialog", () => { + test("uses every available body row and moves the window with the mouse wheel", async () => { + const selected: number[] = []; + const setup = await testRender( + {}} + onClose={() => {}} + onSelect={(index) => selected.push(index)} + />, + { width: 40, height: 10 }, + ); + try { + await act(async () => { + await setup.renderOnce(); + }); + const frame = setup.captureCharFrame(); + expect(frame).toContain("parent-1"); + expect(frame).toContain("parent-2"); + + await act(async () => { + await setup.mockMouse.scroll(20, 5, "down"); + await setup.renderOnce(); + }); + expect(selected).toEqual([1]); + } finally { + setup.renderer.destroy(); + } + }); +}); diff --git a/src/ui/log/ParentSelectorDialog.tsx b/src/ui/log/ParentSelectorDialog.tsx index 27d2b546b..969a7aa73 100644 --- a/src/ui/log/ParentSelectorDialog.tsx +++ b/src/ui/log/ParentSelectorDialog.tsx @@ -24,7 +24,8 @@ export function ParentSelectorDialog({ onSelect: (index: number) => void; }) { const width = Math.max(1, Math.min(70, terminalWidth - 2)); - const height = Math.max(1, Math.min(parentRevisionIds.length + 5, terminalHeight - 2)); + // Borders, title padding, help row, and bottom padding consume six rows. + const height = Math.max(1, Math.min(parentRevisionIds.length + 6, terminalHeight - 2)); const bodyWidth = Math.max(1, width - 4); const visibleRows = Math.max(1, height - 6); const start = Math.max( @@ -37,9 +38,15 @@ export function ParentSelectorDialog({ terminalHeight={terminalHeight} terminalWidth={terminalWidth} theme={theme} - title="Open parent" + title="Compare with parent" width={width} onClose={onClose} + onMouseScroll={(event) => { + const direction = event.scroll?.direction; + if (direction === "up") onSelect(Math.max(0, selectedIndex - 1)); + else if (direction === "down") + onSelect(Math.min(parentRevisionIds.length - 1, selectedIndex + 1)); + }} > {fitText("Enter/click open · Esc cancel", bodyWidth)} diff --git a/src/ui/log/colorPolicy.test.ts b/src/ui/log/colorPolicy.test.ts new file mode 100644 index 000000000..390c48256 --- /dev/null +++ b/src/ui/log/colorPolicy.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { resolveTheme } from "../themes"; +import { interactiveLogUsesColor, monochromeLogTheme } from "./colorPolicy"; + +describe("interactive log color policy", () => { + test("honors explicit color precedence and terminal conventions", () => { + expect(interactiveLogUsesColor("always", { NO_COLOR: "", TERM: "dumb" })).toBe(true); + expect(interactiveLogUsesColor("never", {})).toBe(false); + expect(interactiveLogUsesColor("auto", { NO_COLOR: "" })).toBe(false); + expect(interactiveLogUsesColor("auto", { TERM: "dumb" })).toBe(false); + expect(interactiveLogUsesColor("auto", { TERM: "xterm-256color" })).toBe(true); + }); + + test("does not expose selected theme colors when color is disabled", () => { + const selected = resolveTheme("github-dark-default", null); + const neutral = monochromeLogTheme(selected, "dark"); + expect(neutral.id).toBe("terminal-monochrome"); + expect(neutral.accent).toBe("#ffffff"); + expect(neutral.background).toBe("#000000"); + expect(neutral.accent).not.toBe(selected.accent); + }); +}); diff --git a/src/ui/log/colorPolicy.ts b/src/ui/log/colorPolicy.ts new file mode 100644 index 000000000..eed8977d9 --- /dev/null +++ b/src/ui/log/colorPolicy.ts @@ -0,0 +1,37 @@ +import type { ThemeMode } from "@opentui/core"; +import type { HistoryColorMode } from "../../core/run/commandInputs"; +import type { AppTheme } from "../themes"; +import { resolveHistoryColor } from "../history/staticProjection"; + +/** Resolve whether interactive history may apply the selected Hunk palette. */ +export function interactiveLogUsesColor( + mode: HistoryColorMode, + env: NodeJS.ProcessEnv, + stdoutIsTTY = true, +) { + return resolveHistoryColor({ mode, env, stdoutIsTTY }); +} + +/** Replace theme-specific chrome colors with a stable monochrome terminal palette. */ +export function monochromeLogTheme(theme: AppTheme, terminalMode: ThemeMode): AppTheme { + const light = terminalMode === "light"; + const background = light ? "#ffffff" : "#000000"; + const foreground = light ? "#000000" : "#ffffff"; + const selection = light ? "#d0d0d0" : "#404040"; + return { + ...theme, + id: "terminal-monochrome", + label: "Terminal monochrome", + appearance: light ? "light" : "dark", + background, + panel: background, + panelAlt: background, + border: foreground, + accent: foreground, + accentMuted: selection, + text: foreground, + muted: foreground, + selectedHunk: selection, + badgeNeutral: foreground, + }; +} diff --git a/src/ui/log/commands.test.ts b/src/ui/log/commands.test.ts index fc7cdb5cb..e00d524ee 100644 --- a/src/ui/log/commands.test.ts +++ b/src/ui/log/commands.test.ts @@ -4,6 +4,7 @@ import type { LogSnapshot } from "./controller"; import { buildLogHelpSections, isLogCommandEnabled, + logCommand, logCommandHint, matchLogCommand, } from "./commands"; @@ -53,6 +54,8 @@ describe("log command authority", () => { expect(matchLogCommand(key("x", "j"))).toBe("next"); expect(matchLogCommand(key("c", "\x03", true))).toBe("quit"); expect(logCommandHint("next")).toBe("↓ / j"); + expect(logCommand("open-first-parent").label).toBe("Compare with first parent"); + expect(logCommand("open-parent").label).toBe("Compare with parent…"); expect(buildLogHelpSections().flatMap((section) => section.rows)).toContainEqual({ keys: "↓ / j", description: "next commit", diff --git a/src/ui/log/commands.ts b/src/ui/log/commands.ts index 001bc3bba..aeceb57b5 100644 --- a/src/ui/log/commands.ts +++ b/src/ui/log/commands.ts @@ -156,8 +156,12 @@ export const LOG_COMMANDS: readonly LogCommandDefinition[] = [ shortcuts: [{ token: "sequence:N", display: "N" }], helpSection: "Navigation", }, - { id: "open-first-parent", label: "Open first parent", menu: "commit" }, - { id: "open-parent", label: "Open parent…", menu: "commit" }, + { + id: "open-first-parent", + label: "Compare with first parent", + menu: "commit", + }, + { id: "open-parent", label: "Compare with parent…", menu: "commit" }, { id: "help", label: "Keyboard shortcuts", diff --git a/src/ui/log/controller.test.ts b/src/ui/log/controller.test.ts index 00748a073..1762ffce8 100644 --- a/src/ui/log/controller.test.ts +++ b/src/ui/log/controller.test.ts @@ -79,6 +79,20 @@ describe("LogController", () => { await controller.close(); }); + test("forces ASCII graph presentation for TERM=dumb", async () => { + const previous = process.env.TERM; + process.env.TERM = "dumb"; + try { + const { runtime } = createRuntime(); + const controller = new LogController(runtime); + expect(controller.getSnapshot().presentation.unicode).toBe(false); + await controller.close(); + } finally { + if (previous === undefined) delete process.env.TERM; + else process.env.TERM = previous; + } + }); + test("initializes format from CLI input and loads enough pages for navigation", async () => { const { runtime } = createRuntime(["one", "two", "three", "four", "five"]); runtime.input.format = "medium"; diff --git a/src/ui/log/controller.ts b/src/ui/log/controller.ts index d6c55b25d..494cd6575 100644 --- a/src/ui/log/controller.ts +++ b/src/ui/log/controller.ts @@ -56,7 +56,7 @@ export class LogController { presentation: { format: runtime.input.format, graph: true, - unicode: !runtime.input.ascii, + unicode: !runtime.input.ascii && process.env.TERM !== "dumb", author: true, date: true, decorations: true, diff --git a/src/ui/log/runInteractiveLog.tsx b/src/ui/log/runInteractiveLog.tsx index e70f7b2dc..e4f1bc875 100644 --- a/src/ui/log/runInteractiveLog.tsx +++ b/src/ui/log/runInteractiveLog.tsx @@ -16,6 +16,7 @@ import { LogApp, type LogAppOutcome } from "./LogApp"; import { LogController } from "./controller"; import { launchHistoryReview } from "./reviewLaunch"; import type { HistoryRuntime } from "../history/types"; +import { interactiveLogUsesColor } from "./colorPolicy"; const LOG_SHUTDOWN_SIGNALS: NodeJS.Signals[] = process.platform === "win32" @@ -80,7 +81,14 @@ async function mountLogSurface( interrupt = installJobControlInterruptSupport(renderer, requestInterrupt); suspend = installJobControlSuspendSupport(renderer); disconnect = installTerminalDisconnectSupport(stdin, requestQuit); - root.render(); + root.render( + , + ); return await outcome; } finally { for (const [signal, handler] of signalHandlers) process.off(signal, handler); diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index c51de6834..b03dd34ef 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -47,11 +47,17 @@ function createMergeHistoryRepo() { writeFileSync(join(cwd, "base.ts"), "export const base = true;\n"); git(cwd, ["add", "base.ts"]); git(cwd, ["commit", "-qm", "Root"]); + const defaultBranch = Bun.spawnSync(["git", "branch", "--show-current"], { + cwd, + stdout: "pipe", + }) + .stdout.toString() + .trim(); git(cwd, ["checkout", "-qb", "side"]); writeFileSync(join(cwd, "side.ts"), "export const side = true;\n"); git(cwd, ["add", "side.ts"]); git(cwd, ["commit", "-qm", "Side"]); - git(cwd, ["checkout", "-q", "master"]); + git(cwd, ["checkout", "-q", defaultBranch]); writeFileSync(join(cwd, "main.ts"), "export const main = true;\n"); git(cwd, ["add", "main.ts"]); git(cwd, ["commit", "-qm", "Main"]); @@ -106,6 +112,16 @@ describe("interactive hunk log", () => { }); expect(returned).toContain("Enter open"); + // Scrolling the history body dismisses an open dropdown before moving selection. + await session.press("f10"); + await session.waitForText(/Open selected commit/, { timeout: 5_000 }); + session.writeRaw("\x1b[<65;50;5M"); + await harness.waitForSnapshot( + session, + (text) => !text.includes("Open selected commit"), + 5_000, + ); + // Clicking outside the id selects the second row without opening it. session.writeRaw("\x1b[<0;50;3M\x1b[<0;50;3m"); await session.press("enter"); @@ -124,8 +140,9 @@ describe("interactive hunk log", () => { await session.press("q"); await session.waitForText(/Second history commit/, { timeout: 15_000 }); - // Opening again without moving proves return restored the immutable-id selection. - await session.press("enter"); + // Opening again without moving proves return restored the immutable-id selection. The + // coalesced trailing q must be consumed by the log transition rather than closing the child. + session.writeRaw("\rq"); await session.waitForText(/historyValue = 'second'/, { timeout: 15_000 }); await session.press("q"); await session.waitForText(/Second history commit/, { timeout: 15_000 }); @@ -135,6 +152,46 @@ describe("interactive hunk log", () => { } }); + test("uses an ASCII graph in a dumb terminal", async () => { + const cwd = createHistoryRepo(); + const session = await harness.launchHunk({ + args: ["log", "--interactive", "--ascii", "--no-extensions"], + cwd, + cols: 80, + rows: 16, + env: { TERM: "dumb", NO_COLOR: "" }, + }); + try { + const history = await session.waitForText(/Second history commit/, { timeout: 15_000 }); + expect(history).toContain("*"); + expect(history).not.toContain("●"); + await session.press("q"); + } finally { + session.close(); + } + }); + + test("refreshes from a new provider cursor and reveals a new commit", async () => { + const cwd = createHistoryRepo(); + const session = await harness.launchHunk({ + args: ["log", "--interactive", "--color", "never", "--no-extensions"], + cwd, + cols: 90, + rows: 18, + }); + try { + await session.waitForText(/Second history commit/, { timeout: 15_000 }); + writeFileSync(join(cwd, "history.ts"), "export const historyValue = 'third';\n"); + git(cwd, ["commit", "-qam", "Third history commit"]); + await session.press("r"); + const refreshed = await session.waitForText(/Third history commit/, { timeout: 15_000 }); + expect(refreshed).toContain("History refreshed"); + await session.press("q"); + } finally { + session.close(); + } + }); + test("opens a merge against the provider-selected parent", async () => { const cwd = createMergeHistoryRepo(); const session = await harness.launchHunk({ @@ -149,13 +206,14 @@ describe("interactive hunk log", () => { await session.press("right"); await session.press("right"); await session.press("right"); - await session.waitForText(/Open parent/, { timeout: 5_000 }); + await session.waitForText(/Compare with parent/, { timeout: 5_000 }); await session.press("down"); await session.press("down"); await session.press("down"); await session.press("enter"); - await session.waitForText(/Open parent/, { timeout: 5_000 }); - await session.press("down"); + await session.waitForText(/Compare with parent/, { timeout: 5_000 }); + session.writeRaw("\x1b[<65;50;10M"); + await session.waitIdle(); await session.press("enter"); const review = await session.waitForText(/main\.ts/, { timeout: 15_000 }); expect(review).not.toContain("side.ts"); From c2481ed3144b9373ad3588bdc077cff6d6644210 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 5 Sep 2026 19:06:08 -0400 Subject: [PATCH 3/4] fix(extensions): restore history review options contract --- src/extension-api/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 5184853ae..9f4ccb4f5 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -799,6 +799,12 @@ export interface ExtensionVcsHistorySource { close(): void | Promise; } +/** Optional provider-neutral selection facts for reviewing one history item. */ +export interface ExtensionVcsHistoryReviewOptions { + /** One ordered parent id returned on the commit, when the caller chooses a specific parent. */ + parentRevisionId?: string; +} + /** A provider-owned declaration of how Hunk should review one history item. */ export type ExtensionVcsHistoryReviewAction = | { @@ -826,6 +832,7 @@ export interface ExtensionVcsHistoryCapability { planReview( commit: ExtensionVcsHistoryCommit, context: ExtensionVcsLoadContext, + options?: ExtensionVcsHistoryReviewOptions, ): ExtensionVcsHistoryReviewAction | Promise; } From aaf4f649d2b87899429d3f36bad4ca30746f743c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 5 Sep 2026 19:07:01 -0400 Subject: [PATCH 4/4] test(ui): await committed extension reload frame --- src/ui/AppHost.extensions.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ui/AppHost.extensions.test.tsx b/src/ui/AppHost.extensions.test.tsx index ed5523487..8eff9e09a 100644 --- a/src/ui/AppHost.extensions.test.tsx +++ b/src/ui/AppHost.extensions.test.tsx @@ -1050,7 +1050,11 @@ describe("mounted lifecycle ordering", () => { expect(events).toContain("result:ok"); expect(events).toContain("trailing:ok"); expect(events.filter((event) => event === "reload:extension")).toHaveLength(2); - expect(setup.captureCharFrame()).toContain("four"); + await flushUntil( + setup, + () => setup.captureCharFrame().includes("four"), + "the successor extension reload frame to render", + ); }); });