diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 7275d3d87e13..ebdb76688f95 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -54,6 +54,9 @@ export const Plugin = Schema.Union([ }), ]) +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", @@ -108,8 +111,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 session's last 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 bc7aff1dba88..fa14d764ba48 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 } @@ -70,11 +70,16 @@ function storedView(value: unknown): DiffView | undefined { if (value === "split" || value === "unified") return value } -function diffSourceLabel(mode: DiffMode) { - if (mode === "branch") return "All" - if (mode === "committed") return "Committed" - return "Uncommitted" -} +const DIFF_SOURCES = { + branch: { label: "All", description: "Branch + local changes" }, + committed: { label: "Committed", description: "Branch commits only" }, + working: { label: "Uncommitted", description: "Local changes only" }, + turn: { label: "Last turn", description: "Latest session turn" }, +} satisfies Record + +const VCS_SOURCES = ["branch", "committed", "working"] as const + +const needsBase = (mode: DiffMode) => mode === "branch" || mode === "committed" function DiffViewer(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() @@ -93,12 +98,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 sources = (): readonly DiffMode[] => (sessionID() ? [...VCS_SOURCES, "turn"] : VCS_SOURCES) + const initialMode = params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch" + const [mode, setMode] = createSignal(sources().includes(initialMode) ? initialMode : "branch") 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 +129,32 @@ 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 [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 + const diffInput = createMemo(() => { + const current = mode() + const id = sessionID() + if (current === "turn" && id) return { mode: current, sessionID: id } + const vcs = current === "turn" ? "branch" : current + return { + mode: vcs, + location: location(), + key: baseKey(), + selected: needsBase(vcs) ? selectedBase() : undefined, + } + }) + const [diff, { refetch }] = createResource(diffInput, async (input) => { + if (input.mode === "turn") { + return { + base: null, + files: normalizeDiffs( + await props.context.client.session.diff({ sessionID: input.sessionID, context: VCS_DIFF_CONTEXT_LINES }), + ), + } + } + 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: [] } } @@ -145,12 +166,21 @@ function DiffViewer(props: { context: Plugin.Context }) { }) return { base, files: normalizeDiffs(result.data ?? []) } }) + // Each completed turn replaces the last one. + createEffect((previous) => { + if (mode() !== "turn") return undefined + const id = sessionID() + const status = id ? props.context.data.session.status(id) : undefined + if (previous === "running" && status === "idle") void refetch() + return status + }) const sourceBase = () => { const ref = selectedBase() return ref ? { name: ref, ref } : reportedBases().get(baseKey()) } const result = () => (diff.error || diff.loading ? undefined : diff()) const sourceDetail = () => { + if (mode() === "turn") return diff.error ? "Diff unavailable" : undefined if (mode() === "working") return "vs HEAD" if (diff.error) return "Base or diff unavailable" if (!result()) return "Resolving diff…" @@ -167,6 +197,7 @@ function DiffViewer(props: { context: Plugin.Context }) { loading={diff.loading} error={diff.error} mode={mode()} + sources={sources()} sourceDetail={sourceDetail()} sourceBase={sourceBase()} unavailable={mode() === "committed" && !!result() && !result()?.base} @@ -261,6 +292,7 @@ export function DiffViewerContent(props: { loading?: boolean error?: unknown mode: DiffMode + sources: readonly DiffMode[] sourceDetail?: string sourceBase?: Pick | null unavailable?: boolean @@ -702,20 +734,6 @@ export function DiffViewerContent(props: { ] const openSwitchDiffDialog = () => { - const options = [ - { - value: "branch" as const, - description: "Branch + local changes", - }, - { - value: "committed" as const, - description: "Branch commits only", - }, - { - value: "working" as const, - description: "Local changes only", - }, - ] dialog.show(() => ( title="Diff source" @@ -723,13 +741,14 @@ export function DiffViewerContent(props: { renderFilter={false} current={mode()} options={[ - ...options.map((option) => ({ - ...option, - title: diffSourceLabel(option.value), - titleView: diffSourceLabel(option.value).padEnd(11), + ...props.sources.map((source) => ({ + value: source, + title: DIFF_SOURCES[source].label, + titleView: DIFF_SOURCES[source].label.padEnd(11), + description: DIFF_SOURCES[source].description, onSelect() { dialog.clear() - props.onSwitchSource(option.value) + props.onSwitchSource(source) }, })), ...(props.onChooseBase @@ -811,7 +830,7 @@ export function DiffViewerContent(props: { flexShrink={0} wrapMode="none" > - {diffSourceLabel(mode())} + {DIFF_SOURCES[mode()].label} @@ -834,7 +853,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."} @@ -868,7 +887,7 @@ export function DiffViewerContent(props: { expandedNodes={expandedFileNodes()} onRowClick={clickFileTreeRow} onFileContextMenu={openFileMenu} - source={diffSourceLabel(mode())} + source={DIFF_SOURCES[mode()].label} sourceDetail={props.sourceDetail} onSwitchSource={openSwitchDiffDialog} footer={} diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index c310ff2546f0..69820f215cc7 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -209,6 +209,80 @@ test("explicit route source overrides the configured default", async () => { } }) +test("the turn source diffs the session's last turn 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") && frame.includes("const first")) + expect(viewer.app.captureCharFrame()).not.toContain("Base not reported") + 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: session.location, mode: "working", context: "12" }) + } finally { + viewer.app.renderer.destroy() + } +}) + +test("the turn source refreshes only when the session finishes a turn", async () => { + const viewer = await renderDiffViewer(hunkDiff, { source: "turn" }) + try { + expect(viewer.turnDiffRequests).toHaveLength(1) + viewer.setSessionStatus("running") + await viewer.app.flush() + expect(viewer.turnDiffRequests).toHaveLength(1) + viewer.setSessionStatus("idle") + await viewer.app.waitFor(() => viewer.turnDiffRequests.length === 2) + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + expect(viewer.diffRequests).toHaveLength(0) + } 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" }) + try { + await empty.app.waitForFrame((frame) => frame.includes("No changes to show")) + expect(empty.baseRequests).toHaveLength(0) + expect(empty.diffRequests).toHaveLength(0) + expect(empty.turnDiffRequests).toHaveLength(1) + } 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 +314,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+Latest session turn/) + 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("Latest session turn")).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 +368,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 +389,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 +423,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 +447,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 +464,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 +497,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 +525,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 +550,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 +569,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 +598,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 +616,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,7 +1876,7 @@ 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 @@ -1814,6 +1890,7 @@ async function renderDiffViewer( const [current, setCurrent] = createSignal(options.initialRoute ?? startRoute) const [sessionLocation, setSessionLocation] = createSignal(session.location) const [branch, setBranch] = createSignal("feature") + const [sessionStatus, setSessionStatus] = createSignal<"idle" | "running">("idle") const state = options.state ?? path.join(temporary.path, crypto.randomUUID()) let renderDiff: Page["render"] | undefined let renderCommands: SlotClaim<"app">["render"] | undefined @@ -1826,6 +1903,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 +1936,11 @@ async function renderDiffViewer( ), }) } + if (url.pathname === "/api/session/session-1/diff") { + turnDiffRequests.push(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 = { @@ -1896,7 +1979,7 @@ async function renderDiffViewer( storage: useStorage(), client: createApi(transport.fetch), data: { - session: { get: () => ({ ...session, location: sessionLocation() }) }, + session: { get: () => ({ ...session, location: sessionLocation() }), status: sessionStatus }, location: { default: () => ({ directory: "/repo/default" }), vcs: { info: () => ({ branch: { current: branch() } }) }, @@ -2003,9 +2086,11 @@ async function renderDiffViewer( imageReadInput: () => imageReadInput, baseRequests, diffRequests, + turnDiffRequests, branchesRequests, mutationRequests, setSessionLocation, + setSessionStatus, setBranch, state, writes, diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index a67d1d0277ea..ae59c8f7bca9 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 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 4aef5392743b..cd9a6b71143f 100644 --- a/services/www/src/docs/content/cli/config.mdx +++ b/services/www/src/docs/content/cli/config.mdx @@ -194,18 +194,19 @@ Configure the initial diff scope and presentation:
-| Setting | Values | Description | -| -------------- | ----------------------------------- | ------------------------------------------------------------- | -| `diffs.source` | `branch`, `committed`, or `working` | Sets the initial review scope. | -| `diffs.wrap` | `word` or `none` | Wraps long lines at words or leaves them unwrapped. | -| `diffs.tree` | boolean | Shows the diff file tree. | -| `diffs.single` | boolean | Shows only the selected file patch. | -| `diffs.view` | `auto`, `split`, or `unified` | Sets the layout. `auto` chooses from the available width. | +| Setting | Values | Description | +| -------------- | ------------------------------------------- | --------------------------------------------------------- | +| `diffs.source` | `branch`, `committed`, `working`, or `turn` | Sets the initial review scope. | +| `diffs.wrap` | `word` or `none` | Wraps long lines at words or leaves them unwrapped. | +| `diffs.tree` | boolean | Shows the diff file tree. | +| `diffs.single` | boolean | Shows only the selected file patch. | +| `diffs.view` | `auto`, `split`, or `unified` | Sets the layout. `auto` chooses from the available width. |
`branch` shows **All** branch and local changes, `committed` shows branch commits only, and `working` shows staged, -unstaged, and untracked changes. +unstaged, and untracked changes. `turn` shows **Last turn**, the files the session changed in its latest turn; it +refreshes when the session finishes a turn and falls back to `branch` when `/diff` is not opened from a session. In `/diff`, press `d` to change the scope or choose a comparison branch from **Base**. These in-view choices last until the TUI exits and do not change `cli.json`. diff --git a/services/www/src/docs/content/cli/keybinds.mdx b/services/www/src/docs/content/cli/keybinds.mdx index 07ad4e8c97b0..5b97792efebe 100644 --- a/services/www/src/docs/content/cli/keybinds.mdx +++ b/services/www/src/docs/content/cli/keybinds.mdx @@ -117,7 +117,7 @@ The `app.clear` command works only in [`opencode mini`](/cli). Press `ctrl+l` to ## Diff Viewer -Press `d` (`diff.switch_source`) to choose **All**, **Committed**, or **Uncommitted**, or select **Base** to change the comparison branch. Choices are remembered until the TUI exits. Set the initial scope with [`diffs.source` in `cli.json`](/cli/config#diffs). +Press `d` (`diff.switch_source`) to choose **All**, **Committed**, **Uncommitted**, or **Last turn** (inside a session), or select **Base** to change the comparison branch. Choices are remembered until the TUI exits. Set the initial scope with [`diffs.source` in `cli.json`](/cli/config#diffs). Scrolling, paging, and start/end shortcuts always control the diff. There is no keyboard focus switch: click files to open them, click folders to expand or collapse them, and use the mouse wheel to scroll the file tree.