SLOPSHOPPER

qa-jump

/qa pane: every question you asked this session with the start of Claude's answer; click to jump the transcript to the answer or the question.

newpanecommandtoast
v0.1.1MITupdated 2026-10-15aecheman27-gif/claude-mods/qa-jump
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · qa-jump
│ ┃ Questions ✕ › fix the failing auth test and add an audit log call │ ┃ Questions you type show here; click one to j │ ┃ answer. ⏺ 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 │ │ › /qa │ ⎿ qa-jump: Questions pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Questions
Questions you type show here; click one to jump to its answer.
README

claude-mods

Personal Claude Code mods, built or vendored at a reviewed commit. Each mod lives in its own folder and is listed in .claude-plugin/marketplace.json.

Source 2 files
hooks/register.tsx 133 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Entry } from '../types'
5
6// A /qa pane listing every question you typed this session, newest first, with
7// the start of Claude's answer under it. A click scrolls the transcript to the
8// answer (or back to the question). It learns the rows as the main conversation
9// stores them (each row's id is the one its transcript row is drawn under) and
10// changes nothing. No network, no processes, no model calls.
11
12const PANE = 'qa'
13const HUMAN = new Set(['composer', 'bridge'])
14const KEEP = 300
15const SHOW = 40
16
17const entries = atom({ plugin: 'qa-jump', key: 'entries' } as const, [])
18
19const oneLine = (text: string, max: number) => {
20  const t = text
21    .replace(/```[\s\S]*?```/g, ' ')
22    .replace(/[#*_`>|]/g, '')
23    .replace(/\s+/g, ' ')
24    .trim()
25  return t.length > max ? t.slice(0, Math.max(1, max - 1)) + '…' : t
26}
27
28/** Typed (composer), sent from a phone (bridge), or sent for the person by another mod's button (`asUser`). */
29const byPerson = (origin: { kind: string; asUser?: boolean }) => HUMAN.has(origin.kind) || (origin.kind === 'plugin' && origin.asUser === true)
30
31/** A prompt folded into a running turn, out of the engine's framing around it. */
32const folded = (text: string) => {
33  const body = text.replace(/<\/?system-reminder>/g, '').trim()
34  return /^The user sent [^\n]*:\n([\s\S]*?)(?:\n\nThis is how Claude Code surfaces[\s\S]*)?$/.exec(body)?.[1]?.trim() ?? body
35}
36
37/** The transcript row a press scrolls to: the answer where it lands (else where it starts), or the question. */
38export const targetOf = (x: Entry, to: 'answer' | 'ask'): string | undefined => (to === 'ask' ? x.id : (x.answerId ?? x.firstAnswerId))
39
40async function jump($: EngineInterface, id: string | undefined) {
41  if (!id) return
42  const why = await $.ui
43    .scroll({ to: { requestId: id }, block: 'start' })
44    .then(r => r.deny)
45    .catch((err: unknown) => (err instanceof Error ? err.message : String(err)))
46  if (why) $.ui.toast(`qa-jump: couldn't scroll there (${why})`)
47}
48
49export const register: Register = on => {
50  on('session.start', async ($, e, next) => {
51    await $.command.register({ name: 'qa', description: 'Show or hide the questions pane: click one to jump to its answer' })
52    return next(e)
53  })
54
55  on('command.run', { command: 'qa' }, async $ => {
56    if ((await $.ui.panes()).some(p => p.id === PANE)) {
57      await $.ui.close({ id: PANE })
58      return { text: 'Questions pane closed.' }
59    }
60    await $.ui.open({ id: PANE, title: 'Questions' })
61    return { text: 'Questions pane opened.' }
62  })
63
64  // A prompt you typed starts an entry; the response rows after it are its
65  // answer: the first one opens it, the last one with text is where it lands.
66  // One typed while Claude works is folded into the running turn (a
67  // `queued_command` row by the delivery door) and starts an entry the same way.
68  // A prompt nobody typed (a task notification, a plugin, another agent) closes
69  // it: what answers that prompt isn't the answer to yours.
70  on('session.append', { door: ['prompt', 'response', 'delivery'] }, async ($, e, next) => {
71    const kept = await next(e)
72    if (e.agentId) return kept
73    const text = e.message.content
74      .map(b => (b.type === 'text' ? b.text : ''))
75      .join('\n')
76      .trim()
77    if (e.door !== 'response') {
78      const isFolded = e.door === 'delivery'
79      if (isFolded && e.message.name !== 'queued_command') return kept
80      if (!byPerson(e.origin)) {
81        if (!isFolded)
82          await update($, entries, list => {
83            const last = list.at(-1)
84            return !last || last.isClosed ? list : [...list.slice(0, -1), { ...last, isClosed: true }]
85          })
86        return kept
87      }
88      const asked = isFolded ? folded(text) : text
89      if (e.message.isMeta || !asked || asked.startsWith('/')) return kept
90      await update($, entries, list => [...list, { n: (list.at(-1)?.n ?? 0) + 1, id: e.uuid, question: oneLine(asked, 200) }].slice(-KEEP))
91    } else {
92      await update($, entries, list => {
93        const last = list.at(-1)
94        if (!last || last.isClosed) return list
95        const now: Entry = { ...last, firstAnswerId: last.firstAnswerId ?? e.uuid }
96        if (text) {
97          now.answerId = e.uuid
98          now.preview = oneLine(text, 160)
99        }
100        return [...list.slice(0, -1), now]
101      })
102    }
103    return kept
104  }).catch(($, e, next) => next(e))
105
106  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
107    const { Box, Button, Text } = $.ui.resolve(e)
108    const list = (await read($, entries)).slice(-SHOW).reverse()
109    const width = e.props.bodyColumns
110    return (
111      <Box flexDirection="column" width={width}>
112        {list.length === 0 && <Text dimColor>Questions you type show here; click one to jump to its answer.</Text>}
113        {list.map(x => (
114          <Box flexDirection="column">
115            <Box flexDirection="row" columnGap={1}>
116              <Text color="#38bdf8">{String(x.n).padStart(3)}</Text>
117              <Box flexGrow={1} flexShrink={1}>
118                <Text wrap="truncate-end">{x.question}</Text>
119              </Box>
120              {targetOf(x, 'answer') ? <Button key={`a-${x.n}`} label="answer" variant="primary" onPress={() => void jump($, targetOf(x, 'answer'))} /> : null}
121              <Button key={`q-${x.n}`} label="ask" onPress={() => void jump($, targetOf(x, 'ask'))} />
122            </Box>
123            <Text dimColor wrap="truncate-end">
124              {'    ↳ '}
125              {x.preview ?? (x.firstAnswerId ? 'working…' : 'no answer yet')}
126            </Text>
127          </Box>
128        ))}
129      </Box>
130    )
131  })
132}
133
types/index.d.ts 13 lines
1/**
2 * One question you asked, and the transcript rows of Claude's reply to it.
3 * `isClosed`: a prompt you didn't type (a task notification, a plugin, another
4 * agent) came after it, so the response rows that follow aren't its answer.
5 */
6export type Entry = { n: number; id: string; question: string; firstAnswerId?: string; answerId?: string; preview?: string; isClosed?: boolean }
7
8declare module 'claude-code' {
9  interface PluginState {
10    'qa-jump': { entries: Entry[] }
11  }
12}
13