A side pane with the live work map and pinned diagrams, plus tabs for progress, diffs, the PR and its CI, and every chat this terminal tab has held

A Claude Code mod that opens a side pane with a live picture of the work: every diagram Claude draws, pinned and readable, and a small map of what Claude is doing right now.

Claude explains things with mermaid charts, but in a terminal you only see the raw code, and it scrolls away. Meanwhile the shape of the work (which files, which steps, what connects to what) lives only in Claude's context. This pane shows you both.
/diagram) a cheap Haiku call asks whether the work changed shape. Only on yes does a fork of the session return a small graph of the files, components and steps it is working on, and the pane draws it. The node Claude is on right now is marked ▶.subgraph opens at once, and any other box gets a detail drawn by a fork of the session. b goes back, t to the top. A box marked ▸ already has a detail.c (or Tab to the field) and type a change into the field under a diagram ("split auth into login and token refresh") and a fork redraws it. Or ask Claude: it reads and edits the pane with two tools, diagram_get and diagram_set.Claude sees the pane only through those two tools, plus progress_set for the progress tab.
The pane has five tabs: m map (everything above), w progress, d diffs, g github, s chats. Every tab starts with the same strip: branch, PR, CI, how many files changed, how full the context is. Under it is a line saying what this chat is for and where it stands. That line is written by the same fork that draws the map, so it costs nothing extra.
✓ done, ▶ now, ✗ failed, – skipped or · not yet, with the turn it happened in and what marked it. The stages come from what Claude ran, so they cost nothing to track:TodoWrite or plan mode is plan, and file edits are implement;npm test, pytest, go test, claude plugin test, …) is test, and its exit code is pass or fail;/code-review is review, an edit after it is fix, and a test after that is verify;git commit, git push, gh pr create and gh pr merge are their own stages, and CI and merge come from the GitHub tab.Claude can also name a stage with the progress_set tool ("fixing review findings 3/10"). A chat that came from a handoff starts where the chat before it stood. The current stage also shows in the strip on every tab, and next to each chat in the chats tab.
v cycles through three views: this chat, the PR, and uncommitted changes only.The PR view is what GitHub's Files changed shows: the branch's commits on top of the point where it last met the PR's base. That base is fetched from origin at most every five minutes. Merging or rebasing main into the branch moves that point forward, so main's own changes never show. Times in this view are each file's last commit.
An open diff is drawn hunk by hunk, and each hunk sits under a ↗ line N link. Cmd+click the link and VS Code opens at that line. o does the same through the code command. For searching across the whole PR, VS Code's GitHub Pull Requests view (View All Changes) is the better fit: it gives you a read-only diff editor with cmd+F and go-to-definition. Each row shows the file's status, +added −removed, and how long ago it was last written. ✎ marks files Claude wrote in this session. Press a number to open that file's diff, then n / p for the next or previous file and b to go back to the list. The diff is drawn with Claude Code's own diff colors. The list refreshes as Claude edits files, not only when the turn ends.
✓ passed ✗ failing … running), with every failing or running check named. It also shows how far the branch is ahead of or behind upstream, and the latest workflow runs on the branch. It reads from gh every 3 minutes, every 45 seconds while checks run, and right after Claude runs git push or gh pr create.● working, ! needs you, ✓ finished but not yet seen, ○ idle) and its title, and ◀ here marks your own pane. A number, or a click, jumps to that pane with herdr agent focus. herdr reports which Claude session each pane holds, so a pane whose chat ran this mod also shows that chat's summary, turns, context fill and handoff source. Below that are the earlier chats that no pane holds any more.Without herdr: every chat this terminal tab has held, newest first. A /clear, a handoff or a fresh claude in the same tab adds a row. Each row shows when the chat ran, its turns, how full its context got, how it ended, its branch and its summary. A chat that started from another's HANDOFF-xxxxxxxx.md note (as cache-clock writes them) is linked to that chat. Once there are two or more chats, a Haiku call writes one overall line for the whole chain. copy resume command copies claude --resume <id> for an older chat.
"This tab" is the first of these that is set: HERDR_TAB_ID, TMUX_PANE, ZELLIJ_PANE_ID, WEZTERM_PANE, KITTY_WINDOW_ID, ITERM_SESSION_ID, TERM_SESSION_ID. With none of them set, the project folder stands in for the tab. A tab used for another project, or left idle for a day, starts a new chain. -p runs read the chain but are never added to it.
The diffs and GitHub tabs work in the git repository of the file Claude wrote last, or else of the session's folder. A session started above its repositories still shows the right one.



claude plugin marketplace add abdul-raafai-asim/claude-code-mods
claude plugin install diagram-pane@claude-code-mods
Or inside Claude Code: /plugin marketplace add abdul-raafai-asim/claude-code-mods, then /plugin install diagram-pane@claude-code-mods.
The pane opens on its own in a terminal 144 columns or wider. Narrower than that, type /diagram once and it opens at any width. With fullscreen rendering it docks beside the transcript; in the classic renderer it sits above the prompt.
One pane per herdr tab. Under herdr, only one chat in a tab shows the pane, however many chats the tab holds. A new chat in the tab, such as a handoff's, takes the pane over, and the chat it left closes its own. When the chat that shows it exits, the next chat to check (within 15 seconds) takes it. /diagram in any chat moves the pane there. The tab's record is ~/.claude/diagram-pane/tabs/<tab>.json. Outside herdr each chat has a terminal pane of its own, so each shows its own.
Show and hide. A bare /diagram toggles the pane. Hiding is for the whole tab: the pane stays hidden in every chat in the tab, and in new ones, until you show it again. The x hide button in the pane's tab row, the pane's close mark and ctrl+x x hide it the same way. /diagram hide and /diagram show do one or the other.
explain the checkout flow with a mermaid flowchart) and it is pinned when the turn ends./diagram diffs, /diagram github, /diagram chats, /diagram map open the pane on that tab, with the keyboard./diagram shows the pane (hidden or in another chat) and runs the gate, redrawing the map if the work changed; when this chat already shows it, /diagram hides it for the tab. /diagram force skips the gate and draws the map from scratch, dropping edits./diagram open gives the pane the keyboard, so number keys work at once. Clicking the pane, or ctrl+x tab, does the same; Esc hands the keys back to the prompt.diagram_get, then diagram_set, and the pane shows the changed level.| Option | Default | What it does | | :- | :- | :- | | everyTurns | 3 | Turns between work-map checks. Set it with /plugin configure diagram-pane@claude-code-mods. | | maxPins | 5 | How many pinned diagrams the pane keeps | | DIAGRAM_PANE_EVERY (env) | unset | Overrides everyTurns for one session, handy for a demo: DIAGRAM_PANE_EVERY=1 claude |
turn.complete it reads the transcript ($.session.messages()) and pins any new mermaid or ASCII diagram it finds in Claude's replies.$.model.complete on Haiku with the current map, the files touched since that are not on it, and a digest of tool calls. It answers YES or NO.$.model.fork asks the session itself for { title, nodes, edges } as JSON. A fork sees the whole conversation but cannot call tools, so it is cheap and served mostly from the prompt cache.hooks/panel.js runs git and gh through $.process.run and lays out the diffs, github and chats tabs; hooks/work.js holds the parsers (numstat, porcelain status, unified diff per file, check rollups, the tab's chain), with no $, so the tests feed it text.$.store under the tab's id, read fresh and written back on every turn, so a chat in another process of the same tab adds to it rather than overwriting it.hooks/draw.js lays out both mermaid charts and the map the same way: layers top to bottom, boxes wrapped to the pane's width, connectors drawn with box characters.sub), so the whole map is a tree. hooks/graph.js walks it, patches it, and writes it back out as mermaid with each detail as a subgraph, which is what diagram_get returns.diagram_set takes a target (view, map or pin:N), a path of node ids to go down, and either a whole mermaid flowchart for that level or ops (add_node, remove_node, relabel, set_kind, add_edge, remove_edge). Boxes that keep their id keep their details.edited. The next refresh hands the fork the current map and asks it to keep those edits, and boxes that keep their id and label keep their details.$.store, so they survive a hot reload or --resume. Pins are rebuilt from the transcript, with edits laid back over them.graph and flowchart charts are redrawn. Sequence, class, state and other types are pinned as raw code. diagram_set with mermaid, or a typed change, can turn one into a flowchart.graph LR, because a narrow pane has no room sideways.⇢ and ↺. A row too wide for the pane wraps, and edges into the wrapped part are listed the same way.text), and shown as written./diagram says so.everyTurns turns of work gets a fresh map at its next turn or diagram_get, without waiting./clear starts the pane fresh: the old session's map, pins and view are dropped (its saved map stays under its own id for --resume), and a map or detail fork still running from before /clear is discarded.gh signed in. It reads only, as whichever account gh has active.claude --plugin-dir ./diagram-pane
claude plugin validate .
claude plugin test .
For headless runs, DIAGRAM_PANE_SYNC=1 waits for the gate and fork before the turn ends, and DIAGRAM_PANE_DUMP=<file> writes the pane's text to a file.
hooks/register.js 1043 lines1// diagram-pane: a side pane that keeps a live picture of the work.
2//
3// 1. Pinned diagrams: every mermaid block or ASCII box diagram Claude writes
4// is pinned in the pane, newest first, drawn as boxes and arrows.
5// 2. Live work map: every few turns (and on /diagram) a cheap Haiku gate asks
6// "did the work change shape?". Only on YES does a fork of the session
7// draw a small JSON graph of what Claude is doing right now.
8// 3. Going into a box: a number key opens that box's detail, a mermaid
9// subgraph or one a fork of the session draws. b goes back, t to the top.
10// 4. Changing a diagram: Claude reads and changes the pane with the
11// diagram_get and diagram_set tools, and the person can type a change
12// into the pane itself.
13//
14// Claude learns what the pane shows only through those two tools.
15
16import { findDiagrams, parseMermaid, drawGraph, short } from './draw.js'
17import { adoptIds, allIds, applyOps, boxOf, carrySubs, cleanId, levelAt, setLevel, toMermaid, uniqueIds } from './graph.js'
18import {
19 DIFF_MODES, TABS, W, plain, chatItems, declare, focusPane, progressItems, refreshHerdr, diffItems, githubItems, headItems, readContext, readMessages, refreshGh, refreshGit, refreshOverall,
20 hideTab, settleOwner, showHere, startPanelSession, updateChain,
21} from './panel.js'
22
23const PANE = 'diagrams'
24const TOOL_GET = 'mcp__diagram-pane__diagram_get'
25const TOOL_SET = 'mcp__diagram-pane__diagram_set'
26const HOME = { root: 'map', path: [] }
27const NOTE = 'The map is drawn after a few turns, or now with /diagram'
28
29// Everything the pane shows. Pins are rebuilt from the transcript; the map, the
30// edited pins and the view are also saved in $.store.
31const S = {
32 pins: [], // [{ kind, code, title, graph, turn, edited }], newest first
33 seen: new Set(), // the code of every diagram pinned so far, to skip repeats
34 scanned: 0, // how many transcript messages we already looked at
35 turn: 0, // prompts the user has sent in this session
36 sinceMap: 0, // turns since the gate last ran
37 map: null, // { title, graph, turn, edited } from the last fork or edit
38 mapAt: 0, // transcript length when the map was drawn
39 edits: {}, // pin code -> { graph, title }: the pins someone changed
40 view: HOME, // what the pane shows: { root: 'map' | 'pin', code?, path: [node ids gone into] }
41 loading: new Set(),// views whose detail a fork is drawing
42 loaded: false, // the saved map has been read
43 caughtUp: false, // the one early map draw for a session with work but no map was tried
44 maxPins: 5,
45 every: 3, // turns between map checks (everyTurns, or DIAGRAM_PANE_EVERY)
46 note: NOTE,
47 busy: false, // a gate or fork is running
48 sessionId: null, // the session the state above belongs to
49 gen: 0, // bumped when a new session starts, so late forks drop their reply
50 mapEdits: 0, // bumped on every hand edit of the map, so a map fork that started before one drops its reply
51}
52
53// /clear keeps the process (session.start does not fire) but starts a new
54// session id: drop the old session's map, pins and view
55function startSession(id) {
56 if (S.sessionId !== null && S.sessionId !== id) {
57 Object.assign(S, {
58 pins: [], seen: new Set(), scanned: 0, sinceMap: 0, map: null, mapAt: 0,
59 edits: {}, view: HOME, loading: new Set(), loaded: false, caughtUp: false, note: NOTE, gen: S.gen + 1,
60 })
61 }
62 const changed = S.sessionId !== id
63 S.sessionId = id
64 startPanelSession(id)
65 return changed
66}
67
68export function register(on, options) {
69 const every = Number(options?.everyTurns) || 3
70 S.every = every
71 S.maxPins = Number(options?.maxPins) || 5
72
73 on('session.start', async ($, e, next) => {
74 W.interactive = e.isInteractive !== false
75 // Open the pane right away, unless another chat in this herdr tab shows it.
76 // A new chat (a handoff's) takes it over; a reload of this one does not.
77 // Unasked, it is placed on a wide terminal (144+ columns); /diagram opens it at any width.
78 const fresh = W.interactive && (await $.session.messages()).length === 0
79 await placePane($, { fresh, herdr: true })
80 try {
81 await $.command.register({ name: 'diagram', description: 'Show or hide the work pane for this tab: map, progress, diffs, GitHub, chats', argumentHint: '[show|hide|force|open|map|progress|diffs|github|chats]' })
82 } catch (err) {
83 $.ui.log('could not add /diagram: ' + err.message)
84 }
85 for (const tool of TOOLS) {
86 try {
87 await $.tool.register(tool)
88 } catch (err) {
89 $.ui.log(`could not add ${tool.name}: ` + err.message)
90 }
91 }
92 // The diffs, the GitHub state and the relative times go stale between turns
93 $.clock.every(15000, () => void tick($).catch(() => {}))
94 // Another chat in the tab took the pane, or the tab hid or showed it
95 $.clock.every(3000, () => void placePane($).catch(() => {}))
96 void tick($, true).catch(() => {})
97 return next(e)
98 })
99
100 // A file Claude writes shows in the diffs tab before the turn ends
101 on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'] }, async ($, e, next) => {
102 const result = await next(e)
103 try {
104 W.gitDueAt = 0
105 const f = e.file_path || e.notebook_path
106 if (f && !W.touched.includes(f)) W.touched.push(f)
107 // Not awaited: the turn goes on while git runs
108 if (W.tab === 'diffs' || !W.git) void refreshGit($).then(() => redraw($)).catch(() => {})
109 } catch {}
110 return result
111 }).catch(($, e, next) => next(e))
112
113 // The tab's chain records how each chat ended
114 on('session.end', async ($, e, next) => {
115 try {
116 if (W.sessionId === e.sessionId) await updateChain($, { ended: e.reason === 'prompt_input_exit' ? 'exit' : e.reason })
117 } catch {}
118 return next(e)
119 })
120
121 // After each main turn: pin new diagrams, and every few turns refresh the map
122 on('turn.complete', async ($, e, next) => {
123 const result = await next(e)
124 if (e.agentId || e.isAborted) return result
125 const messages = await sync($)
126 S.sinceMap += 1
127 await placePane($)
128 S.every = Number(await $.env.get('DIAGRAM_PANE_EVERY')) || every
129 const waitSync = await $.env.get("DIAGRAM_PANE_SYNC")
130 if (S.sinceMap >= S.every || overdue()) {
131 const work = overdue() ? catchUp($, messages) : refreshMap($, messages, false)
132 // Headless runs exit when the hook returns, so tests can ask us to wait
133 if (waitSync) await work
134 }
135 // The other tabs: git now, gh soon after a push, the chain every turn
136 const pushed = messages.slice(-30).some(m => (m.toolUses || []).some(t => t.tool === 'Bash' && /\bgit push\b|\bgh pr (create|merge|ready)\b/.test(String(t.input?.command || ''))))
137 if (pushed) W.ghDueAt = 0
138 const panel = (async () => {
139 await readContext($)
140 await refreshGit($)
141 await updateChain($, { turns: S.turn })
142 await redraw($)
143 await tick($)
144 })().catch(err => $.ui.log('diagram-pane: ' + err.message, { to: 'debug' }))
145 if (waitSync) await panel
146 await redraw($)
147 return result
148 })
149
150 // /diagram runs the gate now; /diagram force skips the gate and draws the map
151 // from scratch; /diagram open gives the pane the keyboard, so number keys work
152 on('command.run', { command: 'diagram' }, async ($, e) => {
153 const arg = e.args.trim()
154 const tab = TABS.find(t => t.id === arg || (arg === 'diff' && t.id === 'diffs') || (arg === 'gh' && t.id === 'github'))
155 // Bare /diagram toggles the pane for the whole tab; any other form shows it here
156 // (open but undrawn, on a narrow terminal, counts as not shown)
157 const shown = (await settleOwner($)).show && (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
158 if (arg === 'hide' || (arg === '' && shown)) {
159 await hidePane($)
160 return {}
161 }
162 await alone(async () => {
163 await showHere($)
164 await $.ui.open({ id: PANE, title: 'Diagrams', ...(arg === 'open' || tab ? { focus: true } : {}) })
165 })
166 if (arg === 'show') {
167 void tick($, true).catch(() => {})
168 return {}
169 }
170 const messages = await sync($)
171 if (tab) {
172 W.tab = tab.id
173 await saveSaved($)
174 await redraw($)
175 void tick($, true).catch(() => {})
176 return {}
177 }
178 if (arg === 'open') {
179 await redraw($)
180 return {}
181 }
182 const work = refreshMap($, messages, arg === 'force')
183 if (await $.env.get('DIAGRAM_PANE_SYNC')) await work
184 return {}
185 })
186
187 // Claude names its stage
188 on('tool.call', { tool: 'mcp__diagram-pane__progress_set' }, async ($, e) => {
189 if (!STAGE_LIST.includes(e.stage)) return { deny: `stage must be one of: ${STAGE_LIST.join(', ')}` }
190 await sync($)
191 declare({ stage: e.stage, note: plain(e.note || ''), done: e.done, total: e.total }, e.tool_use_id)
192 await redraw($)
193 return { result: `The progress pane shows: ${e.stage}${e.note ? ' · ' + e.note : ''}${e.total ? ` ${e.done ?? 0}/${e.total}` : ''}.` }
194 }).catch(() => ({ deny: 'progress_set failed; the pane is unchanged.' }))
195
196 // Claude's way into the pane
197 on('tool.call', { tool: TOOL_GET }, async $ => {
198 const messages = await sync($)
199 // A session with work but no saved map (a reinstall, a new marketplace,
200 // --resume into a session never mapped): draw it now, so Claude gets a map
201 if (overdue()) await catchUp($, messages)
202 return { result: describe() }
203 }).catch(() => ({ deny: 'diagram_get failed; the pane is unchanged.' }))
204
205 on('tool.call', { tool: TOOL_SET }, async ($, e) => {
206 try {
207 await sync($)
208 const text = setDiagram(resolveTarget(String(e.target || 'view')), e)
209 await saveSaved($)
210 await placePane($)
211 await redraw($)
212 return { result: text }
213 } catch (err) {
214 return { deny: 'diagram_set: ' + err.message }
215 }
216 }).catch(() => ({ deny: 'diagram_set failed; the pane is unchanged.' }))
217
218 // Closed by hand (its mark, ctrl+x x): hidden for the whole tab until /diagram
219 on('ui.close', async ($, e, next) => {
220 const result = await next(e)
221 if (e.id === PANE && e.origin?.kind === 'person') await hideTab($).catch(() => {})
222 return result
223 }).catch(($, e, next) => next(e))
224
225 // The pane itself: lines of Text, rows of Buttons, and one Input
226 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
227 const { Box, Text, Button, Input, Code, Link } = $.ui.resolve(e)
228 const items = paneItems(Math.max(30, e.props.bodyColumns || 60), actions($), await $.clock.now())
229 return h(Box, { flexDirection: 'column' }, ...items.map(it => {
230 if (it.input) return h(Input, it.input)
231 if (it.code) return h(Code, { format: 'diff', source: it.code.source, path: it.code.path })
232 if (it.link) return Link ? h(Link, { href: it.link.href, label: it.link.label }) : h(Text, { color: 'blue', wrap: 'truncate-end' }, it.link.label)
233 if (it.buttons) {
234 return h(Box, { flexDirection: 'row', flexWrap: 'wrap', columnGap: 2, ...(it.indent ? { paddingLeft: it.indent } : {}) },
235 ...(it.lead ? [h(Text, { bold: true, wrap: 'truncate-end' }, textOnly(it.lead))] : []),
236 ...it.buttons.map(b => h(Button, {
237 key: b.key, label: textOnly(b.label), onPress: b.onPress,
238 ...(b.hotkey ? { hotkey: b.hotkey, plain: true } : b.plain ? { plain: true } : {}),
239 })))
240 }
241 return h(Text, { color: it.color, bold: it.bold, dimColor: it.dim, wrap: it.wrap ? 'wrap' : 'truncate-end' }, textOnly(it.text) || ' ')
242 }))
243 })
244}
245
246// No map, yet the transcript already holds a map's worth of turns. The map is
247// saved per install, so this is a session mapped by another install (or never):
248// waiting `every` more turns would leave the pane empty for no reason.
249// One catch-up try per load; after that the usual cadence takes over.
250const overdue = () => !S.map && !S.busy && !S.caughtUp && S.turn >= S.every
251
252function catchUp($, messages) {
253 S.caughtUp = true
254 return refreshMap($, messages, false)
255}
256
257// Read the saved map once, then pin what the transcript has that is new
258async function sync($) {
259 const fresh = startSession(await $.session.id())
260 const messages = await $.session.messages()
261 S.turn = turnsIn(messages)
262 readMessages(messages, await $.session.cwd().catch(() => ""))
263 await loadSaved($)
264 pinNewDiagrams(messages, S.maxPins)
265 if (fresh) await redraw($)
266 return messages
267}
268
269// ---------------------------------------------------------------------------
270// Pinned diagrams
271// ---------------------------------------------------------------------------
272
273// A turn is a prompt the user typed (tool results also arrive as user messages)
274function turnsIn(messages) {
275 return messages.filter(m => m.role === 'user' && m.text && !m.toolResults?.length).length
276}
277
278function pinNewDiagrams(messages, maxPins) {
279 if (messages.length < S.scanned) S.scanned = 0 // compacted: rescan, `seen` stops repeats
280 messages.forEach((m, i) => {
281 if (i < S.scanned || m.role !== 'assistant') return
282 for (const d of findDiagrams(m.text)) {
283 if (S.seen.has(d.code)) continue
284 S.seen.add(d.code)
285 // An edited pin comes back as edited; else parse mermaid flowcharts into a
286 // graph, and show anything else as written
287 const edit = S.edits[d.code]
288 const graph = edit?.graph || (d.kind === 'mermaid' ? parseMermaid(d.code) : null)
289 S.pins.unshift({ ...d, title: edit?.title || d.title, graph, edited: Boolean(edit), turn: turnsIn(messages.slice(0, i)) })
290 }
291 })
292 S.scanned = messages.length
293 S.pins.length = Math.min(S.pins.length, maxPins)
294}
295
296const pinOf = code => S.pins.find(p => p.code === code)
297
298// ---------------------------------------------------------------------------
299// Live work map: the gate, then the fork
300// ---------------------------------------------------------------------------
301
302const GATE_SYSTEM = 'You watch a coding agent so a small diagram of its work stays current. Answer with exactly one word: YES or NO.'
303
304const SIDE = '[diagram-pane side request: not from the user. Do not continue the task and do not call tools.]'
305
306const JSON_SHAPE = `{"title":"what it is, max 40 chars","nodes":[{"id":"a","label":"max 20 chars","kind":"file|component|step","now":false}],"edges":[{"from":"a","to":"b","label":"optional, max 10 chars"}]}`
307
308const MAP_SHAPE = JSON_SHAPE.replace('{"title"', '{"summary":"two plain sentences for the user: what this chat is for, and where it stands now","title"')
309
310const FORK_PROMPT = `${SIDE}
311Draw a compact map of the work in this session right now: the files, components and steps involved, and how they connect.
312Reply with ONLY one JSON object, no prose and no code fence:
313${MAP_SHAPE}
314Map the task, not the chat: steps are steps of the work ("add discount", "run tests"), never "read files" or "explain".
315Rules: 3 to 8 nodes. Edges point from earlier to later, or from caller to callee, so the map reads top to bottom. At most 10 edges. Use file basenames as file labels. Set "now": true on the one node being worked on at this moment.`
316
317// The map the person sees goes to the fork, so a redraw builds on it, and on
318// their edits, instead of starting over
319function basePrompt(map) {
320 return `\n\nThe map the user sees now${map.edited ? ' (the user or Claude edited it by hand: keep those edits)' : ''}:\n` +
321 `${toMermaid(shallow(map.graph))}\n` +
322 'Keep the ids and labels of nodes that still fit. Add, drop or reconnect only what the work changed. Reply with the JSON as above.'
323}
324
325async function refreshMap($, messages, force) {
326 if (S.busy) return
327 S.busy = true
328 S.sinceMap = 0
329 const gen = S.gen
330 try {
331 // 1. The cheap gate: skipped only when there is no map yet, or on /diagram force
332 if (S.map && !force) {
333 S.note = 'gate: checking if the work changed shape...'
334 await redraw($)
335 const activity = messages.slice(S.mapAt)
336 const gate = await $.model.complete({
337 model: 'haiku',
338 system: GATE_SYSTEM,
339 prompt: `CURRENT MAP: ${mapSummary()}\n\nFILES TOUCHED SINCE, NOT ON THE MAP: ${newFiles(activity).join(', ') || 'none'}\n\n` +
340 `ACTIVITY SINCE THE MAP WAS DRAWN:\n${digest(activity)}\n\n` +
341 'Answer YES if the map is now out of date: the work touched files or components the map lacks, added a step ' +
342 '(such as tests, a new feature, a fix), or moved to a different task. Answer NO only if the map still shows the work. YES or NO?',
343 maxTokens: 5,
344 timeoutMs: 20000,
345 })
346 if (gen !== S.gen) return
347 const yes = gate.isAnswered && /^\s*yes/i.test(gate.text)
348 if (!yes) {
349 S.note = `gate said same shape at turn ${S.turn}, map kept` + (gate.isAnswered ? '' : ` (${gate.reason})`)
350 return
351 }
352 }
353 // 2. The fork: the session's own context, asked for JSON. It cannot call tools.
354 // /diagram force draws from scratch; otherwise the fork builds on the current map.
355 const base = force ? null : S.map
356 const edits = S.mapEdits
357 S.note = 'fork: drawing the work map...'
358 await redraw($)
359 const r = await $.model.fork({ prompt: FORK_PROMPT + (base ? basePrompt(base) : '') })
360 if (gen !== S.gen) return // /clear ran meanwhile: this map is the old session's
361 if (!r.isAnswered) {
362 S.note = r.reason === 'nothing-to-fork' ? 'nothing to map yet: send a prompt first' : 'fork failed: ' + r.reason
363 return
364 }
365 const map = parseMap(r.text)
366 if (!map) {
367 S.note = 'fork reply was not a graph: ' + short(r.text.replace(/\s+/g, ' '), 60)
368 return
369 }
370 // Someone changed the map while the fork ran: their change wins over a map
371 // drawn from what was there before it
372 if (S.mapEdits !== edits) {
373 S.note = `kept the map as edited at turn ${S.turn}; it refreshes at the next check`
374 return
375 }
376 // Boxes that kept their id and label keep the detail drawn inside them, read
377 // from the map as it is now, so a box opened while the fork ran keeps its detail
378 const now = force ? null : S.map
379 const graph = uniqueIds(now ? carrySubs(now.graph, map.graph, { sameLabel: true }) : map.graph)
380 S.map = { title: map.title, graph, turn: S.turn, edited: Boolean(now?.edited), summary: map.summary || now?.summary || '' }
381 S.mapAt = messages.length
382 S.note = `map drawn at turn ${S.turn}`
383 await saveSaved($)
384 // The chat's own summary goes to the tab's chain, and the chain's line follows it
385 if (map.summary) {
386 await updateChain($, { summary: map.summary, title: map.title }).catch(() => {})
387 void refreshOverall($).then(() => redraw($)).catch(() => {})
388 }
389 } catch (err) {
390 if (gen === S.gen) S.note = 'map error: ' + err.message
391 } finally {
392 S.busy = false
393 await redraw($)
394 }
395}
396
397// A fork's JSON becomes the same { nodes, edges } shape a mermaid chart parses to
398export function parseMap(text) {
399 try {
400 const json = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1))
401 const nodes = (json.nodes || []).slice(0, 10).map(n => ({
402 id: cleanId(n.id),
403 label: short(n.label || n.id, 22),
404 box: boxOf(n.kind),
405 ...(n.now ? { now: true } : {}),
406 }))
407 const edges = (json.edges || []).slice(0, 14).map(e => ({ from: cleanId(e.from), to: cleanId(e.to), label: short(e.label || '', 10) }))
408 const summary = typeof json.summary === 'string' ? short(plain(json.summary), 400) : ''
409 return nodes.length ? { title: short(json.title || 'the work', 50), graph: { nodes, edges }, ...(summary ? { summary } : {}) } : null
410 } catch {
411 return null
412 }
413}
414
415// The map, the edited pins and the view are kept per session in the plugin's
416// store, so they survive a reload or --resume
417async function loadSaved($) {
418 if (S.loaded) return
419 S.loaded = true
420 const saved = ((await $.store.get('maps')) || {})[S.sessionId]
421 if (!saved) return
422 if (saved.map) Object.assign(S, { map: saved.map, mapAt: saved.mapAt || 0, note: `map from turn ${saved.map.turn}` })
423 if (saved.edits) S.edits = saved.edits
424 if (saved.view) S.view = saved.view
425 if (TABS.some(t => t.id === saved.tab)) W.tab = saved.tab
426}
427
428async function saveSaved($) {
429 if (S.sessionId === null) return
430 const maps = (await $.store.get('maps')) || {}
431 // Edits of pins that fell off the pane are no longer needed
432 const edits = Object.fromEntries(Object.entries(S.edits).filter(([code]) => pinOf(code)))
433 maps[S.sessionId] = { map: S.map, mapAt: S.mapAt, edits, view: S.view, tab: W.tab, at: await $.clock.now() }
434 // Keep the 20 newest sessions only
435 const keep = Object.entries(maps).sort((a, b) => b[1].at - a[1].at).slice(0, 20)
436 await $.store.set('maps', Object.fromEntries(keep))
437}
438
439function mapSummary() {
440 const { nodes, edges } = S.map.graph
441 const name = id => nodes.find(n => n.id === id)?.label
442 return `"${S.map.title}": ` + nodes.map(n => n.label).join(', ') + '; ' + edges.map(e => `${name(e.from)} -> ${name(e.to)}`).join(', ')
443}
444
445// Files Claude read, wrote or edited whose names the map does not mention
446function newFiles(messages) {
447 const onMap = S.map.graph.nodes.map(n => n.label).join(' ')
448 const names = messages.flatMap(m => (m.toolUses || []).map(t => String(t.input?.file_path || '').split('/').pop()))
449 return [...new Set(names)].filter(n => n && !onMap.includes(n))
450}
451
452// What happened since the map was drawn, as short lines: prompts and tool calls
453function digest(messages) {
454 const lines = []
455 for (const m of messages) {
456 if (m.role === 'user' && m.text && !m.toolResults?.length) lines.push('user: ' + short(m.text.replace(/\s+/g, ' '), 160))
457 for (const t of m.toolUses || []) {
458 const i = t.input || {}
459 lines.push(`${t.tool}: ` + short(String(i.file_path || i.path || i.command || i.pattern || i.description || ''), 100))
460 }
461 }
462 return lines.slice(-40).join('\n') || '(nothing)'
463}
464
465// One level, without the details inside its boxes: what a prompt needs
466const shallow = g => ({ nodes: g.nodes.map(({ sub, ...n }) => n), edges: g.edges })
467
468// ---------------------------------------------------------------------------
469// Going into a box
470// ---------------------------------------------------------------------------
471
472const rootGraph = (view = S.view) => (view.root === 'pin' ? pinOf(view.code)?.graph : S.map?.graph) || null
473
474const isHome = (view = S.view) => view.root === 'map' && !view.path.length
475
476const viewKey = view => `${view.root}\u0000${view.code || ''}\u0000${view.path.join('/')}`
477
478// The names of the levels down to the view, for the pane's header
479function crumbs(view = S.view) {
480 const pin = view.root === 'pin' ? pinOf(view.code) : null
481 const names = [pin ? short(pin.title || `pin ${S.pins.indexOf(pin) + 1}`, 24) : 'Work map']
482 let g = rootGraph(view)
483 for (const id of view.path) {
484 const n = g?.nodes.find(n => n.id === id)
485 names.push(n ? short(n.label, 20) : id)
486 g = n?.sub
487 }
488 return names
489}
490
491// A redrawn map or a dropped pin can take the view's boxes away: keep the part
492// of the path that is still there
493function normalizeView() {
494 const v = S.view
495 if (v.root === 'pin' && !pinOf(v.code)) return (S.view = HOME)
496 let g = rootGraph(v)
497 const keep = []
498 for (const id of v.path) {
499 const n = g?.nodes.find(n => n.id === id)
500 if (!n) break
501 keep.push(id)
502 g = n.sub
503 }
504 if (keep.length !== v.path.length) S.view = { ...v, path: keep }
505}
506
507function actions($) {
508 const go = async view => {
509 S.view = view
510 await redraw($)
511 await saveSaved($)
512 }
513 return {
514 drill: id => drill($, id),
515 back: () => go(S.view.path.length ? { ...S.view, path: S.view.path.slice(0, -1) } : HOME),
516 top: () => go(HOME),
517 openPin: code => go({ root: 'pin', code, path: [] }),
518 edit: text => editByFork($, text),
519 // A surface that cannot move the focus leaves the field to Tab or a click
520 typeChange: () => $.ui.focus({ requestId: PANE, key: inputKey() }).catch(() => {}),
521 tab: async id => {
522 W.tab = id
523 await redraw($)
524 await saveSaved($)
525 if (id !== 'map') await tick($, true).catch(() => {})
526 },
527 openFile: async path => {
528 W.openFile = path
529 await redraw($)
530 },
531 diffMode: async () => {
532 W.diffMode = DIFF_MODES[(DIFF_MODES.indexOf(W.diffMode) + 1) % DIFF_MODES.length]
533 W.openFile = null
534 await refreshGit($)
535 await redraw($)
536 },
537 hide: () => hidePane($),
538 refresh: async () => {
539 if (W.tab === 'github') await refreshGh($)
540 else await refreshGit($)
541 await redraw($)
542 },
543 openInEditor: async (file, line) => {
544 try {
545 const r = await $.process.run(['code', '-g', `${file}:${line}`], { timeoutMs: 15000 })
546 if (r.exitCode !== 0) $.ui.toast('VS Code did not open: ' + (r.stderr.trim() || `exit ${r.exitCode}`))
547 } catch {
548 $.ui.toast('No `code` command: in VS Code run "Shell Command: Install \'code\' command in PATH"')
549 }
550 },
551 focusPane: async pane => {
552 const err = await focusPane($, pane).catch(e => e.message)
553 if (err) $.ui.toast('Could not jump to that pane: ' + err)
554 },
555 copy: async text => {
556 const r = await $.ui.copy({ text })
557 $.ui.toast(r.isCopied ? 'Copied: ' + text : 'Could not copy: ' + text)
558 },
559 }
560}
561
562async function drill($, id) {
563 normalizeView()
564 const { graph } = levelAt(rootGraph(), S.view.path)
565 const node = graph?.nodes.find(n => n.id === id)
566 if (!node) return
567 S.view = { ...S.view, path: [...S.view.path, id] }
568 await redraw($)
569 await saveSaved($)
570 if (!node.sub) await expand($, S.view)
571}
572
573const DETAIL_PROMPT = (where, parent, node) => `${SIDE}
574The user is looking at this diagram in a side pane (${where}):
575${toMermaid(shallow(parent))}
576They opened the box "${node.label}" (id ${node.id}). Draw what is inside it, one level more detailed: its parts (functions, sub-steps, components, resources) and how they connect.
577Use what this session has seen. Where you have not seen the inside, draw only what you can infer, and do not invent file names.
578Reply with ONLY one JSON object, no prose and no code fence:
579${JSON_SHAPE}
580Rules: 3 to 8 nodes, at most 10 edges, edges read top to bottom. Leave "now" false.`
581
582// A box with no detail yet: ask a fork of the session to draw one
583async function expand($, view) {
584 const key = viewKey(view)
585 if (S.loading.has(key)) return
586 const id = view.path.at(-1)
587 const parentView = { ...view, path: view.path.slice(0, -1) }
588 const parent = levelAt(rootGraph(view), parentView.path).graph
589 const node = parent?.nodes.find(n => n.id === id)
590 if (!node) return
591 S.loading.add(key)
592 S.note = `fork: drawing the inside of ${short(node.label, 24)}...`
593 await redraw($)
594 const gen = S.gen
595 try {
596 const r = await $.model.fork({ prompt: DETAIL_PROMPT(crumbs(parentView).join(' › '), parent, node) })
597 if (gen !== S.gen) return
598 if (!r.isAnswered) {
599 S.note = r.reason === 'nothing-to-fork' ? 'nothing to fork yet: send a prompt first' : 'fork failed: ' + r.reason
600 return
601 }
602 const detail = parseMap(r.text)
603 if (!detail) {
604 S.note = 'fork reply was not a graph: ' + short(r.text.replace(/\s+/g, ' '), 60)
605 return
606 }
607 // The diagram may have changed while the fork ran: attach only to a box that
608 // is still there and still has no detail
609 const root = rootGraph(view)
610 const at = levelAt(root, view.path)
611 if (at.graph || !at.node) return
612 commitRoot(view, setLevel(root, view.path, adoptIds(detail.graph, allIds(root), id)), {})
613 S.note = `drew the inside of ${short(node.label, 24)}`
614 await saveSaved($)
615 } catch (err) {
616 if (gen === S.gen) S.note = 'detail error: ' + err.message
617 } finally {
618 S.loading.delete(key)
619 await redraw($)
620 }
621}
622
623// ---------------------------------------------------------------------------
624// Changing a diagram: Claude's tools and the pane's own input
625// ---------------------------------------------------------------------------
626
627const STAGE_LIST = ['plan', 'implement', 'test', 'review', 'fix', 'verify', 'commit', 'push', 'pr', 'ci', 'merge']
628
629const TOOLS = [
630 {
631 name: 'progress_set',
632 description:
633 "Say which stage of the work you are in, for the user's progress pane. The pane already infers stages from your " +
634 'tool calls (edits, test commands, review agents, git and gh); call this when that would be wrong or too coarse: ' +
635 'when you start a stage the calls do not show (planning in prose, an adversarial review by hand), or to count ' +
636 'progress inside one ("fixing review findings", done 3, total 10). One short call; it changes nothing else.',
637 inputSchema: {
638 type: 'object',
639 properties: {
640 stage: { type: 'string', enum: STAGE_LIST },
641 note: { type: 'string', description: 'A few words on what is happening in this stage' },
642 done: { type: 'number', description: 'Items done in this stage, with total' },
643 total: { type: 'number', description: 'Items in this stage' },
644 },
645 required: ['stage'],
646 },
647 isDeferred: false,
648 },
649 {
650 name: 'diagram_get',
651 description:
652 "Read what the user's diagram side pane shows: the live work map, the diagrams pinned from your replies, and which box " +
653 'the user has opened. Each comes back as mermaid with stable node ids; a subgraph is a box with a detail diagram inside ' +
654 'that the user can open. Call this when the user talks about "the diagram", "the map", a box or a pin, and before ' +
655 'diagram_set. The user can change diagrams from the pane too, so read again instead of trusting an earlier copy.',
656 inputSchema: { type: 'object', properties: {} },
657 isDeferred: false,
658 },
659 {
660 name: 'diagram_set',
661 description:
662 "Change a diagram in the user's side pane. target: \"view\" (what the user is looking at; the default), \"map\" " +
663 '(the live work map) or "pin:N" (a pinned diagram, N as diagram_get lists it). path: node ids to go down through ' +
664 "from that diagram's top level (from the open box for \"view\"); the change applies to the last node's detail, " +
665 'created if it has none. Give mermaid (a whole flowchart that replaces that level: ids you keep keep their details, ' +
666 'subgraph blocks become details the user can open) and/or ops (small patches). The pane then shows the changed ' +
667 'level. Edits stay: the automatic map refresh builds on them instead of redrawing over them.',
668 inputSchema: {
669 type: 'object',
670 properties: {
671 target: { type: 'string', description: '"view", "map" or "pin:N"' },
672 path: { type: 'array', items: { type: 'string' }, description: 'Node ids to go into, top level first' },
673 mermaid: { type: 'string', description: 'A flowchart (flowchart TD) that replaces the level' },
674 ops: {
675 type: 'array',
676 description: 'Patches, applied in order after mermaid',
677 items: {
678 type: 'object',
679 properties: {
680 op: { type: 'string', enum: ['add_node', 'remove_node', 'relabel', 'set_kind', 'add_edge', 'remove_edge'] },
681 id: { type: 'string' },
682 label: { type: 'string' },
683 kind: { type: 'string', enum: ['file', 'component', 'step'] },
684 from: { type: 'string' },
685 to: { type: 'string' },
686 },
687 required: ['op'],
688 },
689 },
690 title: { type: 'string', description: 'A new title for the diagram' },
691 },
692 },
693 isDeferred: false,
694 },
695]
696
697// What diagram_get returns: every diagram as mermaid, and where the user is
698export function describe() {
699 normalizeView()
700 const out = [`The pane shows: ${crumbs().join(' › ')}` +
701 (S.view.path.length ? ` (path ${JSON.stringify(S.view.path)} in ${S.view.root === 'map' ? '"map"' : `"pin:${S.pins.indexOf(pinOf(S.view.code)) + 1}"`})` : '')]
702 if (S.map) out.push(`\n## map: "${S.map.title}" (turn ${S.map.turn}${S.map.edited ? ', edited' : ''})\n\`\`\`mermaid\n${toMermaid(S.map.graph)}\n\`\`\``)
703 else {
704 const left = Math.max(1, S.every - S.sinceMap)
705 out.push(`\n## map: not drawn yet. Last status: ${S.note}.\n` +
706 'This install has no saved map for this session (maps are kept per install, so a reinstall or a new marketplace starts empty). ' +
707 `The next check runs after ${left} more turn${left > 1 ? 's' : ''}. The user can run /diagram to draw it now, ` +
708 'or diagram_set with target "map" and mermaid draws one by hand.')
709 }
710 S.pins.forEach((p, i) => {
711 out.push(`\n## pin:${i + 1}: "${p.title || 'untitled'}" (turn ${p.turn}${p.edited ? ', edited' : ''})`)
712 if (p.graph) out.push(`\`\`\`mermaid\n${toMermaid(p.graph)}\n\`\`\``)
713 else out.push(`Not a flowchart, shown as written (mermaid in diagram_set replaces it with one):\n\`\`\`${p.kind === 'mermaid' ? 'mermaid' : 'text'}\n${p.code}\n\`\`\``)
714 })
715 if (!S.pins.length) out.push('\nNo pinned diagrams.')
716 return out.join('\n')
717}
718
719function resolveTarget(target) {
720 if (target === 'view') {
721 normalizeView()
722 return { ...S.view }
723 }
724 if (target === 'map') return { ...HOME }
725 const m = /^pin:?\s*(\d+)$/i.exec(target)
726 if (m) {
727 const pin = S.pins[Number(m[1]) - 1]
728 if (!pin) throw new Error(`no ${target}: there ${S.pins.length === 1 ? 'is 1 pin' : `are ${S.pins.length} pins`}`)
729 return { root: 'pin', code: pin.code, path: [] }
730 }
731 throw new Error(`unknown target "${target}": use "view", "map" or "pin:N"`)
732}
733
734// Apply { path, mermaid, ops, title } at `view`, show the changed level, and say what changed
735export function setDiagram(view, input) {
736 const { mermaid, ops, title } = input
737 const path = [...view.path, ...(Array.isArray(input.path) ? input.path.map(String) : [])]
738 if (!mermaid && !ops?.length && !title) throw new Error('give mermaid, ops or title')
739 const root = rootGraph(view)
740 let level = { nodes: [], edges: [] }
741 if (root) {
742 const at = levelAt(root, path)
743 if (!at.graph && !at.node) throw new Error(`path ${JSON.stringify(path)} does not lead to a box`)
744 level = at.graph || level
745 } else if (path.length) throw new Error('that diagram has no boxes to go into yet')
746 else if (!mermaid && !ops?.length) throw new Error('that diagram has no boxes yet: give mermaid')
747
748 // Only a new title: the diagram's boxes stay as they are
749 if (!mermaid && !ops?.length) {
750 commitRoot(view, root, { edited: true, title })
751 S.view = { ...view, path }
752 return `Retitled ${crumbs().join(' › ')}.`
753 }
754
755 let next = level
756 const changes = []
757 if (mermaid) {
758 const parsed = parseMermaid(String(mermaid).replace(/^\s*```(?:mermaid)?\s*\n?|```\s*$/g, ''))
759 if (!parsed) throw new Error('mermaid must be a flowchart ("flowchart TD" or "graph TD") with at least one node')
760 next = carrySubs(level, parsed)
761 changes.push(`redrew it with ${parsed.nodes.length} boxes`)
762 }
763 if (ops?.length) {
764 const r = applyOps(next, ops)
765 next = r.graph
766 changes.push(...r.changes)
767 }
768 if (title) changes.push('retitled it')
769 // A detail's ids must not clash with the rest of the tree
770 if (path.length) next = adoptIds(next, allIds(setLevel(root, path, { nodes: [], edges: [] })), path.at(-1))
771 // A detail with no boxes left is no detail: the box goes back to having none
772 const tree = path.length ? setLevel(root, path, next.nodes.length ? next : null) : next
773 // A box added on top may take an id already used inside a detail: the deeper one is renamed
774 commitRoot(view, uniqueIds(tree), { edited: true, title })
775 S.view = { ...view, path }
776 normalizeView()
777 next = levelAt(rootGraph(), S.view.path).graph || { nodes: [], edges: [] }
778 return `Changed ${crumbs().join(' › ')}: ${changes.join(', ')}. The pane shows it now:\n` +
779 `\`\`\`mermaid\n${toMermaid(next)}\n\`\`\``
780}
781
782// Store a changed tree as the map or as the pin it came from
783function commitRoot(view, graph, { edited = false, title }) {
784 if (view.root === 'map') {
785 S.map = {
786 title: title || S.map?.title || 'diagram',
787 graph,
788 turn: S.map?.turn ?? S.turn,
789 edited: Boolean(S.map?.edited || edited),
790 summary: S.map?.summary || '',
791 }
792 if (edited) S.mapEdits += 1
793 return
794 }
795 const pin = pinOf(view.code)
796 if (!pin) throw new Error('that pin is no longer in the pane')
797 pin.graph = graph
798 if (title) pin.title = title
799 if (edited || pin.edited) {
800 pin.edited = true
801 S.edits[pin.code] = { graph, title: pin.title }
802 }
803}
804
805const EDIT_PROMPT = (where, source, text) => `${SIDE}
806The user is looking at this diagram in a side pane (${where}):
807${source}
808They typed this change: "${text}"
809Reply with ONLY the changed diagram as a mermaid flowchart ("flowchart TD"), no prose. Keep the ids of boxes that stay.`
810
811// The pane's input: a fork of the session redraws the level the person is on
812async function editByFork($, text) {
813 text = String(text).trim()
814 if (!text) return
815 normalizeView()
816 const view = { ...S.view }
817 const root = rootGraph(view)
818 const at = root ? levelAt(root, view.path) : { graph: null }
819 const pin = view.root === 'pin' ? pinOf(view.code) : null
820 const source = at.graph ? toMermaid(shallow(at.graph)) : !view.path.length && pin ? pin.code : null
821 if (!source) {
822 S.note = 'nothing to change here yet'
823 return redraw($)
824 }
825 S.note = `fork: changing it: "${short(text, 30)}"...`
826 await redraw($)
827 const gen = S.gen
828 try {
829 const r = await $.model.fork({ prompt: EDIT_PROMPT(crumbs(view).join(' › '), source, text) })
830 if (gen !== S.gen) return
831 if (!r.isAnswered) {
832 S.note = r.reason === 'nothing-to-fork' ? 'nothing to fork yet: send a prompt first' : 'fork failed: ' + r.reason
833 return
834 }
835 // The chart is in a code fence, or starts at a line of its own that opens with
836 // flowchart or graph (never at those words inside the prose before it)
837 const fenced = r.text.match(/```(?:mermaid)?\s*\n([\s\S]*?)```/)
838 const start = r.text.search(/^[ \t]*(flowchart|graph)\b/m)
839 if (!fenced && start < 0) throw new Error('the reply had no flowchart')
840 const code = fenced ? fenced[1] : r.text.slice(start)
841 setDiagram({ ...view, path: [] }, { path: view.path, mermaid: code })
842 S.note = `changed: ${short(text, 40)}`
843 await saveSaved($)
844 } catch (err) {
845 if (gen === S.gen) S.note = 'change failed: ' + err.message
846 } finally {
847 await redraw($)
848 }
849}
850
851// ---------------------------------------------------------------------------
852// What the pane draws
853// ---------------------------------------------------------------------------
854
855// Mark the box Claude is on and the boxes that already have a detail inside.
856// With `keys`, also number the first nine boxes: the keys that open them. Pins on
857// the home view have no keys of their own, so they are drawn without numbers.
858function marked(graph, keys = true) {
859 return {
860 nodes: graph.nodes.map((n, i) => ({
861 ...n,
862 label: (keys && i < 9 ? `${i + 1}·` : '') + (n.now ? '▶ ' : '') + n.label + (n.sub ? ' ▸' : ''),
863 })),
864 edges: graph.edges,
865 }
866}
867
868// A Button per box: the first nine also have their number as a hotkey, the rest
869// are reached with Tab or a click
870function nodeButtons(graph, act) {
871 return graph.nodes.map((n, i) => ({
872 key: 'node:' + n.id,
873 ...(i < 9 ? { hotkey: String(i + 1) } : {}),
874 label: short(n.label, 16) + (n.sub ? ' ▸' : ''),
875 onPress: () => act?.drill(n.id),
876 }))
877}
878
879// The change field's key: one per view, so text typed for one diagram does not
880// follow you into another. A pin is named by its code, not its place in the
881// list, which shifts when a new diagram is pinned.
882function inputKey(view = S.view) {
883 return ['edit', view.root === 'pin' ? 'pin' + hash(view.code || '') : 'map', ...view.path].join('-')
884}
885
886function hash(text) {
887 let h = 5381
888 for (let i = 0; i < text.length; i++) h = ((h * 33) ^ text.charCodeAt(i)) >>> 0
889 return h.toString(36)
890}
891
892// Items of { text, color, bold, dim }, { buttons, lead? } or { input }, cut to `w` columns
893function paneItems(w, act, now = Date.now()) {
894 const head = headItems(w, act, S.map?.summary)
895 if (W.tab === 'diffs') return [...head, ...diffItems(w, act, now)]
896 if (W.tab === 'github') return [...head, ...githubItems(w, act, now)]
897 if (W.tab === 'chats') return [...head, ...chatItems(w, act, now)]
898 if (W.tab === 'progress') return [...head, ...progressItems(w, act)]
899 return [...head, ...mapItems(w, act)]
900}
901
902function mapItems(w, act) {
903 normalizeView()
904 const out = []
905 const add = (text, style = {}) => out.push({ text, ...style })
906 const rule = () => add('─'.repeat(w), { dim: true })
907 // The change field, and a c key that moves the focus into it: number keys and
908 // b and t stay hotkeys, and the person need not Tab past every box to type
909 const input = placeholder => {
910 out.push({ buttons: [{ key: 'change', hotkey: 'c', label: 'type a change', onPress: () => act?.typeChange() }] })
911 out.push({ input: { key: inputKey(), placeholder, submitLabel: 'change', onSubmit: value => act?.edit(value) } })
912 add('keys: click the pane, ctrl+x tab or /diagram open', { dim: true })
913 }
914
915 // Inside a box, or a pin opened on its own: that one diagram
916 if (!isHome()) {
917 const v = S.view
918 add(short('◆ ' + crumbs().join(' › '), w), { bold: true, color: v.root === 'map' ? 'cyan' : 'magenta' })
919 out.push({ buttons: [
920 { key: 'back', hotkey: 'b', label: 'back', onPress: () => act?.back() },
921 { key: 'top', hotkey: 't', label: 'top', onPress: () => act?.top() },
922 ] })
923 const { graph, node } = levelAt(rootGraph(), v.path)
924 const pin = v.root === 'pin' ? pinOf(v.code) : null
925 if (graph) {
926 for (const l of drawGraph(marked(graph), w)) add(l, { color: v.root === 'map' ? 'cyan' : undefined })
927 out.push({ buttons: nodeButtons(graph, act) })
928 } else if (node) {
929 add(S.loading.has(viewKey(v)) ? `… drawing the inside of ${node.label}` : `${node.label} has no detail yet.`, { dim: true })
930 } else if (pin) {
931 for (const l of pin.code.split('\n')) add(l, { dim: pin.kind === 'mermaid' })
932 }
933 add((S.busy ? '… ' : '') + S.note, { dim: true })
934 if (graph || pin) input('describe a change to this diagram')
935 return out
936 }
937
938 add(spread('◆ LIVE WORK MAP', S.map ? `turn ${S.map.turn}${S.map.edited ? ', edited' : ''}` : '', w), { bold: true, color: 'cyan' })
939 if (S.map) {
940 add(S.map.title, { bold: true })
941 for (const l of drawGraph(marked(S.map.graph), w)) add(l, { color: 'cyan' })
942 add('┌ file ╭ component ╔ step ▶ now ▸ has detail', { dim: true })
943 }
944 add((S.busy ? '… ' : '') + S.note, { dim: true })
945 if (S.map) {
946 out.push({ buttons: nodeButtons(S.map.graph, act) })
947 input('describe a change to the map')
948 }
949 rule()
950
951 add(spread('◆ PINNED DIAGRAMS', S.pins.length ? `${S.pins.length} pinned` : '', w), { bold: true, color: 'magenta' })
952 if (!S.pins.length) add('Mermaid or box diagrams Claude writes appear here.', { dim: true })
953 S.pins.forEach((p, i) => {
954 if (i) add('')
955 const what = p.graph ? `flowchart, ${p.graph.nodes.length} boxes` : p.kind === 'mermaid' ? 'mermaid' : 'box diagram'
956 const head = short(`${i + 1}. ${p.title || what} · turn ${p.turn}${p.edited ? ' · edited' : ''}`, w - 12)
957 out.push({ lead: head, buttons: [{ key: 'pin:' + i, label: 'open ▸', onPress: () => act?.openPin(p.code) }] })
958 if (p.graph) for (const l of drawGraph(marked(p.graph, false), w)) add(l)
959 else {
960 // Not a flowchart we can draw: show the code as written, dimmed for mermaid
961 if (p.kind === 'mermaid') add('(raw mermaid: only flowcharts are drawn)', { dim: true })
962 for (const l of p.code.split('\n')) add(l, { dim: p.kind === 'mermaid' })
963 }
964 })
965 return out
966}
967
968// The pane as plain text: what tests and DIAGRAM_PANE_DUMP read
969export function paneText(w = 60, now = Date.now()) {
970 return paneItems(w, null, now).map(it => {
971 if (it.input) return `> ${it.input.placeholder}`
972 if (it.code) return it.code.source
973 if (it.link) return it.link.label
974 if (it.buttons) return [it.lead, ...it.buttons.map(b => (b.hotkey ? `${b.hotkey}: ${b.label}` : `[ ${b.label} ]`))].filter(Boolean).join(' ')
975 return it.text
976 }).join('\n')
977}
978
979function spread(left, right, w) {
980 return right ? left + ' '.repeat(Math.max(1, w - left.length - right.length)) + right : left
981}
982
983// A Text takes no control characters: escapes go, the rest become spaces
984// eslint-disable-next-line no-control-regex
985const textOnly = text => String(text ?? '').replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ' ')
986
987// Every 15 seconds while the pane is open: git while the diffs tab shows (else
988// each minute), gh when it is due (sooner while CI runs), and the times redrawn
989async function tick($, force = false) {
990 // A /clear starts a new session with no event of its own until its first turn ends
991 if (!S.sessionId || S.sessionId !== (await $.session.id())) await sync($)
992 if (!(await placePane($, { herdr: true })) && !force) return
993 if (!force && !(await $.ui.panes()).some(p => p.id === PANE)) return
994 const now = await $.clock.now()
995 if (force || W.tab === 'diffs' || now >= W.gitDueAt) {
996 W.gitDueAt = now + 60000
997 await refreshGit($)
998 }
999 if ((force && W.tab === 'github') || now >= W.ghDueAt) await refreshGh($)
1000 if (W.herdr === null || W.tab === 'chats') await refreshHerdr($)
1001 if (force && W.tab === 'chats') await updateChain($, { turns: S.turn })
1002 await redraw($)
1003}
1004
1005// Open or close the pane as the tab's record says; true when this chat shows it
1006let placing = Promise.resolve(true)
1007let holds = 0
1008function placePane($, opts = {}) {
1009 if (holds) return placing
1010 placing = placing.catch(() => {}).then(async () => {
1011 const { show } = await settleOwner($, opts)
1012 const up = (await $.ui.panes()).some(p => p.id === PANE)
1013 if (show && !up) await $.ui.open({ id: PANE, title: 'Diagrams' })
1014 if (!show && up) await $.ui.close({ id: PANE })
1015 return show
1016 })
1017 return placing
1018}
1019
1020// A show or hide the person asked for: no check of the record runs meanwhile,
1021// so none read before the change can undo it
1022async function alone(work) {
1023 holds++
1024 try {
1025 await placing.catch(() => {})
1026 return await work()
1027 } finally {
1028 holds--
1029 }
1030}
1031
1032const hidePane = $ => alone(async () => {
1033 await hideTab($)
1034 if ((await $.ui.panes()).some(p => p.id === PANE)) await $.ui.close({ id: PANE })
1035})
1036
1037// Ask for a redraw, and in tests also write the pane's text to a file
1038async function redraw($) {
1039 $.ui.invalidate('ui.render')
1040 const dump = await $.env.get('DIAGRAM_PANE_DUMP')
1041 if (dump) await $.fs.write(dump, paneText(60) + '\n')
1042}
1043hooks/draw.js 357 lines1// draw.js: turns diagrams into lines of terminal text. No $ here, just strings.
2//
3// findDiagrams(markdown) -> the mermaid and ASCII diagrams in a reply
4// parseMermaid(code) -> { nodes, edges } for a flowchart, or null
5// drawGraph(graph, width) -> lines of box-and-arrow text, top to bottom
6
7// ---------------------------------------------------------------------------
8// 1. Find diagrams in a reply
9// ---------------------------------------------------------------------------
10
11const CORNERS = /[┌┐└┘╭╮╰╯╔╗╚╝]|\+[-=]{3,}/
12const ARROWS = /(--+>|──+[>▶]|→|▶|▼|↓|\^)/
13const PLAIN = ['', 'text', 'txt', 'ascii', 'plain', 'diagram', 'art']
14
15// Returns [{ kind: 'mermaid' | 'ascii', code, title }] in reply order
16export function findDiagrams(markdown) {
17 const found = []
18 const fence = /```([\w-]*)[^\n]*\n([\s\S]*?)```/g
19 let m
20 while ((m = fence.exec(markdown))) {
21 const lang = m[1].toLowerCase()
22 const code = m[2].replace(/\s+$/, '')
23 const title = titleBefore(markdown.slice(0, m.index))
24 if (lang === 'mermaid') found.push({ kind: 'mermaid', code, title })
25 else if (PLAIN.includes(lang) && looksLikeBoxes(code)) found.push({ kind: 'ascii', code, title })
26 }
27 return found
28}
29
30// An ASCII diagram has box corners, or several lines of arrows
31function looksLikeBoxes(code) {
32 const lines = code.split('\n')
33 const corners = lines.filter(l => CORNERS.test(l)).length
34 const arrows = lines.filter(l => ARROWS.test(l)).length
35 return corners >= 2 || (arrows >= 2 && lines.length <= 40)
36}
37
38// A heading, or a line ending in ':', just before the diagram makes a good title
39function titleBefore(text) {
40 const lines = text.split('\n').map(l => l.trim()).filter(Boolean).slice(-3).reverse()
41 const pick = lines.find(l => /^#+\s/.test(l) || /:\**$/.test(l))
42 if (!pick || pick.startsWith('```')) return ''
43 return pick.replace(/^#+\s*/, '').replace(/^[-*\d.]+\s+/, '').replace(/[*_`]/g, '').replace(/:$/, '').slice(0, 60)
44}
45
46// ---------------------------------------------------------------------------
47// 2. Parse a mermaid flowchart into nodes and edges
48// ---------------------------------------------------------------------------
49
50// Shape openers, longest first, with their closer and the box we draw for them
51const SHAPES = [
52 ['([', '])', 'round'], ['[[', ']]', 'single'], ['[(', ')]', 'round'], ['((', '))', 'round'],
53 ['{{', '}}', 'double'], ['[/', '/]', 'single'], ['[\\', '\\]', 'single'], ['>', ']', 'single'],
54 ['[', ']', 'single'], ['(', ')', 'round'], ['{', '}', 'double'],
55]
56// Edge operators: an optional label in the middle (-- yes -->) or in pipes (-->|yes|)
57const EDGE_MID = /^\s*(?:<)?(?:--|-\.|==)\s+([^|>]+?)\s+(?:-->|--[ox]|---|\.->|-\.-|==>|===)/
58const EDGE = /^\s*(?:<)?(?:-\.+->|-\.+-|--+>|--+[ox]|---+|==+>|===+|~~~)(?:\|([^|]*)\|)?/
59const SKIP = /^(classDef|class|style|linkStyle|click|direction|%%|accTitle|accDescr)\b/
60
61// A subgraph becomes a node whose `sub` is its contents, so the pane shows it
62// as one box and can go into it. Edges that cross a subgraph's border are drawn
63// to the subgraph's box on the level where both ends are visible.
64export function parseMermaid(code) {
65 const stmts = code.split(/[\n;]/).map(l => l.trim()).filter(Boolean)
66 if (!/^(graph|flowchart)\b/i.test(stmts[0] || '')) return null
67 const nodes = new Map()
68 const edges = []
69 const stack = []
70 for (const stmt of stmts.slice(1)) {
71 if (/^subgraph\b/.test(stmt)) {
72 const g = readSubgraph(stmt.slice(8).trim())
73 const known = nodes.get(g.id)
74 if (known) Object.assign(known, { label: g.label, box: 'round', group: true, parent: stack.at(-1) ?? null })
75 else nodes.set(g.id, { id: g.id, label: g.label, box: 'round', group: true, parent: stack.at(-1) ?? null })
76 stack.push(g.id)
77 } else if (/^end$/.test(stmt)) stack.pop()
78 else if (!SKIP.test(stmt)) parseStatement(stmt, nodes, edges, stack.at(-1) ?? null)
79 }
80 if (!nodes.size) return null
81 return nest(nodes, edges, null)
82}
83
84// `subgraph id`, `subgraph id [Label]`, `subgraph "Label"` or `subgraph Some label`
85function readSubgraph(rest) {
86 // A quoted label may hold brackets: `subgraph S["arr[0] store"]`
87 const m = rest.match(/^(\w+)\s*\[\s*"([^"]*)"\s*\]$/) || rest.match(/^(\w+)\s*\[\s*([^\]"]*)\s*\]$/)
88 if (m) return { id: m[1], label: clean(m[2]) || m[1] }
89 const label = clean(rest) || 'group'
90 return { id: /^\w+$/.test(rest) ? rest : label.replace(/\W+/g, '_'), label }
91}
92
93// The level of the tree under `parent`: its own nodes, and the edges between them
94// after lifting each end to the ancestor that sits on this level
95function nest(flat, edges, parent) {
96 const parentOf = id => flat.get(id)?.parent ?? null
97 const onLevel = id => {
98 let cur = id
99 for (let hops = 0; cur != null && parentOf(cur) !== parent && hops < 50; hops++) cur = parentOf(cur)
100 return cur != null && parentOf(cur) === parent ? cur : null
101 }
102 const nodes = []
103 for (const n of flat.values()) {
104 if ((n.parent ?? null) !== parent) continue
105 const node = { id: n.id, label: n.label, box: n.box }
106 if (n.group) {
107 const sub = nest(flat, edges, n.id)
108 if (sub.nodes.length) node.sub = sub
109 }
110 nodes.push(node)
111 }
112 const out = []
113 const seen = new Set()
114 for (const e of edges) {
115 const from = onLevel(e.from), to = onLevel(e.to)
116 if (!from || !to || from === to) continue
117 const key = `${from}>${to}>${e.label}`
118 if (seen.has(key)) continue
119 seen.add(key)
120 out.push({ from, to, label: e.label })
121 }
122 return { nodes, edges: out }
123}
124
125// A statement is: group (edge group)*, where a group is node (& node)*.
126// A node mentioned inside a subgraph belongs to it, as in mermaid.
127function parseStatement(stmt, nodes, edges, parent) {
128 let rest = stmt
129 let prev = null
130 while (rest.trim()) {
131 const group = []
132 for (;;) {
133 const n = readNode(rest)
134 if (!n) return
135 rest = n.rest
136 const known = nodes.get(n.id)
137 if (!known) nodes.set(n.id, { id: n.id, label: n.label || n.id, box: n.box || 'single', parent })
138 else {
139 if (n.label && !known.group) Object.assign(known, { label: n.label, box: n.box })
140 // Declared first, linked later: a node moves into a deeper subgraph, never out of one.
141 // A subgraph stays where it was declared: moving it could nest it inside itself.
142 if (parent && !known.group && known.id !== parent && within(nodes, parent, known.parent)) known.parent = parent
143 }
144 group.push(n.id)
145 const amp = rest.match(/^\s*&\s*/)
146 if (!amp) break
147 rest = rest.slice(amp[0].length)
148 }
149 if (prev) for (const from of prev.ids) for (const to of group) edges.push({ from, to, label: prev.label })
150 const e = rest.match(EDGE_MID) || rest.match(EDGE)
151 if (!e) return
152 prev = { ids: group, label: clean(e[1] || '') }
153 rest = rest.slice(e[0].length)
154 }
155}
156
157// Is subgraph `inner` inside `outer` (or is `outer` the top level)?
158function within(nodes, inner, outer) {
159 if (outer == null) return true
160 for (let cur = nodes.get(inner)?.parent, hops = 0; cur != null && hops < 50; cur = nodes.get(cur)?.parent, hops++) {
161 if (cur === outer) return true
162 }
163 return false
164}
165
166function readNode(text) {
167 const m = text.match(/^\s*([\w]+)/)
168 if (!m) return null
169 let rest = text.slice(m[0].length)
170 for (const [open, close, box] of SHAPES) {
171 if (!rest.startsWith(open)) continue
172 let body = rest.slice(open.length)
173 let label
174 const quoted = body.match(/^\s*"([^"]*)"\s*/)
175 if (quoted) { label = quoted[1]; body = body.slice(quoted[0].length) }
176 const end = body.indexOf(close)
177 if (end < 0) return null
178 if (label === undefined) label = body.slice(0, end)
179 return { id: m[1], label: clean(label), box, rest: body.slice(end + close.length) }
180 }
181 return { id: m[1], label: '', box: '', rest }
182}
183
184function clean(s) {
185 return s.trim().replace(/^"(.*)"$/, '$1').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '')
186 .replace(/"/g, '"').replace(/[ \t]+/g, ' ').replace(/ *\n */g, '\n').trim()
187}
188
189// ---------------------------------------------------------------------------
190// 3. Draw a graph as boxes and arrows, top to bottom, in `width` columns
191// ---------------------------------------------------------------------------
192
193const BOX = {
194 single: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│', out: '┬' },
195 round: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', out: '┬' },
196 double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║', out: '╤' },
197}
198// A connector cell remembers which way its lines go: up, down, left, right
199const U = 1, D = 2, L = 4, R = 8
200const JOIN = { [U | D]: '│', [L | R]: '─', [D | R]: '┌', [D | L]: '┐', [U | R]: '└', [U | L]: '┘',
201 [U | D | R]: '├', [U | D | L]: '┤', [D | L | R]: '┬', [U | L | R]: '┴', [U | D | L | R]: '┼',
202 [U]: '│', [D]: '│', [L]: '─', [R]: '─' }
203
204// graph: { nodes: [{ id, label, box }], edges: [{ from, to, label }] }
205export function drawGraph(graph, width) {
206 const nodes = graph.nodes
207 const ids = new Set(nodes.map(n => n.id))
208 const edges = graph.edges.filter(e => ids.has(e.from) && ids.has(e.to) && e.from !== e.to)
209 const layer = layers(nodes, edges)
210 const rows = displayRows(nodes, edges, layer, width)
211
212 // Measure every box, then place the rows one under another
213 const place = new Map()
214 let y = 0
215 const routed = new Set()
216 const gaps = []
217 rows.forEach((row, r) => {
218 // Labels get 22 columns when the row has room, else 16 (what displayRows packed for)
219 const roomy = row.reduce((s, n) => s + Math.min(longest(n.label), 22) + 6, -2) <= width
220 const boxes = row.map(n => ({ n, lines: wrap(n.label, roomy ? 22 : 16) }))
221 boxes.forEach(b => { b.w = Math.max(...b.lines.map(l => l.length)) + 4; b.h = b.lines.length + 2 })
222 const total = boxes.reduce((s, b) => s + b.w, 0) + (boxes.length - 1) * 2
223 let x = Math.max(0, Math.floor((width - total) / 2))
224 for (const b of boxes) { place.set(b.n.id, { ...b, x, y, row: r, cx: x + Math.floor(b.w / 2) }); x += b.w + 2 }
225 y += Math.max(...boxes.map(b => b.h))
226 // Edges from this row straight to the next one get a 3-line gap to run in
227 const next = new Set((rows[r + 1] || []).map(n => n.id))
228 const down = edges.filter(e => row.some(n => n.id === e.from) && next.has(e.to))
229 down.forEach(e => routed.add(e))
230 if (r < rows.length - 1) { gaps.push({ y, edges: down }); y += down.length ? 3 : 1 }
231 })
232
233 // Paint onto a grid: boxes as characters, connectors as direction bits
234 const grid = Array.from({ length: y }, () => Array.from({ length: width }, () => ({ ch: ' ', bits: 0 })))
235 const put = (gy, gx, ch) => { if (grid[gy]?.[gx]) grid[gy][gx].ch = ch }
236 const bit = (gy, gx, b) => { if (grid[gy]?.[gx]) grid[gy][gx].bits |= b }
237 for (const p of place.values()) {
238 const s = BOX[p.n.box] || BOX.single
239 put(p.y, p.x, s.tl); put(p.y, p.x + p.w - 1, s.tr)
240 put(p.y + p.h - 1, p.x, s.bl); put(p.y + p.h - 1, p.x + p.w - 1, s.br)
241 for (let i = 1; i < p.w - 1; i++) { put(p.y, p.x + i, s.h); put(p.y + p.h - 1, p.x + i, s.h) }
242 p.lines.forEach((line, i) => {
243 put(p.y + 1 + i, p.x, s.v); put(p.y + 1 + i, p.x + p.w - 1, s.v)
244 ;[...line.padEnd(p.w - 4)].forEach((c, j) => put(p.y + 1 + i, p.x + 2 + j, c))
245 })
246 }
247 const labels = []
248 for (const g of gaps) {
249 for (const e of g.edges) {
250 const a = place.get(e.from), b = place.get(e.to)
251 const s = BOX[a.n.box] || BOX.single
252 // Enter straight down when the source sits above the target, else at its centre
253 const tx = a.cx > b.x + 1 && a.cx < b.x + b.w - 2 ? a.cx : b.cx
254 put(a.y + a.h - 1, a.cx, s.out) // the line leaves the box's bottom edge
255 for (let y = a.y + a.h; y <= g.y; y++) bit(y, a.cx, U | D) // down past shorter boxes
256 bit(g.y + 1, a.cx, U) // turn onto the bus row
257 for (let x = Math.min(a.cx, tx); x < Math.max(a.cx, tx); x++) { bit(g.y + 1, x, R); bit(g.y + 1, x + 1, L) }
258 bit(g.y + 1, tx, D)
259 put(g.y + 2, tx, '▼') // and points into the target
260 if (e.label) labels.push({ e, y: g.y + 2, x: tx + 1 })
261 }
262 }
263 // Labels go beside their arrow once every arrow is down, so none covers another
264 const notes = []
265 for (const { e, y, x } of labels) {
266 const text = ' ' + short(e.label, 14) + ' '
267 const free = [...text].every((_, j) => grid[y][x + j] && grid[y][x + j].ch === ' ')
268 if (free) [...text.trimEnd()].forEach((c, j) => put(y, x + j, c))
269 else notes.push(e)
270 }
271 const out = grid.map(row => row.map(c => (c.ch === ' ' && c.bits ? JOIN[c.bits] : c.ch)).join('').trimEnd())
272
273 // Edges we could not route straight down are listed under the drawing
274 const label = id => short(nodes.find(n => n.id === id).label, 18)
275 for (const e of edges) if (!routed.has(e) || notes.includes(e)) {
276 const back = layer.get(e.to) <= layer.get(e.from)
277 out.push(short(` ${back ? '↺' : '⇢'} ${label(e.from)} → ${label(e.to)}${e.label ? ' (' + e.label + ')' : ''}`, width))
278 }
279 return out
280}
281
282// Longest-path layers, after dropping the edges that close a cycle
283function layers(nodes, edges) {
284 const out = new Map(nodes.map(n => [n.id, []]))
285 const state = new Map()
286 const forward = []
287 const visit = id => {
288 state.set(id, 1)
289 for (const e of edges.filter(e => e.from === id)) {
290 if (state.get(e.to) === 1) continue // a back edge: skip it for layering
291 forward.push(e)
292 if (!state.get(e.to)) visit(e.to)
293 }
294 state.set(id, 2)
295 }
296 nodes.forEach(n => { if (!state.get(n.id)) visit(n.id) })
297 forward.forEach(e => out.get(e.from).push(e.to))
298 const layer = new Map(nodes.map(n => [n.id, 0]))
299 for (let pass = 0; pass < nodes.length; pass++) {
300 let moved = false
301 for (const e of forward) if (layer.get(e.to) < layer.get(e.from) + 1) { layer.set(e.to, layer.get(e.from) + 1); moved = true }
302 if (!moved) break
303 }
304 return layer
305}
306
307// Group nodes by layer, order each layer under its parents, and split a layer
308// that is too wide for the pane into several rows
309function displayRows(nodes, edges, layer, width) {
310 const depth = Math.max(...layer.values())
311 const pos = new Map()
312 const rows = []
313 for (let d = 0; d <= depth; d++) {
314 const these = nodes.filter(n => layer.get(n.id) === d)
315 const centre = n => {
316 const ps = edges.filter(e => e.to === n.id && pos.has(e.from)).map(e => pos.get(e.from))
317 return ps.length ? ps.reduce((a, b) => a + b, 0) / ps.length : nodes.indexOf(n)
318 }
319 these.sort((a, b) => centre(a) - centre(b))
320 these.forEach((n, i) => pos.set(n.id, i))
321 // Fill a row while the boxes fit side by side (labels up to 16 wide), then start another
322 let row = [], used = 0
323 for (const n of these) {
324 const w = Math.min(longest(n.label), 16) + 4 + (row.length ? 2 : 0)
325 if (row.length && used + w > width) { rows.push(row); row = [], used = 0 }
326 row.push(n); used += w
327 }
328 if (row.length) rows.push(row)
329 }
330 return rows
331}
332
333// Word-wrap a label into at most 3 lines of `w` columns
334function wrap(text, w) {
335 const lines = []
336 // A <br> in a mermaid label is a line break we keep
337 for (const part of String(text).split('\n')) {
338 let cur = ''
339 for (const word of part.split(/\s+/).filter(Boolean)) {
340 const piece = word.length > w ? word.slice(0, w - 1) + '…' : word
341 if (!cur) cur = piece
342 else if ((cur + ' ' + piece).length <= w) cur += ' ' + piece
343 else { lines.push(cur); cur = piece }
344 }
345 if (cur) lines.push(cur)
346 }
347 if (lines.length > 3) { lines.length = 3; lines[2] = short(lines[2] + ' …', w) }
348 return lines.length ? lines : ['?']
349}
350
351export function short(s, n) {
352 s = String(s).replace(/\n/g, ' ')
353 return s.length <= n ? s : s.slice(0, Math.max(1, n - 1)) + '…'
354}
355
356const longest = label => Math.max(...String(label).split('\n').map(l => l.length))
357hooks/graph.js 208 lines1// graph.js: the graph tree the pane navigates and edits. No $ here, just data.
2//
3// A graph is { nodes: [{ id, label, box, now?, sub? }], edges: [{ from, to, label }] }.
4// A node's `sub` is its detail: another graph, one level down.
5//
6// levelAt(root, path) -> the graph a path of node ids leads to
7// setLevel(root, path, level) -> a copy of root with that level replaced
8// applyOps(graph, ops) -> a patched copy, and what changed
9// uniqueIds(root) -> a copy where no id appears twice in the tree
10// toMermaid(graph) -> flowchart source, details as subgraphs
11
12const KIND_BOX = { file: 'single', component: 'round', step: 'double' }
13
14export const clone = g => JSON.parse(JSON.stringify(g))
15
16export const boxOf = kind => KIND_BOX[kind] || 'single'
17
18// Mermaid ids are words; anything else becomes `_`
19export const cleanId = id => String(id).replace(/\W/g, '_') || '_'
20
21// Follow `path` down through each node's `sub`. Returns { graph } when the path
22// ends on a level, { graph: null, node } when the last node has no detail yet,
23// and { graph: null, node: null } when a node on the path is gone.
24export function levelAt(root, path) {
25 let g = root
26 for (const id of path) {
27 const n = g?.nodes.find(n => n.id === id)
28 if (!n) return { graph: null, node: null }
29 if (!n.sub) return { graph: null, node: n }
30 g = n.sub
31 }
32 return { graph: g, node: null }
33}
34
35// A copy of `root` with the level at `path` replaced; the last node on the path
36// gets `level` as its detail, created when it had none.
37export function setLevel(root, path, level) {
38 if (!path.length) return clone(level)
39 const out = clone(root)
40 let g = out
41 path.forEach((id, i) => {
42 const n = g.nodes.find(n => n.id === id)
43 if (!n) throw new Error(`no node "${id}" on the path`)
44 if (i === path.length - 1) n.sub = clone(level)
45 else if (!n.sub) throw new Error(`node "${id}" has no detail to go into`)
46 else g = n.sub
47 })
48 return out
49}
50
51// Every node id in the tree, at every level
52export function allIds(g, out = new Set()) {
53 for (const n of g?.nodes || []) {
54 out.add(n.id)
55 if (n.sub) allIds(n.sub, out)
56 }
57 return out
58}
59
60// Mermaid ids are global, so a detail's ids must not clash with the rest of the
61// tree: clashing ones get `prefix_` in front. `taken` is the rest of the tree's ids.
62export function adoptIds(level, taken, prefix) {
63 const out = clone(level)
64 const rename = new Map()
65 const walk = g => {
66 for (const n of g.nodes) {
67 if (taken.has(n.id)) {
68 let id = `${cleanId(prefix)}_${n.id}`
69 for (let i = 2; taken.has(id); i++) id = `${cleanId(prefix)}_${n.id}${i}`
70 rename.set(n.id, id)
71 n.id = id
72 }
73 taken.add(n.id)
74 if (n.sub) walk(n.sub)
75 }
76 for (const e of g.edges) {
77 e.from = rename.get(e.from) || e.from
78 e.to = rename.get(e.to) || e.to
79 }
80 }
81 walk(out)
82 return out
83}
84
85// Mermaid ids are global, so one id may appear only once in the whole tree.
86// Levels are walked top first, so a box on a higher level keeps its id and a
87// clashing one further down gets its parent's id in front.
88export function uniqueIds(root) {
89 const out = clone(root)
90 const taken = new Set()
91 let level = [{ g: out, prefix: null }]
92 while (level.length) {
93 const below = []
94 for (const { g, prefix } of level) {
95 const rename = new Map()
96 for (const n of g.nodes) {
97 if (taken.has(n.id)) {
98 const base = prefix ? `${cleanId(prefix)}_${n.id}` : n.id
99 let id = base
100 for (let i = 2; taken.has(id); i++) id = `${base}${i}`
101 rename.set(n.id, id)
102 n.id = id
103 }
104 taken.add(n.id)
105 }
106 for (const e of g.edges) {
107 e.from = rename.get(e.from) || e.from
108 e.to = rename.get(e.to) || e.to
109 }
110 for (const n of g.nodes) if (n.sub) below.push({ g: n.sub, prefix: n.id })
111 }
112 level = below
113 }
114 return out
115}
116
117// Keep the details of nodes that survive a redraw. A node survives on the same
118// id; with `sameLabel` it must keep its label too (a fork may reuse ids).
119export function carrySubs(before, after, { sameLabel = false } = {}) {
120 const out = clone(after)
121 for (const n of out.nodes) {
122 const old = before?.nodes.find(o => o.id === n.id && (!sameLabel || o.label === n.label))
123 if (old?.sub && !n.sub) n.sub = clone(old.sub)
124 }
125 return out
126}
127
128// Patch a graph. ops: [{ op, id, label, kind, from, to }], op one of
129// add_node, remove_node, relabel, set_kind, add_edge, remove_edge.
130// Returns { graph, changes }; throws on an op it cannot apply.
131export function applyOps(graph, ops) {
132 const g = clone(graph || { nodes: [], edges: [] })
133 const changes = []
134 const find = id => {
135 const n = g.nodes.find(n => n.id === id)
136 if (!n) throw new Error(`no node "${id}" (ids: ${g.nodes.map(n => n.id).join(', ') || 'none'})`)
137 return n
138 }
139 for (const o of ops || []) {
140 switch (o.op) {
141 case 'add_node': {
142 const id = cleanId(o.id || o.label)
143 if (g.nodes.some(n => n.id === id)) throw new Error(`node "${id}" already exists`)
144 g.nodes.push({ id, label: String(o.label || id), box: boxOf(o.kind) })
145 changes.push(`added ${id}`)
146 break
147 }
148 case 'remove_node': {
149 const n = find(o.id)
150 g.nodes = g.nodes.filter(x => x !== n)
151 const gone = g.edges.filter(e => e.from === n.id || e.to === n.id).length
152 g.edges = g.edges.filter(e => e.from !== n.id && e.to !== n.id)
153 changes.push(`removed ${n.id}` + (gone ? ` and ${gone} edge${gone > 1 ? 's' : ''}` : ''))
154 break
155 }
156 case 'relabel': {
157 const n = find(o.id)
158 n.label = String(o.label ?? n.label)
159 changes.push(`relabelled ${n.id}`)
160 break
161 }
162 case 'set_kind': {
163 find(o.id).box = boxOf(o.kind)
164 changes.push(`${o.id} is now a ${o.kind}`)
165 break
166 }
167 case 'add_edge': {
168 find(o.from), find(o.to)
169 g.edges.push({ from: o.from, to: o.to, label: String(o.label || '') })
170 changes.push(`linked ${o.from} → ${o.to}`)
171 break
172 }
173 case 'remove_edge': {
174 const before = g.edges.length
175 g.edges = g.edges.filter(e => !(e.from === o.from && e.to === o.to))
176 if (g.edges.length === before) throw new Error(`no edge ${o.from} → ${o.to}`)
177 changes.push(`unlinked ${o.from} → ${o.to}`)
178 break
179 }
180 default:
181 throw new Error(`unknown op "${o.op}"`)
182 }
183 }
184 return { graph: g, changes }
185}
186
187// Flowchart source for a graph; a node with a detail becomes a subgraph, so
188// parseMermaid reads the whole tree back
189export function toMermaid(graph) {
190 return ['flowchart TD', ...body(graph, ' ')].join('\n')
191}
192
193function body(g, pad) {
194 const out = []
195 for (const n of g.nodes) {
196 if (n.sub?.nodes.length) {
197 out.push(`${pad}subgraph ${n.id}["${quote(n.label)}"]`, ...body(n.sub, pad + ' '), `${pad}end`)
198 } else {
199 const [open, close] = { round: ['("', '")'], double: ['{"', '"}'] }[n.box] || ['["', '"]']
200 out.push(`${pad}${n.id}${open}${quote(n.label)}${close}`)
201 }
202 }
203 for (const e of g.edges) out.push(`${pad}${e.from} -->${e.label ? `|${quote(e.label)}|` : ''} ${e.to}`)
204 return out
205}
206
207const quote = s => String(s).replace(/"/g, "'").replace(/\|/g, '/').replace(/\n/g, '<br>')
208hooks/panel.js 786 lines1// panel.js: the pane's other tabs. The map and the pins are register.js's;
2// this file keeps the rest of what the pane shows, reads it with git and gh,
3// and lays each tab out as the same items register.js draws.
4//
5// diffs every file changed against the base branch (or HEAD), with its
6// line counts and when it was last written; a number opens its diff
7// github the branch's PR, its review state and CI checks, the latest runs
8// chats the sessions this terminal tab has held, oldest first, each with
9// what it did; a /clear, a handoff or a new `claude` adds one
10
11import { short } from './draw.js'
12import {
13 STAGES, STATUS_MARK, ago, paneOwner, progressFrom, stageRows, changedFiles, ciWord, herdrPanes, hunksOf, lastCommitted, clock, handoffFrom, joinChain, leftCut, prWord, reviewWord, runState, splitDiff, tabIdFrom, tallyChecks,
14} from './work.js'
15
16export const TABS = [
17 { id: 'map', hotkey: 'm', label: 'map' },
18 { id: 'progress', hotkey: 'w', label: 'progress' },
19 { id: 'diffs', hotkey: 'd', label: 'diffs' },
20 { id: 'github', hotkey: 'g', label: 'github' },
21 { id: 'chats', hotkey: 's', label: 'chats' },
22]
23
24const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
25
26export const W = {
27 tab: 'map',
28 repo: null, // the git repository the chat works in: its root
29 git: null, // { branch, baseRef, baseLabel, files, diffs, at, error }
30 diffMode: 'chat', // 'chat': since this chat started; 'branch': since the branch left its base; 'head': uncommitted only
31 openFile: null, // the file whose diff the diffs tab shows
32 gh: null, // { pr, checks, runs, sync, at, error }
33 chain: null, // { sessions, overall, overallOf }
34 chainKey: null,
35 tabId: null,
36 sessionId: null,
37 interactive: true, // a -p run or the SDK is no chat of the tab's
38 ctx: null, // the context window's fill, percent
39 firstPrompt: '',
40 touched: [], // files Claude wrote this session, newest last, absolute
41 fetched: {}, // base branch -> when origin's copy was last fetched
42 herdr: null, // { bin, workspace, pane, tab, label, panes, at, error }: the tab's live panes
43 tabFile: undefined,// the herdr tab's record of which pane shows the pane (null outside herdr)
44 myPane: null, // this chat's herdr pane
45 paneOwner: null, // the herdr pane that shows the tab's pane
46 hidden: false, // hidden outside herdr, where no tab record is kept
47 msgs: [], // the transcript as last read, for the progress tab
48 declared: null, // { input, seen }: the last progress_set, until the transcript holds it
49 busy: { git: false, gh: false, overall: false },
50 ghDueAt: 0,
51 gitDueAt: 0,
52}
53
54// A new session id (/clear): the tab's chain goes on, the session's own facts start over
55export function startPanelSession(id) {
56 if (W.sessionId === id) return
57 Object.assign(W, { sessionId: id, touched: [], firstPrompt: '', openFile: null, ctx: null, msgs: [], declared: null })
58}
59
60// ---------------------------------------------------------------------------
61// Which repository
62// ---------------------------------------------------------------------------
63
64const dirOf = p => String(p).replace(/\/[^/]*$/, '') || '/'
65
66async function toplevel($, dir) {
67 try {
68 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir, timeoutMs: 5000 })
69 return r.exitCode === 0 ? r.stdout.trim() : null
70 } catch {
71 return null
72 }
73}
74
75// The repository of the file Claude wrote last, else of the session's folder:
76// a session started above its repositories still shows the one it works in
77async function findRepo($) {
78 for (const file of [...W.touched].reverse()) {
79 const top = await toplevel($, dirOf(file))
80 if (top) return top
81 }
82 return toplevel($, await $.session.cwd())
83}
84
85// What the transcript says about this session: the files written, the first prompt
86export function readMessages(messages, cwd) {
87 const abs = p => (String(p).startsWith('/') ? String(p) : `${cwd}/${p}`)
88 const touched = []
89 for (const m of messages) {
90 for (const t of m.toolUses || []) {
91 const f = t.input?.file_path || t.input?.notebook_path
92 if (EDIT_TOOLS.includes(t.tool) && f) {
93 const p = abs(f)
94 const i = touched.indexOf(p)
95 if (i >= 0) touched.splice(i, 1)
96 touched.push(p)
97 }
98 }
99 }
100 W.touched = touched
101 W.msgs = messages
102 // A slash command (/reload-plugins, /diagram) and its output are no prompt
103 const first = messages.find(m => m.role === 'user' && m.text && !m.toolResults?.length && !/^\s*<(command-|local-command-)/.test(m.text))
104 W.firstPrompt = first ? plain(first.text) : ''
105}
106
107// One line of text a Text element takes: no control characters, no escapes
108// eslint-disable-next-line no-control-regex
109export const plain = text => String(text ?? '').replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f-\x9f]+/g, ' ').replace(/\s+/g, ' ').trim()
110
111// ---------------------------------------------------------------------------
112// git: the diffs tab
113// ---------------------------------------------------------------------------
114
115async function git($, args, timeoutMs = 15000) {
116 const r = await $.process.run(['git', '-c', 'core.quotePath=false', ...args], { cwd: W.repo, timeoutMs })
117 if (r.exitCode !== 0) return null
118 // Output cut at 4 MiB ends mid-file: drop the last file rather than draw half of it
119 return r.isStdoutTruncated ? r.stdout.slice(0, Math.max(0, r.stdout.lastIndexOf('\ndiff --git '))) : r.stdout
120}
121
122// A repository born during the chat is compared with nothing at all
123const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
124
125export const DIFF_MODES = ['chat', 'pr', 'head']
126const MODE_NAMES = { chat: 'this chat', pr: 'the PR', head: 'uncommitted' }
127
128// origin's copy of the base branch, fetched at most every five minutes, so the
129// PR view starts where the branch left the base as it is on GitHub
130async function fetchBase($, branch) {
131 const now = await $.clock.now()
132 if (W.fetched[branch] && now - W.fetched[branch] < 300000) return
133 W.fetched[branch] = now
134 // Never a credential prompt: a fetch that needs one just fails
135 await $.process.run(['git', 'fetch', '--quiet', '--no-tags', 'origin', branch], { cwd: W.repo, timeoutMs: 20000, env: { GIT_TERMINAL_PROMPT: '0' } }).catch(() => {})
136}
137
138// What a view compares: `ref` against the working tree, or `ref` to `to` (committed only)
139async function baseOf($) {
140 if (W.diffMode === 'head') return { ref: 'HEAD', label: 'uncommitted' }
141 if (W.diffMode === 'chat') {
142 // The commit HEAD was on when the chat started: committed or not, what
143 // changed since is this chat's work (and anything pulled in meanwhile)
144 const { startedAt } = await $.session.usage()
145 const at = (await git($, ['rev-list', '-1', `--before=@${Math.floor(startedAt / 1000)}`, 'HEAD']))?.trim()
146 return { ref: at || EMPTY_TREE, label: `this chat (since ${clock(startedAt).split(' ')[1]})` }
147 }
148 // The PR: what the branch's commits add on top of where it last met its
149 // base. Merging or rebasing the base in moves that point, so the base's own
150 // changes never show, as on GitHub's Files changed.
151 const pr = W.gh?.pr?.state === 'OPEN' || W.gh?.pr?.state === 'MERGED' ? W.gh.pr : null
152 const head = (await git($, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']))?.trim()
153 const wanted = pr?.baseRefName || head?.replace(/^origin\//, '') || 'main'
154 await fetchBase($, wanted)
155 const candidates = [...new Set([`origin/${wanted}`, head, 'origin/main', 'origin/master', 'main', 'master'].filter(Boolean))]
156 for (const c of candidates) {
157 const mb = (await git($, ['merge-base', 'HEAD', c]))?.trim()
158 if (mb) return { ref: mb, to: 'HEAD', label: `${pr ? `PR #${pr.number}` : 'branch'} vs ${c.replace(/^origin\//, '')}`, committed: true }
159 }
160 return { ref: 'HEAD', label: 'uncommitted' }
161}
162
163export async function refreshGit($) {
164 // A view switched while git runs is read again once it is done
165 if (W.busy.git) {
166 W.gitAgain = true
167 return
168 }
169 W.busy.git = true
170 W.gitAgain = false
171 try {
172 W.repo = await findRepo($)
173 if (!W.repo) {
174 W.git = { error: 'not in a git repository', files: [], diffs: {} }
175 return
176 }
177 const branch = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD']))?.trim() || '?'
178 const base = await baseOf($)
179 const range = base.to ? [base.ref, base.to] : [base.ref]
180 const hasLog = base.ref !== 'HEAD' && base.ref !== EMPTY_TREE
181 const [numstat, status, nameStatus, diff, log, dates] = await Promise.all([
182 git($, ['diff', '--numstat', '-M', ...range]),
183 // The PR view is commits only: what the working tree has on top is not in it
184 base.committed ? '' : git($, ['status', '--porcelain=v1', '--untracked-files=all']),
185 git($, ['diff', '--name-status', '-M', ...range]),
186 git($, ['diff', '-M', '--no-color', ...range], 30000),
187 hasLog ? git($, ['log', '--format=%h %s', `${base.ref}..HEAD`]) : '',
188 base.committed ? git($, ['log', '--format=@%ct', '--name-only', `${base.ref}..HEAD`]) : '',
189 ])
190 const commits = (log || '').split('\n').filter(Boolean)
191 const committedAt = lastCommitted(dates)
192 const diffs = splitDiff(diff || '')
193 // Untracked files have no diff in git: their lines are all new
194 const untracked = {}
195 for (const line of (status || '').split('\n')) {
196 if (!line.startsWith('?? ')) continue
197 const path = line.slice(3).replace(/^"|"$/g, '')
198 if (Object.keys(untracked).length >= 20) break
199 try {
200 const text = await $.fs.read(`${W.repo}/${path}`)
201 if (typeof text !== 'string' || text.length > 200000) continue
202 const lines = text.replace(/\n$/, '').split('\n')
203 untracked[path] = lines.length
204 diffs[path] = `@@ -0,0 +1,${lines.length} @@\n` + lines.map(l => '+' + l).join('\n')
205 } catch {}
206 }
207 const files = changedFiles({ numstat, status, nameStatus, untracked }).slice(0, 80)
208 const mine = new Set(W.touched.map(p => p.startsWith(W.repo + '/') ? p.slice(W.repo.length + 1) : null).filter(Boolean))
209 await Promise.all(files.map(async f => {
210 f.mine = mine.has(f.path)
211 // In the PR view a file's time is its last commit's; elsewhere, its last write
212 if (base.committed) {
213 f.mtime = committedAt[f.path] || 0
214 return
215 }
216 try {
217 f.mtime = (await $.fs.stat(`${W.repo}/${f.path}`)).mtimeMs
218 } catch {
219 f.mtime = 0 // deleted
220 }
221 }))
222 files.sort((a, b) => b.mtime - a.mtime)
223 W.git = { branch, baseRef: base.ref, baseLabel: base.label, committed: Boolean(base.committed), files, diffs, commits, at: await $.clock.now() }
224 if (W.openFile && !files.some(f => f.path === W.openFile)) W.openFile = null
225 } catch (err) {
226 W.git = { ...(W.git || { files: [], diffs: {} }), error: 'git: ' + err.message }
227 } finally {
228 W.busy.git = false
229 }
230 if (W.gitAgain) await refreshGit($)
231}
232
233// ---------------------------------------------------------------------------
234// gh: the github tab
235// ---------------------------------------------------------------------------
236
237const PR_FIELDS = 'number,title,url,state,isDraft,reviewDecision,statusCheckRollup,headRefName,baseRefName,mergeable,mergeStateStatus,updatedAt,latestReviews'
238const RUN_FIELDS = 'databaseId,status,conclusion,workflowName,displayTitle,createdAt,url,event'
239
240export async function refreshGh($) {
241 if (W.busy.gh) return
242 W.busy.gh = true
243 try {
244 if (!W.repo) W.repo = await findRepo($)
245 if (!W.repo) {
246 W.gh = { error: 'not in a git repository' }
247 return
248 }
249 const run = argv => $.process.run(argv, { cwd: W.repo, timeoutMs: 20000 })
250 const branch = W.git?.branch || (await git($, ['rev-parse', '--abbrev-ref', 'HEAD']))?.trim()
251 const [pr, runs, counts] = await Promise.all([
252 run(['gh', 'pr', 'view', '--json', PR_FIELDS]),
253 run(['gh', 'run', 'list', '--branch', branch || 'HEAD', '--limit', '5', '--json', RUN_FIELDS]),
254 git($, ['rev-list', '--left-right', '--count', '@{upstream}...HEAD']),
255 ])
256 const out = { at: await $.clock.now(), branch }
257 if (pr.exitCode === 0) {
258 out.pr = JSON.parse(pr.stdout)
259 out.checks = tallyChecks(out.pr.statusCheckRollup)
260 } else if (!/no pull requests found/i.test(pr.stderr)) {
261 out.error = short('gh: ' + (pr.stderr.trim().split('\n')[0] || `exit ${pr.exitCode}`), 120)
262 }
263 if (runs.exitCode === 0) out.runs = JSON.parse(runs.stdout || '[]')
264 if (counts) {
265 const [behind, ahead] = counts.trim().split(/\s+/).map(Number)
266 out.sync = { ahead, behind }
267 } else out.sync = null
268 W.gh = out
269 const pending = out.checks?.pending || (out.runs || []).some(r => runState(r) === 'pending')
270 W.ghDueAt = out.at + (pending ? 45000 : 180000)
271 } catch (err) {
272 W.gh = { ...(W.gh || {}), error: 'gh: ' + err.message }
273 W.ghDueAt = (await $.clock.now()) + 180000
274 } finally {
275 W.busy.gh = false
276 }
277}
278
279// ---------------------------------------------------------------------------
280// Chats: the tab's chain of sessions
281// ---------------------------------------------------------------------------
282
283async function tabId($) {
284 return tabIdFrom({
285 HERDR_TAB_ID: await $.env.get('HERDR_TAB_ID'),
286 TMUX_PANE: await $.env.get('TMUX_PANE'),
287 ZELLIJ_PANE_ID: await $.env.get('ZELLIJ_PANE_ID'),
288 WEZTERM_PANE: await $.env.get('WEZTERM_PANE'),
289 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
290 ITERM_SESSION_ID: await $.env.get('ITERM_SESSION_ID'),
291 TERM_SESSION_ID: await $.env.get('TERM_SESSION_ID'),
292 })
293}
294
295// The chain's writes go one at a time: each reads the chain fresh, changes
296// it and writes it back, so a summary written while a turn's row is written
297// is not lost to it
298let chainQueue = Promise.resolve()
299const queued = work => {
300 const run = chainQueue.then(work)
301 chainQueue = run.catch(() => {})
302 return run
303}
304
305// Record this session in the tab's chain, so a second process in the tab loses little
306export function updateChain($, fields = {}) {
307 return queued(() => writeChain($, fields))
308}
309
310async function writeChain($, fields) {
311 if (!W.sessionId) return
312 if (W.tabId === null) W.tabId = (await tabId($)) || ''
313 // The project is where the session started, which does not move as Claude
314 // works in one repository or another beneath it
315 const root = await $.session.root()
316 W.chainKey = 'chain:' + (W.tabId || 'folder:' + root)
317 const now = await $.clock.now()
318 const chain = await $.store.get(W.chainKey)
319 // A -p run or the SDK reads the tab's chain and is not added to it
320 if (!W.interactive) {
321 W.chain = chain?.sessions ? chain : { sessions: [] }
322 return
323 }
324 const known = chain?.sessions?.find(s => s.id === W.sessionId)
325 const row = {
326 id: W.sessionId,
327 root,
328 ...(W.repo ? { repo: W.repo } : {}),
329 ...(W.herdr?.pane ? { pane: W.herdr.pane } : {}),
330 lastAt: now,
331 ...(W.git?.branch ? { branch: W.git.branch } : {}),
332 ...(W.firstPrompt ? { firstPrompt: short(W.firstPrompt, 200) } : {}),
333 ...(W.ctx !== null ? { ctx: W.ctx } : {}),
334 ...progressRow(),
335 ...fields,
336 }
337 // Once the session has a first prompt: did it start from an earlier one's handoff?
338 if (W.firstPrompt && !known?.from && !known?.firstPrompt && chain?.sessions?.length) {
339 const from = handoffFrom([W.firstPrompt], chain.sessions.filter(s => s.id !== W.sessionId))
340 if (from) row.from = from
341 }
342 W.chain = joinChain(chain, row, now)
343 await $.store.set(W.chainKey, W.chain)
344}
345
346const OVERALL_SYSTEM = 'You summarize a chain of coding-agent chats in one terminal tab. Reply with one or two plain sentences, no preamble.'
347
348// One line on the whole chain, from each chat's own summary: a Haiku call,
349// and only when the summaries changed
350export async function refreshOverall($) {
351 const done = (W.chain?.sessions || []).filter(s => s.summary || s.firstPrompt)
352 if (done.length < 2 || W.busy.overall) return
353 const of = done.map(s => `${s.id.slice(0, 8)}:${s.summary || s.firstPrompt}`).join('|')
354 if (W.chain.overallOf === of) return
355 W.busy.overall = true
356 try {
357 const r = await $.model.complete({
358 model: 'haiku',
359 system: OVERALL_SYSTEM,
360 prompt: 'The chats, oldest first. Each picked up where the one before stopped (a handoff, a /clear or a restart):\n' +
361 done.map((s, i) => `${i + 1}. ${s.summary || 'started with: ' + s.firstPrompt}`).join('\n') +
362 '\n\nWhat is the overall goal, and where does it stand now?',
363 maxTokens: 160,
364 timeoutMs: 20000,
365 })
366 if (!r.isAnswered) return
367 await queued(async () => {
368 const chain = (await $.store.get(W.chainKey)) || W.chain
369 W.chain = { ...chain, overall: plain(r.text), overallOf: of }
370 await $.store.set(W.chainKey, W.chain)
371 })
372 } finally {
373 W.busy.overall = false
374 }
375}
376
377// herdr: the panes of this tab that run an agent, and which session each holds
378export async function refreshHerdr($) {
379 if (W.herdr === null) {
380 if ((await $.env.get('HERDR_ENV')) !== '1') {
381 W.herdr = { off: true }
382 return
383 }
384 W.herdr = {
385 bin: (await $.env.get('HERDR_BIN_PATH')) || 'herdr',
386 workspace: await $.env.get('HERDR_WORKSPACE_ID'),
387 pane: await $.env.get('HERDR_PANE_ID'),
388 tab: await $.env.get('HERDR_TAB_ID'),
389 panes: [],
390 }
391 }
392 const h = W.herdr
393 if (h.off || !h.tab) return
394 try {
395 const [panes, tab] = await Promise.all([
396 $.process.run([h.bin, 'pane', 'list', ...(h.workspace ? ['--workspace', h.workspace] : [])], { timeoutMs: 5000 }),
397 $.process.run([h.bin, 'tab', 'get', h.tab], { timeoutMs: 5000 }),
398 ])
399 if (panes.exitCode !== 0) throw new Error(panes.stderr.trim().split('\n')[0] || `exit ${panes.exitCode}`)
400 h.panes = herdrPanes(panes.stdout, h.tab)
401 try {
402 h.label = JSON.parse(tab.stdout)?.result?.tab?.label || ''
403 } catch {}
404 h.at = await $.clock.now()
405 h.error = null
406 } catch (err) {
407 h.error = 'herdr: ' + err.message
408 }
409}
410
411// Jump to a pane: herdr focuses its tab and the pane, and marks it seen
412export async function focusPane($, pane) {
413 const h = W.herdr
414 if (!h?.bin) return 'herdr is not running here'
415 const r = await $.process.run([h.bin, 'agent', 'focus', pane], { timeoutMs: 5000 })
416 return r.exitCode === 0 ? null : (r.stderr.trim().split('\n')[0] || `exit ${r.exitCode}`)
417}
418
419// ---------------------------------------------------------------------------
420// One pane per herdr tab
421// ---------------------------------------------------------------------------
422
423// The tab's record, { owner: herdr pane, hidden, at }, is a file every chat in
424// the tab reads: $.store is each process's own copy
425async function tabFile($) {
426 if (W.tabFile !== undefined) return W.tabFile
427 const [pane, tab, home] = await Promise.all([$.env.get('HERDR_PANE_ID'), $.env.get('HERDR_TAB_ID'), $.env.get('HOME')])
428 W.myPane = (await $.env.get('HERDR_ENV')) === '1' && pane && tab ? pane : null
429 W.tabFile = W.myPane && home ? `${home}/.claude/diagram-pane/tabs/${String(tab).replace(/[^\w-]/g, '_')}.json` : null
430 return W.tabFile
431}
432
433async function readTab($) {
434 const file = await tabFile($)
435 if (!file) return null
436 try {
437 return JSON.parse(await $.fs.read(file))
438 } catch {
439 return null
440 }
441}
442
443export async function writeTab($, fields) {
444 const file = await tabFile($)
445 if (!file) return
446 const rec = { ...(await readTab($)), ...fields, at: await $.clock.now() }
447 await $.fs.write(file, JSON.stringify(rec) + '\n')
448}
449
450// Whether this chat shows the tab's pane, claiming it when it is new to the tab
451// or the owner's pane is gone. `herdr` asks herdr which panes still run an agent.
452export async function settleOwner($, { fresh = false, herdr = false } = {}) {
453 const rec = await readTab($)
454 if (!W.myPane) return { show: !W.hidden }
455 let live = null
456 if (herdr && rec?.owner && rec.owner !== W.myPane && !fresh) {
457 await refreshHerdr($)
458 if (W.herdr?.at && !W.herdr.error) live = W.herdr.panes
459 }
460 const d = paneOwner(rec, W.myPane, live, { fresh })
461 if (d.claim) await writeTab($, { owner: W.myPane })
462 W.paneOwner = d.claim || rec?.owner === W.myPane ? W.myPane : rec?.owner
463 return d
464}
465
466// /diagram and the pane's hide button: this chat takes the pane, or the tab hides it
467// (outside herdr, for this chat alone)
468export async function showHere($) {
469 W.hidden = false
470 await writeTab($, { owner: W.myPane, hidden: false })
471}
472
473export async function hideTab($) {
474 W.hidden = true
475 await writeTab($, { hidden: true })
476}
477
478// ---------------------------------------------------------------------------
479// Progress
480// ---------------------------------------------------------------------------
481
482const PROGRESS_TOOL = 'mcp__diagram-pane__progress_set'
483
484// Claude's progress_set lands at once; once the transcript holds the call
485// (by its id) the transcript's copy is the one read, in its place
486export function declare(input, id) {
487 W.declared = { input, id }
488}
489const holds = (msgs, id) => msgs.some(m => (m.toolUses || []).some(t => t.tool_use_id === id))
490
491// The chat's progress: the transcript's signals, the PR's CI and merge, and,
492// for a chat that came from a handoff, where the chat before it stood
493export function progress() {
494 let msgs = W.msgs
495 if (W.declared && !holds(msgs, W.declared.id)) {
496 msgs = [...msgs, { role: 'assistant', text: '', toolUses: [{ tool: PROGRESS_TOOL, input: W.declared.input, text: 'ok' }] }]
497 }
498 const row = W.chain?.sessions?.find(s => s.id === W.sessionId)
499 const from = row?.from && W.chain.sessions.find(s => s.id === row.from)
500 return progressFrom(msgs, { pr: W.gh?.pr, checks: W.gh?.checks, seed: from?.progress })
501}
502
503// What the chain keeps of it: the stage and each stage's turn and note
504function progressRow() {
505 const p = progress()
506 if (!p.now) return {}
507 const stages = Object.fromEntries(Object.entries(p.stages).map(([k, s]) => [k, { turn: s.turn, note: s.note || (s.files?.length ? `${s.files.length} files` : ''), failed: s.failed }]))
508 return { progress: { now: p.now, stages } }
509}
510
511export function progressItems(w, act) {
512 const out = []
513 const add = (text, style = {}) => out.push({ text, ...style })
514 const p = progress()
515 add(spread('◆ PROGRESS', p.now ? `now: ${p.now}` : '', w), { bold: true, color: 'yellow' })
516 if (!p.now) add('Nothing to place yet: the stages fill in as Claude plans, edits, tests, reviews, commits and pushes.', { dim: true, wrap: true })
517 for (const r of stageRows(p)) {
518 const isNow = r.mark === '▶' || (r.name === p.now)
519 const when = r.inherited ? 'prev chat' : r.turn != null ? `turn ${r.turn}` : ''
520 const what = r.note || (r.files?.length ? `${r.files.length} file${r.files.length > 1 ? 's' : ''}` : '')
521 add(short(`${r.mark} ${r.name.padEnd(10)} ${when.padEnd(9)} ${what}`, w), {
522 bold: isNow,
523 color: r.mark === '✗' ? 'red' : isNow ? 'yellow' : r.mark === '✓' ? 'green' : undefined,
524 dim: r.mark === '·' || r.mark === '–' || r.inherited,
525 })
526 }
527 if (p.declared) add('Claude: ' + p.declared, { wrap: true, color: 'yellow' })
528 add('')
529 add('✓ done ▶ now ✗ failed – skipped · not yet', { dim: true })
530 add('Stages come from what Claude ran: edits, test commands, review agents, git and gh, and the PR\'s CI. Claude can name a stage itself with progress_set.', { dim: true, wrap: true })
531 return out
532}
533
534export async function readContext($) {
535 try {
536 const { context } = await $.session.usage()
537 W.ctx = context.percent ?? null
538 } catch {}
539}
540
541// ---------------------------------------------------------------------------
542// What the pane draws
543// ---------------------------------------------------------------------------
544
545function spread(left, right, w) {
546 return right ? left + ' '.repeat(Math.max(1, w - left.length - right.length)) + right : left
547}
548
549// The tab bar, then one line of state every tab shares, then what this chat is doing
550export function headItems(w, act, summary) {
551 const out = []
552 out.push({ buttons: TABS.map(t => ({
553 key: 'tab:' + t.id, hotkey: t.hotkey, label: (W.tab === t.id ? '▸' : '') + t.label, onPress: () => act?.tab(t.id),
554 })).concat({ key: 'hide', hotkey: 'x', label: 'hide', onPress: () => act?.hide() }) })
555 out.push({ text: short(strip(), w), dim: true })
556 const what = summary || (W.firstPrompt ? 'started with: ' + W.firstPrompt : '')
557 if (what) out.push({ text: '▍' + what, wrap: true, color: 'yellow' })
558 out.push({ text: '─'.repeat(w), dim: true })
559 return out
560}
561
562function strip() {
563 const parts = []
564 const p = progress()
565 if (p.now) parts.push('▶ ' + p.now)
566 if (W.git?.branch) parts.push('⎇ ' + W.git.branch)
567 if (W.gh) {
568 parts.push(prWord(W.gh.pr))
569 if (W.gh.pr) parts.push('CI ' + ciWord(W.gh.checks))
570 }
571 if (W.git?.files) {
572 const add = W.git.files.reduce((n, f) => n + f.add, 0)
573 const del = W.git.files.reduce((n, f) => n + f.del, 0)
574 parts.push(`${W.git.files.length} files +${add} −${del}`)
575 }
576 if (W.ctx !== null) parts.push(`ctx ${W.ctx}%`)
577 return parts.join(' · ') || 'reading git…'
578}
579
580export function diffItems(w, act, now) {
581 const out = []
582 const add = (text, style = {}) => out.push({ text, ...style })
583 const g = W.git
584 if (!g) return [{ text: 'reading git…', dim: true }]
585 if (g.error && !g.files?.length) return [{ text: g.error, dim: true }]
586
587 if (W.openFile) {
588 const f = g.files.find(x => x.path === W.openFile)
589 const i = g.files.indexOf(f)
590 add(short(`◆ ${W.openFile}`, w), { bold: true, color: 'green' })
591 const verb = g.committed ? 'committed' : 'written'
592 add(f ? `${f.status} +${f.add} −${f.del} · ${f.mtime ? `${verb} ${clock(f.mtime)} (${ago(f.mtime, now)} ago)` : 'gone from disk'}${f.mine ? ' · ✎ by Claude' : ''}` : '', { dim: true })
593 const abs = `${W.repo}/${W.openFile}`
594 const parts = hunksOf(g.diffs[W.openFile])
595 out.push({ buttons: [
596 { key: 'back', hotkey: 'b', label: 'files', onPress: () => act?.openFile(null) },
597 ...(f?.status !== 'D' ? [{ key: 'vscode', hotkey: 'o', label: 'open in VS Code', onPress: () => act?.openInEditor(abs, parts[0]?.line || 1) }] : []),
598 ...(i > 0 ? [{ key: 'prev', hotkey: 'p', label: 'prev', onPress: () => act?.openFile(g.files[i - 1].path) }] : []),
599 ...(i >= 0 && i < g.files.length - 1 ? [{ key: 'next', hotkey: 'n', label: 'next', onPress: () => act?.openFile(g.files[i + 1].path) }] : []),
600 ] })
601 if (f?.binary) add('binary file', { dim: true })
602 else if (parts.length) {
603 // One block per hunk, each under a link to its line in VS Code: cmd+click it
604 let room = DIFF_ROOM
605 let cut = 0
606 for (const p of parts) {
607 const { source } = fitDiff(p.text)
608 if (!source || source.length > room) {
609 cut++
610 continue
611 }
612 room -= source.length
613 if (f?.status !== 'D') out.push({ link: { href: `vscode://file${abs.split('/').map(encodeURIComponent).join('/')}:${p.line}`, label: `↗ line ${p.line}` } })
614 out.push({ code: { source, path: W.openFile } })
615 }
616 if (cut) add(`… ${cut} hunk${cut > 1 ? 's' : ''} not drawn: too long for the pane (o opens the file)`, { dim: true })
617 }
618 else add(f?.status === 'D' ? 'deleted' : 'no diff to show', { dim: true })
619 return out
620 }
621
622 const add$ = g.files.reduce((n, f) => n + f.add, 0)
623 const del$ = g.files.reduce((n, f) => n + f.del, 0)
624 add(spread('◆ DIFFS ' + g.baseLabel, `${g.files.length} files +${add$} −${del$}`, w), { bold: true, color: 'green' })
625 const nextMode = DIFF_MODES[(DIFF_MODES.indexOf(W.diffMode) + 1) % DIFF_MODES.length]
626 out.push({ buttons: [
627 { key: 'mode', hotkey: 'v', label: 'show ' + MODE_NAMES[nextMode], onPress: () => act?.diffMode() },
628 { key: 'refresh', hotkey: 'r', label: 'refresh', onPress: () => act?.refresh() },
629 ] })
630 add(short(`repo ${W.repo}`, w), { dim: true })
631 if (g.commits?.length) {
632 add(`${g.commits.length} commit${g.commits.length > 1 ? 's' : ''} in this range:`, { dim: true })
633 for (const c of g.commits.slice(0, 5)) add(short(' ' + c, w), { dim: true })
634 if (g.commits.length > 5) add(` … ${g.commits.length - 5} more`, { dim: true })
635 }
636 if (!g.files.length) {
637 add({
638 chat: 'Nothing changed in this repository since this chat started.',
639 pr: 'This branch has no commits of its own on top of its base yet.',
640 head: 'No uncommitted changes.',
641 }[W.diffMode], { dim: true, wrap: true })
642 add(`v shows ${MODE_NAMES[nextMode]}.`, { dim: true })
643 }
644 const wide = Math.max(10, w - 26)
645 g.files.forEach((f, i) => {
646 const counts = f.binary ? 'bin' : `+${f.add} −${f.del}`
647 const when = f.mtime ? ago(f.mtime, now) : g.committed ? '' : 'gone'
648 // The first nine draw as `1: label` beside their hotkey; the rest line up under them
649 const label = `${i < 9 ? '' : ' '}${f.status} ${leftCut(f.path, wide).padEnd(wide)} ${counts.padStart(9)} ${when.padStart(4)}${f.mine ? ' ✎' : ''}`
650 out.push({ buttons: [{ key: 'file:' + f.path, ...(i < 9 ? { hotkey: String(i + 1) } : {}), plain: true, label, onPress: () => act?.openFile(f.path) }] })
651 })
652 add(`M modified A added D deleted ? new · ✎ Claude wrote it · times are last ${g.committed ? 'commit' : 'write'}`, { dim: true })
653 if (g.committed) add('Full search and cmd+click: open the PR in VS Code\'s GitHub Pull Requests view.', { dim: true, wrap: true })
654 return out
655}
656
657// A Code element takes tab and newline as its only control characters, and
658// draws 100,000 characters in all: keep whole hunks up to 60,000
659const DIFF_ROOM = 60000
660export function fitDiff(hunks) {
661 // eslint-disable-next-line no-control-regex
662 const clean = hunks.replace(/\r/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '�')
663 if (clean.length <= DIFF_ROOM) return { source: clean, cut: 0 }
664 const parts = clean.split(/\n(?=@@ )/)
665 const kept = []
666 let size = 0
667 for (const p of parts) {
668 if (size + p.length > DIFF_ROOM) break
669 kept.push(p)
670 size += p.length + 1
671 }
672 return { source: kept.join('\n'), cut: parts.length - kept.length }
673}
674
675export function githubItems(w, act, now) {
676 const out = []
677 const add = (text, style = {}) => out.push({ text, ...style })
678 const gh = W.gh
679 add(spread('◆ GITHUB', gh?.at ? `read ${ago(gh.at, now)} ago` : '', w), { bold: true, color: 'blue' })
680 out.push({ buttons: [{ key: 'refresh', hotkey: 'r', label: 'refresh', onPress: () => act?.refresh() }] })
681 if (!gh) return [...out, { text: W.busy.gh ? 'asking gh…' : 'not read yet', dim: true }]
682 if (gh.error) add(gh.error, { color: 'red' })
683 const sync = gh.sync ? `${gh.sync.ahead} ahead, ${gh.sync.behind} behind upstream` : 'no upstream: not pushed'
684 add(`⎇ ${gh.branch || '?'} · ${sync}`)
685 add('')
686 const pr = gh.pr
687 if (pr) {
688 add(short(`${prWord(pr)}: ${pr.title}`, w), { bold: true })
689 out.push({ link: { href: pr.url, label: short(pr.url, w) } })
690 const review = reviewWord(pr)
691 const reviewers = (pr.latestReviews || []).map(r => `${r.author?.login || '?'} ${String(r.state || '').toLowerCase().replace('_', ' ')}`)
692 add(`${pr.headRefName} → ${pr.baseRefName} · ${String(pr.mergeStateStatus || pr.mergeable || '').toLowerCase()}`, { dim: true })
693 if (review || reviewers.length) add('review: ' + [review, ...reviewers].filter(Boolean).join(', '))
694 const t = gh.checks
695 add('')
696 add(`CI ${ciWord(t)}` + (t && t.pass + t.fail + t.pending ? ` (✓${t.pass} ✗${t.fail} …${t.pending}${t.skip ? ' skip ' + t.skip : ''})` : ''), {
697 bold: true, color: t?.fail ? 'red' : t?.pending ? 'yellow' : 'green',
698 })
699 for (const c of t?.failing || []) add(short(' ✗ ' + c.name, w), { color: 'red' })
700 for (const c of t?.waiting || []) add(short(' … ' + c.name, w), { color: 'yellow' })
701 } else {
702 add('No PR for this branch.', { dim: true })
703 }
704 if (gh.runs?.length) {
705 add('')
706 add('Latest runs on this branch', { bold: true })
707 for (const r of gh.runs) {
708 const st = runState(r)
709 const mark = { pass: '✓', fail: '✗', pending: '…', skip: '–' }[st]
710 add(short(`${mark} ${r.workflowName} · ${r.event} · ${ago(Date.parse(r.createdAt), now)} ago`, w), {
711 color: st === 'fail' ? 'red' : st === 'pending' ? 'yellow' : undefined,
712 })
713 }
714 }
715 return out
716}
717
718export function chatItems(w, act, now) {
719 const out = []
720 const add = (text, style = {}) => out.push({ text, ...style })
721 const c = W.chain || { sessions: [] }
722 const h = W.herdr
723 const live = h?.panes || []
724 const rowOf = id => c.sessions.find(s => s.id === id)
725 const name = h?.tab ? `${h.label ? `"${h.label}"` : h.tab}` : W.tabId || 'this folder'
726 const count = live.length ? `${live.length} pane${live.length === 1 ? '' : 's'}` : `${c.sessions.length} chat${c.sessions.length === 1 ? '' : 's'}`
727 add(spread('◆ CHATS IN THIS TAB', `${name} · ${count}`, w), { bold: true, color: 'magenta' })
728 if (c.overall) add('Overall: ' + c.overall, { wrap: true })
729 else if (c.sessions.length > 1 && W.busy.overall) add('… summarizing the chain', { dim: true })
730 if (h?.error) add(h.error, { color: 'red' })
731
732 // What a recorded chat says about itself, under its row
733 const details = (s, isNow) => {
734 if (!s) return
735 const facts = [
736 s.turns ? `${s.turns} turns` : '',
737 s.ctx != null ? `ctx ${s.ctx}%` : '',
738 s.progress?.now ? `▶ ${s.progress.now}` : '',
739 s.branch ? `⎇ ${s.branch}` : '',
740 s.ended ? `ended (${s.ended})` : '',
741 ].filter(Boolean).join(' · ')
742 if (s.from) {
743 const k = c.sessions.findIndex(x => x.id === s.from)
744 add(` ↳ from ${k >= 0 ? `chat ${s.from.slice(0, 8)}'s handoff note` : 'a handoff'}`, { dim: true })
745 }
746 const what = s.summary || (s.firstPrompt ? 'started with: ' + s.firstPrompt : '')
747 if (what) add(' ' + what, { wrap: true, dim: !isNow })
748 if (facts) add(' ' + facts, { dim: true })
749 }
750
751 // The tab's panes as herdr has them: a number jumps to one
752 if (live.length) {
753 add('')
754 add('Live in herdr, newest first · a number jumps to that pane', { dim: true })
755 live.forEach((p, i) => {
756 const isNow = p.pane === h.pane
757 const mark = STATUS_MARK[p.status] || '?'
758 const label = `${i < 9 ? '' : ' '}${mark} ${p.status.padEnd(7)} ${short(p.title || p.agent, Math.max(10, w - 26))}${isNow ? ' ◀ here' : ''}`
759 out.push({ buttons: [{
760 key: 'pane:' + p.pane, ...(i < 9 ? { hotkey: String(i + 1) } : {}), plain: true, label,
761 onPress: () => act?.focusPane(p.pane),
762 }] })
763 details(p.session && rowOf(p.session), isNow)
764 })
765 } else if (!c.sessions.length) {
766 add('No chats recorded in this tab yet: this one is added at the end of its first turn.', { dim: true, wrap: true })
767 }
768
769 // Chats the tab held that no pane holds now
770 const gone = [...c.sessions].reverse().filter(s => !live.some(p => p.session === s.id))
771 if (gone.length) {
772 add('')
773 add(live.length ? 'Earlier chats, no longer in a pane' : 'Chats in this tab, newest first', { dim: true })
774 for (const s of gone) {
775 const isNow = s.id === W.sessionId
776 add(short(`${isNow ? '▶' : ' '} ${s.id.slice(0, 8)} ${clock(s.startedAt)} · ${isNow ? 'now' : `${ago(s.lastAt, now)} ago`}`, w), { bold: isNow })
777 details(s, isNow)
778 if (!isNow) out.push({ buttons: [{ key: 'resume:' + s.id, label: 'copy resume command', onPress: () => act?.copy(`claude --resume ${s.id}`) }], indent: 4 })
779 }
780 }
781 add('')
782 add('● working ! needs you ✓ finished, unseen ○ idle', { dim: true })
783 if (W.ctx !== null && W.ctx >= 80) add(`Context is ${W.ctx}% full: time to hand off soon.`, { color: 'yellow' })
784 return out
785}
786hooks/work.js 442 lines1// work.js: what the pane's other tabs read, as plain functions with no `$`.
2//
3// diffs `git diff --numstat` and `git status` into one row per changed file
4// github `gh pr view` and `gh run list` into a PR line and a CI tally
5// chats the chain of sessions one terminal tab has held, across /clear,
6// handoffs and restarts
7//
8// register.js runs the commands and keeps the results; everything here is
9// pure, so the tests feed it text and read what comes back.
10
11// ---------------------------------------------------------------------------
12// Diffs
13// ---------------------------------------------------------------------------
14
15// `git diff --numstat` names a rename as `old => new` or `dir/{old => new}/f`:
16// the file is the new name
17export function renamedTo(path) {
18 const braced = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(path)
19 if (braced) return (braced[1] + braced[3] + braced[4]).replace(/\/\//g, '/')
20 const plain = /^(.*) => (.*)$/.exec(path)
21 return plain ? plain[2] : path
22}
23
24// One row per file: { path, add, del, binary }
25export function parseNumstat(text) {
26 return String(text || '').split('\n').filter(Boolean).map(line => {
27 const [add, del, ...rest] = line.split('\t')
28 const binary = add === '-' && del === '-'
29 return { path: renamedTo(rest.join('\t')), add: binary ? 0 : Number(add) || 0, del: binary ? 0 : Number(del) || 0, binary }
30 })
31}
32
33// `git status --porcelain`: path -> the two status letters, `??` for untracked
34export function parseStatus(text) {
35 const out = {}
36 for (const line of String(text || '').split('\n')) {
37 if (line.length < 4) continue
38 const code = line.slice(0, 2)
39 let path = line.slice(3)
40 if (path.includes(' -> ')) path = path.split(' -> ').pop()
41 out[path.replace(/^"|"$/g, '')] = code
42 }
43 return out
44}
45
46// What a status code means, in one letter the list shows
47export function statusLetter(code, isNew) {
48 if (!code) return isNew ? 'A' : 'M'
49 if (code === '??') return '?'
50 const c = code.trim()[0]
51 return { M: 'M', A: 'A', D: 'D', R: 'R', C: 'C', U: 'U', T: 'M' }[c] || 'M'
52}
53
54// `git diff --name-status` against the base: path -> its letter (R100 is R)
55export function parseNameStatus(text) {
56 const out = {}
57 for (const line of String(text || '').split('\n')) {
58 const [code, ...paths] = line.split('\t')
59 if (code && paths.length) out[paths.at(-1)] = code[0]
60 }
61 return out
62}
63
64// The changed files: numstat rows merged with untracked files (which numstat
65// never lists). The letter is the change against the base (`nameStatus`),
66// which `git status` cannot say for what the branch already committed.
67export function changedFiles({ numstat, status, nameStatus, untracked = {} }) {
68 const rows = parseNumstat(numstat)
69 const codes = parseStatus(status)
70 const letters = parseNameStatus(nameStatus)
71 const seen = new Set(rows.map(r => r.path))
72 for (const [path, code] of Object.entries(codes)) {
73 if (code === '??' && !seen.has(path) && !path.endsWith('/')) {
74 rows.push({ path, add: untracked[path] ?? 0, del: 0, binary: false })
75 seen.add(path)
76 }
77 }
78 return rows.map(r => ({ ...r, status: codes[r.path] === '??' ? '?' : letters[r.path] || statusLetter(codes[r.path]) }))
79}
80
81// A unified diff split per file: { path -> the file's hunks, headers dropped }
82export function splitDiff(text) {
83 const files = {}
84 let path = null
85 let body = []
86 const flush = () => {
87 if (path !== null && body.length) files[path] = body.join('\n')
88 }
89 for (const line of String(text || '').split('\n')) {
90 if (line.startsWith('diff --git ')) {
91 flush()
92 body = []
93 // `diff --git a/x b/x`: the b side is the file as it is now
94 const m = / b\/(.*)$/.exec(line)
95 path = m ? m[1] : null
96 continue
97 }
98 if (path === null) continue
99 // Inside a hunk every line is the file's own, even one that opens with --- or +++
100 if (body.length) {
101 body.push(line)
102 continue
103 }
104 if (line.startsWith('+++ ')) {
105 const m = /^\+\+\+ b\/(.*)$/.exec(line)
106 if (m) path = m[1]
107 continue
108 }
109 if (line.startsWith('@@')) body.push(line)
110 }
111 flush()
112 return files
113}
114
115// `git log --format=@%ct --name-only`, newest first: path -> when a commit
116// last changed it, in ms
117export function lastCommitted(text) {
118 const out = {}
119 let at = 0
120 for (const line of String(text || '').split('\n')) {
121 if (line.startsWith('@')) at = Number(line.slice(1)) * 1000
122 else if (line && !(line in out)) out[line] = at
123 }
124 return out
125}
126
127// The hunks of one file's diff, each with the line it starts at in the new file
128export function hunksOf(diff) {
129 return String(diff || '').split(/\n(?=@@ )/).filter(h => h.startsWith('@@')).map(text => {
130 const m = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(text)
131 return { text, line: m ? Math.max(1, Number(m[1])) : 1 }
132 })
133}
134
135// ---------------------------------------------------------------------------
136// GitHub
137// ---------------------------------------------------------------------------
138
139// A check from statusCheckRollup is a CheckRun (status, conclusion) or a
140// StatusContext (state): both come out as pass, fail, pending or skip
141export function checkState(c) {
142 const s = String(c.conclusion || c.state || c.status || '').toUpperCase()
143 if (['SUCCESS', 'NEUTRAL'].includes(s)) return 'pass'
144 if (['SKIPPED', 'STALE', 'CANCELLED'].includes(s)) return 'skip'
145 if (['FAILURE', 'ERROR', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(s)) return 'fail'
146 return 'pending'
147}
148
149export function tallyChecks(rollup) {
150 const t = { pass: 0, fail: 0, pending: 0, skip: 0, failing: [], waiting: [] }
151 const seen = new Set()
152 for (const c of rollup || []) {
153 const name = c.name || c.context || 'check'
154 // A re-run lists the check again: the latest one counts
155 const key = `${c.workflowName || ''}/${name}`
156 if (seen.has(key)) continue
157 seen.add(key)
158 const st = checkState(c)
159 t[st] += 1
160 if (st === 'fail') t.failing.push({ name, url: c.detailsUrl || c.targetUrl || '' })
161 if (st === 'pending') t.waiting.push({ name, url: c.detailsUrl || c.targetUrl || '' })
162 }
163 return t
164}
165
166// The CI word the strip shows
167export function ciWord(t) {
168 if (!t || t.pass + t.fail + t.pending + t.skip === 0) return 'no checks'
169 if (t.fail) return `✗ ${t.fail} failing`
170 if (t.pending) return `… ${t.pending} running`
171 return `✓ ${t.pass} passed`
172}
173
174export function prWord(pr) {
175 if (!pr) return 'no PR'
176 const state = pr.isDraft && pr.state === 'OPEN' ? 'draft' : String(pr.state || '').toLowerCase()
177 return `PR #${pr.number} ${state}`
178}
179
180export function reviewWord(pr) {
181 const d = String(pr?.reviewDecision || '')
182 return { APPROVED: 'approved', CHANGES_REQUESTED: 'changes requested', REVIEW_REQUIRED: 'review required' }[d] || ''
183}
184
185// A workflow run from `gh run list` as one state, like a check
186export function runState(r) {
187 return checkState({ status: r.status, conclusion: r.conclusion })
188}
189
190// ---------------------------------------------------------------------------
191// Chats: the chain of sessions in one terminal tab
192// ---------------------------------------------------------------------------
193
194// What names "this tab": a multiplexer's or a terminal's own id for it, the
195// first one set. With none, the project folder stands for the tab.
196export function tabIdFrom(env) {
197 for (const [name, prefix] of [
198 ['HERDR_TAB_ID', 'herdr'], ['TMUX_PANE', 'tmux'], ['ZELLIJ_PANE_ID', 'zellij'], ['WEZTERM_PANE', 'wezterm'],
199 ['KITTY_WINDOW_ID', 'kitty'], ['ITERM_SESSION_ID', 'iterm'], ['TERM_SESSION_ID', 'term'],
200 ]) {
201 if (env[name]) return `${prefix}:${env[name]}`
202 }
203 return null
204}
205
206const DAY = 24 * 60 * 60 * 1000
207
208// Add or update `session` (by id) in `chain`. A tab reused for other work
209// (another project, or after a day idle) starts a new chain instead.
210// Returns the chain to keep.
211export function joinChain(chain, session, now) {
212 const fresh = { sessions: [], startedAt: now, overall: '' }
213 let c = chain && Array.isArray(chain.sessions) ? chain : fresh
214 const last = c.sessions.at(-1)
215 const known = c.sessions.some(s => s.id === session.id)
216 if (!known && last && ((last.root && last.root !== session.root) || now - (last.lastAt || 0) > DAY)) c = fresh
217 const sessions = known
218 ? c.sessions.map(s => (s.id === session.id ? { ...s, ...session } : s))
219 : [...c.sessions, { ...session, startedAt: session.startedAt ?? now }]
220 return { ...c, sessions: sessions.slice(-30) }
221}
222
223// A session that names an earlier one's handoff note (HANDOFF-<8 chars>.md)
224// came from it
225export function handoffFrom(texts, sessions) {
226 const all = texts.join('\n')
227 for (const s of sessions) {
228 const short = String(s.id).slice(0, 8)
229 if (all.includes(`HANDOFF-${short}`)) return s.id
230 }
231 return /\bhand-?off\b/i.test(all) ? 'unknown' : null
232}
233
234// `herdr pane list` JSON -> the agent panes of one tab, newest first. herdr's
235// ids count up in base 36 (`wJ:p1F` after `wJ:pZ`), so the order is the order
236// the panes were made: a handoff's new pane comes after the one it left.
237export function herdrPanes(json, tabId) {
238 let panes
239 try {
240 panes = (typeof json === 'string' ? JSON.parse(json) : json)?.result?.panes || []
241 } catch {
242 return []
243 }
244 const order = id => parseInt(String(id).split(':p')[1] || '0', 36) || 0
245 return panes
246 .filter(p => p.tab_id === tabId && p.agent)
247 .map(p => ({
248 pane: p.pane_id,
249 agent: p.agent,
250 status: p.agent_status || 'unknown',
251 session: p.agent_session?.value || null,
252 title: p.terminal_title_stripped || p.terminal_title || '',
253 focused: Boolean(p.focused),
254 }))
255 .sort((a, b) => order(b.pane) - order(a.pane))
256}
257
258export const STATUS_MARK = { working: '●', blocked: '!', done: '✓', idle: '○', unknown: '?' }
259
260// ---------------------------------------------------------------------------
261// Small words
262// ---------------------------------------------------------------------------
263
264export function ago(ms, now) {
265 if (!ms) return ''
266 const s = Math.max(0, Math.round((now - ms) / 1000))
267 if (s < 60) return `${s}s`
268 if (s < 3600) return `${Math.round(s / 60)}m`
269 if (s < DAY / 1000) return `${Math.round(s / 3600)}h`
270 return `${Math.round(s / 86400)}d`
271}
272
273export function clock(ms) {
274 if (!ms) return ''
275 const d = new Date(ms)
276 const p = n => String(n).padStart(2, '0')
277 return `${d.getMonth() + 1}/${d.getDate()} ${p(d.getHours())}:${p(d.getMinutes())}`
278}
279
280// A path cut from the left, so the file name stays
281export function leftCut(path, n) {
282 return path.length <= n ? path : '…' + path.slice(path.length - n + 1)
283}
284
285// ---------------------------------------------------------------------------
286// Progress: which stage of the work the chat is in
287// ---------------------------------------------------------------------------
288
289export const STAGES = ['plan', 'implement', 'test', 'review', 'fix', 'verify', 'commit', 'push', 'pr', 'ci', 'merge']
290
291const EDITS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
292const REVIEW_RE = /\b(review|audit|adversarial|critique|find (the )?bugs|verify (the )?findings)\b/i
293
294// A command as a list of the commands it runs: heredoc bodies and quoted text
295// dropped (a grep for "git push" pushes nothing), split at && || ; | and newlines,
296// each without the VAR=value words before it
297export function commandsIn(cmd) {
298 return String(cmd || '')
299 .replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2\b/g, '<<')
300 .replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""')
301 .split(/&&|\|\||[;|\n]/)
302 .map(c => c.trim().replace(/^(\w+=\S*\s+)+/, '').replace(/^(cd \S+\s*)$/, ''))
303 .filter(Boolean)
304}
305
306// One command that runs tests: a runner in the command's own place, never a
307// file name (jest.config.js) or an install (pip install pytest)
308const TEST_RUNNER = /^((npx|pnpx|bunx|uv run|poetry run|python3? -m)\s+)?(pytest|jest|vitest|mocha|rspec|tox|phpunit|unittest)(\s|$)|^(go|cargo) test\b|^(npm|pnpm|yarn|bun)( run)? test\b|^claude plugin test\b|^make (test|check)\b|^(npx )?playwright test\b/
309const GIT = /^git(\s+-[Cc]\s+\S+)*\s+/
310const SHELL_EDIT_RE = /^sed -i\b|^python3? - <<|^python3? -\s*$|^cat >|^tee\b|^patch\b|^perl -p?i/
311
312// The commit message a git commit names: -m "...", -am '...', or a heredoc's first line
313export function commitMessage(cmd) {
314 const m = /\s-[a-zA-Z]*m\s*(["'])((?:\\.|(?!\1)[^\\])*)\1/.exec(cmd)
315 if (m && !m[2].startsWith('$(')) return m[2].replace(/\\(.)/g, '$1').split('\n')[0]
316 const h = /<<-?\s*['"]?(\w+)['"]?[^\n]*\n\s*([^\n]+)/.exec(cmd)
317 return h ? h[2].trim() : ''
318}
319
320// The signals of one tool call, in order: [{ stage, note, failed, file }].
321// `after` says what came before, since an edit after a review is a fix and a
322// test after a fix is a verify.
323function signalsOf(t, after) {
324 const i = t.input || {}
325 const running = t.text === undefined && t.result === undefined && !t.isError
326 if (t.tool === 'mcp__diagram-pane__progress_set') {
327 if (!STAGES.includes(i.stage)) return []
328 const of = i.total ? ` ${i.done ?? 0}/${i.total}` : ''
329 return [{ stage: i.stage, note: (i.note || '') + of, declared: true }]
330 }
331 if (['ExitPlanMode', 'EnterPlanMode', 'TodoWrite'].includes(t.tool)) return [{ stage: 'plan', note: t.tool }]
332 if (EDITS.includes(t.tool)) return [{ stage: after.has('review') ? 'fix' : 'implement', file: String(i.file_path || i.notebook_path || '') }]
333 if (['Agent', 'Task'].includes(t.tool)) {
334 if (i.subagent_type === 'Plan') return [{ stage: 'plan', note: i.description || 'Plan agent' }]
335 if (REVIEW_RE.test(`${i.description || ''} ${i.prompt || ''}`.slice(0, 2000))) return [{ stage: 'review', note: i.description || 'review agent' }]
336 return []
337 }
338 if (t.tool === 'Skill' && /review/.test(String(i.skill || ''))) return [{ stage: 'review', note: '/' + i.skill }]
339 if (t.tool !== 'Bash') return []
340 const raw = String(i.command || '')
341 const out = []
342 const seen = new Set(after)
343 for (const c of commandsIn(raw)) {
344 let sig = null
345 const g = GIT.test(c) ? c.replace(GIT, '') : null
346 if (g?.startsWith('commit')) sig = { stage: 'commit', note: commitMessage(raw) }
347 else if (g?.startsWith('push')) sig = { stage: 'push' }
348 else if (/^gh pr create\b/.test(c)) sig = { stage: 'pr', note: 'gh pr create' }
349 else if (/^gh pr merge\b/.test(c)) sig = { stage: 'merge', note: 'gh pr merge' }
350 else if (TEST_RUNNER.test(c)) sig = { stage: seen.has('fix') ? 'verify' : 'test', note: c.split(/\s+/).slice(0, 3).join(' '), test: true }
351 else if (SHELL_EDIT_RE.test(c)) sig = { stage: seen.has('review') ? 'fix' : 'implement' }
352 if (sig) {
353 out.push(sig)
354 seen.add(sig.stage)
355 }
356 }
357 // Which part of a chain failed is not said: the last part that marks a stage
358 // carries the call's outcome, and the parts before it went through
359 const last = out.at(-1)
360 if (last) {
361 if (running) last.note = [last.note, 'running'].filter(Boolean).join(' · ')
362 else {
363 last.failed = Boolean(t.isError)
364 if (last.test) last.note += t.isError ? ' · failed' : ' · passed'
365 else if (t.isError) last.note = [last.note, 'failed'].filter(Boolean).join(' · ')
366 }
367 }
368 return out
369}
370
371// The stages as the transcript shows them: { stages: { name -> { turn, last,
372// note, files, failed } }, now, declared }. CI and merge come from the PR.
373export function progressFrom(messages, { pr, checks, seed } = {}) {
374 const stages = {}
375 let now = null
376 let declared = null
377 let turn = 0
378 const after = new Set()
379 for (const m of messages || []) {
380 if (m.role === 'user' && m.text && !m.toolResults?.length) turn++
381 for (const t of m.toolUses || []) {
382 for (const sig of signalsOf(t, after)) {
383 const s = (stages[sig.stage] ||= { turn, files: [] })
384 s.last = turn
385 if (sig.note) s.note = sig.note
386 if (sig.file && !s.files.includes(sig.file)) s.files.push(sig.file)
387 s.failed = Boolean(sig.failed)
388 after.add(sig.stage)
389 now = sig.stage
390 declared = sig.declared ? sig.note : null
391 }
392 }
393 }
394 // A chat that came from a handoff starts where the one before it stood
395 if (seed?.stages) {
396 for (const [name, s] of Object.entries(seed.stages)) if (!stages[name]) stages[name] = { ...s, inherited: true }
397 if (!now) now = seed.now
398 }
399 if (pr) {
400 if (!stages.pr) stages.pr = { turn: null, files: [], note: `#${pr.number}` }
401 // Once pushed, the work waits on CI and then the merge; a later edit or
402 // test is the work again, and stays the stage
403 const waiting = !now || STAGES.indexOf(now) >= STAGES.indexOf('push')
404 if (checks && checks.pass + checks.fail + checks.pending > 0) {
405 stages.ci = { turn: null, files: [], note: checks.fail ? `${checks.fail} failing` : checks.pending ? `${checks.pending} running` : `${checks.pass} passed`, failed: checks.fail > 0, running: checks.pending > 0 }
406 if (waiting && now !== 'merge') now = 'ci'
407 }
408 if (pr.state === 'MERGED') {
409 stages.merge = { turn: stages.merge?.turn ?? null, files: [], note: 'merged' }
410 if (waiting) now = 'merge'
411 }
412 }
413 return { stages, now, declared }
414}
415
416// Each stage's mark: ✓ done, ▶ now, ✗ failed, – skipped, · not yet
417export function stageRows(p) {
418 const at = STAGES.indexOf(p.now)
419 return STAGES.map((name, i) => {
420 const s = p.stages[name]
421 let mark = '·'
422 if (name === p.now) mark = s?.failed ? '✗' : '▶'
423 else if (s) mark = s.failed ? '✗' : s.running ? '…' : '✓'
424 else if (i < at) mark = '–'
425 return { name, mark, ...(s || {}) }
426 })
427}
428
429// One pane per herdr tab. `rec` is the tab's record ({ owner, hidden }), `me`
430// this chat's herdr pane, `live` the tab's agent panes (null when unknown).
431// A chat claims the pane when it is new to the tab (a handoff's chat takes over
432// from the one it left) or the owner's pane is gone. It shows the pane when it
433// owns it and nobody hid it. Outside herdr every chat has a terminal pane of
434// its own, so it always shows.
435export function paneOwner(rec, me, live, { fresh = false } = {}) {
436 if (!me) return { show: true, claim: false }
437 const owner = rec?.owner || null
438 const alive = Boolean(owner) && (owner === me || !live || live.some(p => p.pane === owner))
439 const mine = owner === me || fresh || !alive
440 return { show: mine && !rec?.hidden, claim: mine && owner !== me }
441}
442