Draws each Bash call and each file change (Write, Edit, NotebookEdit) as a bordered panel, and frames the code blocks in replies with copy and collapse buttons.

<img src="../../docs/images/icons/q-panel.svg" width="72" alt="" align="right">
Part of q-harness, a collection of Claude Code plugins by phuquocchamp
Did that command work, and what did Claude just change? q-panel draws each Bash call and each file change (Write, Edit, NotebookEdit) as a clear bordered panel, and frames the code blocks in Claude's replies so you can collapse them.
Version 0.3.0 · Author phuquocchamp · Replaces q-bash 0.4.0, q-git 0.1.1 and q-files 0.1.0
| Feature | What you see | Use it when |
|---|---|---|
| Bash panel | A bordered panel per command: $ npm test, status and time (failed · 8.4s), the call's description, and the last 4 lines of output with stderr in red. The border is green (done), red (failed), yellow (running, interrupted) or blue (background, timed out). Click the header for the full command and up to 400 lines (head and tail). | You want to know at a glance whether a command passed and why it failed. |
| Badges | Only when there is something to say: tests [✓ 128 ✗ 2] (vitest, jest, pytest, go test, cargo test), changed lines [+42 −7], git [⎇ committed 9f3e21a on main] [PR #42 created], [⚠ unsandboxed]. | Claude ran tests, changed files from the shell, or committed and pushed. |
| File panel | A bordered panel per Write, Edit and NotebookEdit call: ▸ Edit src/app.ts done · +5 −1, then the first 4 changed lines (green +, red −). Click the header for the whole diff with its hunk headers and context lines (up to 400 rows). New files say Create … created; a failed edit draws red with the error; a running one is yellow writing…. | You want to see what Claude changed in a file without opening it. |
| Code blocks in replies | Each fenced block in Claude's reply is drawn in a rounded frame with the language and line count on top, coloured by Claude Code's own highlighter. Its header has [ copy ], which puts the block on your clipboard, and [ collapse ] / [ expand ]; clicking the language label (▾ ts) also folds it. Blocks longer than 40 lines start collapsed. Prose around the blocks stays Claude Code's normal markdown, and replies without a code block are not touched. | Claude answers with code you want to copy, or long code you want to fold away. |
| Tidy transcript | Claude Code's own result block under a Bash or file row is hidden (Bash images excepted). Groups made only of Bash, Write, Edit and NotebookEdit calls are unfolded so each call gets its panel. | Always on (unfoldGroups turns the unfolding off). |
| Command | What it does |
|---|---|
/q-panel config | Show the settings |
/q-panel config <key> <value> | filePanels, unfoldGroups, codeBlocks (on/off), previewLines (a number, default 4), codeFoldLines (a number, default 40) |
/q-panel config reset | Restore the defaults |
/q-panel help | Usage |
filePanels off leaves Write, Edit and NotebookEdit rows to Claude Code; codeBlocks off leaves replies to Claude Code. previewLines is shared by the Bash and file panels. codeFoldLines sets how long a block can be before it starts collapsed. Settings are saved across sessions; which panels and blocks you opened is kept for the session only.
! <command> keep Claude Code's own plain row. Claude Code draws that row itself and gives plugins no hook on it, so no plugin can frame it. Ask Claude to run the command if you want the panel./q-panel config codeBlocks off.The guard on destructive commands, the row above the prompt (git status and turn summary), the files and git timeline panes, and /q-panel files, timeline, status, clear, band and guard are gone. For a check on destructive commands, add them to permissions.ask in Claude Code's settings. Settings saved by 0.1.0 (band, git, files, guard) are ignored.
The three plugins are merged into this one and removed from the marketplace. Uninstall them first, or you get two panels:
claude plugin uninstall q-bash && claude plugin uninstall q-git && claude plugin uninstall q-files
claude plugin marketplace update q-harness
claude plugin install q-panel@q-harness
Saved settings don't carry over (each plugin has its own store), so set previewLines again if you changed it. Command names changed: /q-bash config … is now /q-panel config ….
/plugin install q-panel --marketplace phuquocchamp/q-harness
claude --plugin-dir ./plugins/q-panel # load from disk; saving a file reloads it
claude plugin validate ./plugins/q-panel
claude plugin test ./plugins/q-panel
hooks/register.tsx holds every hook, the state atoms and every helper that takes $ (the validator follows $ and on only within one module). hooks/lib/ holds pure helpers:
| File | Holds |
|---|---|
bash.ts | Clipping, previews, test-output parsing, the Bash result record |
files.ts | The diff of a Write, Edit or NotebookEdit call, previews, relative paths |
markdown.ts | Splitting a reply at its code fences |
settings.ts | Defaults, /q-panel config parsing, usage text |
Session state (types/index.d.ts): settings, open per Bash or file call, duration per Bash call, codeOpen per code block. Only settings is saved across sessions.
Tests live in tests/: one file per helper (lib-*.test.ts) and one per drawn surface (bash-panel, file-panel, code-blocks, settings); host.ts is the shared fake host.
hooks/register.tsx 386 lines1// q-panel: bordered panels for Bash and file-changing tool calls (Write, Edit, NotebookEdit), and
2// framed, collapsible code blocks in replies.
3// Every hook and every helper that takes `$` lives in this file (the engine follows `$` only within
4// one module); pure helpers are in ./lib.
5
6import { atom, memberOf, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { PanelSettings } from '../types'
10import {
11 MAX_EXPANDED_LINES,
12 SLOW_MS,
13 clip,
14 diffStats,
15 formatBytes,
16 formatMs,
17 gitBadges,
18 headTail,
19 lines,
20 parseTestSummary,
21 previewTail,
22 readOutput,
23 statusOf,
24 type BashInput,
25 type Line,
26} from './lib/bash'
27import { FILE_TOOLS, fileChange, previewChanges, relativeTo, type DiffLine } from './lib/files'
28import { hasFence, splitFences } from './lib/markdown'
29import { DEFAULT_SETTINGS, USAGE, parseConfig, pickSettings, showSettings } from './lib/settings'
30
31const settings = atom({ plugin: 'q-panel', key: 'settings' } as const, DEFAULT_SETTINGS as PanelSettings)
32const open = atom({ plugin: 'q-panel', key: 'open' } as const, false)
33const duration = atom({ plugin: 'q-panel', key: 'duration' } as const, 0)
34const codeOpen = atom({ plugin: 'q-panel', key: 'codeOpen' } as const, null as boolean | null)
35
36const SETTINGS_KEY = 'settings'
37
38/** Tools that get a bordered panel. */
39const PANEL_TOOLS = new Set(['Bash', ...FILE_TOOLS])
40
41const saveSettings = async ($: EngineInterface, next: PanelSettings) => {
42 await update($, settings, () => next)
43 await $.store.set(SETTINGS_KEY, next)
44}
45
46type Surface = Parameters<EngineInterface['ui']['copy']>[0]['surface']
47
48/** Copies `text` to the clipboard of the surface that was pressed and says how it went. */
49const copyText = async ($: EngineInterface, text: string, surface: Surface, what: string) => {
50 try {
51 const r = await $.ui.copy({ text, surface })
52 $.ui.toast(r.isCopied ? `${what} copied` : `Could not copy: ${r.reason ?? 'no clipboard'}`)
53 } catch (err) {
54 $.ui.toast(`Could not copy: ${err instanceof Error ? err.message : String(err)}`)
55 }
56}
57
58/** Colour of a diff row in a file panel. */
59const DIFF_COLOR: Record<DiffLine['kind'], string | undefined> = { '+': 'green', '-': 'red', ' ': undefined, '@': 'cyan' }
60
61export const register: Register = on => {
62 // ---- settings and /q-panel ----------------------------------------------------------------
63
64 on('session.start', async ($, e, next) => {
65 try {
66 const saved = await $.store.get(SETTINGS_KEY)
67 await update($, settings, () => pickSettings(saved))
68 } catch {
69 // Defaults stand when the store cannot be read.
70 }
71 await $.command.register({
72 name: 'q-panel',
73 description: 'Settings for the Bash and file panels and the code blocks in replies',
74 argumentHint: 'config [<key> <value> | reset] | help',
75 })
76 return next(e)
77 })
78
79 on('command.run', { command: 'q-panel' }, async ($, e) => {
80 const [sub = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
81 switch (sub) {
82 case 'config': {
83 const cfg = await read($, settings)
84 if (rest.length === 0) return { text: `q-panel settings:\n${showSettings(cfg)}` }
85 if (rest[0] === 'reset') {
86 await saveSettings($, DEFAULT_SETTINGS)
87 return { text: 'q-panel settings restored to the defaults.' }
88 }
89 const [key = '', value = ''] = rest
90 const patch = parseConfig(key, value)
91 if (typeof patch === 'string') return { text: patch, exitCode: 2 }
92 await saveSettings($, { ...cfg, ...patch })
93 return { text: `${key} set to ${value}.` }
94 }
95 case '':
96 case 'help':
97 return { text: USAGE }
98 default:
99 return { text: `Unknown subcommand "${sub}".\n${USAGE}`, exitCode: 2 }
100 }
101 })
102
103 // ---- timing: the Bash panel shows how long each call took -----------------------------------
104
105 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
106 const at = await $.clock.now()
107 try {
108 return await next(e)
109 } finally {
110 // The row's requestId is the tool_use_id, so the panel reads this member with memberOf(duration, e).
111 if (e.tool_use_id) {
112 const ms = (await $.clock.now()) - at
113 await update($, memberOf(duration, { requestId: e.tool_use_id }), () => ms).catch(() => undefined)
114 }
115 }
116 }).catch(($, e, next) => next(e)) // `next` replays a settled call, so a failure here never runs the tool twice
117
118 // ---- transcript: unfold panel-only groups, drop the result blocks the panels replace -------
119
120 // A group made only of panel tools is unfolded so each call gets its panel. A mixed group
121 // (with Read, Grep, …) stays folded: unfolding it would put every read on screen too.
122 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
123 const cfg = await read($, settings)
124 const drawn = (tool: string) => tool === 'Bash' || (cfg.filePanels && FILE_TOOLS.has(tool))
125 return cfg.unfoldGroups && e.props.calls.length > 0 && e.props.calls.every(c => drawn(c.tool))
126 ? next({ ...e, props: { ...e.props, isExpanded: true } })
127 : next(e)
128 })
129
130 // The panels draw the output themselves. Bash image output is the exception: the engine draws the picture, the panel cannot.
131 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
132 const t = e.props.tool
133 const isPanel = t === 'Bash' ? !readOutput(e.props.output).isImage : FILE_TOOLS.has(t) && (await read($, settings)).filePanels
134 if (!isPanel) return next(e)
135 const { Box } = $.ui.resolve(e)
136 return <Box />
137 })
138
139 // ---- Bash panel ------------------------------------------------------------------------
140
141 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
142 const p = e.props
143 if (p.tool !== 'Bash') return next(e)
144
145 const { Box, Text, Button } = $.ui.resolve(e)
146 const cfg = await read($, settings)
147 const input = (p.input ?? {}) as BashInput
148 const command = input.command ?? ''
149 const out = readOutput(p.output)
150
151 const isOpen = await read($, memberOf(open, e))
152 const toggle = () => update($, memberOf(open, e), v => !v)
153
154 const width = Math.max(20, (e.viewport?.columns ?? 100) - 6)
155 const ms = await read($, memberOf(duration, e))
156
157 const stdout = out.isImage ? [] : lines(out.stdout)
158 const stderr = lines(out.stderr)
159 const all: Line[] = [...stdout.map(t => ({ t, err: false })), ...stderr.map(t => ({ t, err: true }))]
160
161 const [state, color] = statusOf(p, out)
162 const took = ms > 0 && !p.isRunning ? ` · ${formatMs(ms)}` : ''
163
164 const cmdLines = command.split('\n')
165 const head = clip(cmdLines[0] ?? '', width - 8 - state.length - took.length) + (cmdLines.length > 1 ? ' …' : '')
166
167 const tests = p.isRunning ? undefined : parseTestSummary(`${out.stdout ?? ''}\n${out.stderr ?? ''}`)
168 const files = out.bashEditDiff?.files ?? []
169 const changed = diffStats(files)
170 const badges: { text: string; color: string }[] = [
171 ...(tests ? [{ text: `✓ ${tests.passed} ✗ ${tests.failed}${tests.skipped > 0 ? ` ○ ${tests.skipped}` : ''}`, color: tests.failed > 0 ? 'red' : 'green' }] : []),
172 ...(files.length > 0 ? [{ text: `+${changed.added} −${changed.removed}`, color: 'blue' }] : []),
173 ...gitBadges(out).map(text => ({ text, color: 'green' })),
174 ...(input.dangerouslyDisableSandbox ? [{ text: '⚠ unsandboxed', color: 'red' }] : []),
175 ]
176
177 const preview = previewTail(stdout, stderr, cfg.previewLines)
178 const hiddenBefore = all.length - preview.length
179 const expanded = headTail(all, MAX_EXPANDED_LINES)
180
181 const drawLine = ({ t, err }: Line, i: number) => (
182 <Text key={`l${i}`} color={err ? 'red' : undefined} dimColor={!err && !isOpen}>
183 {isOpen ? t : clip(t, width)}
184 </Text>
185 )
186
187 return (
188 <Box flexDirection="column" width="100%" borderStyle="round" borderColor={color} paddingX={1}>
189 <Box flexDirection="row" justifyContent="space-between">
190 <Button key={`q-panel-${e.requestId}`} plain onPress={toggle}>
191 <Text color={color}>{isOpen ? '▾' : '▸'} </Text>
192 <Text bold>$ {head}</Text>
193 </Button>
194 <Text color={color}>
195 {' '}
196 {state}
197 {took && <Text color={ms >= SLOW_MS ? 'yellow' : color}>{took}</Text>}
198 </Text>
199 </Box>
200
201 {input.description && <Text dimColor>{input.description}</Text>}
202
203 {badges.length > 0 && (
204 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
205 {badges.map((b, i) => (
206 <Text key={`b${i}`} color={b.color}>
207 [{b.text}]
208 </Text>
209 ))}
210 </Box>
211 )}
212
213 {out.returnCodeInterpretation && <Text dimColor>ℹ {out.returnCodeInterpretation}</Text>}
214
215 {isOpen && cmdLines.length > 1 && (
216 <Box flexDirection="column" marginTop={1}>
217 {cmdLines.map((l, i) => (
218 <Text key={`c${i}`} bold>
219 {l}
220 </Text>
221 ))}
222 </Box>
223 )}
224
225 {all.length > 0 && (
226 <Box flexDirection="column" marginTop={isOpen ? 1 : 0}>
227 {!isOpen && hiddenBefore > 0 && <Text dimColor>… {hiddenBefore} earlier lines</Text>}
228 {!isOpen && preview.map(drawLine)}
229 {isOpen && expanded.head.map(drawLine)}
230 {isOpen && expanded.gap > 0 && <Text dimColor>… {expanded.gap} lines hidden …</Text>}
231 {isOpen && expanded.tail.map((l, i) => drawLine(l, expanded.head.length + i))}
232 </Box>
233 )}
234
235 {out.isImage && <Text dimColor>(image output, drawn below)</Text>}
236 {!p.isRunning && !out.isImage && !out.backgroundTaskId && all.length === 0 && <Text dimColor>(no output)</Text>}
237 {out.persistedOutputPath && (
238 <Text dimColor>
239 full output{out.persistedOutputSize ? ` (${formatBytes(out.persistedOutputSize)})` : ''} saved at {out.persistedOutputPath}
240 </Text>
241 )}
242
243 <Box flexDirection="row">
244 <Button key={`q-panel-copy-${e.requestId}`} plain onPress={press => copyText($, command, press.surface, 'Command')}>
245 <Text dimColor>[ copy command ]</Text>
246 </Button>
247 </Box>
248 </Box>
249 )
250 })
251
252 // ---- file panel: Write, Edit, NotebookEdit -----------------------------------------------
253
254 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
255 const p = e.props
256 if (!FILE_TOOLS.has(p.tool)) return next(e)
257 const cfg = await read($, settings)
258 if (!cfg.filePanels) return next(e)
259
260 const { Box, Text, Button } = $.ui.resolve(e)
261 const change = fileChange(p.tool, p.input, p.output)
262 const root = await $.session.root().catch(() => undefined)
263 const path = relativeTo(change.path, root)
264
265 const isOpen = await read($, memberOf(open, e))
266 const toggle = () => update($, memberOf(open, e), v => !v)
267 const width = Math.max(20, (e.viewport?.columns ?? 100) - 6)
268
269 // A failed or refused call carries the text the model read instead of a result record.
270 const error = p.isErrored && typeof p.output === 'string' ? p.output : undefined
271 const [state, color] = p.isRunning
272 ? ['writing…', 'yellow']
273 : p.isInterrupted
274 ? ['interrupted', 'yellow']
275 : p.isErrored
276 ? ['failed', 'red']
277 : change.staged
278 ? ['held for review', 'blue']
279 : [change.created ? 'created' : 'done', 'green']
280
281 const stats = change.added + change.removed > 0 ? `+${change.added} −${change.removed}` : ''
282 const right = ` ${state}${stats ? ` · ${stats}` : ''}`
283 const head = clip(path, width - 6 - change.verb.length - right.length)
284
285 const rows = isOpen ? change.diff.slice(0, MAX_EXPANDED_LINES) : previewChanges(change.diff, cfg.previewLines)
286 const changed = change.added + change.removed
287 const more = isOpen ? change.diff.length - rows.length : changed - rows.length
288
289 const drawRow = (l: DiffLine, i: number) => {
290 const sign = l.kind === '@' ? '' : `${l.kind} `
291 return (
292 <Text key={`d${i}`} color={DIFF_COLOR[l.kind]} dimColor={l.kind === ' ' || l.kind === '@'}>
293 {isOpen ? `${sign}${l.t}` : clip(`${sign}${l.t}`, width)}
294 </Text>
295 )
296 }
297
298 return (
299 <Box flexDirection="column" width="100%" borderStyle="round" borderColor={color} paddingX={1}>
300 <Box flexDirection="row" justifyContent="space-between">
301 <Button key={`q-panel-${e.requestId}`} plain onPress={toggle}>
302 <Text color={color}>{isOpen ? '▾' : '▸'} </Text>
303 <Text bold>{change.verb} </Text>
304 <Text>{head}</Text>
305 </Button>
306 <Text color={color}>{right}</Text>
307 </Box>
308 {error && <Text color="red">{isOpen ? error : clip(error.split('\n')[0] ?? '', width)}</Text>}
309 {!error && rows.length > 0 && (
310 <Box flexDirection="column">
311 {rows.map(drawRow)}
312 {more > 0 && <Text dimColor>{isOpen ? `… ${more} more lines` : `… ${more} more changed lines`}</Text>}
313 </Box>
314 )}
315 </Box>
316 )
317 })
318
319 // ---- replies: fenced code blocks drawn framed and highlighted, collapsed on press ---------
320
321 // Prose stays the engine's own markdown; only the fences are drawn by q-panel. A reply with no
322 // fence, a summary row, or codeBlocks off is left to the engine untouched.
323 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
324 const p = e.props
325 if (p.isSummary || !hasFence(p.text) || !(await read($, settings)).codeBlocks) return next(e)
326
327 const { Box, Text, Button, Markdown, Code } = $.ui.resolve(e)
328 const cfg = await read($, settings)
329 const parts = splitFences(p.text)
330
331 const drawn = await Promise.all(
332 parts.map(async (seg, i) => {
333 if (seg.kind === 'md') return <Markdown key={`md${i}`} text={seg.text} />
334
335 const lineCount = seg.code ? seg.code.split('\n').length : 0
336 // A long finished block starts collapsed; a press flips it, and the press is remembered.
337 const startsOpen = !seg.closed || lineCount <= cfg.codeFoldLines
338 const member = memberOf(codeOpen, { requestId: `${e.requestId}#${i}` })
339 const isOpen = (await read($, member)) ?? startsOpen
340 const toggle = () => update($, member, v => !(v ?? startsOpen))
341
342 return (
343 <Box key={`code${i}`} flexDirection="column" width="100%" borderStyle="round" borderColor="gray" paddingX={1}>
344 <Box flexDirection="row" justifyContent="space-between">
345 <Button key={`q-panel-code-${e.requestId}-${i}`} plain onPress={toggle}>
346 <Text color="cyan">
347 {isOpen ? '▾' : '▸'} {seg.lang || 'text'}
348 </Text>
349 </Button>
350 <Box flexDirection="row" columnGap={1}>
351 <Text dimColor>
352 {lineCount} {lineCount === 1 ? 'line' : 'lines'}
353 {seg.closed ? '' : ' …'}
354 </Text>
355 {seg.closed && seg.code !== '' && (
356 <Button key={`q-panel-code-copy-${e.requestId}-${i}`} plain onPress={press => copyText($, seg.code, press.surface, 'Code')}>
357 <Text dimColor>[ copy ]</Text>
358 </Button>
359 )}
360 <Button key={`q-panel-code-fold-${e.requestId}-${i}`} plain onPress={toggle}>
361 <Text dimColor>[ {isOpen ? 'collapse' : 'expand'} ]</Text>
362 </Button>
363 </Box>
364 </Box>
365 {isOpen && seg.code !== '' && <Code source={seg.code} language={seg.lang || undefined} />}
366 </Box>
367 )
368 }),
369 )
370
371 // The engine's own row opens a reply with a bullet and indents the text under it; a tree of
372 // our own replaces that row, so the terminal gets both back here.
373 if (e.surface !== 'terminal') return <Box flexDirection="column">{drawn}</Box>
374 return (
375 <Box flexDirection="row">
376 <Box width={2} flexShrink={0}>
377 <Text>{p.isFirstOfReply ? '⏺' : ' '}</Text>
378 </Box>
379 <Box flexDirection="column" flexGrow={1}>
380 {drawn}
381 </Box>
382 </Box>
383 )
384 })
385}
386hooks/lib/bash.ts 186 lines1// Pure helpers for the Bash panel. No `$` access here, so everything is easy to test.
2
3/** Fixed limits; not settings, to keep the surface small. */
4export const MAX_EXPANDED_LINES = 400
5export const SLOW_MS = 10_000
6
7// ANSI CSI / OSC escape sequences.
8const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(\x07|\x1b\\)|\x1b[@-Z\\-_]/g
9export const stripAnsi = (text: string) => text.replace(ANSI, '')
10
11/** Terminal cells a code point takes: 2 for wide East Asian and emoji, 0 for combining marks. */
12const cellWidth = (cp: number) => {
13 if (cp === 0 || (cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || cp === 0xfe0f) return 0
14 if (
15 (cp >= 0x1100 && cp <= 0x115f) ||
16 (cp >= 0x2e80 && cp <= 0xa4cf) ||
17 (cp >= 0xac00 && cp <= 0xd7a3) ||
18 (cp >= 0xf900 && cp <= 0xfaff) ||
19 (cp >= 0xfe30 && cp <= 0xfe4f) ||
20 (cp >= 0xff00 && cp <= 0xff60) ||
21 (cp >= 0xffe0 && cp <= 0xffe6) ||
22 (cp >= 0x1f300 && cp <= 0x1faff) ||
23 (cp >= 0x20000 && cp <= 0x3fffd)
24 )
25 return 2
26 return 1
27}
28
29export const displayWidth = (text: string) => {
30 let w = 0
31 for (const ch of text) w += cellWidth(ch.codePointAt(0) ?? 0)
32 return w
33}
34
35/** Cuts a line to `width` terminal cells, ending with `…` when cut. */
36export const clip = (line: string, width: number) => {
37 if (displayWidth(line) <= width) return line
38 const room = Math.max(1, width - 1)
39 let w = 0
40 let out = ''
41 for (const ch of line) {
42 const cw = cellWidth(ch.codePointAt(0) ?? 0)
43 if (w + cw > room) break
44 out += ch
45 w += cw
46 }
47 return out + '…'
48}
49
50/** Splits output into lines, dropping trailing blank lines. */
51export const lines = (text: string | undefined) => {
52 const trimmed = stripAnsi(text ?? '').replace(/\s+$/, '')
53 return trimmed === '' ? [] : trimmed.split('\n')
54}
55
56export type Line = { t: string; err: boolean }
57
58/**
59 * Collapsed preview: the last `n` lines. When both streams have output, stderr
60 * gets at most half the room so warnings cannot push the result out of view.
61 */
62export const previewTail = (stdout: string[], stderr: string[], n: number): Line[] => {
63 const errRoom = stdout.length === 0 ? n : Math.min(stderr.length, Math.ceil(n / 2))
64 const outRoom = n - errRoom
65 return [
66 ...stdout.slice(stdout.length - Math.min(outRoom, stdout.length)).map(t => ({ t, err: false })),
67 ...stderr.slice(stderr.length - Math.min(errRoom, stderr.length)).map(t => ({ t, err: true })),
68 ]
69}
70
71/** Expanded view: everything up to `max`, else the head and the tail around a gap. */
72export const headTail = (all: Line[], max: number): { head: Line[]; tail: Line[]; gap: number } => {
73 if (all.length <= max) return { head: all, tail: [], gap: 0 }
74 const half = Math.floor(max / 2)
75 return { head: all.slice(0, half), tail: all.slice(all.length - (max - half)), gap: all.length - max }
76}
77
78export const formatMs = (ms: number) =>
79 ms < 1000 ? `${Math.round(ms)}ms` : ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`
80
81export const formatBytes = (n: number) =>
82 n < 1024 ? `${n} B` : n < 1024 ** 2 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 ** 2).toFixed(1)} MB`
83
84export type TestSummary = { passed: number; failed: number; skipped: number }
85
86/** Recognises the summary line of vitest, jest, pytest, go test and cargo test. */
87export const parseTestSummary = (text: string): TestSummary | undefined => {
88 const num = (re: RegExp) => Number(re.exec(text)?.[1] ?? 0)
89 // vitest / jest: "Tests: 2 failed, 128 passed, 3 skipped" or "Tests 2 failed | 128 passed (133)"
90 const jest = /^\s*Tests:?\s+(.*)$/m.exec(text)?.[1]
91 if (jest && /\d+\s+(passed|failed)/.test(jest)) {
92 const pick = (w: string) => Number(new RegExp(String.raw`(\d+)\s+${w}`).exec(jest)?.[1] ?? 0)
93 return { passed: pick('passed'), failed: pick('failed'), skipped: pick('skipped') + pick('todo') }
94 }
95 // pytest: "==== 2 failed, 128 passed, 3 skipped in 1.2s ===="
96 const py = /^=+ (.*\d+ (passed|failed).*) in [\d.]+s/m.exec(text)?.[1]
97 if (py) {
98 const pick = (w: string) => Number(new RegExp(String.raw`(\d+) ${w}`).exec(py)?.[1] ?? 0)
99 return { passed: pick('passed'), failed: pick('failed') + pick('error'), skipped: pick('skipped') }
100 }
101 // cargo: "test result: ok. 12 passed; 0 failed; 1 ignored"
102 if (/test result: (ok|FAILED)\./.test(text))
103 return { passed: num(/(\d+) passed;/), failed: num(/(\d+) failed;/), skipped: num(/(\d+) ignored;/) }
104 // go test: count "--- PASS" / "--- FAIL" / "--- SKIP"
105 const go = (w: string) => (text.match(new RegExp(String.raw`^\s*--- ${w}:`, 'gm')) ?? []).length
106 if (go('PASS') + go('FAIL') > 0) return { passed: go('PASS'), failed: go('FAIL'), skipped: go('SKIP') }
107 return undefined
108}
109
110export const diffStats = (files: { hunks: { lines: string[] }[] }[]) => {
111 let added = 0
112 let removed = 0
113 for (const f of files)
114 for (const h of f.hunks)
115 for (const l of h.lines) {
116 if (l.startsWith('+')) added += 1
117 else if (l.startsWith('-')) removed += 1
118 }
119 return { added, removed }
120}
121
122// ---- Bash result record -------------------------------------------------------------
123
124export type BashInput = {
125 command?: string
126 description?: string
127 run_in_background?: boolean
128 dangerouslyDisableSandbox?: boolean
129}
130
131export type DiffFile = { filePath: string; hunks: { lines: string[] }[] }
132
133/** The fields of the Bash result record the panel reads. */
134export type BashOutput = {
135 stdout?: string
136 stderr?: string
137 interrupted?: boolean
138 isImage?: boolean
139 backgroundTaskId?: string
140 backgroundedByUser?: boolean
141 timedOutAfterMs?: number
142 returnCodeInterpretation?: string
143 persistedOutputPath?: string
144 persistedOutputSize?: number
145 gitOperation?: {
146 commit?: { sha: string; kind: string; branch?: string }
147 push?: { branch: string }
148 branch?: { ref: string; action: string }
149 pr?: { number: number; url?: string; action: string }
150 }
151 bashEditDiff?: { files: DiffFile[]; moreFiles: number; unavailable?: true; skipped?: true }
152}
153
154/** A row's output: the result record, or the error text the model read when the call failed. */
155export const readOutput = (output: unknown): BashOutput =>
156 typeof output === 'string' ? { stderr: output } : ((output ?? {}) as BashOutput)
157
158/** Header label and border color for a Bash call. */
159export const statusOf = (
160 p: { isRunning: boolean; isInterrupted: boolean; isErrored: boolean },
161 out: BashOutput,
162): [string, string] =>
163 p.isRunning
164 ? ['running…', 'yellow']
165 : out.backgroundTaskId
166 ? [`⧗ background ${out.backgroundTaskId}`, 'blue']
167 : out.timedOutAfterMs
168 ? [`⏱ timed out after ${formatMs(out.timedOutAfterMs)}`, 'blue']
169 : p.isInterrupted || out.interrupted
170 ? ['interrupted', 'yellow']
171 : p.isErrored
172 ? ['failed', 'red']
173 : ['done', 'green']
174
175/** Short labels for git activity the engine detected in the command. */
176export const gitBadges = (out: BashOutput) => {
177 const g = out.gitOperation
178 if (!g) return []
179 return [
180 g.commit && `⎇ ${g.commit.kind} ${g.commit.sha.slice(0, 7)}${g.commit.branch ? ` on ${g.commit.branch}` : ''}`,
181 g.push && `↑ pushed ${g.push.branch}`,
182 g.branch && `⑂ ${g.branch.action} ${g.branch.ref}`,
183 g.pr && `PR #${g.pr.number} ${g.pr.action}`,
184 ].filter((b): b is string => typeof b === 'string')
185}
186hooks/lib/files.ts 108 lines1// Pure helpers for the file panels (Write, Edit, NotebookEdit). No `$` access here, so everything is easy to test.
2
3/** Tools drawn as a file panel. */
4export const FILE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit'])
5
6/** One row of a file panel's diff: an added, removed or context line, or a hunk header. */
7export type DiffLine = { t: string; kind: '+' | '-' | ' ' | '@' }
8
9export type FileChange = {
10 path: string
11 /** Header verb: `Write`, `Create`, `Edit`, `Edit ×all`, `Insert cell`, … */
12 verb: string
13 added: number
14 removed: number
15 /** The diff, hunk headers included; empty while the call runs or when there is nothing to show. */
16 diff: DiffLine[]
17 /** True when the file was created rather than changed. */
18 created: boolean
19 /** True when the change was held for review and the file is unchanged. */
20 staged: boolean
21}
22
23type Hunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
24
25type Input = {
26 file_path?: string
27 notebook_path?: string
28 content?: string
29 old_string?: string
30 new_string?: string
31 replace_all?: boolean
32 new_source?: string
33 edit_mode?: string
34}
35
36type Output = {
37 type?: 'create' | 'update'
38 structuredPatch?: Hunk[]
39 content?: string
40 staged?: boolean
41 old_source?: string
42 new_source?: string
43 edit_mode?: string
44}
45
46const splitLines = (text: string | undefined) => (text ? text.replace(/\n$/, '').split('\n') : [])
47
48const fromHunks = (hunks: Hunk[]): DiffLine[] =>
49 hunks.flatMap(h => [
50 { t: `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@`, kind: '@' as const },
51 ...h.lines.map((l): DiffLine => {
52 const k = l[0]
53 return { t: l.slice(1), kind: k === '+' || k === '-' ? k : ' ' }
54 }),
55 ])
56
57const fromPair = (before: string | undefined, after: string | undefined): DiffLine[] => [
58 ...splitLines(before).map(t => ({ t, kind: '-' as const })),
59 ...splitLines(after).map(t => ({ t, kind: '+' as const })),
60]
61
62const count = (diff: DiffLine[], kind: '+' | '-') => diff.filter(l => l.kind === kind).length
63
64/**
65 * What a Write, Edit or NotebookEdit call changed, from its input and (once resolved) its result.
66 * While the call runs, or when it failed, the diff comes from the input so the panel still shows the intent.
67 */
68export const fileChange = (tool: string, input: unknown, output: unknown): FileChange => {
69 const i = (input ?? {}) as Input
70 const o = (output && typeof output === 'object' ? output : {}) as Output
71 const path = i.file_path ?? i.notebook_path ?? ''
72
73 let verb = tool
74 let diff: DiffLine[]
75 let created = false
76
77 if (tool === 'NotebookEdit') {
78 const mode = o.edit_mode ?? i.edit_mode ?? 'replace'
79 verb = mode === 'insert' ? 'Insert cell' : mode === 'delete' ? 'Delete cell' : 'Edit cell'
80 diff = fromPair(o.old_source, mode === 'delete' ? undefined : (o.new_source ?? i.new_source))
81 } else if (o.structuredPatch && o.structuredPatch.length > 0) {
82 diff = fromHunks(o.structuredPatch)
83 } else if (tool === 'Write') {
84 // A new file has no patch: every line is added.
85 diff = splitLines(o.content ?? i.content).map(t => ({ t, kind: '+' as const }))
86 } else {
87 diff = fromPair(i.old_string, i.new_string)
88 }
89
90 if (tool === 'Write') {
91 created = o.type === 'create'
92 verb = created ? 'Create' : 'Write'
93 }
94 if (tool === 'Edit' && i.replace_all) verb = 'Edit ×all'
95
96 return { path, verb, added: count(diff, '+'), removed: count(diff, '-'), diff, created, staged: o.staged === true }
97}
98
99/** The changed lines a collapsed panel shows: the first `n` added or removed lines. */
100export const previewChanges = (diff: DiffLine[], n: number) => diff.filter(l => l.kind === '+' || l.kind === '-').slice(0, n)
101
102/** `path` relative to `root` when it lies inside it. */
103export const relativeTo = (path: string, root: string | undefined) => {
104 if (!root) return path
105 const base = root.endsWith('/') ? root : `${root}/`
106 return path.startsWith(base) ? path.slice(base.length) : path
107}
108hooks/lib/markdown.ts 45 lines1// Splits an assistant reply into markdown prose and fenced code blocks. Pure, so it is easy to test.
2
3export type Segment =
4 | { kind: 'md'; text: string }
5 /** `closed` is false while a reply is still streaming the block. */
6 | { kind: 'code'; lang: string; code: string; closed: boolean }
7
8const OPEN = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)[^`]*$/
9
10/**
11 * Cuts `text` at CommonMark fences (``` or ~~~, three or more, up to three spaces of indent).
12 * A fence closes on the same character, at least as long, with nothing after it.
13 */
14export const splitFences = (text: string): Segment[] => {
15 const out: Segment[] = []
16 const prose: string[] = []
17 const flush = () => {
18 const t = prose.join('\n')
19 if (t.trim()) out.push({ kind: 'md', text: t })
20 prose.length = 0
21 }
22
23 const rows = text.split('\n')
24 for (let i = 0; i < rows.length; i++) {
25 const row = rows[i] ?? ''
26 const m = OPEN.exec(row)
27 if (!m) {
28 prose.push(row)
29 continue
30 }
31 const fence = m[1] ?? '```'
32 const close = new RegExp(`^ {0,3}${fence[0] === '`' ? '`' : '~'}{${fence.length},}[ \\t]*$`)
33 const body: string[] = []
34 let j = i + 1
35 while (j < rows.length && !close.test(rows[j] ?? '')) body.push(rows[j++] ?? '')
36 flush()
37 out.push({ kind: 'code', lang: (m[2] ?? '').toLowerCase(), code: body.join('\n'), closed: j < rows.length })
38 i = j
39 }
40 flush()
41 return out
42}
43
44export const hasFence = (text: string) => text.split('\n').some(r => OPEN.test(r))
45hooks/lib/settings.ts 50 lines1// Settings and the /q-panel usage text. Pure, so everything is easy to test.
2
3import type { PanelSettings } from '../../types'
4
5export const DEFAULT_SETTINGS: PanelSettings = {
6 filePanels: true,
7 unfoldGroups: true,
8 previewLines: 4,
9 codeBlocks: true,
10 codeFoldLines: 40,
11}
12
13const BOOLEAN = ['filePanels', 'unfoldGroups', 'codeBlocks'] as const
14const NUMERIC = ['previewLines', 'codeFoldLines'] as const
15
16/** Takes only the current settings from a stored value, so unknown keys are ignored and bad values defaulted. */
17export const pickSettings = (saved: unknown): PanelSettings => {
18 const s = (saved && typeof saved === 'object' ? saved : {}) as Record<string, unknown>
19 const next = { ...DEFAULT_SETTINGS }
20 for (const k of BOOLEAN) if (typeof s[k] === 'boolean') next[k] = s[k] as boolean
21 for (const k of NUMERIC) if (Number.isInteger(s[k]) && (s[k] as number) > 0) next[k] = s[k] as number
22 return next
23}
24
25/** Parses `<key> <value>` into a settings patch, or an error message. */
26export const parseConfig = (key: string, value: string): Partial<PanelSettings> | string => {
27 if ((NUMERIC as readonly string[]).includes(key)) {
28 const n = Number(value)
29 if (!Number.isInteger(n) || n < 1) return `${key} needs a whole number of 1 or more.`
30 return { [key]: n }
31 }
32 if ((BOOLEAN as readonly string[]).includes(key)) {
33 if (!['on', 'off', 'true', 'false'].includes(value)) return `${key} needs on or off.`
34 return { [key]: value === 'on' || value === 'true' }
35 }
36 return `Unknown setting "${key}". Settings: ${[...BOOLEAN, ...NUMERIC].join(', ')}.`
37}
38
39export const showSettings = (cfg: PanelSettings) =>
40 [...BOOLEAN, ...NUMERIC].map(k => ` ${k.padEnd(14)} ${typeof cfg[k] === 'boolean' ? (cfg[k] ? 'on' : 'off') : String(cfg[k])}`).join('\n')
41
42export const USAGE = [
43 'Usage: /q-panel <subcommand>',
44 ' config show the settings',
45 ' config <key> <value> change a setting: filePanels, unfoldGroups, codeBlocks (on|off),',
46 ' previewLines, codeFoldLines (a number)',
47 ' config reset restore the defaults',
48 ' help this text',
49].join('\n')
50types/index.d.ts 28 lines1export type PanelSettings = {
2 /** Draw Write, Edit and NotebookEdit calls as bordered panels with their diff. */
3 filePanels: boolean
4 /** Unfold a tool group when every call in it is drawn as a panel (Bash, Write, Edit, NotebookEdit). */
5 unfoldGroups: boolean
6 /** Lines shown in a collapsed Bash or file panel. */
7 previewLines: number
8 /** Draw fenced code blocks in replies as framed, highlighted blocks that collapse on press. */
9 codeBlocks: boolean
10 /** Code blocks longer than this start collapsed. */
11 codeFoldLines: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'q-panel': {
17 /** Loaded from `$.store` at session start; the only value kept across sessions. */
18 settings: PanelSettings
19 /** Per Bash or file call (keyed by tool_use_id): is the panel expanded? */
20 open: StateFamily<boolean>
21 /** Per Bash call: wall-clock duration in ms, recorded around `tool.call`. */
22 duration: StateFamily<number>
23 /** Per code block in a reply (keyed by `<requestId>#<index>`): expanded or collapsed by a press; null until pressed. */
24 codeOpen: StateFamily<boolean | null>
25 }
26 }
27}
28