Work through (many) decisions without losing track, at your own pace and in your own order. Every decision Claude asks for shows in one bar above the prompt…

Work through (many) decisions without losing track, at your own pace and in your own order. A Claude Code mod.
When Claude needs your input, the questions no longer get buried in its reply. Every decision it asks for goes into one bar above the prompt, and shows in context in the chat as well, the two kept in sync. Each stays clearly open until you answer it or it no longer applies. Meanwhile you keep working: dig into one, ask Claude to change something, come back to the rest. Then agree, disagree or add a remark, and send one or all at once.

At the prompt of a terminal session:
/plugin install decision-bar --marketplace michielwittkampf/decision-bar
From a shell:
claude plugin install decision-bar --marketplace michielwittkampf/decision-bar
In the desktop app:
Click + next to the prompt box, then Plugins → Add marketplace → michielwittkampf/decision-bar → Add plugin
Works in the desktop app (Code tab) and in the terminal, and on mobile with buttons only. Not yet in a side chat of the desktop app, where Claude asks in the chat instead (#103429).

/decisions.

Everything stays on your machine: the mod makes no network requests and reads no credentials. The short version is in PRIVACY.md; the details are below.
prompt.compose): when to put decisions in the bar, how to word them, and the form the answers come back in. It also asks Claude to put the question it ends a turn with in the bar rather than in the desktop app's next-step box: the app hides the bar while that box asks something, and the box's question is gone once you send anything else. With the setting closingQuestionInBar off (in /config), only while decisions are open. It leaves everything else Claude is told as it is.decisions tool and answers it itself (tool.call): it puts the proposals in the bar and tells Claude which decisions are open, whether you can see the bar, and, for a new chat language, which of the bar's texts to translate. Every other tool call passes through unchanged.$.prompt.submit), to the same conversation. It holds your answers and what they answer: a line per decision, <label> <agree|disagree>: <proposal> or <label> (no decision yet), with your remark on its own line below. Label and proposal are the ones Claude put in the bar, each cut to one plain line of limited length, without invisible characters (joiners and direction marks that scripts and emoji need stay), and a label cannot start with /, ! or @. Agree, disagree and remark follow the chat language; the line shape itself is fixed.prompt.submit, prompt.edit) are only read to keep a remark you were typing./decisions command (command.run) brings the decisions you hid last back into the bar.Findings of the plugin directory's scan, and why they are what they are. The code says the same next to each place the scan points at.
session.surfaces) only decides whether Claude is told to ask in its reply as well, and never goes into a message. See What the mod does.Things the mod works around today, as feedback for the Claude Code team and for other mod authors, each with its issue on anthropics/claude-code where one is filed. The decision bar itself is proposed as a built-in feature in #103281.
wrap_up) asks a question with answer buttons, the app does not show the band above the prompt, although it asks the mod to draw it and hasSurvey is false. All open decisions are out of sight exactly when Claude waits for an answer. The mod therefore tells Claude to ask in the bar instead while decisions are open; a band that stays visible beside the box would let both be used.Input at a fixed width on one line. One that widens to the room on the line and then wraps onto more lines as you type would show the whole remark while you write it, without a separate full-remark line below.ui.focus when Tab moves through the band above the prompt and when focus leaves it, but not when you click into an Input. The mod commits a typed remark as soon as focus moves on; after a click into another field it has to wait until you type there.Input's cursor where it was, but the desktop app scrolls the field back to the start of its text, so once a remark is wider than the field you no longer see where you type. The mod therefore keeps typed text out of shared state until your next action. A field that keeps its scroll across redraws would let the counts and the full remark update live.isExpanded on it, so a decision box next to another tool call is hidden. Claude is told to keep text between them.| Path | What |
|---|---|
hooks/register.tsx | the tool, Claude's instructions, state and the bar |
hooks/decision-bar.test.tsx | behaviour tests (terminal and desktop) |
types/index.d.ts | type contract of the state |
.claude-plugin/ | manifest, marketplace file and the directory icon |
claude --plugin-dir ., or install it from the folder: claude plugin marketplace add . and claude plugin install decision-bar@michielwittkampf.claude plugin validate . and claude plugin test ../reload-plugins picks up the change.The plugin-authoring skill in Claude Code has the mod API for your version. Things to know:
drafts, not in $.state, and is committed by commitDrafts on any other action; reading goes through withDrafts. Keep it that way. Writing $.state on every keystroke redraws the field: the cursor stays where it was, but the desktop app scrolls the field back to the start of its text, so a remark wider than the field is typed out of sight. A test with short text does not show this.e (e.proposals); in tests $.tool.call({ tool, proposals }).isVisible: the app asked this mod itself to draw its bar or a chat box (drawnOn) on a surface that $.session.surfaces() still lists. The list alone is not enough: it holds every client of the session, also one there for another mod's pane or another feature. In tests, answer session.surfaces with { value: [...] }.next is reserved inside a hooks module: an inner variable named next stops it from loading.$.store; setup in the tests provides one.-Infinity from Math.max() over an empty list) is replaced by the app's own drawing without any visible error in an installed plugin: the reason is only in the debug log (claude --debug). Run the checkout with --plugin-dir . to see refusals as a line in the transcript, and in tests $.ui.mount rejects with the reason.MIT — see LICENSE.
hooks/register.tsx 1086 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, LastHidden, Proposal, Row } from '../types'
5
6const TOOL = 'decisions'
7const TOOL_FULL = 'mcp__decision-bar__decisions'
8const COMMAND = 'decisions'
9// The engine may report the tool with a prefix, but the server part must be exactly decision-bar,
10// so a tool of another server whose name merely ends the same is not taken for this one.
11const OUR_TOOL = new RegExp(`(?:^|_)decision-bar__${TOOL}$`)
12const isOurTool = (name: string) => name === TOOL_FULL || OUR_TOOL.test(name)
13
14// The bar's rows, in order: each proposal with its choice (agree, disagree, or nothing yet: null)
15// and its committed remark. One atom, so a proposal and its choice and remark cannot drift apart.
16const rows = atom({ plugin: 'decision-bar', key: 'rows' } as const, [] as Row[])
17// The last remark sent per open decision (key: `rowKey(p)`): a confirmation in the row, and the
18// same remark is not sent twice.
19const sentRemarks = atom({ plugin: 'decision-bar', key: 'sentRemarks' } as const, {} as Record<string, string>)
20// Labels the user dismissed with Hide: a later call does not bring them back.
21const hiddenLabels = atom({ plugin: 'decision-bar', key: 'hiddenLabels' } as const, [] as string[])
22// The last hidden group, to restore; `show` is the row with the Restore button.
23const lastHidden = atom({ plugin: 'decision-bar', key: 'lastHidden' } as const, null as LastHidden | null)
24const collapsed = atom({ plugin: 'decision-bar', key: 'collapsed' } as const, false)
25// Proposals (`rowKey`) whose field is being edited: their full-remark line is struck through,
26// as visibly stale text next to the field.
27const editing = atom({ plugin: 'decision-bar', key: 'editing' } as const, [] as string[])
28// The decision sent per proposal (`rowKey`), so its box in the chat can show what was answered.
29const sentDecisions = atom({ plugin: 'decision-bar', key: 'answered' } as const, {} as Record<string, 'agree' | 'disagree'>)
30// The remark sent with that decision, if any, shown under it in the box.
31const sentAnswerRemarks = atom({ plugin: 'decision-bar', key: 'answeredRemarks' } as const, {} as Record<string, string>)
32
33// The bar's own texts. Only English is built in; any other chat language gets them once from
34// Claude (`translation` in the tool) and the mod keeps them per language in `$.store`.
35// `{name}` is filled in; a translation must keep the same `{names}`.
36const EN = {
37 heading: 'Decisions',
38 agree: 'agree',
39 disagree: 'disagree',
40 open: '{n} open',
41 chosen: '{n} chosen',
42 decided: '{n} decided',
43 and: 'and',
44 remarkField: 'remark (optional)',
45 send: 'send',
46 remarkSent: '✓ remark sent',
47 sendAll: 'Send all',
48 sendOne: 'Send',
49 agreeAll: 'Agree to all',
50 agreeOne: 'Agree',
51 hide: 'Hide',
52 hiddenOne: '1 decision hidden',
53 hiddenMany: '{n} decisions hidden',
54 restoreLater: '(restore later with /{command})',
55 restore: 'Restore',
56 close: 'Close',
57 closed: 'closed',
58 nothingToSendDecision: 'Nothing to send: choose agree or disagree, or type a remark.',
59 messageDecision: '{label} {decision}: {proposal}',
60 undecided: '{label} (no decision yet)',
61 messageRemark: 'Remark: {remark}',
62 commandRestored: '{n} decision(s) back above the prompt.',
63 commandEmpty: 'No hidden decisions to restore.',
64} as const
65type TextKey = keyof typeof EN
66type Translate = (k: TextKey, vars?: Record<string, string | number>) => string
67
68// The bar's texts in the chat language; empty = English. Every session starts in English; Claude
69// reports another chat language with a call holding only `language`.
70const texts = atom({ plugin: 'decision-bar', key: 'texts' } as const, {} as Record<string, string>)
71const storeKey = (code: string) => `translation:${code}`
72
73const translator =
74 (table: Record<string, string>): Translate =>
75 (k, vars = {}) =>
76 (table[k] ?? EN[k]).replace(/\{(\w+)\}/g, (m, name: string) => (name in vars ? String(vars[name]) : m))
77const currentTranslator = async ($: EngineInterface) => translator(await read($, texts))
78
79const placeholders = (text: string) => [...text.matchAll(/\{(\w+)\}/g)].map(m => m[1]).sort().join(',')
80
81// Why the checks below. What Claude puts in the bar (labels, proposals, translations of the bar's
82// texts) leaves as a message from the user when they press Send. A user message carries the user's
83// authority: Claude, Claude Code and its permission modes take it as instruction and consent,
84// while text Claude wrote or read is only data. If Claude was steered by text it read (prompt
85// injection), the bar must not turn that into consent the user never saw. So a press means what
86// the row shows: one plain line per answer, nothing hidden, nothing that later changes on its own.
87// The limits are generous for normal use and only bound how much text can travel this way.
88
89// "nl-BE", "NL" and "nl_nl" are all "nl"; anything that is not a language code is ignored, since
90// the code ends up in a key of the mod's store and in the tool result Claude reads back.
91const languageCode = (input: unknown) => {
92 const code = typeof input === 'string' ? input.trim().toLowerCase().split(/[-_]/)[0]! : ''
93 return /^[a-z]{2,3}$/.test(code) ? code : undefined
94}
95
96// Text from Claude goes into the bar and, through the message texts, into what is sent as the
97// user, so it may hold no line breaks or invisible characters: those could make one answer look
98// like two, or carry text the user cannot see. Joiners and direction marks (U+200C to U+200F) stay:
99// Persian, Indic scripts, Arabic and Hebrew need them, and so do emoji like a family of three.
100const UNSAFE = /[\p{Cc}\u2028\u2029]|(?![\u200C-\u200F])\p{Cf}/u
101// Room for the longest text in a long language; anything beyond that is not a translation.
102const MAX_TEXT = 200
103// Not translated: it is language-neutral, and a translation of it would be the one place where
104// text from Claude is added to every answer line the user sends.
105const FIXED: readonly string[] = ['messageDecision']
106// An own key of EN only: `in` is also true for the keys every object inherits, and such a key
107// broke the call.
108const isTextKey = (k: string): k is TextKey => Object.prototype.hasOwnProperty.call(EN, k)
109
110// A translation from Claude: only known keys with one short line of text and the same {placeholders}.
111const checkTranslation = (input: unknown) => {
112 const accepted: Record<string, string> = {}
113 const rejected: string[] = []
114 if (input && typeof input === 'object') {
115 for (const [k, v] of Object.entries(input)) {
116 if (!isTextKey(k) || FIXED.includes(k)) {
117 continue
118 }
119 if (
120 typeof v === 'string' &&
121 v.trim() !== '' &&
122 v.length <= MAX_TEXT &&
123 !UNSAFE.test(v) &&
124 placeholders(v) === placeholders(EN[k])
125 ) {
126 accepted[k] = v
127 } else {
128 rejected.push(k)
129 }
130 }
131 }
132 return { accepted, rejected }
133}
134
135// Collapse/expand: medium triangles (⏵ ⏷). The small ones (▸ ▾) were too small to make out,
136// the full ones (▶ ▼) too heavy.
137const CLOSED = '⏵'
138const OPENED = '⏷'
139// Longer than this and the remark is also shown in full below the field (which is one line).
140const LONG_REMARK = 22
141
142// Separator between items in a summary: a normal space plus an en space on each side.
143// Two normal spaces would be collapsed into one by the desktop app.
144const SEPARATOR = ' · '
145
146// Before a remark under an answer: an arrow that ties it to the answer above. The chat shows
147// messages as plain text, so indenting or italics are not available.
148const INDENT = '↳ '
149
150// Ends every message from the bar. Claude Code joins messages queued during a turn with a single
151// line break; this trailing one turns that into a blank line between separately sent answers.
152const TRAILER = '\n'
153
154const rowKey = (p: Proposal) => `${p.label}\u0000${p.text}`
155
156// What is typed in a remark field, per proposal (`rowKey`). Deliberately not in `$.state`: every
157// write there redraws the bar, and a redrawn field is handed its text again (`value`), which made
158// the field flicker on every keystroke and jump back to the start of the text. Committed to
159// `rows` on any other action (`commitDrafts`); a reload of the mod loses only uncommitted typing.
160let drafts = new Map<string, string>()
161// The field (`key`) last typed in: focus moving there is not another action.
162let lastField: string | undefined
163// The surfaces on which the app asked this mod itself to draw its bar or a chat box. Not that the
164// session has a surface (`$.session.surfaces()` lists every client, also one there for another
165// mod's pane or another feature), but that this mod's own drawing was asked there: a side chat in
166// the desktop app runs the mod but never asks it to draw.
167let drawnOn = new Set<string>()
168
169// Whether the user can see the bar now: this mod drew on a surface that is still attached. Where
170// the engine cannot say which surfaces are attached, assume it can. The answer only decides
171// whether the tool result asks Claude to put the decisions in its reply too (NOT_VISIBLE); the
172// surfaces read here never go into a prompt.
173const isVisible = async ($: EngineInterface) => {
174 const attached = await $.session.surfaces().catch(() => undefined)
175 return attached === undefined || attached.some(s => drawnOn.has(s))
176}
177const NOT_VISIBLE =
178 'Not visible: this session has not drawn the bar or a decision box (for example a side chat in the desktop app), so the user cannot see them. Ask these decisions in your reply as well, with the same labels, and do not say they are in the bar. They stay in the bar in case it appears; remove them with `remove` once answered.'
179// The rows with what is typed but not yet committed as their remark.
180const withDrafts = (list: Row[]): Row[] => list.map(row => ({ ...row, r: drafts.get(rowKey(row.p)) ?? row.r }))
181const commitDrafts = async ($: EngineInterface) => {
182 lastField = undefined
183 if (drafts.size > 0) {
184 const now = withDrafts(await read($, rows))
185 drafts.clear()
186 await update($, rows, () => now)
187 }
188 if ((await read($, editing)).length > 0) {
189 await update($, editing, () => [])
190 }
191}
192
193// Width of a button with its border and the gap next to it, in cells (an estimate).
194const buttonWidth = (label: string) => [...label].length + 5
195
196// How many lines a text takes when word-wrapped at `width` cells.
197const lineCount = (text: string, width: number) => {
198 if (width < 1) {
199 return Infinity
200 }
201 let count = 1
202 let used = 0
203 for (const word of text.split(/[ ]+/).filter(Boolean)) {
204 const length = [...word].length
205 if (used > 0 && used + 1 + length <= width) {
206 used += 1 + length
207 continue
208 }
209 if (used > 0) {
210 count += 1
211 }
212 // A word wider than the line runs on to the next.
213 count += Math.floor((length - 1) / width)
214 used = length % width || width
215 }
216 return count
217}
218
219// Breaks words longer than `width` cells: soft hyphens (shown only where the line breaks) or,
220// with `soft` false, a hyphen and a line break every `width - 1` characters.
221const breakLongWords = (text: string, width: number, soft: boolean) =>
222 text
223 .split(' ')
224 .map(word => {
225 const chars = [...word]
226 if (chars.length <= width || width < 2) {
227 return word
228 }
229 if (soft) {
230 return chars.join('\u00AD')
231 }
232 const parts: string[] = []
233 for (let i = 0; i < chars.length; i += width - 1) {
234 parts.push(chars.slice(i, i + width - 1).join(''))
235 }
236 return parts.join('-\n')
237 })
238 .join(' ')
239
240// Decided is agree or disagree; the rest is open.
241const tally = (list: Row[]) => {
242 const decided = list.filter(row => row.d !== null).length
243 const withRemark = list.filter(row => !!row.r.trim()).length
244 return { open: list.length - decided, decided, withRemark }
245}
246
247// Expanded, in the heading: "4 open · 3 chosen"; a count of zero is left out.
248const headingTally = (t: Translate, list: Row[]) => {
249 const { open, decided } = tally(list)
250 return [open > 0 ? t('open', { n: open }) : '', decided > 0 ? t('chosen', { n: decided }) : '']
251 .filter(Boolean)
252 .join(SEPARATOR)
253}
254
255// Collapsed and narrow: "1 open and 2 decided · 2 ✎"; a count of zero is left out.
256const miniSummary = (t: Translate, list: Row[]) => {
257 const { open, decided, withRemark } = tally(list)
258 const parts = [open > 0 ? t('open', { n: open }) : '', decided > 0 ? t('decided', { n: decided }) : ''].filter(
259 Boolean,
260 )
261 return parts.join(` ${t('and')} `) + (withRemark > 0 ? `${SEPARATOR}${withRemark} ✎` : '')
262}
263
264// Where Claude asks the question it ends a turn with. The desktop app hides the bar while its
265// next-step box (`wrap_up`) asks something, and that box's question is gone once the user sends
266// anything else. With the setting `closingQuestionInBar` on (the default) the question goes in the
267// bar whenever the bar can be seen; off, only while decisions are open, so the bar is not hidden.
268const CLOSING_QUESTION_ALWAYS =
269 'The desktop app hides the bar while its next-step box (`wrap_up`) shows a question with answer buttons, and that question is gone once the user sends anything else. So while the bar can be seen, put the question you end your turn with in the bar as a decision, also when no other decisions are open, and leave `ask` and `actions` out of `wrap_up`. Not when the tool result says "Not visible": then the box is the place to ask.'
270const CLOSING_QUESTION_WHILE_OPEN =
271 'The desktop app hides the bar while its next-step box (`wrap_up`) shows a question with answer buttons. So when the bar has open decisions at the end of your turn, leave `ask` and `actions` out of `wrap_up`: put the question you would ask there in the bar as a decision instead. Not when the tool result says "Not visible": then the box is the place to ask.'
272
273// What Claude is told about the bar, with the closing-question rule the setting picks. Exported for
274// its tests.
275export const instructions = (questionInBar: boolean) => [
276 '# Decision bar',
277 `When you ask the user for one or more decisions you propose (for example decision point C13, or "shall I do X?"), put them in the bar above the input field with ${TOOL_FULL} at the end of your turn.`,
278 '- `language`: the language of the chat as an ISO 639-1 code (en, nl, de, …). The bar shows its own texts in that language; the answers come back in it too.',
279 '- `label`: the short name of the decision, such as "C13" or "Order". No "agree" in it: the user picks agree or disagree.',
280 '- `text`: the proposal in one sentence, complete enough to understand without your reply; the bar shows label and text, both in the chat language.',
281 '- In your reply, use the same label, in bold, for each proposal, so the user can find the row in the bar.',
282 'Each call draws a box with its decisions in the chat, so place it where you discuss them: several calls in one reply are fine, each after the text it belongs to. Always keep text between a call and any other tool call: the desktop app folds consecutive tool calls into a collapsed group, which hides the box.',
283 'Only real open decisions: not what you already built or did, and not what the user already answered in the chat (a short "go" or "ok" counts).',
284 'What is in the bar stays until the user sends or hides it, also while you chat. On a later call give only new or changed decisions: an existing label is updated, not duplicated. Remove a decision answered or overtaken in the chat with `remove: [label]`.',
285 'A label the user hid does not come back; the tool result says which. Only ask about it again in your text when really needed.',
286 'If the tool result starts a sentence with "Not visible", the user cannot see the bar in this session (for example a side chat in the desktop app): ask those decisions in your reply as well, with the same labels, and do not say they are in the bar.',
287 'If the tool result asks for the bar texts in the chat language, call it again right away with `language` and `translation` as it says, before ending your turn.',
288 questionInBar ? CLOSING_QUESTION_ALWAYS : CLOSING_QUESTION_WHILE_OPEN,
289 'Bar language: the bar starts each session in English; after that it is the `language` of your last call in this session. Make a call with only `language` (it changes nothing but the language) only at the end of a turn in which the chat language changed while the bar has open rows. Otherwise no extra call.',
290 'Answers arrive as "<label> <agree>: <proposal>", "<label> <disagree>: <proposal>" or "<label> (no decision yet)", with any remark on its own line below starting with "↳", in the bar language.',
291 'This bar is the Claude Code mod `decision-bar`; if the user wants to change it, first read the README.md in its folder (behaviour, files, testing, pitfalls).',
292].join('\n')
293
294// One line (with any remark below it) per proposal; empty when there is nothing to report.
295const messageLine = (t: Translate, p: Proposal, decision: Decision, remark: string | undefined) => {
296 const r = remark?.trim() ?? ''
297 if (decision === null) {
298 // The remark always goes on its own line, so it is never mistaken for the proposal.
299 return r ? `${t('undecided', { label: p.label })}\n${INDENT}${t('messageRemark', { remark: r })}` : ''
300 }
301 const core = t('messageDecision', {
302 label: p.label,
303 decision: t(decision === 'agree' ? 'agree' : 'disagree'),
304 proposal: p.text,
305 })
306 return r ? `${core}\n${INDENT}${t('messageRemark', { remark: r })}` : core
307}
308
309// Label and proposal lead what is sent as the user, so each is one plain line of limited length:
310// line breaks and tabs become spaces, invisible characters go (see UNSAFE), and a label cannot
311// start like a command, bash mode or a file mention.
312// A label is a short name (C13, Order); a proposal is one sentence. Longer is cut with "…", which
313// the row shows too, so what is sent never holds more than the user saw.
314const MAX_LABEL = 40
315const MAX_PROPOSAL = 400
316const oneLine = (text: string, max: number) => {
317 const flat = text
318 .replace(/[\p{Cc}\u2028\u2029\s]+/gu, ' ')
319 .replace(/(?![\u200C-\u200F])\p{Cf}/gu, '')
320 .trim()
321 return flat.length > max ? `${flat.slice(0, max - 1).trimEnd()}…` : flat
322}
323const cleanLabel = (label: string) => oneLine(label, MAX_LABEL).replace(/^[/!@\s]+/, '')
324
325// The `proposals` argument of a tool call, as Claude sent it: entries without a label are left out,
326// and a proposal without text shows its label.
327const parseProposals = (input: unknown): Proposal[] =>
328 (Array.isArray(input) ? input : [])
329 .filter((p): p is { label: string; text: string } => typeof p?.label === 'string' && typeof p?.text === 'string')
330 .map(p => ({ label: cleanLabel(p.label), text: oneLine(p.text, MAX_PROPOSAL) }))
331 .filter(p => p.label !== '')
332 .map(p => ({ label: p.label, text: p.text || p.label }))
333
334// A tool call's proposals merged into the bar's rows. Existing rows keep their choice and remark;
335// an updated proposal (other text) starts again without either, so a remark written for the old
336// text does not go out with the new one. New labels are added at the end. A label the user hid is
337// skipped. `remove` takes out rows that were in the bar; a label proposed in this same call stays.
338// Exported for its tests.
339export const mergeProposals = (rows: Row[], incoming: Proposal[], toRemove: Set<string>, hidden: string[]) => {
340 const skipped = incoming.filter(p => hidden.includes(p.label)).map(p => p.label)
341 const admitted = incoming.filter(p => !hidden.includes(p.label))
342 const removed = rows.filter(row => toRemove.has(row.p.label)).map(row => row.p.label)
343 // A new array: the rows passed in are left as they are.
344 const merged = rows.filter(row => !toRemove.has(row.p.label))
345 const updated: string[] = []
346 const added: string[] = []
347 for (const p of admitted) {
348 const at = merged.findIndex(row => row.p.label === p.label)
349 if (at === -1) {
350 added.push(p.label)
351 merged.push({ p, d: null, r: '' })
352 } else if (merged[at]!.p.text !== p.text) {
353 merged[at] = { p, d: null, r: '' }
354 updated.push(p.label)
355 }
356 }
357 // No limit: a decision only leaves the bar when it is sent, hidden or removed.
358 return { rows: merged, added, updated, removed, skipped }
359}
360
361// Up to 1.2.9 the bar copied the question of the desktop app's next-step box into a row of its
362// own, with `options`. Such a row in a session's state goes. Exported for its tests.
363export const withoutQuestions = (list: Row[]) => list.filter(row => !('options' in row.p))
364
365// Hide: out of the bar, and the labels of the decisions remembered.
366const hide = async ($: EngineInterface) => {
367 await commitDrafts($)
368 const list = await read($, rows)
369 const labels = list.map(row => row.p.label)
370 await update($, hiddenLabels, old => [...new Set([...old, ...labels])])
371 await update($, lastHidden, () => ({ rows: list, show: true }))
372 await clear($)
373}
374
375// Brings back the last hidden group, with choices and remarks as they were. What was added in the
376// meantime stays; a label that is back already is not added twice.
377const restore = async ($: EngineInterface): Promise<number> => {
378 await commitDrafts($)
379 const hidden = await read($, lastHidden)
380 if (!hidden || hidden.rows.length === 0) {
381 return 0
382 }
383 const now = await read($, rows)
384 const back = hidden.rows.filter(row => !now.some(n => n.p.label === row.p.label))
385 const merged = [...back, ...now]
386 const labels = hidden.rows.map(row => row.p.label)
387 await update($, hiddenLabels, old => old.filter(l => !labels.includes(l)))
388 await update($, rows, () => merged)
389 await update($, lastHidden, () => null)
390 return back.length
391}
392
393const clear = async ($: EngineInterface) => {
394 drafts.clear()
395 await update($, rows, () => [])
396 await update($, sentRemarks, () => ({}))
397}
398
399// Sends the lines for `indexes`. A proposal with a decision disappears; a proposal with only a
400// remark stays open, without that remark.
401const send = async ($: EngineInterface, indexes: number[], agreeToAll = false): Promise<boolean> => {
402 await commitDrafts($)
403 const stored = await read($, rows)
404 const list = agreeToAll ? stored.map((row): Row => ({ ...row, d: 'agree' })) : stored
405 const sentBefore = await read($, sentRemarks)
406 const t = await currentTranslator($)
407 // Only a remark, and exactly that one was sent already: not again.
408 const alreadySent = (i: number) => {
409 const row = list[i]
410 const r = row?.r.trim() ?? ''
411 return !!row && row.d === null && r !== '' && sentBefore[rowKey(row.p)] === r
412 }
413 const lines = indexes
414 .filter(i => !alreadySent(i))
415 .map(i => (list[i] ? messageLine(t, list[i].p, list[i].d, list[i].r) : ''))
416 .filter(l => l !== '')
417 if (lines.length === 0) {
418 await update($, rows, old => old.map((row, i) => (indexes.includes(i) ? { ...row, r: '' } : row)))
419 return false
420 }
421
422 const sent = new Set(indexes)
423 const stays = (i: number) => !sent.has(i) || list[i]!.d === null
424 const keep = list.map((_, i) => i).filter(stays)
425 await update($, rows, () => keep.map(i => (sent.has(i) ? { ...list[i]!, r: '' } : list[i]!)))
426 await update($, sentRemarks, old => {
427 const remembered = { ...old }
428 for (const i of keep) {
429 const r = list[i]!.r.trim()
430 if (sent.has(i) && r !== '') {
431 remembered[rowKey(list[i]!.p)] = r
432 }
433 }
434 return remembered
435 })
436 // Remember what was decided, so the decision's box in the chat can show it.
437 await update($, sentDecisions, old => {
438 const recorded: Record<string, 'agree' | 'disagree'> = { ...(old as Record<string, 'agree' | 'disagree'>) }
439 for (const i of indexes) {
440 const row = list[i]
441 if (row && row.d !== null) {
442 recorded[rowKey(row.p)] = row.d
443 }
444 }
445 return recorded
446 })
447 await update($, sentAnswerRemarks, old => {
448 const recorded: Record<string, string> = { ...(old as Record<string, string>) }
449 for (const i of indexes) {
450 const row = list[i]
451 const r = row?.r.trim() ?? ''
452 if (row && row.d !== null && r !== '') {
453 recorded[rowKey(row.p)] = r
454 }
455 }
456 return recorded
457 })
458 // Sent as the user, because these are the user's answers, and only from a press of Send, Send
459 // all, Agree or Enter in a remark field: no hook sends. The message holds the rows' labels and
460 // proposals (checked, see the comment above `languageCode`), the bar's texts and what the user
461 // typed, nothing else read from the session. README: "What the mod does".
462 // A blank line between answers, so each stands on its own in the chat.
463 await $.prompt.submit({ text: lines.join('\n\n') + TRAILER, asUser: true })
464 return true
465}
466
467// The language of a call: sets the bar's texts, adds what a translation that came with it still
468// lacked, and asks Claude for what is missing. Returns the tool result sentence.
469const setLanguage = async ($: EngineInterface, code: string | undefined, translation: unknown) => {
470 if (!code) {
471 return ''
472 }
473 if (code === 'en') {
474 await update($, texts, () => ({}))
475 return ''
476 }
477 const stored = checkTranslation(await $.store.get(storeKey(code))).accepted
478 const { accepted, rejected } = checkTranslation(translation)
479 // Only what is still missing is added: a later call cannot change texts already in use.
480 const merged: Record<string, string> = { ...accepted, ...stored }
481 // Agree and disagree must read differently, or an answer cannot be told apart.
482 const t = translator(merged)
483 if (t('agree').trim().toLowerCase() === t('disagree').trim().toLowerCase()) {
484 delete merged.agree
485 delete merged.disagree
486 rejected.push('agree', 'disagree')
487 }
488 if (JSON.stringify(merged) !== JSON.stringify(stored)) {
489 await $.store.set(storeKey(code), merged)
490 }
491 await update($, texts, () => merged)
492
493 const missing = (Object.keys(EN) as TextKey[]).filter(k => !(k in merged) && !FIXED.includes(k))
494 return [
495 rejected.length > 0
496 ? `Not used from translation (empty, longer than ${MAX_TEXT} characters, more than one line, other {placeholders} than asked, or agree and disagree the same): ${rejected.join(', ')}.`
497 : '',
498 missing.length > 0
499 ? `The bar shows English where it has no "${code}" texts yet. Call ${TOOL_FULL} again now with language "${code}" and translation: these texts translated into that language, same keys, {placeholders} and symbols unchanged (no proposals needed): ${JSON.stringify(Object.fromEntries(missing.map(k => [k, EN[k]])))}`
500 : '',
501 ]
502 .filter(Boolean)
503 .join(' ')
504}
505
506// The elements of one surface, as `$.ui.resolve` hands them out (the same for the bar and a
507// chat row); `Input` is missing on mobile.
508// eslint-disable-next-line @typescript-eslint/no-explicit-any
509type Elements = { Box: any; Button: any; Text: any; Input?: any }
510
511// How the bar or a chat box asks for its rows. `rows` are the bar's rows. Element keys go under
512// `prefix`, so the bar's and each box's elements stay apart. `shown` is what the label column is
513// sized for: a chat box shows its own proposals, also when they have all left the bar and `rows`
514// is empty. `remarkAsText`: no field, the remark as plain text (a chat box in the terminal, where
515// only the bar and panes take keys).
516type RowViewOptions = {
517 surface: string
518 rows: Row[]
519 prefix: string
520 columns: number
521 shown?: Proposal[]
522 remarkAsText?: boolean
523}
524
525// What the bar and the chat boxes share to draw rows: the elements, the state and the handlers.
526// `stored` holds the rows as committed, `current` the same with what is being typed (`drafts`).
527type RowView = Elements & {
528 t: Translate
529 prefix: string
530 stored: Row[]
531 current: Row[]
532 sent: Record<string, string>
533 showFull: (i: number) => boolean
534 isStale: (p: Proposal) => boolean
535 setRemark: (i: number, text: string) => Promise<void>
536 keepAndSend: (i: number, text: string) => Promise<void>
537 choose: (i: number, decision: Exclude<Decision, null>) => Promise<unknown>
538 roomy: boolean
539 labelText: (p: Proposal) => string
540 labelWidth: number
541 fieldWidth: (buttons: string[]) => number
542 remarkAsText: boolean
543}
544
545const rowView = async (
546 $: EngineInterface,
547 els: Elements,
548 { surface, rows: stored, prefix, columns, shown = stored.map(row => row.p), remarkAsText = false }: RowViewOptions,
549): Promise<RowView> => {
550 const { Box, Button, Text } = els
551 const Input = remarkAsText ? undefined : els.Input
552 const t = translator(await read($, texts))
553 const current = withDrafts(stored)
554 const sent = await read($, sentRemarks)
555 const beingEdited = await read($, editing)
556 // The line shows the last committed text; while the field is edited it is struck through:
557 // visibly stale, until the next action updates it.
558 const showFull = (i: number) => (stored[i]?.r.trim().length ?? 0) > LONG_REMARK
559 const isStale = (p: Proposal) => beingEdited.includes(rowKey(p))
560
561 // Typing does not redraw (see `drafts`); only clearing "✓ remark sent" or striking through the
562 // full remark does, once.
563 const setRemark = async (i: number, text: string) => {
564 const p = stored[i]?.p
565 if (!p) {
566 return
567 }
568 const field = `${prefix}remark-${i}`
569 // Typed in another field first: that is another action, the other field is committed.
570 // Works also where the app does not report focus moves to the mod.
571 if (lastField !== undefined && lastField !== field) {
572 await commitDrafts($)
573 }
574 drafts.set(rowKey(p), text)
575 lastField = field
576 if (showFull(i) && !beingEdited.includes(rowKey(p))) {
577 await update($, editing, old => [...old, rowKey(p)])
578 }
579 if (sent[rowKey(p)] !== undefined && sent[rowKey(p)] !== text.trim()) {
580 await update($, sentRemarks, old => {
581 const { [rowKey(p)]: _gone, ...rest } = old
582 return rest
583 })
584 }
585 }
586 // Enter or the "send" button next to a field: only that row. If the app passes an empty field
587 // with the button, the typed remark stays; if nothing goes out, a toast says so.
588 const keepAndSend = async (i: number, text: string) => {
589 if (text.trim() !== '') {
590 await setRemark(i, text)
591 }
592 const done = await send($, [i])
593 if (!done) {
594 $.ui.toast(t('nothingToSendDecision'))
595 }
596 }
597 // Pressing the chosen state again sets it back to "no decision".
598 const choose = (i: number, decision: Exclude<Decision, null>) =>
599 commitDrafts($).then(() =>
600 update($, rows, old => old.map((row, j) => (j === i ? { ...row, d: row.d === decision ? null : decision } : row))),
601 )
602
603 // In the terminal every line costs room above the prompt; elsewhere there is space to breathe.
604 const roomy = surface !== 'terminal'
605 // Labels in one column of equal width, so buttons and fields line up. At most 14 cells: a
606 // longer label wraps onto two lines (top-aligned), leaving the room to proposal and field.
607 const labelWidth = Math.min(14, Math.max(0, ...shown.map(p => [...p.label].length)) + 2)
608 // A word longer than the column is broken with a hyphen. Outside the terminal soft hyphens let
609 // the app break only where proportional text really needs it; the terminal gets hard breaks.
610 const labelText = (p: Proposal) => breakLongWords(p.label, labelWidth - 1, roomy)
611 // The field as wide as the line allows next to the buttons before it and "send" after it. The
612 // desktop app draws an Input at its own width; this only takes effect in the terminal.
613 const fieldWidth = (buttons: string[]) =>
614 Math.max(
615 20,
616 columns - (roomy ? 1 : 0) - (labelWidth + 1) - [...buttons, t('send')].reduce((sum, b) => sum + buttonWidth(b), 0) - 1,
617 )
618 return {
619 Box, Button, Text, Input, t, prefix, stored, current, sent, showFull, isStale,
620 setRemark, keepAndSend, choose, roomy, labelText, labelWidth, fieldWidth, remarkAsText,
621 }
622}
623
624// The top line of every row: the label in its column, and the proposal next to it.
625const rowTop = (v: RowView, p: Proposal, i: number) => {
626 const { Box, Text, prefix: k } = v
627 return (
628 <Box key={`${k}top-${i}`} flexDirection="row" alignItems="flex-start" columnGap={1}>
629 <Box key={`${k}label-box-${i}`} width={v.labelWidth} flexShrink={0}>
630 <Text key={`${k}label-${i}`} bold>
631 {v.labelText(p)}
632 </Text>
633 </Box>
634 <Box key={`${k}proposal-box-${i}`} flexGrow={1} flexShrink={1}>
635 <Text key={`${k}proposal-${i}`}>{p.text}</Text>
636 </Box>
637 </Box>
638 )
639}
640
641// The remark field. Enter or its button sends only this row; the rest stays. `buttons` stand
642// before it on the line and fix its width.
643const remarkField = (v: RowView, i: number, placeholder: string, buttons: string[]) => {
644 const { Box, Input, prefix: k } = v
645 return (
646 Input && (
647 <Box key={`${k}field-${i}`} flexGrow={1} minWidth={20} width={v.fieldWidth(buttons)}>
648 <Input
649 key={`${k}remark-${i}`}
650 placeholder={placeholder}
651 value={v.current[i]?.r ?? ''}
652 submitLabel={v.t('send')}
653 onInput={(text: string) => void v.setRemark(i, text)}
654 onSubmit={(text: string) => void v.keepAndSend(i, text)}
655 />
656 </Box>
657 )
658 )
659}
660
661// A remark longer than the field, in full below the row (see `showFull`).
662const fullRemark = (v: RowView, p: Proposal, i: number) => {
663 const { Box, Text, prefix: k } = v
664 return (
665 v.showFull(i) &&
666 !v.remarkAsText && (
667 <Box key={`${k}full-${i}`} marginLeft={v.labelWidth + 1}>
668 <Text key={`${k}full-remark-${i}`} dimColor strikethrough={v.isStale(p)}>
669 {v.stored[i]!.r}
670 </Text>
671 </Box>
672 )
673 )
674}
675
676// One decision row: label, proposal, agree / disagree, remark field and its full text below.
677const decisionRow = (v: RowView, p: Proposal, i: number) => {
678 const { Box, Button, Text, t, prefix: k, sent } = v
679 const d = v.stored[i]?.d ?? null
680 const r = v.current[i]?.r ?? ''
681 return (
682 // Outside the terminal some air around the buttons, and clearly more between rows.
683 <Box key={`${k}row-${i}`} flexDirection="column" rowGap={v.roomy ? 1 : 0} marginTop={v.roomy && i > 0 ? 1 : 0}>
684 {rowTop(v, p, i)}
685 <Box
686 key={`${k}choice-${i}`}
687 flexDirection="row"
688 alignItems="center"
689 flexWrap="wrap"
690 columnGap={1}
691 marginLeft={v.labelWidth + 1}
692 >
693 <Button
694 key={`${k}agree-${i}`}
695 label={`${d === 'agree' ? '●' : '○'} ${t('agree')}`}
696 variant={d === 'agree' ? 'primary' : 'secondary'}
697 onPress={() => v.choose(i, 'agree')}
698 />
699 <Button
700 key={`${k}disagree-${i}`}
701 label={`${d === 'disagree' ? '●' : '○'} ${t('disagree')}`}
702 variant={d === 'disagree' ? 'primary' : 'secondary'}
703 onPress={() => v.choose(i, 'disagree')}
704 />
705 {remarkField(v, i, t('remarkField'), [`○ ${t('agree')}`, `○ ${t('disagree')}`])}
706 {v.remarkAsText && r.trim() && (
707 <Text key={`${k}remark-text-${i}`} dimColor>
708 {`✎ ${r.trim()}`}
709 </Text>
710 )}
711 {sent[rowKey(p)] !== undefined && !r.trim() && (
712 <Text key={`${k}sent-${i}`} dimColor>
713 {t('remarkSent')}
714 </Text>
715 )}
716 </Box>
717 {fullRemark(v, p, i)}
718 </Box>
719 )
720}
721
722// After Hide, in place of the bar: how many decisions went, with Restore and Close.
723const hiddenNotice = ($: EngineInterface, els: Elements, t: Translate, n: number) => {
724 const { Box, Button, Text } = els
725 return (
726 <Box flexDirection="row" alignItems="center" columnGap={1}>
727 <Text key="hidden" dimColor>
728 {n === 1 ? t('hiddenOne') : t('hiddenMany', { n })} {t('restoreLater', { command: COMMAND })}
729 </Text>
730 <Button key="restore" label={t('restore')} onPress={() => void restore($)} />
731 <Button
732 key="close"
733 label={t('close')}
734 role="dismiss"
735 onPress={() => update($, lastHidden, s => (s ? { ...s, show: false } : s))}
736 />
737 </Box>
738 )
739}
740
741// What the collapsed and the expanded bar both need.
742const barBasics = ($: EngineInterface, v: RowView) => ({
743 all: v.stored.map((_, i) => i),
744 // With one row no "all": you would look for what else goes along.
745 sendLabel: v.t(v.stored.length === 1 ? 'sendOne' : 'sendAll'),
746 toggle: () => commitDrafts($).then(() => update($, collapsed, c => !c)),
747})
748
749// Collapsed: one line with the heading as a button, the state of each row, and Send all once
750// something is chosen or typed.
751const collapsedBar = ($: EngineInterface, v: RowView, columns: number) => {
752 const { Box, Button, Text, t, current } = v
753 const { all, sendLabel, toggle } = barBasics($, v)
754 const hasSomething = current.some(row => row.d !== null || row.r.trim())
755 // Per row its state: ✓ agree / ✗ disagree / ○ nothing yet, ✎ with a remark.
756 const summary = current
757 .map(({ p, d, r }) => {
758 const pen = r.trim() ? ' ✎' : ''
759 const state = d === 'agree' ? '✓' : d === 'disagree' ? '✗' : '○'
760 return `${p.label} ${state}${pen}`
761 })
762 .join(SEPARATOR)
763 // If the summary does not fit in two lines next to the buttons, only the counts. Outside the
764 // terminal text is narrower than a cell: about 30% more fits on a line there.
765 const room =
766 (columns - buttonWidth(`${CLOSED} ${t('heading')}`) - (hasSomething ? buttonWidth(sendLabel) : 0)) *
767 (v.roomy ? 1.3 : 1)
768 const lines = columns > 0 ? lineCount(summary, room) : 1
769 return (
770 <Box flexDirection="row" alignItems="center" columnGap={1}>
771 <Button key="toggle" label={`${CLOSED} ${t('heading')}`} onPress={toggle} />
772 <Box key="summary-box" flexGrow={1} flexShrink={1}>
773 <Text key="summary" dimColor>
774 {lines > 2 ? miniSummary(t, current) : summary}
775 </Text>
776 </Box>
777 {hasSomething && <Button key="send-all" label={sendLabel} variant="primary" onPress={() => send($, all)} />}
778 </Box>
779 )
780}
781
782// Expanded: the heading with the counts, every row, and the buttons that act on all of them.
783const expandedBar = ($: EngineInterface, v: RowView) => {
784 const { Box, Button, Text, t, stored, current } = v
785 const { all, sendLabel, toggle } = barBasics($, v)
786 // "Send all" is always there: typing a remark alone does not redraw the bar, so the button
787 // would otherwise appear only after another action.
788 const sendAll = async () => {
789 if (!(await send($, all))) {
790 $.ui.toast(t('nothingToSendDecision'))
791 }
792 }
793 return (
794 <Box flexDirection="column" rowGap={1}>
795 <Box key="heading" flexDirection="row" alignItems="center" columnGap={1}>
796 <Button key="toggle" label={`${OPENED} ${t('heading')}`} plain onPress={toggle} />
797 <Text key="tally" dimColor>
798 {headingTally(t, current)}
799 </Text>
800 </Box>
801 {/* Outside the terminal the chevron sits a little to the right because of the button's
802 border: the content moves one cell along so it lines up below it. */}
803 <Box key="content" flexDirection="column" rowGap={1} marginLeft={v.roomy ? 1 : 0}>
804 {stored.map(({ p }, i) => decisionRow(v, p, i))}
805 <Box key="actions" flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
806 <Button key="send-all" label={sendLabel} variant="primary" onPress={() => void sendAll()} />
807 <Button key="agree-all" label={t(stored.length === 1 ? 'agreeOne' : 'agreeAll')} onPress={() => send($, all, true)} />
808 <Button key="hide" label={t('hide')} role="dismiss" onPress={() => hide($)} />
809 </Box>
810 </Box>
811 </Box>
812 )
813}
814
815export const register: Register = (on, options) => {
816 // A setting of the plugin (`userConfig` in plugin.json); on unless turned off.
817 const questionInBar = options?.closingQuestionInBar !== false
818 drafts = new Map()
819 lastField = undefined
820 drawnOn = new Set()
821
822 on('session.start', async ($, e, next) => {
823 // A question copied by 1.2.9 goes once (see `withoutQuestions`); other rows stay as they are.
824 await update($, rows, old => {
825 const kept = withoutQuestions(old)
826 return kept.length < old.length ? kept : old
827 }).catch(() => undefined)
828 await update($, lastHidden, last => {
829 const kept = last ? withoutQuestions(last.rows) : []
830 return last && kept.length < last.rows.length ? { ...last, rows: kept } : last
831 }).catch(() => undefined)
832 await $.tool.register({
833 name: TOOL,
834 description:
835 "Puts a row per proposed decision (label and proposal) above the user's input field, with agree / disagree and a remark field. An existing label is updated; `remove` removes decisions answered in the chat.",
836 inputSchema: {
837 type: 'object',
838 properties: {
839 language: {
840 type: 'string',
841 description: "Language of the chat, ISO 639-1 (en, nl, de, …); the bar's own texts follow it",
842 },
843 translation: {
844 type: 'object',
845 additionalProperties: { type: 'string' },
846 description:
847 "Only when the tool result asks for it: the bar's texts in `language`, with the keys and {placeholders} it gives",
848 },
849 proposals: {
850 type: 'array',
851 items: {
852 type: 'object',
853 properties: {
854 label: { type: 'string', description: 'Short name of the decision (e.g. "C13")' },
855 text: { type: 'string', description: 'The proposal in one sentence' },
856 },
857 required: ['label', 'text'],
858 },
859 },
860 remove: {
861 type: 'array',
862 items: { type: 'string' },
863 description: 'Labels of decisions answered or overtaken in the chat',
864 },
865 },
866 required: ['language'],
867 },
868 isDeferred: false,
869 })
870 await $.command.register({
871 name: COMMAND,
872 description: 'Bring the last hidden decisions back into the bar',
873 })
874
875 return next(e)
876 })
877
878 // Only this mod's own /decisions command (the matcher); other commands never reach this hook.
879 on('command.run', { command: COMMAND }, async $ => {
880 const count = await restore($)
881 const t = await currentTranslator($)
882 return { text: count > 0 ? t('commandRestored', { n: count }) : t('commandEmpty') }
883 })
884
885 // Adds one section of its own, `instructions`: how Claude uses the bar. Everything else Claude is
886 // told passes through unchanged. README: "What the mod does".
887 on('prompt.compose', async ($, e, next) => {
888 const composed = await next(e)
889
890 return {
891 ...composed,
892 sections: [...composed.sections, { id: 'decision-bar:instructions', text: instructions(questionInBar), scope: 'session' }],
893 }
894 })
895
896 // No matcher on the exact name: the engine may report the tool with a prefix.
897 // This hook answers one tool itself: `decisions`, the tool this mod registers, which has nothing
898 // else behind it (`isOurTool`). Every other tool call passes through untouched.
899 on('tool.call', async ($, e, next) => {
900 if (!isOurTool(e.tool)) {
901 return next(e)
902 }
903
904 // The tool's arguments sit flat next to `tool` in `e`.
905 const {
906 proposals: proposalsInput,
907 remove: removeInput,
908 language: languageInput,
909 translation: translationInput,
910 } = e as { proposals?: unknown; remove?: unknown; language?: unknown; translation?: unknown }
911 const incoming = parseProposals(proposalsInput)
912 const toRemove = new Set(
913 (Array.isArray(removeInput) ? removeInput : []).filter((l): l is string => typeof l === 'string').map(cleanLabel),
914 )
915
916 try {
917 await commitDrafts($)
918 const languageSentence = await setLanguage($, languageCode(languageInput), translationInput)
919 const [before, hidden] = await Promise.all([read($, rows), read($, hiddenLabels)])
920 const { rows: keep, added, updated, removed, skipped } = mergeProposals(before, incoming, toRemove, hidden)
921 await update($, rows, () => keep)
922
923 const sentence = (head: string, labels: string[]) => (labels.length > 0 ? `${head}: ${labels.join(', ')}.` : '')
924 const open = keep.map(row => row.p.label)
925 const visible = await isVisible($)
926 return {
927 result: [
928 sentence('Added', added),
929 sentence('Updated', updated),
930 sentence('Removed', removed),
931 sentence('Not shown, hidden earlier by the user', skipped),
932 open.length > 0 ? `Now open in the bar: ${open.join(', ')}.` : 'The bar has no open decisions.',
933 open.length > 0 && !visible ? NOT_VISIBLE : '',
934 languageSentence,
935 ]
936 .filter(s => s !== '')
937 .join(' '),
938 }
939 } catch (error) {
940 return { result: `The decisions could not be prepared: ${String(error)}` }
941 }
942 })
943
944 // To another field or a button (Tab, arrows, click): what was typed is committed.
945 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
946 const result = await next(e)
947 if (e.element !== lastField) {
948 await commitDrafts($).catch(() => undefined)
949 }
950 return result
951 })
952 // Typing in Claude Code's own input field is another action too.
953 on('prompt.edit', async ($, e, next) => {
954 if (lastField !== undefined) {
955 await commitDrafts($).catch(() => undefined)
956 }
957 return next(e)
958 })
959
960 // A message of the user's own: the Restore row and what was typed in the bar are settled.
961 on('prompt.submit', async ($, e, next) => {
962 // The "hidden — Restore" row belongs to the moment of hiding; /decisions keeps working.
963 await update($, lastHidden, last => (last && last.show ? { ...last, show: false } : last)).catch(() => undefined)
964 await commitDrafts($).catch(() => undefined)
965 return next(e)
966 })
967
968 // A group of tool calls folds into one line ("Used 2 tools"); one holding a decision box stays
969 // open, so the box is visible without expanding the group. The terminal honours this; the
970 // desktop app folds groups its own way, hence the instruction to call the tool on its own.
971 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
972 if (e.props.isExpanded || !e.props.calls.some(c => isOurTool(c.tool))) {
973 return next(e)
974 }
975 return next({ ...e, props: { ...e.props, isExpanded: true } })
976 })
977
978 // In the chat: the tool call's own row becomes a box with the decisions it proposed, drawn like
979 // the bar's rows and sharing their state, so you can decide in context and scroll back later. A
980 // decision that left the bar shows its sent answer, or "closed" when the bar no longer knows it.
981 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
982 if (!isOurTool(e.props.tool)) {
983 return next(e)
984 }
985 drawnOn.add(e.surface)
986 const input = e.props.input as { proposals?: unknown }
987 const asked = parseProposals(input?.proposals)
988 if (asked.length === 0) {
989 return next(e)
990 }
991 const els = $.ui.resolve(e) as unknown as Elements
992 const list = await read($, rows)
993 const prefix = `chat-${e.props.tool_use_id}-`
994 // Columns 0: the field takes its minimum width, so it stays on the row inside the box. A
995 // transcript row takes no keys in the terminal, so there the remark is text, typed in the bar.
996 const v = await rowView($, els, {
997 surface: e.surface,
998 rows: list,
999 prefix,
1000 columns: 0,
1001 shown: asked,
1002 remarkAsText: e.surface === 'terminal',
1003 })
1004 const { Box, Button, Text, t } = v
1005 const done = await read($, sentDecisions)
1006 const doneRemarks = await read($, sentAnswerRemarks)
1007 const indexOf = (p: Proposal) => list.findIndex(row => rowKey(row.p) === rowKey(p))
1008 const openIndexes = asked.map(indexOf).filter(i => i >= 0)
1009 const openRows = openIndexes.map(i => v.current[i]!)
1010 const sendBox = async () => {
1011 if (!(await send($, openIndexes))) {
1012 $.ui.toast(t('nothingToSendDecision'))
1013 }
1014 }
1015 return (
1016 <Box flexDirection="column" rowGap={1} borderStyle="round" paddingX={1}>
1017 <Box key={`${prefix}heading`} flexDirection="row" columnGap={1}>
1018 <Text key={`${prefix}title`} bold>
1019 {t('heading')}
1020 </Text>
1021 <Text key={`${prefix}tally`} dimColor>
1022 {headingTally(t, openRows)}
1023 </Text>
1024 </Box>
1025 {asked.map((p, k) => {
1026 const i = indexOf(p)
1027 if (i >= 0) {
1028 return decisionRow(v, list[i]!.p, i)
1029 }
1030 const answer = done[rowKey(p)]
1031 return (
1032 <Box key={`${prefix}closed-${k}`} flexDirection="row" alignItems="flex-start" columnGap={1}>
1033 <Box key={`${prefix}closed-label-box-${k}`} width={v.labelWidth} flexShrink={0}>
1034 <Text key={`${prefix}closed-label-${k}`} bold dimColor>
1035 {breakLongWords(p.label, v.labelWidth - 1, v.roomy)}
1036 </Text>
1037 </Box>
1038 <Box key={`${prefix}closed-text-box-${k}`} flexDirection="column" flexGrow={1} flexShrink={1}>
1039 <Text key={`${prefix}closed-text-${k}`} dimColor>
1040 {p.text}
1041 </Text>
1042 <Text key={`${prefix}closed-answer-${k}`} dimColor>
1043 {answer === 'agree' ? `✓ ${t('agree')}` : answer === 'disagree' ? `✗ ${t('disagree')}` : t('closed')}
1044 </Text>
1045 {answer && doneRemarks[rowKey(p)] && (
1046 <Text key={`${prefix}closed-remark-${k}`} dimColor>
1047 {`${INDENT}${doneRemarks[rowKey(p)]}`}
1048 </Text>
1049 )}
1050 </Box>
1051 </Box>
1052 )
1053 })}
1054 {openIndexes.length > 0 && (
1055 <Box key={`${prefix}actions`} flexDirection="row" columnGap={1}>
1056 <Button
1057 key={`${prefix}send-box`}
1058 label={t(openIndexes.length === 1 ? 'sendOne' : 'sendAll')}
1059 variant="primary"
1060 onPress={() => void sendBox()}
1061 />
1062 </Box>
1063 )}
1064 </Box>
1065 )
1066 })
1067
1068 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1069 // Asked to draw the band, also while it is empty: the bar can be seen on this surface.
1070 drawnOn.add(e.surface)
1071 const list = await read($, rows)
1072
1073 // Also visible while Claude works: a sent message then waits until the turn is done.
1074 const table = $.ui.resolve(e) as Elements
1075 if (list.length === 0) {
1076 const hidden = await read($, lastHidden)
1077 if (!hidden || !hidden.show || hidden.rows.length === 0) {
1078 return next(e)
1079 }
1080 return hiddenNotice($, table, translator(await read($, texts)), hidden.rows.length)
1081 }
1082 const v = await rowView($, table, { surface: e.surface, rows: list, prefix: '', columns: e.props.bodyColumns })
1083 return (await read($, collapsed)) ? collapsedBar($, v, e.props.bodyColumns) : expandedBar($, v)
1084 })
1085}
1086types/index.d.ts 24 lines1// A decision Claude proposes: its label and the proposal in one sentence.
2export type Proposal = { label: string; text: string }
3export type Decision = 'agree' | 'disagree' | null
4// A row of the bar: the proposal with its choice and remark.
5export type Row = { p: Proposal; d: Decision; r: string }
6// The last hidden set: each row with its choice and remark.
7export type LastHidden = { rows: Row[]; show: boolean }
8
9declare module 'claude-code' {
10 interface PluginState {
11 'decision-bar': {
12 rows: Row[]
13 sentRemarks: Record<string, string>
14 hiddenLabels: string[]
15 lastHidden: LastHidden | null
16 collapsed: boolean
17 editing: string[]
18 answered: Record<string, 'agree' | 'disagree'>
19 answeredRemarks: Record<string, string>
20 texts: Record<string, string>
21 }
22 }
23}
24