SLOPSHOPPER

quote-buttons

A quote button on every chat message, an edit button on your own, and a 5 second undo for prompts sent while a turn runs.

newbandrowstoastpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quote-buttons
⟨Claude Code's own drawing⟩ ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ⟨Claude Code's own drawing⟩ ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
⟨Claude Code's own drawing⟩
Claude's reply
⟨Claude Code's own drawing⟩
README

quote-buttons

A Claude Code mod. It adds four things.

  1. Quote button. Every user message and every assistant text block shows [ ↩ quote ] on hover (top right of the message). A click puts the message in the prompt box as a quote (> per line, at most 6 lines and 400 characters, … when cut) above your draft.
  2. Edit button. Your own messages also show [ ✎ edit ]. It puts the original text in the box. If the box has text, it shows Clear the box first and changes nothing. Real rewind stays Esc Esc.
  3. Undo send. A prompt you submit while a turn runs is held for 5 seconds. The band above the prompt shows Sending in 5s: <text> and [ undo ]. Undo drops the prompt and puts the text back in the box (below any draft). Prompts sent while idle are untouched.
  4. Hand-off buttons. Every message also shows [ ⇢ with context ] and [ ⇢ fresh ] in the same hover box. Each opens a new Konsole tab in the session's project root.
  5. ⇢ with context runs claude --resume <session id> --fork-session "Take this over and do it now:\n\n<message>", a fork of the whole conversation.
  6. ⇢ fresh runs claude "<message>", a new session.
  7. The tab is opened with konsole --new-tab --workdir <root> -e claude ..., by argv (no shell, so quotes and newlines are safe). Toast: Opened a new tab (with context) or (fresh).
  8. If Konsole is missing, the launch fails or there is no session id yet, the toast gives the reason and the full command goes to the clipboard to paste into any terminal.

The band above the prompt is shared: the mod wraps what other mods draw there.

Limits

  • The hand-off buttons need Konsole, and --new-tab needs "Run all Konsole windows in a single process" (the default); otherwise Konsole opens a new window.
  • Buttons need the fullscreen terminal layout (mouse clicks). On the main screen (CLAUDE_CODE_NO_FLICKER=0, tmux by default) the transcript is printed into scrollback and there is no pointer, so the message buttons cannot be clicked. The undo band button is a normal Button (click, or Tab focus and Enter where the band holds the keys); not tested on the main screen.
  • The 5 second hold costs 5 s of the hook's 10 s budget.

Develop

claude plugin validate . claude plugin test .

Source 3 files
hooks/register.tsx 151 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Held } from '../types'
5import { HOLD_SECONDS, handOffArgv, preview, quoteInto, shellLine } from './quote'
6
7const held = atom({ plugin: 'quote-buttons', key: 'held' } as const, [] as Held[])
8
9type Box = { text: string; cursor: number }
10
11async function quote($: EngineInterface, message: string): Promise<void> {
12  const draft: Box = await $.prompt.read()
13  await $.prompt.fill({ text: quoteInto(message, draft.text), mode: 'replace' })
14}
15
16async function edit($: EngineInterface, message: string): Promise<void> {
17  const draft: Box = await $.prompt.read()
18  if (draft.text !== '') {
19    $.ui.toast('Clear the box first')
20    return
21  }
22  await $.prompt.fill({ text: message, mode: 'replace' })
23}
24
25// Opens a new Konsole tab running Claude on the message: a fork of this session
26// (withContext) or a fresh one. On any failure the command goes to the clipboard.
27async function handOff($: EngineInterface, message: string, withContext: boolean): Promise<void> {
28  const label = withContext ? 'with context' : 'fresh'
29  let argv: string[] = []
30  try {
31    const root = await $.session.root()
32    let id: string | undefined
33    if (withContext) {
34      id = await $.session.id()
35      if (!id) {
36        argv = handOffArgv(root, message)
37        throw new Error('No session id yet')
38      }
39    }
40    argv = handOffArgv(root, message, id)
41    // Konsole hands the tab to its running window and exits. A short timeout keeps a
42    // Konsole that stays attached from holding the call; a timeout is not a failure.
43    const res = await $.process.run(argv, { cwd: root, timeoutMs: 3000 }).catch((err: unknown) => {
44      if (/still running|timed? ?out/i.test(String(err))) return { exitCode: 0, stderr: '' }
45      throw err
46    })
47    if (res.exitCode !== 0) throw new Error(res.stderr.trim() || `konsole exited with ${res.exitCode}`)
48    $.ui.toast(`Opened a new tab (${label})`)
49  } catch (err) {
50    const reason = err instanceof Error ? err.message : String(err)
51    if (argv.length > 0) await $.ui.copy({ text: shellLine(argv) }).catch(() => {})
52    $.ui.toast(`Could not open a tab: ${reason}. Command copied.`)
53  }
54}
55
56// Puts an unsent prompt back: replaces an empty box, goes below a draft otherwise.
57async function putBack($: EngineInterface, text: string): Promise<void> {
58  const draft: Box = await $.prompt.read()
59  if (draft.text === '') await $.prompt.fill({ text, mode: 'replace' })
60  else await $.prompt.fill({ text: `\n${text}`, mode: 'append' })
61}
62
63// Wraps the engine's drawing of a message, never changing it. The buttons sit
64// dim at the right of the first row and show on hover only.
65async function buttons($: EngineInterface, e: any, next: any, canEdit: boolean) {
66  const tree = await next(e)
67  const text: unknown = e.props.text
68  if (typeof text !== 'string' || text.trim() === '') return tree
69  if (e.props.task !== undefined || e.props.from !== undefined) return tree
70  try {
71    const { Box, Button } = $.ui.resolve(e)
72    return (
73      <Box key={`qb-${e.requestId}`} flexDirection="column">
74        {tree}
75        <Box position="absolute" top={0} right={0} display="none" hover={{ display: 'flex' }}>
76          <Button key="qb-quote" label="↩ quote" dimColor onPress={() => quote($, text)} />
77          {canEdit ? <Button key="qb-edit" label="✎ edit" dimColor onPress={() => edit($, text)} /> : null}
78          <Button key="qb-ctx" label="⇢ with context" dimColor onPress={() => handOff($, text, true)} />
79          <Button key="qb-fresh" label="⇢ fresh" dimColor onPress={() => handOff($, text, false)} />
80        </Box>
81      </Box>
82    )
83  } catch {
84    return tree
85  }
86}
87
88export const register: Register = on => {
89  // One abort handle per held prompt. Lost on a hot reload, which only ends the hold early.
90  const undoers = new Map<string, () => void>()
91  let seq = 0
92
93  on('ui.render', { component: 'UserMessage' }, ($, e, next) => buttons($, e, next, true))
94  on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => buttons($, e, next, false))
95
96  on('prompt.submit', async ($, e, next) => {
97    const isTypedMidTurn = e.turnId !== undefined && (e.origin === undefined || e.origin.kind === 'composer')
98    if (!isTypedMidTurn) return next(e)
99
100    const id = `h${++seq}`
101    const ctrl = new AbortController()
102    let isUndone = false
103    undoers.set(id, () => {
104      isUndone = true
105      ctrl.abort()
106    })
107    const onAbort = () => ctrl.abort()
108    next.signal.addEventListener('abort', onAbort)
109    try {
110      for (let s = HOLD_SECONDS; s > 0; s--) {
111        const left = s
112        await update($, held, hs => [...hs.filter(x => x.id !== id), { id, text: e.text, seconds: left }])
113        await $.clock.sleep(1000, { signal: ctrl.signal })
114      }
115    } catch {
116      // Aborted by the undo button or by the dispatch ending.
117    } finally {
118      next.signal.removeEventListener('abort', onAbort)
119      undoers.delete(id)
120      await update($, held, hs => hs.filter(x => x.id !== id)).catch(() => {})
121    }
122    if (isUndone) {
123      await putBack($, e.text)
124      return { drop: 'Unsent. Your text is back in the box.' }
125    }
126    return next(e)
127  })
128
129  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
130    const below = await next(e)
131    try {
132      const hs = await read($, held)
133      if (hs.length === 0) return below
134      const { Box, Button, Text } = $.ui.resolve(e)
135      return (
136        <Box flexDirection="column">
137          {hs.map(row => (
138            <Box key={`qb-held-${row.id}`}>
139              <Text>{`Sending in ${row.seconds}s: ${preview(row.text)} `}</Text>
140              <Button key={`qb-undo-${row.id}`} label="undo" onPress={() => undoers.get(row.id)?.()} />
141            </Box>
142          ))}
143          {below.type !== 'engine' ? below : null}
144        </Box>
145      )
146    } catch {
147      return below
148    }
149  })
150}
151
hooks/quote.ts 62 lines
1export const MAX_LINES = 6
2export const MAX_CHARS = 400
3export const HOLD_SECONDS = 5
4export const PREVIEW_CHARS = 60
5
6/** Quotes a message: each line prefixed `> `, at most 6 lines and 400 characters, `…` when cut. */
7export function quoteBlock(text: string): string {
8  const lines = text.replace(/\r\n?/g, '\n').replace(/^\n+|\s+$/g, '').split('\n')
9  const out: string[] = []
10  let chars = 0
11  let isCut = false
12  for (const line of lines) {
13    if (out.length >= MAX_LINES) {
14      isCut = true
15      break
16    }
17    const room = MAX_CHARS - chars
18    if (line.length > room) {
19      out.push(line.slice(0, room))
20      isCut = true
21      break
22    }
23    out.push(line)
24    chars += line.length
25  }
26  if (isCut) out[out.length - 1] += '…'
27  return out.map(line => `> ${line}`).join('\n')
28}
29
30/** The box after a quote: the quote, a newline, then the existing draft. */
31export function quoteInto(message: string, draft: string): string {
32  return `${quoteBlock(message)}\n${draft}`
33}
34
35/** First 60 characters of a held prompt, on one line. */
36export function preview(text: string): string {
37  const one = text.replace(/\s+/g, ' ').trim()
38  return one.length > PREVIEW_CHARS ? `${one.slice(0, PREVIEW_CHARS)}…` : one
39}
40
41/** The hand-off prompt for a fork that keeps the conversation. */
42export function takeOverPrompt(message: string): string {
43  return `Take this over and do it now:\n\n${message}`
44}
45
46/**
47 * The argv that opens a new Konsole tab in `root` running Claude. `-e` comes last
48 * because Konsole hands it every argument after. With a session id: a fork of that
49 * conversation with the take-over prompt. Without: a new session on the message.
50 */
51export function handOffArgv(root: string, message: string, sessionId?: string): string[] {
52  const head = ['konsole', '--new-tab', '--workdir', root, '-e', 'claude']
53  if (sessionId !== undefined) return [...head, '--resume', sessionId, '--fork-session', takeOverPrompt(message)]
54  // A message that starts with a dash would read as a flag, so `--` ends the options.
55  return message.startsWith('-') ? [...head, '--', message] : [...head, message]
56}
57
58/** The argv as one line a person can paste into a shell (single quotes around anything odd). */
59export function shellLine(argv: readonly string[]): string {
60  return argv.map(a => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`)).join(' ')
61}
62
types/index.d.ts 15 lines
1export type Held = {
2  /** Unique per held prompt. */
3  id: string
4  /** The prompt text being held. */
5  text: string
6  /** Seconds left before it is delivered. */
7  seconds: number
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'quote-buttons': { held: Held[] }
13  }
14}
15