SLOPSHOPPER

podside

A k9s-style Kubernetes pod pane that lives beside Claude Code

newpanebandcommandtoastprompt
v0.2.0Apache-2.0updated 2026-10-02ohosgor/podside
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · podside
│ ┃ podside ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ podside kubectl / all namespaces │ ┃ ● 0 ◐ 0 ✕ 0 ○ 0 loading… ⏺ Read(src/auth.ts) │ ┃ namespace: all ▾ search: name or status ⎿ Read 6 lines │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) │ ┃ ──────────── ⎿ Added 2 lines, removed 1 line │ ┃ name ready status ⏺ Bash(bun test) │ ┃ restarts age ⎿ 3 pass, 1 fail │ ┃ no pods │ ┃ ──────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ──────────── │ ┃ l: logs d: describe a: ask Claude f: fail ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ How to use podside › /podside │ ┃ ctrl+x Tab give the keyboard to the pane ⎿ podside: podside pane opened. │ ┃ (or click it) │ ┃ j k move the selection down or up (↑ │ ┃ ↓ and Tab work too) │ ┃ l d logs (following) or describe of │ ┃ the selected pod │ ┃ a ask Claude to diagnose the │ ┃ selected pod │ ┃ f n failing pods only · next │ ┃ namespace │ ┃ b r back to the list · refresh now │ ┃ search Tab to the search field and type ◆ / all ● 0 open pane ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ / all ● 0 open pane
Pane · podside
◆ podside kubectl / all namespaces ● 0 ◐ 0 ✕ 0 ○ 0 loading… namespace: all ▾ search: name or status ──────────────────────────────────────────────────────── name ready status restarts age no pods ──────────────────────────────────────────────────────── l: logs d: describe a: ask Claude f: failing only j: dow How to use podside ctrl+x Tab give the keyboard to the pane (or click it) j k move the selection down or up (↑ ↓ and Tab work too) l d logs (following) or describe of the selected pod a ask Claude to diagnose the selected pod f n failing pods only · next namespace b r back to the list · refresh now search Tab to the search field and type to filter by name or status ● running ◐ starting ✕ failing ○ completed While this pane is open, Claude sees the selected pod: just ask "why is this pod failing?"
README

podside

A Kubernetes pod pane that lives beside Claude Code.

podside is a Claude Code mod: it draws a live pod list next to your conversation, lets you read logs and describe output with one key, and tells Claude which pod you are looking at, so you can just ask "why is this pod failing?".

It is inspired by the keyboard-first flow of k9s, but it is a separate project with no affiliation to k9s.

podside open beside a Claude Code session: the pod list on the right with a crash-looping pod selected, and Claude's diagnosis of that pod on the left

<sub>A demo cluster. Pressing <code>a</code> on the crash-looping <code>checkout-api</code> pod sent its describe output and logs to Claude, which traced the crash to a database Service with no ready endpoints and suggested read-only commands to confirm it.</sub>

What it does

  • Live pod list, grouped by namespace, refreshed every 5 seconds while the pane is open
  • Health at a glance: ● running, ◐ starting, ✕ failing, ○ completed, with totals in the top bar
  • Logs (following, newest at the bottom) and describe output for the selected pod
  • Ask Claude: sends the pod's describe output and recent logs to Claude for a diagnosis
  • Selection as context: while the pane is open, every prompt you send carries the selected pod, so "this pod" means something
  • A one-line summary above the prompt (context, namespace, healthy and failing counts) once you have opened the pane
  • Read-only by design: podside only runs kubectl get, logs, describe, version and config reads. It never writes to your cluster, and its context switcher passes --context instead of touching your kubeconfig.

Screenshots

Logs of the selected pod, followingThe pane with its built-in help
The logs view of a crash-looping pod, ending in a fatal database errorThe pod list with the help section open below the action bar

Install

Requires Claude Code v2.1.287 or later and kubectl on your PATH.

/plugin marketplace add ohosgor/podside
/plugin install podside@podside

Or from your shell:

claude plugin marketplace add ohosgor/podside
claude plugin install podside@podside

Use

/podside              # all namespaces
/podside my-namespace # one namespace

The pane takes the keyboard when it opens. Esc hands it back to the prompt; click the pane or press ctrl+x then Tab to return.

KeyAction
j kMove the selection down / up (↑ ↓ and Tab work too)
lLogs of the selected pod
dDescribe the selected pod
aAsk Claude about the selected pod
bBack to the list
fToggle failing pods only
nNext namespace
rRefresh now
hShow or hide the built-in help

The context, namespace and search fields at the top are reachable with Tab. A short guide sits at the bottom of the pane on first use; h hides it and podside remembers that.

Develop

git clone https://github.com/ohosgor/podside
claude --plugin-dir ./podside   # hot-reloads on save
claude plugin validate ./podside
claude plugin test ./podside

The hooks module is hooks/register.tsx; its tests are in register.test.tsx and run against a stubbed kubectl.

Security

A mod runs inside Claude Code with your permissions. podside's own calls are limited to the kubectl reads listed above, $.prompt.submit for "Ask Claude", and drawing its pane. You can list everything it hooks and calls without running it:

claude plugin validate .

Data and privacy

  • What podside runs: only kubectl, with your own kubeconfig: config get-contexts, config current-context, config view --minify, version, get ns, get pods, logs and describe pod. It talks to your cluster through kubectl and makes no other network requests.
  • What reaches Claude: while the pane is open, each prompt you send carries the selected pod's namespace, name, status, readiness, restarts, age, node and IP. When you press a, the pod's describe output and its last 120 log lines are sent as a prompt. Nothing is sent while the pane is closed or no pod is selected.
  • What it stores: whether you hid the help (h), in Claude Code's local plugin store. Pod data lives only in the session's memory.
  • What it never does: write to your cluster, change your kubeconfig, read environment variables or credentials, or send data anywhere else.

License

Apache-2.0

Source 2 files
hooks/register.tsx 680 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { ClusterInfo, Pod, View } from '../types'
5
6// Read-only: podside only runs kubectl get/logs/describe and never writes to the cluster.
7
8const PANE = 'podside'
9const FAST_MS = 5_000
10const SLOW_MS = 30_000
11const ALL = '_all'
12
13// Mid-tone colors that read on both dark and light terminals; everything else is dim or plain.
14const P = {
15  accent: '#2DD4BF',
16  ok: '#22C55E',
17  warn: '#F59E0B',
18  bad: '#EF4444',
19}
20
21type Health = 'ok' | 'warn' | 'bad' | 'done'
22
23const GLYPH: Record<Health, { mark: string; color?: string; dim?: boolean }> = {
24  ok: { mark: '●', color: P.ok },
25  warn: { mark: '◐', color: P.warn },
26  bad: { mark: '✕', color: P.bad },
27  done: { mark: '○', dim: true },
28}
29
30const pods = atom({ plugin: 'podside', key: 'pods' } as const, [] as Pod[])
31const selected = atom({ plugin: 'podside', key: 'selected' } as const, null as string | null)
32const view = atom({ plugin: 'podside', key: 'view' } as const, 'list' as View)
33const detail = atom({ plugin: 'podside', key: 'detail' } as const, '')
34const ctx = atom({ plugin: 'podside', key: 'ctx' } as const, '')
35const contexts = atom({ plugin: 'podside', key: 'contexts' } as const, [] as string[])
36const ns = atom({ plugin: 'podside', key: 'ns' } as const, ALL)
37const namespaces = atom({ plugin: 'podside', key: 'namespaces' } as const, [] as string[])
38const error = atom({ plugin: 'podside', key: 'error' } as const, null as string | null)
39const syncedAt = atom({ plugin: 'podside', key: 'syncedAt' } as const, 0)
40const isActive = atom({ plugin: 'podside', key: 'isActive' } as const, false)
41const filter = atom({ plugin: 'podside', key: 'filter' } as const, '')
42const onlyFailing = atom({ plugin: 'podside', key: 'onlyFailing' } as const, false)
43const isHelpOpen = atom({ plugin: 'podside', key: 'isHelpOpen' } as const, true)
44const info = atom(
45  { plugin: 'podside', key: 'info' } as const,
46  { cluster: '', user: '', k8s: '' } as ClusterInfo,
47)
48
49let isPaneOpen = false
50let isBusy = false
51let lastPoll = 0
52let timer: Timer | undefined
53
54async function kubectl($: EngineInterface, args: string[], timeoutMs = 15_000) {
55  const context = await read($, ctx)
56  const argv = ['kubectl', ...(context ? ['--context', context] : []), ...args]
57  const { exitCode, stdout, stderr } = await $.process.run(argv, { timeoutMs })
58  if (exitCode !== 0) throw new Error(stderr.trim() || `kubectl exited with code ${exitCode}`)
59  return stdout
60}
61
62// Parses the `kubectl get pods -o wide` table: kubectl computes STATUS, and the output is far
63// smaller than -o json (which can exceed the 4 MiB process output cap on big clusters).
64// RESTARTS may contain spaces ("13 (34h ago)"), so the trailing columns are read from the end.
65function parsePods(out: string, hasNs: boolean, namespace: string): Pod[] {
66  const head = hasNs ? 4 : 3
67  return out
68    .split('\n')
69    .map(line => line.trim().split(/\s+/))
70    .filter(cols => cols.length >= head + 6)
71    .map(cols => {
72      const [podNs = '', name = '', ready = '', status = ''] = hasNs ? cols : [namespace, ...cols]
73      const [age = '', ip = '', node = ''] = cols.slice(-5)
74      const restarts = cols.slice(head, -5).join(' ')
75      const [up, total] = ready.split('/')
76      return {
77        ns: podNs,
78        name,
79        ready,
80        status,
81        restarts,
82        age,
83        ip: ip === '<none>' ? '-' : ip,
84        node: node === '<none>' ? '-' : node,
85        isHealthy: status === 'Completed' || (status === 'Running' && up === total),
86      }
87    })
88}
89
90function health(p: Pod): Health {
91  if (p.status === 'Completed') return 'done'
92  if (p.isHealthy) return 'ok'
93  const isStarting =
94    p.status === 'Pending' ||
95    p.status === 'ContainerCreating' ||
96    p.status === 'PodInitializing' ||
97    p.status === 'Running' ||
98    /^Init:\d+\/\d+$/.test(p.status)
99  return isStarting ? 'warn' : 'bad'
100}
101
102function ago(now: number, then: number) {
103  const s = Math.max(0, Math.floor((now - then) / 1000))
104  return s < 120 ? `${s}s` : `${Math.floor(s / 60)}m`
105}
106
107const fit = (text: string, width: number) =>
108  width <= 0 ? '' : text.length > width ? text.slice(0, Math.max(1, width - 1)) + '…' : text.padEnd(width)
109
110const tail = (text: string, max: number) => (text.length > max ? '…\n' + text.slice(-max) : text)
111
112const keyOf = (p: Pod) => `${p.ns}/${p.name}`
113
114function visiblePods(all: Pod[], query: string, isFailingOnly: boolean) {
115  return all.filter(
116    p =>
117      (!isFailingOnly || !p.isHealthy) &&
118      (!query || `${p.ns}/${p.name} ${p.status}`.toLowerCase().includes(query)),
119  )
120}
121
122// Columns after the status dot: each sized to its longest value, the name taking what is left
123function columns(width: number, list: Pod[]) {
124  const longest = (id: keyof Pod, title: string, cap: number) =>
125    Math.min(cap, Math.max(title.length, ...list.map(p => String(p[id]).length)) + 2)
126  const cols: { id: keyof Pod; title: string; width: number }[] = [
127    { id: 'name', title: 'name', width: 0 },
128    { id: 'ready', title: 'ready', width: longest('ready', 'ready', 8) },
129    { id: 'status', title: 'status', width: longest('status', 'status', 24) },
130    { id: 'restarts', title: 'restarts', width: longest('restarts', 'restarts', 15) },
131  ]
132  if (width >= 120) cols.push({ id: 'ip', title: 'ip', width: longest('ip', 'ip', 17) })
133  if (width >= 140) cols.push({ id: 'node', title: 'node', width: longest('node', 'node', 18) })
134  cols.push({ id: 'age', title: 'age', width: longest('age', 'age', 8) })
135  const fixed = cols.reduce((n, c) => n + c.width, 0)
136  cols[0]!.width = Math.max(16, width - 3 - fixed) // gutter, dot and its space
137  return cols
138}
139
140// Pod names end in the distinguishing hash, so cut them in the middle
141const fitName = (text: string, width: number) =>
142  text.length <= width
143    ? text.padEnd(width)
144    : text.slice(0, Math.ceil((width - 1) / 2)) + '…' + text.slice(text.length - Math.floor((width - 1) / 2))
145
146async function refreshContexts($: EngineInterface) {
147  try {
148    const { stdout } = await $.process.run(['kubectl', 'config', 'get-contexts', '-o', 'name'])
149    await update($, contexts, () => stdout.split('\n').map(s => s.trim()).filter(Boolean))
150    if (!(await read($, ctx))) {
151      const cur = await $.process.run(['kubectl', 'config', 'current-context'])
152      await update($, ctx, () => cur.stdout.trim())
153    }
154    const out = await kubectl($, ['get', 'ns', '-o', 'jsonpath={.items[*].metadata.name}'])
155    await update($, namespaces, () => out.split(/\s+/).filter(Boolean))
156    const who = await kubectl($, [
157      'config', 'view', '--minify', '-o',
158      'jsonpath={.contexts[0].context.cluster} {.contexts[0].context.user}',
159    ]).catch(() => '')
160    const [cluster = '-', user = '-'] = who.trim().split(' ')
161    const version = await kubectl($, ['version', '-o', 'json'], 5_000).catch(() => '{}')
162    let k8s = ''
163    try {
164      k8s = JSON.parse(version).serverVersion?.gitVersion ?? ''
165    } catch {}
166    await update($, info, () => ({ cluster, user, k8s }))
167  } catch (err) {
168    await update($, error, () => String((err as Error).message ?? err))
169  }
170}
171
172async function refreshPods($: EngineInterface) {
173  if (isBusy) return
174  isBusy = true
175  try {
176    const namespace = await read($, ns)
177    const scope = namespace === ALL ? ['-A'] : ['-n', namespace]
178    const out = await kubectl($, ['get', 'pods', ...scope, '-o', 'wide', '--no-headers'])
179    const list = parsePods(out, namespace === ALL, namespace)
180    await update($, pods, () => list)
181    await update($, error, () => null)
182    const stamp = await $.clock.now()
183    await update($, syncedAt, () => stamp)
184    // The log view follows: refresh it with the list
185    if ((await read($, view)) === 'logs') await loadDetail($, 'logs', false)
186  } catch (err) {
187    await update($, error, () => String((err as Error).message ?? err).split('\n')[0] ?? '')
188  } finally {
189    isBusy = false
190    lastPoll = await $.clock.now()
191  }
192}
193
194async function activate($: EngineInterface) {
195  await update($, isActive, () => true)
196  if (!timer) {
197    // Every 5 s while the pane is open, every 30 s otherwise (for the band).
198    timer = $.clock.every(FAST_MS, () => {
199      void (async () => {
200        const now = await $.clock.now()
201        if (isPaneOpen || now - lastPoll >= SLOW_MS) await refreshPods($)
202      })()
203    })
204  }
205  await refreshContexts($)
206  await refreshPods($)
207}
208
209async function openPane($: EngineInterface) {
210  isPaneOpen = true
211  await $.ui.open({ id: PANE, title: 'podside', focus: true })
212}
213
214async function setNamespace($: EngineInterface, value: string) {
215  await update($, ns, () => value)
216  await update($, selected, () => null)
217  await update($, view, () => 'list')
218  await refreshPods($)
219}
220
221async function nextNamespace($: EngineInterface) {
222  const order = [ALL, ...(await read($, namespaces))]
223  const at = order.indexOf(await read($, ns))
224  await setNamespace($, order[(at + 1) % order.length] ?? ALL)
225}
226
227async function loadDetail($: EngineInterface, kind: 'logs' | 'describe', isFirst = true) {
228  const key = await read($, selected)
229  if (!key) {
230    if (isFirst) $.ui.toast('Select a pod first')
231    return
232  }
233  const [podNs = '', podName = ''] = key.split('/')
234  if (isFirst) {
235    await update($, view, () => kind)
236    await update($, detail, () => 'Loading…')
237  }
238  try {
239    const out =
240      kind === 'logs'
241        ? await kubectl($, ['logs', '-n', podNs, podName, '--all-containers=true', '--tail=300'])
242        : await kubectl($, ['describe', 'pod', '-n', podNs, podName])
243    await update($, detail, () => out.trimEnd() || '(empty)')
244  } catch (err) {
245    await update($, detail, () => `Error: ${(err as Error).message}`)
246  }
247}
248
249async function moveSelection($: EngineInterface, delta: number) {
250  const list = visiblePods(
251    await read($, pods),
252    (await read($, filter)).toLowerCase(),
253    await read($, onlyFailing),
254  )
255  if (list.length === 0) return
256  const sel = await read($, selected)
257  const at = list.findIndex(p => keyOf(p) === sel)
258  const next = at < 0 ? 0 : Math.min(list.length - 1, Math.max(0, at + delta))
259  const key = keyOf(list[next]!)
260  await update($, selected, () => key)
261}
262
263async function toggleHelp($: EngineInterface) {
264  const isOpen = await update($, isHelpOpen, v => !v)
265  await $.store.set('helpHidden', !isOpen)
266}
267
268async function askClaude($: EngineInterface) {
269  const key = await read($, selected)
270  if (!key) {
271    $.ui.toast('Select a pod first')
272    return
273  }
274  const [podNs = '', podName = ''] = key.split('/')
275  const context = await read($, ctx)
276  $.ui.toast(`Collecting details for ${podName}…`)
277  const grab = (args: string[]) => kubectl($, args).catch(err => `Error: ${(err as Error).message}`)
278  const [describe, logs] = await Promise.all([
279    grab(['describe', 'pod', '-n', podNs, podName]),
280    grab(['logs', '-n', podNs, podName, '--all-containers=true', '--tail=120']),
281  ])
282  await $.prompt.submit({
283    text: [
284      `Investigate this Kubernetes pod: context \`${context}\`, namespace \`${podNs}\`, pod \`${podName}\`.`,
285      'Explain its state, the root cause of any problem, and a suggested fix. Do not run commands that write to the cluster; ask me first if one is needed.',
286      '',
287      '### kubectl describe',
288      '```',
289      tail(describe, 8000),
290      '```',
291      '',
292      '### Recent logs',
293      '```',
294      tail(logs, 6000),
295      '```',
296    ].join('\n'),
297  })
298  $.ui.toast('Sent to Claude')
299}
300
301export const register: Register = on => {
302  on('session.start', async ($, e, next) => {
303    await $.command.register({
304      name: 'podside',
305      description: 'Open the Kubernetes pod pane',
306      argumentHint: '[namespace|all]',
307    })
308    // Module variables reset on reload; ask the engine whether the pane is still open
309    isPaneOpen = (await $.ui.panes()).some(p => p.id === PANE)
310    if ((await $.store.get('helpHidden')) === true) await update($, isHelpOpen, () => false)
311    if (await read($, isActive)) void activate($)
312    return next(e)
313  })
314
315  on('command.run', { command: 'podside' }, async ($, e) => {
316    const arg = e.args.trim()
317    if (arg) await setNamespace($, arg === 'all' ? ALL : arg)
318    await openPane($)
319    void activate($)
320    return { text: 'podside pane opened.' }
321  })
322
323  // Moving the focus onto a row selects that pod, so the arrow keys pick pods directly
324  on('ui.focus', { requestId: PANE }, async ($, e, next) => {
325    const el = e.element
326    if (el?.startsWith('pod:')) {
327      const key = el.slice(4)
328      if ((await read($, selected)) !== key) await update($, selected, () => key)
329    }
330    return next(e)
331  })
332
333  // While the pane is open and a pod is selected, attach that pod to the user's prompt as context
334  on('prompt.submit', async ($, e, next) => {
335    const key = await read($, selected)
336    if (!isPaneOpen || !key || e.origin?.kind === 'plugin') return next(e)
337    const pod = (await read($, pods)).find(p => keyOf(p) === key)
338    const note = [
339      `Pod selected in the podside pane: context \`${await read($, ctx)}\`, namespace \`${pod?.ns ?? key.split('/')[0]}\`, pod \`${pod?.name ?? key.split('/')[1]}\`.`,
340      pod
341        ? `Status: ${pod.status}, READY ${pod.ready}, RESTARTS ${pod.restarts}, AGE ${pod.age}, NODE ${pod.node}, IP ${pod.ip}.`
342        : 'The pod is not in the latest listing (it may have been deleted).',
343      `Open view in the pane: ${await read($, view)}. When the user says "this pod", they mean this one.`,
344    ].join('\n')
345    return next({ ...e, context: [...(e.context ?? []), note] })
346  })
347
348  on('ui.close', { id: PANE }, async ($, e, next) => {
349    isPaneOpen = false
350    return next(e)
351  })
352
353  on('session.end', async ($, e, next) => {
354    // session.start does not fire again after /clear or /resume, so keep the timer
355    if (e.reason !== 'clear' && e.reason !== 'resume') {
356      timer?.cancel()
357      timer = undefined
358    }
359    return next(e)
360  })
361
362  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
363    const els = $.ui.resolve(e)
364    const { Box, Text, Button } = els
365    // The mobile surface has no Select or Input; skip those controls there
366    const Select = 'Select' in els ? els.Select : undefined
367    const Input = 'Input' in els ? els.Input : undefined
368
369    const width = Math.max(56, e.props.bodyColumns ?? 100)
370    // Docked beside the transcript the pane has a fixed height; inline it grows up to a limit
371    const paneRows =
372      e.props.placement === 'dock'
373        ? (e.props.scroll?.bodyRows ?? 40)
374        : Math.max(14, Math.floor((e.viewport?.rows ?? 40) * 0.6))
375    const all = await read($, pods)
376    const sel = await read($, selected)
377    const mode = await read($, view)
378    const context = await read($, ctx)
379    const namespace = await read($, ns)
380    const err = await read($, error)
381    const synced = await read($, syncedAt)
382    const query = (await read($, filter)).toLowerCase()
383    const isFailingOnly = await read($, onlyFailing)
384    const showHelp = await read($, isHelpOpen)
385    // Lines the bottom bar takes: rule, actions (two when narrow), error, hint or help
386    const footerLines = 2 + (width < 100 ? 1 : 0) + (err ? 1 : 0) + (showHelp ? 13 : 1)
387    const { k8s } = await read($, info)
388    const now = await $.clock.now()
389    const nsLabel = namespace === ALL ? 'all namespaces' : namespace
390
391    const counts: Record<Health, number> = { ok: 0, warn: 0, bad: 0, done: 0 }
392    for (const pod of all) counts[health(pod)] += 1
393
394    const rule = <Text dimColor>{'─'.repeat(width)}</Text>
395
396    // ── Top bar: wordmark, where you are, health totals ───────────────────
397    const isWide = width >= 110
398    const topBar = (
399      <Box flexDirection={isWide ? 'row' : 'column'} justifyContent="space-between">
400        <Text>
401          <Text color={P.accent} bold>◆ podside</Text>
402          <Text dimColor>{'  '}</Text>
403          <Text bold>{context || 'kubectl'}</Text>
404          <Text dimColor> / </Text>
405          <Text bold>{nsLabel}</Text>
406          {k8s ? <Text dimColor>{'  '}{k8s}</Text> : null}
407        </Text>
408        <Text>
409          {(['ok', 'warn', 'bad', 'done'] as const).map(state => (
410            <Text color={GLYPH[state].color} dimColor={GLYPH[state].dim}>
411              {GLYPH[state].mark} {counts[state]}{'  '}
412            </Text>
413          ))}
414          <Text dimColor>
415            {err ? 'sync failed' : synced ? `synced ${ago(now, synced)} ago` : 'loading…'}
416          </Text>
417        </Text>
418      </Box>
419    )
420
421    // ── Help: a one-line hint always, the full guide until the user hides it ──
422    const hint = (
423      <Text dimColor>
424        {e.props.isFocused
425          ? 'j k select a pod · Esc back to the prompt · h help'
426          : 'Click the pane or press ctrl+x then Tab to use it · h help'}
427      </Text>
428    )
429    const helpRow = (keys: string, text: string) => (
430      <Text>
431        <Text color={P.accent}>{fit(keys, 12)}</Text>
432        <Text dimColor>{text}</Text>
433      </Text>
434    )
435    const help = (
436      <Box flexDirection="column" marginTop={1}>
437        <Text bold>How to use podside</Text>
438        {e.props.isFocused
439          ? helpRow('Esc', 'hand the keyboard back to the prompt; the pane stays open')
440          : helpRow('ctrl+x Tab', 'give the keyboard to the pane (or click it)')}
441        {helpRow('j  k', 'move the selection down or up (↑ ↓ and Tab work too)')}
442        {helpRow('l  d', 'logs (following) or describe of the selected pod')}
443        {helpRow('a', 'ask Claude to diagnose the selected pod')}
444        {helpRow('f  n', 'failing pods only · next namespace')}
445        {helpRow('b  r', 'back to the list · refresh now')}
446        {helpRow('search', 'Tab to the search field and type to filter by name or status')}
447        <Text>
448          <Text color={GLYPH.ok.color}>● running  </Text>
449          <Text color={GLYPH.warn.color}>◐ starting  </Text>
450          <Text color={GLYPH.bad.color}>✕ failing  </Text>
451          <Text dimColor>○ completed</Text>
452        </Text>
453        <Text dimColor>
454          While this pane is open, Claude sees the selected pod: just ask "why is this pod failing?"
455        </Text>
456      </Box>
457    )
458
459    // ── Bottom bar: every action, one key each ─────────────────────────────
460    const action = (hotkey: string, label: string, onPress: () => void) => (
461      <Button key={`do:${hotkey}`} plain hotkey={hotkey} label={label} onPress={onPress} />
462    )
463    const bottomBar = (
464      <Box flexDirection="column">
465        {rule}
466        <Box columnGap={2} flexWrap="wrap">
467          {mode !== 'list' && action('b', 'back', () => void update($, view, () => 'list'))}
468          {action('l', 'logs', () => void loadDetail($, 'logs'))}
469          {action('d', 'describe', () => void loadDetail($, 'describe'))}
470          {action('a', 'ask Claude', () => void askClaude($))}
471          {mode === 'list' &&
472            action('f', isFailingOnly ? 'show all' : 'failing only', () =>
473              void update($, onlyFailing, v => !v),
474            )}
475          {mode === 'list' && action('j', 'down', () => void moveSelection($, 1))}
476          {mode === 'list' && action('k', 'up', () => void moveSelection($, -1))}
477          {mode === 'list' && action('n', 'next namespace', () => void nextNamespace($))}
478          {action('r', 'refresh', () => void refreshPods($))}
479          {action('h', showHelp ? 'hide help' : 'help', () => void toggleHelp($))}
480        </Box>
481        {err && <Text color={P.bad}>{fit(err, width)}</Text>}
482        {showHelp ? help : hint}
483      </Box>
484    )
485
486    // ── Logs / describe ───────────────────────────────────────────────────
487    if (mode !== 'list') {
488      const raw = (await read($, detail)).split('\n')
489      // Logs stay pinned to the newest lines; describe reads from the top
490      const room = Math.max(5, paneRows - 3 - footerLines)
491      const lines = mode === 'logs' ? raw.slice(-room) : raw.slice(0, 400)
492      return (
493        <Box flexDirection="column">
494          {topBar}
495          <Text>
496            <Text color={P.accent}>▌</Text>
497            <Text bold> {sel ?? ''}</Text>
498            <Text dimColor>
499              {'  '}
500              {mode === 'logs' ? 'logs · following' : 'describe'}
501            </Text>
502          </Text>
503          {rule}
504          {lines.map(line => {
505            const m = mode === 'describe' ? /^(\s*)([^:]{1,40}:)(.*)$/.exec(line) : null
506            return m ? (
507              <Text>
508                <Text dimColor>{m[1]}{m[2]}</Text>
509                <Text>{fit(m[3] ?? '', width - (m[1]?.length ?? 0) - (m[2]?.length ?? 0))}</Text>
510              </Text>
511            ) : (
512              <Text>{fit(line, width)}</Text>
513            )
514          })}
515          {bottomBar}
516        </Box>
517      )
518    }
519
520    // ── Pod list, grouped by namespace ─────────────────────────────────────
521    const controls = (
522      <Box gap={2}>
523        {Select && (await read($, contexts)).length > 1 && (
524          <Select
525            key="ctx"
526            label="context"
527            value={context}
528            options={(await read($, contexts)).map(c => ({ value: c }))}
529            onSelect={(v: string) => {
530              void (async () => {
531                await update($, ctx, () => v)
532                await update($, filter, () => '')
533                await setNamespace($, ALL)
534                await refreshContexts($)
535              })()
536            }}
537          />
538        )}
539        {Select && (
540          <Select
541            key="ns"
542            label="namespace"
543            value={namespace}
544            options={[
545              { value: ALL, label: 'all' },
546              ...(await read($, namespaces)).map(n => ({ value: n })),
547            ]}
548            onSelect={(v: string) => void setNamespace($, v)}
549          />
550        )}
551        {Input && (
552          <Input
553            key="filter"
554            label="search"
555            placeholder="name or status"
556            value={query}
557            onInput={(v: string) => void update($, filter, () => v)}
558            onSubmit={(v: string) => void update($, filter, () => v)}
559          />
560        )}
561      </Box>
562    )
563
564    const list = visiblePods(all, query, isFailingOnly)
565    const cols = columns(width, list)
566    const [nameCol, ...restCols] = cols
567    const cells = (p: Pod) => restCols.map(c => fit(String(p[c.id]), c.width)).join('')
568
569    // Draw only the lines that fit, as a window around the selected pod, so the
570    // action bar and help below the list always stay on screen.
571    type Line = { kind: 'ns'; ns: string } | { kind: 'pod'; pod: Pod }
572    const lines: Line[] = []
573    for (const pod of list) {
574      const prev = lines[lines.length - 1]
575      const prevNs = prev ? (prev.kind === 'ns' ? prev.ns : prev.pod.ns) : ''
576      if (namespace === ALL && pod.ns !== prevNs) lines.push({ kind: 'ns', ns: pod.ns })
577      lines.push({ kind: 'pod', pod })
578    }
579    const headerLines = isWide ? 4 : 5
580    const room = Math.max(4, paneRows - headerLines - footerLines - 1)
581    const at = Math.max(0, lines.findIndex(l => l.kind === 'pod' && keyOf(l.pod) === sel))
582    const from = Math.min(Math.max(0, at - Math.floor(room / 2)), Math.max(0, lines.length - room))
583    const visible = lines.slice(from, from + room)
584    const first = visible[0]
585    if (namespace === ALL && first?.kind === 'pod') {
586      // Keep the namespace of the first row in view
587      visible.unshift({ kind: 'ns', ns: first.pod.ns })
588      visible.pop()
589    }
590    const above = from
591    const below = Math.max(0, lines.length - from - room)
592
593    const groupInfo = (group: string) => {
594      const inGroup = list.filter(q => q.ns === group)
595      return { total: inGroup.length, failing: inGroup.filter(q => !q.isHealthy).length }
596    }
597
598    const rows = visible.map(line => {
599      if (line.kind === 'ns') {
600        const { total, failing } = groupInfo(line.ns)
601        return (
602          <Box key={`ns:${line.ns}`}>
603            <Text bold>{line.ns}</Text>
604            <Text dimColor>
605              {'  '}
606              {total} pods
607            </Text>
608            {failing > 0 && <Text color={P.bad}>{'  '}{failing} failing</Text>}
609          </Box>
610        )
611      }
612      const p = line.pod
613      const key = keyOf(p)
614      const isSel = sel === key
615      const g = GLYPH[health(p)]
616      return (
617        <Box key={`row:${key}`}>
618          <Text color={P.accent}>{isSel ? '▌' : ' '}</Text>
619          <Text color={g.color} dimColor={g.dim}>{g.mark} </Text>
620          <Button
621            key={`pod:${key}`}
622            plain
623            label={fitName(p.name, nameCol!.width - 1) + ' '}
624            dimColor={health(p) === 'done'}
625            onPress={() => void update($, selected, () => key)}
626          />
627          <Text bold={isSel} dimColor={!isSel}>{cells(p)}</Text>
628        </Box>
629      )
630    })
631
632    return (
633      <Box flexDirection="column">
634        {topBar}
635        {controls}
636        {rule}
637        <Text dimColor>
638          {'   '}
639          {cols.map(c => fit(c.title, c.width)).join('')}
640        </Text>
641        {rows}
642        {list.length === 0 ? (
643          <Text dimColor>
644            {'   '}
645            {err ? 'kubectl failed, see below' : isFailingOnly ? 'nothing failing' : 'no pods'}
646          </Text>
647        ) : (
648          <Text dimColor>
649            {'   '}
650            {above > 0 ? `↑ ${above} above  ` : ''}
651            {below > 0 ? `↓ ${below} below  ` : ''}
652            {above + below > 0 ? 'j / k to move' : `${list.length} shown`}
653          </Text>
654        )}
655        {bottomBar}
656      </Box>
657    )
658  })
659
660  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
661    if (e.props.hasSurvey || !(await read($, isActive)) || (await read($, error))) return next(e)
662    const list = await read($, pods)
663    const bad = list.filter(p => health(p) === 'bad').length
664    const ok = list.filter(p => health(p) === 'ok').length
665    const namespace = await read($, ns)
666    const { Box, Text, Button } = $.ui.resolve(e)
667    return (
668      <Box gap={1}>
669        <Text color={P.accent}>◆</Text>
670        <Text dimColor>
671          {await read($, ctx)} / {namespace === ALL ? 'all' : namespace}
672        </Text>
673        <Text color={P.ok}>● {ok}</Text>
674        {bad > 0 && <Text color={P.bad}>✕ {bad}</Text>}
675        <Button key="open" label="open pane" plain dimColor onPress={() => void openPane($)} />
676      </Box>
677    )
678  })
679}
680
types/index.d.ts 38 lines
1export type Pod = {
2  ns: string
3  name: string
4  ready: string
5  status: string
6  restarts: string
7  age: string
8  ip: string
9  node: string
10  isHealthy: boolean
11}
12
13export type View = 'list' | 'logs' | 'describe'
14
15export type ClusterInfo = { cluster: string; user: string; k8s: string }
16
17declare module 'claude-code' {
18  interface PluginState {
19    podside: {
20      pods: Pod[]
21      selected: string | null
22      view: View
23      detail: string
24      ctx: string
25      contexts: string[]
26      ns: string
27      namespaces: string[]
28      error: string | null
29      syncedAt: number
30      isActive: boolean
31      filter: string
32      onlyFailing: boolean
33      isHelpOpen: boolean
34      info: ClusterInfo
35    }
36  }
37}
38