SLOPSHOPPER

answers

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

newpanebandcommandprompt
v0.1.0MITupdated 2026-10-06rjohnt/answers-claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · answers
│ ┃ Answers ✕ › fix the failing auth test and add an audit log call │ ┃ 8:53am │ ┃ fix the failing auth test and add an audit ⏺ Read(src/auth.ts) │ ┃ log call ⎿ Read 6 lines │ ┃ Done. I made refresh reject expired claims, ⏺ Update(src/auth.ts) │ ┃ added an audit call, and created ⎿ Added 2 lines, removed 1 line │ ┃ src/audit.ts. One test still fails (revokes ⏺ Bash(bun test) │ ┃ on logout), which looks unrelated to this ⎿ 3 pass, 1 fail │ ┃ change. │ ┃ [ Expand ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ │ › /answers │ ⎿ answers: Answers pane opened. │ │ ↳ Done. I made refresh reject expired claims, added an audit call, and created src/audit.ts. One test still fails (r [ Answers ] [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
↳ Done. I made refresh reject expired claims, added an audit call, and created src/audit.ts. One tes [ Answers ] [ Hide ]
Pane · Answers
8:53am fix the failing auth test and add an audit log call Done. I made refresh reject expired claims, added an audit call, and created src/audit.ts. One test still fails (revokes on logout), which looks unrelated to this change. [ Expand ]
README

answers (Claude Code mod)

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

Mockup: a busy main thread on the left, the band above the prompt, and the Answers pane on the right

Mockup, drawn in Claude colours. The real pane uses your terminal theme.

  • Band above the prompt: latest answer's headline + any open "Needs you:" ask. Buttons: Answers, Hide.
  • /answers pane: newest first; time, your prompt as a bold header, the reply's opening paragraph; Expand/Collapse for the full reply.
  • Only main-loop replies to prompts you typed are filed (subagent hand-backs, watcher digests and pasted notices are skipped).

Install

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

Source 3 files
hooks/register.tsx 135 lines
1import { 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}
135
hooks/logic.ts 80 lines
1// 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}
80
types/index.d.ts 22 lines
1export 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