SLOPSHOPPER

reply-tools

Copy buttons on code blocks and a one-click tl;dr of long replies

newrowscommandtoastpromptmodel
v0.3.0MITupdated 2026-10-06GururOzatmaca/claude-mods/reply-tools
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reply-tools
› fix the failing auth test and add an audit log call ⏺ 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 › /cb ⎿ reply-tools: No code blocks in recent replies. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Three small mods for Claude Code. Install the ones you want.

claude plugin marketplace add GururOzatmaca/claude-mods
claude plugin install prompt-boost@claude-mods
claude plugin install reply-tools@claude-mods
claude plugin install jump-list@claude-mods

Needs Claude Code 2.1.287 or newer. If the install says options not yet set, ignore it; the defaults work.

prompt-boost

Add ++ to a messy prompt. It gets rewritten in clear English and put back in the box. You check it, then press Enter.

Typing a prompt with ++ Rewritten prompt in the box

  • ++ at the start or end. ++o uses Opus, ++q adds open questions.
  • Only the wording changes. Names, paths and errors stay exactly as you wrote them.
  • /ap undo brings back your original.

reply-tools

A copy button on every code block, and a tl;dr button under long replies.

Reply with copy and tl;dr buttons tl;dr of the reply

  • ⧉ copy all copies several shell commands at once.
  • tl;dr keeps every step and warning. ↑ original switches back.
  • Turn parts off with /plugin configure reply-tools@claude-mods.

jump-list

A side list of every prompt in the session, with its time. Click one to jump back.

<img src="docs/images/jump-list-pane.png" alt="Jump list" width="232">

  • ↓ end goes to the bottom. – folds it into one line.
  • Works after resume too. Open it with /jumps.
  • A very old prompt may need a second click.

Good to know

Mods run with your permissions. prompt-boost and tl;dr make one model call each time you use them. jump-list reads the session's transcript file. Run claude plugin validate <folder> to see exactly what a mod does.

MIT license.

Source 3 files
hooks/register.tsx 197 lines
1import type { EngineInterface, Register, RenderSurface } from 'claude-code'
2
3import { codeBlocks, isShell, splitFences } from './fences'
4import { isLong, questionFor, TLDR_MODEL, TLDR_SYSTEM, tldrPrompt } from './simplify'
5
6const INSTRUCTION = [
7  'Copyable output: the user has a copy button on every fenced code block in your replies.',
8  'Put anything the user is meant to copy and paste (shell commands, scripts, config, emails, chat messages, commit messages, text to send) inside a fenced code block.',
9  'Use a language tag for code (```bash, ```ts) and ```text for messages and prose meant to be sent.',
10  'Put commands the user runs in sequence in one ```bash block rather than splitting them, unless an explanation must sit between steps.',
11  'Do not fence text that is not meant to be copied.',
12].join(' ')
13
14const SELECTION_SETTLE_MS = 350
15const TLDR_TIMEOUT_MS = 30000
16const TLDR_MAX_TOKENS = 1000
17
18type View = 'original' | 'tldr'
19type ReplyState = { view: View; isLoading: boolean; tldr?: string }
20
21const replies = new Map<string, ReplyState>()
22
23const lineCount = (code: string) => code.split('\n').length
24
25function stateOf(id: string): ReplyState {
26  const found = replies.get(id)
27  if (found !== undefined) return found
28  const fresh: ReplyState = { view: 'original', isLoading: false }
29  replies.set(id, fresh)
30  return fresh
31}
32
33async function copyAfterClick($: EngineInterface, code: string, surface: RenderSurface) {
34  await $.ui.copy({ text: code, surface })
35  await $.clock.sleep(SELECTION_SETTLE_MS)
36  return copy($, code, surface)
37}
38
39async function copy($: EngineInterface, code: string, surface?: RenderSurface) {
40  const result = await $.ui.copy({ text: code, surface })
41  const lines = lineCount(code)
42  $.ui.toast(
43    result.isCopied
44      ? `Copied ${lines} line${lines === 1 ? '' : 's'}`
45      : `Copy failed: ${result.reason}`,
46  )
47  return result.isCopied
48}
49
50async function showView($: EngineInterface, id: string, text: string, view: View) {
51  const state = stateOf(id)
52  if (view === 'original' || state.tldr !== undefined) {
53    state.view = view
54    $.ui.invalidate('ui.render')
55    return
56  }
57  state.isLoading = true
58  $.ui.invalidate('ui.render')
59  const messages = (await $.session.messages()).map(m => ({ role: m.role, text: m.text }))
60  const result = await $.model.complete({
61    model: TLDR_MODEL,
62    system: TLDR_SYSTEM,
63    prompt: tldrPrompt(questionFor(messages, text), text),
64    maxTokens: TLDR_MAX_TOKENS,
65    timeoutMs: TLDR_TIMEOUT_MS,
66  })
67  state.isLoading = false
68  if (result.isAnswered && result.text.trim() !== '') {
69    state.tldr = result.text.trim()
70    state.view = 'tldr'
71  } else {
72    $.ui.toast(`tl;dr failed: ${result.isAnswered ? 'empty reply' : result.reason}`)
73  }
74  $.ui.invalidate('ui.render')
75}
76
77export const register: Register = (on, options) => {
78  const copyButtons = options.copy_buttons !== false
79  const copyInstruction = options.copy_instruction !== false
80  const tldrButton = options.tldr_button !== false
81
82  if (copyInstruction) {
83    on('prompt.compose', async ($, e, next) => {
84      const composed = await next(e)
85      return {
86        sections: [...composed.sections, { id: 'reply-tools:instruction', text: INSTRUCTION, scope: 'session' }],
87      }
88    })
89  }
90
91  on('session.start', async ($, e, next) => {
92    await $.command.register({
93      name: 'cb',
94      description: 'Copy code block N of the last reply; "all" joins every shell block; no N lists them',
95      argumentHint: '[n|all]',
96      immediate: true,
97    })
98    return next(e)
99  })
100
101  on('command.run', { command: 'cb' }, async ($, e) => {
102    const messages = await $.session.messages()
103    const last = [...messages]
104      .reverse()
105      .find(m => m.role === 'assistant' && codeBlocks(m.text).length > 0)
106    if (last === undefined) return { text: 'No code blocks in recent replies.' }
107
108    const blocks = codeBlocks(last.text)
109    const arg = String(e.args ?? '').trim()
110
111    if (arg === 'all') {
112      const shell = blocks.filter(b => isShell(b.language))
113      if (shell.length === 0) return { text: 'No shell blocks in the last reply.' }
114      await copy($, shell.map(b => b.code).join('\n'))
115      return { text: `Copied ${shell.length} shell block${shell.length === 1 ? '' : 's'}.` }
116    }
117
118    const n = Number.parseInt(arg, 10)
119
120    if (Number.isNaN(n)) {
121      const only = blocks.length === 1 ? blocks[0] : undefined
122      if (only !== undefined) {
123        await copy($, only.code)
124        return { text: 'Copied code block 1.' }
125      }
126      const list = blocks
127        .map(b => `${b.index}. ${b.language || 'text'}: ${(b.code.split('\n')[0] ?? '').slice(0, 60)}`)
128        .join('\n')
129      return { text: `Code blocks in the last reply:\n${list}\nRun /cb N to copy one, /cb all for every shell block.` }
130    }
131
132    const block = blocks.find(b => b.index === n)
133    if (block === undefined) return { text: `No block ${n}. The last reply has ${blocks.length}.` }
134    await copy($, block.code)
135    return { text: `Copied code block ${n}.` }
136  })
137
138  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
139    const original = e.props.text
140    const id = e.requestId
141    const canSummarize = tldrButton && id !== undefined && isLong(original)
142    const state = canSummarize ? stateOf(id) : undefined
143    const shown = state?.view === 'tldr' && state.tldr !== undefined ? state.tldr : original
144
145    const segments = copyButtons ? splitFences(shown) : [{ kind: 'prose' as const, text: shown }]
146    const hasCode = segments.some(s => s.kind === 'code')
147    if (!hasCode && state === undefined) return next(e)
148
149    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
150
151    return (
152      <Box flexDirection="column">
153        {segments.map((s, i) =>
154          s.kind === 'prose' ? (
155            <Markdown key={`p${i}`} text={s.text} />
156          ) : (
157            <Box key={`c${i}`} flexDirection="column">
158              <Box flexDirection="row" gap={2}>
159                <Button
160                  key={`copy-${s.index}`}
161                  label={`⧉ copy ${s.index}`}
162                  plain
163                  dimColor
164                  onPress={press => void copyAfterClick($, s.code, press.surface)}
165                />
166                {s.group !== undefined && (
167                  <Button
168                    key={`copy-all-${s.index}`}
169                    label={`⧉ copy all ${s.group.size}`}
170                    plain
171                    onPress={press => void copyAfterClick($, s.group?.code ?? s.code, press.surface)}
172                  />
173                )}
174              </Box>
175              <Markdown key={`m${i}`} text={s.raw} />
176            </Box>
177          ),
178        )}
179        {state !== undefined && id !== undefined && (
180          <Box key="tldr-row" flexDirection="row" gap={2}>
181            {state.isLoading ? (
182              <Text dimColor>Summarizing...</Text>
183            ) : state.view === 'original' ? (
184              <Button key="tldr" label="↓ tl;dr" plain dimColor onPress={() => void showView($, id, original, 'tldr')} />
185            ) : (
186              <>
187                <Text dimColor>tl;dr ·</Text>
188                <Button key="original" label="↑ original" plain dimColor onPress={() => void showView($, id, original, 'original')} />
189              </>
190            )}
191          </Box>
192        )}
193      </Box>
194    )
195  })
196}
197
hooks/fences.ts 84 lines
1export type Group = { code: string; size: number }
2
3export type Segment =
4  | { kind: 'prose'; text: string }
5  | { kind: 'code'; raw: string; code: string; language: string; index: number; group?: Group }
6
7const OPEN = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/
8const SHELL = new Set(['bash', 'sh', 'shell', 'zsh', 'console', 'shellscript'])
9const MAX_GAP_LINES = 2
10
11export const isShell = (language: string) => SHELL.has(language.toLowerCase())
12
13export function splitFences(text: string): Segment[] {
14  const lines = text.split('\n')
15  const segments: Segment[] = []
16  let prose: string[] = []
17  let index = 0
18  let i = 0
19
20  const flushProse = () => {
21    if (prose.join('').trim() !== '') segments.push({ kind: 'prose', text: prose.join('\n') })
22    prose = []
23  }
24
25  while (i < lines.length) {
26    const line = lines[i] ?? ''
27    const open = OPEN.exec(line)
28    if (open === null) {
29      prose.push(line)
30      i += 1
31      continue
32    }
33    const fence = open[1] ?? '```'
34    const close = new RegExp(`^ {0,3}${fence.startsWith('`') ? '`' : '~'}{${fence.length},}\\s*$`)
35    let end = i + 1
36    while (end < lines.length && !close.test(lines[end] ?? '')) end += 1
37    if (end >= lines.length) {
38      prose.push(...lines.slice(i))
39      break
40    }
41    flushProse()
42    index += 1
43    segments.push({
44      kind: 'code',
45      raw: lines.slice(i, end + 1).join('\n'),
46      code: lines.slice(i + 1, end).join('\n'),
47      language: open[2] ?? '',
48      index,
49    })
50    i = end + 1
51  }
52  flushProse()
53
54  return groupShell(segments)
55}
56
57function groupShell(segments: Segment[]): Segment[] {
58  const runs: Extract<Segment, { kind: 'code' }>[][] = []
59  let run: Extract<Segment, { kind: 'code' }>[] = []
60
61  for (const s of segments) {
62    if (s.kind === 'code' && isShell(s.language)) {
63      run.push(s)
64    } else if (s.kind === 'code' || s.text.trim().split('\n').length > MAX_GAP_LINES) {
65      runs.push(run)
66      run = []
67    }
68  }
69  runs.push(run)
70
71  for (const r of runs) {
72    const first = r[0]
73    if (first !== undefined && r.length > 1) {
74      first.group = { code: r.map(b => b.code).join('\n'), size: r.length }
75    }
76  }
77
78  return segments
79}
80
81export function codeBlocks(text: string) {
82  return splitFences(text).filter(s => s.kind === 'code')
83}
84
hooks/simplify.ts 56 lines
1export const MIN_LINES = 15
2export const TLDR_MODEL = 'sonnet'
3export const MAX_WORDS = 100
4
5const MAX_QUESTION_CHARS = 1500
6
7export const TLDR_SYSTEM = `You summarize one reply from Claude Code, a coding assistant, for a developer who reads English as a second language.
8
9You get the developer's question in <question> (it may be empty) and Claude's reply in <reply>. Summarize only the reply.
10
11Output only the summary in markdown. No preamble, no explanation of what you did.
12
13Hard limits:
141. At most ${MAX_WORDS} words in total, not counting code blocks. Count them. This limit beats every other wish to include more.
152. No headings, no title line. Start with 1 to 3 plain sentences that answer the question: the core idea, as if explaining to a beginner. Cover the main concept even when the reply spends more space on details.
163. Then at most 5 bullets. Each bullet is one line of at most 15 words and holds one point.
174. Keep a code block only if the developer must run or copy it, and put it after the bullets.
185. Output this one summary and stop. Never add a second version or sections from the original.
19
20What to keep, in this order of priority. When the word limit forces a cut, drop examples, background and nice-to-know details first:
21- every step the developer must do, and every open point Claude left (e.g. a permission still to add),
22- every status: done, not done, not deployed, not tested, failed,
23- every warning or risk, and each option's verdict exactly as given (recommended, works but risky, do not use),
24- every condition that limits a statement (only when, after, if, unless, a pushed branch, removed rather than stopped, inside a transaction),
25- exact names of errors (e.g. ERESOLVE), environments (e.g. staging), changed files, commands and numbers.
26
27Never add facts, advice or steps that are not in the reply. Never change who did something (Claude did the work, not the developer).
28Use simple A2-level English. Keep technical terms and explain them in a few plain words; never replace a term with a vaguer word.`
29
30export function isLong(text: string): boolean {
31  return text.split('\n').length >= MIN_LINES
32}
33
34export type ConversationMessage = { role: 'user' | 'assistant'; text: string }
35
36export function questionFor(messages: readonly ConversationMessage[], reply: string): string {
37  const probe = reply.trim().slice(0, 200)
38  let at = -1
39  for (let i = messages.length - 1; i >= 0; i -= 1) {
40    const m = messages[i]
41    if (m !== undefined && m.role === 'assistant' && m.text.includes(probe)) {
42      at = i
43      break
44    }
45  }
46  for (let i = at - 1; i >= 0; i -= 1) {
47    const m = messages[i]
48    if (m !== undefined && m.role === 'user' && m.text.trim() !== '') return m.text.trim().slice(0, MAX_QUESTION_CHARS)
49  }
50  return ''
51}
52
53export function tldrPrompt(question: string, reply: string): string {
54  return `<question>\n${question}\n</question>\n\n<reply>\n${reply}\n</reply>`
55}
56