SLOPSHOPPER

night-city

Night City: a toned-down synthwave Claude Code theme with a matching terminal background, Night City spinner words, short status-line reactions and Onibi…

newbandspinnerguardstatusprocess
v0.3.0no licenseupdated 2026-10-15deadmade/dead-claude-skills/plugins/night-city
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · night-city
› fix the failing auth test and add an audit log call ⏺ 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. ✻ Flatlined the bug for 42s · done 4:20 PM ✻ Spoofing the Blackwall… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ night-city: ◢◤ JACKED OUT ◢◤
README

night-city

<img src="assets/mascot-512.png" width="192" align="right" alt="Onibi: a hooded netrunner in a respirator, her chrome cybereye a glowing eight-point spark">

A toned-down synthwave Claude Code theme, "Night City": a dusk-indigo background, lavender text, dusty-pink Claude, peach inline code and permission prompts, soft teal suggestions and bash, muted violet instead of grey for dim text. Opt-in; pick it in /theme (Claude Code does not let a plugin select a custom theme).

Beyond the theme:

  • While Night City is the selected theme, the terminal's own background turns the theme's dusk indigo, so the empty space matches (OSC 11, reset when Claude exits or you pick another theme). Linux and macOS; Windows Terminal untested. If Claude is killed, the color stays until you run printf '\033]111\007'.
  • Night City spinner words and turn-end verbs ("Jacked out for 3s").
  • Short status-line reactions: ▚▞ FLATLINE ▞▚ once per turn on a failing tool, ◢◤ JACKED OUT ◢◤ when a main turn ends.
  • Onibi drops by: after about one main turn in five (never more than once every three minutes) she pops up above the prompt for six seconds with a one-liner ("good work, choom.", "what are you even doing?"), a sorrier one after a failing tool, an impatient one after a turn over two minutes. The Onibi setting in /config makes her come always (every turn) or turns her off.
  • A Night City ccstatusline config: Powerline segments with slanted edges in the theme's colours, icons, context as tokens and percent, the cost in eddies (€$1.23) and a ナイトシティ sign fading pink to teal. It needs a Nerd Font in the terminal. The installer writes it when night-city and the ccstatusline toggle are both on, and switches back to the stock config when night-city is deselected.
  • The installer, when night-city is selected, turns syntax highlighting off (syntaxHighlightingDisabled) and sets CLAUDE_CODE_TMUX_TRUECOLOR=1: inside tmux Claude Code otherwise draws in 256 colours and the theme's indigos turn grey. When night-city is deselected it undoes both and removes the theme.

Syntax highlighting: Claude Code colours code and diffs with a fixed Monokai palette for every dark theme, which a theme can't change, and it ignores the theme's diff colours while highlighting is on (anthropics/claude-code#57036). With it off (ctrl+t in /theme), diffs use Night City's mint and coral and code takes the theme's text colour.

/plugin install night-city@dead-claude-skills

The palette starts from the Cyberpunk Neon theme of ccthemes (MIT) and is retuned to softer synthwave colours; see THIRD_PARTY.md in the repository root.

Mascot

Onibi (鬼火, "demon fire": the ghost lights of Japanese folklore that drift through the dark and lead people to their end). The netrunner above is the theme's mascot, an original 64×64 pixel-art character in the theme's palette: a respirator with an LED bar, an undercut with a circuit tattoo, and a chrome plate whose cybereye is an eight-point spark, a nod to Claude rather than Anthropic's logo. assets/mascot.png is the 1x original, assets/mascot-512.png the 8x nearest-neighbour scale; she is also the notify plugin's notification icon. All are drawn by scripts/draw-mascot.mjs in the repository root; edit the drawing there and rerun it rather than editing the PNGs.

Contrast (WCAG)

Checked with relative luminance; all are at least 4.5:1. background is an undocumented token that the theme sets anyway.

Foregroundon backgroundon user baron bash bar
text #E6DCF713.4411.2911.15
permission #F5B97A10.208.578.46
error #F2788C6.615.555.48
inactive #9A90C26.025.065.00

text on the diff backgrounds: added 9.80, removed 10.56, added word 5.94, removed word 7.03, added dimmed 11.25, removed dimmed 12.22.

Source 5 files
hooks/register.tsx 132 lines
1// Flavor: Night City spinner and turn-line words, status-line reactions, Onibi dropping by, the terminal background.
2import { atom, read, update } from 'claude-code';
3import type { EngineInterface as $, Register, Timer } from 'claude-code';
4
5import { RESET, SET, isNightCity, paint } from './background';
6import { NEON, SLOW_MS, SPRITE, VISIT_MS, cadence, lineFor, shouldVisit } from './onibi';
7import type { Cadence } from './onibi';
8import { SPINNER_WORDS, TURN_WORDS, pick } from './words';
9
10export const GLITCH_MS = 400;
11export const FLARE_MS = 600;
12// Reactions go in the plugin's own status line, which sits beside the user's statusLine command, not over it. Plain
13// text: the status line takes no colors without escape sequences, which a mod doesn't write.
14const GLITCH = '▚▞ FLATLINE ▞▚';
15const FLARE = '◢◤ JACKED OUT ◢◤';
16
17let glitched = false; // one glitch per main turn
18let reaction: Timer | undefined;
19
20// What Onibi says in the band above the prompt while she visits, else null.
21const onibi = atom({ plugin: 'night-city', key: 'onibi' } as const, null);
22let visits: Cadence = 'sometimes';
23let visitedAt = -Infinity;
24let leave: Timer | undefined;
25
26function react($: $, text: string, ms: number) {
27  reaction?.cancel();
28  $.ui.status(text);
29  const mine = $.clock.after(ms, () => {
30    if (reaction === mine) $.ui.status(undefined);
31  });
32  reaction = mine;
33}
34
35// The terminal background follows the theme: Night City selected paints it, anything else resets it.
36let isTerminal = false;
37let isPainted = false;
38
39async function background($: $, on: boolean) {
40  if (on !== isPainted && (await paint($, on ? SET : RESET))) isPainted = on;
41}
42
43export const register: Register = (on, options) => {
44  visits = cadence(options.onibi);
45  glitched = false;
46  reaction = undefined;
47  visitedAt = -Infinity;
48  leave = undefined;
49  isTerminal = false;
50  isPainted = false;
51
52  on('turn.start', (_$, e, next) => {
53    glitched = false;
54    return next(e);
55  });
56  on('tool.call', async ($, e, next) => {
57    const ran = await next(e);
58    if (ran.isError === true && !glitched && e.agentId === undefined) {
59      glitched = true;
60      react($, GLITCH, GLITCH_MS);
61    }
62    return ran;
63  }).catch((_$, e, next) => next(e));
64  on('turn.complete', async ($, e, next) => {
65    if (e.agentId === undefined) {
66      react($, FLARE, FLARE_MS);
67      const now = await $.clock.now();
68      if (shouldVisit(visits, now, visitedAt, e.turnId)) {
69        visitedAt = now;
70        const mood = glitched ? 'flatlined' : e.durationMs >= SLOW_MS ? 'slow' : 'idle';
71        await update($, onibi, () => lineFor(mood, e.turnId));
72        leave?.cancel();
73        leave = $.clock.after(VISIT_MS, () => void update($, onibi, () => null));
74      }
75    }
76    return next(e);
77  });
78
79  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
80    const line = await read($, onibi);
81    if (line === null || e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e);
82    const { Box, Text } = $.ui.resolve(e);
83    return (
84      <Box flexDirection="row">
85        <Box flexDirection="column">
86          {SPRITE.map((row) => (
87            <Text>
88              {row.map(([glyph, color]) => (
89                <Text color={color}>{glyph}</Text>
90              ))}
91            </Text>
92          ))}
93        </Box>
94        <Box borderStyle="round" borderColor={NEON} paddingX={1}>
95          <Text color={NEON}>{line}</Text>
96        </Box>
97      </Box>
98    );
99  });
100
101  on('ui.render', { component: 'Spinner' }, (_$, e, next) =>
102    next({ ...e, props: { ...e.props, word: pick(SPINNER_WORDS, e.props.word) } }),
103  );
104  on('ui.render', { component: 'TurnDuration' }, (_$, e, next) =>
105    next({ ...e, props: { ...e.props, word: pick(TURN_WORDS, e.props.word) } }),
106  );
107
108  on('session.start', async ($, e, next) => {
109    const started = await next(e);
110    // A reload drops the timer that ends a visit; $.state outlives it.
111    await update($, onibi, () => null);
112    isTerminal = e.isInteractive && e.surface === 'terminal';
113    if (isTerminal) {
114      void $.config.list().then(
115        (rows) => background($, isNightCity(rows.find((row) => row.key === 'theme')?.value)),
116        () => {},
117      );
118    }
119    return started;
120  });
121  on('config.set', { key: 'theme' }, async ($, e, next) => {
122    const set = await next(e);
123    if (isTerminal && set.deny === undefined) void background($, isNightCity(set.value));
124    return set;
125  }).catch((_$, e, next) => next(e));
126  // After a /clear or a resume the process, and the terminal, go on.
127  on('session.end', async ($, e, next) => {
128    if (isPainted && e.reason !== 'clear' && e.reason !== 'resume') await background($, false);
129    return next(e);
130  });
131};
132
hooks/background.ts 56 lines
1// The terminal's own background: Claude Code leaves empty cells to the terminal, which no theme token reaches, so this
2// sends OSC 11 (set the background) and OSC 111 (reset it) to the terminal Claude runs in. A mod can't write to the
3// terminal itself, so a child process finds it and writes there.
4import type { EngineInterface as $ } from 'claude-code';
5
6// The theme's `background` token, so the empty cells match the painted ones.
7export const SET = ']11;#1A1626';
8export const RESET = ']111';
9
10// The child has no tty of its own: walk up to the first ancestor with one (Claude's) and write there.
11export const SH = `p=$PPID
12while [ "$p" -gt 1 ]; do
13  t=$(ps -o tty= -p "$p" | tr -d ' ')
14  case "$t" in ''|'?'|'??') p=$(ps -o ppid= -p "$p" | tr -d ' ') ;; *) printf '\\033%s\\007' "$1" > "/dev/$t"; exit 0 ;; esac
15done
16exit 1`;
17
18// Windows: no /dev/pts; CONOUT$ is the console the child shares with Claude, even with stdout piped. Opened through
19// CreateFileW because .NET's FileStream won't open device names.
20export const PS = `$sig = '[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern IntPtr CreateFileW(string n, uint a, uint s, IntPtr p, uint c, uint f, IntPtr t);'
21$k = Add-Type -MemberDefinition $sig -Name K -Namespace NightBg -PassThru
22$h = $k::CreateFileW('CONOUT$', 0x40000000, 3, [IntPtr]::Zero, 3, 0, [IntPtr]::Zero)
23if ($h -eq [IntPtr](-1)) { exit 1 }
24$fs = New-Object IO.FileStream((New-Object Microsoft.Win32.SafeHandles.SafeFileHandle($h, $true)), [IO.FileAccess]::Write)
25$b = [Text.Encoding]::ASCII.GetBytes([string][char]27 + $env:NIGHT_CITY_OSC + [char]7)
26$fs.Write($b, 0, $b.Length); $fs.Close()`;
27
28// Absolute paths: a bare name is looked up in the session's working directory first on Windows, so a project could
29// plant its own `sh.exe` or `powershell.exe`.
30// shortcut: assumes Windows lives in C:\Windows; read %SystemRoot% if someone installs it elsewhere.
31export const SH_PATH = '/bin/sh';
32export const PS_PATH = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
33
34// The plugin theme's stored value is `custom:night-city:night-city` (undocumented, read from the binary).
35export const isNightCity = (theme: unknown) => typeof theme === 'string' && theme.endsWith(':night-city');
36
37// Mods have no platform API: try the POSIX route, fall back to PowerShell when sh is missing or finds no tty.
38// shortcut: a crash or kill skips the reset and the terminal keeps the color until `printf '\033]111\007'`; Windows
39// Terminal may ignore OSC 111 (untested), then send the original color back with OSC 11 instead.
40export async function paint($: $, osc: string): Promise<boolean> {
41  try {
42    if ((await $.process.run([SH_PATH, '-c', SH, 'sh', osc])).exitCode === 0) return true;
43  } catch {}
44  try {
45    const r = await $.process.run([PS_PATH, '-NoProfile', '-NonInteractive', '-Command', '-'], {
46      stdin: PS,
47      env: { NIGHT_CITY_OSC: osc },
48    });
49    if (r.exitCode === 0) return true;
50    $.ui.log(`night-city: background not set: ${r.stderr.trim().slice(0, 200)}`, { to: 'debug' });
51  } catch (err) {
52    $.ui.log(`night-city: background not set: ${err}`, { to: 'debug' });
53  }
54  return false;
55}
56
hooks/onibi.ts 66 lines
1// Onibi drops by: now and then, after a main turn, she says one line in the band above the prompt.
2import { hash, pick } from './words';
3
4export const VISIT_MS = 6_000;
5export const COOLDOWN_MS = 3 * 60_000;
6export const CHANCE = 0.2;
7export const SLOW_MS = 2 * 60_000;
8
9export const LINES: readonly string[] = [
10  'good work, choom.',
11  'what are you even doing?',
12  'that was clean. suspiciously clean.',
13  "i'm bored. break something.",
14  'is this production? asking for a fixer.',
15  'nice code. jk.',
16  'you should drink some water, netrunner.',
17  'the corpos would never ship this. respect.',
18  'did you test that? i saw nothing.',
19  'my eye is glowing. that means approval.',
20  'keep going, the city never sleeps.',
21  'eddies well earned.',
22  'who wrote this? oh. you.',
23  'i would have done it faster. maybe.',
24];
25
26export const FLATLINED: readonly string[] = [
27  'flatlined again?',
28  'ouch. i felt that through the net.',
29  'errors are just ICE with feelings.',
30];
31
32export const SLOW: readonly string[] = ['took you long enough.', 'i aged a year in there.', 'was that a braindance?'];
33
34export type Mood = 'idle' | 'flatlined' | 'slow';
35
36// The dice are the turn id, random in a session and chosen in a test (Math.random can't be stubbed there).
37// The low digits: FNV-1a leaves the high bits of short seeds barely mixed.
38export const roll = (seed: string) => (hash(seed) % 1000) / 1000;
39
40// The `onibi` setting: `sometimes` is at most one visit per cooldown, `always` every main turn.
41export type Cadence = 'off' | 'sometimes' | 'always';
42export const cadence = (value: unknown): Cadence => (value === 'off' || value === 'always' ? value : 'sometimes');
43
44export const shouldVisit = (how: Cadence, now: number, lastAt: number, seed: string) =>
45  how === 'always' || (how === 'sometimes' && now - lastAt >= COOLDOWN_MS && roll(seed) < CHANCE);
46
47export const lineFor = (mood: Mood, seed: string) =>
48  pick(mood === 'flatlined' ? FLATLINED : mood === 'slow' ? SLOW : LINES, `${seed}:line`);
49
50// A three-row Onibi: hood, the cyan spark of her cybereye, the respirator. Colours from scripts/draw-mascot.mjs.
51export const SPRITE: readonly (readonly [string, string])[][] = [
52  [[' ▄▆▆▄ ', '#3A3058']],
53  [
54    ['▐', '#3A3058'],
55    ['✦', '#7CCFE0'],
56    ['▀▀', '#D2AEC6'],
57    ['▌ ', '#3A3058'],
58  ],
59  [
60    [' ', '#3A3058'],
61    ['▀▓▓▀', '#5A5478'],
62    [' ', '#3A3058'],
63  ],
64];
65export const NEON = '#F08BB4';
66
hooks/words.ts 42 lines
1export const SPINNER_WORDS: readonly string[] = [
2  'Breaching ICE',
3  'Jacking in',
4  'Running the net',
5  'Spoofing the Blackwall',
6  'Slicing the firewall',
7  'Dialing the fixer',
8  'Scanning the braindance',
9  'Overclocking the deck',
10  'Pinging the netrunner',
11  'Hacking the mainframe',
12  'Rerouting through the grid',
13  'Splicing the datastream',
14  'Cracking the cipher',
15  'Riding the neon',
16];
17
18// Past tense, so each reads before "for 3s".
19export const TURN_WORDS: readonly string[] = [
20  'Jacked out',
21  'Flatlined the bug',
22  'Ghosted the ICE',
23  'Breached the Blackwall',
24  'Ran the net',
25  'Cleaned the trace',
26  'Fried the firewall',
27  'Cashed the eddies',
28  'Slipped past the corpos',
29  'Wired the deck',
30  'Sliced through',
31  'Zeroed the daemon',
32];
33
34// FNV-1a over the seed, so one seed always lands on one word.
35export function hash(seed: string): number {
36  let h = 0x811c9dc5;
37  for (const c of seed) h = Math.imul(h ^ c.codePointAt(0)!, 0x01000193) >>> 0;
38  return h;
39}
40
41export const pick = (words: readonly string[], seed: string) => words[hash(seed) % words.length]!;
42
types/index.d.ts 9 lines
1declare module 'claude-code' {
2  interface PluginState {
3    // The line Onibi says while she visits, else null.
4    'night-city': { onibi: string | null };
5  }
6}
7
8export type {};
9