/pane-peek: search panes of every herdr workspace, insert a [herdr-id:...] chip for the chosen one, and read marked panes for Claude when you send

A Claude Code mod for people who run their sessions inside herdr. Type /pane-peek, search the panes of every workspace, press Enter, and a [herdr-id:w1E:p4] marker for that pane lands in your prompt, so you can tell Claude exactly which pane to look at or act on.
https://github.com/user-attachments/assets/6443c4ac-5dab-417b-83a4-4331b475ced6
In a Claude Code terminal session:
/plugin install pane-peek --marketplace arthurseredaa/herdr-pane-peek
Answer y to add the marketplace, then pick a scope (user scope makes it available in every session).
| Key | Does |
|---|---|
/pane-peek | opens the picker; the search field has the keyboard |
| type | filters panes; every word must match the workspace, tab, cwd, agent kind, status, session title or pane id (career claude finds Claude sessions in a workspace called career-ops) |
| Enter in the search field | inserts the highlighted pane (the first match until you move) |
| ↑ ↓ / Tab, then Enter | pick another row and insert it |
| Esc | closes without inserting |
The pane lands in the prompt at the cursor as a marker with a trailing space: [herdr-id:w1E:p4] . Keep typing around it: "[herdr-id:w1E:p4] why do the tests fail there?". The marker is drawn as a chip in your theme's suggestion colors, and Backspace or Delete next to it removes the whole marker. A toast says where the pane lives (workspace/tab · ~/path). If the prompt cannot take text, the marker is copied to the clipboard instead.
Panes are grouped under their workspace. Each row is ● tab · claude idle · my-project — session title: the dot is green for done, yellow for idle, blue for working, red for blocked and grey for a plain shell, and agents that are blocked or working are listed first inside their workspace. The counter beside the search field shows matches/total. The pane you are in is left out of the list; type this to find it (it is marked (this)). The list refreshes every 5 seconds while the picker is open.
When you send a prompt that contains markers, the mod reads each marked pane first (herdr pane read, the last 120 lines, at most 3 panes, anything unreadable is reported) and hands the snapshot to Claude beside your message. Claude answers from it in one step instead of loading a skill and running the command itself, which is much faster. The snapshot is taken when you press Enter, and it goes to the model, so keep that in mind for panes that show secrets. To act in a pane (send input, run something) Claude still uses the herdr skill below.
Reading works on its own: the mod attaches the pane to your message and says what the marker refers to. To let Claude do things in a pane (send input, run a command, prompt the agent running there), install the official herdr skill, which explains herdr panes and the herdr CLI. The word herdr in the marker is what makes Claude load it. Install it once:
npx skills add herdrdev/herdr --skill herdr -g -a claude-code
-a claude-code (add -y to skip the confirmations). Without -g it installs into the current project only.herdr --skill prints the copy that ships with it (the copy on GitHub can be newer).Optional: herdr integration install claude adds a hook that lets herdr see Claude's state precisely, so the statuses in the picker are more accurate. It changes your Claude Code hooks, so it is not done for you.
herdr CLI on PATH; HERDR_PANE_ID is how the mod marks your own pane).git clone git@github.com:arthurseredaa/herdr-pane-peek.git
cd herdr-pane-peek
claude --plugin-dir . # load it from the folder; saving a file reloads the mod
claude plugin validate . # manifest, marketplace and hooks
claude plugin test . # the *.test.ts files
hooks/register.tsx: the hooks (command, pane drawing, focus tracking, pick).hooks/peek.ts: pure helpers (herdr api snapshot parsing, filtering, row labels, the marker and its chip behaviour).types/index.d.ts: the $.state contract.tsconfig.json extends a file Claude Code writes into .claude-plugin/types/ the first time it loads the mod, so tsc -p . works after one load.MIT
hooks/register.tsx 192 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import {
5 READ_LINES,
6 decorate,
7 describeOrigin,
8 effective,
9 expandDeletion,
10 filterPanes,
11 formatToken,
12 layoutRows,
13 paneContext,
14 paneUnreadable,
15 parseSnapshot,
16 rowLabel,
17 statusColor,
18 tokenIds,
19} from './peek'
20
21const PANE = 'pane-peek'
22const REFRESH_MS = 5_000
23const PANE_ROWS = 20
24
25const panes = atom({ plugin: 'pane-peek', key: 'panes' } as const, [])
26const query = atom({ plugin: 'pane-peek', key: 'query' } as const, '')
27const selected = atom({ plugin: 'pane-peek', key: 'selected' } as const, null)
28const error = atom({ plugin: 'pane-peek', key: 'error' } as const, null)
29
30async function refresh($: EngineInterface) {
31 try {
32 const got = await $.process.run(['herdr', 'api', 'snapshot'], { timeoutMs: 5000 })
33 if (got.exitCode !== 0) throw new Error(got.stderr.trim() || `exit ${got.exitCode}`)
34 const found = parseSnapshot(got.stdout, await $.env.get('HERDR_PANE_ID'))
35 await update($, panes, () => found)
36 await update($, error, () => null)
37 } catch (failure) {
38 await update($, error, () => `herdr api snapshot: ${failure instanceof Error ? failure.message : String(failure)}`)
39
40 return
41 }
42}
43
44async function setQuery($: EngineInterface, value: string) {
45 await update($, query, () => value)
46 await update($, selected, () => null)
47}
48
49async function highlight($: EngineInterface, paneId: string) {
50 await update($, selected, () => paneId)
51}
52
53/** Closes the pane first: while it holds the keys the prompt box refuses a fill. */
54async function pick($: EngineInterface, paneId: string) {
55 await $.ui.close({ id: PANE })
56 const token = formatToken(paneId)
57 const found = (await read($, panes)).find(p => p.paneId === paneId)
58 const from = found === undefined ? paneId : describeOrigin(found, await $.env.get('HOME'))
59 const filled = await $.prompt.fill({ text: `${token} `, mode: 'insert', decorations: decorate(token) })
60 if (filled.isFilled) {
61 $.ui.toast(`inserted pane from ${from}`)
62 } else {
63 await $.ui.copy({ text: token })
64 $.ui.toast(`copied pane from ${from} (the prompt would not take it)`)
65 }
66}
67
68async function pickFirst($: EngineInterface) {
69 const list = filterPanes(await read($, panes), await read($, query))
70 const target = effective(list, await read($, selected))
71 if (target !== undefined) await pick($, target.paneId)
72}
73
74export const register: Register = on => {
75 let isOpen = false
76
77 on('session.start', async ($, e, next) => {
78 await $.command.register({
79 name: 'pane-peek',
80 description: 'Pick a herdr pane from any workspace and put its id in the prompt',
81 immediate: true,
82 })
83 $.clock.every(REFRESH_MS, () => {
84 if (isOpen) void refresh($)
85 })
86
87 return next(e)
88 })
89
90 on('command.run', { command: 'pane-peek' }, async $ => {
91 isOpen = true
92 await update($, query, () => '')
93 await update($, selected, () => null)
94 await $.ui.open({ id: PANE, title: 'herdr panes', focus: true, closeOnEscape: true, rows: PANE_ROWS })
95 await refresh($)
96
97 return {}
98 })
99
100 // The chips live in the draft as plain text: repaint them after every edit and
101 // let a deletion that touches one take the whole of it.
102 on('prompt.edit', async ($, e, next) => {
103 const done = await next(e)
104 const widened = e.inputText === '' ? expandDeletion(e.text, e.start, e.end) : null
105 const text = widened?.text ?? done.text
106 const cursor = widened?.cursor ?? done.cursor
107
108 return { ...done, text, cursor, decorations: [...(done.decorations ?? []), ...decorate(text)] }
109 }).catch(($, e, next) => next(e))
110
111 // A marked pane is read here, once, so the model answers from the snapshot
112 // instead of loading the skill and running `herdr pane read` itself.
113 on('prompt.submit', async ($, e, next) => {
114 const ids = tokenIds(e.text)
115 if (ids.length === 0) return next(e)
116
117 const blocks: string[] = []
118 for (const id of ids) {
119 const got = await $.process.run(
120 ['herdr', 'pane', 'read', id, '--source', 'recent-unwrapped', '--lines', String(READ_LINES)],
121 { timeoutMs: 5000 },
122 )
123 blocks.push(got.exitCode === 0 ? paneContext(id, got.stdout) : paneUnreadable(id, got.stderr.trim() || `exit ${got.exitCode}`))
124 }
125
126 return next({ ...e, context: [...(e.context ?? []), ...blocks] })
127 }).catch(($, e, next) => next(e))
128
129 on('ui.close', async ($, e, next) => {
130 if (e.id === PANE) isOpen = false
131
132 return next(e)
133 })
134
135 on('ui.focus', async ($, e, next) => {
136 const moved = await next(e)
137 if (e.requestId === PANE && e.element?.startsWith('row:') === true && moved.deny === undefined) {
138 await highlight($, e.element.slice('row:'.length))
139 }
140
141 return moved
142 })
143
144 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
145 // herdr is a terminal tool; the mobile surface has no text field to draw.
146 if (e.surface === 'mobile') return next(e)
147 const { Box, Button, Input, Text } = $.ui.resolve(e)
148 const all = await read($, panes)
149 const q = await read($, query)
150 const problem = await read($, error)
151 const columns = e.props.bodyColumns ?? 80
152 const list = filterPanes(all, q)
153 const total = Math.max(list.length, all.filter(p => !p.isThis).length)
154 // lines the rows and headings may take: all but the search line, the hint and a "more" line
155 const { groups, hidden } = layoutRows(list, Math.max(3, (e.viewport?.rows ?? PANE_ROWS) - 3))
156
157 return (
158 <Box flexDirection="column">
159 <Box>
160 <Box flexGrow={1}>
161 <Input
162 key="q"
163 autoFocus
164 placeholder="search…"
165 value={q}
166 submitLabel="insert"
167 onInput={value => setQuery($, value)}
168 onSubmit={() => pickFirst($)}
169 />
170 </Box>
171 <Text dimColor>{` ${list.length}/${total}`}</Text>
172 </Box>
173 {problem !== null && <Text color="error">{problem}</Text>}
174 {problem === null && list.length === 0 && <Text color="inactive">No panes found.</Text>}
175 {groups.map(g => (
176 <Box key={`ws:${g.workspace}`} flexDirection="column">
177 <Text bold color="claude">{g.workspace}</Text>
178 {g.panes.map(p => (
179 <Box key={`pane:${p.paneId}`}>
180 <Text color={statusColor(p.status)}> ● </Text>
181 <Button plain dimColor label={rowLabel(p, columns - 4)} key={`row:${p.paneId}`} onPress={() => pick($, p.paneId)} />
182 </Box>
183 ))}
184 </Box>
185 ))}
186 {hidden > 0 && <Text color="inactive">{`… ${hidden} more, narrow the search`}</Text>}
187 <Text color="inactive">↑↓ choose · ⏎ insert · esc close</Text>
188 </Box>
189 )
190 })
191}
192hooks/peek.ts 199 lines1import type { PromptDecoration } from 'claude-code'
2
3import type { PeekPane } from '../types'
4
5type Snapshot = {
6 result?: {
7 snapshot?: {
8 workspaces?: { workspace_id: string; label?: string; number?: number }[]
9 tabs?: { tab_id: string; label?: string; number?: number }[]
10 panes?: {
11 pane_id: string
12 workspace_id: string
13 tab_id: string
14 cwd?: string
15 terminal_title_stripped?: string
16 }[]
17 agents?: { pane_id: string; agent?: string; agent_status?: string; terminal_title_stripped?: string }[]
18 }
19 }
20}
21
22/** `herdr api snapshot` -> every pane of every workspace, in sidebar order. */
23export function parseSnapshot(json: string, thisPane: string | undefined): PeekPane[] {
24 const snap = (JSON.parse(json) as Snapshot).result?.snapshot ?? {}
25 const workspaces = new Map((snap.workspaces ?? []).map(w => [w.workspace_id, w]))
26 const tabs = new Map((snap.tabs ?? []).map(t => [t.tab_id, t]))
27 const agents = new Map((snap.agents ?? []).map(a => [a.pane_id, a]))
28
29 const panes = (snap.panes ?? []).map((p): PeekPane => {
30 const ws = workspaces.get(p.workspace_id)
31 const tab = tabs.get(p.tab_id)
32 const agent = agents.get(p.pane_id)
33
34 return {
35 paneId: p.pane_id,
36 workspace: ws?.label ?? p.workspace_id,
37 workspaceNo: ws?.number ?? 0,
38 tab: tab?.label ?? p.tab_id,
39 tabNo: tab?.number ?? 0,
40 agent: agent?.agent ?? null,
41 status: agent?.agent_status ?? 'shell',
42 title: agent?.terminal_title_stripped ?? p.terminal_title_stripped ?? '',
43 cwd: p.cwd ?? '',
44 isThis: p.pane_id === thisPane,
45 }
46 })
47
48 // Array.prototype.sort is stable: panes keep herdr's order among equals.
49 return panes.sort((a, b) => a.workspaceNo - b.workspaceNo || attention(a) - attention(b) || a.tabNo - b.tabNo)
50}
51
52/** Within a workspace the agents that need a look come first: blocked, working, other agents, shells. */
53function attention(p: PeekPane): number {
54 if (p.agent === null) return 3
55 if (p.status === 'blocked') return 0
56 if (p.status === 'working') return 1
57
58 return 2
59}
60
61export function base(cwd: string): string {
62 return cwd.split('/').filter(Boolean).pop() ?? cwd
63}
64
65/**
66 * Every word of `query` must occur somewhere in the pane's searchable text.
67 * The pane you are in is left out of an empty search and found by the word "this".
68 */
69export function filterPanes(panes: PeekPane[], query: string): PeekPane[] {
70 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
71 const wantsThis = words.includes('this')
72 const pool = wantsThis ? panes : panes.filter(p => !p.isThis)
73 if (words.length === 0) return pool
74
75 return pool.filter(p => {
76 const hay = [p.paneId, p.workspace, p.tab, p.agent ?? 'shell', p.status, p.title, p.cwd, p.isThis ? 'this' : ''].join(' ').toLowerCase()
77
78 return words.every(w => hay.includes(w))
79 })
80}
81
82export function clip(line: string, columns: number): string {
83 return line.length > columns ? `${line.slice(0, Math.max(1, columns - 1))}…` : line
84}
85
86/** The row's text; the workspace is the group heading above it and the status dot is drawn beside it. */
87export function rowLabel(p: PeekPane, columns: number): string {
88 const kind = p.agent === null ? 'shell' : `${p.agent} ${p.status}`
89 const title = p.agent !== null && p.title !== '' ? ` — ${p.title}` : ''
90
91 return clip(`${p.tab} · ${kind} · ${base(p.cwd)}${p.isThis ? ' (this)' : ''}${title}`, columns)
92}
93
94/** Theme keys, so the dots follow the person's theme. */
95export function statusColor(status: string): 'ide' | 'warning' | 'success' | 'error' | 'inactive' {
96 if (status === 'working') return 'ide'
97 if (status === 'blocked') return 'error'
98 if (status === 'idle') return 'warning'
99 if (status === 'done') return 'success'
100
101 return 'inactive'
102}
103
104export type Group = { workspace: string; panes: PeekPane[] }
105
106/** Panes grouped by workspace in list order, cut to `room` lines (a heading costs one); `hidden` counts the panes cut. */
107export function layoutRows(list: PeekPane[], room: number): { groups: Group[]; hidden: number } {
108 const groups: Group[] = []
109 let used = 0
110 let shown = 0
111 for (const p of list) {
112 const last = groups[groups.length - 1]
113 const opens = last?.workspace !== p.workspace
114 if (used + (opens ? 2 : 1) > room) break
115 if (opens) groups.push({ workspace: p.workspace, panes: [p] })
116 else last.panes.push(p)
117 used += opens ? 2 : 1
118 shown += 1
119 }
120
121 return { groups, hidden: list.length - shown }
122}
123
124/** The pane Enter acts on: the ring's row if it is still listed, else the first. */
125export function effective(list: PeekPane[], selected: string | null): PeekPane | undefined {
126 return list.find(p => p.paneId === selected) ?? list[0]
127}
128
129/** What the prompt gets for a picked pane: a marker the herdr skill can recognise. */
130export function formatToken(paneId: string): string {
131 return `[herdr-id:${paneId}]`
132}
133
134export type Span = { start: number; end: number }
135
136export function findTokens(text: string): Span[] {
137 return [...text.matchAll(/\[herdr-id:[^\]\s]+\]/g)].map(m => ({ start: m.index, end: m.index + m[0].length }))
138}
139
140/** Theme keys, so the chip follows the person's theme: Claude's suggestion accent behind inverse text. */
141const CHIP = { color: 'inverseText', backgroundColor: 'suggestion', bold: true } as const
142
143export function decorate(text: string): PromptDecoration[] {
144 return findTokens(text).map(span => ({ ...span, ...CHIP }))
145}
146
147/**
148 * A deletion that touches a token takes the whole token with it, like a placeholder chip.
149 * `[start, end)` is the span the edit removed from `text`, the draft before the edit.
150 * Returns the draft and cursor after the widened deletion, or null when no token is touched.
151 */
152export function expandDeletion(text: string, start: number, end: number): { text: string; cursor: number } | null {
153 let from = start
154 let to = end
155 for (const t of findTokens(text)) {
156 if (t.start < to && t.end > from) {
157 from = Math.min(from, t.start)
158 to = Math.max(to, t.end)
159 }
160 }
161 if (from === start && to === end) return null
162
163 return { text: text.slice(0, from) + text.slice(to), cursor: from }
164}
165
166/** Where a pane lives, for the toast: `workspace/tab · ~/path`. */
167export function describeOrigin(p: PeekPane, home: string | undefined): string {
168 const short = home !== undefined && home !== '' && p.cwd.startsWith(home) ? `~${p.cwd.slice(home.length)}` : p.cwd
169
170 return `${p.workspace}/${p.tab} · ${short}`
171}
172
173export const READ_LINES = 120
174export const MAX_PANES = 3
175const MAX_CHARS = 20_000
176
177/** The distinct pane ids named by `[herdr-id:...]` markers in `text`, in order, at most MAX_PANES. */
178export function tokenIds(text: string): string[] {
179 const ids = [...text.matchAll(/\[herdr-id:([^\]\s]+)\]/g)].map(m => m[1]!)
180
181 return [...new Set(ids)].slice(0, MAX_PANES)
182}
183
184/** What the model reads beside the prompt for one marked pane. */
185export function paneContext(paneId: string, output: string): string {
186 const body = output.trim() === '' ? '(the pane is empty)' : output.trimEnd().slice(-MAX_CHARS)
187
188 return [
189 `Contents of herdr pane ${paneId}, read when the prompt was sent (\`herdr pane read ${paneId} --source recent-unwrapped --lines ${READ_LINES}\`); a snapshot, not live. The user's marker [herdr-id:${paneId}] refers to this pane. To act in a pane (send input, run commands) use the herdr skill.`,
190 '```',
191 body,
192 '```',
193 ].join('\n')
194}
195
196export function paneUnreadable(paneId: string, detail: string): string {
197 return `The user marked herdr pane ${paneId} but it could not be read (${detail}). Use the herdr skill to find out why if it matters.`
198}
199types/index.d.ts 26 lines1export type PeekPane = {
2 paneId: string
3 workspace: string
4 workspaceNo: number
5 tab: string
6 tabNo: number
7 /** Agent kind (`claude`) when herdr recognises one, else null: a plain shell. */
8 agent: string | null
9 status: string
10 title: string
11 cwd: string
12 isThis: boolean
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'pane-peek': {
18 panes: PeekPane[]
19 query: string
20 /** The row the focus ring last stood on; Enter in the search field picks it. */
21 selected: string | null
22 error: string | null
23 }
24 }
25}
26