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…

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.
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.
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.
hooks/register.ts 994 lines1/**
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}
994hooks/layout.ts 558 lines1/**
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
558hooks/board.ts 316 lines1/**
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