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…

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.

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.
PATH: the conflict checks need git merge-tree --write-tree.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.claude -p.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.
| Command | Does |
|---|---|
/radar | Opens 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 help | Opens 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 off | Closes the radar |

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:
| Control | Does |
|---|---|
h: help | switches between the queue and a help that explains every lane, detail, comment and control |
c: compact | folds the radar into its status line: the counts, then what lands next; c: expand brings the queue back |
x: exit | closes 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.
The radar belongs to the conversation it was opened in:
| You | The radar |
|---|---|
resume a session: claude --continue, claude --resume, /resume | comes 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 session | stays closed |
/clear | stays as it was, but wakes nothing until a skill opens it again: the wakes were asked for by the conversation you cleared |
/branch | stays as it was in the branch, wake list and all |
claude --resume <id> --fork-session | stays 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.
| Lane | Meaning |
|---|---|
| ready | no uncommitted changes, no busy session, merges cleanly; ranked, land #1 first |
| needs rebase | conflicts with the base; rebase due once nothing that lands before it still conflicts with it, else rebase after <branch> |
| stacked | holds every commit of another open branch, plus its own: land that one first |
| in progress | uncommitted 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 hold | parked with /radar hold or the tool |
| cleanup | nothing 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.
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.
| Action | Input | Does |
|---|---|---|
open | base?, wake? | Opens the radar and answers the snapshot |
snapshot | Reads now and answers the snapshot | |
close | Closes the radar | |
hold, unhold | branch, reason? | Parks a branch, or lets it go |
comment | branch, 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 |
status | text | One 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).
In /config, under the plugin:
Everything stays on your machine. The radar makes no network calls, reads no transcript and no credential, and sends nothing anywhere. In detail:
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.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./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.
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./config, passes the change on as it came. It changes no setting and takes no permission decision.| Platform | Status |
|---|---|
| Linux, WSL2 | Tested with Claude Code 2.1.295 and 2.1.296, git 2.53 |
| Windows | Not 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 |
| macOS | Not 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 |
nothing beyond main rather than landed in main.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.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").claude --resume <id> --fork-session comes back with the radar closed: Claude Code does not tell a mod which session the fork came from.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.hooks/register.tsx is the hooks module; the rest of hooks/ is its parts.
| Event | What the hook does |
|---|---|
session.start | Registers /radar and the radar tool; in a resumed session, opens the radar it left open |
command.run of radar | Opens or closes the radar, opens it on its help, holds or lets go of a branch |
tool.call of mcp__worktree-radar__radar | Answers the tool's actions: open, snapshot, close, hold, unhold, comment, status |
ui.render of AbovePrompt | Draws the band from the latest read, fitted to the room Claude Code gives it |
session.end | Saves an open radar for the session's resume |
classic.SessionStart after /clear, /resume, /branch | Carries the radar over, or brings back the one the resumed session left |
config.set of theme | Picks 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.
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.
If the radar saves you time, buy me a few tokens on Ko-fi.
MIT, see LICENSE.
hooks/register.tsx 586 lines1import { 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}
586hooks/command.ts 49 lines1/**
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}
49hooks/host.ts 113 lines1import 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}
113hooks/limits.ts 55 lines1/**
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
55hooks/names.ts 15 lines1/**
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'
15hooks/radar.ts 479 lines1import 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}
479hooks/rows.ts 1154 lines1import 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}
1154hooks/saved.ts 212 lines1import 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}
212hooks/text.ts 363 lines1import { 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}
363hooks/tool.ts 116 lines1import 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}
116hooks/view.tsx 122 lines1import 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}
122hooks/git.ts 108 lines1import 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