diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index c0a90585acf1..7282550de17d 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -54,6 +54,10 @@ export const Plugin = Schema.Union([ }), ]) +/** VCS review scopes plus the last session turn, which only exists while a session is open. */ +export const DiffSource = Schema.Union([Vcs.Mode, Schema.Literal("turn")]) +export type DiffSource = Schema.Schema.Type + export const Cursor = Schema.Struct({ style: Schema.optional(Schema.Literals(["block", "underline", "line", "default"])).annotate({ description: "Cursor shape. Use 'default' to preserve the terminal setting", @@ -109,8 +113,9 @@ export const Info = Schema.Struct({ ).annotate({ description: "System notification and sound settings" }), diffs: Schema.optional( Schema.Struct({ - source: Schema.optional(Vcs.Mode).annotate({ - description: "Initial diff source; defaults to 'branch' (branch and uncommitted changes)", + source: Schema.optional(DiffSource).annotate({ + description: + "Initial diff source; defaults to 'branch' (branch and uncommitted changes). 'turn' shows the last session turn and falls back to 'branch' outside a session", }), wrap: Schema.optional(Schema.Literals(["word", "none"])).annotate({ description: "Line wrapping behavior in diff output", diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index 3ba35761ee29..82e084957dec 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -21,7 +21,7 @@ import { EmptyBorder } from "../../ui/border" import { FilePath } from "../../ui/file-path" import { getScrollAcceleration } from "../../util/scroll" import { createDebouncedSignal } from "../../util/signal" -import { useConfig } from "../../config" +import { type DiffSource, useConfig } from "../../config" import { locationKey } from "../../context/data" import { useThemes } from "../../context/theme" import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff" @@ -44,7 +44,7 @@ const FILE_TREE_MIN_WIDTH = 30 const FILE_TREE_MAX_WIDTH = 40 const FILE_HEADER_HEIGHT = 2 const VCS_DIFF_CONTEXT_LINES = 12 -type DiffMode = Vcs.Mode +type DiffMode = DiffSource type DiffView = "split" | "unified" type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number } type FileMenuState = { readonly fileIndex: number; readonly x: number; readonly y: number } @@ -73,9 +73,13 @@ function storedView(value: unknown): DiffView | undefined { function diffSourceLabel(mode: DiffMode) { if (mode === "branch") return "All" if (mode === "committed") return "Committed" + if (mode === "turn") return "Last turn" return "Uncommitted" } +/** Branch comparisons resolve a review base; the working copy and the session turn do not. */ +const needsBase = (mode: DiffMode) => mode === "branch" || mode === "committed" + function DiffViewer(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() const config = useConfig() @@ -93,12 +97,15 @@ function DiffViewer(props: { context: Plugin.Context }) { } | undefined } - const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch") + const sessionID = () => params()?.sessionID + const initialMode = params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch" + // The session turn only exists inside a session; a remembered or configured turn source falls back elsewhere. + const [mode, setMode] = createSignal(initialMode === "turn" && !sessionID() ? "branch" : initialMode) const location = createMemo( () => { - const sessionID = params()?.sessionID - return sessionID - ? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default()) + const id = sessionID() + return id + ? (props.context.data.session.get(id)?.location ?? props.context.data.location.default()) : props.context.data.location.default() }, undefined, @@ -121,19 +128,30 @@ function DiffViewer(props: { context: Plugin.Context }) { bases.set(key, pending) return pending } - const diffInput = createMemo(() => ({ - mode: mode(), - location: location(), - key: baseKey(), - selected: mode() === "working" ? undefined : selectedBase(), - })) + const diffInput = createMemo(() => { + const current = mode() + if (current === "turn") return { sessionID: sessionID() } + return { + mode: current, + location: location(), + key: baseKey(), + selected: needsBase(current) ? selectedBase() : undefined, + } + }) const [diff] = createResource(diffInput, async (input) => { - const base = - input.mode === "working" - ? undefined - : input.selected - ? { name: input.selected, ref: input.selected } - : (await loadBase(input.location, input.key)).data + if ("sessionID" in input) { + if (!input.sessionID) return { base: null, files: [] } + const files = await props.context.client.session.diff({ + sessionID: input.sessionID, + context: VCS_DIFF_CONTEXT_LINES, + }) + return { base: null, files: normalizeDiffs(files) } + } + const base = !needsBase(input.mode) + ? undefined + : input.selected + ? { name: input.selected, ref: input.selected } + : (await loadBase(input.location, input.key)).data if (input !== diffInput() || (input.mode === "committed" && !base)) { return { base: null, files: [] } } @@ -151,6 +169,7 @@ function DiffViewer(props: { context: Plugin.Context }) { } const result = () => (diff.error || diff.loading ? undefined : diff()) const sourceDetail = () => { + if (mode() === "turn") return diff.error ? "Diff unavailable" : "since your last prompt" if (mode() === "working") return "vs HEAD" if (diff.error) return "Base or diff unavailable" if (!result()) return "Resolving diff…" @@ -167,6 +186,7 @@ function DiffViewer(props: { context: Plugin.Context }) { loading={diff.loading} error={diff.error} mode={mode()} + turnSource={!!sessionID()} sourceDetail={sourceDetail()} sourceBase={sourceBase()} unavailable={mode() === "committed" && !!result() && !result()?.base} @@ -261,6 +281,8 @@ export function DiffViewerContent(props: { loading?: boolean error?: unknown mode: DiffMode + /** Offer the session turn source; only meaningful when the viewer opened from a session. */ + turnSource?: boolean sourceDetail?: string sourceBase?: Pick | null unavailable?: boolean @@ -715,6 +737,14 @@ export function DiffViewerContent(props: { value: "working" as const, description: "Local changes only", }, + ...(props.turnSource + ? [ + { + value: "turn" as const, + description: "Since your last prompt", + }, + ] + : []), ] dialog.show(() => ( @@ -834,7 +864,7 @@ export function DiffViewerContent(props: { - {!props.sourceBase && mode() !== "working" + {!props.sourceBase && needsBase(mode()) ? "Could not load diff. Choose a base branch from Diff source, or select Uncommitted." : "Could not load diff. Reopen the diff viewer to try again."} diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index 94ee08840d4b..15602cd4cc5c 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -209,6 +209,68 @@ test("explicit route source overrides the configured default", async () => { } }) +test("the turn source diffs the session without resolving a base", async () => { + const viewer = await renderDiffViewer(hunkDiff, { height: 30, kittyKeyboard: true }) + try { + await chooseSource(viewer, 3) + await viewer.app.waitForFrame((frame) => frame.includes("Last turn · since your last prompt")) + expect(viewer.app.captureCharFrame()).toContain("const first") + expect(viewer.turnDiffRequests).toHaveLength(1) + expect(viewer.turnDiffRequests[0].searchParams.get("context")).toBe("12") + expect(viewer.turnDiffRequests[0].searchParams.has("from")).toBe(false) + expect(viewer.diffRequests).toHaveLength(1) + expect(viewer.baseRequests).toHaveLength(1) + await chooseSource(viewer, 2) + await viewer.app.waitForFrame((frame) => frame.includes("Uncommitted · vs HEAD") && frame.includes("const first")) + expect(viewer.vcsDiffInput()).toEqual({ + location: { directory: "/repo/session" }, + mode: "working", + context: "12", + }) + } finally { + viewer.app.renderer.destroy() + } +}) + +test("an empty or failing turn diff uses the viewer's empty and error states", async () => { + const empty = await renderDiffViewer([], { source: "turn", turnDiffResponse: async () => json({ data: [] }) }) + try { + expect(empty.baseRequests).toHaveLength(0) + expect(empty.diffRequests).toHaveLength(0) + expect(empty.turnDiffRequests).toHaveLength(1) + await empty.app.waitForFrame((frame) => frame.includes("No changes to show")) + } finally { + empty.app.renderer.destroy() + } + const failing = await renderDiffViewer([], { source: "turn", fail: true }) + try { + await failing.app.waitForFrame((frame) => frame.includes("Could not load diff. Reopen the diff viewer")) + expect(failing.app.captureCharFrame()).toContain("Last turn · Diff unavailable") + expect(failing.app.captureCharFrame()).not.toContain("Choose a base branch") + } finally { + failing.app.renderer.destroy() + } +}) + +test("the turn source is unavailable outside a session and falls back to the branch scope", async () => { + const viewer = await renderDiffViewer(hunkDiff, { source: "turn", height: 30, initialRoute: { type: "home" } }) + try { + expect(viewer.vcsDiffInput()).toEqual({ + location: { directory: "/repo/default" }, + mode: "branch", + base: "refs/heads/v2", + context: "12", + }) + expect(viewer.turnDiffRequests).toHaveLength(0) + viewer.app.mockInput.pressKey("d") + await viewer.app.waitForFrame((frame) => frame.includes("Diff source")) + expect(viewer.app.captureCharFrame()).not.toContain("Last turn") + expect(viewer.app.captureCharFrame()).toMatch(/Base\s+v2/) + } finally { + viewer.app.renderer.destroy() + } +}) + test.each([50, 80, 160])( "keeps scope, base, and review count on one row with a selectable base at %i columns", async (width) => { @@ -240,9 +302,11 @@ test.each([50, 80, 160])( expect(rows[first]).toMatch(/All\s+Branch \+ local changes/) expect(rows[first + 1]).toMatch(/Committed\s+Branch commits only/) expect(rows[first + 2]).toMatch(/Uncommitted\s+Local changes only/) - expect(rows[first + 3]).toMatch(/Base\s+release/) + expect(rows[first + 3]).toMatch(/Last turn\s+Since your last prompt/) + expect(rows[first + 4]).toMatch(/Base\s+release/) expect(rows[first + 1].indexOf("Branch commits only")).toBe(rows[first].indexOf("Branch + local changes")) expect(rows[first + 2].indexOf("Local changes only")).toBe(rows[first].indexOf("Branch + local changes")) + expect(rows[first + 3].indexOf("Since your last prompt")).toBe(rows[first].indexOf("Branch + local changes")) viewer.app.mockInput.pressEscape() await viewer.app.waitForFrame((frame) => !frame.includes("Diff source")) viewer.commands.get("diff.mark_reviewed")!.run() @@ -292,7 +356,7 @@ test.each([50, 80, 100, 160])( ) test("opening the source chooser from initial Uncommitted does not resolve a branch base", async () => { - const viewer = await renderDiffViewer(hunkDiff, { source: "working" }) + const viewer = await renderDiffViewer(hunkDiff, { source: "working", height: 30 }) try { viewer.app.mockInput.pressKey("d") await viewer.app.waitForFrame((frame) => frame.includes("Diff source")) @@ -313,7 +377,7 @@ test.each(["branch", "committed", "working"] as const)( viewer.commands.get("diff.mark_reviewed")!.run() await viewer.app.flush() expect(viewer.app.captureCharFrame()).toContain("1/1") - await chooseSource(viewer, 3) + await chooseSource(viewer, 4) await viewer.app.waitForFrame((frame) => frame.includes("Base branch") && frame.includes("origin/release")) expect(viewer.app.captureCharFrame()).toMatch(/●\s+v2/) expect(viewer.branchesRequests[0].searchParams.get("location[directory]")).toBe("/repo/session") @@ -347,7 +411,7 @@ test.each(["branch", "committed", "working"] as const)( expect(viewer.app.captureCharFrame()).toContain("0/1") expect(viewer.diffRequests).toHaveLength(source === "working" ? 2 : 3) if (source !== "working") expect(viewer.vcsDiffInput()).toMatchObject({ base: "origin/release" }) - await chooseSource(viewer, 3) + await chooseSource(viewer, 4) await viewer.app.waitForFrame((frame) => /●\s+origin\/release/.test(frame)) expect(viewer.baseRequests).toHaveLength(1) } finally { @@ -371,7 +435,7 @@ test.each(["branch", "committed"] as const)("an ambiguous base never requests a expect(viewer.app.captureCharFrame()).toContain("Choose a base branch") expect(viewer.app.captureCharFrame()).not.toContain("No changes to show") expect(viewer.diffRequests).toHaveLength(0) - await chooseSource(viewer, 3) + await chooseSource(viewer, 4) await viewer.app.waitForFrame((frame) => frame.includes("Base branch") && frame.includes("origin/release")) viewer.app.mockInput.pressKey("HOME") viewer.app.mockInput.pressArrow("down") @@ -388,7 +452,7 @@ test.each(["branch", "committed"] as const)("an ambiguous base never requests a test("base and scope choices survive reopening but not a new TUI instance", async () => { const viewer = await renderDiffViewer(hunkDiff, { source: "working", height: 30, kittyKeyboard: true }) try { - await chooseSource(viewer, 3) + await chooseSource(viewer, 4) await viewer.app.waitForFrame((frame) => frame.includes("origin/release")) viewer.app.mockInput.pressArrow("down") viewer.app.mockInput.pressEnter() @@ -421,7 +485,7 @@ test("base and scope choices survive reopening but not a new TUI instance", asyn test("base choices are isolated by branch within the same location", async () => { const viewer = await renderDiffViewer(hunkDiff, { height: 30, kittyKeyboard: true }) try { - await chooseSource(viewer, 3) + await chooseSource(viewer, 4) await viewer.app.waitForFrame((frame) => frame.includes("origin/release")) viewer.app.mockInput.pressArrow("down") viewer.app.mockInput.pressEnter() @@ -449,13 +513,13 @@ test("an invalid comparison reports an error and allows another base choice", as : json({ location: session.location, data: hunkDiff }), }) try { - await chooseSource(viewer, 3) + await chooseSource(viewer, 4) await viewer.app.waitForFrame((frame) => frame.includes("origin/release")) viewer.app.mockInput.pressArrow("down") viewer.app.mockInput.pressEnter() await viewer.app.waitForFrame((frame) => frame.includes("Base or diff unavailable")) expect(viewer.app.captureCharFrame()).not.toContain("No changes to show") - await chooseSource(viewer, 3) + await chooseSource(viewer, 4) await viewer.app.waitForFrame((frame) => frame.includes("origin/release")) viewer.app.mockInput.pressKey("HOME") viewer.app.mockInput.pressEnter() @@ -474,7 +538,7 @@ test("base search failures are visible without changing the diff", async () => { branchesResponse: async () => json({ message: "branches unavailable" }, { status: 503 }), }) try { - await chooseSource(viewer, 3) + await chooseSource(viewer, 4) await viewer.app.waitForFrame((frame) => frame.includes("Could not load branches")) expect(viewer.app.captureCharFrame()).toContain("All · vs v2") expect(viewer.mutationRequests).toHaveLength(0) @@ -493,7 +557,7 @@ test("a late base lookup cannot overwrite an in-memory base choice", async () => baseResponse: () => pending.promise, }) try { - await chooseSource(viewer, 3) + await chooseSource(viewer, 4) await viewer.app.waitForFrame((frame) => frame.includes("origin/release")) viewer.app.mockInput.pressArrow("down") viewer.app.mockInput.pressEnter() @@ -522,7 +586,7 @@ test("dismissing the base picker leaves the comparison unchanged", async () => { kittyKeyboard: true, }) try { - await chooseSource(viewer, 3) + await chooseSource(viewer, 4) await viewer.app.waitForFrame((frame) => frame.includes("origin/release")) viewer.app.mockInput.pressArrow("down") viewer.app.mockInput.pressEscape() @@ -540,7 +604,7 @@ test("dismissing the base picker leaves the comparison unchanged", async () => { test("the base picker remembers its captured location without refreshing a moved session", async () => { const viewer = await renderDiffViewer(hunkDiff, { height: 30, kittyKeyboard: true }) try { - await chooseSource(viewer, 3) + await chooseSource(viewer, 4) await viewer.app.waitForFrame((frame) => frame.includes("origin/release")) viewer.setSessionLocation({ directory: "/repo/moved" }) await viewer.app.flush() @@ -1800,12 +1864,13 @@ async function renderDiffViewer( onSessionTab?: () => void keybinds?: TuiKeybind.KeybindOverrides kittyKeyboard?: boolean - source?: "branch" | "committed" | "working" + source?: "branch" | "committed" | "working" | "turn" base?: typeof baseFixture | null open?: boolean pending?: boolean baseResponse?: () => Promise diffResponse?: (url: URL) => Promise + turnDiffResponse?: (url: URL) => Promise branchesResponse?: (url: URL) => Promise state?: string } = {}, @@ -1826,6 +1891,7 @@ async function renderDiffViewer( const writes: Info[] = [] const baseRequests: URL[] = [] const diffRequests: URL[] = [] + const turnDiffRequests: URL[] = [] const branchesRequests: URL[] = [] const mutationRequests: URL[] = [] const config = createTuiResolvedConfig(stored.info) @@ -1858,6 +1924,12 @@ async function renderDiffViewer( ), }) } + if (url.pathname === "/api/session/session-1/diff") { + turnDiffRequests.push(url) + if (options.turnDiffResponse) return options.turnDiffResponse(url) + if (options.fail) return json({ message: "boom" }, { status: 500 }) + return json({ data: vcsDiff }) + } if (url.pathname !== "/api/vcs/diff") return diffRequests.push(url) vcsDiffInput = { @@ -2003,6 +2075,7 @@ async function renderDiffViewer( imageReadInput: () => imageReadInput, baseRequests, diffRequests, + turnDiffRequests, branchesRequests, mutationRequests, setSessionLocation, diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 08d8df55f1ba..a1a867cef81f 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -9,8 +9,8 @@ import { CommandMap, Definitions } from "../src/config/v1/keybind" const decodeInfo = Schema.decodeUnknownSync(Info) -test("validates the three explicit diff source defaults", () => { - for (const source of ["branch", "committed", "working"] as const) { +test("validates the four explicit diff source defaults", () => { + for (const source of ["branch", "committed", "working", "turn"] as const) { expect(decodeInfo({ diffs: { source } })).toEqual({ diffs: { source } }) } expect(decodeInfo({ diffs: {} })).toEqual({ diffs: {} }) diff --git a/services/www/src/docs/content/cli/config.mdx b/services/www/src/docs/content/cli/config.mdx index b497e0024bb9..ed3a4f752b98 100644 --- a/services/www/src/docs/content/cli/config.mdx +++ b/services/www/src/docs/content/cli/config.mdx @@ -166,16 +166,18 @@ Configure diff presentation: } ``` -| Field | Values | Description | -| -------- | ----------------------------------- | ------------------------------------------------------------- | -| `source` | `branch`, `committed`, or `working` | Sets the initial review scope. Defaults to `branch`. | -| `wrap` | `word` or `none` | Sets line wrapping. | -| `tree` | boolean | Shows the diff file tree. | -| `single` | boolean | Shows only the selected file patch. | -| `view` | `auto`, `split`, or `unified` | Sets the layout. `auto` chooses based on the available width. | +| Field | Values | Description | +| -------- | ------------------------------------------- | ------------------------------------------------------------- | +| `source` | `branch`, `committed`, `working`, or `turn` | Sets the initial review scope. Defaults to `branch`. | +| `wrap` | `word` or `none` | Sets line wrapping. | +| `tree` | boolean | Shows the diff file tree. | +| `single` | boolean | Shows only the selected file patch. | +| `view` | `auto`, `split`, or `unified` | Sets the layout. `auto` chooses based on the available width. | `branch` shows **All** (branch and local changes), `committed` shows **Committed** (branch commits only), and `working` shows **Uncommitted** (staged, unstaged, and untracked changes). +`turn` shows **Last turn**: the files the session changed since your last prompt. It is only available when `/diff` opens from a session and falls back to `branch` elsewhere. + In `/diff`, press `d` to change the scope or choose a comparison branch from **Base**. OpenCode infers the base from local Git history when possible; otherwise it asks you to choose before loading a branch diff. Your base choice for each branch and last selected scope survive closing and reopening the viewer, but reset when the TUI exits. These selections do not change `cli.json`.