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.

A Claude Code mod. It adds four things.
[ ↩ 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.[ ✎ 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.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.[ ⇢ with context ] and [ ⇢ fresh ] in the same hover box. Each opens a new Konsole tab in the session's project root.⇢ 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.⇢ fresh runs claude "<message>", a new session.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).The band above the prompt is shared: the mod wraps what other mods draw there.
--new-tab needs "Run all Konsole windows in a single process" (the default); otherwise Konsole opens a new window.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.claude plugin validate . claude plugin test .
hooks/register.tsx 151 lines1import { 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}
151hooks/quote.ts 62 lines1export 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}
62types/index.d.ts 15 lines1export 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