SLOPSHOPPER

decision-bar

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…

bandrowsguardcommandtoastprompt
v1.4.0MITupdated 2026-10-26michielwittkampf/decision-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · decision-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /decisions ⎿ decision-bar: No hidden decisions to restore. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

decision-bar

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.

The decision bar with two decisions, a remark shown in full, and Send all

Install

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).

Why use it

  • One overview instead of questions scattered through the text. However many decisions are open, they are together in one place, each with its own choice and remark, and a heading that counts what is open and what is chosen. New questions join the list instead of replacing it.
  • Your order, your pace. Nothing blocks the conversation. Answer one decision now and leave the rest while you dig into them; Claude can keep working in the meantime.
  • Decide in context. The decisions also appear in the chat, right next to Claude's reasoning. Answer there while you read, or later in the bar. Afterwards the box keeps showing what you decided and which remark went with which decision, so scrolling back shows each answer next to its question. In the terminal you type a remark in the bar; the box in the chat shows it.

A decision box in the chat, with a choice, a remark and Send all

  • Nothing gets lost. A decision stays visibly open until you answer it. Answered it in the chat, or has it been overtaken? Claude takes it off the list. Hidden decisions come back with /decisions.
  • More than yes or no. Agree or disagree with a remark, or send just a remark: it goes out with the decision it is about, so Claude knows exactly what it refers to.
  • Out of the way when you need the room. Collapsed, the bar is a single line with the state of every decision, and the conversation gets the space back.

Collapsed, the bar shrinks to one line and leaves the space above it free for the conversation

  • In your language. Buttons, counts and answers follow the language of the chat.

The bar in Dutch, with several decisions, each with its own choice and remark, and the counts in the heading

What the mod does

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.

  • Instructions for Claude. Each turn the mod adds one section to what Claude is told (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.
  • Its own tool. The mod registers the 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.
  • What it sends as you. Only when you click Send, Send all or Agree to all, or press Enter in a remark field, does the mod submit a message in your name ($.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.
  • Your own prompts (prompt.submit, prompt.edit) are only read to keep a remark you were typing.
  • The /decisions command (command.run) brings the decisions you hid last back into the bar.
  • What it keeps. Decisions, choices and remarks live in the session's state; a proposal Claude changes starts again without its choice and remark. The bar's texts in each language Claude translated them into are kept in the mod's own store, so a language is translated once. A translation only fills in what is still missing, one short line per text, and agree and disagree must differ.

Notes for reviewers

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.

  • "Mod can read the conversation and submit a prompt." Submitting the answers is the mod's purpose. Nothing reaches the message except the label and proposal Claude put in the bar, the bar's own texts and what you typed, and only on your click or Enter; no hook submits. What the mod reads (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.

What would make it better

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.

  • The band above the prompt beside the next-step box (#105230). While the desktop app's next-step box (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.
  • A text input that grows with its text (#103305). The desktop app draws an 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.
  • A focus event for a click into a field (#103306). The desktop app raises 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.
  • Text inputs that keep their scroll (#103302). A redraw keeps an 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.
  • Tool groups that stay open (#103303). The desktop app folds consecutive tool calls into a collapsed group and does not honour a mod's isExpanded on it, so a decision box next to another tool call is hidden. Claude is told to keep text between them.
  • Keys for rows in the chat. In the terminal only the band above the prompt and panes take keys, so a field in a transcript row can be shown but not typed in. The decision box there shows the remark as text and leaves typing to the bar; the desktop app takes input in the chat, and keeps the field.
  • Visible refusals (#103304). When the engine refuses a mod's drawing, an installed mod falls back to the app's own row without a word; the reason is only in the debug log.
  • Mod drawing in a side chat (#103429). decision-bar does not work in a side chat of the desktop app: the app shows nothing the mod draws there, no bar above the prompt and no decision box in the chat, although the mod runs and stores the decisions. The mod notices that it was never asked to draw there and tells Claude to ask in the chat instead. A session started from another session (not as a side chat) shows the bar and the boxes.
  • Spacing in proportional layouts. Margins come in whole character cells, which is coarse in the desktop app.

Developing

PathWhat
hooks/register.tsxthe tool, Claude's instructions, state and the bar
hooks/decision-bar.test.tsxbehaviour tests (terminal and desktop)
types/index.d.tstype contract of the state
.claude-plugin/manifest, marketplace file and the directory icon
  1. Run your checkout with claude --plugin-dir ., or install it from the folder: claude plugin marketplace add . and claude plugin install decision-bar@michielwittkampf.
  2. Change the code and tests; check with claude plugin validate . and claude plugin test ..
  3. In a running session, /reload-plugins picks up the change.

The plugin-authoring skill in Claude Code has the mod API for your version. Things to know:

  • Typed text lives in 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.
  • Tool arguments sit flat in e (e.proposals); in tests $.tool.call({ tool, proposals }).
  • Whether the user can see the bar is 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.
  • The test kit has no $.store; setup in the tests provides one.
  • A tree the engine refuses (a prop out of range, such as a width of -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.

License

MIT — see LICENSE.

Source 2 files
hooks/register.tsx 1086 lines
1import { 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}
1086
types/index.d.ts 24 lines
1// 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