SLOPSHOPPER

diagram-pane

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

newpaneguardcommandtoasttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diagram-pane
│ ┃ diagrams ✕ › fix the failing auth test and add an audit log call │ ┃ m: ▸map w: progress d: diffs g: github s │ ┃ ⎇ ? · PR #undefined · CI no checks · 0 fil… ⏺ Read(src/auth.ts) │ ┃ ▍started with: fix the failing auth test ⎿ Read 6 lines │ ┃ ───────────────────────────────────────────… ⏺ Update(src/auth.ts) │ ┃ ◆ LIVE WORK MAP ⎿ Added 2 lines, removed 1 line │ ┃ The map is drawn after a few turns, or now … ⏺ Bash(bun test) │ ┃ ───────────────────────────────────────────… ⎿ 3 pass, 1 fail │ ┃ ◆ PINNED DIAGRAMS │ ┃ Mermaid or box diagrams Claude writes appea… ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /diagram │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · diagrams
m: ▸map w: progress d: diffs g: github s: chats x: hide ⎇ ? · PR #undefined · CI no checks · 0 files +0 −0 · c… ▍started with: fix the failing auth test ──────────────────────────────────────────────────────── ◆ LIVE WORK MAP The map is drawn after a few turns, or now with /diagram ──────────────────────────────────────────────────────── ◆ PINNED DIAGRAMS Mermaid or box diagrams Claude writes appear here.
README

diagram-pane

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.

diagram-pane: Claude adds a discount module on the left; on the right the pane has gone into the checkout.js box, its numbered steps drawn as boxes, with a change being typed into the field below

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.

  • Pinned diagrams. Whenever a reply has a mermaid code block or an ASCII box diagram, it is pinned in the pane, newest first. Mermaid flowcharts are redrawn as boxes and arrows that fit a 60 column pane.
  • Live work map. Every few turns (and on /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 ▶.
  • Go into a box. Boxes are numbered. Press a number to open that box's detail: a mermaid 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.
  • Change a diagram. Press 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.
  • Square boxes are files, round ones components, double ones steps or decisions.

Claude sees the pane only through those two tools, plus progress_set for the progress tab.

The other tabs

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.

  • Progress. Where the work stands, as stages: plan → implement → test → review → fix → verify → commit → push → PR → CI → merge. Each stage shows ✓ 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;
  • a test command (npm test, pytest, go test, claude plugin test, …) is test, and its exit code is pass or fail;
  • a review subagent or /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.

  • Diffs. What this chat changed: every file that differs from the commit HEAD was on when the chat started, committed since or not, and the commits made since then are listed above the files. 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.

  • GitHub. The branch's PR: title, link, review decision and reviewers, merge state, and the CI checks tallied (✓ 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.
  • Chats. Under herdr, the list starts with every pane in this herdr tab that runs an agent, newest first. Each row shows the pane's state (● 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.

A mermaid chart in Claude's reply on the left, the same chart drawn as boxes and arrows in the pane on the right

A plain question: the Haiku gate says the work kept its shape, so the map stays and no fork runs

Claude is asked to add a receipt step: it calls diagram_get and diagram_set, and the map in the pane gains the step and is marked edited

Install

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.

Usage

  • Ask for a diagram (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.
  • Ask Claude to change what you are looking at: "add the NAT gateway to the network box", "move the cache under the API layer". It calls diagram_get, then diagram_set, and the pane shows the changed level.

Settings

| 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 |

How it works

  • On turn.complete it reads the transcript ($.session.messages()) and pins any new mermaid or ASCII diagram it finds in Claude's replies.
  • Every N turns the gate runs: $.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.
  • On YES, $.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.
  • The chain of chats is kept in $.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.
  • A box's detail is stored inside it (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.
  • Opening a box with no detail forks the session with the level it sits on and asks for the inside as the same JSON. The detail's ids are renamed where they clash with the rest of the tree, since mermaid ids are global.
  • 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.
  • An edited map is marked 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.
  • The map, the edited pins and the open view are saved per session in $.store, so they survive a hot reload or --resume. Pins are rebuilt from the transcript, with edits laid back over them.

Limits

  • Only mermaid 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.
  • Number keys reach the first nine boxes of a level; Tab or a click reaches the rest. Pinned diagrams on the home view are drawn without numbers: open a pin first to go into its boxes.
  • A fork cannot call tools, so a detail it draws holds only what the session has already seen.
  • Edges between boxes in different subgraphs are drawn to the subgraph's box on the level where both ends show; inside a subgraph, edges that leave it are not shown.
  • Every chart is drawn top to bottom, even graph LR, because a narrow pane has no room sideways.
  • Edges that skip a row or point back up are not routed. They are listed under the drawing with ⇢ and ↺. A row too wide for the pane wraps, and edges into the wrapped part are listed the same way.
  • ASCII diagrams are only picked up from plain code blocks (no language, or text), and shown as written.
  • The map check runs after a turn ends, not while Claude is working. Before the first reply there is nothing to fork, and /diagram says so.
  • The map, edits and view are saved per install: moving the plugin to another marketplace or reinstalling it starts with no saved maps. A session that already has 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.
  • The GitHub tab needs gh signed in. It reads only, as whichever account gh has active.
  • The diffs tab lists 80 files at most, and counts the lines of only the first 20 untracked files.
  • The map is the model's own summary of the work, so it can include a step such as "run tests" beside the files.

Develop

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.

Source 5 files
hooks/register.js 1043 lines
1// 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}
1043
hooks/draw.js 357 lines
1// 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(/&quot;/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))
357
hooks/graph.js 208 lines
1// 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>')
208
hooks/panel.js 786 lines
1// 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}
786
hooks/work.js 442 lines
1// 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