diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 395e96b37..c65106ad3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,14 +2,6 @@ ## [Unreleased] -### Changed - -- `/arminsayshi` now plays a 3D version in fullscreen mode, with one cube per pixel of Armin. The 3D pi logo easter egg on header logo click is replaced by it. - -### Removed - -- Removed the daxnuts easter egg shown when selecting `opencode/kimi-k2.5`. - ## [1.0.0] - 2026-10-01 ### New Features diff --git a/packages/coding-agent/src/modes/interactive/components/armin-3d.lazy.ts b/packages/coding-agent/src/modes/interactive/components/armin-3d.lazy.ts deleted file mode 100644 index e4f1f5fff..000000000 --- a/packages/coding-agent/src/modes/interactive/components/armin-3d.lazy.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type TUI, TuiAltScreen } from "@earendil-works/pi-tui"; - -/** - * Plays the 3D Armin easter egg (see armin-3d.ts), which loads on first use. Only fullscreen mode can show it, - * because it dissolves the rendered screen. The screen is captured before loading. Returns false when it cannot - * play, so the caller can fall back to the inline version. - */ -export function playArmin3d(tui: TUI): boolean { - if (!(tui instanceof TuiAltScreen)) return false; - if (tui.hasOverlay()) return true; - const screen = tui.getScreenLines(); - import("./armin-3d.ts").then( - (module) => module.playArmin3d(tui, screen), - () => {}, - ); - return true; -} diff --git a/packages/coding-agent/src/modes/interactive/components/easter-egg-3d.lazy.ts b/packages/coding-agent/src/modes/interactive/components/easter-egg-3d.lazy.ts new file mode 100644 index 000000000..01715e199 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/easter-egg-3d.lazy.ts @@ -0,0 +1,27 @@ +import { type TUI, TuiAltScreen } from "@earendil-works/pi-tui"; +import type { EasterEgg3d } from "./easter-egg-3d.ts"; + +/** + * Plays a 3D easter egg (see easter-egg-3d.ts), which loads on first use. Only fullscreen mode can show it, because + * it dissolves the rendered screen. The screen is captured before loading. Returns false when it cannot play. + */ +function playEasterEgg3d(tui: TUI, egg: EasterEgg3d): boolean { + if (!(tui instanceof TuiAltScreen)) return false; + if (tui.hasOverlay()) return true; + const screen = tui.getScreenLines(); + import("./easter-egg-3d.ts").then( + (module) => module.playEasterEgg3d(tui, screen, egg), + () => {}, + ); + return true; +} + +/** Plays the 3D pi logo, lifting off the header logo whose top-left cell is at `column`, `row`. */ +export function playPiLogo3d(tui: TUI, column: number, row: number): void { + playEasterEgg3d(tui, { kind: "pi-logo", column, row }); +} + +/** Plays the 3D Armin. Returns false when it cannot play, so the caller can fall back to the inline version. */ +export function playArmin3d(tui: TUI): boolean { + return playEasterEgg3d(tui, { kind: "armin" }); +} diff --git a/packages/coding-agent/src/modes/interactive/components/armin-3d.ts b/packages/coding-agent/src/modes/interactive/components/easter-egg-3d.ts similarity index 82% rename from packages/coding-agent/src/modes/interactive/components/armin-3d.ts rename to packages/coding-agent/src/modes/interactive/components/easter-egg-3d.ts index 6c14a6a14..073bacb1b 100644 --- a/packages/coding-agent/src/modes/interactive/components/armin-3d.ts +++ b/packages/coding-agent/src/modes/interactive/components/easter-egg-3d.ts @@ -17,11 +17,15 @@ import { ARMIN_HEIGHT, ARMIN_WIDTH, isArminPixel } from "./armin.ts"; import { formatKeyText } from "./keybinding-hints.ts"; /** - * Fullscreen 3D version of the /arminsayshi easter egg. The current screen dissolves into braille dust, spreading - * out from the center, while Armin's bitmap, built from one cube per pixel, grows out of the center and spins. - * Leaving plays the same timeline backwards, so Armin shrinks away and the screen reassembles. + * Fullscreen 3D easter eggs: the pi logo (header logo click) and Armin (/arminsayshi). Both are bitmaps built from + * one block per pixel. The current screen dissolves into braille dust while the model spins in the center and its + * blocks play a sliding puzzle. Leaving plays the same timeline backwards, so the screen reassembles. * - * The cubes are ray cast per braille dot. A braille cell holds 2x4 roughly square dots. + * The pi logo lifts off the header, flies to the center, and grows; the dust spreads out from the header logo. + * Armin grows out of a speck at the center; the dust spreads out from the center. + * + * The blocks are ray cast per braille dot. A braille cell holds 2x4 roughly square dots, so one pixel of a + * half-block bitmap is exactly 2x2 dots, and the header logo (4x2 cells) is 8x8 dots when it lifts off. */ type Rgb = readonly [number, number, number]; @@ -52,15 +56,16 @@ interface Star { speed: number; } -interface LogoBox { +interface Box { min: readonly [number, number, number]; max: readonly [number, number, number]; + color: Rgb; } interface Pose { centerX: number; centerY: number; - /** Braille dots per logo pixel at depth 0. */ + /** Braille dots per model pixel at depth 0. */ scale: number; yaw: number; pitch: number; @@ -68,28 +73,89 @@ interface Pose { } const DEPTH = 0.7; -// Far enough that the perspective stays mild for a figure about 36 cubes tall. -const CAMERA_DISTANCE = 80; const FLY_START = 0.1; const FLY_DURATION = 1.3; -/** Braille dots per pixel when Armin appears, growing from a speck at the center. */ +/** Braille dots per pixel when a model without an origin appears, growing from a speck at the center. */ const START_SCALE = 0.1; /** Grid position of a block: column and row in the bitmap, and layer (-1, 0, 1) in depth. */ type Cell3 = readonly [number, number, number]; -// One block per foreground pixel of Armin's bitmap. Each is a block that the puzzle slides around. -const BLOCKS: Array<{ home: Cell3 }> = []; -for (let row = 0; row < ARMIN_HEIGHT; row++) { - for (let column = 0; column < ARMIN_WIDTH; column++) { - if (isArminPixel(column, row)) BLOCKS.push({ home: [column, row, 0] }); - } +/** A bitmap built from one block per foreground pixel. Each is a block that the puzzle slides around. */ +interface Model { + /** Bitmap size in pixels. */ + columns: number; + rows: number; + blocks: Array<{ home: Cell3; color: Rgb }>; + /** Camera distance from the model's center, in pixels. */ + cameraDistance: number; + /** Farthest distance of any block corner from the center, with blocks on the outer depth layers. */ + radius: number; + /** Largest share of the screen width the spinning model covers. */ + widthShare: number; + /** Number of blocks the puzzle moves per step, given a random number from 0 to 1. */ + puzzleMoves: (random: number) => number; + /** + * Top-left cell of the model's half-block rendering on screen. The model lifts off from there and replaces it. + * Undefined to grow out of the center. + */ + origin: { column: number; row: number } | undefined; } -// Farthest distance of any block corner from the origin, with blocks on the outer depth layers. -const LOGO_RADIUS = Math.hypot(ARMIN_WIDTH / 2, ARMIN_HEIGHT / 2, 1 + DEPTH / 2); -// The sliding puzzle: after Armin spun for a while, blocks slide into free neighboring cells, a few at a time. -// Each cycle shuffles, flies every block back home, and holds the figure briefly. +function createModel( + columns: number, + rows: number, + pixel: (column: number, row: number) => Rgb | undefined, + options: Pick & { + puzzleMoves: (random: number, blockCount: number) => number; + }, +): Model { + const blocks: Model["blocks"] = []; + for (let row = 0; row < rows; row++) { + for (let column = 0; column < columns; column++) { + const color = pixel(column, row); + if (color) blocks.push({ home: [column, row, 0], color }); + } + } + return { + columns, + rows, + blocks, + cameraDistance: options.cameraDistance, + radius: Math.hypot(columns / 2, rows / 2, 1 + DEPTH / 2), + widthShare: options.widthShare, + puzzleMoves: (random) => options.puzzleMoves(random, blocks.length), + origin: options.origin, + }; +} + +const CORAL: Rgb = [228, 138, 122]; +const BLUE: Rgb = [79, 142, 179]; +const YELLOW: Rgb = [234, 182, 93]; +const PI_LOGO_PIXELS = ["ccc.", "b.c.", "bb.y", "b..y"]; +const PI_LOGO_COLORS: Record = { c: CORAL, b: BLUE, y: YELLOW }; + +function piLogoModel(origin: { column: number; row: number }): Model { + return createModel(4, 4, (column, row) => PI_LOGO_COLORS[PI_LOGO_PIXELS[row]![column]!], { + cameraDistance: 10, + widthShare: 0.35, + puzzleMoves: (random) => (random < 0.4 ? 2 : 1), + origin, + }); +} + +function arminModel(color: Rgb): Model { + return createModel(ARMIN_WIDTH, ARMIN_HEIGHT, (column, row) => (isArminPixel(column, row) ? color : undefined), { + // Far enough that the perspective stays mild for a figure about 36 blocks tall. + cameraDistance: 80, + widthShare: 0.45, + puzzleMoves: (random, blockCount) => Math.round(blockCount * (0.03 + random * 0.04)), + origin: undefined, + }); +} + +// The sliding puzzle: after the model spun for a while, blocks slide into free neighboring cells, a few at a time. +// Each cycle shuffles, flies every block back home, and holds the model briefly. const PUZZLE_START = FLY_START + FLY_DURATION + 3; const PUZZLE_STEP = 0.3; const PUZZLE_STEPS = 12; @@ -110,18 +176,19 @@ const PUZZLE_MOVES: Cell3[] = [ ]; /** Block positions after each step of one shuffle cycle, starting at home. Deterministic per cycle. */ -function shuffleSteps(cycle: number): Cell3[][] { - let positions: Cell3[] = BLOCKS.map((block) => block.home); +function shuffleSteps(model: Model, cycle: number): Cell3[][] { + const { columns, rows } = model; + let positions: Cell3[] = model.blocks.map((block) => block.home); const steps = [positions]; const lastMoved = new Set(); let random = cycle * 7_919 + 1; const next = () => hash(random++); - const key = ([x, y, z]: Cell3) => ((z + 1) * ARMIN_HEIGHT + y) * ARMIN_WIDTH + x; + const key = ([x, y, z]: Cell3) => ((z + 1) * rows + y) * columns + x; for (let step = 0; step < PUZZLE_STEPS; step++) { const occupied = new Set(positions.map(key)); const nextPositions = [...positions]; const moved = new Set(); - const moveCount = Math.round(BLOCKS.length * (0.03 + next() * 0.04)); + const moveCount = model.puzzleMoves(next()); for (let move = 0; move < moveCount; move++) { const candidates: Array<{ block: number; target: Cell3 }> = []; for (let block = 0; block < positions.length; block++) { @@ -129,7 +196,7 @@ function shuffleSteps(cycle: number): Cell3[][] { const [x, y, z] = positions[block]!; for (const [dx, dy, dz] of PUZZLE_MOVES) { const target: Cell3 = [x + dx, y + dy, z + dz]; - if (target[0] < 0 || target[0] >= ARMIN_WIDTH || target[1] < 0 || target[1] >= ARMIN_HEIGHT) continue; + if (target[0] < 0 || target[0] >= columns || target[1] < 0 || target[1] >= rows) continue; if (target[2] < -1 || target[2] > 1 || occupied.has(key(target))) continue; candidates.push({ block, target }); } @@ -416,11 +483,11 @@ interface Face { } /** - * Renders the logo blocks into braille cells. Only faces that point at the camera and are not covered by a + * Renders the model's blocks into braille cells. Only faces that point at the camera and are not covered by a * touching block are drawn. Each face is rasterized over its projected bounds by intersecting each dot's ray * with the face's plane, with a depth buffer resolving overlaps. Buffers are reused between frames. */ -class LogoRaster { +class BlockRaster { /** Braille dot bits per cell. */ bits = new Uint8Array(0); /** Lit dots per cell. */ @@ -441,13 +508,17 @@ class LogoRaster { private dirty: { minX: number; minY: number; maxX: number; maxY: number } | undefined; /** Cells with a halo from the previous frame, cleared before the next one. */ private haloDirty: { minX: number; minY: number; maxX: number; maxY: number } | undefined; + private readonly cameraDistance: number; + + constructor(cameraDistance: number) { + this.cameraDistance = cameraDistance; + } render( width: number, height: number, pose: Pose, - boxes: readonly LogoBox[], - color: Rgb, + boxes: readonly Box[], background: Rgb, haloStrength: number, ): void { @@ -485,10 +556,11 @@ class LogoRaster { const m = rotation(pose.yaw, pose.pitch, pose.roll); const { centerX, centerY, scale } = pose; - // The camera sits at (0, 0, CAMERA_DISTANCE) in camera space; object space is the transpose rotation. - const origin = [m[6]! * CAMERA_DISTANCE, m[7]! * CAMERA_DISTANCE, m[8]! * CAMERA_DISTANCE]; + const cameraDistance = this.cameraDistance; + // The camera sits at (0, 0, cameraDistance) in camera space; object space is the transpose rotation. + const origin = [m[6]! * cameraDistance, m[7]! * cameraDistance, m[8]! * cameraDistance]; const light = isLight(background); - const faces = this.visibleFaces(m, origin, pose, boxes, color, dotWidth, dotHeight, light); + const faces = this.visibleFaces(m, origin, pose, boxes, dotWidth, dotHeight, light); if (faces.length === 0) return; let minX = dotWidth; let minY = dotHeight; @@ -519,9 +591,9 @@ class LogoRaster { const sx = (face.minX + 0.5 - centerX) / scale; for (let dotY = face.minY; dotY <= face.maxY; dotY++) { const sy = (dotY + 0.5 - centerY) / scale; - let directionA = m[a]! * sx + m[3 + a]! * sy - m[6 + a]! * CAMERA_DISTANCE; - let directionU = m[u]! * sx + m[3 + u]! * sy - m[6 + u]! * CAMERA_DISTANCE; - let directionV = m[v]! * sx + m[3 + v]! * sy - m[6 + v]! * CAMERA_DISTANCE; + let directionA = m[a]! * sx + m[3 + a]! * sy - m[6 + a]! * cameraDistance; + let directionU = m[u]! * sx + m[3 + u]! * sy - m[6 + u]! * cameraDistance; + let directionV = m[v]! * sx + m[3 + v]! * sy - m[6 + v]! * cameraDistance; let index = dotY * dotWidth + face.minX; for (let dotX = face.minX; dotX <= face.maxX; dotX++, index++) { const t = (face.plane - originA) / directionA; @@ -555,7 +627,7 @@ class LogoRaster { if (id === 0) continue; const face = faces[id - 1]!; // Points farther from the camera fade slightly toward the background for depth. - const fog = clamp01(0.15 - CAMERA_DISTANCE * (1 - depth[index]!) * 0.12) * (light ? 0.5 : 1); + const fog = clamp01(0.15 - cameraDistance * (1 - depth[index]!) * 0.12) * (light ? 0.5 : 1); faceIds[index] = 0; depth[index] = Infinity; const cellX = dotX >> 1; @@ -669,13 +741,13 @@ class LogoRaster { m: number[], origin: number[], pose: Pose, - boxes: readonly LogoBox[], - color: Rgb, + boxes: readonly Box[], dotWidth: number, dotHeight: number, lightBackground: boolean, ): Face[] { const faces: Face[] = []; + const cameraDistance = this.cameraDistance; for (const box of boxes) { for (let a = 0; a < 3; a++) { const u = (a + 1) % 3; @@ -709,7 +781,7 @@ class LogoRaster { const x = m[0]! * corner[0]! + m[1]! * corner[1]! + m[2]! * corner[2]!; const y = m[3]! * corner[0]! + m[4]! * corner[1]! + m[5]! * corner[2]!; const z = m[6]! * corner[0]! + m[7]! * corner[1]! + m[8]! * corner[2]!; - const perspective = (pose.scale * CAMERA_DISTANCE) / (CAMERA_DISTANCE - z); + const perspective = (pose.scale * cameraDistance) / (cameraDistance - z); const screenX = pose.centerX + x * perspective; const screenY = pose.centerY + y * perspective; minX = Math.min(minX, screenX); @@ -749,9 +821,9 @@ class LogoRaster { vMin: box.min[v]!, vMax: box.max[v]!, ...face, - red: Math.min(255, color[0] * light + specular), - green: Math.min(255, color[1] * light + specular), - blue: Math.min(255, color[2] * light + specular), + red: Math.min(255, box.color[0] * light + specular), + green: Math.min(255, box.color[1] * light + specular), + blue: Math.min(255, box.color[2] * light + specular), }); } } @@ -760,13 +832,18 @@ class LogoRaster { } } +/** + * Which easter egg to play. The pi logo lifts off the header logo, whose top-left cell is at `column`, `row`. + */ +export type EasterEgg3d = { kind: "pi-logo"; column: number; row: number } | { kind: "armin" }; + let playing = false; /** * Show the animation as a fullscreen overlay until it is dismissed. The overlay takes focus and mouse input and * returns focus when hidden, so the rest of the UI keeps running underneath untouched. */ -export async function playArmin3d(tui: TUI, screen: readonly string[]): Promise { +export async function playEasterEgg3d(tui: TUI, screen: readonly string[], egg: EasterEgg3d): Promise { if (playing) return; playing = true; // Fading needs the terminal's actual default colors; the theme only knows its own. @@ -777,26 +854,27 @@ export async function playArmin3d(tui: TUI, screen: readonly string[]): Promise< const colors = { foreground: toRgbTuple(reported.foreground, toRgb(theme.colors.text)), background: toRgbTuple(reported.background, dark ? [0, 0, 0] : [255, 255, 255]), - armin: toRgb(theme.colors.accent), }; if (tui.hasOverlay()) { playing = false; return; } - const animation = new Armin3d(tui, screen, colors, () => { + const model = + egg.kind === "armin" ? arminModel(toRgb(theme.colors.accent)) : piLogoModel({ column: egg.column, row: egg.row }); + const animation = new EasterEgg3dAnimation(tui, screen, model, colors, () => { playing = false; overlay.hide(); }); const overlay = tui.showOverlay(animation, { anchor: "top-left", width: "100%", maxHeight: "100%" }); } -export class Armin3d implements Component { +export class EasterEgg3dAnimation implements Component { private readonly tui: TUI; /** The screen to dissolve, as rendered lines. */ private readonly screen: readonly string[]; + private readonly model: Model; private readonly foreground: Rgb; private readonly background: Rgb; - private readonly color: Rgb; private readonly onDone: () => void; private readonly startTime = performance.now(); private lastRender = performance.now(); @@ -811,19 +889,21 @@ export class Armin3d implements Component { private stars: Array = []; private cells: ScreenCell[][] = []; private readonly ansiCache = new Map(); - private readonly logo = new LogoRaster(); + private readonly raster: BlockRaster; constructor( tui: TUI, screen: readonly string[], - colors: { foreground: Rgb; background: Rgb; armin: Rgb }, + model: Model, + colors: { foreground: Rgb; background: Rgb }, onDone: () => void, ) { this.tui = tui; this.screen = screen; + this.model = model; + this.raster = new BlockRaster(model.cameraDistance); this.foreground = colors.foreground; this.background = colors.background; - this.color = colors.armin; this.onDone = onDone; this.timer = setInterval(() => { // Also stop when no longer rendered, e.g. when pi hides all overlays on exit. @@ -908,18 +988,19 @@ export class Armin3d implements Component { offsets = this.blockOffsets(time); } - const boxes: LogoBox[] = BLOCKS.map((block, index) => { + const { columns, rows } = this.model; + const boxes: Box[] = this.model.blocks.map((block, index) => { const [dx, dy, dz] = offsets[index]!; - const x = block.home[0] - ARMIN_WIDTH / 2 + dx; - const y = block.home[1] - ARMIN_HEIGHT / 2 + dy; - return { min: [x, y, dz - DEPTH / 2], max: [x + 1, y + 1, dz + DEPTH / 2] }; + const x = block.home[0] - columns / 2 + dx; + const y = block.home[1] - rows / 2 + dy; + return { min: [x, y, dz - DEPTH / 2], max: [x + 1, y + 1, dz + DEPTH / 2], color: block.color }; }); - const logo = this.logo; - logo.render(width, height, pose, boxes, this.color, background, isLight(background) ? LIGHT_HALO_STRENGTH : 0); + const raster = this.raster; + raster.render(width, height, pose, boxes, background, isLight(background) ? LIGHT_HALO_STRENGTH : 0); const halo = (index: number, base: Rgb | undefined): Rgb | undefined => { - const amount = logo.haloAmount[index]!; + const amount = raster.haloAmount[index]!; if (amount <= 0) return base; - const color = logo.haloRgb; + const color = raster.haloRgb; return mix(base ?? background, [color[index * 3]!, color[index * 3 + 1]!, color[index * 3 + 2]!], amount); }; const backgroundFade = smooth(dissolveTime / BACKGROUND_FADE); @@ -975,11 +1056,11 @@ export class Armin3d implements Component { emit(hint.text[offset]!, offset < hint.keyLength ? hint.keyColor : hint.color, cellBg); continue; } - const dots = logo.bits[index]!; + const dots = raster.bits[index]!; if (dots) { // Quantized so neighboring cells on one face usually share a color and its escape sequence. - const step = LOGO_COLOR_STEP * logo.counts[index]!; - const rgb = logo.rgb; + const step = LOGO_COLOR_STEP * raster.counts[index]!; + const rgb = raster.rgb; const color: Rgb = [ Math.round(rgb[index * 3]! / step) * LOGO_COLOR_STEP, Math.round(rgb[index * 3 + 1]! / step) * LOGO_COLOR_STEP, @@ -994,7 +1075,7 @@ export class Armin3d implements Component { continue; } const dust = (dissolveTime - cell.delay) / DUST_DURATION; - if (dust < 0 && cell.width === 2 && !logo.bits[index + 1]) { + if (dust < 0 && cell.width === 2 && !raster.bits[index + 1]) { emit(cell.text, mix(cell.fg, background, textFade), cellBg); column++; continue; @@ -1041,16 +1122,17 @@ export class Armin3d implements Component { this.onDone(); } - /** Each block's displacement from its place in the logo at `time`, in grid units. */ + /** Each block's displacement from its place in the model at `time`, in grid units. */ private blockOffsets(time: number): Array<[number, number, number]> { - const home = (): Array<[number, number, number]> => BLOCKS.map(() => [0, 0, 0]); + const blocks = this.model.blocks; + const home = (): Array<[number, number, number]> => blocks.map(() => [0, 0, 0]); if (time < PUZZLE_START) return home(); const cycle = Math.floor((time - PUZZLE_START) / PUZZLE_CYCLE); const local = time - PUZZLE_START - cycle * PUZZLE_CYCLE; - if (this.shuffle?.cycle !== cycle) this.shuffle = { cycle, steps: shuffleSteps(cycle) }; + if (this.shuffle?.cycle !== cycle) this.shuffle = { cycle, steps: shuffleSteps(this.model, cycle) }; const steps = this.shuffle.steps; const offset = (position: Cell3, index: number): [number, number, number] => { - const block = BLOCKS[index]!.home; + const block = blocks[index]!.home; return [position[0] - block[0], position[1] - block[1], position[2] - block[2]]; }; const shuffleEnd = PUZZLE_STEP * PUZZLE_STEPS; @@ -1089,12 +1171,19 @@ export class Armin3d implements Component { } private pose(width: number, height: number, progress: number, time: number): Pose { - const reach = LOGO_RADIUS * (CAMERA_DISTANCE / (CAMERA_DISTANCE - LOGO_RADIUS)); - const startScale = START_SCALE; - const endScale = Math.max(startScale, Math.min(width * 2 * 0.45, (height * 4 - 8) * 0.48) / reach); + const { columns, rows, cameraDistance, radius, widthShare, origin } = this.model; + const reach = radius * (cameraDistance / (cameraDistance - radius)); + // Lifting off, the front face must cover exactly the half-block cells (2x2 dots per pixel) despite the + // perspective. + const startScale = origin ? (2 * (cameraDistance - DEPTH / 2)) / cameraDistance : START_SCALE; + const endScale = Math.max(startScale, Math.min(width * 2 * widthShare, (height * 4 - 8) * 0.48) / reach); + const endX = width; + const endY = height * 2 - 2; + const startX = origin ? origin.column * 2 + columns : endX; + const startY = origin ? origin.row * 4 + rows : endY; return { - centerX: width, - centerY: height * 2 - 2, + centerX: startX + (endX - startX) * progress, + centerY: startY + (endY - startY) * progress, // Interpolate the zoom geometrically so it feels uniform. scale: startScale * (endScale / startScale) ** progress, yaw: spinAngle(time), @@ -1144,8 +1233,10 @@ export class Armin3d implements Component { private prepareCells(width: number, foreground: Rgb): ScreenCell[][] { const cells = parseScreen(this.screen, width, foreground, this.background); const height = Math.max(1, this.tui.terminal.rows); - const centerX = width / 2; - const centerY = height / 2; + const { columns, rows, origin } = this.model; + // The dust spreads out from where the model starts. + const centerX = origin ? origin.column + columns / 2 : width / 2; + const centerY = origin ? origin.row + rows / 4 : height / 2; const farthest = Math.hypot(Math.max(centerX, width - centerX), Math.max(centerY, height - centerY) * 2); for (let row = 0; row < cells.length; row++) { const line = cells[row]!; @@ -1164,6 +1255,22 @@ export class Armin3d implements Component { cell.ink = glyphInk(owner.text, seed); } } + // The 3D model replaces its half-block rendering on screen. + if (origin) { + for (let row = origin.row; row < origin.row + Math.ceil(rows / 2); row++) { + const line = cells[row]; + if (!line) continue; + for (let column = origin.column; column < origin.column + columns; column++) { + const cell = line[column]; + if (cell) { + cell.text = " "; + cell.width = 1; + cell.ink = 0; + cell.bg = undefined; + } + } + } + } return cells; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5d4742d32..6e508f706 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -48,6 +48,8 @@ import { type TUI, TuiAltScreen, TuiMainScreen, + type TuiMouseEvent, + type TuiMouseEventResult, visibleWidth, } from "@earendil-works/pi-tui"; import chalk from "chalk"; @@ -132,7 +134,6 @@ import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version- import { reportBug } from "./bug-report.ts"; import { createChatViewport } from "./chat-viewport.ts"; import { ArminComponent } from "./components/armin.ts"; -import { playArmin3d } from "./components/armin-3d.lazy.ts"; import { AssistantMessageComponent } from "./components/assistant-message.ts"; import { BashExecutionComponent } from "./components/bash-execution.ts"; import { BranchSummaryMessageComponent } from "./components/branch-summary-message.ts"; @@ -142,6 +143,7 @@ import { CustomEntryComponent } from "./components/custom-entry.ts"; import { CustomMessageComponent } from "./components/custom-message.ts"; import { DynamicBorder } from "./components/dynamic-border.ts"; import { EarendilAnnouncementComponent } from "./components/earendil-announcement.ts"; +import { playArmin3d, playPiLogo3d } from "./components/easter-egg-3d.lazy.ts"; import { ExtensionEditorComponent } from "./components/extension-editor.ts"; import { ExtensionInputComponent } from "./components/extension-input.ts"; import { ExtensionSelectorComponent } from "./components/extension-selector.ts"; @@ -248,6 +250,17 @@ class ExpandableText extends ThemedText implements Expandable { } } +/** The built-in header. Clicking its logo (the first two lines, after one column of padding) plays an easter egg. */ +class BuiltInHeader extends ExpandableText { + onLogoClick: ((column: number, row: number) => void) | undefined; + + handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined { + if (event.type !== "click" || event.y > 1 || event.x < 1 || event.x > 4 || !this.onLogoClick) return undefined; + this.onLogoClick(event.screenX - event.x + 1, event.screenY - event.y); + return { handled: true }; + } +} + type CompactionQueuedMessage = { text: string; mode: "steer" | "followUp"; @@ -1034,13 +1047,15 @@ export class InteractiveMode { ); const onboarding = () => theme.fg("dim", `Pi can explain its own features and look up its docs. Ask it how to use or extend Pi.`); - this.builtInHeader = new ExpandableText( + const header = new BuiltInHeader( () => `${withLogo(compactInstructions())}\n${compactOnboarding()}\n\n${onboarding()}`, () => `${withLogo(expandedInstructions())}\n\n${onboarding()}`, this.getStartupExpansionState(), 1, 0, ); + if (showLogo) header.onLogoClick = (column, row) => playPiLogo3d(this.renderer, column, row); + this.builtInHeader = header; // Setup UI layout this.headerContainer.addChild(new Spacer(1));