The diff pane: /diff and its Pane drawing, a refresh on X-Coder's edits, shell commands and finished turns, the pane's opening on the first edit, and the ask…

The diff pane as a plugin: /diff opens the session's uncommitted changes beside the transcript, one row per changed file and every file's hunks beneath, and closes it again; each toggle leaves Diff panel shown or Diff panel hidden in the transcript. The header, the file list and its toggles stay put while the wheel moves the hunks under them three rows a tick, or the list a file a tick while the wheel is over a list longer than its eight rows (the plugin answers the pane's ui.scroll itself); a row's click puts that file's hunks at the top; the list also scrolls under the built-in's list keys (ctrl+up/ctrl+down, opt+up/opt+down), and ctrl+x b moves the comparison base on, as the built-in's chord does: both through Buttons that declare the engine's own actions. The pane refreshes as X-Coder edits and runs shell commands, and while it is open it polls the repository's HEAD so a commit or checkout made elsewhere shows too. The main loop's first successful edit of a session opens the pane by itself, as the built-in panel opens on its first checkpoint: where the layout docks it beside the transcript (the fullscreen layout, which each drawing's viewport says), the terminal is wide enough (144 columns when the person never chose, 110 when they kept it open before; a person who closed it is left alone) and file checkpointing is on; a subagent's edit opens nothing, and where the surface does not say, nothing opens by itself. A docked pane fetches before it opens, as the built-in panel primes its data, so it never lands on Loading diff…; an open the engine leaves waiting undrawn is withdrawn, so no later resize seats it, and the next edit asks again. A session resumed or continued whose transcript already holds such an edit opens the pane on the same terms as soon as the width is known, as the built-in opens on the history it restores.
Under the fullscreen layout a terminal under 110 columns gets the built-in's line asking for a wider one and nothing opens. Without that layout (X_CODER_NO_FLICKER=0, which /diff learns from the command's presentation) the pane opens inline at any width, focused and as tall as its content (the open's rows), in the built-in dialog's shape: the title, the count, five file rows at a time round the selected one (❯, where the focus ring starts; the plugin follows the ring's walk through ui.focus and re-centres the rows as the built-in does), the key hints; Enter shows that file's hunks alone, Escape backs out to the list and then closes, leaving Diff dialog dismissed; toasts are held while it is up. A file's ask button arms that file: its hunks ride the next prompt as context, once.
The pane compares the working tree against HEAD, split at the session's start (the default; the start the engine gives in $.session.usage(), so a resumed session keeps its first and /clear begins anew), against HEAD plainly, or against the merge-base with the default branch; the base line under the header names a base other than the session's, and the choice is kept per repository in the plugin's store. A picker shows one earlier turn's edits instead of the working tree, read from the session's messages. Files that changed before the session started (by their timestamp, among the paths already dirty when the pane first read the repository), and noise (lockfiles, generated and test files), are listed apart and folded until asked for; a rename lists as git prints it. Outside a git repository /diff says so and does nothing else.
Git runs when the built-in panel's would: nothing at the session's start; one git rev-parse, in the directory the session started in, when /diff or the first edit a pane has room to open on first needs the repository (an answer of no repository is kept too, until /clear or /resume forgets it); and the working tree is read only by a fetch for a pane that is open, after an edit that landed or a shell command that ran. The one read the built-in has no counterpart for is a git status at a pane's first fetch, which stands in for the change time the built-in dates a moved file by.
hooks/register.ts is the module; everything under hooks/ is its parts.
| event | what the hook does |
|---|---|
session.start | Binds the engine once and registers /diff (a session where another /diff is listed leaves the plugin idle); asks nothing of the repository, which /diff or the first edit pins when it comes; off its dispatch, reads the transcript, and for a resumed session whose turns edited opens the pane as the first edit would. |
ui.render of PromptHint | Reads the terminal's width and whether its layout docks a pane, which decide whether the first edit opens the pane. |
ui.render of Pane | Draws the pane: docked, the header, base line, source picker, file list and toggles over the window of hunks; inline, the dialog. |
command.run of diff | Pins the repository when none is, opens or closes the pane (focused and closing on Escape without the fullscreen layout), says which, and remembers the choice. |
ui.close of the pane | Backs out of the dialog's detail view instead of closing; else remembers the person's close as /diff's. |
ui.scroll of the pane | Docked, moves the hunks under the pinned header and list (three rows a wheel tick, a page a page key), or the list when the wheel is over it, and keeps the engine's window still. |
ui.focus in the pane | In the dialog's list, selects the file the ring lands on, re-centres the five rows on it, and lands the ring where that row now sits. |
command.run of clear, resume | Closes the pane and forgets the session's state, the pinned repository with it. |
tool.call of Edit, Write, NotebookEdit | After an edit that landed (not refused, not failed), refreshes an open pane; the main loop's first such edit opens it, pinning the repository then if the terminal has the room and checkpointing is on. |
tool.call of Bash, PowerShell | After a command that was not refused, failed and interrupted ones too, refreshes an open pane. |
prompt.submit | Adds the armed file's hunks to the prompt's context and disarms. |
$clock.after, clock.every, clock.now, command.register, env.get (X_CODER_DISABLE_FILE_CHECKPOINTING), fs.list, fs.read, fs.stat, process.run (git, read-only), session.id, session.messages, settings.read, store.get, store.set, telemetry.log, telemetry.mark, ui.close, ui.invalidate, ui.log, ui.open, ui.resolve, ui.status.
$.telemetry is the telemetry plugin's noun; where it is absent the rows are dropped and nothing else changes.
x-coder --plugin-dir /path/to/diff
then /diff inside a git repository with a modified file.
hooks/register.ts 1013 lines1import type {
2 Args,
3 On,
4 PaneOpenArgs,
5 ResultOf,
6 SessionMessage,
7 Timer,
8} from 'x-coder'
9
10import Ask from './ask'
11import Backend from './backend'
12import { COMMAND_SPEC } from './command-spec'
13import { drawnFilesOf } from './drawn-files-of'
14import { entryKindsOf } from './entry-kinds-of'
15import type Git from './git'
16import type { Host } from './host'
17import { isCheckpointing } from './is-checkpointing'
18import { isOnPaneSurface } from './is-on-pane-surface'
19import { isRecord } from './is-record'
20import Limits from './limits'
21import { mapLimited } from './map-limited'
22import { messageOf } from './message-of'
23import { mtimeOf } from './mtime-of'
24import Names from './names'
25import PaneState from './pane-state'
26import PaneToggle from './pane-toggle'
27import Record from './record'
28import Tools from './tools'
29import Turns from './turns'
30import Views from './views'
31
32/**
33 * Registers the diff pane: `/diff` once the built-in stands down, the
34 * pane's drawing and refresh, its opening on X-Coder's first edit, the ask.
35 *
36 * Git runs when the built-in's would: `session.start` binds the host and
37 * registers `/diff`, and off its dispatch reads the transcript, so a resumed
38 * session whose turns already edited opens as its first edit would; `/diff`
39 * or the main loop's first checkpointed edit with room pins the backend,
40 * until `/clear`, which reads afresh under a pane it leaves open; a docked
41 * pane fetches, then opens.
42 *
43 * @param on the engine's registrar
44 */
45export function register(on: On) {
46 let host: Host | null = null
47 let backend: Backend.Backend | null = null
48 let probing: Promise<boolean> | null = null
49 let sessionStartMs = 0
50 let isPaneOpen = false
51 let dialogRows: number | null = null
52 let hasAutoOpened = false
53 let hasRestoredEdits = false
54 let columns: number | null = null
55 let shownSessionId: string | null = null
56 let armed: Ask.ArmedAsk | null = null
57 let carrying: Ask.ArmedAsk | null = null
58 let isRefreshing = false
59 let isRefreshQueued = false
60 let generation = 0
61 let landed = 0
62 let bodyStamp: string | null = null
63 let bodyBase: string | null = null
64
65 const bodyLoads = new Map<string, Promise<Git.FileHunks | null>>()
66
67 const polled = { toplevel: '', headKey: '' }
68 const pin = { cwd: '', isEmpty: false, epoch: 0 }
69
70 let model: PaneState.PaneModel = PaneState.INITIAL_MODEL
71
72 const timers = new Map<'refresh' | 'redraw' | 'poll', Timer>()
73 const loggedBaseKinds = new Set<'ok' | 'sad'>()
74
75 const currentOf = (engine: Host): Host => host ?? engine
76
77 const backendHostOf = (engine: Host): Backend.BackendHost => ({
78 run: (argv, init) =>
79 currentOf(engine).run(
80 argv,
81 pin.cwd === '' ? init : { cwd: pin.cwd, ...init },
82 ),
83 readFile: path => currentOf(engine).readFile(path),
84 mtimeOf: path => mtimeOf(currentOf(engine))(path),
85 entryKindsOf: dir => entryKindsOf(currentOf(engine))(dir),
86 nowMs: () => currentOf(engine).now(),
87 sessionStartMsOf: () => sessionStartMs,
88 onBranchBase: base => {
89 const isError = base.kind === 'error'
90
91 const outcome: Record.MarkOutcome = isError
92 ? { kind: 'sad', reason: base.reason }
93 : {
94 kind: 'ok',
95 props: {
96 outcome: { value: base.kind, of: Record.BASE_OUTCOMES },
97 },
98 }
99
100 if (!loggedBaseKinds.has(outcome.kind)) {
101 loggedBaseKinds.add(outcome.kind)
102
103 Record.recorderOf(currentOf(engine)).mark(
104 Record.FEATURES.baseResolve,
105 outcome,
106 )
107 }
108 },
109 })
110
111 function pinBackend(engine: Host): Promise<boolean> {
112 if (backend || pin.isEmpty) {
113 return Promise.resolve(true)
114 }
115
116 probing ??= probeBackend(engine).finally(() => {
117 probing = null
118 })
119
120 return probing
121 }
122
123 async function probeBackend(engine: Host): Promise<boolean> {
124 const asked = { isAnswered: true }
125 const probeHost = backendHostOf(engine)
126 const { epoch } = pin
127
128 const probed = await Backend.backendOf(
129 {
130 ...probeHost,
131 run: (argv, init) =>
132 probeHost.run(argv, init).catch((error: unknown) => {
133 asked.isAnswered &&=
134 argv[0] !== 'git' || !/\baborted\b/.test(messageOf(error))
135
136 throw error
137 }),
138 },
139 Backend.INSTALLED_BACKEND_PROBES,
140 )
141
142 if (epoch !== pin.epoch) {
143 return false
144 }
145
146 backend ??= probed
147 pin.isEmpty = backend === null && asked.isAnswered
148
149 if (!probed || backend !== probed) {
150 return asked.isAnswered || backend !== null
151 }
152
153 const stored = PaneState.baseModeOf(
154 await engine
155 .storeGet(Names.baseStoreKeyOf(probed.repository.toplevel))
156 .catch(() => undefined),
157 )
158
159 const mode = stored && probed.baseModes.includes(stored) ? stored : null
160
161 model = {
162 ...model,
163 words: probed.words,
164 baseModes: probed.baseModes,
165 ...(mode && { requestedMode: mode }),
166 }
167
168 return true
169 }
170
171 function unpin() {
172 backend = null
173 pin.isEmpty = false
174 pin.epoch += 1
175 polled.toplevel = ''
176 polled.headKey = ''
177 timers.get('poll')?.cancel()
178 timers.delete('poll')
179 }
180
181 function dialogPane(): PaneOpenArgs {
182 return {
183 id: Names.PANE_ID,
184 title: Names.PANE_TITLE,
185 holdToasts: true,
186 closeOnEscape: true,
187 rows: Views.dialogRowsOf(model),
188 }
189 }
190
191 function fitDialog(engine: Host) {
192 const rows = Views.dialogRowsOf(model)
193
194 const isStale =
195 isPaneOpen && model.isFullscreen === false && rows !== dialogRows
196
197 if (isStale) {
198 dialogRows = rows
199 void engine.openPane(dialogPane()).catch(() => undefined)
200 }
201 }
202
203 function redraw(engine: Host) {
204 fitDialog(engine)
205
206 if (timers.has('redraw')) {
207 return
208 }
209
210 timers.set(
211 'redraw',
212 engine.after(Limits.REDRAW_COALESCE_MS, () => {
213 timers.delete('redraw')
214 engine.invalidate()
215 }),
216 )
217 }
218
219 async function loadBodies(engine: Host): Promise<boolean> {
220 const { data } = model
221 const pinned = backend
222
223 if (!data || !pinned) {
224 bodyStamp = null
225 bodyBase = null
226 bodyLoads.clear()
227 model = { ...model, bodies: PaneState.NO_BODIES }
228
229 return false
230 }
231
232 const stamp = bodyStampOf(data)
233 const isNewBase = data.baseRef !== bodyBase
234
235 if (stamp !== bodyStamp) {
236 bodyStamp = stamp
237 bodyLoads.clear()
238 }
239
240 if (isNewBase) {
241 bodyBase = data.baseRef
242 model = { ...model, bodies: PaneState.NO_BODIES }
243 redraw(engine)
244 }
245
246 return fetchBodies(engine, pinned, data)
247 }
248
249 const bodyStampOf = (data: Git.DiffData) => `${generation}|${data.baseRef}`
250
251 async function fetchBodies(
252 engine: Host,
253 pinned: Backend.Backend,
254 data: Git.DiffData,
255 ): Promise<boolean> {
256 const stamp = bodyStampOf(data)
257
258 function loadOf(file: Git.FileStat): Promise<Git.FileHunks | null> {
259 const load = pinned.fetchFileHunks(data, file)
260 bodyLoads.set(file.path, load)
261
262 return load.then(body => {
263 if (bodyStamp === stamp) {
264 model = {
265 ...model,
266 bodies: new Map(model.bodies).set(file.path, body),
267 }
268
269 redraw(engine)
270 }
271
272 return body
273 })
274 }
275
276 return (
277 await mapLimited(
278 drawnFilesOf(model).filter(file => !bodyLoads.has(file.path)),
279 Limits.BODY_FETCH_CONCURRENCY,
280 loadOf,
281 )
282 ).includes(null)
283 }
284
285 function startPoll(engine: Host, pinned: Backend.Backend) {
286 const readHeadKey = () => pinned.headKeyOf().catch(() => '')
287
288 if (polled.toplevel === pinned.repository.toplevel) {
289 return
290 }
291
292 timers.get('poll')?.cancel()
293 polled.toplevel = pinned.repository.toplevel
294 polled.headKey = ''
295
296 timers.set(
297 'poll',
298 engine.every(Limits.HEAD_POLL_MS, () => {
299 if (!isPaneOpen) {
300 return
301 }
302
303 void readHeadKey().then(key => {
304 const hasMoved = polled.headKey !== '' && key !== polled.headKey
305
306 polled.headKey = key
307
308 if (hasMoved) {
309 scheduleRefresh(engine)
310 }
311 })
312 }),
313 )
314 }
315
316 async function refresh(engine: Host): Promise<void> {
317 if (isRefreshing) {
318 isRefreshQueued = true
319
320 return
321 }
322
323 isRefreshing = true
324
325 const record = Record.recorderOf(engine)
326 const pinned = backend
327
328 const fetched = (): Promise<Git.FetchOutcome> =>
329 pinned
330 ? pinned.fetchDiff(model.requestedMode)
331 : Promise.resolve({ kind: 'no-repository' })
332
333 try {
334 model = { ...model, isLoading: model.data === null }
335
336 const [outcome, messages] = await Promise.all([
337 fetched(),
338 engine.messages().catch((): SessionMessage[] => []),
339 ])
340
341 model = PaneState.afterFetch(model, { outcome, messages })
342
343 switch (outcome.kind) {
344 case 'no-repository':
345 break
346 case 'unavailable':
347 record.mark(Record.FEATURES.read, {
348 kind: 'sad',
349 reason: 'git_diff_failed',
350 })
351
352 break
353 case 'data':
354 generation += 1
355
356 if (pinned) {
357 startPoll(engine, pinned)
358 }
359
360 break
361 }
362
363 const hasHunksFailed = await loadBodies(engine)
364
365 if (outcome.kind === 'data') {
366 record.mark(
367 Record.FEATURES.read,
368 hasHunksFailed
369 ? { kind: 'sad', reason: 'git_hunks_failed' }
370 : { kind: 'ok' },
371 )
372 }
373 } catch (error) {
374 record.mark(Record.FEATURES.read, {
375 kind: 'sad',
376 reason: 'git_diff_threw',
377 })
378
379 throw error
380 } finally {
381 isRefreshing = false
382 redraw(engine)
383
384 if (isRefreshQueued) {
385 isRefreshQueued = false
386 scheduleRefresh(engine)
387 }
388 }
389 }
390
391 function scheduleRefresh(engine: Host): void {
392 timers.get('refresh')?.cancel()
393
394 timers.set(
395 'refresh',
396 engine.after(Limits.REFRESH_DEBOUNCE_MS, () => {
397 timers.delete('refresh')
398 void refresh(engine)
399 }),
400 )
401 }
402
403 async function openPane(
404 engine: Host,
405 trigger: (typeof Record.SHOWN_TRIGGERS)[number],
406 ): Promise<boolean> {
407 const isDialog = model.isFullscreen === false
408
409 model = {
410 ...model,
411 selectedPath: null,
412 dialogView: 'list',
413 place: { ...model.place, top: 0, listStart: 0 },
414 }
415
416 dialogRows = isDialog ? Views.dialogRowsOf(model) : null
417
418 const landedBefore = landed
419
420 if (!isDialog) {
421 await refresh(engine).catch(() => undefined)
422 }
423
424 const opened = await engine.openPane(
425 isDialog
426 ? { ...dialogPane(), focus: true }
427 : { id: Names.PANE_ID, title: Names.PANE_TITLE, holdToasts: true },
428 )
429
430 const isWaiting = isRecord(opened) && opened.isPlaced === false
431
432 if (isWaiting) {
433 await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined)
434
435 return false
436 }
437
438 isPaneOpen = true
439
440 const sessionId = await engine.sessionId().catch(() => null)
441
442 if (sessionId !== null && sessionId !== shownSessionId) {
443 shownSessionId = sessionId
444 Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns))
445 }
446
447 const isStale = isDialog || landed !== landedBefore
448
449 if (isStale) {
450 void refresh(engine)
451 }
452
453 return true
454 }
455
456 async function closePane(engine: Host): Promise<void> {
457 await engine.closePane({ id: Names.PANE_ID })
458 isPaneOpen = false
459 }
460
461 function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) {
462 Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, {
463 kind: 'ok',
464 props: { tab: { value: tab, of: Record.TABS } },
465 })
466 }
467
468 async function openOnFirstEdit(engine: Host): Promise<void> {
469 const isTaken = () => isPaneOpen || hasAutoOpened
470
471 if (isTaken()) {
472 return
473 }
474
475 const preference = await engine.storeGet(Names.STORE_OPEN_KEY)
476 const isKeptOpen = preference === true
477
478 const floor = isKeptOpen
479 ? Limits.OPEN_MIN_COLUMNS
480 : Limits.AUTO_OPEN_MIN_COLUMNS
481
482 const hasRoom =
483 preference !== false &&
484 model.isFullscreen === true &&
485 columns !== null &&
486 columns >= floor
487
488 if (!hasRoom || isTaken()) {
489 return
490 }
491
492 const isCheckpointed = await engine.isCheckpointing().catch(() => true)
493
494 if (!isCheckpointed || isTaken()) {
495 return
496 }
497
498 await pinBackend(engine)
499
500 if (!backend || isTaken()) {
501 return
502 }
503
504 hasAutoOpened = true
505 hasAutoOpened = await openPane(engine, 'auto_open')
506 }
507
508 async function openOnRestore(engine: Host): Promise<void> {
509 const messages = await engine.messages().catch((): SessionMessage[] => [])
510
511 hasRestoredEdits = Turns.turnDiffsOf(messages).length > 0
512
513 if (hasRestoredEdits) {
514 await openOnFirstEdit(engine)
515 }
516 }
517
518 function disarm(engine: Host) {
519 armed = null
520 model = { ...model, armedPath: null }
521 engine.status(undefined)
522 }
523
524 const actionsOf = (engine: Host): Views.PaneActions => ({
525 selectFile: path => {
526 const isDocked = model.placement === 'dock'
527
528 model = {
529 ...model,
530 selectedPath: path,
531 dialogView: isDocked ? model.dialogView : 'detail',
532 place: isDocked ? Views.placeAtFile(model, path) : model.place,
533 }
534
535 redraw(engine)
536 },
537 scrollList: delta => {
538 model = { ...model, place: Views.listScrolledBy(model, delta) }
539 redraw(engine)
540 },
541 toggleNoise: () => {
542 model = { ...model, isNoiseShown: !model.isNoiseShown }
543 void loadBodies(engine)
544 redraw(engine)
545 },
546 togglePreSession: () => {
547 model = { ...model, isPreSessionShown: !model.isPreSessionShown }
548 void loadBodies(engine)
549 redraw(engine)
550 },
551 cycleBase: () => {
552 const { baseModes, requestedMode } = model
553
554 const mode =
555 baseModes[(baseModes.indexOf(requestedMode) + 1) % baseModes.length] ??
556 requestedMode
557
558 if (mode === requestedMode) {
559 return
560 }
561
562 model = { ...model, requestedMode: mode }
563
564 Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, {
565 kind: 'ok',
566 props: { mode: { value: mode, of: model.baseModes } },
567 })
568
569 const toplevel = model.data?.repository.toplevel
570
571 if (toplevel !== undefined) {
572 void engine
573 .storeSet(Names.baseStoreKeyOf(toplevel), mode)
574 .catch(() => undefined)
575 }
576
577 void refresh(engine)
578 redraw(engine)
579 },
580 chooseSource: value => {
581 const index = Number(value)
582 const isTurn = value !== 'current' && Number.isInteger(index)
583
584 const source: PaneState.Source = isTurn
585 ? { kind: 'turn', index }
586 : { kind: 'current' }
587
588 model = { ...model, source, selectedPath: null, dialogView: 'list' }
589 redraw(engine)
590 },
591 toggleAsk: path => {
592 if (armed?.path === path) {
593 disarm(engine)
594 redraw(engine)
595
596 return
597 }
598
599 arm(engine, path)
600 },
601 })
602
603 function arm(engine: Host, path: string) {
604 armed = Ask.armedAskOf(
605 path,
606 PaneState.pickedTurnOf(model)?.files.find(file => file.path === path)
607 ?.hunks ??
608 model.bodies.get(path)?.hunks ??
609 [],
610 )
611
612 model = { ...model, armedPath: path }
613
614 engine.status(
615 `${Views.sanitizeName(path)} rides your next prompt (press ` +
616 `asked ✓ to drop it)`,
617 )
618
619 redraw(engine)
620 }
621
622 async function startedAtOf(engine: Host): Promise<number | null> {
623 const startedAt: unknown = await engine.startedAt().catch(() => undefined)
624
625 return typeof startedAt === 'number' ? startedAt : null
626 }
627
628 async function bind(engine: Host, cwd: string): Promise<void> {
629 sessionStartMs = (await startedAtOf(engine)) ?? (await engine.now())
630 pin.cwd = cwd
631
632 try {
633 await engine.registerCommand(COMMAND_SPEC)
634 host = engine
635 } catch (error) {
636 const reason = messageOf(error)
637
638 if (!Names.BUILTIN_HOLDS_PATTERN.test(reason)) {
639 engine.uiLog(Names.registerFailedTextOf(Views.sanitizeName(reason)))
640 }
641 }
642 }
643
644 on('session.start', async ($, e, next) => {
645 await bind(
646 {
647 now: () => $.clock.now(),
648 after: (ms, fn) => $.clock.after(ms, fn),
649 every: (ms, fn) => $.clock.every(ms, fn),
650 run: (argv, init) => $.process.run(argv, init),
651 stat: path => $.fs.stat(path),
652 listDir: path => $.fs.list(path),
653 readFile: path => $.fs.read(path),
654 storeGet: key => $.store.get(key),
655 storeSet: (key, value) => $.store.set(key, value),
656 isCheckpointing: async () =>
657 isCheckpointing(
658 await $.settings.read(),
659 await $.env.get('X_CODER_DISABLE_FILE_CHECKPOINTING'),
660 ),
661 messages: () => $.session.messages(),
662 invalidate: () => $.ui.invalidate('ui.render'),
663 status: text => $.ui.status(text),
664 uiLog: text => $.ui.log(text),
665 openPane: pane => $.ui.open(pane),
666 closePane: pane => $.ui.close(pane),
667 registerCommand: spec => $.command.register(spec),
668 sessionId: () => $.session.id(),
669 startedAt: () =>
670 $.session
671 .usage()
672 .then((usage: unknown) =>
673 isRecord(usage) ? usage.startedAt : undefined,
674 ),
675 mark: entry => $.telemetry.mark(entry),
676 log: entry => $.telemetry.log(entry),
677 },
678 e.cwd,
679 )
680
681 if (host) {
682 void openOnRestore(host).catch(() => undefined)
683 }
684
685 return next(e)
686 })
687
688 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
689 if (isOnPaneSurface(e)) {
690 const viewport: { columns?: number; isFullscreen?: boolean } | undefined =
691 e.viewport
692
693 const isFirstMeasure = columns === null && viewport?.columns !== undefined
694
695 columns = viewport?.columns ?? columns
696
697 model = {
698 ...model,
699 isFullscreen: viewport?.isFullscreen ?? model.isFullscreen,
700 }
701
702 if (isFirstMeasure && hasRestoredEdits && host) {
703 void openOnFirstEdit(host).catch(() => undefined)
704 }
705 }
706
707 return next(e)
708 })
709
710 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
711 if (e.requestId !== Names.PANE_ID || !host || !isOnPaneSurface(e)) {
712 return next(e)
713 }
714
715 const { Box, Text, Button, Select, Code } = await $.ui.resolve(e)
716
717 columns = e.viewport?.columns ?? columns
718
719 model = {
720 ...model,
721 placement: e.props.placement,
722 place: {
723 ...model.place,
724 columns: Math.max(
725 1,
726 e.props.bodyColumns - Limits.PANE_RIGHT_PAD_COLUMNS,
727 ),
728 rows: e.props.scroll.bodyRows,
729 },
730 }
731
732 return Views.paneView(
733 {
734 ui: { Box, Text, Button, Select, Code },
735 actions: actionsOf(host),
736 columns: e.props.bodyColumns,
737 rows: e.props.scroll.bodyRows,
738 },
739 model,
740 { placement: e.props.placement, terminalColumns: columns },
741 )
742 })
743
744 on('command.run', { command: 'diff' }, async ($, e, next) => {
745 if (!host) {
746 return next(e)
747 }
748
749 const isAnswered = (await pinBackend(host)) || (await pinBackend(host))
750
751 if (!backend) {
752 return {
753 text: isAnswered
754 ? Names.NOT_IN_REPOSITORY_TEXT
755 : Names.GIT_UNANSWERED_TEXT,
756 }
757 }
758
759 const { isFullscreen } = e.presentation
760
761 columns = e.presentation.columns
762 model = { ...model, isFullscreen }
763
764 const toggle = PaneToggle.paneToggleOf({
765 isOpen: isPaneOpen,
766 columns: isFullscreen ? columns : null,
767 })
768
769 if (toggle === 'too-narrow') {
770 return { text: Names.RESIZE_TERMINAL_TEXT }
771 }
772
773 const isOpening = toggle === 'open'
774
775 const isDone = isOpening
776 ? await openPane(host, 'manual')
777 : await closePane(host).then(() => true)
778
779 if (!isDone) {
780 return { text: Names.RESIZE_TERMINAL_TEXT }
781 }
782
783 if (!isFullscreen) {
784 return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT }
785 }
786
787 markTabSwitch(host, isOpening ? 'diff' : 'convo')
788 await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined)
789
790 return {
791 text: isOpening ? Names.PANEL_SHOWN_TEXT : Names.PANEL_HIDDEN_TEXT,
792 }
793 })
794
795 on('ui.close', { id: Names.PANE_ID }, async ($, e, next) => {
796 const isBack =
797 e.origin.kind === 'person' &&
798 model.placement === 'inline' &&
799 model.dialogView === 'detail'
800
801 if (isBack && host) {
802 model = { ...model, dialogView: 'list' }
803 redraw(host)
804
805 dialogRows = Views.dialogRowsOf(model)
806
807 void host
808 .openPane({ ...dialogPane(), focus: true })
809 .catch(() => undefined)
810
811 return { deny: 'back to the file list' }
812 }
813
814 const result = await next(e)
815 const isClosed = result.deny === undefined
816 const isPersons = isClosed && e.origin.kind === 'person'
817
818 if (isClosed) {
819 isPaneOpen = false
820 }
821
822 const isDialog = model.isFullscreen === false
823
824 if (isPersons && host && isDialog) {
825 host.uiLog(Names.DIALOG_DISMISSED_TEXT)
826 }
827
828 if (isPersons && host && !isDialog) {
829 markTabSwitch(host, 'convo')
830 await host.storeSet(Names.STORE_OPEN_KEY, false).catch(() => undefined)
831 }
832
833 return result
834 })
835
836 on('ui.focus', { plugin: Names.PLUGIN_NAME }, ($, e, next) => {
837 const isListed =
838 model.placement === 'inline' && model.dialogView === 'list' && host
839
840 const focus = isListed ? Views.dialogFocusOf(model, e.element) : null
841
842 if (focus === 'stay') {
843 return {}
844 }
845
846 if (!focus || !host) {
847 return next(e)
848 }
849
850 model = { ...model, selectedPath: focus.selectedPath }
851 fitDialog(host)
852 host.invalidate()
853
854 return next({ ...e, element: focus.landing })
855 })
856
857 on('ui.scroll', { requestId: Names.PANE_ID }, ($, e, next) => {
858 const isOwnBody = e.origin.kind === 'person' && model.placement === 'dock'
859
860 if (!isOwnBody || !host) {
861 return next(e)
862 }
863
864 const isOverList = Views.isWheelOverList(model, e)
865
866 model = {
867 ...model,
868 place: isOverList
869 ? Views.listScrolledBy(model, e.by)
870 : Views.bodyScrolledBy(model, e),
871 }
872
873 host.invalidate()
874
875 return {}
876 })
877
878 on('command.run', { command: ['clear', 'resume'] }, async ($, e, next) => {
879 const result = await next(e)
880
881 if (!host) {
882 return result
883 }
884
885 const isResume = e.command === 'resume'
886 const isKeptOpen = isPaneOpen && !isResume
887
888 if (isPaneOpen && isResume) {
889 await closePane(host).catch(() => undefined)
890 }
891
892 unpin()
893 hasAutoOpened = false
894 hasRestoredEdits = false
895 bodyStamp = null
896 bodyBase = null
897 bodyLoads.clear()
898 disarm(host)
899 model = PaneState.afterNewSession(model)
900
901 sessionStartMs =
902 (await startedAtOf(host)) ??
903 (isResume ? sessionStartMs : await host.now())
904
905 if (isKeptOpen) {
906 await pinBackend(host)
907 void refresh(host)
908 }
909
910 if (isResume) {
911 void openOnRestore(host).catch(() => undefined)
912 }
913
914 return result
915 })
916
917 function afterTool(
918 engine: Host,
919 e: Args<'tool.call'>,
920 result: ResultOf['tool.call'] | undefined,
921 ) {
922 const isEdit = Tools.EDITING_TOOLS.some(name => name === e.tool)
923
924 const hasEdited =
925 isEdit &&
926 result !== undefined &&
927 result.deny === undefined &&
928 result.isError !== true
929
930 const hasLanded = isEdit ? hasEdited : Tools.mayHaveWritten(result)
931
932 if (hasLanded) {
933 landed += 1
934 }
935
936 if (hasLanded && isPaneOpen) {
937 scheduleRefresh(engine)
938 }
939
940 const isMainLoopEdit = hasEdited && e.agentId === undefined
941
942 if (isMainLoopEdit) {
943 void openOnFirstEdit(engine).catch(() => undefined)
944 }
945 }
946
947 on(
948 'tool.call',
949 { tool: [...Tools.EDITING_TOOLS, ...Tools.SHELL_TOOLS] },
950 async ($, e, next) => {
951 let result: ResultOf['tool.call'] | undefined
952
953 try {
954 result = await next(e)
955
956 return result
957 } finally {
958 if (host) {
959 afterTool(host, e, result)
960 }
961 }
962 },
963 )
964
965 on('prompt.submit', async ($, e, next) => {
966 const asked = armed
967
968 if (!host || !asked || carrying === asked) {
969 return next(e)
970 }
971
972 const context = e.context ?? []
973
974 const text = Ask.fittedAskTextOf(
975 asked.text,
976 Limits.PROMPT_CONTEXT_MAX_CHARS -
977 context.reduce((sum, entry) => sum + entry.length, 0),
978 )
979
980 if (text === undefined) {
981 disarm(host)
982
983 host.status(
984 `${Views.sanitizeName(asked.path)}'s diff did not fit in the prompt ` +
985 `and was dropped`,
986 )
987
988 redraw(host)
989
990 return next(e)
991 }
992
993 carrying = asked
994
995 try {
996 const result = await next({ ...e, context: [...context, text] })
997
998 if (result.drop === undefined) {
999 Record.recorderOf(host).asked()
1000
1001 if (armed === asked) {
1002 disarm(host)
1003 redraw(host)
1004 }
1005 }
1006
1007 return result
1008 } finally {
1009 carrying = null
1010 }
1011 })
1012}
1013hooks/ask/index.ts 7 lines1export * from './armed-ask'
2export * from './armed-ask-of.js'
3export * from './ask-cut-note'
4export * from './fitted-ask-text-of.js'
5
6export * as default from '.'
7hooks/backend/index.ts 6 lines1export * from './backend-of.js'
2export * from './installed-backend-probes.js'
3export * from './types'
4
5export * as default from '.'
6hooks/command-spec/index.ts 4 lines1export * from './command-spec.js'
2
3export * as default from '.'
4hooks/drawn-files-of/index.ts 4 lines1export * from './drawn-files-of.js'
2
3export * as default from '.'
4hooks/entry-kinds-of/index.ts 4 lines1export * from './entry-kinds-of.js'
2
3export * as default from '.'
4hooks/git/index.ts 21 lines1export * from './argv'
2export * from './cut-hunks'
3export * from './display-path-of'
4export * from './empty-file-hunks'
5export * from './failed-run'
6export * from './fetch-diff'
7export * from './fetch-file-hunks'
8export * from './git-backend-of.js'
9export * from './git-base-modes'
10export * from './git-words'
11export * from './hunk-after.js'
12export * from './hunk-of'
13export * from './large-file-hunks'
14export * from './parse'
15export * from './probes'
16export * from './rename-display-of'
17export * from './tiers'
18export * from './types'
19
20export * as default from '.'
21hooks/host/index.ts 4 lines1export type * from './host.js'
2
3export * as default from '.'
4hooks/is-checkpointing/index.ts 4 lines1export * from './is-checkpointing.js'
2
3export * as default from '.'
4hooks/is-on-pane-surface/index.ts 4 lines1export * from './is-on-pane-surface.js'
2
3export * as default from '.'
4hooks/is-record/index.ts 4 lines1export * from './is-record.js'
2
3export * as default from '.'
4hooks/limits/index.ts 9 lines1export * from './columns'
2export * from './dialog-path-floor.js'
3export * from './sizes'
4export * from './timing'
5export * from './todo-bar-cells.js'
6export * from './todo-bar-reserve.js'
7
8export * as default from '.'
9