SLOPSHOPPER

pane-peek

/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

newpanecommandtoastpromptprocess
v0.4.0MITupdated 2026-10-08arthurseredaa/herdr-pane-peek
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pane-peek
│ ┃ herdr panes ✕ › fix the failing auth test and add an audit log call │ ┃ search… ⏎ insert 0/0 │ ┃ herdr api snapshot: JSON Parse error: ⏺ Read(src/auth.ts) │ ┃ Unexpected EOF ⎿ Read 6 lines │ ┃ ↑↓ choose · ⏎ insert · esc close ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /pane-peek │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · herdr panes
search… ⏎ insert 0/0 herdr api snapshot: JSON Parse error: Unexpected EOF ↑↓ choose · ⏎ insert · esc close
README

herdr-pane-peek

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

Install

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).

Use

KeyDoes
/pane-peekopens the picker; the search field has the keyboard
typefilters 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 fieldinserts the highlighted pane (the first match until you move)
↑ ↓ / Tab, then Enterpick another row and insert it
Esccloses 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.

Panes are read for you

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.

Let Claude act in panes (recommended)

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
  • The installer asks which agents to install to and does not preselect Claude Code, so pass -a claude-code (add -y to skip the confirmations). Without -g it installs into the current project only.
  • Re-run the same command to update. If you would rather match the skill to your installed binary, herdr --skill prints the copy that ships with it (the copy on GitHub can be newer).
  • Details: herdr agent skill docs.

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.

Requirements

  • Claude Code started inside herdr (the herdr CLI on PATH; HERDR_PANE_ID is how the mod marks your own pane).
  • Claude Code 2.1.287 or later (mods are on by default). The mods API can change between releases.

Hacking on it

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.

License

MIT

Source 3 files
hooks/register.tsx 192 lines
1import { 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}
192
hooks/peek.ts 199 lines
1import 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}
199
types/index.d.ts 26 lines
1export 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