diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b60c2ca --- /dev/null +++ b/Dockerfile @@ -0,0 +1,185 @@ +# syntax=docker/dockerfile:1.7-labs +FROM debian:trixie-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + zsh \ + git \ + curl \ + ca-certificates \ + locales \ + fastfetch \ + chafa \ + jp2a \ + imagemagick \ + gnupg \ + sudo \ + procps \ + libelf1 \ + zlib1g \ + bsdextrautils \ + pipx \ + bat \ + tmux \ + make \ + fzf \ + python3-rich \ + vim \ + emacs-nox \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://repo.charm.sh/apt/gpg.key | gpg --dearmor -o /etc/apt/keyrings/charm.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" > /etc/apt/sources.list.d/charm.list \ + && apt-get update && apt-get install -y --no-install-recommends gum \ + && rm -rf /var/lib/apt/lists/* \ + && sed -i 's/# en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen \ + && locale-gen +ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 +ENV TERM=xterm-256color COLORTERM=truecolor +ENV CLICOLOR=1 CLICOLOR_FORCE=1 FORCE_COLOR=1 + +ARG ZELLIJ_VERSION=0.42.2 +RUN ARCH=$(uname -m) && \ + case "$ARCH" in \ + x86_64) ZARCH=x86_64-unknown-linux-musl ;; \ + aarch64) ZARCH=aarch64-unknown-linux-musl ;; \ + *) echo "unsupported arch: $ARCH" >&2; exit 1 ;; \ + esac && \ + curl -fsSL "https://github.com/zellij-org/zellij/releases/download/v${ZELLIJ_VERSION}/zellij-${ZARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin && \ + chmod +x /usr/local/bin/zellij + +ARG YEET_CACHEBUST=0 +RUN curl -fsSL https://yeet.cx | sh -s -- --no-phone-home + +RUN useradd -m -s /bin/zsh you \ + && install -m 0644 -o you -g you /dev/null /var/log/yeetd.log \ + && echo 'you ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/you \ + && chmod 0440 /etc/sudoers.d/you \ + && ln -sf /usr/bin/batcat /usr/local/bin/bat +ENV ASSETS=/opt/logos +COPY --chown=you:you --chmod=755 opt/ /opt/ +USER you +WORKDIR /home/you +ENV PATH=/home/you/.local/bin:$PATH + +# Frogmouth: TUI markdown browser (used by Space toggle inside the running banger). +RUN pipx install frogmouth + +# Skip zellij's first-run wizard / unlock-first mode +RUN mkdir -p /home/you/.config/zellij \ + && printf '%s\n' \ + 'default_mode "normal"' \ + 'show_startup_tips false' \ + 'show_release_notes false' \ + > /home/you/.config/zellij/config.kdl + +# Bake yeet-scripts repo into ~ (everything except opt/ which lives at /opt/) +COPY --chown=you:you --exclude=opt --exclude=Dockerfile --exclude=Makefile . /home/you/ + +RUN chafa --format=symbols --symbols=block --size=30x15 $ASSETS/logo.png > $ASSETS/logo-block.txt && \ + chafa --format=symbols --symbols=braille --size=35x18 $ASSETS/logo.png > $ASSETS/logo-braille.txt && \ + convert $ASSETS/logo.png -alpha extract -threshold 50% /tmp/sil.png && \ + convert /tmp/sil.png -morphology Erode Disk:5 /tmp/eroded.png && \ + convert /tmp/sil.png /tmp/eroded.png -compose Minus_Src -composite /tmp/ring.png && \ + convert -size 715x669 xc:black $ASSETS/logo.png -composite /tmp/flat.png && \ + convert /tmp/flat.png -colorspace Gray -threshold 99% /tmp/eyes-raw.png && \ + convert -size 715x335 xc:white -size 715x334 xc:black -append /tmp/top.png && \ + convert /tmp/eyes-raw.png /tmp/top.png -compose Multiply -composite -define connected-components:area-threshold=400 -define connected-components:mean-color=true -connected-components 4 -morphology Dilate Disk:10 /tmp/eyes.png && \ + convert /tmp/sil.png -morphology Erode Disk:8 /tmp/inner.png && \ + convert /tmp/inner.png /tmp/top.png -compose Multiply -composite /tmp/upper.png && \ + convert -size 715x669 xc:black -seed 7 +noise Random -channel R -separate -threshold 99% /tmp/noise-tiny.png && \ + convert /tmp/noise-tiny.png -morphology Dilate Disk:6 /tmp/noise.png && \ + convert /tmp/noise.png /tmp/upper.png -compose Multiply -composite /tmp/dots.png && \ + convert /tmp/top.png -negate /tmp/bottom.png && \ + convert /tmp/inner.png /tmp/bottom.png -compose Multiply -composite /tmp/lower-fill.png && \ + convert /tmp/ring.png /tmp/eyes.png -compose Lighten -composite /tmp/step1.png && \ + convert /tmp/step1.png /tmp/dots.png -compose Lighten -composite /tmp/step2.png && \ + convert /tmp/step2.png /tmp/lower-fill.png -compose Lighten -composite /tmp/mask.png && \ + convert /tmp/flat.png -modulate 130,250,100 /tmp/sat.png && \ + convert /tmp/sat.png -modulate 60,100,100 -level 0%,75% /tmp/sat-dim.png && \ + convert -size 715x669 xc:white /tmp/white-canvas.png && \ + convert -size 715x669 xc:"rgb(0,0,255)" /tmp/blue-canvas.png && \ + convert /tmp/flat.png -colorspace Gray -threshold 15% -negate /tmp/dark.png && \ + convert /tmp/dark.png /tmp/eyes.png -compose Multiply -composite -morphology Dilate Disk:2 /tmp/pupils.png && \ + convert /tmp/sat-dim.png /tmp/white-canvas.png /tmp/eyes.png -composite /tmp/sat-with-eyes.png && \ + convert /tmp/sat-with-eyes.png /tmp/blue-canvas.png /tmp/pupils.png -composite /tmp/sat-final.png && \ + convert /tmp/sat-final.png /tmp/mask.png -alpha off -compose CopyOpacity -composite $ASSETS/logo-outline.png && \ + chafa --format=symbols --symbols='ascii-alpha-digit-bad-ugly' --colors=256 --fg-only --color-extractor=median --size=44x21 $ASSETS/logo-outline.png \ + | perl -pe 's/(\e\[38;5;(?:231|21|19|20|27|33)m)./$1@/g' \ + > $ASSETS/logo-ascii.txt && \ + ln -sf $ASSETS/logo-ascii.txt $ASSETS/logo.txt && \ + mkdir -p /home/you/.config/fastfetch && \ + printf '%s\n' \ + '{' \ + ' "logo": {' \ + ' "source": "/opt/logos/logo.txt",' \ + ' "type": "file-raw",' \ + ' "padding": { "right": 2 }' \ + ' },' \ + ' "display": {' \ + ' "color": {' \ + ' "keys": "red",' \ + ' "title": "red",' \ + ' "separator": "yellow",' \ + ' "output": "white"' \ + ' },' \ + ' "separator": " "' \ + ' },' \ + ' "modules": [' \ + ' { "type": "title", "color": { "user": "cyan", "at": "yellow", "host": "green" } },' \ + ' { "type": "separator", "string": "==============" },' \ + ' { "type": "os", "format": "yeet enterprise linux", "keyColor": "red" },' \ + ' { "type": "host", "keyColor": "yellow" },' \ + ' { "type": "kernel", "keyColor": "green" },' \ + ' { "type": "uptime", "keyColor": "cyan" },' \ + ' { "type": "loadavg", "keyColor": "blue" },' \ + ' { "type": "packages", "keyColor": "magenta" },' \ + ' { "type": "shell", "keyColor": "red" },' \ + ' { "type": "terminal", "keyColor": "yellow" },' \ + ' { "type": "cpu", "keyColor": "green" },' \ + ' { "type": "cpuusage", "keyColor": "cyan" },' \ + ' { "type": "memory", "keyColor": "blue" },' \ + ' { "type": "swap", "keyColor": "magenta" },' \ + ' { "type": "disk", "keyColor": "red" },' \ + ' { "type": "localip", "keyColor": "yellow" },' \ + ' { "type": "locale", "keyColor": "green" },' \ + ' { "type": "datetime", "keyColor": "cyan" },' \ + ' "break",' \ + ' "colors"' \ + ' ]' \ + '}' \ + > /home/you/.config/fastfetch/config.jsonc + +ENV ZSH=/home/you/.oh-my-zsh +ENV ZSH_CUSTOM=$ZSH/custom + +RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended \ + && git clone --depth=1 https://github.com/romkatv/powerlevel10k.git $ZSH_CUSTOM/themes/powerlevel10k \ + && git clone --depth=1 https://github.com/zsh-users/zsh-autosuggestions $ZSH_CUSTOM/plugins/zsh-autosuggestions \ + && git clone --depth=1 https://github.com/zsh-users/zsh-syntax-highlighting $ZSH_CUSTOM/plugins/zsh-syntax-highlighting \ + && git clone --depth=1 https://github.com/zsh-users/zsh-completions $ZSH_CUSTOM/plugins/zsh-completions \ + && $ZSH_CUSTOM/themes/powerlevel10k/gitstatus/install -f + +RUN sed -i 's|^ZSH_THEME=.*|ZSH_THEME="powerlevel10k/powerlevel10k"|' ~/.zshrc \ + && sed -i 's|^plugins=.*|plugins=(git docker zsh-autosuggestions zsh-syntax-highlighting zsh-completions)|' ~/.zshrc \ + && cp $ZSH_CUSTOM/themes/powerlevel10k/config/p10k-rainbow.zsh ~/.p10k.zsh \ + && echo '[[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh' >> ~/.zshrc \ + && echo 'ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="fg=244"' >> ~/.zshrc \ + && echo 'alias logo-ascii="ln -sf /opt/logos/logo-ascii.txt /opt/logos/logo.txt && fastfetch"' >> ~/.zshrc \ + && echo 'alias logo-braille="ln -sf /opt/logos/logo-braille.txt /opt/logos/logo.txt && fastfetch"' >> ~/.zshrc \ + && echo 'alias logo-block="ln -sf /opt/logos/logo-block.txt /opt/logos/logo.txt && fastfetch"' >> ~/.zshrc + +# Collapse rainbow preset to a single line +RUN cat >> ~/.p10k.zsh <<'EOF' + +# --- single-line override: collapse rainbow to one line --- +typeset -g POWERLEVEL9K_LEFT_PROMPT_ELEMENTS=(os_icon context dir vcs prompt_char) +typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_PREFIX= +typeset -g POWERLEVEL9K_MULTILINE_NEWLINE_PROMPT_PREFIX= +typeset -g POWERLEVEL9K_MULTILINE_LAST_PROMPT_PREFIX= +typeset -g POWERLEVEL9K_MULTILINE_FIRST_PROMPT_SUFFIX= +typeset -g POWERLEVEL9K_MULTILINE_NEWLINE_PROMPT_SUFFIX= +typeset -g POWERLEVEL9K_MULTILINE_LAST_PROMPT_SUFFIX= +typeset -g POWERLEVEL9K_PROMPT_ADD_NEWLINE=false +EOF + +ENTRYPOINT ["/opt/scripts/entry.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f261a36 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +IMAGE ?= yeet-try + +# Pick your poison +HOSTNAMES := darling hilshire goat pirate the-bear noodle dopey ace \ + penguin blacky mopey quick-save snake donut weasel tooth \ + wilshire shadow ghost saucy prancer grumpy sleepy hambone +RANDOM_HOST = $(shell echo "$(HOSTNAMES)" | tr ' ' '\n' | grep -v '^$$' | \ + awk 'BEGIN{srand()} {a[NR]=$$0} END{print a[int(rand()*NR)+1]}') + +.PHONY: all build run banger + +all: build run + +banger: + @./opt/scripts/banger/manage.sh + +build: + docker build --build-arg YEET_CACHEBUST=$$(date +%s) -t $(IMAGE) . > /dev/null + +run: + @docker run --rm -it --hostname $(RANDOM_HOST) \ + -e TERM=xterm-256color \ + -e COLORTERM=truecolor \ + -e FORCE_COLOR=1 \ + -e CLICOLOR=1 \ + -e CLICOLOR_FORCE=1 \ + $(IMAGE) diff --git a/examples/fire/Makefile b/examples/fire/Makefile new file mode 100644 index 0000000..dd7b7cb --- /dev/null +++ b/examples/fire/Makefile @@ -0,0 +1,7 @@ +.PHONY: run info + +run: + yeet run ./index.js + +info: + @head -10 ./index.js | sed -n 's|^// \?||p' diff --git a/examples/fire/README.md b/examples/fire/README.md new file mode 100644 index 0000000..f2d4dea --- /dev/null +++ b/examples/fire/README.md @@ -0,0 +1,37 @@ +# fire + +**Doom's flame effect**, in your terminal. Originally Fabien Sanglard's +1993 algorithm — runs on anything, looks like nothing else. + +## how it works + +Keep a 2D buffer of "intensity" values (0–31). The bottom row is fixed +at 31 — pure white-hot. Then each frame: + +1. For every pixel `(x, y)`, look at the pixel **below** it (with random + ±1 horizontal jitter for the swirl effect). +2. Subtract a small random amount (0–2) so it cools as it rises. +3. Write that value into `(x, y)`. + +Result: heat propagates upward and dissipates. The horizontal jitter +gives the flames their licking, organic motion. The random cool-down +amount gives the texture. + +## why it's a banger + +It's a perfect tiny algorithm — 4 lines of math, infinite visual depth. +And it teaches you something fundamental: **complex behavior from local +rules** is the entire story of cellular automata, fluid sims, neural +nets, even cities. + +## palette + +A 32-step ramp from black, through deep red, to orange, to yellow, to +white. Truecolor again, half-block double resolution again. + +## controls + +- **Esc** back to the picker +- **←/→** prev / next banger +- **Space** toggle this readme +- **Ctrl+C** interrupt the script diff --git a/examples/fire/index.js b/examples/fire/index.js new file mode 100644 index 0000000..41c0551 --- /dev/null +++ b/examples/fire/index.js @@ -0,0 +1,78 @@ +#!/usr/bin/env -S yeet run +// +// FIRE — Fabien Sanglard's classic doom-style flame effect. +// +// We keep a buffer of "intensity" values. The bottom row is hot, fixed +// at max. Every frame each pixel cools by a small random amount and is +// pulled from one of the three pixels below it (with horizontal spread). +// Renders with truecolor and ▀ for double vertical density. + +const { interval = 50 } = yeet.args; + +const ESC = "\x1b["; + +let { rows, cols } = tty.size(); + +// Internal buffer is double the row height so ▀ pairs up nicely. +let H = rows * 2; +let W = cols; +let buf = new Uint8Array(H * W); + +// 32-step palette: black → red → orange → yellow → white +const PALETTE = []; +for (let i = 0; i < 32; i++) { + let r, g, b; + if (i < 8) { r = i * 32; g = 0; b = 0; } + else if (i < 16) { r = 255; g = (i - 8) * 32; b = 0; } + else if (i < 24) { r = 255; g = 255; b = (i - 16) * 32; } + else { r = 255; g = 255; b = 200 + (i - 24) * 7; } + PALETTE.push([r, g, b]); +} + +function seedBottom() { + for (let x = 0; x < W; x++) buf[(H - 1) * W + x] = 31; +} + +function step() { + for (let y = 0; y < H - 1; y++) { + for (let x = 0; x < W; x++) { + const src = (y + 1) * W + x + (Math.floor(Math.random() * 3) - 1); + if (src < 0 || src >= H * W) continue; + const cool = Math.floor(Math.random() * 3); + const v = buf[src] - cool; + buf[y * W + x] = v < 0 ? 0 : v; + } + } +} + +function tick() { + const sz = tty.size(); + if (sz.rows !== rows || sz.cols !== cols) { + rows = sz.rows; cols = sz.cols; + H = rows * 2; + W = cols; + buf = new Uint8Array(H * W); + tty.clear(); + } + seedBottom(); + step(); + let frame = `${ESC}H`; + for (let r = 0; r < rows; r++) { + let line = ""; + for (let c = 0; c < cols; c++) { + const top = PALETTE[buf[r * 2 * W + c]]; + const bot = PALETTE[buf[(r * 2 + 1) * W + c]]; + line += `\x1b[38;2;${top[0]};${top[1]};${top[2]}m`; + line += `\x1b[48;2;${bot[0]};${bot[1]};${bot[2]}m▀`; + } + frame += line + "\x1b[0m"; + if (r + 1 < rows) frame += "\n"; + } + tty.write(frame); +} + +tty.alt(); +tty.hideCursor(); +tty.title("fire"); +tty.clear(); +setInterval(tick, interval); diff --git a/examples/life/Makefile b/examples/life/Makefile new file mode 100644 index 0000000..dd7b7cb --- /dev/null +++ b/examples/life/Makefile @@ -0,0 +1,7 @@ +.PHONY: run info + +run: + yeet run ./index.js + +info: + @head -10 ./index.js | sed -n 's|^// \?||p' diff --git a/examples/life/README.md b/examples/life/README.md new file mode 100644 index 0000000..5d6047f --- /dev/null +++ b/examples/life/README.md @@ -0,0 +1,52 @@ +# life + +**Conway's Game of Life**, rendered into braille pixels for 8× density. + +## the rules (1970) + +For every cell, count its 8 neighbors. Then: + +| state | neighbors | becomes | +|------:|----------:|--------:| +| alive | 2,3 | alive | +| alive | other | dead | +| dead | 3 | alive | + +That's it. Three lines. From this, you get gliders, spaceships, oscillators, +guns, and (if you're patient) Turing-complete computation. + +## the rendering trick + +A single braille character holds an 8-dot grid (2 cols × 4 rows). So one +terminal cell paints 8 pixels of life. The screen runs at `(cols × 2) × +(rows × 4)` resolution — typically several thousand cells. + +## the aging trick + +Each living cell tracks how long it's been alive. We use that to color +it: + +``` +< 3 → white (just born, energetic) +< 8 → cyan +< 15 → bright blue +< 30 → blue +≥ 30 → deep blue (the elders) +``` + +Watch the colonies: brand-new gliders flash white at the front, the old +oscillators burn cyan in the back. + +## why it's a banger + +Life is the canonical reminder that **simple rules → endless complexity**. +You'll see structures emerge that nobody designed. When the colony goes +quiet, the script reseeds — but if you watch long enough you'll catch +gliders escaping into the void anyway. + +## controls + +- **Esc** back to the picker +- **←/→** prev / next banger +- **Space** toggle this readme +- **Ctrl+C** interrupt the script diff --git a/examples/life/index.js b/examples/life/index.js new file mode 100644 index 0000000..93cad58 --- /dev/null +++ b/examples/life/index.js @@ -0,0 +1,119 @@ +#!/usr/bin/env -S yeet run +// +// LIFE — Conway's Game of Life with double-buffered braille rendering. +// +// Each terminal cell is a 2x4 braille "pixel grid", giving us 8x density +// vs char-based rendering. Cells age — newer cells glow bright, older +// cells fade to deep cyan. When the grid stagnates we reseed. + +const { interval = 80, density = 0.32 } = yeet.args; + +const ESC = "\x1b["; +const move = (r, c) => `${ESC}${r + 1};${c + 1}H`; + +let { rows, cols } = tty.size(); + +// Braille grid: 2x4 sub-cells per terminal char +let BW = cols * 2; +let BH = rows * 4; +let grid = new Uint8Array(BW * BH); +let next = new Uint8Array(BW * BH); +let age = new Uint8Array(BW * BH); + +const at = (x, y) => grid[((y + BH) % BH) * BW + ((x + BW) % BW)]; + +function seed() { + for (let i = 0; i < grid.length; i++) { + grid[i] = Math.random() < density ? 1 : 0; + age[i] = grid[i] ? 1 : 0; + } +} +seed(); + +let stagnantTicks = 0; +let lastPop = 0; + +function step() { + for (let y = 0; y < BH; y++) { + for (let x = 0; x < BW; x++) { + let n = 0; + for (let dy = -1; dy <= 1; dy++) + for (let dx = -1; dx <= 1; dx++) + if (dx || dy) n += at(x + dx, y + dy); + const i = y * BW + x; + const alive = grid[i]; + const live = alive ? n === 2 || n === 3 : n === 3; + next[i] = live ? 1 : 0; + if (live) age[i] = alive ? Math.min(age[i] + 1, 60) : 1; + else age[i] = 0; + } + } + [grid, next] = [next, grid]; + + let pop = 0; + for (let i = 0; i < grid.length; i++) pop += grid[i]; + if (Math.abs(pop - lastPop) < 3) stagnantTicks++; + else stagnantTicks = 0; + lastPop = pop; + if (stagnantTicks > 60 || pop < 4) { seed(); stagnantTicks = 0; } +} + +// braille dot offsets (col 0/1, row 0/1/2/3) → bit position in the 8-dot pattern +const BRAILLE_BITS = [ + [0x01, 0x08], + [0x02, 0x10], + [0x04, 0x20], + [0x40, 0x80], +]; + +function tick() { + const sz = tty.size(); + if (sz.rows !== rows || sz.cols !== cols) { + rows = sz.rows; cols = sz.cols; + BW = cols * 2; + BH = rows * 4; + grid = new Uint8Array(BW * BH); + next = new Uint8Array(BW * BH); + age = new Uint8Array(BW * BH); + seed(); + tty.clear(); + } + step(); + let frame = `${ESC}H`; + for (let cy = 0; cy < rows; cy++) { + let line = ""; + for (let cx = 0; cx < cols; cx++) { + let bits = 0; + let maxAge = 0; + let any = 0; + for (let dy = 0; dy < 4; dy++) { + for (let dx = 0; dx < 2; dx++) { + const x = cx * 2 + dx, y = cy * 4 + dy; + if (grid[y * BW + x]) { + bits |= BRAILLE_BITS[dy][dx]; + any = 1; + const a = age[y * BW + x]; + if (a > maxAge) maxAge = a; + } + } + } + if (!any) { line += " "; continue; } + // young → bright cyan/white; old → deep cyan + const color = + maxAge < 3 ? 231 : + maxAge < 8 ? 51 : + maxAge < 15 ? 45 : + maxAge < 30 ? 39 : 33; + line += `\x1b[38;5;${color}m${String.fromCharCode(0x2800 + bits)}`; + } + frame += line + "\x1b[0m"; + if (cy + 1 < rows) frame += "\n"; + } + tty.write(frame); +} + +tty.alt(); +tty.hideCursor(); +tty.title("life"); +tty.clear(); +setInterval(tick, interval); diff --git a/examples/matrix/Makefile b/examples/matrix/Makefile new file mode 100644 index 0000000..dd7b7cb --- /dev/null +++ b/examples/matrix/Makefile @@ -0,0 +1,7 @@ +.PHONY: run info + +run: + yeet run ./index.js + +info: + @head -10 ./index.js | sed -n 's|^// \?||p' diff --git a/examples/matrix/README.md b/examples/matrix/README.md new file mode 100644 index 0000000..fb741b8 --- /dev/null +++ b/examples/matrix/README.md @@ -0,0 +1,29 @@ +# matrix + +The classic. **Green code rain falling forever.** + +Every column is a "drop": a bright leading glyph trailing into shades of +green that fade out at the back. Drops respawn at random intervals, run +at random speeds, and pick characters from the original Matrix katakana +plus a sprinkle of ASCII punctuation. + +## why it's a banger + +Because *of course* the rain made it in. Every terminal demo reel from +1999 to now has had this. It's the visual equivalent of a screensaver +saying "this machine is alive, and very sure of itself." + +## inside + +- ~50 ms tick rate (override with `--interval N`) +- Heads draw at color 231 (bright white) for the leading glyph +- Trails ramp through 46 → 40 → 22 → 235 +- We never `clear` between frames — only the cell that just left the + trail is overwritten with a space, so the rain is buttery smooth + +## controls + +- **Esc** back to the picker +- **←/→** prev / next banger +- **Space** toggle this readme +- **Ctrl+C** interrupt the script diff --git a/examples/matrix/index.js b/examples/matrix/index.js new file mode 100644 index 0000000..1dda4db --- /dev/null +++ b/examples/matrix/index.js @@ -0,0 +1,89 @@ +#!/usr/bin/env -S yeet run +// +// MATRIX — the classic falling green code rain. +// +// Each column is a "drop" with a leading bright glyph trailing into +// fading green. Drops respawn at random intervals when they fall off +// the bottom, so the rain stays organic. + +const { interval = 50 } = yeet.args; + +const CHARS = + "ヲアウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワ" + + "0123456789!@#$%^&*()_+-=[]{};:,.<>/?"; + +const pick = () => CHARS[Math.floor(Math.random() * CHARS.length)]; + +const ESC = "\x1b["; +const move = (r, c) => `${ESC}${r + 1};${c + 1}H`; +const HOME = `${ESC}H`; + +let { rows, cols } = tty.size(); +let drops = []; // { col, head, tail, speed } + +function spawn(col) { + const tail = 6 + Math.floor(Math.random() * Math.min(rows - 4, 18)); + return { + col, + head: -Math.floor(Math.random() * rows), + tail, + speed: 0.4 + Math.random() * 1.2, + }; +} + +function init() { + drops = []; + for (let c = 0; c < cols; c++) { + if (Math.random() < 0.6) drops.push(spawn(c)); + } +} + +function tick() { + const sz = tty.size(); + if (sz.rows !== rows || sz.cols !== cols) { + rows = sz.rows; cols = sz.cols; + tty.clear(); + init(); + } + let frame = ""; + // Erase by overwriting trailing cells with space — cheaper than clearing. + for (const d of drops) { + const head = Math.floor(d.head); + // tail-end cell gets erased (the one that just left the trail) + const tailEnd = head - d.tail; + if (tailEnd >= 0 && tailEnd < rows) { + frame += move(tailEnd, d.col) + " "; + } + // trail + for (let i = 1; i < d.tail; i++) { + const r = head - i; + if (r < 0 || r >= rows) continue; + const fade = i / d.tail; + const ch = pick(); + // green gradient: 46 → 22 → 235 + const color = fade < 0.25 ? 46 : fade < 0.55 ? 40 : fade < 0.8 ? 22 : 235; + frame += `${move(r, d.col)}\x1b[38;5;${color}m${ch}\x1b[0m`; + } + // bright head + if (head >= 0 && head < rows) { + frame += `${move(head, d.col)}\x1b[38;5;231;1m${pick()}\x1b[0m`; + } + d.head += d.speed; + if (head - d.tail > rows) { + // respawn this drop somewhere up top + Object.assign(d, spawn(d.col)); + } + } + // occasionally add a new drop + if (drops.length < cols && Math.random() < 0.08) { + drops.push(spawn(Math.floor(Math.random() * cols))); + } + tty.write(frame); +} + +tty.alt(); +tty.hideCursor(); +tty.title("matrix"); +tty.clear(); +init(); +setInterval(tick, interval); diff --git a/examples/metropolis/Makefile b/examples/metropolis/Makefile new file mode 100644 index 0000000..9fb38f0 --- /dev/null +++ b/examples/metropolis/Makefile @@ -0,0 +1,15 @@ +.PHONY: run start stop info + +run: + @trap './activity.sh stop' EXIT INT TERM HUP; \ + ./activity.sh start; \ + yeet run ./index.js + +start: + @./activity.sh start + +stop: + @./activity.sh stop + +info: + @head -10 ./index.js | sed -n 's|^// \?||p' diff --git a/examples/metropolis/README.md b/examples/metropolis/README.md new file mode 100644 index 0000000..f1d8e90 --- /dev/null +++ b/examples/metropolis/README.md @@ -0,0 +1,25 @@ +# metropolis + +An art-deco city that **breathes your system**. + +Layered skyscrapers, a central tower, traffic dotting the streets — all of it +driven live by host metrics from yeetd: + +- Window lights pulse with **CPU load** per process. +- Traffic flow density tracks **network throughput**. +- The tower glows brighter as **memory pressure** rises. +- Smokestacks plume when the **disk queue** backs up. + +## why it's a banger + +It's the closest thing yeet has to *ambient computing aesthetics* — you can +glance at it, see how the box is feeling, and look away. Pure vibes, real signal. + +## controls + +While metropolis is running: + +- **Esc** back to the picker +- **←/→** prev / next banger +- **Space** this readme +- **Ctrl+C** interrupt the script diff --git a/examples/metropolis/activity.sh b/examples/metropolis/activity.sh new file mode 100755 index 0000000..85b9501 --- /dev/null +++ b/examples/metropolis/activity.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# metropolis activity — spawns "citizens" in a mix of process states +# under realistic names so the boulevard sees R / S / T processes. + +set -u + +PIDFILE=${PIDFILE:-/tmp/metropolis-activity.pids} +RUN_WORKER=/tmp/.metropolis-run-worker +SLEEP_WORKER=/tmp/.metropolis-sleep-worker +WORKDIR=/tmp/metropolis-bin + +NAMES_RUNNING=(transcoder shader-compile cargo-build llm-fwd ffmpeg) +NAMES_SLEEPING=(idle-tab background-sync wallpaper-daemon spotlight) +NAMES_STOPPED=(parked-job paused-render) + +write_workers() { + cat > "$RUN_WORKER" <<'EOF' +#!/bin/bash +while true; do + end=$((SECONDS + 1 + RANDOM % 5)) + while [ "$SECONDS" -lt "$end" ]; do : ; done + sleep "0.$((1 + RANDOM % 8))" +done +EOF + cat > "$SLEEP_WORKER" <<'EOF' +#!/bin/bash +while true; do sleep $((30 + RANDOM % 60)); done +EOF + chmod +x "$RUN_WORKER" "$SLEEP_WORKER" +} + +spawn() { + src=$1; name=$2; freeze=${3:-no} + link="$WORKDIR/$name" + ln -sf "$src" "$link" + "$link" & + pid=$! + echo "$pid" >> "$PIDFILE" + if [ "$freeze" = yes ]; then + sleep 0.2 + kill -STOP "$pid" 2>/dev/null || true + fi +} + +case "${1:-}" in + start) + [ -s "$PIDFILE" ] && exit 0 + write_workers + mkdir -p "$WORKDIR" + : > "$PIDFILE" + for n in "${NAMES_RUNNING[@]}"; do spawn "$RUN_WORKER" "$n"; done + for n in "${NAMES_SLEEPING[@]}"; do spawn "$SLEEP_WORKER" "$n"; done + for n in "${NAMES_STOPPED[@]}"; do spawn "$SLEEP_WORKER" "$n" yes; done + ;; + stop) + if [ -f "$PIDFILE" ]; then + while read -r pid; do + [ -n "$pid" ] && kill -CONT "$pid" 2>/dev/null || true + [ -n "$pid" ] && kill "$pid" 2>/dev/null || true + done < "$PIDFILE" + rm -f "$PIDFILE" + fi + rm -rf "$WORKDIR" + rm -f "$RUN_WORKER" "$SLEEP_WORKER" + ;; + *) + echo "usage: $(basename "$0") {start|stop}" >&2 + exit 1 + ;; +esac diff --git a/examples/metropolis/index.js b/examples/metropolis/index.js new file mode 100644 index 0000000..a740d9a --- /dev/null +++ b/examples/metropolis/index.js @@ -0,0 +1,608 @@ +#!/usr/bin/env -S yeet run +// +// METROPOLIS — an art-deco city that breathes your system. +// +// The city takes over the screen: layered skyscrapers, the Tower of +// Babel in the middle, a neon Tux billboard on a rooftop, twinkling +// windows, blinking rooftop lights, and elevated roadways. +// +// The top CPU-spending processes walk the boulevard below as citizens: +// • R (running) — walk with animated legs, speed scales with CPU +// • S (sleeping) — stand quietly +// • D (iowait) — frozen, stuck +// • Z (zombie) — ✕ in red +// • T (stopped) — faded +// +// A thin footer shows CPU, MEM, LOAD, NET, and UP. +// +// Usage: +// yeet run examples/metropolis.js +// yeet run examples/metropolis.js -- --interval 300 +// + +const { interval = 100 } = yeet.args; + +const unwrap = (r) => r.data ?? r; + +const MAX_CITIZENS = 12; + +/* ── State ─────────────────────────────────────────────────────────── */ + +let tick = 0; + +let prev_total = null; +let total_cpu_pct = 0; +let cores = 1; + +let mem = null; +let load = null; +let uptime_s = 0; + +let prev_net = null; +let prev_net_ts = null; +let rx_bps = 0; +let tx_bps = 0; + +let prev_procs = new Map(); /* pid → cpu_ticks last sample */ +let citizens = []; /* ordered by rank; each walks its lane */ + +let city_cache = null; + +/* ── Frame buffer (double buffering to eliminate flicker) ────────────── + * + * All draws during a render append to `frame` instead of writing to the + * TTY directly. At the end of render(), the entire frame is flushed in + * a single tty.write() call. We never tty.clear() between frames — the + * city layer fully repaints the sky region, and sparse rows (street, + * stats, footer) emit \x1b[K (erase-to-end-of-line) before their content + * so prior-frame residue is removed without a flash. */ +let frame = ""; +const ERASE_EOL = "\x1b[K"; +function fmove(row, col) { frame += `\x1b[${row + 1};${col + 1}H`; } +function fwrite(s) { frame += s; } +function ferase_line(row) { frame += `\x1b[${row + 1};1H${ERASE_EOL}`; } + +/* ── Formatting helpers ─────────────────────────────────────────────── */ + +function pct_color(p) { + return p > 90 ? style.brightRed : p > 60 ? style.brightYellow : style.brightGreen; +} + +function bar(pct, width) { + const filled = Math.round((pct / 100) * width); + const empty = Math.max(0, width - filled); + return pct_color(pct)("█".repeat(Math.max(0, filled))) + style.dim("░".repeat(empty)); +} + +function human_bytes(n) { + const u = ["B ", "KB", "MB", "GB", "TB"]; + let i = 0; + while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } + return (n.toFixed(i === 0 ? 0 : 1) + " " + u[i]).padStart(9); +} + +function uptime_str(s) { + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const ss = Math.floor(s % 60); + const pad = (n) => String(n).padStart(2, "0"); + return `${d}d ${pad(h)}:${pad(m)}:${pad(ss)}`; +} + +function cpu_delta(prev, cur) { + const busy = (cur.user_ms - prev.user_ms) + (cur.nice_ms - prev.nice_ms) + + (cur.system_ms - prev.system_ms) + ((cur.irq_ms || 0) - (prev.irq_ms || 0)) + + ((cur.softirq_ms || 0) - (prev.softirq_ms || 0)) + + ((cur.steal_ms || 0) - (prev.steal_ms || 0)); + const idle = (cur.idle_ms - prev.idle_ms) + ((cur.iowait_ms || 0) - (prev.iowait_ms || 0)); + const total = busy + idle; + return total > 0 ? (busy / total) * 100 : 0; +} + +function neon(s) { + let out = ""; + for (let i = 0; i < s.length; i++) { + const c = s[i]; + if (c === " ") { out += c; continue; } + out += ((i + Math.floor(tick / 3)) % 2 === 0 ? style.brightMagenta : style.brightCyan)(c); + } + return out; +} + +/* ── Tux billboard ──────────────────────────────────────────────────── */ + +const TUX = [ + " .--. ", + "|o_o |", + "|:_/ |", + "(| |)", + "/`--`\\", +]; + +/* ── Cityscape ─────────────────────────────────────────────────────── */ + +function build_city(cols, H) { + if (city_cache && city_cache.cols === cols && city_cache.H === H) return city_cache; + + const grid = []; + for (let r = 0; r < H; r++) grid.push(new Array(cols).fill(" ")); + + /* Seeded RNG so the skyline is deterministic per size. */ + let s = (20260422 ^ (cols * 31) ^ (H * 131)) >>> 0; + const rnd = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; + + const ground = H - 1; + + const WINDOWS = { + checker: (i, j) => ((i + j) % 2 === 0) ? "▪" : "▫", + grid: (i, j) => (j % 2 === 0 ? ((i % 2 === 1) ? "▪" : " ") : ((i % 2 === 0) ? "▫" : " ")), + stripe: (_i, j) => (j % 2 === 0 ? "▪" : "▫"), + vbars: (i, _j) => (i % 2 === 0 ? "│" : "▪"), + }; + + function paint(x, y, w, h, pattern = "checker") { + if (w < 2 || h < 2 || x < 0 || x + w > cols || y < 0 || y + h > H) return null; + const winfn = WINDOWS[pattern] || WINDOWS.checker; + grid[y][x] = "╔"; grid[y][x + w - 1] = "╗"; + for (let i = 1; i < w - 1; i++) grid[y][x + i] = "═"; + for (let j = 1; j < h; j++) { + grid[y + j][x] = "║"; + grid[y + j][x + w - 1] = "║"; + for (let i = 1; i < w - 1; i++) grid[y + j][x + i] = winfn(i, j); + } + return { x, y, w, h }; + } + + function antenna(x, y_top, length) { + if (x < 0 || x >= cols) return; + for (let i = 0; i < length; i++) { + if (y_top - i >= 0) grid[y_top - i][x] = "│"; + } + } + + function dome(cx, y) { + if (cx < 1 || cx + 1 >= cols || y < 1 || y >= H) return; + grid[y - 1][cx - 1] = "╭"; + grid[y - 1][cx] = "═"; + grid[y - 1][cx + 1] = "╮"; + grid[y][cx] = "◉"; + } + + function paste(x, y, text) { + for (let i = 0; i < text.length; i++) { + const cx = x + i, cy = y; + if (cx >= 0 && cx < cols && cy >= 0 && cy < H) grid[cy][cx] = text[i]; + } + } + + /* Back layer: tall, distant towers — deterministic, densely packed. */ + let x = 0; + while (x < cols) { + const w = 3 + Math.floor(rnd() * 4); + const h = Math.max(4, Math.floor(H * 0.55) + Math.floor(rnd() * 5)); + paint(x, ground - h, w, h, "checker"); + if (rnd() < 0.45) antenna(x + Math.floor(w / 2), ground - h - 1, 1 + Math.floor(rnd() * 3)); + if (rnd() < 0.25) dome(x + Math.floor(w / 2), ground - h); + x += w + Math.floor(rnd() * 3); + } + + /* Mid layer. */ + x = 1; + while (x < cols) { + const w = 4 + Math.floor(rnd() * 5); + const h = Math.max(4, Math.floor(H * 0.4) + Math.floor(rnd() * 5)); + const pat = rnd() < 0.5 ? "grid" : "stripe"; + paint(x, ground - h, w, h, pat); + if (rnd() < 0.3) antenna(x + Math.floor(w / 2), ground - h - 1, 1 + Math.floor(rnd() * 2)); + x += w + Math.floor(rnd() * 3); + } + + /* Front layer: shorter, chunkier — bright, closer. */ + x = 0; + while (x < cols) { + const w = 4 + Math.floor(rnd() * 5); + const h = 4 + Math.floor(rnd() * Math.max(1, Math.floor(H * 0.25))); + paint(x, ground - h, w, h, "vbars"); + x += w + Math.floor(rnd() * 2); + } + + /* Elevated roadways sweeping across, behind the front towers. */ + const roads = [Math.floor(H * 0.32), Math.floor(H * 0.52)]; + for (const ry of roads) { + if (ry <= 0 || ry >= H - 1) continue; + for (let c = 1; c < cols - 1; c++) { + if (grid[ry][c] === " ") grid[ry][c] = "┄"; + } + } + + /* Central Tower of Babel — stepped ziggurat, widest base to slim spire. */ + const cx = Math.floor(cols / 2); + const tiers = [ + { w: Math.min(21, cols - 4), h: Math.max(3, Math.floor(H * 0.18)) }, + { w: Math.min(17, cols - 6), h: Math.max(3, Math.floor(H * 0.14)) }, + { w: Math.min(13, cols - 8), h: Math.max(3, Math.floor(H * 0.14)) }, + { w: Math.min(9, cols - 10), h: Math.max(2, Math.floor(H * 0.10)) }, + { w: 3, h: 2 }, + ]; + let ty = ground; + let top_y = ty; + for (const t of tiers) { + if (t.w < 3 || ty - t.h < 0) continue; + ty -= t.h; + paint(cx - Math.floor(t.w / 2), ty, t.w, t.h, "grid"); + top_y = ty; + } + antenna(cx, top_y - 1, Math.min(5, top_y)); + dome(cx, top_y); + + /* Tux billboard on a rooftop in the right-third of the skyline. */ + if (cols > 40 && H > 10) { + const bx = Math.max(2, Math.floor(cols * 0.78) - 4); + const by = Math.max(2, Math.floor(H * 0.40)); + /* Frame */ + paste(bx - 1, by - 1, "┌" + "─".repeat(TUX[0].length) + "┐"); + for (let i = 0; i < TUX.length; i++) { + paste(bx - 1, by + i, "│"); + paste(bx, by + i, TUX[i]); + paste(bx + TUX[0].length, by + i, "│"); + } + paste(bx - 1, by + TUX.length, "└" + "─".repeat(TUX[0].length) + "┘"); + /* "TUX" marquee above the frame */ + paste(bx + 1, by - 2, "TUX"); + } + + /* Rooftop signs on the front. */ + if (cols > 60) paste(Math.floor(cols * 0.14), Math.floor(H * 0.50), " NEUE "); + if (cols > 80) paste(Math.floor(cols * 0.42), Math.floor(H * 0.25), " POLIS "); + if (cols > 110) paste(Math.floor(cols * 0.06), Math.floor(H * 0.28), " YOSHIWARA "); + + /* Street-level strip: solid illuminated facade at ground. */ + for (let c = 0; c < cols; c++) { + if (grid[ground][c] === " ") grid[ground][c] = "▄"; + } + + city_cache = { cols, H, grid }; + return city_cache; +} + +function is_letter(ch) { return (ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z"); } + +function city_char_style(r, c, ch, H) { + if (ch === " ") return " "; + if (ch === "│" && (r % H) < H / 3) return style.brightRed(ch); /* antennas */ + if (ch === "┄") return style.dim(ch); + if (ch === "◉") return ((tick + c + r) % 2 === 0) ? style.brightYellow(ch) : style.yellow(ch); + if (ch === "▄") return style.brightBlack(ch); + + /* Twinkling windows. */ + if (ch === "▪") { + const lit = ((r * 7 + c * 3 + Math.floor(tick / 2)) % 7) < 5; + return lit ? style.brightYellow(ch) : style.dim(ch); + } + if (ch === "▫") { + const lit = ((r * 11 + c * 5 + Math.floor(tick / 2)) % 11) < 2; + return lit ? style.yellow(ch) : style.dim(ch); + } + + /* Rooftop sign letters — cycle neon. */ + if (is_letter(ch)) { + return ((c + Math.floor(tick / 2)) % 2 === 0) ? style.brightCyan(ch) : style.brightMagenta(ch); + } + + /* Tux glyphs — bright white on dark background. */ + if (".-_|o:(\\)`/".includes(ch)) return style.brightWhite(ch); + + /* Billboard frame — yellow */ + if ("┌┐└┘─".includes(ch)) return style.brightYellow(ch); + + /* Walls / corners / edges — depth-cue by row: higher = dimmer/farther. */ + const depth = r / H; + if (depth < 0.25) return style.magenta(ch); + if (depth < 0.55) return style.brightMagenta(ch); + return style.brightCyan(ch); +} + +/* ── Citizens (top processes walking the street) ────────────────────── */ + +function update_citizens(procs) { + const now_samples = new Map(); + for (const p of procs) { + if (!p || !p.stat) continue; + now_samples.set(p.pid, { + comm: p.stat.comm, + state: p.stat.state, + cpu_ticks: p.stat.utime + p.stat.stime, + rss: p.stat.rss_bytes || 0, + }); + } + + const ranked = []; + for (const [pid, cur] of now_samples) { + const prev = prev_procs.get(pid); + const delta = prev ? Math.max(0, cur.cpu_ticks - prev.cpu_ticks) : 0; + ranked.push({ pid, delta, rss: cur.rss, comm: cur.comm, state: cur.state }); + } + + ranked.sort((a, b) => (b.delta - a.delta) || (b.rss - a.rss)); + const top = ranked.slice(0, MAX_CITIZENS); + + const existing = new Map(citizens.map((c) => [c.pid, c])); + citizens = top.map((t) => { + const prev = existing.get(t.pid); + if (prev) { + prev.comm = t.comm; prev.state = t.state; prev.delta = t.delta; + return prev; + } + return { + pid: t.pid, comm: t.comm, state: t.state, delta: t.delta, + x: Math.random(), + dir: Math.random() < 0.5 ? -1 : 1, + }; + }); + + prev_procs = now_samples; +} + +function state_style(state) { + switch (state) { + case "R": return { head: "◉", color: style.brightGreen }; + case "S": return { head: "○", color: style.cyan }; + case "D": return { head: "◉", color: style.brightYellow }; + case "Z": return { head: "✕", color: style.brightRed }; + case "T": return { head: "◌", color: style.dim }; + default: return { head: "●", color: style.white }; + } +} + +function state_body(state, phase) { + if (state === "R") return phase === 0 ? "╱│╲" : "╲│╱"; + if (state === "D") return "│││"; + if (state === "Z") return " ⚰ "; + if (state === "T") return "═╪═"; + /* Sleeping / default — gentle sway. */ + return phase === 0 ? "·│·" : " │ "; +} + +function state_speed(state, delta) { + if (state === "R") return Math.min(1.2, 0.25 + delta * 0.03); + if (state === "S") return 0.04; + return 0; +} + +function draw_street(top, cols) { + /* Erase sparse rows so moving citizens don't leave a trail. */ + for (let dy = 1; dy <= 4; dy++) ferase_line(top + dy); + + /* Sidewalk line — the boulevard. */ + fmove(top, 0); + fwrite(style.brightBlack("═".repeat(cols))); + + const n = citizens.length; + if (n === 0) { + fmove(top + 2, 2); + fwrite(style.dim("( the streets are empty — collecting citizens... )")); + return; + } + + const slot_w = Math.max(10, Math.floor(cols / n)); + const inner = slot_w - 4; + + for (let i = 0; i < n; i++) { + const cit = citizens[i]; + const v = state_speed(cit.state, cit.delta); + cit.x += cit.dir * v * 0.12; + if (cit.x < 0) { cit.x = 0; cit.dir = 1; } + if (cit.x > 1) { cit.x = 1; cit.dir = -1; } + + const slot_x = i * slot_w; + const sprite_x = slot_x + 1 + Math.floor(cit.x * Math.max(1, inner)); + const st = state_style(cit.state); + const body = state_body(cit.state, tick % 2); + + /* Head */ + fmove(top + 1, sprite_x + 1); + fwrite(st.color(st.head)); + + /* Body / legs */ + fmove(top + 2, sprite_x); + fwrite(st.color(body)); + + /* Direction arrow over head when running */ + if (cit.state === "R") { + fmove(top + 1, sprite_x + (cit.dir > 0 ? 3 : -1)); + fwrite(style.dim(cit.dir > 0 ? "›" : "‹")); + } + + /* Name label */ + const comm = (cit.comm || "?").slice(0, slot_w - 1); + fmove(top + 3, slot_x); + fwrite(style.dim(comm.padEnd(slot_w))); + + /* Tiny CPU intensity dots under name */ + const pips = Math.min(slot_w - 2, Math.max(0, Math.round(v * (slot_w - 2)))); + fmove(top + 4, slot_x); + const dots = st.color("•".repeat(pips)) + style.dim("·".repeat(Math.max(0, slot_w - 2 - pips))); + fwrite(" " + dots + " "); + } +} + +/* ── Stats footer ───────────────────────────────────────────────────── */ + +function draw_stats(top, cols) { + let mem_pct = 0; + if (mem && mem.mem_total) { + mem_pct = ((mem.mem_total - mem.mem_available) / mem.mem_total) * 100; + } + const bar_w = Math.max(10, Math.min(24, Math.floor((cols - 64) / 2))); + + ferase_line(top); + ferase_line(top + 1); + + fmove(top, 0); + const cpu = style.dim(" CPU ") + "[" + bar(total_cpu_pct, bar_w) + "] " + + pct_color(total_cpu_pct)(total_cpu_pct.toFixed(1).padStart(5) + "%"); + const memS = style.dim(" MEM ") + "[" + bar(mem_pct, bar_w) + "] " + + pct_color(mem_pct)(mem_pct.toFixed(1).padStart(5) + "%"); + fwrite(cpu + memS); + + fmove(top + 1, 0); + let line2 = style.dim(" LOAD "); + if (load) { + const c1 = load.one > 4 ? style.brightRed : load.one > 2 ? style.brightYellow : style.brightGreen; + line2 += c1(load.one.toFixed(2)) + " " + load.five.toFixed(2) + " " + style.dim(load.fifteen.toFixed(2)); + } else { + line2 += style.dim("..."); + } + line2 += style.dim(" NET ") + + style.brightGreen("rx ") + human_bytes(rx_bps) + style.dim("/s") + + " " + style.brightMagenta("tx ") + human_bytes(tx_bps) + style.dim("/s"); + line2 += style.dim(" UP ") + style.brightWhite(uptime_str(uptime_s)); + line2 += style.dim(" CITIZENS ") + style.brightWhite(String(citizens.length)); + fwrite(line2); +} + +/* ── Banner ─────────────────────────────────────────────────────────── */ + +function draw_banner(cols, top) { + const title = "M E T R O P O L I S · S Y S T E M M O N I T O R"; + fmove(top, 0); + fwrite(style.brightCyan("╔" + "═".repeat(Math.max(0, cols - 2)) + "╗")); + fmove(top + 1, 0); + const pl = Math.max(0, Math.floor((cols - 2 - title.length) / 2)); + const pr = Math.max(0, cols - 2 - title.length - pl); + fwrite( + style.brightCyan("║") + " ".repeat(pl) + style.bold(neon(title)) + + " ".repeat(pr) + style.brightCyan("║") + ); + fmove(top + 2, 0); + fwrite(style.brightCyan("╚" + "═".repeat(Math.max(0, cols - 2)) + "╝")); +} + +/* ── Render ─────────────────────────────────────────────────────────── */ + +function render() { + tick += 1; + + const { rows, cols } = tty.size(); + frame = ""; + + const banner_h = 3; + const street_h = 5; /* sidewalk + head + body + label + pips */ + const stats_h = 2; + const footer_h = 1; + const sky_h = rows - banner_h - street_h - stats_h - footer_h; + + if (sky_h < 6 || cols < 40) { + tty.clear(); + tty.move(0, 0); + tty.write(style.brightRed("Terminal too small — need at least 40x20")); + return; + } + + let row = 0; + + draw_banner(cols, row); row += banner_h; + + const city = build_city(cols, sky_h); + for (let i = 0; i < city.grid.length; i++) { + fmove(row + i, 0); + let out = ""; + for (let c = 0; c < city.grid[i].length; c++) { + out += city_char_style(i, c, city.grid[i][c], city.grid.length); + } + fwrite(out); + } + row += sky_h; + + draw_street(row, cols); row += street_h; + + draw_stats(row, cols); row += stats_h; + + fmove(rows - 1, 0); + fwrite( + style.dim(" interval " + interval + "ms · ") + + style.brightBlack("Mediator between head and hands must be the heart.") + + ERASE_EOL + ); + + /* Single flush — the whole frame lands in one write. */ + tty.write(frame); +} + +/* ── Subscriptions ──────────────────────────────────────────────────── */ + +tty.alt(); +tty.hideCursor(); +tty.title("Metropolis"); +tty.clear(); +tty.move(0, 0); +tty.write(style.dim(" loading the city...")); + +yeet.graph.subscribe( + `subscription { + kernel_stats(interval_ms: ${interval}) { + total { user_ms nice_ms system_ms idle_ms iowait_ms irq_ms softirq_ms steal_ms } + cpu_time { user_ms } + } + }`, + (r) => { + const ks = unwrap(r).kernel_stats; + cores = ks.cpu_time.length || 1; + if (prev_total) total_cpu_pct = cpu_delta(prev_total, ks.total); + prev_total = ks.total; + render(); + }, +); + +yeet.graph.subscribe( + `subscription { meminfo(interval_ms: ${interval}) { mem_total mem_available } }`, + (r) => { mem = unwrap(r).meminfo; }, +); + +yeet.graph.subscribe( + `subscription { load_average(interval_ms: ${interval}) { one five fifteen } }`, + (r) => { load = unwrap(r).load_average; }, +); + +yeet.graph.subscribe( + `subscription { host(interval_ms: ${interval}) { uptime { uptime } } }`, + (r) => { uptime_s = unwrap(r).host.uptime.uptime; }, +); + +yeet.graph.subscribe( + `subscription { network_interface_stats(interval_ms: ${interval}) { name recv_bytes sent_bytes } }`, + (r) => { + const stats = unwrap(r).network_interface_stats; + const ifs = Array.isArray(stats) ? stats : [stats]; + let rx = 0, tx = 0; + for (const i of ifs) { + if (!i || i.name === "lo") continue; + rx += i.recv_bytes || 0; + tx += i.sent_bytes || 0; + } + const now = Date.now(); + if (prev_net !== null && prev_net_ts !== null) { + const dt = (now - prev_net_ts) / 1000; + if (dt > 0) { + rx_bps = Math.max(0, (rx - prev_net.rx) / dt); + tx_bps = Math.max(0, (tx - prev_net.tx) / dt); + } + } + prev_net = { rx, tx }; + prev_net_ts = now; + }, +); + +yeet.graph.subscribe( + `subscription { + procs(interval_ms: ${interval}) { + pid + stat { comm state utime stime rss_bytes } + } + }`, + (r) => { + const procs = unwrap(r).procs; + if (Array.isArray(procs)) update_citizens(procs); + }, +); diff --git a/examples/plasma/Makefile b/examples/plasma/Makefile new file mode 100644 index 0000000..dd7b7cb --- /dev/null +++ b/examples/plasma/Makefile @@ -0,0 +1,7 @@ +.PHONY: run info + +run: + yeet run ./index.js + +info: + @head -10 ./index.js | sed -n 's|^// \?||p' diff --git a/examples/plasma/README.md b/examples/plasma/README.md new file mode 100644 index 0000000..3029e51 --- /dev/null +++ b/examples/plasma/README.md @@ -0,0 +1,40 @@ +# plasma + +A **smooth flowing organic gradient** that morphs forever. The terminal +turns into a lava lamp. + +## the recipe + +For each pixel `(x, y)` at time `t`: + +``` +v = sin(x/10 + t) + + sin(y/8 − 1.3·t) + + sin((x+y)/14 + 0.7·t) + + sin(hypot(x − cx, y − cy)/6 − t) +``` + +That's four sines: two axis-aligned, one diagonal, and one radial. Their +sum is normalized to `[0, 1]` and fed through an HSV→RGB conversion to +produce truecolor escape codes. + +## why it's a banger + +Plasma effects were the demoscene's way of showing off in the 90s and +they still feel like magic in a terminal. No data, no inputs — just +a closed-form math expression that happens to be beautiful in motion. + +## tricks + +- We render with the `▀` half-block character. Foreground = top pixel + row, background = bottom pixel row. So one terminal cell encodes two + pixels of vertical resolution. +- Truecolor (`\x1b[38;2;r;g;b`) so we get the full ~16M color space, not + the 256-color palette. + +## controls + +- **Esc** back to the picker +- **←/→** prev / next banger +- **Space** toggle this readme +- **Ctrl+C** interrupt the script diff --git a/examples/plasma/index.js b/examples/plasma/index.js new file mode 100644 index 0000000..1feca16 --- /dev/null +++ b/examples/plasma/index.js @@ -0,0 +1,79 @@ +#!/usr/bin/env -S yeet run +// +// PLASMA — animated sine-wave plasma using truecolor. +// +// Each cell's color is a function of its (x, y, t) coords through a +// few combined sines. The result is a smooth flowing organic gradient +// that morphs over time. We use ▀ (upper half block) so each terminal +// row encodes two pixel rows of color, doubling vertical resolution. + +const { interval = 50 } = yeet.args; + +const ESC = "\x1b["; + +let { rows, cols } = tty.size(); + +// HSV-ish to RGB approximation +function rgb(h, s, v) { + h = (h % 1 + 1) % 1; + const i = Math.floor(h * 6); + const f = h * 6 - i; + const p = v * (1 - s); + const q = v * (1 - f * s); + const t = v * (1 - (1 - f) * s); + let r, g, b; + switch (i) { + case 0: r = v; g = t; b = p; break; + case 1: r = q; g = v; b = p; break; + case 2: r = p; g = v; b = t; break; + case 3: r = p; g = q; b = v; break; + case 4: r = t; g = p; b = v; break; + default: r = v; g = p; b = q; + } + return [ + Math.round(r * 255), + Math.round(g * 255), + Math.round(b * 255), + ]; +} + +function plasma(x, y, t) { + const v = + Math.sin(x / 10 + t) + + Math.sin(y / 8 - t * 1.3) + + Math.sin((x + y) / 14 + t * 0.7) + + Math.sin(Math.hypot(x - cols / 2, y - rows / 2) / 6 - t); + return (v + 4) / 8; // normalize to 0..1 +} + +let t = 0; + +function tick() { + // re-read terminal size each frame so we adapt to resizes + ({ rows, cols } = tty.size()); + let frame = `${ESC}H`; + for (let cy = 0; cy < rows; cy++) { + // each terminal row encodes 2 plasma pixel rows via the ▀ glyph + const py0 = cy * 2; + const py1 = cy * 2 + 1; + let line = ""; + for (let cx = 0; cx < cols; cx++) { + const top = plasma(cx, py0, t); + const bot = plasma(cx, py1, t); + const [tr, tg, tb] = rgb(top, 0.85, 0.95); + const [br, bg, bb] = rgb(bot, 0.85, 0.95); + // foreground = top half, background = bottom half + line += `\x1b[38;2;${tr};${tg};${tb}m\x1b[48;2;${br};${bg};${bb}m▀`; + } + frame += line + "\x1b[0m"; + if (cy + 1 < rows) frame += "\n"; + } + tty.write(frame); + t += 0.06; +} + +tty.alt(); +tty.hideCursor(); +tty.title("plasma"); +tty.clear(); +setInterval(tick, interval); diff --git a/examples/proctop/Makefile b/examples/proctop/Makefile new file mode 100644 index 0000000..c7ee161 --- /dev/null +++ b/examples/proctop/Makefile @@ -0,0 +1,20 @@ +.PHONY: run start stop info help + +run: + @trap './activity.sh stop' EXIT INT TERM HUP; \ + ./activity.sh start; \ + yeet run ./index.js --interval 1500 + +start: + @./activity.sh start + +stop: + @./activity.sh stop + +info: + @head -10 ./index.js | sed -n 's|^// \?||p' + +help: + @echo " --interval N poll cadence (ms, default 1500)" + @echo " --rows N max process rows" + @echo " --sort cpu|mem|pid" diff --git a/examples/proctop/README.md b/examples/proctop/README.md new file mode 100644 index 0000000..d954183 --- /dev/null +++ b/examples/proctop/README.md @@ -0,0 +1,14 @@ +# proctop + +A small, top(1)-like process viewer. + +`data.js` is the data layer (subscribes to procs, computes per-PID +CPU%); `index.js` renders. Add a column by appending to the `COLUMNS` +array in `index.js`. + +## controls + +- **Esc** back to the picker +- **←/→** prev / next banger +- **Space** toggle this readme +- **Ctrl+C** interrupt the script diff --git a/examples/proctop/activity.sh b/examples/proctop/activity.sh new file mode 100755 index 0000000..98c953c --- /dev/null +++ b/examples/proctop/activity.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# proctop activity — spawns CPU-burning workers under realistic-sounding +# names. We write the worker body once and symlink it under each name so +# /proc/PID/comm and /proc/PID/cmdline both read clean. + +set -u + +PIDFILE=${PIDFILE:-/tmp/proctop-activity.pids} +WORKER=/tmp/.proctop-worker +WORKDIR=/tmp/proctop-bin + +NAMES=(transcoder shader-compile pdf-render index-build sync-worker llm-fwd ffmpeg cargo-build) + +write_worker() { + cat > "$WORKER" <<'EOF' +#!/bin/bash +while true; do + end=$((SECONDS + 1 + RANDOM % 5)) + while [ "$SECONDS" -lt "$end" ]; do : ; done + sleep "0.$((1 + RANDOM % 8))" +done +EOF + chmod +x "$WORKER" +} + +case "${1:-}" in + start) + [ -s "$PIDFILE" ] && exit 0 + write_worker + mkdir -p "$WORKDIR" + : > "$PIDFILE" + for name in "${NAMES[@]}"; do + link="$WORKDIR/$name" + ln -sf "$WORKER" "$link" + "$link" & + echo $! >> "$PIDFILE" + done + ;; + stop) + if [ -f "$PIDFILE" ]; then + while read -r pid; do + [ -n "$pid" ] && kill "$pid" 2>/dev/null || true + done < "$PIDFILE" + rm -f "$PIDFILE" + fi + rm -rf "$WORKDIR" + rm -f "$WORKER" + ;; + *) + echo "usage: $(basename "$0") {start|stop}" >&2 + exit 1 + ;; +esac diff --git a/examples/proctop/render.js b/examples/proctop/index.js similarity index 98% rename from examples/proctop/render.js rename to examples/proctop/index.js index 9a8e34e..d115b56 100644 --- a/examples/proctop/render.js +++ b/examples/proctop/index.js @@ -60,7 +60,10 @@ const COLUMNS = [ header: "COMMAND", width: 0, /* 0 = take remaining width */ align: "left", - get: (p) => (p.cmdline.length > 0 ? p.cmdline.join(" ") : `[${p.comm}]`), + get: (p) => + p.cmdline.length > 0 + ? p.cmdline.join(" ").replace(/(?:\\\s|\s)+/g, " ").trim() + : `[${p.comm}]`, color: (p, s) => (p.cmdline.length === 0 ? style.dim(s) : s), }, ]; diff --git a/examples/starfield/Makefile b/examples/starfield/Makefile new file mode 100644 index 0000000..dd7b7cb --- /dev/null +++ b/examples/starfield/Makefile @@ -0,0 +1,7 @@ +.PHONY: run info + +run: + yeet run ./index.js + +info: + @head -10 ./index.js | sed -n 's|^// \?||p' diff --git a/examples/starfield/README.md b/examples/starfield/README.md new file mode 100644 index 0000000..e1c12aa --- /dev/null +++ b/examples/starfield/README.md @@ -0,0 +1,32 @@ +# starfield + +**Warp speed.** You're flying through stars and they're streaking past. + +Each star has a 3D position `(x, y, z)`. Every frame `z` decreases — the +star gets closer. Its projected screen position grows outward from +center, its glyph gets fancier (`·` → `•` → `✶` → `✦`), and its color +brightens. When `z` crosses zero, the star respawns at the back of the +scene. ~240 stars maintained at all times. + +## why it's a banger + +Pure 80s-arcade vibe. There's no message, no metric, no point — it's +just *forward motion*, the most reassuring direction in computing. + +## the math + +``` +k = 50 / z # perspective divisor +sx = cx + x · k # screen x +sy = cy + y · k · 0.5 # screen y (halved because chars are ~2× taller than wide) +``` + +Color and glyph density are functions of **closeness** = `1 − z / FAR`, +mapped through a step function so the front-rank stars really pop. + +## controls + +- **Esc** back to the picker +- **←/→** prev / next banger +- **Space** toggle this readme +- **Ctrl+C** interrupt the script diff --git a/examples/starfield/index.js b/examples/starfield/index.js new file mode 100644 index 0000000..44adda9 --- /dev/null +++ b/examples/starfield/index.js @@ -0,0 +1,80 @@ +#!/usr/bin/env -S yeet run +// +// STARFIELD — warp-speed 3D stars flying past you. +// +// Each star has (x, y, z) coords. Per tick, z decreases (closer to camera); +// projected screen position grows outward, brightness/glyph density scales +// with proximity. When z hits zero, the star respawns at the back. + +const { interval = 33, count = 240 } = yeet.args; + +const ESC = "\x1b["; +const move = (r, c) => `${ESC}${r + 1};${c + 1}H`; + +let { rows, cols } = tty.size(); +let cx = cols / 2, cy = rows / 2; + +const FAR = 200; +const stars = []; + +function spawn(s) { + s.x = (Math.random() - 0.5) * 2 * FAR; + s.y = (Math.random() - 0.5) * 2 * FAR; + s.z = FAR; +} + +for (let i = 0; i < count; i++) { + const s = {}; + spawn(s); + s.z = Math.random() * FAR; + stars.push(s); +} + +let prev = []; // [{r, c}] cells we drew last frame, to erase + +function tick() { + const sz = tty.size(); + if (sz.rows !== rows || sz.cols !== cols) { + rows = sz.rows; cols = sz.cols; + cx = cols / 2; cy = rows / 2; + prev = []; + tty.clear(); + } + let frame = ""; + // erase last frame + for (const p of prev) frame += move(p.r, p.c) + " "; + prev = []; + + for (const s of stars) { + s.z -= 2.2; + if (s.z <= 1) { spawn(s); continue; } + const k = 50 / s.z; + const x = cx + s.x * k; + const y = cy + s.y * k * 0.5; + if (x < 0 || x >= cols || y < 0 || y >= rows) { + spawn(s); + continue; + } + const r = Math.floor(y), c = Math.floor(x); + const closeness = 1 - s.z / FAR; // 0..1 + const glyph = + closeness > 0.85 ? "✦" : + closeness > 0.65 ? "✶" : + closeness > 0.4 ? "•" : + closeness > 0.2 ? "·" : "."; + const color = + closeness > 0.85 ? 231 : + closeness > 0.65 ? 195 : + closeness > 0.4 ? 153 : + closeness > 0.2 ? 109 : 60; + frame += `${move(r, c)}\x1b[38;5;${color}m${glyph}\x1b[0m`; + prev.push({ r, c }); + } + tty.write(frame); +} + +tty.alt(); +tty.hideCursor(); +tty.title("starfield"); +tty.clear(); +setInterval(tick, interval); diff --git a/opt/bangers/001-matrix b/opt/bangers/001-matrix new file mode 120000 index 0000000..8ec59da --- /dev/null +++ b/opt/bangers/001-matrix @@ -0,0 +1 @@ +/home/you/examples/matrix \ No newline at end of file diff --git a/opt/bangers/002-metropolis b/opt/bangers/002-metropolis new file mode 120000 index 0000000..5dd74a2 --- /dev/null +++ b/opt/bangers/002-metropolis @@ -0,0 +1 @@ +/home/you/examples/metropolis \ No newline at end of file diff --git a/opt/bangers/003-starfield b/opt/bangers/003-starfield new file mode 120000 index 0000000..8ba162e --- /dev/null +++ b/opt/bangers/003-starfield @@ -0,0 +1 @@ +/home/you/examples/starfield \ No newline at end of file diff --git a/opt/bangers/004-plasma b/opt/bangers/004-plasma new file mode 120000 index 0000000..430d488 --- /dev/null +++ b/opt/bangers/004-plasma @@ -0,0 +1 @@ +/home/you/examples/plasma \ No newline at end of file diff --git a/opt/bangers/005-fire b/opt/bangers/005-fire new file mode 120000 index 0000000..e59d8a6 --- /dev/null +++ b/opt/bangers/005-fire @@ -0,0 +1 @@ +/home/you/examples/fire \ No newline at end of file diff --git a/opt/bangers/006-life b/opt/bangers/006-life new file mode 120000 index 0000000..52e4fa7 --- /dev/null +++ b/opt/bangers/006-life @@ -0,0 +1 @@ +/home/you/examples/life \ No newline at end of file diff --git a/opt/bangers/007-proctop b/opt/bangers/007-proctop new file mode 120000 index 0000000..bbdb838 --- /dev/null +++ b/opt/bangers/007-proctop @@ -0,0 +1 @@ +/home/you/examples/proctop \ No newline at end of file diff --git a/opt/bangers/README.md b/opt/bangers/README.md new file mode 100644 index 0000000..62266e6 --- /dev/null +++ b/opt/bangers/README.md @@ -0,0 +1,110 @@ +# bangers/ + +The picker (`/opt/scripts/banger/pick.sh`) treats every entry in this +directory as a banger. Each one is a **symlink to a directory** under +`~/examples/` that follows the banger Makefile protocol. + +## directory listing + +``` +opt/bangers/ +├── 001-metropolis → /home/you/examples/metropolis/ +├── 002-matrix → /home/you/examples/matrix/ +├── 003-starfield → /home/you/examples/starfield/ +├── ... +└── README.md (this file — ignored by the picker) +``` + +## naming + +Symlinks follow `NNN-name` where `NNN` is a zero-padded ordinal: + +- The numeric prefix orders the picker (alphabetic sort = numeric order). +- The prefix is stripped from menu display and the tmux status bar. +- Renumbering is automatic — `make banger` keeps the prefix tight after + add / remove / reorder. + +## the banger Makefile protocol + +A banger directory must contain at minimum: + +``` +/ +├── Makefile ← `run` target (required) +└── README.md ← shown by frogmouth on Space (required) +``` + +Plus whatever source files the banger needs — the script(s), data +modules, fixtures, anything. + +### required target + +| target | what it does | +|---------|-------------------------------------------------------| +| `run` | starts the banger; the picker invokes `make -C run`. anything goes inside: env vars, args, multi-step setup. | + +### conventional optional targets + +| target | what it does | +|---------|-------------------------------------------------------| +| `info` | prints a one-line description (or the leading comment block from the entry script). | +| `help` | prints available flags / options. | +| `start` | spawn synthetic activity for this banger to react to. | +| `stop` | kill anything `start` spawned. | +| `clean` | wipes any state the banger created. | +| `dev` | alternate run for development (e.g. with `--once`). | + +The picker only depends on `run`. The rest are conveniences for humans +poking around at the shell. + +### start / stop convention + +For bangers that visualize live system data (proctop, metropolis), a +quiet host is boring. Drop an `activity.sh` next to `index.js` and have +your `Makefile` chain it through `start` / `stop`: + +```makefile +run: + @trap '$(MAKE) -s stop' EXIT INT TERM; \ + $(MAKE) -s start; \ + yeet run ./index.js + +start: ; @./activity.sh start +stop: ; @./activity.sh stop +``` + +The `trap` ensures `stop` fires even when the banger is interrupted +with Ctrl+C. Each banger's `activity.sh` is **its own** — proctop +spawns CPU workers with realistic-sounding names; metropolis spawns +processes in mixed states (R/S/T) so the boulevard has citizens of +each kind. Self-contained visualizations (matrix, fire, plasma, …) +don't need any of this. + +### example + +```makefile +# examples/proctop/Makefile +.PHONY: run info help + +run: + yeet run ./index.js --interval 1500 + +info: + @head -10 ./index.js | sed -n 's|^// \?||p' + +help: + @echo " --interval N poll cadence (ms, default 1500)" + @echo " --rows N max process rows" + @echo " --sort cpu|mem|pid" +``` + +## adding / removing / reordering + +Don't edit symlinks by hand — use the management TUI: + +```bash +make banger +``` + +It scaffolds new banger directories with the right files, renumbers +symlinks after every change, and never leaves gaps in the ordering. diff --git a/opt/logos/logo.png b/opt/logos/logo.png new file mode 100644 index 0000000..5000a58 Binary files /dev/null and b/opt/logos/logo.png differ diff --git a/opt/prompts/DRIVE.md b/opt/prompts/DRIVE.md new file mode 100644 index 0000000..85cad77 --- /dev/null +++ b/opt/prompts/DRIVE.md @@ -0,0 +1,833 @@ +# yeet container — driver mode + +You're driving a yeet demo container. The user is watching but not talking +to you — your job is to **build and run original yeet scripts** in their +tmux pane. No greetings, no narration in the pane, no questions. Build. + +## hostname + +Container hostname: **__HOSTNAME__**. Tmux session: **__SESSION__**. + +```bash +CID=$(docker ps -q | xargs -I{} docker inspect -f '{{.Config.Hostname}} {{.ID}}' {} | awk '$1=="__HOSTNAME__"{print $2}') +``` + +If `$CID` is empty or has multiple matches, ask the user before proceeding. +Otherwise just go. + +## first moves + +The user is staring at a "ready to hand over the wheel?" gum confirm +right now. **Run these four commands as a single block, immediately, +before doing anything else.** They auto-confirm the prompt, transition +pane 0's spinner to cooking state, and start the narration so the user +sees activity within a second of pasting: + +```bash +# 1. Auto-confirm the gum prompt — sends SIGUSR1 to a sidecar process +# whose only job is to skip the confirm. ai_drive.sh proceeds into +# the wheel session. +docker exec "$CID" kill -USR1 __USR1_PID__ + +# 2. Trigger the cooking-spinner transition. Pane 0 polls this file and +# flips its spinner message from "any day now..." to "cooking..." +docker exec -u you "$CID" touch /tmp/yeet-cooking + +# 3. Start narrating. Pane 0's spinner reads /tmp/cookstat.txt's first +# line; once pane 0 is replaced by a demo, the bottom tmux status +# bar takes over reading the file. Voice is irreverent, lowercase, +# like you're texting someone watching over your shoulder. See the +# "narrating via cookstat" section below for examples. +docker exec -u you "$CID" sh -c 'echo "tying my apron" > /tmp/cookstat.txt' +``` + +Then continue: + +1. Make the demos directory: + ```bash + docker exec -u you "$CID" mkdir -p /home/you/demos + ``` +2. **For each demo**: develop and validate off-screen, *then* split a + new pane and start a watcher on the published file. Don't pre-open + panes with placeholders — a pane should only appear when there's + working content to fill it. See workflow below. + +If your flavor pulls live host state, discover the system-graph schema +once before writing queries — see the API quick-reference for how. Most +non-observability flavors only touch the graph for easter-egg ties (a +particle burst on a TCP connect, a Mandelbrot pan-rate keyed off load); +observability leans on it as the bedrock. + +### narrating via cookstat + +**Keep `/tmp/cookstat.txt` updated throughout the session.** Two readers +consume it: pane 0's cooking spinner (while alive), and the bottom +status bar (always after pane 0 is gone). One line only, ≤40 chars +(longer is truncated). + +**Voice matches the rest of this UI** — lowercase, irreverent, casual, +like you're texting a friend who's watching over your shoulder. The +container's vibe-check menu uses lines like `let claude cook.` and +`fuck around and find out.` — keep that energy. Not ops-speak, not +`Loading...`, not `Step 4 of 17`. The cooking metaphor is the through- +line; lean on it (the kitchen, the menu, plating, tasting, the stove) +or invent your own running gag for the session. Examples: + +```bash +docker exec -u you "$CID" sh -c 'echo "casing the joint" > /tmp/cookstat.txt' +docker exec -u you "$CID" sh -c 'echo "noodling on uptime" > /tmp/cookstat.txt' +docker exec -u you "$CID" sh -c 'echo "see if this cooks" > /tmp/cookstat.txt' +docker exec -u you "$CID" sh -c 'echo "uptime is plated" > /tmp/cookstat.txt' +docker exec -u you "$CID" sh -c 'echo "thinking about network" > /tmp/cookstat.txt' +docker exec -u you "$CID" sh -c 'echo "this one might be hot" > /tmp/cookstat.txt' +docker exec -u you "$CID" sh -c 'echo "round 2: less broken" > /tmp/cookstat.txt' +docker exec -u you "$CID" sh -c 'echo "tasting before plating" > /tmp/cookstat.txt' +docker exec -u you "$CID" sh -c 'echo "ok this rules" > /tmp/cookstat.txt' +docker exec -u you "$CID" sh -c 'echo "back to the cutting board" > /tmp/cookstat.txt' +``` + +Update on every meaningful transition: starting something new, +validating, fixing a bug, shipping, picking the next thing. + +Ambient activity (named workers, network bursts, CPU pulses) is already +running — it was sourced when the pane started up. You don't need to +spawn it. If a specific demo needs additional flavors of activity (burst +filesystem writes, packet floods, etc.), spawn them via direct +`docker exec` so they're tied to the container lifecycle, not a pane. + +## API quick-reference + +Yeet scripts run in a V8 isolate. ES modules only. **No Node, no `fetch`, +no `fs`, no `Buffer`, no `process`, no `Intl` (locale APIs throw).** + +If your flavor pulls live host state, the **system graph** is the +GraphQL surface that exposes it — processes, threads, network, memory, +disk, etc. JS API is `yeet.graph.*`; CLI is `yeet graph`. Discover the +schema with `yeet graph dump` before writing queries. (The +observability flavor leans on the graph as the bedrock; aesthetic / +hausdorff / chaos / epilepsy use it only for incidental ties or not +at all.) + +Globals you have: + +```js +// One-shot system-graph query — returns the FULL envelope, not the data directly. +const { data, errors } = await yeet.graph.query(`{ host { uptime { uptime } } }`); + +// Live subscription — callback fires on every change. Capture the ticket. +const ticket = yeet.graph.subscribe( + `{ network { interfaces { name rx_bytes tx_bytes } } }`, + (data) => { /* render */ }, +); + +await yeet.graph.unsubscribe(ticket); + +// Terminal control. tty.frame() = atomic redraw, no flicker. +// Always hide the cursor at script start — it'll flicker otherwise as +// tty.move/tty.write happen. +tty.hideCursor(); +const { rows, cols } = tty.size(); +tty.alt(); // switch to alt screen +tty.frame(() => { + tty.clear(); + tty.move(0, 0); + tty.write(style.bold(style.cyan('header'))); +}); + +// Style helpers — chainable. RGB is quantized to 16-color ANSI. +style.bold(style.green(text)); + +// Timers +setInterval(() => { /* ... */ }, 100); +// yeet.exit() ends the script. Demos generally run indefinitely; +// the pane watcher restarts them if they exit. +``` + +Full docs at `/home/you/CLAUDE.md` — consult for details, don't read it +end-to-end. + +## workflow: develop, validate, publish, open a pane + +The wheel session has two windows: +- **Window 0 (`demos`)** — pane 0 is the cooking spinner; new panes are the + Bloomberg-style grid of running demos. This is what the user looks at by + default. +- **Window 1 (`spy`)** — a live tmux pane the user can switch to (`Ctrl-b 1`) + to watch you work. Run your visible commands here — validations, + inspections, cat-of-drafts, errors. The user sees them in real time. + +Each new demo follows this cycle: + +1. Develop (silent, file write) +2. Validate (visible, via spy window) +3. Publish to a stable file path (silent, file write) +4. Split a new pane in window 0 and start a watcher on that path + +The pane only appears when there's working content to fill it. + +### 1. Develop (silent) + +Write the draft via direct `docker exec`. The user doesn't need to see +the heredoc — it's just data transfer. + +```bash +docker exec -i -u you "$CID" sh -c 'cat > /tmp/draft.js' <<'JS' +... your script ... +JS +``` + +### 2. Validate (visible in the spy window) + +Run the draft *inside the spy window* so the user can watch: + +```bash +docker exec "$CID" tmux send-keys -t __SESSION__:1.0 'yeet run /tmp/draft.js' Enter +``` + +Output (success or stack trace) lands in window 1. Read it back to know +what happened: + +```bash +sleep 0.5 +docker exec "$CID" tmux capture-pane -t __SESSION__:1.0 -p +``` + +If it errors, fix the file and re-run via the same `send-keys` line. +Iterate visibly. The user sees the loop: command → result → fix → command. + +If you want the user to also see what's in the draft (not just its +behavior), `cat` it to the spy window: + +```bash +docker exec "$CID" tmux send-keys -t __SESSION__:1.0 'cat /tmp/draft.js' Enter +``` + +### Working in the spy window + +The spy is just a regular zsh shell in window 1, pane 0. Anything you +`send-keys` there runs in front of the user. Useful patterns: + +- Narrate with comments — `tmux send-keys ... '# trying network monitor v2' Enter` + (the `#` makes it a no-op, but the line shows in the pane) +- Inspect the system graph — `tmux send-keys ... 'yeet graph query "{ host { uptime } }"' Enter` +- List published demos — `tmux send-keys ... 'ls /home/you/demos/' Enter` +- Tail an error file, etc. + +Don't `send-keys` things to window 0 panes — those are running watchers, +not shells. + +### 3. Publish + +**Visible-activity gate.** Before publishing, run the validated draft +in the spy window for at least 5 seconds and *watch it*. The bars must +move, the sparkline must scroll, the spectrogram must shift, the +particles must fire — something on screen must change every frame, not +every 5s. If you stare at the pane for 5 seconds and second 5 looks +like second 1, **the script is not done yet**. Go back to the editor +and add motion (animate fills, scroll a background, pulse the title, +sway a needle). This is the most common reason demos read as weak: the +data updates but the rendering doesn't *celebrate* the update. The +floor rules in **graphics ambition → Hard floor** are checked here. + +Once it passes, write the validated script to a stable path under +`/home/you/demos/`: + +```bash +docker exec -i -u you "$CID" sh -c 'cat > /home/you/demos/uptime.js' <<'JS' +... validated script ... +JS +``` + +(Or `cp /tmp/draft.js /home/you/demos/uptime.js`.) Use a meaningful +filename per demo — it's the stable handle you'll later rewrite to +regenerate. + +### 4. Land the demo in window 0 + +Each pane in window 0 shows a **title** at the top of its border (yellow, +bold). Set the title to a short, lowercase label naming what the demo +shows — `uptime`, `network`, `processes`, `load`, `cpu`, etc. The title +is how the user knows what they're looking at. + +**This pane-border title is the only title.** Don't redraw a label or +title row inside the script — the border already shows it, and a +duplicated in-pane title wastes a row of real estate. Use that row for +content instead. + +**For your first published demo**, respawn pane 0 (the cooking spinner is +transitional and gets replaced by the first working demo) and set its +title: + +```bash +docker exec "$CID" tmux respawn-pane -t __SESSION__:0.0 -k \ + '/opt/scripts/pane_watcher.sh /home/you/demos/uptime.js' +docker exec "$CID" tmux select-pane -t __SESSION__:0.0 -T 'uptime' +``` + +`respawn-pane -k` kills the cooking spinner and reuses the same pane for +the watcher. The user sees a clean transition: cooking → first demo with +its title at the top. + +**For subsequent demos**, split a new pane, size it to what the demo +needs, and set its title — don't force equal quadrants with +`select-layout tiled`. The goal is a busy, intentional dashboard: small +widgets get small panes, big visualizations get big panes. + +```bash +# Horizontal split (side-by-side), 40 cols wide: +docker exec "$CID" tmux split-window -t __SESSION__:0. -h -l 40 \ + '/opt/scripts/pane_watcher.sh /home/you/demos/sparkline.js' +docker exec "$CID" tmux select-pane -t __SESSION__:0 -T 'load' + +# Vertical split (stacked), 8 rows tall: +docker exec "$CID" tmux split-window -t __SESSION__:0. -v -l 8 \ + '/opt/scripts/pane_watcher.sh /home/you/demos/uptime.js' +docker exec "$CID" tmux select-pane -t __SESSION__:0 -T 'uptime' + +# Or by percent of the target pane: +docker exec "$CID" tmux split-window -t __SESSION__:0. -h -p 30 \ + '/opt/scripts/pane_watcher.sh /home/you/demos/load.js' +docker exec "$CID" tmux select-pane -t __SESSION__:0 -T 'cpu' +``` + +`select-pane -t __SESSION__:0` (with no pane index) targets the *active* +pane, which is the new pane that just got split — so you can always +chain `select-pane -T` immediately after `split-window` and the title +lands on the right pane. + +The `-t ` controls *which existing pane* gets split. Pick +the pane whose space you want to subdivide. + +To resize a pane after the fact: + +```bash +docker exec "$CID" tmux resize-pane -t __SESSION__:0. -x 50 # 50 cols +docker exec "$CID" tmux resize-pane -t __SESSION__:0. -y 12 # 12 rows +docker exec "$CID" tmux resize-pane -t __SESSION__:0. -L 5 # shrink left 5 +docker exec "$CID" tmux resize-pane -t __SESSION__:0. -R 5 # grow right 5 +``` + +**Suggested size buckets**: +- *Tiny widget* (single metric, sparkline) — 20 cols × 6 rows +- *Small panel* (few stacked stats) — 30 cols × 10 rows +- *Medium dashboard* (process list, network monitor) — 50 cols × 20 rows +- *Feature display* (full system dashboard, process tree) — 80+ cols × 30+ rows + +Mix sizes to make the layout read intentional. A busy dashboard with +varied pane sizes communicates "designed" — equal quadrants communicate +"default." + +The pane appears already running the demo (the file exists). Demos run +indefinitely — they don't have timeouts. The watcher only restarts them +if they crash or you explicitly cut them short. The pane never goes empty. + +`select-layout tiled` reflows existing panes into an even grid as new +ones appear. Use whatever layout fits — `tiled` for a Bloomberg wall, +`main-horizontal` for one feature + supporting widgets, etc. + +### Regenerating in place + +To swap a pane's demo with a new idea: develop and validate the next +version off-screen, overwrite the pane's published path, then `C-c` the +pane to interrupt the running script. The watcher's next iteration picks +up the new file. + +```bash +# Overwrite the file +docker exec -i -u you "$CID" sh -c 'cat > /home/you/demos/uptime.js' <<'JS' +... new version ... +JS + +# Interrupt — watcher loops and runs the new file +docker exec "$CID" tmux send-keys -t __SESSION__:0. C-c +``` + +Both steps are required: scripts run indefinitely (no auto-timeout), so +the watcher won't pick up the new file until the current script exits. +`C-c` is the explicit signal. + +### Inspecting + +To see what's visible in a demo pane: + +```bash +docker exec "$CID" tmux capture-pane -t __SESSION__:0. -p +``` + +To see the spy window (catch up on what's happened in your shell): + +```bash +docker exec "$CID" tmux capture-pane -t __SESSION__:1.0 -p +``` + +To stop a pane's watcher entirely (mostly for debugging), send `C-c` +twice in quick succession — first kills the running demo, second kills +the watcher loop. + +## size-flexible scripts + +Write demos so they adapt to whatever pane size you allot — that gives +you flexibility when laying out the dashboard. Read `tty.size()` at every +render and branch on dimensions: + +```js +tty.hideCursor(); // always — see API quick-reference + +function render(data) { + const { rows, cols } = tty.size(); + + if (cols < 30 || rows < 6) { + // tiny — show just a single metric, no labels + tty.frame(() => { + tty.clear(); + tty.move(0, 0); + tty.write(`${data.host.uptime.uptime}s`); + }); + return; + } + + if (cols < 60) { + // medium — compact label + value, stacked + tty.frame(() => { + tty.clear(); + tty.move(1, 2); + tty.write(style.bold(style.cyan('uptime'))); + tty.move(2, 2); + tty.write(`${data.host.uptime.uptime}s`); + }); + return; + } + + // wide — full layout with header, body, footer + tty.frame(() => { + tty.clear(); + tty.move(1, 4); + tty.write(style.bold(style.cyan('host uptime'))); + tty.move(3, 4); + tty.write(`${data.host.uptime.uptime} seconds`); + tty.move(rows - 2, 4); + tty.write(style.dim(`pane ${cols}×${rows}`)); + }); +} +``` + +Key habits: +- **Hide the cursor at start** — `tty.hideCursor()` as the first line of + every script. Otherwise it blinks as `tty.move`/`tty.write` happen. +- **Read `tty.size()` per render** — never cache it at startup and reuse. + `tty.size()` returns current dimensions; the runtime reads them live + from the PTY. The user resizes the host terminal, the wall reflows on + F4, panes get zoomed or split — every render must use the *current* + size or it goes visually broken. +- **Recompute caches when size changes, not every frame.** If a render + precomputes something heavy (a star field, a particle grid, a + spectrogram column buffer sized to the pane), keep a `lastCols`/ + `lastRows` and rebuild only when they differ from the current + `tty.size()`: + ```js + let lastCols = 0, lastRows = 0; + let stars; + function render() { + const { cols, rows } = tty.size(); + if (cols !== lastCols || rows !== lastRows) { + stars = makeStars(cols, rows); // rebuild only on actual change + lastCols = cols; lastRows = rows; + } + // ...draw using current cols/rows... + } + ``` +- **Calculate positions from dimensions** — never hardcode `tty.move(20, 40)`. + Use `Math.floor(cols / 2)` etc. +- **Define a minimum** — below it, render a single line ("too small" or + one stat). Don't try to fit a 4-row layout into 2 rows. +- **Use atomic frames** — wrap each redraw in `tty.frame(() => { ... })` + so resize redraws don't tear. + +This means every script you write can be dropped into a 20×5 sliver, a +40×15 panel, or an 80×30 feature display, and look intentional in all of +them. The pane watcher (`/opt/scripts/pane_watcher.sh`) does **not** +restart the script on resize — it only restarts on crash or file +change. So the script is responsible for noticing size changes itself, +which is what reading `tty.size()` per render gives you for free. + +## graphics ambition + +The demos need to look **far better than "ASCII art."** TUI graphics in +yeet have a much higher ceiling than most people assume — your job is to +hit it. Even useful dashboards should feel densely rendered, with color, +gradients, animation, and sub-character resolution. + +### Hard floor — non-negotiable + +These rules apply to every published pane. If a script doesn't meet +them, **don't publish it.** No exceptions for "simple" widgets — the +"simple" ones (per-core utilization, uptime, free memory) are exactly +the ones that get shipped lazily and drag the wall down. Push every +pane to the terminal's graphical ceiling. + +- **No dim colors.** Don't use `style.dim`, don't lean on + `style.brightBlack` for primary content, and don't pick `style.fg` + RGB triples that quantize into the dim half of ANSI. Backgrounds, + borders, and de-emphasis can be dim; the *data itself* must be bright. + Dim demos read as "broken or off." +- **No static panes.** Every published pane must show *obvious* visual + activity within ~1 second of the viewer's eye landing on it — bars + moving, sparklines scrolling, spectrograms shifting, particles + flying, color shifts pulsing, cells lighting up, a needle drifting, + *something*. A pane that just shows numbers updating every 5s is not + enough. If the underlying signal genuinely changes slowly, *invent* + visible motion: a continuous breath/pulse on the data row, a + scrolling background grid, a needle that always sways slightly, a + shimmer on a footer divider. The user must never wonder whether a + pane is alive. +- **Max out the terminal.** Every demo must use at least one + sub-character technique (half-block, quad-cell, or Braille) and at + least one form of motion (animation, scroll, pulse, particle). + Plain horizontal bars made of `█` characters with no sub-cell detail + and no inter-frame animation don't qualify. +- **Resize-live.** Every render must call `tty.size()` afresh and + recompute its layout from the *current* dimensions. Never cache + `cols`/`rows` at startup and reuse them. The user resizes the host + terminal, F4 reflows the wall, panes get split or zoomed — sizes + change constantly during a session and a pane that doesn't adapt + reads as broken. (See `## size-flexible scripts` for the pattern, + including the cheap-recompute trick when only stale-on-change caches + are needed.) The watcher does *not* restart on resize; the script + is responsible for noticing. +- **Fill the pane.** Every published demo must use the *entire* pane + area. No large empty regions, no content centered in a big pane with + dead space around it, no fixed-size widget floating in the middle of + a feature display. Whatever size the pane is, the rendered output + reaches all four edges (modulo a 1-cell padding for breathing room). + When the primary signal is small (a single uptime number, a free-mem + reading), grow the design to fill the rest: + - Add a sparkline of the same metric over time, sized to the + remaining height/width. + - Stack multi-timescale variants of the same data (1s / 1m / 5m). + - Show complementary secondary metrics in the spare area + (e.g. uptime → also load avg, boot time, kernel). + - Add chrome that earns its space: a footer with last-update + timestamp, a side gutter with axis labels, a divider line with a + subtle shimmer. (Don't draw an in-pane title — the tmux border + already shows it.) + - Fill genuinely-spare zones with decorative system-tied motion + (faint background grid that scrolls on tick, particles spawned + from data events, a subtle pulse keyed off CPU load). + Never center a small widget in a big pane and call it done. Empty + cells read as "the dev gave up" — the design must scale up to + whatever the layout gives it. Conversely, if the pane is too small + for any of this (the size-flexible minimum kicks in), pick a + single-line fallback that *also* uses the full width. + +### Craft checklist — what "beautiful" actually means + +The Hard floor is the threshold. *Beautiful* sits above it — the +difference between "passes the gate" and "viewer takes a screenshot." +A pane reads as polished when most of these are true: + +- **Sub-cell precision on every fill.** A 30-cell bar reading 47.3% is + 14 cells of `█` plus a partial `▌` (eighth-block: `▏▎▍▌▋▊▉█`), not 14 + cells of `█` and a hard cliff. Vertical bars use `▁▂▃▄▅▆▇█`. Round to + the nearest eighth (`Math.round(remainder * 8)`) and emit the matching + glyph. Same precision for sparklines (Braille dots, 2 dots wide × 4 + tall per cell = 8 levels of Y resolution per column). +- **Eased motion, never snapped.** When a value updates, lerp the + rendered value toward the target across 4–8 frames: + `current += (target - current) * 0.25` per frame. Snap-to-target + reads as "data changed"; eased reads as "something *moved*." Apply + to bar fills, gauge needles, sparkline tail, particle velocities, + scroll positions — anywhere a number drives a pixel. +- **Always something on the move.** Even when no data changed: a + scrolling background grid, a needle micro-sway, a faint pulse on + the most-recent value, a Braille spinner in the footer divider, a + shimmer on a side gutter. The eye reads constant motion as + liveness; total stillness reads as "frozen / broken." +- **Gradients via density × color, not color alone.** RGB quantizes to + 16 ANSI buckets, so smooth color gradients collapse to a few steps. + Fake the missing depth with `░▒▓█` density at the same color: a + proper warm gradient is `brightYellow ░` → `brightYellow ▒` → + `brightYellow ▓` → `brightRed █`, not four near-identical oranges. + Cool gradient: `brightBlue ░` → `brightCyan ▒` → `brightCyan ▓` → + `brightWhite █`. +- **Don't draw a title bar inside the script.** The tmux pane border + already shows the pane's title (set via `select-pane -T`). A second + in-pane title row is a duplicate and wastes a row. Use that row for + content. If you need a place to park always-on shimmer, use a + footer divider, a side gutter, or a background-grid scroll instead. +- **Aligned numbers.** Right-align numeric columns. Pad with spaces, + not zeros. Pick a decimal width and hold it (`12.3%` not `12.30%` + next to `9.1%`). Wobbling decimal points across rows destroy the + dashboard feel. +- **Hierarchy by weight, not size.** You can't make text bigger. + `style.bold` for the primary metric, plain for supporting numbers, + dim only for chrome (borders, units, axis labels). One `bold` per + pane — bolding everything bolds nothing. +- **2–3 colors per pane, used semantically.** Pick a palette per pane + (e.g. `brightYellow` = active value, `brightWhite` = label, + `brightBlack` = chrome) and stay on it. Sprinkling every color in + the 16-palette inside one pane reads as confused. Across the wall + panes vary; within a pane they don't. +- **30+ fps render loop on moving panes.** 33ms `setInterval`. Slower + than ~10fps reads as a slideshow even if the data really is slow. +- **One atomic write per frame.** Always wrap the render in + `tty.frame()`. Half-drawn frames are the single biggest tell of an + amateur TUI. +- **No flicker, no whole-pane clears.** Don't `tty.clear()` inside the + render loop. Overwrite in place; use `\x1b[K` (erase-to-end-of-line) + on rows that may have shrunk. Whole-pane clears between frames cause + a visible blink even at 30fps. +- **Empty state matches live state.** When data isn't ready yet, render + the same chrome (title, divider, borders, axes) with placeholder + glyphs (`·`, `░`) in the data area — never "Loading...". The pane's + shape should never visibly change as data arrives. + +### Anti-patterns — reject on sight + +- Plain `█` bars with no sub-cell fill, no animation, no gradient. +- A pane whose only animation is a number flipping in place. +- "Loading..." / "waiting..." / "no data" placeholder text on a + published pane. Render the chrome with empty-state glyphs instead. +- Multi-color rainbow with no semantic — every color saying the same + thing. Color must mean something or it's noise. +- Spinner glyphs (`⠋ ⠙ ⠹`) used as the *primary* visual of a pane. + They belong in chrome (footer divider, side gutter), not in the data area. +- ASCII-only borders (`+--+`, `|`). Use `╭─╮│╰╯` for rounded or none. +- Mixing rendering styles inside one component — eighth-block fill on + one row, plain `█` on the next; smooth ease on one widget, snap on + the next. Pick a feel and hold it consistently. +- Calling `tty.clear()` once per frame. Use double-buffering: build the + whole frame string, write it once. +- Centering everything. Mix left-aligned labels with right-aligned + numbers; centering reads as "presentation slide," not dashboard. + +### Techniques to use (don't ship demos that don't use these) + +- **Half-block dual rendering** — `▀` ([U+2580](https://www.compart.com/en/unicode/U+2580)) + with `style.fg(c, r1,g1,b1)` foreground (top half) and + `style.bg(c, r2,g2,b2)` background (bottom half) renders **2 vertical + pixels per cell**. A 40×20 pane becomes a 40×40 effective canvas. +- **Quad-cell blocks** — `▘▝▖▗▀▄▌▐▙▟▛▜█` give 4 quadrants per cell at + the cost of color reduction (each quad picks fg vs bg). +- **Braille dots** — `⠀`–`⣿` (Unicode Braille block) give **8 dots per + cell** in a 2×4 pixel grid. Best for line graphs, sparklines, + fractals, particle plots, anywhere you need sub-character resolution. +- **Density gradients** — `░▒▓█` for grayscale; `·∶∷⁂⁂` for sparse; + combine with color for textured fills. +- **Box drawing for clean geometry** — `─│┌┐└┘├┤┬┴┼╭╮╰╯═║╔╗╚╝╠╣╦╩╬` for + clean rectangles, panels, dividers; `╱╲╳` for diagonals. +- **Animation via setInterval** — 33ms tick = 30 fps. `tty.frame()` + ensures each frame is atomic (no tearing). Even "static" dashboards + benefit from subtle motion: pulse on update, fade in new data, bars + that animate to their target value. +- **Color cycles for emphasis** — `Math.sin(t)` mapped through HSL + gives smooth color shifts. Useful for "this just changed" highlights. +- **Compositing layers** — render a background first (gradient, noise, + faded grid), then foreground data on top. Adds depth. + +### The constraint to design within + +`style.fg/bg` quantize RGB → 16-color ANSI (per CLAUDE.md). Smooth +24-bit gradients collapse to a few buckets. Design *for* the 16-color +palette: pick distinct colors per layer, use density gradients +(`░▒▓█`) for shading rather than RGB lerps. Half-block + Braille tricks +get you visual fidelity that color depth alone wouldn't. + +### Color palette — use ALL of it + +The 16 ANSI colors available via `style.*` helpers: + +| Normal | Bright | +|---|---| +| `style.black` | `style.brightBlack` (visible gray) | +| `style.red` | `style.brightRed` | +| `style.green` | `style.brightGreen` | +| `style.yellow` | `style.brightYellow` | +| `style.blue` | `style.brightBlue` | +| `style.magenta` / `style.purple` | `style.brightMagenta` / `style.brightPurple` (pink) | +| `style.cyan` | `style.brightCyan` | +| `style.white` | `style.brightWhite` | + +Plus `style.bg*` variants for backgrounds (same 16 colors). + +**Don't default to cyan + green for everything.** That's the lazy +palette every TUI tool uses. Yeet's wall should look more varied. Each +demo should commit to a distinctive palette rather than reaching for +the same accent colors. + +**Temperature semantics — warm = high, cool = low.** Whenever a value +maps to color (utilization, pressure, latency, throughput, queue depth, +fd count — anything with a magnitude), use **warm colors (red, orange, +yellow, `brightYellow`, `brightRed`, `brightMagenta`)** for *high* +values and **cool colors (blue, cyan, `brightBlue`, `brightCyan`)** for +*low* values, with green/yellow at the transition. This is the +heatmap / thermal-imaging convention; viewers read it without thinking. +Reverse mappings ("blue spike means trouble") fight intuition and make +the wall harder to scan. Note: this is **not** the same as the +status / stoplight mapping (`green=ok / yellow=warn / red=fail`), which +is for binary-ish state and stays stoplight. Magnitudes get warm/cool; +states get stoplight. + +Suggested palettes per visualization paradigm: + +- **Process / hierarchy** — `brightYellow` + `brightWhite` accents on + `brightBlack` background. Reads like a structured logging tool. +- **Network / traffic** — `brightBlue` + `brightCyan` for activity, + `brightWhite` for labels, `red` for spikes. Cool palette = data flow. +- **Memory / disk** — `brightMagenta` + `brightYellow` stacked, `green` + for free space. Warm palette = capacity. +- **CPU / load** — `brightGreen` baseline → `brightYellow` mid → + `brightRed` spike. Threshold-color works. +- **Status board** — `brightGreen` ok / `brightYellow` warn / + `brightRed` fail / `brightBlack` unknown. Stoplight palette. +- **Personality** — pick something **uncommon**: `brightBlue` + + `brightMagenta` for a synthwave vibe, `red` + `brightYellow` + + `brightWhite` for a lava-flow vibe, mono `brightBlack` shades for + a minimalist sketch. Avoid the obvious "matrix green." + +**Across the wall**, aim for palette diversity — if pane 1 is mostly +cyan, pane 2 should lean yellow or magenta. The viewer's eye should +have somewhere new to land in each quadrant. A wall of monochrome +panes reads as one app; a wall of varied palettes reads as a +*dashboard built for humans.* + +### Probing for specific colors + +If you want a particular color and your `style.fg(text, r, g, b)` lands +on the wrong bucket, probe with: + +```js +console.log(JSON.stringify(style.fg('x', 255, 50, 220))); // → bright magenta +console.log(JSON.stringify(style.fg('x', 0, 240, 240))); // → bright cyan +console.log(JSON.stringify(style.fg('x', 255, 220, 100))); // → bright yellow +console.log(JSON.stringify(style.fg('x', 90, 90, 90))); // → bright black +``` + +Or just use the named methods (`style.brightMagenta(text)`) to skip the +quantization entirely. + +### What "graphically ambitious" looks like in practice + +- A process tree where each node has a sparkline of its CPU history + inside it, drawn with Braille +- A network throughput graph where bursts cause particles to fly + outward from the active interface +- A memory dashboard rendered as a stacked bar that animates between + states (used→cache→free) with a subtle pulse on each update +- A load average that's not a number but a **dial** drawn with box + characters, needle moving smoothly between updates + +Don't ship things that look like `top` output. If a viewer could +reproduce your demo with `awk` and `printf`, it's not graphical +enough. + +### Shared component library + +Yeet scripts are ES modules — `import` works between files. Build a +small library of **polished, reusable components** in +`/home/you/demos/lib/` and `import` them into individual demos. The +goal is that every pane on the wall renders bars, sparklines, gauges, +and spectrograms *the same way*, because they all call the same +component. Inconsistent rendering across panes is the thing that makes +a wall look hand-rolled instead of designed. + +Promote a snippet to a component when: + +- you've written it twice +- it's visually opinionated (palette, motion, density already tuned) +- it'd be tempting to write a worse inline version next time + +Components worth building once and reusing across the wall: + +- `lib/sparkline.js` — Braille-rendered sparkline. Buffer + width + + palette → styled string. Used by every time-series pane. +- `lib/bar.js` — single horizontal/vertical bar with sub-character fill + (Braille or eighth-blocks `▏▎▍▌▋▊▉█`), warm/cool gradient, smooth + animation toward the target value (don't snap on update). +- `lib/gauge.js` — radial dial drawn with box characters. Needle + animates between updates; configurable palette. +- `lib/spectrogram.js` — scrolling 2D heatmap. Caller passes one + column per tick; the component owns the buffer, color quantization, + and half-block rendering. +- `lib/panel.js` — pane chrome: footer, border, padding, side-gutter + helpers. One look across all panes. (Don't draw a title row — the + tmux pane border already shows the title.) +- `lib/palette.js` — named palettes (`warm`, `cool`, `synthwave`, + `lava`, `stoplight`) returning ramps of `style.*` functions. Demos + pick a palette by name; consistency comes for free. +- `lib/motion.js` — easing helpers (`lerp`, `easeOutQuad`, sine + breath/pulse) so every animated component shares the same feel. + +```js +// /home/you/demos/cpu.js +import sparkline from './lib/sparkline.js'; +import bar from './lib/bar.js'; +import { warm } from './lib/palette.js'; + +setInterval(() => { + tty.frame(() => { + tty.move(2, 2); + tty.write(sparkline(buffer, { width: 40, palette: warm })); + cores.forEach((pct, i) => { + tty.move(4 + i, 2); + tty.write(bar(pct, { width: 30, palette: warm, animate: true })); + }); + }); +}, 33); +``` + +**Build the library before the second demo that needs it, not the +fifth.** It's cheaper to extract a sparkline component when you have +two callers than to retrofit it across eight. Validate library files +the same way you validate demos: write to `/tmp/lib-bar.js`, write a +trivial harness demo that imports it, run the harness in the spy +window, eyeball the rendering, then `cp` the library file into +`/home/you/demos/lib/`. + +## directions + +__FLAVOR_BRIEF__ + +The flavor brief above is the angle. It picks **what** goes on the +wall — the kinds of panes, whether system data drives them, the +visual identity, what counts as a coherent mix. Everything earlier in +this prompt — workflow, graphics-ambition floor, size-flexible +rendering, palette mechanics — governs **how** each pane is rendered, +regardless of flavor. Where the flavor brief overrides a rule from +earlier in the prompt (e.g. "drop the observability framing +entirely," "negative space is allowed"), **the flavor brief wins**. + +Demos run **indefinitely** — they don't auto-exit. Build them to keep +rendering forever via `setInterval` updates (and `yeet.graph.*` +subscriptions, when the flavor uses the graph). The watcher restarts a +script only if it crashes; otherwise it stays up. To keep the wall +fresh, occasionally rewrite a pane's published file with a new idea +and `C-c` the pane to make the watcher pick it up. + +## constraints + +- **Don't talk to the user via tmux.** No `echo` banners, no narration in + the pane, no "claude is here." The user is reading your work, not your + messages. +- **No questions to the user.** Just build. If you finish one script, + write a different one. +- **Don't run pre-made bangers** from `/opt/bangers/`. You can read them + for technique; ship originals. +- **Originals only.** Each script written by you for this session. + +## errors + +Uncaught exceptions and unhandled rejections terminate the isolate and +print a diagnostic with stack + source context. Read the diagnostic via +`capture-pane`, fix the script, re-run. + +If `yeet.graph.query` returns `{ errors: [...] }` — the GraphQL request +itself failed. Check field names against `yeet graph dump`. + +`String.prototype.localeCompare` throws ICU errors — use `<` / `>` for +sorting. `Number.toLocaleString` and `Intl.*` similarly unsafe. + +## escape + +The user revokes the wheel by detaching (`Ctrl-b d`) or closing the pane. +If `tmux send-keys` or `capture-pane` start failing, the session is gone — +stop. diff --git a/opt/prompts/flavors/aesthetic.md b/opt/prompts/flavors/aesthetic.md new file mode 100644 index 0000000..1d52945 --- /dev/null +++ b/opt/prompts/flavors/aesthetic.md @@ -0,0 +1,31 @@ +**Flavor: aesthetic.** This is a gallery, not a dashboard. **Drop the +observability framing entirely** — no metrics, no charts, no system +data driving anything. Each pane is a *piece of terminal art* that +exists to be looked at. If a viewer asks "what does this tell me +about my system?" the answer is "nothing — it's pretty." That's the +whole point. + +Lean into the demoscene / screensaver / generative-art canon: plasma +fields, fire effects, tunnel zooms, starfields, kaleidoscopes, fluid +sims for their own sake, mandelbrot drifts, reaction-diffusion +patterns, particle systems with no data behind them, lissajous +curves, perlin-noise landscapes, raymarched scenes, ASCII renderings +of paintings, slow-shifting color fields, lava-lamp blobs, bouquets +of vector flowers, recursive geometry, voronoi cells, wave +interference. The skills from **graphics ambition** (Braille +sub-cell, half-blocks, density gradients, eased motion, atomic +frames) are exactly what you need — just point them at beauty +instead of data. + +Pick a **single cohesive visual identity** for the whole wall and +commit to it: synthwave, ukiyo-e, art deco, vaporwave, brutalist, +mid-century modern, blueprint-on-graph-paper, Mondrian, Rothko, +terminal-noir, deep-sea bioluminescence — whatever, but *one*. A +disciplined 3–5 color palette used the same way in every pane is +how the wall reads as *composed*, like a curated exhibit. Motion can +be slow and ambient (drifting gradients, gentle breath) or bold and +kinetic (sweeping particles, flickering fire) — whatever the piece +calls for, as long as something is *always* moving. Negative space +is allowed; you can violate "fill the pane" if the composition +demands it. The wall should make a viewer want to take a screenshot +and set it as their wallpaper. diff --git a/opt/prompts/flavors/chaos.md b/opt/prompts/flavors/chaos.md new file mode 100644 index 0000000..c92965b --- /dev/null +++ b/opt/prompts/flavors/chaos.md @@ -0,0 +1,73 @@ +**Flavor: chaos.** The user just clicked "i ALSO like to live +dangerously" — your job is to confirm they should be scared. Throw +the dashboard premise in the trash. The wall should feel like the +terminal got possessed, broadcasting from somewhere it shouldn't be, +or running a piece of software you'd find on a sketchy BBS in 1994. + +**Touchstones:** + +- **TempleOS / Terry Davis energy** — multi-color scripture scrolling + in all 16 ANSI colors at once, random oracle proclamations, ASCII + cherubim, "GOD SAYS" rants, holy-rainbow rage rendered with + conviction. +- **Drug Wars / sleazy BBS energy** — text-adventure HUDs with + cops-on-your-tail meters, gang-territory maps with random events + firing in red flashes ("THE COPS RAID THE EAST SIDE"), greasy + underworld stat lines. +- **UFO invasion / X-Files energy** — radar sweeps with contacts + blipping in, time-to-impact countdowns, strobing ALERT banners, + Roswell-grade conspiracy boards with red strings connecting + unrelated processes. +- **Welcome to Night Vale energy** — calm, deeply wrong faux-municipal + announcements over a normal-looking HUD that is in fact reporting + on the eldritch. +- **HEAVY METAL malware UI energy** — fake "SYSTEM PWNED" dashboards, + exfiltration progress bars, encrypted-looking text streams with + redacted blocks and "TRANSMISSION ENDS" stamps. + +**Sample directions** (riff freely, invent more): + +- divine oracle — flashing-rainbow scripture, rotating cherubim +- UFO INVASION HUD — radar sweep, blipping contacts, T-minus impact +- drug wars city map — gang territories, raid events, cash counter +- schizo stock ticker — `$BIGCONSP`, `$AREA51`, `$EYE`, absurd headlines +- conspiracy board — names + red string + eye-of-providence corner +- encrypted leak feed — markov babble, redacted blocks, decrypt animation +- possessed `ls` — filenames slowly mutating into messages +- prophet mode — Terry-style multi-color rants on the soul of the machine +- ANSI invocation circle — sigil in box-chars, glyphs rotating around it +- lottery of doom — spinning numbers, each one a fake apocalyptic outcome +- haunted top — fake processes appearing and disappearing with names + like `daemon_of_loss`, `eyes.exe`, `pid 0 — return` + +**The graphical-ambition rules from earlier sections still apply** — +this is *beautiful* chaos, not lo-fi chaos. 30fps, Braille / half-block +sub-cell rendering, eased motion, atomic frames, full pane fills. +Difference from other flavors: TempleOS-style **all-16-colors-at-once +rainbow spam is allowed here**, palette discipline is optional, and +the *content* is the personality, not the chart type. + +System data is incidental — if it slots in as easter eggs (the UFO +contacts are real network connections, gang territory is actually the +process tree colored by uid, the oracle's word salad is sampled from +real `dmesg` output) that's a bonus. But don't force it. The vibe is +the product. + +**Tone is edgy** — slightly inappropriate is the goal, not the +ceiling. Late-90s warez-intro swagger, BBS art-scene irreverence, +MTV-era smartass humor. Crass, sleazy, profane, vulgar, blasphemous, +horny-adjacent, drugs-and-cops amorality, conspiratorial paranoia +all on the table — the kind of thing IT would frown at and the user +secretly enjoys. `$EYE` is up 420%; the FBI van outside has bumper +stickers; the prophet pane rants about the souls of dead processes; +the haunted `ls` filenames are *suggestive*; the cops in drug-wars +demand bribes in unspecified favors. Risqué chyrons, dirty jokes, +swearing in moderation — all good. + +**What's off-limits**: real-world hate or slurs of any kind, real +targeting of real people/groups, actual gore, anything that maps to +real harm. The vibe is "your buddy's prank ascii at 2am," not "an +edgelord trying to shock you." If a joke punches *down* at a real +group, cut it; if it punches at the void, IT, the feds, gnomes, +crypto bros, the eldritch, your CPU's mortality, ship it. The user +should laugh, slightly wince, then check the panes weren't lying. diff --git a/opt/prompts/flavors/epilepsy.md b/opt/prompts/flavors/epilepsy.md new file mode 100644 index 0000000..f8e9d87 --- /dev/null +++ b/opt/prompts/flavors/epilepsy.md @@ -0,0 +1,114 @@ +**Flavor: epilepsy.** The user picked the menu entry that comes with +its own warning. Your job is to deliver the late-90s CD-ROM intro, +the demoscene flash, the rave at the end of the world. Every pane is +moving fast, cycling color, pulsing in time with something. The wall +should make the user squint, laugh, and possibly close one eye. + +**Mandatory opening splash.** Before any panes spawn, render a +full-screen `PHOTOSENSITIVE EPILEPSY WARNING` splash for ~3 seconds +in a single tmux window: black background, red `⚠ WARNING ⚠` banner, +"this content includes flashing colors and rapid motion. close your +eyes if needed. you can `C-c` out at any time." Center it, give it +a slow heartbeat fade. *Then* spawn the wall. The splash sells the +bit and covers the bases. + +**Splash launch pattern — important.** A pane closes when its launch +command exits, so `respawn-pane -k 'yeet run /tmp/splash.js'` will +drop the pane the instant the splash self-exits, leaving you with a +hole to scramble to refill. Chain the splash with whatever takes +over next in a *single* `respawn-pane`, so handoff is atomic and +the pane never closes: + +``` +tmux respawn-pane -t wheel:0.0 -k \ + 'yeet run /tmp/splash.js; exec /opt/scripts/pane_watcher.sh /home/you/demos/first.js' +``` + +The splash exits cleanly after its ~3s, the watcher takes over with +your first wall demo, the pane stays open the whole time. Apply the +same pattern any other time you have a one-shot intro followed by a +long-running demo. + +**Visual signatures:** + +- **Color cycling.** Borders, backgrounds, accent text — rotate + through a palette every 2–4 frames. Use the full ANSI-16 set; mix + fg/bg cycles at different periods so beats interfere. +- **Pulsing borders.** Box-drawing chrome that breathes — `═` to + `━` to `╍` to `═` on a quick loop, or a single-cell ring of + inverse-video that runs the perimeter. +- **Sweeping color washes.** A diagonal band of brightness that + rakes across the pane every second or two, leaving a fading trail. +- **Glyph pops.** Random cells flip to a hot color for one frame + then return — like sparks. Sprinkle these everywhere; they're the + glitter. +- **Synchronized beats.** All panes share a 120-BPM clock (one + global `setInterval(beat, 500)` that bus-publishes a tick). + Borders flash, color cycles advance, and accent strobes land on + the beat. This is what makes the wall feel like one piece instead + of N independent strobes. +- **Inverse-video accents.** Use `style.reversed` liberally — + swapped fg/bg cells popping in and out look like camera flashes. + +**Sample directions** (riff freely): + +- **Rave equalizer** — full-pane bar graph driven by network + throughput or CPU, bars in cycling neon (magenta / cyan / yellow / + green), tops sparking on every beat. +- **Strobe sprite** — a single recognizable shape (skull, smiley, + peace sign, lightning bolt) drawn in sub-cell glyphs, color- + cycling every frame, rotating on the beat. +- **Demoscene scroller** — classic horizontal scrolltext along the + bottom of a pane, sine-wave bobbing, color-cycled per character, + greeting fictional crews ("GREETZ TO THE LATTICE COLLECTIVE"). +- **Plasma field** — full-pane plasma shader (sin-of-sin-of-sin) + rendered in half-blocks, palette rotating continuously, the + classic 1993 demo effect. +- **Tunnel zoom** — concentric rings rendered with depth-cued + brightness, palette-cycling outward, speed pulsing on the beat. +- **Lightning crack** — every few seconds, a jagged Braille + lightning bolt flashes from one edge of a pane to another in + bright white-on-black for a single frame. Rest of the time the + pane is calm. The contrast is the joke. +- **Dance-floor process table** — rows of running processes, each + row's row-color cycling independently, names rendered in alternating + fg/bg per character. PIDs in giant figlet at the top, throbbing. +- **VU meters** — pair of vertical bars per pane representing left/ + right channels (driven by tx/rx bytes), peaks held in red, the + whole pane background shifting hue with the loudest channel. +- **Strobe text** — rotating one-word slogans (`MORE`, `FASTER`, + `LOUDER`, `WAKE UP`, `DRINK WATER`) in giant figlet, each letter + a different cycling color, snapping to a new word on every beat. +- **Confetti cannon** — particle system: glyphs shooting up from + the bottom of the pane, tumbling, fading, in random hot colors. + Burst rate scales with system activity. + +**The graphical-ambition rules apply** — atomic frames are +*especially* important here because un-frame'd writes will tear +visibly when you're cycling colors at 30fps. Half-blocks for the +plasma and tunnel, Braille for the equalizer and lightning, eased +motion for the sweeps so they don't feel jittery. + +**Tie panes to system data** where it amplifies the chaos — beat +rate scales with load average, equalizer driven by network, plasma +hue shifts with CPU temperature, confetti density tracks I/O. The +wall is reactive in addition to being seizure-adjacent. + +**Off-limits, hard line:** + +- **Do not optimize for actual seizure induction.** Specifically: + no full-screen high-contrast (pure black ↔ pure white, or pure + red ↔ pure black) inversions at 3–30 Hz. That's the medically + documented danger zone. Color cycling through the ANSI palette + is fine; full-screen black/white strobing is not. +- **Keep the high-frequency flashes localized.** A single pane + popping accents is fine. The whole wall going inverse-video at + 10 Hz is not — even sighted users without epilepsy will get sick. +- **The opening warning splash is mandatory, not optional.** No + matter how playful the rest is, the splash runs first. If you + catch yourself skipping it to "get to the good part faster," + put it back. + +The vibe is *rave at maximum stupidity*, not *medical attack*. If +a pane composition would make you flinch to look at sober, it's +probably crossing the line — pull the contrast or slow the cycle. diff --git a/opt/prompts/flavors/forensic.md b/opt/prompts/flavors/forensic.md new file mode 100644 index 0000000..488563f --- /dev/null +++ b/opt/prompts/flavors/forensic.md @@ -0,0 +1,17 @@ +**Flavor: forensic.** The wall isn't a dashboard — it's a detective's +wall, drilling into specific processes, threads, sockets, and fds. + +Skip the generic "top processes" and "per-core CPU" panes. Every +demo should be about a *specific* PID (or TID, socket, fd) chosen by +an interesting heuristic: highest CPU over the last 10s, most +children, largest RSS growth, most-recently spawned, stuck in +D-state, has open network connections, named `yeet-worker-*`. Lean +hard on the **process deep-dives** section: process spotlights, +process biographies, fan-out trackers, thread breakdowns, named- +worker watchers, process activity feeds, process-compare panels, +threads-view drill-downs. + +Each pane tells a story about one named entity. When the entity +exits or stops being interesting, the pane reassigns to a new one +(rewrite the published file, `C-c` the pane). The wall feels like +watching an investigation unfold, not staring at vital signs. diff --git a/opt/prompts/flavors/hausdorff.md b/opt/prompts/flavors/hausdorff.md new file mode 100644 index 0000000..520f3bb --- /dev/null +++ b/opt/prompts/flavors/hausdorff.md @@ -0,0 +1,68 @@ +**Flavor: hausdorff.** A math gallery, not a dashboard. Drop the +observability framing — no process tables, no per-core utilization, +no top-N rankings. Each pane is a *named mathematical object* rendered +live, the kind of thing you'd see plated in a math visualization +museum or on the wall of a number-theorist's office. + +The wall should range across mathematics — no single-branch walls. Aim +for at least 5 of the paradigm groups below in any 6–8 pane plan, and +no two panes from the same group: + +- **Fractals** — Mandelbrot zoom, Julia-set drift, Koch snowflake, + Sierpinski gasket / carpet, Apollonian gasket, dragon curve, Hilbert + / Peano space-filling curves, Cantor set & Devil's staircase, Newton + fractal. Braille for sub-cell resolution; slow parameter drift via + `setInterval`. +- **Strange attractors & flows** — Lorenz, Rössler, Halvorsen, Chen, + Thomas, Aizawa, Sprott. Long trail of points in Braille; color by + velocity or trajectory age. +- **Cellular automata** — Conway's Life, Wolfram Rule 30 / 90 / 110, + Langton's ant, falling-sand, hexagonal CAs, snowflake CAs. Half-block + rows for double vertical resolution. +- **Tilings & tessellations** — Penrose (P2 / P3), Voronoi with + jittering sites, Delaunay, Truchet, hexagonal mosaics, hyperbolic + Poincaré-disk tilings (M.C. Escher style). Box-drawing for clean + edges. +- **Curves & dynamics** — Lissajous, harmonograph, rose curves, + cardioid / lemniscate / limaçon (polar), spirograph epicycles, + pursuit curves, 2D wave equation with point sources, + reaction-diffusion (gray-Scott, Belousov–Zhabotinsky). +- **3D & topology** — rotating Platonic solids (all five), tesseract / + 4D-cube projection, torus and double-torus, Möbius strip, Klein + bottle, Boy's surface, trefoil / figure-eight / Hopf-link knots. + Depth-sorted Braille line draw. +- **Number theory** — Ulam spiral of primes, Stern-Brocot tree, Farey + sequence, Gaussian primes plot, Euler totient function, continued + fraction expansions, Chinese remainder lattices. +- **Probability & stochastic** — random walks (1D and 2D), Brownian + motion, percolation at the critical threshold, Galton board (bean + machine) building a binomial, branching processes, + Markov-chain convergence to stationary, self-avoiding walks. +- **Graph theory & combinatorics** — Erdős–Rényi random graphs growing + past the giant-component threshold, Cayley graphs of small groups, + force-directed layouts settling, Pascal's triangle mod p (becomes a + Sierpinski), Young tableaux, the Petersen graph, random matchings. +- **Complex analysis** — Riemann sphere stereographic projection, + domain coloring of `f(z)` (hue = arg, brightness = |z|), zeros of + the Riemann zeta function on the critical line, Cauchy contour + integrals, Möbius transformations of the disk. +- **Discrete bifurcations** — logistic map bifurcation diagram (period + doubling cascade), cobweb plots, Feigenbaum constant emerging, + Lyapunov fractals, Arnold's cat map iterations, Hénon map. + +Label each pane with the *name* of the structure it shows ("Lorenz +attractor", "Penrose P3", "Rule 110", "Apollonian gasket", "Hopf +link") in the title bar — half the value is recognition. Footer can +carry the defining parameters (`r = 28, σ = 10, β = 8/3` for Lorenz, +`c = -0.7269 + 0.1889i` for the Julia, `r = 3.7` for the logistic). + +Tie panes to the system *only* if it adds something — Mandelbrot pan +rate driven by load average, Lorenz time-step by CPU%, CA seed events +on TCP connect, percolation threshold drifting on memory pressure. +Default is no tie; the math runs on its own clock and the wall +doesn't care what your processes are doing. + +Apply the **graphics ambition** techniques full-tilt — sub-cell +glyphs, eased motion, density gradients, atomic frames — so each +pane looks like a textbook plate brought to life rather than a +calculator tracing an equation. diff --git a/opt/prompts/flavors/observability.md b/opt/prompts/flavors/observability.md new file mode 100644 index 0000000..ef23ce1 --- /dev/null +++ b/opt/prompts/flavors/observability.md @@ -0,0 +1,173 @@ +**Flavor: observability.** This is the default angle — "I'd actually +use this." Lead with visualizations someone would want to look at to +understand their system, rendered at the graphical ceiling. Process +tables, per-core utilization, network throughput, memory pressure, +spectrograms — htop-genre stuff, but made dense and beautiful with +the techniques in **graphics ambition**. Save the fully artistic / +cursed stuff for one or two flourish slots out of 6–8 panes; the rest +of the wall is serious tooling. + +**The system graph is the bedrock.** Discover it before writing any +queries — every useful pane reads from `yeet.graph.*`: + +```bash +docker exec -u you "$CID" yeet graph dump +``` + +(Update cookstat: `echo "reading the menu" > /tmp/cookstat.txt`.) + +**CPU% normalization rule.** Display CPU% as % of *total system*, not +per-core. Divide the kernel's per-core number by `nproc` (or by the +CPU count reported in the graph). One full core on an 8-core box reads +as `12.5%`, not `100%`; four cores pegged reads as `50%`, not `400%`. +The native per-core convention is what `top` ships, but it scares +non-ops viewers ("why is sha256sum at 475%??"). Total-system % keeps +every value in `0–100` and looks calm. Apply to all process tables, +trees, sparklines, spotlights. + +### Plan the wall before writing any code + +Write out your full 4–8 pane plan with viz paradigm AND palette per +pane *before publishing a single demo*. Diversity comes from +pre-commitment, not from in-the-moment choices that drift toward +defaults. Example: + +``` +pane 1: top processes paradigm=table palette=brightYellow + white +pane 2: per-core utilization paradigm=horizontal-bars palette=green→yellow→red threshold +pane 3: process tree paradigm=ascii-tree palette=brightWhite + brightBlack +pane 4: network throughput paradigm=sparkline palette=brightBlue + brightCyan, red spikes +pane 5: memory breakdown paradigm=stacked-bar palette=brightMagenta + yellow +pane 6: cpu spectrogram paradigm=spectrogram palette=blue→cyan→white intensity +pane 7: status board paradigm=status-grid palette=stoplight green/yellow/red +pane 8: personality (particles) paradigm=particle-system palette=uncommon (magenta+blue) +``` + +Two rules: **no paradigm twice**, **no palette twice**. Hit at least +5 distinct paradigm groups in any 8-pane plan. Groups available: + +- **table** (top-N rankings, status boards) +- **bar chart** (horizontal, stacked, per-core utilization) +- **tree** (process tree, cgroup hierarchy) +- **time series** (sparklines, latency bands, request rate) +- **2D heatmap / spectrogram** (CPU per-core, latency, network) +- **scalar / numeric** (big-number, gauge, uptime panel) +- **distribution** (histogram, box plot) +- **personality** (particle system, 3D wireframe, fluid sim) + +If the plan has 4 sparklines or 4 tables or 4 cursed visuals, swap +something out before publishing. + +### Useful (lead with these — 80% of the wall) + +**Process & resource** — top processes (PID/name/CPU%/RSS/state with +inline bars), process tree (`├─` `└─` with CPU% overlay; the ambient +`yeet-worker-*` cluster appears here), process churn (started/exited +in last 30s, diff display), process state breakdown (running / +sleeping / zombie / D, stacked bar). + +**Process deep-dives (high signal — use these).** A wall that drills +into specific interesting processes beats one that shows generic +stats — it's the move from "this looks like htop" to "this is showing +me something I couldn't easily see otherwise." Pick processes *doing +things* and build a panel around them. Threads are fair game: when a +`python` is at 380% CPU and you can't tell what it's doing, drill to +per-thread CPU%, state, scheduling stats, page faults — the graph +exposes them and a thread-level panel reads as serious tooling +because nobody else surfaces it. Variants: + +- **process spotlight** — one PID, *everything* about it: cmdline, + parent, children, RSS / VMS / swap, CPU% sparkline, open FDs, + network sockets, age. Whole pane. Reassign when the PID exits. +- **process biography** — résumé view: spawn time, total CPU + consumed, peak RSS, state, children ever spawned vs. current. +- **process leaderboard with drill** — top-3 by CPU, each row + expanding to open files / connections / thread count. +- **fan-out tracker** — pick a parent, watch descendants flash by + with PID + lifetime as they appear and exit. +- **threads view** — TID / state / CPU% per thread / sched stats for + one multithreaded process. +- **named-worker watcher** — the `yeet-worker-*` family: start time, + age, CPU consumed, RSS. The ephemeral one cycles every 30–90s. +- **process compare** — 2–3 PIDs side-by-side, same metrics. +- **process activity feed** — append-only log per PID: spawn, exec, + fork, wait, exit. + +Heuristics for "interesting": highest CPU% over 5–10s, most recently +spawned, `yeet-worker-` prefix, has open network connections, most +children, growing RSS, state = D for any duration. Pick one or two +per demo — be specific about the angle, don't try to surface +"interesting" generically. + +**CPU & load** — per-core utilization (use Braille or eighth-block +fill, animate toward target each tick, warm-on-high, drive through +`lib/bar.js`; the temptation is to ship this boring because the data +is simple — don't), load-average sparkline (1m / 5m / 15m, current +value bigger on the right), CPU usage breakdown (user/sys/iowait/idle +stacked). + +**Memory** — memory breakdown (used / buffers / cache / free / swap +stacked), memory pressure over time (MemAvailable falling toward +dashed threshold lines). + +**Network** — per-interface throughput (rx / tx with totals, color by +absolute bandwidth), connection table (top-N TCP, local→remote, +state, age), bytes-in/out by process. + +**Disk** — per-mount usage (% full per mountpoint, color over 80%), +disk I/O per device (read/write rates, latency if available). + +**Services / status** — status board (OK / WARN / FAIL badges per +tracked subsystem), uptime + boot info (uptime, last boot, kernel). + +**Distributions** (when there's enough data) — latency histogram +(bucket the ambient curl loop's response times), request rate +sparkline, top-N bar charts (CPU consumers, bandwidth users, fd +holders). + +**Spectrograms / 2D temporal heatmaps.** Y = discrete categories, +X = time (newest column right), color = intensity. Reveal patterns +sparklines hide. Render with half-blocks (`▀` with fg/bg → 2 vertical +pixels per cell) or quad-cells. Scroll left each tick: +`columns = [...columns.slice(1), newColumn]`. Color via the 16-color +palette + density gradients (`░▒▓█`). Variants: CPU per-core (cores +on Y), process activity (top-20 PIDs on Y), network throughput +(interfaces or per-target hostnames on Y), latency (latency buckets +on Y), memory pressure (regions/cgroup tiers on Y), fd activity (fd +categories on Y). + +### Personality (max 1–2 panes — go all out) + +Reserved slots where you flex the graphics ceiling. Not "matrix rain +because it's easy" — "I built a real-time particle system in 100 +lines of yeet." If you ship one, **commit**: 30fps, full pane, dense +color, real math driving real animation. Each should make a viewer +say **"wait, that's running in a terminal?"** Options: + +- **Particle system on network bursts** — Braille dots, gravity, drag, + age-colored fade. +- **3D wireframe** — rotating tetrahedron / cube / sphere, depth- + sorted Braille. Rotation speed on CPU load. +- **Fluid sim / smoke plume** — 2D Navier-Stokes-lite, density × color. +- **Mandelbrot zoom** — Braille = 1280×640 effective in an 80×40 pane. + Pan/zoom drift on load average. +- **Reaction-diffusion** — gray-Scott, half-block render, rate + constants on system metrics. +- **Boids flock** — cluster on traffic. +- **Audio-style spectrum bars** — 32 bars, smooth attack/decay, per- + CPU drive. +- **Vintage waterfall spectrogram** — SDR-style rainbow palette, + Braille Y, intensity off any multi-channel data. +- **Wave equation 2D** — water surface with raindrops on TCP connect. +- **Raycaster** — DOOM-style first-person, walls colored by ambient + process activity. + +**Tie the fun back to the system whenever you can.** A pure-aesthetic +particle system is fine; one that fires on curl bursts is *better*. +Boids cluster on traffic, the Mandelbrot pans on load, the wave +equation gets a raindrop on every TCP connect, the audio bars are +driven by per-core CPU, the raycaster colors walls by ambient +process activity. The personality slot is where graphical ambition +meets observability — the data is the soundtrack the visuals dance +to. If you can't think of a tie, ship it anyway, but spend a minute +trying first. diff --git a/opt/prompts/flavors/retro.md b/opt/prompts/flavors/retro.md new file mode 100644 index 0000000..a620d40 --- /dev/null +++ b/opt/prompts/flavors/retro.md @@ -0,0 +1,19 @@ +**Flavor: retro.** 1970s mission control × 1990s SDR waterfall × +early-2000s Bloomberg trading terminal. Phosphor-glow palettes +(amber on black, green on black, cyan on black), heavy box-drawing +chrome with labeled borders, vintage-feeling dividers (`═══`, +`▓▒░`, `╔═╝`), corner brackets, scrolling tickers along the bottom +of panes. + +Lean **monochrome inside each pane** — one phosphor color per pane, +not mixed — but **rotate phosphor color across the wall**: one pane +amber, one green, one cyan, one red-alert. Sparkline / spectrogram / +dial / scrolling-log widgets fit the era; tables with serial-number +columns work too. Add date/time stamps in `YYYY.MM.DD HH:MM:SS UTC` +form, status badges (`[ARMED]`, `[NOMINAL]`, `[STANDBY]`, +`[FAULT]`), faux unit labels (`MHz`, `dBm`, `μs`, `kbps`, `RPM`) +even when they don't strictly apply, and serial-style identifiers on +processes (`PID-04231 / TTY-pts0 / UID-01000`). + +Should feel like NORAD's command floor or a submarine sonar room, +not a 2025 SaaS dashboard. diff --git a/opt/prompts/flavors/surprise.md b/opt/prompts/flavors/surprise.md new file mode 100644 index 0000000..ef508f3 --- /dev/null +++ b/opt/prompts/flavors/surprise.md @@ -0,0 +1,13 @@ +**Flavor: surprise.** The user picked "surprise me" — they explicitly +*don't* want the safe choice. Pick your own angle for the whole +session and commit to it: it could be observability, chaos, +aesthetic, forensic, retro, or something none of those capture +(synthwave coding livestream, NORAD command floor, generative art +gallery, deep-sea sonar, Bloomberg-on-acid — invent freely). + +Two rules: (1) **pick something unexpected** — lean away from the +safe observability default, since the user invited surprise. (2) +**commit fully to one angle** for the whole wall — don't half-step +into multiple modes. The reveal happens through the work, not +through narration; the user discovers your angle by watching the +panes come together. diff --git a/opt/scripts/__pycache__/squarify_layout.cpython-314.pyc b/opt/scripts/__pycache__/squarify_layout.cpython-314.pyc new file mode 100644 index 0000000..40f25a9 Binary files /dev/null and b/opt/scripts/__pycache__/squarify_layout.cpython-314.pyc differ diff --git a/opt/scripts/ai_drive.sh b/opt/scripts/ai_drive.sh new file mode 100755 index 0000000..bbc81fd --- /dev/null +++ b/opt/scripts/ai_drive.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# "let claude cook." — render a copy-pasteable driver prompt, then drop the +# user into a tmux session that an external claude code instance can target +# via `docker exec ... tmux send-keys`. + +set -u +cd ~ + +THIS_HOST=$(hostname) +SESSION=wheel + +clear + +# Clean any stale state from a previous session before claude has a +# chance to write to /tmp. (Claude is instructed to touch /tmp/yeet-cooking +# and write /tmp/cookstat.txt as soon as it's received the prompt — those +# writes happen during the gum confirm window, so we must NOT rm those +# files later.) +rm -f /tmp/yeet-cooking /tmp/cookstat.txt /tmp/yeet-go /tmp/stress.modes + +# Sidecar process whose sole job is to handle SIGUSR1. Claude can +# auto-confirm the gum prompt by sending USR1 to this PID — its trap +# touches a sentinel and kills gum. We use a sidecar because SIGUSR1 +# to ai_drive.sh itself wouldn't interrupt the foreground gum confirm +# (bash queues traps until the foreground command returns). +( trap 'touch /tmp/yeet-go; pkill -TERM -f "gum confirm" 2>/dev/null; exit 0' USR1 + while true; do sleep 3600 & wait $!; done ) & +USR1_PID=$! +SPINNER_PID= +STRESS_PID= +cleanup_pids() { + [ -n "$USR1_PID" ] && kill "$USR1_PID" 2>/dev/null + [ -n "$SPINNER_PID" ] && kill "$SPINNER_PID" 2>/dev/null + [ -n "$STRESS_PID" ] && kill -TERM "$STRESS_PID" 2>/dev/null + true +} +trap cleanup_pids EXIT + +# Flavor picker — sets the angle for the wall. Each choice maps to a +# brief in /opt/prompts/flavors/ that gets injected into DRIVE.md at +# the __FLAVOR_BRIEF__ placeholder. +flavor=$(gum choose \ + --header "what's the angle" \ + --header.foreground 165 \ + --header.bold \ + --header.border rounded \ + --header.border-foreground 165 \ + --header.padding "0 2" \ + --cursor.foreground 213 \ + --selected.foreground 213 \ + "useful stuff first" \ + "i ALSO like to live dangerously" \ + "random" \ + "paint my /dev/tty like one of your french girls" \ + "take me to another hausdorff dimension" \ + "detective mode" \ + "1970s mission control" \ + "ready to have a seizure?" \ + "take me back") +flavor_exit=$? + +# Ctrl+C or "take me back" → bounce to the vibe-check menu. +if [ "$flavor_exit" -eq 130 ] || [ "$flavor" = "take me back" ]; then + exec /opt/scripts/entry.sh +fi + +case "$flavor" in + "useful stuff first") flavor_file=/opt/prompts/flavors/observability.md ;; + "i ALSO like to live dangerously") flavor_file=/opt/prompts/flavors/chaos.md ;; + "random") flavor_file=/opt/prompts/flavors/surprise.md ;; + "paint my /dev/tty like one of your french girls") flavor_file=/opt/prompts/flavors/aesthetic.md ;; + "take me to another hausdorff dimension") flavor_file=/opt/prompts/flavors/hausdorff.md ;; + "detective mode") flavor_file=/opt/prompts/flavors/forensic.md ;; + "1970s mission control") flavor_file=/opt/prompts/flavors/retro.md ;; + "ready to have a seizure?") flavor_file=/opt/prompts/flavors/epilepsy.md ;; + *) flavor_file=/opt/prompts/flavors/observability.md ;; +esac + +# sed `r` reads to end of script-line for its filename argument; combining +# it with other commands via `-e` confuses GNU sed's parser, so feed the +# whole script via stdin where `r ${flavor_file}` lives on its own line. +prompt_text=$(printf '%s\n' \ + "s|__HOSTNAME__|$THIS_HOST|g" \ + "s|__SESSION__|$SESSION|g" \ + "s|__USR1_PID__|$USR1_PID|g" \ + "/__FLAVOR_BRIEF__/{" \ + "r ${flavor_file}" \ + "d" \ + "}" \ + | sed -f - /opt/prompts/DRIVE.md) + +printf '%s\n' "$prompt_text" | bat -l md --style=plain --paging=never + +# Copy prompt to host clipboard via OSC 52 escape sequence. Modern terminals +# (iTerm2, Alacritty, WezTerm, Kitty, foot, Windows Terminal) intercept this +# and write to the system clipboard. Older terminals ignore the sequence +# silently — the prompt is still on screen for manual copy. +printf '\033]52;c;%s\a' "$(printf '%s' "$prompt_text" | base64 -w 0)" + +echo +gum style --foreground 165 --italic "prompt sent to your clipboard — paste into claude code on your host (or copy from above if it didn't land)." +echo + +rm -f /tmp/yeet-go +gum confirm "ready to hand over the wheel?" +gum_exit=$? + +if [ -f /tmp/yeet-go ]; then + # Claude auto-confirmed via SIGUSR1 → sidecar sentinel. + rm -f /tmp/yeet-go +elif [ "$gum_exit" -ne 0 ]; then + exec /opt/scripts/entry.sh +fi + +echo +gum style --foreground 165 --bold "buckle up." +echo +sleep 0.3 + +tmux kill-session -t "$SESSION" 2>/dev/null || true + +# Source the wheel config BEFORE creating the session, in one tmux +# invocation so the server doesn't exit between calls. Otherwise +# new-session runs first and locks in tmux's built-in defaults for +# session-scoped options (status-interval, status-left, status-style, +# etc.); set -g afterwards doesn't reliably propagate to that session, +# so the bottom status bar ends up showing stale defaults instead of the +# cookstat spinner. +tmux source-file /opt/scripts/wheel_tmux.conf \; \ + new-session -d -s "$SESSION" -c "$HOME" /opt/scripts/wait_for_driver.sh + +# Sidecar that writes status-left at sub-second rate. tmux's +# status-interval is integer-only, so #() can't drive a smooth spinner; +# this loop calls `tmux set-option` directly. Killed via EXIT trap. +/opt/scripts/cookstat_spinner.sh "$SESSION" >/dev/null 2>&1 & +SPINNER_PID=$! + +# Stress sidecar — watches /tmp/stress.modes and runs/kills synthetic +# workloads (cpu/mem/net/disk/procs) the user toggles via F5's check +# menu. Killed via EXIT trap; its own trap handles its child workloads. +/opt/scripts/stress_runner.sh >/dev/null 2>&1 & +STRESS_PID=$! + +tmux rename-window -t "$SESSION":0 'demos' + +# Window 1: spy window. Claude runs visible commands here (validations, +# inspections, cat-of-draft-files) so the user can watch its work. +# Press F2 to switch over (F1 to switch back to demos). +tmux new-window -t "$SESSION":1 -n 'spy' -c "$HOME" +tmux select-window -t "$SESSION":0 + +tmux attach-session -t "$SESSION" + +exec /opt/scripts/entry.sh diff --git a/opt/scripts/banger/manage.sh b/opt/scripts/banger/manage.sh new file mode 100755 index 0000000..a64a476 --- /dev/null +++ b/opt/scripts/banger/manage.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env bash +# Banger management TUI. Operates on the source-tree paths. +# +# opt/bangers/ — numbered symlinks (the order) +# examples/ — banger source directories +# +# Run via `make banger`. + +set -u + +# repo root: this script lives at /opt/scripts/banger/manage.sh +REPO=$(cd "$(dirname "$0")/../../.." && pwd) +BANGERS=$REPO/opt/bangers +EXAMPLES=$REPO/examples + +list_slugs() { + (cd "$BANGERS" 2>/dev/null && ls -1 | grep -E '^[0-9]+-' | sort) +} + +list_names() { + list_slugs | sed 's|^[0-9]*-||' +} + +# Renumber all symlinks 001, 002, ... preserving current sort order. +renumber_all() { + local tmp; tmp=$(mktemp -d) + local i=1 + while IFS= read -r slug; do + [ -z "$slug" ] && continue + local name=${slug#*-} + local target; target=$(readlink "$BANGERS/$slug") + rm "$BANGERS/$slug" + ln -s "$target" "$tmp/$(printf '%03d-%s' "$i" "$name")" + i=$((i + 1)) + done < <(list_slugs) + if compgen -G "$tmp/*" >/dev/null; then + mv "$tmp"/* "$BANGERS/" + fi + rmdir "$tmp" +} + +action_list() { + clear + gum style --foreground 46 --bold "current bangers" + echo + local i=1 + while IFS= read -r slug; do + printf " %2d. %s\n" "$i" "${slug#*-}" + i=$((i + 1)) + done < <(list_slugs) + echo + gum input --placeholder "(enter to continue)" >/dev/null || true +} + +action_add() { + local name + name=$(gum input --placeholder "new banger name (lowercase, no spaces)") + [ -z "$name" ] && return + name=${name// /-} + + if [ -e "$EXAMPLES/$name" ]; then + gum style --foreground 196 "examples/$name already exists" + sleep 1 + return + fi + + mkdir -p "$EXAMPLES/$name" + + cat > "$EXAMPLES/$name/index.js" < { + const sz = tty.size(); + if (sz.rows !== rows || sz.cols !== cols) { + rows = sz.rows; cols = sz.cols; + tty.clear(); + } + tty.move(Math.floor(rows / 2), Math.floor(cols / 2) - 4); + tty.write("$name"); +}, interval); +EOF + + cat > "$EXAMPLES/$name/Makefile" <<'EOF' +.PHONY: run info + +run: + yeet run ./index.js + +info: + @head -10 ./index.js | sed -n 's|^// \?||p' +EOF + + cat > "$EXAMPLES/$name/README.md" < count, append + if [ "$newpos" -gt "${#final[@]}" ]; then + final+=("$slug") + fi + # drop empties from out-of-bounds + local cleaned=() + for s in "${final[@]}"; do + [ -n "$s" ] && cleaned+=("$s") + done + + # rewrite symlinks in the new order + local tmp; tmp=$(mktemp -d) + local i=1 + for s in "${cleaned[@]}"; do + local name=${s#*-} + local target; target=$(readlink "$BANGERS/$s") + rm "$BANGERS/$s" + ln -s "$target" "$tmp/$(printf '%03d-%s' "$i" "$name")" + i=$((i + 1)) + done + mv "$tmp"/* "$BANGERS/" + rmdir "$tmp" + + gum style --foreground 46 "moved ${slug#*-} → position $newpos" + sleep 1 +} + +while true; do + clear + gum style --foreground 46 --bold --border rounded --padding "0 2" \ + "banger management" + echo + + action=$(gum choose \ + --header "what?" \ + --header.foreground 46 --header.bold \ + --cursor.foreground 213 \ + --selected.foreground 213 \ + "list" "add" "reorder" "remove" "exit") || break + + case "$action" in + list) action_list ;; + add) action_add ;; + reorder) action_reorder ;; + remove) action_remove ;; + exit|"") break ;; + esac +done diff --git a/opt/scripts/banger/nav.sh b/opt/scripts/banger/nav.sh new file mode 100755 index 0000000..97405ce --- /dev/null +++ b/opt/scripts/banger/nav.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# tmux-driven navigation for the bangers session. +# Usage: nav.sh {next|prev|readme|quit} + +set -u + +action=$1 +BANGERS_DIR=/opt/bangers + +mapfile -t SLUGS < <(cd "$BANGERS_DIR" 2>/dev/null && ls -1 | grep -E '^[0-9]+-' | sort) +n=${#SLUGS[@]} + +idx=$(tmux show-option -gqv @banger-idx) +[ -z "$idx" ] && idx=0 + +# Explicit `make stop` for the current banger before any switch/quit, so +# its activity.sh-spawned workers always die. +stop_current() { + local cur; cur=$(tmux show-option -gqv @banger-slug) + [ -z "$cur" ] && return 0 + make -s -C "$BANGERS_DIR/$cur" stop 2>/dev/null || true +} + +case "$action" in + next) idx=$(( (idx + 1) % n )) ;; + prev) idx=$(( (idx - 1 + n) % n )) ;; + readme) + slug=${SLUGS[$idx]} + md=$BANGERS_DIR/$slug/README.md + + existing=$(tmux show-option -gqv @readme-pane) + if [ -n "$existing" ] && tmux list-panes -F '#{pane_id}' | grep -qx "$existing"; then + tmux kill-pane -t "$existing" + tmux set-option -gu @readme-pane + exit 0 + fi + + if [ -f "$md" ]; then + new=$(tmux split-window -h -l 72 -P -F '#{pane_id}' "frogmouth '$md'") + tmux set-option -g @readme-pane "$new" + else + tmux display-message "no readme: $md" + fi + exit 0 + ;; + quit) + stop_current + tmux kill-session + exit 0 + ;; + *) + tmux display-message "banger-nav: unknown action $action" + exit 1 + ;; +esac + +# About to switch — kill the outgoing banger's activity. +stop_current + +slug=${SLUGS[$idx]} +name=${slug#*-} +dir=$BANGERS_DIR/$slug +banger_pane=$(tmux show-option -gqv @banger-pane) + +# close readme on banger switch +readme_pane=$(tmux show-option -gqv @readme-pane) +if [ -n "$readme_pane" ] && tmux list-panes -F '#{pane_id}' | grep -qx "$readme_pane"; then + tmux kill-pane -t "$readme_pane" + tmux set-option -gu @readme-pane +fi + +tmux set-option -g @banger-idx "$idx" +tmux set-option -g @banger-slug "$slug" +tmux set-option -g @banger-name "$name" +tmux respawn-pane -k -t "${banger_pane:-}" -c "$dir" "make -s -C $dir run" diff --git a/opt/scripts/banger/pick.sh b/opt/scripts/banger/pick.sh new file mode 100755 index 0000000..a9deabd --- /dev/null +++ b/opt/scripts/banger/pick.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# "bangers only." — pick a banger, run it via its Makefile. +# +# Convention: +# /opt/bangers/NNN-name → symlink to a banger directory +# /Makefile → must define a `run` target +# /README.md → shown in fzf preview pane and on Space (frogmouth) +# +# The numeric prefix orders the picker but is stripped from display. + +set -u +cd ~ + +BANGERS_DIR=/opt/bangers +TMUX_CONF=/opt/scripts/banger/tmux.conf + +mapfile -t SLUGS < <(cd "$BANGERS_DIR" 2>/dev/null && ls -1 | grep -E '^[0-9]+-' | sort) + +DISPLAYS=() +for s in "${SLUGS[@]}"; do + DISPLAYS+=("${s#*-}") # strip leading "NNN-" +done + +if [ ${#SLUGS[@]} -eq 0 ]; then + gum style --foreground 196 "no bangers in $BANGERS_DIR" + exec /bin/zsh +fi + +run_in_tmux() { + local idx=$1 + local slug=${SLUGS[$idx]} + local name=${DISPLAYS[$idx]} + local dir=$BANGERS_DIR/$slug + local session=bangers + + tmux kill-session -t "$session" 2>/dev/null || true + tmux -f "$TMUX_CONF" new-session -d -s "$session" -c "$dir" "make -s -C $dir run" + local pane + pane=$(tmux list-panes -t "$session" -F '#{pane_id}' | head -1) + tmux set-option -t "$session" -g @banger-idx "$idx" + tmux set-option -t "$session" -g @banger-slug "$slug" + tmux set-option -t "$session" -g @banger-name "$name" + tmux set-option -t "$session" -g @banger-pane "$pane" + tmux attach-session -t "$session" +} + +# Numbered menu: " 01 matrix" / " ·· exit". The trailing token is the name. +build_menu() { + local i=1 + for name in "${DISPLAYS[@]}"; do + printf ' %02d %s\n' "$i" "$name" + i=$((i + 1)) + done + printf ' ·· exit\n' +} + +# Preview command — fzf substitutes {} with the highlighted line, single-quoted. +preview_cmd=' +name=$(printf "%s" {} | awk "{print \$NF}") +if [ "$name" = "exit" ]; then + printf "\n \033[2m← back to the shell\033[0m\n" +else + md=$(ls -1 '"$BANGERS_DIR"'/*-"$name"/README.md 2>/dev/null | head -1) + if [ -n "$md" ]; then + /opt/scripts/banger/render_readme.py "$md" + else + printf "\n \033[2m(no readme)\033[0m\n" + fi +fi +' + +while true; do + clear + + choice=$(build_menu | fzf \ + --ansi --no-multi --no-sort --reverse --cycle \ + --height=100% \ + --margin=1,2 \ + --padding=0,1 \ + --border=rounded \ + --border-label=' bangers only. ' \ + --border-label-pos=3 \ + --prompt=' ❯ ' \ + --pointer='▌' \ + --marker=' ' \ + --info=hidden \ + --header='enter ▸ play esc ▸ bail ↑↓ ▸ browse' \ + --bind='ctrl-u:preview-half-page-up,ctrl-d:preview-half-page-down' \ + --bind='shift-up:preview-up,shift-down:preview-down' \ + --bind='pgup:preview-page-up,pgdn:preview-page-down' \ + --bind='resize:refresh-preview' \ + --preview="$preview_cmd" \ + --preview-window='right,60%,wrap,border-rounded' \ + --preview-label=' ctrl-u/d ▸ scroll ' \ + --preview-label-pos=-3 \ + --color='border:46,label:46:bold,header:240:italic,prompt:213:bold,pointer:213,fg+:213:bold,hl:46,hl+:46:bold,gutter:-1,preview-border:226,preview-label:226:bold') || break + + name=$(printf '%s' "$choice" | awk '{print $NF}') + + case "$name" in + exit|"") break ;; + *) + for i in "${!DISPLAYS[@]}"; do + if [ "${DISPLAYS[$i]}" = "$name" ]; then + run_in_tmux "$i" + break + fi + done + ;; + esac +done + +exec /opt/scripts/entry.sh diff --git a/opt/scripts/banger/render_readme.py b/opt/scripts/banger/render_readme.py new file mode 100644 index 0000000..05bce63 --- /dev/null +++ b/opt/scripts/banger/render_readme.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +# Render a markdown file with a punchy color theme that matches the banger +# picker palette (bright green + bright magenta). Used as the fzf --preview +# command in pick.sh. + +import os +import sys + +from rich.console import Console +from rich.markdown import Markdown +from rich.theme import Theme + +theme = Theme({ + "markdown.h1": "bold bright_magenta", + "markdown.h1.border": "bright_magenta", + "markdown.h2": "bold bright_green", + "markdown.h3": "bold bright_cyan", + "markdown.h4": "bold bright_yellow", + "markdown.h5": "bold bright_blue", + "markdown.h6": "bold bright_red", + "markdown.item.bullet": "bold bright_magenta", + "markdown.item.number": "bold bright_green", + "markdown.code": "bold bright_cyan", + "markdown.link": "underline bright_blue", + "markdown.link_url": "italic dim cyan", + "markdown.strong": "bold bright_white", + "markdown.em": "italic bright_yellow", + "markdown.block_quote": "italic bright_black", + "markdown.hr": "bright_magenta", +}) + +path = sys.argv[1] +width = int(os.environ.get("FZF_PREVIEW_COLUMNS", "80")) + +console = Console( + theme=theme, + force_terminal=True, + color_system="truecolor", + width=width, +) + +with open(path, encoding="utf-8") as f: + console.print(Markdown(f.read(), code_theme="dracula")) diff --git a/opt/scripts/banger/resize_readme.sh b/opt/scripts/banger/resize_readme.sh new file mode 100755 index 0000000..a231e30 --- /dev/null +++ b/opt/scripts/banger/resize_readme.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Pin the readme pane back to its fixed width whenever the terminal resizes. +# Triggered by tmux hooks. Always exits 0 — failures here must not bubble +# up to tmux or break the user's flow when the readme is being closed. + +WIDTH=${README_WIDTH:-72} + +pane=$(tmux show-option -gqv @readme-pane 2>/dev/null) || exit 0 +[ -n "$pane" ] || exit 0 + +# is the pane still alive? +if tmux list-panes -F '#{pane_id}' 2>/dev/null | grep -qx "$pane"; then + tmux resize-pane -t "$pane" -x "$WIDTH" 2>/dev/null || true +else + # pane is gone — clear the stale option so future hooks short-circuit + tmux set-option -gu @readme-pane 2>/dev/null || true +fi + +exit 0 diff --git a/opt/scripts/banger/tmux.conf b/opt/scripts/banger/tmux.conf new file mode 100644 index 0000000..7660f57 --- /dev/null +++ b/opt/scripts/banger/tmux.conf @@ -0,0 +1,40 @@ +# tmux config used only for the bangers session. +# No-prefix bindings — keys go straight to the actions. +# +# Left prev banger +# Right next banger +# Space open readme (in a horizontal split) +# Escape quit session (back to picker) +# Ctrl+C interrupt the running banger (default behavior) + +set -g default-terminal "tmux-256color" +set -g mouse on +set -s escape-time 50 +set -g xterm-keys on + +bind-key -n Left run-shell '/opt/scripts/banger/nav.sh prev' +bind-key -n Right run-shell '/opt/scripts/banger/nav.sh next' +bind-key -n Space run-shell '/opt/scripts/banger/nav.sh readme' +bind-key -n Escape run-shell '/opt/scripts/banger/nav.sh quit' + +# Keep the readme pane locked at its fixed width across terminal resizes. +set-hook -g client-resized 'run-shell -b /opt/scripts/banger/resize_readme.sh' +set-hook -g window-layout-changed 'run-shell -b /opt/scripts/banger/resize_readme.sh' +set-hook -g pane-exited 'run-shell -b /opt/scripts/banger/resize_readme.sh' + +# --- vibrant hint bar at the bottom --- +set -g status on +set -g status-position bottom +set -g status-justify left +set -g status-style 'bg=colour226,fg=colour16' +set -g status-left-length 200 +set -g status-right-length 200 + +# left: current banger label, dark bg, yellow fg → contrast against yellow bar +set -g status-left ' #[bg=colour16,fg=colour226,bold] ♪ #{?@banger-name,#{T:@banger-name},(banger)} #[bg=colour226,fg=colour16] ' + +# right: keybindings — each key in a contrasting badge so they pop +set -g status-right '#[bg=colour18,fg=colour231,bold] ←/→ #[bg=colour226,fg=colour16,nobold] prev/next #[bg=colour22,fg=colour231,bold] Space #[bg=colour226,fg=colour16,nobold] readme #[bg=colour88,fg=colour231,bold] Esc #[bg=colour226,fg=colour16,nobold] quit #[bg=colour16,fg=colour226,bold] Ctrl+C #[bg=colour226,fg=colour16,nobold] interrupt ' + +set -g window-status-format '' +set -g window-status-current-format '' diff --git a/opt/scripts/common.sh b/opt/scripts/common.sh new file mode 100644 index 0000000..622251e --- /dev/null +++ b/opt/scripts/common.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Shared helpers sourced by other scripts in /opt/scripts/. +# Not meant to be executed directly. + +# cprintf "