spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / mods / diff / tests / register.test.ts
1,255 lines · 35.5 KBRaw
1import type { Args, FsEntry, On, ResultOf, SessionMessage } from 'claude-code'
2import { describe, expect, mock, test, tier } from 'claude-code/testing'
3import type { Engine } from 'claude-code/testing'
4
5import Limits from '../hooks/limits'
6import Names from '../hooks/names'
7import Record from '../hooks/record'
8import Fixtures from './fixtures'
9
10tier('builtin')
11
12describe('register', () => {
13 async function drawnRoundMerge($: Engine, on: On, finishedMs: number) {
14 const script = { ...Fixtures.REPOSITORY, 'rev-parse --verify': 'merging' }
15
16 let listing = Fixtures.MERGING.map(([name, kind]): FsEntry => ({
17 name,
18 kind,
19 size: 0,
20 isLink: false,
21 }))
22
23 on('fs.list', (_engine, e) => {
24 const isGitDir = e.path === '/work/.git'
25
26 return { value: isGitDir ? listing : [] }
27 })
28
29 const world = Fixtures.inRepository(on, script)
30
31 const drawn = async () => Fixtures.textOf(await $.ui.render(Fixtures.PANE))
32
33 await $.session.start(Fixtures.SESSION)
34 await $.command.run(Fixtures.DIFF)
35 await world.clock.advance(finishedMs)
36
37 const before = await drawn()
38
39 listing = []
40 script['rev-parse --verify'] = 'merged'
41
42 await world.clock.advance(Limits.HEAD_POLL_MS + Fixtures.SETTLE_MS)
43
44 return { before, after: await drawn() }
45 }
46
47 const NOTICED = {
48 before: expect.stringContaining('Diff unavailable'),
49 after: expect.stringContaining('1 file changed'),
50 }
51
52 test('the start asks nothing of git and registers /diff', async ($, on) => {
53 const world = Fixtures.inRepository(on)
54
55 await $.session.start(Fixtures.SESSION)
56
57 expect(world.runs, "no git, as the built-in's start runs none").toEqual([])
58
59 expect(await $.command.run(Fixtures.DIFF), '/diff was registered').toEqual({
60 text: 'Diff panel shown',
61 })
62 })
63
64 test("git runs at the built-in's moments and no others", async ($, on) => {
65 const world = Fixtures.inRepository(on)
66
67 let read = 0
68
69 function spawnedSince(): string[] {
70 const words = world.runs
71 .slice(read)
72 .map(run => Fixtures.gitWordOf(run.argv))
73 .filter(word => word !== Fixtures.POLL_WORD)
74
75 read = world.runs.length
76
77 return words
78 }
79
80 const edit = () =>
81 $.tool.call({
82 tool: 'Edit',
83 file_path: '/work/app.ts',
84 old_string: '1',
85 new_string: '2',
86 })
87
88 on('tool.call', () => ({ result: 'done' }))
89 on('turn.complete', ($, e) => ({ text: e.answer }))
90 on('command.run', { command: 'clear' }, () => ({}))
91
92 await $.session.start(Fixtures.SESSION)
93 await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
94 await world.clock.advance(Fixtures.SETTLE_MS)
95
96 expect(spawnedSince(), 'the start and the footer: nothing').toEqual([])
97
98 await edit()
99 await world.clock.advance(Fixtures.SETTLE_MS)
100
101 expect(
102 spawnedSince(),
103 'an edit with no room for a pane: nothing, the width is read first',
104 ).toEqual([])
105
106 await $.command.run(Fixtures.DIFF)
107 await world.clock.advance(Fixtures.SETTLE_MS)
108
109 expect(spawnedSince(), '/diff: found, then one fetch for the pane').toEqual(
110 [
111 'rev-parse --show-toplevel',
112 'status',
113 'diff --shortstat',
114 'diff --numstat',
115 'ls-files',
116 'diff -- app.ts',
117 ],
118 )
119
120 await edit()
121 await world.clock.advance(Fixtures.SETTLE_MS)
122
123 expect(spawnedSince(), 'an edit, the pane open: one fetch').toEqual([
124 'diff --shortstat',
125 'diff --numstat',
126 'ls-files',
127 'diff -- app.ts',
128 ])
129
130 await $.tool.call({ tool: 'Bash', command: 'make' })
131 await world.clock.advance(Fixtures.SETTLE_MS)
132
133 expect(spawnedSince(), 'a shell command, the pane open: one fetch').toEqual(
134 ['diff --shortstat', 'diff --numstat', 'ls-files', 'diff -- app.ts'],
135 )
136
137 await $.turn.complete({
138 answer: 'done',
139 durationMs: 1,
140 isAborted: false,
141 turnId: 't1',
142 reason: 'answer',
143 })
144
145 await world.clock.advance(Fixtures.SETTLE_MS)
146
147 expect(spawnedSince(), "a turn's end: nothing").toEqual([])
148
149 await $.command.run(Fixtures.DIFF)
150 await edit()
151 await $.tool.call({ tool: 'Bash', command: 'make' })
152 await world.clock.advance(Fixtures.SETTLE_MS)
153
154 expect(spawnedSince(), 'the pane closed by hand: nothing').toEqual([])
155
156 await $.command.run(Fixtures.CLEAR)
157 await $.command.run(Fixtures.DIFF)
158 await world.clock.advance(Fixtures.SETTLE_MS)
159
160 expect(spawnedSince()[0], '/clear forgot the repository').toBe(
161 'rev-parse --show-toplevel',
162 )
163 })
164
165 test('a shell command held read-only fetches nothing', async ($, on) => {
166 const world = Fixtures.inRepository(on)
167
168 const fetchesSince = (read: number) =>
169 world.runs.slice(read).filter(run => run.argv.includes('--numstat'))
170 .length
171
172 on('tool.call', ($, e) => {
173 const isListing = e.tool === 'Bash' && e.command === 'ls'
174
175 return isListing ? Fixtures.READ_ONLY_ANSWER : { result: 'done' }
176 })
177
178 await $.session.start(Fixtures.SESSION)
179 await $.command.run(Fixtures.DIFF)
180 await world.clock.advance(Fixtures.SETTLE_MS)
181
182 const opened = world.runs.length
183
184 await $.tool.call({ tool: 'Bash', command: 'ls' })
185 await world.clock.advance(Fixtures.SETTLE_MS)
186
187 expect(
188 fetchesSince(opened),
189 "read-only, the pane open: nothing, as the built-in's touch is gated",
190 ).toBe(0)
191
192 const listed = world.runs.length
193
194 await $.tool.call({ tool: 'Bash', command: 'make' })
195 await world.clock.advance(Fixtures.SETTLE_MS)
196
197 expect(fetchesSince(listed), 'a command that may write: one fetch').toBe(1)
198 })
199
200 test('/diff whose probe never answers probes once more', async ($, on) => {
201 const probes: (readonly string[])[] = []
202 const clock = Fixtures.startsSession(on)
203
204 on('process.run', async ($, e) => {
205 probes.push(e.argv)
206
207 if (probes.length === 1) {
208 await clock.sleep(Limits.GIT_TIMEOUT_MS)
209
210 return { deny: Fixtures.GIT_HUNG }
211 }
212
213 return { value: Fixtures.NOT_A_REPOSITORY }
214 })
215
216 await $.session.start(Fixtures.SESSION)
217
218 const ran = $.command.run(Fixtures.DIFF)
219
220 await clock.settle()
221
222 expect(probes, "this /diff's probe, unanswered yet").toHaveLength(1)
223
224 await clock.advance(Limits.GIT_TIMEOUT_MS)
225
226 expect(await ran).toEqual({
227 text: expect.stringContaining("isn't in a git repository"),
228 })
229
230 expect(probes, 'then one more, which answered').toHaveLength(2)
231 })
232
233 test('two /diff typed together probe the repository once', async ($, on) => {
234 const world = Fixtures.inRepository(on)
235
236 await $.session.start(Fixtures.SESSION)
237
238 const [first, second] = await Promise.all([
239 $.command.run(Fixtures.DIFF),
240 $.command.run(Fixtures.DIFF),
241 ])
242
243 const probes = world.runs.filter(run =>
244 run.argv.includes('--show-toplevel'),
245 )
246
247 expect(first.text).toMatch(/^Diff panel (shown|hidden)$/)
248 expect(second.text).toMatch(/^Diff panel (shown|hidden)$/)
249 expect(probes, 'the second joined the probe in flight').toHaveLength(1)
250 })
251
252 test('outside a repository /diff says so, opens nothing', async ($, on) => {
253 const opened: string[] = []
254
255 Fixtures.startsSession(on)
256 on('process.run', () => ({ value: Fixtures.NOT_A_REPOSITORY }))
257
258 on('ui.open', ($, e, next) => {
259 opened.push(e.id)
260
261 return next(e)
262 })
263
264 await $.session.start(Fixtures.SESSION)
265
266 const { text } = await $.command.run(Fixtures.DIFF)
267
268 expect(text).toContain("isn't in a git repository")
269 expect(opened).toEqual([])
270 })
271
272 test('a git that never answers is not "no repository"', async ($, on) => {
273 Fixtures.startsSession(on)
274 on('process.run', () => ({ deny: Fixtures.GIT_HUNG }))
275
276 await $.session.start(Fixtures.SESSION)
277
278 const { text } = await $.command.run(Fixtures.DIFF)
279
280 expect(text).toContain("git didn't answer")
281 })
282
283 test('when the built-in holds /diff, the mod stands down', async ($, on) => {
284 const logged: string[] = []
285 const runs: Args<'process.run'>[] = []
286
287 mock.clock(on)
288 on('session.start', ($, e) => ({ cwd: e.cwd }))
289 on('command.register', () => ({ deny: Fixtures.BUILTIN_HOLDS }))
290 on('command.run', () => ({ text: 'the built-in /diff ran' }))
291 on('tool.call', () => ({ result: 'edited' }))
292
293 on('process.run', ($, e) => {
294 runs.push(e)
295
296 return { value: Fixtures.gitIn(e.argv) }
297 })
298
299 on('ui.log', ($, e) => {
300 logged.push(e.text)
301
302 return { value: undefined }
303 })
304
305 await $.session.start(Fixtures.SESSION)
306
307 expect(await $.command.run(Fixtures.DIFF)).toEqual({
308 text: 'the built-in /diff ran',
309 })
310
311 await $.tool.call({
312 tool: 'Edit',
313 file_path: '/work/app.ts',
314 old_string: '1',
315 new_string: '2',
316 })
317
318 expect(logged).toEqual([])
319 expect(runs, 'idle: no git for /diff or the edit').toEqual([])
320 })
321
322 test('a refusal the built-in did not cause is said aloud', async ($, on) => {
323 const logged: string[] = []
324
325 mock.clock(on)
326 on('session.start', ($, e) => ({ cwd: e.cwd }))
327
328 on('command.register', () => ({
329 deny: '32 commands are registered already',
330 }))
331
332 on('ui.log', ($, e) => {
333 logged.push(e.text)
334
335 return { value: undefined }
336 })
337
338 await $.session.start(Fixtures.SESSION)
339
340 expect(logged).toEqual([
341 'could not register /diff: diff: $.command.register: 32 commands are ' +
342 'registered already; the diff panel is unavailable this session',
343 ])
344 })
345
346 test('/diff opens the pane, unfocused, and says so', async ($, on) => {
347 const world = Fixtures.inRepository(on)
348
349 await $.session.start(Fixtures.SESSION)
350
351 expect(world.runs, 'the start ran nothing').toEqual([])
352
353 expect(await $.command.run(Fixtures.DIFF)).toEqual({
354 text: 'Diff panel shown',
355 })
356
357 expect(world.runs[0]?.argv, '/diff found the repository first').toContain(
358 '--show-toplevel',
359 )
360
361 expect(world.opened).toEqual([{ id: 'diff', title: 'Diff' }])
362
363 await world.clock.advance(Fixtures.SETTLE_MS)
364
365 const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
366
367 expect(drawn).toContain('1 file changed')
368 expect(drawn).toContain('app.ts')
369 })
370
371 test('off fullscreen, /diff opens the dialog, focused', async ($, on) => {
372 const world = Fixtures.inRepository(on)
373
374 await $.session.start(Fixtures.SESSION)
375
376 expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({})
377
378 expect(world.opened[0]).toEqual({
379 id: 'diff',
380 title: 'Diff',
381 closeOnEscape: true,
382 rows: expect.any(Number),
383 focus: true,
384 })
385
386 expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({
387 text: 'Diff dialog dismissed',
388 })
389 })
390
391 test('/diff again closes the pane and says so', async ($, on) => {
392 const world = Fixtures.inRepository(on)
393
394 await $.session.start(Fixtures.SESSION)
395 await $.command.run(Fixtures.DIFF)
396
397 expect(await $.command.run(Fixtures.DIFF)).toEqual({
398 text: 'Diff panel hidden',
399 })
400
401 expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
402 })
403
404 test('a wide terminal opens the pane at the first edit', async ($, on) => {
405 const world = Fixtures.inRepository(on)
406
407 on('tool.call', () => ({ result: 'edited' }))
408
409 await $.session.start(Fixtures.SESSION)
410 await $.ui.render(Fixtures.HINT)
411
412 await $.tool.call({
413 tool: 'Edit',
414 file_path: '/work/app.ts',
415 old_string: '1',
416 new_string: '2',
417 })
418
419 await world.clock.advance(Fixtures.SETTLE_MS)
420
421 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
422 })
423
424 test('on the main screen only /diff opens, as a dialog', async ($, on) => {
425 const world = Fixtures.inRepository(on)
426
427 on('tool.call', () => ({ result: 'edited' }))
428
429 await $.session.start(Fixtures.SESSION)
430 await $.ui.render(Fixtures.MAIN_SCREEN_HINT)
431
432 await $.tool.call({
433 tool: 'Edit',
434 file_path: '/work/app.ts',
435 old_string: '1',
436 new_string: '2',
437 })
438
439 await world.clock.advance(Fixtures.SETTLE_MS)
440
441 expect(world.opened, 'a pane there would be an unasked dialog').toEqual([])
442 expect(world.runs, 'so nothing was asked of the repository').toEqual([])
443
444 expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({})
445
446 expect(
447 world.opened[0],
448 "the person's /diff opens the dialog, not dismisses one",
449 ).toMatchObject({ id: 'diff', focus: true })
450 })
451
452 test('a surface silent on docking opens nothing by itself', async ($, on) => {
453 const world = Fixtures.inRepository(on)
454
455 on('tool.call', () => ({ result: 'edited' }))
456
457 await $.session.start(Fixtures.SESSION)
458 await $.ui.render(Fixtures.UNSAID_HINT)
459
460 await $.tool.call({
461 tool: 'Edit',
462 file_path: '/work/app.ts',
463 old_string: '1',
464 new_string: '2',
465 })
466
467 await world.clock.advance(Fixtures.SETTLE_MS)
468
469 expect(world.opened, 'unknown is not a dock').toEqual([])
470
471 await $.ui.render(Fixtures.HINT)
472
473 await $.tool.call({
474 tool: 'Edit',
475 file_path: '/work/app.ts',
476 old_string: '2',
477 new_string: '3',
478 })
479
480 await world.clock.advance(Fixtures.SETTLE_MS)
481
482 expect(
483 world.opened.map(pane => pane.id),
484 'once a drawing says the layout docks, the next edit opens it',
485 ).toEqual(['diff'])
486 })
487
488 test('an edit that failed or was refused opens nothing', async ($, on) => {
489 const world = Fixtures.inRepository(on)
490
491 const answers: ResultOf['tool.call'][] = [
492 { isError: true, result: 'no such text' },
493 { deny: 'not allowed' },
494 ]
495
496 const edit = () =>
497 $.tool.call({
498 tool: 'Edit',
499 file_path: '/work/app.ts',
500 old_string: '1',
501 new_string: '2',
502 })
503
504 on('tool.call', () => answers.shift() ?? { result: 'edited' })
505
506 await $.session.start(Fixtures.SESSION)
507 await $.ui.render(Fixtures.HINT)
508 await edit()
509 await edit()
510 await world.clock.advance(Fixtures.SETTLE_MS)
511
512 expect(world.opened, 'neither edit landed').toEqual([])
513 expect(world.runs, 'so nothing asked after the repository').toEqual([])
514
515 await edit()
516 await world.clock.advance(Fixtures.SETTLE_MS)
517
518 expect(
519 world.opened.map(pane => pane.id),
520 'the third did',
521 ).toEqual(['diff'])
522 })
523
524 test("only the main loop's edit opens the pane", async ($, on) => {
525 const world = Fixtures.inRepository(on)
526
527 const edit = (agentId?: string) =>
528 $.tool.call({
529 tool: 'Write',
530 file_path: '/work/app.ts',
531 content: '2',
532 ...(agentId !== undefined && { agentId }),
533 })
534
535 on('tool.call', () => ({ result: 'written' }))
536
537 await $.session.start(Fixtures.SESSION)
538 await $.ui.render(Fixtures.HINT)
539 await edit('a-explorer')
540 await world.clock.advance(Fixtures.SETTLE_MS)
541
542 expect(world.opened, 'the built-in checkpoints no subagent edit').toEqual(
543 [],
544 )
545
546 expect(world.runs, 'so nothing was asked of the repository').toEqual([])
547
548 await edit()
549 await world.clock.advance(Fixtures.SETTLE_MS)
550
551 expect(
552 world.opened.map(pane => pane.id),
553 "the main loop's",
554 ).toEqual(['diff'])
555 })
556
557 test('checkpointing off in settings: edits open nothing', async ($, on) => {
558 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
559 settings: { fileCheckpointingEnabled: false },
560 })
561
562 on('tool.call', () => ({ result: 'edited' }))
563
564 await $.session.start(Fixtures.SESSION)
565 await $.ui.render(Fixtures.HINT)
566
567 await $.tool.call({
568 tool: 'Edit',
569 file_path: '/work/app.ts',
570 old_string: '1',
571 new_string: '2',
572 })
573
574 await world.clock.advance(Fixtures.SETTLE_MS)
575
576 expect(world.opened, 'the built-in opens on a checkpoint alone').toEqual([])
577
578 expect(world.runs, 'so nothing was asked of the repository').toEqual([])
579
580 expect(await $.command.run(Fixtures.DIFF)).toEqual({
581 text: 'Diff panel shown',
582 })
583 })
584
585 test('checkpointing off by variable: edits open nothing', async ($, on) => {
586 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
587 env: { CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING: 'true' },
588 })
589
590 on('tool.call', () => ({ result: 'edited' }))
591
592 await $.session.start(Fixtures.SESSION)
593 await $.ui.render(Fixtures.HINT)
594
595 await $.tool.call({
596 tool: 'Edit',
597 file_path: '/work/app.ts',
598 old_string: '1',
599 new_string: '2',
600 })
601
602 await world.clock.advance(Fixtures.SETTLE_MS)
603
604 expect(world.opened).toEqual([])
605 })
606
607 test('an open left waiting is withdrawn; /diff opens it', async ($, on) => {
608 let isNarrow = true
609
610 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
611 stored: { [Names.STORE_OPEN_KEY]: true },
612 isLeftWaiting: () => isNarrow,
613 })
614
615 const edit = () =>
616 $.tool.call({
617 tool: 'Edit',
618 file_path: '/work/app.ts',
619 old_string: '1',
620 new_string: '2',
621 })
622
623 on('tool.call', () => ({ result: 'edited' }))
624
625 await $.session.start(Fixtures.SESSION)
626 await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
627 await edit()
628 await world.clock.advance(Fixtures.SETTLE_MS)
629
630 expect(
631 world.waiting.map(pane => pane.id),
632 'the edit opened',
633 ).toEqual(['diff'])
634
635 expect(
636 world.closed.map(pane => pane.id),
637 'and withdrew the pane the engine left waiting, so no resize seats it',
638 ).toEqual(['diff'])
639
640 expect(
641 world.runs.map(run => Fixtures.gitWordOf(run.argv)).slice(0, 2),
642 'the repository was found and read once before the open, as the ' +
643 'built-in primes; nothing polls for a pane no one sees',
644 ).toEqual(['rev-parse --show-toplevel', 'status'])
645
646 expect(
647 world.runs.map(run => Fixtures.gitWordOf(run.argv)),
648 'no HEAD poll for a withdrawn pane',
649 ).not.toContain(Fixtures.POLL_WORD)
650
651 isNarrow = false
652
653 expect(
654 await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)),
655 '/diff opens it, where a pane believed open would have been hidden',
656 ).toEqual({ text: 'Diff panel shown' })
657
658 await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
659 await edit()
660 await world.clock.advance(Fixtures.SETTLE_MS)
661
662 expect(
663 world.opened.map(pane => pane.id),
664 'closed by hand, no edit opens it again',
665 ).toEqual(['diff'])
666 })
667
668 test('an unplaced open is tried again at the next edit', async ($, on) => {
669 let isNarrow = true
670
671 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
672 stored: { [Names.STORE_OPEN_KEY]: true },
673 isLeftWaiting: () => isNarrow,
674 })
675
676 const edit = () =>
677 $.tool.call({
678 tool: 'Edit',
679 file_path: '/work/app.ts',
680 old_string: '1',
681 new_string: '2',
682 })
683
684 on('tool.call', () => ({ result: 'edited' }))
685
686 await $.session.start(Fixtures.SESSION)
687 await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
688 await edit()
689 await world.clock.advance(Fixtures.SETTLE_MS)
690
691 expect(world.opened, 'the first try waited and was withdrawn').toEqual([])
692
693 isNarrow = false
694
695 await edit()
696 await world.clock.advance(Fixtures.SETTLE_MS)
697
698 expect(
699 world.opened.map(pane => pane.id),
700 'the next edit opened it, as the built-in reads the width again then',
701 ).toEqual(['diff'])
702 })
703
704 test('an open nothing draws yet is kept until drawn', async ($, on) => {
705 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
706 isLeftUnseen: () => true,
707 })
708
709 const logged = Fixtures.keeping<Args<'telemetry.log'>>()
710
711 on('telemetry.log', logged.hook)
712 on('session.id', () => ({ value: 'first' }))
713
714 await $.session.start(Fixtures.SESSION)
715
716 expect(
717 await $.command.run(Fixtures.DIALOG_DIFF),
718 'said as it is, with no word of a terminal to resize',
719 ).toEqual({ text: Names.AWAITS_SURFACE_TEXT })
720
721 await world.clock.advance(Limits.HEAD_POLL_MS + Fixtures.SETTLE_MS)
722
723 expect(
724 world.unseen.map(pane => pane.id),
725 '/diff opened, then fitted the rows to what it read',
726 ).toEqual(['diff', 'diff'])
727
728 expect(world.closed, 'and kept what it asked for').toEqual([])
729
730 expect(
731 world.runs
732 .map(run => Fixtures.gitWordOf(run.argv))
733 .filter(word => word === Fixtures.POLL_WORD),
734 'HEAD was read once at the open, as ever; no poll for a pane no one sees',
735 ).toEqual([Fixtures.POLL_WORD])
736
737 expect(logged.kept, 'nor is it counted shown').toEqual([])
738
739 const read = world.runs.length
740
741 expect(
742 Fixtures.textOf(await $.ui.render(Fixtures.INLINE_PANE)),
743 'the first drawing has what the open read',
744 ).toContain('app.ts')
745
746 await world.clock.advance(Fixtures.SETTLE_MS)
747
748 expect(
749 world.runs.length,
750 'and reads again, for what changed while no one saw',
751 ).toBeGreaterThan(read)
752
753 expect(
754 logged.kept.map(shown => shown.props?.trigger),
755 'and counted it, as the person asked for it',
756 ).toEqual([{ value: 'manual', of: [...Record.SHOWN_TRIGGERS] }])
757
758 expect(
759 await $.command.run(Fixtures.DIALOG_DIFF),
760 'believed open all along, so /diff closes it',
761 ).toEqual({ text: 'Diff dialog dismissed' })
762 })
763
764 test('while it waits unseen an edit reads nothing', async ($, on) => {
765 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
766 stored: { [Names.STORE_OPEN_KEY]: true },
767 isLeftUnseen: () => true,
768 })
769
770 const edit = () =>
771 $.tool.call({
772 tool: 'Edit',
773 file_path: '/work/app.ts',
774 old_string: '1',
775 new_string: '2',
776 })
777
778 on('tool.call', () => ({ result: 'edited' }))
779
780 await $.session.start(Fixtures.SESSION)
781 await $.ui.render(Fixtures.HINT)
782 await edit()
783 await world.clock.advance(Fixtures.SETTLE_MS)
784
785 const read = world.runs.length
786
787 await edit()
788 await world.clock.advance(Limits.HEAD_POLL_MS + Fixtures.SETTLE_MS)
789
790 expect(
791 world.unseen.map(pane => pane.id),
792 'the first edit opened, and the second found it open',
793 ).toEqual(['diff'])
794
795 expect(world.closed, 'kept for whatever attaches').toEqual([])
796 expect(world.runs.length, 'git is left alone meanwhile').toBe(read)
797 })
798
799 test('a docked pane opens once its first fetch settled', async ($, on) => {
800 const opened: string[] = []
801 const clock = Fixtures.startsSession(on)
802
803 on('process.run', async ($, e) => {
804 if (e.argv.includes('--shortstat')) {
805 await clock.sleep(Fixtures.SLOW_DIFF_MS)
806 }
807
808 return { value: Fixtures.gitIn(e.argv) }
809 })
810
811 on('ui.open', ($, e) => {
812 opened.push(e.id)
813
814 return { value: undefined }
815 })
816
817 on('ui.close', () => ({ value: undefined }))
818 on('ui.invalidate', () => ({ value: undefined }))
819 on('ui.render', { component: 'PromptHint' }, () => Fixtures.HINT_DRAWN)
820 on('session.messages', () => ({ value: [] }))
821 on('settings.read', () => ({ value: {} }))
822 on('tool.call', () => ({ result: 'edited' }))
823 mock.store(on)
824 mock.env(on, {})
825
826 await $.session.start(Fixtures.SESSION)
827 await $.ui.render(Fixtures.HINT)
828
829 await $.tool.call({
830 tool: 'Edit',
831 file_path: '/work/app.ts',
832 old_string: '1',
833 new_string: '2',
834 })
835
836 await clock.advance(Fixtures.SLOW_DIFF_MS - 1)
837
838 expect(opened, 'git has not answered: no pane, no Loading frame').toEqual(
839 [],
840 )
841
842 await clock.advance(Fixtures.SETTLE_MS)
843
844 expect(opened, 'the fetch settled: the pane opens filled').toEqual(['diff'])
845
846 const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
847
848 expect(drawn).toContain('1 file changed')
849 expect(drawn).not.toContain('Loading diff')
850 })
851
852 test('/clear leaves an open pane up and reads afresh', async ($, on) => {
853 const world = Fixtures.inRepository(on)
854
855 on('command.run', { command: 'clear' }, () => ({}))
856
857 await $.session.start(Fixtures.SESSION)
858 await $.command.run(Fixtures.DIFF)
859 await world.clock.advance(Fixtures.SETTLE_MS)
860
861 const read = world.runs.length
862
863 await $.command.run(Fixtures.CLEAR)
864 await world.clock.advance(Fixtures.SETTLE_MS)
865
866 expect(world.closed, 'as the built-in panel stays across /clear').toEqual(
867 [],
868 )
869
870 expect(
871 world.runs.slice(read).map(run => Fixtures.gitWordOf(run.argv))[0],
872 'the repository found again for the conversation that starts over',
873 ).toBe('rev-parse --show-toplevel')
874
875 expect(
876 Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
877 'drawn from the fresh read',
878 ).toContain('1 file changed')
879 })
880
881 test('/clear under an open pane counts it shown again', async ($, on) => {
882 const world = Fixtures.inRepository(on)
883 const logged = Fixtures.keeping<Args<'telemetry.log'>>()
884
885 let sessionId = 'first'
886
887 on('telemetry.log', logged.hook)
888 on('session.id', () => ({ value: sessionId }))
889 on('tool.call', () => ({ result: 'edited' }))
890
891 on('command.run', { command: 'clear' }, () => {
892 sessionId = 'second'
893
894 return {}
895 })
896
897 await $.session.start(Fixtures.SESSION)
898 await $.ui.render(Fixtures.HINT)
899
900 await $.tool.call({
901 tool: 'Edit',
902 file_path: '/work/app.ts',
903 old_string: '1',
904 new_string: '2',
905 })
906
907 await world.clock.advance(Fixtures.SETTLE_MS)
908 await $.command.run(Fixtures.CLEAR)
909 await world.clock.advance(Fixtures.SETTLE_MS)
910
911 expect(world.closed, 'the pane stayed up across /clear').toEqual([])
912
913 expect(
914 logged.kept.map(shown => shown.props?.trigger),
915 "once a conversation: Claude's edit opened the first, and the second, " +
916 "which nobody opened, reads as the person's, as the built-in's does",
917 ).toEqual([
918 { value: 'auto_open', of: [...Record.SHOWN_TRIGGERS] },
919 { value: 'manual', of: [...Record.SHOWN_TRIGGERS] },
920 ])
921 })
922
923 test('a merge finished 3 s after the open is noticed', async ($, on) => {
924 expect(
925 await drawnRoundMerge($, on, 3000),
926 'finished in another terminal: read with no edit or command',
927 ).toEqual(NOTICED)
928 })
929
930 test('a merge finished 1.2 s after the open is noticed', async ($, on) => {
931 expect(
932 await drawnRoundMerge($, on, 1200),
933 'before the first look at HEAD on the clock: the open read it',
934 ).toEqual(NOTICED)
935 })
936
937 test('a merge finished 0.3 s after the open is noticed', async ($, on) => {
938 expect(
939 await drawnRoundMerge($, on, 300),
940 'before the first look at HEAD on the clock: the open read it',
941 ).toEqual(NOTICED)
942 })
943
944 test('a resumed session that edited opens before an edit', async ($, on) => {
945 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
946 messages: () => Fixtures.EDITED_TRANSCRIPT,
947 })
948
949 await $.session.start(Fixtures.SESSION)
950
951 expect(world.runs, 'the start itself still runs no git').toEqual([])
952
953 await $.ui.render(Fixtures.HINT)
954 await world.clock.advance(Fixtures.SETTLE_MS)
955
956 expect(
957 world.opened.map(pane => pane.id),
958 'open once the width is known, as the built-in opens on the restore',
959 ).toEqual(['diff'])
960 })
961
962 test('a resumed session opens only where an edit would', async ($, on) => {
963 const narrow = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
964 messages: () => Fixtures.EDITED_TRANSCRIPT,
965 })
966
967 await $.session.start(Fixtures.SESSION)
968 await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
969 await narrow.clock.advance(Fixtures.SETTLE_MS)
970
971 expect(narrow.opened, 'under the unasked floor, never kept open').toEqual(
972 [],
973 )
974
975 expect(narrow.runs, 'and no git for it').toEqual([])
976 })
977
978 test('a resumed session kept open opens at the low floor', async ($, on) => {
979 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
980 messages: () => Fixtures.EDITED_TRANSCRIPT,
981 stored: { [Names.STORE_OPEN_KEY]: true },
982 })
983
984 await $.session.start(Fixtures.SESSION)
985 await $.ui.render(Fixtures.hintAt(Limits.OPEN_MIN_COLUMNS))
986 await world.clock.advance(Fixtures.SETTLE_MS)
987
988 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
989 })
990
991 test('a resumed session with no edits opens nothing', async ($, on) => {
992 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
993 messages: () => [Fixtures.promptOf('just talk')],
994 })
995
996 await $.session.start(Fixtures.SESSION)
997 await $.ui.render(Fixtures.HINT)
998 await world.clock.advance(Fixtures.SETTLE_MS)
999
1000 expect(world.opened).toEqual([])
1001 expect(world.runs).toEqual([])
1002 })
1003
1004 test('a resumed session the person closed opens nothing', async ($, on) => {
1005 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
1006 messages: () => Fixtures.EDITED_TRANSCRIPT,
1007 stored: { [Names.STORE_OPEN_KEY]: false },
1008 })
1009
1010 await $.session.start(Fixtures.SESSION)
1011 await $.ui.render(Fixtures.HINT)
1012 await world.clock.advance(Fixtures.SETTLE_MS)
1013
1014 expect(world.opened).toEqual([])
1015 expect(world.runs).toEqual([])
1016 })
1017
1018 test('/resume closes the pane, reopens it for its turns', async ($, on) => {
1019 let transcript: readonly SessionMessage[] = []
1020
1021 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
1022 messages: () => transcript,
1023 })
1024
1025 on('command.run', { command: 'resume' }, () => {
1026 transcript = Fixtures.EDITED_TRANSCRIPT
1027
1028 return {}
1029 })
1030
1031 await $.session.start(Fixtures.SESSION)
1032 await $.ui.render(Fixtures.HINT)
1033 await $.command.run(Fixtures.DIFF)
1034 await world.clock.advance(Fixtures.SETTLE_MS)
1035 await $.command.run(Fixtures.RESUME)
1036 await world.clock.advance(Fixtures.SETTLE_MS)
1037
1038 expect(
1039 world.closed.map(pane => pane.id),
1040 'closed at /resume',
1041 ).toEqual(['diff'])
1042
1043 expect(
1044 world.opened.map(pane => pane.id),
1045 '/diff opened it, /resume opened it again for the restored turns',
1046 ).toEqual(['diff', 'diff'])
1047 })
1048
1049 test('ask attaches the hunks on screen and calls no tool', async ($, on) => {
1050 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
1051 const called: Args<'tool.call'>[] = []
1052
1053 on('tool.call', ($, e) => {
1054 called.push(e)
1055
1056 return { result: 'called' }
1057 })
1058
1059 on('prompt.submit', ($, e) => ({ text: e.text, context: e.context }))
1060
1061 await $.session.start(Fixtures.WORKTREE_SESSION)
1062 await $.command.run(Fixtures.DIFF)
1063 await world.clock.advance(Fixtures.SETTLE_MS)
1064 await $.ui.render(Fixtures.PANE)
1065 await $.ui.press({ plugin: 'diff', key: 'ask:.env' })
1066 await world.clock.settle()
1067
1068 const armed = Fixtures.jsonOf(await $.ui.render(Fixtures.PANE))
1069
1070 const submitted = await $.prompt.submit(Fixtures.typedPromptOf('why?'))
1071
1072 expect(armed).toContain('asked ✓')
1073 expect(called).toEqual([])
1074
1075 expect(world.statuses.at(-2)).toBe(
1076 '.env rides your next prompt (press asked ✓ to drop it)',
1077 )
1078
1079 expect(submitted).toMatchObject({
1080 context: [
1081 'The user attached the diff of .env from the diff pane to this ' +
1082 'prompt:\n@@ -1 +1 @@\n-KEY=old\n+KEY=new',
1083 ],
1084 })
1085 })
1086
1087 test('an ask with no room in the context is dropped', async ($, on) => {
1088 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
1089 const full = 'x'.repeat(Limits.PROMPT_CONTEXT_MAX_CHARS)
1090 const reached: (readonly string[] | undefined)[] = []
1091
1092 on('prompt.submit', ($, e) => {
1093 reached.push(e.context)
1094
1095 return { text: e.text }
1096 })
1097
1098 await $.session.start(Fixtures.WORKTREE_SESSION)
1099 await $.command.run(Fixtures.DIFF)
1100 await world.clock.advance(Fixtures.SETTLE_MS)
1101 await $.ui.render(Fixtures.PANE)
1102 await $.ui.press({ plugin: 'diff', key: 'ask:.env' })
1103 await world.clock.settle()
1104
1105 await $.prompt.submit(Fixtures.typedPromptOf('why?', [full]))
1106 await $.prompt.submit(Fixtures.typedPromptOf('and now?'))
1107
1108 expect(reached).toEqual([[full], undefined])
1109
1110 expect(world.statuses.at(-1)).toBe(
1111 ".env's diff did not fit in the prompt and was dropped",
1112 )
1113 })
1114
1115 test('a worktree opens on the base it kept', async ($, on) => {
1116 const world = Fixtures.inRepository(on, Fixtures.oneSecret(), {
1117 stored: { 'base:/main/wt': 'uncommitted' },
1118 })
1119
1120 await $.session.start(Fixtures.WORKTREE_SESSION)
1121 await $.command.run(Fixtures.DIFF)
1122 await world.clock.advance(Fixtures.SETTLE_MS)
1123
1124 expect(
1125 Fixtures.stringsOf(await $.ui.render(Fixtures.PANE)),
1126 "the built-in's base line under the header",
1127 ).toContain('uncommitted (vs HEAD)')
1128 })
1129
1130 test('no repository is an answer kept until /clear', async ($, on) => {
1131 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
1132 Fixtures.oneSecret()
1133
1134 const script = { ...notYet }
1135 const world = Fixtures.inRepository(on, script)
1136
1137 const probesOf = () =>
1138 world.runs.filter(run => run.argv.includes('--show-toplevel')).length
1139
1140 on('command.run', { command: 'clear' }, () => ({}))
1141
1142 await $.session.start(Fixtures.WORKTREE_SESSION)
1143
1144 const before = await $.command.run(Fixtures.DIFF)
1145
1146 script['rev-parse --path-format=absolute'] = worktree
1147
1148 const again = await $.command.run(Fixtures.DIFF)
1149 const probesKept = probesOf()
1150
1151 await $.command.run(Fixtures.CLEAR)
1152
1153 const after = await $.command.run(Fixtures.DIFF)
1154
1155 await world.clock.advance(Fixtures.SETTLE_MS)
1156 await $.ui.render(Fixtures.PANE)
1157
1158 script['rev-parse --path-format=absolute'] = Fixtures.ELSEWHERE_LINES
1159
1160 const third = $.command.run(Fixtures.DIFF)
1161
1162 await world.clock.advance(Fixtures.SETTLE_MS)
1163 await third
1164
1165 expect(before.text).toContain("isn't in a git repository")
1166
1167 expect(again.text, "kept, as the built-in's answer is").toContain(
1168 "isn't in a git repository",
1169 )
1170
1171 expect(probesKept, 'one probe answered both').toBe(1)
1172 expect(after.text, '/clear forgot it').toBe('Diff panel shown')
1173 expect(world.opened[0]?.id).toBe('diff')
1174 expect(probesOf(), 'then one more, pinned from there on').toBe(2)
1175
1176 expect(
1177 world.runs.some(run => run.argv.includes('--git-dir=/else/.git')),
1178 ).toBe(false)
1179
1180 expect(
1181 world.runs
1182 .filter(run => run.argv.includes('--numstat'))
1183 .every(run => run.argv[1] === Fixtures.PINNED_LEAD[1]),
1184 ).toBe(true)
1185 })
1186
1187 test('a repository that appears later opens on first edit', async ($, on) => {
1188 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
1189 Fixtures.oneSecret()
1190
1191 const script = { ...notYet }
1192 const world = Fixtures.inRepository(on, script)
1193
1194 on('tool.call', () => ({ result: 'edited' }))
1195
1196 await $.session.start(Fixtures.WORKTREE_SESSION)
1197 await $.ui.render(Fixtures.HINT)
1198
1199 script['rev-parse --path-format=absolute'] = worktree
1200
1201 await $.tool.call({
1202 tool: 'Edit',
1203 file_path: '/main/wt/.env',
1204 old_string: 'old',
1205 new_string: 'new',
1206 })
1207
1208 await world.clock.advance(Fixtures.SETTLE_MS)
1209
1210 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
1211 })
1212
1213 test('a git that cannot start is an answer, not a timeout', async ($, on) => {
1214 const runs: Args<'process.run'>[] = []
1215
1216 Fixtures.startsSession(on)
1217
1218 on('process.run', ($, e) => {
1219 runs.push(e)
1220
1221 return e.argv.includes('--show-toplevel')
1222 ? { deny: Fixtures.FAILED_START }
1223 : { value: Fixtures.gitIn(e.argv, Fixtures.oneSecret()) }
1224 })
1225
1226 await $.session.start(Fixtures.WORKTREE_SESSION)
1227
1228 const { text } = await $.command.run(Fixtures.DIFF)
1229 const probes = runs.filter(run => run.argv.includes('--show-toplevel'))
1230
1231 expect(text).toContain("isn't in a git repository")
1232 expect(probes, "this /diff's alone; no retry").toHaveLength(1)
1233 })
1234
1235 test('/diff on a narrow terminal: the resize line', async ($, on) => {
1236 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
1237
1238 await $.session.start(Fixtures.WORKTREE_SESSION)
1239
1240 const narrow = await $.command.run(
1241 Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS - 1),
1242 )
1243
1244 const openedNarrow = [...world.opened]
1245
1246 await $.command.run(Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS))
1247
1248 expect(narrow.text).toContain(
1249 'Resize your terminal to at least 110 columns to show the diff panel',
1250 )
1251
1252 expect(openedNarrow).toEqual([])
1253 expect(world.opened.map(pane => pane.id)).toEqual([Names.PANE_ID])
1254 })
1255})