A status line and cockpit pane for Claude Code: context, 5h and weekly bars, PR / ticket / issue rows with titles, worktrees, ports that serve a page…

A status line for Claude Code that tells you where you stand, so you don't have to ask: context left, the 5-hour and weekly limits, the branch and its pull request with CI and review state, the tickets and issues mentioned in the chat, and the local dev servers that actually serve a page. Drawn as neon tubes on an ink-wash scroll, with a cockpit that opens as a pane inside Claude Code (/neon-sumi-cockpit) or in any terminal split, and an optional Ghostty shader that makes the neon glow.
Free and open source under the MIT license. Python standard library only, plus one TypeScript module that Claude Code itself runs for the cockpit pane. Works in Claude Code (the CLI) only.
See it running: https://danneftw1.github.io/neon-sumi/
/plugin marketplace add Danneftw1/neon-sumi
/plugin install neon-sumi@neon-sumi
/neon-sumi:install
Needs Python 3.9 or newer as python3 on your PATH, a Nerd Font in the terminal and git. An authenticated gh is optional and enables the GitHub rows and the cockpit's work and inbox blocks.
A plugin cannot change statusLine in settings.json, so /neon-sumi:install does it for you, in the open:
~/.claude/neon-sumi/ and renders once so you can see the output.statusLine and subagentStatusLine entries it wants to add to ~/.claude/settings.json and writes them only after you confirm. It saves a backup first, settings.json.bak-neon-sumi, and touches no other key.To undo it, restore that backup (or remove the two keys) and delete ~/.claude/neon-sumi/ and ~/.cache/neon-sumi/.
The cockpit pane needs no install step: /neon-sumi-cockpit is there as soon as the plugin is enabled. It runs cockpit.py from the plugin's own folder every two seconds with the first python3 on your PATH and draws the result in a pane (beside the transcript in the fullscreen layout from 110 columns, above the prompt otherwise). It takes the 5-hour and weekly figures from the session, reads ~/.claude/neon-sumi/config.json when you have one, and writes nothing to your settings.
The plugin also adds a Neon Sumi colour theme for Claude Code itself. Nothing changes until you pick it in /theme.
#123 references mentioned in the conversation. Only those issue and PR numbers are looked up, on GitHub, as below; nothing else from either leaves your machine.gh. Read-only queries for your pull requests, their checks and reviews, the issues and project-board tickets mentioned in the chat, and (in the cockpit) your notifications. These run in a background process, never on the render path, and use whatever account gh is logged in as.lsof or ss, and docker ps if Docker is installed, to list listening ports; then a plain HTTP request to 127.0.0.1 to see which of them serve a page.~/.claude/projects/ to count which skills fired this week, and the plugin folders to list the ones that stayed silent. It keeps skill names and counts, the first line of each hook refusal in that week and the paths of the transcripts it has read, in ~/.cache/neon-sumi/; none of it leaves your machine, and the pane and the terminal cockpit share that cache.No telemetry, no analytics, no other network access. Nothing is sent to the author of Neon Sumi or to any server they run.
Source, full documentation and issues: https://github.com/Danneftw1/neon-sumi
hooks/cockpit.tsx 181 lines1/*
2 * The Neon Sumi cockpit as a pane inside Claude Code.
3 *
4 * `/neon-sumi-cockpit` opens a pane beside the transcript; every two seconds
5 * it runs `statusline/cockpit.py --once` at the pane's width and draws the
6 * rows it printed, colour for colour. The same script that draws the cockpit
7 * in a tmux or iTerm2 split draws it here, so there is one cockpit, not two.
8 *
9 * The pane is opened only on the person's command, never at session start: an
10 * unasked pane waits undrawn below 144 columns, and most terminals are narrower.
11 */
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { Frame, Span } from '../types'
16
17const PANE = 'neon-sumi-cockpit'
18const COMMAND = 'neon-sumi-cockpit'
19const REFRESH_MS = 2000
20const DEFAULT_COLUMNS = 56
21const frame = atom({ plugin: 'neon-sumi', key: 'frame' } as const, { rows: [], at: 0, columns: 0 } as Frame)
22
23// What the render hook last saw: read by the timer, never by the drawing.
24// A hot reload starts these over; the pane itself is the engine's and stays.
25let wantedColumns = DEFAULT_COLUMNS
26let isRunning = false
27
28const RE_TOKEN = /\x1b\[([0-9;:]*)m|\x1b\]8;;(.*?)\x1b\\/g
29
30function hex(r: number, g: number, b: number): string {
31 return '#' + [r, g, b].map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('')
32}
33
34/** The spelling a Link takes: https anywhere, http on localhost only, as `new URL(href).href`; else none. */
35function linkable(href: string | undefined): string | undefined {
36 if (!href) return undefined
37 try {
38 const u = new URL(href)
39 if (u.username || u.password) return undefined
40 if (u.protocol === 'https:' || (u.protocol === 'http:' && u.hostname === 'localhost')) return u.href
41 } catch {
42 return undefined
43 }
44 return undefined
45}
46
47/** cockpit.py's ANSI: truecolour foreground, bold, underline, OSC 8 links. */
48function spansFromAnsi(text: string): Span[][] {
49 const rows: Span[][] = []
50 for (const raw of text.replace(/\n+$/, '').split('\n')) {
51 const spans: Span[] = []
52 let colour: string | undefined
53 let bold = false
54 let underline = false
55 let href: string | undefined
56 let pos = 0
57 const emit = (chunk: string) => {
58 if (!chunk) return
59 const span: Span = { t: chunk }
60 if (colour) span.c = colour
61 if (bold) span.b = true
62 const link = linkable(href)
63 // A target the pane cannot open (a folder, a SKILL.md) draws as plain text: no dead underline.
64 if (underline && (!href || link)) span.u = true
65 if (link) span.href = link
66 spans.push(span)
67 }
68 RE_TOKEN.lastIndex = 0
69 let m: RegExpExecArray | null
70 while ((m = RE_TOKEN.exec(raw)) !== null) {
71 emit(raw.slice(pos, m.index))
72 pos = m.index + m[0].length
73 if (m[0].startsWith('\x1b]')) {
74 href = m[2] || undefined
75 continue
76 }
77 const ps = (m[1] || '0').split(/[;:]/)
78 for (let i = 0; i < ps.length; i++) {
79 const p = ps[i] || '0'
80 if (p === '0') {
81 colour = undefined
82 bold = false
83 underline = false
84 } else if (p === '1') bold = true
85 else if (p === '4') underline = true
86 else if (p === '24') underline = false
87 else if ((p === '38' || p === '58') && ps[i + 1] === '2' && i + 4 < ps.length) {
88 if (p === '38') colour = hex(Number(ps[i + 2]), Number(ps[i + 3]), Number(ps[i + 4]))
89 i += 4
90 }
91 }
92 }
93 emit(raw.slice(pos))
94 rows.push(spans)
95 }
96 return rows
97}
98
99export const register: Register = on => {
100 on('session.start', async ($, e, next) => {
101 await $.command.register({
102 name: COMMAND,
103 description: 'Open the Neon Sumi cockpit in a pane: skills, PRs, inbox, ports, boards',
104 })
105 $.clock.every(REFRESH_MS, () => {
106 void refreshIfOpen($)
107 })
108 return next(e)
109 })
110
111 on('command.run', { command: COMMAND }, async $ => {
112 const opened = await $.ui.open({ id: PANE, title: 'neon sumi', columns: DEFAULT_COLUMNS })
113 void refresh($)
114 return { text: opened.isPlaced ? 'Cockpit pane opened.' : `Cockpit pane waits: ${opened.reason}` }
115 })
116
117 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
118 const { Box, Text, Link } = $.ui.resolve(e)
119 wantedColumns = Math.max(40, e.props.bodyColumns || DEFAULT_COLUMNS)
120 const f = await read($, frame)
121 if (f.error) {
122 return (
123 <Box flexDirection="column">
124 <Text color="#ff3b5c">cockpit.py failed</Text>
125 <Text dimColor wrap="wrap">{f.error}</Text>
126 </Box>
127 )
128 }
129 if (!f.at) return <Text dimColor>drawing the cockpit…</Text>
130 return (
131 <Box flexDirection="column">
132 {f.rows.map(row => (
133 <Text wrap="truncate-end">
134 {row.length === 0 ? ' ' : row.map(span => (
135 span.href
136 ? <Link href={span.href}><Text color={span.c} bold={span.b} underline={span.u}>{span.t}</Text></Link>
137 : <Text color={span.c} bold={span.b} underline={span.u}>{span.t}</Text>
138 ))}
139 </Text>
140 ))}
141 </Box>
142 )
143 })
144}
145
146/** The timer's tick: redraw only while our pane is open (asked of the engine, so a reload cannot forget it). */
147async function refreshIfOpen($: EngineInterface) {
148 const panes = await $.ui.panes()
149 if (panes.some(p => p.id === PANE)) await refresh($)
150}
151
152async function refresh($: EngineInterface) {
153 if (isRunning) return
154 isRunning = true
155 const columns = wantedColumns
156 try {
157 const script = `${$.plugin.root}/statusline/cockpit.py`
158 // The session's own 5h and weekly figures, so the bars need no status line install.
159 const usage = await $.session.usage()
160 const limits: Record<string, { used_percentage: number; resets_at?: number }> = {}
161 for (const w of usage.rateLimits) {
162 const resets = w.resetsAt ? Date.parse(w.resetsAt) / 1000 : undefined
163 limits[w.kind] = { used_percentage: w.percentUsed, ...(resets ? { resets_at: resets } : {}) }
164 }
165 const env: Record<string, string> = { COLUMNS: String(columns) }
166 // No age: the session says what the last response reported, not when; the pane draws none rather than a false one.
167 if (Object.keys(limits).length) env.NEON_SUMI_RATE_LIMITS = JSON.stringify({ rate_limits: limits })
168 const ran = await $.process.run(['python3', '-B', script, '--once'], { env, timeoutMs: 15000 })
169 if (ran.exitCode !== 0) {
170 await update($, frame, f => ({ ...f, at: Date.now(), columns, error: ran.stderr.trim().split('\n').slice(-3).join('\n') || `exit ${ran.exitCode}` }))
171 return
172 }
173 const rows = spansFromAnsi(ran.stdout)
174 await update($, frame, () => ({ rows, at: Date.now(), columns }))
175 } catch (err) {
176 await update($, frame, f => ({ ...f, at: Date.now(), columns, error: String(err) }))
177 } finally {
178 isRunning = false
179 }
180}
181types/index.d.ts 23 lines1/** One styled run of a cockpit row, as cockpit.py printed it. */
2export type Span = {
3 t: string
4 c?: string
5 b?: boolean
6 u?: boolean
7 href?: string
8}
9
10/** One frame of the cockpit: its rows, when it was drawn, the width it was drawn for. */
11export type Frame = {
12 rows: Span[][]
13 at: number
14 columns: number
15 error?: string
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'neon-sumi': { frame: Frame }
21 }
22}
23