Under Claude's answers (terminal): a copy button for each code block, and a reply button for each table that fills the prompt box with one line per row. Also…

Personal Claude Code mods.
/plugin marketplace add ColoMiranda/claude-mods
/plugin install team-board@claude-mods
/plugin install open-last@claude-mods
/plugin install copy-draft@claude-mods
/plugin install account-guard@claude-mods
/plugin install recent-chats@claude-mods
/reload-plugins
/team shows or hides the pane.open opens it in cmux (else the default app), diff shows what Claude changed in the last turn (in any folder, git or not; shell edits too: sed -i, perl -i, tee, > and >>), and stop kills the server. /open, /last-diff and /stop-servers do the same from the prompt (/diff is Claude Code's own)./gh-check). Red when that account cannot push to the repo, with a switch button and /gh-switch when another signed-in gh account can; a toast when Claude pushes or opens a PR anyway.copy button under each code block of Claude's answers (copy 1, copy 2 when there are several; terminal), and /copy-block [n] to copy a block of the last answer from the prompt. The built-in /copy copies the whole answer. A reply button under each table puts one line per row in the prompt box (R22 - , R23 - ...: the row's first cell, or its number), under anything you typed, so you write what to do with each row; /reply-table [n] does the same from the prompt./rename name (else Claude's title, else the last prompt), how long ago, and under it a one-line summary of where that chat stopped. Haiku writes each summary from the end of the chat (a few hundred tokens) and it is kept until that chat changes; only sessions with a person at the prompt call the model, never -p runs. Press 1 to 5 in the empty prompt box, or click a row, to resume that chat. When the www mod shows tasks, the chats take the next numbers. Type anything else to start a new chat; the band goes away at the first prompt. Not shown on claude -c or claude -r. Not listed: chats open in another terminal (two sessions would write one chat), and headless runs (claude -p, SDK).claude --plugin-dir ./plugins/team-board
claude plugin validate ./plugins/team-board
claude plugin test ./plugins/team-boardhooks/register.tsx 147 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderSurface, Register } from 'claude-code'
3
4import { copiedNote, fencedBlocks, pick, segments, tableReplies } from './parse'
5
6// The bullet that opens a reply, as the engine draws it on macOS (elsewhere it is ●).
7const BULLET = '⏺'
8
9// Markdown takes at most 10000 characters, and tab and newline as its only control characters.
10const drawable = (text: string) => text.length <= 10000 && !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(text)
11
12// The fenced blocks of the last main-loop answer, for /copy-block.
13const lastBlocks = atom({ plugin: 'copy-draft', key: 'lastBlocks' } as const, [])
14// The reply lines of each table of the last main-loop answer, for /reply-table.
15const lastReplies = atom({ plugin: 'copy-draft', key: 'lastReplies' } as const, [])
16
17// Copies `text` on `surface` (left out: the session's first), and says how it went.
18async function copyText($: EngineInterface, text: string, surface?: RenderSurface) {
19 const copied = await $.ui.copy({ text, surface })
20
21 return copied.isCopied ? copiedNote(text) : `Could not copy: ${copied.reason}`
22}
23
24// A copy button's press: the block, and a toast on how it went.
25async function copyAndSay($: EngineInterface, text: string, surface: RenderSurface) {
26 $.ui.toast(await copyText($, text, surface))
27}
28
29// Puts a table's reply lines in the prompt box, under what the person typed so
30// nothing of theirs is lost; says why when the box did not take them.
31async function fillReply($: EngineInterface, reply: string): Promise<string | undefined> {
32 const { text } = await $.prompt.read()
33 const filled =
34 text.trim() === ''
35 ? await $.prompt.fill({ text: reply, mode: 'replace' })
36 : await $.prompt.fill({ text: text.endsWith('\n') ? reply : `\n${reply}`, mode: 'append' })
37
38 return filled.isFilled ? undefined : `Could not fill the prompt box${filled.refusal === undefined ? '' : `: ${filled.refusal}`}.`
39}
40
41// A reply button's press: the lines in the box, or a toast on why not.
42async function fillAndSay($: EngineInterface, reply: string) {
43 const problem = await fillReply($, reply)
44 if (problem !== undefined) $.ui.toast(problem)
45}
46
47export const register: Register = on => {
48 on('session.start', async ($, e, next) => {
49 await $.command.register({ name: 'copy-block', description: 'Copy a fenced code block of the last answer', argumentHint: '[n]', immediate: true })
50 await $.command.register({ name: 'reply-table', description: 'Put one line per row of a table of the last answer in the prompt box', argumentHint: '[n]', immediate: true })
51
52 return next(e)
53 })
54
55 // The blocks and tables of the main loop's answer, when the turn answered. A
56 // subagent's turn is not the person's answer; an interrupted or failed one has
57 // none, and the last ones stay.
58 on('turn.complete', async ($, e, next) => {
59 const done = await next(e)
60 if (e.agentId === undefined && e.reason === 'answer') {
61 await update($, lastBlocks, () => fencedBlocks(e.answer))
62 await update($, lastReplies, () => tableReplies(e.answer))
63 }
64
65 return done
66 })
67
68 on('command.run', { command: 'copy-block' }, async ($, e) => {
69 const blocks = await read($, lastBlocks)
70 const target = pick(blocks.length, e.args, 'block')
71
72 return { text: 'error' in target ? target.error : await copyText($, blocks[target.index] ?? '') }
73 })
74
75 // The filled box shows it worked, so a fill prints nothing.
76 on('command.run', { command: 'reply-table' }, async ($, e) => {
77 const replies = await read($, lastReplies)
78 const target = pick(replies.length, e.args, 'table')
79 if ('error' in target) return { text: target.error }
80 const problem = await fillReply($, replies[target.index] ?? '')
81
82 return problem === undefined ? {} : { text: problem }
83 })
84
85 // A copy button right under each fenced block of an answer row, and a reply
86 // button right under each table. Live, a row holds the prose before a block,
87 // the block and the prose after it, so the row is drawn here in segments
88 // (prose, block or table, its button, prose...) with the bullet and the indent
89 // the engine gives it. The buttons read the row's own text, not the state, so
90 // older messages keep theirs: "copy" for one block, "copy 1", "copy 2" and so
91 // on for several, and "reply", "reply 1"... the same way for tables. The
92 // terminal's alone: no other surface has a clipboard path for ui.copy yet.
93 // Transcript rows hold no focus, so no hotkeys. A row with no block and no
94 // table is passed untouched. When a segment cannot be drawn as markdown (over
95 // 10000 characters, or a control character), the engine's row is drawn and
96 // the buttons come under all of it.
97 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
98 const parts = e.surface === 'terminal' ? segments(e.props.text) : []
99 const blocks = parts.flatMap(part => (part.kind === 'block' ? [part.code] : []))
100 const replies = parts.flatMap(part => (part.kind === 'table' ? [part.reply] : []))
101 if (blocks.length === 0 && replies.length === 0) return next(e)
102 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
103 const copyButton = (no: number) => (
104 <Button key={`copy-${no}`} label={blocks.length === 1 ? 'copy' : `copy ${no}`} onPress={press => copyAndSay($, blocks[no - 1] ?? '', press.surface)} />
105 )
106 const replyButton = (no: number) => (
107 <Button key={`reply-${no}`} label={replies.length === 1 ? 'reply' : `reply ${no}`} onPress={() => fillAndSay($, replies[no - 1] ?? '')} />
108 )
109
110 if (!parts.every(part => drawable(part.kind === 'text' ? part.text : part.source))) {
111 const rest = await next(e)
112
113 return (
114 <Box flexDirection="column">
115 {rest}
116 <Box gap={1} marginLeft={2}>
117 {blocks.map((_, i) => copyButton(i + 1))}
118 {replies.map((_, i) => replyButton(i + 1))}
119 </Box>
120 </Box>
121 )
122 }
123 let blockNo = 0
124 let tableNo = 0
125
126 return (
127 <Box flexDirection="row">
128 <Box minWidth={2} flexShrink={0}>
129 {e.props.isFirstOfReply && <Text>{BULLET}</Text>}
130 </Box>
131 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
132 {parts.map(part =>
133 part.kind === 'text' ? (
134 <Markdown text={part.text} />
135 ) : (
136 <Box flexDirection="column">
137 <Markdown text={part.source} />
138 <Box>{part.kind === 'block' ? copyButton(++blockNo) : replyButton(++tableNo)}</Box>
139 </Box>
140 ),
141 )}
142 </Box>
143 </Box>
144 )
145 })
146}
147hooks/parse.ts 208 lines1// A fence opens a line: ``` or ~~~ (three or more), then an optional info string.
2// A tab before it makes an indented code line, not a fence.
3const OPEN = /^( *)(`{3,}|~{3,})(.*)$/
4// It closes on a line of the same character, at least as long, with only spaces
5// or tabs after it.
6const CLOSE = /^( *)(`{3,}|~{3,})[ \t]*$/
7// A list item's first line: its marker and the spaces after it.
8const ITEM = /^( *)([-*+]|\d{1,9}[.)])( +)(?=\S)/
9
10// Where the content of the list item a fence sits in starts (a column), or 0
11// when it sits in none: the nearest earlier line with less indent than the
12// fence, if it is a list item. A line that is not one and has no indent is a
13// paragraph, which ends the list; a line that is not one and has some is the
14// item's own text, and the search goes on above it.
15function listColumn(prior: readonly string[], indent: number): number {
16 for (const line of [...prior].reverse()) {
17 const lead = line.length - line.trimStart().length
18 if (lead >= indent) continue
19 const [, pad = '', marker = '', spaces = ''] = ITEM.exec(line) ?? []
20 if (marker !== '') return pad.length + marker.length + (spaces.length > 4 ? 1 : spaces.length)
21 if (lead === 0) return 0
22 }
23
24 return 0
25}
26
27// A fence is valid 0 to 3 spaces in, as in CommonMark, counted from the content
28// column of the list item it sits in (0 outside any).
29const fits = (indent: number, column: number) => indent <= 3 || (indent >= column && indent - column <= 3)
30
31// One fenced block found in a text: the lines it spans (the opening fence to the
32// closing one, or to the end when it never closes), the indent its fence had, and
33// its code, the lines between the fences as written less that indent.
34type Found = { start: number; end: number; indent: number; code: string }
35
36// Every fenced code block of a markdown text, in order, with where it sits. A
37// fence closes on the same character at least as long as it opened (so ```` can
38// hold ```), the opening fence's indent is taken off each line (a block inside a
39// list), an unclosed fence runs to the end, and a block with nothing in it is
40// left out. A backtick fence whose info string has a backtick is inline code,
41// not a fence.
42// ponytail: a fence inside a blockquote ("> ```") is not seen, and CRLF reads as
43// LF and the last newline is dropped (both fine to paste). Upgrade path: strip
44// the "> " prefixes before the scan.
45function scan(lines: readonly string[]): Found[] {
46 const found: Found[] = []
47 // The lines before this one, for the list search: a fence is its opening line, not its content.
48 const prior: string[] = []
49 let open: { char: string; size: number; indent: number; column: number; start: number; lines: string[] } | undefined
50 const finish = (last: number, isClosed: boolean) => {
51 if (!open) return
52 // An unclosed fence ends where the text does: its last newline is not content.
53 while (!isClosed && open.lines.length > 0 && open.lines[open.lines.length - 1]?.trim() === '') open.lines.pop()
54 if (open.lines.some(line => line.trim() !== '')) found.push({ start: open.start, end: last, indent: open.indent, code: open.lines.join('\n') })
55 open = undefined
56 }
57
58 lines.forEach((line, at) => {
59 if (open) {
60 const [, pad = '', close = ''] = CLOSE.exec(line) ?? []
61 if (close !== '' && close[0] === open.char && close.length >= open.size && fits(pad.length, open.column)) finish(at, true)
62 else open.lines.push(line.slice(Math.min(open.indent, line.length - line.trimStart().length)))
63
64 return
65 }
66 const [, indent = '', fence = '', info = ''] = OPEN.exec(line) ?? []
67 const column = fence === '' ? 0 : listColumn(prior, indent.length)
68 if (fence !== '' && !(fence[0] === '`' && info.includes('`')) && fits(indent.length, column)) {
69 open = { char: fence[0] ?? '`', size: fence.length, indent: indent.length, column, start: at, lines: [] }
70 // The fence stands for its block: after it, an earlier list is over.
71 prior.push(line)
72 } else if (line.trim() !== '') {
73 prior.push(line)
74 }
75 })
76 finish(lines.length - 1, false)
77
78 return found
79}
80
81// The fenced code blocks of a markdown text, in order, their text without the fences.
82export function fencedBlocks(text: string): string[] {
83 return scan(text.split(/\r?\n/)).map(one => one.code)
84}
85
86// A table row's cells: the line cut at each pipe no backslash escapes, the outer
87// pipes dropped, each cell trimmed.
88function cells(line: string): string[] {
89 let row = line.trim().replace(/\\\|/g, '\u0000')
90 if (row.startsWith('|')) row = row.slice(1)
91 if (row.endsWith('|')) row = row.slice(0, -1)
92
93 return row.split('|').map(cell => cell.replace(/\u0000/g, '|').trim())
94}
95
96// A delimiter row's cell: dashes, a colon at either end for the alignment.
97const RULE = /^:?-+:?$/
98
99// The tables in lines[from, to), as GFM reads them: a header row at most three
100// spaces in (four make an indented code line), then a delimiter row with as many
101// cells, then the rows. Both first lines hold a pipe, so a setext heading
102// ("Title" over "---") is no table. A table with no row is left out: there is
103// nothing to reply to.
104// ponytail: the rows end at a blank line or a line with no pipe, where GFM also
105// takes a line of plain text as a row; and a table in a list item is read as if
106// it stood alone. Upgrade path: the list search scan() does.
107function tables(lines: readonly string[], from: number, to: number): Array<{ start: number; end: number; rows: string[][] }> {
108 const found: Array<{ start: number; end: number; rows: string[][] }> = []
109 for (let at = from; at + 2 < to; at++) {
110 const head = lines[at] ?? ''
111 const rule = lines[at + 1] ?? ''
112 if (!/^ {0,3}\S/.test(head) || !head.includes('|') || !rule.includes('|')) continue
113 const marks = cells(rule)
114 if (marks.length !== cells(head).length || !marks.every(mark => RULE.test(mark))) continue
115 let end = at + 1
116 while (end + 1 < to && (lines[end + 1] ?? '').includes('|')) end++
117 if (end === at + 1) continue
118 found.push({ start: at, end, rows: lines.slice(at + 2, end + 1).map(cells) })
119 at = end
120 }
121
122 return found
123}
124
125// The lines /reply-table puts in the prompt box for a table, one per row, for
126// the person to finish: the row's first cell (bold and code marks dropped, cut
127// to 40 characters) or, when that is empty, the row's number; then " - ".
128function replyLines(rows: readonly (readonly string[])[]): string {
129 return rows
130 .map((row, at) => {
131 const label = [...((row[0] ?? '').replace(/\*\*|`/g, '').trim() || String(at + 1))]
132
133 return `${label.length > 40 ? `${label.slice(0, 39).join('').trimEnd()}…` : label.join('')} - `
134 })
135 .join('\n')
136}
137
138// A text cut at its fenced blocks and its tables: the prose between them; each
139// block as markdown (its fences, dedented to the fence's own indent, so it still
140// reads as a fence away from the list it sat in) with its code; and each table
141// as written, with the lines /reply-table puts in the prompt box for it.
142export type Segment =
143 | { kind: 'text'; text: string }
144 | { kind: 'block'; source: string; code: string }
145 | { kind: 'table'; source: string; reply: string }
146
147export function segments(text: string): Segment[] {
148 const lines = text.split(/\r?\n/)
149 const out: Segment[] = []
150 const plain = (from: number, to: number) => {
151 const piece = lines.slice(from, to)
152 while (piece.length > 0 && piece[0]?.trim() === '') piece.shift()
153 while (piece.length > 0 && piece[piece.length - 1]?.trim() === '') piece.pop()
154 if (piece.length > 0) out.push({ kind: 'text', text: piece.join('\n') })
155 }
156 // The prose between two blocks, cut at its tables.
157 const prose = (from: number, to: number) => {
158 let next = from
159 for (const one of tables(lines, from, to)) {
160 plain(next, one.start)
161 out.push({ kind: 'table', source: lines.slice(one.start, one.end + 1).join('\n'), reply: replyLines(one.rows) })
162 next = one.end + 1
163 }
164 plain(next, to)
165 }
166 let next = 0
167 for (const one of scan(lines)) {
168 prose(next, one.start)
169 const source = lines
170 .slice(one.start, one.end + 1)
171 .map(line => line.slice(Math.min(one.indent, line.length - line.trimStart().length)))
172 .join('\n')
173 out.push({ kind: 'block', source, code: one.code })
174 next = one.end + 1
175 }
176 prose(next, lines.length)
177
178 return out
179}
180
181export function lineCount(block: string): number {
182 return block.split('\n').length
183}
184
185// "Copied 12 lines", "Copied 1 line".
186export function copiedNote(block: string): string {
187 const n = lineCount(block)
188 return `Copied ${n} ${n === 1 ? 'line' : 'lines'}`
189}
190
191// The reply lines of each table of a markdown text, in order.
192export function tableReplies(text: string): string[] {
193 return segments(text).flatMap(part => (part.kind === 'table' ? [part.reply] : []))
194}
195
196// The block or table a /copy-block or /reply-table argument names, 0-based: the
197// last with none, else n (1 is the first); or what to tell the person.
198export function pick(count: number, arg: string, what: 'block' | 'table'): { index: number } | { error: string } {
199 if (count === 0) return { error: `No ${what === 'block' ? 'code block' : 'table'} in the last answer.` }
200 const text = arg.trim()
201 if (text === '') return { index: count - 1 }
202 if (!/^\d+$/.test(text)) return { error: `Usage: /${what === 'block' ? 'copy-block' : 'reply-table'} [n], n from 1 to the number of ${what}s.` }
203 const n = Number(text)
204 if (n < 1 || n > count) return { error: `No ${what} ${n}: the last answer has ${count} ${count === 1 ? what : `${what}s`}.` }
205
206 return { index: n - 1 }
207}
208types/index.d.ts 9 lines1declare module 'claude-code' {
2 interface PluginState {
3 // `lastBlocks`: the fenced code blocks of the last main-loop answer, fences
4 // removed, in order. `lastReplies`: for each table of that answer, in order,
5 // the lines /reply-table puts in the prompt box, one per row.
6 'copy-draft': { lastBlocks: string[]; lastReplies: string[] }
7 }
8}
9