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