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

<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:
printf '\033]111\007'.▚▞ FLATLINE ▞▚ once per turn on a failing tool, ◢◤ JACKED OUT ◢◤ when a main turn ends.Onibi setting in /config makes her come always (every turn) or turns her off.€$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.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.
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.
Checked with relative luminance; all are at least 4.5:1. background is an undocumented token that the theme sets anyway.
| Foreground | on background | on user bar | on bash bar |
|---|---|---|---|
text #E6DCF7 | 13.44 | 11.29 | 11.15 |
permission #F5B97A | 10.20 | 8.57 | 8.46 |
error #F2788C | 6.61 | 5.55 | 5.48 |
inactive #9A90C2 | 6.02 | 5.06 | 5.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.
hooks/register.tsx 132 lines1// 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};
132hooks/background.ts 56 lines1// 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}
56hooks/onibi.ts 66 lines1// 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';
66hooks/words.ts 42 lines1export 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]!;
42types/index.d.ts 9 lines1declare 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