SLOPSHOPPER

parked

A per-project backlog of items needing your attention, parked by you or the orchestrator

newpaneguardcommandtoaststatus
v0.2.0MITupdated 2026-10-06BuddyLim/claude-code-mod-parked
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · parked
│ ┃ Parked ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./detail.tsx │ ┃ j: ↓ k: ↑ o: open ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /park │ ⎿ parked: Parked as #1. /parked shows the list. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ parked: 📌 1 needs attention

Draws

Pane · Parked
▣ client module ./detail.tsx j: ↓ k: ↑ o: open
README

parked

A Claude Code mod that keeps a backlog of things that need your attention, so they don't scroll away during long agentic sessions.

When you orchestrate subagents, the main agent relays job reports full of findings, questions and decisions. You can't deal with all of them as they arrive, and after an unattended run you'd otherwise have to search the transcript for them. With this mod the agent parks each one as it comes up, and you work through them later in a side pane.

What it does

  • The agent parks items for you. It gets three tools (park, resolve, list_parked) and a system-prompt section telling it when to use them. Items are either needs attention (a deferred decision, an unanswered question, a blocker, a finding to act on) or FYI (a finished job, a notable finding, an assumption made on your behalf).
  • Choices come with options. The agent can give a decision numbered options, the one it would pick, the files it is about, and whether work is blocked on it. Unless it is blocked, the agent goes ahead on its pick.
  • Answer in one key. A digit picks an option, a accepts the agent's pick, and w lets you answer in your own words. Accepting the pick of work already under way sends nothing. Other answers are gathered for three seconds and sent to the main agent as one message.
  • Works with the ledger mod, where it is installed. A task whose gate fails is parked as an FYI that closes by itself when the gate passes. Each unit with open review findings gets one item listing them with their files, which updates as findings are fixed and closes when none is left. Without the ledger, nothing changes.
  • You can park too. /park [note] parks the assistant's last reply.
  • A pane to work through them. /parked opens the backlog: needs attention, FYI, then done. Nothing is deleted; done items can be reopened.
  • A status line count, such as 📌 2 need attention · 1 FYI.
  • A side thread per item. Ask about an item in its composer and a subagent investigates with tools. It is briefed from the main session's context first. The thread stays out of the main transcript, and the main agent cannot read it.
  • Send findings to the main agent. One press posts a short summary of the thread (at most about 120 words) to the main conversation.
  • One backlog per project, keyed by the working directory and kept across sessions.

Install

This mod uses Claude Code's function-hooks plugin API.

Clone it into your personal skills folder, where Claude Code loads it in every session:

git clone https://github.com/BuddyLim/claude-code-mod-parked ~/.claude/skills/parked

Or clone it anywhere and load it for one session:

claude --plugin-dir /path/to/claude-code-mod-parked

Using the pane

Run /parked, then click the pane once. Arrow keys only reach the pane after a click; that is a limit of the plugin API, not a choice.

List

KeyDoes
↑ ↓ or k jmove the cursor
Enter, →, l or oopen the item
1–9open that row

Item

An item has three levels, marked with ▸. ↑ ↓ (or k j) move between them.

Level← → (or h l)Other keys
Ticketsprevious / next item
Actionschoose an actionEnter or o runs it
Composertype a question, Enter sends, ↑ leaves
  • 1–9 answer with that option; a accepts the default; w opens the composer for an answer in your own words (a number typed there picks that option). These need the click; without one, reach Accept and Answer on the actions row.
  • d done, r reopen, n next, p previous, b back to the list.
  • g opens the item's file reference in the lens mod, where that is installed; with several references, each press opens the next.
  • i jumps to the composer.
  • s sends the thread's summary to the main agent, once the subagent has replied.
  • PgUp PgDn or the mouse wheel scroll the thread; the wheel over the ticket scrolls the ticket.
  • While you are in the composer, every letter is text.

Without a click

After Ctrl+X Tab (or a fresh /parked) the pane has the keyboard but the arrows do not work. Tab moves down, as ↓ does, in both views. The letter row at the bottom does: h j k l, o for Enter and i for the composer.

Fallback view

/parked lab switches between the keyboard view and an older view built from standard buttons.

Known limits

  • Early-access API. The plugin API this depends on may change between Claude Code releases. It was written against Claude Code 2.1.288.
  • Arrow keys need a click, as above.
  • Answers during a running turn. A mod's own prompt waits for the turn to end, so while one runs the answers are put in the prompt box for you to send with Enter. If the box cannot take them, they are sent when the turn ends.
  • Plain text in the pane. The ticket and thread are shown without Markdown formatting, because the pane scrolls them a row at a time.
  • Subagent completion notices. When a side thread's subagent stops, the main agent may still receive a notice that it finished. The thread's content is not included.
  • Two sessions in one project share a backlog, and two writes at the same instant can lose one.
  • Terminal and desktop only for the keyboard view; other surfaces get the fallback view.

Development

claude plugin validate .   # check the manifest and hooks module
claude plugin test .       # run the tests
  • hooks/register.tsx: the hooks module: tools, commands, storage, the pane.
  • hooks/detail.tsx: the pane's keyboard region.
  • hooks/backlog.ts: the backlog's logic, free of the engine.
  • types/index.d.ts: the state contract.
  • tests/parked.test.ts: the tests.

Licence

MIT

Source 6 files
hooks/register.tsx 1458 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ParkedItem } from '../types'
5import { PAD } from './kit/layout'
6import {
7  EMPTY,
8  ICON,
9  ageOf,
10  answer,
11  answerOf,
12  answersNote,
13  sent,
14  ticketParts,
15  ticketLines,
16  mergedRuns,
17  placeOfRef,
18  syncLedger,
19  unsend,
20  unsentOf,
21  orderOf,
22  briefRequest,
23  describe,
24  doneNote,
25  handoff,
26  investigationPrompt,
27  isBacklog,
28  park,
29  reopen,
30  resolve,
31  say,
32  sayOnce,
33  statusText,
34  threadLines,
35  summaryRequest,
36  titleOf,
37  wrap,
38} from './backlog'
39import type { Backlog } from './backlog'
40
41const PANE = 'parked'
42const PARK = 'mcp__parked__park'
43const RESOLVE = 'mcp__parked__resolve'
44const LIST = 'mcp__parked__list_parked'
45
46const items = atom({ plugin: 'parked', key: 'items' } as const, [])
47// The id of the item the pane shows in full; 0 while it shows the list.
48const selected = atom({ plugin: 'parked', key: 'selected' } as const, 0)
49// The ids of the items whose side thread awaits its subagent's reply.
50const pending = atom({ plugin: 'parked', key: 'pending' } as const, [])
51// The detail view scrolls its two regions itself: rows the thread is scrolled
52// up from its newest message, and rows the ticket is scrolled down from its top.
53const back = atom({ plugin: 'parked', key: 'back' } as const, 0)
54const top = atom({ plugin: 'parked', key: 'top' } as const, 0)
55// Where the last drawing put the ticket, and how far each region can move.
56// Whether the detail view is the keyboard view (a Client region) where the surface has one.
57const lab = atom({ plugin: 'parked', key: 'lab' } as const, true)
58let layout = { ticketStart: 0, ticketEnd: 0, maxTop: 0, maxBack: 0 }
59// The last key pressed on one of the pane's own letter buttons, and how many
60// have been: the keyboard region answers each new count as a key of its own.
61const nudge = atom({ plugin: 'parked', key: 'nudge' } as const, { key: '', n: 0 })
62// Whether the composer, the pane's own text field, has the focus ring.
63const typing = atom({ plugin: 'parked', key: 'typing' } as const, false)
64const typingAt = atom({ plugin: 'parked', key: 'typingAt' } as const, 0)
65// What the keyboard region or a letter button has typed for the composer while
66// the text field itself did not have the keyboard; the field is drawn with it.
67const draft = atom({ plugin: 'parked', key: 'draft' } as const, '')
68// Whether what the composer sends is the user's answer for the main agent,
69// not a question for the side thread's subagent.
70const answering = atom({ plugin: 'parked', key: 'answering' } as const, false)
71// The place last asked to be shown: the lens mod, where loaded, opens it.
72const jump = atom({ plugin: 'parked', key: 'jump' } as const, null)
73// Which of an item's file references the next "go" opens, by item.
74const goAt = new Map<number, number>()
75
76// How long after the last answer the batch of them is sent, so several items
77// answered in a row reach the main agent as one message.
78const FLUSH_MS = 3000
79// Whether the main loop is in a turn; a subagent's run raises no `turn.start`.
80let isRunning = false
81// The timer that sends the batch of answers, while one is waiting.
82let flushTimer: { cancel: () => void } | undefined
83
84// The thread's input is keyed by how many messages the user has sent, so each
85// send draws a fresh, empty field.
86const askKey = (item: ParkedItem) =>
87  `ask-${item.id}-${(item.thread ?? []).filter(one => one.role === 'you').length}`
88
89const GUIDE = `# Parked items
90
91This session has a backlog of parked items the user reviews later in a pane, often after leaving the session unattended. Keep it current with the ${PARK}, ${RESOLVE} and ${LIST} tools.
92
93Park an item with kind "needs-you" for: a decision you deferred to the user, a question you could not answer, a blocker, or a finding the user must act on.
94Park an item with kind "fyi" for: a completed job worth knowing about, a notable finding, or an assumption you made on the user's behalf.
95Park one item per distinct thing to address, not one per report. Give each a short title and a body that stands on its own: what is needed from the user and the relevant findings. Do not park routine progress.
96When an item is a choice, give "options" (short labels, nine at most) and "default", the number of the one you would pick; the user sees that one listed first, as option 1. Set "blocking" to true only when no remaining work can go on without the answer; otherwise go ahead on your default and leave the item open for the user to accept or overturn. Name the places an item is about in "refs", each a path or path:line.
97The user's answers arrive as a prompt that begins "My answers to parked items", and each answered item is already done: do not resolve it again. An answer that differs from your default replaces it, so revise what you built on the default.
98When the user has addressed an item in conversation, call ${RESOLVE} with its id and a one-line record of the outcome.
99Call ${LIST} when resuming work or when unsure what is open.`
100
101// Who parked an item, and the ledger task or unit it belongs to ('' for none).
102const parkerOf = (item: ParkedItem) =>
103  item.origin !== undefined ? 'the ledger' : item.parkedBy === 'user' ? 'you' : 'the agent'
104const tagOf = (item: ParkedItem) =>
105  item.origin?.kind === 'review' ? item.origin.unit : (item.task ?? '')
106
107const SUBAGENT_REFUSAL =
108  'Only the main agent parks items. Report this finding to the orchestrator in your final answer instead.'
109
110const keyOf = async ($: EngineInterface) => `backlog:${await $.session.cwd()}`
111
112const load = async ($: EngineInterface): Promise<Backlog> => {
113  const stored = await $.store.get(await keyOf($))
114
115  return isBacklog(stored) ? stored : EMPTY
116}
117
118const show = async ($: EngineInterface, backlog: Backlog) => {
119  await update($, items, () => backlog.items)
120  $.ui.status(statusText(backlog.items))
121}
122
123// Reads the stored backlog, applies the change and writes it back; answers
124// the reason when the change was refused or the store failed.
125const changeNow = async (
126  $: EngineInterface,
127  apply: (backlog: Backlog) => Backlog | string,
128): Promise<{ backlog: Backlog } | { error: string }> => {
129  try {
130    const after = apply(await load($))
131
132    if (typeof after === 'string') {
133      return { error: after }
134    }
135
136    await $.store.set(await keyOf($), after)
137    await show($, after)
138
139    return { backlog: after }
140  } catch (error) {
141    const reason = `Parked items could not be saved: ${error instanceof Error ? error.message : String(error)}`
142    $.ui.toast(reason)
143
144    return { error: reason }
145  }
146}
147
148// One change at a time. Two hooks recording the same reply would otherwise
149// both read the backlog before either wrote it, and both would add it.
150let changes: Promise<unknown> = Promise.resolve()
151
152const change = (
153  $: EngineInterface,
154  apply: (backlog: Backlog) => Backlog | string,
155): Promise<{ backlog: Backlog } | { error: string }> => {
156  const run = changes.then(() => changeNow($, apply))
157  changes = run.catch(() => undefined)
158
159  return run
160}
161
162// The main session's briefing for an item's subagent: one tool-less question
163// over the session's own transcript, which the main conversation never sees.
164// Empty when the session has nothing to fork or the call fails.
165const briefOf = async ($: EngineInterface, item: ParkedItem): Promise<string> => {
166  try {
167    const reply = await $.model.fork({ prompt: briefRequest(item) })
168
169    return reply.isAnswered ? reply.text.trim() : ''
170  } catch {
171    return ''
172  }
173}
174
175const wordsOf = (text: string) => text.split(/\s+/).filter(Boolean).length
176
177// Sends the user's message to the item's subagent: the one already on the
178// thread while this session still has it, else a fresh one given the thread.
179const ask = async ($: EngineInterface, item: ParkedItem, text: string) => {
180  const settle = () => update($, pending, list => list.filter(id => id !== item.id))
181  const fail = async (reason: string) => {
182    const at = await $.clock.now()
183    await change($, backlog => say(backlog, item.id, { role: 'agent', text: reason, at }))
184    await settle()
185  }
186
187  try {
188    const at = await $.clock.now()
189    const said = await change($, backlog => say(backlog, item.id, { role: 'you', text, at }))
190
191    if ('error' in said) {
192      return
193    }
194
195    await update($, pending, list => [...list.filter(id => id !== item.id), item.id])
196    const next = said.backlog.items.find(one => one.id === item.id)
197
198    if (next !== undefined) {
199      void $.ui.focus({ requestId: PANE, key: askKey(next) }).catch(() => undefined)
200    }
201
202    if (item.agentId !== undefined) {
203      const sent = await $.session.send({ to: { agentId: item.agentId }, text })
204
205      if (sent.isDelivered) {
206        return
207      }
208    }
209
210    const brief = await briefOf($, item)
211    const noted = await $.clock.now()
212    await change($, backlog =>
213      say(backlog, item.id, {
214        role: 'note',
215        text:
216          brief !== ''
217            ? `The subagent was briefed from the main session (${wordsOf(brief)} words).`
218            : 'No briefing: the main session had no context to give. The subagent has the ticket only.',
219        at: noted,
220      }),
221    )
222
223    const spawned = await $.agent.spawn({
224      prompt: investigationPrompt(item, text, brief),
225      description: `Parked #${item.id}: ${item.title}`.slice(0, 60),
226    })
227
228    if (spawned.agentId === undefined) {
229      await fail(`The subagent could not start: ${spawned.deny ?? 'no reason given'}`)
230
231      return
232    }
233
234    const agentId = spawned.agentId
235    await change($, backlog => ({
236      ...backlog,
237      items: backlog.items.map(one => (one.id === item.id ? { ...one, agentId } : one)),
238    }))
239  } catch (error) {
240    await fail(`The subagent could not be reached: ${error instanceof Error ? error.message : String(error)}`)
241  }
242}
243
244// A subagent's report, read from its own transcript: it hands its report back
245// through a tool call, so its turn can end with no visible answer.
246const reportOf = async ($: EngineInterface, agentId: string): Promise<string> => {
247  const rows = await $.session.messages({ agentId })
248
249  if (!Array.isArray(rows)) {
250    return ''
251  }
252
253  for (const row of [...rows].reverse()) {
254    if (row.role !== 'assistant') {
255      continue
256    }
257
258    const handback = row.toolUses.findLast(use => /handback/i.test(use.tool))
259    // The report is the hand-back's longest text argument.
260    const report = Object.values(handback?.input ?? {})
261      .filter((value): value is string => typeof value === 'string')
262      .sort((a, b) => b.length - a.length)[0]
263
264    if (report !== undefined && report.trim() !== '') {
265      return report.trim()
266    }
267
268    if (row.text.trim() !== '') {
269      return row.text.trim()
270    }
271  }
272
273  return ''
274}
275
276const goTo = async ($: EngineInterface, id: number) => {
277  await update($, back, () => 0)
278  await update($, draft, () => '')
279  await update($, answering, () => false)
280  await update($, top, () => 0)
281  await update($, selected, () => id)
282}
283
284// Gives the pane's text field the focus ring, so what is typed lands in it,
285// clicked or not.
286const focusComposer = async ($: EngineInterface, item: ParkedItem) => {
287  // The ring may still be resting on the text field from an earlier
288  // visit, the region having taken the keys with a click since. Focusing
289  // where the ring already is moves nothing, so it goes to the letter
290  // row first: the move back is what hands the field the keyboard.
291  await $.ui.focus({ requestId: PANE, key: 'key-k' }).catch(() => undefined)
292  await $.ui.focus({ requestId: PANE, key: askKey(item) }).catch(() => undefined)
293  // A new visit, whether or not the ring reported a move.
294  await update($, typing, () => true)
295  await update($, typingAt, count => count + 1)
296}
297
298// Tells the main agent every answer it has not had, as one message. While a
299// turn runs the message goes into the prompt box, where the user's Enter
300// delivers it into that turn; a prompt a plugin submits waits for the turn's end.
301const flush = async ($: EngineInterface) => {
302  // The answers given in this session are taken and marked sent in one change,
303  // before they are delivered: a second flush that starts while this one waits
304  // on the prompt finds none of them and so cannot send them again.
305  const sessionId = await $.session.id()
306  let list: ParkedItem[] = []
307  await change($, backlog => {
308    list = unsentOf(backlog.items, sessionId)
309
310    return sent(backlog, list.map(one => one.id))
311  })
312
313  if (list.length === 0) {
314    return
315  }
316
317  try {
318    await deliver($, list)
319  } catch (error) {
320    // Not delivered: they are unsent again, for the next flush to take.
321    await change($, backlog => unsend(backlog, list.map(one => one.id)))
322    throw error
323  }
324}
325
326const deliver = async ($: EngineInterface, list: readonly ParkedItem[]) => {
327  const text = answersNote(list)
328  const count = `${list.length} answer${list.length === 1 ? '' : 's'}`
329  let isFilled = false
330
331  if (isRunning) {
332    const box = await $.prompt.read().then(
333      read => read.text,
334      () => '',
335    )
336    const lead = box === '' || box.endsWith('\n') ? '' : '\n'
337    isFilled = await $.prompt.fill({ text: `${lead}${text}\n`, mode: 'append' }).then(
338      filled => filled.isFilled,
339      () => false,
340    )
341  }
342
343  if (!isFilled) {
344    await $.prompt.submit({ text })
345  }
346
347  $.ui.toast(
348    isFilled
349      ? `Parked: ${count} in the prompt box. Press Enter to send into the running turn.`
350      : `Parked: ${count} sent to the main agent.`,
351  )
352}
353
354// Sends the batch of answers once no new one has come for FLUSH_MS.
355const queueFlush = ($: EngineInterface) => {
356  flushTimer?.cancel()
357  flushTimer = $.clock.after(FLUSH_MS, () => {
358    flushTimer = undefined
359    void flush($).catch(() => undefined)
360  })
361}
362
363// Answers the open item for the user: an option by its number, or their words.
364const respond = async ($: EngineInterface, item: ParkedItem, value: number | string) => {
365  const made = answerOf(item, value)
366
367  if (typeof made === 'string') {
368    $.ui.toast(made)
369
370    return
371  }
372
373  const order = orderOf(await read($, items))
374  const when = await $.clock.now()
375  const sessionId = await $.session.id()
376  const changed = await change($, backlog => answer(backlog, item.id, value, when, sessionId))
377
378  if ('error' in changed) {
379    $.ui.toast(changed.error)
380
381    return
382  }
383
384  if (made.isSilent) {
385    $.ui.toast(`Parked #${item.id}: default accepted. The agent already went that way.`)
386  } else {
387    $.ui.toast(`Parked #${item.id}: answer queued for the main agent.`)
388    queueFlush($)
389  }
390
391  // On to the next open item, or back to the list when none is left.
392  const following = order.find(one => one.status === 'open' && one.id !== item.id)
393  await goTo($, following?.id ?? 0)
394}
395
396// A side thread in a few lines, written by a small model. Empty when the call
397// fails, and the hand-off then falls back to the start of the last reply.
398const summaryOf = async ($: EngineInterface, item: ParkedItem): Promise<string> => {
399  try {
400    const reply = await $.model.complete({
401      model: 'haiku',
402      prompt: summaryRequest(item),
403      maxTokens: 400,
404      timeoutMs: 30000,
405    })
406
407    return reply.isAnswered ? reply.text.trim() : ''
408  } catch {
409    return ''
410  }
411}
412
413// What the composer sends: the user's answer for the main agent while they are
414// answering, else a question for the side thread's subagent.
415const send = async ($: EngineInterface, item: ParkedItem, text: string) => {
416  if (await read($, answering)) {
417    await respond($, item, text)
418  } else {
419    await ask($, item, text)
420  }
421}
422
423// Runs one action on the open item.
424const perform = async ($: EngineInterface, item: ParkedItem, act: string) => {
425  const order = orderOf(await read($, items))
426  const at = order.findIndex(one => one.id === item.id)
427
428  if (act === 'prev' || act === 'next') {
429    const to = order[at + (act === 'next' ? 1 : -1)]
430
431    if (to !== undefined) {
432      await goTo($, to.id)
433    }
434  } else if (act === 'done' && item.status === 'open') {
435    const when = await $.clock.now()
436    await change($, backlog => resolve(backlog, item.id, doneNote(item), 'user', when))
437    // On to the next open item, or back to the list when none is left.
438    const following = order.find(one => one.status === 'open' && one.id !== item.id)
439    await goTo($, following?.id ?? 0)
440  } else if (act === 'accept' && item.status === 'open' && item.preferred !== undefined) {
441    await respond($, item, item.preferred)
442  } else if (/^pick-[1-9]$/.test(act) && item.status === 'open') {
443    await respond($, item, Number(act.slice(5)))
444  } else if (act === 'go' && (item.refs ?? []).length > 0) {
445    // Each press asks for the next of the item's places, round and round.
446    const refs = item.refs ?? []
447    const at = (goAt.get(item.id) ?? 0) % refs.length
448    const place = placeOfRef(refs[at] ?? '')
449
450    goAt.set(item.id, at + 1)
451    await update($, jump, last => ({ ...place, n: (last?.n ?? 0) + 1 }))
452    $.ui.toast(
453      `Parked #${item.id}: ${refs[at]} asked of lens${refs.length > 1 ? ` (${at + 1} of ${refs.length})` : ''}`,
454    )
455  } else if (act === 'write' && item.status === 'open') {
456    await focusComposer($, item)
457    await update($, answering, () => true)
458  } else if (act === 'reopen') {
459    await change($, backlog => reopen(backlog, item.id))
460  } else if (act === 'send' && (item.thread ?? []).some(one => one.role === 'agent')) {
461    $.ui.toast(`Parked #${item.id}: summarising the thread for the main agent…`)
462    await $.prompt.submit({ text: handoff(item, await summaryOf($, item)) })
463    $.ui.toast(`Parked #${item.id}: findings sent to the main agent.`)
464  } else if (act === 'back') {
465    await goTo($, 0)
466  }
467}
468
469export const register: Register = on => {
470  on('session.start', async ($, e, next) => {
471    // Answers a reload or a restart left unsent go out with the next batch.
472    queueFlush($)
473
474    await $.command.register({
475      name: 'park',
476      description: "Park a note, or the assistant's last reply: /park <note> · /park · /park + <note> for both",
477    })
478    await $.command.register({
479      name: 'parked',
480      description: 'Show parked items in a pane',
481    })
482    await $.tool.register({
483      name: 'park',
484      description:
485        'Park an item for the user to review later. Use kind "needs-you" for decisions, unanswered questions, blockers and findings the user must act on; "fyi" for completed jobs, notable findings and assumptions made on their behalf.',
486      inputSchema: {
487        type: 'object',
488        properties: {
489          kind: { type: 'string', enum: ['needs-you', 'fyi'] },
490          title: { type: 'string', description: 'One line naming the item' },
491          body: {
492            type: 'string',
493            description: 'What is needed from the user and the relevant findings; must stand on its own',
494          },
495          options: {
496            type: 'array',
497            items: { type: 'string' },
498            maxItems: 9,
499            description: 'For a choice: a short label per option, which the user picks by number',
500          },
501          default: {
502            type: 'integer',
503            description: 'The number of the option you would pick, 1 for the first',
504          },
505          blocking: {
506            type: 'boolean',
507            description:
508              'True only when no remaining work can go on without the answer; otherwise proceed on your default',
509          },
510          refs: {
511            type: 'array',
512            items: { type: 'string' },
513            description: 'The places the item is about, each a path or path:line',
514          },
515          task: {
516            type: 'string',
517            description: 'The id of the ledger task the item belongs to, when the ledger has a run',
518          },
519        },
520        required: ['kind', 'title', 'body'],
521      },
522    })
523    await $.tool.register({
524      name: 'resolve',
525      description: 'Mark a parked item done once the user has addressed it.',
526      inputSchema: {
527        type: 'object',
528        properties: {
529          id: { type: 'number' },
530          resolution: { type: 'string', description: 'One line on how it was addressed' },
531        },
532        required: ['id', 'resolution'],
533      },
534    })
535    await $.tool.register({
536      name: 'list_parked',
537      description: 'List the parked items of this project: open ones, and done ones if asked.',
538      inputSchema: {
539        type: 'object',
540        properties: { includeDone: { type: 'boolean' } },
541      },
542    })
543
544    try {
545      await show($, await load($))
546    } catch {
547      $.ui.toast('Parked items could not be loaded.')
548    }
549
550    return next(e)
551  })
552
553  on('prompt.compose', async ($, e, next) => {
554    const composed = await next(e)
555
556    return {
557      sections: [
558        ...composed.sections,
559        { id: 'parked:guide', text: GUIDE, scope: 'session' },
560      ],
561    }
562  })
563
564  on('tool.call', { tool: PARK }, async ($, e) => {
565    if (e.agentId !== undefined) {
566      return { deny: SUBAGENT_REFUSAL }
567    }
568
569    const { kind, title, body } = e
570
571    if (
572      (kind !== 'needs-you' && kind !== 'fyi') ||
573      typeof title !== 'string' ||
574      typeof body !== 'string' ||
575      title.trim() === ''
576    ) {
577      return { deny: 'park needs kind ("needs-you" or "fyi"), a title and a body.' }
578    }
579
580    const texts = (value: unknown) =>
581      Array.isArray(value) && value.every(one => typeof one === 'string' && one.trim() !== '')
582        ? value.map(one => String(one).trim())
583        : undefined
584    const options = e.options === undefined ? [] : texts(e.options)
585    const refs = e.refs === undefined ? [] : texts(e.refs)
586    const preferred = e.default
587
588    if (options === undefined || options.length > 9 || refs === undefined) {
589      return { deny: 'park takes options as at most nine short labels, and refs as paths or path:line.' }
590    }
591
592    if (
593      preferred !== undefined &&
594      (typeof preferred !== 'number' || !Number.isInteger(preferred) || preferred < 1 || preferred > options.length)
595    ) {
596      return { deny: `park takes default as the number of one of its ${options.length} options, 1 for the first.` }
597    }
598
599    const sessionId = await $.session.id()
600    const now = await $.clock.now()
601    const changed = await change($, backlog =>
602      park(backlog, {
603        kind,
604        title: title.trim(),
605        body,
606        options,
607        ...(typeof preferred === 'number' ? { preferred } : {}),
608        isBlocking: e.blocking === true,
609        ...(typeof e.task === 'string' && e.task.trim() !== '' ? { task: e.task.trim() } : {}),
610        refs,
611        parkedBy: 'model',
612        sessionId,
613        now,
614      }),
615    )
616
617    if ('error' in changed) {
618      return { deny: changed.error }
619    }
620
621    return { result: `Parked as #${changed.backlog.nextId - 1}.` }
622  })
623
624  on('tool.call', { tool: RESOLVE }, async ($, e) => {
625    if (e.agentId !== undefined) {
626      return { deny: SUBAGENT_REFUSAL }
627    }
628
629    const { id, resolution } = e
630
631    if (typeof id !== 'number' || typeof resolution !== 'string') {
632      return { deny: 'resolve needs a numeric id and a resolution.' }
633    }
634
635    const now = await $.clock.now()
636    const changed = await change($, backlog => resolve(backlog, id, resolution, 'model', now))
637
638    return 'error' in changed ? { deny: changed.error } : { result: `Resolved #${id}.` }
639  })
640
641  on('tool.call', { tool: LIST }, async ($, e) => {
642    const backlog = await load($)
643    const list = backlog.items.filter(one => e.includeDone === true || one.status === 'open')
644
645    return {
646      result: list.length === 0 ? 'No parked items.' : list.map(describe).join('\n\n'),
647    }
648  })
649
650  // The ledger mod, where it is loaded, changed its run: a failed gate and a
651  // unit's open findings become items here, and close as the run moves on.
652  on('state.set', { plugin: 'ledger', key: 'runs' }, async ($, e, next) => {
653    const written = await next(e)
654    // Every run under way counts, not only the one planned last.
655    const seenRun = mergedRuns(e.value)
656    const sessionId = await $.session.id()
657    const now = await $.clock.now()
658    const before = await load($)
659
660    // Most changes of a run (a spawn, a token count) alter no item.
661    if (syncLedger(before, seenRun, now, sessionId) !== before) {
662      await change($, backlog => syncLedger(backlog, seenRun, now, sessionId))
663    }
664
665    return written
666  })
667
668  on('turn.start', async ($, e, next) => {
669    isRunning = true
670
671    return next(e)
672  })
673
674  // A thread's subagent finished a run: its answer is the thread's next message.
675  on('turn.complete', async ($, e, next) => {
676    if (e.agentId === undefined) {
677      isRunning = false
678    }
679
680    if (e.agentId !== undefined) {
681      const agentId = e.agentId
682      const item = (await load($)).items.find(one => one.agentId === agentId)
683
684      if (item !== undefined) {
685        const at = await $.clock.now()
686        const found = e.answer.trim() !== '' ? e.answer.trim() : await reportOf($, agentId).catch(() => '')
687        const text = found !== '' ? found : `(The subagent stopped without a report: ${e.reason}.)`
688        await change($, backlog => sayOnce(backlog, item.id, { role: 'agent', text, at }))
689        await update($, pending, list => list.filter(id => id !== item.id))
690        $.ui.toast(`Parked #${item.id}: the subagent replied.`)
691      }
692    }
693
694    return next(e)
695  })
696
697  // A thread's subagent also reports to the main conversation when it stops:
698  // its hand-back message, then a task notification, each a prompt of its own.
699  // The thread is private, so both are dropped before they start a turn.
700  on('prompt.submit', async ($, e, next) => {
701    const kind = e.origin.kind
702
703    if (kind !== 'peer' && kind !== 'peer-send-message' && kind !== 'task-notification') {
704      return next(e)
705    }
706
707    const item = (await load($)).items.find(
708      one => one.agentId !== undefined && e.text.includes(one.agentId),
709    )
710
711    if (item === undefined) {
712      return next(e)
713    }
714
715    // The hand-back carries the full report; keep it when the thread lacks it.
716    const report = (e.text.split('The report follows:')[1] ?? '')
717      .replace(/<\/agent-message>[\s\S]*$/, '')
718      .replace(/^ {2}/gm, '')
719      .trim()
720    const probe = report.slice(0, 60)
721    const isKept = (item.thread ?? []).some(one => one.role === 'agent' && one.text.includes(probe))
722
723    if (kind !== 'task-notification' && report !== '' && !isKept) {
724      const at = await $.clock.now()
725      await change($, backlog => sayOnce(backlog, item.id, { role: 'agent', text: report, at }))
726      await update($, pending, list => list.filter(id => id !== item.id))
727    }
728
729    return { drop: `Parked #${item.id}: the subagent's reply is in the pane.` }
730  })
731
732  on('command.run', { command: 'park' }, async ($, e) => {
733    // `/park` parks the assistant's last reply. `/park <note>` parks the note
734    // alone: what the user writes is often about something other than that
735    // reply. `/park + <note>` parks the reply with the note on it.
736    const typed = e.args.trim()
737    const hasReply = typed === '' || typed.startsWith('+')
738    const note = typed.replace(/^\+\s*/, '')
739    const reply = hasReply
740      ? (await $.session.messages()).findLast(one => one.role === 'assistant' && one.text.trim() !== '')
741      : undefined
742
743    if (hasReply && reply === undefined) {
744      return { text: 'Nothing to park yet: the assistant has not replied.' }
745    }
746
747    const body = reply?.text ?? ''
748    const sessionId = await $.session.id()
749    const now = await $.clock.now()
750    const changed = await change($, backlog =>
751      park(backlog, {
752        kind: 'needs-you',
753        title: titleOf(note, body),
754        body,
755        note,
756        parkedBy: 'user',
757        sessionId,
758        now,
759      }),
760    )
761
762    return {
763      text:
764        'error' in changed
765          ? changed.error
766          : `Parked as #${changed.backlog.nextId - 1}. /parked shows the list.`,
767    }
768  })
769
770  // What the keyboard region posts: a list row to open, an action on the open
771  // item, or a message for its side thread.
772  on('ui.message', async ($, e, next) => {
773    if (e.requestId !== PANE || typeof e.data !== 'object' || e.data === null) {
774      return next(e)
775    }
776
777    const data = e.data as {
778      act?: unknown
779      ask?: unknown
780      open?: unknown
781      focus?: unknown
782      blur?: unknown
783      draft?: unknown
784    }
785    const list = await read($, items)
786
787    // The region took the keys back from the composer with a key or a click.
788    if (data.blur === true) {
789      await update($, typing, () => false)
790      await update($, answering, () => false)
791
792      return {}
793    }
794
795    // The composer level: the pane's text field takes the focus ring.
796    if (data.focus === 'composer') {
797      const id = await read($, selected)
798      const target = list.find(one => one.id === id)
799
800      if (target !== undefined) {
801        await focusComposer($, target)
802      }
803
804      return {}
805    }
806
807    if (typeof data.open === 'number') {
808      if (list.some(one => one.id === data.open)) {
809        await goTo($, data.open)
810      }
811
812      return {}
813    }
814
815    const openId = await read($, selected)
816    const item = list.find(one => one.id === openId)
817
818    if (item === undefined) {
819      return next(e)
820    }
821
822    if (typeof data.draft === 'string') {
823      // The region typed for the composer: the field is drawn with its text.
824      const text = data.draft
825      await update($, draft, () => text)
826    } else if (typeof data.ask === 'string' && data.ask.trim() !== '') {
827      await update($, draft, () => '')
828      await update($, back, () => 0)
829      await send($, item, data.ask.trim())
830    } else if (typeof data.act === 'string') {
831      await perform($, item, data.act)
832    }
833
834    return {}
835  })
836
837  // The composer level follows the focus ring: on while the ring is on the
838  // text field, off when Tab or a click moves the ring elsewhere.
839  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
840    const moved = await next(e)
841    const isComposer = e.element?.startsWith('ask-') === true
842
843    // Each time the ring lands on the composer is a visit, numbered so the
844    // keyboard region can tell a new one from the one it last took the keys
845    // back from.
846    if (isComposer) {
847      await update($, typing, () => true)
848      await update($, typingAt, count => count + 1)
849    } else if (await read($, typing)) {
850      await update($, typing, () => false)
851      await update($, answering, () => false)
852    }
853
854    return moved
855  })
856
857  on('command.run', { command: 'parked' }, async ($, e) => {
858    if (e.args.trim() === 'lab') {
859      const isOn = !(await read($, lab))
860      await update($, lab, () => isOn)
861
862      return { text: `Parked: the keyboard view is ${isOn ? 'on' : 'off'}.` }
863    }
864
865    await show($, await load($))
866    // focus + closeOnEscape + holdToasts makes the pane a dialog: it owns the
867    // arrows and Tab until Esc, so they stop reaching the agents view.
868    await $.ui.open({
869      id: PANE,
870      title: 'Parked',
871      focus: true,
872      closeOnEscape: true,
873      holdToasts: true,
874    })
875
876    return { text: 'Parked items pane opened.' }
877  })
878
879  // In the detail view the pane's tree fits its window, so the engine has
880  // nothing to scroll: the wheel over the ticket moves the ticket, and the
881  // wheel elsewhere or the scroll keys move the thread.
882  on('ui.scroll', { requestId: PANE }, async ($, e, next) => {
883    if (e.origin.kind !== 'person') {
884      return next(e)
885    }
886
887    // An arrow pressed while the pane, not its keyboard region, has the keys
888    // arrives as a one-row scroll with no pointer: it is passed to the region
889    // as the arrow it was.
890    if (e.pointer === undefined && Math.abs(e.by) === 1 && (await read($, lab))) {
891      const key = e.by < 0 ? 'up' : 'down'
892      await update($, nudge, last => ({ key, n: last.n + 1 }))
893
894      return {}
895    }
896
897    if ((await read($, selected)) === 0) {
898      return next(e)
899    }
900
901    const row = e.pointer?.row
902    const { ticketStart, ticketEnd, maxTop, maxBack } = layout
903
904    if (row !== undefined && row >= ticketStart && row < ticketEnd) {
905      await update($, top, at => Math.min(maxTop, Math.max(0, at + e.by)))
906    } else {
907      await update($, back, at => Math.min(maxBack, Math.max(0, at - e.by)))
908    }
909
910    return {}
911  })
912
913  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
914    // The thread needs a text field, which the mobile surface does not draw.
915    if (e.surface === 'mobile') {
916      return next(e)
917    }
918
919    const table = $.ui.resolve(e)
920    const { Box, Button, Input, Text } = table
921    // The keyboard view is drawn only where the surface has Client.
922    const Client = (await read($, lab)) && 'Client' in table ? table.Client : undefined
923    const waiting = await read($, pending)
924    const list = await read($, items)
925    const openId = await read($, selected)
926    const now = await $.clock.now()
927    // What is drawn is narrower than the pane by the padding at each side.
928    const columns = Math.max(30, (e.props.bodyColumns ?? e.viewport?.columns ?? 60) - 2 * PAD)
929    const rule = '─'.repeat(columns)
930
931    const open = list.filter(one => one.status === 'open')
932    // Those work is blocked on come first, as `orderOf` has them.
933    const asked = open.filter(one => one.kind === 'needs-you')
934    const needs = [
935      ...asked.filter(one => one.isBlocking === true),
936      ...asked.filter(one => one.isBlocking !== true),
937    ]
938    const toneOf = (one: ParkedItem) => (one.isBlocking === true ? 'error' : 'warning')
939    const glyphOf = (one: ParkedItem) => (one.isBlocking === true ? ICON.blocking : ICON.needs)
940    const isAnswering = await read($, answering)
941    const fyi = open.filter(one => one.kind === 'fyi')
942    const done = list.filter(one => one.status === 'done').slice(-10).reverse()
943    // The order the list draws in, which Next and Prev walk.
944    const order = orderOf(list)
945    const item = order.find(one => one.id === openId)
946    // A row of the pane's own buttons, one per letter. Claude Code presses one
947    // when its letter is typed while the pane has the keyboard (ctrl+x tab),
948    // with no click; the press is handed to the keyboard region as that key.
949    const legend = (keys: readonly (readonly [string, string])[]) => (
950      <Box columnGap={2}>
951        {keys.map(([hot, means]) => (
952          <Button
953            key={`key-${hot}`}
954            plain
955            dimColor
956            hotkey={hot}
957            label={means}
958            onPress={async () => {
959              // While the composer is in use a letter is text for it, never a
960              // shortcut: the press means the text field did not get the key.
961              if (await read($, typing)) {
962                await update($, draft, text => `${text}${hot}`)
963              } else {
964                await update($, nudge, last => ({ key: hot, n: last.n + 1 }))
965              }
966            }}
967          />
968        ))}
969      </Box>
970    )
971
972    const go = async (id: number) => {
973      await update($, back, () => 0)
974      await update($, top, () => 0)
975      await update($, selected, () => id)
976    }
977
978    if (item !== undefined) {
979      const at = order.indexOf(item)
980      const before = order[at - 1]
981      const after = order[at + 1]
982      const isOpen = item.status === 'open'
983      const thread = item.thread ?? []
984      const isWaiting = waiting.includes(item.id)
985      const tone = !isOpen ? 'success' : item.kind === 'fyi' ? 'cyan' : toneOf(item)
986      const label = !isOpen
987        ? `${ICON.done} DONE`
988        : item.kind === 'fyi'
989          ? `${ICON.fyi} FYI`
990          : item.isBlocking === true
991            ? `${ICON.blocking} NEEDS ATTENTION · BLOCKING`
992            : `${ICON.needs} NEEDS ATTENTION`
993      const canAccept = isOpen && item.preferred !== undefined
994
995      // Every row is counted so the tree fits the pane's window exactly: the
996      // header, the ticket and the composer stay put, and only the two regions
997      // move. Fixed rows: status, title, meta, buttons, rule; then rule and
998      // thread heading; then the bordered composer (three rows) and the hint.
999      const bodyRows = Math.max(16, e.props.scroll.bodyRows) - 1
1000      // The note is part of the ticket's own rows, not a row above them.
1001      const noteRows = 0
1002      const doneRows = item.resolution !== undefined ? 1 : 0
1003      // The action buttons wrap in a narrow pane; count the rows they take.
1004      const actions = [
1005        canAccept ? 'a: Accept' : '',
1006        isOpen ? 'd: Done' : 'r: Reopen',
1007        isOpen ? 'w: Answer' : '',
1008        (item.refs ?? []).length > 0 ? 'g: Open' : '',
1009        after !== undefined ? 'n: Next' : '',
1010        before !== undefined ? 'p: Prev' : '',
1011        thread.some(one => one.role === 'agent') ? 's: Send' : '',
1012        'b: Back',
1013      ].filter(Boolean)
1014      let buttonRows = 1
1015      let used = 0
1016
1017      for (const action of actions) {
1018        // A button draws as `[ label ]`, with one column between neighbours.
1019        const width = action.length + 4
1020
1021        if (used > 0 && used + 1 + width > columns) {
1022          buttonRows += 1
1023          used = width
1024        } else {
1025          used += (used > 0 ? 1 : 0) + width
1026        }
1027      }
1028
1029      // The keyboard view has six rows beneath its region (actions, composer,
1030      // send, letter keys), one more than the classic view counts with one
1031      // button row.
1032      if (Client !== undefined) {
1033        buttonRows = 3
1034      }
1035
1036      const room = Math.max(6, bodyRows - (4 + buttonRows + noteRows + doneRows + 2 + 4))
1037      const ticket = ticketLines(item, columns)
1038      const ticketRows = Math.min(ticket.length, Math.max(3, Math.floor(room * 0.4)))
1039      const threadRows = Math.max(3, room - ticketRows)
1040      const talk = threadLines(thread, columns)
1041      const maxTop = Math.max(0, ticket.length - ticketRows)
1042      const maxBack = Math.max(0, talk.length - threadRows)
1043      const topAt = Math.min(await read($, top), maxTop)
1044      const backAt = Math.min(await read($, back), maxBack)
1045      const end = talk.length - backAt
1046      const shown = talk.slice(Math.max(0, end - threadRows), end)
1047      const ticketStart = 4 + buttonRows + noteRows + doneRows
1048      layout = { ticketStart, ticketEnd: ticketStart + ticketRows, maxTop, maxBack }
1049
1050      const more = (above: number, below: number) =>
1051        [above > 0 ? `↑${above}` : '', below > 0 ? `↓${below}` : ''].filter(Boolean).join(' ')
1052
1053      // Where the surface has Client, the whole item is one region that owns
1054      // the keyboard once clicked: ↑ and ↓ move between the tickets, the
1055      // actions, the composer and Send, and ← and → act on the level in focus.
1056      // What it posts is answered by the `ui.message` hook.
1057      if (Client !== undefined) {
1058        const inner = 5 + noteRows + doneRows
1059        layout = { ticketStart: inner, ticketEnd: inner + ticketRows, maxTop, maxBack }
1060        const isTyping = await read($, typing)
1061        const typed = await read($, draft)
1062
1063        return (
1064          <Box flexDirection="column" paddingX={PAD}>
1065            <Client
1066              key="detail"
1067              module="./detail.tsx"
1068              height={bodyRows - 5}
1069              props={{
1070                nudge: await read($, nudge),
1071                isTyping,
1072                typingAt: await read($, typingAt),
1073                draft: typed,
1074                view: 'detail',
1075                id: item.id,
1076                columns,
1077                label,
1078                tone,
1079                position: `${at + 1} of ${order.length}`,
1080                title: `#${item.id} ${item.title}`,
1081                meta: `parked by ${parkerOf(item)} · ${ageOf(item.createdAt, now)} ago${tagOf(item) === '' ? '' : ` · ${tagOf(item)}`}`,
1082                actions: [
1083                  ...(canAccept ? [{ act: 'accept', label: 'a: Accept', hot: 'a' }] : []),
1084                  isOpen ? { act: 'done', label: 'd: Done', hot: 'd' } : { act: 'reopen', label: 'r: Reopen', hot: 'r' },
1085                  ...(isOpen ? [{ act: 'write', label: 'w: Answer', hot: 'w' }] : []),
1086                  ...((item.refs ?? []).length > 0 ? [{ act: 'go', label: 'g: Open', hot: 'g' }] : []),
1087                  ...(after !== undefined ? [{ act: 'next', label: 'n: Next', hot: 'n' }] : []),
1088                  ...(before !== undefined ? [{ act: 'prev', label: 'p: Prev', hot: 'p' }] : []),
1089                  { act: 'back', label: 'b: Back', hot: 'b' },
1090                ],
1091                canSend: thread.some(one => one.role === 'agent'),
1092                // How many options a digit can pick; none once the item is done.
1093                options: isOpen ? (item.options?.length ?? 0) : 0,
1094                isAnswering,
1095                note: '',
1096                resolution: item.resolution ?? '',
1097                // The newest rows are kept when a text outgrows what props may carry.
1098                ticket: ticket
1099                  .slice(0, 400)
1100                  .map(row =>
1101                    row.isNote
1102                      ? [{ text: row.text, shade: true }]
1103                      : row.color !== undefined
1104                        ? [{ text: row.text, color: row.color }]
1105                        : ticketParts(row.text),
1106                  ),
1107                ticketRows,
1108                top: topAt,
1109                talk: talk.slice(-400),
1110                threadRows,
1111                back: backAt,
1112                heading: `Side thread${thread.length === 0 ? '' : ` (${thread.length})`}`,
1113                busy: isWaiting ? 'the subagent is working…' : '',
1114              }}
1115            />
1116            <Box
1117              borderStyle="round"
1118              borderColor={isAnswering ? 'warning' : isTyping ? 'cyan' : 'gray'}
1119              paddingX={1}
1120            >
1121              <Input
1122                key={askKey(item)}
1123                value={typed}
1124                label={isTyping ? '▸ ' : '  '}
1125                placeholder={
1126                  isAnswering
1127                    ? 'Your answer for the main agent · enter sends'
1128                    : isTyping
1129                      ? 'Type a question · enter on an empty line leaves'
1130                      : isWaiting
1131                        ? 'Add to your question…'
1132                        : 'Ask about this item…'
1133                }
1134                submitLabel="send"
1135                onSubmit={async (value: string) => {
1136                  if (value.trim() !== '') {
1137                    await update($, draft, () => '')
1138                    await update($, back, () => 0)
1139                    await send($, item, value.trim())
1140                  } else {
1141                    // Enter on an empty line leaves the composer: the ring
1142                    // moves to the letter row, where h j k l navigate again.
1143                    await $.ui.focus({ requestId: PANE, key: 'key-k' }).catch(() => undefined)
1144                  }
1145                }}
1146              />
1147            </Box>
1148            <Box>
1149              {thread.some(one => one.role === 'agent') ? (
1150                <Button
1151                  key="send"
1152                  plain
1153                  hotkey="s"
1154                  label="Send findings to main agent"
1155                  onPress={async () => {
1156                    // While the composer is in use, s is a letter for it.
1157                    if (await read($, typing)) {
1158                      await update($, draft, text => `${text}s`)
1159                    } else {
1160                      await perform($, item, 'send')
1161                    }
1162                  }}
1163                />
1164              ) : (
1165                <Text dimColor>Findings can be sent to the main agent once the subagent replies.</Text>
1166              )}
1167            </Box>
1168            {legend([
1169              ['h', '←'],
1170              ['j', '↓'],
1171              ['k', '↑'],
1172              ['l', '→'],
1173              ['o', 'enter'],
1174              ['i', 'type'],
1175            ])}
1176          </Box>
1177        )
1178      }
1179
1180      return (
1181        <Box flexDirection="column" paddingX={PAD}>
1182          <Box justifyContent="space-between">
1183            <Text color={tone} bold>
1184              {label}
1185            </Text>
1186            <Text dimColor>
1187              {at + 1} of {order.length}
1188            </Text>
1189          </Box>
1190          <Text bold wrap="truncate-end">
1191            #{item.id} {item.title}
1192          </Text>
1193          <Text dimColor wrap="truncate-end">
1194            parked by {parkerOf(item)} · {ageOf(item.createdAt, now)} ago
1195            {tagOf(item) === '' ? '' : ` · ${tagOf(item)}`}
1196          </Text>
1197          <Box columnGap={1} flexWrap="wrap">
1198            {canAccept && (
1199              <Button key="accept" label="a: Accept" hotkey="a" onPress={() => perform($, item, 'accept')} />
1200            )}
hooks/kit/layout.ts 24 lines
1// Shared by the lens, parked and ledger mods. The source is the mod-kit folder;
2// each mod carries a copy under hooks/kit, written there by sync.sh. Edit the
3// source and sync, never a copy.
4
5// The cells a pane leaves clear at each side.
6export const PAD = 2
7
8// Text no longer than `most`, an ellipsis standing for what was cut.
9export const cut = (text: string, most: number): string =>
10  text.length > most ? `${text.slice(0, Math.max(0, most - 1))}…` : text
11
12// How long ago `then` was, as one short word: minutes, hours, then days.
13export const ageOf = (then: number, now: number): string => {
14  const minutes = Math.max(0, Math.round((now - then) / 60000))
15
16  if (minutes < 60) {
17    return `${minutes}m`
18  }
19
20  const hours = Math.round(minutes / 60)
21
22  return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`
23}
24
hooks/backlog.ts 745 lines
1import type { ParkedItem, ParkedKind, ParkedMessage } from '../types'
2import type { LedgerFindingSeen, LedgerRunSeen } from '../types/ledger'
3import { FILE_COLOR, FILE_ICONS, FOLDER_COLOR, ICON, STAR_COLOR, refIcon } from './kit/icons'
4
5// What of the ledger's run has already been turned into items: the tasks whose
6// gate stood failed, and the findings a review item has carried. An item is
7// made when one of these changes, so one the user closed is not made again.
8export type LedgerSeen = { gates: string[]; findings: number[] }
9
10export type Backlog = { nextId: number; items: ParkedItem[]; seen?: LedgerSeen }
11
12export const EMPTY: Backlog = { nextId: 1, items: [] }
13
14export type NewItem = {
15  kind: ParkedKind
16  title: string
17  body: string
18  note?: string
19  options?: string[]
20  preferred?: number
21  isBlocking?: boolean
22  refs?: string[]
23  task?: string
24  origin?: ParkedItem['origin']
25  parkedBy: 'model' | 'user'
26  sessionId: string
27  now: number
28}
29
30export const isBacklog = (value: unknown): value is Backlog =>
31  typeof value === 'object' &&
32  value !== null &&
33  typeof (value as Backlog).nextId === 'number' &&
34  Array.isArray((value as Backlog).items)
35
36// The glyphs and their colours are the kit's, shared with lens and ledger.
37export { ICON }
38
39// The colour each glyph that leads a ticket row is drawn in.
40const LEAD_COLORS: Record<string, string> = {
41  ...Object.fromEntries(FILE_ICONS.map(([, glyph, color]) => [glyph, color])),
42  [ICON.file]: FILE_COLOR,
43  [ICON.folderOpen]: FOLDER_COLOR,
44  [ICON.options]: 'cyan',
45}
46
47export type TicketPart = { text: string; color?: string }
48
49// One row of the ticket as the pieces it is drawn in: the glyph that leads it
50// and the default's star take a colour, the rest is plain.
51export const ticketParts = (row: string): TicketPart[] => {
52  const lead = /^(\s*)(\S) (.*)$/u.exec(row)
53  const color = lead === null ? undefined : LEAD_COLORS[lead[2] ?? '']
54  const head: TicketPart[] =
55    lead === null || color === undefined
56      ? []
57      : [...(lead[1] === '' ? [] : [{ text: lead[1] ?? '' }]), { text: `${lead[2]} `, color }]
58  const rest = head.length === 0 ? row : (lead?.[3] ?? '')
59  const mark = `${ICON.preferred} default`
60  const at = rest.indexOf(mark)
61
62  return [
63    ...head,
64    ...(at === -1
65      ? [{ text: rest }]
66      : [{ text: rest.slice(0, at) }, { text: mark, color: STAR_COLOR }, { text: rest.slice(at + mark.length) }]),
67  ].filter(part => part.text !== '')
68}
69
70// The glyph of a ref's file type; a ref is a path, or path:line.
71export const fileIcon = (ref: string): string => refIcon(ref).glyph
72
73// The options with the preferred one first, so the default is always option 1.
74const defaultFirst = (options: readonly string[], preferred: number | undefined): string[] =>
75  preferred === undefined
76    ? [...options]
77    : [...options.slice(preferred - 1, preferred), ...options.slice(0, preferred - 1), ...options.slice(preferred)]
78
79export const park = (backlog: Backlog, item: NewItem): Backlog => ({
80  ...backlog,
81  nextId: backlog.nextId + 1,
82  items: [
83    ...backlog.items,
84    {
85      id: backlog.nextId,
86      kind: item.kind,
87      title: item.title,
88      body: item.body,
89      ...(item.note ? { note: item.note } : {}),
90      ...(item.options !== undefined && item.options.length > 0
91        ? { options: defaultFirst(item.options, item.preferred) }
92        : {}),
93      ...(item.preferred !== undefined ? { preferred: 1 } : {}),
94      ...(item.isBlocking === true ? { isBlocking: true as const } : {}),
95      ...(item.refs !== undefined && item.refs.length > 0 ? { refs: item.refs } : {}),
96      ...(item.task ? { task: item.task } : {}),
97      ...(item.origin !== undefined ? { origin: item.origin } : {}),
98      parkedBy: item.parkedBy,
99      sessionId: item.sessionId,
100      createdAt: item.now,
101      status: 'open',
102    },
103  ],
104})
105
106// A string answer is the reason the change was refused.
107export const resolve = (
108  backlog: Backlog,
109  id: number,
110  resolution: string,
111  resolvedBy: 'model' | 'user',
112  now: number,
113): Backlog | string => {
114  const item = backlog.items.find(one => one.id === id)
115
116  if (item === undefined) {
117    return `No parked item #${id}.`
118  }
119
120  if (item.status === 'done') {
121    return `Parked item #${id} is already done.`
122  }
123
124  return {
125    ...backlog,
126    items: backlog.items.map(one =>
127      one.id === id
128        ? { ...one, status: 'done', resolution, resolvedBy, resolvedAt: now }
129        : one,
130    ),
131  }
132}
133
134export const reopen = (backlog: Backlog, id: number): Backlog | string => {
135  const item = backlog.items.find(one => one.id === id)
136
137  if (item === undefined) {
138    return `No parked item #${id}.`
139  }
140
141  const { resolution: _r, resolvedBy: _b, resolvedAt: _a, answer: _w, answeredIn: _s, isUnsent: _u, ...rest } = item
142
143  return {
144    ...backlog,
145    items: backlog.items.map(one =>
146      one.id === id ? { ...rest, status: 'open' } : one,
147    ),
148  }
149}
150
151export const statusText = (items: readonly ParkedItem[]): string | undefined => {
152  const open = items.filter(one => one.status === 'open')
153  const needs = open.filter(one => one.kind === 'needs-you').length
154  const blocking = open.filter(one => one.kind === 'needs-you' && one.isBlocking === true).length
155  const fyi = open.length - needs
156  const parts = [
157    needs > 0 ? `${needs} need${needs === 1 ? 's' : ''} attention${blocking > 0 ? ` (${blocking} blocking)` : ''}` : '',
158    fyi > 0 ? `${fyi} FYI` : '',
159  ].filter(Boolean)
160
161  // The row is for what waits on the person: FYIs alone are not worth one,
162  // and ride along only when something does need them.
163  return needs === 0 ? undefined : `📌 ${parts.join(' · ')}`
164}
165
166export const titleOf = (note: string, reply: string): string => {
167  const first =
168    note.trim() ||
169    (reply.split('\n').find(line => line.trim() !== '') ?? '').replace(/^[#>*\-\s]+/, '').trim()
170
171  return first.length > 80 ? `${first.slice(0, 79)}…` : first
172}
173
174export { ageOf } from './kit/layout'
175
176export const describe = (item: ParkedItem): string =>
177  [
178    `#${item.id} [${item.kind}] ${item.title}${item.status === 'done' ? ' (done)' : ''}`,
179    item.note ? `Note: ${item.note}` : '',
180    item.isBlocking === true && item.status === 'open' ? 'Blocking: work is waiting on the user.' : '',
181    item.body,
182    item.options !== undefined
183      ? `Options: ${item.options.map((label, at) => `${at + 1}) ${label}`).join('  ')}${item.preferred !== undefined ? ` (default ${item.preferred})` : ''}`
184      : '',
185    item.refs !== undefined ? `Files: ${item.refs.join(', ')}` : '',
186    item.resolution ? `Resolution: ${item.resolution}` : '',
187  ]
188    .filter(Boolean)
189    .join('\n')
190
191// Adds a message to an item's side thread, and names the subagent answering it.
192export const say = (
193  backlog: Backlog,
194  id: number,
195  message: ParkedMessage,
196  agentId?: string,
197): Backlog | string => {
198  if (!backlog.items.some(one => one.id === id)) {
199    return `No parked item #${id}.`
200  }
201
202  return {
203    ...backlog,
204    items: backlog.items.map(one =>
205      one.id === id
206        ? {
207            ...one,
208            thread: [...(one.thread ?? []), message],
209            ...(agentId !== undefined ? { agentId } : {}),
210          }
211        : one,
212    ),
213  }
214}
215
216const lastOf = (item: ParkedItem, role: ParkedMessage['role']) =>
217  (item.thread ?? []).findLast(one => one.role === role)
218
219// What a fresh subagent is told: the item, the thread so far and the new message.
220export const investigationPrompt = (item: ParkedItem, text: string, brief = ''): string =>
221  [
222    'You are helping the user look into one parked item from a long orchestration session, in a side thread the main agent does not see.',
223    'Investigate with your tools as far as the question needs, then answer the user directly and concisely. Do not change any files unless the user asks you to.',
224    '',
225    `Parked item #${item.id} [${item.kind}]: ${item.title}`,
226    item.body,
227    item.note ? `The user's note: ${item.note}` : '',
228    ...(brief.trim() !== '' ? ['', 'Briefing from the main session, written for you from its full context:', brief.trim()] : []),
229    ...((item.thread ?? []).some(one => one.role !== 'note')
230      ? ['', 'Earlier in this thread:', ...(item.thread ?? []).filter(one => one.role !== 'note').map(one => `${one.role === 'you' ? 'User' : 'You'}: ${one.text}`)]
231      : []),
232    '',
233    `The user's message: ${text}`,
234  ]
235    .filter((line, index, all) => line !== '' || all[index - 1] !== '')
236    .join('\n')
237
238// The one message "Send to agent" posts to the main conversation.
239const cut = (text: string, most: number) => (text.length > most ? `${text.slice(0, most - 1)}…` : text)
240
241// What a small model is asked, to turn a side thread into the few lines the
242// main agent needs: the thread itself stays out of the main conversation.
243export const summaryRequest = (item: ParkedItem): string =>
244  [
245    'Below is a parked item from a coding session and a side thread in which the user and a subagent looked into it.',
246    'Summarise the thread for the main agent in at most 120 words: what was found, what the user decided or wants, and what the main agent should do next, if anything.',
247    'Plain text, no preamble, no headings. State only what the thread supports. If it reached no conclusion, say so in one line.',
248    '',
249    `Parked item #${item.id} [${item.kind}]: ${item.title}`,
250    cut(item.body, 4000),
251    '',
252    'Side thread:',
253    // The newest part of a long thread is the part that holds its conclusion.
254    (item.thread ?? [])
255      .filter(one => one.role !== 'note')
256      .map(one => `${one.role === 'you' ? 'User' : 'Subagent'}: ${one.text}`)
257      .join('\n\n')
258      .slice(-12000),
259  ].join('\n')
260
261// The one message "Send" posts to the main conversation: the thread's summary,
262// or, when none could be made, the start of the subagent's last reply.
263export const handoff = (item: ParkedItem, summary = ''): string => {
264  const finding = lastOf(item, 'agent')
265  const said = lastOf(item, 'you')
266
267  return [
268    `About parked item #${item.id}: ${item.title}`,
269    '',
270    ...(summary.trim() !== ''
271      ? ['Summary of a side thread I held with a subagent on this:', summary.trim()]
272      : [
273          'I looked into this in a side thread with a subagent. The start of its latest findings:',
274          cut(finding?.text ?? '(none yet)', 600),
275          ...(said !== undefined ? ['', `My last message there: ${cut(said.text, 300)}`] : []),
276        ]),
277  ].join('\n')
278}
279
280export const doneNote = (item: ParkedItem): string => {
281  const finding = lastOf(item, 'agent')
282
283  if (finding === undefined) {
284    return 'Marked done by you.'
285  }
286
287  const line = finding.text.replace(/\s+/g, ' ').trim()
288
289  return `Marked done by you after a side thread. Last finding: ${line.length > 140 ? `${line.slice(0, 139)}…` : line}`
290}
291
292// Splits text into rows no wider than `width`, so a region of the pane can show
293// a window of them; a wrapped list line keeps its indent.
294export const wrap = (text: string, width: number): string[] =>
295  text.split('\n').flatMap(line => {
296    const clean = line.replace(/\*\*(.+?)\*\*/g, '$1').trimEnd()
297    const indent = Math.min(clean.match(/^\s*(?:[-*]\s+|\d+\.\s+)?/)?.[0].length ?? 0, Math.max(0, width - 10))
298    const rows: string[] = []
299    let rest = clean
300
301    while (rest.length > width) {
302      const space = rest.lastIndexOf(' ', width)
303      const cut = space > indent ? space : width
304      rows.push(rest.slice(0, cut))
305      rest = ' '.repeat(indent) + rest.slice(cut).trimStart()
306    }
307
308    return [...rows, rest]
309  })
310
311// What the main session is asked, in a fork of its own context, so a thread's
312// subagent starts with the background an orchestrator would hand it.
313export const briefRequest = (item: ParkedItem): string =>
314  [
315    'This is a request for a handover, not a continuation of your work. Do not act on it or call tools.',
316    'A separate subagent is about to help the user look into the parked item below. It knows nothing about this session.',
317    'Write the briefing you would give it: the goal of the work this item came from; what was done and found that bears on it; the files, commands and names it will need; decisions already made; and what is still unknown.',
318    'Be specific and brief, in plain text with no preamble. If nothing in this session bears on the item, say so in one line.',
319    '',
320    `Parked item #${item.id} [${item.kind}]: ${item.title}`,
321    item.body,
322  ].join('\n')
323
324// The order the pane lists items in, which Next and Prev walk: open items that
325// need the user (those blocking work first), then open FYIs, then the ten most
326// recently parked done items.
327export const orderOf = (items: readonly ParkedItem[]): ParkedItem[] => {
328  const open = items.filter(one => one.status === 'open')
329
330  return [
331    ...open.filter(one => one.kind === 'needs-you' && one.isBlocking === true),
332    ...open.filter(one => one.kind === 'needs-you' && one.isBlocking !== true),
333    ...open.filter(one => one.kind === 'fyi'),
334    ...items.filter(one => one.status === 'done').slice(-10).reverse(),
335  ]
336}
337
338// A thread as one Markdown text, for the formatted view.
339export const threadText = (item: ParkedItem): string =>
340  (item.thread ?? [])
341    .map(one =>
342      one.role === 'note' ? `*· ${one.text}*` : `**${one.role === 'you' ? 'You' : 'Subagent'}**\n\n${one.text}`,
343    )
344    .join('\n\n')
345
346// A reply's text with spacing and Markdown marks removed, cut short: two
347// routes deliver the same report with different escaping and indentation.
348const gist = (text: string) => text.replace(/[\s\\*`_>#-]/g, '').slice(0, 80)
349
350// Adds a subagent's reply unless the thread already holds it since the user's
351// last message: its turn's end and its hand-back both carry the same report.
352export const sayOnce = (backlog: Backlog, id: number, message: ParkedMessage): Backlog | string => {
353  const thread = backlog.items.find(one => one.id === id)?.thread ?? []
354  const since = thread.slice(thread.findLastIndex(one => one.role === 'you') + 1)
355
356  return since.some(one => one.role === 'agent' && gist(one.text) === gist(message.text))
357    ? backlog
358    : say(backlog, id, message)
359}
360
361export type ParkedAction = { act: 'done' | 'reopen' | 'next' | 'prev' | 'back'; label: string; hot: string }
362
363// The actions an item's row offers, in the order ← and → walk them.
364export const actionsOf = (order: readonly ParkedItem[], item: ParkedItem): ParkedAction[] => {
365  const at = order.findIndex(one => one.id === item.id)
366
367  return [
368    item.status === 'open'
369      ? { act: 'done', label: 'Done', hot: 'd' }
370      : { act: 'reopen', label: 'Reopen', hot: 'r' },
371    ...(at < order.length - 1 ? [{ act: 'next', label: 'Next', hot: 'n' } as const] : []),
372    ...(at > 0 ? [{ act: 'prev', label: 'Prev', hot: 'p' } as const] : []),
373    { act: 'back', label: 'Back', hot: 'b' },
374  ]
375}
376
377export type ThreadLine = { text: string; kind: 'you' | 'dot' | 'text' | 'note' }
378
379// A thread as rows a window can show a slice of. The user's message is shaded
380// rows under a ❯, as a prompt is; a reply opens with a dot and its later
381// rows are indented under it; a note is one dim line. A blank row parts them.
382export const threadLines = (thread: readonly ParkedMessage[], columns: number): ThreadLine[] =>
383  thread.flatMap((one, index): ThreadLine[] => {
384    const gap: ThreadLine[] = index > 0 ? [{ text: '', kind: 'text' }] : []
385
386    if (one.role === 'you') {
387      // As Claude Code draws a prompt: a ❯ on the first row, later rows
388      // indented under it, each row padded so its shading runs the full width.
389      return [
390        ...gap,
391        ...wrap(one.text, Math.max(4, columns - 2)).map(
392          (text, at): ThreadLine => ({ text: `${at === 0 ? '❯' : ' '} ${text}`.padEnd(columns), kind: 'you' }),
393        ),
394      ]
395    }
396
397    if (one.role === 'note') {
398      return [...gap, ...wrap(`· ${one.text}`, columns).map((text): ThreadLine => ({ text, kind: 'note' }))]
399    }
400
401    return [
402      ...gap,
403      ...wrap(one.text, Math.max(4, columns - 2)).map(
404        (text, at): ThreadLine => ({ text: `${at === 0 ? '●' : ' '} ${text}`, kind: at === 0 ? 'dot' : 'text' }),
405      ),
406    ]
407  })
408
409// What the ticket region shows: the choices first, so they are in view however
410// long the body is, then the body, then the places it is about.
411export const ticketText = (item: ParkedItem): string =>
412  [
413    item.options !== undefined
414      ? [
415          `${ICON.options} Options`,
416          ...item.options.map(
417            (label, at) =>
418              `  ${at + 1}. ${label}${item.preferred === at + 1 ? `  ${ICON.preferred} default` : ''}`,
419          ),
420        ].join('\n')
421      : '',
422    item.body,
423    item.refs !== undefined
424      ? [`${ICON.folderOpen} Files`, ...item.refs.map(ref => `  ${fileIcon(ref)} ${ref}`)].join('\n')
425      : '',
426  ]
427    .filter(Boolean)
428    .join('\n\n')
429
430const optionText = (item: ParkedItem, at: number) => `option ${at}, ${item.options?.[at - 1] ?? ''}`
431
432// `text` is the answer as the main agent reads it. A silent answer is not sent
433// at all: the user took the default of an item the agent had gone ahead on.
434export type ParkedAnswer = { text: string; isSilent: boolean }
435
436// The user's answer to an item: an option by its number (a typed number names
437// one too, where the item has options), or their own words. A string answer
438// is the reason it was refused.
439export const answerOf = (item: ParkedItem, reply: number | string): ParkedAnswer | string => {
440  const typed = typeof reply === 'string' ? reply.trim() : ''
441  const at =
442    typeof reply === 'number'
443      ? reply
444      : item.options !== undefined && /^[1-9]$/.test(typed)
445        ? Number(typed)
446        : undefined
447
448  if (at === undefined) {
449    return typed === '' ? `Parked item #${item.id} needs an answer.` : { text: typed, isSilent: false }
450  }
451
452  if (!Number.isInteger(at) || at < 1 || at > (item.options?.length ?? 0)) {
453    return `Parked item #${item.id} has no option ${at}.`
454  }
455
456  return { text: optionText(item, at), isSilent: item.isBlocking !== true && at === item.preferred }
457}
458
459// Marks an item done with the user's answer, to be sent to the main agent
460// unless it is silent.
461export const answer = (
462  backlog: Backlog,
463  id: number,
464  reply: number | string,
465  now: number,
466  sessionId?: string,
467): Backlog | string => {
468  const item = backlog.items.find(one => one.id === id)
469
470  if (item === undefined) {
471    return `No parked item #${id}.`
472  }
473
474  if (item.status === 'done') {
475    return `Parked item #${id} is already done.`
476  }
477
478  const made = answerOf(item, reply)
479
480  if (typeof made === 'string') {
481    return made
482  }
483
484  const line = made.text.replace(/\s+/g, ' ')
485  const resolution = made.isSilent
486    ? `You accepted the default: ${line}`
487    : `You answered: ${line.length > 140 ? `${line.slice(0, 139)}…` : line}`
488
489  return {
490    ...backlog,
491    items: backlog.items.map(one =>
492      one.id === id
493        ? {
494            ...one,
495            status: 'done',
496            resolution,
497            resolvedBy: 'user',
498            resolvedAt: now,
499            answer: made.text,
500            ...(sessionId !== undefined ? { answeredIn: sessionId } : {}),
501            ...(made.isSilent ? {} : { isUnsent: true as const }),
502          }
503        : one,
504    ),
505  }
506}
507
508// The answers the main agent has not been told yet. Given a session, only the
509// ones answered in it: another session's agent has no use for them.
510export const unsentOf = (items: readonly ParkedItem[], sessionId?: string): ParkedItem[] =>
511  items.filter(
512    one =>
513      one.isUnsent === true &&
514      one.answer !== undefined &&
515      (sessionId === undefined || one.answeredIn === sessionId),
516  )
517
518// Puts answers back among the unsent, when sending them failed.
519export const unsend = (backlog: Backlog, ids: readonly number[]): Backlog => ({
520  ...backlog,
521  items: backlog.items.map(one =>
522    ids.includes(one.id) && one.answer !== undefined ? { ...one, isUnsent: true as const } : one,
523  ),
524})
525
526export const sent = (backlog: Backlog, ids: readonly number[]): Backlog => ({
527  ...backlog,
528  items: backlog.items.map(one => {
529    if (!ids.includes(one.id)) {
530      return one
531    }
532
533    const { isUnsent: _u, ...rest } = one
534
535    return rest
536  }),
537})
538
539// The one message that carries the user's answers to the main agent. Where an
540// answer departs from the default the agent went ahead on, it says so.
541export const answersNote = (items: readonly ParkedItem[]): string =>
542  [
543    'My answers to parked items:',
544    ...items.map(one => {
545      const isOverride =
546        one.isBlocking !== true &&
547        one.preferred !== undefined &&
548        one.answer !== optionText(one, one.preferred)
549
550      return `- #${one.id} ${one.title}: ${one.answer ?? ''}${isOverride ? ` (not your default, ${optionText(one, one.preferred ?? 0)}: revise what was built on it)` : ''}`
551    }),
552  ].join('\n')
553
554// The unit findings with no task of the run are reviewed under.
555const NO_UNIT = 'Other changes'
556
557const placeOf = (finding: LedgerFindingSeen) =>
558  finding.line === undefined ? finding.path : `${finding.path}:${finding.line}`
559
560const many = (count: number, one: string) => `${count} ${one}${count === 1 ? '' : 's'}`
561
562// What a unit's review item says of its open findings.
563const reviewOf = (unit: string, open: readonly LedgerFindingSeen[]) => {
564  const errors = open.filter(one => one.severity === 'error').length
565
566  return {
567    kind: (errors > 0 ? 'needs-you' : 'fyi') as ParkedKind,
568    title: `Review of ${unit}: ${many(open.length, 'finding')}${errors > 0 ? ` (${many(errors, 'error')})` : ''}`,
569    body: open
570      .map(one => `- ${one.severity} ${placeOf(one)} ${one.summary}${one.task ? ` (${one.task})` : ''}`)
571      .join('\n'),
572    refs: [...new Set(open.map(placeOf))],
573  }
574}
575
576const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b)
577
578// Brings the backlog in step with the ledger mod's run, where that mod is
579// loaded: a task whose gate fails gets an FYI that closes when it leaves that
580// state, and each unit with open findings gets one item listing them, which
581// closes when all are fixed. Answers the same backlog when nothing changed.
582export const syncLedger = (
583  backlog: Backlog,
584  run: LedgerRunSeen | null | undefined,
585  now: number,
586  sessionId: string,
587): Backlog => {
588  const seen = backlog.seen ?? { gates: [], findings: [] }
589  const tasks = run?.tasks ?? []
590  const findings = run?.findings ?? []
591  let next: Backlog = backlog
592
593  const close = (isOurs: (item: ParkedItem) => boolean, resolution: string) => {
594    if (next.items.some(one => one.status === 'open' && isOurs(one))) {
595      next = {
596        ...next,
597        items: next.items.map(one =>
598          one.status === 'open' && isOurs(one)
599            ? { ...one, status: 'done', resolution, resolvedBy: 'model', resolvedAt: now }
600            : one,
601        ),
602      }
603    }
604  }
605
606  // Gates: an item as a task's gate starts failing, closed as it stops.
607  const failed = tasks.filter(one => one.status === 'gate-failed')
608
609  for (const task of failed.filter(one => !seen.gates.includes(one.id))) {
610    next = park(next, {
611      kind: 'fyi',
612      title: `Gate failed: ${task.id} ${task.title}`,
613      body: `${task.note ?? 'The gate failed; no note was given.'}\n\nThe agent is expected to fix this and run the gate again. This item closes by itself when it does.`,
614      task: task.id,
615      origin: { kind: 'gate', task: task.id },
616      parkedBy: 'model',
617      sessionId,
618      now,
619    })
620  }
621
622  for (const id of seen.gates.filter(one => !failed.some(task => task.id === one))) {
623    const status = tasks.find(one => one.id === id)?.status
624
625    close(
626      item => item.origin?.kind === 'gate' && item.origin.task === id,
627      status === 'done'
628        ? 'The gate passed.'
629        : status === undefined
630          ? 'The run was cleared or planned again.'
631          : 'The task is being reworked.',
632    )
633  }
634
635  // Reviews: one item per unit that has open findings.
636  const unitOf = (finding: LedgerFindingSeen) =>
637    tasks.find(one => one.id === finding.task)?.unit ?? NO_UNIT
638  const open = findings.filter(one => one.status === 'open')
639  const units = new Set([
640    ...open.map(unitOf),
641    ...next.items.flatMap(one =>
642      one.status === 'open' && one.origin?.kind === 'review' ? [one.origin.unit] : [],
643    ),
644  ])
645
646  for (const unit of units) {
647    const mine = open.filter(one => unitOf(one) === unit)
648    const item = next.items.find(
649      one => one.status === 'open' && one.origin?.kind === 'review' && one.origin.unit === unit,
650    )
651
652    if (mine.length === 0) {
653      close(
654        one => one.origin?.kind === 'review' && one.origin.unit === unit,
655        run ? 'Every finding is fixed.' : 'The run was cleared.',
656      )
657    } else if (item !== undefined) {
658      const { refs, ...said } = reviewOf(unit, mine)
659      const updated: ParkedItem = { ...item, ...said, refs }
660
661      if (!same(updated, item)) {
662        next = { ...next, items: next.items.map(one => (one.id === item.id ? updated : one)) }
663      }
664    } else if (mine.some(one => !seen.findings.includes(one.id))) {
665      next = park(next, {
666        ...reviewOf(unit, mine),
667        origin: { kind: 'review', unit },
668        parkedBy: 'model',
669        sessionId,
670        now,
671      })
672    }
673  }
674
675  const after: LedgerSeen = {
676    gates: failed.map(one => one.id),
677    findings: run ? [...new Set([...seen.findings, ...findings.map(one => one.id)])] : [],
678  }
679
680  return same(after, seen) && next === backlog ? backlog : { ...next, seen: after }
681}
682
683export type TicketLine = { text: string; isNote: boolean; color?: string }
684
685// The ticket as rows a window can show a slice of. What the user parked is in
686// labelled sections: "Your note", shaded under a ❯ as a prompt is, then
687// "Assistant reply" under a dot, as the assistant's replies are drawn. What
688// the agent parked is its own description and is drawn plain, with no label.
689export const ticketLines = (item: ParkedItem, columns: number): TicketLine[] => {
690  const note = (item.note ?? '').trim()
691  const body = ticketText(item)
692  const inner = Math.max(4, columns - 2)
693  const isReply = item.parkedBy === 'user'
694  const noteRows: TicketLine[] =
695    note === ''
696      ? []
697      : [
698          { text: 'Your note:', isNote: false, color: 'warning' },
699          ...wrap(note, inner).map((text, at) => ({
700            text: `${at === 0 ? '❯' : ' '} ${text}`.padEnd(columns),
701            isNote: true,
702          })),
703        ]
704  const bodyRows: TicketLine[] =
705    body === ''
706      ? []
707      : isReply
708        ? [
709            { text: 'Assistant reply:', isNote: false, color: 'cyan' },
710            ...wrap(body, inner).map((text, at) => ({ text: `${at === 0 ? '●' : ' '} ${text}`, isNote: false })),
711          ]
712        : wrap(body, columns).map(text => ({ text, isNote: false }))
713
714  return [
715    ...noteRows,
716    ...(noteRows.length > 0 && bodyRows.length > 0 ? [{ text: '', isNote: false }] : []),
717    ...bodyRows,
718  ]
719}
720
721// Every run the ledger keeps, as the one view `syncLedger` reads: the tasks of
722// the runs under way, and the findings of all. A task id names one task among
723// the runs under way and a finding's number one finding, so nothing clashes.
724// With no run at all there is nothing: undefined when the ledger is not there.
725export const mergedRuns = (
726  runs: readonly LedgerRunSeen[] | null | undefined,
727): LedgerRunSeen | null | undefined =>
728  runs === null || runs === undefined
729    ? undefined
730    : runs.length === 0
731      ? null
732      : {
733          goal: '',
734          tasks: runs.filter(one => one.doneAt === undefined).flatMap(one => one.tasks),
735          findings: runs.flatMap(one => one.findings),
736        }
737
738// A file reference as a place to show: its path, and its line (0 when the
739// reference names the file alone). A column after the line is left out.
740export const placeOfRef = (ref: string): { path: string; line: number } => {
741  const at = /^(.*?):(\d+)(?::\d+)?$/.exec(ref.trim())
742
743  return at === null ? { path: ref.trim(), line: 0 } : { path: at[1] ?? '', line: Number(at[2]) }
744}
745
hooks/detail.tsx 528 lines
1import type { ClientModule, ClientSurface } from 'claude-code'
2
3type Line = { text: string; kind: 'you' | 'dot' | 'text' | 'note' }
4type Action = { act: string; label: string; hot: string }
5
6// One row of the list: a section heading, or an item.
7type Entry = {
8  kind: 'head' | 'row'
9  id: number
10  glyph: string
11  tone: string
12  text: string
13  meta: string
14  isDone: boolean
15}
16
17type ListProps = {
18  view: 'list'
19  columns: number
20  rows: number
21  summary: string
22  entries: Entry[]
23  nudge: Nudge
24}
25
26type DetailProps = {
27  view: 'detail'
28  id: number
29  columns: number
30  label: string
31  tone: string
32  position: string
33  title: string
34  meta: string
35  actions: Action[]
36  // Whether the thread has findings the Send row can pass to the main agent.
37  canSend: boolean
38  // How many options the item offers: a digit up to it answers with that one.
39  options: number
40  // Whether the composer sends the user's answer to the main agent.
41  isAnswering: boolean
42  note: string
43  resolution: string
44  // The ticket's rows, how many its window shows, and how far it is scrolled.
45  // Each row is the pieces it is drawn in, a glyph's piece with its colour.
46  ticket: { text: string; color?: string; shade?: boolean }[][]
47  ticketRows: number
48  top: number
49  // The thread's rows, how many its window shows, and how far the pane's own
50  // scroll (the wheel) has moved it up from its newest row.
51  talk: Line[]
52  threadRows: number
53  back: number
54  heading: string
55  // What the thread is waiting on, shown beside a spinner; empty when idle.
56  busy: string
57  // Whether the pane's text field has the focus ring: the composer level.
58  isTyping: boolean
59  // Counts the times the ring has entered the text field.
60  typingAt: number
61  // The text the composer holds that was typed through this region.
62  draft: string
63  nudge: Nudge
64}
65
66type Props = ListProps | DetailProps
67
68// The levels of an item's view the keyboard moves between with ↑ and ↓.
69const TICKETS = 0
70const ACTIONS = 1
71const COMPOSER = 2
72
73// `id` is the item last shown in full (0 for none), and `cursor` the list row
74// the keyboard is on. One instance draws both views, so the keyboard stays
75// with the pane when it moves between the list and an item.
76type State = {
77  id: number
78  tier: number
79  pick: number
80
81  up: number
82  cursor: number
83  frame: number
84  // The visit to the composer this region has ended by taking the keys back.
85  left: number
86  // What this region has typed for the composer, ahead of what props hold;
87  // null when it has typed nothing.
88  typed: string | null
89}
90
91const START: State = { id: 0, tier: TICKETS, pick: 0, up: 0, cursor: 0, frame: 0, left: -1, typed: null }
92
93const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
94// Stops the spinner's timer while one runs; the module keeps it between calls.
95let stopSpin: (() => void) | undefined
96
97const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value))
98
99// The vim keys, as the arrows and Enter they stand for.
100const VIM: Record<string, string> = { h: 'left', j: 'down', k: 'up', l: 'right', o: 'return' }
101
102// A key pressed on one of the pane's own letter buttons, which need no click:
103// the hooks module counts them, and a new count is a key to answer.
104type Nudge = { key: string; n: number }
105type KeyInfo = { ctrl?: true; meta?: true; isNudge?: true }
106
107// The last nudge answered (-1 before the first drawing), and the last ↑ or ↓
108// that came as a real key: the pane may report the same press again as a
109// scroll, which the hooks module turns into a nudge.
110let seen = -1
111let lastArrow = { key: '', at: 0 }
112
113// Hands a view's key handler both routes: the region's own keys, once it is
114// clicked, and the nudges from the pane's letter buttons.
115const listen = (surface: ClientSurface<State>, nudge: Nudge, onKey: (key: string, info?: KeyInfo) => void) => {
116  surface.onKey(event => {
117    if (event.key === 'up' || event.key === 'down') {
118      lastArrow = { key: event.key, at: Date.now() }
119    }
120
121    onKey(event.key, event)
122  })
123
124  if (seen === -1) {
125    seen = nudge.n
126  } else if (nudge.n !== seen) {
127    seen = nudge.n
128
129    if (!(nudge.key === lastArrow.key && Date.now() - lastArrow.at < 80)) {
130      onKey(nudge.key, { isNudge: true })
131    }
132  }
133}
134
135// The backlog as a list the keyboard walks: ↑ and ↓ move, Enter or → opens.
136const List = (props: ListProps, surface: ClientSurface<State>) => {
137  const { Box, Text } = surface.elements
138  const state = surface.state ?? START
139  const rows = props.entries.filter(entry => entry.kind === 'row')
140  const last = Math.max(0, rows.length - 1)
141  // Coming back from an item, the cursor is on that item's row.
142  const came = state.id === 0 ? -1 : rows.findIndex(entry => entry.id === state.id)
143  const cursor = clamp(came >= 0 ? came : state.cursor, 0, last)
144  const move = (to: number) => surface.setState({ ...state, id: 0, cursor: clamp(to, 0, last) })
145  const open = (at: number) => {
146    const entry = rows[at]
147
148    if (entry !== undefined) {
149      // An item opened from the list starts on its tickets level.
150      surface.setState({ ...state, id: 0, cursor: at, tier: TICKETS })
151      surface.post({ open: entry.id })
152    }
153  }
154
155  // The window of entries that fits, kept around the cursor's row.
156  const height = Math.max(3, props.rows - 4)
157  const here = rows[cursor]
158  const cursorAt = here === undefined ? 0 : props.entries.indexOf(here)
159  const start = clamp(cursorAt - Math.floor(height / 2), 0, Math.max(0, props.entries.length - height))
160  const shown = props.entries.slice(start, start + height)
161
162  listen(surface, props.nudge, raw => {
163    const key = VIM[raw] ?? raw
164
165    if (key === 'up') {
166      move(cursor - 1)
167    } else if (key === 'down' || key === 'tab') {
168      move(cursor + 1)
169    } else if (key === 'pageup') {
170      move(cursor - height)
171    } else if (key === 'pagedown') {
172      move(cursor + height)
173    } else if (key === 'return' || key === 'right') {
174      open(cursor)
175    }
176  })
177
178  surface.onPointer(event => {
179    // Two rows sit above the entries: the summary and a rule.
180    const entry = event.type === 'down' ? shown[event.y - 2] : undefined
181
182    if (entry !== undefined && entry.kind === 'row') {
183      open(rows.indexOf(entry))
184    }
185  })
186
187  return (
188    <Box flexDirection="column">
189      <Text bold wrap="truncate-end">
190        📌 Parked{props.summary === '' ? '' : ` · ${props.summary}`}
191      </Text>
192      <Text dimColor>{'─'.repeat(props.columns)}</Text>
193      <Box flexDirection="column" height={height} overflow="hidden">
194        {rows.length === 0 && <Text dimColor>Nothing parked yet. /park [note] parks the last reply.</Text>}
195        {shown.map(entry => {
196          if (entry.kind === 'head') {
197            return (
198              <Text color={entry.tone} bold>
199                {entry.text}
200              </Text>
201            )
202          }
203
204          const at = rows.indexOf(entry)
205          const isHere = at === cursor
206
207          return (
208            <Box justifyContent="space-between">
209              <Text wrap="truncate-end">
210                <Text color="cyan">{isHere ? '▸ ' : '  '}</Text>
211                <Text color={entry.tone}>{entry.glyph} </Text>
212                <Text dimColor>#{entry.id} </Text>
213                <Text inverse={isHere} dimColor={entry.isDone && !isHere}>
214                  {entry.text}
215                </Text>
216              </Text>
217              <Text dimColor>{entry.meta}</Text>
218            </Box>
219          )
220        })}
221      </Box>
222      <Text dimColor>{'─'.repeat(props.columns)}</Text>
223      <Text dimColor wrap="truncate-end">
224        click once for keys · ↑↓ or j k move · enter, → or l opens · esc releases
225      </Text>
226    </Box>
227  )
228}
229
230// One parked item's header, actions, ticket and side thread. ↑ and ↓ move
231// between the tickets, the actions and the composer; ← and → act on the level
232// in focus. The composer itself is the pane's own text field beneath this
233// region: reaching its level asks the hooks module to give it the focus ring,
234// so typing needs no click. What changes the backlog is posted there too.
235const Detail = (props: DetailProps, surface: ClientSurface<State>) => {
236  const { Box, Text } = surface.elements
237  const kept = surface.state
238  // Another item starts afresh, but stays on the level the keyboard was at.
239  const state: State =
240    kept !== undefined && kept.id === props.id
241      ? kept
242      : { ...START, id: props.id, tier: kept?.tier ?? TICKETS, cursor: kept?.cursor ?? 0 }
243  const set = (patch: Partial<State>) => surface.setState({ ...state, ...patch })
244  // The composer level is on while the pane's text field has the focus ring.
245  // A key or a click that reaches this region means the region has the keys
246  // instead, whatever the ring last reported: that visit to the composer is
247  // then over, and `left` remembers which one it was.
248  const isTyping = props.isTyping && state.left !== props.typingAt
249  const tier = isTyping ? COMPOSER : state.tier === COMPOSER ? ACTIONS : state.tier
250  const leave = isTyping ? { left: props.typingAt } : {}
251  const toComposer = () => surface.post({ focus: 'composer' })
252  const pick = clamp(state.pick, 0, props.actions.length - 1)
253  const mostBack = Math.max(0, props.talk.length - props.threadRows)
254  const backAt = clamp(props.back + state.up, 0, mostBack)
255  const scroll = (by: number) => set({ up: clamp(backAt + by, 0, mostBack) - props.back })
256
257  // The spinner turns on the surface's frame clock while the thread waits. The
258  // timer reads the state as it is when it fires, not as it was when it began.
259  if (props.busy !== '' && stopSpin === undefined) {
260    stopSpin = surface.every(120, () => {
261      const now = surface.state ?? state
262      surface.setState({ ...now, frame: now.frame + 1 })
263    })
264  } else if (props.busy === '' && stopSpin !== undefined) {
265    stopSpin()
266    stopSpin = undefined
267  }
268
269  const run = (act: string | undefined) => {
270    if (act !== undefined && (act !== 'send' || props.canSend)) {
271      surface.post({ act })
272    }
273  }
274
275  // Where each action sits on its row, for a click to find it.
276  let column = 2
277  const spans = props.actions.map(action => {
278    const from = column
279    column += action.label.length + 3
280
281    return { action, from, to: column - 2 }
282  })
283  const actionRow = 3
284
285  // Both listeners are set again on each call, so they read this call's state.
286  listen(surface, props.nudge, (raw, info = {}) => {
287    // On the composer level every key is for the composer, never a shortcut.
288    // A key that arrives here means this region has the keyboard and the text
289    // field does not, so the region does the typing itself: it keeps the text
290    // and posts it, and the hooks module draws it in the field. Only ↑ and ↓
291    // leave the level.
292    if (isTyping) {
293      const text = state.typed ?? props.draft
294
295      if (raw === 'up' || raw === 'down') {
296        set({ ...leave, tier: raw === 'up' ? ACTIONS : TICKETS, typed: null })
297        surface.post({ blur: true })
298      } else if (info.isNudge === true) {
299        // A letter button pressed while typing is answered by the hooks module.
300      } else if (raw === 'return') {
301        if (text.trim() !== '') {
302          surface.post({ ask: text.trim() })
303          set({ typed: '', up: -props.back })
304        }
305      } else if (raw === 'backspace' || raw === 'delete') {
306        const next = [...text].slice(0, -1).join('')
307        set({ typed: next })
308        surface.post({ draft: next })
309      } else if (raw === 'space' || (info.ctrl !== true && info.meta !== true && [...raw].length === 1)) {
310        const next = `${text}${raw === 'space' ? ' ' : raw}`
311        set({ typed: next })
312        surface.post({ draft: next })
313      }
314
315      return
316    }
317
318    // h j k l o are ← ↓ ↑ → Enter.
319    const key = VIM[raw] ?? raw
320    const page = Math.max(1, props.threadRows - 1)
321
322    if (key === 'pageup') {
323      scroll(page)
324    } else if (key === 'pagedown') {
325      scroll(-page)
326    } else if (key === 'i') {
327      toComposer()
328    } else if (key === 'up') {
329      // Up from the tickets wraps round to the composer.
330      if (tier === TICKETS) {
331        toComposer()
332      } else {
333        set({ tier: tier === COMPOSER ? ACTIONS : TICKETS })
334      }
335    } else if (key === 'down' || key === 'tab') {
336      if (tier === TICKETS) {
337        set({ tier: ACTIONS })
338      } else if (tier === ACTIONS) {
339        toComposer()
340      } else {
341        set({ tier: TICKETS })
342      }
343    } else if (tier === TICKETS && (key === 'left' || key === 'right')) {
344      run(key === 'left' ? 'prev' : 'next')
345    } else if (tier === ACTIONS && key === 'left') {
346      set({ pick: clamp(pick - 1, 0, props.actions.length - 1) })
347    } else if (tier === ACTIONS && key === 'right') {
348      set({ pick: clamp(pick + 1, 0, props.actions.length - 1) })
349    } else if (tier === ACTIONS && key === 'return') {
350      run(props.actions[pick]?.act)
351    } else if (key === 's') {
352      run('send')
353    } else if (/^[1-9]$/.test(key)) {
354      if (Number(key) <= props.options) {
355        run(`pick-${key}`)
356      }
357    } else {
358      run(props.actions.find(action => action.hot === key)?.act)
359    }
360  })
361
362  surface.onPointer(event => {
363    if (event.type !== 'down') {
364      return
365    }
366
367    if (event.y === actionRow) {
368      const hit = spans.find(span => event.x >= span.from && event.x <= span.to)
369
370      if (hit === undefined) {
371        set({ ...leave, tier: ACTIONS })
372      } else {
373        set({ ...leave, tier: ACTIONS, pick: props.actions.indexOf(hit.action) })
374        run(hit.action.act)
375
376        return
377      }
378    } else if (event.y < actionRow) {
379      set({ ...leave, tier: TICKETS })
380    } else if (isTyping) {
381      set({ ...leave, tier: ACTIONS })
382    }
383
384    // A click here took the keys from the composer.
385    if (isTyping) {
386      surface.post({ blur: true })
387    }
388  })
389
390  const mark = (level: number) => (tier === level ? '▸ ' : '  ')
391  const more = (above: number, below: number) =>
392    [above > 0 ? `↑${above}` : '', below > 0 ? `↓${below}` : ''].filter(Boolean).join(' ')
393  const topAt = clamp(props.top, 0, Math.max(0, props.ticket.length - props.ticketRows))
394  const end = props.talk.length - backAt
395  const shown = props.talk.slice(Math.max(0, end - props.threadRows), end)
396  // With options, what answers them leads the hint: the row is cut at the edge.
397  const picks = props.options > 0 ? `1-${props.options} answer · ` : ''
398  const hint =
399    tier === TICKETS
400      ? `${picks}←→ or h l switch ticket · ↓ or j actions · i composer`
401      : tier === ACTIONS
402        ? `${picks}←→ or h l choose · enter or o runs · ↑↓ or k j levels`
403        : props.isAnswering
404          ? 'answering the main agent · enter sends · ↑ leaves it'
405          : 'typing in the composer · enter sends · ↑ leaves it'
406
407  return (
408    <Box flexDirection="column">
409      <Box justifyContent="space-between">
410        <Text>
411          <Text color="cyan">{mark(TICKETS)}</Text>
412          <Text color={props.tone} bold>
413            {props.label}
414          </Text>
415        </Text>
416        <Text dimColor>
417          {tier === TICKETS ? '← ' : ''}
418          {props.position}
419          {tier === TICKETS ? ' →' : ''}
420        </Text>
421      </Box>
422      <Text bold wrap="truncate-end">
423        {'  '}
424        {props.title}
425      </Text>
426      <Text dimColor wrap="truncate-end">
427        {'  '}
428        {props.meta}
429      </Text>
430      <Text wrap="truncate-end">
431        <Text color="cyan">{mark(ACTIONS)}</Text>
432        {spans.map(({ action }, index) => (
433          <Text>
434            <Text inverse={tier === ACTIONS && index === pick}>[{action.label}]</Text>{' '}
435          </Text>
436        ))}
437      </Text>
438      <Box justifyContent="space-between">
439        <Text dimColor>── Ticket</Text>
440        <Text dimColor>{more(topAt, props.ticket.length - props.ticketRows - topAt)}</Text>
441      </Box>
442      {props.note !== '' && (
443        <Text wrap="truncate-end">
444          <Text color="warning">Your note: </Text>
445          {props.note}
446        </Text>
447      )}
448      {props.resolution !== '' && (
449        <Text wrap="truncate-end">
450          <Text color="success">Resolved: </Text>
451          {props.resolution}
452        </Text>
453      )}
454      <Box flexDirection="column" height={props.ticketRows} overflow="hidden">
455        {props.ticket.slice(topAt, topAt + props.ticketRows).map(parts =>
456          // A row of the user's own note: shaded under a ❯, as a prompt is.
457          parts[0]?.shade === true ? (
458            <Text backgroundColor="userMessageBackground">
459              <Text dimColor>{parts[0].text.slice(0, 2)}</Text>
460              {parts[0].text.slice(2)}
461            </Text>
462          ) : (
463            <Text wrap="truncate-end" bold={parts[0]?.text.startsWith('#') === true}>
464              {parts.length === 0
465                ? ' '
466                : parts.map(part =>
467                    part.color === undefined ? <Text>{part.text}</Text> : <Text color={part.color}>{part.text}</Text>,
468                  )}
469            </Text>
470          ),
471        )}
472      </Box>
473      <Text dimColor>{'─'.repeat(props.columns)}</Text>
474      <Box justifyContent="space-between">
475        <Text>
476          <Text bold>{props.heading}</Text>
477          {props.busy !== '' && (
478            <Text color="cyan">
479              {' '}
480              {[...SPIN][state.frame % 10]} {props.busy}
481            </Text>
482          )}
483        </Text>
484        <Text dimColor>{more(Math.max(0, end - props.threadRows), backAt)}</Text>
485      </Box>
486      <Box flexDirection="column" height={props.threadRows} overflow="hidden">
487        {props.talk.length === 0 && (
488          <Text dimColor wrap="wrap">
489            Ask a subagent to look into this. The main agent does not see this thread.
490          </Text>
491        )}
492        {shown.map(line =>
493          line.kind === 'you' ? (
494            <Text backgroundColor="userMessageBackground">
495              <Text dimColor>{line.text.slice(0, 2)}</Text>
496              {line.text.slice(2)}
497            </Text>
498          ) : (
499            <Text
500              wrap="truncate-end"
501              bold={line.text.startsWith('#')}
502              dimColor={line.kind === 'note'}
503            >
504              {line.text === '' ? ' ' : line.text}
505            </Text>
506          ),
507        )}
508      </Box>
509      <Text dimColor wrap="truncate-end">
510        {hint}
511      </Text>
512    </Box>
513  )
514}
515
516const Pane: ClientModule<Props, State> = (props, surface) => {
517  // A new instance has no state yet: a timer left by an earlier one died with
518  // it. The list shows no spinner, so it runs none either.
519  if (stopSpin !== undefined && (surface.state === undefined || props.view === 'list')) {
520    stopSpin()
521    stopSpin = undefined
522  }
523
524  return props.view === 'list' ? List(props, surface) : Detail(props, surface)
525}
526
527export default Pane
528
hooks/kit/icons.ts 60 lines
1// Shared by the lens, parked and ledger mods. The source is the mod-kit folder;
2// each mod carries a copy under hooks/kit, written there by sync.sh. Edit the
3// source and sync, never a copy.
4
5export type Icon = { glyph: string; color: string }
6
7// Nerd Font glyphs, as a terminal file tree draws them, in each language's
8// usual colour. They need a Nerd Font, or a terminal that ships the symbols.
9export const FILE_ICONS: readonly (readonly [pattern: RegExp, glyph: string, color: string])[] = [
10  [/\.pyi?$/, '\u{e73c}', '#ffd43b'],
11  [/\.[cm]?[tj]sx$/, '\u{e7ba}', '#20c2e3'],
12  [/\.[cm]?ts$/, '\u{e628}', '#519aba'],
13  [/\.[cm]?js$/, '\u{e74e}', '#cbcb41'],
14  [/\.json$/, '\u{e60b}', '#cbcb41'],
15  [/\.(tf|tfvars)$/, '\u{e69a}', '#7b42bc'],
16  [/\.(ya?ml|toml|ini|cfg|env)$/, '\u{e615}', '#6d8086'],
17  [/\.(md|mdx)$/, '\u{e73e}', '#dddddd'],
18  [/\.(sh|bash|zsh)$/, '\u{e795}', '#4d5a5e'],
19  [/\.(css|scss|less)$/, '\u{e749}', '#42a5f5'],
20  [/\.html?$/, '\u{e736}', '#e44d26'],
21  [/\.sql$/, '\u{e706}', '#dad8d8'],
22  [/\.(png|jpe?g|gif|svg|webp|ico)$/, '\u{f1c5}', '#a074c4'],
23  [/(^|\/)Dockerfile$/, '\u{f308}', '#458ee6'],
24  [/(^|\/)\.git(ignore|attributes)$/, '\u{e702}', '#f54d27'],
25  [/\.lock$/, '\u{f023}', '#bbbbbb'],
26]
27
28// The glyphs the mods draw for their own things, one name each.
29export const ICON = {
30  file: '\u{f15b}',
31  folder: '\u{f07b}',
32  folderOpen: '\u{f07c}',
33  needs: '\u{f059}',
34  blocking: '\u{f071}',
35  fyi: '\u{f05a}',
36  done: '\u{f058}',
37  failed: '\u{f057}',
38  running: '\u{f110}',
39  todo: '\u{f10c}',
40  thread: '\u{f075}',
41  options: '\u{f0cb}',
42  preferred: '\u{f005}',
43  tasks: '\u{f0ae}',
44  agents: '\u{f085}',
45  finding: '\u{f188}',
46} as const
47
48export const FILE_COLOR = '#6d8086'
49export const FOLDER_COLOR = '#dcb67a'
50export const STAR_COLOR = '#ffd43b'
51
52export const iconOf = (path: string): Icon => {
53  const hit = FILE_ICONS.find(([pattern]) => pattern.test(path))
54
55  return hit === undefined ? { glyph: ICON.file, color: FILE_COLOR } : { glyph: hit[1], color: hit[2] }
56}
57
58// The icon of a ref's file type; a ref is a path, or path:line.
59export const refIcon = (ref: string): Icon => iconOf(ref.replace(/:\d+(?::\d+)?$/, ''))
60
types/index.d.ts 66 lines
1export type ParkedKind = 'needs-you' | 'fyi'
2
3// 'note' is a line the mod itself adds to a thread, such as a briefing's record.
4export type ParkedMessage = { role: 'you' | 'agent' | 'note'; text: string; at: number }
5
6export type ParkedItem = {
7  id: number
8  kind: ParkedKind
9  title: string
10  body: string
11  note?: string
12  parkedBy: 'model' | 'user'
13  sessionId: string
14  createdAt: number
15  status: 'open' | 'done'
16  resolution?: string
17  resolvedBy?: 'model' | 'user'
18  resolvedAt?: number
19  // The choices a decision offers, and the one (counted from 1) the agent
20  // would pick: it proceeds on that one unless the item is blocking.
21  options?: string[]
22  preferred?: number
23  // Set when no remaining work can go on until the user answers.
24  isBlocking?: true
25  // The places the item is about, each a path or path:line.
26  refs?: string[]
27  // The ledger task the item belongs to, where the ledger mod has a run.
28  task?: string
29  // Set on an item parked from the ledger's run, not by the agent or the user:
30  // a task's failed gate, or the open findings of a unit's review.
31  origin?: { kind: 'gate'; task: string } | { kind: 'review'; unit: string }
32  // What the user answered, and whether the main agent is still to be told.
33  answer?: string
34  // The session the user answered in: its agent is the one to tell.
35  answeredIn?: string
36  isUnsent?: true
37  // The side conversation held in the pane; the main agent never reads it.
38  thread?: ParkedMessage[]
39  // The subagent answering the thread, while this session still has it.
40  agentId?: string
41}
42
43// A place parked asks to be shown, which the lens mod opens where it is loaded:
44// a path from the session's folder (or absolute), a line (0 for the file as a
45// whole), and a count of the asks, so the same place asked twice is two asks.
46export type ParkedJump = { path: string; line: number; n: number }
47
48declare module 'claude-code' {
49  interface PluginState {
50    parked: {
51      jump: ParkedJump | null
52      nudge: { key: string; n: number }
53      typing: boolean
54      typingAt: number
55      draft: string
56      answering: boolean
57      items: ParkedItem[]
58      selected: number
59      pending: number[]
60      back: number
61      top: number
62      lab: boolean
63    }
64  }
65}
66