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,160 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([{ 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('a docked pane opens once its first fetch settled', async ($, on) => {
705 const opened: string[] = []
706 const clock = Fixtures.startsSession(on)
707
708 on('process.run', async ($, e) => {
709 if (e.argv.includes('--shortstat')) {
710 await clock.sleep(Fixtures.SLOW_DIFF_MS)
711 }
712
713 return { value: Fixtures.gitIn(e.argv) }
714 })
715
716 on('ui.open', ($, e) => {
717 opened.push(e.id)
718
719 return { value: undefined }
720 })
721
722 on('ui.close', () => ({ value: undefined }))
723 on('ui.invalidate', () => ({ value: undefined }))
724 on('ui.render', { component: 'PromptHint' }, () => Fixtures.HINT_DRAWN)
725 on('session.messages', () => ({ value: [] }))
726 on('settings.read', () => ({ value: {} }))
727 on('tool.call', () => ({ result: 'edited' }))
728 mock.store(on)
729 mock.env(on, {})
730
731 await $.session.start(Fixtures.SESSION)
732 await $.ui.render(Fixtures.HINT)
733
734 await $.tool.call({
735 tool: 'Edit',
736 file_path: '/work/app.ts',
737 old_string: '1',
738 new_string: '2',
739 })
740
741 await clock.advance(Fixtures.SLOW_DIFF_MS - 1)
742
743 expect(opened, 'git has not answered: no pane, no Loading frame').toEqual(
744 [],
745 )
746
747 await clock.advance(Fixtures.SETTLE_MS)
748
749 expect(opened, 'the fetch settled: the pane opens filled').toEqual(['diff'])
750
751 const drawn = Fixtures.textOf(await $.ui.render(Fixtures.PANE))
752
753 expect(drawn).toContain('1 file changed')
754 expect(drawn).not.toContain('Loading diff')
755 })
756
757 test('/clear leaves an open pane up and reads afresh', async ($, on) => {
758 const world = Fixtures.inRepository(on)
759
760 on('command.run', { command: 'clear' }, () => ({}))
761
762 await $.session.start(Fixtures.SESSION)
763 await $.command.run(Fixtures.DIFF)
764 await world.clock.advance(Fixtures.SETTLE_MS)
765
766 const read = world.runs.length
767
768 await $.command.run(Fixtures.CLEAR)
769 await world.clock.advance(Fixtures.SETTLE_MS)
770
771 expect(world.closed, 'as the built-in panel stays across /clear').toEqual(
772 [],
773 )
774
775 expect(
776 world.runs.slice(read).map(run => Fixtures.gitWordOf(run.argv))[0],
777 'the repository found again for the conversation that starts over',
778 ).toBe('rev-parse --show-toplevel')
779
780 expect(
781 Fixtures.textOf(await $.ui.render(Fixtures.PANE)),
782 'drawn from the fresh read',
783 ).toContain('1 file changed')
784 })
785
786 test('/clear under an open pane counts it shown again', async ($, on) => {
787 const world = Fixtures.inRepository(on)
788 const logged = Fixtures.keeping<Args<'telemetry.log'>>()
789
790 let sessionId = 'first'
791
792 on('telemetry.log', logged.hook)
793 on('session.id', () => ({ value: sessionId }))
794 on('tool.call', () => ({ result: 'edited' }))
795
796 on('command.run', { command: 'clear' }, () => {
797 sessionId = 'second'
798
799 return {}
800 })
801
802 await $.session.start(Fixtures.SESSION)
803 await $.ui.render(Fixtures.HINT)
804
805 await $.tool.call({
806 tool: 'Edit',
807 file_path: '/work/app.ts',
808 old_string: '1',
809 new_string: '2',
810 })
811
812 await world.clock.advance(Fixtures.SETTLE_MS)
813 await $.command.run(Fixtures.CLEAR)
814 await world.clock.advance(Fixtures.SETTLE_MS)
815
816 expect(world.closed, 'the pane stayed up across /clear').toEqual([])
817
818 expect(
819 logged.kept.map(shown => shown.props?.trigger),
820 "once a conversation: Claude's edit opened the first, and the second, " +
821 "which nobody opened, reads as the person's, as the built-in's does",
822 ).toEqual([
823 { value: 'auto_open', of: [...Record.SHOWN_TRIGGERS] },
824 { value: 'manual', of: [...Record.SHOWN_TRIGGERS] },
825 ])
826 })
827
828 test('a merge finished 3 s after the open is noticed', async ($, on) => {
829 expect(
830 await drawnRoundMerge($, on, 3000),
831 'finished in another terminal: read with no edit or command',
832 ).toEqual(NOTICED)
833 })
834
835 test('a merge finished 1.2 s after the open is noticed', async ($, on) => {
836 expect(
837 await drawnRoundMerge($, on, 1200),
838 'before the first look at HEAD on the clock: the open read it',
839 ).toEqual(NOTICED)
840 })
841
842 test('a merge finished 0.3 s after the open is noticed', async ($, on) => {
843 expect(
844 await drawnRoundMerge($, on, 300),
845 'before the first look at HEAD on the clock: the open read it',
846 ).toEqual(NOTICED)
847 })
848
849 test('a resumed session that edited opens before an edit', async ($, on) => {
850 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
851 messages: () => Fixtures.EDITED_TRANSCRIPT,
852 })
853
854 await $.session.start(Fixtures.SESSION)
855
856 expect(world.runs, 'the start itself still runs no git').toEqual([])
857
858 await $.ui.render(Fixtures.HINT)
859 await world.clock.advance(Fixtures.SETTLE_MS)
860
861 expect(
862 world.opened.map(pane => pane.id),
863 'open once the width is known, as the built-in opens on the restore',
864 ).toEqual(['diff'])
865 })
866
867 test('a resumed session opens only where an edit would', async ($, on) => {
868 const narrow = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
869 messages: () => Fixtures.EDITED_TRANSCRIPT,
870 })
871
872 await $.session.start(Fixtures.SESSION)
873 await $.ui.render(Fixtures.hintAt(Limits.AUTO_OPEN_MIN_COLUMNS - 1))
874 await narrow.clock.advance(Fixtures.SETTLE_MS)
875
876 expect(narrow.opened, 'under the unasked floor, never kept open').toEqual(
877 [],
878 )
879
880 expect(narrow.runs, 'and no git for it').toEqual([])
881 })
882
883 test('a resumed session kept open opens at the low floor', async ($, on) => {
884 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
885 messages: () => Fixtures.EDITED_TRANSCRIPT,
886 stored: { [Names.STORE_OPEN_KEY]: true },
887 })
888
889 await $.session.start(Fixtures.SESSION)
890 await $.ui.render(Fixtures.hintAt(Limits.OPEN_MIN_COLUMNS))
891 await world.clock.advance(Fixtures.SETTLE_MS)
892
893 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
894 })
895
896 test('a resumed session with no edits opens nothing', async ($, on) => {
897 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
898 messages: () => [Fixtures.promptOf('just talk')],
899 })
900
901 await $.session.start(Fixtures.SESSION)
902 await $.ui.render(Fixtures.HINT)
903 await world.clock.advance(Fixtures.SETTLE_MS)
904
905 expect(world.opened).toEqual([])
906 expect(world.runs).toEqual([])
907 })
908
909 test('a resumed session the person closed opens nothing', async ($, on) => {
910 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
911 messages: () => Fixtures.EDITED_TRANSCRIPT,
912 stored: { [Names.STORE_OPEN_KEY]: false },
913 })
914
915 await $.session.start(Fixtures.SESSION)
916 await $.ui.render(Fixtures.HINT)
917 await world.clock.advance(Fixtures.SETTLE_MS)
918
919 expect(world.opened).toEqual([])
920 expect(world.runs).toEqual([])
921 })
922
923 test('/resume closes the pane, reopens it for its turns', async ($, on) => {
924 let transcript: readonly SessionMessage[] = []
925
926 const world = Fixtures.inRepository(on, Fixtures.REPOSITORY, {
927 messages: () => transcript,
928 })
929
930 on('command.run', { command: 'resume' }, () => {
931 transcript = Fixtures.EDITED_TRANSCRIPT
932
933 return {}
934 })
935
936 await $.session.start(Fixtures.SESSION)
937 await $.ui.render(Fixtures.HINT)
938 await $.command.run(Fixtures.DIFF)
939 await world.clock.advance(Fixtures.SETTLE_MS)
940 await $.command.run(Fixtures.RESUME)
941 await world.clock.advance(Fixtures.SETTLE_MS)
942
943 expect(
944 world.closed.map(pane => pane.id),
945 'closed at /resume',
946 ).toEqual(['diff'])
947
948 expect(
949 world.opened.map(pane => pane.id),
950 '/diff opened it, /resume opened it again for the restored turns',
951 ).toEqual(['diff', 'diff'])
952 })
953
954 test('ask attaches the hunks on screen and calls no tool', async ($, on) => {
955 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
956 const called: Args<'tool.call'>[] = []
957
958 on('tool.call', ($, e) => {
959 called.push(e)
960
961 return { result: 'called' }
962 })
963
964 on('prompt.submit', ($, e) => ({ text: e.text, context: e.context }))
965
966 await $.session.start(Fixtures.WORKTREE_SESSION)
967 await $.command.run(Fixtures.DIFF)
968 await world.clock.advance(Fixtures.SETTLE_MS)
969 await $.ui.render(Fixtures.PANE)
970 await $.ui.press({ plugin: 'diff', key: 'ask:.env' })
971 await world.clock.settle()
972
973 const armed = Fixtures.jsonOf(await $.ui.render(Fixtures.PANE))
974
975 const submitted = await $.prompt.submit(Fixtures.typedPromptOf('why?'))
976
977 expect(armed).toContain('asked ✓')
978 expect(called).toEqual([])
979
980 expect(world.statuses.at(-2)).toBe(
981 '.env rides your next prompt (press asked ✓ to drop it)',
982 )
983
984 expect(submitted).toMatchObject({
985 context: [
986 'The user attached the diff of .env from the diff pane to this ' +
987 'prompt:\n@@ -1 +1 @@\n-KEY=old\n+KEY=new',
988 ],
989 })
990 })
991
992 test('an ask with no room in the context is dropped', async ($, on) => {
993 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
994 const full = 'x'.repeat(Limits.PROMPT_CONTEXT_MAX_CHARS)
995 const reached: (readonly string[] | undefined)[] = []
996
997 on('prompt.submit', ($, e) => {
998 reached.push(e.context)
999
1000 return { text: e.text }
1001 })
1002
1003 await $.session.start(Fixtures.WORKTREE_SESSION)
1004 await $.command.run(Fixtures.DIFF)
1005 await world.clock.advance(Fixtures.SETTLE_MS)
1006 await $.ui.render(Fixtures.PANE)
1007 await $.ui.press({ plugin: 'diff', key: 'ask:.env' })
1008 await world.clock.settle()
1009
1010 await $.prompt.submit(Fixtures.typedPromptOf('why?', [full]))
1011 await $.prompt.submit(Fixtures.typedPromptOf('and now?'))
1012
1013 expect(reached).toEqual([[full], undefined])
1014
1015 expect(world.statuses.at(-1)).toBe(
1016 ".env's diff did not fit in the prompt and was dropped",
1017 )
1018 })
1019
1020 test('a worktree opens on the base it kept', async ($, on) => {
1021 const world = Fixtures.inRepository(on, Fixtures.oneSecret(), {
1022 stored: { 'base:/main/wt': 'uncommitted' },
1023 })
1024
1025 await $.session.start(Fixtures.WORKTREE_SESSION)
1026 await $.command.run(Fixtures.DIFF)
1027 await world.clock.advance(Fixtures.SETTLE_MS)
1028
1029 expect(
1030 Fixtures.stringsOf(await $.ui.render(Fixtures.PANE)),
1031 "the built-in's base line under the header",
1032 ).toContain('uncommitted (vs HEAD)')
1033 })
1034
1035 test('no repository is an answer kept until /clear', async ($, on) => {
1036 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
1037 Fixtures.oneSecret()
1038
1039 const script = { ...notYet }
1040 const world = Fixtures.inRepository(on, script)
1041
1042 const probesOf = () =>
1043 world.runs.filter(run => run.argv.includes('--show-toplevel')).length
1044
1045 on('command.run', { command: 'clear' }, () => ({}))
1046
1047 await $.session.start(Fixtures.WORKTREE_SESSION)
1048
1049 const before = await $.command.run(Fixtures.DIFF)
1050
1051 script['rev-parse --path-format=absolute'] = worktree
1052
1053 const again = await $.command.run(Fixtures.DIFF)
1054 const probesKept = probesOf()
1055
1056 await $.command.run(Fixtures.CLEAR)
1057
1058 const after = await $.command.run(Fixtures.DIFF)
1059
1060 await world.clock.advance(Fixtures.SETTLE_MS)
1061 await $.ui.render(Fixtures.PANE)
1062
1063 script['rev-parse --path-format=absolute'] = Fixtures.ELSEWHERE_LINES
1064
1065 const third = $.command.run(Fixtures.DIFF)
1066
1067 await world.clock.advance(Fixtures.SETTLE_MS)
1068 await third
1069
1070 expect(before.text).toContain("isn't in a git repository")
1071
1072 expect(again.text, "kept, as the built-in's answer is").toContain(
1073 "isn't in a git repository",
1074 )
1075
1076 expect(probesKept, 'one probe answered both').toBe(1)
1077 expect(after.text, '/clear forgot it').toBe('Diff panel shown')
1078 expect(world.opened[0]?.id).toBe('diff')
1079 expect(probesOf(), 'then one more, pinned from there on').toBe(2)
1080
1081 expect(
1082 world.runs.some(run => run.argv.includes('--git-dir=/else/.git')),
1083 ).toBe(false)
1084
1085 expect(
1086 world.runs
1087 .filter(run => run.argv.includes('--numstat'))
1088 .every(run => run.argv[1] === Fixtures.PINNED_LEAD[1]),
1089 ).toBe(true)
1090 })
1091
1092 test('a repository that appears later opens on first edit', async ($, on) => {
1093 const { 'rev-parse --path-format=absolute': worktree = '', ...notYet } =
1094 Fixtures.oneSecret()
1095
1096 const script = { ...notYet }
1097 const world = Fixtures.inRepository(on, script)
1098
1099 on('tool.call', () => ({ result: 'edited' }))
1100
1101 await $.session.start(Fixtures.WORKTREE_SESSION)
1102 await $.ui.render(Fixtures.HINT)
1103
1104 script['rev-parse --path-format=absolute'] = worktree
1105
1106 await $.tool.call({
1107 tool: 'Edit',
1108 file_path: '/main/wt/.env',
1109 old_string: 'old',
1110 new_string: 'new',
1111 })
1112
1113 await world.clock.advance(Fixtures.SETTLE_MS)
1114
1115 expect(world.opened.map(pane => pane.id)).toEqual(['diff'])
1116 })
1117
1118 test('a git that cannot start is an answer, not a timeout', async ($, on) => {
1119 const runs: Args<'process.run'>[] = []
1120
1121 Fixtures.startsSession(on)
1122
1123 on('process.run', ($, e) => {
1124 runs.push(e)
1125
1126 return e.argv.includes('--show-toplevel')
1127 ? { deny: Fixtures.FAILED_START }
1128 : { value: Fixtures.gitIn(e.argv, Fixtures.oneSecret()) }
1129 })
1130
1131 await $.session.start(Fixtures.WORKTREE_SESSION)
1132
1133 const { text } = await $.command.run(Fixtures.DIFF)
1134 const probes = runs.filter(run => run.argv.includes('--show-toplevel'))
1135
1136 expect(text).toContain("isn't in a git repository")
1137 expect(probes, "this /diff's alone; no retry").toHaveLength(1)
1138 })
1139
1140 test('/diff on a narrow terminal: the resize line', async ($, on) => {
1141 const world = Fixtures.inRepository(on, Fixtures.oneSecret())
1142
1143 await $.session.start(Fixtures.WORKTREE_SESSION)
1144
1145 const narrow = await $.command.run(
1146 Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS - 1),
1147 )
1148
1149 const openedNarrow = [...world.opened]
1150
1151 await $.command.run(Fixtures.diffAt(Limits.OPEN_MIN_COLUMNS))
1152
1153 expect(narrow.text).toContain(
1154 'Resize your terminal to at least 110 columns to show the diff panel',
1155 )
1156
1157 expect(openedNarrow).toEqual([])
1158 expect(world.opened.map(pane => pane.id)).toEqual([Names.PANE_ID])
1159 })
1160})