From ad9d68e488e79ce553f2a55dab1b46c4c4266ed5 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 19 Dec 2025 21:35:09 +0100 Subject: [PATCH] Fix footer overflow on narrow terminals, add /arminsayshi easter egg --- packages/coding-agent/CHANGELOG.md | 4 + .../src/modes/interactive/components/armin.ts | 382 ++++++++++++++++++ .../modes/interactive/components/footer.ts | 14 +- .../src/modes/interactive/interactive-mode.ts | 12 + 4 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/armin.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5f153fba..a5cb17a1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **Footer overflow on narrow terminals**: Fixed footer path display exceeding terminal width when resizing to very narrow widths, causing rendering crashes. /arminsayshi + ## [0.24.2] - 2025-12-20 ### Fixed diff --git a/packages/coding-agent/src/modes/interactive/components/armin.ts b/packages/coding-agent/src/modes/interactive/components/armin.ts new file mode 100644 index 00000000..39d74757 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/armin.ts @@ -0,0 +1,382 @@ +/** + * Armin says hi! A fun easter egg with animated XBM art. + */ + +import type { Component, TUI } from "@mariozechner/pi-tui"; +import { theme } from "../theme/theme.js"; + +// XBM image: 31x36 pixels, LSB first, 1=background, 0=foreground +const WIDTH = 31; +const HEIGHT = 36; +const BITS = [ + 0xff, 0xff, 0xff, 0x7f, 0xff, 0xf0, 0xff, 0x7f, 0xff, 0xed, 0xff, 0x7f, 0xff, 0xdb, 0xff, 0x7f, 0xff, 0xb7, 0xff, + 0x7f, 0xff, 0x77, 0xfe, 0x7f, 0x3f, 0xf8, 0xfe, 0x7f, 0xdf, 0xff, 0xfe, 0x7f, 0xdf, 0x3f, 0xfc, 0x7f, 0x9f, 0xc3, + 0xfb, 0x7f, 0x6f, 0xfc, 0xf4, 0x7f, 0xf7, 0x0f, 0xf7, 0x7f, 0xf7, 0xff, 0xf7, 0x7f, 0xf7, 0xff, 0xe3, 0x7f, 0xf7, + 0x07, 0xe8, 0x7f, 0xef, 0xf8, 0x67, 0x70, 0x0f, 0xff, 0xbb, 0x6f, 0xf1, 0x00, 0xd0, 0x5b, 0xfd, 0x3f, 0xec, 0x53, + 0xc1, 0xff, 0xef, 0x57, 0x9f, 0xfd, 0xee, 0x5f, 0x9f, 0xfc, 0xae, 0x5f, 0x1f, 0x78, 0xac, 0x5f, 0x3f, 0x00, 0x50, + 0x6c, 0x7f, 0x00, 0xdc, 0x77, 0xff, 0xc0, 0x3f, 0x78, 0xff, 0x01, 0xf8, 0x7f, 0xff, 0x03, 0x9c, 0x78, 0xff, 0x07, + 0x8c, 0x7c, 0xff, 0x0f, 0xce, 0x78, 0xff, 0xff, 0xcf, 0x7f, 0xff, 0xff, 0xcf, 0x78, 0xff, 0xff, 0xdf, 0x78, 0xff, + 0xff, 0xdf, 0x7d, 0xff, 0xff, 0x3f, 0x7e, 0xff, 0xff, 0xff, 0x7f, +]; + +const BYTES_PER_ROW = Math.ceil(WIDTH / 8); +const DISPLAY_HEIGHT = Math.ceil(HEIGHT / 2); // Half-block rendering + +type Effect = "typewriter" | "scanline" | "rain" | "fade" | "crt" | "glitch" | "dissolve"; + +const EFFECTS: Effect[] = ["typewriter", "scanline", "rain", "fade", "crt", "glitch", "dissolve"]; + +// Get pixel at (x, y): true = foreground, false = background +function getPixel(x: number, y: number): boolean { + if (y >= HEIGHT) return false; + const byteIndex = y * BYTES_PER_ROW + Math.floor(x / 8); + const bitIndex = x % 8; + return ((BITS[byteIndex] >> bitIndex) & 1) === 0; +} + +// Get the character for a cell (2 vertical pixels packed) +function getChar(x: number, row: number): string { + const upper = getPixel(x, row * 2); + const lower = getPixel(x, row * 2 + 1); + if (upper && lower) return "█"; + if (upper) return "▀"; + if (lower) return "▄"; + return " "; +} + +// Build the final image grid +function buildFinalGrid(): string[][] { + const grid: string[][] = []; + for (let row = 0; row < DISPLAY_HEIGHT; row++) { + const line: string[] = []; + for (let x = 0; x < WIDTH; x++) { + line.push(getChar(x, row)); + } + grid.push(line); + } + return grid; +} + +export class ArminComponent implements Component { + private ui: TUI; + private interval: ReturnType | null = null; + private effect: Effect; + private finalGrid: string[][]; + private currentGrid: string[][]; + private effectState: Record = {}; + private cachedLines: string[] = []; + private cachedWidth = 0; + private gridVersion = 0; + private cachedVersion = -1; + + constructor(ui: TUI) { + this.ui = ui; + this.effect = EFFECTS[Math.floor(Math.random() * EFFECTS.length)]; + this.finalGrid = buildFinalGrid(); + this.currentGrid = this.createEmptyGrid(); + + this.initEffect(); + this.startAnimation(); + } + + invalidate(): void { + this.cachedWidth = 0; + } + + render(width: number): string[] { + if (width === this.cachedWidth && this.cachedVersion === this.gridVersion) { + return this.cachedLines; + } + + const padding = 1; + const availableWidth = width - padding; + + this.cachedLines = this.currentGrid.map((row) => { + // Clip row to available width before applying color + const clipped = row.slice(0, availableWidth).join(""); + const padRight = Math.max(0, width - padding - clipped.length); + return " " + theme.fg("accent", clipped) + " ".repeat(padRight); + }); + + // Add "ARMIN SAYS HI" at the end + const message = "ARMIN SAYS HI"; + const msgPadRight = Math.max(0, width - padding - message.length); + this.cachedLines.push(" " + theme.fg("accent", message) + " ".repeat(msgPadRight)); + + this.cachedWidth = width; + this.cachedVersion = this.gridVersion; + + return this.cachedLines; + } + + private createEmptyGrid(): string[][] { + return Array.from({ length: DISPLAY_HEIGHT }, () => Array(WIDTH).fill(" ")); + } + + private initEffect(): void { + switch (this.effect) { + case "typewriter": + this.effectState = { pos: 0 }; + break; + case "scanline": + this.effectState = { row: 0 }; + break; + case "rain": + // Track falling position for each column + this.effectState = { + drops: Array.from({ length: WIDTH }, () => ({ + y: -Math.floor(Math.random() * DISPLAY_HEIGHT * 2), + settled: 0, + })), + }; + break; + case "fade": { + // Shuffle all pixel positions + const positions: [number, number][] = []; + for (let row = 0; row < DISPLAY_HEIGHT; row++) { + for (let x = 0; x < WIDTH; x++) { + positions.push([row, x]); + } + } + // Fisher-Yates shuffle + for (let i = positions.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [positions[i], positions[j]] = [positions[j], positions[i]]; + } + this.effectState = { positions, idx: 0 }; + break; + } + case "crt": + this.effectState = { expansion: 0 }; + break; + case "glitch": + this.effectState = { phase: 0, glitchFrames: 8 }; + break; + case "dissolve": { + // Start with random noise + this.currentGrid = Array.from({ length: DISPLAY_HEIGHT }, () => + Array.from({ length: WIDTH }, () => { + const chars = [" ", "░", "▒", "▓", "█", "▀", "▄"]; + return chars[Math.floor(Math.random() * chars.length)]; + }), + ); + // Shuffle positions for gradual resolve + const dissolvePositions: [number, number][] = []; + for (let row = 0; row < DISPLAY_HEIGHT; row++) { + for (let x = 0; x < WIDTH; x++) { + dissolvePositions.push([row, x]); + } + } + for (let i = dissolvePositions.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [dissolvePositions[i], dissolvePositions[j]] = [dissolvePositions[j], dissolvePositions[i]]; + } + this.effectState = { positions: dissolvePositions, idx: 0 }; + break; + } + } + } + + private startAnimation(): void { + const fps = this.effect === "glitch" ? 60 : 30; + this.interval = setInterval(() => { + const done = this.tickEffect(); + this.updateDisplay(); + this.ui.requestRender(); + if (done) { + this.stopAnimation(); + } + }, 1000 / fps); + } + + private stopAnimation(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + private tickEffect(): boolean { + switch (this.effect) { + case "typewriter": + return this.tickTypewriter(); + case "scanline": + return this.tickScanline(); + case "rain": + return this.tickRain(); + case "fade": + return this.tickFade(); + case "crt": + return this.tickCrt(); + case "glitch": + return this.tickGlitch(); + case "dissolve": + return this.tickDissolve(); + default: + return true; + } + } + + private tickTypewriter(): boolean { + const state = this.effectState as { pos: number }; + const pixelsPerFrame = 3; + + for (let i = 0; i < pixelsPerFrame; i++) { + const row = Math.floor(state.pos / WIDTH); + const x = state.pos % WIDTH; + if (row >= DISPLAY_HEIGHT) return true; + this.currentGrid[row][x] = this.finalGrid[row][x]; + state.pos++; + } + return false; + } + + private tickScanline(): boolean { + const state = this.effectState as { row: number }; + if (state.row >= DISPLAY_HEIGHT) return true; + + // Copy row + for (let x = 0; x < WIDTH; x++) { + this.currentGrid[state.row][x] = this.finalGrid[state.row][x]; + } + state.row++; + return false; + } + + private tickRain(): boolean { + const state = this.effectState as { + drops: { y: number; settled: number }[]; + }; + + let allSettled = true; + this.currentGrid = this.createEmptyGrid(); + + for (let x = 0; x < WIDTH; x++) { + const drop = state.drops[x]; + + // Draw settled pixels + for (let row = DISPLAY_HEIGHT - 1; row >= DISPLAY_HEIGHT - drop.settled; row--) { + if (row >= 0) { + this.currentGrid[row][x] = this.finalGrid[row][x]; + } + } + + // Check if this column is done + if (drop.settled >= DISPLAY_HEIGHT) continue; + + allSettled = false; + + // Find the target row for this column (lowest non-space pixel) + let targetRow = -1; + for (let row = DISPLAY_HEIGHT - 1 - drop.settled; row >= 0; row--) { + if (this.finalGrid[row][x] !== " ") { + targetRow = row; + break; + } + } + + // Move drop down + drop.y++; + + // Draw falling drop + if (drop.y >= 0 && drop.y < DISPLAY_HEIGHT) { + if (targetRow >= 0 && drop.y >= targetRow) { + // Settle + drop.settled = DISPLAY_HEIGHT - targetRow; + drop.y = -Math.floor(Math.random() * 5) - 1; + } else { + // Still falling + this.currentGrid[drop.y][x] = "▓"; + } + } + } + + return allSettled; + } + + private tickFade(): boolean { + const state = this.effectState as { positions: [number, number][]; idx: number }; + const pixelsPerFrame = 15; + + for (let i = 0; i < pixelsPerFrame; i++) { + if (state.idx >= state.positions.length) return true; + const [row, x] = state.positions[state.idx]; + this.currentGrid[row][x] = this.finalGrid[row][x]; + state.idx++; + } + return false; + } + + private tickCrt(): boolean { + const state = this.effectState as { expansion: number }; + const midRow = Math.floor(DISPLAY_HEIGHT / 2); + + this.currentGrid = this.createEmptyGrid(); + + // Draw from middle expanding outward + const top = midRow - state.expansion; + const bottom = midRow + state.expansion; + + for (let row = Math.max(0, top); row <= Math.min(DISPLAY_HEIGHT - 1, bottom); row++) { + for (let x = 0; x < WIDTH; x++) { + this.currentGrid[row][x] = this.finalGrid[row][x]; + } + } + + state.expansion++; + return state.expansion > DISPLAY_HEIGHT; + } + + private tickGlitch(): boolean { + const state = this.effectState as { phase: number; glitchFrames: number }; + + if (state.phase < state.glitchFrames) { + // Glitch phase: show corrupted version + this.currentGrid = this.finalGrid.map((row) => { + const offset = Math.floor(Math.random() * 7) - 3; + const glitchRow = [...row]; + + // Random horizontal offset + if (Math.random() < 0.3) { + const shifted = glitchRow.slice(offset).concat(glitchRow.slice(0, offset)); + return shifted.slice(0, WIDTH); + } + + // Random vertical swap + if (Math.random() < 0.2) { + const swapRow = Math.floor(Math.random() * DISPLAY_HEIGHT); + return [...this.finalGrid[swapRow]]; + } + + return glitchRow; + }); + state.phase++; + return false; + } + + // Final frame: show clean image + this.currentGrid = this.finalGrid.map((row) => [...row]); + return true; + } + + private tickDissolve(): boolean { + const state = this.effectState as { positions: [number, number][]; idx: number }; + const pixelsPerFrame = 20; + + for (let i = 0; i < pixelsPerFrame; i++) { + if (state.idx >= state.positions.length) return true; + const [row, x] = state.positions[state.idx]; + this.currentGrid[row][x] = this.finalGrid[row][x]; + state.idx++; + } + return false; + } + + private updateDisplay(): void { + this.gridVersion++; + } + + dispose(): void { + this.stopAnimation(); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 7d7ae27e..5eb3727a 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -188,11 +188,15 @@ export class FooterComponent implements Component { } // Truncate path if too long to fit width - const maxPathLength = Math.max(20, width - 10); // Leave some margin - if (pwd.length > maxPathLength) { - const start = pwd.slice(0, Math.floor(maxPathLength / 2) - 2); - const end = pwd.slice(-(Math.floor(maxPathLength / 2) - 1)); - pwd = `${start}...${end}`; + if (pwd.length > width) { + const half = Math.floor(width / 2) - 2; + if (half > 0) { + const start = pwd.slice(0, half); + const end = pwd.slice(-(half - 1)); + pwd = `${start}...${end}`; + } else { + pwd = pwd.slice(0, Math.max(1, width)); + } } // Build stats line diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 9c651a3b..48f29c0a 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -37,6 +37,7 @@ import { loadProjectContextFiles } from "../../core/system-prompt.js"; import type { TruncationResult } from "../../core/tools/truncate.js"; import { getChangelogPath, parseChangelog } from "../../utils/changelog.js"; import { copyToClipboard } from "../../utils/clipboard.js"; +import { ArminComponent } from "./components/armin.js"; import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; import { CompactionComponent } from "./components/compaction.js"; @@ -685,6 +686,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/arminsayshi") { + this.handleArminSaysHi(); + this.editor.setText(""); + return; + } if (text === "/resume") { this.showSessionSelector(); this.editor.setText(""); @@ -1756,6 +1762,12 @@ export class InteractiveMode { this.ui.requestRender(); } + private handleArminSaysHi(): void { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new ArminComponent(this.ui)); + this.ui.requestRender(); + } + private async handleBashCommand(command: string): Promise { const isDeferred = this.session.isStreaming; this.bashComponent = new BashExecutionComponent(command, this.ui);