diff --git a/mods/diff/.claude-plugin/plugin.json b/mods/diff/.claude-plugin/plugin.json new file mode 100644 index 0000000000000000000000000000000000000000..6ccd58321752baed05346480e073d2e7cb33ce39 --- /dev/null +++ b/mods/diff/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "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.", + "author": { + "name": "Anthropic" + } +} diff --git a/mods/diff/hooks/array-of/array-of.ts b/mods/diff/hooks/array-of/array-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..f92993a4d3f604600e829cc90762db8a0d646569 --- /dev/null +++ b/mods/diff/hooks/array-of/array-of.ts @@ -0,0 +1,9 @@ +/** + * A value a tool stored as the array it is, or an empty one when it is + * anything else. + * + * @param value a field of a tool's input or result + * @returns the array's items, unknown each + */ +export const arrayOf = (value: unknown): readonly unknown[] => + Array.isArray(value) ? value : [] diff --git a/mods/diff/hooks/array-of/index.ts b/mods/diff/hooks/array-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ccf2dc564f78f2d922f32ac339820eaee0cbc2b --- /dev/null +++ b/mods/diff/hooks/array-of/index.ts @@ -0,0 +1,3 @@ +export * from './array-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/ask/armed-ask-of.ts b/mods/diff/hooks/ask/armed-ask-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0a64d84d15f71a1386f38d8339d02c644df0d8e --- /dev/null +++ b/mods/diff/hooks/ask/armed-ask-of.ts @@ -0,0 +1,31 @@ +import type Git from '../git' +import Limits from '../limits' +import type { ArmedAsk } from './armed-ask' + +/** + * A file's diff as the context block the next prompt carries when the + * person presses ask. + * + * A line saying where it came from, then the hunks in unified form, cut at + * MAX_LINES_PER_FILE lines. + * + * @param path the file's repository-relative path + * @param hunks its hunks as the pane shows them + * @returns the armed ask + */ +export function armedAskOf(path: string, hunks: readonly Git.Hunk[]): ArmedAsk { + const body = hunks + .flatMap(hunk => [ + `@@ -${hunk.oldStart} +${hunk.newStart} @@`, + ...hunk.lines, + ]) + .slice(0, Limits.MAX_LINES_PER_FILE) + + return { + path, + text: + `The user attached the diff of ${path} from the diff pane to this ` + + `prompt:\n${body.join('\n')}`, + lines: body.length, + } +} diff --git a/mods/diff/hooks/ask/fitted-ask-text-of.ts b/mods/diff/hooks/ask/fitted-ask-text-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..b06a53a94a5ef6ef1fe67641c0cdbf9ae67a4b01 --- /dev/null +++ b/mods/diff/hooks/ask/fitted-ask-text-of.ts @@ -0,0 +1,37 @@ +import { ASK_CUT_NOTE } from './ask-cut-note' + +/** + * An armed ask's text as it fits in the room a prompt's context has left: + * whole when it fits, else its first lines and ASK_CUT_NOTE. + * + * @param text the armed ask's text + * @param room the characters the context has left + * @returns the text to attach, or undefined when not even its first line fits + */ +export function fittedAskTextOf( + text: string, + room: number, +): string | undefined { + if (text.length <= room) { + return text + } + + const kept: string[] = [] + + let used = ASK_CUT_NOTE.length + + for (const line of text.split('\n')) { + const cost = line.length + 1 + + if (used + cost > room) { + break + } + + kept.push(line) + used += cost + } + + const hasBody = kept.length > 1 + + return hasBody ? `${kept.join('\n')}\n${ASK_CUT_NOTE}` : undefined +} diff --git a/mods/diff/hooks/ask/index.ts b/mods/diff/hooks/ask/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..db49ce7a7b2ee144c6ce98a6b1bd76093a4d8b0f --- /dev/null +++ b/mods/diff/hooks/ask/index.ts @@ -0,0 +1,6 @@ +export * from './armed-ask' +export * from './armed-ask-of.js' +export * from './ask-cut-note' +export * from './fitted-ask-text-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/backend/backend-of.ts b/mods/diff/hooks/backend/backend-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..871284fa7fca1a709623e8a0a20e3792f6f781f3 --- /dev/null +++ b/mods/diff/hooks/backend/backend-of.ts @@ -0,0 +1,30 @@ +import Git from '../git' +import type { Backend, BackendHost, BackendProbe } from './types' + +/** + * The backend holding the session's directory: another system's when its + * probe answers first, else git when a git working tree holds it, else none. + * + * Other systems are asked first so one may claim a working copy by its own + * rule (the nearer of two markers, say); each answers null wherever git + * should govern. + * + * @param host the bound host's runner and probes + * @param probes probes for other version-control systems, in order + * (INSTALLED_BACKEND_PROBES at the pane; none as published) + * @returns the pinned backend, or null outside any working tree + */ +export async function backendOf( + host: BackendHost, + probes: Iterable, +): Promise { + for (const probe of probes) { + const backend = await probe(host) + + if (backend) { + return backend + } + } + + return Git.gitBackendOf(host) +} diff --git a/mods/diff/hooks/backend/index.ts b/mods/diff/hooks/backend/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..69344a6e34e7822a9a1217d1e250271c13125e1c --- /dev/null +++ b/mods/diff/hooks/backend/index.ts @@ -0,0 +1,5 @@ +export * from './backend-of.js' +export * from './installed-backend-probes.js' +export * from './types' + +export * as default from '.' diff --git a/mods/diff/hooks/backend/installed-backend-probes.ts b/mods/diff/hooks/backend/installed-backend-probes.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9c9670efa3dbf85a2c1ed276c4869fffd81de11 --- /dev/null +++ b/mods/diff/hooks/backend/installed-backend-probes.ts @@ -0,0 +1,12 @@ +import type { BackendProbe } from './types' + +/** + * Where a host adds backends: probes for version-control systems other than + * git, asked in insertion order before git (backendOf). Empty as shipped. + * + * A host compiling the module in adds a probe once, before the plugin + * registers: the module runs natively, and there is no other channel (the + * options are the manifest's userConfig; the scan wants a plain function). + */ +// module-state-allow: module-evaluation code wiring — a compiling host adds its probe functions (code references, not runtime input) once before registration and never removes or rebinds them; tests pass probes to backendOf directly; a per-Host copy would hold the same functions +export const INSTALLED_BACKEND_PROBES = new Set() diff --git a/mods/diff/hooks/classify/index.ts b/mods/diff/hooks/classify/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9a1d20c07b6d1698331baafa23194d2c1319105 --- /dev/null +++ b/mods/diff/hooks/classify/index.ts @@ -0,0 +1,7 @@ +export * from './base-name-of' +export * from './is-generated-file' +export * from './is-noise-file.js' +export * from './is-test-file' +export * from './rooted-path-of' + +export * as default from '.' diff --git a/mods/diff/hooks/classify/is-noise-file.ts b/mods/diff/hooks/classify/is-noise-file.ts new file mode 100644 index 0000000000000000000000000000000000000000..3dfa1a5514218fd6f8e70350fd1f04f1e1597d41 --- /dev/null +++ b/mods/diff/hooks/classify/is-noise-file.ts @@ -0,0 +1,12 @@ +import { isGeneratedFile } from './is-generated-file' +import { isTestFile } from './is-test-file' + +/** + * Whether a changed file is hidden behind the pane's tests-and-generated + * line until the person asks to see it. + * + * @param path the file's path from the repository root + * @returns whether it is a test or a generated file + */ +export const isNoiseFile = (path: string) => + isTestFile(path) || isGeneratedFile(path) diff --git a/mods/diff/hooks/command-spec/command-spec.ts b/mods/diff/hooks/command-spec/command-spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b4ea85a620bb08332d9b144b482cadb34d08d2a --- /dev/null +++ b/mods/diff/hooks/command-spec/command-spec.ts @@ -0,0 +1,10 @@ +import Names from '../names' + +/** + * What `session.start` hands `$.command.register`: the one place the + * `/diff` spec is built, where a later CommandSpec field joins. + */ +export const COMMAND_SPEC = { + name: Names.COMMAND_NAME, + description: Names.COMMAND_DESCRIPTION, +} as const diff --git a/mods/diff/hooks/command-spec/index.ts b/mods/diff/hooks/command-spec/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..861ab9b13025184e07e840cdb356eceae28fd069 --- /dev/null +++ b/mods/diff/hooks/command-spec/index.ts @@ -0,0 +1,3 @@ +export * from './command-spec.js' + +export * as default from '.' diff --git a/mods/diff/hooks/count-of/count-of.ts b/mods/diff/hooks/count-of/count-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..989649ed4311a4eb821d8f228a97b46666c150f2 --- /dev/null +++ b/mods/diff/hooks/count-of/count-of.ts @@ -0,0 +1,10 @@ +/** + * How many items of a list pass a test, without the intermediate array a + * filter would build. + * + * @param items the list + * @param passes the test + * @returns the number of passing items + */ +export const countOf = (items: readonly T[], passes: (item: T) => boolean) => + items.reduce((total, item) => total + (passes(item) ? 1 : 0), 0) diff --git a/mods/diff/hooks/count-of/index.ts b/mods/diff/hooks/count-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c6256c3886a40ef5f0145b70a2daa6adf444478 --- /dev/null +++ b/mods/diff/hooks/count-of/index.ts @@ -0,0 +1,3 @@ +export * from './count-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/drawn-files-of/drawn-files-of.ts b/mods/diff/hooks/drawn-files-of/drawn-files-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..3944253c7adfe3b88a0136850db7bf770ba7b09f --- /dev/null +++ b/mods/diff/hooks/drawn-files-of/drawn-files-of.ts @@ -0,0 +1,31 @@ +import type Git from '../git' +import Limits from '../limits' +import PaneState from '../pane-state' + +/** + * The files whose bodies the pane draws now, in drawing order: the listed + * session rows, then the pre-session rows while their section is open. + * + * Past PRE_SESSION_BODY_CAP pre-session files only their legend shows, so + * none of their bodies is wanted. + * + * @param model the pane's state: its fetch and its two toggles + * @returns the rows, none without a fetch + */ +export function drawnFilesOf( + model: Pick< + PaneState.PaneModel, + 'data' | 'isNoiseShown' | 'isPreSessionShown' + >, +): readonly Git.FileStat[] { + const partition = PaneState.partitionOf( + model.data?.files ?? [], + model.isNoiseShown ? 'shown' : 'hidden', + ) + + const isBodied = + model.isPreSessionShown && + partition.preSession.length <= Limits.PRE_SESSION_BODY_CAP + + return [...partition.shown, ...(isBodied ? partition.preSession : [])] +} diff --git a/mods/diff/hooks/drawn-files-of/index.ts b/mods/diff/hooks/drawn-files-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f9a4dfb5c34e78a58b0bad0dfc0e66a619a0831 --- /dev/null +++ b/mods/diff/hooks/drawn-files-of/index.ts @@ -0,0 +1,3 @@ +export * from './drawn-files-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/entry-kinds-of/index.ts b/mods/diff/hooks/entry-kinds-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..97b3c17456500f93dfb5e9fabb36c1d2ad81aee3 --- /dev/null +++ b/mods/diff/hooks/entry-kinds-of/index.ts @@ -0,0 +1,3 @@ +export * from './entry-kinds-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/hooks.json b/mods/diff/hooks/hooks.json new file mode 100644 index 0000000000000000000000000000000000000000..42164197dd51f1b05f62422c9d108e825e5f1a08 --- /dev/null +++ b/mods/diff/hooks/hooks.json @@ -0,0 +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", + "modules": ["./register.ts"] +} diff --git a/mods/diff/hooks/host/host.ts b/mods/diff/hooks/host/host.ts new file mode 100644 index 0000000000000000000000000000000000000000..59ae0e7b576ae0bd833c34f0df232a5cbf1c7338 --- /dev/null +++ b/mods/diff/hooks/host/host.ts @@ -0,0 +1,116 @@ +import type { + CommandSpec, + EngineInterface, + FsEntry, + FsStat, + PaneCloseArgs, + PaneOpenArgs, + ProcessRunInit, + ProcessRunResult, + SessionMessage, + TimerCall, +} from 'claude-code' + +/** + * The engine as `session.start` bound it from its `$`, each member spelled + * `$.noun.event(...)` there; used by every later hook, timer and press. + */ +export type Host = { + /** + * `$.clock.now`. + */ + now: () => Promise + + /** + * `$.clock.after`. + */ + after: TimerCall + + /** + * `$.clock.every`. + */ + every: TimerCall + + /** + * `$.process.run`. + */ + run: ( + argv: readonly string[], + init: ProcessRunInit, + ) => Promise + + /** + * `$.fs.stat`. + */ + stat: (path: string) => Promise + + /** + * `$.fs.list`: a directory's entries by kind, links never followed. + */ + listDir: (path: string) => Promise + + /** + * `$.fs.read`. + */ + readFile: (path: string) => Promise + + /** + * Reads the plugin's store (`$.store.get`). + */ + storeGet: (key: string) => Promise + + /** + * Writes the plugin's store (`$.store.set`). + */ + storeSet: (key: string, value: unknown) => Promise + + /** + * `$.session.messages`. + */ + messages: () => Promise + + /** + * `$.ui.invalidate("ui.render")`: every pane instance draws again. + */ + invalidate: () => void + + /** + * `$.ui.status`: the plugin's line under the prompt. + */ + status: (text: string | undefined) => void + + /** + * One debug line under the plugin's name (`$.ui.log`). + */ + uiLog: (text: string) => void + + /** + * `$.ui.open`. + */ + openPane: (pane: PaneOpenArgs) => Promise + + /** + * `$.ui.close`. + */ + closePane: (pane: PaneCloseArgs) => Promise + + /** + * `$.command.register`; rejects while another `/diff` is listed. + */ + registerCommand: (spec: CommandSpec) => Promise + + /** + * Which session the pane-shown row is latched to, as `$.session.id` says. + */ + sessionId: () => Promise + + /** + * `$.telemetry.mark`; rejects where the telemetry built-in is absent. + */ + mark: EngineInterface['telemetry']['mark'] + + /** + * `$.telemetry.log`; rejects where the telemetry built-in is absent. + */ + log: EngineInterface['telemetry']['log'] +} diff --git a/mods/diff/hooks/host/index.ts b/mods/diff/hooks/host/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5abe18a84a75f673885372a3525ec02fa19fd881 --- /dev/null +++ b/mods/diff/hooks/host/index.ts @@ -0,0 +1,3 @@ +export type * from './host.js' + +export * as default from '.' diff --git a/mods/diff/hooks/index.ts b/mods/diff/hooks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..66550f5fb154df8a0fff18b716f986b6738fdbd7 --- /dev/null +++ b/mods/diff/hooks/index.ts @@ -0,0 +1,28 @@ +export * from './array-of' +export * from './ask' +export * from './backend' +export * from './classify' +export * from './command-spec' +export * from './count-of' +export * from './drawn-files-of' +export * from './entry-kinds-of' +export * from './git' +export * from './host' +export * from './is-on-pane-surface' +export * from './is-record' +export * from './kept-of' +export * from './limits' +export * from './map-limited' +export * from './message-of' +export * from './mtime-of' +export * from './names' +export * from './pane-state' +export * from './pane-toggle' +export * from './record' +export * from './register.js' +export * from './todos' +export * from './tools' +export * from './turns' +export * from './views' + +export * as default from '.' diff --git a/mods/diff/hooks/is-on-pane-surface/index.ts b/mods/diff/hooks/is-on-pane-surface/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f9fce2e788d607683ba0ed69c662e98cef06942e --- /dev/null +++ b/mods/diff/hooks/is-on-pane-surface/index.ts @@ -0,0 +1,3 @@ +export * from './is-on-pane-surface.js' + +export * as default from '.' diff --git a/mods/diff/hooks/is-on-pane-surface/is-on-pane-surface.ts b/mods/diff/hooks/is-on-pane-surface/is-on-pane-surface.ts new file mode 100644 index 0000000000000000000000000000000000000000..63025caff1b02979b15752323532d8f84fe6fe6c --- /dev/null +++ b/mods/diff/hooks/is-on-pane-surface/is-on-pane-surface.ts @@ -0,0 +1,12 @@ +import type { RenderSurface } from 'claude-code' + +/** + * Whether a render event comes from a surface that draws the pane: every + * one but the mobile app, which has no Select and is not terminal-wide. + * + * @param e the render event, or anything naming its surface + * @returns false on `mobile`, true otherwise, narrowing `e` to the rest + */ +export const isOnPaneSurface = >( + e: E, +): e is Exclude> => e.surface !== 'mobile' diff --git a/mods/diff/hooks/is-record/index.ts b/mods/diff/hooks/is-record/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3255fa137aac45bd1e3619cd5b89e35bb672011 --- /dev/null +++ b/mods/diff/hooks/is-record/index.ts @@ -0,0 +1,3 @@ +export * from './is-record.js' + +export * as default from '.' diff --git a/mods/diff/hooks/is-record/is-record.ts b/mods/diff/hooks/is-record/is-record.ts new file mode 100644 index 0000000000000000000000000000000000000000..aebdb34e0304fca5f0f16468e70ec8d32f4012fd --- /dev/null +++ b/mods/diff/hooks/is-record/is-record.ts @@ -0,0 +1,8 @@ +/** + * Whether a value is a plain object that can be read field by field. + * + * @param value anything a tool result may be + * @returns whether it is a non-null, non-array object + */ +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) diff --git a/mods/diff/hooks/kept-of/index.ts b/mods/diff/hooks/kept-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..679992a36027f3e9c4191e115135083ab61f7d41 --- /dev/null +++ b/mods/diff/hooks/kept-of/index.ts @@ -0,0 +1,3 @@ +export * from './kept-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/limits/dialog-path-floor.ts b/mods/diff/hooks/limits/dialog-path-floor.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ec5a38433a9efafcf4541e801111e08eac1adc9 --- /dev/null +++ b/mods/diff/hooks/limits/dialog-path-floor.ts @@ -0,0 +1,5 @@ +/** + * The fewest cells an inline file row gives its path, however narrow the + * pane (DiffFileList's 20). + */ +export const DIALOG_PATH_FLOOR = 20 diff --git a/mods/diff/hooks/limits/index.ts b/mods/diff/hooks/limits/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5f06d81e6c83739db30c86ffdce6a498fa37228 --- /dev/null +++ b/mods/diff/hooks/limits/index.ts @@ -0,0 +1,8 @@ +export * from './columns' +export * from './dialog-path-floor.js' +export * from './sizes' +export * from './timing' +export * from './todo-bar-cells.js' +export * from './todo-bar-reserve.js' + +export * as default from '.' diff --git a/mods/diff/hooks/limits/todo-bar-cells.ts b/mods/diff/hooks/limits/todo-bar-cells.ts new file mode 100644 index 0000000000000000000000000000000000000000..364678a51d06275d790ec29a0ec5abb5793b0b42 --- /dev/null +++ b/mods/diff/hooks/limits/todo-bar-cells.ts @@ -0,0 +1,4 @@ +/** + * The widest the todo progress bar draws (ReplDiffSidebar's 20). + */ +export const TODO_BAR_CELLS = 20 diff --git a/mods/diff/hooks/limits/todo-bar-reserve.ts b/mods/diff/hooks/limits/todo-bar-reserve.ts new file mode 100644 index 0000000000000000000000000000000000000000..24b170853963969b18016117e5cfe74380029baa --- /dev/null +++ b/mods/diff/hooks/limits/todo-bar-reserve.ts @@ -0,0 +1,4 @@ +/** + * The cells kept beside the todo bar for its `done/total` count. + */ +export const TODO_BAR_RESERVE = 12 diff --git a/mods/diff/hooks/map-limited/index.ts b/mods/diff/hooks/map-limited/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..45383b7ef100c41b86f10d5fd8f32e4b9a2de206 --- /dev/null +++ b/mods/diff/hooks/map-limited/index.ts @@ -0,0 +1,3 @@ +export * from './map-limited.js' + +export * as default from '.' diff --git a/mods/diff/hooks/map-limited/map-limited.ts b/mods/diff/hooks/map-limited/map-limited.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8d11b718844747a306941c5697745970747c8c5 --- /dev/null +++ b/mods/diff/hooks/map-limited/map-limited.ts @@ -0,0 +1,30 @@ +/** + * Maps every item through an async function, a bounded number of them in + * flight at once, and resolves to the results in the items' order. + * + * @param items the inputs + * @param limit how many may run at once, at least one + * @param fn the work for one item + * @returns the results, in order + */ +export async function mapLimited( + items: readonly T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + const results: R[] = [] + + let next = 0 + + async function worker(): Promise { + for (let at = next++; at < items.length; at = next++) { + results[at] = await fn(items[at] as T) + } + } + + await Promise.all( + Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker), + ) + + return results +} diff --git a/mods/diff/hooks/mtime-of/index.ts b/mods/diff/hooks/mtime-of/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6298941ef7af4ce60b4765a1116145988556e837 --- /dev/null +++ b/mods/diff/hooks/mtime-of/index.ts @@ -0,0 +1,3 @@ +export * from './mtime-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/mtime-of/mtime-of.ts b/mods/diff/hooks/mtime-of/mtime-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..80e175fbde97528764113c1c4bc0a5a05d35994a --- /dev/null +++ b/mods/diff/hooks/mtime-of/mtime-of.ts @@ -0,0 +1,16 @@ +import type { Host } from '../host' + +/** + * A path's modification time through the host, or null when the path + * cannot be read (GitDeps `mtimeOf`). + * + * @param engine the bound host + * @returns the probe + */ +export const mtimeOf = + (engine: Host) => + (path: string): Promise => + engine.stat(path).then( + stat => stat.mtimeMs, + () => null, + ) diff --git a/mods/diff/hooks/names/command-description.ts b/mods/diff/hooks/names/command-description.ts new file mode 100644 index 0000000000000000000000000000000000000000..780af8520ab3a15be8f69ded6b9dc1408fa54fd2 --- /dev/null +++ b/mods/diff/hooks/names/command-description.ts @@ -0,0 +1,6 @@ +/** + * What the typeahead and `/help` show for `/diff` (the built-in's fullscreen + * wording: the pane is the panel's successor). + */ +export const COMMAND_DESCRIPTION = + 'Toggle the diff panel showing uncommitted changes' diff --git a/mods/diff/hooks/names/command-name.ts b/mods/diff/hooks/names/command-name.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d9143f81d66d41162f190e546d7cc1d17f6a7b0 --- /dev/null +++ b/mods/diff/hooks/names/command-name.ts @@ -0,0 +1,4 @@ +/** + * The slash command the plugin serves once the built-in `/diff` stands down. + */ +export const COMMAND_NAME = 'diff' diff --git a/mods/diff/hooks/names/index.ts b/mods/diff/hooks/names/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..70e4a6321a48e3f0ab3b4e0c13813c0061f43925 --- /dev/null +++ b/mods/diff/hooks/names/index.ts @@ -0,0 +1,9 @@ +export * from './command-description.js' +export * from './command-name.js' +export * from './keys' +export * from './pane-id.js' +export * from './pane-title.js' +export * from './plugin-name.js' +export * from './texts' + +export * as default from '.' diff --git a/mods/diff/hooks/names/pane-id.ts b/mods/diff/hooks/names/pane-id.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3a550edb36da6c9b9987da7506557bf64309936 --- /dev/null +++ b/mods/diff/hooks/names/pane-id.ts @@ -0,0 +1,4 @@ +/** + * The one pane the plugin opens; its `requestId` at `ui.render`. + */ +export const PANE_ID = 'diff' diff --git a/mods/diff/hooks/names/pane-title.ts b/mods/diff/hooks/names/pane-title.ts new file mode 100644 index 0000000000000000000000000000000000000000..5040523cdfc1ae7d3ca756131e1b8aa4b2e3f0f1 --- /dev/null +++ b/mods/diff/hooks/names/pane-title.ts @@ -0,0 +1,4 @@ +/** + * The pane's title: its tab while another pane is open too. + */ +export const PANE_TITLE = 'Diff' diff --git a/mods/diff/hooks/names/plugin-name.ts b/mods/diff/hooks/names/plugin-name.ts new file mode 100644 index 0000000000000000000000000000000000000000..69f7113465bda47bf0defa225beabdaf80fecff1 --- /dev/null +++ b/mods/diff/hooks/names/plugin-name.ts @@ -0,0 +1,5 @@ +/** + * The plugin's own name, as the engine stamps it on the elements its render + * hooks draw (`ui.press`, `ui.focus`). + */ +export const PLUGIN_NAME = 'diff' diff --git a/mods/diff/hooks/pane-state/dialog-title-of.ts b/mods/diff/hooks/pane-state/dialog-title-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..84cf4805f880e8b7c00f437ab3a6b258baeb01de --- /dev/null +++ b/mods/diff/hooks/pane-state/dialog-title-of.ts @@ -0,0 +1,37 @@ +import type { DialogTitle } from './dialog-title' +import type { PaneModel } from './pane-model' +import { pickedTurnOf } from './picked-turn-of' +import { turnTitleOf } from './turn-title-of' + +/** + * The inline pane's heading (DiffDialog's title): the picked turn and its + * prompt's opening words, or what the working tree is compared with. + * + * An unborn HEAD lists staged and new files; a branch diff names its base; + * anything else is the uncommitted changes against HEAD. + * + * @param model the pane's state + * @returns the title and its dim subtitle + */ +export function dialogTitleOf(model: PaneModel): DialogTitle { + const turn = pickedTurnOf(model) + const source = model.data?.source + const isUnborn = model.data?.isUnborn === true + const isBranch = source?.kind === 'branch' + + function currentOf(): DialogTitle { + switch (true) { + case isUnborn: + return { title: 'Staged and new files', subtitle: '(no commits yet)' } + case isBranch && source?.kind === 'branch': + return { + title: 'Branch changes', + subtitle: `(vs ${source.baseBranch})`, + } + default: + return { title: 'Uncommitted changes', subtitle: '(git diff HEAD)' } + } + } + + return turn ? turnTitleOf(turn) : currentOf() +} diff --git a/mods/diff/hooks/pane-state/empty-state-of.ts b/mods/diff/hooks/pane-state/empty-state-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..81fa94ad7574f2ef557b13c7654cbbb83ccf13fd --- /dev/null +++ b/mods/diff/hooks/pane-state/empty-state-of.ts @@ -0,0 +1,70 @@ +import Names from '../names' +import type { EmptyState } from './empty-state' +import type { PaneModel } from './pane-model' + +/** + * The headline that replaces the count when nothing is listed, worded as + * ReplDiffSidebar words it, keyed on the mode the data was FETCHED in. + * + * Null while there are files to count. No data after a settled fetch is + * "Diff unavailable"; no tracked rows while the untracked listing was + * withheld claims nothing about new files. + * + * @param model the last good fetch (null when none ever settled), the words + * @param filesCount the header's session file count + * @returns the empty state, or null + */ +export function emptyStateOf( + model: Pick, + filesCount: number, +): EmptyState | null { + const { data, words } = model + + if (!data) { + return { + headline: 'Diff unavailable', + hint: + `Couldn't read the ${words.diffCommand} — it will retry on the ` + + 'next change', + } + } + + if (filesCount > 0) { + return null + } + + if (data.isUntrackedWithheld) { + return { + headline: 'No tracked changes', + hint: Names.untrackedWithheldTextOf(model), + } + } + + if (data.isUnborn) { + return { + headline: 'No commits yet', + hint: "Nothing to diff against until the repo's first commit", + } + } + + switch (data.mode) { + case 'uncommitted': + return { headline: 'No uncommitted changes', hint: null } + case 'branch': + if (data.source.kind === 'branch') { + return { + headline: `No changes vs ${data.source.baseBranch}`, + hint: null, + } + } + + return { + headline: `No changes vs ${data.source.base}`, + hint: + 'No base branch to compare against — showing changes vs ' + + data.source.base, + } + case 'session': + return { headline: 'No changes this session', hint: null } + } +} diff --git a/mods/diff/hooks/pane-state/header-totals-of.ts b/mods/diff/hooks/pane-state/header-totals-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..615c4dc4717f3b738798dd1fdd66582d95d31cd2 --- /dev/null +++ b/mods/diff/hooks/pane-state/header-totals-of.ts @@ -0,0 +1,32 @@ +import type Git from '../git' +import type { HeaderTotals } from './header-totals' +import type { Partition } from './partition' + +/** + * The header's counts (ReplDiffSidebar): the fetched totals less the + * pre-session rows, and how many session files fell past the row cap. + * + * Files past the cap were never dated, so they always count and always + * show as `notShown`: an edit that sorts after fifty older ones stays seen. + * + * @param data the fetch + * @param partition its rows, grouped + * @returns the counts and how many session files are past the cap + */ +export function headerTotalsOf( + data: Git.DiffData, + partition: Partition, +): HeaderTotals { + const { stats, files } = data + const before = partition.preSession + const filesCount = stats.filesCount - before.length + + return { + filesCount, + linesAdded: + stats.linesAdded - before.reduce((sum, file) => sum + file.added, 0), + linesRemoved: + stats.linesRemoved - before.reduce((sum, file) => sum + file.removed, 0), + notShown: Math.max(0, filesCount - (files.length - before.length)), + } +} diff --git a/mods/diff/hooks/pane-state/index.ts b/mods/diff/hooks/pane-state/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..33e7edfaffea54bb9d980e2c3ccc133189f08ebd --- /dev/null +++ b/mods/diff/hooks/pane-state/index.ts @@ -0,0 +1,25 @@ +export * from './after-fetch' +export * from './after-new-session' +export * from './base-mode-of' +export * from './bodies' +export * from './dialog-focus' +export * from './dialog-title' +export * from './dialog-title-of.js' +export * from './empty-state' +export * from './empty-state-of.js' +export * from './header-totals' +export * from './header-totals-of.js' +export * from './initial-model' +export * from './mode-label-of' +export * from './no-bodies' +export * from './pane-model' +export * from './partition' +export * from './partition-of.js' +export * from './picked-turn-of' +export * from './source' +export * from './turn-title-of' +export * from './turn-totals-of' +export * from './unborn-note-of.js' +export * from './zero-totals' + +export * as default from '.' diff --git a/mods/diff/hooks/pane-state/partition-of.ts b/mods/diff/hooks/pane-state/partition-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..4173c3673b88408813b9e01bbe48b89a21150d55 --- /dev/null +++ b/mods/diff/hooks/pane-state/partition-of.ts @@ -0,0 +1,32 @@ +import Classify from '../classify' +import Git from '../git' +import type { Partition } from './partition' + +/** + * A fetch's rows split as ReplDiffSidebar splits them, in its order (by + * name): pre-session apart, tests and generated files hidden unless shown. + * + * No read-deny bucket: the pane feeds nothing to the model on its own. + * + * @param files the fetched rows + * @param noise `shown` keeps tests and generated files in the list + * @returns the groups + */ +export function partitionOf( + files: readonly Git.FileStat[], + noise: 'shown' | 'hidden', +): Partition { + const sorted = [...files].sort((a, b) => + Git.displayPathOf(a).localeCompare(Git.displayPathOf(b)), + ) + + const preSession = sorted.filter(file => file.isPreSession) + const session = sorted.filter(file => !file.isPreSession) + const quiet = session.filter(file => !Classify.isNoiseFile(file.path)) + + return { + shown: noise === 'shown' ? session : quiet, + preSession, + noiseCount: session.length - quiet.length, + } +} diff --git a/mods/diff/hooks/pane-state/unborn-note-of.ts b/mods/diff/hooks/pane-state/unborn-note-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ff674fd0c3f49ab50bf2d92e8c27de367448875 --- /dev/null +++ b/mods/diff/hooks/pane-state/unborn-note-of.ts @@ -0,0 +1,22 @@ +import type { PaneModel } from './pane-model' + +/** + * The dim line under the header on an unborn HEAD that still lists rows: + * what the rows are, since no comparison runs before the first commit. + * + * ReplDiffSidebar's no-commits qualifier; null with commits or no rows. + * + * @param model the last good fetch + * @param filesCount the header's session file count + * @returns the line, or null + */ +export function unbornNoteOf( + model: Pick, + filesCount: number, +): string | null { + const isUnbornListing = model.data?.isUnborn === true && filesCount > 0 + + return isUnbornListing + ? 'no commits yet — showing staged and new files' + : null +} diff --git a/mods/diff/hooks/record/index.ts b/mods/diff/hooks/record/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..25284729c497aee404dab1e61ef0423459233613 --- /dev/null +++ b/mods/diff/hooks/record/index.ts @@ -0,0 +1,12 @@ +export * from './base-outcomes' +export * from './features' +export * from './mark-outcome' +export * from './recorder' +export * from './recorder-of.js' +export * from './safely' +export * from './shown-triggers' +export * from './tabs' +export * from './width-bucket-of' +export * from './width-buckets' + +export * as default from '.' diff --git a/mods/diff/hooks/record/recorder-of.ts b/mods/diff/hooks/record/recorder-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3cc85dccbfada8078b1e05971de9cf81019a32f --- /dev/null +++ b/mods/diff/hooks/record/recorder-of.ts @@ -0,0 +1,31 @@ +import type { Host } from '../host' +import { FEATURES } from './features' +import type { Recorder } from './recorder' +import { safely } from './safely' +import { SHOWN_TRIGGERS } from './shown-triggers' +import { WIDTH_BUCKETS } from './width-buckets' + +/** + * The plugin's Recorder over the host: each row goes through the telemetry + * built-in's `$.telemetry`, named and shaped as the built-in panel's own. + * + * A missing noun or a refused row is dropped. + * + * @param host the engine as session.start bound it + * @returns the recorder + */ +export const recorderOf = (host: Host): Recorder => ({ + mark: (feature, outcome) => safely(() => host.mark({ feature, ...outcome })), + shown: (trigger, bucket) => + safely(() => + host.log({ + event: 'tengu_repl_diff_panel_shown', + props: { + trigger: { value: trigger, of: SHOWN_TRIGGERS }, + terminal_width_bucket: { value: bucket, of: WIDTH_BUCKETS }, + }, + }), + ), + asked: () => + safely(() => host.mark({ feature: FEATURES.selectionAttach, kind: 'ok' })), +}) diff --git a/mods/diff/hooks/register.ts b/mods/diff/hooks/register.ts new file mode 100644 index 0000000000000000000000000000000000000000..019db9dd162102443339113034acefd524292922 --- /dev/null +++ b/mods/diff/hooks/register.ts @@ -0,0 +1,907 @@ +import type { + On, + PaneOpenArgs, + ResultOf, + SessionMessage, + Timer, +} from 'claude-code' + +import Ask from './ask' +import Backend from './backend' +import { COMMAND_SPEC } from './command-spec' +import { drawnFilesOf } from './drawn-files-of' +import { entryKindsOf } from './entry-kinds-of' +import type Git from './git' +import type { Host } from './host' +import { isOnPaneSurface } from './is-on-pane-surface' +import Limits from './limits' +import { mapLimited } from './map-limited' +import { messageOf } from './message-of' +import { mtimeOf } from './mtime-of' +import Names from './names' +import PaneState from './pane-state' +import PaneToggle from './pane-toggle' +import Record from './record' +import Tools from './tools' +import Views from './views' + +/** + * Registers the diff pane: `/diff` once the built-in stands down, the + * pane's drawing and refresh, its opening on Claude's first edit, the ask. + * + * Git runs when the built-in's would: `session.start` binds the host and + * registers `/diff`; `/diff` or the first edit with room pins the backend + * where the session started, until `/clear`; an open pane alone fetches. + * + * @param on the engine's registrar + */ +export function register(on: On) { + let host: Host | null = null + let backend: Backend.Backend | null = null + let probing: Promise | null = null + let sessionStartMs = 0 + let isPaneOpen = false + let dialogRows: number | null = null + let hasAutoOpened = false + let columns: number | null = null + let shownSessionId: string | null = null + let armed: Ask.ArmedAsk | null = null + let carrying: Ask.ArmedAsk | null = null + let isRefreshing = false + let isRefreshQueued = false + let generation = 0 + let bodyStamp: string | null = null + let bodyBase: string | null = null + + const bodyLoads = new Map>() + + const polled = { toplevel: '', headKey: '' } + const pin = { cwd: '', isEmpty: false, epoch: 0 } + + let model: PaneState.PaneModel = PaneState.INITIAL_MODEL + + const timers = new Map<'refresh' | 'redraw' | 'poll', Timer>() + const loggedBaseKinds = new Set<'ok' | 'sad'>() + + const currentOf = (engine: Host): Host => host ?? engine + + const backendHostOf = (engine: Host): Backend.BackendHost => ({ + run: (argv, init) => + currentOf(engine).run( + argv, + pin.cwd === '' ? init : { cwd: pin.cwd, ...init }, + ), + readFile: path => currentOf(engine).readFile(path), + mtimeOf: path => mtimeOf(currentOf(engine))(path), + entryKindsOf: dir => entryKindsOf(currentOf(engine))(dir), + nowMs: () => currentOf(engine).now(), + sessionStartMsOf: () => sessionStartMs, + onBranchBase: base => { + const isError = base.kind === 'error' + + const outcome: Record.MarkOutcome = isError + ? { kind: 'sad', reason: base.reason } + : { + kind: 'ok', + props: { + outcome: { value: base.kind, of: Record.BASE_OUTCOMES }, + }, + } + + if (!loggedBaseKinds.has(outcome.kind)) { + loggedBaseKinds.add(outcome.kind) + + Record.recorderOf(currentOf(engine)).mark( + Record.FEATURES.baseResolve, + outcome, + ) + } + }, + }) + + function pinBackend(engine: Host): Promise { + if (backend || pin.isEmpty) { + return Promise.resolve(true) + } + + probing ??= probeBackend(engine).finally(() => { + probing = null + }) + + return probing + } + + async function probeBackend(engine: Host): Promise { + const asked = { isAnswered: true } + const probeHost = backendHostOf(engine) + const { epoch } = pin + + const probed = await Backend.backendOf( + { + ...probeHost, + run: (argv, init) => + probeHost.run(argv, init).catch((error: unknown) => { + asked.isAnswered &&= + argv[0] !== 'git' || !/\baborted\b/.test(messageOf(error)) + + throw error + }), + }, + Backend.INSTALLED_BACKEND_PROBES, + ) + + if (epoch !== pin.epoch) { + return false + } + + backend ??= probed + pin.isEmpty = backend === null && asked.isAnswered + + if (!probed || backend !== probed) { + return asked.isAnswered || backend !== null + } + + const stored = PaneState.baseModeOf( + await engine + .storeGet(Names.baseStoreKeyOf(probed.repository.toplevel)) + .catch(() => undefined), + ) + + const mode = stored && probed.baseModes.includes(stored) ? stored : null + + model = { + ...model, + words: probed.words, + baseModes: probed.baseModes, + ...(mode && { requestedMode: mode }), + } + + return true + } + + function unpin() { + backend = null + pin.isEmpty = false + pin.epoch += 1 + polled.toplevel = '' + polled.headKey = '' + timers.get('poll')?.cancel() + timers.delete('poll') + } + + function dialogPane(): PaneOpenArgs { + return { + id: Names.PANE_ID, + title: Names.PANE_TITLE, + holdToasts: true, + closeOnEscape: true, + rows: Views.dialogRowsOf(model), + } + } + + function fitDialog(engine: Host) { + const rows = Views.dialogRowsOf(model) + + const isStale = + isPaneOpen && model.isFullscreen === false && rows !== dialogRows + + if (isStale) { + dialogRows = rows + void engine.openPane(dialogPane()).catch(() => undefined) + } + } + + function redraw(engine: Host) { + fitDialog(engine) + + if (timers.has('redraw')) { + return + } + + timers.set( + 'redraw', + engine.after(Limits.REDRAW_COALESCE_MS, () => { + timers.delete('redraw') + engine.invalidate() + }), + ) + } + + async function loadBodies(engine: Host): Promise { + const { data } = model + const pinned = backend + + if (!data || !pinned) { + bodyStamp = null + bodyBase = null + bodyLoads.clear() + model = { ...model, bodies: PaneState.NO_BODIES } + + return false + } + + const stamp = bodyStampOf(data) + const isNewBase = data.baseRef !== bodyBase + + if (stamp !== bodyStamp) { + bodyStamp = stamp + bodyLoads.clear() + } + + if (isNewBase) { + bodyBase = data.baseRef + model = { ...model, bodies: PaneState.NO_BODIES } + redraw(engine) + } + + return fetchBodies(engine, pinned, data) + } + + const bodyStampOf = (data: Git.DiffData) => `${generation}|${data.baseRef}` + + async function fetchBodies( + engine: Host, + pinned: Backend.Backend, + data: Git.DiffData, + ): Promise { + const stamp = bodyStampOf(data) + + function loadOf(file: Git.FileStat): Promise { + const load = pinned.fetchFileHunks(data, file) + bodyLoads.set(file.path, load) + + return load.then(body => { + if (bodyStamp === stamp) { + model = { + ...model, + bodies: new Map(model.bodies).set(file.path, body), + } + + redraw(engine) + } + + return body + }) + } + + return ( + await mapLimited( + drawnFilesOf(model).filter(file => !bodyLoads.has(file.path)), + Limits.BODY_FETCH_CONCURRENCY, + loadOf, + ) + ).includes(null) + } + + function startPoll(engine: Host, pinned: Backend.Backend) { + const readHeadKey = () => pinned.headKeyOf().catch(() => '') + + if (polled.toplevel === pinned.repository.toplevel) { + return + } + + timers.get('poll')?.cancel() + polled.toplevel = pinned.repository.toplevel + polled.headKey = '' + + timers.set( + 'poll', + engine.every(Limits.HEAD_POLL_MS, () => { + if (!isPaneOpen) { + return + } + + void readHeadKey().then(key => { + const hasMoved = polled.headKey !== '' && key !== polled.headKey + + polled.headKey = key + + if (hasMoved) { + scheduleRefresh(engine) + } + }) + }), + ) + } + + async function refresh(engine: Host): Promise { + if (isRefreshing) { + isRefreshQueued = true + + return + } + + isRefreshing = true + + const record = Record.recorderOf(engine) + const pinned = backend + + const fetched = (): Promise => + pinned + ? pinned.fetchDiff(model.requestedMode) + : Promise.resolve({ kind: 'no-repository' }) + + try { + model = { ...model, isLoading: model.data === null } + + const [outcome, messages] = await Promise.all([ + fetched(), + engine.messages().catch((): SessionMessage[] => []), + ]) + + model = PaneState.afterFetch(model, { outcome, messages }) + + switch (outcome.kind) { + case 'no-repository': + break + case 'unavailable': + record.mark(Record.FEATURES.read, { + kind: 'sad', + reason: 'git_diff_failed', + }) + + break + case 'data': + generation += 1 + + if (pinned) { + startPoll(engine, pinned) + } + + break + } + + const hasHunksFailed = await loadBodies(engine) + + if (outcome.kind === 'data') { + record.mark( + Record.FEATURES.read, + hasHunksFailed + ? { kind: 'sad', reason: 'git_hunks_failed' } + : { kind: 'ok' }, + ) + } + } catch (error) { + record.mark(Record.FEATURES.read, { + kind: 'sad', + reason: 'git_diff_threw', + }) + + throw error + } finally { + isRefreshing = false + redraw(engine) + + if (isRefreshQueued) { + isRefreshQueued = false + scheduleRefresh(engine) + } + } + } + + function scheduleRefresh(engine: Host): void { + timers.get('refresh')?.cancel() + + timers.set( + 'refresh', + engine.after(Limits.REFRESH_DEBOUNCE_MS, () => { + timers.delete('refresh') + void refresh(engine) + }), + ) + } + + async function openPane( + engine: Host, + trigger: (typeof Record.SHOWN_TRIGGERS)[number], + ): Promise { + const isDialog = model.isFullscreen === false + + model = { + ...model, + selectedPath: null, + dialogView: 'list', + place: { ...model.place, top: 0, listStart: 0 }, + } + + dialogRows = isDialog ? Views.dialogRowsOf(model) : null + + await engine.openPane( + isDialog + ? { ...dialogPane(), focus: true } + : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true }, + ) + + isPaneOpen = true + + const sessionId = await engine.sessionId().catch(() => null) + + if (sessionId !== null && sessionId !== shownSessionId) { + shownSessionId = sessionId + Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns)) + } + + void refresh(engine) + } + + async function closePane(engine: Host): Promise { + await engine.closePane({ id: Names.PANE_ID }) + isPaneOpen = false + } + + function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) { + Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, { + kind: 'ok', + props: { tab: { value: tab, of: Record.TABS } }, + }) + } + + async function openOnFirstEdit(engine: Host): Promise { + const isTaken = () => isPaneOpen || hasAutoOpened + + if (isTaken()) { + return + } + + const preference = await engine.storeGet(Names.STORE_OPEN_KEY) + const isKeptOpen = preference === true + + const floor = isKeptOpen + ? Limits.OPEN_MIN_COLUMNS + : Limits.AUTO_OPEN_MIN_COLUMNS + + const hasRoom = + preference !== false && + model.isFullscreen === true && + columns !== null && + columns >= floor + + if (!hasRoom || isTaken()) { + return + } + + await pinBackend(engine) + + if (!backend || isTaken()) { + return + } + + hasAutoOpened = true + await openPane(engine, 'auto_open') + } + + function disarm(engine: Host) { + armed = null + model = { ...model, armedPath: null } + engine.status(undefined) + } + + const actionsOf = (engine: Host): Views.PaneActions => ({ + selectFile: path => { + const isDocked = model.placement === 'dock' + + model = { + ...model, + selectedPath: path, + dialogView: isDocked ? model.dialogView : 'detail', + place: isDocked ? Views.placeAtFile(model, path) : model.place, + } + + redraw(engine) + }, + scrollList: delta => { + model = { ...model, place: Views.listScrolledBy(model, delta) } + redraw(engine) + }, + toggleNoise: () => { + model = { ...model, isNoiseShown: !model.isNoiseShown } + void loadBodies(engine) + redraw(engine) + }, + togglePreSession: () => { + model = { ...model, isPreSessionShown: !model.isPreSessionShown } + void loadBodies(engine) + redraw(engine) + }, + cycleBase: () => { + const { baseModes, requestedMode } = model + + const mode = + baseModes[(baseModes.indexOf(requestedMode) + 1) % baseModes.length] ?? + requestedMode + + if (mode === requestedMode) { + return + } + + model = { ...model, requestedMode: mode } + + Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, { + kind: 'ok', + props: { mode: { value: mode, of: model.baseModes } }, + }) + + const toplevel = model.data?.repository.toplevel + + if (toplevel !== undefined) { + void engine + .storeSet(Names.baseStoreKeyOf(toplevel), mode) + .catch(() => undefined) + } + + void refresh(engine) + redraw(engine) + }, + chooseSource: value => { + const index = Number(value) + const isTurn = value !== 'current' && Number.isInteger(index) + + const source: PaneState.Source = isTurn + ? { kind: 'turn', index } + : { kind: 'current' } + + model = { ...model, source, selectedPath: null, dialogView: 'list' } + redraw(engine) + }, + toggleAsk: path => { + if (armed?.path === path) { + disarm(engine) + redraw(engine) + + return + } + + arm(engine, path) + }, + }) + + function arm(engine: Host, path: string) { + armed = Ask.armedAskOf( + path, + PaneState.pickedTurnOf(model)?.files.find(file => file.path === path) + ?.hunks ?? + model.bodies.get(path)?.hunks ?? + [], + ) + + model = { ...model, armedPath: path } + + engine.status( + `${Views.sanitizeName(path)} rides your next prompt (press ` + + `asked ✓ to drop it)`, + ) + + redraw(engine) + } + + async function bind(engine: Host, cwd: string): Promise { + sessionStartMs = await engine.now() + pin.cwd = cwd + + try { + await engine.registerCommand(COMMAND_SPEC) + host = engine + } catch (error) { + const reason = messageOf(error) + + if (!Names.BUILTIN_HOLDS_PATTERN.test(reason)) { + engine.uiLog(Names.registerFailedTextOf(Views.sanitizeName(reason))) + } + } + } + + on('session.start', async ($, e, next) => { + await bind( + { + now: () => $.clock.now(), + after: (ms, fn) => $.clock.after(ms, fn), + every: (ms, fn) => $.clock.every(ms, fn), + run: (argv, init) => $.process.run(argv, init), + stat: path => $.fs.stat(path), + listDir: path => $.fs.list(path), + readFile: path => $.fs.read(path), + storeGet: key => $.store.get(key), + storeSet: (key, value) => $.store.set(key, value), + messages: () => $.session.messages(), + invalidate: () => $.ui.invalidate('ui.render'), + status: text => $.ui.status(text), + uiLog: text => $.ui.log(text), + openPane: pane => $.ui.open(pane), + closePane: pane => $.ui.close(pane), + registerCommand: spec => $.command.register(spec), + sessionId: () => $.session.id(), + mark: entry => $.telemetry.mark(entry), + log: entry => $.telemetry.log(entry), + }, + e.cwd, + ) + + return next(e) + }) + + on('ui.render', { component: 'PromptHint' }, ($, e, next) => { + if (isOnPaneSurface(e)) { + columns = e.viewport?.columns ?? columns + + model = { + ...model, + isFullscreen: e.viewport?.isFullscreen ?? model.isFullscreen, + } + } + + return next(e) + }) + + on('ui.render', { component: 'Pane' }, async ($, e, next) => { + if (e.requestId !== Names.PANE_ID || !host || !isOnPaneSurface(e)) { + return next(e) + } + + const { Box, Text, Button, Select, Code } = await $.ui.resolve(e) + + columns = e.viewport?.columns ?? columns + + model = { + ...model, + placement: e.props.placement, + place: { + ...model.place, + columns: Math.max( + 1, + e.props.bodyColumns - Limits.PANE_RIGHT_PAD_COLUMNS, + ), + rows: e.props.scroll.bodyRows, + }, + } + + return Views.paneView( + { + ui: { Box, Text, Button, Select, Code }, + actions: actionsOf(host), + columns: e.props.bodyColumns, + rows: e.props.scroll.bodyRows, + }, + model, + { placement: e.props.placement, terminalColumns: columns }, + ) + }) + + on('command.run', { command: Names.COMMAND_NAME }, async ($, e, next) => { + if (!host) { + return next(e) + } + + const isAnswered = (await pinBackend(host)) || (await pinBackend(host)) + + if (!backend) { + return { + text: isAnswered + ? Names.NOT_IN_REPOSITORY_TEXT + : Names.GIT_UNANSWERED_TEXT, + } + } + + const { isFullscreen } = e.presentation + + columns = e.presentation.columns + model = { ...model, isFullscreen } + + const toggle = PaneToggle.paneToggleOf({ + isOpen: isPaneOpen, + columns: isFullscreen ? columns : null, + }) + + if (toggle === 'too-narrow') { + return { text: Names.RESIZE_TERMINAL_TEXT } + } + + const isOpening = toggle === 'open' + await (isOpening ? openPane(host, 'manual') : closePane(host)) + + if (!isFullscreen) { + return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT } + } + + markTabSwitch(host, isOpening ? 'diff' : 'convo') + await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined) + + return { + text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT, + } + }) + + on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => { + const isBack = + e.origin.kind === 'person' && + model.placement === 'inline' && + model.dialogView === 'detail' + + if (isBack && host) { + model = { ...model, dialogView: 'list' } + redraw(host) + + dialogRows = Views.dialogRowsOf(model) + + void host + .openPane({ ...dialogPane(), focus: true }) + .catch(() => undefined) + + return { deny: 'back to the file list' } + } + + const result = await next(e) + const isClosed = result.deny === undefined + const isPersons = isClosed && e.origin.kind === 'person' + + if (isClosed) { + isPaneOpen = false + } + + const isDialog = model.isFullscreen === false + + if (isPersons && host && isDialog) { + host.uiLog(Names.DIALOG_DISMISSED_TEXT) + } + + if (isPersons && host && !isDialog) { + markTabSwitch(host, 'convo') + await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined) + } + + return result + }) + + on('ui.focus', { plugin: Names.PLUGIN_NAME }, ($, e, next) => { + const isListed = + model.placement === 'inline' && model.dialogView === 'list' && host + + const focus = isListed ? Views.dialogFocusOf(model, e.element) : null + + if (focus === 'stay') { + return {} + } + + if (!focus || !host) { + return next(e) + } + + model = { ...model, selectedPath: focus.selectedPath } + fitDialog(host) + host.invalidate() + + return next({ ...e, element: focus.landing }) + }) + + on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => { + const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock' + + if (!isOwnBody || !host) { + return next(e) + } + + const isOverList = Views.isWheelOverList(model, e) + + model = { + ...model, + place: isOverList + ? Views.listScrolledBy(model, e.by) + : Views.bodyScrolledBy(model, e), + } + + host.invalidate() + + return {} + }) + + on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => { + const result = await next(e) + + if (host) { + if (isPaneOpen) { + await closePane(host).catch(() => undefined) + } + + unpin() + hasAutoOpened = false + bodyStamp = null + bodyBase = null + bodyLoads.clear() + disarm(host) + model = PaneState.afterNewSession(model) + } + + return result + }) + + function afterTool( + engine: Host, + tool: string, + result: ResultOf['tool.call'] | undefined, + ) { + const isEdit = Tools.EDITING_TOOLS.some(name => name === tool) + + const hasEdited = + isEdit && + result !== undefined && + result.deny === undefined && + result.isError !== true + + const isStale = + isPaneOpen && + (isEdit ? hasEdited : result === undefined || result.deny === undefined) + + if (isStale) { + scheduleRefresh(engine) + } + + if (hasEdited) { + void openOnFirstEdit(engine).catch(() => undefined) + } + } + + on( + 'tool.call', + { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] }, + async ($, e, next) => { + let result: ResultOf['tool.call'] | undefined + + try { + result = await next(e) + + return result + } finally { + if (host) { + afterTool(host, e.tool, result) + } + } + }, + ) + + on('prompt.submit', async ($, e, next) => { + const asked = armed + + if (!host || !asked || carrying === asked) { + return next(e) + } + + const context = e.context ?? [] + + const text = Ask.fittedAskTextOf( + asked.text, + Limits.PROMPT_CONTEXT_MAX_CHARS - + context.reduce((sum, entry) => sum + entry.length, 0), + ) + + if (text === undefined) { + disarm(host) + + host.status( + `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` + + `and was dropped`, + ) + + redraw(host) + + return next(e) + } + + carrying = asked + + try { + const result = await next({ ...e, context: [...context, text] }) + + if (result.drop === undefined) { + Record.recorderOf(host).asked() + + if (armed === asked) { + disarm(host) + redraw(host) + } + } + + return result + } finally { + carrying = null + } + }) +} diff --git a/mods/diff/hooks/todos/index.ts b/mods/diff/hooks/todos/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ce645daaaffd37efcb2ebe8534717ff6adeaef8 --- /dev/null +++ b/mods/diff/hooks/todos/index.ts @@ -0,0 +1,4 @@ +export * from './todo-progress' +export * from './todo-progress-of.js' + +export * as default from '.' diff --git a/mods/diff/hooks/tools/editing-tools.ts b/mods/diff/hooks/tools/editing-tools.ts new file mode 100644 index 0000000000000000000000000000000000000000..dec3dfc79d2fba712bf2830bdc7056847b7e3993 --- /dev/null +++ b/mods/diff/hooks/tools/editing-tools.ts @@ -0,0 +1,5 @@ +/** + * The tools whose success means Claude changed a file: the pane's open + * trigger and a refresh (fileHistory's `track` ops in the built-in). + */ +export const EDITING_TOOLS = ['Edit', 'Write', 'NotebookEdit'] as const diff --git a/mods/diff/hooks/tools/index.ts b/mods/diff/hooks/tools/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3083c37a55c6c8c2b9ac6faedd256e425f1f2d3b --- /dev/null +++ b/mods/diff/hooks/tools/index.ts @@ -0,0 +1,5 @@ +export * from './editing-tools.js' +export * from './shell-tools.js' +export * from './todo-tool.js' + +export * as default from '.' diff --git a/mods/diff/hooks/tools/shell-tools.ts b/mods/diff/hooks/tools/shell-tools.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7dd5f027c8d49ef04d6d7db88039101ee51d825 --- /dev/null +++ b/mods/diff/hooks/tools/shell-tools.ts @@ -0,0 +1,5 @@ +/** + * The tools whose completion may have changed the working tree without an + * edit record: a refresh trigger, never an open trigger. + */ +export const SHELL_TOOLS = ['Bash', 'PowerShell'] as const diff --git a/mods/diff/hooks/tools/todo-tool.ts b/mods/diff/hooks/tools/todo-tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e8e4039088c4d8c840cc2ee4db5c3d5551ee064 --- /dev/null +++ b/mods/diff/hooks/tools/todo-tool.ts @@ -0,0 +1,4 @@ +/** + * The tool whose latest input carries the session's todo list. + */ +export const TODO_TOOL = 'TodoWrite' diff --git a/mods/diff/tests/git.test.ts b/mods/diff/tests/git.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff9f3c44fd43610d626163630c9a88d757f6f492 --- /dev/null +++ b/mods/diff/tests/git.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, mock, test, tier } from 'claude-code/testing' + +import Git from '../hooks/git' +import Fixtures from './fixtures' + +tier('builtin') + +describe('git', () => { + test('each git child names the pin, in the C locale', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.oneSecret()) + + await $.session.start(Fixtures.WORKTREE_SESSION) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + await $.ui.render(Fixtures.PANE) + await $.ui.press({ plugin: 'diff', key: 'ask:.env' }) + await world.clock.settle() + + const [discovery, ...pinned] = world.runs + const diffs = pinned.filter(run => run.argv.includes('diff')) + + expect(discovery?.argv).toContain('--show-toplevel') + + expect( + discovery?.init?.cwd, + "found where the session started, wherever Claude's shell has gone", + ).toBe('/main/wt') + + expect(diffs.length).toBeGreaterThanOrEqual(3) + + for (const run of world.runs) { + expect(run.init?.env).toEqual(Git.GIT_CHILD_ENV) + } + + for (const run of pinned) { + expect(run.argv.slice(0, Fixtures.PINNED_LEAD.length)).toEqual( + Fixtures.PINNED_LEAD, + ) + + expect(run.init).toMatchObject({ cwd: '/main/wt' }) + } + + expect(world.runs.some(run => run.argv.includes('config'))).toBe(false) + }) + + test('the working tree is walked once, by the first fetch', async ($, on) => { + const world = Fixtures.inRepository(on) + + const walks = () => + world.runs.filter(run => Fixtures.gitWordOf(run.argv) === 'status') + + const fetches = () => + world.runs.filter( + run => Fixtures.gitWordOf(run.argv) === 'diff --numstat', + ) + + on('tool.call', () => ({ result: 'ran' })) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(walks(), 'the first fetch walked').toHaveLength(1) + expect(fetches()).toHaveLength(1) + + await $.tool.call({ tool: 'Bash', command: 'make' }) + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(fetches(), 'the command fetched again').toHaveLength(2) + expect(walks(), 'and read the walk it had').toHaveLength(1) + expect(walks()[0]?.init?.cwd, 'pinned like every child').toBe('/work') + }) + + test('a file moved in since the start is session work', async ($, on) => { + const clock = Fixtures.startsSession(on, Fixtures.SETTLE_MS) + + on('process.run', ($, e) => ({ + value: Fixtures.gitIn(e.argv, Fixtures.MOVED_IN), + })) + + on('ui.open', () => ({ value: undefined })) + on('ui.invalidate', () => ({ value: undefined })) + on('session.messages', () => ({ value: [] })) + Fixtures.oldFiles(on) + mock.store(on) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIFF) + await clock.advance(Fixtures.SETTLE_MS) + + const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) + + expect(drawn).toContain('1 file changed +1 -1') + expect(drawn).toContain('moved.ts') + expect(drawn).toContain('+1 file edited before this session (show)') + }) +}) diff --git a/mods/diff/tests/register.test.ts b/mods/diff/tests/register.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..19967e1496e8748f916a88122fe3e9c9299826fc --- /dev/null +++ b/mods/diff/tests/register.test.ts @@ -0,0 +1,669 @@ +import type { Args, ResultOf } from 'claude-code' +import { describe, expect, mock, test, tier } from 'claude-code/testing' + +import Limits from '../hooks/limits' +import Names from '../hooks/names' +import Fixtures from './fixtures' + +tier('builtin') + +describe('register', () => { + test('the start asks nothing of git and registers /diff', async ($, on) => { + const world = Fixtures.inRepository(on) + + await $.session.start(Fixtures.SESSION) + + expect(world.runs, "no git, as the built-in's start runs none").toEqual([]) + + expect(await $.command.run(Fixtures.DIFF), '/diff was registered').toEqual({ + text: 'Diff panel shown', + }) + }) + + test("git runs at the built-in's moments and no others", async ($, on) => { + const world = Fixtures.inRepository(on) + + let read = 0 + + function spawnedSince(): string[] { + const words = world.runs + .slice(read) + .map(run => Fixtures.gitWordOf(run.argv)) + .filter(word => word !== Fixtures.POLL_WORD) + + read = world.runs.length + + return words + } + + const edit = () => + $.tool.call({ + tool: 'Edit', + file_path: '/work/app.ts', + old_string: '1', + new_string: '2', + }) + + on('tool.call', () => ({ result: 'done' })) + on('turn.complete', ($, e) => ({ text: e.answer })) + on('command.run', { command: 'clear' }, () => ({})) + + await $.session.start(Fixtures.SESSION) + await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)) + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(spawnedSince(), 'the start and the footer: nothing').toEqual([]) + + await edit() + await world.clock.advance(Fixtures.SETTLE_MS) + + expect( + spawnedSince(), + 'an edit with no room for a pane: nothing, the width is read first', + ).toEqual([]) + + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(spawnedSince(), '/diff: found, then one fetch for the pane').toEqual( + [ + 'rev-parse --show-toplevel', + 'status', + 'diff --shortstat', + 'diff --numstat', + 'ls-files', + 'diff -- app.ts', + ], + ) + + await edit() + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(spawnedSince(), 'an edit, the pane open: one fetch').toEqual([ + 'diff --shortstat', + 'diff --numstat', + 'ls-files', + 'diff -- app.ts', + ]) + + await $.tool.call({ tool: 'Bash', command: 'make' }) + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(spawnedSince(), 'a shell command, the pane open: one fetch').toEqual( + ['diff --shortstat', 'diff --numstat', 'ls-files', 'diff -- app.ts'], + ) + + await $.turn.complete({ + answer: 'done', + durationMs: 1, + isAborted: false, + turnId: 't1', + reason: 'answer', + }) + + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(spawnedSince(), "a turn's end: nothing").toEqual([]) + + await $.command.run(Fixtures.DIFF) + await edit() + await $.tool.call({ tool: 'Bash', command: 'make' }) + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(spawnedSince(), 'the pane closed by hand: nothing').toEqual([]) + + await $.command.run(Fixtures.CLEAR) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(spawnedSince()[0], '/clear forgot the repository').toBe( + 'rev-parse --show-toplevel', + ) + }) + + test('/diff whose probe never answers probes once more', async ($, on) => { + const probes: (readonly string[])[] = [] + const clock = Fixtures.startsSession(on) + + on('process.run', async ($, e) => { + probes.push(e.argv) + + if (probes.length === 1) { + await clock.sleep(Limits.GIT_TIMEOUT_MS) + + return { deny: Fixtures.GIT_HUNG } + } + + return { value: Fixtures.NOT_A_REPOSITORY } + }) + + await $.session.start(Fixtures.SESSION) + + const ran = $.command.run(Fixtures.DIFF) + + await clock.settle() + + expect(probes, "this /diff's probe, unanswered yet").toHaveLength(1) + + await clock.advance(Limits.GIT_TIMEOUT_MS) + + expect(await ran).toEqual({ + text: expect.stringContaining("isn't in a git repository"), + }) + + expect(probes, 'then one more, which answered').toHaveLength(2) + }) + + test('two /diff typed together probe the repository once', async ($, on) => { + const world = Fixtures.inRepository(on) + + await $.session.start(Fixtures.SESSION) + + const [first, second] = await Promise.all([ + $.command.run(Fixtures.DIFF), + $.command.run(Fixtures.DIFF), + ]) + + const probes = world.runs.filter(run => + run.argv.includes('--show-toplevel'), + ) + + expect(first.text).toMatch(/^Diff panel (shown|hidden)$/) + expect(second.text).toMatch(/^Diff panel (shown|hidden)$/) + expect(probes, 'the second joined the probe in flight').toHaveLength(1) + }) + + test('outside a repository /diff says so, opens nothing', async ($, on) => { + const opened: string[] = [] + + Fixtures.startsSession(on) + on('process.run', () => ({ value: Fixtures.NOT_A_REPOSITORY })) + + on('ui.open', ($, e, next) => { + opened.push(e.id) + + return next(e) + }) + + await $.session.start(Fixtures.SESSION) + + const { text } = await $.command.run(Fixtures.DIFF) + + expect(text).toContain("isn't in a git repository") + expect(opened).toEqual([]) + }) + + test('a git that never answers is not "no repository"', async ($, on) => { + Fixtures.startsSession(on) + on('process.run', () => ({ deny: Fixtures.GIT_HUNG })) + + await $.session.start(Fixtures.SESSION) + + const { text } = await $.command.run(Fixtures.DIFF) + + expect(text).toContain("git didn't answer") + }) + + test('when the built-in holds /diff, the mod stands down', async ($, on) => { + const logged: string[] = [] + const runs: Args<'process.run'>[] = [] + + mock.clock(on) + on('session.start', ($, e) => ({ cwd: e.cwd })) + on('command.register', () => ({ deny: Fixtures.BUILTIN_HOLDS })) + on('command.run', () => ({ text: 'the built-in /diff ran' })) + on('tool.call', () => ({ result: 'edited' })) + + on('process.run', ($, e) => { + runs.push(e) + + return { value: Fixtures.gitIn(e.argv) } + }) + + on('ui.log', ($, e) => { + logged.push(e.text) + + return { value: undefined } + }) + + await $.session.start(Fixtures.SESSION) + + expect(await $.command.run(Fixtures.DIFF)).toEqual({ + text: 'the built-in /diff ran', + }) + + await $.tool.call({ + tool: 'Edit', + file_path: '/work/app.ts', + old_string: '1', + new_string: '2', + }) + + expect(logged).toEqual([]) + expect(runs, 'idle: no git for /diff or the edit').toEqual([]) + }) + + test('a refusal the built-in did not cause is said aloud', async ($, on) => { + const logged: string[] = [] + + mock.clock(on) + on('session.start', ($, e) => ({ cwd: e.cwd })) + + on('command.register', () => ({ + deny: '32 commands are registered already', + })) + + on('ui.log', ($, e) => { + logged.push(e.text) + + return { value: undefined } + }) + + await $.session.start(Fixtures.SESSION) + + expect(logged).toEqual([ + 'could not register /diff: diff: $.command.register: 32 commands are ' + + 'registered already; the diff panel is unavailable this session', + ]) + }) + + test('/diff opens the pane, unfocused, and says so', async ($, on) => { + const world = Fixtures.inRepository(on) + + await $.session.start(Fixtures.SESSION) + + expect(world.runs, 'the start ran nothing').toEqual([]) + + expect(await $.command.run(Fixtures.DIFF)).toEqual({ + text: 'Diff panel shown', + }) + + expect(world.runs[0]?.argv, '/diff found the repository first').toContain( + '--show-toplevel', + ) + + expect(world.opened).toEqual([ + { id: 'diff', title: 'Diff', holdToasts: true }, + ]) + + await world.clock.advance(Fixtures.SETTLE_MS) + + const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) + + expect(drawn).toContain('1 file changed') + expect(drawn).toContain('app.ts') + }) + + test('off fullscreen, /diff opens the dialog, focused', async ($, on) => { + const world = Fixtures.inRepository(on) + + await $.session.start(Fixtures.SESSION) + + expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({}) + + expect(world.opened[0]).toEqual({ + id: 'diff', + title: 'Diff', + holdToasts: true, + closeOnEscape: true, + rows: expect.any(Number), + focus: true, + }) + + expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({ + text: 'Diff dialog dismissed', + }) + }) + + test('/diff again closes the pane and says so', async ($, on) => { + const world = Fixtures.inRepository(on) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIFF) + + expect(await $.command.run(Fixtures.DIFF)).toEqual({ + text: 'Diff panel hidden', + }) + + expect(world.closed.map(pane => pane.id)).toEqual(['diff']) + }) + + test('a wide terminal opens the pane at the first edit', async ($, on) => { + const world = Fixtures.inRepository(on) + + on('tool.call', () => ({ result: 'edited' })) + + await $.session.start(Fixtures.SESSION) + await $.ui.render(Fixtures.HINT) + + await $.tool.call({ + tool: 'Edit', + file_path: '/work/app.ts', + old_string: '1', + new_string: '2', + }) + + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(world.opened.map(pane => pane.id)).toEqual(['diff']) + }) + + test('on the main screen the first edit opens nothing, and /diff still opens the dialog', async ($, on) => { + const world = Fixtures.inRepository(on) + + on('tool.call', () => ({ result: 'edited' })) + + await $.session.start(Fixtures.SESSION) + await $.ui.render(Fixtures.MAIN_SCREEN_HINT) + + await $.tool.call({ + tool: 'Edit', + file_path: '/work/app.ts', + old_string: '1', + new_string: '2', + }) + + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(world.opened, 'a pane there would be an unasked dialog').toEqual([]) + expect(world.runs, 'so nothing was asked of the repository').toEqual([]) + + expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({}) + + expect( + world.opened[0], + "the person's /diff opens the dialog, not dismisses one", + ).toMatchObject({ id: 'diff', focus: true }) + }) + + test('a surface that does not say whether it docks a pane opens nothing at the first edit', async ($, on) => { + const world = Fixtures.inRepository(on) + + on('tool.call', () => ({ result: 'edited' })) + + await $.session.start(Fixtures.SESSION) + await $.ui.render(Fixtures.UNSAID_HINT) + + await $.tool.call({ + tool: 'Edit', + file_path: '/work/app.ts', + old_string: '1', + new_string: '2', + }) + + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(world.opened, 'unknown is not a dock').toEqual([]) + + await $.ui.render(Fixtures.HINT) + + await $.tool.call({ + tool: 'Edit', + file_path: '/work/app.ts', + old_string: '2', + new_string: '3', + }) + + await world.clock.advance(Fixtures.SETTLE_MS) + + expect( + world.opened.map(pane => pane.id), + 'once a drawing says the layout docks, the next edit opens it', + ).toEqual(['diff']) + }) + + test('an edit that failed or was refused opens nothing', async ($, on) => { + const world = Fixtures.inRepository(on) + + const answers: ResultOf['tool.call'][] = [ + { isError: true, result: 'no such text' }, + { deny: 'not allowed' }, + ] + + const edit = () => + $.tool.call({ + tool: 'Edit', + file_path: '/work/app.ts', + old_string: '1', + new_string: '2', + }) + + on('tool.call', () => answers.shift() ?? { result: 'edited' }) + + await $.session.start(Fixtures.SESSION) + await $.ui.render(Fixtures.HINT) + await edit() + await edit() + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(world.opened, 'neither edit landed').toEqual([]) + expect(world.runs, 'so nothing asked after the repository').toEqual([]) + + await edit() + await world.clock.advance(Fixtures.SETTLE_MS) + + expect( + world.opened.map(pane => pane.id), + 'the third did', + ).toEqual(['diff']) + }) + + test('/clear closes the pane it finds open', async ($, on) => { + const world = Fixtures.inRepository(on) + + on('command.run', { command: 'clear' }, () => ({})) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIFF) + await $.command.run(Fixtures.CLEAR) + + expect(world.closed.map(pane => pane.id)).toEqual(['diff']) + }) + + test('ask attaches the hunks on screen and calls no tool', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.oneSecret()) + const called: Args<'tool.call'>[] = [] + + on('tool.call', ($, e) => { + called.push(e) + + return { result: 'called' } + }) + + on('prompt.submit', ($, e) => ({ text: e.text, context: e.context })) + + await $.session.start(Fixtures.WORKTREE_SESSION) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + await $.ui.render(Fixtures.PANE) + await $.ui.press({ plugin: 'diff', key: 'ask:.env' }) + await world.clock.settle() + + const armed = Fixtures.jsonOf(await $.ui.render(Fixtures.PANE)) + + const submitted = await $.prompt.submit(Fixtures.typedPromptOf('why?')) + + expect(armed).toContain('asked ✓') + expect(called).toEqual([]) + + expect(world.statuses.at(-2)).toBe( + '.env rides your next prompt (press asked ✓ to drop it)', + ) + + expect(submitted).toMatchObject({ + context: [ + 'The user attached the diff of .env from the diff pane to this ' + + 'prompt:\n@@ -1 +1 @@\n-KEY=old\n+KEY=new', + ], + }) + }) + + test('an ask with no room in the context is dropped', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.oneSecret()) + const full = 'x'.repeat(Limits.PROMPT_CONTEXT_MAX_CHARS) + const reached: (readonly string[] | undefined)[] = [] + + on('prompt.submit', ($, e) => { + reached.push(e.context) + + return { text: e.text } + }) + + await $.session.start(Fixtures.WORKTREE_SESSION) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + await $.ui.render(Fixtures.PANE) + await $.ui.press({ plugin: 'diff', key: 'ask:.env' }) + await world.clock.settle() + + await $.prompt.submit(Fixtures.typedPromptOf('why?', [full])) + await $.prompt.submit(Fixtures.typedPromptOf('and now?')) + + expect(reached).toEqual([[full], undefined]) + + expect(world.statuses.at(-1)).toBe( + ".env's diff did not fit in the prompt and was dropped", + ) + }) + + test('a worktree opens on the base it kept', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.oneSecret(), { + 'base:/main/wt': 'uncommitted', + }) + + await $.session.start(Fixtures.WORKTREE_SESSION) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + expect( + Fixtures.stringsOf(await $.ui.render(Fixtures.PANE)), + "the built-in's base line under the header", + ).toContain('uncommitted (vs HEAD)') + }) + + test('no repository is an answer kept until /clear', async ($, on) => { + const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } = + Fixtures.oneSecret() + + const script: Record = notYet + const world = Fixtures.inRepository(on, script) + + const probesOf = () => + world.runs.filter(run => run.argv.includes('--show-toplevel')).length + + on('command.run', { command: 'clear' }, () => ({})) + + await $.session.start(Fixtures.WORKTREE_SESSION) + + const before = await $.command.run(Fixtures.DIFF) + + script['rev-parse --path-format=absolute'] = worktree + + const again = await $.command.run(Fixtures.DIFF) + const probesKept = probesOf() + + await $.command.run(Fixtures.CLEAR) + + const after = await $.command.run(Fixtures.DIFF) + + await world.clock.advance(Fixtures.SETTLE_MS) + await $.ui.render(Fixtures.PANE) + + script['rev-parse --path-format=absolute'] = Fixtures.ELSEWHERE_LINES + + const third = $.command.run(Fixtures.DIFF) + + await world.clock.advance(Fixtures.SETTLE_MS) + await third + + expect(before.text).toContain("isn't in a git repository") + + expect(again.text, "kept, as the built-in's answer is").toContain( + "isn't in a git repository", + ) + + expect(probesKept, 'one probe answered both').toBe(1) + expect(after.text, '/clear forgot it').toBe('Diff panel shown') + expect(world.opened[0]?.id).toBe('diff') + expect(probesOf(), 'then one more, pinned from there on').toBe(2) + + expect( + world.runs.some(run => run.argv.includes('--git-dir=/else/.git')), + ).toBe(false) + + expect( + world.runs + .filter(run => run.argv.includes('--numstat')) + .every(run => run.argv[1] === Fixtures.PINNED_LEAD[1]), + ).toBe(true) + }) + + test('a repository that appears later opens on first edit', async ($, on) => { + const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } = + Fixtures.oneSecret() + + const script: Record = notYet + const world = Fixtures.inRepository(on, script) + + on('tool.call', () => ({ result: 'edited' })) + + await $.session.start(Fixtures.WORKTREE_SESSION) + await $.ui.render(Fixtures.HINT) + + script['rev-parse --path-format=absolute'] = worktree + + await $.tool.call({ + tool: 'Edit', + file_path: '/main/wt/.env', + old_string: 'old', + new_string: 'new', + }) + + await world.clock.advance(Fixtures.SETTLE_MS) + + expect(world.opened.map(pane => pane.id)).toEqual(['diff']) + }) + + test('a git that cannot start is an answer, not a timeout', async ($, on) => { + const runs: Args<'process.run'>[] = [] + + Fixtures.startsSession(on) + + on('process.run', ($, e) => { + runs.push(e) + + return e.argv.includes('--show-toplevel') + ? { deny: Fixtures.FAILED_START } + : { value: Fixtures.gitIn(e.argv, Fixtures.oneSecret()) } + }) + + await $.session.start(Fixtures.WORKTREE_SESSION) + + const { text } = await $.command.run(Fixtures.DIFF) + const probes = runs.filter(run => run.argv.includes('--show-toplevel')) + + expect(text).toContain("isn't in a git repository") + expect(probes, "this /diff's alone; no retry").toHaveLength(1) + }) + + test('/diff on a narrow terminal: the resize line', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.oneSecret()) + + await $.session.start(Fixtures.WORKTREE_SESSION) + + const narrow = await $.command.run( + Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS - 1), + ) + + const openedNarrow = [...world.opened] + + await $.command.run(Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS)) + + expect(narrow.text).toContain( + 'Resize your terminal to at least 110 columns to show the diff panel', + ) + + expect(openedNarrow).toEqual([]) + expect(world.opened.map(pane => pane.id)).toEqual([Names.PANE_ID]) + }) +}) diff --git a/mods/diff/tests/views.test.ts b/mods/diff/tests/views.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..91c8e9ff267a82fd46bbc1a431a932916e971945 --- /dev/null +++ b/mods/diff/tests/views.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test, tier } from 'claude-code/testing' + +import Fixtures from './fixtures' + +tier('builtin') + +describe('views', () => { + test('docked, every file has its hunks under the list', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.TWO_FILES) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) + + expect(drawn).toContain('2 files changed +3 -1') + + expect(drawn, "a closing empty row, as the built-in's").toContain( + '+const a = 2\n ', + ) + + expect(drawn, 'none after the last file').toMatch(/\+export const c = 2$/) + expect(drawn).not.toContain('❯') + }) + + test('inline, a row opens that file alone', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.TWO_FILES) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIALOG_DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + await $.ui.render(Fixtures.INLINE_PANE) + + expect(await $.ui.press({ plugin: 'diff', key: 'file:lib.ts' })).toEqual({ + element: 'file:lib.ts', + }) + + const drawn = Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE)) + + expect(drawn).toContain('+export const c = 2') + expect(drawn).not.toContain('+const a = 2') + expect(drawn).not.toContain('app.ts') + expect(drawn).toContain('\u2191/\u2193 to scroll \u00b7 Esc to back') + }) + + test('docked, a wheel tick moves the body, not the list', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.MANY_FILES) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + const before = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) + + expect(before, 'listed, and named over its body').toContain('file0.ts') + expect(before.split('file0.ts')).toHaveLength(3) + + expect(await $.ui.scroll(Fixtures.WHEEL_TICK)).toEqual({}) + + const after = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) + + expect(after.split('file0.ts'), 'its name row scrolled away').toHaveLength( + 2, + ) + + expect(after).toContain('-const v = 0') + expect(after).toContain('10 files changed +10 -10') + expect(after, 'the list stays').not.toContain('more above') + }) + + test('docked, the wheel over a long list moves the list', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.MANY_FILES) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + await $.ui.render(Fixtures.PANE) + + expect(await $.ui.scroll(Fixtures.WHEEL_OVER_LIST)).toEqual({}) + + const after = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) + + expect(after).toContain('\u2191 1 more above') + expect(after).toContain('file8.ts') + expect(after.split('file0.ts'), 'the body stays').toHaveLength(2) + }) + + test('past eight files the docked list scrolls by key', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.MANY_FILES) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) + + expect(drawn).toContain('10 files changed +10 -10') + expect(drawn).toContain('file7.ts') + expect(drawn).toContain('\u2193 2 more below (opt+\u2193 to scroll)') + expect(drawn).not.toContain('file8.ts') + + expect(await $.ui.press({ plugin: 'diff', key: 'list-down' })).toEqual({ + element: 'list-down', + }) + + const scrolled = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) + + expect(scrolled).toContain('\u2191 1 more above') + expect(scrolled).toContain('file8.ts') + expect(scrolled).toContain('\u2193 1 more below') + }) + + test('a rename lists as git prints it, and reads no body', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.RENAMED) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE)) + + expect(drawn).toContain('1 file changed') + expect(drawn).toContain('docs/{notes.txt => renamed-notes.txt}') + expect(drawn).toContain('No diff content') + + expect( + world.runs.filter(run => run.argv.includes('docs/renamed-notes.txt')), + ).toEqual([]) + }) + + test('inline, the dialog lists every file and its keys', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.TWO_FILES) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIALOG_DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + const drawn = Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE)) + + expect(drawn).toContain('Uncommitted changes (git diff HEAD)') + expect(drawn).toContain('2 files changed +3 -1') + expect(drawn).toContain('\u276f app.ts') + expect(drawn).toContain(' lib.ts') + expect(drawn).not.toContain('+const a = 2') + + expect(drawn).toContain( + '\u2191/\u2193 to select \u00b7 Enter to view \u00b7 Esc to close', + ) + + expect(world.opened.at(-1), 'sized to its rows once listed').toEqual({ + id: 'diff', + title: 'Diff', + holdToasts: true, + closeOnEscape: true, + rows: 8, + }) + }) + + test('narrow under the fullscreen layout, the resize line', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.TWO_FILES) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + expect( + Fixtures.textOf(await $.ui.render(Fixtures.NARROW_INLINE_PANE)), + ).toBe( + 'Resize your terminal to at least 110 columns to show the diff panel', + ) + }) + + test('off fullscreen, the window follows the walk', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.MANY_FILES) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIALOG_DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + const drawn = Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE)) + + expect(drawn).toContain('\u276f file0.ts') + expect(drawn).toContain(' file4.ts') + expect(drawn).not.toContain('file5.ts') + expect(drawn).toContain(' \u2193 5 more files') + expect(await $.ui.focus(Fixtures.ringOnto('file:file3.ts'))).toEqual({}) + + expect( + world.focused.map(focus => focus.element), + 'its row once centred', + ).toEqual(['file:file2.ts']) + + const walked = Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE)) + + expect(walked).toContain(' \u2191 1 more file') + expect(walked).toContain('\u276f file3.ts') + expect(walked).toContain(' file5.ts') + expect(walked).toContain(' \u2193 4 more files') + expect(walked).not.toContain('file0.ts') + }) + + test('off fullscreen, the walk stops at the last file', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.MANY_FILES) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIALOG_DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + await $.ui.render(Fixtures.INLINE_PANE) + await $.ui.focus(Fixtures.ringOnto('file:file3.ts')) + await $.ui.render(Fixtures.INLINE_PANE) + await $.ui.focus(Fixtures.ringOnto('file:file5.ts')) + await $.ui.render(Fixtures.INLINE_PANE) + await $.ui.focus(Fixtures.ringOnto('file:file7.ts')) + await $.ui.render(Fixtures.INLINE_PANE) + await $.ui.focus(Fixtures.ringOnto('file:file9.ts')) + + const last = Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE)) + + expect(last).toContain('\u276f file9.ts') + expect(last).toContain(' \u2191 5 more files') + + expect( + await $.ui.focus(Fixtures.ringOnto('file:file5.ts')), + 'the ring would wrap to the first row drawn; it stays', + ).toEqual({}) + + expect(Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE))).toContain( + '\u276f file9.ts', + ) + + expect(world.focused, 'the wrap never reached the engine').toHaveLength(4) + }) + + test('off fullscreen, the dialog draws at any width', async ($, on) => { + const world = Fixtures.inRepository(on, Fixtures.TWO_FILES) + + await $.session.start(Fixtures.SESSION) + await $.command.run(Fixtures.DIALOG_DIFF) + await world.clock.advance(Fixtures.SETTLE_MS) + + expect( + Fixtures.textOf(await $.ui.render(Fixtures.NARROW_INLINE_PANE)), + ).toContain('Uncommitted changes (git diff HEAD)') + }) +}) diff --git a/mods/sec-default/hooks/past-users/provided/index.ts b/mods/sec-default/hooks/past-users/provided/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..afb8511e1d3ef164b00f8f9114035cf80eab83c2 --- /dev/null +++ b/mods/sec-default/hooks/past-users/provided/index.ts @@ -0,0 +1,4 @@ +export type * from './provided.js' +export type * from './provided-next.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/past-users/provided/provided-next.ts b/mods/sec-default/hooks/past-users/provided/provided-next.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7507ac740c9156b570e8798d0ee64e1da087430 --- /dev/null +++ b/mods/sec-default/hooks/past-users/provided/provided-next.ts @@ -0,0 +1,14 @@ +import type { TargetTier } from 'claude-code' + +/** + * What the shared hook needs of `next` on those events: the call, and the + * continuation past the user tier. + */ +export type ProvidedNext = { + (e: E): Promise + + /** + * Continues the dispatch at `tier`, the links between skipped (Next's). + */ + readonly to: (e: E, tier: TargetTier) => Promise +} diff --git a/mods/sec-default/hooks/past-users/provided/provided.ts b/mods/sec-default/hooks/past-users/provided/provided.ts new file mode 100644 index 0000000000000000000000000000000000000000..22cb19d6094d953e82adbf42fc3b45d94ddcac7e --- /dev/null +++ b/mods/sec-default/hooks/past-users/provided/provided.ts @@ -0,0 +1,9 @@ +/** + * An event input that carries who provides its subject (`tool.describe`, + * `command.describe`, `agent.offer`, `agent.spawn`), read for its tier. + * + * Typed loosely on purpose: a missing or odd provider fails closed. + */ +export type Provided = { + readonly provider?: { readonly tier?: unknown } | null +} diff --git a/mods/sec-default/hooks/past-users/user-reachable-tiers/index.ts b/mods/sec-default/hooks/past-users/user-reachable-tiers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4cc4ca686d771c64a3b58e9dc02a21b33de72872 --- /dev/null +++ b/mods/sec-default/hooks/past-users/user-reachable-tiers/index.ts @@ -0,0 +1,3 @@ +export * from './user-reachable-tiers.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/past-users/user-reachable-tiers/user-reachable-tiers.ts b/mods/sec-default/hooks/past-users/user-reachable-tiers/user-reachable-tiers.ts new file mode 100644 index 0000000000000000000000000000000000000000..10c3da2ec8a2c8d732c102418ac95b028a01aa1c --- /dev/null +++ b/mods/sec-default/hooks/past-users/user-reachable-tiers/user-reachable-tiers.ts @@ -0,0 +1,11 @@ +/** + * The provider tiers whose subject the user tier may still rewrite: a + * person's plugin, a bundled one, the engine. + * + * Any other provider, or none, is the organization's (fail closed). + */ +export const USER_REACHABLE_TIERS: readonly unknown[] = Object.freeze([ + 'user', + 'builtin', + 'core', +]) diff --git a/mods/sec-default/hooks/policy/create-policy-memo/index.ts b/mods/sec-default/hooks/policy/create-policy-memo/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..725bbbf5d47acb9f3017f38e9080df3e69e80dd9 --- /dev/null +++ b/mods/sec-default/hooks/policy/create-policy-memo/index.ts @@ -0,0 +1,3 @@ +export * from './create-policy-memo.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/index.ts b/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..92b1cef6b6f2e8b58e3792e6a71befe83e871318 --- /dev/null +++ b/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/index.ts @@ -0,0 +1,4 @@ +export * from './is-org-tool.js' +export * from './org-server-names' + +export * as default from '.' diff --git a/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/is-org-tool.ts b/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/is-org-tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..65e467261e320885a37b3e4ac649fa456cc57bb1 --- /dev/null +++ b/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/is-org-tool.ts @@ -0,0 +1,14 @@ +import type { Settings } from 'claude-code' + +import { orgServerNames } from './org-server-names' + +/** + * Whether a tool is one of a managed MCP server's: `mcp____...` for + * a server the policy's allowlist names. + * + * @param policy the managed settings, as `$.settings.read` answers them + * @param tool the tool's name as the model calls it + * @returns true for a managed server's tool + */ +export const isOrgTool = (policy: Settings, tool: string) => + orgServerNames(policy).some(server => tool.startsWith(`mcp__${server}__`)) diff --git a/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/org-server-names/index.ts b/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/org-server-names/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbda5ec43cb2613a0238bdf1a9aa7376df023679 --- /dev/null +++ b/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/org-server-names/index.ts @@ -0,0 +1,3 @@ +export * from './org-server-names.js' + +export * as default from '.' diff --git a/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/org-server-names/org-server-names.ts b/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/org-server-names/org-server-names.ts new file mode 100644 index 0000000000000000000000000000000000000000..21cd63cc795d260aef875f15c8e4b4a88fd01eb0 --- /dev/null +++ b/mods/sec-default/hooks/policy/managed-tools-restored/is-org-tool/org-server-names/org-server-names.ts @@ -0,0 +1,30 @@ +import type { Settings } from 'claude-code' + +/** + * The MCP servers managed policy names, as their tools are prefixed + * (`mcp____`): allowlist entries with a serverName, and its own. + * + * Its own are the servers it delivers (managedMcpServers keys), which need + * no allowedMcpServers entry. + * + * @param policy the managed settings, as `$.settings.read` answers them + * @returns one name per named or delivered server; none without either + */ +export function orgServerNames(policy: Settings): string[] { + const allowlist = policy.allowedMcpServers + const delivered = policy.managedMcpServers + const isMap = typeof delivered === 'object' && !Array.isArray(delivered) + + return [ + ...(Array.isArray(allowlist) ? allowlist : []).flatMap((entry: unknown) => { + const isEntry = + typeof entry === 'object' && entry !== null && 'serverName' in entry + + const name = isEntry ? entry.serverName : undefined + const isNamed = typeof name === 'string' && name !== '' + + return isNamed ? [name] : [] + }), + ...Object.keys(isMap ? (delivered ?? {}) : {}), + ] +} diff --git a/mods/sec-default/tests/fixtures/agent-offered.ts b/mods/sec-default/tests/fixtures/agent-offered.ts new file mode 100644 index 0000000000000000000000000000000000000000..afbcd7c3d48f89b0dacb318f33f53bb1f66d24b0 --- /dev/null +++ b/mods/sec-default/tests/fixtures/agent-offered.ts @@ -0,0 +1,14 @@ +import type { AgentOfferInput, Origin } from 'claude-code' + +/** + * An agent type's offer as the engine asks for it, pinned to who provides + * the agent. + * + * @param agent the agent type + * @param provider who provides it, as the engine pinned it + * @returns the `agent.offer` input + */ +export const agentOffered = ( + agent: string, + provider: Origin, +): AgentOfferInput => ({ agent, description: 'd', source: 'plugin', provider }) diff --git a/mods/sec-default/tests/fixtures/agent-spawned.ts b/mods/sec-default/tests/fixtures/agent-spawned.ts new file mode 100644 index 0000000000000000000000000000000000000000..9dfab6c77a5b8fa06d6bc4999d8d48379a0a5b08 --- /dev/null +++ b/mods/sec-default/tests/fixtures/agent-spawned.ts @@ -0,0 +1,20 @@ +import type { AgentSpawnInput, Origin } from 'claude-code' + +/** + * A subagent's spawn as the engine raises it, pinned to who provides the + * agent's definition. + * + * @param provider who provides the agent, as the engine pinned it + * @returns the `agent.spawn` input + */ +export const agentSpawned = (provider: Origin): AgentSpawnInput => ({ + tool_use_id: 't1', + prompt: 'list the files in src', + description: 'List files', + subagentType: 'general-purpose', + provider, + parentModel: 'the-model', + permissionMode: 'default', + background: false, + fork: false, +}) diff --git a/mods/sec-default/tests/fixtures/allowlist.ts b/mods/sec-default/tests/fixtures/allowlist.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3db029dada2bfec6bbe4d8f123414a1799c3ed3 --- /dev/null +++ b/mods/sec-default/tests/fixtures/allowlist.ts @@ -0,0 +1,8 @@ +import type { Settings } from 'claude-code' + +/** + * Managed settings holding an MCP allowlist: a tool policy in force. + */ +export const ALLOWLIST: Settings = { + allowedMcpServers: [{ serverName: 'corp' }], +} diff --git a/mods/sec-default/tests/fixtures/command-described.ts b/mods/sec-default/tests/fixtures/command-described.ts new file mode 100644 index 0000000000000000000000000000000000000000..025c27ce6082d5ee4532446c8714f599d2a04f52 --- /dev/null +++ b/mods/sec-default/tests/fixtures/command-described.ts @@ -0,0 +1,20 @@ +import type { CommandDescribeInput, Origin } from 'claude-code' + +/** + * A visible command's description as the engine asks for it, pinned to + * who provides the command. + * + * @param command the command's name, no slash + * @param provider who provides it, as the engine pinned it + * @returns the `command.describe` input + */ +export const commandDescribed = ( + command: string, + provider: Origin, +): CommandDescribeInput => ({ + command, + description: 'd', + isHidden: false, + immediate: false, + provider, +}) diff --git a/mods/sec-default/tests/fixtures/denying.ts b/mods/sec-default/tests/fixtures/denying.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd574d2be45a2fce7f47b1d5fc07145e05201a5f --- /dev/null +++ b/mods/sec-default/tests/fixtures/denying.ts @@ -0,0 +1,12 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that refuses every `$.tool.register` + * beneath it. + */ +export const denying: Plugin = { + name: 'denying', + register(on) { + on('tool.register', () => ({ deny: 'denied by denying' })) + }, +} diff --git a/mods/sec-default/tests/fixtures/dropping.ts b/mods/sec-default/tests/fixtures/dropping.ts new file mode 100644 index 0000000000000000000000000000000000000000..023d7e9e025b82bb411a78dbf7d297fbd790ceb8 --- /dev/null +++ b/mods/sec-default/tests/fixtures/dropping.ts @@ -0,0 +1,11 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that drops the memory section. + */ +export const dropping: Plugin = { + name: 'dropping', + register(on) { + on('prompt.section', () => ({ text: null })) + }, +} diff --git a/mods/sec-default/tests/fixtures/fullscreen.ts b/mods/sec-default/tests/fixtures/fullscreen.ts new file mode 100644 index 0000000000000000000000000000000000000000..2abe8ba8b162e7f07e9e9c89c90303b58779e212 --- /dev/null +++ b/mods/sec-default/tests/fixtures/fullscreen.ts @@ -0,0 +1,10 @@ +import type { CommandPresentation } from 'claude-code' + +/** + * Where a typed command's answer shows in these tests: the fullscreen + * layout on a 160-column terminal. + */ +export const FULLSCREEN: CommandPresentation = { + isFullscreen: true, + columns: 160, +} diff --git a/mods/sec-default/tests/fixtures/index.ts b/mods/sec-default/tests/fixtures/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7dacbf05cc03a097a6a27b449a1515fd459af51c --- /dev/null +++ b/mods/sec-default/tests/fixtures/index.ts @@ -0,0 +1,32 @@ +export * from './agent-offered.js' +export * from './agent-spawned.js' +export * from './allowlist.js' +export * from './command-described.js' +export * from './denying.js' +export * from './dropping.js' +export * from './fullscreen.js' +export * from './listing.js' +export * from './managed-policy.js' +export * from './marking.js' +export * from './memory.js' +export * from './no-allowlist.js' +export * from './odd-providers.js' +export * from './org-providers.js' +export * from './policy-command.js' +export * from './policy-reads.js' +export * from './reading.js' +export * from './registered-tool-of.js' +export * from './registering.js' +export * from './relabeling.js' +export * from './server-policy.js' +export * from './session.js' +export * from './signing.js' +export * from './stripping.js' +export * from './subjects-echoed.js' +export * from './tool-described.js' +export * from './tools.js' +export * from './tools-command.js' +export * from './tools-registered.js' +export * from './user-reachable-providers.js' + +export * as default from '.' diff --git a/mods/sec-default/tests/fixtures/listing.ts b/mods/sec-default/tests/fixtures/listing.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ce5dae19f78649f8d229ea5c63c5d2281f579d8 --- /dev/null +++ b/mods/sec-default/tests/fixtures/listing.ts @@ -0,0 +1,16 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin whose `/tools` answers the tool listing it reads, one + * `name: description` line per tool. + */ +export const listing: Plugin = { + name: 'listing', + register(on) { + on('command.run', { command: 'tools' }, async $ => ({ + text: (await $.tool.list()) + .map(tool => `${tool.name}: ${tool.description}`) + .join('\n'), + })) + }, +} diff --git a/mods/sec-default/tests/fixtures/managed-policy.ts b/mods/sec-default/tests/fixtures/managed-policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b1c5a59d2ef2d29e3c021bd43d85b2b41ef92d1 --- /dev/null +++ b/mods/sec-default/tests/fixtures/managed-policy.ts @@ -0,0 +1,10 @@ +import type { Settings } from 'claude-code' + +/** + * Managed settings with an MCP allowlist naming the corp server and one + * policy-installed plugin, `suite`. + */ +export const MANAGED_POLICY: Settings = { + allowedMcpServers: [{ serverName: 'corp' }], + enabledPlugins: { 'suite@corp-market': true }, +} diff --git a/mods/sec-default/tests/fixtures/marking.ts b/mods/sec-default/tests/fixtures/marking.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f94da9282791e255a277be1692f560561c95b33 --- /dev/null +++ b/mods/sec-default/tests/fixtures/marking.ts @@ -0,0 +1,17 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that marks all four subject events. + * + * A tool's description, a command's hidden flag, an agent's offer, a + * spawn's model. + */ +export const marking: Plugin = { + name: 'marking', + register(on) { + on('tool.describe', ($, e) => ({ description: `user: ${e.description}` })) + on('command.describe', ($, e, next) => next({ ...e, isHidden: true })) + on('agent.offer', () => ({ isOffered: false })) + on('agent.spawn', () => ({ model: 'user' })) + }, +} diff --git a/mods/sec-default/tests/fixtures/memory.ts b/mods/sec-default/tests/fixtures/memory.ts new file mode 100644 index 0000000000000000000000000000000000000000..068c7e1d01d387c3926a393dd8f9b21d0814d8dd --- /dev/null +++ b/mods/sec-default/tests/fixtures/memory.ts @@ -0,0 +1,9 @@ +import type { PromptSectionInput } from 'claude-code' + +/** + * The system prompt's memory section, as the engine asks for it. + */ +export const MEMORY: PromptSectionInput = { + name: 'memory', + text: 'the org says hi', +} diff --git a/mods/sec-default/tests/fixtures/no-allowlist.ts b/mods/sec-default/tests/fixtures/no-allowlist.ts new file mode 100644 index 0000000000000000000000000000000000000000..84f674444f5bc600a896fb8b999ff6ca1d2a90e8 --- /dev/null +++ b/mods/sec-default/tests/fixtures/no-allowlist.ts @@ -0,0 +1,6 @@ +import type { Settings } from 'claude-code' + +/** + * Managed settings with permissions but no MCP allowlist or denylist. + */ +export const NO_ALLOWLIST: Settings = { permissions: { allow: [] } } diff --git a/mods/sec-default/tests/fixtures/odd-providers.ts b/mods/sec-default/tests/fixtures/odd-providers.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a51d2534febde0741c3176fcaae8d577f5428da --- /dev/null +++ b/mods/sec-default/tests/fixtures/odd-providers.ts @@ -0,0 +1,12 @@ +import type { Origin } from 'claude-code' + +/** + * Providers no site pins: tierless, or of a tier the engine does not have. + * + * Only a plugin's own `$` call can hand one down; sec-default fails closed + * on it. + */ +export const ODD_PROVIDERS: readonly Origin[] = [ + { plugin: 'x' }, + { plugin: 'x', tier: 'managed' }, +] as readonly unknown[] as readonly Origin[] diff --git a/mods/sec-default/tests/fixtures/org-providers.ts b/mods/sec-default/tests/fixtures/org-providers.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3121acd73f93ecc740f1b4022070246b94da743 --- /dev/null +++ b/mods/sec-default/tests/fixtures/org-providers.ts @@ -0,0 +1,12 @@ +import type { Origin } from 'claude-code' + +/** + * Providers in the organization's tiers: a policy-installed plugin + * (prepended by default, or appended), a policy MCP server, its folder. + */ +export const ORG_PROVIDERS: readonly Origin[] = [ + { plugin: 'suite@corp-market', tier: 'prepend' }, + { plugin: 'audit@corp-market', tier: 'append' }, + { plugin: 'mcp:corp', tier: 'prepend' }, + { plugin: 'policy', tier: 'prepend' }, +] diff --git a/mods/sec-default/tests/fixtures/policy-command.ts b/mods/sec-default/tests/fixtures/policy-command.ts new file mode 100644 index 0000000000000000000000000000000000000000..6bd0c70ea6f1cdcd64388757b29e8a13d3e871b8 --- /dev/null +++ b/mods/sec-default/tests/fixtures/policy-command.ts @@ -0,0 +1,13 @@ +import type { CommandRunInput } from 'claude-code' + +import { FULLSCREEN } from './fullscreen.js' + +/** + * `/policy` as the person types it: the reading plugin's command. + */ +export const POLICY_COMMAND: CommandRunInput = { + command: 'policy', + args: '', + origin: { kind: 'composer' }, + presentation: FULLSCREEN, +} diff --git a/mods/sec-default/tests/fixtures/policy-reads.ts b/mods/sec-default/tests/fixtures/policy-reads.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b79a51e863faa55956d0aa65aea5033f177b401 --- /dev/null +++ b/mods/sec-default/tests/fixtures/policy-reads.ts @@ -0,0 +1,21 @@ +import type { On, Settings } from 'claude-code' + +/** + * Answers every settings read beneath the plugins with the policy given, + * counting the reads. + * + * @param on the test's `on` + * @param policy the managed settings in force + * @returns how many reads reached the bottom so far + */ +export function policyReads(on: On, policy: Settings): () => number { + let reads = 0 + + on('settings.read', () => { + reads += 1 + + return { value: policy } + }) + + return () => reads +} diff --git a/mods/sec-default/tests/fixtures/reading.ts b/mods/sec-default/tests/fixtures/reading.ts new file mode 100644 index 0000000000000000000000000000000000000000..437f087d7c1f58982b5c7c22de59a4c2c9860497 --- /dev/null +++ b/mods/sec-default/tests/fixtures/reading.ts @@ -0,0 +1,14 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed whose `/policy` answers the managed + * settings it reads, as JSON. + */ +export const reading: Plugin = { + name: 'reading', + register(on) { + on('command.run', { command: 'policy' }, async $ => ({ + text: JSON.stringify(await $.settings.read({ source: 'policy' })), + })) + }, +} diff --git a/mods/sec-default/tests/fixtures/registered-tool-of.ts b/mods/sec-default/tests/fixtures/registered-tool-of.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6c222432c263a66f9ce81da8da07244dc8f63eb --- /dev/null +++ b/mods/sec-default/tests/fixtures/registered-tool-of.ts @@ -0,0 +1,10 @@ +/** + * What registering a plugin's tool answers: the name it is called by. + * + * @param plugin the plugin that asked + * @param name the tool's own name + * @returns the `tool.register` result + */ +export const registeredToolOf = (plugin: string, name: string) => ({ + value: { tool: `mcp__plugin_${plugin}__${name}` }, +}) diff --git a/mods/sec-default/tests/fixtures/registering.ts b/mods/sec-default/tests/fixtures/registering.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d68de140f44480b6a5a1bb44ae5109a7a93664b --- /dev/null +++ b/mods/sec-default/tests/fixtures/registering.ts @@ -0,0 +1,25 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin that registers a tool when the session starts, in the tier + * given: the person's own by default. + * + * @param name the plugin's name + * @param tier the tier it loads in + * @returns the plugin + */ +export const registering = (name: string, tier?: Plugin['tier']): Plugin => ({ + name, + tier, + register(on) { + on('session.start', async ($, e, next) => { + await $.tool.register({ + name: 'greet', + description: 'Says hello.', + inputSchema: { type: 'object' }, + }) + + return next(e) + }) + }, +}) diff --git a/mods/sec-default/tests/fixtures/relabeling.ts b/mods/sec-default/tests/fixtures/relabeling.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff5a9e8f981e8095afcbe62786fcd5acb9393b93 --- /dev/null +++ b/mods/sec-default/tests/fixtures/relabeling.ts @@ -0,0 +1,24 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that hides the organization's tools from + * the listing and relabels every other one. + */ +export const relabeling: Plugin = { + name: 'relabeling', + register(on) { + on('tool.list', async ($, e, next) => { + const listed = await next(e) + const { value } = listed + const isListed = value !== undefined + + return isListed + ? { + value: value + .filter(tool => !tool.name.startsWith('mcp__corp__')) + .map(tool => ({ ...tool, description: 'relabeled' })), + } + : listed + }) + }, +} diff --git a/mods/sec-default/tests/fixtures/server-policy.ts b/mods/sec-default/tests/fixtures/server-policy.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7797b72b620652a14ef3f22a286aaf9cd6ea842 --- /dev/null +++ b/mods/sec-default/tests/fixtures/server-policy.ts @@ -0,0 +1,9 @@ +import type { Settings } from 'claude-code' + +/** + * Managed settings that deliver the corp MCP server themselves, with no + * allowlist naming it: a tool policy in force all the same. + */ +export const SERVER_POLICY: Settings = { + managedMcpServers: { corp: { type: 'http', url: 'https://corp.example/x' } }, +} diff --git a/mods/sec-default/tests/fixtures/session.ts b/mods/sec-default/tests/fixtures/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..c387d8e755d6a8020364bae829baa466d2732f11 --- /dev/null +++ b/mods/sec-default/tests/fixtures/session.ts @@ -0,0 +1,10 @@ +import type { SessionStartInput } from 'claude-code' + +/** + * An interactive terminal session in /work. + */ +export const SESSION: SessionStartInput = { + surface: 'terminal', + isInteractive: true, + cwd: '/work', +} diff --git a/mods/sec-default/tests/fixtures/signing.ts b/mods/sec-default/tests/fixtures/signing.ts new file mode 100644 index 0000000000000000000000000000000000000000..68e1aac8c8401d67114b7c191830f4bc3701e35a --- /dev/null +++ b/mods/sec-default/tests/fixtures/signing.ts @@ -0,0 +1,14 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * The organization's own plugin, in its last tier, which signs the section. + */ +export const signing: Plugin = { + name: 'signing', + tier: 'append', + register(on) { + on('prompt.section', ($, e, next) => + next({ ...e, text: `${e.text} (signed)` }), + ) + }, +} diff --git a/mods/sec-default/tests/fixtures/stripping.ts b/mods/sec-default/tests/fixtures/stripping.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f34e3245eedaf32fec360093e861b6bcdc5aff3 --- /dev/null +++ b/mods/sec-default/tests/fixtures/stripping.ts @@ -0,0 +1,23 @@ +import type { Plugin } from 'claude-code/testing' + +/** + * A plugin the person installed that strips the MCP allowlist from every + * settings read beneath it, as one hiding the organization's policy would. + */ +export const stripping: Plugin = { + name: 'stripping', + register(on) { + on('settings.read', async ($, e, next) => { + const read = await next(e) + const { value } = read + + if (value === undefined) { + return read + } + + const { allowedMcpServers: _hidden, ...rest } = value + + return { value: rest } + }) + }, +} diff --git a/mods/sec-default/tests/fixtures/subjects-echoed.ts b/mods/sec-default/tests/fixtures/subjects-echoed.ts new file mode 100644 index 0000000000000000000000000000000000000000..43cd69e8a4fb5e81280622d050ed44cd4aff913f --- /dev/null +++ b/mods/sec-default/tests/fixtures/subjects-echoed.ts @@ -0,0 +1,22 @@ +import type { On } from 'claude-code' + +/** + * Answers the four subject events beneath every plugin as the engine's + * core would. + * + * The description and hidden flag the chain left, the agent offered, the + * spawn on the model `core`. + * + * @param on the test's `on` + */ +export function subjectsEchoed(on: On) { + on('tool.describe', ($, e) => ({ description: e.description })) + + on('command.describe', ($, e) => ({ + description: e.description, + isHidden: e.isHidden, + })) + + on('agent.offer', () => ({ isOffered: true })) + on('agent.spawn', () => ({ model: 'core' })) +} diff --git a/mods/sec-default/tests/fixtures/tool-described.ts b/mods/sec-default/tests/fixtures/tool-described.ts new file mode 100644 index 0000000000000000000000000000000000000000..04f02a74a535181d11114598ab2cbd35b15e4fec --- /dev/null +++ b/mods/sec-default/tests/fixtures/tool-described.ts @@ -0,0 +1,14 @@ +import type { Origin, ToolDescribeInput } from 'claude-code' + +/** + * A tool's description as the engine asks for it, `d`, pinned to who + * provides the tool. + * + * @param tool the tool's name + * @param provider who provides it, as the engine pinned it + * @returns the `tool.describe` input + */ +export const toolDescribed = ( + tool: string, + provider: Origin, +): ToolDescribeInput => ({ tool, description: 'd', provider }) diff --git a/mods/sec-default/tests/fixtures/tools-command.ts b/mods/sec-default/tests/fixtures/tools-command.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a7b35301a6a97f03a063b95a1d4e66db911c17e --- /dev/null +++ b/mods/sec-default/tests/fixtures/tools-command.ts @@ -0,0 +1,13 @@ +import type { CommandRunInput } from 'claude-code' + +import { FULLSCREEN } from './fullscreen.js' + +/** + * `/tools` as the person types it: the listing plugin's command. + */ +export const TOOLS_COMMAND: CommandRunInput = { + command: 'tools', + args: '', + origin: { kind: 'composer' }, + presentation: FULLSCREEN, +} diff --git a/mods/sec-default/tests/fixtures/tools-registered.ts b/mods/sec-default/tests/fixtures/tools-registered.ts new file mode 100644 index 0000000000000000000000000000000000000000..11d91e52cd1deefed50d68c3ef967a9702bd5ab9 --- /dev/null +++ b/mods/sec-default/tests/fixtures/tools-registered.ts @@ -0,0 +1,24 @@ +import type { On } from 'claude-code' + +import { registeredToolOf } from './registered-tool-of.js' + +/** + * A session starting, where each tool a plugin asks for is registered and + * kept by the name of the plugin that asked. + * + * @param on the test's `on` + * @returns the plugins whose tools were registered, in order + */ +export function toolsRegistered(on: On) { + const registered: string[] = [] + + on('session.start', ($, e) => ({ cwd: e.cwd })) + + on('tool.register', ($, e, next) => { + registered.push(next.origin.plugin) + + return registeredToolOf(next.origin.plugin, e.name) + }) + + return registered +} diff --git a/mods/sec-default/tests/fixtures/tools.ts b/mods/sec-default/tests/fixtures/tools.ts new file mode 100644 index 0000000000000000000000000000000000000000..6ef775cab591d7a6aba652796b8b05a3c12927e3 --- /dev/null +++ b/mods/sec-default/tests/fixtures/tools.ts @@ -0,0 +1,13 @@ +import type { ToolInfo } from 'claude-code' + +/** + * The session's tools: one from the organization's MCP server, one built in. + */ +export const TOOLS: readonly ToolInfo[] = [ + { + name: 'mcp__corp__search', + description: 'Searches the corp wiki.', + mcp: true, + }, + { name: 'Bash', description: 'Runs a command.', mcp: false }, +] diff --git a/mods/sec-default/tests/fixtures/user-reachable-providers.ts b/mods/sec-default/tests/fixtures/user-reachable-providers.ts new file mode 100644 index 0000000000000000000000000000000000000000..d40189302e436b69333e86cafb1b65f056f09408 --- /dev/null +++ b/mods/sec-default/tests/fixtures/user-reachable-providers.ts @@ -0,0 +1,12 @@ +import type { Origin } from 'claude-code' + +/** + * Providers whose subjects a plugin the person installed may still + * rewrite: a person's plugin or MCP server, a bundled plugin, the engine. + */ +export const USER_REACHABLE_PROVIDERS: readonly Origin[] = [ + { plugin: 'mine@market', tier: 'user' }, + { plugin: 'mcp:mine', tier: 'user' }, + { plugin: 'bundled@builtin', tier: 'builtin' }, + { plugin: 'engine', tier: 'core' }, +] diff --git a/mods/sec-default/tests/policy/create-policy-memo.test.ts b/mods/sec-default/tests/policy/create-policy-memo.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..22cd3c8159f181ae21401f7c42fa2db416b2e6a4 --- /dev/null +++ b/mods/sec-default/tests/policy/create-policy-memo.test.ts @@ -0,0 +1,72 @@ +import type { Settings } from 'claude-code' +import { describe, expect, test, tier } from 'claude-code/testing' + +import Policy from '../../hooks/policy' +import Fixtures from '../fixtures' + +tier('prepend') + +describe('create-policy-memo', () => { + function countedRead() { + let reads = 0 + + function read(): Promise { + reads += 1 + + return Promise.resolve(Fixtures.MANAGED_POLICY) + } + + return { read, reads: () => reads } + } + + test('a burst of reads inside the window is one read', async () => { + let at = 0 + + const memo = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS, () => at) + const { read, reads } = countedRead() + const burst = await Promise.all([memo(read), memo(read), memo(read)]) + + at += Policy.POLICY_MEMO_MS + + await memo(read) + + expect(burst).toEqual([ + Fixtures.MANAGED_POLICY, + Fixtures.MANAGED_POLICY, + Fixtures.MANAGED_POLICY, + ]) + + expect(reads()).toBe(1) + }) + + test('past the window the next decision reads again', async () => { + let at = 0 + + const memo = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS, () => at) + const { read, reads } = countedRead() + + await memo(read) + + at += Policy.POLICY_MEMO_MS + 1 + + await memo(read) + + expect(reads()).toBe(2) + }) + + test('a failed read is served for its window: fails closed', async () => { + const memo = Policy.createPolicyMemo(Policy.POLICY_MEMO_MS, () => 0) + + let reads = 0 + + function failing(): Promise { + reads += 1 + + return Promise.reject(new Error('settings unreadable')) + } + + await expect(memo(failing)).rejects.toThrow('settings unreadable') + await expect(memo(failing)).rejects.toThrow('settings unreadable') + expect(reads).toBe(1) + }) +})