SLOPSHOPPER

question-log

Highlights assistant replies that end by asking you something, logs every AskUserQuestion and detected question with its answer, and shows them in a /questions…

newpanerowsguardcommandstatus
v0.1.0MITupdated 2026-10-06mrjk05/modemon/mods/question-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · question-log
│ ┃ Questions ✕ › fix the failing auth test and add an audit log call │ ┃ No questions yet. AskUserQuestion dialogs │ ┃ and replies that end with a question show ⏺ Read(src/auth.ts) │ ┃ here. ⎿ 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 │ │ › /questions │ ⎿ question-log: Question log opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Questions
No questions yet. AskUserQuestion dialogs and replies that end with a question show here.
README

question-log

Makes it obvious when Claude is waiting on you, and keeps a log of every question and your answer.

  • Captures AskUserQuestion: each dialog's questions, headers and options, and what you chose. Multi-select answers are comma-joined, and text typed under "Other" is logged as Other: "...".
  • Detects questions in replies: when a reply's final paragraph asks you something ("Should I also add a test?"), it is logged as an open question. Your next prompt answers it, and its first 200 characters are kept as the answer.
  • Highlight: a reply detected as a question is drawn in a round, coloured box with a ? Question for you header. The box is yellow while the question is open and magenta once you have answered.
  • Log pane: /questions lists every question in order (newest at the bottom). Open ones are highlighted, answers are indented under their question, and each has a relative timestamp. Where no pane can be drawn (the mobile app, an older desktop, a narrow terminal) the log is shown inline as the command's output instead.
  • Status line: ? 2 open under the prompt while anything is unanswered. It clears at zero.

Install

/plugin install question-log --marketplace mrjk05/modemon

Answer y to add the marketplace, then pick a scope.

What it looks like

A reply that ends with a question:

╭──────────────────────────────────────────────────────────────╮
│ ? Question for you                                           │
│ I fixed the relative path and the suite passes.              │
│                                                              │
│ Should I also add a regression test?                         │
╰──────────────────────────────────────────────────────────────╯

The /questions pane:

┌ Questions ───────────────────────────────────────────────────┐
│ 3 questions · 1 open                                         │
│                                                              │
│ ✓ answered  12m ago · AskUserQuestion                        │
│   [Auth] Which auth method?                                  │
│   options: OAuth · API key                                   │
│     ↳ Other: "magic links"  11m ago                          │
│                                                              │
│ ✓ answered  6m ago · in reply                                │
│   Want me to push the branch?                                │
│     ↳ yes, and open a PR  5m ago                             │
│                                                              │
│ ? open  just now · in reply                                  │
│   Should I also add a regression test?                       │
└──────────────────────────────────────────────────────────────┘
  ? 1 open

Surfaces

Works on the terminal, the desktop app's Code tab and the Claude mobile app (watching a cloud or Remote Control session).

TerminalDesktopMobile
Question highlight boxyesyesyes (Box, Text and Markdown only)
/questions log paneyesyesno: mobile draws no panes
/questions inline logwhen the pane can't be placedwhen the pane can't be placedalways
? 2 open status lineyesyesyes

The inline log is compact: a 3 questions · 1 open summary, then the open questions (oldest first), then the answered ones (newest first) with their answers, out of the last 20 entries. Older entries are counted (5 older not shown). Lines are cut to the width of the screen. The command's text is the same log as markdown, so a surface that draws the row itself still shows it.

On mobile, /questions in a session whose terminal has the pane open still closes it there, and the phone shows the log inline.

Commands

CommandWhat it does
/questionsOpens the log pane, or closes it if it is already open. Shows the log inline where no pane can be drawn
/questions clearEmpties the log and clears the status line

Config

These show up in /config under question-log:

FieldDefaultWhat it does
detectQuestionstrueLog replies whose final paragraph asks you something
highlighttrueDraw detected-question replies in the coloured, bordered box

How detection works

Only the end of the main conversation's final reply in a turn counts. The detector reads the last paragraph. If that paragraph is a list, it also reads the paragraph that introduces the list. The reply counts as a question when that block ends on a question. A short closer after the question ("Let me know.") still counts, and so does a list of options right after the question.

These never count:

  • questions in fenced code blocks, inline code, block quotes, tables or quoted text
  • URLs with ? in them
  • headings such as ## Why does this fail?
  • rhetorical questions that the reply answers itself ("Why does this fail? Because ...")
  • questions earlier in the reply

Subagent turns and interrupted turns are ignored. A slash command, or a prompt that a plugin or a scheduled task sends, does not answer an open question. Only a prompt you send yourself does (typed, or sent through Remote Control or the SDK).

Limitations

  • Detection is a heuristic. A question with no ? ("Let me know which you prefer.") is missed. A reply that ends on a code block after its question is treated as not asking.
  • The answer to a detected question is your next prompt, whether or not it actually answers the question.
  • The highlight replaces the engine's drawing of that reply block with this plugin's own box and Markdown, so the reply's leading bullet is not drawn. The hook cannot wrap the engine's own rendering. AssistantMessage has no isExpanded prop in this build, so the box shows in the ctrl+o view too. Only the reply's last text block is boxed.
  • Rows are matched to log entries by their transcript id where it is known, and otherwise by a hash of the reply's text. Two replies with identical text are both boxed.
  • The AskUserQuestion dialog itself is left alone: its render props carry only the questions, so there is no safe way to add an accent without redrawing the engine's dialog.
  • The log lives in session state. It survives hot reloads, is emptied by /clear, and is not kept across sessions. It holds the last 200 entries.
  • The inline log is drawn from the log as it is now, so an earlier /questions row shows the current log, not the log when it ran.
  • Relative timestamps in the pane refresh whenever the log changes, not on a timer.
  • Built against Claude Code 2.1.290. The mods API is early access and may change between releases.

License

MIT

Source 3 files
hooks/register.tsx 319 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
3
4import type { QuestionLogEntry } from '../types'
5import {
6  answerDetected,
7  askQuestions,
8  detectQuestion,
9  findFlagged,
10  formatAskAnswer,
11  INLINE_HEAD,
12  inlineMarkdown,
13  inlineSelection,
14  normalize,
15  questionLine,
16  snippet,
17  summaryLine,
18  relativeTime,
19  statusText,
20  textHash,
21} from './lib'
22
23const PANE = 'question-log'
24const COMMAND = 'questions'
25const MAX_ENTRIES = 200
26
27const log = atom({ plugin: 'question-log', key: 'log' } as const, [])
28
29/** Origins of a prompt the person wrote: only these answer an open question. */
30function isPersonsPrompt(origin: PromptOrigin): boolean {
31  return origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk'
32}
33
34/** Applies `change` to the log and pins the `? N open` status line to match. */
35async function commit(
36  $: EngineInterface,
37  change: (entries: QuestionLogEntry[]) => QuestionLogEntry[],
38): Promise<QuestionLogEntry[]> {
39  const next = await update($, log, entries => change(entries).slice(-MAX_ENTRIES))
40  $.ui.status(statusText(next))
41  return next
42}
43
44/** The text blocks of a response row, joined. */
45function rowText(content: ReadonlyArray<{ type: string; [field: string]: unknown }>): string {
46  return content
47    .filter(block => block.type === 'text' && typeof block.text === 'string')
48    .map(block => String(block.text))
49    .join('\n')
50}
51
52export const register: Register = (on, options) => {
53  const detectQuestions = options.detectQuestions !== false
54  const highlight = options.highlight !== false
55
56  // The last main-loop response row with text this turn, so a detected
57  // question can name the row it came from.
58  let lastResponse: { uuid: string; text: string } | null = null
59
60  on('session.start', async ($, e, next) => {
61    await $.command.register({
62      name: COMMAND,
63      description: 'Toggle the question log pane (shown inline where no pane is drawn, as on mobile)',
64      argumentHint: '[clear]',
65      immediate: true,
66    })
67    $.ui.status(statusText(await read($, log)))
68    return next(e)
69  })
70
71  on('session.end', async ($, e, next) => {
72    if (e.reason === 'clear') {
73      lastResponse = null
74      await commit($, () => [])
75    }
76    return next(e)
77  })
78
79  // AskUserQuestion: logged open while the dialog is up, answered from its result.
80  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
81    const at = await $.clock.now()
82    const id = e.tool_use_id
83    await commit($, entries => [...entries, { id, kind: 'ask', at, questions: askQuestions(e.questions) }])
84
85    const ran = await next(e)
86
87    let answer: string
88    if (ran.deny !== undefined) answer = '(dismissed)'
89    else if (ran.isError === true) answer = '(dismissed)'
90    else {
91      const result = ran.result
92      answer = formatAskAnswer(e.questions, result.answers as Record<string, unknown>, result.response)
93    }
94    const answeredAt = await $.clock.now()
95    await commit($, entries => entries.map(entry => (entry.id === id ? { ...entry, answer, answeredAt } : entry)))
96    return ran
97  }).catch(($, e, next) => next(e))
98
99  // The main loop's response rows: remember the last one that carries text.
100  on('session.append', { door: 'response' }, async ($, e, next) => {
101    const stored = await next(e)
102    if (e.agentId === undefined && e.message.role === 'assistant') {
103      const text = rowText(e.message.content)
104      if (text.trim().length > 0) lastResponse = { uuid: e.uuid, text }
105    }
106    return stored
107  }).catch(($, e, next) => next(e))
108
109  // Detected questions: the reply's final text, as the turn ends.
110  on('turn.complete', async ($, e, next) => {
111    const response = lastResponse
112    if (e.agentId === undefined) lastResponse = null
113    if (!detectQuestions || e.agentId !== undefined || e.reason !== 'answer') return next(e)
114
115    const found = detectQuestion(e.answer)
116    if (found.isQuestion) {
117      const at = await $.clock.now()
118      const entry: QuestionLogEntry = {
119        id: `d-${e.turnId}`,
120        kind: 'detected',
121        at,
122        questions: found.questions.map(text => ({ text })),
123      }
124      const isSameRow = response !== null && normalize(e.answer).endsWith(normalize(response.text))
125      if (response !== null && isSameRow) {
126        entry.messageId = response.uuid
127        entry.textHash = textHash(response.text)
128      } else {
129        entry.textHash = textHash(e.answer)
130      }
131      await commit($, entries => [...entries.filter(one => one.id !== entry.id), entry])
132    }
133    return next(e)
134  })
135
136  // The person's next prompt answers every open detected question.
137  on('prompt.submit', async ($, e, next) => {
138    const entered = await next(e)
139    if (entered.drop !== undefined || !isPersonsPrompt(e.origin)) return entered
140    if (e.text.trimStart().startsWith('/')) return entered
141    const entries = await read($, log)
142    if (entries.some(entry => entry.kind === 'detected' && entry.answer === undefined)) {
143      const now = await $.clock.now()
144      await commit($, list => answerDetected(list, e.text, now))
145    }
146    return entered
147  }).catch(($, e, next) => next(e))
148
149  on('command.run', { command: COMMAND }, async ($, e) => {
150    const arg = e.args.trim().toLowerCase()
151    if (arg === 'clear') {
152      await commit($, () => [])
153      return { text: 'Question log cleared.' }
154    }
155    if (arg !== '') return { text: `Usage: /${COMMAND} [clear]` }
156
157    // Only a drawn pane toggles closed: one waiting undrawn (no surface here
158    // places panes, or the terminal is narrow) is answered inline again.
159    const isUp = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
160    if (isUp) {
161      await $.ui.close({ id: PANE })
162      return { text: 'Question log closed.' }
163    }
164    const opened = await $.ui.open({ id: PANE, title: 'Questions' })
165    if (opened.isPlaced) return { text: 'Question log opened.' }
166    // No pane drawn (the mobile app, an older desktop, a narrow terminal):
167    // the log is the command's answer, as markdown the CommandOutput hook
168    // below draws as a compact tree.
169    return { text: inlineMarkdown(await read($, log), await $.clock.now()) }
170  })
171
172  // `/questions` inline: where the pane could not be placed, and always on
173  // mobile, which draws no panes. Box and Text only, sized to the viewport.
174  on('ui.render', { component: 'CommandOutput', props: { command: COMMAND } }, async ($, e, next) => {
175    if (e.props.isErrored || e.props.args.trim() !== '') return next(e)
176    const isInline = e.props.text.startsWith(INLINE_HEAD)
177    if (!isInline && e.surface !== 'mobile') return next(e)
178
179    const { Box, Text } = $.ui.resolve(e)
180    const entries = await read($, log)
181    const now = await $.clock.now()
182    const columns = Math.max(24, e.viewport?.columns ?? 80)
183    const width = columns - 4
184
185    if (entries.length === 0) {
186      return (
187        <Box flexDirection="column">
188          <Text dimColor>No questions yet.</Text>
189        </Box>
190      )
191    }
192
193    const { open, answered, hidden } = inlineSelection(entries)
194    return (
195      <Box flexDirection="column">
196        <Text bold>{summaryLine(entries)}</Text>
197        {open.map(entry => (
198          <Box key={entry.id} flexDirection="column">
199            <Text>
200              <Text bold color="yellow">
201                {'? '}
202              </Text>
203              <Text color="yellow">{questionLine(entry, width)}</Text>
204              <Text dimColor>
205                {'  '}
206                {relativeTime(entry.at, now)}
207              </Text>
208            </Text>
209          </Box>
210        ))}
211        {answered.map(entry => (
212          <Box key={entry.id} flexDirection="column">
213            <Text>
214              <Text color="green">{'✓ '}</Text>
215              <Text>{questionLine(entry, width)}</Text>
216              <Text dimColor>
217                {'  '}
218                {relativeTime(entry.at, now)}
219              </Text>
220            </Text>
221            <Box paddingLeft={2}>
222              <Text dimColor>
223                {'↳ '}
224                {snippet(entry.answer ?? '', width - 2)}
225              </Text>
226            </Box>
227          </Box>
228        ))}
229        {hidden > 0 && <Text dimColor>{hidden} older not shown · /questions clear empties the log</Text>}
230      </Box>
231    )
232  })
233
234  // Highlight: a reply flagged as a question is drawn in a coloured box.
235  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
236    if (!highlight || e.props.isSummary === true) return next(e)
237    if (!e.props.text.includes('?')) return next(e)
238    const entries = await read($, log)
239    const flagged = findFlagged(entries, e.requestId, e.props.text)
240    if (flagged === undefined) return next(e)
241
242    const { Box, Text, Markdown } = $.ui.resolve(e)
243    const isOpen = flagged.answer === undefined
244    const accent = isOpen ? 'yellow' : 'magenta'
245    return (
246      <Box flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1}>
247        <Text bold color={accent}>
248          ? Question for you
249          {isOpen ? '' : ' · answered'}
250        </Text>
251        <Markdown text={e.props.text} />
252      </Box>
253    )
254  })
255
256  // The log pane: every question in order, newest at the bottom.
257  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
258    const { Box, Text } = $.ui.resolve(e)
259    const entries = await read($, log)
260    const now = await $.clock.now()
261    const open = entries.filter(entry => entry.answer === undefined).length
262
263    if (entries.length === 0) {
264      return (
265        <Box flexDirection="column">
266          <Text dimColor>No questions yet. AskUserQuestion dialogs and replies that end with a question show here.</Text>
267        </Box>
268      )
269    }
270
271    return (
272      <Box flexDirection="column">
273        <Text bold>
274          {entries.length} question{entries.length === 1 ? '' : 's'}
275          {open > 0 ? ` · ${open} open` : ''}
276        </Text>
277        {entries.map(entry => {
278          const isOpen = entry.answer === undefined
279          return (
280            <Box key={entry.id} flexDirection="column" marginTop={1}>
281              <Text>
282                <Text bold color={isOpen ? 'yellow' : 'green'}>
283                  {isOpen ? '? open' : '✓ answered'}
284                </Text>
285                <Text dimColor>
286                  {'  '}
287                  {relativeTime(entry.at, now)} {'·'} {entry.kind === 'ask' ? 'AskUserQuestion' : 'in reply'}
288                </Text>
289              </Text>
290              {entry.questions.map(question => (
291                <Box flexDirection="column" paddingLeft={2}>
292                  <Text color={isOpen ? 'yellow' : undefined} bold={isOpen}>
293                    {question.header !== undefined ? `[${question.header}] ` : ''}
294                    {question.text}
295                  </Text>
296                  {question.options !== undefined && (
297                    <Text dimColor>options: {question.options.join(' · ')}</Text>
298                  )}
299                </Box>
300              ))}
301              {entry.answer !== undefined && (
302                <Box paddingLeft={4}>
303                  <Text>
304                    {'↳ '}
305                    {entry.answer}
306                    <Text dimColor>
307                      {entry.answeredAt !== undefined ? `  ${relativeTime(entry.answeredAt, now)}` : ''}
308                    </Text>
309                  </Text>
310                </Box>
311              )}
312            </Box>
313          )
314        })}
315      </Box>
316    )
317  })
318}
319
hooks/lib.ts 380 lines
1// Pure helpers for question-log: the question detector, AskUserQuestion
2// answer formatting, and small text utilities. No `$`, so unit-testable.
3
4import type { QuestionLogEntry, QuestionLogQuestion } from '../types'
5
6export type Detection = { isQuestion: boolean; questions: string[] }
7
8const MAX_QUESTIONS = 4
9const MAX_QUESTION_CHARS = 300
10
11/** Trailing sentences after the last question that still leave it a question ("Let me know."). */
12const CLOSER =
13  /^(let me know|thanks|thank you|happy to|i can|i'?ll|either way|just say|no rush|cheers|or (?:i|we) can)\b/i
14
15/** What stands in for a code block, a block quote or a table. */
16const PLACEHOLDER = '[omitted]'
17
18/** Lines that are list items: `- x`, `* x`, `+ x`, `1. x`, `1) x`. */
19const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/
20
21/** A quotation with its words kept: a mark that ended it ends the sentence outside, as a full stop. */
22function quote(open: string, quoted: string, close: string): string {
23  return `${open}${unpunctuate(quoted)}${close}${/[.!?]\s*$/.test(quoted) ? '.' : ''}`
24}
25
26/**
27 * Removes what must never count as the reply asking: fenced code blocks,
28 * block quotes, tables, inline code, quoted text, links' targets and URLs.
29 */
30export function stripNonProse(text: string): string {
31  const lines = text.replace(/\r\n?/g, '\n').split('\n')
32  const kept: string[] = []
33  let fence: string | null = null
34  for (const line of lines) {
35    const opener = /^\s{0,3}(`{3,}|~{3,})/.exec(line)
36    if (fence !== null) {
37      if (opener !== null && opener[1] !== undefined && opener[1][0] === fence[0] && opener[1].length >= fence.length) {
38        fence = null
39      }
40      continue
41    }
42    if (opener !== null && opener[1] !== undefined) {
43      // A placeholder line, so a reply ending on code or a quote ends on no question.
44      fence = opener[1]
45      kept.push(PLACEHOLDER)
46      continue
47    }
48    if (/^\s*>/.test(line) || /^\s*\|/.test(line)) {
49      if (kept[kept.length - 1] !== PLACEHOLDER) kept.push(PLACEHOLDER)
50      continue
51    }
52    kept.push(line)
53  }
54  return kept
55    .join('\n')
56    .replace(/`([^`\n]*)`/g, (_, code: string) => unpunctuate(code))
57    .replace(/\[([^\]\n]*)\]\([^)\n]*\)/g, '$1')
58    .replace(/\bhttps?:\/\/\S+/g, ' ')
59    .replace(/"([^"\n]*)"/g, (_, quoted: string) => quote('"', quoted, '"'))
60    .replace(/\u201c([^\u201d\n]*)\u201d/g, (_, quoted: string) => quote('\u201c', quoted, '\u201d'))
61    .replace(/(^|[\s(])'([^'\n]{2,})'(?=[\s.,;:!?)]|$)/g, (_, lead: string, quoted: string) => lead + quote("'", quoted, "'"))
62}
63
64/** Quoted or code text keeps its words but loses the marks that end a sentence. */
65function unpunctuate(text: string): string {
66  return text.replace(/[?!.]+/g, '')
67}
68
69/** Splits text into paragraphs (blank-line separated), each trimmed, empties dropped. */
70export function paragraphs(text: string): string[] {
71  return text
72    .split(/\n\s*\n/)
73    .map(p => p.trim())
74    .filter(p => p.length > 0)
75}
76
77function isHeadingLine(line: string): boolean {
78  const t = line.trim()
79  if (/^#{1,6}\s/.test(t)) return true
80  // A line that is nothing but bold or italic text reads as a heading.
81  return /^(\*\*|__)[^*_]+(\*\*|__):?$/.test(t)
82}
83
84function cleanSentence(s: string): string {
85  return s
86    .replace(LIST_ITEM, '')
87    .replace(/[*_]{1,3}/g, '')
88    .replace(/\s+/g, ' ')
89    .trim()
90}
91
92/** Splits prose into sentences on `.`, `!` or `?` followed by space, keeping the mark. */
93export function sentences(text: string): string[] {
94  const out: string[] = []
95  const re = /[^.!?]*(?:[.!?]+["')\]*_]*|$)/g
96  for (const m of text.matchAll(re)) {
97    const s = m[0].trim()
98    if (s.length > 0) out.push(s)
99  }
100  return out
101}
102
103function isQuestionSentence(s: string): boolean {
104  return /\?["')\]*_]*$/.test(s.trim())
105}
106
107/**
108 * The block of the reply the detector reads: its final paragraph, or, when
109 * that is a list, the list together with the paragraph that introduces it.
110 * Headings close no block: a reply ending on a heading asks nothing.
111 */
112function finalBlock(prose: string): string[] | null {
113  const paras = paragraphs(prose)
114  const last = paras[paras.length - 1]
115  if (last === undefined) return null
116  const lastLines = last.split('\n').filter(l => l.trim().length > 0)
117  if (lastLines.length > 0 && lastLines.every(isHeadingLine)) return null
118  const isList = lastLines.length > 0 && lastLines.every(l => LIST_ITEM.test(l) || /^\s{2,}\S/.test(l))
119  const block = [...lastLines]
120  if (isList) {
121    const intro = paras[paras.length - 2]
122    if (intro !== undefined) {
123      const introLines = intro.split('\n').filter(l => l.trim().length > 0 && !isHeadingLine(l))
124      block.unshift(...introLines)
125    }
126  }
127  return block.filter(l => !isHeadingLine(l))
128}
129
130/**
131 * Whether an assistant reply's final paragraph asks the user something, and
132 * the question sentence(s) it asks.
133 *
134 * Only the end of the reply counts (a rhetorical "Why does this fail?" mid
135 * reply or as a heading is ignored); code fences, block quotes, inline code
136 * and quoted text never count; the reply must end on the question, or on a
137 * short closer after it ("Let me know."), and a list of questions at the end
138 * counts with its introduction.
139 */
140export function detectQuestion(text: string): Detection {
141  const none: Detection = { isQuestion: false, questions: [] }
142  const block = finalBlock(stripNonProse(text))
143  if (block === null || block.length === 0) return none
144
145  const found: string[] = []
146  let lastIsQuestion = false
147  let trailing: string[] = []
148  for (const line of block) {
149    const parts = sentences(cleanSentence(line))
150    for (const part of parts) {
151      if (isQuestionSentence(part)) {
152        found.push(part)
153        trailing = []
154        lastIsQuestion = true
155      } else {
156        trailing.push(part)
157        lastIsQuestion = false
158      }
159    }
160  }
161  if (found.length === 0) return none
162
163  if (!lastIsQuestion) {
164    const lastQuestionIndex = block.findIndex(l => sentences(cleanSentence(l)).some(isQuestionSentence))
165    const after = block.slice(lastQuestionIndex + 1)
166    const afterIsOptions = after.length > 0 && after.every(l => LIST_ITEM.test(l))
167    const isCloser = trailing.length <= 2 && trailing.every(s => CLOSER.test(s) || s.length <= 3)
168    const questionIntroducesOptions = afterIsOptions && after.length <= 8
169    if (!isCloser && !questionIntroducesOptions) return none
170  }
171
172  const questions = found
173    .map(q => (q.length > MAX_QUESTION_CHARS ? `${q.slice(0, MAX_QUESTION_CHARS - 1)}…` : q))
174    .slice(-MAX_QUESTIONS)
175  return { isQuestion: true, questions }
176}
177
178/** Collapses whitespace and trims, so two drawings of one text compare equal. */
179export function normalize(text: string): string {
180  return text.replace(/\s+/g, ' ').trim()
181}
182
183/** FNV-1a over the normalized text, as 8 hex digits: a cheap key for a reply's text. */
184export function textHash(text: string): string {
185  const s = normalize(text)
186  let h = 0x811c9dc5
187  for (let i = 0; i < s.length; i++) {
188    h ^= s.charCodeAt(i)
189    h = Math.imul(h, 0x01000193) >>> 0
190  }
191  return h.toString(16).padStart(8, '0')
192}
193
194/** The first `max` characters of a reply, on one line, with an ellipsis when cut. */
195export function snippet(text: string, max = 200): string {
196  const s = normalize(text)
197  return s.length > max ? `${s.slice(0, max - 1)}…` : s
198}
199
200/** The shape of one AskUserQuestion question this module reads. */
201export type AskQuestionInput = {
202  question: string
203  header?: string
204  options?: ReadonlyArray<{ label: string }>
205  multiSelect?: boolean
206}
207
208/** AskUserQuestion's questions as log questions. */
209export function askQuestions(input: ReadonlyArray<AskQuestionInput>): QuestionLogQuestion[] {
210  return input.map(q => {
211    const out: QuestionLogQuestion = { text: q.question }
212    if (q.header !== undefined && q.header.length > 0) out.header = q.header
213    const labels = (q.options ?? []).map(o => o.label)
214    if (labels.length > 0) out.options = labels
215    return out
216  })
217}
218
219/**
220 * One question's answer as the log shows it: the chosen label, the chosen
221 * labels of a multi-select joined by ", ", and text typed under "Other" as
222 * `Other: "..."`.
223 */
224export function formatOneAnswer(raw: string, labels: readonly string[], isMulti: boolean): string {
225  const value = raw.trim()
226  if (value.length === 0) return '(no answer)'
227  if (labels.includes(value)) return value
228  if (!isMulti) return `Other: "${value}"`
229  const tokens = value.split(/,\s*/)
230  const chosen: string[] = []
231  const rest: string[] = []
232  for (const token of tokens) {
233    if (labels.includes(token.trim())) chosen.push(token.trim())
234    else rest.push(token)
235  }
236  const other = rest.join(', ').trim()
237  if (other.length > 0) chosen.push(`Other: "${other}"`)
238  return chosen.join(', ')
239}
240
241/**
242 * The answer string of an AskUserQuestion call from its result's `answers`
243 * (question text to answer) and `response` (free text typed instead).
244 */
245export function formatAskAnswer(
246  questions: ReadonlyArray<AskQuestionInput>,
247  answers: Readonly<Record<string, unknown>> | undefined,
248  response?: string,
249): string {
250  const parts: string[] = []
251  for (const q of questions) {
252    const raw = answers?.[q.question]
253    const labels = (q.options ?? []).map(o => o.label)
254    const formatted =
255      typeof raw === 'string'
256        ? formatOneAnswer(raw, labels, q.multiSelect === true)
257        : Array.isArray(raw)
258          ? formatOneAnswer(raw.filter(x => typeof x === 'string').join(', '), labels, true)
259          : '(no answer)'
260    parts.push(questions.length > 1 ? `${q.header || q.question}: ${formatted}` : formatted)
261  }
262  const anyAnswered = parts.some(p => !p.endsWith('(no answer)'))
263  if (!anyAnswered && response !== undefined && response.trim().length > 0) {
264    return `Other: "${snippet(response)}"`
265  }
266  return parts.join('; ')
267}
268
269/** "just now", "42s ago", "5m ago", "3h ago", "2d ago". */
270export function relativeTime(at: number, now: number): string {
271  const s = Math.max(0, Math.round((now - at) / 1000))
272  if (s < 10) return 'just now'
273  if (s < 60) return `${s}s ago`
274  const m = Math.floor(s / 60)
275  if (m < 60) return `${m}m ago`
276  const h = Math.floor(m / 60)
277  if (h < 24) return `${h}h ago`
278  return `${Math.floor(h / 24)}d ago`
279}
280
281/** How many entries still wait for an answer. */
282export function openCount(log: readonly QuestionLogEntry[]): number {
283  return log.filter(entry => entry.answer === undefined).length
284}
285
286/** The status line text: `? N open`, or undefined (cleared) at none. */
287export function statusText(log: readonly QuestionLogEntry[]): string | undefined {
288  const n = openCount(log)
289  return n > 0 ? `? ${n} open` : undefined
290}
291
292/** Marks every open detected question answered by a prompt's text. */
293export function answerDetected(
294  log: readonly QuestionLogEntry[],
295  promptText: string,
296  now: number,
297): QuestionLogEntry[] {
298  const answer = snippet(promptText, 200)
299  return log.map(entry =>
300    entry.kind === 'detected' && entry.answer === undefined ? { ...entry, answer, answeredAt: now } : entry,
301  )
302}
303
304/** Whether a drawn AssistantMessage row is a logged detected question. */
305export function findFlagged(
306  log: readonly QuestionLogEntry[],
307  requestId: string,
308  text: string,
309): QuestionLogEntry | undefined {
310  let hash: string | undefined
311  for (let i = log.length - 1; i >= 0; i--) {
312    const entry = log[i]
313    if (entry === undefined || entry.kind !== 'detected') continue
314    if (entry.messageId !== undefined && entry.messageId === requestId) return entry
315    if (entry.textHash !== undefined) {
316      hash ??= textHash(text)
317      if (entry.textHash === hash) return entry
318    }
319  }
320  return undefined
321}
322
323/** How many entries the inline log (`/questions` where no pane is drawn) shows at most. */
324export const INLINE_CAP = 20
325
326/** The first line of `/questions`' text when the pane could not be placed: the inline log follows. */
327export const INLINE_HEAD = 'Question log (no pane here, shown inline):'
328
329/**
330 * What the inline log shows: of the last `cap` entries, the open ones first
331 * (oldest first, the order they were asked), then the answered ones newest
332 * first; `hidden` counts the older entries left out.
333 */
334export function inlineSelection(
335  log: readonly QuestionLogEntry[],
336  cap = INLINE_CAP,
337): { open: QuestionLogEntry[]; answered: QuestionLogEntry[]; hidden: number; openTotal: number } {
338  const recent = log.slice(-cap)
339  const open = recent.filter(entry => entry.answer === undefined)
340  const answered = recent.filter(entry => entry.answer !== undefined).reverse()
341  return { open, answered, hidden: log.length - recent.length, openTotal: openCount(log) }
342}
343
344/** One entry's questions on one line: `[Header] text` joined by ` · `. */
345export function questionLine(entry: QuestionLogEntry, max = 200): string {
346  const text = entry.questions
347    .map(question => `${question.header !== undefined ? `[${question.header}] ` : ''}${question.text}`)
348    .join(' · ')
349  return snippet(text, max)
350}
351
352/** The inline log's summary line: `3 questions · 1 open`. */
353export function summaryLine(log: readonly QuestionLogEntry[]): string {
354  const open = openCount(log)
355  return `${log.length} question${log.length === 1 ? '' : 's'}${open > 0 ? ` · ${open} open` : ''}`
356}
357
358/** Escapes what markdown would read as formatting in a logged text. */
359function mdEscape(text: string): string {
360  return text.replace(/([\\`*_[\]<>#|])/g, '\\$1')
361}
362
363/**
364 * The inline log as markdown: the `/questions` output text where no pane is
365 * drawn, which any surface (and the transcript) shows even when no render
366 * hook draws it as a tree.
367 */
368export function inlineMarkdown(log: readonly QuestionLogEntry[], now: number, cap = INLINE_CAP): string {
369  if (log.length === 0) return `${INLINE_HEAD}\n\nNo questions yet.`
370  const { open, answered, hidden } = inlineSelection(log, cap)
371  const lines = [INLINE_HEAD, '', `**${summaryLine(log)}**`, '']
372  for (const entry of open) lines.push(`- ❓ ${mdEscape(questionLine(entry))} _(${relativeTime(entry.at, now)})_`)
373  for (const entry of answered) {
374    lines.push(`- ✓ ${mdEscape(questionLine(entry))} _(${relativeTime(entry.at, now)})_`)
375    lines.push(`  ↳ ${mdEscape(snippet(entry.answer ?? '', 120))}`)
376  }
377  if (hidden > 0) lines.push('', `_${hidden} older not shown_`)
378  return lines.join('\n')
379}
380
types/index.d.ts 29 lines
1/** One question of a log entry: AskUserQuestion's header, text and option labels, or a detected sentence. */
2export type QuestionLogQuestion = {
3  header?: string
4  text: string
5  options?: string[]
6}
7
8/** One logged question: an AskUserQuestion call or a question detected at the end of a reply. */
9export type QuestionLogEntry = {
10  id: string
11  kind: 'ask' | 'detected'
12  /** When it was asked, ms since the epoch. */
13  at: number
14  questions: QuestionLogQuestion[]
15  /** The answer: the dialog's choices, or the first ~200 characters of your next prompt. Absent while open. */
16  answer?: string
17  answeredAt?: number
18  /** The transcript row (session.append uuid) of the reply that asked, for a detected question. */
19  messageId?: string
20  /** A hash of the reply's text, to recognise its row when the row's id is not known. */
21  textHash?: string
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    'question-log': { log: QuestionLogEntry[] }
27  }
28}
29