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
| @@ -23,9 +23,13 @@ | ||
| 23 | 23 | itself. A docked pane fetches before it opens, as the built-in panel |
| 24 | 24 | primes its data, so it never lands on `Loading diff…`; an open the engine |
| 25 | 25 | leaves waiting undrawn is withdrawn, so no later resize seats it, and the |
| 26 | -next edit asks again. A session resumed or continued whose transcript | |
| 27 | -already holds such an edit opens the pane on the same terms as soon as the | |
| 28 | -width is known, as the built-in opens on the history it restores. | |
| 26 | +next edit asks again. An open answered `no-surface` (nothing is attached | |
| 27 | +that draws it yet, as before a host's page attaches) is kept instead: | |
| 28 | +`/diff` says so, nothing polls or refreshes while no one sees the pane, and | |
| 29 | +its first drawing reads the repository again and counts it shown. A session | |
| 30 | +resumed or continued whose transcript already holds such an edit opens the | |
| 31 | +pane on the same terms as soon as the width is known, as the built-in opens | |
| 32 | +on the history it restores. | |
| 29 | 33 | |
| 30 | 34 | Under the fullscreen layout a terminal under 110 columns gets the |
| 31 | 35 | built-in's line asking for a wider one and nothing opens. Without that |
| @@ -1,0 +1,6 @@ | ||
| 1 | +/** | |
| 2 | + * `/diff`'s answer where nothing is attached that draws the pane yet: it is | |
| 3 | + * kept open, and shows as soon as something is. | |
| 4 | + */ | |
| 5 | +export const AWAITS_SURFACE_TEXT = | |
| 6 | + 'Nothing here can draw the diff panel yet; it opens as soon as something can' |
| @@ -1,3 +1,4 @@ | ||
| 1 | +export * from './awaits-surface-text.js' | |
| 1 | 2 | export * from './builtin-holds-pattern.js' |
| 2 | 3 | export * from './git-unanswered-text.js' |
| 3 | 4 | export * from './more-below-text.js' |
| @@ -1,0 +1,4 @@ | ||
| 1 | +export type * from './open-outcome.js' | |
| 2 | +export * from './open-outcome-of.js' | |
| 3 | + | |
| 4 | +export * as default from '.' |
| @@ -1,0 +1,25 @@ | ||
| 1 | +import { isRecord } from '../is-record' | |
| 2 | +import type { OpenOutcome } from './open-outcome.js' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Reads what `$.ui.open` resolved: an unplaced pane is withdrawn, unless the | |
| 6 | + * reason starts `no-surface`, where the engine keeps it for a surface to come. | |
| 7 | + * | |
| 8 | + * An engine that resolves nothing, or never gives that reason, reads as | |
| 9 | + * before: placed, or withdrawn. | |
| 10 | + * | |
| 11 | + * @param opened what the open resolved | |
| 12 | + * @returns what the plugin does with the pane | |
| 13 | + */ | |
| 14 | +export function openOutcomeOf(opened: unknown): OpenOutcome { | |
| 15 | + const isUnplaced = isRecord(opened) && opened.isPlaced === false | |
| 16 | + | |
| 17 | + if (!isUnplaced) { | |
| 18 | + return 'placed' | |
| 19 | + } | |
| 20 | + | |
| 21 | + const isUnseen = | |
| 22 | + typeof opened.reason === 'string' && opened.reason.startsWith('no-surface') | |
| 23 | + | |
| 24 | + return isUnseen ? 'awaited' : 'withdrawn' | |
| 25 | +} |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * What became of an open: the pane is drawn (`placed`), kept until something | |
| 3 | + * attaches that draws it (`awaited`), or taken back (`withdrawn`). | |
| 4 | + */ | |
| 5 | +export type OpenOutcome = 'placed' | 'awaited' | 'withdrawn' |
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | import { messageOf } from './message-of' |
| 22 | 22 | import { mtimeOf } from './mtime-of' |
| 23 | 23 | import Names from './names' |
| 24 | +import OpenOutcome from './open-outcome' | |
| 24 | 25 | import PaneState from './pane-state' |
| 25 | 26 | import PaneToggle from './pane-toggle' |
| 26 | 27 | import Record from './record' |
| @@ -44,6 +45,7 @@ | ||
| 44 | 45 | let probing: Promise<boolean> | null = null |
| 45 | 46 | let sessionStartMs = 0 |
| 46 | 47 | let isPaneOpen = false |
| 48 | + let awaited: (typeof Record.SHOWN_TRIGGERS)[number] | null = null | |
| 47 | 49 | let dialogRows: number | null = null |
| 48 | 50 | let hasAutoOpened = false |
| 49 | 51 | let hasRestoredEdits = false |
| @@ -69,6 +71,7 @@ | ||
| 69 | 71 | const loggedBaseKinds = new Set<'ok' | 'sad'>() |
| 70 | 72 | |
| 71 | 73 | const currentOf = (engine: Host): Host => host ?? engine |
| 74 | + const isPaneSeen = (): boolean => isPaneOpen && awaited === null | |
| 72 | 75 | |
| 73 | 76 | const backendHostOf = (engine: Host): Backend.BackendHost => ({ |
| 74 | 77 | run: (argv, init) => |
| @@ -302,7 +305,7 @@ | ||
| 302 | 305 | timers.set( |
| 303 | 306 | 'poll', |
| 304 | 307 | engine.every(Limits.HEAD_POLL_MS, () => { |
| 305 | - if (!isPaneOpen) { | |
| 308 | + if (!isPaneSeen()) { | |
| 306 | 309 | return |
| 307 | 310 | } |
| 308 | 311 | |
| @@ -414,10 +417,20 @@ | ||
| 414 | 417 | } |
| 415 | 418 | } |
| 416 | 419 | |
| 420 | + async function showKept( | |
| 421 | + engine: Host, | |
| 422 | + trigger: (typeof Record.SHOWN_TRIGGERS)[number], | |
| 423 | + ): Promise<void> { | |
| 424 | + await markShown(engine, trigger) | |
| 425 | + await pinBackend(engine) | |
| 426 | + keepBaseline() | |
| 427 | + void refresh(engine) | |
| 428 | + } | |
| 429 | + | |
| 417 | 430 | async function openPane( |
| 418 | 431 | engine: Host, |
| 419 | 432 | trigger: (typeof Record.SHOWN_TRIGGERS)[number], |
| 420 | - ): Promise<boolean> { | |
| 433 | + ): Promise<OpenOutcome.OpenOutcome> { | |
| 421 | 434 | const isDialog = model.isFullscreen === false |
| 422 | 435 | |
| 423 | 436 | model = { |
| @@ -441,18 +454,22 @@ | ||
| 441 | 454 | : { id: Names.PANE_ID, title: Names.PANE_TITLE }, |
| 442 | 455 | ) |
| 443 | 456 | |
| 444 | - const isWaiting = isRecord(opened) && opened.isPlaced === false | |
| 457 | + const outcome = OpenOutcome.openOutcomeOf(opened) | |
| 445 | 458 | |
| 446 | - if (isWaiting) { | |
| 459 | + if (outcome === 'withdrawn') { | |
| 447 | 460 | await engine.closePane({ id: Names.PANE_ID }).catch(() => undefined) |
| 448 | 461 | |
| 449 | - return false | |
| 462 | + return outcome | |
| 450 | 463 | } |
| 451 | 464 | |
| 452 | 465 | isPaneOpen = true |
| 453 | 466 | keepBaseline() |
| 454 | 467 | |
| 455 | - await markShown(engine, trigger) | |
| 468 | + if (outcome === 'awaited') { | |
| 469 | + awaited = trigger | |
| 470 | + } else { | |
| 471 | + await markShown(engine, trigger) | |
| 472 | + } | |
| 456 | 473 | |
| 457 | 474 | const isStale = isDialog || landed !== landedBefore |
| 458 | 475 | |
| @@ -460,12 +477,13 @@ | ||
| 460 | 477 | void refresh(engine) |
| 461 | 478 | } |
| 462 | 479 | |
| 463 | - return true | |
| 480 | + return outcome | |
| 464 | 481 | } |
| 465 | 482 | |
| 466 | 483 | async function closePane(engine: Host): Promise<void> { |
| 467 | 484 | await engine.closePane({ id: Names.PANE_ID }) |
| 468 | 485 | isPaneOpen = false |
| 486 | + awaited = null | |
| 469 | 487 | } |
| 470 | 488 | |
| 471 | 489 | function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) { |
| @@ -512,7 +530,7 @@ | ||
| 512 | 530 | } |
| 513 | 531 | |
| 514 | 532 | hasAutoOpened = true |
| 515 | - hasAutoOpened = await openPane(engine, 'auto_open') | |
| 533 | + hasAutoOpened = (await openPane(engine, 'auto_open')) !== 'withdrawn' | |
| 516 | 534 | } |
| 517 | 535 | |
| 518 | 536 | async function openOnRestore(engine: Host): Promise<void> { |
| @@ -727,6 +745,13 @@ | ||
| 727 | 745 | |
| 728 | 746 | columns = e.viewport?.columns ?? columns |
| 729 | 747 | |
| 748 | + if (awaited !== null) { | |
| 749 | + const trigger = awaited | |
| 750 | + | |
| 751 | + awaited = null | |
| 752 | + void showKept(host, trigger).catch(() => undefined) | |
| 753 | + } | |
| 754 | + | |
| 730 | 755 | /** |
| 731 | 756 | * A seat that changed since the last drawing lists other rows, whose |
| 732 | 757 | * bodies are read once. |
| @@ -796,14 +821,18 @@ | ||
| 796 | 821 | |
| 797 | 822 | const isOpening = toggle === 'open' |
| 798 | 823 | |
| 799 | - const isDone = isOpening | |
| 824 | + const outcome = isOpening | |
| 800 | 825 | ? await openPane(host, 'manual') |
| 801 | - : await closePane(host).then(() => true) | |
| 826 | + : await closePane(host).then(() => 'placed' as const) | |
| 802 | 827 | |
| 803 | - if (!isDone) { | |
| 828 | + if (outcome === 'withdrawn') { | |
| 804 | 829 | return { text: Names.RESIZE_TERMINAL_TEXT } |
| 805 | 830 | } |
| 806 | 831 | |
| 832 | + if (outcome === 'awaited') { | |
| 833 | + return { text: Names.AWAITS_SURFACE_TEXT } | |
| 834 | + } | |
| 835 | + | |
| 807 | 836 | if (!isFullscreen) { |
| 808 | 837 | return isOpening ? {} : { text: Names.DIALOG_DISMISSED_TEXT } |
| 809 | 838 | } |
| @@ -841,6 +870,7 @@ | ||
| 841 | 870 | |
| 842 | 871 | if (isClosed) { |
| 843 | 872 | isPaneOpen = false |
| 873 | + awaited = null | |
| 844 | 874 | } |
| 845 | 875 | |
| 846 | 876 | const isDocking = model.isFullscreen !== false |
| @@ -904,7 +934,7 @@ | ||
| 904 | 934 | } |
| 905 | 935 | |
| 906 | 936 | const isResume = e.command === 'resume' |
| 907 | - const isKeptOpen = isPaneOpen && !isResume | |
| 937 | + const isKeptOpen = isPaneSeen() && !isResume | |
| 908 | 938 | |
| 909 | 939 | if (isPaneOpen && isResume) { |
| 910 | 940 | await closePane(host).catch(() => undefined) |
| @@ -924,10 +954,7 @@ | ||
| 924 | 954 | (isResume ? sessionStartMs : await host.now()) |
| 925 | 955 | |
| 926 | 956 | if (isKeptOpen) { |
| 927 | - await markShown(host, 'manual') | |
| 928 | - await pinBackend(host) | |
| 929 | - keepBaseline() | |
| 930 | - void refresh(host) | |
| 957 | + await showKept(host, 'manual') | |
| 931 | 958 | } |
| 932 | 959 | |
| 933 | 960 | if (isResume) { |
| @@ -956,7 +983,7 @@ | ||
| 956 | 983 | landed += 1 |
| 957 | 984 | } |
| 958 | 985 | |
| 959 | - if (hasLanded && isPaneOpen) { | |
| 986 | + if (hasLanded && isPaneSeen()) { | |
| 960 | 987 | scheduleRefresh(engine) |
| 961 | 988 | } |
| 962 | 989 |
| @@ -36,6 +36,12 @@ | ||
| 36 | 36 | isLeftWaiting?: () => boolean |
| 37 | 37 | |
| 38 | 38 | /** |
| 39 | + * Whether an open made now is kept with nothing attached to draw it | |
| 40 | + * (LEFT_UNSEEN), as before a host's page attaches; placed when not given. | |
| 41 | + */ | |
| 42 | + isLeftUnseen?: () => boolean | |
| 43 | + | |
| 44 | + /** | |
| 39 | 45 | * Why a hunks read asked now is refused, as the host refuses a git that |
| 40 | 46 | * outran its timeout; answered from the script when null or not given. |
| 41 | 47 | */ |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | export type * from './beneath.js' |
| 2 | +export * from './left-unseen.js' | |
| 2 | 3 | export * from './left-waiting.js' |
| 3 | 4 | export * from './slow-diff-ms.js' |
| 4 | 5 |
| @@ -1,0 +1,16 @@ | ||
| 1 | +import type { ResultOf } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * What an engine answers an open it keeps where nothing is attached to draw | |
| 5 | + * it yet: a host whose page has not attached, or one that never draws. | |
| 6 | + * | |
| 7 | + * Typed through `never` so it compiles against declarations that predate the | |
| 8 | + * answer, where `ui.open` resolves nothing. | |
| 9 | + */ | |
| 10 | +export const LEFT_UNSEEN: ResultOf['ui.open'] = { | |
| 11 | + value: { | |
| 12 | + isPlaced: false, | |
| 13 | + reason: | |
| 14 | + 'no-surface: no surface is attached ($.session.surfaces() is empty)', | |
| 15 | + } as never, | |
| 16 | +} |
| @@ -12,16 +12,18 @@ | ||
| 12 | 12 | * A session in a repository git answers for from a script (REPOSITORY, in |
| 13 | 13 | * /work, when none is given), keeping what the plugin does there. |
| 14 | 14 | * |
| 15 | - * Kept: each git run, ring move, pane opened, left waiting or closed, status | |
| 16 | - * line. The clock starts at 0 and the engine draws the hint; the rest of the | |
| 17 | - * world is the test's (Beneath). Rewriting the script changes git's answers. | |
| 15 | + * Kept: each git run, ring move, pane opened, left waiting, left unseen or | |
| 16 | + * closed, status line. The clock starts at 0 and the engine draws the hint. | |
| 18 | 17 | * |
| 18 | + * The rest of the world is the test's (Beneath). Rewriting the script changes | |
| 19 | + * git's answers. | |
| 20 | + * | |
| 19 | 21 | * @param on the test's `on` |
| 20 | 22 | * @param script git's output for each invocation whose line holds the key |
| 21 | 23 | * @param beneath the store, settings, environment, transcript, whether an |
| 22 | 24 | * open is seated, whether a hunks read is refused |
| 23 | - * @returns the runs, the ring's moves, the panes opened, left waiting and | |
| 24 | - * closed, the statuses, the clock | |
| 25 | + * @returns the runs, the ring's moves, the panes opened, left waiting, left | |
| 26 | + * unseen and closed, the statuses, the clock | |
| 25 | 27 | */ |
| 26 | 28 | export function inRepository( |
| 27 | 29 | on: On, |
| @@ -32,6 +34,7 @@ | ||
| 32 | 34 | const focused: Args<'ui.focus'>[] = [] |
| 33 | 35 | const statuses: (string | undefined)[] = [] |
| 34 | 36 | const waiting: Args<'ui.open'>[] = [] |
| 37 | + const unseen: Args<'ui.open'>[] = [] | |
| 35 | 38 | const opened = keeping<Args<'ui.open'>>() |
| 36 | 39 | const closed = keeping<Args<'ui.close'>>() |
| 37 | 40 | const clock = startsSession(on) |
| @@ -59,7 +62,14 @@ | ||
| 59 | 62 | |
| 60 | 63 | on('ui.open', (engine, e) => { |
| 61 | 64 | const isWaiting = beneath.isLeftWaiting?.() === true |
| 65 | + const isUnseen = beneath.isLeftUnseen?.() === true | |
| 62 | 66 | |
| 67 | + if (isUnseen) { | |
| 68 | + unseen.push(e) | |
| 69 | + | |
| 70 | + return Beneath.LEFT_UNSEEN | |
| 71 | + } | |
| 72 | + | |
| 63 | 73 | if (!isWaiting) { |
| 64 | 74 | return opened.hook(engine, e) |
| 65 | 75 | } |
| @@ -82,6 +92,7 @@ | ||
| 82 | 92 | focused, |
| 83 | 93 | opened: opened.kept, |
| 84 | 94 | waiting, |
| 95 | + unseen, | |
| 85 | 96 | closed: closed.kept, |
| 86 | 97 | statuses, |
| 87 | 98 | clock, |
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | |
| 5 | 5 | import Limits from '../hooks/limits' |
| 6 | 6 | import Names from '../hooks/names' |
| 7 | +import OpenOutcome from '../hooks/open-outcome' | |
| 7 | 8 | import Record from '../hooks/record' |
| 8 | 9 | import Fixtures from './fixtures' |
| 9 | 10 | |
| @@ -701,6 +702,101 @@ | ||
| 701 | 702 | ).toEqual(['diff']) |
| 702 | 703 | }) |
| 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 | + | |
| 704 | 800 | test('a docked pane opens once its first fetch settled', async ($, on) => { |
| 705 | 801 | const opened: string[] = [] |
| 706 | 802 | const clock = Fixtures.startsSession(on) |
| @@ -1157,4 +1253,25 @@ | ||
| 1157 | 1253 | expect(openedNarrow).toEqual([]) |
| 1158 | 1254 | expect(world.opened.map(pane => pane.id)).toEqual([Names.PANE_ID]) |
| 1159 | 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 | + }) | |
| 1160 | 1277 | }) |