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…

newbandguardprompt
★ 2v0.1.0MITupdated 2026-09-27leeovery/tick/.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 is attached, it leaves the menu as text so every screen shows it, though a screen that attaches after a menu was drawn on the terminal does not get that menu. No gate's prose names the mod; only workflow-start's setup step does, when the mod is switched on but not yet 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 the first /workflow-start in a project switches it on wherever it can run: Claude Code's terminal app, from 2.1.282, with this directory installed in the project. There every /workflow-start puts CLAUDE_CODE_ENABLE_FUNCTION_HOOKS into the env of the project's .claude/settings.json wherever it is not already "1". Claude Code reads its settings only when it starts, so a start that writes it ends by asking for a restart, and the next session loads the mod; a start that finds it there with the mod not running stops and says why. Anywhere else — the web, another Claude Code app, an older version, a project without this directory — nothing is written, and the workflows carry on with the text menus.

The flag is committed, so a teammate's IDE extension, Claude Code on the web or a Claude Code older than 2.1.282 can still load the mod. At the session's start it applies the boot's rules: where CLAUDE_CODE_ENTRYPOINT is other than cli, CLAUDE_CODE_REMOTE is set, or the version the session reports is older than 2.1.282 or not a release's (a development build counts as older, as it does for the boot), it announces nothing, so it draws, keeps and sets nothing — the menus stay text, and Claude Code runs as it would without it.

What it sets in Claude Code

Every session in Claude Code's terminal app, from 2.1.282, 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. 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.

Function hooks are early access: Claude Code loads this mod only where CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 is set, in the env of any of its settings files or in the shell, and the test script sets it for itself.

Source 3 files
hooks/register.ts 994 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 also sets Claude Code's harness for the workflows: every session
21 * gets the SendUserMessage tool, kept behind ToolSearch, and a conversation
22 * the engine has marked as running the workflows goes without Claude's
23 * thinking summarised as output and without the nudge to say what it is
24 * doing, the person's own values of both put back as it ends. A conversation
25 * the engine has not marked keeps them untouched.
26 *
27 * All of it happens in Claude Code's terminal app alone, from 2.1.282.
28 * Elsewhere — an IDE extension, Claude Code on the web, an older Claude
29 * Code — the session is not announced, and the module draws, keeps and sets
30 * nothing.
31 */
32import type {
33  AgentLoop,
34  EngineInterface,
35  PromptOrigin,
36  Register,
37  SessionMessage,
38} from 'claude-code'
39
40import {
41  IDLE,
42  NO_SENDS,
43  answerOf,
44  linesOf,
45  type Gate,
46  type Option,
47  type Sends,
48} from './layout.ts'
49
50/** The payload's marker and the menu it sits directly above. */
51const GATE_MARKER = '=== GATE ('
52const MENU_MARKER = '=== MENU'
53const SECTION_MARKER = '=== '
54
55/** The `Client`'s key: what `ui.message` matches the board's posts on. */
56const ELEMENT = 'gate'
57
58/**
59 * The file in the conversation's folder where a send leaves what it answered,
60 * for a mod that draws the sent row.
61 */
62const SENT = 'sent.json'
63
64/** The record that claims no send, which that mod draws nothing from. */
65const NOTHING_SENT = 'null'
66
67/**
68 * Where each conversation that runs the workflows keeps what belongs to it,
69 * in the workflows' system config directory.
70 */
71const CONVERSATIONS = 'conversations'
72
73/** The file the engine marks a conversation that runs the workflows with. */
74const MARKER = 'workflow'
75
76/** The file a conversation's band is kept in for a resume. */
77const KEPT = 'gate.json'
78
79/** The oldest Claude Code the mod runs on, major, minor and patch. */
80const OLDEST = [2, 1, 282]
81
82/** A release's version, as `claude --version` prints it. */
83const RELEASE = /^(\d+)\.(\d+)\.(\d+)$/
84
85const STOP_NOTE =
86  "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."
87
88/**
89 * What the band holds of the conversation's gates. A turn the person starts
90 * clears all of it but the gate that turn answers; a turn anyone else starts
91 * leaves it as it is; the conversation's end clears all.
92 */
93type Band = {
94  /** The gate a render armed, waiting on the end of its turn. */
95  armed: Gate | null
96  /** The gate on the band, from a turn's end to a turn the person starts. */
97  drawn: Gate | null
98  /** The answer a press put in the prompt box: a press on its row sends it. */
99  picked: string | null
100  /** The answer held to send when Claude finishes, and one a gate dropped. */
101  sends: Sends
102  /**
103   * Whether the submission that opens the next turn is the person's; a turn
104   * no submission opened counts as theirs.
105   */
106  isOpenedByPerson: boolean
107  /** Whose the running turn is, the person's or anyone else's; null idle. */
108  running: 'person' | 'other' | null
109  /** The gate on the band when the person's running turn began. */
110  answering: Gate | null
111  /** Whether the running turn has called a tool. */
112  hasCalledTool: boolean
113}
114
115const emptyBand = (): Band => ({
116  armed: null,
117  drawn: null,
118  picked: null,
119  sends: NO_SENDS,
120  isOpenedByPerson: true,
121  running: null,
122  answering: null,
123  hasCalledTool: false,
124})
125
126/**
127 * What a turn's end leaves for the prompt box: a held answer's row, sent now
128 * or handed back as a pick; and the text of a pick whose gate went, which
129 * the box may still hold.
130 */
131type Settled = {
132  held: { option: Option; isToSend: boolean } | null
133  gone: string | null
134}
135
136const NOTHING_SETTLED: Settled = { held: null, gone: null }
137
138/** Whether two gates state the same question over the same rows. */
139const isSameGate = (gate: Gate | null, other: Gate | null) =>
140  JSON.stringify(gate) === JSON.stringify(other)
141
142/**
143 * The gate a Bash result's stdout states directly above its menu, and that
144 * stdout with the payload taken out, which is this module's input alone:
145 * `text` keeps the menu as the engine wrote it, `cut` puts the instruction to
146 * stop in its place. Null where the stdout states no gate.
147 */
148function gateIn(
149  stdout: string,
150): { gate: Gate; text: string; cut: string } | null {
151  if (!stdout.includes(GATE_MARKER)) {
152    return null
153  }
154
155  const lines = stdout.split('\n')
156  const at = lines.findIndex(line => line.startsWith(GATE_MARKER))
157
158  if (at === -1) {
159    return null
160  }
161
162  const payload = lines[at + 1]
163  const menu = lines[at + 2]
164
165  if (payload === undefined || !(menu ?? '').startsWith(MENU_MARKER)) {
166    return null
167  }
168
169  const { gate: name, ...gate } = JSON.parse(payload) as Gate & { gate: string }
170  const before = lines.slice(0, at)
171  const after = lines.findIndex(
172    (line, n) => n > at + 2 && line.startsWith(SECTION_MARKER),
173  )
174
175  return {
176    gate,
177    text: [...before, ...lines.slice(at + 2)].join('\n'),
178    cut: [
179      ...before,
180      `=== MENU: ${name} (drawn above the prompt — do NOT emit it; stop and wait) ===`,
181      STOP_NOTE,
182      ...(after === -1 ? [''] : lines.slice(after)),
183    ].join('\n'),
184  }
185}
186
187/**
188 * Whether `version` is a release the mod runs on; one that does not read as
189 * a release, a development build's included, counts as older.
190 */
191function isSupported(version: string): boolean {
192  const release = RELEASE.exec(version)
193
194  if (release === null) {
195    return false
196  }
197
198  for (const [n, oldest] of OLDEST.entries()) {
199    const part = Number(release[n + 1])
200
201    if (part !== oldest) {
202      return part > oldest
203    }
204  }
205
206  return true
207}
208
209/**
210 * Whether the mod applies to the session, read as the engine's boot reads
211 * it: Claude Code's terminal app — the `cli` entrypoint, not Claude Code on
212 * the web — at a version the mod runs on. The flag that loads the mod is
213 * committed, so a teammate's IDE extension, the web or an older Claude Code
214 * can load it too.
215 */
216async function isApplicable($: EngineInterface): Promise<boolean> {
217  if (
218    (await $.env.get('CLAUDE_CODE_ENTRYPOINT')) !== 'cli' ||
219    (await $.env.get('CLAUDE_CODE_REMOTE'))
220  ) {
221    return false
222  }
223
224  return isSupported((await $.session.version()).version)
225}
226
227/**
228 * Whether the session announces the gate surface: set at its start, and
229 * still set after a reload of the module. The band is kept and read back,
230 * and the harness put on, only where it is, so a process whose session
231 * never announced — one the mod does not apply to, or one this module was
232 * loaded into after it started — never gets either.
233 */
234async function isAnnounced($: EngineInterface): Promise<boolean> {
235  return (await $.env.get('WORKFLOWS_GATE_SURFACE')) === '1'
236}
237
238/** Whether an event is the conversation's own, not a subagent's loop. */
239const inConversation = (e: AgentLoop) => e.agentId === undefined
240
241/**
242 * Whether the band takes a gate a Bash call stated: the conversation's own
243 * call, not a subagent's; and the terminal the session's only screen, since
244 * the band is the terminal's and any other screen shows the menu as text
245 * alone.
246 */
247async function isForBand($: EngineInterface, e: AgentLoop): Promise<boolean> {
248  if (!inConversation(e)) {
249    return false
250  }
251
252  const surfaces = await $.session.surfaces()
253
254  return surfaces.length === 1 && surfaces[0] === 'terminal'
255}
256
257/**
258 * Whether a submission is the person's: their Enter at the prompt, their
259 * message through Remote Control, or this plugin sending their press. One
260 * with no origin is the person's own, as the engine reads it.
261 */
262const isPersons = (origin: PromptOrigin | undefined, plugin: string) =>
263  origin === undefined ||
264  origin.kind === 'composer' ||
265  origin.kind === 'bridge' ||
266  (origin.kind === 'plugin' && origin.name === plugin)
267
268/** The row a post names, or null when it names none of the gate's. */
269function optionIn(gate: Gate, data: unknown): Option | null {
270  const said = (data as { answer?: unknown } | null)?.answer
271
272  return typeof said === 'string'
273    ? (gate.options.find(option => answerOf(option) === said) ?? null)
274    : null
275}
276
277/** Puts `text` in the prompt box in place of what is there; whether it took. */
278async function fill($: EngineInterface, text: string): Promise<boolean> {
279  const { isFilled } = await $.prompt.fill({ text, mode: 'replace' })
280
281  return isFilled
282}
283
284/**
285 * Sends a row as the next message, the send recorded in the conversation's
286 * folder where it has one; whether it entered. A send that fails or is
287 * dropped leaves no send recorded.
288 */
289async function submit(
290  $: EngineInterface,
291  gate: Gate,
292  option: Option,
293): Promise<boolean> {
294  const answer = answerOf(option)
295  const folder = await folderOf($, await $.session.id())
296  const record = async (text: string) => {
297    if (folder !== null) {
298      await $.fs.write(inFolder(folder, SENT), text)
299    }
300  }
301  let isSent = false
302
303  try {
304    await record(
305      JSON.stringify({ answer, question: gate.question, label: option.head }),
306    )
307    // Framed for the model and labelled on screen as this plugin's, by design.
308    const { drop } = await $.prompt.submit({ text: answer })
309
310    isSent = drop === undefined
311  } finally {
312    if (!isSent) {
313      await record(NOTHING_SENT)
314    }
315  }
316
317  return isSent
318}
319
320/**
321 * Empties the prompt box while it holds exactly `text`, a pick's answer; a
322 * draft the person has edited stays.
323 */
324async function unpick($: EngineInterface, text: string) {
325  const box = await $.prompt.read()
326
327  if (box.text === text) {
328    await fill($, '')
329  }
330}
331
332/**
333 * Sends the picked row, the box cleared first; a send that fails or is
334 * dropped puts the answer back in the box, where the pick says it is.
335 */
336async function send($: EngineInterface, gate: Gate, option: Option) {
337  let isSent = false
338
339  await fill($, '')
340
341  try {
342    isSent = await submit($, gate, option)
343  } finally {
344    if (!isSent) {
345      await fill($, answerOf(option))
346    }
347  }
348}
349
350/**
351 * Where a conversation stands: its session id, which names its folder, and
352 * the stamp of the step its transcript ends on.
353 */
354type Place = { id: string; stamp: string }
355
356/** What a conversation's folder keeps of its band: its gate, and where the transcript ended. */
357type Kept = { stamp: string; gate: Gate }
358
359/** A band read back from its folder: where the conversation stands, its gate. */
360type ReadBack = { place: Place; gate: Gate | null }
361
362/**
363 * A read-back the band owes before it is trusted, never taken from `ended`:
364 * the conversation that ended in this process, while the transcript still
365 * holds it.
366 */
367type Owed = { ended: Place | null }
368
369/** The tool calls a message makes or answers, by id. */
370const callsOf = (message: SessionMessage) => [
371  ...message.toolUses.map(use => use.tool_use_id),
372  ...(message.toolResults ?? []).map(result => result.tool_use_id),
373]
374
375/**
376 * The lines Claude Code writes around an interrupted turn rather than the
377 * conversation taking a step: the interruption itself, which can land after
378 * that turn's end has kept the band, and the reply it puts in the model's
379 * place when the conversation is resumed after one. No stamp reads them.
380 */
381const isInterruption = (message: SessionMessage) =>
382  (message.role === 'user' &&
383    message.text.startsWith('[Request interrupted by user')) ||
384  (message.role === 'assistant' &&
385    message.text === 'No response requested.' &&
386    message.toolUses.length === 0)
387
388/**
389 * Where the conversation stands now; null until it takes a step. The stamp
390 * is the last step — who wrote it, its text, the calls it makes or answers —
391 * and the newest call of all, so a conversation that moved on and came to
392 * rest on the same words is told apart.
393 */
394async function placeOf($: EngineInterface): Promise<Place | null> {
395  const id = await $.session.id()
396  const steps = (await $.session.messages()).filter(
397    message => !isInterruption(message),
398  )
399  const last = steps.at(-1)
400
401  if (last === undefined) {
402    return null
403  }
404
405  const calls = steps.flatMap(callsOf)
406
407  return {
408    id,
409    stamp: JSON.stringify([
410      last.role,
411      last.text,
412      callsOf(last),
413      calls.at(-1) ?? null,
414    ]),
415  }
416}
417
418const isSamePlace = (place: Place, other: Place | null) =>
419  other !== null && place.id === other.id && place.stamp === other.stamp
420
421/** Whether `place` is the conversation `seen` read, however far on. */
422const isSameConversation = (place: Place | null, seen: Place | null) =>
423  place !== null && seen !== null && place.id === seen.id
424
425const isKept = (value: unknown): value is Kept =>
426  typeof value === 'object' &&
427  value !== null &&
428  typeof (value as Kept).stamp === 'string'
429
430/**
431 * The folder of the conversation `id`, which the engine names by the id's
432 * safe characters alone, in the workflows' system config directory:
433 * `WORKFLOWS_CONFIG_DIR`, else `.config/workflows` in the home directory.
434 * Found by the id, so a `cd` or a resume elsewhere finds it all the same;
435 * null where the process names neither directory.
436 */
437async function folderOf(
438  $: EngineInterface,
439  id: string,
440): Promise<string | null> {
441  const home = await $.env.get('HOME')
442  const config =
443    (await $.env.get('WORKFLOWS_CONFIG_DIR')) ||
444    (home ? `${home}/.config/workflows` : null)
445
446  return config === null
447    ? null
448    : `${config}/${CONVERSATIONS}/${id.replace(/[^A-Za-z0-9_-]/g, '')}`
449}
450
451/** A file in a conversation's folder. */
452const inFolder = (folder: string, file: string) => `${folder}/${file}`
453
454/**
455 * The folder of the conversation `id` where the engine has marked it as one
456 * that runs the workflows; null for any other conversation.
457 */
458async function markedFolder(
459  $: EngineInterface,
460  id: string,
461): Promise<string | null> {
462  const folder = await folderOf($, id)
463
464  return folder !== null && (await $.fs.exists(inFolder(folder, MARKER)))
465    ? folder
466    : null
467}
468
469/**
470 * Keeps what the band shows for the conversation at `place` — the gate, or
471 * nothing — in its folder, in a session that announced and a conversation
472 * that runs the workflows: any other conversation gets no folder.
473 */
474async function keep($: EngineInterface, place: Place | null, gate: Gate | null) {
475  if (place === null || !(await isAnnounced($))) {
476    return
477  }
478
479  const folder = await markedFolder($, place.id)
480
481  if (folder === null) {
482    return
483  }
484
485  const kept: Kept | null = gate === null ? null : { stamp: place.stamp, gate }
486
487  await $.fs.write(inFolder(folder, KEPT), JSON.stringify(kept))
488}
489
490/** What `folder` keeps of its band; undefined where it keeps nothing readable. */
491async function keptIn($: EngineInterface, folder: string): Promise<unknown> {
492  try {
493    return JSON.parse(await $.fs.read(inFolder(folder, KEPT)))
494  } catch {
495    return undefined
496  }
497}
498
499/**
500 * The person's own values of the settings the harness sets, absent where
501 * unset: what the harness replaced, kept while it is on.
502 */
503type Replaced = {
504  CLAUDE_CODE_THINKING_DISPLAY_UPDATES?: string
505  CLAUDE_CODE_SILENT_TURN_REMINDER?: string
506}
507
508/**
509 * Puts Claude Code's harness on for a workflow session, one whose
510 * conversation the engine has marked in a session that announced: no
511 * summary of Claude's thinking printed as if it were output, and no nudge to
512 * say what it is doing. Claude Code reads both per request. What it replaces
513 * is kept in the process's environment, which a reload of the module's files
514 * keeps, and only where nothing is kept yet: the values are read before that
515 * is looked at, so a harness another call has just put on is never kept as
516 * the person's.
517 */
518async function harnessOn($: EngineInterface) {
519  if (!(await isAnnounced($))) {
520    return
521  }
522
523  const replaced: Replaced = {
524    CLAUDE_CODE_THINKING_DISPLAY_UPDATES: await $.env.get(
525      'CLAUDE_CODE_THINKING_DISPLAY_UPDATES',
526    ),
527    CLAUDE_CODE_SILENT_TURN_REMINDER: await $.env.get(
528      'CLAUDE_CODE_SILENT_TURN_REMINDER',
529    ),
530  }
531
532  if ((await $.env.get('WORKFLOWS_HARNESS_REPLACED')) === undefined) {
533    await $.env.set('WORKFLOWS_HARNESS_REPLACED', JSON.stringify(replaced))
534  }
535
536  await $.env.set('CLAUDE_CODE_THINKING_DISPLAY_UPDATES', 'false')
537  await $.env.set('CLAUDE_CODE_SILENT_TURN_REMINDER', 'false')
538}
539
540/**
541 * Takes the harness off, putting back exactly what it replaced: the person's
542 * value, or none. Where the harness is not on, nothing is touched.
543 */
544async function harnessOff($: EngineInterface) {
545  const kept = await $.env.get('WORKFLOWS_HARNESS_REPLACED')
546
547  if (kept === undefined) {
548    return
549  }
550
551  const replaced = JSON.parse(kept) as Replaced
552
553  await $.env.set(
554    'CLAUDE_CODE_THINKING_DISPLAY_UPDATES',
555    replaced.CLAUDE_CODE_THINKING_DISPLAY_UPDATES,
556  )
557  await $.env.set(
558    'CLAUDE_CODE_SILENT_TURN_REMINDER',
559    replaced.CLAUDE_CODE_SILENT_TURN_REMINDER,
560  )
561  await $.env.set('WORKFLOWS_HARNESS_REPLACED', undefined)
562}
563
564/**
565 * Settles the read-back the band owes, if `owing` says it owes one: `take`
566 * gets the conversation the transcript holds now, with its kept gate where
567 * the transcript still ends where it was kept, or none where it has moved
568 * on, the kept gate dropped while the read-back is still owed. A
569 * conversation the engine has marked gets the workflow harness back, again
570 * while the read-back is still owed. Nothing settles in a session that did
571 * not announce, while the transcript is empty, or while it still holds the
572 * conversation that ended in this process.
573 */
574async function readBack(
575  $: EngineInterface,
576  owing: () => Owed | null,
577  take: (asked: Owed, read: ReadBack) => void,
578) {
579  const asked = owing()
580
581  if (asked === null || !(await isAnnounced($))) {
582    return
583  }
584
585  const place = await placeOf($)
586
587  if (place === null || isSamePlace(place, asked.ended)) {
588    return
589  }
590
591  const folder = await markedFolder($, place.id)
592
593  if (folder !== null && owing() === asked) {
594    await harnessOn($)
595  }
596
597  const kept = folder === null ? undefined : await keptIn($, folder)
598
599  if (isKept(kept) && kept.stamp === place.stamp) {
600    take(asked, { place, gate: kept.gate })
601
602    return
603  }
604
605  if (folder !== null && isKept(kept) && owing() === asked) {
606    await $.fs.write(inFolder(folder, KEPT), JSON.stringify(null))
607  }
608
609  take(asked, { place, gate: null })
610}
611
612export const register: Register = on => {
613  let band = emptyBand()
614
615  /** Whether a send is under way, so a press meanwhile cannot send twice. */
616  let isSending = false
617
618  /**
619   * The read-back the band owes: from the module's load, which a restart or
620   * a reload of its files begins, and from a conversation's end in this
621   * process, until the next turn or a read settles it.
622   */
623  let owed: Owed | null = { ended: null }
624
625  /** Where the conversation stood when the band was last kept or read back. */
626  let seen: Place | null = null
627
628  /**
629   * The person takes the turn: the band comes down, keeping the gate their
630   * turn answers. Whether a gate came down.
631   */
632  const takeDown = (): boolean => {
633    const { drawn } = band
634
635    band = { ...emptyBand(), running: 'person', answering: drawn }
636
637    return drawn !== null
638  }
639
640  /**
641   * Settles the band as the conversation's turn ends. The person's turn
642   * draws the gate it rendered; an Esc drops that and takes the answer back,
643   * its gate returning, until the turn calls a tool, which may already have
644   * acted on the answer: a read and a write look alike from here. Anyone
645   * else's turn leaves the band as it is unless it rendered another gate: at
646   * its end that gate takes the band and drops a held answer unsent; after an
647   * Esc, which leaves the model at that gate's stop, the band empties. A held
648   * answer on the gate still there sends now, or after an Esc goes back to
649   * the prompt as a pick; a pick whose gate went goes with it.
650   */
651  const endTurn = (isInterrupted: boolean): Settled => {
652    const { running, armed, drawn, picked, answering, hasCalledTool, sends } =
653      band
654
655    band = { ...band, armed: null, running: null, answering: null }
656
657    if (running !== 'other') {
658      band.drawn = isInterrupted ? (hasCalledTool ? null : answering) : armed
659
660      return NOTHING_SETTLED
661    }
662
663    const gate = armed ?? drawn
664
665    if (!isSameGate(gate, drawn)) {
666      band = isInterrupted
667        ? { ...band, drawn: null, picked: null, sends: NO_SENDS }
668        : {
669            ...band,
670            drawn: gate,
671            picked: null,
672            sends: { held: null, dropped: sends.held },
673          }
674
675      return { held: null, gone: picked }
676    }
677
678    band.sends = { ...sends, held: null }
679
680    const option =
681      drawn === null || sends.held === null
682        ? null
683        : optionIn(drawn, { answer: sends.held })
684
685    return {
686      held: option === null ? null : { option, isToSend: !isInterrupted },
687      gone: null,
688    }
689  }
690
691  const owing = () => owed
692
693  /**
694   * Takes a read-back onto the band, unless a turn or a conversation's end
695   * overtook it while it read: the conversation it read is the one the band
696   * follows from here.
697   */
698  const takeBack = (asked: Owed, read: ReadBack) => {
699    if (owed !== asked) {
700      return
701    }
702
703    owed = null
704    seen = read.place
705
706    if (read.gate !== null) {
707      band.drawn = read.gate
708    }
709  }
710
711  // Announced, never always-on: the engine collects a gate only for a session
712  // that asked for one, and every Bash child inherits this. Where the mod
713  // does not apply nothing is announced, which leaves it inert there. A
714  // fresh load comes back to a conversation this module has not followed, so
715  // the band is read back from the conversation's folder.
716  on('session.start', async ($, e, next) => {
717    if (!(await isApplicable($))) {
718      return next(e)
719    }
720
721    await $.env.set('WORKFLOWS_GATE_SURFACE', '1')
722
723    // Claude Code builds its tool catalogue just after this hook, so only
724    // here does the switch that gives the session SendUserMessage count.
725    await $.env.set('CLAUDE_CODE_PEWTER_OWL_TOOL', 'true')
726
727    owed = { ended: null }
728    await readBack($, owing, takeBack)
729
730    if (band.drawn !== null) {
731      $.ui.invalidate('ui.render')
732    }
733
734    return next(e)
735  }).catch(($, e, next) => next(e))
736
737  // A /clear or a resume goes on in this process as another conversation,
738  // which no gate of this one answers and which is no workflow session until
739  // the engine marks it or it is read back as one it has marked. What the
740  // band showed is kept for this one first, stamped where its transcript
741  // ends now, which can have moved since its last turn's end.
742  on('session.end', async ($, e, next) => {
743    try {
744      const place = await placeOf($)
745
746      if (isSameConversation(place, seen)) {
747        await keep($, place, band.drawn)
748        seen = place
749      }
750    } finally {
751      band = emptyBand()
752      owed = { ended: seen }
753      seen = null
754      $.ui.invalidate('ui.render')
755      await harnessOff($)
756    }
757
758    return next(e)
759  }).catch(($, e, next) => next(e))
760
761  on('tool.call', ($, e, next) => {
762    if (inConversation(e)) {
763      band.hasCalledTool = true
764    }
765
766    return next(e)
767  }).catch(($, e, next) => next(e))
768
769  // Every engine call marks the conversation that made it, whatever its exit,
770  // so after each of the conversation's own commands the mark says whether it
771  // runs the workflows; a command that only mentions the engine marks nothing.
772  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
773    const result = await next(e)
774
775    if (
776      inConversation(e) &&
777      (await markedFolder($, await $.session.id())) !== null
778    ) {
779      await harnessOn($)
780    }
781
782    if (result.deny !== undefined || result.isError === true) {
783      return result
784    }
785
786    const record = result.result
787    const stated = gateIn(record.stdout)
788
789    if (stated === null) {
790      return result
791    }
792
793    const isArmed = await isForBand($, e)
794
795    if (isArmed) {
796      band.armed = stated.gate
797    }
798
799    return {
800      result: { ...record, stdout: isArmed ? stated.cut : stated.text },
801    }
802  }).catch(($, e, next) => next(e))
803
804  // The tool waits behind ToolSearch in every session that announced,
805  // workflow or not: one answer, since a changed answer sends the tool list
806  // again and spends the prompt cache.
807  on('tool.describe', { tool: 'SendUserMessage' }, async ($, e, next) => {
808    const described = await next(e)
809
810    return (await isAnnounced($))
811      ? { ...described, isDeferred: true }
812      : described
813  }).catch(($, e, next) => next(e))
814
815  // Drawn at the turn's end, once what the rows choose between is on screen,
816  // and kept with where the transcript ends, which a resume must still match.
817  // A held answer is not kept: it waits on a turn no resume brings back. It
818  // sends once the turn is over, and one not sent is put back as a pick; the
819  // answer of a pick whose gate went leaves the prompt box.
820  on('turn.complete', async ($, e, next) => {
821    if (!inConversation(e)) {
822      return next(e)
823    }
824
825    const settled = endTurn(e.isAborted)
826    const { drawn } = band
827
828    owed = null
829
830    if (drawn !== null) {
831      $.ui.invalidate('ui.render')
832    }
833
834    const place = await placeOf($)
835
836    await keep($, place, drawn)
837    seen = place
838
839    const answered = await next(e)
840    const { held, gone } = settled
841
842    if (gone !== null) {
843      await unpick($, gone)
844    }
845
846    if (held !== null && drawn !== null) {
847      const answer = answerOf(held.option)
848      let isSent = false
849
850      isSending = true
851
852      try {
853        isSent = held.isToSend && (await submit($, drawn, held.option))
854      } finally {
855        isSending = false
856
857        if (!isSent && (await fill($, answer))) {
858          band.picked = answer
859          $.ui.invalidate('ui.render')
860        }
861      }
862    }
863
864    return answered
865  }).catch(($, e, next) => next(e))
866
867  // A drawing while a read-back is owed — a reload's first, the first after
868  // a conversation's end, one before the transcript was there to read —
869  // settles it first.
870  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
871    await readBack($, owing, takeBack)
872
873    const gate = band.drawn
874
875    if (gate === null || e.props.hasSurvey || e.surface !== 'terminal') {
876      return next(e)
877    }
878
879    const beneath = await next(e)
880
881    try {
882      const { Box, Client } = await $.ui.resolve(e)
883      const { bodyColumns: columns, maxRows } = e.props
884
885      return Box({
886        flexDirection: 'column',
887        children: [
888          // The module's path is read off this source, so it is a literal;
889          // the region must be exactly as tall as the drawing, or the pointer
890          // cannot reach the rows past its edge.
891          h(Client, {
892            key: ELEMENT,
893            module: './board.ts',
894            width: columns,
895            height: linesOf(gate, columns, IDLE, band.sends, maxRows).length,
896            props: {
897              gate,
898              picked: band.picked,
899              ...band.sends,
900              columns,
901              maxRows,
902            },
903          }),
904          beneath,
905        ],
906      })
907    } catch {
908      return beneath
909    }
910  }).catch(($, e, next) => next(e))
911
912  // The board has no `$`: a press posts here, and this picks its row or, on
913  // the row already picked, sends it. The turn a send opens takes the gate
914  // off the band; a send that fails or is dropped leaves the row there to
915  // press again. While anyone else's turn runs, the send is held, its answer
916  // out of the prompt box, until that turn ends; a press on the held row
917  // takes it back to a pick, and a press on another row picks that instead.
918  on('ui.message', { element: ELEMENT }, async ($, e, next) => {
919    const gate = band.drawn
920    const option = gate === null || isSending ? null : optionIn(gate, e.data)
921
922    if (gate === null || option === null) {
923      return next(e)
924    }
925
926    const answer = answerOf(option)
927
928    if (answer !== band.picked) {
929      if (await fill($, answer)) {
930        band.picked = answer
931        band.sends = { ...band.sends, held: null }
932        $.ui.invalidate('ui.render')
933      }
934    } else if (band.running === 'other') {
935      if (await fill($, '')) {
936        band.picked = null
937        band.sends = { ...band.sends, held: answer }
938        $.ui.invalidate('ui.render')
939      }
940    } else {
941      isSending = true
942
943      try {
944        await send($, gate, option)
945      } finally {
946        isSending = false
947      }
948    }
949
950    return next(e)
951  }).catch(($, e, next) => next(e))
952
953  // A submission made while the session idles opens the next turn; one made
954  // over a running turn joins it, and the person's makes anyone else's turn
955  // theirs, which takes the band down.
956  on('prompt.submit', ($, e, next) => {
957    const isTheirs = isPersons(e.origin, $.plugin.name)
958
959    if (e.turnId === undefined) {
960      band.isOpenedByPerson = isTheirs
961    } else if (isTheirs && band.running === 'other') {
962      if (takeDown()) {
963        $.ui.invalidate('ui.render')
964      }
965    }
966
967    return next(e)
968  }).catch(($, e, next) => next(e))
969
970  // A gate lives from its render to the turn the person starts to answer it,
971  // which keeps it to put back; a gate the conversation re-presents is armed
972  // again by its own render. Anyone else's turn — an agent's report, a
973  // notification, a schedule — leaves the band live, its rows still to
974  // press. The band follows the conversation the turn runs in, owing no
975  // read-back.
976  on('turn.start', ($, e, next) => {
977    owed = null
978
979    if (!band.isOpenedByPerson) {
980      band = {
981        ...band,
982        armed: null,
983        isOpenedByPerson: true,
984        running: 'other',
985        hasCalledTool: false,
986      }
987    } else if (takeDown()) {
988      $.ui.invalidate('ui.render')
989    }
990
991    return next(e)
992  }).catch(($, e, next) => next(e))
993}
994
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