SLOPSHOPPER

wt

The worktree you are in, as wt sees it: how far behind trunk, its own remote, and wt up, undo and push from a small dialog. Optional: open it in gittree, and…

newpanebandguardcommandtoast
v0.2.0no licenseupdated 2026-10-09anders-lindstrom/wt/mods/wt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wt
│ ┃ wt ✕ › fix the failing auth test and add an audit log call │ ┃ No wt worktree here: this folder is not in a │ ┃ repository wt knows, or wt is not installed. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ r: refresh ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ q: close click here, or ctrl+x then tab, fo ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /wt │ ⎿ wt: No wt worktree here. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · wt
No wt worktree here: this folder is not in a repository wt knows, or wt is not installed. r: refresh q: close click here, or ctrl+x then tab, for the keys
README

wt

Git worktree tooling: one implementation, per-repo configuration.

Replaces the ~1,580-line copy of worktree scripts that seven repositories each carried privately — copies that had drifted into two to four variants of nearly every file, to the point where the same bug was found and fixed twice, in two repos, with two incompatible fixes, and neither reached the other five.

Install

git clone git@github.com:anders-lindstrom/wt.git && cd wt
./install.sh                 # builds to ~/.local/bin, installs to ~/.local/share/wt

Then in your shell rc:

source ~/.local/share/wt/wt.sh

Setting up a repository

cd your-repo
wt init          # writes bin/worktree/worktree.conf
wt doctor        # check it

wt init asks for the three keys a repository actually varies, offering detected values as the defaults:

trunk (MAIN_BRANCH) [main]:
branch prefix [feat_wt]:
build command (blank for none): make build

Every other key is written commented at its default, so the file is this repository's reference for what it may set — uncommenting a line as it stands changes nothing. An answer that would not validate is rejected at the prompt, not written and then discovered by the next command:

  ! WORKTREE_BRANCH_PREFIX="wip_wt" yields default type "wip", which is not in
    WORKTREE_TYPES; set WORKTREE_DEFAULT_TYPE to choose one of: feat fix docs …

With --yes, or with no terminal to answer from, the detected values are written without asking — so a script, a hook or an agent gets the same result without hanging on a prompt. --force replaces a configuration already there; without it, an existing file is an error rather than something to overwrite.

A repository with no configuration works too: every command runs on what wt init --yes would write — trunk from origin's HEAD, else the first of development, main and master that exists here or on origin, the default prefix, no build command — detected on each run and never written to the repository. That is what lets a harness make worktrees in a clone nobody set up (the Claude Code hooks, for one). wt new says so in one line on stderr, wt config shows the values as detected, and wt doctor notes it without counting it as a problem. Nothing that runs code is detected: only a bin/worktree/provision.sh the repository itself carries runs. When not even trunk can be detected — no origin HEAD, and none of those three branches — wt does not take the checked-out branch for it: commands stop with "cannot tell which branch is trunk", wt init --yes refuses, and wt init asks you to name it.

If the config lands somewhere git ignores — a repo ignoring bin/ for its build output also ignores bin/worktree/ — wt init says so, because that configuration would work for you and for nobody who clones the repo.

Commands

Grouped the way wt --help groups them. Every command carries worked examples: wt <command> --help.

Make a worktree

wt new <type>/<work>create a branch and worktree, then provision it (--base, --no-setup, --no-build, --no-superset, --dry-run)
wt checkout <branch> [<work>]put a worktree on a branch that already exists; <remote>/<branch>, or a bare name only one remote has, creates the local branch tracking it, from the refs as last fetched; also wt co (--dry-run)
wt pr checkout [<number>]put a worktree on a pull request; with no number, pick one from the open ones, your review queue first
wt pr listevery open pull request, its state and checks, and the worktree on it
wt pr open [<work>]open a worktree's pull request in the browser; with no argument, the one you are in

Get to your work

wt cd [<pattern>]cd to a worktree, in this shell; . is the one you are in, bare or / the main checkout
wt exec <pattern> <cmd>…run a command there, in a subshell; your shell stays put
wt attach [<pattern>] [<session>]open the Claude session in a worktree, in this terminal; asks which when there are several; --resume continues a conversation there instead
wt listevery worktree, in any layout; s marks Superset's, ! one nothing owns; a PR column when a worktree here has one, asked for by branch and cached for a few minutes (--no-pr, --refresh); a SESSION column when one has a Claude session in it and the terminal is wide enough for it (--wide whatever the width, --no-sessions); --all, --roots, --profile for many repositories
wt status [<work>]each worktree's branch, whether its checkout is clean, and how far behind and ahead of trunk it is; with a worktree named, that one in full with wt sync's verdict; wt list's PR column and cache (--no-pr, --refresh; --all, --roots, --profile for many repositories)
wt find <pattern>resolve a worktree by fuzzy name, across repositories (--candidates)
wt path <work> / wt branch <work>where a piece of work lives or would go, and its branch; exact, this repository only, for scripts
wt reposevery repository wt manages under your roots, with its worktrees and whether wt sync is set up (--all, --roots, --profile, --paths)

Keep up with trunk

wt up [<work>]sync the worktree you are in with trunk by rebasing it, only if it goes through without you — conflict-free, or every stop resolved by .wt-sync.yaml; otherwise it touches nothing and says why. Also where trunk declares no .wt-sync.yaml, conflict-free only. The short form of wt sync rebase . --if-ready (--push, --no-push, --no-fetch, --yes/-y for yes to everything including the push, --force/-f to go ahead past an agent session in it)
wt status <work> --json / wt up --jsona worktree's plan for wt up, and a run's result, as one JSON object each for tools driving wt (--expect holds a run to its plan); wt schema prints their JSON Schemas, and docs/json.md explains them
wt sweep --dry-run --json / wt sweep --yes --jsona sweep's plan, every row with why it is merged or kept, and a sweep's result row by row, for tools driving wt (--expect holds the sweep to its plan); see docs/json.md
wt remove <work> --dry-run --json / wt remove <work> --yes --jsona removal's plan — where the branch stands, every reason it would refuse, what it would lose — and its result effect by effect, for tools driving wt (--expect holds the removal to its plan)
wt restore <dir> --dry-run --json / wt restore <dir> --jsonwhat putting back a worktree moved to a folder would do to its branch, and what it did; recovery.json in the folder is itself versioned (wt schema recovery)
wt purge <dir> --json / … --yes --jsonwhat deleting a removed worktree's folder for good would delete and leave unreachable, with a token, and what became of each pin and the folder (--expect holds the purge to its plan)
wt refs sweep, restore, purge --json; wt refs swept --jsoneach plan with a token and each result row by row, for the ref sweep; see docs/json.md
wt sync --json / `wt sync rebase\resume\undo --json`the sync overview of every worktree, and a verb's result, as JSON for tools (wt sync rebase --expect holds a run to the overview); wt schema sync and wt schema sync-run
wt new … --dry-run --json / wt new … --jsonthe same for creating a worktree, and for wt checkout: the plan with a token, and a result naming every side effect (--expect refuses a moved base or branch)
wt syncwhat rebasing each worktree onto trunk would do, simulated after fetching trunk; changes nothing of yours (--no-fetch)
wt sync rebase [<work>...]sync the named worktrees by rebasing them onto trunk, with the declared strategies (--no-fetch, --yes/-y, --push, --no-push, --if-ready); also spelled wt sync <work>... --rebase. With nothing named, every worktree the table calls ready except recipe?, asked first — and with no terminal, only with --yes. --if-ready rebases what is ready and fails if anything was not. The push question defaults to no; --yes answers yes to it, like every question, unless --no-push
wt sync resume <work>continue the rebase a run left at a conflict that was yours (--yes/-y, --push, --no-push); also spelled wt sync <work> --resume
wt sync undo <work>put back every ref the last wt sync rebase on this worktree moved, aborting a rebase a run handed over (--force/-f, --yes/-y); also spelled wt sync <work> --undo
wt sync doctorcheck what a run needs; --fix turns on rerere and removes expired locks, --prune deletes old safety refs; a keeper row says whether one is installed and how its last pass went
wt sync keep onceone unattended pass: fetch trunk and, when it moved, rebase every ready worktree nobody is in and push what finished (--no-push); keep run still works; logged to .git/wt-sync-keep.log; what the job runs, and what cron runs elsewhere
wt sync keep startinstall a launchd job (macOS) that runs wt sync keep once every 30 minutes (--every, --no-push), pushing the way this shell's git does; wt sync keep status for the last pass and the next, wt sync keep stop to remove it; --all, --roots or --profile on all three for many repositories

Put worktrees in their place

wt migrate <work> [<type>/<name>]move a worktree where it belongs, renaming or retyping it on the way (--dry-run, --force/-f); also wt move
wt adopt <path>provision a worktree another tool created (--relocate, --no-build)
wt setup [<from-dir>]provision the worktree you are in (--no-build, --source to name what ran it)
wt remove <work>remove a worktree; delete its branch when merged — on trunk, or as a pull request the cache says landed — keep it when not (--yes, --dry-run, . for the one you are in, --force/-f past a session, a held lock or hidden files, --force=<list> past only those named, --move-to <dir> to move it into a folder instead of deleting it, --keep-superset, --json, --expect)
wt restore <dir>put back a worktree --move-to moved into <dir>, and its branch, from the recovery.json there (--dry-run, --json)
wt purge <dir>delete a worktree --move-to moved into <dir> for good: the refs pinning its commits, then the folder; refuses a folder no removal made or one a removal or restore left half-way, and finishes a purge that stopped (--yes, --dry-run, --json, --expect)
wt sweepdelete local branches already merged into trunk — or whose pull request GitHub merged — and remove the worktrees on such branches that nothing is using; from the main checkout only (--no-fetch, --yes, --dry-run, --json, --expect, --move-to <dir>, --keep-superset), or across repositories with --all, --roots, --profile
wt refs sweepmove backup branches and tags (backup/*, safe-* and the like) that another ref contains, or older than ref_sweep_age, to pins under refs/wt-swept/<run>/; wt refs swept lists runs, wt refs restore <run> puts one back, wt refs purge deletes pins for good (--remote, --only, --run-id, --yes, --dry-run, --json, --expect)

This repository, and this build

wt initcreate this repository's worktree.conf, pinning what wt otherwise detects on each run (--yes to skip the prompts, --force/-f to replace one)
wt config [--shell]the resolved configuration, typed or eval-able
wt config get/set/unset/pathyour own settings, per machine, from anywhere
wt doctorcheck config, required tools and worktree health, and your roots and profiles; --all, --roots, --profile check every repository, wt sync doctor included where it is set up
wt about / wt versionwhich build this is and what changed; the version alone, for scripts
wt completion zshshell completion, including live work names

A bare <work> takes the repository's default type, so wt new thing creates feat_wt/thing. <work> matches exactly, inside this repository, and is what every command that changes or deletes takes; <pattern> (cd, exec, find) is fuzzy and searches your other repositories too.

Many repositories

wt knows where your repositories live: your roots, set per machine.

# ~/.config/wt/config.toml
[roots]
work = "~/src/work"        # a folder: every repository one level down
oss = "~/src/oss"
dotfiles = "~/dotfiles"    # a repository: that one, searched no deeper

[profiles]
api = ["~/src/work/api", "~/src/work/billing"]

wt config set root.work ~/src/work and wt config set profile.api "<dir> <dir>" write them; WT_ROOTS overrides the table. Only repositories with a bin/worktree configuration count.

  • wt repos lists them, and wt cd <repo> goes to one.
  • wt sweep --all and wt sync --all --rebase work across every one, planned a few at a time and asked once; wt sync --all is the overview of each. --roots work or --profile api narrows the run.
  • wt list, wt status and wt doctor take the same flags.
  • wt doctor checks that every root is there and every profile entry is still a repository wt manages, inside a root.

Shell functions

A binary cannot change its caller's directory. These do:

wt_cd <pattern>the same as wt cd, if you prefer the underscore form
wt_exec <pattern> <cmd>…run a command there, in a subshell; your shell stays put
wt_attach [<pattern>] [<session>]the same as wt attach
wt_dir <pattern>print the path (stdout is path-only)
wt_ls [pattern]list worktrees, or show what a pattern matches
wt_rm_meremove the worktree you are standing in

wt cd, wt exec and wt attach are the same functions under a nicer name: wt is itself a shell function that handles those three and passes everything else to the binary. The first two are there because a process cannot change its caller's directory, and wt attach because your shell's claude may be a function of your own, which only your shell can call.

They are thin wrappers over wt find; the matching itself lives in the binary where it is tested. A pattern of . is the worktree you are standing in, and / the repository's main checkout.

Layout

<parent>/<repo>_wt/<type>_wt/<work>          branch: <type>_wt/<work>

code/
├─ myrepo/                                    ← the repo
└─ myrepo_wt/
   ├─ feat_wt/api-tidy/                       branch: feat_wt/api-tidy
   └─ fix_wt/login-crash/                     branch: fix_wt/login-crash

The path tail below <repo>_wt/ is character-for-character the branch name, so the two convert with no rules to remember. Why the layout is this shape, and the rules a tool building on it follows: docs/worktree-conventions.md.

Branches without the _wt

The _wt on a branch is one setting. A repository whose branches should read fix/login-crash says so in its own file:

WORKTREE_BRANCH_SUFFIX=""

and a person who wants that wherever the repository has not decided says so in theirs, once, for every repository they work in:

wt config set branch_suffix ""      # or "-wt", or whatever yours carry

The worktrees do not move for it. The folders are wt's layout, not a name anybody types, and they keep the suffix WORKTREE_TYPE_SUFFIX gives them:

code/
├─ myrepo/                                    ← the repo
└─ myrepo_wt/
   ├─ feat_wt/api-tidy/                       branch: feat/api-tidy
   └─ fix_wt/login-crash/                     branch: fix/login-crash

The repository outranks the person, but only where it said something: a committed file that never mentions the suffix is not a decision, so whoever clones it may still name their own branches. wt config and wt doctor both print which file decided.

What the suffix buys, and what dropping it costs: a suffixed branch says at a glance that wt made it, and wt remove renames an unmerged one out of the prefix to keep its commits (fix_wt/login-crash → login-crash). Without one, any <word>/<name> branch — dependabot/npm-x, an agent's own claude/… — reads as a worktree branch of type <word>. Nothing breaks: every lookup still goes through git worktree list, so only the names change.

What each type is called

feat or feature, docs or doc: the word is a preference, and the type underneath it is not. Name the ones you want called something else, in the repository's file or in your own:

WORKTREE_TYPE_NAMES=(feat=feature docs=doc)      # the repository's naming
wt config set type_names "feat=feature"          # or yours, everywhere
$ wt new feat/login-crash        # or feature/login-crash: the same work
  ~/src/myrepo_wt/feat_wt/login-crash        branch: feature/login-crash

The type is the identity — a fix is a fix whether its branches say fix, fix_wt or bugfix — so it is what the folders carry and what every command reasons in. Only the branch reads the word, which is why renaming one strands no checkout, and why a branch named before the change still resolves.

Everything takes both spellings: wt new, wt migrate, wt path, wt branch, the type read out of a bare name (wt new feature_dev-123), the type read off somebody else's pull request branch, and the completions, which offer the words you would type. The same two-file rule as the suffix applies — the repository where it named its types, you everywhere else — and a pair for a type a repository does not have is a name for somewhere else, so it quietly does not apply there.

Two names for one type, or a name that is already another type, would make a branch ambiguous; the repository's file is told so, and your own pairs are dropped where they would collide.

The type comes from the spec — wt new fix/login-crash — or, for a bare name, is read out of the name itself: wt new fix_dev-123 creates fix_wt/dev-123. A name whose first word is not a type is left whole, and an explicit <type>/<work> always wins. This is what lets the type survive a tool that has nowhere to enter one; Superset mints every branch from a single fixed prefix.

Worktrees in other layouts keep working. Every lookup goes through git worktree list, never the shape of a path, so worktrees made by Superset, by plain git worktree add, or before a repo was migrated all resolve.

Superset's layout

Superset builds <parent>/<repo>_wt/<repo>/<type>_wt/<work> — the canonical path with the repository name repeated, because it joins its per-project worktree base directory with <repo>/<branch>. No setting on either side removes that segment.

wt treats it as a layout of its own rather than a fault. wt setup provisions a Superset workspace where it stands and never moves it, wt doctor passes it, and wt list marks it s. Only ! — a layout nothing owns, such as a pre-migration <repo>-<work> checkout — is a wt migrate candidate, because Superset stores the absolute path of every workspace and a move leaves that workspace pointing at nothing. wt migrate says so before it moves.

Worktrees wt makes show up in Superset

Off until you turn it on. Superset is a per-person choice, so nothing here happens until:

wt config set superset true

After that, wt new, wt checkout and wt pr checkout hand the worktree to Superset once it is provisioned, so one started from the shell appears in the app beside the ones started there. Superset adopts the checkout git already has — it creates nothing and moves nothing, and the workspace points at wt's canonical path.

This needs all three of: the superset CLI, on the PATH or at ~/.superset/bin/superset where the desktop app installs it; the host service running, which means the app is open; and this repository already one of Superset's projects. A machine with no Superset, and a repository Superset does not track, are ordinary states and pass in silence. A Superset that is there and would not answer is one line on stderr and nothing else — wt new still prints only the path on stdout and still exits the same way. wt never starts the app and never creates a project. wt doctor reports which of the three it found, whatever the mode.

Registering a branch that already has a workspace is Superset's own no-op, so running it twice changes nothing. A new workspace makes Superset run the project's setup step if it has one, which for these repos is wt setup --source superset: a second, idempotent provisioning pass on top of the one wt new just did. --no-setup therefore skips registration too.

Removal mirrors it. wt remove and wt sweep, plain or --move-to, delete the worktree's workspace with superset ws delete — only after the directory has left its path and git no longer lists it, because Superset's delete force-removes the checkout. A Superset that is down or errs is one line and the removal stands. wt restore registers the worktree again.

Off again with wt config set superset false, which stops wt running Superset at all. With it on, a repository can still decline with SUPERSET_REGISTER=off in its worktree.conf, and one run can with wt new --no-superset. A repository that asks for it with SUPERSET_REGISTER=on hears about every way the registration can stop, this one included, and wt doctor counts those as problems.

Pull requests

On by default, and inert where it cannot apply. wt reads pull requests through the gh CLI and keeps no credentials of its own: whatever gh is logged in as is what wt sees. Nothing wt does writes to GitHub — the only gh command it runs that changes anything is gh pr checkout, and what that changes is your own checkout.

wt pr list          # every open pull request, and the worktree on it
wt pr checkout 12   # a worktree for that one, provisioned like any other
wt pr checkout      # pick from the open ones
wt pr open          # this worktree's pull request, in the browser

wt pr checkout makes the worktree at the canonical path and provisions it through the same step as wt new, so everything else — provision.sh, the build, the Superset registration if you have opted into it — happens by its own rules. The worktree goes on the pull request's own head branch, put there by gh pr checkout running inside the new worktree, so a push from it updates the pull request. That is what makes a pull request from a fork work: gh points the branch's remote at the fork, which nothing else would get right. Your main checkout is never switched.

The worktree is named pr-<number>-<branch>, under the type its branch suggests — feat_wt/pr-12-residential_fixes — so wt cd pr-12 finds it. A head branch that already follows this repository's convention keeps its own name instead, so a branch wt made lands exactly where wt new would have put it. A pull request whose branch is already in a worktree prints that path and makes nothing.

Given a number, a merged or closed pull request is checked out too: finished work is worth re-reading. GitHub deletes the head branch when a pull request merges, so wt falls back to refs/pull/<number>/head — the same commit, on a branch with no upstream, and it says so.

A local branch of the pull request's name that has diverged from it — commits of its own, and missing some of the pull request's — stops the checkout before anything is made: gh only fast-forwards a branch that exists. That check is for an open pull request from this repository's own branches; a fork's, or a finished one, goes to gh as it is. wt prints how far a

Source 2 files
hooks/register.tsx 1250 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Busy, Input, Outcome, Plan, Review } from '../types'
5
6// The worktree the session stands in, as `wt status --json` reports it: one
7// line above the prompt in git's marks, the part of it that is wrong in the
8// warning colour, and a small dialog (`/wt`, or a click on that line) where
9// the keys act. Nothing is pinned under the prompt: the engine draws a mod's
10// notice there as a warning whatever it says. Every action is a wt command the person pressed or
11// typed; nothing here rebases, pushes or undoes by itself.
12//
13// Two knobs, off unless the person's settings turn them on, each a section of
14// its own that reads nothing of wt's but the worktree's path: `gittree` (open
15// the worktree in gittree) and `reviewStatus` (when this conversation last ran
16// a review skill, and when the person last typed: above the prompt once each
17// is old, and in the dialog).
18
19type Engine = EngineInterface
20type Loose = Record<string, any>
21type Ran = { exitCode: number; stdout: string; stderr: string }
22type Action = 'up' | 'upDiverged' | 'undo' | 'push' | 'resume' | 'fetch' | 'refresh' | 'details' | 'close' | 'dismiss' | 'gittree'
23type Tone = 'suggestion' | 'success' | 'warning' | 'error'
24// One part of the line above the prompt, in the colour that says how it is:
25// none when it is as it should be.
26type Part = { text: string; tone: Tone | 'dim' | undefined; isOwn?: true }
27type Note = Part
28// What the dialog shows for a worktree: what is not as it should be, and the
29// keys, the one that moves the branch on a row of its own.
30type Detail = { notes: Note[]; primary: Action | null; hint: string; follow: Action[]; other: Action[] }
31
32const PANE = 'wt'
33const TEN_MINUTES = 600_000
34const PULSE_MS = 10_000
35// Past this, what is shown about trunk says how old it is.
36const STALE_MS = 60 * 60_000
37
38const plan = atom({ plugin: 'wt', key: 'plan' } as const, null)
39const busy = atom({ plugin: 'wt', key: 'busy' } as const, 'idle')
40const outcome = atom({ plugin: 'wt', key: 'outcome' } as const, null)
41const inputs = atom({ plugin: 'wt', key: 'inputs' } as const, 0)
42const lastInput = atom({ plugin: 'wt', key: 'lastInput' } as const, null)
43const lastReview = atom({ plugin: 'wt', key: 'lastReview' } as const, null)
44const firstInput = atom({ plugin: 'wt', key: 'firstInput' } as const, null)
45
46// The knobs as the person's settings have them; `register` fills them in.
47// A skill that performs a review: "review" in its name, and not the one about
48// receiving one.
49const REVIEW_SKILLS = '^(?!.*receiving).*review'
50const knobs = {
51  hasGittree: false,
52  hasReview: false,
53  reviewSkills: new RegExp(REVIEW_SKILLS, 'i'),
54  // How old the last input and the last review are before the line above
55  // the prompt says them.
56  inputAfterMs: 5 * 60_000,
57  reviewAfterMs: 30 * 60_000,
58}
59
60const LABEL: Record<Action, string> = {
61  up: 'wt up',
62  upDiverged: 'wt up over the divergence',
63  undo: 'undo',
64  push: 'push…',
65  resume: 'resume',
66  fetch: 'fetch',
67  refresh: 'refresh',
68  details: '/wt',
69  close: 'close',
70  dismiss: 'dismiss',
71  gittree: 'gittree',
72}
73// On the line above the prompt gittree is a word to click: the arrow says it
74// opens somewhere else.
75const GITTREE_OUT = 'gittree ↗'
76// The dialog's keys are letters: it holds the keyboard while it is open.
77const HOTKEY: Partial<Record<Action, string>> = {
78  up: 'u',
79  upDiverged: 'v',
80  undo: 'z',
81  push: 'p',
82  resume: 'c',
83  fetch: 'f',
84  refresh: 'r',
85  gittree: 'g',
86  dismiss: 'd',
87  close: 'q',
88}
89// Said beside the dialog's close key: how it goes away while it holds the
90// keyboard, and how it gets the keyboard when it opened without it.
91const CLOSING = 'Esc closes too'
92const KEYLESS = 'click here, or ctrl+x then tab, for the keys'
93const RUNNING: Record<Busy, string> = {
94  idle: '',
95  up: 'wt up is running: fetch, fast-forward, rebase…',
96  undo: 'wt sync undo is running…',
97  resume: 'wt sync resume is running…',
98}
99
100const parse = (text: string): Loose | null => {
101  try {
102    const value: unknown = JSON.parse(text)
103
104    return value !== null && typeof value === 'object' ? (value as Loose) : null
105  } catch {
106    return null
107  }
108}
109
110const short = (sha: unknown): string => (typeof sha === 'string' && sha !== '' ? sha.slice(0, 7) : '?')
111const lastLine = (text: string): string => text.trim().split('\n').pop()?.trim() ?? ''
112
113// How long ago, in the fewest words that stay true.
114const ago = (now: number, at: number): string => {
115  const minutes = Math.max(0, Math.round((now - at) / 60_000))
116
117  if (minutes < 1) {
118    return 'just now'
119  }
120
121  return minutes < 90
122    ? `${minutes} min ago`
123    : minutes < 2_880
124      ? `${Math.round(minutes / 60)}h ago`
125      : `${Math.round(minutes / 1_440)} days ago`
126}
127
128const inputsSince = (count: number): string => (count === 0 ? 'no input since' : count === 1 ? '1 input since' : `${count} inputs since`)
129
130// What the review knob puts on the line above the prompt: the last input and
131// the last review, each only once it is older than its limit. A conversation
132// no review has run in is as old as its first input.
133const lingerOf = (typed: Input | null, review: Review | null, first: number | null, count: number, now: number): string => {
134  const parts: string[] = []
135
136  if (typed !== null && now - typed.at >= knobs.inputAfterMs) {
137    parts.push(`last input ${ago(now, typed.at)}: “${typed.head}”`)
138  }
139
140  if (review !== null) {
141    if (now - review.at >= knobs.reviewAfterMs) {
142      parts.push(`reviewed ${ago(now, review.at)}, ${inputsSince(count - review.inputs)}`)
143    }
144  } else if (first !== null && now - first >= knobs.reviewAfterMs) {
145    parts.push(`not reviewed yet, ${count === 1 ? '1 input' : `${count} inputs`}`)
146  }
147
148  return parts.join(' · ')
149}
150
151const planOf = (stdout: string): Plan | null => {
152  const s = parse(stdout)
153
154  if (s === null || s.command !== 'status' || s.worktree === null || typeof s.worktree !== 'object') {
155    return null
156  }
157
158  const w: Loose = s.worktree
159  const own: Loose | null = w.ownRemote ?? null
160  const stack: Loose[] = Array.isArray(s.stack) ? s.stack : []
161  const sessions: Loose[] = Array.isArray(s.sessions) ? s.sessions : []
162
163  return {
164    work: String(w.work),
165    branch: typeof w.branch === 'string' && w.branch !== '' ? w.branch : null,
166    path: String(w.path),
167    isMain: w.isMain === true,
168    trunk: String(s.trunk ?? ''),
169    trunkRef: String(s.trunkRef ?? s.trunk ?? ''),
170    trunkFetchedAt: s.trunkRefUpdatedAt ?? null,
171    gitDir: null,
172    fetchedAt: null,
173    trunkRemoteAhead: Number(s.trunkSync?.remoteAhead ?? 0),
174    behind: typeof w.behind === 'number' ? w.behind : null,
175    ahead: typeof w.ahead === 'number' ? w.ahead : null,
176    tree: String(w.state ?? 'clean'),
177    ownRemote:
178      own === null
179        ? null
180        : {
181            ref: own.ref ?? null,
182            state: String(own.state),
183            ahead: own.ahead ?? null,
184            behind: own.behind ?? null,
185            blocks: own.blocks ?? null,
186          },
187    upEligible: s.upEligible === true,
188    upIneligibleCode: s.upIneligibleCode ?? null,
189    upIneligibleReason: s.upIneligibleReason ?? null,
190    token: s.token ?? null,
191    stack: stack.map(member => String(member.work)).filter(name => name !== String(w.work)),
192    sessions: sessions.map(one => ({ name: String(one.name), kind: String(one.kind), state: String(one.state) })),
193    sessionsError: typeof s.sessionsError === 'string' ? s.sessionsError : null,
194    schemaVersion: String(s.schemaVersion ?? ''),
195  }
196}
197
198// The remote a ref lives on: "origin" of "origin/feat/login".
199const remoteOf = (ref: string): string => ref.split('/')[0] ?? ref
200
201// The branch against trunk in marks: ↓ what trunk has that it lacks, ↑ its own
202// on top, ✓ when it lacks nothing, ? when wt could not count.
203const trunkMarks = (p: Plan): string =>
204  p.behind === null ? '?' : `${p.behind > 0 ? `↓${p.behind}` : '✓'}${p.ahead !== null && p.ahead > 0 ? `↑${p.ahead}` : ''}`
205
206// The branch against its own remote in the same marks, then the remote's
207// name; undefined when there is nothing to say (in sync, or no ref of its own).
208const ownMarks = (p: Plan): string | undefined => {
209  const own = p.ownRemote
210
211  if (own === null || own.ref === null) {
212    return undefined
213  }
214
215  const remote = remoteOf(own.ref)
216
217  switch (own.state) {
218    case 'rebased':
219      return `↑ ${remote} (rebased)`
220    case 'ahead':
221      return `↑${own.ahead ?? ''} ${remote}`
222    case 'behind':
223      return `↓${own.behind ?? ''} ${remote}`
224    case 'diverged':
225      return `↓${own.behind ?? ''}↑${own.ahead ?? ''} ${remote} diverged`
226    case 'gone':
227      return `✗ ${remote}`
228    default:
229      return undefined
230  }
231}
232
233// The same in words, for the dialog.
234const ownWords = (p: Plan): string | undefined => {
235  const own = p.ownRemote
236
237  if (own === null || own.ref === null) {
238    return undefined
239  }
240
241  switch (own.state) {
242    case 'rebased':
243      return `rebased and not pushed to ${own.ref} yet`
244    case 'ahead':
245      return `${own.ahead} to push to ${own.ref}`
246    case 'behind':
247      return `${own.behind} behind ${own.ref}`
248    case 'diverged':
249      return `diverged from ${own.ref}: ${own.ahead} here, ${own.behind} there`
250    case 'gone':
251      return `${own.ref} is gone`
252    case 'unknown':
253      return `${own.ref} could not be checked`
254    default:
255      return undefined
256  }
257}
258
259// How old what is known about trunk is, once that is worth saying: nothing
260// here fetches, so a count against trunk is as of the last fetch anyone made.
261const staleWords = (p: Plan, now: number): string | undefined =>
262  p.fetchedAt !== null && now - p.fetchedAt > STALE_MS ? `fetched ${ago(now, p.fetchedAt)}` : undefined
263
264const ownBehind = (p: Plan): number => (p.ownRemote?.state === 'behind' ? (p.ownRemote.behind ?? 0) : 0)
265const behindTrunk = (p: Plan): number => p.behind ?? 0
266
267// Why `wt up` would not run on it as it stands, or undefined when it would.
268// Changes in the tree are not judged here: status counts untracked files and
269// the run refuses tracked changes only, so the run is the one to say.
270const obstacle = (p: Plan): string | undefined => {
271  if (!p.upEligible) {
272    return p.upIneligibleReason ?? p.upIneligibleCode ?? 'wt would refuse it'
273  }
274
275  const working = p.sessions.filter(one => one.state === 'busy').map(one => one.name)
276
277  return working.length > 0 ? `another session is working in it (${working.join(', ')})` : undefined
278}
279
280const hasWork = (p: Plan): boolean => behindTrunk(p) > 0 || ownBehind(p) > 0
281const canUp = (p: Plan): boolean => !p.isMain && p.token !== null && hasWork(p) && obstacle(p) === undefined
282
283// The worktree as the parts of one line: against trunk, against its own
284// remote, a rebase waiting, a run wt would refuse, how old the view of trunk
285// is. What wt up would do something about is in the accent colour, and only
286// while wt up would run; what is wrong is in the warning colour, and comes
287// before what is dim, so that a narrow terminal cuts the dim part first.
288const standing = (p: Plan, now: number): Part[] => {
289  const parts: Part[] = []
290
291  if (p.isMain) {
292    parts.push({ text: p.trunkRemoteAhead > 0 ? `main checkout ↓${p.trunkRemoteAhead} ${remoteOf(p.trunkRef)}` : 'main checkout', tone: undefined })
293  } else {
294    const own = ownMarks(p)
295    const state = p.ownRemote?.state
296    const isHandedOver = p.upIneligibleCode === 'handedOver'
297    const wouldRun = !isHandedOver && obstacle(p) === undefined
298    // Uncommitted changes, as a git prompt marks them, after the last mark.
299    const dirty = !isHandedOver && p.tree === 'dirty' ? ' *' : ''
300    parts.push({
301      text: `${p.work} ${trunkMarks(p)} ${p.trunk}${own === undefined ? dirty : ''}`,
302      tone: wouldRun && behindTrunk(p) > 0 ? 'suggestion' : undefined,
303    })
304
305    if (own !== undefined) {
306      parts.push({
307        text: `${own}${dirty}`,
308        tone: state === 'diverged' || state === 'gone' ? 'warning' : wouldRun && state === 'behind' ? 'suggestion' : undefined,
309        isOwn: true,
310      })
311    }
312
313    if (isHandedOver) {
314      parts.push({ text: 'REBASE STOPPED', tone: 'warning' })
315    } else if (!wouldRun && hasWork(p)) {
316      parts.push({ text: 'wt up would refuse', tone: 'warning' })
317    }
318  }
319
320  const stale = staleWords(p, now)
321
322  if (stale !== undefined) {
323    parts.push({ text: stale, tone: 'dim' })
324  }
325
326  return parts
327}
328
329// The same as text, for a command's answer.
330const statusText = (p: Plan | null, now: number): string | undefined =>
331  p === null
332    ? undefined
333    : standing(p, now)
334        .map(part => part.text)
335        .join(' · ')
336
337// The line above the prompt, or null outside a worktree of wt's: a run under
338// way, what the last run came to, or how the worktree stands.
339const lineOf = (p: Plan | null, last: Outcome | null, state: Busy, now: number): Part[] | null => {
340  if (state !== 'idle') {
341    return [{ text: RUNNING[state], tone: 'suggestion' }]
342  }
343
344  // An outcome is shown, and acted on, only for the worktree it was run on.
345  // It never hides what is wrong with the worktree as it stands now, and one
346  // that succeeded is followed by what is left to push.
347  if (last !== null && p !== null && last.path === p.path) {
348    return [
349      { text: last.brief, tone: last.isOk ? 'success' : 'error' },
350      ...standing(p, now).filter(part => part.tone === 'warning' || (last.isOk && part.isOwn === true)),
351    ]
352  }
353
354  if (p === null || p.isMain) {
355    return null
356  }
357
358  return standing(p, now)
359}
360
361// What the dialog shows for the worktree; `mine` is the last outcome when it
362// is this worktree's.
363const detailOf = (p: Plan, mine: Outcome | null): Detail => {
364  const notes: Note[] = []
365  const isHandedOver = p.upIneligibleCode === 'handedOver'
366  const own = ownWords(p)
367  const why = p.isMain || isHandedOver ? undefined : obstacle(p)
368
369  if (p.isMain && p.trunkRemoteAhead > 0) {
370    notes.push({ text: `local ${p.trunk} is ${p.trunkRemoteAhead} behind ${p.trunkRef}`, tone: 'warning' })
371  }
372
373  if (own !== undefined) {
374    const state = p.ownRemote?.state
375    notes.push({ text: own, tone: state === 'diverged' || state === 'gone' ? 'error' : state === 'behind' ? 'warning' : undefined })
376  }
377
378  if (!p.isMain && p.tree !== 'clean') {
379    notes.push({ text: p.tree === 'dirty' ? '* uncommitted changes (untracked files count)' : `the tree is ${p.tree}`, tone: 'warning' })
380  }
381
382  if (p.stack.length > 0) {
383    notes.push({ text: `moves with ${p.stack.join(', ')}`, tone: undefined })
384  }
385
386  if (p.sessionsError !== null) {
387    notes.push({ text: `sessions could not be listed: ${p.sessionsError}`, tone: 'warning' })
388  } else if (p.sessions.length > 0) {
389    notes.push({ text: `sessions: ${p.sessions.map(one => `${one.name} (${one.kind}, ${one.state})`).join(', ')}`, tone: undefined })
390  }
391
392  // Why there is no key for wt up, for someone who looked for it: said only
393  // when wt counted, and the count is none.
394  if (!p.isMain && !isHandedOver && p.behind === 0 && ownBehind(p) === 0 && mine === null && p.upIneligibleCode !== 'ownRemoteDiverged') {
395    notes.push({ text: 'not behind: wt up has nothing to do', tone: 'dim' })
396  }
397
398  if (isHandedOver) {
399    notes.push({ text: 'a rebase stopped at a conflict and is yours: resolve it in the worktree, then resume; or undo it', tone: 'warning' })
400  } else if (why !== undefined && (hasWork(p) || p.upIneligibleCode === 'ownRemoteDiverged')) {
401    notes.push({ text: `wt up would refuse: ${why}`, tone: 'warning' })
402  }
403
404  let primary: Action | null = null
405  let hint = ''
406  const follow: Action[] = []
407
408  if (isHandedOver) {
409    primary = 'resume'
410    hint = 'once the conflict is resolved'
411    follow.push('undo')
412  } else if (canUp(p)) {
413    primary = 'up'
414    hint = `fetches, then rebases onto ${p.trunkRef}; never pushes`
415  } else if (p.upIneligibleCode === 'ownRemoteDiverged' && p.token !== null) {
416    primary = 'upDiverged'
417    hint = 'rebases although the branch diverged from its own remote'
418  }
419
420  if (mine !== null) {
421    if (mine.pushCommand !== null) {
422      follow.push('push')
423    }
424
425    if (mine.canUndo && !follow.includes('undo')) {
426      follow.push('undo')
427    }
428
429    follow.push('dismiss')
430  }
431
432  return { notes, primary, hint, follow, other: knobs.hasGittree ? ['fetch', 'gittree', 'refresh'] : ['fetch', 'refresh'] }
433}
434
435const failed = (action: Outcome['action'], p: Plan, text: string, brief: string): Outcome => ({
436  action,
437  work: p.work,
438  path: p.path,
439  isOk: false,
440  text,
441  brief: `✗ ${brief}`,
442  pushCommand: null,
443  canUndo: false,
444})
445
446// The results that mean a participant is where the run meant to leave it.
447const SETTLED: readonly string[] = ['rebased', 'fastForwarded', 'skipped', 'undone']
448const RESULT_WORDS: Record<string, string> = {
449  handedOver: 'stopped at a conflict',
450  notRun: 'was not run',
451  refused: 'was refused',
452  restored: 'failed and was put back',
453  needsRecovery: 'needs recovery',
454  interrupted: 'was interrupted',
455  rebasedStepFailed: 'was rebased, but a step after it failed',
456}
457
458// One wt run's JSON (`wt up`, `wt sync resume`, `wt sync undo`) as the sentence
459// the dialog shows, the few words the line above the prompt shows, and what
460// can follow it. A run moves the whole stack, so another member that did not
461// settle makes the outcome a failure and takes the push away: the person
462// sorts the stack out first.
463const outcomeOf = (action: Outcome['action'], ran: Ran, p: Plan): Outcome => {
464  const result = parse(ran.stdout)
465
466  if (result === null) {
467    return failed(action, p, lastLine(ran.stderr) || `wt exited ${ran.exitCode} and printed no result`, `wt ${action} failed for ${p.work}`)
468  }
469
470  const list: Loose[] = Array.isArray(result.worktrees) ? result.worktrees : []
471  const mine = list.find(one => one.path === p.path) ?? list.find(one => one.work === p.work)
472  const error = typeof result.error === 'string' && result.error !== '' ? result.error : null
473  const reasonOf = (one: Loose): string => (typeof one.reason === 'string' && one.reason !== '' ? `: ${one.reason}` : '')
474
475  // A run wt refused before it touched anything lists its participants as
476  // not run and says why at the top.
477  if (mine === undefined || String(mine.result) === 'notRun') {
478    const why = error ?? (mine !== undefined && reasonOf(mine) !== '' ? reasonOf(mine).slice(2) : lastLine(ran.stderr))
479
480    return failed(action, p, `wt did not touch ${p.work}${why === '' ? '' : `: ${why}`}`, `wt did not touch ${p.work}`)
481  }
482
483  const unsettled = list.filter(one => one !== mine && !SETTLED.includes(String(one.result)))
484  const also =
485    unsettled.length === 0
486      ? ''
487      : ` But in its stack ${unsettled.map(one => `${one.work} ${RESULT_WORDS[String(one.result)] ?? one.result}${reasonOf(one)}`).join('; ')}.`
488  const range = `${short(mine.before)} → ${short(mine.after)}`
489  const sync: Loose | null = mine.ownRemoteSync ?? null
490  const forwarded = sync?.fastForwarded === true ? `, after a fast-forward to ${sync.ref}` : ''
491  const pushCommand = unsettled.length === 0 && Array.isArray(mine.pushCommand) ? mine.pushCommand.map(String) : null
492  const reason = reasonOf(mine)
493  const recovery = typeof mine.recovery === 'string' && mine.recovery !== '' ? ` ${mine.recovery}` : ''
494  const onto = String(result.trunkRef ?? p.trunkRef)
495  const done = (text: string, brief: string, canUndo: boolean): Outcome => ({
496    action,
497    work: p.work,
498    path: p.path,
499    isOk: unsettled.length === 0,
500    text: text + also,
501    brief: unsettled.length === 0 ? `✓ ${brief}` : `✗ ${brief}, but its stack did not settle`,
502    pushCommand,
503    canUndo,
504  })
505
506  switch (String(mine.result)) {
507    case 'rebased':
508      return done(`${p.work} rebased onto ${onto}${forwarded} (${range}). Undo puts it back.`, `${p.work} rebased onto ${onto}`, true)
509    case 'fastForwarded':
510      return done(
511        `${p.work} fast-forwarded to ${sync?.ref ?? 'its own remote'} (${range}); nothing was left to rebase.`,
512        `${p.work} fast-forwarded`,
513        true,
514      )
515    case 'undone':
516      return done(`${p.work} is back where it was (${range}).`, `${p.work} is back where it was`, false)
517    case 'skipped':
518      return done(`${p.work}: nothing to do${reason}`, `${p.work}: nothing to do`, false)
519    case 'handedOver':
520      return {
521        ...failed(
522          action,
523          p,
524          `${p.work}: the rebase stopped at a conflict and is yours now. Resolve it in the worktree, then resume; or undo it.${also}`,
525          `${p.work}: the rebase stopped at a conflict`,
526        ),
527        canUndo: true,
528      }
529    case 'refused':
530      return failed(action, p, `wt refused ${p.work}${reason}${also}`, `wt refused ${p.work}`)
531    case 'restored':
532      return failed(action, p, `${p.work}: the rebase failed and wt put the branch back${reason}.${also}`, `${p.work}: the rebase failed, the branch is back`)
533    case 'rebasedStepFailed':
534    case 'interrupted':
535      return {
536        ...failed(action, p, `${p.work} ${RESULT_WORDS[String(mine.result)]}${reason}.${recovery}${also}`, `${p.work} ${RESULT_WORDS[String(mine.result)]}`),
537        canUndo: true,
538      }
539    default:
540      return failed(action, p, `${p.work}: ${mine.result}${reason}.${recovery}${also}`, `${p.work}: ${mine.result}`)
541  }
542}
543
544let isChecking = false
545let wantsAnother = false
546// Whether a model turn runs: set by turn.start and turn.complete, and by the
547// band wherever it is drawn. wt moves nothing under one.
548let isTurnRunning = false
549// The move under way, if any: one at a time, and a prompt sent meanwhile
550// waits for it rather than start a turn on a branch in mid-rebase.
551let moving: Promise<void> | null = null
552// The fetch the person asked for, while it runs: a move waits for it.
553let fetching: Promise<void> | null = null
554
555// An argument as a shell reads it back unchanged.
556const quoted = (arg: string): string => (/^[A-Za-z0-9_/.:=@%+,-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`)
557
558// When the repository last fetched anything: the age of FETCH_HEAD in its git
559// directory, which every worktree of it shares. Read, never caused.
560const fetched = async ($: Engine, path: string): Promise<Pick<Plan, 'gitDir' | 'fetchedAt'>> => {
561  try {
562    const ran = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: path, timeoutMs: 5_000 })
563    const gitDir = ran.exitCode === 0 ? ran.stdout.trim() : ''
564
565    if (gitDir === '') {
566      return { gitDir: null, fetchedAt: null }
567    }
568
569    try {
570      return { gitDir, fetchedAt: (await $.fs.stat(`${gitDir}/FETCH_HEAD`)).mtimeMs }
571    } catch {
572      // Never fetched.
573      return { gitDir, fetchedAt: null }
574    }
575  } catch {
576    return { gitDir: null, fetchedAt: null }
577  }
578}
579
580// Reads the worktree again. One `wt status` at a time: a second ask while
581// one runs is served by one more run after it.
582const refresh = async ($: Engine): Promise<Plan | null> => {
583  if (isChecking) {
584    wantsAnother = true
585
586    return read($, plan)
587  }
588
589  isChecking = true
590
591  try {
592    // Read before the worktree is, so that what is read of the worktree is
593    // from after the run it came from.
594    const shown = await read($, outcome)
595    let found: Plan | null = null
596
597    try {
598      found = planOf((await $.process.run(['wt', 'status', '--json'], { timeoutMs: 20_000 })).stdout)
599    } catch {
600      // wt is not installed, or did not answer in time: nothing to show.
601    }
602
603    if (found !== null) {
604      found = { ...found, ...(await fetched($, found.path)) }
605    }
606
607    const held = await read($, plan)
608
609    if (JSON.stringify(held) !== JSON.stringify(found)) {
610      await update($, plan, () => found)
611    }
612
613    // A run that succeeded has nothing left to say once its branch is pushed.
614    if (
615      found !== null &&
616      shown !== null &&
617      shown.isOk &&
618      shown.pushCommand !== null &&
619      shown.path === found.path &&
620      found.ownRemote?.state === 'inSync' &&
621      JSON.stringify(await read($, outcome)) === JSON.stringify(shown)
622    ) {
623      await update($, outcome, () => null)
624    }
625
626    return found
627  } finally {
628    isChecking = false
629
630    if (wantsAnother) {
631      wantsAnother = false
632      refresh($).catch(() => undefined)
633    }
634  }
635}
636
637// The refs the worktree's standing rests on, as they stood at the last pulse.
638let pulse: string | null = null
639
640// Keeps what is shown current for someone coming back to this terminal from
641// another: every few seconds one cheap read of the branch, trunk and remote
642// refs, and a full `wt status` only when one of them has moved. A fetch, a
643// commit or a rebase made anywhere else shows here within one pulse.
644const beat = async ($: Engine): Promise<void> => {
645  const p = await read($, plan)
646
647  if (p === null || moving !== null || isChecking) {
648    return
649  }
650
651  const refs = [`refs/heads/${p.trunk}`, `refs/remotes/${p.trunkRef}`]
652
653  if (p.branch !== null) {
654    refs.push(`refs/heads/${p.branch}`)
655  }
656
657  if (p.ownRemote !== null && p.ownRemote.ref !== null) {
658    refs.push(`refs/remotes/${p.ownRemote.ref}`)
659  }
660
661  const ran = await $.process.run(['git', 'for-each-ref', '--format=%(refname) %(objectname)', ...refs], { cwd: p.path, timeoutMs: 5_000 })
662
663  if (ran.exitCode !== 0) {
664    return
665  }
666
667  // A fetch that moved no ref of ours still makes what is shown newer.
668  let fetchedAt = 0
669
670  if (p.gitDir !== null) {
671    try {
672      fetchedAt = (await $.fs.stat(`${p.gitDir}/FETCH_HEAD`)).mtimeMs
673    } catch {
674      // Never fetched.
675    }
676  }
677
678  const before = pulse
679  pulse = `${p.path}\n${fetchedAt}\n${ran.stdout}`
680
681  if (before !== null && before !== pulse) {
682    await refresh($)
683  }
684}
685
686// Tells the model what the person just did to the branch under it.
687const tell = async ($: Engine, text: string): Promise<void> => {
688  try {
689    await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
690  } catch {
691    // A run no plugin may shape: the band still says it.
692  }
693}
694
695// The rows the dialog needs for what it has to show.
696const rowsOf = async ($: Engine): Promise<number> => {
697  const p = await read($, plan)
698  const last = await read($, outcome)
699
700  if (p === null) {
701    return 5
702  }
703
704  const mine = last !== null && last.path === p.path ? last : null
705  const detail = detailOf(p, mine)
706
707  return (
708    1 +
709    detail.notes.length +
710    (mine !== null ? 3 : 0) +
711    1 +
712    (detail.primary !== null ? 1 : 0) +
713    (detail.follow.length > 0 ? 1 : 0) +
714    1 +
715    (knobs.hasReview ? 3 : 0) +
716    2
717  )
718}
719
720// Opens the dialog with the keyboard in it, so its letter keys answer and Esc
721// closes it, as tall as what it has to show. With text in the prompt the keys
722// stay there; Esc closes it once the prompt is empty.
723const openPane = async ($: Engine): Promise<void> => {
724  await $.ui.open({ id: PANE, title: 'wt', focus: true, closeOnEscape: true, rows: await rowsOf($) })
725}
726
727// Whether the dialog is the pane on show. One open behind another pane, or
728// waiting undrawn, is not: asked for, that one is raised.
729const isOnShow = async ($: Engine): Promise<boolean> => (await $.ui.panes()).some(one => one.id === PANE && one.isShown && one.isPlaced)
730
731// Asks again for the rows an open dialog needs, now that a run has left a
732// result and its keys to show: the rows, and never the keyboard, so that a
733// letter typed at the prompt is not taken for one of its keys. Whether it is
734// still on show is asked last, so that one just closed stays closed.
735const resize = async ($: Engine): Promise<void> => {
736  try {
737    const rows = await rowsOf($)
738
739    if (await isOnShow($)) {
740      await $.ui.open({ id: PANE, title: 'wt', closeOnEscape: true, rows })
741    }
742  } catch {
743    // The dialog scrolls instead.
744  }
745}
746
747// Runs one action for the worktree. The moves leave an outcome to show.
748const act = async ($: Engine, action: Action): Promise<string> => {
749  if (action === 'details') {
750    // A click on the line toggles, as bare /wt does: on show, it closes.
751    if (await isOnShow($)) {
752      await $.ui.close({ id: PANE })
753
754      return 'wt pane closed.'
755    }
756
757    await openPane($)
758
759    return 'wt pane opened.'
760  }
761
762  if (action === 'close') {
763    await $.ui.close({ id: PANE })
764
765    return 'wt pane closed.'
766  }
767
768  const p = await read($, plan)
769  const last = await read($, outcome)
770
771  if (action === 'dismiss') {
772    // What the last run came to goes away; what is still to do comes back.
773    await update($, outcome, () => null)
774
775    return 'Dismissed.'
776  }
777
778  if (action === 'refresh') {
779    return statusText(await refresh($), await $.clock.now()) ?? 'No wt worktree here.'
780  }
781
782  if (action === 'gittree') {
783    // gittree shows the repository and worktree holding the path, launching
784    // the app or bringing it forward; outside a wt worktree, the session's own.
785    let text: string
786
787    try {
788      const ran = await $.process.run(['gittree', p?.path ?? '.'], { timeoutMs: 20_000 })
789      text = ran.exitCode === 0 ? `Opened ${p?.work ?? 'this folder'} in gittree.` : `gittree: ${lastLine(ran.stderr) || `exited ${ran.exitCode}`}`
790    } catch (error) {
791      text = `gittree did not start: ${error instanceof Error ? error.message : String(error)}`
792    }
793
794    $.ui.toast(text)
795
796    return text
797  }
798
799  if (p === null) {
800    return 'No wt worktree here.'
801  }
802
803  if (action === 'fetch') {
804    // wt up fetches too, and two fetches at once clash over a ref's lock.
805    if (moving !== null) {
806      return 'wt is already running here.'
807    }
808
809    if (fetching !== null) {
810      return 'A fetch is already running here.'
811    }
812
813    let text: string
814    let release = (): void => undefined
815
816    fetching = new Promise<void>(resolve => {
817      release = resolve
818    })
819
820    try {
821      const ran = await $.process.run(['git', 'fetch', '--quiet', 'origin', p.trunk], { cwd: p.path, timeoutMs: 120_000 })
822      text = ran.exitCode === 0 ? `Fetched ${p.trunkRef}.` : `The fetch failed: ${lastLine(ran.stderr) || `git exited ${ran.exitCode}`}`
823    } catch (error) {
824      text = `The fetch did not finish: ${error instanceof Error ? error.message : String(error)}`
825    } finally {
826      fetching = null
827      release()
828    }
829
830    await refresh($)
831    $.ui.toast(text)
832
833    return text
834  }
835
836  if (action === 'push') {
837    // The push is the person's to run: it goes in the prompt as a shell
838    // line, so they see it, press Enter, and the repository's git hooks run.
839    if (last === null || last.pushCommand === null || last.path !== p.path) {
840      return 'Nothing to push from here: run wt up first.'
841    }
842
843    const line = `! ${last.pushCommand.map(quoted).join(' ')}`
844    let isFilled = false
845
846    // The prompt takes nothing while a dialog holds the keys, and the push is
847    // run from the prompt: the dialog closes first.
848    try {
849      await $.ui.close({ id: PANE })
850      isFilled = (await $.prompt.fill({ text: line })).isFilled
851    } catch {
852      // Said below, with the command.
853    }
854
855    const text = isFilled ? 'The push is in the prompt: press Enter to run it.' : `The prompt did not take the push. Run it yourself: ${line}`
856    $.ui.toast(text)
857
858    return text
859  }
860
861  if (moving !== null) {
862    return 'wt is already running here.'
863  }
864
865  if (isTurnRunning) {
866    const text = `${LABEL[action]}: a turn is running in ${p.work}. wt can move it once the turn is done.`
867    $.ui.toast(text)
868
869    return text
870  }
871
872  let argv: string[]
873
874  switch (action) {
875    case 'up':
876    case 'upDiverged':
877      if (p.token === null) {
878        return `wt up would refuse ${p.work}: ${obstacle(p) ?? 'it has no plan'}`
879      }
880
881      argv = ['wt', 'up', '--yes', '--no-push', '--json', '--expect', p.token]
882
883      if (action === 'upDiverged') {
884        argv.push('--allow-diverged')
885      }
886
887      break
888    case 'undo':
889      // "." is the worktree the command runs in: a bare work name can be two.
890      argv = ['wt', 'sync', 'undo', '.', '--yes', '--json']
891      break
892    case 'resume':
893      argv = ['wt', 'sync', 'resume', '.', '--yes', '--no-push', '--json']
894      break
895  }
896
897  const kind: Outcome['action'] = action === 'upDiverged' ? 'up' : action
898  let release = (): void => undefined
899  let next: Outcome
900
901  // Taken before the first await, so a second press finds it.
902  moving = new Promise<void>(resolve => {
903    release = resolve
904  })
905
906  try {
907    await update($, busy, () => kind)
908
909    if (fetching !== null) {
910      await fetching
911    }
912
913    try {
914      next = outcomeOf(kind, await $.process.run(argv, { cwd: p.path, timeoutMs: TEN_MINUTES }), p)
915    } catch (error) {
916      next = failed(
917        kind,
918        p,
919        `${argv.slice(0, 3).join(' ')} did not finish: ${error instanceof Error ? error.message : String(error)}`,
920        `${argv.slice(0, 3).join(' ')} did not finish`,
921      )
922    }
923
924    await update($, outcome, () => next)
925  } finally {
926    await update($, busy, () => 'idle').catch(() => undefined)
927    moving = null
928    release()
929  }
930
931  await refresh($)
932  await resize($)
933  await tell(
934    $,
935    `[wt plugin] The person ran ${argv.slice(0, 3).join(' ')} on the worktree ${p.work} (${p.branch ?? 'detached'}). ${next.text}` +
936      (next.isOk ? ' Its files and commits may have changed: read again before editing.' : ''),
937  )
938
939  return next.text
940}
941
942export const register: Register = (on, options) => {
943  knobs.hasGittree = options.gittree === true
944  knobs.hasReview = options.reviewStatus === true
945  knobs.inputAfterMs = (typeof options.inputAfterMinutes === 'number' && options.inputAfterMinutes >= 0 ? options.inputAfterMinutes : 5) * 60_000
946  knobs.reviewAfterMs = (typeof options.reviewAfterMinutes === 'number' && options.reviewAfterMinutes >= 0 ? options.reviewAfterMinutes : 30) * 60_000
947
948  try {
949    knobs.reviewSkills = new RegExp(typeof options.reviewSkills === 'string' && options.reviewSkills !== '' ? options.reviewSkills : REVIEW_SKILLS, 'i')
950  } catch {
951    knobs.reviewSkills = new RegExp(REVIEW_SKILLS, 'i')
952  }
953
954  on('session.start', async ($, e, next) => {
955    await $.command.register({
956      name: 'wt',
957      description: 'The worktree you are in: its place against trunk, and wt up, undo, push from here',
958      argumentHint: knobs.hasGittree
959        ? '[up|undo|push|resume|fetch|refresh|dismiss|close|gittree]'
960        : '[up|undo|push|resume|fetch|refresh|dismiss|close]',
961    })
962    // A reload ends whatever this module was waiting on: nothing runs now.
963    await update($, busy, () => 'idle')
964    // Nothing of this mod's is pinned under the prompt; an older load's goes.
965    $.ui.status(undefined)
966    refresh($).catch(() => undefined)
967    // What is shown counts time: a minute later it says a minute more.
968    $.clock.every(60_000, () => {
969      $.ui.invalidate('ui.render')
970    })
971    $.clock.every(PULSE_MS, () => {
972      beat($).catch(() => undefined)
973    })
974    // What no ref shows (a file edited from outside, a session come or gone)
975    // is caught by the full read.
976    $.clock.every(180_000, () => {
977      refresh($).catch(() => undefined)
978    })
979
980    return next(e)
981  })
982
983  // Whatever a turn did to the checkout shows once it is done, and after each
984  // shell command that could have moved a branch.
985  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
986    const ran = await next(e)
987
988    if (/\b(git|wt|gh)\b/.test(e.command)) {
989      refresh($).catch(() => undefined)
990    }
991
992    return ran
993  }).catch(($, e, next) => next(e))
994
995  on('turn.start', ($, e, next) => {
996    isTurnRunning = true
997
998    return next(e)
999  })
1000
1001  on('turn.complete', ($, e, next) => {
1002    isTurnRunning = false
1003    refresh($).catch(() => undefined)
1004
1005    return next(e)
1006  })
1007
1008  // A new conversation starts with no review and no input behind it.
1009  on('session.end', async ($, e, next) => {
1010    if (e.reason === 'clear') {
1011      await update($, inputs, () => 0)
1012      await update($, lastInput, () => null)
1013      await update($, lastReview, () => null)
1014      await update($, firstInput, () => null)
1015    }
1016
1017    return next(e)
1018  })
1019
1020  // The review knob: the person's own inputs, counted and remembered by how
1021  // they began, and each review skill as it is expanded for the model, typed
1022  // as /name or called through the Skill tool alike.
1023  on('prompt.submit', async ($, e, next) => {
1024    // A prompt sent while wt moves the branch waits for the move to end.
1025    if (moving !== null) {
1026      await moving
1027    }
1028
1029    // The person's own: typed here, sent from a phone, or typed in an app
1030    // that hosts the session.
1031    const isOwn = e.origin.kind === 'composer' || e.origin.kind === 'bridge' || e.origin.kind === 'sdk'
1032
1033    // A run that succeeded and left nothing to push has been seen once the
1034    // person types on: the line goes back to how the worktree stands.
1035    if (isOwn && !e.text.trimStart().startsWith('/')) {
1036      const last = await read($, outcome)
1037
1038      if (last !== null && last.isOk && last.pushCommand === null) {
1039        await update($, outcome, () => null)
1040      }
1041    }
1042
1043    if (knobs.hasReview && isOwn) {
1044      const letters = Array.from(e.text.replace(/\s+/g, ' ').trim())
1045      const now = await $.clock.now()
1046      const typed: Input = { at: now, head: letters.length > 20 ? `${letters.slice(0, 20).join('')}…` : letters.join('') }
1047      const count = (await read($, inputs)) + 1
1048      await update($, inputs, () => count)
1049      await update($, lastInput, () => typed)
1050
1051      if ((await read($, firstInput)) === null) {
1052        await update($, firstInput, () => now)
1053      }
1054
1055      // A review skill expanded just before its own /name arrived here belongs
1056      // to this input, not to the one before it.
1057      const review = await read($, lastReview)
1058
1059      if (review !== null && now - review.at < 5_000 && e.text.trimStart().startsWith(`/${review.skill}`)) {
1060        await update($, lastReview, () => ({ ...review, inputs: count }))
1061      }
1062    }
1063
1064    return next(e)
1065  }).catch(($, e, next) => next(e))
1066
1067  on('skill.prompt', async ($, e, next) => {
1068    if (knobs.hasReview && knobs.reviewSkills.test(e.skill)) {
1069      const ran: Review = { skill: e.skill, at: await $.clock.now(), inputs: await read($, inputs) }
1070      await update($, lastReview, () => ran)
1071    }
1072
1073    return next(e)
1074  })
1075
1076  on('command.run', { command: 'wt' }, async ($, e) => {
1077    const word = e.args.trim().split(/\s+/)[0] ?? ''
1078    const known: readonly Action[] = knobs.hasGittree
1079      ? ['up', 'undo', 'push', 'resume', 'fetch', 'refresh', 'dismiss', 'close', 'gittree']
1080      : ['up', 'undo', 'push', 'resume', 'fetch', 'refresh', 'dismiss', 'close']
1081    const action = known.find(one => one === word)
1082
1083    if (action !== undefined) {
1084      return { text: await act($, action) }
1085    }
1086
1087    if (word !== '') {
1088      return { text: `/wt ${word}: not one of ${known.join(', ')}.` }
1089    }
1090
1091    // Bare, it toggles: the dialog on show is the dialog closed.
1092    if (await isOnShow($)) {
1093      return { text: await act($, 'close') }
1094    }
1095
1096    const p = await refresh($)
1097    await openPane($)
1098
1099    return { text: statusText(p, await $.clock.now()) ?? 'No wt worktree here.' }
1100  })
1101
1102  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1103    isTurnRunning = e.props.isWorking
1104
1105    if (e.props.hasSurvey) {
1106      return next(e)
1107    }
1108
1109    const now = await $.clock.now()
1110    const line = lineOf(await read($, plan), await read($, outcome), await read($, busy), now)
1111    const linger = knobs.hasReview
1112      ? lingerOf(await read($, lastInput), await read($, lastReview), await read($, firstInput), await read($, inputs), now)
1113      : ''
1114
1115    // The line takes no keys: the only ones that would answer from the prompt
1116    // are digits, and a digit typed into an empty prompt is the person's to
1117    // type. It names the command instead, and a click on that toggles it.
1118    //
1119    // Outside a worktree of wt's, with nothing old to recall and no gittree,
1120    // nothing is drawn.
1121    if (line === null && linger === '' && !knobs.hasGittree) {
1122      return next(e)
1123    }
1124
1125    const { Box, Button, Text } = $.ui.resolve(e)
1126
1127    return (
1128      <Box gap={1}>
1129        {line !== null && <Button key="details" plain label={LABEL.details} onPress={() => act($, 'details')} />}
1130        <Text wrap="truncate-end">
1131          {line !== null &&
1132            line.map((part, at) => [
1133              at === 0 ? '· ' : ' · ',
1134              <Text color={part.tone === 'dim' ? undefined : part.tone} dimColor={part.tone === 'dim'}>
1135                {part.text}
1136              </Text>,
1137            ])}
1138          {linger !== '' && line !== null && ' · '}
1139          {linger !== '' && <Text dimColor>{linger}</Text>}
1140        </Text>
1141        <Box flexGrow={1} />
1142        {knobs.hasGittree && <Button key="gittree" plain label={GITTREE_OUT} onPress={() => act($, 'gittree')} />}
1143      </Box>
1144    )
1145  })
1146
1147  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1148    const { Box, Button, Text } = $.ui.resolve(e)
1149    const p = await read($, plan)
1150    const last = await read($, outcome)
1151    const state = await read($, busy)
1152    const keyFor = (one: Action) => <Button key={one} plain label={LABEL[one]} hotkey={HOTKEY[one]} onPress={() => act($, one)} />
1153    const footer = (
1154      <Box gap={2} marginTop={1}>
1155        <Button key="close" plain dimColor role="dismiss" label={LABEL.close} hotkey={HOTKEY.close} onPress={() => act($, 'close')} />
1156        <Text dimColor>{e.props.isFocused ? CLOSING : KEYLESS}</Text>
1157      </Box>
1158    )
1159
1160    if (p === null) {
1161      return (
1162        <Box flexDirection="column">
1163          <Text dimColor wrap="wrap">
1164            No wt worktree here: this folder is not in a repository wt knows, or wt is not installed.
1165          </Text>
1166          <Box gap={3} marginTop={1}>
1167            {keyFor('refresh')}
1168            {knobs.hasGittree && keyFor('gittree')}
1169          </Box>
1170          {footer}
1171        </Box>
1172      )
1173    }
1174
1175    const now = await $.clock.now()
1176    // The last outcome, when it is this worktree's.
1177    const mine = last !== null && last.path === p.path ? last : null
1178    const detail = detailOf(p, mine)
1179    const review = knobs.hasReview ? await read($, lastReview) : null
1180    const typed = knobs.hasReview ? await read($, lastInput) : null
1181    const count = knobs.hasReview ? await read($, inputs) : 0
1182
1183    return (
1184      <Box flexDirection="column">
1185        <Box gap={2}>
1186          <Text bold>{p.work}</Text>
1187          {p.isMain ? (
1188            <Text dimColor>main checkout</Text>
1189          ) : (
1190            <Text color={p.behind === null || p.behind > 0 ? 'warning' : 'success'}>{`${trunkMarks(p)} ${p.trunk}`}</Text>
1191          )}
1192          <Text dimColor>{p.fetchedAt === null ? 'fetch time unknown' : `fetched ${ago(now, p.fetchedAt)}`}</Text>
1193        </Box>
1194        {detail.notes.map(note => (
1195          <Text color={note.tone === 'dim' ? undefined : note.tone} dimColor={note.tone === 'dim'} wrap="wrap">
1196            {note.text}
1197          </Text>
1198        ))}
1199        {state !== 'idle' && (
1200          <Box marginTop={1}>
types/index.d.ts 90 lines
1/** A branch against the ref a push of it would replace, as `wt status --json` reports it. */
2export type OwnRemote = {
3  ref: string | null
4  /** none | gone | unknown | inSync | ahead | behind | rebased | diverged */
5  state: string
6  ahead: number | null
7  behind: number | null
8  /** null | diverged | operation | dirty | session: why a run would refuse. */
9  blocks: string | null
10}
11
12export type WtSession = { name: string; kind: string; state: string }
13
14/** The worktree the session stands in, digested from `wt status --json`. */
15export type Plan = {
16  work: string
17  branch: string | null
18  path: string
19  isMain: boolean
20  trunk: string
21  trunkRef: string
22  /** When the trunk ref last moved here (ISO), or null: status does not fetch. */
23  trunkFetchedAt: string | null
24  /** The repository's git directory, absolute; null when it could not be read. */
25  gitDir: string | null
26  /** When the repository last fetched anything (ms since the epoch), or null. */
27  fetchedAt: number | null
28  /** Commits origin's trunk has that local trunk lacks. */
29  trunkRemoteAhead: number
30  /** null when wt could not count them. */
31  behind: number | null
32  ahead: number | null
33  /** clean | dirty | unreadable */
34  tree: string
35  /** null from a wt older than status 1.6.0. */
36  ownRemote: OwnRemote | null
37  upEligible: boolean
38  upIneligibleCode: string | null
39  upIneligibleReason: string | null
40  /** Passed to `wt up --expect`. */
41  token: string | null
42  /** The other worktrees a run on this one would move, parents first. */
43  stack: string[]
44  /** Other agent sessions in the worktree. */
45  sessions: WtSession[]
46  /** Why the sessions could not be listed, when they could not. */
47  sessionsError: string | null
48  schemaVersion: string
49}
50
51/** What the last action from the dialog or `/wt` came to. */
52export type Outcome = {
53  action: 'up' | 'undo' | 'resume'
54  /** The worktree it was run on: shown and acted on for that one only. */
55  work: string
56  path: string
57  isOk: boolean
58  /** The whole of it, for the dialog. */
59  text: string
60  /** A few words led by ✓ or ✗, for the line above the prompt. */
61  brief: string
62  /** The lease-protected push wt printed for the rebased branch, as argv. */
63  pushCommand: string[] | null
64  canUndo: boolean
65}
66
67export type Busy = 'idle' | 'up' | 'undo' | 'resume'
68
69/** The person's last own input: when, and how it began (cut with an ellipsis). */
70export type Input = { at: number; head: string }
71
72/** The last review skill this conversation ran, and the input count then. */
73export type Review = { skill: string; at: number; inputs: number }
74
75declare module 'claude-code' {
76  interface PluginState {
77    wt: {
78      plan: Plan | null
79      busy: Busy
80      outcome: Outcome | null
81      /** The person's own inputs so far in this conversation. */
82      inputs: number
83      lastInput: Input | null
84      lastReview: Review | null
85      /** When the person's first own input of this conversation was. */
86      firstInput: number | null
87    }
88  }
89}
90