From ac350125ec0e277def4d95237af83c3ca1691930 Mon Sep 17 00:00:00 2001 From: Alice Poteat Date: Fri, 2 Oct 2026 18:35:29 -0700 Subject: [PATCH 1/2] diff: toasts show while the pane is open The pane and the dialog were opened with holdToasts, which holds every toast while the pane is shown: other plugins' and the app's own, as this plugin's. Nothing in the mod relies on the hold, and a toast no longer lands between the pane and the prompt, so the option goes from both opens. --- mods/diff/README.md | 4 ++-- mods/diff/hooks/register.ts | 3 +-- mods/diff/tests/register.test.ts | 5 +---- mods/diff/tests/views.test.ts | 1 - 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/mods/diff/README.md b/mods/diff/README.md index 9340542248..b221a113e5 100644 --- a/mods/diff/README.md +++ b/mods/diff/README.md @@ -37,8 +37,8 @@ where the focus ring starts; the plugin follows the ring's walk through `ui.focus` and re-centres the rows as the built-in does), the key hints; Enter shows that file's hunks alone, Escape backs out to the list and then closes, leaving `Diff dialog -dismissed`; toasts are held while it is up. A file's ask button arms that -file: its hunks ride the next prompt as context, once. +dismissed`; toasts show while it is up, as beside the docked pane. A file's +ask button arms that file: its hunks ride the next prompt as context, once. The pane compares the working tree against HEAD, split at the session's start (the default; the start the engine gives in `$.session.usage()`, so a diff --git a/mods/diff/hooks/register.ts b/mods/diff/hooks/register.ts index 3273ad0a36..519d17f574 100644 --- a/mods/diff/hooks/register.ts +++ b/mods/diff/hooks/register.ts @@ -180,7 +180,6 @@ export function register(on: On) { return { id: Names.PANE_ID, title: Names.PANE_TITLE, - holdToasts: true, closeOnEscape: true, rows: Views.dialogRowsOf(model), } @@ -439,7 +438,7 @@ export function register(on: On) { const opened = await engine.openPane( isDialog ? { ...dialogPane(), focus: true } - : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true }, + : { id: Names.PANE_ID, title: Names.PANE_TITLE }, ) const isWaiting = isRecord(opened) && opened.isPlaced === false diff --git a/mods/diff/tests/register.test.ts b/mods/diff/tests/register.test.ts index 124f6d84ee..65ab8de379 100644 --- a/mods/diff/tests/register.test.ts +++ b/mods/diff/tests/register.test.ts @@ -358,9 +358,7 @@ describe('register', () => { '--show-toplevel', ) - expect(world.opened).toEqual([ - { id: 'diff', title: 'Diff', holdToasts: true }, - ]) + expect(world.opened).toEqual([{ id: 'diff', title: 'Diff' }]) await world.clock.advance(Fixtures.SETTLE_MS) @@ -380,7 +378,6 @@ describe('register', () => { expect(world.opened[0]).toEqual({ id: 'diff', title: 'Diff', - holdToasts: true, closeOnEscape: true, rows: expect.any(Number), focus: true, diff --git a/mods/diff/tests/views.test.ts b/mods/diff/tests/views.test.ts index 1ba8ad485d..b07b606215 100644 --- a/mods/diff/tests/views.test.ts +++ b/mods/diff/tests/views.test.ts @@ -290,7 +290,6 @@ describe('views', () => { expect(world.opened.at(-1), 'sized to its rows once listed').toEqual({ id: 'diff', title: 'Diff', - holdToasts: true, closeOnEscape: true, rows: 8, }) From b3eaa37a430a30b7a9ba73560441cf0db4f34252 Mon Sep 17 00:00:00 2001 From: Alice Poteat Date: Fri, 2 Oct 2026 21:16:51 -0700 Subject: [PATCH 2/2] diff: a pane nothing can draw yet is kept, and shows once something can An open the engine answers no-surface (nothing is attached that draws it yet, as before a host's page attaches) was withdrawn like one left waiting for width, so the pane was lost. It is kept: /diff says so, nothing polls or refreshes while no one sees the pane, and its first drawing reads the repository again and counts it shown. --- mods/diff/README.md | 10 +- .../hooks/names/texts/awaits-surface-text.ts | 6 ++ mods/diff/hooks/names/texts/index.ts | 1 + mods/diff/hooks/open-outcome/index.ts | 4 + .../hooks/open-outcome/open-outcome-of.ts | 25 +++++ mods/diff/hooks/open-outcome/open-outcome.ts | 5 + mods/diff/hooks/register.ts | 61 ++++++++---- mods/diff/tests/fixtures/beneath/beneath.ts | 6 ++ mods/diff/tests/fixtures/beneath/index.ts | 1 + .../tests/fixtures/beneath/left-unseen.ts | 16 ++++ mods/diff/tests/fixtures/in-repository.ts | 21 +++- .../open-outcome/open-outcome-of.test.ts | 27 ++++++ mods/diff/tests/register.test.ts | 95 +++++++++++++++++++ 13 files changed, 253 insertions(+), 25 deletions(-) create mode 100644 mods/diff/hooks/names/texts/awaits-surface-text.ts create mode 100644 mods/diff/hooks/open-outcome/index.ts create mode 100644 mods/diff/hooks/open-outcome/open-outcome-of.ts create mode 100644 mods/diff/hooks/open-outcome/open-outcome.ts create mode 100644 mods/diff/tests/fixtures/beneath/left-unseen.ts create mode 100644 mods/diff/tests/open-outcome/open-outcome-of.test.ts diff --git a/mods/diff/README.md b/mods/diff/README.md index b221a113e5..e70da44f9e 100644 --- a/mods/diff/README.md +++ b/mods/diff/README.md @@ -23,9 +23,13 @@ opens nothing, and where the surface does not say, nothing opens by itself. A docked pane fetches before it opens, as the built-in panel primes its data, so it never lands on `Loading diff…`; an open the engine leaves waiting undrawn is withdrawn, so no later resize seats it, and the -next edit asks again. A session resumed or continued whose transcript -already holds such an edit opens the pane on the same terms as soon as the -width is known, as the built-in opens on the history it restores. +next edit asks again. An open answered `no-surface` (nothing is attached +that draws it yet, as before a host's page attaches) is kept instead: +`/diff` says so, nothing polls or refreshes while no one sees the pane, and +its first drawing reads the repository again and counts it shown. A session +resumed or continued whose transcript already holds such an edit opens the +pane on the same terms as soon as the width is known, as the built-in opens +on the history it restores. Under the fullscreen layout a terminal under 110 columns gets the built-in's line asking for a wider one and nothing opens. Without that diff --git a/mods/diff/hooks/names/texts/awaits-surface-text.ts b/mods/diff/hooks/names/texts/awaits-surface-text.ts new file mode 100644 index 0000000000..0cb777acc6 --- /dev/null +++ b/mods/diff/hooks/names/texts/awaits-surface-text.ts @@ -0,0 +1,6 @@ +/** + * `/diff`'s answer where nothing is attached that draws the pane yet: it is + * kept open, and shows as soon as something is. + */ +export const AWAITS_SURFACE_TEXT = + 'Nothing here can draw the diff panel yet; it opens as soon as something can' diff --git a/mods/diff/hooks/names/texts/index.ts b/mods/diff/hooks/names/texts/index.ts index 7e801a5983..58df1c9bd3 100644 --- a/mods/diff/hooks/names/texts/index.ts +++ b/mods/diff/hooks/names/texts/index.ts @@ -1,3 +1,4 @@ +export * from './awaits-surface-text.js' export * from './builtin-holds-pattern.js' export * from './git-unanswered-text.js' export * from './more-below-text.js' diff --git a/mods/diff/hooks/open-outcome/index.ts b/mods/diff/hooks/open-outcome/index.ts new file mode 100644 index 0000000000..6b9eb193fb --- /dev/null +++ b/mods/diff/hooks/open-outcome/index.ts @@ -0,0 +1,4 @@ +export type * from './open-outcome.js' +export * from './open-outcome-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/open-outcome/open-outcome-of.ts b/mods/diff/hooks/open-outcome/open-outcome-of.ts new file mode 100644 index 0000000000..997bfdd791 --- /dev/null +++ b/mods/diff/hooks/open-outcome/open-outcome-of.ts @@ -0,0 +1,25 @@ +import { isRecord } from '../is-record' +import type { OpenOutcome } from './open-outcome.js' + +/** + * Reads what `$.ui.open` resolved: an unplaced pane is withdrawn, unless the + * reason starts `no-surface`, where the engine keeps it for a surface to come. + * + * An engine that resolves nothing, or never gives that reason, reads as + * before: placed, or withdrawn. + * + * @param opened what the open resolved + * @returns what the plugin does with the pane + */ +export function openOutcomeOf(opened: unknown): OpenOutcome { + const isUnplaced = isRecord(opened) && opened.isPlaced === false + + if (!isUnplaced) { + return 'placed' + } + + const isUnseen = + typeof opened.reason === 'string' && opened.reason.startsWith('no-surface') + + return isUnseen ? 'awaited' : 'withdrawn' +} diff --git a/mods/diff/hooks/open-outcome/open-outcome.ts b/mods/diff/hooks/open-outcome/open-outcome.ts new file mode 100644 index 0000000000..0c6c4b9227 --- /dev/null +++ b/mods/diff/hooks/open-outcome/open-outcome.ts @@ -0,0 +1,5 @@ +/** + * What became of an open: the pane is drawn (`placed`), kept until something + * attaches that draws it (`awaited`), or taken back (`withdrawn`). + */ +export type OpenOutcome = 'placed' | 'awaited' | 'withdrawn' diff --git a/mods/diff/hooks/register.ts b/mods/diff/hooks/register.ts index 519d17f574..c33439446a 100644 --- a/mods/diff/hooks/register.ts +++ b/mods/diff/hooks/register.ts @@ -21,6 +21,7 @@ import Limits from './limits' import { messageOf } from './message-of' import { mtimeOf } from './mtime-of' import Names from './names' +import OpenOutcome from './open-outcome' import PaneState from './pane-state' import PaneToggle from './pane-toggle' import Record from './record' @@ -44,6 +45,7 @@ export function register(on: On) { let probing: Promise | null = null let sessionStartMs = 0 let isPaneOpen = false + let awaited: (typeof Record.SHOWN_TRIGGERS)[number] | null = null let dialogRows: number | null = null let hasAutoOpened = false let hasRestoredEdits = false @@ -69,6 +71,7 @@ export function register(on: On) { const loggedBaseKinds = new Set<'ok' | 'sad'>() const currentOf = (engine: Host): Host => host ?? engine + const isPaneSeen = (): boolean => isPaneOpen && awaited === null const backendHostOf = (engine: Host): Backend.BackendHost => ({ run: (argv, init) => @@ -302,7 +305,7 @@ export function register(on: On) { timers.set( 'poll', engine.every(Limits.HEAD_POLL_MS, () => { - if (!isPaneOpen) { + if (!isPaneSeen()) { return } @@ -414,10 +417,20 @@ export function register(on: On) { } } + async function showKept( + engine: Host, + trigger: (typeof Record.SHOWN_TRIGGERS)[number], + ): Promise { + await markShown(engine, trigger) + await pinBackend(engine) + keepBaseline() + void refresh(engine) + } + async function openPane( engine: Host, trigger: (typeof Record.SHOWN_TRIGGERS)[number], - ): Promise { + ): Promise { const isDialog = model.isFullscreen === false model = { @@ -441,18 +454,22 @@ export function register(on: On) { : { id: Names.PANE_ID, title: Names.PANE_TITLE }, ) - const isWaiting = isRecord(opened) && opened.isPlaced === false + const outcome = OpenOutcome.openOutcomeOf(opened) - if (isWaiting) { + if (outcome === 'withdrawn') { await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined) - return false + return outcome } isPaneOpen = true keepBaseline() - await markShown(engine, trigger) + if (outcome === 'awaited') { + awaited = trigger + } else { + await markShown(engine, trigger) + } const isStale = isDialog || landed !== landedBefore @@ -460,12 +477,13 @@ export function register(on: On) { void refresh(engine) } - return true + return outcome } async function closePane(engine: Host): Promise { await engine.closePane({ id: Names.PANE_ID }) isPaneOpen = false + awaited = null } function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) { @@ -512,7 +530,7 @@ export function register(on: On) { } hasAutoOpened = true - hasAutoOpened = await openPane(engine, 'auto_open') + hasAutoOpened = (await openPane(engine, 'auto_open')) !== 'withdrawn' } async function openOnRestore(engine: Host): Promise { @@ -727,6 +745,13 @@ export function register(on: On) { columns = e.viewport?.columns ?? columns + if (awaited !== null) { + const trigger = awaited + + awaited = null + void showKept(host, trigger).catch(() => undefined) + } + /** * A seat that changed since the last drawing lists other rows, whose * bodies are read once. @@ -796,14 +821,18 @@ export function register(on: On) { const isOpening = toggle === 'open' - const isDone = isOpening + const outcome = isOpening ? await openPane(host, 'manual') - : await closePane(host).then(() => true) + : await closePane(host).then(() => 'placed' as const) - if (!isDone) { + if (outcome === 'withdrawn') { return { text: Names.RESIZE_TERMINAL_TEXT } } + if (outcome === 'awaited') { + return { text: Names.AWAITS_SURFACE_TEXT } + } + if (!isFullscreen) { return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT } } @@ -841,6 +870,7 @@ export function register(on: On) { if (isClosed) { isPaneOpen = false + awaited = null } const isDocking = model.isFullscreen !== false @@ -904,7 +934,7 @@ export function register(on: On) { } const isResume = e.command === 'resume' - const isKeptOpen = isPaneOpen && !isResume + const isKeptOpen = isPaneSeen() && !isResume if (isPaneOpen && isResume) { await closePane(host).catch(() => undefined) @@ -924,10 +954,7 @@ export function register(on: On) { (isResume ? sessionStartMs : await host.now()) if (isKeptOpen) { - await markShown(host, 'manual') - await pinBackend(host) - keepBaseline() - void refresh(host) + await showKept(host, 'manual') } if (isResume) { @@ -956,7 +983,7 @@ export function register(on: On) { landed += 1 } - if (hasLanded && isPaneOpen) { + if (hasLanded && isPaneSeen()) { scheduleRefresh(engine) } diff --git a/mods/diff/tests/fixtures/beneath/beneath.ts b/mods/diff/tests/fixtures/beneath/beneath.ts index f51ad9b26b..f8d169fffe 100644 --- a/mods/diff/tests/fixtures/beneath/beneath.ts +++ b/mods/diff/tests/fixtures/beneath/beneath.ts @@ -35,6 +35,12 @@ export type Beneath = { */ isLeftWaiting?: () => boolean + /** + * Whether an open made now is kept with nothing attached to draw it + * (LEFT_UNSEEN), as before a host's page attaches; placed when not given. + */ + isLeftUnseen?: () => boolean + /** * Why a hunks read asked now is refused, as the host refuses a git that * outran its timeout; answered from the script when null or not given. diff --git a/mods/diff/tests/fixtures/beneath/index.ts b/mods/diff/tests/fixtures/beneath/index.ts index 14b085b5e9..1aeb0a316b 100644 --- a/mods/diff/tests/fixtures/beneath/index.ts +++ b/mods/diff/tests/fixtures/beneath/index.ts @@ -1,4 +1,5 @@ export type * from './beneath.js' +export * from './left-unseen.js' export * from './left-waiting.js' export * from './slow-diff-ms.js' diff --git a/mods/diff/tests/fixtures/beneath/left-unseen.ts b/mods/diff/tests/fixtures/beneath/left-unseen.ts new file mode 100644 index 0000000000..338df0d8e5 --- /dev/null +++ b/mods/diff/tests/fixtures/beneath/left-unseen.ts @@ -0,0 +1,16 @@ +import type { ResultOf } from 'claude-code' + +/** + * What an engine answers an open it keeps where nothing is attached to draw + * it yet: a host whose page has not attached, or one that never draws. + * + * Typed through `never` so it compiles against declarations that predate the + * answer, where `ui.open` resolves nothing. + */ +export const LEFT_UNSEEN: ResultOf['ui.open'] = { + value: { + isPlaced: false, + reason: + 'no-surface: no surface is attached ($.session.surfaces() is empty)', + } as never, +} diff --git a/mods/diff/tests/fixtures/in-repository.ts b/mods/diff/tests/fixtures/in-repository.ts index 3094adac4f..7ef62ef20d 100644 --- a/mods/diff/tests/fixtures/in-repository.ts +++ b/mods/diff/tests/fixtures/in-repository.ts @@ -12,16 +12,18 @@ import { startsSession } from './starts-session.js' * A session in a repository git answers for from a script (REPOSITORY, in * /work, when none is given), keeping what the plugin does there. * - * Kept: each git run, ring move, pane opened, left waiting or closed, status - * line. The clock starts at 0 and the engine draws the hint; the rest of the - * world is the test's (Beneath). Rewriting the script changes git's answers. + * Kept: each git run, ring move, pane opened, left waiting, left unseen or + * closed, status line. The clock starts at 0 and the engine draws the hint. + * + * The rest of the world is the test's (Beneath). Rewriting the script changes + * git's answers. * * @param on the test's `on` * @param script git's output for each invocation whose line holds the key * @param beneath the store, settings, environment, transcript, whether an * open is seated, whether a hunks read is refused - * @returns the runs, the ring's moves, the panes opened, left waiting and - * closed, the statuses, the clock + * @returns the runs, the ring's moves, the panes opened, left waiting, left + * unseen and closed, the statuses, the clock */ export function inRepository( on: On, @@ -32,6 +34,7 @@ export function inRepository( const focused: Args<'ui.focus'>[] = [] const statuses: (string | undefined)[] = [] const waiting: Args<'ui.open'>[] = [] + const unseen: Args<'ui.open'>[] = [] const opened = keeping>() const closed = keeping>() const clock = startsSession(on) @@ -59,6 +62,13 @@ export function inRepository( on('ui.open', (engine, e) => { const isWaiting = beneath.isLeftWaiting?.() === true + const isUnseen = beneath.isLeftUnseen?.() === true + + if (isUnseen) { + unseen.push(e) + + return Beneath.LEFT_UNSEEN + } if (!isWaiting) { return opened.hook(engine, e) @@ -82,6 +92,7 @@ export function inRepository( focused, opened: opened.kept, waiting, + unseen, closed: closed.kept, statuses, clock, diff --git a/mods/diff/tests/open-outcome/open-outcome-of.test.ts b/mods/diff/tests/open-outcome/open-outcome-of.test.ts new file mode 100644 index 0000000000..af46f942f7 --- /dev/null +++ b/mods/diff/tests/open-outcome/open-outcome-of.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import OpenOutcome from '../../hooks/open-outcome' +import Fixtures from '../fixtures' + +tier('builtin') + +describe('open-outcome-of', () => { + test('a placed pane, and an engine that resolves nothing, are placed', () => { + expect(OpenOutcome.openOutcomeOf({ isPlaced: true })).toBe('placed') + expect(OpenOutcome.openOutcomeOf(undefined)).toBe('placed') + }) + + test('left waiting it is withdrawn, with a reason or none', () => { + expect(OpenOutcome.openOutcomeOf(Fixtures.LEFT_WAITING.value)).toBe( + 'withdrawn', + ) + + expect(OpenOutcome.openOutcomeOf({ isPlaced: false })).toBe('withdrawn') + }) + + test('with nothing attached to draw it yet it is awaited', () => { + expect(OpenOutcome.openOutcomeOf(Fixtures.LEFT_UNSEEN.value)).toBe( + 'awaited', + ) + }) +}) diff --git a/mods/diff/tests/register.test.ts b/mods/diff/tests/register.test.ts index 65ab8de379..be3a40a1c8 100644 --- a/mods/diff/tests/register.test.ts +++ b/mods/diff/tests/register.test.ts @@ -701,6 +701,101 @@ describe('register', () => { ).toEqual(['diff']) }) + test('an open nothing draws yet is kept until drawn', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { + isLeftUnseen: () => true, + }) + + const logged = Fixtures.keeping>() + + on('telemetry.log', logged.hook) + on('session.id', () => ({ value: 'first' })) + + await $.session.start(Fixtures.SESSION) + + expect( + await $.command.run(Fixtures.DIALOG_DIFF), + 'said as it is, with no word of a terminal to resize', + ).toEqual({ text: Names.AWAITS_SURFACE_TEXT }) + + await world.clock.advance(Limits.HEAD_POLL_MS + Fixtures.SETTLE_MS) + + expect( + world.unseen.map(pane => pane.id), + '/diff opened, then fitted the rows to what it read', + ).toEqual(['diff', 'diff']) + + expect(world.closed, 'and kept what it asked for').toEqual([]) + + expect( + world.runs + .map(run => Fixtures.gitWordOf(run.argv)) + .filter(word => word === Fixtures.POLL_WORD), + 'HEAD was read once at the open, as ever; no poll for a pane no one sees', + ).toEqual([Fixtures.POLL_WORD]) + + expect(logged.kept, 'nor is it counted shown').toEqual([]) + + const read = world.runs.length + + expect( + Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE)), + 'the first drawing has what the open read', + ).toContain('app.ts') + + await world.clock.advance(Fixtures.SETTLE_MS) + + expect( + world.runs.length, + 'and reads again, for what changed while no one saw', + ).toBeGreaterThan(read) + + expect( + logged.kept.map(shown => shown.props?.trigger), + 'and counted it, as the person asked for it', + ).toEqual([{ value: 'manual', of: [...Record.SHOWN_TRIGGERS] }]) + + expect( + await $.command.run(Fixtures.DIALOG_DIFF), + 'believed open all along, so /diff closes it', + ).toEqual({ text: 'Diff dialog dismissed' }) + }) + + test('while it waits unseen an edit reads nothing', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, { + stored: { [Names.STORE_OPEN_KEY]: true }, + isLeftUnseen: () => true, + }) + + const edit = () => + $.tool.call({ + tool: 'Edit', + file_path: '/work/app.ts', + old_string: '1', + new_string: '2', + }) + + on('tool.call', () => ({ result: 'edited' })) + + await $.session.start(Fixtures.SESSION) + await $.ui.render(Fixtures.HINT) + await edit() + await world.clock.advance(Fixtures.SETTLE_MS) + + const read = world.runs.length + + await edit() + await world.clock.advance(Limits.HEAD_POLL_MS + Fixtures.SETTLE_MS) + + expect( + world.unseen.map(pane => pane.id), + 'the first edit opened, and the second found it open', + ).toEqual(['diff']) + + expect(world.closed, 'kept for whatever attaches').toEqual([]) + expect(world.runs.length, 'git is left alone meanwhile').toBe(read) + }) + test('a docked pane opens once its first fetch settled', async ($, on) => { const opened: string[] = [] const clock = Fixtures.startsSession(on)