SLOPSHOPPER

Neon Sumi

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…

newpanecommandprocesstimer
★ 2v1.1.0MITupdated 2026-10-07Danneftw1/neon-sumi/plugins/neon-sumi
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · neon-sumi
│ ┃ neon sumi ✕ › fix the failing auth test and add an audit log call │ ┃ dev │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ 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 │ │ › /neon-sumi-cockpit │ ⎿ neon-sumi: Cockpit pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · neon sumi
dev
README

Neon Sumi

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/

Install

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

What the install skill changes

A plugin cannot change statusLine in settings.json, so /neon-sumi:install does it for you, in the open:

  • It copies the scripts to ~/.claude/neon-sumi/ and renders once so you can see the output.
  • It shows you the 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.

Privacy: what it reads and where it connects

  • Your session, locally. The status line payload from Claude Code, and the session transcript to find the links, files and #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.
  • GitHub, through your own 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.
  • Your own machine's ports. 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.
  • Skills, locally. The cockpit reads your session transcripts under ~/.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

Source 2 files
hooks/cockpit.tsx 181 lines
1/*
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}
181
types/index.d.ts 23 lines
1/** 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