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