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

Makes it obvious when Claude is waiting on you, and keeps a log of every question and your answer.
Other: "...".? Question for you header. The box is yellow while the question is open and magenta once you have answered./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.? 2 open under the prompt while anything is unanswered. It clears at zero./plugin install question-log --marketplace mrjk05/modemon
Answer y to add the marketplace, then pick a scope.
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
Works on the terminal, the desktop app's Code tab and the Claude mobile app (watching a cloud or Remote Control session).
| Terminal | Desktop | Mobile | |
|---|---|---|---|
| Question highlight box | yes | yes | yes (Box, Text and Markdown only) |
/questions log pane | yes | yes | no: mobile draws no panes |
/questions inline log | when the pane can't be placed | when the pane can't be placed | always |
? 2 open status line | yes | yes | yes |
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.
| Command | What it does |
|---|---|
/questions | Opens the log pane, or closes it if it is already open. Shows the log inline where no pane can be drawn |
/questions clear | Empties the log and clears the status line |
These show up in /config under question-log:
| Field | Default | What it does |
|---|---|---|
detectQuestions | true | Log replies whose final paragraph asks you something |
highlight | true | Draw detected-question replies in the coloured, bordered box |
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:
? in them## Why does this fail?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).
? ("Let me know which you prefer.") is missed. A reply that ends on a code block after its question is treated as not asking.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./clear, and is not kept across sessions. It holds the last 200 entries./questions row shows the current log, not the log when it ran.MIT
hooks/register.tsx 319 lines1import { 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}
319hooks/lib.ts 380 lines1// 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}
380types/index.d.ts 29 lines1/** 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