Skip to content
2 changes: 1 addition & 1 deletion mods/diff/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "diff",
"version": "0.1.0",
"description": "The diff pane as a plugin: /diff opens the session's uncommitted changes beside the transcript, file by file with their hunks, refreshed as Claude edits and runs commands; the first edit opens it where the terminal is wide enough, and a file's ask button rides its hunks on the next prompt.",
"description": "The diff pane as a plugin: /diff opens the session's uncommitted changes beside the transcript, file by file with their hunks, refreshed as Claude edits and runs commands; the first edit it has a change to list for opens it where the terminal is wide enough, and a file's ask button rides its hunks on the next prompt.",
"author": {
"name": "Anthropic"
}
Expand Down
42 changes: 23 additions & 19 deletions mods/diff/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,22 @@ built-in's list keys (`ctrl+up`/`ctrl+down`, `opt+up`/`opt+down`), and
through Buttons that declare the engine's own actions. The pane refreshes
as Claude edits and runs shell commands, and while it is open it polls
the repository's HEAD so a commit or checkout made elsewhere shows too.
The main loop's first successful edit of a session opens the pane by
itself, as the built-in panel opens on its first checkpoint: where the
layout docks it beside the transcript (the fullscreen layout, which each
drawing's `viewport` says), the terminal is wide enough (144 columns when
the person never chose, 110 when they kept it open before; a person who
closed it is left alone) and file checkpointing is on; a subagent's edit
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.
The main loop's first successful edit of a session the diff has a file to
list for opens the pane by itself, as the built-in panel opens on its first
checkpoint: where the layout docks it beside the transcript (the fullscreen
layout, which each drawing's `viewport` says), the terminal is wide enough
(144 columns when the person never chose, 110 when they kept it open
before; a person who closed it is left alone) and file checkpointing is on;
a subagent's edit opens nothing, and where the surface does not say,
nothing opens by itself. An edit to a file outside the repository, or one
after which the diff lists nothing or cannot be read, opens nothing and
leaves the opening to a later edit. 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.

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
Expand Down Expand Up @@ -58,10 +61,11 @@ one `git rev-parse`, in the directory the session started in, when `/diff`
or the first edit a pane has room to open on first needs the repository
(an answer of no repository is kept too, until `/clear` or `/resume`
forgets it); and the working tree is read only by a fetch for a pane that
is open, after an edit that landed or a shell command that ran. The one
read the built-in has no counterpart for is a `git status` at a pane's
first fetch, which stands in for the change time the built-in dates a
moved file by.
is open, after an edit that landed or a shell command that ran, or by the
fetch each edit inside the repository, with room for a pane, makes until
one lists a file and the pane opens on it. The one read the built-in has
no counterpart for is a `git status` at the first of those fetches, which
stands in for the change time the built-in dates a moved file by.

`hooks/register.ts` is the module; everything under `hooks/` is its parts.

Expand All @@ -70,14 +74,14 @@ moved file by.
| event | what the hook does |
| --- | --- |
| `session.start` | Binds the engine once and registers `/diff` (a session where another `/diff` is listed leaves the plugin idle); asks nothing of the repository, which `/diff` or the first edit pins when it comes; off its dispatch, reads the transcript, and for a resumed session whose turns edited opens the pane as the first edit would. |
| `ui.render` of `PromptHint` | Reads the terminal's width and whether its layout docks a pane, which decide whether the first edit opens the pane. |
| `ui.render` of `PromptHint` | Reads the terminal's width and whether its layout docks a pane, which decide whether the first edit may open the pane. |
| `ui.render` of `Pane` | Draws the pane: docked, the header, base line, source picker, file list and toggles over the window of hunks; inline, the dialog. |
| `command.run` of `diff` | Pins the repository when none is, opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. |
| `ui.close` of the pane | Backs out of the dialog's detail view instead of closing; else remembers the person's close as `/diff`'s. |
| `ui.scroll` of the pane | Docked, moves the hunks under the pinned header and list (three rows a wheel tick, a page a page key), or the list when the wheel is over it, and keeps the engine's window still. |
| `ui.focus` in the pane | In the dialog's list, selects the file the ring lands on, re-centres the five rows on it, and lands the ring where that row now sits. |
| `command.run` of `clear`, `resume` | Closes the pane and forgets the session's state, the pinned repository with it. |
| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the main loop's first such edit opens it, pinning the repository then if the terminal has the room and checkpointing is on. |
| `tool.call` of `Edit`, `Write`, `NotebookEdit` | After an edit that landed (not refused, not failed), refreshes an open pane; the main loop's first such edit inside the repository whose fetch lists a file opens it on that fetch, pinning the repository then if the terminal has the room and checkpointing is on. |
| `tool.call` of `Bash`, `PowerShell` | After a command that was not refused, failed and interrupted ones too, refreshes an open pane. |
| `prompt.submit` | Adds the armed file's hunks to the prompt's context and disarms. |

Expand Down
2 changes: 1 addition & 1 deletion mods/diff/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"description": "The diff pane: /diff and its Pane drawing, a refresh on Claude's edits, shell commands and finished turns, the pane's opening on the first edit, and the ask that rides a file's hunks on the next prompt",
"description": "The diff pane: /diff and its Pane drawing, a refresh on Claude's edits, shell commands and finished turns, the pane's opening on the first edit with a change to list, and the ask that rides a file's hunks on the next prompt",
"modules": ["./register.ts"]
}
1 change: 1 addition & 0 deletions mods/diff/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './git'
export * from './host'
export * from './is-checkpointing'
export * from './is-on-pane-surface'
export * from './is-outside-working-tree'
export * from './is-record'
export * from './kept-of'
export * from './limits'
Expand Down
4 changes: 4 additions & 0 deletions mods/diff/hooks/is-outside-working-tree/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './is-outside-working-tree.js'
export * from './normal-path-of.js'

export * as default from '.'
29 changes: 29 additions & 0 deletions mods/diff/hooks/is-outside-working-tree/is-outside-working-tree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Git from '../git'
import { normalPathOf } from './normal-path-of.js'

/**
* Whether a file a tool edited lies outside the pinned working tree, by
* how the paths are spelled alone (normalPathOf): no row could show it.
*
* A relative path is read from the session's directory. Where that
* directory is itself not spelled under the top (a symbolic link on the
* way to it), spelling proves nothing and nothing is outside.
*
* @param path the path the tool was called with
* @param tree the session's directory and the working tree's top
* @returns true only for a path surely outside the tree
*/
export function isOutsideWorkingTree(
path: string,
tree: { cwd: string; toplevel: string },
): boolean {
const top = normalPathOf(tree.toplevel)
const absolute = Git.isAbsolutePath(path) ? path : `${tree.cwd}/${path}`

const isUnderTop = (spelled: string) =>
top === '' || spelled === top || spelled.startsWith(`${top}/`)

return (
isUnderTop(normalPathOf(tree.cwd)) && !isUnderTop(normalPathOf(absolute))
)
}
25 changes: 25 additions & 0 deletions mods/diff/hooks/is-outside-working-tree/normal-path-of.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* An absolute path spelled one way, its names joined by `/` with no
* leading or trailing one: either separator splits, `.` and empty names
* drop, `..` drops the name before it. No disk is read, no link followed.
*
* A drive-letter path is lowercased, as its file system compares names.
*
* @param path an absolute path, POSIX or drive-letter
* @returns the names joined, empty for the root
*/
export function normalPathOf(path: string): string {
const names: string[] = []

for (const name of path.split(/[\\/]/)) {
if (name === '..') {
names.pop()
} else if (name !== '' && name !== '.') {
names.push(name)
}
}

const spelled = names.join('/')

return /^[A-Za-z]:/.test(spelled) ? spelled.toLowerCase() : spelled
}
22 changes: 22 additions & 0 deletions mods/diff/hooks/pane-state/has-session-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type Git from '../git'

/**
* Whether a fetch settled with a file the header would count, so a pane
* opened on it lists something where emptyStateOf would find a headline.
*
* No repository, a diff git could not read, and rows that all predate the
* session count nothing.
*
* @param outcome how the fetch ended
* @returns whether the header's session file count is above zero
*/
export function hasSessionFiles(outcome: Git.FetchOutcome): boolean {
if (outcome.kind !== 'data') {
return false
}

const { stats, files } = outcome.data
const before = files.filter(file => file.isPreSession)

return stats.filesCount - before.length > 0
}
1 change: 1 addition & 0 deletions mods/diff/hooks/pane-state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './dialog-title'
export * from './dialog-title-of.js'
export * from './empty-state'
export * from './empty-state-of.js'
export * from './has-session-files.js'
export * from './header-totals'
export * from './header-totals-of.js'
export * from './initial-model'
Expand Down
Loading
Loading