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

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.

<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>
● running, ◐ starting, ✕ failing, ○ completed, with totals in the top bardescribe output and recent logs to Claude for a diagnosiskubectl get, logs, describe, version and config reads. It never writes to your cluster, and its context switcher passes --context instead of touching your kubeconfig.| Logs of the selected pod, following | The pane with its built-in help |
|---|---|
![]() | ![]() |
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
/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.
| Key | Action |
|---|---|
j k | Move the selection down / up (↑ ↓ and Tab work too) |
l | Logs of the selected pod |
d | Describe the selected pod |
a | Ask Claude about the selected pod |
b | Back to the list |
f | Toggle failing pods only |
n | Next namespace |
r | Refresh now |
h | Show 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.
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.
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 .
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.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.h), in Claude Code's local plugin store. Pod data lives only in the session's memory.hooks/register.tsx 680 lines1import { 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}
680types/index.d.ts 38 lines1export 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