SLOPSHOPPER

Worktree Radar

A live radar of your parallel branches and worktrees above the prompt: which merge cleanly, which conflict, which are stacked, which a Claude session is still…

newbandguardcommandtoasttool
v0.1.0MITupdated 2026-10-11floheissler/cc-worktree-radar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · worktree-radar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /radar ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Worktree Radar · reading app… h: help c: compact x: exit │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Worktree Radar · reading app… h: help c: compact x: exit │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

worktree-radar

CI

A Claude Code mod for running several Claude sessions in parallel, each on its own branch or worktree. /radar shows every local branch in the band above the prompt: which merge cleanly into the base branch, which conflict, which are stacked on another, which a Claude session is still working on, and the order to land them in.

/radar typed in a Claude Code session, and the band opening above the prompt: every branch of the repository in its lane, the ready ones ranked, and the status line naming what lands next

  • See every branch's lane at a glance: ready, needs rebase, stacked, in progress, on hold, cleanup.
  • Know what lands next: ready branches are ranked so that each landing forces the least rebase work on the others.
  • Catch conflicts before they happen: simulated merges show which branches conflict with the base, and which would conflict once another one lands.
  • See who works where: the Claude session or subagent in each worktree, busy or idle.
  • Hold a branch out of the landing order, say while it waits on a review.
  • Drive it from a skill: Claude gets a tool to read the facts as JSON, leave comments on rows and be woken when a branch becomes ready.
  • Stay current: it checks every few seconds, and comes back with your session when you resume it.

It only looks. It never merges, rebases, tags, pushes or moves a ref.

It is a mod: a plugin of function hooks that draws in Claude Code's own interface.

Requirements

  • Claude Code 2.1.295 or later, in a terminal: mods are on by default there. Tested with Claude Code 2.1.295 and 2.1.296. Mods are a young part of Claude Code and their API can change between releases, so after a Claude Code update, update this mod too.
  • git 2.38 or later on the PATH: the conflict checks need git merge-tree --write-tree.
  • The claude command on the PATH, to see which session works where (claude agents --json). Without it, everything else works and the band says sessions unknown.
  • The band draws in the terminal and in the Desktop app's Code tab (covered by tests, not yet looked at live), nowhere else: not in the VS Code extension's chat panel, or claude -p.

Install

At the Claude Code prompt in a terminal:

/plugin install worktree-radar --marketplace floheissler/cc-worktree-radar

Claude Code asks you to confirm adding the marketplace, then shows the plugin's details: review what it adds and pick a scope. Or, in two steps from your shell:

claude plugin marketplace add floheissler/cc-worktree-radar
claude plugin install worktree-radar@cc-worktree-radar

Like every mod, it runs with your permissions: Privacy lists what it touches, and you can check that yourself before installing.

This repository is its own marketplace, named cc-worktree-radar. Marketplaces other than Anthropic's don't update on their own: run claude plugin update worktree-radar@cc-worktree-radar for a new release, or turn on auto-update for cc-worktree-radar in /plugin under Marketplaces.

To run it from a local checkout for one session instead:

claude --plugin-dir /path/to/cc-worktree-radar

Then, in a session inside a git repository, run /radar.

Use

CommandDoes
/radarOpens the radar on the session's repository, or closes it
/radar <base>Opens it with another base branch than the detected one (origin/HEAD, else main, else master)
/radar helpOpens it on its help: what each lane, detail and comment means
/radar hold <branch> [reason]Parks a branch, say one that is done but waits on a review: it leaves the landing order (the ready lane and next) whatever else it shows. Only the radar records it: the branch and git stay as they are. Every session of the repository on this machine sees the hold until it is let go
/radar unhold <branch>Lets it go again
/radar offCloses the radar

The band: four ready branches ranked, two of which would force each other to rebase, with Claude's comment on which lands first; a branch that needs a rebase, a stacked one, one in progress, one on hold and three to clean up. Under it the status line: the counts, what lands next, a skill's status and who works in the main worktree

Each row is a branch: its place in the landing order, its lane, the commits it has ahead and the age of the last one. DETAIL is what the radar reads about it: why it is in its lane and what comes next (conflicts: cart.ts, rebase after #2 fix/totals, lands after #1 feat/x, who works there). COMMENT is what a skill or agent left on the branch through the radar's tool: Claude's words, marked ✻ and quoted, shown until the branch moves, or, kept, until it lands. The column shows while a row has a comment, on a band wide enough for it.

When Claude Code gives the band fewer rows than there are branches, lanes fold into one line each (needs rebase (3): feat/a, feat/b, feat/c): cleanup first, needs rebase last. The ready lane keeps its first branches, in landing order, and folds the rest into one line (ready (+12): …). The status line under the panel always stays.

The band is framed like a panel, with three controls in its header:

ControlDoes
h: helpswitches between the queue and a help that explains every lane, detail, comment and control
c: compactfolds the radar into its status line: the counts, then what lands next; c: expand brings the queue back
x: exitcloses the radar, as /radar does

Under the panel, the status line reads left to right: the title, the repository and its base (shop [main]), what each lane holds, and what lands next. Its right end holds messages: a skill's status line, who works in the main worktree and what is uncommitted there (main worktree: session fix-menu, 2 uncommitted, work no row shows while the base is checked out there; the radar's own session is left out), a failed read. On a narrow band the messages go first, then the long lane labels (2 rebase, 1 wip), the title, the repository and the counts; land next stays to the end. Compact, the panel goes and the status line stays alone above the prompt, the controls at its end where the messages were; only a failed read still shows.

Click the controls, or give the band the keyboard (Ctrl+X Tab, or a click) and press the letter. On a band narrower than 90 columns the labels shorten to h: ?, c: - and x: ×. The [-] beside the band is Claude Code's own: it hides the band (Ctrl+X Ctrl+A), and a mod cannot move it into the frame. It updates by itself while open: every few seconds the radar checks the branch tips, the worktrees, the holds, the running Claude sessions and its own subagents, and reads the repository again once a change has settled.

Across sessions

The radar belongs to the conversation it was opened in:

YouThe radar
resume a session: claude --continue, claude --resume, /resumecomes back as that session left it, even if its process died: open or closed, folded or not, with a skill's wake list, status line and comments
start a new sessionstays closed
/clearstays as it was, but wakes nothing until a skill opens it again: the wakes were asked for by the conversation you cleared
/branchstays as it was in the branch, wake list and all
claude --resume <id> --fork-sessionstays closed: Claude Code does not tell a mod which session the fork came from

The last 50 sessions that ended with the radar open are remembered.

The lanes

LaneMeaning
readyno uncommitted changes, no busy session, merges cleanly; ranked, land #1 first
needs rebaseconflicts with the base; rebase due once nothing that lands before it still conflicts with it, else rebase after <branch>
stackedholds every commit of another open branch, plus its own: land that one first
in progressuncommitted changes, a rebase under way, or a busy Claude session in its worktree; also a branch with no commits yet whose session is open, and a worktree a subagent works in (its row says agent: and its task)
on holdparked with /radar hold or the tool
cleanupnothing left to land: landed in main when its work reached the base, through a merge or a fast-forward (even with its session still open), else no commits and no session. worktree being removed while Claude Code deletes its worktree (a closed background job's): nobody there, tracked files going, nothing else changed

The ranking: stack parents first, then the landing that forces the least rebase work on the other branches (in a conflicting pair the bigger one lands and the smaller one rebases), then the branch that finished first.

Blind spots: branches that merge cleanly can still break each other (only tests after each landing catch that), and an idle session is not necessarily done. A branch landed by a squash merge still shows its commits as left to land: the base holds a rewritten copy of them, not the commits themselves.

Driving it from a skill

The mod gives the model a tool, mcp__worktree-radar__radar, so a skill of your own can open the radar and act on what it sees.

ActionInputDoes
openbase?, wake?Opens the radar and answers the snapshot
snapshotReads now and answers the snapshot
closeCloses the radar
hold, unholdbranch, reason?Parks a branch, or lets it go
commentbranch, text, keep?A short comment on the branch's row, shown until its tip moves; with keep: true, on every tip until the branch lands or is gone (merge once ready, set before the first commit). Empty text clears it
statustextOne line under the queue (release v0.4.1 pending); empty text clears it

wake lists the events that wake the session with a one-line prompt: ready, conflict, rebase-due, stacked, cleanup, base-moved, session-idle (a session or agent going idle, or a subagent finishing). Each event wakes once per branch tip. While the session is at work, a wake waits for its turn to end, and what the radar finds meanwhile comes in one more wake after it. /radar itself wakes nothing. The prompt is a report and says so:

worktree-radar: feat/a is ready to land (#1) · main moved 3f2a1c9→8b0e4d2. This is a report from a read-only view, not a request: act on it only as your user or your instructions say.

Claude Code hands a plugin's prompt to the model as starting the turn in the user's place, and a bare "is ready to land" read that way has moved a model to land the branch itself. What to do on an event is your skill's to say: told to land ready branches, a model does; told nothing, it reports.

The snapshot is JSON (schema: 1): the repository, the base (name, sha, upstream, uncommitted files in the main worktree, mainWorktreeSessions: who works there but the radar's own session), whether sessions could be listed, next, and per branch: name, sha, lane, rank, ahead, behind, fileCount, insertions, deletions, lastCommitAt, upstream, conflicts, stackedOn, dependents, sharesWith, breaks (branches that would conflict once it landed), rebaseCost, waitsFor, isRebaseDue, isLanded (nothing left to land, and its work reached the base: through a merge, or, as the branch's own reflog shows, a fast-forward), hold, comment, worktree (path, uncommitted files, rebase under way, isBeingRemoved, sessions) and a one-line summary. A worktree's sessions lists who works there: Claude sessions (kind: "session": id, name, status) and subagents (kind: "agent": the agent's id, which SendMessage addresses, name, status, description of its task where the radar's own session spawned it, and parent, the session it runs in).

Settings

In /config, under the plugin:

  • Notify when a branch is ready (on): a desktop notification when a branch becomes ready to land.
  • Seconds between checks (4): how often the open radar checks for a change.

Privacy

Everything stays on your machine. The radar makes no network calls, reads no transcript and no credential, and sends nothing anywhere. In detail:

  • The programs it runs, and why. Two, both local, both started without a shell. git, in your repository, to read the branches and worktrees and to check merges in memory: rev-parse (where the repository is, a branch's tip, git's own paths), for-each-ref, symbolic-ref, worktree list --porcelain, status --porcelain, rev-list, diff --numstat, merge-tree --write-tree, commit-tree (simulated merges, whose unreferenced objects git gc prunes), reflog show and merge-base --is-ancestor. No ref, index or worktree is touched, and nothing is fetched or pushed. And claude agents --json, Claude Code's own list of the sessions on this machine and the folder each works in, every other check while the radar is open. The commands are built at run time (git's name your branches), which is why the directory cannot read them in full: this is the whole list.
  • What it reads. Git's answers above, and two of git's own files that tell a rebase under way (rebase-merge/head-name, rebase-apply/head-name in a worktree's git folder). From the session: its id and folder, the theme setting (to color the base's name), the subagents this session started (their ids, names, task descriptions and states, shown on the rows they work in), and the calls Claude makes to the radar's own tool.
  • What it sends, and where. Nothing leaves the machine through the radar. What it reads goes to the band above the prompt, to the tool's answer in this session, and to git as branch names. Holds, and the radar each session left open, are kept in Claude Code's plugin store on this machine.
  • The prompts it submits. Only when a skill opened the radar with a wake list, and only the events: branch names, a rank, conflicting file paths, the base's short commit ids, session and agent names, then a fixed note. /radar alone submits none. For example:
  worktree-radar: feat/a is ready to land (#1) · main moved 3f2a1c9→8b0e4d2. This is a report from a read-only view, not a request: act on it only as your user or your instructions say.

The same event line, without the note, goes to Claude Code's notification when a branch becomes ready, if Notify when a branch is ready is on.

  • The tool it answers. It registers one tool, radar (mcp__worktree-radar__radar), and answers calls to that tool only: with the snapshot, or a line saying what it did. It stands in for no other tool.
  • Settings. It reads the theme setting and, when you change the theme in /config, passes the change on as it came. It changes no setting and takes no permission decision.

Platforms

PlatformStatus
Linux, WSL2Tested with Claude Code 2.1.295 and 2.1.296, git 2.53
WindowsNot tested yet. Windows paths (C:\…, either slash) are covered by tests; whether claude agents starts without a shell there is open: if not, the band says sessions unknown
macOSNot tested. Paths are POSIX, as on Linux
Desktop app (Code tab)The band draws there in the tests, mounted on that surface; not looked at live yet

Known limits

  • It reads; it never merges, rebases, deletes a branch or removes a worktree. Your skill, Claude or you do that.
  • A branch landed by a squash merge still shows its commits as left to land: the base holds a rewritten copy of them.
  • Without a reflog for a branch (none kept, or expired), a branch that landed by a fast-forward reads as never having had work: nothing beyond main rather than landed in main.
  • Uncommitted changes are not polled: a git status per worktree is too slow for that on some file systems (about a second each under WSL's /mnt/c). They show at the next read, which follows any other change, and at least once a minute.
  • Which session works where comes from claude agents --json, so the claude command must be on the PATH; otherwise the band says sessions unknown. A subagent shows in a worktree Claude Code locked for it (an Agent call with isolation: "worktree").
  • Holds live in Claude Code's plugin store on this machine: every session of the repository here sees them; another machine does not.
  • claude --resume <id> --fork-session comes back with the radar closed: Claude Code does not tell a mod which session the fork came from.
  • With the theme set to auto or a custom theme, the base's orange is a middle shade that reads on light and dark: a mod cannot learn which background auto chose.

How it works

hooks/register.tsx is the hooks module; the rest of hooks/ is its parts.

EventWhat the hook does
session.startRegisters /radar and the radar tool; in a resumed session, opens the radar it left open
command.run of radarOpens or closes the radar, opens it on its help, holds or lets go of a branch
tool.call of mcp__worktree-radar__radarAnswers the tool's actions: open, snapshot, close, hold, unhold, comment, status
ui.render of AbovePromptDraws the band from the latest read, fitted to the room Claude Code gives it
session.endSaves an open radar for the session's resume
classic.SessionStart after /clear, /resume, /branchCarries the radar over, or brings back the one the resumed session left
config.set of themePicks the base's color for the new theme

While the radar is open, a poll every few seconds reads the branch tips (git for-each-ref), the worktrees (git worktree list --porcelain), the holds, the session's own subagents and, every other poll, claude agents --json. Once a change has settled, a read follows: per branch git rev-list, git diff --numstat and git merge-tree --write-tree against the base, git status in its worktree, and for branches that touch the same files a simulated merge (git commit-tree) to find the conflicts one landing would cause another. Every git run has GIT_OPTIONAL_LOCKS=0, so it never takes a lock a commit beside it needs, the C locale, and a timeout. The simulated merges leave unreferenced objects that git gc prunes; no ref, index or worktree is touched.

What the band shows lives in the session's $.state; holds (holds: and the repository's .git folder) and the radar each session left open (resume: and the session's id, the last 50) live in Claude Code's plugin store (~/.claude/plugins/store/). claude plugin validate . lists every call the module makes.

Develop

Install the working copy once, from the checkout as a local marketplace. A local marketplace whose entry is a relative path is read in place, so every session runs your working copy:

claude plugin marketplace add /path/to/cc-worktree-radar
claude plugin install worktree-radar@cc-worktree-radar

After an edit, run /reload-plugins in a session to load it. For a session that hot-reloads on every save instead, start it with claude --plugin-dir /path/to/cc-worktree-radar; that needs file watching, which a WSL checkout under /mnt/c does not reliably get.

Before each commit:

claude plugin validate --strict .   # what the module hooks and calls, and anything the engine would refuse
claude plugin test .                # the tests under tests/
tsc -p .                            # type-check against this build's API

tsc reads the types Claude Code lays in .claude-plugin/types/ (ignored by git) when it loads the mod with --plugin-dir; start one such session after a Claude Code update to refresh them.

The version lives in .claude-plugin/plugin.json alone; bump it with every release, because users only receive a release whose version changed.

Support

If the radar saves you time, buy me a few tokens on Ko-fi.

Buy me a few tokens on Ko-fi

License

MIT, see LICENSE.

Source 18 files
hooks/register.tsx 586 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { RadarConfig } from '../types'
5
6import { commandRequestOf } from './command'
7import type { Host, RadarOptions } from './host'
8import Limits from './limits'
9import { BAND_TITLE, COMMAND_DESCRIPTION, COMMAND_HINT } from './names'
10import { createRadar, type Radar } from './radar'
11import { bandLinesOf, controlsOf, frameOf } from './rows'
12import { forgetRadar, keepRadar, loadRadar, pruneSaved, saveRadar, savedRadarOf, type SavedRadar } from './saved'
13import { clipChars, messageOf, sanitize } from './text'
14import { TOOL_DESCRIPTION, TOOL_INPUT_SCHEMA, toolJsonOf, toolRequestOf } from './tool'
15import { bandView } from './view'
16
17/**
18 * The radar's session state. The band draws from it, so every write
19 * redraws the band; a reload of the module keeps it. A new process starts
20 * without it, and `/clear`, `/resume` and `/branch` reset it: an open radar
21 * is also saved in the store for the session's resume (saved.ts).
22 */
23const RADAR = atom({ plugin: 'worktree-radar', key: 'radar' } as const, null)
24const SNAPSHOT = atom({ plugin: 'worktree-radar', key: 'snapshot' } as const, null)
25const PROBLEM = atom({ plugin: 'worktree-radar', key: 'problem' } as const, null)
26const COMMENTS = atom({ plugin: 'worktree-radar', key: 'comments' } as const, {})
27const STATUS = atom({ plugin: 'worktree-radar', key: 'status' } as const, null)
28const HELP_SHOWN = atom({ plugin: 'worktree-radar', key: 'helpShown' } as const, false)
29const COMPACT = atom({ plugin: 'worktree-radar', key: 'compact' } as const, false)
30const DUE_MARKS = atom({ plugin: 'worktree-radar', key: 'dueMarks' } as const, {})
31const THEME_SETTING = atom({ plugin: 'worktree-radar', key: 'themeSetting' } as const, null)
32
33/**
34 * Reads the person's settings, each checked: `userConfig` in plugin.json.
35 *
36 * @param options the values Claude Code hands `register`
37 * @returns the settings, defaults filled in
38 */
39function optionsOf(options: PluginOptions): RadarOptions {
40  const seconds = typeof options['pollSeconds'] === 'number' ? options['pollSeconds'] : 4
41
42  return {
43    notifyReady: options['notifyReady'] !== false,
44    pollMs: Math.round(Math.min(Math.max(seconds, 1), 60) * 1000),
45  }
46}
47
48/**
49 * Binds the radar's engine calls to one hook's `$`.
50 *
51 * @param $ the engine, as a hook receives it
52 * @param options the person's settings
53 * @returns the calls, as a plain record
54 */
55function hostOf($: EngineInterface, options: RadarOptions): Host {
56  return {
57    cwd: () => $.session.cwd(),
58    sessionId: () => $.session.id(),
59    run: (argv, init) => $.process.run(argv, init),
60    agents: () =>
61      $.agent.list().then(
62        agents => agents.map(agent => ({ id: agent.id, name: agent.name ?? null, description: agent.description, status: agent.status })),
63        () => [],
64      ),
65    themeSetting: () =>
66      $.config.list().then(
67        rows => {
68          const value = rows.find(row => row.key === 'theme')?.value
69
70          return typeof value === 'string' ? value : null
71        },
72        () => null,
73      ),
74    readText: path => $.fs.read(path).then(
75      text => text,
76      () => null,
77    ),
78    now: () => $.clock.now(),
79    after: (ms, fn) => $.clock.after(ms, fn),
80    invalidate: () => $.ui.invalidate('ui.render'),
81    toast: text => $.ui.toast(text),
82    notify: async text => {
83      await $.ui.notify(text, { title: BAND_TITLE })
84    },
85    submit: async text => {
86      await $.prompt.submit({ text })
87    },
88    store: {
89      get: key => $.store.get(key),
90      set: (key, value) => $.store.set(key, value),
91      delete: key => $.store.delete(key),
92      keys: () => $.store.keys(),
93    },
94    state: {
95      radar: {
96        get: () => read($, RADAR),
97        set: async fn => {
98          await update($, RADAR, fn)
99        },
100      },
101      snapshot: {
102        get: () => read($, SNAPSHOT),
103        set: async fn => {
104          await update($, SNAPSHOT, fn)
105        },
106      },
107      problem: {
108        get: () => read($, PROBLEM),
109        set: async fn => {
110          await update($, PROBLEM, fn)
111        },
112      },
113      comments: {
114        get: () => read($, COMMENTS),
115        set: async fn => {
116          await update($, COMMENTS, fn)
117        },
118      },
119      status: {
120        get: () => read($, STATUS),
121        set: async fn => {
122          await update($, STATUS, fn)
123        },
124      },
125      helpShown: {
126        get: () => read($, HELP_SHOWN),
127        set: async fn => {
128          await update($, HELP_SHOWN, fn)
129        },
130      },
131      compact: {
132        get: () => read($, COMPACT),
133        set: async fn => {
134          await update($, COMPACT, fn)
135        },
136      },
137      dueMarks: {
138        get: () => read($, DUE_MARKS),
139        set: async fn => {
140          await update($, DUE_MARKS, fn)
141        },
142      },
143      themeSetting: {
144        get: () => read($, THEME_SETTING),
145        set: async fn => {
146          await update($, THEME_SETTING, fn)
147        },
148      },
149    },
150    options,
151  }
152}
153
154/**
155 * The session the open radar is saved under: the one it opened or reopened
156 * in. `$.session.id()` lags a `/resume` or `/branch`, answering the session
157 * left until the next command, so a save in between must not ask it. Null
158 * while closed, and after a reload of the module, when it has caught up.
159 */
160let savedAs: string | null = null
161
162/**
163 * Saves the open radar as it stands, after a change a person or a caller
164 * made (folding, the status line, a comment), so a process that dies
165 * before `session.end` still comes back with it. The due marks a read finds
166 * ride along with these saves and the one at `session.end`, not on their
167 * own: a read can land in the lag `savedAs` covers, and only refines the
168 * rebase order.
169 *
170 * @param host the engine's calls
171 */
172async function keepChange(host: Host): Promise<void> {
173  try {
174    await keepRadar(host, savedAs ?? (await host.sessionId()))
175  } catch {
176    // It comes back as it was at its last save, or at session.end
177  }
178}
179
180/**
181 * Opens the radar on a folder, replacing one open before, and starts its
182 * polls; the first read follows at once. It is saved for the session's
183 * resume as it opens, and again on each change (`keepChange`): a process
184 * that dies without ending the session still comes back with it.
185 *
186 * @param host the engine's calls
187 * @param radar the radar's polls and reads
188 * @param config what to watch
189 * @returns once the first read is done
190 */
191async function openRadar(host: Host, radar: Radar, config: RadarConfig): Promise<void> {
192  radar.stop()
193  await host.state.radar.set(() => config)
194  await host.state.snapshot.set(() => null)
195  await host.state.problem.set(() => null)
196
197  try {
198    const sessionId = await host.sessionId()
199
200    savedAs = sessionId
201    await keepRadar(host, sessionId)
202    void pruneSaved(host, sessionId).catch(() => undefined)
203  } catch {
204    // It opens all the same, and is saved when the session ends
205  }
206
207  await radar.start(host)
208}
209
210/**
211 * Stops the radar and empties the state the band draws from.
212 */
213async function clearRadar(host: Host, radar: Radar): Promise<void> {
214  radar.stop()
215  savedAs = null
216  await host.state.radar.set(() => null)
217  await host.state.snapshot.set(() => null)
218  await host.state.problem.set(() => null)
219  await host.state.status.set(() => null)
220  await host.state.helpShown.set(() => false)
221}
222
223/**
224 * Closes the radar, and drops what the session saved for its resume.
225 */
226async function closeRadar(host: Host, radar: Radar): Promise<void> {
227  await clearRadar(host, radar)
228
229  try {
230    await forgetRadar(host, await host.sessionId())
231  } catch {
232    // A resume would open it again: closed, it stays closed in this session
233  }
234}
235
236/**
237 * Opens a radar a session left, as it was left, and saves it for this
238 * session; with none, the radar is closed. Called where the state starts
239 * over: a new process, `/clear`, `/resume` and `/branch`.
240 *
241 * @param host the engine's calls
242 * @param radar the radar's polls and reads
243 * @param saved what the session left, or null
244 * @param sessionId the session it opens in: the conversation on screen
245 */
246async function reopenRadar(host: Host, radar: Radar, saved: SavedRadar | null, sessionId: string): Promise<void> {
247  radar.stop()
248
249  if (saved === null) {
250    // The engine reset the state already; an open radar left here would be
251    // another conversation's
252    if ((await host.state.radar.get()) !== null) {
253      await clearRadar(host, radar)
254    }
255
256    return
257  }
258
259  await host.state.compact.set(() => saved.compact)
260  await host.state.status.set(() => saved.status)
261  await host.state.comments.set(() => ({ ...saved.comments }))
262  await host.state.dueMarks.set(() => ({ ...saved.dueMarks }))
263  await host.state.helpShown.set(() => false)
264  await host.state.snapshot.set(() => null)
265  await host.state.problem.set(() => null)
266  // Last, so the band's first drawing has the rest
267  await host.state.radar.set(() => saved.radar)
268  savedAs = sessionId
269  await saveRadar(host, sessionId, { ...saved, savedAt: await host.now() }).catch(() => undefined)
270  void radar.start(host).catch(() => undefined)
271}
272
273/**
274 * The tool's answer with the radar's latest read.
275 */
276async function answerOf(host: Host): Promise<{ result: string } | { deny: string }> {
277  const [snapshot, problem] = await Promise.all([host.state.snapshot.get(), host.state.problem.get()])
278
279  if (snapshot === null) {
280    return { deny: `The radar cannot read the repository: ${problem ?? 'no read yet'}` }
281  }
282
283  return { result: toolJsonOf(snapshot) }
284}
285
286/**
287 * Registers the radar: `/radar` and the `radar` tool open a live view of
288 * the repository's branches and worktrees in the band above the prompt,
289 * kept fresh by polls while it is open.
290 */
291export const register: Register = (on, pluginOptions) => {
292  const options = optionsOf(pluginOptions)
293  const radar = createRadar()
294  /**
295   * Whether `session.start` ran for this module: at a process's start
296   * `classic.SessionStart` fires before it, and `session.start` opens what a
297   * resumed session left.
298   */
299  let hasStarted = false
300  /**
301   * The radar the session that just ended had open, for the conversation
302   * that follows it in this process (`/clear`, `/branch`).
303   */
304  let ended: SavedRadar | null = null
305
306  on('session.start', async ($, e, next) => {
307    hasStarted = true
308
309    try {
310      await $.command.register({
311        name: 'radar',
312        description: COMMAND_DESCRIPTION,
313        argumentHint: COMMAND_HINT,
314        immediate: true,
315      })
316    } catch (error) {
317      $.ui.log(`worktree-radar: could not register /radar: ${messageOf(error)}`)
318    }
319
320    try {
321      await $.tool.register({
322        name: 'radar',
323        description: TOOL_DESCRIPTION,
324        inputSchema: TOOL_INPUT_SCHEMA,
325        isDeferred: false,
326      })
327    } catch (error) {
328      $.ui.log(`worktree-radar: could not register its tool: ${messageOf(error)}`)
329    }
330
331    // A reload of the module cancels its timers but keeps the state: an
332    // open radar polls again. A new process starts with no state: a resumed
333    // session (`claude --continue`, `--resume`) opens the radar it left open
334    const host = hostOf($, options)
335
336    if ((await host.state.radar.get()) !== null) {
337      void radar.start(host).catch(() => undefined)
338    } else if (e.isInteractive) {
339      void host
340        .sessionId()
341        .then(async sessionId => reopenRadar(host, radar, await loadRadar(host, sessionId), sessionId))
342        .catch(() => undefined)
343    }
344
345    return next(e)
346  })
347
348  /**
349   * `/clear`, `/resume` and `/branch` (`fork`) reset the session state, and
350   * the conversation goes on under another session id, `e.session_id`
351   * (`$.session.id()` still answers the old one here). The resumed
352   * conversation gets the radar it left, or none; `/branch` carries the
353   * radar on whole, and `/clear` without its caller's wake list: the wakes
354   * were asked for by the conversation it cleared.
355   */
356  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
357    const carried = ended
358
359    ended = null
360
361    if (hasStarted) {
362      const host = hostOf($, options)
363      const saved =
364        e.source === 'resume'
365          ? await loadRadar(host, e.session_id)
366          : e.source === 'clear' && carried !== null
367            ? { ...carried, radar: { ...carried.radar, wake: [] } }
368            : carried
369
370      await reopenRadar(host, radar, saved, e.session_id)
371    }
372
373    return next(e)
374  }).catch(($, e, next) => next(e))
375
376  /**
377   * The session ends: an exit, `/clear`, `/resume` or `/branch`. An open
378   * radar is saved under its id as it stands, for a resume, and kept for the
379   * conversation that follows in this process. A closed one writes nothing.
380   */
381  on('session.end', async ($, e, next) => {
382    const host = hostOf($, options)
383
384    ended = null
385
386    try {
387      ended = await savedRadarOf(host)
388
389      if (ended !== null) {
390        await saveRadar(host, e.sessionId, ended)
391      }
392    } catch {
393      // It comes back as it was when it opened
394    }
395
396    return next(e)
397  })
398
399  on('command.run', { command: 'radar' }, async ($, e) => {
400    const host = hostOf($, options)
401    const request = commandRequestOf(e.args)
402    const open = await host.state.radar.get()
403    const root = open?.root ?? (await host.cwd())
404
405    switch (request.kind) {
406      case 'toggle':
407        if (open !== null) {
408          await closeRadar(host, radar)
409        } else {
410          void openRadar(host, radar, { root, base: null, wake: [] }).catch(() => undefined)
411        }
412
413        return {}
414      case 'open':
415        void openRadar(host, radar, { root: await host.cwd(), base: request.base, wake: open?.wake ?? [] }).catch(
416          () => undefined,
417        )
418
419        return {}
420      case 'close':
421        await closeRadar(host, radar)
422
423        return {}
424      case 'help':
425        await host.state.helpShown.set(() => true)
426
427        if (open === null) {
428          void openRadar(host, radar, { root, base: null, wake: [] }).catch(() => undefined)
429        }
430
431        return {}
432      case 'hold':
433      case 'unhold': {
434        const why = await radar.setHold(host, root, request.branch, request.kind === 'hold' ? request.reason : null)
435        const branch = sanitize(request.branch)
436
437        host.toast(why ?? (request.kind === 'hold' ? `Holding ${branch}` : `${branch} is no longer held`))
438
439        if (why === null && open !== null) {
440          await radar.readNow(host)
441        }
442
443        return {}
444      }
445      case 'usage':
446        return { text: `Usage: /radar ${COMMAND_HINT}` }
447    }
448  })
449
450  on('tool.call', { tool: /^mcp__worktree-radar__radar$/ }, async ($, e) => {
451    const host = hostOf($, options)
452    const request = toolRequestOf(e as unknown as Readonly<Record<string, unknown>>)
453
454    if ('error' in request) {
455      return { deny: request.error }
456    }
457
458    const open = await host.state.radar.get()
459    const root = open?.root ?? (await host.cwd())
460
461    switch (request.action) {
462      case 'open':
463        await openRadar(host, radar, { root: await host.cwd(), base: request.base, wake: request.wake })
464
465        return answerOf(host)
466      case 'close':
467        await closeRadar(host, radar)
468
469        return { result: 'The radar is closed.' }
470      case 'snapshot':
471        if (open === null) {
472          return { deny: 'The radar is not open: call it with action "open" first.' }
473        }
474
475        await radar.readNow(host)
476
477        return answerOf(host)
478      case 'hold':
479      case 'unhold': {
480        const why = await radar.setHold(host, root, request.branch, request.action === 'hold' ? request.reason : null)
481
482        if (why !== null) {
483          return { deny: why }
484        }
485
486        if (open === null) {
487          return { result: request.action === 'hold' ? `Holding ${request.branch}.` : `${request.branch} is no longer held.` }
488        }
489
490        await radar.readNow(host)
491
492        return answerOf(host)
493      }
494      case 'comment': {
495        const why = await radar.setComment(host, root, request.branch, request.text, request.keep)
496
497        if (why !== null) {
498          return { deny: why }
499        }
500
501        if (open === null) {
502          return { result: `Comment set on ${request.branch}.` }
503        }
504
505        await keepChange(host)
506        await radar.readNow(host)
507
508        return answerOf(host)
509      }
510      case 'status': {
511        const text = request.text.trim()
512
513        await host.state.status.set(() => (text === '' ? null : clipChars(text, Limits.COMMENT_CHARS)))
514        await keepChange(host)
515
516        return { result: text === '' ? 'Status line cleared.' : 'Status line set.' }
517      }
518    }
519  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'worktree-radar failed to answer; see claude --debug' }))
520
521  // The theme changes in /config: the base's color follows at once. The
522  // change passes on as it came; should another hook refuse it, the next
523  // read sets the color back from what /config holds
524  on('config.set', { key: 'theme' }, ($, e, next) => {
525    const value = e.value
526
527    if (typeof value === 'string') {
528      void hostOf($, options).state.themeSetting.set(() => value)
529    }
530
531    return next(e)
532  })
533
534  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
535    const host = hostOf($, options)
536    const open = await host.state.radar.get()
537
538    if (open === null || e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) {
539      return next(e)
540    }
541
542    const [snapshot, problem, status, isHelpShown, isCompact, themeSetting, now] = await Promise.all([
543      host.state.snapshot.get(),
544      host.state.problem.get(),
545      host.state.status.get(),
546      host.state.helpShown.get(),
547      host.state.compact.get(),
548      host.state.themeSetting.get(),
549      host.now(),
550    ])
551    const columns = e.props.bodyColumns
552    const frame = frameOf(columns, e.props.maxRows)
553    const lines = bandLinesOf({
554      snapshot,
555      problem,
556      status,
557      root: open.root,
558      now,
559      columns: frame.columns,
560      maxRows: frame.maxRows,
561      isHelpShown,
562      isCompact,
563      themeSetting,
564    })
565    const controls = controlsOf(frame.columns, isHelpShown, isCompact)
566
567    // Compact, the band is its status line; the help still shows whole
568    return bandView($.ui.resolve(e), lines, columns, frame, isCompact && !isHelpShown, controls, {
569      help: () => {
570        void hostOf($, options).state.helpShown.set(shown => !shown)
571      },
572      compact: () => {
573        const host = hostOf($, options)
574
575        void host.state.compact
576          .set(compact => !compact)
577          .then(() => keepChange(host))
578          .catch(() => undefined)
579      },
580      exit: () => {
581        void closeRadar(hostOf($, options), radar)
582      },
583    })
584  })
585}
586
hooks/command.ts 49 lines
1/**
2 * What `/radar` was asked, from its arguments.
3 */
4
5export type CommandRequest =
6  | { readonly kind: 'toggle' }
7  | { readonly kind: 'open'; readonly base: string }
8  | { readonly kind: 'close' }
9  | { readonly kind: 'help' }
10  | { readonly kind: 'hold'; readonly branch: string; readonly reason: string }
11  | { readonly kind: 'unhold'; readonly branch: string }
12  | { readonly kind: 'usage' }
13
14/**
15 * Reads `/radar`'s arguments: none toggles the radar, `off` closes it,
16 * `help` opens it on its help, a branch name opens it on that base,
17 * `hold <branch> [reason]` and `unhold <branch>` park a branch and let it
18 * go.
19 *
20 * @param args what followed the command
21 * @returns the request
22 */
23export function commandRequestOf(args: string): CommandRequest {
24  const words = args.trim().split(/\s+/).filter(word => word !== '')
25  const [first, branch, ...rest] = words
26
27  if (first === undefined) {
28    return { kind: 'toggle' }
29  }
30
31  if ((first === 'off' || first === 'close') && words.length === 1) {
32    return { kind: 'close' }
33  }
34
35  if (first === 'help' && words.length === 1) {
36    return { kind: 'help' }
37  }
38
39  if (first === 'hold' && branch !== undefined) {
40    return { kind: 'hold', branch, reason: rest.join(' ') }
41  }
42
43  if (first === 'unhold' && branch !== undefined && rest.length === 0) {
44    return { kind: 'unhold', branch }
45  }
46
47  return words.length === 1 && first !== 'hold' && first !== 'unhold' ? { kind: 'open', base: first } : { kind: 'usage' }
48}
49
hooks/host.ts 113 lines
1import type { Timer } from 'claude-code'
2
3import type { CallerComment, RadarConfig, Snapshot } from '../types'
4
5import type { Run } from './git'
6import type { AgentRow } from './sessions'
7
8/**
9 * One value of the radar's session state: read it, or write it from its
10 * current value, which redraws the band.
11 */
12export type StateCell<T> = {
13  readonly get: () => Promise<T>
14  readonly set: (fn: (value: T) => T) => Promise<void>
15}
16
17/**
18 * The radar's session state, value by value (`PluginState['worktree-radar']`).
19 */
20export type RadarState = {
21  readonly radar: StateCell<RadarConfig | null>
22  readonly snapshot: StateCell<Snapshot | null>
23  readonly problem: StateCell<string | null>
24  readonly comments: StateCell<Record<string, CallerComment>>
25  readonly status: StateCell<string | null>
26  readonly helpShown: StateCell<boolean>
27  readonly compact: StateCell<boolean>
28  readonly dueMarks: StateCell<Record<string, string>>
29  readonly themeSetting: StateCell<string | null>
30}
31
32/**
33 * The person's settings for the radar (`userConfig` in plugin.json).
34 */
35export type RadarOptions = {
36  /**
37   * Raise a native notification when a branch becomes READY.
38   */
39  readonly notifyReady: boolean
40  /**
41   * Between two checks for a change, in milliseconds.
42   */
43  readonly pollMs: number
44}
45
46/**
47 * What the radar asks of the engine, bound to one hook's `$` by `hostOf`
48 * in register.tsx: every engine call the mod makes is spelled there, where
49 * `claude plugin validate` reads it, and the rest of the code takes this
50 * plain record instead of `$`.
51 */
52export type Host = {
53  /**
54   * The session's working folder: where the radar opens by default.
55   */
56  readonly cwd: () => Promise<string>
57  /**
58   * The session's id: what `claude --resume` returns to, and the key of the
59   * radar it left open.
60   */
61  readonly sessionId: () => Promise<string>
62  /**
63   * Runs a program by its argument vector, no shell: git, and
64   * `claude agents --json` for the sessions.
65   */
66  readonly run: Run
67  /**
68   * The subagents of this session, as `$.agent.list()` gives them; none
69   * where the engine cannot say.
70   */
71  readonly agents: () => Promise<readonly AgentRow[]>
72  /**
73   * The person's theme setting, `$.config.list()`'s `theme` row; null where
74   * it cannot be read.
75   */
76  readonly themeSetting: () => Promise<string | null>
77  /**
78   * A file's text, or null where it cannot be read.
79   */
80  readonly readText: (path: string) => Promise<string | null>
81  /**
82   * Milliseconds since the epoch.
83   */
84  readonly now: () => Promise<number>
85  readonly after: (ms: number, fn: () => void) => Timer
86  /**
87   * Draws the band again from what the state holds.
88   */
89  readonly invalidate: () => void
90  readonly toast: (text: string) => void
91  /**
92   * A native notification by the person's own channel.
93   */
94  readonly notify: (text: string) => Promise<void>
95  /**
96   * Queues a prompt that starts a turn once the session is idle.
97   */
98  readonly submit: (text: string) => Promise<void>
99  /**
100   * The plugin's store, kept between sessions and shared by every session on
101   * the machine: the holds, per repository, and the radar each session left
102   * open, per session.
103   */
104  readonly store: {
105    readonly get: (key: string) => Promise<unknown>
106    readonly set: (key: string, value: unknown) => Promise<void>
107    readonly delete: (key: string) => Promise<void>
108    readonly keys: () => Promise<string[]>
109  }
110  readonly state: RadarState
111  readonly options: RadarOptions
112}
113
hooks/limits.ts 55 lines
1/**
2 * The radar's fixed numbers, in one place.
3 */
4const Limits = {
5  /**
6   * How long one git run may take. Git under `/mnt/c` and on network shares
7   * is slow; a run past this counts as failed.
8   */
9  GIT_TIMEOUT_MS: 20_000,
10  /**
11   * How long `claude agents --json` may take.
12   */
13  SESSIONS_TIMEOUT_MS: 10_000,
14  /**
15   * Git runs in flight at once during a read.
16   */
17  GIT_PARALLEL: 8,
18  /**
19   * Between two checks for a change while the radar is open.
20   */
21  POLL_MS: 4_000,
22  /**
23   * How long a change must stand still before the radar reads the
24   * repository again: a commit, a rebase or a checkout moves several refs
25   * in a row.
26   */
27  SETTLE_MS: 2_000,
28  /**
29   * Longest a caller's comment or status line is kept, in characters.
30   */
31  COMMENT_CHARS: 200,
32  /**
33   * Longest a hold's reason is kept, in characters.
34   */
35  REASON_CHARS: 200,
36  /**
37   * How far back the base's first-parent line is read to tell a branch that
38   * landed through a merge from a fresh one; a tip older than that reads as
39   * fresh unless the branch's reflog shows it landed.
40   */
41  FIRST_PARENT_COMMITS: 10_000,
42  /**
43   * Names a summary lists before `+n more`.
44   */
45  LIST_NAMES: 3,
46  /**
47   * Sessions whose open radar the store keeps for a resume; past it, the
48   * one saved longest ago is dropped. One is a few hundred bytes, more with
49   * a caller's comments.
50   */
51  SAVED_SESSIONS: 50,
52} as const
53
54export default Limits
55
hooks/names.ts 15 lines
1/**
2 * The words the radar shows, and the names it registers.
3 */
4
5export const BAND_TITLE = 'Worktree Radar'
6
7export const COMMAND_NAME = 'radar'
8
9export const COMMAND_DESCRIPTION =
10  'Live view of your branches and worktrees above the prompt: what lands cleanly, what conflicts, who is still working'
11
12export const COMMAND_HINT = '[base] | help | hold <branch> [reason] | unhold <branch> | off'
13
14export const TOOL_NAME = 'radar'
15
hooks/radar.ts 479 lines
1import type { Timer } from 'claude-code'
2
3import type { Hold, RadarConfig } from '../types'
4
5import { commentsAfter, readSnapshot } from './analysis'
6import { dueMarksOf, eventsOf, wakeTextOf, type RadarEvent } from './changes'
7import { gitOf, type Git } from './git'
8import type { Host } from './host'
9import Limits from './limits'
10import { HEADS_FORMAT, parseWorktrees, placeOf } from './repo'
11import { folderKeyOf, parseSessions, SESSIONS_ARGV, type AgentRow, type SessionRow } from './sessions'
12import { clipChars, sanitize } from './text'
13
14/**
15 * The radar at work while it is open: the polls that notice a change, the
16 * reads that follow it, and what a read tells the session.
17 *
18 * A poll is cheap (the branch tips, the worktree list, the sessions, the
19 * holds, the session's own agents); a read runs every simulated merge, so it runs only once a change has settled, one
20 * at a time. The band redraws from the state each read writes.
21 */
22
23/**
24 * The store key prefix of a repository's holds; the repository's shared
25 * `.git` folder follows, so every worktree of it sees the same holds.
26 */
27export const HOLDS_KEY_PREFIX = 'holds:'
28
29/**
30 * How long a read may stand before the radar reads again with no change
31 * seen: uncommitted changes and branch ages move without a ref moving.
32 */
33export const REFRESH_MS = 60_000
34
35/**
36 * Polls between two listings of the sessions; the polls between reuse the
37 * last list.
38 */
39export const SESSIONS_EVERY = 2
40
41export function holdsKeyOf(commonDir: string): string {
42  return `${HOLDS_KEY_PREFIX}${folderKeyOf(commonDir)}`
43}
44
45/**
46 * The holds a store value holds, each checked: what the store holds is
47 * untrusted.
48 *
49 * @param value what the store answered
50 * @returns the holds by branch
51 */
52export function holdsIn(value: unknown): Record<string, Hold> {
53  const holds: Record<string, Hold> = {}
54
55  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
56    return holds
57  }
58
59  for (const [branch, hold] of Object.entries(value as Record<string, unknown>)) {
60    const fields = hold as Record<string, unknown> | null
61
62    if (fields !== null && typeof fields === 'object' && typeof fields['reason'] === 'string' && typeof fields['at'] === 'number') {
63      holds[branch] = { reason: fields['reason'], at: fields['at'] }
64    }
65  }
66
67  return holds
68}
69
70/**
71 * What a poll saw: one string that changes when anything the lanes depend
72 * on moved, and the sessions it listed on the way.
73 */
74type Probe = {
75  readonly fingerprint: string
76  readonly sessions: SessionRow[] | null
77}
78
79export type Radar = {
80  /**
81   * Starts the polls for the open radar, and reads at once.
82   */
83  readonly start: (host: Host) => Promise<void>
84  /**
85   * Stops the polls; a read under way is dropped.
86   */
87  readonly stop: () => void
88  /**
89   * Reads now, after any read under way.
90   */
91  readonly readNow: (host: Host) => Promise<void>
92  /**
93   * Holds a branch with a reason, or lets go of it with null.
94   *
95   * @returns null, or why not
96   */
97  readonly setHold: (host: Host, root: string, branch: string, reason: string | null) => Promise<string | null>
98  /**
99   * Comments on a branch's current tip, or on every tip until it lands
100   * (`isKept`); clears the comment with an empty text.
101   *
102   * @returns null, or why not
103   */
104  readonly setComment: (host: Host, root: string, branch: string, text: string, isKept: boolean) => Promise<string | null>
105}
106
107/**
108 * Lists the machine's Claude sessions.
109 *
110 * @param host the engine's calls
111 * @returns the sessions, or null where `claude agents --json` failed
112 */
113async function sessionsOf(host: Host): Promise<SessionRow[] | null> {
114  try {
115    const out = await host.run(SESSIONS_ARGV, { timeoutMs: Limits.SESSIONS_TIMEOUT_MS })
116
117    return out.exitCode === 0 ? parseSessions(out.stdout) : null
118  } catch {
119    return null
120  }
121}
122
123/**
124 * Takes the cheap fingerprint of a repository. Two things in it move no ref:
125 * the holds, which another session sets, and the state of the session's own
126 * agents that work in a worktree, which says whether one is still at work
127 * while its worktree's lock stays.
128 *
129 * @param host the engine's calls
130 * @param git git, bound to the radar's folder
131 * @param holds the repository's holds, as the store has them now
132 * @param agents the session's own agents, as `$.agent.list()` has them now
133 * @param listed the sessions listed a moment ago, to skip listing them again
134 * @returns the fingerprint, or null where git failed
135 */
136async function probeOf(
137  host: Host,
138  git: Git,
139  holds: Readonly<Record<string, Hold>>,
140  agents: readonly AgentRow[],
141  listed?: SessionRow[] | null,
142): Promise<Probe | null> {
143  const [heads, trees, sessions] = await Promise.all([
144    git(['for-each-ref', `--format=${HEADS_FORMAT}`, 'refs/heads']),
145    git(['worktree', 'list', '--porcelain']),
146    listed === undefined ? sessionsOf(host) : Promise.resolve(listed),
147  ])
148
149  if (heads === null || trees === null) {
150    return null
151  }
152
153  const roots = parseWorktrees(trees.stdout).map(tree => folderKeyOf(tree.path))
154  const here = (sessions ?? [])
155    .filter(session => roots.some(root => folderKeyOf(session.cwd) === root || folderKeyOf(session.cwd).startsWith(`${root}/`)))
156    .map(session => `${session.id}:${session.status}:${folderKeyOf(session.cwd)}`)
157    .sort()
158
159  const held = Object.entries(holds)
160    .map(([branch, hold]) => `${branch}:${hold.at}:${hold.reason}`)
161    .sort()
162  // An agent's worktree is named for it, and its lock names it too
163  const working = agents
164    .filter(agent => trees.stdout.includes(`agent-${agent.id}`))
165    .map(agent => `${agent.id}:${agent.status}`)
166    .sort()
167
168  return {
169    fingerprint: [heads.stdout, trees.stdout, sessions === null ? '?' : here.join('\n'), held.join('\n'), working.join('\n')].join('\0'),
170    sessions,
171  }
172}
173
174/**
175 * Makes the radar of one session. Its timers and reads are the module's: a
176 * reload of the module starts them over, from the state the host kept.
177 *
178 * @returns the radar
179 */
180export function createRadar(): Radar {
181  let generation = 0
182  let timer: Timer | null = null
183  let shownFingerprint: string | null = null
184  let pendingFingerprint: string | null = null
185  let lastReadAt = 0
186  let ticks = 0
187  let lastSessions: SessionRow[] | null | undefined
188  let running: Promise<void> | null = null
189  let queued: Promise<void> | null = null
190  const commonDirs = new Map<string, string>()
191  const reported = new Set<string>()
192  // The wake the session has not taken yet, and the events found since
193  let pendingWake: Promise<void> | null = null
194  let outbox: RadarEvent[] = []
195
196  const schedule = (host: Host, at: number, ms: number): void => {
197    timer?.cancel()
198    timer = host.after(ms, () => {
199      void tick(host, at).catch(() => schedule(host, at, host.options.pollMs))
200    })
201  }
202
203  const holdsFor = async (host: Host, git: Git, root: string): Promise<Record<string, Hold>> => {
204    let commonDir = commonDirs.get(root)
205
206    if (commonDir === undefined) {
207      commonDir = (await placeOf(git))?.commonDir
208
209      if (commonDir === undefined) {
210        return {}
211      }
212
213      commonDirs.set(root, commonDir)
214    }
215
216    return holdsIn(await host.store.get(holdsKeyOf(commonDir)).catch(() => undefined))
217  }
218
219  /**
220   * Sends what the outbox holds as one wake, unless the session has not
221   * taken the last one yet: a submitted prompt settles only once its turn
222   * starts, after whatever turn the session is in. The events found
223   * meanwhile go out together once it has, so a long turn ends in one wake,
224   * not one per read. Nothing waits on it: a read that waited stalled the
225   * polls for the whole turn, and a read the session's own tool call ran
226   * waited on a wake that could start only after that call.
227   */
228  const sendWakes = (host: Host): void => {
229    if (pendingWake !== null || outbox.length === 0) {
230      return
231    }
232
233    const text = wakeTextOf(outbox)
234
235    outbox = []
236    pendingWake = host
237      .submit(text)
238      .catch(() => undefined)
239      .finally(() => {
240        pendingWake = null
241        sendWakes(host)
242      })
243  }
244
245  const announce = async (host: Host, radar: RadarConfig, events: readonly RadarEvent[]): Promise<void> => {
246    const fresh = events.filter(event => !reported.has(event.key))
247
248    for (const event of fresh) {
249      reported.add(event.key)
250    }
251
252    outbox.push(...fresh.filter(event => radar.wake.includes(event.kind)))
253    sendWakes(host)
254
255    const ready = fresh.filter(event => event.kind === 'ready')
256
257    if (host.options.notifyReady && ready.length > 0) {
258      const text = ready.map(event => `${event.branch?.name ?? ''} is ready to land (#${event.branch?.rank ?? '?'})`).join(' · ')
259
260      await host.notify(sanitize(text)).catch(() => undefined)
261    }
262  }
263
264  const read = async (host: Host): Promise<void> => {
265    const at = generation
266    const radar = await host.state.radar.get()
267
268    if (radar === null) {
269      return
270    }
271
272    const git = gitOf(host.run, radar.root)
273    const [holds, agents] = await Promise.all([holdsFor(host, git, radar.root), host.agents()])
274    const probe = await probeOf(host, git, holds, agents)
275
276    lastSessions = probe?.sessions
277    const [comments, dueMarks, now, self] = await Promise.all([
278      host.state.comments.get(),
279      host.state.dueMarks.get(),
280      host.now(),
281      host.sessionId().catch(() => null),
282    ])
283    const result = await readSnapshot({
284      git,
285      readText: host.readText,
286      sessions: probe?.sessions ?? null,
287      agents,
288      self,
289      holds,
290      comments,
291      dueMarks,
292      base: radar.base,
293      now,
294    })
295
296    if (at !== generation) {
297      return
298    }
299
300    lastReadAt = now
301
302    if (result.problem !== undefined) {
303      await host.state.problem.set(() => result.problem)
304
305      return
306    }
307
308    commonDirs.set(radar.root, result.commonDir)
309    shownFingerprint = probe?.fingerprint ?? null
310
311    const [before, theme, shownTheme] = await Promise.all([
312      host.state.snapshot.get(),
313      host.themeSetting(),
314      host.state.themeSetting.get(),
315    ])
316
317    // The theme picks the base's color; a read keeps it current
318    if (theme !== shownTheme) {
319      await host.state.themeSetting.set(() => theme)
320    }
321
322    await host.state.snapshot.set(() => result.snapshot)
323    await host.state.problem.set(() => null)
324    await host.state.dueMarks.set(marks => dueMarksOf(marks, result.snapshot))
325    await host.state.comments.set(comments => commentsAfter(comments, result.snapshot))
326    await announce(host, radar, eventsOf(before, result.snapshot))
327  }
328
329  const readNow = (host: Host): Promise<void> => {
330    if (queued !== null) {
331      return queued
332    }
333
334    const runRead = (): Promise<void> => {
335      running = read(host).finally(() => {
336        running = null
337      })
338
339      return running
340    }
341
342    if (running === null) {
343      return runRead()
344    }
345
346    queued = running
347      .catch(() => undefined)
348      .then(() => {
349        queued = null
350
351        return runRead()
352      })
353
354    return queued
355  }
356
357  const tick = async (host: Host, at: number): Promise<void> => {
358    if (at !== generation) {
359      return
360    }
361
362    const radar = await host.state.radar.get()
363
364    if (radar === null) {
365      return
366    }
367
368    // Listing the sessions starts the claude CLI: every other poll is enough
369    ticks += 1
370
371    const git = gitOf(host.run, radar.root)
372    const [holds, agents] = await Promise.all([holdsFor(host, git, radar.root), host.agents()])
373    const probe = await probeOf(host, git, holds, agents, ticks % SESSIONS_EVERY === 0 ? undefined : lastSessions)
374    const now = await host.now()
375
376    lastSessions = probe?.sessions
377
378    if (at !== generation) {
379      return
380    }
381
382    if (probe !== null && probe.fingerprint !== shownFingerprint) {
383      // A change reads once it stands still: a commit, a rebase or a
384      // checkout moves several refs in a row
385      if (probe.fingerprint !== pendingFingerprint) {
386        pendingFingerprint = probe.fingerprint
387        schedule(host, at, Limits.SETTLE_MS)
388
389        return
390      }
391
392      pendingFingerprint = null
393      await readNow(host)
394    } else if (now - lastReadAt >= REFRESH_MS) {
395      await readNow(host)
396    } else {
397      // Ages move on between reads
398      host.invalidate()
399    }
400
401    schedule(host, at, host.options.pollMs)
402  }
403
404  return {
405    start: async host => {
406      generation += 1
407      shownFingerprint = null
408      pendingFingerprint = null
409
410      const at = generation
411
412      await readNow(host)
413
414      if (at === generation) {
415        schedule(host, at, host.options.pollMs)
416      }
417    },
418    stop: () => {
419      generation += 1
420      timer?.cancel()
421      timer = null
422      shownFingerprint = null
423      pendingFingerprint = null
424      // A closed radar wakes no one; a wake already submitted is the engine's
425      outbox = []
426    },
427    readNow,
428    setHold: async (host, root, branch, reason) => {
429      const git = gitOf(host.run, root)
430      const place = await placeOf(git)
431
432      if (place === null) {
433        return 'not in a git repository'
434      }
435
436      if (reason !== null && (await git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])) === null) {
437        return `no branch named ${branch}`
438      }
439
440      const key = holdsKeyOf(place.commonDir)
441      const holds = holdsIn(await host.store.get(key).catch(() => undefined))
442
443      if (reason === null) {
444        delete holds[branch]
445      } else {
446        holds[branch] = { reason: clipChars(reason.trim(), Limits.REASON_CHARS), at: await host.now() }
447      }
448
449      await host.store.set(key, holds)
450
451      return null
452    },
453    setComment: async (host, root, branch, text, isKept) => {
454      const tip = await gitOf(host.run, root)(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
455
456      if (tip === null) {
457        return `no branch named ${branch}`
458      }
459
460      const sha = tip.stdout.trim()
461      const kept = clipChars(text.trim(), Limits.COMMENT_CHARS)
462
463      await host.state.comments.set(comments => {
464        const next = { ...comments }
465
466        if (kept === '') {
467          delete next[branch]
468        } else {
469          next[branch] = { sha, text: kept, isKept }
470        }
471
472        return next
473      })
474
475      return null
476    },
477  }
478}
479
hooks/rows.ts 1154 lines
1import type { Color, ThemeKey } from 'claude-code'
2
3import type { BranchInfo, Lane, Snapshot } from '../types'
4
5import { shortList } from './analysis'
6import { BAND_TITLE } from './names'
7import { cellWidth, padEnd, sanitize, truncateEnd, truncateMiddle } from './text'
8
9/**
10 * The band's lines, laid out in cells before anything is drawn: pure, so
11 * every width can be tested without the engine.
12 */
13
14/**
15 * A run of text drawn in one style.
16 */
17export type Segment = {
18  readonly text: string
19  /**
20   * A theme key, which follows the person's theme; a raw color only where a
21   * constant says why.
22   */
23  readonly color?: Color
24  readonly dim?: boolean
25  readonly bold?: boolean
26  readonly italic?: boolean
27}
28
29export type Line = {
30  readonly key: string
31  readonly segments: readonly Segment[]
32}
33
34export type BandModel = {
35  readonly snapshot: Snapshot | null
36  readonly problem: string | null
37  /**
38   * A caller's line under the queue.
39   */
40  readonly status: string | null
41  /**
42   * The folder the radar was opened from.
43   */
44  readonly root: string
45  /**
46   * Now, in milliseconds since the epoch.
47   */
48  readonly now: number
49  /**
50   * The cells each line may take.
51   */
52  readonly columns: number
53  /**
54   * The rows the band shows whole; past them it scrolls.
55   */
56  readonly maxRows: number
57  /**
58   * Whether the help stands in for the queue.
59   */
60  readonly isHelpShown: boolean
61  /**
62   * Whether the queue is folded into the header's one line.
63   */
64  readonly isCompact: boolean
65  /**
66   * The person's theme setting, for the base's color; null where unknown.
67   */
68  readonly themeSetting: string | null
69}
70
71/**
72 * The band's title, leading the status line (the foot, or the compact
73 * line).
74 */
75export function titleOf(): Segment[] {
76  return [{ text: BAND_TITLE, bold: true }]
77}
78
79export const LANE_LABELS: Readonly<Record<Lane, string>> = {
80  READY: 'ready',
81  NEEDS_REBASE: 'needs rebase',
82  STACKED: 'stacked',
83  IN_PROGRESS: 'in progress',
84  HOLD: 'on hold',
85  CLEANUP: 'cleanup',
86}
87
88/**
89 * The lane labels of a narrow band.
90 */
91export const SHORT_LANE_LABELS: Readonly<Record<Lane, string>> = {
92  READY: 'ready',
93  NEEDS_REBASE: 'rebase',
94  STACKED: 'stack',
95  IN_PROGRESS: 'wip',
96  HOLD: 'hold',
97  CLEANUP: 'done',
98}
99
100/**
101 * What each lane means, for the help; `{base}` stands for the base branch.
102 */
103export const LANE_MEANINGS: Readonly<Record<Lane, string>> = {
104  READY: 'merges cleanly into {base}, nothing uncommitted, no busy session in its worktree',
105  NEEDS_REBASE: 'conflicts with {base}: it needs a rebase before it can land',
106  STACKED: 'holds every commit of another open branch: that one lands first',
107  IN_PROGRESS: 'uncommitted changes, a rebase under way, or a busy Claude session or a running subagent in its worktree',
108  HOLD: 'parked by you with /radar hold: out of the landing order, whatever else it shows, until you let it go',
109  CLEANUP: 'nothing left to land: landed in {base}, or no commits and no session',
110}
111
112/**
113 * Theme keys, so the lanes follow the person's theme.
114 */
115export const LANE_COLORS: Readonly<Record<Lane, ThemeKey>> = {
116  READY: 'success',
117  NEEDS_REBASE: 'error',
118  STACKED: 'warning',
119  IN_PROGRESS: 'suggestion',
120  HOLD: 'merged',
121  CLEANUP: 'inactive',
122}
123
124/**
125 * Lanes folded into one line each, in this order, while the band has too
126 * few rows: the ones that need nobody's action first, the ones waiting on
127 * a rebase last. The ready lane never folds whole: its head stays, in
128 * landing order, and only its tail folds (`bandLinesOf`).
129 */
130const FOLD_ORDER: readonly Lane[] = ['CLEANUP', 'IN_PROGRESS', 'HOLD', 'STACKED', 'NEEDS_REBASE']
131
132/**
133 * From this width on a row shows every column; below it the commits ahead
134 * and the comments go and the lane labels shorten; below the next, the age
135 * and the details go too.
136 */
137export const WIDE_COLUMNS = 64
138export const MEDIUM_COLUMNS = 40
139
140/**
141 * The fixed columns: the rank (` 1 `), the gap after the branch, commits
142 * ahead (`   +3`) and the age of the last commit (`  25m`), the numbers
143 * right-aligned, and the gap before the detail and before a comment.
144 */
145const RANK_CELLS = 3
146const GAP_CELLS = 2
147const AHEAD_CELLS = 5
148const AGE_CELLS = 5
149
150/**
151 * The most of what the fixed columns leave that the branch column may
152 * take, per width: wide, then medium; and the least it takes, so its name
153 * (`BRANCH`) fits.
154 */
155const BRANCH_SHARE = { wide: 0.45, medium: 0.5 } as const
156const BRANCH_MIN_CELLS = 6
157
158/**
159 * With comments shown, the most of what the row leaves after the branch
160 * that the detail column may take, and the least a comment column is worth:
161 * below it, the comments go.
162 */
163const DETAIL_SHARE = 0.6
164const DETAIL_MIN_CELLS = 6
165const COMMENT_MIN_CELLS = 16
166
167/**
168 * The comment column's name, which the header must show where the column
169 * starts: before the controls at the header's end.
170 */
171const COMMENT_NAME = 'COMMENT'
172
173/**
174 * The base branch's color in the status line, by the person's theme
175 * setting. A dark theme gets xterm's orange 208, the one the owner's own
176 * status line names the branch with, so the two agree (6.9:1 on #1e1e1e).
177 * On white 208 reads 2.4:1 (seen live in the light theme, the band's one
178 * color under 4.4:1), so a light theme gets 130, a darker orange (4.7:1).
179 * Where the background is unknown (`auto`, which follows the terminal, a
180 * custom theme, the setting not read), 166 between them: 3.8:1 on white,
181 * 4.4:1 on #1e1e1e. Raw colors: no theme key is an orange that reads on
182 * white (Claude's own `claude` is 3.2:1 there), and a mod cannot learn
183 * which theme `auto` chose.
184 *
185 * @param themeSetting the theme setting, `/config`'s `theme` row
186 * @returns the color
187 */
188export function baseColorOf(themeSetting: string | null): string {
189  if (themeSetting?.startsWith('dark') === true) {
190    return '#ff8700'
191  }
192
193  return themeSetting?.startsWith('light') === true ? '#af5f00' : '#d75f00'
194}
195
196/**
197 * What marks a comment as Claude's words: the mark Claude Code draws its
198 * own lines with (`✻ Crunched for 7s`), in Claude's color.
199 */
200const COMMENT_MARK: Segment = { text: '✻ ', color: 'claude' }
201
202/**
203 * The queue's columns at one width: each one's cells, 0 for a column the
204 * width leaves out. The branch column is as wide as the longest name, up
205 * to its share; the others are fixed, so every row of a drawing lines up.
206 */
207export type TableColumns = {
208  readonly branch: number
209  readonly lane: number
210  readonly labels: Readonly<Record<Lane, string>>
211  readonly ahead: number
212  readonly age: number
213  readonly hasDetail: boolean
214  /**
215   * The detail column's cells when a comment column follows it; 0 when the
216   * detail takes the rest of the row.
217   */
218  readonly detail: number
219  /**
220   * The comment column's cells; 0 when no comment is shown.
221   */
222  readonly comment: number
223}
224
225/**
226 * Sizes the queue's columns to the band and the branches it shows. A
227 * comment column comes only on a wide band, while a row shown has a
228 * comment; the detail column is then as wide as its longest, up to its
229 * share, and the comment takes the rest. The comment column reaches the
230 * row's end, under the header's controls: it is at least wide enough for
231 * its name to start before them, the detail giving up cells for that, and
232 * goes when the detail would be left too few.
233 *
234 * @param columns the cells a row may take
235 * @param names the branches' names, sanitized
236 * @param details the cells each shown branch's detail takes
237 * @param isCommented whether a branch shown has a comment
238 * @param reserved the cells the controls take at the header's end
239 * @returns the columns
240 */
241export function tableColumnsOf(
242  columns: number,
243  names: readonly string[],
244  details: readonly number[] = [],
245  isCommented = false,
246  reserved = 0,
247): TableColumns {
248  const shortLabel = Math.max(...Object.values(SHORT_LANE_LABELS).map(label => label.length))
249  const longest = Math.max(BRANCH_MIN_CELLS, ...names.map(cellWidth))
250  const fit = (left: number, share: number): number => Math.max(Math.min(longest, Math.floor(left * share)), 1)
251
252  if (columns >= WIDE_COLUMNS) {
253    const lane = Math.max(...Object.values(LANE_LABELS).map(label => label.length))
254    const left = columns - RANK_CELLS - GAP_CELLS - lane - AHEAD_CELLS - AGE_CELLS - GAP_CELLS
255
256    const branch = fit(left, BRANCH_SHARE.wide)
257    const rest = left - branch
258    const least = Math.max(COMMENT_MIN_CELLS, reserved + COMMENT_NAME.length)
259    const detail = Math.min(Math.max(DETAIL_MIN_CELLS, ...details), Math.floor(rest * DETAIL_SHARE), rest - GAP_CELLS - least)
260    const comment = rest - detail - GAP_CELLS
261    const isShown = isCommented && detail >= DETAIL_MIN_CELLS && comment >= least
262
263    return {
264      branch,
265      lane,
266      labels: LANE_LABELS,
267      ahead: AHEAD_CELLS,
268      age: AGE_CELLS,
269      hasDetail: true,
270      detail: isShown ? detail : 0,
271      comment: isShown ? comment : 0,
272    }
273  }
274
275  if (columns >= MEDIUM_COLUMNS) {
276    const left = columns - RANK_CELLS - GAP_CELLS - shortLabel - AGE_CELLS - GAP_CELLS
277
278    return {
279      branch: fit(left, BRANCH_SHARE.medium),
280      lane: shortLabel,
281      labels: SHORT_LANE_LABELS,
282      ahead: 0,
283      age: AGE_CELLS,
284      hasDetail: true,
285      detail: 0,
286      comment: 0,
287    }
288  }
289
290  return {
291    branch: Math.max(Math.min(longest, columns - RANK_CELLS - GAP_CELLS - shortLabel), 1),
292    lane: shortLabel,
293    labels: SHORT_LANE_LABELS,
294    ahead: 0,
295    age: 0,
296    hasDetail: false,
297    detail: 0,
298    comment: 0,
299  }
300}
301
302/**
303 * The row naming the queue's columns, aligned with them.
304 *
305 * @param table the columns
306 * @param columns the cells the row may take
307 * @returns the row
308 */
309export function columnHeaderOf(table: TableColumns, columns: number): Line {
310  const name = (text: string): Segment => ({ text, dim: true })
311  const groups: Segment[][] = [
312    [name('#'.padStart(RANK_CELLS - 1).padEnd(RANK_CELLS))],
313    [name(padEnd(truncateEnd('BRANCH', table.branch), table.branch))],
314    [name(' '.repeat(GAP_CELLS)), name(padEnd(truncateEnd('LANE', table.lane), table.lane))],
315  ]
316
317  if (table.ahead > 0) {
318    groups.push([name('AHEAD'.padStart(table.ahead))])
319  }
320
321  if (table.age > 0) {
322    groups.push([name('AGE'.padStart(table.age))])
323  }
324
325  if (table.hasDetail) {
326    groups.push([name(' '.repeat(GAP_CELLS)), name(table.comment > 0 ? padEnd('DETAIL', table.detail) : 'DETAIL')])
327  }
328
329  if (table.comment > 0) {
330    groups.push([name(' '.repeat(GAP_CELLS)), name(COMMENT_NAME)])
331  }
332
333  // A column name is shown whole or not at all, never cut
334  return { key: 'columns', segments: fitWhole(groups, columns) }
335}
336
337/**
338 * One of the header's controls, drawn as its hotkey, a colon and its label
339 * (`h: help`).
340 */
341export type Control = {
342  readonly key: 'help' | 'compact' | 'exit'
343  readonly hotkey: string
344  readonly label: string
345}
346
347/**
348 * Below this width the controls' labels shorten to one character each, so
349 * the header keeps room for the counts.
350 */
351export const SHORT_CONTROLS_BELOW = 90
352
353/**
354 * The header's controls: the help, the compact line and closing the radar,
355 * each labelled for what a press does now.
356 *
357 * @param columns the cells the band's lines take
358 * @param isHelpShown whether the help is shown
359 * @param isCompact whether the queue is folded into one line
360 * @returns the controls, in drawing order
361 */
362export function controlsOf(columns: number, isHelpShown: boolean, isCompact: boolean): Control[] {
363  const isShort = columns < SHORT_CONTROLS_BELOW
364
365  return [
366    { key: 'help', hotkey: 'h', label: isShort ? '?' : isHelpShown ? 'back' : 'help' },
367    { key: 'compact', hotkey: 'c', label: isShort ? (isCompact ? '+' : '-') : isCompact ? 'expand' : 'compact' },
368    { key: 'exit', hotkey: 'x', label: isShort ? '×' : 'exit' },
369  ]
370}
371
372/**
373 * The cells the controls take at the header's end: a gap before them, each
374 * `k: label`, a gap between two.
375 *
376 * @param controls the controls
377 * @returns their width in cells
378 */
379export function controlsCellsOf(controls: readonly Control[]): number {
380  return controls.reduce((sum, control) => sum + 1 + cellWidth(`${control.hotkey}: ${control.label}`), 0)
381}
382
383/**
384 * The cells the header's text may take beside the controls.
385 */
386function headerRoomOf(model: BandModel, columns: number): number {
387  return columns - controlsCellsOf(controlsOf(columns, model.isHelpShown, model.isCompact))
388}
389
390/**
391 * What the frame around the band takes: a border and a cell of padding on
392 * each side, a border row above and below.
393 */
394export const FRAME_CELLS = 4
395export const FRAME_ROWS = 2
396
397/**
398 * Below this width the band drops its frame, so the rows keep the cells.
399 */
400export const FRAME_MIN_COLUMNS = 24
401
402/**
403 * The room the band's lines get inside its frame.
404 */
405export type Frame = {
406  readonly isFramed: boolean
407  /**
408   * The cells each line may take.
409   */
410  readonly columns: number
411  /**
412   * The rows the lines show whole.
413   */
414  readonly maxRows: number
415}
416
417/**
418 * Fits the frame to the band: framed from `FRAME_MIN_COLUMNS` on, the lines
419 * laid out inside the border and its padding.
420 *
421 * @param columns the cells the band's tree is laid out in (`bodyColumns`)
422 * @param maxRows the rows the band shows whole
423 * @returns the frame and the room inside it
424 */
425export function frameOf(columns: number, maxRows: number): Frame {
426  return columns >= FRAME_MIN_COLUMNS
427    ? { isFramed: true, columns: columns - FRAME_CELLS, maxRows: Math.max(maxRows - FRAME_ROWS, 1) }
428    : { isFramed: false, columns, maxRows }
429}
430
431/**
432 * An age as people read it: `42s`, `5m`, `3h`, `2d`.
433 *
434 * @param seconds the age
435 * @returns the age in its largest whole unit
436 */
437export function ageOf(seconds: number): string {
438  for (const [unit, size] of [
439    ['d', 86_400],
440    ['h', 3_600],
441    ['m', 60],
442  ] as const) {
443    if (seconds >= size) {
444      return `${Math.floor(seconds / size)}${unit}`
445    }
446  }
447
448  return `${Math.max(Math.floor(seconds), 0)}s`
449}
450
451/**
452 * The cells a line takes.
453 */
454export function lineWidthOf(line: Line): number {
455  return line.segments.reduce((sum, segment) => sum + cellWidth(segment.text), 0)
456}
457
458/**
459 * Cuts a line's segments at a width, the last one kept ending in `…`.
460 *
461 * @param segments the line's runs, sanitized
462 * @param cells the cells it may take
463 * @returns the runs that fit
464 */
465export function fitSegments(segments: readonly Segment[], cells: number): Segment[] {
466  const out: Segment[] = []
467  let left = cells
468
469  for (const segment of segments) {
470    const width = cellWidth(segment.text)
471
472    if (width <= left) {
473      out.push(segment)
474      left -= width
475
476      continue
477    }
478
479    if (left > 0) {
480      out.push({ ...segment, text: truncateEnd(segment.text, left) })
481    }
482
483    break
484  }
485
486  return out
487}
488
489/**
490 * What the radar reads about a row's branch beside its lane: why it is
491 * there and what comes next, from the facts alone. A caller's words are the
492 * comment's, never here.
493 *
494 * @param branch the branch
495 * @param base the base branch's name
496 * @returns the detail's runs, sanitized
497 */
498export function detailOf(branch: BranchInfo, base: string, ranks: ReadonlyMap<string, number> = new Map()): Segment[] {
499  const parts: Segment[] = []
500  const tree = branch.worktree
501  const name = (text: string): string => sanitize(text)
502  // Another branch in a detail is named with its place in the landing order
503  // when it has one (`#1 feat/x`)
504  const ref = (other: string): string => {
505    const rank = ranks.get(other)
506
507    return rank === undefined ? name(other) : `#${rank} ${name(other)}`
508  }
509
510  switch (branch.lane) {
511    case 'READY':
512      if (branch.dependents.length > 0) {
513        parts.push({ text: `${name(shortList(branch.dependents, 1))} stacked on it` })
514      }
515
516      // Ready already says it merges cleanly: the detail only adds what else
517      if (branch.breaks.length > 0) {
518        parts.push({ text: `forces a rebase of ${shortList(branch.breaks.map(broken => ref(broken.branch)), 1)}` })
519      }
520
521      break
522    case 'NEEDS_REBASE':
523      parts.push({ text: `conflicts: ${name(shortList(branch.conflicts ?? [], 1))}` })
524      parts.push(
525        (branch.waitsFor?.length ?? 0) > 0
526          ? { text: `rebase after ${shortList((branch.waitsFor ?? []).map(ref), 1)}` }
527          : { text: 'rebase due', color: 'error' },
528      )
529      break
530    case 'STACKED':
531      parts.push({ text: `lands after ${ref(branch.stackedOn ?? '')}` })
532      break
533    case 'IN_PROGRESS': {
534      const busy = tree?.sessions.find(session => session.status !== 'idle')
535
536      if (tree?.isRebasing) {
537        parts.push({ text: 'rebasing' })
538      } else if (busy?.kind === 'agent') {
539        // An agent's own name is an id: its task says more, or its session
540        parts.push({
541          text: busy.description ? `agent: ${name(busy.description)}` : busy.parent ? `agent of ${name(busy.parent)}` : `agent ${name(busy.name)}`,
542        })
543      } else if (busy !== undefined) {
544        parts.push({ text: `session ${name(busy.name)} ${name(busy.status)}` })
545      }
546
547      if (tree?.isBeingRemoved) {
548        parts.push({ text: 'worktree being removed' })
549      } else if ((tree?.dirty ?? 0) > 0) {
550        parts.push({ text: `${tree?.dirty} uncommitted` })
551      }
552
553      if (branch.ahead === 0) {
554        parts.push({ text: branch.isLanded ? `landed in ${name(base)}` : 'no commits yet' })
555      }
556
557      break
558    }
559    case 'HOLD':
560      parts.push({ text: branch.hold?.reason ? name(branch.hold.reason) : 'held' })
561      break
562    case 'CLEANUP':
563      if (branch.isLanded || tree === null) {
564        parts.push({ text: branch.isLanded ? `landed in ${name(base)}` : `nothing beyond ${name(base)}` })
565      }
566
567      // Where it is, the snapshot says; here, that there is one to remove
568      if (tree !== null) {
569        parts.push({ text: tree.isBeingRemoved ? 'worktree being removed' : 'has a worktree' })
570      }
571
572      break
573  }
574
575  return parts.flatMap((part, at) => (at === 0 ? [part] : [{ text: ' · ', dim: true }, part]))
576}
577
578/**
579 * A caller's comment on a row: Claude's words, as only the model calls the
580 * radar's tool, marked as Claude Code marks its own lines and quoted, the
581 * quote cut inside its marks to fit.
582 *
583 * @param branch the branch
584 * @param cells the cells the comment may take
585 * @returns the comment's runs, sanitized; none without a comment or room
586 */
587export function commentOf(branch: BranchInfo, cells: number): Segment[] {
588  const marks = cellWidth(COMMENT_MARK.text) + 2
589  const text = branch.comment === null ? '' : sanitize(branch.comment)
590
591  if (text === '' || cells <= marks) {
592    return []
593  }
594
595  return [COMMENT_MARK, { text: `“${truncateEnd(text, cells - marks)}”`, color: 'inactive', italic: true }]
596}
597
598/**
599 * One branch's row.
600 *
601 * @param branch the branch
602 * @param base the base branch's name
603 * @param table the queue's columns
604 * @param columns the cells the row may take
605 * @param nowSeconds now, in seconds since the epoch
606 * @param ranks the landing order's places, by branch
607 * @returns the row
608 */
609export function rowOf(
610  branch: BranchInfo,
611  base: string,
612  table: TableColumns,
613  columns: number,
614  nowSeconds: number,
615  ranks: ReadonlyMap<string, number> = new Map(),
616): Line {
617  // A branch out of the landing order has no place in it: a dim dash
618  const rank = branch.rank === null ? '-' : String(branch.rank)
619  const segments: Segment[] = [
620    { text: rank.padStart(RANK_CELLS - 1).padEnd(RANK_CELLS), bold: branch.rank !== null, dim: branch.rank === null },
621    { text: padEnd(truncateMiddle(sanitize(branch.name), table.branch), table.branch) },
622    { text: ' '.repeat(GAP_CELLS) },
623    { text: padEnd(table.labels[branch.lane], table.lane), color: LANE_COLORS[branch.lane] },
624  ]
625
626  if (table.ahead > 0) {
627    segments.push({ text: (branch.ahead > 0 ? `+${branch.ahead}` : '').padStart(table.ahead) })
628  }
629
630  // The age of a branch with work of its own, beside what it has ahead
631  if (table.age > 0) {
632    segments.push({ text: (branch.ahead > 0 ? ageOf(nowSeconds - branch.lastCommitAt) : '').padStart(table.age) })
633  }
634
635  if (table.hasDetail) {
636    const detail = detailOf(branch, base, ranks)
637
638    segments.push({ text: ' '.repeat(GAP_CELLS) })
639
640    if (table.comment > 0) {
641      const fitted = fitSegments(detail, table.detail)
642
643      segments.push(...fitted, { text: ' '.repeat(table.detail - lineWidthOf({ key: '', segments: fitted }) + GAP_CELLS) })
644      segments.push(...commentOf(branch, table.comment))
645    } else {
646      segments.push(...detail)
647    }
648  }
649
650  return { key: `row:${branch.name}`, segments: fitSegments(segments, columns) }
651}
652
653/**
654 * The line that stands for a folded lane, or for the rest of one whose head
655 * has rows of its own: its label, how many it holds (`+` for the rest),
656 * and their names.
657 *
658 * @param lane the lane
659 * @param branches the branches the line stands for
660 * @param columns the cells it may take
661 * @param isRest whether rows above show the lane's first branches
662 */
663function foldedLineOf(lane: Lane, branches: readonly BranchInfo[], columns: number, isRest = false): Line {
664  return {
665    key: `fold:${lane}`,
666    segments: fitSegments(
667      [
668        { text: '   ' },
669        { text: `${LANE_LABELS[lane]} (${isRest ? '+' : ''}${branches.length}): `, color: LANE_COLORS[lane] },
670        { text: sanitize(branches.map(branch => branch.name).join(', ')), dim: true },
671      ],
672      columns,
673    ),
674  }
675}
676
677/**
678 * Where the radar looks: the repository and the base everything lands on.
679 */
680function placeOf(model: BandModel, snapshot: Snapshot): Segment[] {
681  return [
682    { text: sanitize(nameOf(snapshot.repo || model.root)) },
683    { text: ' ' },
684    { text: `[${sanitize(snapshot.base.name)}]`, color: baseColorOf(model.themeSetting) },
685  ]
686}
687
688/**
689 * What lands next: the branch ranked #1, or that nothing is ready.
690 */
691function nextOf(snapshot: Snapshot): Segment[] {
692  return snapshot.next === null
693    ? [{ text: 'nothing ready to land', dim: true }]
694    : [{ text: 'land next → ', dim: true }, { text: sanitize(snapshot.next), color: 'success', bold: true }]
695}
696
697/**
698 * Joins groups of runs with a dim ` · `, leaving out empty ones.
699 */
700function joined(groups: readonly (readonly Segment[])[]): Segment[] {
701  return groups
702    .filter(group => group.length > 0)
703    .flatMap((group, at) => (at === 0 ? [...group] : [{ text: ' · ', dim: true }, ...group]))
704}
705
706/**
707 * The first of several layouts that fits whole, else the last cut to fit:
708 * a line that gives up its least needed parts first.
709 */
710function firstFitting(layouts: readonly (readonly Segment[])[], cells: number): Segment[] {
711  const fitting = layouts.find(layout => lineWidthOf({ key: '', segments: layout }) <= cells)
712
713  return fitting === undefined ? fitSegments(layouts[layouts.length - 1] ?? [], cells) : [...fitting]
714}
715
716/**
717 * The top line when there is no queue to show: the help's heading, the
718 * read under way, or why there is none; compact, the whole band in a line.
719 */
720function headerOf(model: BandModel, snapshot: Snapshot | null, columns: number): Line {
721  const room = headerRoomOf(model, columns)
722
723  if (model.isHelpShown) {
724    return { key: 'header', segments: fitSegments([{ text: 'Help', bold: true }, { text: ' · what each part of the band means', dim: true }], room) }
725  }
726
727  if (snapshot === null) {
728    const repo = sanitize(nameOf(model.root))
729
730    return {
731      key: 'header',
732      segments: fitSegments(
733        joined([
734          titleOf(),
735          model.problem === null ? [{ text: `reading ${repo}…`, dim: true }] : [{ text: sanitize(model.problem), color: 'error' }],
736        ]),
737        room,
738      ),
739    }
740  }
741
742  // Compact, the band is this line, read as the foot's status line is
743  const failed: Segment[] = model.problem === null ? [] : [{ text: 'last read failed', color: 'error' }]
744
745  return { key: 'header', segments: firstFitting(statusLayoutsOf(model, snapshot, failed), room) }
746}
747
748/**
749 * Keeps whole groups of runs while they fit, in order, and stops at the
750 * first that does not: for lines where a cut piece would read wrong (the
751 * column names, the counts).
752 *
753 * @param groups the groups, each drawn whole or not at all
754 * @param cells the cells they may take
755 * @returns the runs of the groups that fit
756 */
757export function fitWhole(groups: readonly (readonly Segment[])[], cells: number): Segment[] {
758  const out: Segment[] = []
759  let left = cells
760
761  for (const group of groups) {
762    const width = lineWidthOf({ key: '', segments: group })
763
764    if (width > left) {
765      break
766    }
767
768    out.push(...group)
769    left -= width
770  }
771
772  return out
773}
774
775/**
776 * What each lane holds, in lane order: `3 ready`, `1 stacked`; with the
777 * short labels, `2 rebase`, `1 wip`.
778 */
779function countsOf(snapshot: Snapshot, labels: Readonly<Record<Lane, string>> = LANE_LABELS): Segment[] {
780  return (Object.keys(labels) as Lane[]).flatMap((lane, at, lanes) => {
781    const count = snapshot.branches.filter(branch => branch.lane === lane).length
782    const isFirst = lanes.slice(0, at).every(earlier => !snapshot.branches.some(branch => branch.lane === earlier))
783
784    return count === 0
785      ? []
786      : [...(isFirst ? [] : [{ text: ' · ', dim: true }]), { text: `${count} ${labels[lane]}`, color: LANE_COLORS[lane] }]
787  })
788}
789
790/**
791 * The status line's layouts, fullest first: the title, the place, the
792 * counts and what lands next; then the counts in short labels, then without
793 * the title, without the place, without the counts.
794 */
795function statusLayoutsOf(model: BandModel, snapshot: Snapshot, after: readonly Segment[]): Segment[][] {
796  const place = placeOf(model, snapshot)
797  const counts = countsOf(snapshot)
798  const short = countsOf(snapshot, SHORT_LANE_LABELS)
799  const next = nextOf(snapshot)
800
801  return [
802    joined([titleOf(), place, counts, next, after]),
803    joined([titleOf(), place, short, next, after]),
804    joined([place, short, next, after]),
805    joined([short, next, after]),
806    joined([next, after]),
807  ]
808}
809
810/**
811 * Cells between the foot's left part and its messages, at the least, and
812 * the fewest cells a message is shown in.
813 */
814const FOOT_GAP_CELLS = 3
815const FOOT_MESSAGE_MIN_CELLS = 10
816
817/**
818 * How many of the status line's fullest layouts a message may push the
819 * line down through: the long labels and the title, not the place.
820 */
821const MESSAGE_OUTRANKS = 3
822
823/**
824 * The foot, a status line read left to right: the radar, where it looks,
825 * what each lane holds, what lands next. Its right end is for messages: a
826 * caller's line, then what to know (who works in the main worktree and
827 * what is uncommitted there, sessions unknown, a failed read). A narrow band gives up the messages,
828 * then the long lane labels, the title, the place and the
829 * counts, before what lands next.
830 */
831function footOf(model: BandModel, snapshot: Snapshot, columns: number): Line {
832  const layouts = statusLayoutsOf(model, snapshot, [])
833  const messages = joined(messagesOf(model, snapshot).map(message => [message]))
834  const messageCells = lineWidthOf({ key: '', segments: messages })
835  const widthOf = (segments: readonly Segment[]) => lineWidthOf({ key: '', segments })
836  // A message is worth the long labels and the title, never the place, the
837  // counts or what lands next: past those it is cut
838  const roomy = layouts
839    .slice(0, MESSAGE_OUTRANKS)
840    .find(layout => messageCells > 0 && widthOf(layout) + FOOT_GAP_CELLS + messageCells <= columns)
841  const left = roomy ?? firstFitting(layouts, columns)
842  const leftCells = widthOf(left)
843  const room = columns - leftCells - FOOT_GAP_CELLS
844  const shown = room >= FOOT_MESSAGE_MIN_CELLS ? fitSegments(messages, room) : []
845  const shownCells = widthOf(shown)
846
847  return {
848    key: 'foot',
849    segments: shownCells === 0 ? left : [...left, { text: ' '.repeat(columns - leftCells - shownCells) }, ...shown],
850  }
851}
852
853/**
854 * The messages at the foot's right end: a caller's line first, then what
855 * to know.
856 */
857function messagesOf(model: BandModel, snapshot: Snapshot): Segment[] {
858  const dirty = snapshot.base.mainWorktreeDirty ?? 0
859  const sessions = snapshot.base.mainWorktreeSessions
860  const messages: Segment[] = []
861
862  if (model.status !== null && model.status !== '') {
863    messages.push({ text: sanitize(model.status), color: 'remember' })
864  }
865
866  // Who works in the main worktree, then what is uncommitted there: work
867  // that shows on no branch's row
868  const main = [
869    ...(sessions.length === 1 ? [`session ${sanitize(sessions[0]?.name ?? '')}`] : []),
870    ...(sessions.length > 1 ? [`${sessions.length} sessions`] : []),
871    ...(dirty > 0 ? [`${dirty} uncommitted`] : []),
872  ]
873
874  if (main.length > 0) {
875    messages.push({ text: `main worktree: ${main.join(', ')}`, color: 'warning' })
876  }
877
878  if (!snapshot.areSessionsKnown) {
879    messages.push({ text: 'sessions unknown', dim: true })
880  }
881
882  if (model.problem !== null) {
883    messages.push({ text: `last read failed: ${sanitize(model.problem)}`, color: 'error' })
884  }
885
886  return messages
887}
888
889function nameOf(path: string): string {
890  const parts = path.split(/[\\/]/).filter(part => part !== '')
891
892  return parts[parts.length - 1] ?? path
893}
894
895/**
896 * Lays the band out: the header, a row per branch, and the foot, in at most
897 * `maxRows` lines. When rows run short, lanes fold into a line each
898 * (`FOLD_ORDER`), then the ready lane's tail folds behind its head, then
899 * what still does not fit is cut from the bottom: the foot always stays,
900 * its counts naming every lane. Past `maxRows`, Claude Code cuts the band's
901 * bottom itself, and the foot with it.
902 *
903 * @param model what the band shows and the room it has
904 * @returns the lines, each at most `columns` wide
905 */
906export function bandLinesOf(model: BandModel): Line[] {
907  const columns = Math.max(model.columns, 1)
908  const { snapshot } = model
909
910  if (model.isHelpShown) {
911    return [headerOf(model, snapshot, columns), ...helpLinesOf(snapshot?.base.name ?? 'the base', columns)]
912  }
913
914  if (snapshot === null) {
915    return [headerOf(model, null, columns)]
916  }
917
918  const header = headerOf(model, snapshot, columns)
919
920  if (model.isCompact) {
921    return [header]
922  }
923  if (snapshot.branches.length === 0) {
924    return [
925      {
926        key: 'empty',
927        segments: fitSegments([{ text: `no branches besides ${sanitize(snapshot.base.name)}`, dim: true }], headerRoomOf(model, columns)),
928      },
929      footOf(model, snapshot, columns),
930    ]
931  }
932
933  const foot = footOf(model, snapshot, columns)
934
935  const nowSeconds = Math.floor(model.now / 1000)
936  const folded = new Set<Lane>()
937  // The column names and the foot take a row each
938  const room = Math.max(model.maxRows - 2, 1)
939  const rowsWith = (): number =>
940    snapshot.branches.filter(branch => !folded.has(branch.lane)).length +
941    [...folded].filter(lane => snapshot.branches.some(branch => branch.lane === lane)).length
942
943  for (const lane of FOLD_ORDER) {
944    if (rowsWith() <= room) {
945      break
946    }
947
948    // A lane of one branch takes a row either way
949    if (snapshot.branches.filter(branch => branch.lane === lane).length > 1) {
950      folded.add(lane)
951    }
952  }
953
954  // The ready lane keeps its head, in landing order; a tail of two or more
955  // folds into one line after it
956  const ready = snapshot.branches.filter(branch => branch.lane === 'READY')
957  const over = rowsWith() - room
958  const trimmed = over > 0 ? Math.max(ready.length - over - 1, 1) : ready.length
959  const head = ready.length - trimmed >= 2 ? trimmed : ready.length
960  const rest = ready.slice(head)
961
962  const ranks = new Map(snapshot.branches.flatMap(branch => (branch.rank === null ? [] : [[branch.name, branch.rank] as const])))
963  const shown = snapshot.branches.filter(branch => !folded.has(branch.lane) && !rest.includes(branch))
964  const table = tableColumnsOf(
965    columns,
966    shown.map(branch => sanitize(branch.name)),
967    shown.map(branch => lineWidthOf({ key: '', segments: detailOf(branch, snapshot.base.name, ranks) })),
968    shown.some(branch => branch.comment !== null && branch.comment !== ''),
969    columns - headerRoomOf(model, columns),
970  )
971  const body: Line[] = []
972
973  for (const lane of Object.keys(LANE_LABELS) as Lane[]) {
974    const inLane = snapshot.branches.filter(branch => branch.lane === lane)
975
976    if (inLane.length === 0) {
977      continue
978    }
979
980    if (folded.has(lane)) {
981      body.push(foldedLineOf(lane, inLane, columns))
982    } else {
983      body.push(
984        ...inLane.filter(branch => !rest.includes(branch)).map(branch => rowOf(branch, snapshot.base.name, table, columns, nowSeconds, ranks)),
985      )
986
987      if (lane === 'READY' && rest.length > 0) {
988        body.push(foldedLineOf(lane, rest, columns, true))
989      }
990    }
991  }
992
993  // Still too many: the lanes lowest in the order go, the foot stays
994  return [columnHeaderOf(table, headerRoomOf(model, columns)), ...body.slice(0, room), foot]
995}
996
997/**
998 * Breaks text into lines of at most `cells` cells at its spaces; a word
999 * longer than a line is cut.
1000 *
1001 * @param text one paragraph, sanitized
1002 * @param cells the cells a line may take
1003 * @returns the lines
1004 */
1005export function wrapWords(text: string, cells: number): string[] {
1006  const lines: string[] = []
1007  let line = ''
1008
1009  for (const word of text.split(' ').filter(part => part !== '')) {
1010    const next = line === '' ? word : `${line} ${word}`
1011
1012    if (cellWidth(next) <= cells) {
1013      line = next
1014
1015      continue
1016    }
1017
1018    if (line !== '') {
1019      lines.push(line)
1020    }
1021
1022    line = cellWidth(word) <= cells ? word : truncateEnd(word, cells)
1023  }
1024
1025  return line === '' ? lines : [...lines, line]
1026}
1027
1028/**
1029 * One entry of the help: a term in its own style, what it means beside it.
1030 */
1031type HelpEntry = {
1032  /**
1033   * A mark drawn before the term, in its own style (a comment's ✻).
1034   */
1035  readonly mark?: Segment
1036  readonly term: Segment
1037  readonly meaning: string
1038}
1039
1040/**
1041 * Cells the help's term column takes, its gap included.
1042 */
1043const TERM_CELLS = 24
1044
1045/**
1046 * Below this width a meaning goes under its term, not beside it.
1047 */
1048const SIDE_BY_SIDE_COLUMNS = TERM_CELLS + 20
1049
1050/**
1051 * What the lanes, the notes and the controls mean, laid out to the band.
1052 *
1053 * @param base the base branch's name
1054 * @param columns the cells each line may take
1055 * @returns the help's lines, under the header
1056 */
1057export function helpLinesOf(base: string, columns: number): Line[] {
1058  const onto = sanitize(base)
1059  const sections: readonly { readonly title: string; readonly entries: readonly HelpEntry[] }[] = [
1060    {
1061      title: 'Lanes',
1062      entries: (Object.keys(LANE_LABELS) as Lane[]).map(lane => ({
1063        term: { text: LANE_LABELS[lane], color: LANE_COLORS[lane] },
1064        meaning: LANE_MEANINGS[lane].replaceAll('{base}', onto),
1065      })),
1066    },
1067    {
1068      title: 'On a row',
1069      entries: [
1070        {
1071          term: { text: '1 2 3', bold: true },
1072          meaning:
1073            'the order to land the ready branches in: stack parents first, then whichever forces the least rebase work on the others, then whichever finished first. A branch with - in place of a number cannot land yet: its lane and detail say why',
1074        },
1075        { term: { text: '+3' }, meaning: `commits the branch has that ${onto} lacks` },
1076        { term: { text: '5m' }, meaning: 'since its last commit' },
1077        { term: { text: 'rebase due', color: 'error' }, meaning: 'rebase it now: nothing that lands before it still conflicts with it' },
1078        { term: { text: 'rebase after #2 X' }, meaning: 'X lands first and conflicts with it too; a rebase now would have to be done again' },
1079        { term: { text: 'lands after #1 X' }, meaning: 'stacked on X: it lands once X has landed' },
1080        { term: { text: 'forces a rebase of X' }, meaning: 'landing this branch makes X conflict' },
1081        { term: { text: 'X stacked on it' }, meaning: 'X holds this branch: this one lands first' },
1082        { term: { text: `landed in ${onto}` }, meaning: `its work reached ${onto}: through a merge, or a fast-forward its reflog shows` },
1083        {
1084          mark: COMMENT_MARK,
1085          term: { text: '“handed back”', color: 'inactive', italic: true },
1086          meaning: 'a comment: words Claude left on the branch through the radar\'s tool, shown until the branch moves, or, kept, until it lands. The details beside it are the radar\'s own',
1087        },
1088      ],
1089    },
1090    {
1091      title: 'Status line',
1092      entries: [
1093        { term: { text: `repo [${onto}]` }, meaning: `the repository, and the base branch everything lands on` },
1094        { term: { text: '3 ready · 1 stacked' }, meaning: 'how many branches each lane holds' },
1095        { term: { text: 'land next → X' }, meaning: 'the branch ranked #1' },
1096        { term: { text: 'at the right' }, meaning: 'messages: a skill\'s line, uncommitted files in the main worktree, a failed read' },
1097      ],
1098    },
1099    {
1100      title: 'Controls',
1101      entries: [
1102        { term: { text: 'h' }, meaning: 'this help, and back; the band takes keys after ctrl+x tab or a click' },
1103        { term: { text: 'c' }, meaning: 'folds the radar into its status line: the counts, then what lands next' },
1104        { term: { text: 'x' }, meaning: 'exits the radar, as /radar does' },
1105        {
1106          term: { text: '/radar hold <branch>' },
1107          meaning:
1108            'parks a branch. Words after it are the reason, shown on its row: /radar hold feat/x waits on review. It leaves the ready lane and next skips it. Only the radar notes it: the branch and git stay as they are. Every session of this repository on this machine sees the hold until /radar unhold <branch>',
1109        },
1110      ],
1111    },
1112  ]
1113  const isSideBySide = columns >= SIDE_BY_SIDE_COLUMNS
1114  const lines: Line[] = []
1115
1116  sections.forEach((section, at) => {
1117    // An empty row between two sections; a space, so the row keeps its height
1118    if (at > 0) {
1119      lines.push({ key: `help:gap:${section.title}`, segments: [{ text: ' ' }] })
1120    }
1121
1122    lines.push({ key: `help:${section.title}`, segments: fitSegments([{ text: section.title, bold: true }], columns) })
1123
1124    for (const entry of section.entries) {
1125      const key = `help:${section.title}:${entry.term.text}`
1126      const marked: Segment[] = [{ text: '  ' }, ...(entry.mark === undefined ? [] : [entry.mark]), entry.term]
1127      const term = fitSegments(marked, Math.max(columns, 1))
1128
1129      if (isSideBySide) {
1130        const cells = lineWidthOf({ key: '', segments: term })
1131
1132        wrapWords(entry.meaning, columns - TERM_CELLS).forEach((text, at) => {
1133          lines.push({
1134            key: `${key}:${at}`,
1135            segments: fitSegments(
1136              [...(at === 0 ? [...term, { text: ' '.repeat(Math.max(TERM_CELLS - cells, 0)) }] : [{ text: ' '.repeat(TERM_CELLS) }]), { text, dim: true }],
1137              columns,
1138            ),
1139          })
1140        })
1141
1142        continue
1143      }
1144
1145      lines.push({ key, segments: term })
1146      wrapWords(entry.meaning, Math.max(columns - 4, 1)).forEach((text, at) => {
1147        lines.push({ key: `${key}:${at}`, segments: fitSegments([{ text: '    ' }, { text, dim: true }], columns) })
1148      })
1149    }
1150  })
1151
1152  return lines
1153}
1154
hooks/saved.ts 212 lines
1import type { CallerComment, RadarConfig } from '../types'
2
3import { wakeEventsOf } from './changes'
4import type { Host } from './host'
5import Limits from './limits'
6import { clipChars } from './text'
7
8/**
9 * The radar a session left open, kept in the store for its resume. The
10 * session state does not outlive the process, and `/clear`, `/resume` and
11 * `/branch` reset it; the store outlives both.
12 *
13 * One key per session, by its id: `claude --continue`, `claude --resume
14 * <id>` and `/resume` return to a session under its own id, so its key finds
15 * what it left. A new session has a new id and finds nothing.
16 */
17
18/**
19 * The store key prefix of a session's radar; the session's id follows.
20 */
21export const SAVED_KEY_PREFIX = 'resume:'
22
23/**
24 * What a session's radar comes back as: the open radar with its caller's
25 * wake list, the view, and what a caller set. The read is not kept: a
26 * resumed radar reads again. Neither is the help, which is a passing look.
27 */
28export type SavedRadar = {
29  readonly radar: RadarConfig
30  readonly compact: boolean
31  readonly status: string | null
32  readonly comments: Readonly<Record<string, CallerComment>>
33  readonly dueMarks: Readonly<Record<string, string>>
34  /**
35   * When it was saved, in milliseconds since the epoch.
36   */
37  readonly savedAt: number
38}
39
40export function savedKeyOf(sessionId: string): string {
41  return `${SAVED_KEY_PREFIX}${sessionId}`
42}
43
44function fieldsOf(value: unknown): Readonly<Record<string, unknown>> | null {
45  return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
46}
47
48function commentsIn(value: unknown): Record<string, CallerComment> {
49  const comments: Record<string, CallerComment> = {}
50
51  for (const [branch, comment] of Object.entries(fieldsOf(value) ?? {})) {
52    const fields = fieldsOf(comment)
53
54    if (fields !== null && typeof fields['sha'] === 'string' && typeof fields['text'] === 'string' && fields['text'] !== '') {
55      comments[branch] = { sha: fields['sha'], text: clipChars(fields['text'], Limits.COMMENT_CHARS), isKept: fields['isKept'] === true }
56    }
57  }
58
59  return comments
60}
61
62function dueMarksIn(value: unknown): Record<string, string> {
63  const marks: Record<string, string> = {}
64
65  for (const [branch, sha] of Object.entries(fieldsOf(value) ?? {})) {
66    if (typeof sha === 'string') {
67      marks[branch] = sha
68    }
69  }
70
71  return marks
72}
73
74/**
75 * The radar a store value holds, each field checked: what the store holds
76 * is untrusted, and a field it lacks takes its default.
77 *
78 * @param value what the store answered
79 * @returns the radar, or null where the value names no folder to watch
80 */
81export function savedRadarIn(value: unknown): SavedRadar | null {
82  const fields = fieldsOf(value)
83  const radar = fieldsOf(fields?.['radar'])
84  const root = radar?.['root']
85  const base = radar?.['base']
86  const status = fields?.['status']
87  const savedAt = fields?.['savedAt']
88
89  if (fields === null || radar === null || typeof root !== 'string' || root === '') {
90    return null
91  }
92
93  return {
94    radar: {
95      root,
96      base: typeof base === 'string' && base !== '' ? base : null,
97      wake: wakeEventsOf(radar['wake']),
98    },
99    compact: fields['compact'] === true,
100    status: typeof status === 'string' && status !== '' ? clipChars(status, Limits.COMMENT_CHARS) : null,
101    comments: commentsIn(fields['comments']),
102    dueMarks: dueMarksIn(fields['dueMarks']),
103    savedAt: typeof savedAt === 'number' && Number.isFinite(savedAt) ? savedAt : 0,
104  }
105}
106
107/**
108 * The keys to drop so that at most `keep` remain: the ones saved longest
109 * ago.
110 *
111 * @param saved each key and when it was saved
112 * @param keep how many to keep
113 * @returns the keys to drop
114 */
115export function droppedKeysOf(saved: readonly { readonly key: string; readonly savedAt: number }[], keep: number): string[] {
116  return [...saved]
117    .sort((a, b) => b.savedAt - a.savedAt || a.key.localeCompare(b.key))
118    .slice(Math.max(keep, 0))
119    .map(entry => entry.key)
120}
121
122/**
123 * The open radar as the session state holds it now.
124 *
125 * @param host the engine's calls
126 * @returns the radar to save, or null while it is closed
127 */
128export async function savedRadarOf(host: Host): Promise<SavedRadar | null> {
129  const [radar, compact, status, comments, dueMarks, savedAt] = await Promise.all([
130    host.state.radar.get(),
131    host.state.compact.get(),
132    host.state.status.get(),
133    host.state.comments.get(),
134    host.state.dueMarks.get(),
135    host.now(),
136  ])
137
138  return radar === null ? null : { radar, compact, status, comments, dueMarks, savedAt }
139}
140
141/**
142 * Saves a session's radar under its id.
143 *
144 * @param host the engine's calls
145 * @param sessionId the session's id
146 * @param saved the radar
147 */
148export async function saveRadar(host: Host, sessionId: string, saved: SavedRadar): Promise<void> {
149  await host.store.set(savedKeyOf(sessionId), saved)
150}
151
152/**
153 * Saves the radar as the session state holds it now, if it is open.
154 *
155 * @param host the engine's calls
156 * @param sessionId the session's id
157 */
158export async function keepRadar(host: Host, sessionId: string): Promise<void> {
159  const saved = await savedRadarOf(host)
160
161  if (saved !== null) {
162    await saveRadar(host, sessionId, saved)
163  }
164}
165
166/**
167 * Drops what a session saved: its radar was closed.
168 */
169export async function forgetRadar(host: Host, sessionId: string): Promise<void> {
170  await host.store.delete(savedKeyOf(sessionId))
171}
172
173/**
174 * The radar a session left open, if any.
175 *
176 * @param host the engine's calls
177 * @param sessionId the session's id
178 * @returns the radar, or null
179 */
180export async function loadRadar(host: Host, sessionId: string): Promise<SavedRadar | null> {
181  return savedRadarIn(await host.store.get(savedKeyOf(sessionId)))
182}
183
184/**
185 * Drops the sessions saved longest ago past `SAVED_SESSIONS`, keeping the
186 * one just saved: every session that ends with the radar open leaves a key,
187 * and most are never resumed.
188 *
189 * Each session's own key keeps sessions from writing over each other. A
190 * drop can still race a session saving the dropped key at that moment: that
191 * session comes back closed, nothing else.
192 *
193 * @param host the engine's calls
194 * @param sessionId the session just saved
195 */
196export async function pruneSaved(host: Host, sessionId: string): Promise<void> {
197  const kept = savedKeyOf(sessionId)
198  const others = (await host.store.keys()).filter(key => key.startsWith(SAVED_KEY_PREFIX) && key !== kept)
199
200  if (others.length < Limits.SAVED_SESSIONS) {
201    return
202  }
203
204  const saved = await Promise.all(
205    others.map(async key => ({ key, savedAt: savedRadarIn(await host.store.get(key))?.savedAt ?? 0 })),
206  )
207
208  for (const dropped of droppedKeysOf(saved, Limits.SAVED_SESSIONS - 1)) {
209    await host.store.delete(dropped)
210  }
211}
212
hooks/text.ts 363 lines
1import { EMOJI, EMOJI_MODIFIER_BASE, WIDE, ZERO_WIDTH } from './cell-widths'
2
3/**
4 * Characters that never reach the terminal as themselves: C0 and C1
5 * controls (an escape sequence in a file name or a file's text draws as
6 * text), the bidirectional controls Claude Code itself strips (they could
7 * reorder what a name shows) and lone surrogates.
8 */
9const UNSAFE_CHARACTERS = /[\u0000-\u001F\u007F-\u009F\u061C\u202A-\u202E\u2066-\u2069\uD800-\uDFFF]/gu
10
11/**
12 * Combining marks one character keeps: enough for every script, few enough
13 * that a name stacked with thousands of marks cannot pass as one cell.
14 */
15const MAX_MARKS = 8
16
17const MARK_RUN = new RegExp(`([\\p{Mn}\\p{Me}]{${MAX_MARKS}})[\\p{Mn}\\p{Me}]+`, 'gu')
18
19/**
20 * Makes untrusted text safe to draw on one row: a C0 control or DEL becomes
21 * its Control Picture (a tab `␉`, a line feed `␊`, an escape `␛`), any other
22 * unsafe character U+FFFD, and a run of combining marks is cut to
23 * `MAX_MARKS`.
24 *
25 * @param text a file name, or one line of a file with its tabs expanded
26 * @returns the text, safe to draw
27 */
28export function sanitize(text: string): string {
29  return text
30    .replace(UNSAFE_CHARACTERS, char => {
31      const code = char.charCodeAt(0)
32
33      return code < 0x20 ? String.fromCharCode(0x2400 + code) : code === 0x7f ? '\u2421' : '\uFFFD'
34    })
35    .replace(MARK_RUN, '$1')
36}
37
38/**
39 * Whether a code point lies in one of a table's [first, last] pairs.
40 */
41function isIn(table: readonly number[], cp: number): boolean {
42  let lo = 0
43  let hi = table.length / 2
44
45  while (lo < hi) {
46    const mid = (lo + hi) >> 1
47
48    if ((table[mid * 2] ?? 0) <= cp) {
49      lo = mid + 1
50    } else {
51      hi = mid
52    }
53  }
54
55  return lo > 0 && cp <= (table[lo * 2 - 1] ?? -1)
56}
57
58/**
59 * The cells one code point takes on its own: 0, 1 or 2.
60 */
61function codePointWidth(cp: number): number {
62  return isIn(ZERO_WIDTH, cp) ? 0 : isIn(WIDE, cp) ? 2 : 1
63}
64
65/**
66 * Whether a code point can start an emoji cluster: the Emoji property, from
67 * U+203C on and outside U+2C00 – U+1EFFF, as Bun counts it.
68 */
69function isEmojiBase(cp: number): boolean {
70  return cp >= 0x203c && (cp < 0x2c00 || cp >= 0x1f000) && isIn(EMOJI, cp)
71}
72
73const isRegionalIndicator = (cp: number) => cp >= 0x1f1e6 && cp <= 0x1f1ff
74const isSkinTone = (cp: number) => cp >= 0x1f3fb && cp <= 0x1f3ff
75
76const SEGMENTER =
77  typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null
78
79const SKIN_TONE = /[\u{1F3FB}-\u{1F3FF}]/u
80
81/**
82 * Splits a grapheme before each skin tone that follows no Emoji_Modifier_Base
83 * (`‼🏽` is two characters), as Bun does and Unicode's current rules do not.
84 */
85function splitSkinTones(grapheme: string): string[] {
86  if (!SKIN_TONE.test(grapheme)) {
87    return [grapheme]
88  }
89
90  const parts: string[] = []
91  let part = ''
92  let previous = -1
93
94  for (const char of grapheme) {
95    const cp = char.codePointAt(0) ?? 0
96
97    if (isSkinTone(cp) && part !== '' && !isIn(EMOJI_MODIFIER_BASE, previous)) {
98      parts.push(part)
99      part = ''
100    }
101
102    part += char
103    previous = cp
104  }
105
106  return [...parts, part]
107}
108
109/**
110 * Splits text into what Claude Code measures as one character: graphemes
111 * by `Intl.Segmenter`, split where Bun breaks and Unicode does not, or code
112 * points where the runtime has no segmenter.
113 */
114function graphemesOf(text: string): string[] {
115  if (SEGMENTER === null) {
116    return Array.from(text)
117  }
118
119  return Array.from(SEGMENTER.segment(text), part => part.segment).flatMap(splitSkinTones)
120}
121
122/**
123 * The cells one grapheme takes, by the rules Claude Code measures with
124 * (Bun's `stringWidth`, East Asian Ambiguous as narrow): a flag, a keycap
125 * and an emoji with a skin tone or a joiner take 2; VS16 widens an emoji to
126 * 2; anything else is the sum of its code points.
127 *
128 * Without `Intl.Segmenter` a grapheme is one code point, and the joiner,
129 * VS16 and the keycap mark count a cell each, so text is never measured
130 * narrower than it draws.
131 */
132function graphemeWidth(grapheme: string): number {
133  const first = grapheme.codePointAt(0) ?? 0
134
135  if (grapheme.length === 1 && first < 0x80) {
136    return first >= 0x20 && first < 0x7f ? 1 : 0
137  }
138
139  if (SEGMENTER === null) {
140    return first === 0x200d || first === 0xfe0f || first === 0x20e3 ? 1 : codePointWidth(first)
141  }
142
143  let count = 0
144  let sum = 0
145  let hasKeycap = false
146  let hasRegional = false
147  let hasSkinTone = false
148  let hasJoiner = false
149  let hasVs15 = false
150  let hasVs16 = false
151
152  for (const char of grapheme) {
153    const cp = char.codePointAt(0) ?? 0
154
155    count += 1
156    sum += codePointWidth(cp)
157    hasKeycap ||= cp === 0x20e3
158    hasRegional ||= isRegionalIndicator(cp)
159    hasSkinTone ||= isSkinTone(cp)
160    hasJoiner ||= cp === 0x200d
161    hasVs15 ||= cp === 0xfe0e
162    hasVs16 ||= cp === 0xfe0f
163  }
164
165  const isEmoji = isEmojiBase(first)
166
167  if (hasRegional && count >= 2) {
168    return 2
169  }
170
171  if (hasKeycap) {
172    return 2
173  }
174
175  if (hasRegional) {
176    return 1
177  }
178
179  if (isEmoji && (hasSkinTone || hasJoiner)) {
180    return 2
181  }
182
183  if (hasVs15 || hasVs16) {
184    const base = codePointWidth(first)
185
186    return base === 2 || (hasVs16 && (isEmoji || first === 0xa9 || first === 0xae)) ? 2 : base
187  }
188
189  return sum
190}
191
192const PRINTABLE_ASCII = /^[\u0020-\u007E]*$/
193
194/**
195 * The terminal cells text takes, as Claude Code measures it: CJK and most
196 * emoji two, combining marks none.
197 *
198 * @param text one line of text, sanitized
199 * @returns its width in cells
200 */
201export function cellWidth(text: string): number {
202  if (PRINTABLE_ASCII.test(text)) {
203    return text.length
204  }
205
206  let width = 0
207
208  for (const grapheme of graphemesOf(text)) {
209    width += graphemeWidth(grapheme)
210  }
211
212  return width
213}
214
215
216/**
217 * Cuts text to a width in cells, keeping its start and end around an
218 * ellipsis; a grapheme is never split.
219 *
220 * @param text one line of text, sanitized
221 * @param cells the cells it may take
222 * @returns the text if it fits, else its start, `…` and its end, at most
223 *   `cells` wide; the start gets the odd cell
224 */
225export function truncateMiddle(text: string, cells: number): string {
226  if (cells <= 0) {
227    return ''
228  }
229
230  if (cellWidth(text) <= cells) {
231    return text
232  }
233
234  if (cells === 1) {
235    return '…'
236  }
237
238  const parts = graphemesOf(text).map(grapheme => ({ grapheme, width: graphemeWidth(grapheme) }))
239  const room = cells - 1
240  let head = 0
241  let headCells = 0
242
243  while (head < parts.length && headCells + (parts[head]?.width ?? 0) <= Math.ceil(room / 2)) {
244    headCells += parts[head]?.width ?? 0
245    head += 1
246  }
247
248  // The end takes what the start left, a cell a wide character did not fit
249  // in included
250  let tail = parts.length
251  let tailCells = 0
252
253  while (tail > head && tailCells + (parts[tail - 1]?.width ?? 0) <= room - headCells) {
254    tailCells += parts[tail - 1]?.width ?? 0
255    tail -= 1
256  }
257
258  const join = (from: number, to: number) =>
259    parts
260      .slice(from, to)
261      .map(part => part.grapheme)
262      .join('')
263
264  return `${join(0, head)}…${join(tail, parts.length)}`
265}
266
267/**
268 * Cuts text to a width in cells, ending it with an ellipsis; a grapheme is
269 * never split.
270 *
271 * @param text one line of text, sanitized
272 * @param cells the cells it may take
273 * @returns the text if it fits, else its start and `…`, at most `cells` wide
274 */
275export function truncateEnd(text: string, cells: number): string {
276  if (cells <= 0) {
277    return ''
278  }
279
280  if (cellWidth(text) <= cells) {
281    return text
282  }
283
284  let kept = ''
285  let used = 0
286
287  for (const grapheme of graphemesOf(text)) {
288    const width = graphemeWidth(grapheme)
289
290    if (used + width > cells - 1) {
291      break
292    }
293
294    kept += grapheme
295    used += width
296  }
297
298  return `${kept}…`
299}
300
301/**
302 * Pads text with spaces to a width in cells, so a row's Button presses
303 * across it.
304 *
305 * @param text one line of text, sanitized
306 * @param cells the width wanted
307 * @returns the text, `cells` wide when it was narrower
308 */
309export function padEnd(text: string, cells: number): string {
310  const width = cellWidth(text)
311
312  return width >= cells ? text : text + ' '.repeat(cells - width)
313}
314
315/**
316 * Caps text at a number of UTF-16 units, ending it with an ellipsis; a
317 * surrogate pair is never split. A size cap for long lines, not a fit to a
318 * row: measuring every line of a large file in cells would cost too much.
319 *
320 * @param text the text
321 * @param max the most UTF-16 units to keep, the ellipsis included
322 * @returns the text, at most `max` units long
323 */
324export function clipChars(text: string, max: number): string {
325  if (text.length <= max) {
326    return text
327  }
328
329  if (max <= 0) {
330    return ''
331  }
332
333  const cut = max - 1
334  const code = text.charCodeAt(cut - 1)
335  const end = cut > 0 && code >= 0xd800 && code <= 0xdbff ? cut - 1 : cut
336
337  return `${text.slice(0, end)}…`
338}
339
340
341/**
342 * A count and its noun: `1 line`, `2 lines`.
343 *
344 * @param count the count
345 * @param noun the noun in the singular
346 * @returns the phrase
347 */
348export function plural(count: number, noun: string): string {
349  return `${count.toLocaleString('en-US')} ${noun}${count === 1 ? '' : 's'}`
350}
351
352/**
353 * An error's message, one short line, safe to draw.
354 *
355 * @param error what was thrown or rejected
356 * @returns the message
357 */
358export function messageOf(error: unknown): string {
359  const message = error instanceof Error ? error.message : String(error)
360
361  return clipChars(sanitize(message.split('\n')[0] ?? ''), 200)
362}
363
hooks/tool.ts 116 lines
1import type { Snapshot, WakeEvent } from '../types'
2
3import { WAKE_EVENTS, wakeEventsOf } from './changes'
4
5/**
6 * The tool the model calls to drive the radar: its description and schema,
7 * the reading of a call's input, and the JSON it answers with.
8 */
9
10export const TOOL_ACTIONS = ['open', 'close', 'snapshot', 'hold', 'unhold', 'comment', 'status'] as const
11
12export type ToolAction = (typeof TOOL_ACTIONS)[number]
13
14export const TOOL_DESCRIPTION = [
15  'Worktree Radar: a live, read-only view of every local branch and worktree of the repository, drawn above the prompt, sorted into lanes on their way into the base branch.',
16  'Lanes: READY (clean worktree, no busy session, merges cleanly; ranked, land #1 first), NEEDS_REBASE (conflicts with the base; isRebaseDue when nothing ahead of it in waitsFor still conflicts with it), STACKED (holds another open branch; land that first), IN_PROGRESS (uncommitted changes, a rebase under way, or a busy Claude session or a running subagent there; worktree.sessions lists both, kind session or agent), HOLD, CLEANUP (nothing beyond the base and no session).',
17  'It never merges, rebases, tags, pushes or moves a ref.',
18  'Actions: open (starts the radar for the session folder and returns the snapshot; `wake` lists events that wake this session with a one-line report, which asks for nothing: ' +
19    WAKE_EVENTS.join(', ') +
20    '), snapshot (reads now and returns the facts as JSON), close, hold/unhold (park a branch, with a reason), comment (a short comment shown on the branch row until its tip moves; with `keep`, until it lands or is gone; empty text clears), status (one line under the queue; empty clears).',
21].join(' ')
22
23export const TOOL_INPUT_SCHEMA = {
24  type: 'object',
25  properties: {
26    action: { type: 'string', enum: [...TOOL_ACTIONS] },
27    base: {
28      type: 'string',
29      description: 'open: the base branch. Detected when left out: origin/HEAD, else main, else master.',
30    },
31    wake: {
32      type: 'array',
33      items: { type: 'string', enum: [...WAKE_EVENTS] },
34      description: 'open: the events that wake this session. None when left out.',
35    },
36    branch: { type: 'string', description: 'hold, unhold, comment: the branch.' },
37    reason: { type: 'string', description: 'hold: why it is held.' },
38    text: { type: 'string', description: "comment: the comment on the branch's current tip; status: the line under the queue. Empty clears." },
39    keep: {
40      type: 'boolean',
41      description: 'comment: true keeps it on the tips that follow, until it is cleared, the branch is gone or it lands.',
42    },
43  },
44  required: ['action'],
45  additionalProperties: false,
46} as const
47
48/**
49 * A call's input, read.
50 */
51export type ToolRequest =
52  | { readonly action: 'open'; readonly base: string | null; readonly wake: WakeEvent[] }
53  | { readonly action: 'close' | 'snapshot' }
54  | { readonly action: 'hold'; readonly branch: string; readonly reason: string }
55  | { readonly action: 'unhold'; readonly branch: string }
56  | { readonly action: 'comment'; readonly branch: string; readonly text: string; readonly keep: boolean }
57  | { readonly action: 'status'; readonly text: string }
58
59function textField(input: Readonly<Record<string, unknown>>, name: string): string | null {
60  const value = input[name]
61
62  return typeof value === 'string' ? value : null
63}
64
65/**
66 * Reads a call's input.
67 *
68 * @param input the call's arguments
69 * @returns the request, or why it cannot be read
70 */
71export function toolRequestOf(input: Readonly<Record<string, unknown>>): ToolRequest | { readonly error: string } {
72  const action = input['action']
73  const branch = textField(input, 'branch')?.trim() ?? ''
74
75  switch (action) {
76    case 'open': {
77      const base = textField(input, 'base')?.trim() ?? ''
78
79      return { action, base: base === '' ? null : base, wake: wakeEventsOf(input['wake']) }
80    }
81    case 'close':
82    case 'snapshot':
83      return { action }
84    case 'hold':
85    case 'unhold':
86    case 'comment':
87      if (branch === '') {
88        return { error: `${action} needs a branch` }
89      }
90
91      return action === 'hold'
92        ? { action, branch, reason: textField(input, 'reason') ?? '' }
93        : action === 'unhold'
94          ? { action, branch }
95          : { action, branch, text: textField(input, 'text') ?? '', keep: input['keep'] === true }
96    case 'status':
97      return { action, text: textField(input, 'text') ?? '' }
98    default:
99      return { error: `action must be one of ${TOOL_ACTIONS.join(', ')}` }
100  }
101}
102
103/**
104 * The snapshot as the tool answers it: every fact but the branches' file
105 * lists, which can run to thousands of paths; `fileCount` stands in.
106 *
107 * @param snapshot the radar's read
108 * @returns the JSON text
109 */
110export function toolJsonOf(snapshot: Snapshot): string {
111  return JSON.stringify({
112    ...snapshot,
113    branches: snapshot.branches.map(({ files, ...branch }) => ({ ...branch, fileCount: files.length })),
114  })
115}
116
hooks/view.tsx 122 lines
1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type { Control, Frame, Line, Segment } from './rows'
4
5/**
6 * The elements the band draws with, from the table `$.ui.resolve(e)` hands
7 * the render hook: the band is raised on the terminal and the desktop.
8 */
9export type Ui = Pick<ElementTable<'terminal' | 'desktop'>, 'Box' | 'Text' | 'Button'>
10
11/**
12 * What the band's controls do, by their keys.
13 */
14export type BandActions = Readonly<Record<Control['key'], () => void>>
15
16/**
17 * The frame's look: rounded, in the color of the prompt's own border, so
18 * the band reads as a panel of Claude Code's.
19 */
20export const FRAME_STYLE = { borderStyle: 'round', borderColor: 'promptBorder', paddingX: 1 } as const
21
22/**
23 * Cells a wide band keeps its text from its sides: the frame's border and
24 * padding. The status line under the frame, and the compact line with no
25 * frame, keep as many, so every line starts where the table's does.
26 */
27const INSET = FRAME_STYLE.paddingX + 1
28
29/**
30 * Draws the band's lines, one row each, with the controls (`h: help`,
31 * `c: compact`, `x: exit`) after the first. On a wide band the lines sit in
32 * a frame and the status line (`foot`) under it, so the two read apart
33 * without a rule between them; compact, the band is its one line with no
34 * frame, an empty row above it keeping it apart from what Claude Code draws
35 * there (the spinner, a tip) as the frame's top edge does. A narrow band has
36 * no frame: the lines follow one another.
37 *
38 * @param ui the surface's elements
39 * @param lines the lines, laid out by `bandLinesOf` for the frame's room
40 * @param columns the cells the band's tree is laid out in, frame included
41 * @param frame the frame, from `frameOf`
42 * @param isCompact whether the band is its compact line
43 * @param controls the first line's controls, from `controlsOf`
44 * @param actions what the controls do
45 * @returns the band's tree
46 */
47export function bandView(
48  ui: Ui,
49  lines: readonly Line[],
50  columns: number,
51  frame: Frame,
52  isCompact: boolean,
53  controls: readonly Control[],
54  actions: BandActions,
55): RenderElement {
56  const { Box, Text, Button } = ui
57  const runs = (segments: readonly Segment[]) =>
58    segments.map((segment, part) => (
59      <Text
60        key={`${part}`}
61        {...(segment.color === undefined ? {} : { color: segment.color })}
62        {...(segment.dim ? { dimColor: true } : {})}
63        {...(segment.bold ? { bold: true } : {})}
64        {...(segment.italic ? { italic: true } : {})}
65      >
66        {segment.text}
67      </Text>
68    ))
69  const rowOf = (line: Line, isFirst: boolean) => (
70    <Box key={line.key} flexDirection="row" width={frame.columns}>
71      <Box flexGrow={1} flexShrink={1}>
72        <Text wrap="truncate-end">{runs(line.segments)}</Text>
73      </Box>
74      {isFirst ? (
75        <Box flexDirection="row" flexShrink={0} columnGap={1} marginLeft={1}>
76          {controls.map(control => (
77            <Button
78              key={control.key}
79              label={control.label}
80              hotkey={control.hotkey}
81              plain
82              dimColor
83              {...(control.key === 'exit' ? { role: 'dismiss' as const } : {})}
84              onPress={actions[control.key]}
85            />
86          ))}
87        </Box>
88      ) : null}
89    </Box>
90  )
91
92  if (!frame.isFramed) {
93    return (
94      <Box flexDirection="column" width={columns} marginTop={isCompact ? 1 : 0}>
95        {lines.map((line, at) => rowOf(line, at === 0))}
96      </Box>
97    )
98  }
99
100  const panel = lines.filter(line => line.key !== 'foot')
101  const foot = lines.find(line => line.key === 'foot')
102
103  return (
104    <Box flexDirection="column" width={columns}>
105      {isCompact ? (
106        <Box key="line" flexDirection="column" width={columns} paddingX={INSET} marginTop={1}>
107          {panel.map((line, at) => rowOf(line, at === 0))}
108        </Box>
109      ) : (
110        <Box key="frame" flexDirection="column" width={columns} {...FRAME_STYLE}>
111          {panel.map((line, at) => rowOf(line, at === 0))}
112        </Box>
113      )}
114      {foot === undefined ? null : (
115        <Box key="status" flexDirection="column" width={columns} paddingX={INSET}>
116          {rowOf(foot, false)}
117        </Box>
118      )}
119    </Box>
120  )
121}
122
hooks/git.ts 108 lines
1import type { ProcessRunInit, ProcessRunResult } from 'claude-code'
2
3import Limits from './limits'
4
5/**
6 * Every git run the radar makes goes through one `Git`, which `gitOf` binds
7 * to the engine's process call.
8 */
9
10/**
11 * Set over the session's environment for every git run: a read never takes
12 * a lock a commit running beside it needs (so a status never writes the
13 * index), and git speaks plain C, whatever the person's language.
14 */
15export const GIT_ENV: Readonly<Record<string, string>> = { GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' }
16
17/**
18 * Runs a program by its argument vector, no shell; resolves once it exits.
19 */
20export type Run = (argv: readonly string[], init: ProcessRunInit) => Promise<ProcessRunResult>
21
22export type GitOptions = {
23  /**
24   * Where git runs; the folder `gitOf` was bound to when left out.
25   */
26  readonly cwd?: string
27  /**
28   * Set over `GIT_ENV`.
29   */
30  readonly env?: Readonly<Record<string, string>>
31  /**
32   * The exit codes that count as an answer; `[0]` when left out.
33   */
34  readonly ok?: readonly number[]
35}
36
37export type GitOutput = {
38  readonly code: number
39  readonly stdout: string
40}
41
42/**
43 * Runs git with its arguments, the command first.
44 *
45 * @returns the exit code and what git wrote, or null where it exited with
46 *   another code, could not start (no git), ran out of time or wrote more
47 *   than the engine reads
48 */
49export type Git = (args: readonly string[], options?: GitOptions) => Promise<GitOutput | null>
50
51/**
52 * Binds git to the engine's process call and a folder.
53 *
54 * @param run the engine's process call
55 * @param cwd the folder git runs in unless a call names another
56 * @returns the git runner
57 */
58export function gitOf(run: Run, cwd: string): Git {
59  return async (args, options = {}) => {
60    try {
61      const result = await run(['git', ...args], {
62        cwd: options.cwd ?? cwd,
63        env: { ...GIT_ENV, ...options.env },
64        timeoutMs: Limits.GIT_TIMEOUT_MS,
65      })
66
67      if (result.isStdoutTruncated || !(options.ok ?? [0]).includes(result.exitCode)) {
68        return null
69      }
70
71      return { code: result.exitCode, stdout: result.stdout }
72    } catch {
73      return null
74    }
75  }
76}
77
78/**
79 * Maps items through an async function, at most `limit` at a time, keeping
80 * their order.
81 *
82 * @param items the inputs
83 * @param limit how many run at once
84 * @param fn the work for one input
85 * @returns the outputs, in the inputs' order
86 */
87export async function mapLimit<T, R>(
88  items: readonly T[],
89  limit: number,
90  fn: (item: T) => Promise<R>,
91): Promise<R[]> {
92  const out: R[] = new Array<R>(items.length)
93  let next = 0
94
95  const worker = async (): Promise<void> => {
96    while (next < items.length) {
97      const at = next
98
99      next += 1
100      out[at] = await fn(items[at] as T)
101    }
102  }
103
104  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
105
106  return out
107}
108