SaylorTwift HF Staff commited on
Commit
bdbd3b2
·
verified ·
1 Parent(s): bee0296

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. mods/diff/.claude-plugin/plugin.json +8 -0
  2. mods/diff/hooks/array-of/array-of.ts +9 -0
  3. mods/diff/hooks/array-of/index.ts +3 -0
  4. mods/diff/hooks/ask/armed-ask-of.ts +31 -0
  5. mods/diff/hooks/ask/fitted-ask-text-of.ts +37 -0
  6. mods/diff/hooks/ask/index.ts +6 -0
  7. mods/diff/hooks/backend/backend-of.ts +30 -0
  8. mods/diff/hooks/backend/index.ts +5 -0
  9. mods/diff/hooks/backend/installed-backend-probes.ts +12 -0
  10. mods/diff/hooks/classify/index.ts +7 -0
  11. mods/diff/hooks/classify/is-noise-file.ts +12 -0
  12. mods/diff/hooks/command-spec/command-spec.ts +10 -0
  13. mods/diff/hooks/command-spec/index.ts +3 -0
  14. mods/diff/hooks/count-of/count-of.ts +10 -0
  15. mods/diff/hooks/count-of/index.ts +3 -0
  16. mods/diff/hooks/drawn-files-of/drawn-files-of.ts +31 -0
  17. mods/diff/hooks/drawn-files-of/index.ts +3 -0
  18. mods/diff/hooks/entry-kinds-of/index.ts +3 -0
  19. mods/diff/hooks/hooks.json +4 -0
  20. mods/diff/hooks/host/host.ts +116 -0
  21. mods/diff/hooks/host/index.ts +3 -0
  22. mods/diff/hooks/index.ts +28 -0
  23. mods/diff/hooks/is-on-pane-surface/index.ts +3 -0
  24. mods/diff/hooks/is-on-pane-surface/is-on-pane-surface.ts +12 -0
  25. mods/diff/hooks/is-record/index.ts +3 -0
  26. mods/diff/hooks/is-record/is-record.ts +8 -0
  27. mods/diff/hooks/kept-of/index.ts +3 -0
  28. mods/diff/hooks/limits/dialog-path-floor.ts +5 -0
  29. mods/diff/hooks/limits/index.ts +8 -0
  30. mods/diff/hooks/limits/todo-bar-cells.ts +4 -0
  31. mods/diff/hooks/limits/todo-bar-reserve.ts +4 -0
  32. mods/diff/hooks/map-limited/index.ts +3 -0
  33. mods/diff/hooks/map-limited/map-limited.ts +30 -0
  34. mods/diff/hooks/mtime-of/index.ts +3 -0
  35. mods/diff/hooks/mtime-of/mtime-of.ts +16 -0
  36. mods/diff/hooks/names/command-description.ts +6 -0
  37. mods/diff/hooks/names/command-name.ts +4 -0
  38. mods/diff/hooks/names/index.ts +9 -0
  39. mods/diff/hooks/names/pane-id.ts +4 -0
  40. mods/diff/hooks/names/pane-title.ts +4 -0
  41. mods/diff/hooks/names/plugin-name.ts +5 -0
  42. mods/diff/hooks/pane-state/dialog-title-of.ts +37 -0
  43. mods/diff/hooks/pane-state/empty-state-of.ts +70 -0
  44. mods/diff/hooks/pane-state/header-totals-of.ts +32 -0
  45. mods/diff/hooks/pane-state/index.ts +25 -0
  46. mods/diff/hooks/pane-state/partition-of.ts +32 -0
  47. mods/diff/hooks/pane-state/unborn-note-of.ts +22 -0
  48. mods/diff/hooks/record/index.ts +12 -0
  49. mods/diff/hooks/record/recorder-of.ts +31 -0
  50. mods/diff/hooks/register.ts +907 -0
mods/diff/.claude-plugin/plugin.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "diff",
3
+ "version": "0.1.0",
4
+ "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.",
5
+ "author": {
6
+ "name": "Anthropic"
7
+ }
8
+ }
mods/diff/hooks/array-of/array-of.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * A value a tool stored as the array it is, or an empty one when it is
3
+ * anything else.
4
+ *
5
+ * @param value a field of a tool's input or result
6
+ * @returns the array's items, unknown each
7
+ */
8
+ export const arrayOf = (value: unknown): readonly unknown[] =>
9
+ Array.isArray(value) ? value : []
mods/diff/hooks/array-of/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './array-of.js'
2
+
3
+ export * as default from '.'
mods/diff/hooks/ask/armed-ask-of.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type Git from '../git'
2
+ import Limits from '../limits'
3
+ import type { ArmedAsk } from './armed-ask'
4
+
5
+ /**
6
+ * A file's diff as the context block the next prompt carries when the
7
+ * person presses ask.
8
+ *
9
+ * A line saying where it came from, then the hunks in unified form, cut at
10
+ * MAX_LINES_PER_FILE lines.
11
+ *
12
+ * @param path the file's repository-relative path
13
+ * @param hunks its hunks as the pane shows them
14
+ * @returns the armed ask
15
+ */
16
+ export function armedAskOf(path: string, hunks: readonly Git.Hunk[]): ArmedAsk {
17
+ const body = hunks
18
+ .flatMap(hunk => [
19
+ `@@ -${hunk.oldStart} +${hunk.newStart} @@`,
20
+ ...hunk.lines,
21
+ ])
22
+ .slice(0, Limits.MAX_LINES_PER_FILE)
23
+
24
+ return {
25
+ path,
26
+ text:
27
+ `The user attached the diff of ${path} from the diff pane to this ` +
28
+ `prompt:\n${body.join('\n')}`,
29
+ lines: body.length,
30
+ }
31
+ }
mods/diff/hooks/ask/fitted-ask-text-of.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ASK_CUT_NOTE } from './ask-cut-note'
2
+
3
+ /**
4
+ * An armed ask's text as it fits in the room a prompt's context has left:
5
+ * whole when it fits, else its first lines and ASK_CUT_NOTE.
6
+ *
7
+ * @param text the armed ask's text
8
+ * @param room the characters the context has left
9
+ * @returns the text to attach, or undefined when not even its first line fits
10
+ */
11
+ export function fittedAskTextOf(
12
+ text: string,
13
+ room: number,
14
+ ): string | undefined {
15
+ if (text.length <= room) {
16
+ return text
17
+ }
18
+
19
+ const kept: string[] = []
20
+
21
+ let used = ASK_CUT_NOTE.length
22
+
23
+ for (const line of text.split('\n')) {
24
+ const cost = line.length + 1
25
+
26
+ if (used + cost > room) {
27
+ break
28
+ }
29
+
30
+ kept.push(line)
31
+ used += cost
32
+ }
33
+
34
+ const hasBody = kept.length > 1
35
+
36
+ return hasBody ? `${kept.join('\n')}\n${ASK_CUT_NOTE}` : undefined
37
+ }
mods/diff/hooks/ask/index.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export * from './armed-ask'
2
+ export * from './armed-ask-of.js'
3
+ export * from './ask-cut-note'
4
+ export * from './fitted-ask-text-of.js'
5
+
6
+ export * as default from '.'
mods/diff/hooks/backend/backend-of.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Git from '../git'
2
+ import type { Backend, BackendHost, BackendProbe } from './types'
3
+
4
+ /**
5
+ * The backend holding the session's directory: another system's when its
6
+ * probe answers first, else git when a git working tree holds it, else none.
7
+ *
8
+ * Other systems are asked first so one may claim a working copy by its own
9
+ * rule (the nearer of two markers, say); each answers null wherever git
10
+ * should govern.
11
+ *
12
+ * @param host the bound host's runner and probes
13
+ * @param probes probes for other version-control systems, in order
14
+ * (INSTALLED_BACKEND_PROBES at the pane; none as published)
15
+ * @returns the pinned backend, or null outside any working tree
16
+ */
17
+ export async function backendOf(
18
+ host: BackendHost,
19
+ probes: Iterable<BackendProbe>,
20
+ ): Promise<Backend | null> {
21
+ for (const probe of probes) {
22
+ const backend = await probe(host)
23
+
24
+ if (backend) {
25
+ return backend
26
+ }
27
+ }
28
+
29
+ return Git.gitBackendOf(host)
30
+ }
mods/diff/hooks/backend/index.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ export * from './backend-of.js'
2
+ export * from './installed-backend-probes.js'
3
+ export * from './types'
4
+
5
+ export * as default from '.'
mods/diff/hooks/backend/installed-backend-probes.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { BackendProbe } from './types'
2
+
3
+ /**
4
+ * Where a host adds backends: probes for version-control systems other than
5
+ * git, asked in insertion order before git (backendOf). Empty as shipped.
6
+ *
7
+ * A host compiling the module in adds a probe once, before the plugin
8
+ * registers: the module runs natively, and there is no other channel (the
9
+ * options are the manifest's userConfig; the scan wants a plain function).
10
+ */
11
+ // 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
12
+ export const INSTALLED_BACKEND_PROBES = new Set<BackendProbe>()
mods/diff/hooks/classify/index.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ export * from './base-name-of'
2
+ export * from './is-generated-file'
3
+ export * from './is-noise-file.js'
4
+ export * from './is-test-file'
5
+ export * from './rooted-path-of'
6
+
7
+ export * as default from '.'
mods/diff/hooks/classify/is-noise-file.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { isGeneratedFile } from './is-generated-file'
2
+ import { isTestFile } from './is-test-file'
3
+
4
+ /**
5
+ * Whether a changed file is hidden behind the pane's tests-and-generated
6
+ * line until the person asks to see it.
7
+ *
8
+ * @param path the file's path from the repository root
9
+ * @returns whether it is a test or a generated file
10
+ */
11
+ export const isNoiseFile = (path: string) =>
12
+ isTestFile(path) || isGeneratedFile(path)
mods/diff/hooks/command-spec/command-spec.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import Names from '../names'
2
+
3
+ /**
4
+ * What `session.start` hands `$.command.register`: the one place the
5
+ * `/diff` spec is built, where a later CommandSpec field joins.
6
+ */
7
+ export const COMMAND_SPEC = {
8
+ name: Names.COMMAND_NAME,
9
+ description: Names.COMMAND_DESCRIPTION,
10
+ } as const
mods/diff/hooks/command-spec/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './command-spec.js'
2
+
3
+ export * as default from '.'
mods/diff/hooks/count-of/count-of.ts ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * How many items of a list pass a test, without the intermediate array a
3
+ * filter would build.
4
+ *
5
+ * @param items the list
6
+ * @param passes the test
7
+ * @returns the number of passing items
8
+ */
9
+ export const countOf = <T>(items: readonly T[], passes: (item: T) => boolean) =>
10
+ items.reduce((total, item) => total + (passes(item) ? 1 : 0), 0)
mods/diff/hooks/count-of/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './count-of.js'
2
+
3
+ export * as default from '.'
mods/diff/hooks/drawn-files-of/drawn-files-of.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type Git from '../git'
2
+ import Limits from '../limits'
3
+ import PaneState from '../pane-state'
4
+
5
+ /**
6
+ * The files whose bodies the pane draws now, in drawing order: the listed
7
+ * session rows, then the pre-session rows while their section is open.
8
+ *
9
+ * Past PRE_SESSION_BODY_CAP pre-session files only their legend shows, so
10
+ * none of their bodies is wanted.
11
+ *
12
+ * @param model the pane's state: its fetch and its two toggles
13
+ * @returns the rows, none without a fetch
14
+ */
15
+ export function drawnFilesOf(
16
+ model: Pick<
17
+ PaneState.PaneModel,
18
+ 'data' | 'isNoiseShown' | 'isPreSessionShown'
19
+ >,
20
+ ): readonly Git.FileStat[] {
21
+ const partition = PaneState.partitionOf(
22
+ model.data?.files ?? [],
23
+ model.isNoiseShown ? 'shown' : 'hidden',
24
+ )
25
+
26
+ const isBodied =
27
+ model.isPreSessionShown &&
28
+ partition.preSession.length <= Limits.PRE_SESSION_BODY_CAP
29
+
30
+ return [...partition.shown, ...(isBodied ? partition.preSession : [])]
31
+ }
mods/diff/hooks/drawn-files-of/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './drawn-files-of.js'
2
+
3
+ export * as default from '.'
mods/diff/hooks/entry-kinds-of/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './entry-kinds-of.js'
2
+
3
+ export * as default from '.'
mods/diff/hooks/hooks.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "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",
3
+ "modules": ["./register.ts"]
4
+ }
mods/diff/hooks/host/host.ts ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ CommandSpec,
3
+ EngineInterface,
4
+ FsEntry,
5
+ FsStat,
6
+ PaneCloseArgs,
7
+ PaneOpenArgs,
8
+ ProcessRunInit,
9
+ ProcessRunResult,
10
+ SessionMessage,
11
+ TimerCall,
12
+ } from 'claude-code'
13
+
14
+ /**
15
+ * The engine as `session.start` bound it from its `$`, each member spelled
16
+ * `$.noun.event(...)` there; used by every later hook, timer and press.
17
+ */
18
+ export type Host = {
19
+ /**
20
+ * `$.clock.now`.
21
+ */
22
+ now: () => Promise<number>
23
+
24
+ /**
25
+ * `$.clock.after`.
26
+ */
27
+ after: TimerCall
28
+
29
+ /**
30
+ * `$.clock.every`.
31
+ */
32
+ every: TimerCall
33
+
34
+ /**
35
+ * `$.process.run`.
36
+ */
37
+ run: (
38
+ argv: readonly string[],
39
+ init: ProcessRunInit,
40
+ ) => Promise<ProcessRunResult>
41
+
42
+ /**
43
+ * `$.fs.stat`.
44
+ */
45
+ stat: (path: string) => Promise<FsStat>
46
+
47
+ /**
48
+ * `$.fs.list`: a directory's entries by kind, links never followed.
49
+ */
50
+ listDir: (path: string) => Promise<FsEntry[]>
51
+
52
+ /**
53
+ * `$.fs.read`.
54
+ */
55
+ readFile: (path: string) => Promise<string>
56
+
57
+ /**
58
+ * Reads the plugin's store (`$.store.get`).
59
+ */
60
+ storeGet: (key: string) => Promise<unknown>
61
+
62
+ /**
63
+ * Writes the plugin's store (`$.store.set`).
64
+ */
65
+ storeSet: (key: string, value: unknown) => Promise<void>
66
+
67
+ /**
68
+ * `$.session.messages`.
69
+ */
70
+ messages: () => Promise<SessionMessage[]>
71
+
72
+ /**
73
+ * `$.ui.invalidate("ui.render")`: every pane instance draws again.
74
+ */
75
+ invalidate: () => void
76
+
77
+ /**
78
+ * `$.ui.status`: the plugin's line under the prompt.
79
+ */
80
+ status: (text: string | undefined) => void
81
+
82
+ /**
83
+ * One debug line under the plugin's name (`$.ui.log`).
84
+ */
85
+ uiLog: (text: string) => void
86
+
87
+ /**
88
+ * `$.ui.open`.
89
+ */
90
+ openPane: (pane: PaneOpenArgs) => Promise<void>
91
+
92
+ /**
93
+ * `$.ui.close`.
94
+ */
95
+ closePane: (pane: PaneCloseArgs) => Promise<void>
96
+
97
+ /**
98
+ * `$.command.register`; rejects while another `/diff` is listed.
99
+ */
100
+ registerCommand: (spec: CommandSpec) => Promise<unknown>
101
+
102
+ /**
103
+ * Which session the pane-shown row is latched to, as `$.session.id` says.
104
+ */
105
+ sessionId: () => Promise<string>
106
+
107
+ /**
108
+ * `$.telemetry.mark`; rejects where the telemetry built-in is absent.
109
+ */
110
+ mark: EngineInterface['telemetry']['mark']
111
+
112
+ /**
113
+ * `$.telemetry.log`; rejects where the telemetry built-in is absent.
114
+ */
115
+ log: EngineInterface['telemetry']['log']
116
+ }
mods/diff/hooks/host/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export type * from './host.js'
2
+
3
+ export * as default from '.'
mods/diff/hooks/index.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export * from './array-of'
2
+ export * from './ask'
3
+ export * from './backend'
4
+ export * from './classify'
5
+ export * from './command-spec'
6
+ export * from './count-of'
7
+ export * from './drawn-files-of'
8
+ export * from './entry-kinds-of'
9
+ export * from './git'
10
+ export * from './host'
11
+ export * from './is-on-pane-surface'
12
+ export * from './is-record'
13
+ export * from './kept-of'
14
+ export * from './limits'
15
+ export * from './map-limited'
16
+ export * from './message-of'
17
+ export * from './mtime-of'
18
+ export * from './names'
19
+ export * from './pane-state'
20
+ export * from './pane-toggle'
21
+ export * from './record'
22
+ export * from './register.js'
23
+ export * from './todos'
24
+ export * from './tools'
25
+ export * from './turns'
26
+ export * from './views'
27
+
28
+ export * as default from '.'
mods/diff/hooks/is-on-pane-surface/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './is-on-pane-surface.js'
2
+
3
+ export * as default from '.'
mods/diff/hooks/is-on-pane-surface/is-on-pane-surface.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { RenderSurface } from 'claude-code'
2
+
3
+ /**
4
+ * Whether a render event comes from a surface that draws the pane: every
5
+ * one but the mobile app, which has no Select and is not terminal-wide.
6
+ *
7
+ * @param e the render event, or anything naming its surface
8
+ * @returns false on `mobile`, true otherwise, narrowing `e` to the rest
9
+ */
10
+ export const isOnPaneSurface = <E extends Record<'surface', RenderSurface>>(
11
+ e: E,
12
+ ): e is Exclude<E, Record<'surface', 'mobile'>> => e.surface !== 'mobile'
mods/diff/hooks/is-record/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './is-record.js'
2
+
3
+ export * as default from '.'
mods/diff/hooks/is-record/is-record.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Whether a value is a plain object that can be read field by field.
3
+ *
4
+ * @param value anything a tool result may be
5
+ * @returns whether it is a non-null, non-array object
6
+ */
7
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
8
+ typeof value === 'object' && value !== null && !Array.isArray(value)
mods/diff/hooks/kept-of/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './kept-of.js'
2
+
3
+ export * as default from '.'
mods/diff/hooks/limits/dialog-path-floor.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /**
2
+ * The fewest cells an inline file row gives its path, however narrow the
3
+ * pane (DiffFileList's 20).
4
+ */
5
+ export const DIALOG_PATH_FLOOR = 20
mods/diff/hooks/limits/index.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ export * from './columns'
2
+ export * from './dialog-path-floor.js'
3
+ export * from './sizes'
4
+ export * from './timing'
5
+ export * from './todo-bar-cells.js'
6
+ export * from './todo-bar-reserve.js'
7
+
8
+ export * as default from '.'
mods/diff/hooks/limits/todo-bar-cells.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /**
2
+ * The widest the todo progress bar draws (ReplDiffSidebar's 20).
3
+ */
4
+ export const TODO_BAR_CELLS = 20
mods/diff/hooks/limits/todo-bar-reserve.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /**
2
+ * The cells kept beside the todo bar for its `done/total` count.
3
+ */
4
+ export const TODO_BAR_RESERVE = 12
mods/diff/hooks/map-limited/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './map-limited.js'
2
+
3
+ export * as default from '.'
mods/diff/hooks/map-limited/map-limited.ts ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Maps every item through an async function, a bounded number of them in
3
+ * flight at once, and resolves to the results in the items' order.
4
+ *
5
+ * @param items the inputs
6
+ * @param limit how many may run at once, at least one
7
+ * @param fn the work for one item
8
+ * @returns the results, in order
9
+ */
10
+ export async function mapLimited<T, R>(
11
+ items: readonly T[],
12
+ limit: number,
13
+ fn: (item: T) => Promise<R>,
14
+ ): Promise<R[]> {
15
+ const results: R[] = []
16
+
17
+ let next = 0
18
+
19
+ async function worker(): Promise<void> {
20
+ for (let at = next++; at < items.length; at = next++) {
21
+ results[at] = await fn(items[at] as T)
22
+ }
23
+ }
24
+
25
+ await Promise.all(
26
+ Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker),
27
+ )
28
+
29
+ return results
30
+ }
mods/diff/hooks/mtime-of/index.ts ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export * from './mtime-of.js'
2
+
3
+ export * as default from '.'
mods/diff/hooks/mtime-of/mtime-of.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Host } from '../host'
2
+
3
+ /**
4
+ * A path's modification time through the host, or null when the path
5
+ * cannot be read (GitDeps `mtimeOf`).
6
+ *
7
+ * @param engine the bound host
8
+ * @returns the probe
9
+ */
10
+ export const mtimeOf =
11
+ (engine: Host) =>
12
+ (path: string): Promise<number | null> =>
13
+ engine.stat(path).then(
14
+ stat => stat.mtimeMs,
15
+ () => null,
16
+ )
mods/diff/hooks/names/command-description.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /**
2
+ * What the typeahead and `/help` show for `/diff` (the built-in's fullscreen
3
+ * wording: the pane is the panel's successor).
4
+ */
5
+ export const COMMAND_DESCRIPTION =
6
+ 'Toggle the diff panel showing uncommitted changes'
mods/diff/hooks/names/command-name.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /**
2
+ * The slash command the plugin serves once the built-in `/diff` stands down.
3
+ */
4
+ export const COMMAND_NAME = 'diff'
mods/diff/hooks/names/index.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ export * from './command-description.js'
2
+ export * from './command-name.js'
3
+ export * from './keys'
4
+ export * from './pane-id.js'
5
+ export * from './pane-title.js'
6
+ export * from './plugin-name.js'
7
+ export * from './texts'
8
+
9
+ export * as default from '.'
mods/diff/hooks/names/pane-id.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /**
2
+ * The one pane the plugin opens; its `requestId` at `ui.render`.
3
+ */
4
+ export const PANE_ID = 'diff'
mods/diff/hooks/names/pane-title.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ /**
2
+ * The pane's title: its tab while another pane is open too.
3
+ */
4
+ export const PANE_TITLE = 'Diff'
mods/diff/hooks/names/plugin-name.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /**
2
+ * The plugin's own name, as the engine stamps it on the elements its render
3
+ * hooks draw (`ui.press`, `ui.focus`).
4
+ */
5
+ export const PLUGIN_NAME = 'diff'
mods/diff/hooks/pane-state/dialog-title-of.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { DialogTitle } from './dialog-title'
2
+ import type { PaneModel } from './pane-model'
3
+ import { pickedTurnOf } from './picked-turn-of'
4
+ import { turnTitleOf } from './turn-title-of'
5
+
6
+ /**
7
+ * The inline pane's heading (DiffDialog's title): the picked turn and its
8
+ * prompt's opening words, or what the working tree is compared with.
9
+ *
10
+ * An unborn HEAD lists staged and new files; a branch diff names its base;
11
+ * anything else is the uncommitted changes against HEAD.
12
+ *
13
+ * @param model the pane's state
14
+ * @returns the title and its dim subtitle
15
+ */
16
+ export function dialogTitleOf(model: PaneModel): DialogTitle {
17
+ const turn = pickedTurnOf(model)
18
+ const source = model.data?.source
19
+ const isUnborn = model.data?.isUnborn === true
20
+ const isBranch = source?.kind === 'branch'
21
+
22
+ function currentOf(): DialogTitle {
23
+ switch (true) {
24
+ case isUnborn:
25
+ return { title: 'Staged and new files', subtitle: '(no commits yet)' }
26
+ case isBranch && source?.kind === 'branch':
27
+ return {
28
+ title: 'Branch changes',
29
+ subtitle: `(vs ${source.baseBranch})`,
30
+ }
31
+ default:
32
+ return { title: 'Uncommitted changes', subtitle: '(git diff HEAD)' }
33
+ }
34
+ }
35
+
36
+ return turn ? turnTitleOf(turn) : currentOf()
37
+ }
mods/diff/hooks/pane-state/empty-state-of.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Names from '../names'
2
+ import type { EmptyState } from './empty-state'
3
+ import type { PaneModel } from './pane-model'
4
+
5
+ /**
6
+ * The headline that replaces the count when nothing is listed, worded as
7
+ * ReplDiffSidebar words it, keyed on the mode the data was FETCHED in.
8
+ *
9
+ * Null while there are files to count. No data after a settled fetch is
10
+ * "Diff unavailable"; no tracked rows while the untracked listing was
11
+ * withheld claims nothing about new files.
12
+ *
13
+ * @param model the last good fetch (null when none ever settled), the words
14
+ * @param filesCount the header's session file count
15
+ * @returns the empty state, or null
16
+ */
17
+ export function emptyStateOf(
18
+ model: Pick<PaneModel, 'data' | 'words'>,
19
+ filesCount: number,
20
+ ): EmptyState | null {
21
+ const { data, words } = model
22
+
23
+ if (!data) {
24
+ return {
25
+ headline: 'Diff unavailable',
26
+ hint:
27
+ `Couldn't read the ${words.diffCommand} — it will retry on the ` +
28
+ 'next change',
29
+ }
30
+ }
31
+
32
+ if (filesCount > 0) {
33
+ return null
34
+ }
35
+
36
+ if (data.isUntrackedWithheld) {
37
+ return {
38
+ headline: 'No tracked changes',
39
+ hint: Names.untrackedWithheldTextOf(model),
40
+ }
41
+ }
42
+
43
+ if (data.isUnborn) {
44
+ return {
45
+ headline: 'No commits yet',
46
+ hint: "Nothing to diff against until the repo's first commit",
47
+ }
48
+ }
49
+
50
+ switch (data.mode) {
51
+ case 'uncommitted':
52
+ return { headline: 'No uncommitted changes', hint: null }
53
+ case 'branch':
54
+ if (data.source.kind === 'branch') {
55
+ return {
56
+ headline: `No changes vs ${data.source.baseBranch}`,
57
+ hint: null,
58
+ }
59
+ }
60
+
61
+ return {
62
+ headline: `No changes vs ${data.source.base}`,
63
+ hint:
64
+ 'No base branch to compare against — showing changes vs ' +
65
+ data.source.base,
66
+ }
67
+ case 'session':
68
+ return { headline: 'No changes this session', hint: null }
69
+ }
70
+ }
mods/diff/hooks/pane-state/header-totals-of.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type Git from '../git'
2
+ import type { HeaderTotals } from './header-totals'
3
+ import type { Partition } from './partition'
4
+
5
+ /**
6
+ * The header's counts (ReplDiffSidebar): the fetched totals less the
7
+ * pre-session rows, and how many session files fell past the row cap.
8
+ *
9
+ * Files past the cap were never dated, so they always count and always
10
+ * show as `notShown`: an edit that sorts after fifty older ones stays seen.
11
+ *
12
+ * @param data the fetch
13
+ * @param partition its rows, grouped
14
+ * @returns the counts and how many session files are past the cap
15
+ */
16
+ export function headerTotalsOf(
17
+ data: Git.DiffData,
18
+ partition: Partition,
19
+ ): HeaderTotals {
20
+ const { stats, files } = data
21
+ const before = partition.preSession
22
+ const filesCount = stats.filesCount - before.length
23
+
24
+ return {
25
+ filesCount,
26
+ linesAdded:
27
+ stats.linesAdded - before.reduce((sum, file) => sum + file.added, 0),
28
+ linesRemoved:
29
+ stats.linesRemoved - before.reduce((sum, file) => sum + file.removed, 0),
30
+ notShown: Math.max(0, filesCount - (files.length - before.length)),
31
+ }
32
+ }
mods/diff/hooks/pane-state/index.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export * from './after-fetch'
2
+ export * from './after-new-session'
3
+ export * from './base-mode-of'
4
+ export * from './bodies'
5
+ export * from './dialog-focus'
6
+ export * from './dialog-title'
7
+ export * from './dialog-title-of.js'
8
+ export * from './empty-state'
9
+ export * from './empty-state-of.js'
10
+ export * from './header-totals'
11
+ export * from './header-totals-of.js'
12
+ export * from './initial-model'
13
+ export * from './mode-label-of'
14
+ export * from './no-bodies'
15
+ export * from './pane-model'
16
+ export * from './partition'
17
+ export * from './partition-of.js'
18
+ export * from './picked-turn-of'
19
+ export * from './source'
20
+ export * from './turn-title-of'
21
+ export * from './turn-totals-of'
22
+ export * from './unborn-note-of.js'
23
+ export * from './zero-totals'
24
+
25
+ export * as default from '.'
mods/diff/hooks/pane-state/partition-of.ts ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Classify from '../classify'
2
+ import Git from '../git'
3
+ import type { Partition } from './partition'
4
+
5
+ /**
6
+ * A fetch's rows split as ReplDiffSidebar splits them, in its order (by
7
+ * name): pre-session apart, tests and generated files hidden unless shown.
8
+ *
9
+ * No read-deny bucket: the pane feeds nothing to the model on its own.
10
+ *
11
+ * @param files the fetched rows
12
+ * @param noise `shown` keeps tests and generated files in the list
13
+ * @returns the groups
14
+ */
15
+ export function partitionOf(
16
+ files: readonly Git.FileStat[],
17
+ noise: 'shown' | 'hidden',
18
+ ): Partition {
19
+ const sorted = [...files].sort((a, b) =>
20
+ Git.displayPathOf(a).localeCompare(Git.displayPathOf(b)),
21
+ )
22
+
23
+ const preSession = sorted.filter(file => file.isPreSession)
24
+ const session = sorted.filter(file => !file.isPreSession)
25
+ const quiet = session.filter(file => !Classify.isNoiseFile(file.path))
26
+
27
+ return {
28
+ shown: noise === 'shown' ? session : quiet,
29
+ preSession,
30
+ noiseCount: session.length - quiet.length,
31
+ }
32
+ }
mods/diff/hooks/pane-state/unborn-note-of.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { PaneModel } from './pane-model'
2
+
3
+ /**
4
+ * The dim line under the header on an unborn HEAD that still lists rows:
5
+ * what the rows are, since no comparison runs before the first commit.
6
+ *
7
+ * ReplDiffSidebar's no-commits qualifier; null with commits or no rows.
8
+ *
9
+ * @param model the last good fetch
10
+ * @param filesCount the header's session file count
11
+ * @returns the line, or null
12
+ */
13
+ export function unbornNoteOf(
14
+ model: Pick<PaneModel, 'data'>,
15
+ filesCount: number,
16
+ ): string | null {
17
+ const isUnbornListing = model.data?.isUnborn === true && filesCount > 0
18
+
19
+ return isUnbornListing
20
+ ? 'no commits yet — showing staged and new files'
21
+ : null
22
+ }
mods/diff/hooks/record/index.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export * from './base-outcomes'
2
+ export * from './features'
3
+ export * from './mark-outcome'
4
+ export * from './recorder'
5
+ export * from './recorder-of.js'
6
+ export * from './safely'
7
+ export * from './shown-triggers'
8
+ export * from './tabs'
9
+ export * from './width-bucket-of'
10
+ export * from './width-buckets'
11
+
12
+ export * as default from '.'
mods/diff/hooks/record/recorder-of.ts ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Host } from '../host'
2
+ import { FEATURES } from './features'
3
+ import type { Recorder } from './recorder'
4
+ import { safely } from './safely'
5
+ import { SHOWN_TRIGGERS } from './shown-triggers'
6
+ import { WIDTH_BUCKETS } from './width-buckets'
7
+
8
+ /**
9
+ * The plugin's Recorder over the host: each row goes through the telemetry
10
+ * built-in's `$.telemetry`, named and shaped as the built-in panel's own.
11
+ *
12
+ * A missing noun or a refused row is dropped.
13
+ *
14
+ * @param host the engine as session.start bound it
15
+ * @returns the recorder
16
+ */
17
+ export const recorderOf = (host: Host): Recorder => ({
18
+ mark: (feature, outcome) => safely(() => host.mark({ feature, ...outcome })),
19
+ shown: (trigger, bucket) =>
20
+ safely(() =>
21
+ host.log({
22
+ event: 'tengu_repl_diff_panel_shown',
23
+ props: {
24
+ trigger: { value: trigger, of: SHOWN_TRIGGERS },
25
+ terminal_width_bucket: { value: bucket, of: WIDTH_BUCKETS },
26
+ },
27
+ }),
28
+ ),
29
+ asked: () =>
30
+ safely(() => host.mark({ feature: FEATURES.selectionAttach, kind: 'ok' })),
31
+ })
mods/diff/hooks/register.ts ADDED
@@ -0,0 +1,907 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ On,
3
+ PaneOpenArgs,
4
+ ResultOf,
5
+ SessionMessage,
6
+ Timer,
7
+ } from 'claude-code'
8
+
9
+ import Ask from './ask'
10
+ import Backend from './backend'
11
+ import { COMMAND_SPEC } from './command-spec'
12
+ import { drawnFilesOf } from './drawn-files-of'
13
+ import { entryKindsOf } from './entry-kinds-of'
14
+ import type Git from './git'
15
+ import type { Host } from './host'
16
+ import { isOnPaneSurface } from './is-on-pane-surface'
17
+ import Limits from './limits'
18
+ import { mapLimited } from './map-limited'
19
+ import { messageOf } from './message-of'
20
+ import { mtimeOf } from './mtime-of'
21
+ import Names from './names'
22
+ import PaneState from './pane-state'
23
+ import PaneToggle from './pane-toggle'
24
+ import Record from './record'
25
+ import Tools from './tools'
26
+ import Views from './views'
27
+
28
+ /**
29
+ * Registers the diff pane: `/diff` once the built-in stands down, the
30
+ * pane's drawing and refresh, its opening on Claude's first edit, the ask.
31
+ *
32
+ * Git runs when the built-in's would: `session.start` binds the host and
33
+ * registers `/diff`; `/diff` or the first edit with room pins the backend
34
+ * where the session started, until `/clear`; an open pane alone fetches.
35
+ *
36
+ * @param on the engine's registrar
37
+ */
38
+ export function register(on: On) {
39
+ let host: Host | null = null
40
+ let backend: Backend.Backend | null = null
41
+ let probing: Promise<boolean> | null = null
42
+ let sessionStartMs = 0
43
+ let isPaneOpen = false
44
+ let dialogRows: number | null = null
45
+ let hasAutoOpened = false
46
+ let columns: number | null = null
47
+ let shownSessionId: string | null = null
48
+ let armed: Ask.ArmedAsk | null = null
49
+ let carrying: Ask.ArmedAsk | null = null
50
+ let isRefreshing = false
51
+ let isRefreshQueued = false
52
+ let generation = 0
53
+ let bodyStamp: string | null = null
54
+ let bodyBase: string | null = null
55
+
56
+ const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>()
57
+
58
+ const polled = { toplevel: '', headKey: '' }
59
+ const pin = { cwd: '', isEmpty: false, epoch: 0 }
60
+
61
+ let model: PaneState.PaneModel = PaneState.INITIAL_MODEL
62
+
63
+ const timers = new Map<'refresh' | 'redraw' | 'poll', Timer>()
64
+ const loggedBaseKinds = new Set<'ok' | 'sad'>()
65
+
66
+ const currentOf = (engine: Host): Host => host ?? engine
67
+
68
+ const backendHostOf = (engine: Host): Backend.BackendHost => ({
69
+ run: (argv, init) =>
70
+ currentOf(engine).run(
71
+ argv,
72
+ pin.cwd === '' ? init : { cwd: pin.cwd, ...init },
73
+ ),
74
+ readFile: path => currentOf(engine).readFile(path),
75
+ mtimeOf: path => mtimeOf(currentOf(engine))(path),
76
+ entryKindsOf: dir => entryKindsOf(currentOf(engine))(dir),
77
+ nowMs: () => currentOf(engine).now(),
78
+ sessionStartMsOf: () => sessionStartMs,
79
+ onBranchBase: base => {
80
+ const isError = base.kind === 'error'
81
+
82
+ const outcome: Record.MarkOutcome = isError
83
+ ? { kind: 'sad', reason: base.reason }
84
+ : {
85
+ kind: 'ok',
86
+ props: {
87
+ outcome: { value: base.kind, of: Record.BASE_OUTCOMES },
88
+ },
89
+ }
90
+
91
+ if (!loggedBaseKinds.has(outcome.kind)) {
92
+ loggedBaseKinds.add(outcome.kind)
93
+
94
+ Record.recorderOf(currentOf(engine)).mark(
95
+ Record.FEATURES.baseResolve,
96
+ outcome,
97
+ )
98
+ }
99
+ },
100
+ })
101
+
102
+ function pinBackend(engine: Host): Promise<boolean> {
103
+ if (backend || pin.isEmpty) {
104
+ return Promise.resolve(true)
105
+ }
106
+
107
+ probing ??= probeBackend(engine).finally(() => {
108
+ probing = null
109
+ })
110
+
111
+ return probing
112
+ }
113
+
114
+ async function probeBackend(engine: Host): Promise<boolean> {
115
+ const asked = { isAnswered: true }
116
+ const probeHost = backendHostOf(engine)
117
+ const { epoch } = pin
118
+
119
+ const probed = await Backend.backendOf(
120
+ {
121
+ ...probeHost,
122
+ run: (argv, init) =>
123
+ probeHost.run(argv, init).catch((error: unknown) => {
124
+ asked.isAnswered &&=
125
+ argv[0] !== 'git' || !/\baborted\b/.test(messageOf(error))
126
+
127
+ throw error
128
+ }),
129
+ },
130
+ Backend.INSTALLED_BACKEND_PROBES,
131
+ )
132
+
133
+ if (epoch !== pin.epoch) {
134
+ return false
135
+ }
136
+
137
+ backend ??= probed
138
+ pin.isEmpty = backend === null && asked.isAnswered
139
+
140
+ if (!probed || backend !== probed) {
141
+ return asked.isAnswered || backend !== null
142
+ }
143
+
144
+ const stored = PaneState.baseModeOf(
145
+ await engine
146
+ .storeGet(Names.baseStoreKeyOf(probed.repository.toplevel))
147
+ .catch(() => undefined),
148
+ )
149
+
150
+ const mode = stored && probed.baseModes.includes(stored) ? stored : null
151
+
152
+ model = {
153
+ ...model,
154
+ words: probed.words,
155
+ baseModes: probed.baseModes,
156
+ ...(mode && { requestedMode: mode }),
157
+ }
158
+
159
+ return true
160
+ }
161
+
162
+ function unpin() {
163
+ backend = null
164
+ pin.isEmpty = false
165
+ pin.epoch += 1
166
+ polled.toplevel = ''
167
+ polled.headKey = ''
168
+ timers.get('poll')?.cancel()
169
+ timers.delete('poll')
170
+ }
171
+
172
+ function dialogPane(): PaneOpenArgs {
173
+ return {
174
+ id: Names.PANE_ID,
175
+ title: Names.PANE_TITLE,
176
+ holdToasts: true,
177
+ closeOnEscape: true,
178
+ rows: Views.dialogRowsOf(model),
179
+ }
180
+ }
181
+
182
+ function fitDialog(engine: Host) {
183
+ const rows = Views.dialogRowsOf(model)
184
+
185
+ const isStale =
186
+ isPaneOpen && model.isFullscreen === false && rows !== dialogRows
187
+
188
+ if (isStale) {
189
+ dialogRows = rows
190
+ void engine.openPane(dialogPane()).catch(() => undefined)
191
+ }
192
+ }
193
+
194
+ function redraw(engine: Host) {
195
+ fitDialog(engine)
196
+
197
+ if (timers.has('redraw')) {
198
+ return
199
+ }
200
+
201
+ timers.set(
202
+ 'redraw',
203
+ engine.after(Limits.REDRAW_COALESCE_MS, () => {
204
+ timers.delete('redraw')
205
+ engine.invalidate()
206
+ }),
207
+ )
208
+ }
209
+
210
+ async function loadBodies(engine: Host): Promise<boolean> {
211
+ const { data } = model
212
+ const pinned = backend
213
+
214
+ if (!data || !pinned) {
215
+ bodyStamp = null
216
+ bodyBase = null
217
+ bodyLoads.clear()
218
+ model = { ...model, bodies: PaneState.NO_BODIES }
219
+
220
+ return false
221
+ }
222
+
223
+ const stamp = bodyStampOf(data)
224
+ const isNewBase = data.baseRef !== bodyBase
225
+
226
+ if (stamp !== bodyStamp) {
227
+ bodyStamp = stamp
228
+ bodyLoads.clear()
229
+ }
230
+
231
+ if (isNewBase) {
232
+ bodyBase = data.baseRef
233
+ model = { ...model, bodies: PaneState.NO_BODIES }
234
+ redraw(engine)
235
+ }
236
+
237
+ return fetchBodies(engine, pinned, data)
238
+ }
239
+
240
+ const bodyStampOf = (data: Git.DiffData) => `${generation}|${data.baseRef}`
241
+
242
+ async function fetchBodies(
243
+ engine: Host,
244
+ pinned: Backend.Backend,
245
+ data: Git.DiffData,
246
+ ): Promise<boolean> {
247
+ const stamp = bodyStampOf(data)
248
+
249
+ function loadOf(file: Git.FileStat): Promise<Git.FileHunks | null> {
250
+ const load = pinned.fetchFileHunks(data, file)
251
+ bodyLoads.set(file.path, load)
252
+
253
+ return load.then(body => {
254
+ if (bodyStamp === stamp) {
255
+ model = {
256
+ ...model,
257
+ bodies: new Map(model.bodies).set(file.path, body),
258
+ }
259
+
260
+ redraw(engine)
261
+ }
262
+
263
+ return body
264
+ })
265
+ }
266
+
267
+ return (
268
+ await mapLimited(
269
+ drawnFilesOf(model).filter(file => !bodyLoads.has(file.path)),
270
+ Limits.BODY_FETCH_CONCURRENCY,
271
+ loadOf,
272
+ )
273
+ ).includes(null)
274
+ }
275
+
276
+ function startPoll(engine: Host, pinned: Backend.Backend) {
277
+ const readHeadKey = () => pinned.headKeyOf().catch(() => '')
278
+
279
+ if (polled.toplevel === pinned.repository.toplevel) {
280
+ return
281
+ }
282
+
283
+ timers.get('poll')?.cancel()
284
+ polled.toplevel = pinned.repository.toplevel
285
+ polled.headKey = ''
286
+
287
+ timers.set(
288
+ 'poll',
289
+ engine.every(Limits.HEAD_POLL_MS, () => {
290
+ if (!isPaneOpen) {
291
+ return
292
+ }
293
+
294
+ void readHeadKey().then(key => {
295
+ const hasMoved = polled.headKey !== '' && key !== polled.headKey
296
+
297
+ polled.headKey = key
298
+
299
+ if (hasMoved) {
300
+ scheduleRefresh(engine)
301
+ }
302
+ })
303
+ }),
304
+ )
305
+ }
306
+
307
+ async function refresh(engine: Host): Promise<void> {
308
+ if (isRefreshing) {
309
+ isRefreshQueued = true
310
+
311
+ return
312
+ }
313
+
314
+ isRefreshing = true
315
+
316
+ const record = Record.recorderOf(engine)
317
+ const pinned = backend
318
+
319
+ const fetched = (): Promise<Git.FetchOutcome> =>
320
+ pinned
321
+ ? pinned.fetchDiff(model.requestedMode)
322
+ : Promise.resolve({ kind: 'no-repository' })
323
+
324
+ try {
325
+ model = { ...model, isLoading: model.data === null }
326
+
327
+ const [outcome, messages] = await Promise.all([
328
+ fetched(),
329
+ engine.messages().catch((): SessionMessage[] => []),
330
+ ])
331
+
332
+ model = PaneState.afterFetch(model, { outcome, messages })
333
+
334
+ switch (outcome.kind) {
335
+ case 'no-repository':
336
+ break
337
+ case 'unavailable':
338
+ record.mark(Record.FEATURES.read, {
339
+ kind: 'sad',
340
+ reason: 'git_diff_failed',
341
+ })
342
+
343
+ break
344
+ case 'data':
345
+ generation += 1
346
+
347
+ if (pinned) {
348
+ startPoll(engine, pinned)
349
+ }
350
+
351
+ break
352
+ }
353
+
354
+ const hasHunksFailed = await loadBodies(engine)
355
+
356
+ if (outcome.kind === 'data') {
357
+ record.mark(
358
+ Record.FEATURES.read,
359
+ hasHunksFailed
360
+ ? { kind: 'sad', reason: 'git_hunks_failed' }
361
+ : { kind: 'ok' },
362
+ )
363
+ }
364
+ } catch (error) {
365
+ record.mark(Record.FEATURES.read, {
366
+ kind: 'sad',
367
+ reason: 'git_diff_threw',
368
+ })
369
+
370
+ throw error
371
+ } finally {
372
+ isRefreshing = false
373
+ redraw(engine)
374
+
375
+ if (isRefreshQueued) {
376
+ isRefreshQueued = false
377
+ scheduleRefresh(engine)
378
+ }
379
+ }
380
+ }
381
+
382
+ function scheduleRefresh(engine: Host): void {
383
+ timers.get('refresh')?.cancel()
384
+
385
+ timers.set(
386
+ 'refresh',
387
+ engine.after(Limits.REFRESH_DEBOUNCE_MS, () => {
388
+ timers.delete('refresh')
389
+ void refresh(engine)
390
+ }),
391
+ )
392
+ }
393
+
394
+ async function openPane(
395
+ engine: Host,
396
+ trigger: (typeof Record.SHOWN_TRIGGERS)[number],
397
+ ): Promise<void> {
398
+ const isDialog = model.isFullscreen === false
399
+
400
+ model = {
401
+ ...model,
402
+ selectedPath: null,
403
+ dialogView: 'list',
404
+ place: { ...model.place, top: 0, listStart: 0 },
405
+ }
406
+
407
+ dialogRows = isDialog ? Views.dialogRowsOf(model) : null
408
+
409
+ await engine.openPane(
410
+ isDialog
411
+ ? { ...dialogPane(), focus: true }
412
+ : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true },
413
+ )
414
+
415
+ isPaneOpen = true
416
+
417
+ const sessionId = await engine.sessionId().catch(() => null)
418
+
419
+ if (sessionId !== null && sessionId !== shownSessionId) {
420
+ shownSessionId = sessionId
421
+ Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns))
422
+ }
423
+
424
+ void refresh(engine)
425
+ }
426
+
427
+ async function closePane(engine: Host): Promise<void> {
428
+ await engine.closePane({ id: Names.PANE_ID })
429
+ isPaneOpen = false
430
+ }
431
+
432
+ function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) {
433
+ Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, {
434
+ kind: 'ok',
435
+ props: { tab: { value: tab, of: Record.TABS } },
436
+ })
437
+ }
438
+
439
+ async function openOnFirstEdit(engine: Host): Promise<void> {
440
+ const isTaken = () => isPaneOpen || hasAutoOpened
441
+
442
+ if (isTaken()) {
443
+ return
444
+ }
445
+
446
+ const preference = await engine.storeGet(Names.STORE_OPEN_KEY)
447
+ const isKeptOpen = preference === true
448
+
449
+ const floor = isKeptOpen
450
+ ? Limits.OPEN_MIN_COLUMNS
451
+ : Limits.AUTO_OPEN_MIN_COLUMNS
452
+
453
+ const hasRoom =
454
+ preference !== false &&
455
+ model.isFullscreen === true &&
456
+ columns !== null &&
457
+ columns >= floor
458
+
459
+ if (!hasRoom || isTaken()) {
460
+ return
461
+ }
462
+
463
+ await pinBackend(engine)
464
+
465
+ if (!backend || isTaken()) {
466
+ return
467
+ }
468
+
469
+ hasAutoOpened = true
470
+ await openPane(engine, 'auto_open')
471
+ }
472
+
473
+ function disarm(engine: Host) {
474
+ armed = null
475
+ model = { ...model, armedPath: null }
476
+ engine.status(undefined)
477
+ }
478
+
479
+ const actionsOf = (engine: Host): Views.PaneActions => ({
480
+ selectFile: path => {
481
+ const isDocked = model.placement === 'dock'
482
+
483
+ model = {
484
+ ...model,
485
+ selectedPath: path,
486
+ dialogView: isDocked ? model.dialogView : 'detail',
487
+ place: isDocked ? Views.placeAtFile(model, path) : model.place,
488
+ }
489
+
490
+ redraw(engine)
491
+ },
492
+ scrollList: delta => {
493
+ model = { ...model, place: Views.listScrolledBy(model, delta) }
494
+ redraw(engine)
495
+ },
496
+ toggleNoise: () => {
497
+ model = { ...model, isNoiseShown: !model.isNoiseShown }
498
+ void loadBodies(engine)
499
+ redraw(engine)
500
+ },
501
+ togglePreSession: () => {
502
+ model = { ...model, isPreSessionShown: !model.isPreSessionShown }
503
+ void loadBodies(engine)
504
+ redraw(engine)
505
+ },
506
+ cycleBase: () => {
507
+ const { baseModes, requestedMode } = model
508
+
509
+ const mode =
510
+ baseModes[(baseModes.indexOf(requestedMode) + 1) % baseModes.length] ??
511
+ requestedMode
512
+
513
+ if (mode === requestedMode) {
514
+ return
515
+ }
516
+
517
+ model = { ...model, requestedMode: mode }
518
+
519
+ Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, {
520
+ kind: 'ok',
521
+ props: { mode: { value: mode, of: model.baseModes } },
522
+ })
523
+
524
+ const toplevel = model.data?.repository.toplevel
525
+
526
+ if (toplevel !== undefined) {
527
+ void engine
528
+ .storeSet(Names.baseStoreKeyOf(toplevel), mode)
529
+ .catch(() => undefined)
530
+ }
531
+
532
+ void refresh(engine)
533
+ redraw(engine)
534
+ },
535
+ chooseSource: value => {
536
+ const index = Number(value)
537
+ const isTurn = value !== 'current' && Number.isInteger(index)
538
+
539
+ const source: PaneState.Source = isTurn
540
+ ? { kind: 'turn', index }
541
+ : { kind: 'current' }
542
+
543
+ model = { ...model, source, selectedPath: null, dialogView: 'list' }
544
+ redraw(engine)
545
+ },
546
+ toggleAsk: path => {
547
+ if (armed?.path === path) {
548
+ disarm(engine)
549
+ redraw(engine)
550
+
551
+ return
552
+ }
553
+
554
+ arm(engine, path)
555
+ },
556
+ })
557
+
558
+ function arm(engine: Host, path: string) {
559
+ armed = Ask.armedAskOf(
560
+ path,
561
+ PaneState.pickedTurnOf(model)?.files.find(file => file.path === path)
562
+ ?.hunks ??
563
+ model.bodies.get(path)?.hunks ??
564
+ [],
565
+ )
566
+
567
+ model = { ...model, armedPath: path }
568
+
569
+ engine.status(
570
+ `${Views.sanitizeName(path)} rides your next prompt (press ` +
571
+ `asked ✓ to drop it)`,
572
+ )
573
+
574
+ redraw(engine)
575
+ }
576
+
577
+ async function bind(engine: Host, cwd: string): Promise<void> {
578
+ sessionStartMs = await engine.now()
579
+ pin.cwd = cwd
580
+
581
+ try {
582
+ await engine.registerCommand(COMMAND_SPEC)
583
+ host = engine
584
+ } catch (error) {
585
+ const reason = messageOf(error)
586
+
587
+ if (!Names.BUILTIN_HOLDS_PATTERN.test(reason)) {
588
+ engine.uiLog(Names.registerFailedTextOf(Views.sanitizeName(reason)))
589
+ }
590
+ }
591
+ }
592
+
593
+ on('session.start', async ($, e, next) => {
594
+ await bind(
595
+ {
596
+ now: () => $.clock.now(),
597
+ after: (ms, fn) => $.clock.after(ms, fn),
598
+ every: (ms, fn) => $.clock.every(ms, fn),
599
+ run: (argv, init) => $.process.run(argv, init),
600
+ stat: path => $.fs.stat(path),
601
+ listDir: path => $.fs.list(path),
602
+ readFile: path => $.fs.read(path),
603
+ storeGet: key => $.store.get(key),
604
+ storeSet: (key, value) => $.store.set(key, value),
605
+ messages: () => $.session.messages(),
606
+ invalidate: () => $.ui.invalidate('ui.render'),
607
+ status: text => $.ui.status(text),
608
+ uiLog: text => $.ui.log(text),
609
+ openPane: pane => $.ui.open(pane),
610
+ closePane: pane => $.ui.close(pane),
611
+ registerCommand: spec => $.command.register(spec),
612
+ sessionId: () => $.session.id(),
613
+ mark: entry => $.telemetry.mark(entry),
614
+ log: entry => $.telemetry.log(entry),
615
+ },
616
+ e.cwd,
617
+ )
618
+
619
+ return next(e)
620
+ })
621
+
622
+ on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
623
+ if (isOnPaneSurface(e)) {
624
+ columns = e.viewport?.columns ?? columns
625
+
626
+ model = {
627
+ ...model,
628
+ isFullscreen: e.viewport?.isFullscreen ?? model.isFullscreen,
629
+ }
630
+ }
631
+
632
+ return next(e)
633
+ })
634
+
635
+ on('ui.render', { component: 'Pane' }, async ($, e, next) => {
636
+ if (e.requestId !== Names.PANE_ID || !host || !isOnPaneSurface(e)) {
637
+ return next(e)
638
+ }
639
+
640
+ const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
641
+
642
+ columns = e.viewport?.columns ?? columns
643
+
644
+ model = {
645
+ ...model,
646
+ placement: e.props.placement,
647
+ place: {
648
+ ...model.place,
649
+ columns: Math.max(
650
+ 1,
651
+ e.props.bodyColumns - Limits.PANE_RIGHT_PAD_COLUMNS,
652
+ ),
653
+ rows: e.props.scroll.bodyRows,
654
+ },
655
+ }
656
+
657
+ return Views.paneView(
658
+ {
659
+ ui: { Box, Text, Button, Select, Code },
660
+ actions: actionsOf(host),
661
+ columns: e.props.bodyColumns,
662
+ rows: e.props.scroll.bodyRows,
663
+ },
664
+ model,
665
+ { placement: e.props.placement, terminalColumns: columns },
666
+ )
667
+ })
668
+
669
+ on('command.run', { command: Names.COMMAND_NAME }, async ($, e, next) => {
670
+ if (!host) {
671
+ return next(e)
672
+ }
673
+
674
+ const isAnswered = (await pinBackend(host)) || (await pinBackend(host))
675
+
676
+ if (!backend) {
677
+ return {
678
+ text: isAnswered
679
+ ? Names.NOT_IN_REPOSITORY_TEXT
680
+ : Names.GIT_UNANSWERED_TEXT,
681
+ }
682
+ }
683
+
684
+ const { isFullscreen } = e.presentation
685
+
686
+ columns = e.presentation.columns
687
+ model = { ...model, isFullscreen }
688
+
689
+ const toggle = PaneToggle.paneToggleOf({
690
+ isOpen: isPaneOpen,
691
+ columns: isFullscreen ? columns : null,
692
+ })
693
+
694
+ if (toggle === 'too-narrow') {
695
+ return { text: Names.RESIZE_TERMINAL_TEXT }
696
+ }
697
+
698
+ const isOpening = toggle === 'open'
699
+ await (isOpening ? openPane(host, 'manual') : closePane(host))
700
+
701
+ if (!isFullscreen) {
702
+ return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
703
+ }
704
+
705
+ markTabSwitch(host, isOpening ? 'diff' : 'convo')
706
+ await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
707
+
708
+ return {
709
+ text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT,
710
+ }
711
+ })
712
+
713
+ on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
714
+ const isBack =
715
+ e.origin.kind === 'person' &&
716
+ model.placement === 'inline' &&
717
+ model.dialogView === 'detail'
718
+
719
+ if (isBack && host) {
720
+ model = { ...model, dialogView: 'list' }
721
+ redraw(host)
722
+
723
+ dialogRows = Views.dialogRowsOf(model)
724
+
725
+ void host
726
+ .openPane({ ...dialogPane(), focus: true })
727
+ .catch(() => undefined)
728
+
729
+ return { deny: 'back to the file list' }
730
+ }
731
+
732
+ const result = await next(e)
733
+ const isClosed = result.deny === undefined
734
+ const isPersons = isClosed && e.origin.kind === 'person'
735
+
736
+ if (isClosed) {
737
+ isPaneOpen = false
738
+ }
739
+
740
+ const isDialog = model.isFullscreen === false
741
+
742
+ if (isPersons && host && isDialog) {
743
+ host.uiLog(Names.DIALOG_DISMISSED_TEXT)
744
+ }
745
+
746
+ if (isPersons && host && !isDialog) {
747
+ markTabSwitch(host, 'convo')
748
+ await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined)
749
+ }
750
+
751
+ return result
752
+ })
753
+
754
+ on('ui.focus', { plugin: Names.PLUGIN_NAME }, ($, e, next) => {
755
+ const isListed =
756
+ model.placement === 'inline' && model.dialogView === 'list' && host
757
+
758
+ const focus = isListed ? Views.dialogFocusOf(model, e.element) : null
759
+
760
+ if (focus === 'stay') {
761
+ return {}
762
+ }
763
+
764
+ if (!focus || !host) {
765
+ return next(e)
766
+ }
767
+
768
+ model = { ...model, selectedPath: focus.selectedPath }
769
+ fitDialog(host)
770
+ host.invalidate()
771
+
772
+ return next({ ...e, element: focus.landing })
773
+ })
774
+
775
+ on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => {
776
+ const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock'
777
+
778
+ if (!isOwnBody || !host) {
779
+ return next(e)
780
+ }
781
+
782
+ const isOverList = Views.isWheelOverList(model, e)
783
+
784
+ model = {
785
+ ...model,
786
+ place: isOverList
787
+ ? Views.listScrolledBy(model, e.by)
788
+ : Views.bodyScrolledBy(model, e),
789
+ }
790
+
791
+ host.invalidate()
792
+
793
+ return {}
794
+ })
795
+
796
+ on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
797
+ const result = await next(e)
798
+
799
+ if (host) {
800
+ if (isPaneOpen) {
801
+ await closePane(host).catch(() => undefined)
802
+ }
803
+
804
+ unpin()
805
+ hasAutoOpened = false
806
+ bodyStamp = null
807
+ bodyBase = null
808
+ bodyLoads.clear()
809
+ disarm(host)
810
+ model = PaneState.afterNewSession(model)
811
+ }
812
+
813
+ return result
814
+ })
815
+
816
+ function afterTool(
817
+ engine: Host,
818
+ tool: string,
819
+ result: ResultOf['tool.call'] | undefined,
820
+ ) {
821
+ const isEdit = Tools.EDITING_TOOLS.some(name => name === tool)
822
+
823
+ const hasEdited =
824
+ isEdit &&
825
+ result !== undefined &&
826
+ result.deny === undefined &&
827
+ result.isError !== true
828
+
829
+ const isStale =
830
+ isPaneOpen &&
831
+ (isEdit ? hasEdited : result === undefined || result.deny === undefined)
832
+
833
+ if (isStale) {
834
+ scheduleRefresh(engine)
835
+ }
836
+
837
+ if (hasEdited) {
838
+ void openOnFirstEdit(engine).catch(() => undefined)
839
+ }
840
+ }
841
+
842
+ on(
843
+ 'tool.call',
844
+ { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] },
845
+ async ($, e, next) => {
846
+ let result: ResultOf['tool.call'] | undefined
847
+
848
+ try {
849
+ result = await next(e)
850
+
851
+ return result
852
+ } finally {
853
+ if (host) {
854
+ afterTool(host, e.tool, result)
855
+ }
856
+ }
857
+ },
858
+ )
859
+
860
+ on('prompt.submit', async ($, e, next) => {
861
+ const asked = armed
862
+
863
+ if (!host || !asked || carrying === asked) {
864
+ return next(e)
865
+ }
866
+
867
+ const context = e.context ?? []
868
+
869
+ const text = Ask.fittedAskTextOf(
870
+ asked.text,
871
+ Limits.PROMPT_CONTEXT_MAX_CHARS -
872
+ context.reduce((sum, entry) => sum + entry.length, 0),
873
+ )
874
+
875
+ if (text === undefined) {
876
+ disarm(host)
877
+
878
+ host.status(
879
+ `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` +
880
+ `and was dropped`,
881
+ )
882
+
883
+ redraw(host)
884
+
885
+ return next(e)
886
+ }
887
+
888
+ carrying = asked
889
+
890
+ try {
891
+ const result = await next({ ...e, context: [...context, text] })
892
+
893
+ if (result.drop === undefined) {
894
+ Record.recorderOf(host).asked()
895
+
896
+ if (armed === asked) {
897
+ disarm(host)
898
+ redraw(host)
899
+ }
900
+ }
901
+
902
+ return result
903
+ } finally {
904
+ carrying = null
905
+ }
906
+ })
907
+ }