Answers-only view of the main thread: each reply filed under the prompt it answers, latest headline + open ask pinned above the prompt

An answers-only view of a busy main thread: each reply is filed under the prompt it answers.

Mockup, drawn in Claude colours. The real pane uses your terminal theme.
/answers pane: newest first; time, your prompt as a bold header, the reply's opening paragraph; Expand/Collapse for the full reply./plugin marketplace add rjohnt/answers-claude-mod
/plugin install answers@answers-claude-mod
Or load it without installing: copy this folder into your session's dev-mods folder (hot reload), or run claude --plugin-dir <this folder>. Check: claude plugin validate . and claude plugin test .
MIT licensed.
hooks/register.tsx 135 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Entry } from '../types'
5import { askOf, bodyAfterHeadline, headlineOf, isNoisePrompt, shorten, summaryOf, clockTime, stripPasteTags } from './logic'
6
7const PANE = 'answers'
8const entries = atom({ plugin: 'answers', key: 'entries' } as const, [])
9const pending = atom({ plugin: 'answers', key: 'pending' } as const, null)
10const mainTurns = atom({ plugin: 'answers', key: 'mainTurns' } as const, [])
11const expanded = atom({ plugin: 'answers', key: 'expanded' } as const, [])
12const isBandHidden = atom({ plugin: 'answers', key: 'isBandHidden' } as const, false)
13
14export const register: Register = on => {
15 on('session.start', async ($, e, next) => {
16 await $.command.register({ name: 'answers', description: 'Show only my questions and the replies to them' })
17 return next(e)
18 })
19
20 on('command.run', { command: 'answers' }, async $ => {
21 await update($, isBandHidden, () => false)
22 await $.ui.open({ id: PANE, title: 'Answers' })
23 return { text: 'Answers pane opened.' }
24 })
25
26 // A prompt the user typed opens a new entry; its replies fill it. It also answers any open ask.
27 on('prompt.submit', async ($, e, next) => {
28 const text = stripPasteTags(e.text)
29 if (!isNoisePrompt(text)) {
30 try {
31 const id = await $.clock.now()
32 const entry: Entry = { id, at: id, question: shorten(text, 200), headline: '', answer: '', ask: null, isAskOpen: false }
33 await update($, entries, list => [...list.map(x => (x.isAskOpen ? { ...x, isAskOpen: false } : x)), entry].slice(-150))
34 await update($, pending, () => id)
35 } catch {
36 // Never block a prompt over bookkeeping.
37 }
38 }
39 return next(e)
40 }).catch(($, e, next) => next(e))
41
42 // Only the main loop raises turn.start; subagent runs (their hand-back reports) don't.
43 on('turn.start', async ($, e, next) => {
44 await update($, mainTurns, list => [...(Array.isArray(list) ? list : []), e.turnId].slice(-50))
45 return next(e)
46 })
47
48 // The main loop's reply to the user's latest prompt; a later reply to the same prompt replaces it.
49 on('turn.complete', async ($, e, next) => {
50 const ran = await next(e)
51 const turns: unknown = await read($, mainTurns)
52 const isMain = e.loop === undefined && Array.isArray(turns) && turns.includes(e.turnId)
53 const id = await read($, pending)
54 if (isMain && id !== null && e.reason === 'answer' && e.answer.trim()) {
55 const ask = askOf(e.answer)
56 await update($, entries, list =>
57 list.map(x => (x.id === id ? { ...x, headline: headlineOf(e.answer), answer: e.answer, ask, isAskOpen: ask !== null } : x)),
58 )
59 }
60 return ran
61 })
62
63 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
64 const list = (await read($, entries)).filter(x => x.answer)
65 const last = list[list.length - 1]
66 if (e.props.hasSurvey || !last || (await read($, isBandHidden))) return next(e)
67 const { Box, Button, Text } = $.ui.resolve(e)
68 const open = list.filter(x => x.isAskOpen)
69 return (
70 <Box flexDirection="column">
71 <Box>
72 <Text color="cyan" bold>↳ </Text>
73 <Text wrap="truncate-end">{last.headline}</Text>
74 </Box>
75 {open.length > 0 && (
76 <Box>
77 <Text wrap="wrap">
78 <Text color="magenta" bold>Needs you: </Text>
79 <Text bold>{open[open.length - 1].ask}</Text>
80 {open.length > 1 && <Text dimColor> (+{open.length - 1} more)</Text>}
81 </Text>
82 </Box>
83 )}
84 <Box>
85 <Button key="open" label="Answers" onPress={() => $.ui.open({ id: PANE, title: 'Answers' })} />
86 <Text> </Text>
87 <Button key="hide" label="Hide" onPress={() => update($, isBandHidden, () => true)} />
88 </Box>
89 </Box>
90 )
91 })
92
93 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
94 const { Box, Button, Text } = $.ui.resolve(e)
95 const all = await read($, entries)
96 const list = [...all].reverse().filter(x => !x.question.startsWith('(no prompt'))
97 const rawOpen: unknown = await read($, expanded)
98 const open = Array.isArray(rawOpen) ? (rawOpen as number[]) : []
99 if (list.length === 0) return <Text dimColor>No answers yet. Your prompts and the replies to them collect here.</Text>
100 return (
101 <Box flexDirection="column">
102 {list.map(x => {
103 const isOpen = open.includes(x.id)
104 return (
105 <Box key={String(x.id)} flexDirection="column" marginBottom={1}>
106 <Text dimColor>{clockTime(x.at)}</Text>
107 <Text color="cyan" bold wrap="wrap">{x.question}</Text>
108 <Box flexDirection="column">
109 {x.answer ? (
110 <Box>
111 <Text wrap="wrap">
112 {x.isAskOpen && <Text color="magenta" bold>Needs you: </Text>}
113 {isOpen ? x.headline : summaryOf(x.answer)}
114 </Text>
115 </Box>
116 ) : (
117 <Text dimColor>working on it…</Text>
118 )}
119 {isOpen && x.answer && <Text dimColor={false}>{bodyAfterHeadline(x.answer)}</Text>}
120 {x.answer && (
121 <Button
122 key={`t${x.id}`}
123 label={isOpen ? 'Collapse' : 'Expand'}
124 onPress={() => update($, expanded, prev => { const ids = Array.isArray(prev) ? prev : []; return ids.includes(x.id) ? ids.filter(y => y !== x.id) : [...ids, x.id] })}
125 />
126 )}
127 </Box>
128 </Box>
129 )
130 })}
131 </Box>
132 )
133 })
134}
135hooks/logic.ts 80 lines1// Pure helpers, kept apart so tests can run them without the engine.
2
3/** Prompts the user didn't type as a question: watcher digests, task/agent notices. */
4export function isNoisePrompt(text: string): boolean {
5 const t = text.trimStart()
6 return /^\[hd (watcher|MESSAGE|ANNOUNCEMENT)/.test(t) || /^<(task-notification|agent-message|system-reminder)/.test(t)
7}
8
9function clean(line: string): string {
10 return line
11 .replace(/^\s*(#{1,6}\s+|[-*•◦]\s+|>\s*)/, '')
12 .replace(/\*\*|`/g, '')
13 .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
14 .trim()
15}
16
17/** First meaningful line of a reply, markdown stripped, capped. */
18export function headlineOf(answer: string, max = 160): string {
19 for (const raw of answer.split('\n')) {
20 if (/^\s*```/.test(raw)) continue
21 const line = clean(raw)
22 if (line) return line.length > max ? line.slice(0, max - 1) + '…' : line
23 }
24 return ''
25}
26
27/** The question the reply ends on, if its last paragraph asks the user something. */
28export function askOf(answer: string, max = 200): string | null {
29 const paras = answer.trim().split(/\n\s*\n/).filter(p => !/^\s*```/.test(p))
30 const last = paras[paras.length - 1] ?? ''
31 if (!last.includes('?')) return null
32 const sentences = clean(last.replace(/\n/g, ' ')).split(/(?<=[.?!])\s+/)
33 const q = [...sentences].reverse().find(s => s.trim().endsWith('?'))
34 if (!q) return null
35 const s = q.trim()
36 return s.length > max ? s.slice(0, max - 1) + '…' : s
37}
38
39export function shorten(text: string, max = 120): string {
40 const one = text.replace(/\s+/g, ' ').trim()
41 return one.length > max ? one.slice(0, max - 1) + '…' : one
42}
43
44/** The reply without its first meaningful line, so an expanded entry doesn't repeat its headline. */
45export function bodyAfterHeadline(answer: string): string {
46 const lines = answer.split('\n')
47 const i = lines.findIndex(l => l.trim() && !/^\s*```/.test(l))
48 return i < 0 ? answer : lines.slice(i + 1).join('\n').replace(/^\s*\n/, '')
49}
50
51/** The reply's opening paragraph (markdown stripped, joined, capped) for the collapsed view. */
52export function summaryOf(answer: string, max = 420): string {
53 const paras = answer.trim().split(/\n\s*\n/)
54 for (const p of paras) {
55 if (/^\s*```/.test(p)) continue
56 const text = p.split('\n').map(clean).filter(Boolean).join(' ')
57 if (text) return text.length > max ? text.slice(0, max - 1) + '…' : text
58 }
59 return ''
60}
61
62/** "12:10pm" in the given IANA time zone, or the system's local zone when omitted. */
63export function clockTime(ms: number, timeZone?: string): string {
64 try {
65 const opts: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' }
66 if (timeZone) opts.timeZone = timeZone
67 return new Intl.DateTimeFormat('en-US', opts).format(new Date(ms)).replace(/\s/g, '').toLowerCase()
68 } catch {
69 const d = new Date(ms)
70 const h = d.getHours()
71 const m = String(d.getMinutes()).padStart(2, '0')
72 return `${h % 12 || 12}:${m}${h < 12 ? 'am' : 'pm'}`
73 }
74}
75
76/** Drop the <pasted_content …> wrapper tags the harness puts around pastes. */
77export function stripPasteTags(text: string): string {
78 return text.replace(/<\/?pasted_content[^>]*>/g, '').trim()
79}
80types/index.d.ts 22 lines1export type Entry = {
2 id: number
3 at: number
4 question: string
5 headline: string
6 answer: string
7 ask: string | null
8 isAskOpen: boolean
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 answers: {
14 entries: Entry[]
15 pending: number | null
16 mainTurns: string[]
17 expanded: number[]
18 isBandHidden: boolean
19 }
20 }
21}
22