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,163 lines · 32.7 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([
362 { id: 'diff', title: 'Diff', holdToasts: true },
363 ])
364
365 await world.clock.advance(Fixtures.SETTLE_MS)
366
367 const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
368
369 expect(drawn).toContain('1 file changed')
370 expect(drawn).toContain('app.ts')
371 })
372
373 test('off fullscreen, /diff opens the dialog, focused', async ($, on) => {
374 const world = Fixtures.inRepository(on)
375
376 await $.session.start(Fixtures.SESSION)
377
378 expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({})
379
380 expect(world.opened[0]).toEqual({
381 id: 'diff',
382 title: 'Diff',
383 holdToasts: true,
384 closeOnEscape: true,
385 rows: expect.any(Number),
386 focus: true,
387 })
388
389 expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({
390 text: 'Diff dialog dismissed',
391 })
392 })
393
394 test('/diff again closes the pane and says so', async ($, on) => {
395 const world = Fixtures.inRepository(on)
396
397 await $.session.start(Fixtures.SESSION)
398 await $.command.run(Fixtures.DIFF)
399
400 expect(await $.command.run(Fixtures.DIFF)).toEqual({
401 text: 'Diff panel hidden',
402 })
403
404 expect(world.closed.map(pane => pane.id)).toEqual(['diff'])
405 })
406
407 test('a wide terminal opens the pane at the first edit', async ($, on) => {
408 const world = Fixtures.inRepository(on)
409
410 on('tool.call', () => ({ result: 'edited' }))
411
412 await $.session.start(Fixtures.SESSION)
413 await $.ui.render(Fixtures.HINT)
414
415 await $.tool.call({
416 tool: 'Edit',
417 file_path: '/work/app.ts',
418 old_string: '1',
419 new_string: '2',
420 })
421
422 await world.clock.advance(Fixtures.SETTLE_MS)
423
424 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
425 })
426
427 test('on the main screen only /diff opens, as a dialog', async ($, on) => {
428 const world = Fixtures.inRepository(on)
429
430 on('tool.call', () => ({ result: 'edited' }))
431
432 await $.session.start(Fixtures.SESSION)
433 await $.ui.render(Fixtures.MAIN_SCREEN_HINT)
434
435 await $.tool.call({
436 tool: 'Edit',
437 file_path: '/work/app.ts',
438 old_string: '1',
439 new_string: '2',
440 })
441
442 await world.clock.advance(Fixtures.SETTLE_MS)
443
444 expect(world.opened, 'a pane there would be an unasked dialog').toEqual([])
445 expect(world.runs, 'so nothing was asked of the repository').toEqual([])
446
447 expect(await $.command.run(Fixtures.DIALOG_DIFF)).toEqual({})
448
449 expect(
450 world.opened[0],
451 "the person's /diff opens the dialog, not dismisses one",
452 ).toMatchObject({ id: 'diff', focus: true })
453 })
454
455 test('a surface silent on docking opens nothing by itself', async ($, on) => {
456 const world = Fixtures.inRepository(on)
457
458 on('tool.call', () => ({ result: 'edited' }))
459
460 await $.session.start(Fixtures.SESSION)
461 await $.ui.render(Fixtures.UNSAID_HINT)
462
463 await $.tool.call({
464 tool: 'Edit',
465 file_path: '/work/app.ts',
466 old_string: '1',
467 new_string: '2',
468 })
469
470 await world.clock.advance(Fixtures.SETTLE_MS)
471
472 expect(world.opened, 'unknown is not a dock').toEqual([])
473
474 await $.ui.render(Fixtures.HINT)
475
476 await $.tool.call({
477 tool: 'Edit',
478 file_path: '/work/app.ts',
479 old_string: '2',
480 new_string: '3',
481 })
482
483 await world.clock.advance(Fixtures.SETTLE_MS)
484
485 expect(
486 world.opened.map(pane => pane.id),
487 'once a drawing says the layout docks, the next edit opens it',
488 ).toEqual(['diff'])
489 })
490
491 test('an edit that failed or was refused opens nothing', async ($, on) => {
492 const world = Fixtures.inRepository(on)
493
494 const answers: ResultOf['tool.call'][] = [
495 { isError: true, result: 'no such text' },
496 { deny: 'not allowed' },
497 ]
498
499 const edit = () =>
500 $.tool.call({
501 tool: 'Edit',
502 file_path: '/work/app.ts',
503 old_string: '1',
504 new_string: '2',
505 })
506
507 on('tool.call', () => answers.shift() ?? { result: 'edited' })
508
509 await $.session.start(Fixtures.SESSION)
510 await $.ui.render(Fixtures.HINT)
511 await edit()
512 await edit()
513 await world.clock.advance(Fixtures.SETTLE_MS)
514
515 expect(world.opened, 'neither edit landed').toEqual([])
516 expect(world.runs, 'so nothing asked after the repository').toEqual([])
517
518 await edit()
519 await world.clock.advance(Fixtures.SETTLE_MS)
520
521 expect(
522 world.opened.map(pane => pane.id),
523 'the third did',
524 ).toEqual(['diff'])
525 })
526
527 test("only the main loop's edit opens the pane", async ($, on) => {
528 const world = Fixtures.inRepository(on)
529
530 const edit = (agentId?: string) =>
531 $.tool.call({
532 tool: 'Write',
533 file_path: '/work/app.ts',
534 content: '2',
535 ...(agentId !== undefined && { agentId }),
536 })
537
538 on('tool.call', () => ({ result: 'written' }))
539
540 await $.session.start(Fixtures.SESSION)
541 await $.ui.render(Fixtures.HINT)
542 await edit('a-explorer')
543 await world.clock.advance(Fixtures.SETTLE_MS)
544
545 expect(world.opened, 'the built-in checkpoints no subagent edit').toEqual(
546 [],
547 )
548
549 expect(world.runs, 'so nothing was asked of the repository').toEqual([])
550
551 await edit()
552 await world.clock.advance(Fixtures.SETTLE_MS)
553
554 expect(
555 world.opened.map(pane => pane.id),
556 "the main loop's",
557 ).toEqual(['diff'])
558 })
559
560 test('checkpointing off in settings: edits open nothing', async ($, on) => {
561 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
562 settings: { fileCheckpointingEnabled: false },
563 })
564
565 on('tool.call', () => ({ result: 'edited' }))
566
567 await $.session.start(Fixtures.SESSION)
568 await $.ui.render(Fixtures.HINT)
569
570 await $.tool.call({
571 tool: 'Edit',
572 file_path: '/work/app.ts',
573 old_string: '1',
574 new_string: '2',
575 })
576
577 await world.clock.advance(Fixtures.SETTLE_MS)
578
579 expect(world.opened, 'the built-in opens on a checkpoint alone').toEqual([])
580
581 expect(world.runs, 'so nothing was asked of the repository').toEqual([])
582
583 expect(await $.command.run(Fixtures.DIFF)).toEqual({
584 text: 'Diff panel shown',
585 })
586 })
587
588 test('checkpointing off by variable: edits open nothing', async ($, on) => {
589 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
590 env: { CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING: 'true' },
591 })
592
593 on('tool.call', () => ({ result: 'edited' }))
594
595 await $.session.start(Fixtures.SESSION)
596 await $.ui.render(Fixtures.HINT)
597
598 await $.tool.call({
599 tool: 'Edit',
600 file_path: '/work/app.ts',
601 old_string: '1',
602 new_string: '2',
603 })
604
605 await world.clock.advance(Fixtures.SETTLE_MS)
606
607 expect(world.opened).toEqual([])
608 })
609
610 test('an open left waiting is withdrawn; /diff opens it', async ($, on) => {
611 let isNarrow = true
612
613 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
614 stored: { [Names.STORE_OPEN_KEY]: true },
615 isLeftWaiting: () => isNarrow,
616 })
617
618 const edit = () =>
619 $.tool.call({
620 tool: 'Edit',
621 file_path: '/work/app.ts',
622 old_string: '1',
623 new_string: '2',
624 })
625
626 on('tool.call', () => ({ result: 'edited' }))
627
628 await $.session.start(Fixtures.SESSION)
629 await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
630 await edit()
631 await world.clock.advance(Fixtures.SETTLE_MS)
632
633 expect(
634 world.waiting.map(pane => pane.id),
635 'the edit opened',
636 ).toEqual(['diff'])
637
638 expect(
639 world.closed.map(pane => pane.id),
640 'and withdrew the pane the engine left waiting, so no resize seats it',
641 ).toEqual(['diff'])
642
643 expect(
644 world.runs.map(run => Fixtures.gitWordOf(run.argv)).slice(0, 2),
645 'the repository was found and read once before the open, as the ' +
646 'built-in primes; nothing polls for a pane no one sees',
647 ).toEqual(['rev-parse --show-toplevel', 'status'])
648
649 expect(
650 world.runs.map(run => Fixtures.gitWordOf(run.argv)),
651 'no HEAD poll for a withdrawn pane',
652 ).not.toContain(Fixtures.POLL_WORD)
653
654 isNarrow = false
655
656 expect(
657 await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1)),
658 '/diff opens it, where a pane believed open would have been hidden',
659 ).toEqual({ text: 'Diff panel shown' })
660
661 await $.command.run(Fixtures.diffAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
662 await edit()
663 await world.clock.advance(Fixtures.SETTLE_MS)
664
665 expect(
666 world.opened.map(pane => pane.id),
667 'closed by hand, no edit opens it again',
668 ).toEqual(['diff'])
669 })
670
671 test('an unplaced open is tried again at the next edit', async ($, on) => {
672 let isNarrow = true
673
674 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
675 stored: { [Names.STORE_OPEN_KEY]: true },
676 isLeftWaiting: () => isNarrow,
677 })
678
679 const edit = () =>
680 $.tool.call({
681 tool: 'Edit',
682 file_path: '/work/app.ts',
683 old_string: '1',
684 new_string: '2',
685 })
686
687 on('tool.call', () => ({ result: 'edited' }))
688
689 await $.session.start(Fixtures.SESSION)
690 await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
691 await edit()
692 await world.clock.advance(Fixtures.SETTLE_MS)
693
694 expect(world.opened, 'the first try waited and was withdrawn').toEqual([])
695
696 isNarrow = false
697
698 await edit()
699 await world.clock.advance(Fixtures.SETTLE_MS)
700
701 expect(
702 world.opened.map(pane => pane.id),
703 'the next edit opened it, as the built-in reads the width again then',
704 ).toEqual(['diff'])
705 })
706
707 test('a docked pane opens once its first fetch settled', async ($, on) => {
708 const opened: string[] = []
709 const clock = Fixtures.startsSession(on)
710
711 on('process.run', async ($, e) => {
712 if (e.argv.includes('--shortstat')) {
713 await clock.sleep(Fixtures.SLOW_DIFF_MS)
714 }
715
716 return { value: Fixtures.gitIn(e.argv) }
717 })
718
719 on('ui.open', ($, e) => {
720 opened.push(e.id)
721
722 return { value: undefined }
723 })
724
725 on('ui.close', () => ({ value: undefined }))
726 on('ui.invalidate', () => ({ value: undefined }))
727 on('ui.render', { component: 'PromptHint' }, () => Fixtures.HINT_DRAWN)
728 on('session.messages', () => ({ value: [] }))
729 on('settings.read', () => ({ value: {} }))
730 on('tool.call', () => ({ result: 'edited' }))
731 mock.store(on)
732 mock.env(on, {})
733
734 await $.session.start(Fixtures.SESSION)
735 await $.ui.render(Fixtures.HINT)
736
737 await $.tool.call({
738 tool: 'Edit',
739 file_path: '/work/app.ts',
740 old_string: '1',
741 new_string: '2',
742 })
743
744 await clock.advance(Fixtures.SLOW_DIFF_MS - 1)
745
746 expect(opened, 'git has not answered: no pane, no Loading frame').toEqual(
747 [],
748 )
749
750 await clock.advance(Fixtures.SETTLE_MS)
751
752 expect(opened, 'the fetch settled: the pane opens filled').toEqual(['diff'])
753
754 const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
755
756 expect(drawn).toContain('1 file changed')
757 expect(drawn).not.toContain('Loading diff')
758 })
759
760 test('/clear leaves an open pane up and reads afresh', async ($, on) => {
761 const world = Fixtures.inRepository(on)
762
763 on('command.run', { command: 'clear' }, () => ({}))
764
765 await $.session.start(Fixtures.SESSION)
766 await $.command.run(Fixtures.DIFF)
767 await world.clock.advance(Fixtures.SETTLE_MS)
768
769 const read = world.runs.length
770
771 await $.command.run(Fixtures.CLEAR)
772 await world.clock.advance(Fixtures.SETTLE_MS)
773
774 expect(world.closed, 'as the built-in panel stays across /clear').toEqual(
775 [],
776 )
777
778 expect(
779 world.runs.slice(read).map(run => Fixtures.gitWordOf(run.argv))[0],
780 'the repository found again for the conversation that starts over',
781 ).toBe('rev-parse --show-toplevel')
782
783 expect(
784 Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
785 'drawn from the fresh read',
786 ).toContain('1 file changed')
787 })
788
789 test('/clear under an open pane counts it shown again', async ($, on) => {
790 const world = Fixtures.inRepository(on)
791 const logged = Fixtures.keeping<Args<'telemetry.log'>>()
792
793 let sessionId = 'first'
794
795 on('telemetry.log', logged.hook)
796 on('session.id', () => ({ value: sessionId }))
797 on('tool.call', () => ({ result: 'edited' }))
798
799 on('command.run', { command: 'clear' }, () => {
800 sessionId = 'second'
801
802 return {}
803 })
804
805 await $.session.start(Fixtures.SESSION)
806 await $.ui.render(Fixtures.HINT)
807
808 await $.tool.call({
809 tool: 'Edit',
810 file_path: '/work/app.ts',
811 old_string: '1',
812 new_string: '2',
813 })
814
815 await world.clock.advance(Fixtures.SETTLE_MS)
816 await $.command.run(Fixtures.CLEAR)
817 await world.clock.advance(Fixtures.SETTLE_MS)
818
819 expect(world.closed, 'the pane stayed up across /clear').toEqual([])
820
821 expect(
822 logged.kept.map(shown => shown.props?.trigger),
823 "once a conversation: Claude's edit opened the first, and the second, " +
824 "which nobody opened, reads as the person's, as the built-in's does",
825 ).toEqual([
826 { value: 'auto_open', of: [...Record.SHOWN_TRIGGERS] },
827 { value: 'manual', of: [...Record.SHOWN_TRIGGERS] },
828 ])
829 })
830
831 test('a merge finished 3 s after the open is noticed', async ($, on) => {
832 expect(
833 await drawnRoundMerge($, on, 3000),
834 'finished in another terminal: read with no edit or command',
835 ).toEqual(NOTICED)
836 })
837
838 test('a merge finished 1.2 s after the open is noticed', async ($, on) => {
839 expect(
840 await drawnRoundMerge($, on, 1200),
841 'before the first look at HEAD on the clock: the open read it',
842 ).toEqual(NOTICED)
843 })
844
845 test('a merge finished 0.3 s after the open is noticed', async ($, on) => {
846 expect(
847 await drawnRoundMerge($, on, 300),
848 'before the first look at HEAD on the clock: the open read it',
849 ).toEqual(NOTICED)
850 })
851
852 test('a resumed session that edited opens before an edit', async ($, on) => {
853 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
854 messages: () => Fixtures.EDITED_TRANSCRIPT,
855 })
856
857 await $.session.start(Fixtures.SESSION)
858
859 expect(world.runs, 'the start itself still runs no git').toEqual([])
860
861 await $.ui.render(Fixtures.HINT)
862 await world.clock.advance(Fixtures.SETTLE_MS)
863
864 expect(
865 world.opened.map(pane => pane.id),
866 'open once the width is known, as the built-in opens on the restore',
867 ).toEqual(['diff'])
868 })
869
870 test('a resumed session opens only where an edit would', async ($, on) => {
871 const narrow = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
872 messages: () => Fixtures.EDITED_TRANSCRIPT,
873 })
874
875 await $.session.start(Fixtures.SESSION)
876 await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
877 await narrow.clock.advance(Fixtures.SETTLE_MS)
878
879 expect(narrow.opened, 'under the unasked floor, never kept open').toEqual(
880 [],
881 )
882
883 expect(narrow.runs, 'and no git for it').toEqual([])
884 })
885
886 test('a resumed session kept open opens at the low floor', async ($, on) => {
887 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
888 messages: () => Fixtures.EDITED_TRANSCRIPT,
889 stored: { [Names.STORE_OPEN_KEY]: true },
890 })
891
892 await $.session.start(Fixtures.SESSION)
893 await $.ui.render(Fixtures.hintAt(Limits.OPEN_MIN_COLUMNS))
894 await world.clock.advance(Fixtures.SETTLE_MS)
895
896 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
897 })
898
899 test('a resumed session with no edits opens nothing', async ($, on) => {
900 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
901 messages: () => [Fixtures.promptOf('just talk')],
902 })
903
904 await $.session.start(Fixtures.SESSION)
905 await $.ui.render(Fixtures.HINT)
906 await world.clock.advance(Fixtures.SETTLE_MS)
907
908 expect(world.opened).toEqual([])
909 expect(world.runs).toEqual([])
910 })
911
912 test('a resumed session the person closed opens nothing', async ($, on) => {
913 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
914 messages: () => Fixtures.EDITED_TRANSCRIPT,
915 stored: { [Names.STORE_OPEN_KEY]: false },
916 })
917
918 await $.session.start(Fixtures.SESSION)
919 await $.ui.render(Fixtures.HINT)
920 await world.clock.advance(Fixtures.SETTLE_MS)
921
922 expect(world.opened).toEqual([])
923 expect(world.runs).toEqual([])
924 })
925
926 test('/resume closes the pane, reopens it for its turns', async ($, on) => {
927 let transcript: readonly SessionMessage[] = []
928
929 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
930 messages: () => transcript,
931 })
932
933 on('command.run', { command: 'resume' }, () => {
934 transcript = Fixtures.EDITED_TRANSCRIPT
935
936 return {}
937 })
938
939 await $.session.start(Fixtures.SESSION)
940 await $.ui.render(Fixtures.HINT)
941 await $.command.run(Fixtures.DIFF)
942 await world.clock.advance(Fixtures.SETTLE_MS)
943 await $.command.run(Fixtures.RESUME)
944 await world.clock.advance(Fixtures.SETTLE_MS)
945
946 expect(
947 world.closed.map(pane => pane.id),
948 'closed at /resume',
949 ).toEqual(['diff'])
950
951 expect(
952 world.opened.map(pane => pane.id),
953 '/diff opened it, /resume opened it again for the restored turns',
954 ).toEqual(['diff', 'diff'])
955 })
956
957 test('ask attaches the hunks on screen and calls no tool', async ($, on) => {
958 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
959 const called: Args<'tool.call'>[] = []
960
961 on('tool.call', ($, e) => {
962 called.push(e)
963
964 return { result: 'called' }
965 })
966
967 on('prompt.submit', ($, e) => ({ text: e.text, context: e.context }))
968
969 await $.session.start(Fixtures.WORKTREE_SESSION)
970 await $.command.run(Fixtures.DIFF)
971 await world.clock.advance(Fixtures.SETTLE_MS)
972 await $.ui.render(Fixtures.PANE)
973 await $.ui.press({ plugin: 'diff', key: 'ask:.env' })
974 await world.clock.settle()
975
976 const armed = Fixtures.jsonOf(await $.ui.render(Fixtures.PANE))
977
978 const submitted = await $.prompt.submit(Fixtures.typedPromptOf('why?'))
979
980 expect(armed).toContain('asked ✓')
981 expect(called).toEqual([])
982
983 expect(world.statuses.at(-2)).toBe(
984 '.env rides your next prompt (press asked ✓ to drop it)',
985 )
986
987 expect(submitted).toMatchObject({
988 context: [
989 'The user attached the diff of .env from the diff pane to this ' +
990 'prompt:\n@@ -1 +1 @@\n-KEY=old\n+KEY=new',
991 ],
992 })
993 })
994
995 test('an ask with no room in the context is dropped', async ($, on) => {
996 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
997 const full = 'x'.repeat(Limits.PROMPT_CONTEXT_MAX_CHARS)
998 const reached: (readonly string[] | undefined)[] = []
999
1000 on('prompt.submit', ($, e) => {
1001 reached.push(e.context)
1002
1003 return { text: e.text }
1004 })
1005
1006 await $.session.start(Fixtures.WORKTREE_SESSION)
1007 await $.command.run(Fixtures.DIFF)
1008 await world.clock.advance(Fixtures.SETTLE_MS)
1009 await $.ui.render(Fixtures.PANE)
1010 await $.ui.press({ plugin: 'diff', key: 'ask:.env' })
1011 await world.clock.settle()
1012
1013 await $.prompt.submit(Fixtures.typedPromptOf('why?', [full]))
1014 await $.prompt.submit(Fixtures.typedPromptOf('and now?'))
1015
1016 expect(reached).toEqual([[full], undefined])
1017
1018 expect(world.statuses.at(-1)).toBe(
1019 ".env's diff did not fit in the prompt and was dropped",
1020 )
1021 })
1022
1023 test('a worktree opens on the base it kept', async ($, on) => {
1024 const world = Fixtures.inRepository(on, Fixtures.oneSecret(), {
1025 stored: { 'base:/main/wt': 'uncommitted' },
1026 })
1027
1028 await $.session.start(Fixtures.WORKTREE_SESSION)
1029 await $.command.run(Fixtures.DIFF)
1030 await world.clock.advance(Fixtures.SETTLE_MS)
1031
1032 expect(
1033 Fixtures.stringsOf(await $.ui.render(Fixtures.PANE)),
1034 "the built-in's base line under the header",
1035 ).toContain('uncommitted (vs HEAD)')
1036 })
1037
1038 test('no repository is an answer kept until /clear', async ($, on) => {
1039 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
1040 Fixtures.oneSecret()
1041
1042 const script = { ...notYet }
1043 const world = Fixtures.inRepository(on, script)
1044
1045 const probesOf = () =>
1046 world.runs.filter(run => run.argv.includes('--show-toplevel')).length
1047
1048 on('command.run', { command: 'clear' }, () => ({}))
1049
1050 await $.session.start(Fixtures.WORKTREE_SESSION)
1051
1052 const before = await $.command.run(Fixtures.DIFF)
1053
1054 script['rev-parse --path-format=absolute'] = worktree
1055
1056 const again = await $.command.run(Fixtures.DIFF)
1057 const probesKept = probesOf()
1058
1059 await $.command.run(Fixtures.CLEAR)
1060
1061 const after = await $.command.run(Fixtures.DIFF)
1062
1063 await world.clock.advance(Fixtures.SETTLE_MS)
1064 await $.ui.render(Fixtures.PANE)
1065
1066 script['rev-parse --path-format=absolute'] = Fixtures.ELSEWHERE_LINES
1067
1068 const third = $.command.run(Fixtures.DIFF)
1069
1070 await world.clock.advance(Fixtures.SETTLE_MS)
1071 await third
1072
1073 expect(before.text).toContain("isn't in a git repository")
1074
1075 expect(again.text, "kept, as the built-in's answer is").toContain(
1076 "isn't in a git repository",
1077 )
1078
1079 expect(probesKept, 'one probe answered both').toBe(1)
1080 expect(after.text, '/clear forgot it').toBe('Diff panel shown')
1081 expect(world.opened[0]?.id).toBe('diff')
1082 expect(probesOf(), 'then one more, pinned from there on').toBe(2)
1083
1084 expect(
1085 world.runs.some(run => run.argv.includes('--git-dir=/else/.git')),
1086 ).toBe(false)
1087
1088 expect(
1089 world.runs
1090 .filter(run => run.argv.includes('--numstat'))
1091 .every(run => run.argv[1] === Fixtures.PINNED_LEAD[1]),
1092 ).toBe(true)
1093 })
1094
1095 test('a repository that appears later opens on first edit', async ($, on) => {
1096 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
1097 Fixtures.oneSecret()
1098
1099 const script = { ...notYet }
1100 const world = Fixtures.inRepository(on, script)
1101
1102 on('tool.call', () => ({ result: 'edited' }))
1103
1104 await $.session.start(Fixtures.WORKTREE_SESSION)
1105 await $.ui.render(Fixtures.HINT)
1106
1107 script['rev-parse --path-format=absolute'] = worktree
1108
1109 await $.tool.call({
1110 tool: 'Edit',
1111 file_path: '/main/wt/.env',
1112 old_string: 'old',
1113 new_string: 'new',
1114 })
1115
1116 await world.clock.advance(Fixtures.SETTLE_MS)
1117
1118 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
1119 })
1120
1121 test('a git that cannot start is an answer, not a timeout', async ($, on) => {
1122 const runs: Args<'process.run'>[] = []
1123
1124 Fixtures.startsSession(on)
1125
1126 on('process.run', ($, e) => {
1127 runs.push(e)
1128
1129 return e.argv.includes('--show-toplevel')
1130 ? { deny: Fixtures.FAILED_START }
1131 : { value: Fixtures.gitIn(e.argv, Fixtures.oneSecret()) }
1132 })
1133
1134 await $.session.start(Fixtures.WORKTREE_SESSION)
1135
1136 const { text } = await $.command.run(Fixtures.DIFF)
1137 const probes = runs.filter(run => run.argv.includes('--show-toplevel'))
1138
1139 expect(text).toContain("isn't in a git repository")
1140 expect(probes, "this /diff's alone; no retry").toHaveLength(1)
1141 })
1142
1143 test('/diff on a narrow terminal: the resize line', async ($, on) => {
1144 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
1145
1146 await $.session.start(Fixtures.WORKTREE_SESSION)
1147
1148 const narrow = await $.command.run(
1149 Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS - 1),
1150 )
1151
1152 const openedNarrow = [...world.opened]
1153
1154 await $.command.run(Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS))
1155
1156 expect(narrow.text).toContain(
1157 'Resize your terminal to at least 110 columns to show the diff panel',
1158 )
1159
1160 expect(openedNarrow).toEqual([])
1161 expect(world.opened.map(pane => pane.id)).toEqual([Names.PANE_ID])
1162 })
1163})