SLOPSHOPPER

workflow-gates

Draws the agentic-workflows engine's gates in the band above the prompt — rows a click puts in the prompt and a second click sends — instead of a menu the…

newbandguardtoastprompttimer
★ 3v0.1.0MITupdated 2026-10-08leeovery/portal/.claude/skills/workflow-gates
A shopper browsing a rack in a slop shop
README

workflow-gates

A Claude Code mod that draws the workflow engine's gates in the band above the prompt instead of leaving the model to reproduce them.

The engine states each gate as data beside the menu it composed. This mod announces itself at the session's start so the engine collects that data, arms the gate off the Bash result that carried it, cuts the menu out of what the model reads, and draws the rows where they stay put while the transcript scrolls; while any screen but the terminal or the Desktop app is attached beside it, it leaves the menu as text so every screen shows it, though a screen that attaches after a menu was drawn does not get that menu. No gate's prose names the mod; only workflow-start's setup step does, with a notice when the mod could run in Claude Code's terminal app or the Desktop app's Code tab but is not running.

A click on a row puts its answer in the prompt box; a second click on it sends it as the next message, which the workflows read as the answer. Once a click has given the band the keyboard, the arrows move between rows, Enter or a row's own key picks, and Enter on the picked row sends. A sent answer enters under the plugin's name, framed for the model and labelled in the transcript as the plugin's; the mod leaves what it sent in the conversation's own folder (see below) as sent.json. A second plugin, workflow-gates-rows (../workflow-gates-rows/), reads that record to draw the row as the question and the answer, since no plugin can redraw the row of a prompt it submitted. Typing answers too: a key and Enter at the prompt, or Esc then Enter after a pick. Rows only typing can answer — Ask, Comment, a range — draw dim, and a click on one says to type it in the prompt. The footer under the rows says which: how to answer, what is in the prompt, or where to type.

The band is never taller than the rows Claude Code gives it, so it never scrolls. A gate that fits shows whole: a rule, the statement and the question, the rows, the footer. One that does not keeps its rule, question and footer in place and shows its rows a page at a time, every page the same height, over a line reading ↑ previous ↓ next page 1 of 3; a click on either turns the page and puts the cursor on its first row. The arrows carry the cursor across pages, the page following it, and a row's own key picks that row whichever page it is on.

A turn the person did not start — a background agent's report, a notification, a schedule — leaves the band as it is, its rows live. The band comes off when the person starts a turn or replies into a running one, or when a turn draws a different gate over it. Esc on such a turn leaves the band as it is, unless the turn had already rendered a different gate: the model now waits at that gate's stop, so the band empties. A second click while Claude works holds the answer instead of sending it: its row reads · queued, and the footer says it sends when Claude finishes. A click on the queued row takes it back to a pick, and a click on another row picks that one instead. As the turn ends, the held answer sends if the same gate is still on the band; if the turn rendered a different gate, the answer is dropped unsent, and the new gate's footer says so; if Esc stopped the turn with the same gate still up, the answer goes back into the prompt box as a pick. When a pick's gate goes, its answer leaves the prompt box too, unless the person has edited it there. Typing while Claude works joins Claude Code's own queue.

Esc on a turn an answer started or joined puts its gate back, dropping whatever that turn drew, as long as no tool has run in it; once one has, the band stays empty, since the mod cannot tell a read from a write. A /clear takes the gate off the band.

At the end of every turn, and as the conversation ends, the mod keeps what the band shows — the gate, or nothing — in the conversation's own folder, ~/.config/workflows/conversations/{session-id}/gate.json (under WORKFLOWS_CONFIG_DIR where that is set, beside the workflows' system config), found by the session id wherever the session's working directory has moved, and stamped with where the transcript ends, not counting the lines Claude Code writes around an interrupted turn. A conversation resumed with claude --resume or /resume, or picked up again by a restart or a reload of the mod's files, gets its gate back as long as its transcript still ends there, with nothing picked or held — a held answer waits on a turn that does not come back; one that moved on while the mod was not loaded gets nothing. A session the mod was loaded into after it started carries no announcement, so there it keeps and reads back nothing, and a conversation that does not run the workflows has no folder and keeps nothing — nor does one in a process that names neither a home directory nor WORKFLOWS_CONFIG_DIR. The folder holds one gate, and goes once Claude Code has deleted the conversation's transcript, so a kept gate lives as long as its conversation can be resumed, with one exception: a conversation whose end the session-end hook never saw keeps its folder — the session that first installed the hook, which Claude Code picks up only as a session starts, or one that crashed.

The engine emits the menu regardless, so where the mod is off or absent the model reads the text menu the engine wrote.

The mod is part of the workflows, and it runs in Claude Code's terminal app and the Desktop app's Code tab, from 2.1.287, with this directory installed in the project; mods are on by default there, so nothing switches it on. Claude Code loads a plugin as a session starts, so a session already open when the mod was installed or updated loads it in the next one. The mod is the workflows' upgrade layer and the engine's text menus their floor: a /workflow-start that finds the mod not running — a session started before the mod was installed or updated, mods turned off by --safe-mode, --bare, "disableAllHooks": true or an organization's policy, or installed mods switched off remotely by Anthropic — says so and carries on with typed menus; one that runs on a Claude Code older than 2.1.287 stops and says why. Anywhere else — the web, the VS Code extension, a Desktop session that runs in the cloud, another entrypoint, a project without this directory — the workflows carry on with the text menus and say nothing.

The Desktop app runs the session as an SDK host: the session starts drawing nowhere and the app attaches after, so the band reads the screens attached at each Bash call that states a gate, and the person's own message there arrives as the host's (an sdk origin), which the band reads as theirs.

Claude Code can load the mod where it does not run — another entrypoint, or an older Claude Code with function hooks switched on — so at the session's start the mod applies the boot's rules: where CLAUDE_CODE_ENTRYPOINT is none of cli, claude-desktop and claude-desktop-3p, CLAUDE_CODE_REMOTE is set, or the version the session reports is older than 2.1.287 or not a release's (a development build among them), it announces nothing, so it draws, keeps and sets nothing — the menus stay text, and Claude Code runs as it would without it.

Handoffs

The mod also carries the workflows' handoffs: every move into work, which the engine names with engine handoff, starts in a cleared conversation. At the session's start, where it announces the gate surface, the mod announces that it carries handoffs too (WORKFLOWS_HANDOFF=1, a variable of its own, which the engine reads apart from WORKFLOWS_GATE_SURFACE). The engine then answers a handoff with a HANDOFF payload beside the line naming where the work goes: the continuation to send (`Invoke /<skill> <args>.) and that line. The payload on a Bash result of the conversation's own call — never a subagent's — arms the handoff, and every call has it cut from what the model reads; the line and the skill stay. As the turn ends the mod clears the conversation and, in the conversation that follows, leaves what it sends with the line as sent.json in that conversation's folder, then sends the continuation, which Claude acts on there, and shows a toast naming where the work went (Handed off → Planning · auth-flow). The clear runs from a timer started at the turn's end, whatever else there fails: a mod cannot run a command inside a hook the turn waits on. A send that is dropped or fails goes into the prompt box for Enter instead; where the box will not take it either, the toast holds the continuation for the person to send, and stays longer. A clear that fails sends in place, so the work still goes on. Esc on the turn that handed off carries nothing: Esc means stop. workflow-gates-rows` draws the continuation's transcript row as the line. Where the mod does not announce, the engine says so and the workflows invoke the skill in the same conversation.

What it sets in Claude Code

Every session in either app, from 2.1.287, starts with Claude Code's SendUserMessage tool switched on (CLAUDE_CODE_PEWTER_OWL_TOOL=true): Claude Code builds its tool list just after the session starts, so that is the only moment the switch counts. The mod keeps the tool behind ToolSearch in every such session, one answer that never changes and so never spends the prompt cache; a plain session's tool list is Claude Code's own.

In a conversation that runs the workflows there, the mod sets CLAUDE_CODE_THINKING_DISPLAY_UPDATES=false, which stops one-line summaries of Claude's thinking printing as if they were output, and CLAUDE_CODE_SILENT_TURN_REMINDER=false, which stops the nudge to say what Claude is doing; project settings cannot set the second. Claude Code reads both per request. Every engine call marks the conversation that made it — a workflow file in its folder, named by the session id Claude Code hands every command — and the mod reads that mark by its own session id after each of the conversation's Bash calls and when it starts, so a command that only mentions the engine marks nothing. What the settings replace, the person's own value or none, is kept in the process's environment (WORKFLOWS_HARNESS_REPLACED), which a reload of the mod's files keeps, and a /clear or a resume puts it back exactly — except the clear a handoff runs, which leads into a workflow conversation by construction and so leaves the workflow values on, the person's own put back as that conversation ends. A marked conversation gets the workflow values back when the mod next follows it, whether claude --resume, a restart or /resume in the same process brings it back. A plain conversation in the same project keeps Claude Code's defaults and the person's own settings: the mod never touches either there.

Working on it

npm run mod:types # fetch the API declarations into types/ (gitignored) npm run typecheck:mod # tsc against those declarations npm run test:mod # claude plugin test

The declarations come from the Claude Code repository and are regenerable, so they are not committed. Fetch them before the first typecheck.

The suites run on Claude Code 2.1.287 or newer, where mods are on by default.

Source 3 files
hooks/register.ts 1149 lines
1/**
2 * workflow-gates — the agentic-workflows engine's gates drawn above the
3 * prompt instead of printed by the model.
4 *
5 * The engine states each gate as data beside the menu it composed. This
6 * module announces itself so the engine collects it, arms the gate off the
7 * Bash result that carried it, cuts the menu out of what the model reads, and
8 * draws the rows in the band once the model's turn is over. A press picks its
9 * row's answer into the prompt box; a second press on that row sends it as the
10 * next message, which the workflows' prose reads as the answer, or while
11 * Claude works on anything else holds it until Claude finishes. In a session
12 * that announced, what the band shows is kept in the conversation's folder
13 * across a restart, so a conversation resumed where nothing has happened
14 * since shows its gate again.
15 *
16 * Every path up to the cut fails open: the engine emits the menu regardless,
17 * so where the module never loads, or a cut throws or overruns, the model
18 * reads the text menu the engine wrote.
19 *
20 * The module carries the workflows' handoffs too: a move into work the engine
21 * names arms off the Bash result that carried it, cut out of what the model
22 * reads, and once the turn ends the module clears the conversation and sends
23 * the continuation into the new one, an Esc on that turn carrying nothing.
24 *
25 * The module also sets Claude Code's harness for the workflows: every session
26 * gets the SendUserMessage tool, kept behind ToolSearch, and a conversation
27 * the engine has marked as running the workflows goes without Claude's
28 * thinking summarised as output and without the nudge to say what it is
29 * doing, the person's own values of both put back as it ends. A conversation
30 * the engine has not marked keeps them untouched.
31 *
32 * All of it happens in Claude Code's terminal app and the Desktop app's Code
33 * tab alone, from 2.1.287. Elsewhere — the VS Code extension, Claude Code on
34 * the web, an older Claude Code — the session is not announced, and the
35 * module draws, keeps and sets nothing.
36 */
37import type {
38  AgentLoop,
39  EngineInterface,
40  PromptOrigin,
41  Register,
42  RenderSurface,
43  SessionMessage,
44} from 'claude-code'
45
46import {
47  IDLE,
48  NO_SENDS,
49  answerOf,
50  linesOf,
51  type Gate,
52  type Option,
53  type Sends,
54} from './layout.ts'
55
56/** The payload's marker and the menu it sits directly above. */
57const GATE_MARKER = '=== GATE ('
58const MENU_MARKER = '=== MENU'
59const SECTION_MARKER = '=== '
60
61/** The marker of a handoff's payload. */
62const HANDOFF_MARKER = '=== HANDOFF ('
63
64/** The `Client`'s key: what `ui.message` matches the board's posts on. */
65const ELEMENT = 'gate'
66
67/**
68 * The file in the conversation's folder where a send leaves what it answered,
69 * for a mod that draws the sent row.
70 */
71const SENT = 'sent.json'
72
73/** The record that claims no send, which that mod draws nothing from. */
74const NOTHING_SENT = 'null'
75
76/**
77 * What a send leaves in the conversation's folder: the answer to a gate, with
78 * the question and the row's label; or a handoff's continuation, with the line
79 * naming where the work went.
80 */
81type Sent =
82  | { answer: string; question: string; label: string }
83  | { answer: string; line: string }
84
85/** A move into work the engine named: the continuation to send, and its line. */
86type Handoff = { text: string; line: string }
87
88/** How long a continuation the mod could not carry stays on screen to send. */
89const UNCARRIED_TOAST_MS = 30_000
90
91/**
92 * Where each conversation that runs the workflows keeps what belongs to it,
93 * in the workflows' system config directory.
94 */
95const CONVERSATIONS = 'conversations'
96
97/** The file the engine marks a conversation that runs the workflows with. */
98const MARKER = 'workflow'
99
100/** The file a conversation's band is kept in for a resume. */
101const KEPT = 'gate.json'
102
103/**
104 * The entrypoints the mod runs under, as the engine's boot reads them:
105 * Claude Code's terminal app, and the Desktop app's Code tab on Anthropic's
106 * API or a third-party provider.
107 */
108const ENTRYPOINTS: ReadonlySet<string> = new Set([
109  'cli',
110  'claude-desktop',
111  'claude-desktop-3p',
112])
113
114/** The surfaces the band draws on: the terminal app's, and the Desktop app's. */
115const BAND_SURFACES: ReadonlySet<RenderSurface> = new Set(['terminal', 'desktop'])
116
117/** The oldest Claude Code the mod runs on, major, minor and patch. */
118const OLDEST = [2, 1, 287]
119
120/** A release's version, as `claude --version` prints it. */
121const RELEASE = /^(\d+)\.(\d+)\.(\d+)$/
122
123const STOP_NOTE =
124  "The options are on screen as buttons. The user's answer arrives as their next message — typed by them, or sent for them by the workflow-gates plugin when they press a row."
125
126/**
127 * What the band holds of the conversation's gates. A turn the person starts
128 * clears all of it but the gate that turn answers; a turn anyone else starts
129 * leaves it as it is; the conversation's end clears all.
130 */
131type Band = {
132  /** The gate a render armed, waiting on the end of its turn. */
133  armed: Gate | null
134  /** The gate on the band, from a turn's end to a turn the person starts. */
135  drawn: Gate | null
136  /** The answer a press put in the prompt box: a press on its row sends it. */
137  picked: string | null
138  /** The answer held to send when Claude finishes, and one a gate dropped. */
139  sends: Sends
140  /**
141   * Whether the submission that opens the next turn is the person's; a turn
142   * no submission opened counts as theirs.
143   */
144  isOpenedByPerson: boolean
145  /** Whose the running turn is, the person's or anyone else's; null idle. */
146  running: 'person' | 'other' | null
147  /** The gate on the band when the person's running turn began. */
148  answering: Gate | null
149  /** Whether the running turn has called a tool. */
150  hasCalledTool: boolean
151}
152
153const emptyBand = (): Band => ({
154  armed: null,
155  drawn: null,
156  picked: null,
157  sends: NO_SENDS,
158  isOpenedByPerson: true,
159  running: null,
160  answering: null,
161  hasCalledTool: false,
162})
163
164/**
165 * What a turn's end leaves for the prompt box: a held answer's row, sent now
166 * or handed back as a pick; and the text of a pick whose gate went, which
167 * the box may still hold.
168 */
169type Settled = {
170  held: { option: Option; isToSend: boolean } | null
171  gone: string | null
172}
173
174const NOTHING_SETTLED: Settled = { held: null, gone: null }
175
176/** Whether two gates state the same question over the same rows. */
177const isSameGate = (gate: Gate | null, other: Gate | null) =>
178  JSON.stringify(gate) === JSON.stringify(other)
179
180/**
181 * The gate a Bash result's stdout states directly above its menu, and that
182 * stdout with the payload taken out, which is this module's input alone:
183 * `text` keeps the menu as the engine wrote it, `cut` puts the instruction to
184 * stop in its place. Null where the stdout states no gate.
185 */
186function gateIn(
187  stdout: string,
188): { gate: Gate; text: string; cut: string } | null {
189  if (!stdout.includes(GATE_MARKER)) {
190    return null
191  }
192
193  const lines = stdout.split('\n')
194  const at = lines.findIndex(line => line.startsWith(GATE_MARKER))
195
196  if (at === -1) {
197    return null
198  }
199
200  const payload = lines[at + 1]
201  const menu = lines[at + 2]
202
203  if (payload === undefined || !(menu ?? '').startsWith(MENU_MARKER)) {
204    return null
205  }
206
207  const { gate: name, ...gate } = JSON.parse(payload) as Gate & { gate: string }
208  const before = lines.slice(0, at)
209  const after = lines.findIndex(
210    (line, n) => n > at + 2 && line.startsWith(SECTION_MARKER),
211  )
212
213  return {
214    gate,
215    text: [...before, ...lines.slice(at + 2)].join('\n'),
216    cut: [
217      ...before,
218      `=== MENU: ${name} (drawn above the prompt — do NOT emit it; stop and wait) ===`,
219      STOP_NOTE,
220      ...(after === -1 ? [''] : lines.slice(after)),
221    ].join('\n'),
222  }
223}
224
225/**
226 * The handoff a Bash result's stdout carries, and that stdout with its
227 * payload taken out; null where it carries none.
228 */
229function handoffIn(stdout: string): { handoff: Handoff; text: string } | null {
230  if (!stdout.includes(HANDOFF_MARKER)) {
231    return null
232  }
233
234  const lines = stdout.split('\n')
235  const at = lines.findIndex(line => line.startsWith(HANDOFF_MARKER))
236  const payload = lines[at + 1]
237
238  if (at === -1 || payload === undefined) {
239    return null
240  }
241
242  const { text, line } = JSON.parse(payload) as Handoff
243
244  return {
245    handoff: { text, line },
246    text: [...lines.slice(0, at), ...lines.slice(at + 2)].join('\n'),
247  }
248}
249
250/**
251 * Whether `version` is a release the mod runs on; one that does not read as
252 * a release, a development build's included, counts as older.
253 */
254function isSupported(version: string): boolean {
255  const release = RELEASE.exec(version)
256
257  if (release === null) {
258    return false
259  }
260
261  for (const [n, oldest] of OLDEST.entries()) {
262    const part = Number(release[n + 1])
263
264    if (part !== oldest) {
265      return part > oldest
266    }
267  }
268
269  return true
270}
271
272/**
273 * Whether the mod applies to the session, read as the engine's boot reads
274 * it: Claude Code's terminal app or the Desktop app's Code tab — one of the
275 * mod's entrypoints, not Claude Code on the web — at a version the mod runs
276 * on. Claude Code loads the mod wherever mods are on — the VS Code
277 * extension's session, or an older Claude Code's with function hooks
278 * switched on — so the check is the mod's own.
279 */
280async function isApplicable($: EngineInterface): Promise<boolean> {
281  if (
282    !ENTRYPOINTS.has((await $.env.get('CLAUDE_CODE_ENTRYPOINT')) ?? '') ||
283    (await $.env.get('CLAUDE_CODE_REMOTE'))
284  ) {
285    return false
286  }
287
288  return isSupported((await $.session.version()).version)
289}
290
291/**
292 * Whether the session announces the gate surface: set at its start, and
293 * still set after a reload of the module. The band is kept and read back,
294 * and the harness put on, only where it is, so a process whose session
295 * never announced — one the mod does not apply to, or one this module was
296 * loaded into after it started — never gets either.
297 */
298async function isAnnounced($: EngineInterface): Promise<boolean> {
299  return (await $.env.get('WORKFLOWS_GATE_SURFACE')) === '1'
300}
301
302/** Whether an event is the conversation's own, not a subagent's loop. */
303const inConversation = (e: AgentLoop) => e.agentId === undefined
304
305/**
306 * Whether the band takes a gate a Bash call stated: the conversation's own
307 * call, not a subagent's; and the session's only screen the terminal or the
308 * Desktop app, since the band is theirs and any other screen attached beside
309 * it shows the menu as text alone. The Desktop app attaches after the
310 * session starts, so this is read at the call.
311 */
312async function isForBand($: EngineInterface, e: AgentLoop): Promise<boolean> {
313  if (!inConversation(e)) {
314    return false
315  }
316
317  const [only, ...others] = await $.session.surfaces()
318
319  return only !== undefined && others.length === 0 && BAND_SURFACES.has(only)
320}
321
322/**
323 * Whether a submission is the person's: their Enter at the prompt, their
324 * message through Remote Control, their message in the Desktop app — whose
325 * Code tab runs the session as an SDK host, so it arrives as the host's own —
326 * or this plugin sending their press. One with no origin is the person's
327 * own, as the engine reads it.
328 */
329const isPersons = (origin: PromptOrigin | undefined, plugin: string) =>
330  origin === undefined ||
331  origin.kind === 'composer' ||
332  origin.kind === 'bridge' ||
333  origin.kind === 'sdk' ||
334  (origin.kind === 'plugin' && origin.name === plugin)
335
336/** The row a post names, or null when it names none of the gate's. */
337function optionIn(gate: Gate, data: unknown): Option | null {
338  const said = (data as { answer?: unknown } | null)?.answer
339
340  return typeof said === 'string'
341    ? (gate.options.find(option => answerOf(option) === said) ?? null)
342    : null
343}
344
345/** Puts `text` in the prompt box in place of what is there; whether it took. */
346async function fill($: EngineInterface, text: string): Promise<boolean> {
347  const { isFilled } = await $.prompt.fill({ text, mode: 'replace' })
348
349  return isFilled
350}
351
352/**
353 * Sends the next message, the send recorded in the conversation's folder
354 * where it has one; whether it entered. A send that fails or is dropped
355 * leaves no send recorded.
356 */
357async function deliver($: EngineInterface, sent: Sent): Promise<boolean> {
358  const folder = await folderOf($, await $.session.id())
359  const record = async (text: string) => {
360    if (folder !== null) {
361      await $.fs.write(inFolder(folder, SENT), text)
362    }
363  }
364  let isSent = false
365
366  try {
367    await record(JSON.stringify(sent))
368    // Framed for the model and labelled on screen as this plugin's, by design.
369    const { drop } = await $.prompt.submit({ text: sent.answer })
370
371    isSent = drop === undefined
372  } finally {
373    if (!isSent) {
374      await record(NOTHING_SENT)
375    }
376  }
377
378  return isSent
379}
380
381/** Sends a row as the next message; whether it entered. */
382const submit = ($: EngineInterface, gate: Gate, option: Option) =>
383  deliver($, {
384    answer: answerOf(option),
385    question: gate.question,
386    label: option.head,
387  })
388
389/** Whether `attempt` took; one that fails did not. */
390const took = (attempt: Promise<boolean>) => attempt.catch(() => false)
391
392/**
393 * Carries a handoff: the conversation cleared, then the continuation sent into
394 * the new one, recorded with its line in that conversation's folder first,
395 * and a toast saying where the work went. A clear that fails sends in place,
396 * so the work still goes on; a send that fails or is dropped waits in the
397 * prompt box for Enter; where the box refuses it too, the toast holds the
398 * continuation for the person to send.
399 */
400async function handOff($: EngineInterface, { text, line }: Handoff) {
401  try {
402    await $.command.run({ command: 'clear' })
403  } catch {
404    // Not cleared: the continuation goes on in this conversation.
405  }
406
407  const isCarried =
408    (await took(deliver($, { answer: text, line }))) ||
409    (await took(fill($, text)))
410
411  if (isCarried) {
412    $.ui.toast(`Handed off ${line}`)
413  } else {
414    $.ui.toast(`Not handed off ${line} — send this to carry on: ${text}`, {
415      timeoutMs: UNCARRIED_TOAST_MS,
416    })
417  }
418}
419
420/**
421 * Empties the prompt box while it holds exactly `text`, a pick's answer; a
422 * draft the person has edited stays.
423 */
424async function unpick($: EngineInterface, text: string) {
425  const box = await $.prompt.read()
426
427  if (box.text === text) {
428    await fill($, '')
429  }
430}
431
432/**
433 * Sends the picked row, the box cleared first; a send that fails or is
434 * dropped puts the answer back in the box, where the pick says it is.
435 */
436async function send($: EngineInterface, gate: Gate, option: Option) {
437  let isSent = false
438
439  await fill($, '')
440
441  try {
442    isSent = await submit($, gate, option)
443  } finally {
444    if (!isSent) {
445      await fill($, answerOf(option))
446    }
447  }
448}
449
450/**
451 * Where a conversation stands: its session id, which names its folder, and
452 * the stamp of the step its transcript ends on.
453 */
454type Place = { id: string; stamp: string }
455
456/** What a conversation's folder keeps of its band: its gate, and where the transcript ended. */
457type Kept = { stamp: string; gate: Gate }
458
459/** A band read back from its folder: where the conversation stands, its gate. */
460type ReadBack = { place: Place; gate: Gate | null }
461
462/**
463 * A read-back the band owes before it is trusted, never taken from `ended`:
464 * the conversation that ended in this process, while the transcript still
465 * holds it.
466 */
467type Owed = { ended: Place | null }
468
469/** The tool calls a message makes or answers, by id. */
470const callsOf = (message: SessionMessage) => [
471  ...message.toolUses.map(use => use.tool_use_id),
472  ...(message.toolResults ?? []).map(result => result.tool_use_id),
473]
474
475/**
476 * The lines Claude Code writes around an interrupted turn rather than the
477 * conversation taking a step: the interruption itself, which can land after
478 * that turn's end has kept the band, and the reply it puts in the model's
479 * place when the conversation is resumed after one. No stamp reads them.
480 */
481const isInterruption = (message: SessionMessage) =>
482  (message.role === 'user' &&
483    message.text.startsWith('[Request interrupted by user')) ||
484  (message.role === 'assistant' &&
485    message.text === 'No response requested.' &&
486    message.toolUses.length === 0)
487
488/**
489 * Where the conversation stands now; null until it takes a step. The stamp
490 * is the last step — who wrote it, its text, the calls it makes or answers —
491 * and the newest call of all, so a conversation that moved on and came to
492 * rest on the same words is told apart.
493 */
494async function placeOf($: EngineInterface): Promise<Place | null> {
495  const id = await $.session.id()
496  const steps = (await $.session.messages()).filter(
497    message => !isInterruption(message),
498  )
499  const last = steps.at(-1)
500
501  if (last === undefined) {
502    return null
503  }
504
505  const calls = steps.flatMap(callsOf)
506
507  return {
508    id,
509    stamp: JSON.stringify([
510      last.role,
511      last.text,
512      callsOf(last),
513      calls.at(-1) ?? null,
514    ]),
515  }
516}
517
518const isSamePlace = (place: Place, other: Place | null) =>
519  other !== null && place.id === other.id && place.stamp === other.stamp
520
521/** Whether `place` is the conversation `seen` read, however far on. */
522const isSameConversation = (place: Place | null, seen: Place | null) =>
523  place !== null && seen !== null && place.id === seen.id
524
525const isKept = (value: unknown): value is Kept =>
526  typeof value === 'object' &&
527  value !== null &&
528  typeof (value as Kept).stamp === 'string'
529
530/**
531 * The folder of the conversation `id`, which the engine names by the id's
532 * safe characters alone, in the workflows' system config directory:
533 * `WORKFLOWS_CONFIG_DIR`, else `.config/workflows` in the home directory.
534 * Found by the id, so a `cd` or a resume elsewhere finds it all the same;
535 * null where the process names neither directory.
536 */
537async function folderOf(
538  $: EngineInterface,
539  id: string,
540): Promise<string | null> {
541  const home = await $.env.get('HOME')
542  const config =
543    (await $.env.get('WORKFLOWS_CONFIG_DIR')) ||
544    (home ? `${home}/.config/workflows` : null)
545
546  return config === null
547    ? null
548    : `${config}/${CONVERSATIONS}/${id.replace(/[^A-Za-z0-9_-]/g, '')}`
549}
550
551/** A file in a conversation's folder. */
552const inFolder = (folder: string, file: string) => `${folder}/${file}`
553
554/**
555 * The folder of the conversation `id` where the engine has marked it as one
556 * that runs the workflows; null for any other conversation.
557 */
558async function markedFolder(
559  $: EngineInterface,
560  id: string,
561): Promise<string | null> {
562  const folder = await folderOf($, id)
563
564  return folder !== null && (await $.fs.exists(inFolder(folder, MARKER)))
565    ? folder
566    : null
567}
568
569/**
570 * Keeps what the band shows for the conversation at `place` — the gate, or
571 * nothing — in its folder, in a session that announced and a conversation
572 * that runs the workflows: any other conversation gets no folder.
573 */
574async function keep($: EngineInterface, place: Place | null, gate: Gate | null) {
575  if (place === null || !(await isAnnounced($))) {
576    return
577  }
578
579  const folder = await markedFolder($, place.id)
580
581  if (folder === null) {
582    return
583  }
584
585  const kept: Kept | null = gate === null ? null : { stamp: place.stamp, gate }
586
587  await $.fs.write(inFolder(folder, KEPT), JSON.stringify(kept))
588}
589
590/** What `folder` keeps of its band; undefined where it keeps nothing readable. */
591async function keptIn($: EngineInterface, folder: string): Promise<unknown> {
592  try {
593    return JSON.parse(await $.fs.read(inFolder(folder, KEPT)))
594  } catch {
595    return undefined
596  }
597}
598
599/**
600 * The person's own values of the settings the harness sets, absent where
601 * unset: what the harness replaced, kept while it is on.
602 */
603type Replaced = {
604  CLAUDE_CODE_THINKING_DISPLAY_UPDATES?: string
605  CLAUDE_CODE_SILENT_TURN_REMINDER?: string
606}
607
608/**
609 * Puts Claude Code's harness on for a workflow session, one whose
610 * conversation the engine has marked in a session that announced: no
611 * summary of Claude's thinking printed as if it were output, and no nudge to
612 * say what it is doing. Claude Code reads both per request. What it replaces
613 * is kept in the process's environment, which a reload of the module's files
614 * keeps, and only where nothing is kept yet: the values are read before that
615 * is looked at, so a harness another call has just put on is never kept as
616 * the person's.
617 */
618async function harnessOn($: EngineInterface) {
619  if (!(await isAnnounced($))) {
620    return
621  }
622
623  const replaced: Replaced = {
624    CLAUDE_CODE_THINKING_DISPLAY_UPDATES: await $.env.get(
625      'CLAUDE_CODE_THINKING_DISPLAY_UPDATES',
626    ),
627    CLAUDE_CODE_SILENT_TURN_REMINDER: await $.env.get(
628      'CLAUDE_CODE_SILENT_TURN_REMINDER',
629    ),
630  }
631
632  if ((await $.env.get('WORKFLOWS_HARNESS_REPLACED')) === undefined) {
633    await $.env.set('WORKFLOWS_HARNESS_REPLACED', JSON.stringify(replaced))
634  }
635
636  await $.env.set('CLAUDE_CODE_THINKING_DISPLAY_UPDATES', 'false')
637  await $.env.set('CLAUDE_CODE_SILENT_TURN_REMINDER', 'false')
638}
639
640/**
641 * Takes the harness off, putting back exactly what it replaced: the person's
642 * value, or none. Where the harness is not on, nothing is touched.
643 */
644async function harnessOff($: EngineInterface) {
645  const kept = await $.env.get('WORKFLOWS_HARNESS_REPLACED')
646
647  if (kept === undefined) {
648    return
649  }
650
651  const replaced = JSON.parse(kept) as Replaced
652
653  await $.env.set(
654    'CLAUDE_CODE_THINKING_DISPLAY_UPDATES',
655    replaced.CLAUDE_CODE_THINKING_DISPLAY_UPDATES,
656  )
657  await $.env.set(
658    'CLAUDE_CODE_SILENT_TURN_REMINDER',
659    replaced.CLAUDE_CODE_SILENT_TURN_REMINDER,
660  )
661  await $.env.set('WORKFLOWS_HARNESS_REPLACED', undefined)
662}
663
664/**
665 * Settles the read-back the band owes, if `owing` says it owes one: `take`
666 * gets the conversation the transcript holds now, with its kept gate where
667 * the transcript still ends where it was kept, or none where it has moved
668 * on, the kept gate dropped while the read-back is still owed. A
669 * conversation the engine has marked gets the workflow harness back, again
670 * while the read-back is still owed. Nothing settles in a session that did
671 * not announce, while the transcript is empty, or while it still holds the
672 * conversation that ended in this process.
673 */
674async function readBack(
675  $: EngineInterface,
676  owing: () => Owed | null,
677  take: (asked: Owed, read: ReadBack) => void,
678) {
679  const asked = owing()
680
681  if (asked === null || !(await isAnnounced($))) {
682    return
683  }
684
685  const place = await placeOf($)
686
687  if (place === null || isSamePlace(place, asked.ended)) {
688    return
689  }
690
691  const folder = await markedFolder($, place.id)
692
693  if (folder !== null && owing() === asked) {
694    await harnessOn($)
695  }
696
697  const kept = folder === null ? undefined : await keptIn($, folder)
698
699  if (isKept(kept) && kept.stamp === place.stamp) {
700    take(asked, { place, gate: kept.gate })
701
702    return
703  }
704
705  if (folder !== null && isKept(kept) && owing() === asked) {
706    await $.fs.write(inFolder(folder, KEPT), JSON.stringify(null))
707  }
708
709  take(asked, { place, gate: null })
710}
711
712export const register: Register = on => {
713  let band = emptyBand()
714
715  /** Whether a send is under way, so a press meanwhile cannot send twice. */
716  let isSending = false
717
718  /**
719   * The read-back the band owes: from the module's load, which a restart or
720   * a reload of its files begins, and from a conversation's end in this
721   * process, until the next turn or a read settles it.
722   */
723  let owed: Owed | null = { ended: null }
724
725  /** Where the conversation stood when the band was last kept or read back. */
726  let seen: Place | null = null
727
728  /** The handoff a call armed, carried once its turn ends. */
729  let handoff: Handoff | null = null
730
731  /**
732   * Whether the clear under way is this module's own, for a handoff: the
733   * conversation it leads into runs the workflows by construction, so the
734   * workflow harness stays on through it.
735   */
736  let isClearingForHandoff = false
737
738  /**
739   * The person takes the turn: the band comes down, keeping the gate their
740   * turn answers. Whether a gate came down.
741   */
742  const takeDown = (): boolean => {
743    const { drawn } = band
744
745    band = { ...emptyBand(), running: 'person', answering: drawn }
746
747    return drawn !== null
748  }
749
750  /**
751   * Settles the band as the conversation's turn ends. The person's turn
752   * draws the gate it rendered; an Esc drops that and takes the answer back,
753   * its gate returning, until the turn calls a tool, which may already have
754   * acted on the answer: a read and a write look alike from here. Anyone
755   * else's turn leaves the band as it is unless it rendered another gate: at
756   * its end that gate takes the band and drops a held answer unsent; after an
757   * Esc, which leaves the model at that gate's stop, the band empties. A held
758   * answer on the gate still there sends now, or after an Esc goes back to
759   * the prompt as a pick; a pick whose gate went goes with it.
760   */
761  const endTurn = (isInterrupted: boolean): Settled => {
762    const { running, armed, drawn, picked, answering, hasCalledTool, sends } =
763      band
764
765    band = { ...band, armed: null, running: null, answering: null }
766
767    if (running !== 'other') {
768      band.drawn = isInterrupted ? (hasCalledTool ? null : answering) : armed
769
770      return NOTHING_SETTLED
771    }
772
773    const gate = armed ?? drawn
774
775    if (!isSameGate(gate, drawn)) {
776      band = isInterrupted
777        ? { ...band, drawn: null, picked: null, sends: NO_SENDS }
778        : {
779            ...band,
780            drawn: gate,
781            picked: null,
782            sends: { held: null, dropped: sends.held },
783          }
784
785      return { held: null, gone: picked }
786    }
787
788    band.sends = { ...sends, held: null }
789
790    const option =
791      drawn === null || sends.held === null
792        ? null
793        : optionIn(drawn, { answer: sends.held })
794
795    return {
796      held: option === null ? null : { option, isToSend: !isInterrupted },
797      gone: null,
798    }
799  }
800
801  const owing = () => owed
802
803  /**
804   * Takes a read-back onto the band, unless a turn or a conversation's end
805   * overtook it while it read: the conversation it read is the one the band
806   * follows from here.
807   */
808  const takeBack = (asked: Owed, read: ReadBack) => {
809    if (owed !== asked) {
810      return
811    }
812
813    owed = null
814    seen = read.place
815
816    if (read.gate !== null) {
817      band.drawn = read.gate
818    }
819  }
820
821  // Announced, never always-on: the engine collects a gate, and composes a
822  // handoff for this module to carry, only for a session that asked, and
823  // every Bash child inherits this. Where the mod does not apply nothing is
824  // announced, which leaves it inert there. A fresh load comes back to a
825  // conversation this module has not followed, so the band is read back from
826  // the conversation's folder.
827  on('session.start', async ($, e, next) => {
828    if (!(await isApplicable($))) {
829      return next(e)
830    }
831
832    await $.env.set('WORKFLOWS_GATE_SURFACE', '1')
833    await $.env.set('WORKFLOWS_HANDOFF', '1')
834
835    // Claude Code builds its tool catalogue just after this hook, so only
836    // here does the switch that gives the session SendUserMessage count.
837    await $.env.set('CLAUDE_CODE_PEWTER_OWL_TOOL', 'true')
838
839    owed = { ended: null }
840    await readBack($, owing, takeBack)
841
842    if (band.drawn !== null) {
843      $.ui.invalidate('ui.render')
844    }
845
846    return next(e)
847  }).catch(($, e, next) => next(e))
848
849  // A /clear or a resume goes on in this process as another conversation,
850  // which no gate of this one answers and which is no workflow session until
851  // the engine marks it or it is read back as one it has marked. What the
852  // band showed is kept for this one first, stamped where its transcript
853  // ends now, which can have moved since its last turn's end. The clear a
854  // handoff runs leads into a workflow conversation, so the harness stays on.
855  on('session.end', async ($, e, next) => {
856    const isHandingOff = isClearingForHandoff && e.reason === 'clear'
857
858    isClearingForHandoff = false
859
860    try {
861      const place = await placeOf($)
862
863      if (isSameConversation(place, seen)) {
864        await keep($, place, band.drawn)
865        seen = place
866      }
867    } finally {
868      band = emptyBand()
869      handoff = null
870      owed = { ended: seen }
871      seen = null
872      $.ui.invalidate('ui.render')
873
874      if (!isHandingOff) {
875        await harnessOff($)
876      }
877    }
878
879    return next(e)
880  }).catch(($, e, next) => next(e))
881
882  on('tool.call', ($, e, next) => {
883    if (inConversation(e)) {
884      band.hasCalledTool = true
885    }
886
887    return next(e)
888  }).catch(($, e, next) => next(e))
889
890  // Every engine call marks the conversation that made it, whatever its exit,
891  // so after each of the conversation's own commands the mark says whether it
892  // runs the workflows; a command that only mentions the engine marks nothing.
893  // A failure replays the payloads uncut, so the harness never fails the call
894  // and nothing is armed until nothing more can fail.
895  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
896    const result = await next(e)
897
898    try {
899      if (
900        inConversation(e) &&
901        (await markedFolder($, await $.session.id())) !== null
902      ) {
903        await harnessOn($)
904      }
905    } catch {
906      // The harness waits for the conversation's next call.
907    }
908
909    if (result.deny !== undefined || result.isError === true) {
910      return result
911    }
912
913    const record = result.result
914    const carried = handoffIn(record.stdout)
915    const stdout = carried === null ? record.stdout : carried.text
916    const stated = gateIn(stdout)
917    const isArmed = stated !== null && (await isForBand($, e))
918
919    if (carried !== null && inConversation(e)) {
920      handoff = carried.handoff
921    }
922
923    if (stated === null) {
924      return carried === null ? result : { result: { ...record, stdout } }
925    }
926
927    if (isArmed) {
928      band.armed = stated.gate
929    }
930
931    return {
932      result: { ...record, stdout: isArmed ? stated.cut : stated.text },
933    }
934  }).catch(($, e, next) => next(e))
935
936  // The tool waits behind ToolSearch in every session that announced,
937  // workflow or not: one answer, since a changed answer sends the tool list
938  // again and spends the prompt cache.
939  on('tool.describe', { tool: 'SendUserMessage' }, async ($, e, next) => {
940    const described = await next(e)
941
942    return (await isAnnounced($))
943      ? { ...described, isDeferred: true }
944      : described
945  }).catch(($, e, next) => next(e))
946
947  // Drawn at the turn's end, once what the rows choose between is on screen,
948  // and kept with where the transcript ends, which a resume must still match.
949  // A held answer is not kept: it waits on a turn no resume brings back. It
950  // sends once the turn is over, and one not sent is put back as a pick; the
951  // answer of a pick whose gate went leaves the prompt box. A handoff the turn
952  // armed is carried once it is over, whatever else at its end fails; an Esc
953  // means stop, and carries nothing.
954  on('turn.complete', async ($, e, next) => {
955    if (!inConversation(e)) {
956      return next(e)
957    }
958
959    const carried = handoff
960
961    handoff = null
962
963    try {
964      const settled = endTurn(e.isAborted)
965      const { drawn } = band
966
967      owed = null
968
969      if (drawn !== null) {
970        $.ui.invalidate('ui.render')
971      }
972
973      const place = await placeOf($)
974
975      await keep($, place, drawn)
976      seen = place
977
978      const answered = await next(e)
979      const { held, gone } = settled
980
981      if (gone !== null) {
982        await unpick($, gone)
983      }
984
985      if (held !== null && drawn !== null) {
986        const answer = answerOf(held.option)
987        let isSent = false
988
989        isSending = true
990
991        try {
992          isSent = held.isToSend && (await submit($, drawn, held.option))
993        } finally {
994          isSending = false
995
996          if (!isSent && (await fill($, answer))) {
997            band.picked = answer
998            $.ui.invalidate('ui.render')
999          }
1000        }
1001      }
1002
1003      return answered
1004    } finally {
1005      if (carried !== null && !e.isAborted) {
1006        // A command cannot run inside a hook the turn waits on, so the clear
1007        // runs from a timer once this hook is done — proven in the lab.
1008        $.clock.after(0, () => {
1009          isClearingForHandoff = true
1010          void handOff($, carried).finally(() => {
1011            isClearingForHandoff = false
1012          })
1013        })
1014      }
1015    }
1016  }).catch(($, e, next) => next(e))
1017
1018  // A drawing while a read-back is owed — a reload's first, the first after
1019  // a conversation's end, one before the transcript was there to read —
1020  // settles it first.
1021  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1022    await readBack($, owing, takeBack)
1023
1024    const gate = band.drawn
1025
1026    if (
1027      gate === null ||
1028      e.props.hasSurvey ||
1029      (e.surface !== 'terminal' && e.surface !== 'desktop')
1030    ) {
1031      return next(e)
1032    }
1033
1034    const beneath = await next(e)
1035
1036    try {
1037      const { Box, Client } = await $.ui.resolve(e)
1038      const { bodyColumns: columns, maxRows } = e.props
1039
1040      return Box({
1041        flexDirection: 'column',
1042        children: [
1043          // The module's path is read off this source, so it is a literal;
1044          // the region must be exactly as tall as the drawing, or the pointer
1045          // cannot reach the rows past its edge.
1046          h(Client, {
1047            key: ELEMENT,
1048            module: './board.ts',
1049            width: columns,
1050            height: linesOf(gate, columns, IDLE, band.sends, maxRows).length,
1051            props: {
1052              gate,
1053              picked: band.picked,
1054              ...band.sends,
1055              columns,
1056              maxRows,
1057            },
1058          }),
1059          beneath,
1060        ],
1061      })
1062    } catch {
1063      return beneath
1064    }
1065  }).catch(($, e, next) => next(e))
1066
1067  // The board has no `$`: a press posts here, and this picks its row or, on
1068  // the row already picked, sends it. The turn a send opens takes the gate
1069  // off the band; a send that fails or is dropped leaves the row there to
1070  // press again. While anyone else's turn runs, the send is held, its answer
1071  // out of the prompt box, until that turn ends; a press on the held row
1072  // takes it back to a pick, and a press on another row picks that instead.
1073  on('ui.message', { element: ELEMENT }, async ($, e, next) => {
1074    const gate = band.drawn
1075    const option = gate === null || isSending ? null : optionIn(gate, e.data)
1076
1077    if (gate === null || option === null) {
1078      return next(e)
1079    }
1080
1081    const answer = answerOf(option)
1082
1083    if (answer !== band.picked) {
1084      if (await fill($, answer)) {
1085        band.picked = answer
1086        band.sends = { ...band.sends, held: null }
1087        $.ui.invalidate('ui.render')
1088      }
1089    } else if (band.running === 'other') {
1090      if (await fill($, '')) {
1091        band.picked = null
1092        band.sends = { ...band.sends, held: answer }
1093        $.ui.invalidate('ui.render')
1094      }
1095    } else {
1096      isSending = true
1097
1098      try {
1099        await send($, gate, option)
1100      } finally {
1101        isSending = false
1102      }
1103    }
1104
1105    return next(e)
1106  }).catch(($, e, next) => next(e))
1107
1108  // A submission made while the session idles opens the next turn; one made
1109  // over a running turn joins it, and the person's makes anyone else's turn
1110  // theirs, which takes the band down.
1111  on('prompt.submit', ($, e, next) => {
1112    const isTheirs = isPersons(e.origin, $.plugin.name)
1113
1114    if (e.turnId === undefined) {
1115      band.isOpenedByPerson = isTheirs
1116    } else if (isTheirs && band.running === 'other') {
1117      if (takeDown()) {
1118        $.ui.invalidate('ui.render')
1119      }
1120    }
1121
1122    return next(e)
1123  }).catch(($, e, next) => next(e))
1124
1125  // A gate lives from its render to the turn the person starts to answer it,
1126  // which keeps it to put back; a gate the conversation re-presents is armed
1127  // again by its own render. Anyone else's turn — an agent's report, a
1128  // notification, a schedule — leaves the band live, its rows still to
1129  // press. The band follows the conversation the turn runs in, owing no
1130  // read-back.
1131  on('turn.start', ($, e, next) => {
1132    owed = null
1133
1134    if (!band.isOpenedByPerson) {
1135      band = {
1136        ...band,
1137        armed: null,
1138        isOpenedByPerson: true,
1139        running: 'other',
1140        hasCalledTool: false,
1141      }
1142    } else if (takeDown()) {
1143      $.ui.invalidate('ui.render')
1144    }
1145
1146    return next(e)
1147  }).catch(($, e, next) => next(e))
1148}
1149
hooks/layout.ts 558 lines
1/**
2 * The gate's geometry and text shaping, shared by the hooks module and the
3 * board: the band is one list of lines, which the hook counts to size the
4 * `Client`'s region and the board draws line for line. Any drift between the
5 * two is dead space under the last row, or rows the pointer cannot reach.
6 *
7 * The engine states every gate as data, so nothing here parses markdown: a
8 * row arrives already split into what it is and what it says about itself.
9 */
10
11/** One pressable row of the engine's gate payload. */
12export type Option = {
13  key: string
14  word: string | null
15  head: string
16  tail: string | null
17  cue: string | null
18  holder: string | null
19  detail: string | null
20  struck: boolean
21  recommended: boolean
22}
23
24/** One row of the payload a span or a natural reply can only answer. */
25export type Typed = {
26  label: string
27  description: string
28  detail: string | null
29}
30
31/** A gate as the engine states it, less its name. */
32export type Gate = {
33  question: string
34  statement: string
35  options: Option[]
36  typed: Typed[]
37}
38
39/** A run of a drawn line under one style. */
40export type Run = {
41  text: string
42  bold?: boolean
43  dim?: boolean
44  italic?: boolean
45  strikethrough?: boolean
46  underline?: boolean
47  /** Drawn in the colour the band asks in, rather than the text's own. */
48  accent?: boolean
49}
50
51/**
52 * What the footer says: how to answer, what a pick put in the prompt, what
53 * waits to send when Claude finishes, what a new gate kept from sending, or
54 * how to answer the typed row last clicked.
55 */
56export type Footer =
57  | { kind: 'idle' }
58  | { kind: 'picked'; answer: string }
59  | { kind: 'queued'; answer: string }
60  | { kind: 'dropped'; answer: string }
61  | { kind: 'typed'; label: string }
62
63/**
64 * What became of a send pressed while Claude works: the answer held until it
65 * finishes, and one a new gate kept from sending, which that gate's footer
66 * names.
67 */
68export type Sends = { held: string | null; dropped: string | null }
69
70/**
71 * A line of a row: its wrapped label or its detail, each carrying the row so
72 * a pointer lands on any of them. The key column, filled on the first line
73 * alone, and the label are padded out so a background spans the band.
74 */
75export type RowLine = {
76  kind: 'option' | 'typed'
77  index: number
78  key: Run[]
79  runs: Run[]
80}
81
82/** The line under a page of rows: which page shows, of how many. */
83export type PagerLine = { kind: 'pager'; page: number; pages: number }
84
85/** One line of the band, top to bottom. */
86export type Line =
87  | { kind: 'rule' }
88  | { kind: 'blank' }
89  | { kind: 'prose'; glyph: boolean; runs: Run[] }
90  | RowLine
91  | PagerLine
92  | { kind: 'footer'; runs: Run[] }
93
94/** The cursor's gutter, where the footer also sits. */
95export const GUTTER = 2
96
97/** The `◆ ` the question opens with; the statement lines up past it. */
98export const GLYPH_COLUMN = 2
99
100export const IDLE: Footer = { kind: 'idle' }
101
102export const NO_SENDS: Sends = { held: null, dropped: null }
103
104const GAP = 2
105const MIN_LABEL = 8
106
107const TAIL_SEPARATOR = ' — '
108const NOTE_SEPARATOR = ' · '
109const RECOMMENDED = ' (recommended)'
110const QUEUED = ' · queued'
111
112const IDLE_HINT = 'Click to choose · click again to send · or type'
113const PICKED_HINT = ' is in your prompt · click again to send'
114const QUEUED_HINT = ' sends when Claude finishes · click to undo'
115const DROPPED_HINT = ' not sent — the menu changed'
116
117const PREVIOUS = '↑ previous'
118const NEXT = '↓ next'
119const PAGER_GAP = '   '
120
121/** A typed row's label that is a span of numbers, as the engine writes one. */
122const RANGE = /^\d+–\d+$/
123
124const RULE: Line = { kind: 'rule' }
125const BLANK: Line = { kind: 'blank' }
126
127/** The cells of the pager line its previous and next presses cover. */
128export const PAGER_SPANS = {
129  previous: { from: GUTTER, to: GUTTER + PREVIOUS.length },
130  next: {
131    from: GUTTER + PREVIOUS.length + PAGER_GAP.length,
132    to: GUTTER + PREVIOUS.length + PAGER_GAP.length + NEXT.length,
133  },
134}
135
136/** What a row shows in its key column, and what pressing it answers with. */
137export const answerOf = (option: Option) => option.word ?? option.key
138
139/**
140 * The row the cursor starts on, so Enter on arrival takes what the engine
141 * recommends: that row, else the first not struck through, else the first.
142 */
143export function startingRow(options: readonly Option[]): number {
144  const recommended = options.findIndex(option => option.recommended)
145
146  return recommended !== -1
147    ? recommended
148    : Math.max(0, options.findIndex(option => !option.struck))
149}
150
151/** Fills a line out to `width`, so a background spans it. */
152export function pad(runs: readonly Run[], width: number): Run[] {
153  const short = width - runs.reduce((cells, run) => cells + run.text.length, 0)
154
155  return short > 0 ? [...runs, { text: ' '.repeat(short) }] : [...runs]
156}
157
158/**
159 * Greedy word wrap that keeps each run's styling: a label too long for its
160 * column breaks onto the next line rather than being cut, so nothing the
161 * engine wrote is lost. A line's words come back under one run per style,
162 * and the space a break falls on is dropped rather than drawn.
163 */
164export function wrapRuns(runs: readonly Run[], width: number): Run[][] {
165  const column = Math.max(1, width)
166  const lines: Run[][] = []
167
168  let line: { source: Run; run: Run }[] = []
169  let used = 0
170  let gap: { source: Run; text: string } | null = null
171
172  const wrap = () => {
173    lines.push(line.map(entry => entry.run))
174    line = []
175    used = 0
176    gap = null
177  }
178
179  const add = (source: Run, text: string) => {
180    const last = line.at(-1)
181
182    if (last !== undefined && last.source === source) {
183      last.run.text += text
184    } else {
185      line.push({ source, run: { ...source, text } })
186    }
187
188    used += text.length
189  }
190
191  for (const source of runs) {
192    for (const piece of source.text.split(/(\s+)/)) {
193      if (piece === '') {
194        continue
195      }
196
197      if (piece.trim() === '') {
198        gap = used > 0 ? { source, text: piece } : null
199        continue
200      }
201
202      let word = piece
203
204      while (word.length > column) {
205        if (used > 0) {
206          wrap()
207        }
208
209        add(source, word.slice(0, column))
210        word = word.slice(column)
211        wrap()
212      }
213
214      if (used > 0 && used + (gap?.text.length ?? 0) + word.length > column) {
215        wrap()
216      } else if (gap !== null) {
217        add(gap.source, gap.text)
218      }
219
220      gap = null
221      add(source, word)
222    }
223  }
224
225  if (line.length > 0 || lines.length === 0) {
226    wrap()
227  }
228
229  return lines
230}
231
232/** Text whose lines are its own, each wrapped to `width`; none for no text. */
233const paragraphs = (
234  text: string,
235  width: number,
236  style: Omit<Run, 'text'> = {},
237) =>
238  text === ''
239    ? []
240    : text
241        .split('\n')
242        .flatMap(line => wrapRuns([{ ...style, text: line }], width))
243
244/**
245 * The band's two columns at this width: the keys, and the labels beside
246 * them. The band has no frame, so a row is gutter, key, gap, label.
247 */
248export function geometry(gate: Gate, columns: number) {
249  const keyWidth = Math.max(
250    1,
251    ...gate.options.map(option => answerOf(option).length),
252    ...gate.typed.map(row => row.label.length),
253  )
254
255  return {
256    keyWidth,
257    labelWidth: Math.max(MIN_LABEL, columns - GUTTER - keyWidth - GAP),
258  }
259}
260
261/**
262 * The key column's runs: the word with the row's key underlined where the
263 * word spells it, as a menu marks its accelerator; a bare key as it is.
264 */
265function keyRuns(option: Option): Run[] {
266  const shown = answerOf(option)
267  const at =
268    option.word === null
269      ? -1
270      : shown.toLowerCase().indexOf(option.key.toLowerCase())
271
272  if (at === -1) {
273    return [{ text: shown }]
274  }
275
276  const end = at + option.key.length
277
278  return [
279    { text: shown.slice(0, at) },
280    { text: shown.slice(at, end), underline: true },
281    { text: shown.slice(end) },
282  ].filter(run => run.text !== '')
283}
284
285/** A label part after its separator; nothing for a part the row lacks. */
286const partRuns = (
287  separator: string,
288  text: string | null,
289  style: Omit<Run, 'text'> = {},
290): Run[] => (text === null ? [] : [{ text: `${separator}${text}`, ...style }])
291
292/**
293 * A row's label by the text menu's grammar: the tail dim and italic after a
294 * dash, and a cue the same after a dot; a held row struck from its head
295 * through its cue, the holder plain after the strike; and the recommendation
296 * last, bold in the accent colour.
297 */
298function labelRuns(option: Option): Run[] {
299  const strike = option.struck ? { strikethrough: true } : {}
300  const aside = { ...strike, dim: true, italic: true }
301
302  return [
303    { text: option.head, ...strike },
304    ...partRuns(TAIL_SEPARATOR, option.tail, aside),
305    ...partRuns(NOTE_SEPARATOR, option.cue, aside),
306    ...partRuns(NOTE_SEPARATOR, option.holder),
307    ...(option.recommended
308      ? [{ text: RECOMMENDED, bold: true, accent: true }]
309      : []),
310  ]
311}
312
313/**
314 * The pressable rows and then the typed ones, each row's lines a group
315 * closed by its detail; every pressable row marked queued after its label
316 * where `queued`.
317 */
318function rowGroups(gate: Gate, columns: number, queued: boolean): RowLine[][] {
319  const { keyWidth, labelWidth } = geometry(gate, columns)
320
321  const linesOfRow = (
322    kind: RowLine['kind'],
323    index: number,
324    key: Run[],
325    label: Run[],
326    detail: string | null,
327  ): RowLine[] =>
328    [
329      ...wrapRuns(label, labelWidth),
330      ...paragraphs(detail ?? '', labelWidth, { dim: true }),
331    ].map((runs, n) => ({
332      kind,
333      index,
334      key: pad(n === 0 ? key : [], keyWidth + GAP),
335      runs: pad(runs, labelWidth),
336    }))
337
338  return [
339    ...gate.options.map((option, index) =>
340      linesOfRow(
341        'option',
342        index,
343        keyRuns(option),
344        [...labelRuns(option), ...(queued ? [{ text: QUEUED }] : [])],
345        option.detail,
346      ),
347    ),
348    ...gate.typed.map((row, index) =>
349      linesOfRow(
350        'typed',
351        index,
352        [{ text: row.label, dim: true }],
353        [{ text: row.description, dim: true }],
354        row.detail,
355      ),
356    ),
357  ]
358}
359
360// A click leaves the keys with the band; only the person can hand them back.
361const typedHint = (label: string) =>
362  RANGE.test(label)
363    ? `${label} — Esc, then type the numbers in the prompt`
364    : `${label} — Esc, then type it in the prompt`
365
366/** The footer's words: dim, the answer it speaks of named in bold. */
367export function footerRuns(footer: Footer): Run[] {
368  switch (footer.kind) {
369    case 'idle':
370      return [{ text: IDLE_HINT, dim: true }]
371    case 'picked':
372      return [
373        { text: footer.answer, bold: true },
374        { text: PICKED_HINT, dim: true },
375      ]
376    case 'queued':
377      return [
378        { text: footer.answer, bold: true },
379        { text: QUEUED_HINT, dim: true },
380      ]
381    case 'dropped':
382      return [
383        { text: footer.answer, bold: true },
384        { text: DROPPED_HINT, dim: true },
385      ]
386    case 'typed':
387      return [{ text: typedHint(footer.label), dim: true }]
388  }
389}
390
391const footerLines = (footer: Footer, columns: number) =>
392  wrapRuns(footerRuns(footer), columns - GUTTER)
393
394/**
395 * The footer, in a slot as tall as the tallest thing it can say for this
396 * gate, so a click never moves the rows above it.
397 */
398function footerSlot(
399  gate: Gate,
400  footer: Footer,
401  columns: number,
402  dropped: string | null,
403): Line[] {
404  const states: Footer[] = [
405    IDLE,
406    ...gate.options.flatMap(option => [
407      { kind: 'picked' as const, answer: answerOf(option) },
408      { kind: 'queued' as const, answer: answerOf(option) },
409    ]),
410    ...gate.typed.map(row => ({ kind: 'typed' as const, label: row.label })),
411    ...(dropped === null
412      ? []
413      : [{ kind: 'dropped' as const, answer: dropped }]),
414  ]
415
416  const rows = Math.max(
417    ...states.map(state => footerLines(state, columns).length),
418  )
419  const said = footerLines(footer, columns)
420
421  return Array.from({ length: rows }, (_, n) => ({
422    kind: 'footer',
423    runs: said[n] ?? [],
424  }))
425}
426
427/**
428 * The rows cut into pages of `budget` lines, in order: a page takes whole
429 * rows while they fit, and a row taller than a page is cut to it.
430 */
431function pagesOf(groups: readonly RowLine[][], budget: number): RowLine[][] {
432  const pages: RowLine[][] = []
433  let page: RowLine[] = []
434
435  for (const group of groups) {
436    const lines = group.slice(0, budget)
437
438    if (page.length > 0 && page.length + lines.length > budget) {
439      pages.push(page)
440      page = []
441    }
442
443    page = [...page, ...lines]
444  }
445
446  return [...pages, page]
447}
448
449/** The page holding the option at `cursor`; the first for none. */
450const pageHolding = (pages: readonly RowLine[][], cursor: number) =>
451  Math.max(
452    0,
453    pages.findIndex(page =>
454      page.some(line => line.kind === 'option' && line.index === cursor),
455    ),
456  )
457
458/**
459 * Every line the band draws for a gate, top to bottom: the rule, a blank, the
460 * statement, the question, a blank, the rows, a blank and the footer. Where
461 * that would stand taller than `maxRows`, the rows show a page at a time over
462 * a pager line, every page padded to one height; the page shown is `page`, or
463 * the one holding the option at `cursor`. How many lines there are is the
464 * region's height whatever the footer says, whichever row is held and
465 * whichever page shows.
466 */
467export function linesOf(
468  gate: Gate,
469  columns: number,
470  footer: Footer = IDLE,
471  { held, dropped }: Sends = NO_SENDS,
472  maxRows = Infinity,
473  { page, cursor = -1 }: { page?: number; cursor?: number } = {},
474): Line[] {
475  const width = columns - GLYPH_COLUMN
476  const statement = paragraphs(gate.statement, width)
477  const question = paragraphs(gate.question, width, { bold: true })
478  const prose = (runs: Run[], glyph: boolean): Line => ({
479    kind: 'prose',
480    glyph,
481    runs,
482  })
483
484  const plain = rowGroups(gate, columns, false)
485  const queued = rowGroups(gate, columns, true)
486  const heldAt = gate.options.findIndex(option => answerOf(option) === held)
487  const groups = plain.map((group, n) =>
488    n === heldAt ? (queued[n] ?? group) : group,
489  )
490  const rows = groups.flat()
491  const marks = queued.map((group, n) => group.length - (plain[n]?.length ?? 0))
492  const tallest = plain.flat().length + Math.max(0, ...marks)
493  const head: Line[] = [
494    RULE,
495    BLANK,
496    ...statement.map(runs => prose(runs, false)),
497    ...question.map((runs, n) => prose(runs, n === 0)),
498    BLANK,
499  ]
500  const foot = [BLANK, ...footerSlot(gate, footer, columns, dropped)]
501  // Lines under the footer that the rows take back when a held row's mark
502  // wraps, so the whole gate holds its height.
503  const spare = Array.from(
504    { length: tallest - rows.length },
505    (): Line => ({ kind: 'footer', runs: [] }),
506  )
507  const whole = [...head, ...rows, ...foot, ...spare]
508
509  if (whole.length <= maxRows) {
510    return whole
511  }
512
513  const budget = Math.max(1, maxRows - head.length - 1 - foot.length)
514  const pages = pagesOf(groups, budget)
515  const shown = Math.min(pages.length - 1, page ?? pageHolding(pages, cursor))
516  const lines: Line[] = pages[shown] ?? []
517
518  return [
519    ...head,
520    ...lines,
521    ...Array.from({ length: budget - lines.length }, () => BLANK),
522    { kind: 'pager', page: shown, pages: pages.length },
523    ...foot,
524  ]
525}
526
527/** The pager line's words, a press that goes nowhere dim. */
528export const pagerRuns = ({ page, pages }: PagerLine): Run[] => [
529  { text: PREVIOUS, dim: page === 0 },
530  { text: PAGER_GAP },
531  { text: NEXT, dim: page === pages - 1 },
532  { text: `${PAGER_GAP}page ${page + 1} of ${pages}`, dim: true },
533]
534
535/** The page the option at `cursor` shows on; the first where none pages. */
536export const pageOf = (
537  gate: Gate,
538  columns: number,
539  sends: Sends,
540  maxRows: number,
541  cursor: number,
542) =>
543  linesOf(gate, columns, IDLE, sends, maxRows, { cursor }).find(
544    (line): line is PagerLine => line.kind === 'pager',
545  )?.page ?? 0
546
547/** The first option on `page`; null where the page shows none. */
548export const firstOnPage = (
549  gate: Gate,
550  columns: number,
551  sends: Sends,
552  maxRows: number,
553  page: number,
554) =>
555  linesOf(gate, columns, IDLE, sends, maxRows, { page }).find(
556    (line): line is RowLine => line.kind === 'option',
557  )?.index ?? null
558
hooks/board.ts 316 lines
1/**
2 * The gate's surface module: its own keys and pointer, no `$`. A press posts
3 * the row back to the hooks module, which picks it into the prompt or, on the
4 * row already picked, sends it — holding it while Claude works, until a press
5 * takes it back; a click on a typed row only tells the person how to answer
6 * it.
7 *
8 * Colours are theme keys, never values, so the band resolves against the
9 * person's theme — the ANSI themes included, where the palette is the
10 * terminal's own and a raw colour would be ignored.
11 */
12import type { ClientSurface, RenderElement } from 'claude-code'
13
14import {
15  GLYPH_COLUMN,
16  GUTTER,
17  IDLE,
18  NO_SENDS,
19  PAGER_SPANS,
20  answerOf,
21  firstOnPage,
22  linesOf,
23  pageOf,
24  pagerRuns,
25  startingRow,
26  type Footer,
27  type Gate,
28  type Line,
29  type PagerLine,
30  type RowLine,
31  type Run,
32  type Sends,
33} from './layout.ts'
34
35type Props = Sends & {
36  gate: Gate
37  picked: string | null
38  columns: number
39  maxRows: number
40}
41
42/**
43 * The cursor, the typed row whose hint the footer shows, the page of rows
44 * showing, and the rows they belong to: another gate's rows start all three
45 * afresh.
46 */
47type State = { cursor: number; hint: string | null; page: number; rows: string }
48
49/** The prompt's own border, so the rule reads as the gate's top edge. */
50const RULE = 'promptBorder'
51/** The blue-violet the prompt and its dialogs already ask in. */
52const ACCENT = 'permission'
53const HOVERED = 'selectionBg'
54const PICKED = 'diffAddedDimmed'
55
56const GLYPH = '◆'.padEnd(GLYPH_COLUMN)
57const NO_GLYPH = ' '.repeat(GLYPH_COLUMN)
58const RULE_CELL = '─'
59const CURSOR = '▌'.padEnd(GUTTER)
60const NO_CURSOR = ' '.repeat(GUTTER)
61
62/**
63 * What the listeners read, rather than what they closed over: they are
64 * registered once, on the first draw, and a redraw can hand this instance
65 * another gate's rows.
66 */
67let shown: {
68  gate: Gate
69  lines: readonly Line[]
70  columns: number
71  sends: Sends
72  maxRows: number
73} = {
74  gate: { question: '', statement: '', options: [], typed: [] },
75  lines: [],
76  columns: 0,
77  sends: NO_SENDS,
78  maxRows: Infinity,
79}
80
81/**
82 * The footer follows the last click: a typed row's hint, else the answer
83 * held or picked, else the one a new gate kept from sending.
84 */
85function footerOf(
86  hint: string | null,
87  picked: string | null,
88  { held, dropped }: Sends,
89): Footer {
90  if (hint !== null) {
91    return { kind: 'typed', label: hint }
92  }
93
94  if (held !== null) {
95    return { kind: 'queued', answer: held }
96  }
97
98  if (picked !== null) {
99    return { kind: 'picked', answer: picked }
100  }
101
102  return dropped === null ? IDLE : { kind: 'dropped', answer: dropped }
103}
104
105export default function GateBoard(
106  { gate, picked, held, dropped, columns, maxRows }: Props,
107  surface: ClientSurface<State>,
108): RenderElement {
109  const { Box, Text } = surface.elements
110
111  if (surface.state === undefined) {
112    listen(surface)
113  }
114
115  const sends = { held, dropped }
116  const rows = JSON.stringify(gate.options)
117  const cursor = startingRow(gate.options)
118  const state =
119    surface.state?.rows === rows
120      ? surface.state
121      : {
122          cursor,
123          hint: null,
124          page: pageOf(gate, columns, sends, maxRows, cursor),
125          rows,
126        }
127
128  if (state !== surface.state) {
129    surface.setState(state)
130  }
131
132  const footer = footerOf(state.hint, picked, sends)
133  const lines = linesOf(gate, columns, footer, sends, maxRows, {
134    page: state.page,
135  })
136  const pickedRow = gate.options.findIndex(
137    option => answerOf(option) === (held ?? picked),
138  )
139
140  shown = { gate, lines, columns, sends, maxRows }
141
142  const styled = (run: Run, color?: string, backgroundColor?: string) =>
143    Text({
144      color: run.accent === true ? ACCENT : color,
145      backgroundColor,
146      bold: run.bold,
147      dimColor: run.dim,
148      italic: run.italic,
149      strikethrough: run.strikethrough,
150      underline: run.underline,
151      children: run.text,
152    })
153
154  const row = (line: RowLine) => {
155    const isOption = line.kind === 'option'
156    const isCursor = isOption && line.index === state.cursor
157    const backgroundColor =
158      isOption && line.index === pickedRow
159        ? PICKED
160        : isCursor
161          ? HOVERED
162          : undefined
163
164    return Text({
165      children: [
166        Text({
167          color: ACCENT,
168          backgroundColor,
169          children: isCursor ? CURSOR : NO_CURSOR,
170        }),
171        ...line.key.map(run =>
172          styled(run, isOption ? ACCENT : undefined, backgroundColor),
173        ),
174        ...line.runs.map(run => styled(run, undefined, backgroundColor)),
175      ],
176    })
177  }
178
179  const pager = (line: PagerLine) =>
180    Text({
181      children: [
182        Text({ children: NO_CURSOR }),
183        ...pagerRuns(line).map(run =>
184          styled(run, run.dim === true ? undefined : ACCENT),
185        ),
186      ],
187    })
188
189  const draw = (line: Line): RenderElement => {
190    switch (line.kind) {
191      case 'rule':
192        return Text({
193          color: RULE,
194          children: RULE_CELL.repeat(Math.max(1, columns)),
195        })
196      case 'blank':
197        return Text({ children: ' ' })
198      case 'prose':
199        return Text({
200          children: [
201            line.glyph
202              ? Text({ color: ACCENT, bold: true, children: GLYPH })
203              : Text({ children: NO_GLYPH }),
204            ...line.runs.map(run => styled(run)),
205          ],
206        })
207      case 'footer':
208        return Text({
209          children: [
210            Text({ children: NO_CURSOR }),
211            ...line.runs.map(run => styled(run)),
212          ],
213        })
214      case 'pager':
215        return pager(line)
216      default:
217        return row(line)
218    }
219  }
220
221  return Box({ flexDirection: 'column', children: lines.map(draw) })
222}
223
224/**
225 * Keys while the band has the focus, and the pointer over the rows: both move
226 * the cursor and press a row; a click on a typed row shows its hint, and one
227 * on the pager turns the page.
228 */
229function listen(surface: ClientSurface<State>) {
230  const update = (state: State, change: Partial<State>) => {
231    const next = { ...state, ...change }
232
233    if (
234      next.cursor !== state.cursor ||
235      next.hint !== state.hint ||
236      next.page !== state.page
237    ) {
238      surface.setState(next)
239    }
240  }
241
242  const pageHolding = (cursor: number) =>
243    pageOf(shown.gate, shown.columns, shown.sends, shown.maxRows, cursor)
244
245  // The page follows the cursor onto a row it does not show.
246  const moveTo = (state: State, cursor: number) =>
247    update(state, { cursor, page: pageHolding(cursor) })
248
249  const turnTo = (state: State, page: number) => {
250    const { gate, columns, sends, maxRows } = shown
251    const first = firstOnPage(gate, columns, sends, maxRows, page)
252
253    update(state, { page, cursor: first ?? state.cursor })
254  }
255
256  const press = (state: State, index: number) => {
257    const option = shown.gate.options[index]
258
259    if (option !== undefined) {
260      update(state, { cursor: index, hint: null, page: pageHolding(index) })
261      surface.post({ answer: answerOf(option) })
262    }
263  }
264
265  const within = (x: number, span: { from: number; to: number }) =>
266    x >= span.from && x < span.to
267
268  surface.onKey(({ key }) => {
269    const state = surface.state
270    const count = shown.gate.options.length
271
272    if (state === undefined || count === 0) {
273      return
274    }
275
276    if (key === 'up') {
277      moveTo(state, (state.cursor - 1 + count) % count)
278    } else if (key === 'down') {
279      moveTo(state, (state.cursor + 1) % count)
280    } else if (key === 'return') {
281      press(state, state.cursor)
282    } else {
283      press(
284        state,
285        shown.gate.options.findIndex(option => option.key === key),
286      )
287    }
288  })
289
290  surface.onPointer(event => {
291    const state = surface.state
292    const line = shown.lines[event.y]
293
294    if (state === undefined || line === undefined) {
295      return
296    }
297
298    if (line.kind === 'pager' && event.type === 'down') {
299      if (within(event.x, PAGER_SPANS.previous) && line.page > 0) {
300        turnTo(state, line.page - 1)
301      } else if (
302        within(event.x, PAGER_SPANS.next) &&
303        line.page < line.pages - 1
304      ) {
305        turnTo(state, line.page + 1)
306      }
307    } else if (line.kind === 'option' && event.type === 'down') {
308      press(state, line.index)
309    } else if (line.kind === 'option' && event.type === 'move') {
310      update(state, { cursor: line.index })
311    } else if (line.kind === 'typed' && event.type === 'down') {
312      update(state, { hint: shown.gate.typed[line.index]?.label ?? null })
313    }
314  })
315}
316