diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 7508bc06a..82e1fa5fe 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -10,6 +10,7 @@ import type { BtwPanelController } from '../controllers/btw-panel'; import type { StreamingUIController } from '../controllers/streaming-ui'; import type { TasksBrowserController } from '../controllers/tasks-browser'; import { tryHandleDanceCommand } from '../easter-eggs/dance'; +import { tryHandleFootballCommand } from '../easter-eggs/football'; import type { ResolvedTheme } from '../theme/colors'; import type { TUIState } from '../tui-state'; import type { @@ -220,12 +221,15 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi return; } case 'message': - // Unknown slash command: let /dance claim it before it falls through to - // the model as a normal message. This runs *after* builtin and skill - // resolution, so a real command or a same-named skill always wins. + // Unknown slash command: let the easter eggs claim it before it falls + // through to the model as a normal message. This runs *after* builtin and + // skill resolution, so a real command or a same-named skill always wins. if (parsedCommand !== null && tryHandleDanceCommand(host, parsedCommand)) { return; } + if (parsedCommand !== null && tryHandleFootballCommand(host, parsedCommand)) { + return; + } host.sendNormalUserInput(intent.input); return; case 'builtin': diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts index a9d21452e..f04df1da2 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -4,12 +4,14 @@ import type { TUI } from '@moonshot-ai/pi-tui'; import { BRAILLE_SPINNER_FRAMES, BRAILLE_SPINNER_INTERVAL_MS, + FOOTBALL_SPINNER_FRAMES, + FOOTBALL_SPINNER_INTERVAL_MS, MOON_SPINNER_FRAMES, MOON_SPINNER_INTERVAL_MS, } from '#/tui/constant/rendering'; import { currentTheme } from '#/tui/theme'; -export type SpinnerStyle = 'moon' | 'braille'; +export type SpinnerStyle = 'moon' | 'braille' | 'football'; export class MoonLoader extends Text { private currentFrame = 0; @@ -37,8 +39,21 @@ export class MoonLoader extends Text { ) { super('', 1, 0); this.ui = ui; - this.frames = style === 'moon' ? [...MOON_SPINNER_FRAMES] : [...BRAILLE_SPINNER_FRAMES]; - this.interval = style === 'moon' ? MOON_SPINNER_INTERVAL_MS : BRAILLE_SPINNER_INTERVAL_MS; + switch (style) { + case 'football': + this.frames = [...FOOTBALL_SPINNER_FRAMES]; + this.interval = FOOTBALL_SPINNER_INTERVAL_MS; + break; + case 'braille': + this.frames = [...BRAILLE_SPINNER_FRAMES]; + this.interval = BRAILLE_SPINNER_INTERVAL_MS; + break; + case 'moon': + default: + this.frames = [...MOON_SPINNER_FRAMES]; + this.interval = MOON_SPINNER_INTERVAL_MS; + break; + } this.colorFn = colorFn; this.label = label; this.start(); diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index baf8de083..b5a97bf69 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -18,3 +18,10 @@ export const BRAILLE_SPINNER_INTERVAL_MS = 80; export const MOON_SPINNER_FRAMES = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']; export const MOON_SPINNER_INTERVAL_MS = 120; + +// The `/football` easter egg swaps the moon loader for a spinning football. +// A single emoji can't rotate in place, so the ball itself rolls back and forth +// within a fixed-width field — a rolling ball is a spinning ball. Every frame +// stays the same width so the label never jitters. +export const FOOTBALL_SPINNER_FRAMES = ['⚽ ', ' ⚽ ', ' ⚽', ' ⚽ ']; +export const FOOTBALL_SPINNER_INTERVAL_MS = 120; diff --git a/apps/kimi-code/src/tui/easter-eggs/football.ts b/apps/kimi-code/src/tui/easter-eggs/football.ts new file mode 100644 index 000000000..938aa8323 --- /dev/null +++ b/apps/kimi-code/src/tui/easter-eggs/football.ts @@ -0,0 +1,136 @@ +/** + * `/football` easter egg — everything it needs lives in this one file: the + * one-shot kick animation, the "loader is now a football" flag, and the + * command handler. Removing the feature is "delete this file + its import + * sites". + * + * Like `/dance`, it is deliberately NOT registered in BUILTIN_SLASH_COMMANDS, + * so it stays out of `/help` and autocomplete; `executeSlashCommand` calls the + * handler as a fallback after builtin/skill resolution, so a real command or a + * same-named skill always wins. `footerball` is accepted as a typo-tolerant + * alias. + */ + +import type { Container, TUI } from '@moonshot-ai/pi-tui'; + +import type { SlashCommandHost } from '../commands/dispatch'; +import type { ParsedSlashInput } from '../commands/types'; +import { MoonLoader } from '../components/chrome/moon-loader'; +import { currentTheme } from '../theme'; + +/** How long the football spins before settling into the tip line. */ +export const FOOTBALL_KICK_MS = 2200; + +let footballActive = false; +let currentKick: FootballKick | undefined; + +/** Whether the session's moon loader should render as a spinning football. */ +export function isFootballActive(): boolean { + return footballActive; +} + +export function setFootballActive(active: boolean): void { + footballActive = active; +} + +/** The easter-egg tip shown once the kick animation settles. */ +export function footballTipLine(): string { + const cmd = (text: string): string => currentTheme.boldFg('primary', text); + return `⚽ Goooal! Easter egg unlocked — the moon loader is now a spinning football. Use ${cmd('/football off')} to revert.`; +} + +/** + * A one-shot football animation: a spinning football in the transcript that + * settles into the easter-egg tip after `FOOTBALL_KICK_MS`. It reuses the + * `MoonLoader` with the `football` style, so the kick previews exactly what the + * loader will look like. + */ +export class FootballKick { + private readonly loader: MoonLoader; + private readonly container: Container; + private settleTimer: ReturnType | null = null; + private settled = false; + + constructor(ui: TUI, container: Container) { + this.container = container; + this.loader = new MoonLoader(ui, 'football'); + this.container.addChild(this.loader); + } + + /** Begin the spin; the loader is already spinning from its constructor. */ + start(): void { + this.settleTimer = setTimeout(() => { + this.settle(); + }, FOOTBALL_KICK_MS); + } + + /** Stop the spin and freeze on the easter-egg tip line. */ + settle(): void { + if (this.settled) return; + this.settled = true; + if (this.settleTimer !== null) { + clearTimeout(this.settleTimer); + this.settleTimer = null; + } + this.loader.stop(); + this.loader.setText(footballTipLine()); + } + + /** + * Clear timers without rendering the tip — for shutdown or when a newer + * `/football` supersedes this one. Removes the still-spinning loader so no + * half-finished frame is left behind. + */ + dispose(): void { + if (this.settleTimer !== null) { + clearTimeout(this.settleTimer); + this.settleTimer = null; + } + this.loader.stop(); + if (!this.settled) { + // pi-tui Container.removeChild (not a DOM node); `child.remove()` does not exist. + // oxlint-disable-next-line unicorn/prefer-dom-node-remove + this.container.removeChild(this.loader); + } + } +} + +/** Drop the in-flight kick, if any. Safe to call when idle. */ +export function disposeFootballKick(): void { + currentKick?.dispose(); + currentKick = undefined; +} + +/** + * Handle `/football`: + * /football play the kick animation and turn the moon loader into a football + * /football off bring the moon loader back + * + * (`footerball` is accepted as a typo-tolerant alias.) + * + * Returns true when it claimed the input. + */ +export function tryHandleFootballCommand( + host: SlashCommandHost, + parsed: ParsedSlashInput, +): boolean { + if (parsed.name !== 'football' && parsed.name !== 'footerball') return false; + + const sub = parsed.args.trim().toLowerCase(); + if (sub === 'off') { + setFootballActive(false); + disposeFootballKick(); + host.showStatus('The moon loader is back.'); + return true; + } + + setFootballActive(true); + // Supersede any in-flight kick so repeated `/football` doesn't stack + // half-finished animations in the transcript. + disposeFootballKick(); + const kick = new FootballKick(host.state.ui, host.state.transcriptContainer); + currentKick = kick; + kick.start(); + host.state.ui.requestRender(); + return true; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 7a423911f..aa4d1ac72 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -106,6 +106,7 @@ import { SessionReplayRenderer } from './controllers/session-replay'; import { StreamingUIController } from './controllers/streaming-ui'; import { TasksBrowserController } from './controllers/tasks-browser'; import { installRainbowDance } from './easter-eggs/dance'; +import { disposeFootballKick, isFootballActive } from './easter-eggs/football'; import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; @@ -794,6 +795,7 @@ export class KimiTUI { } finally { this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.uninstallRainbowDance(); + disposeFootballKick(); try { await this.state.terminal.drainInput(); } catch { @@ -2238,7 +2240,7 @@ export class KimiTUI { this.state.ui.requestRender(); return; case 'waiting': { - const spinner = this.ensureActivitySpinner('moon'); + const spinner = this.ensureActivitySpinner(this.moonStyle()); this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( @@ -2270,7 +2272,7 @@ export class KimiTUI { break; } case 'tool': { - const spinner = this.ensureActivitySpinner('moon'); + const spinner = this.ensureActivitySpinner(this.moonStyle()); this.syncAgentSwarmActivitySpinner(placeSpinnerInAgentSwarm ? spinner : undefined); if (placeSpinnerInAgentSwarm) break; this.state.activityContainer.addChild( @@ -2554,6 +2556,11 @@ export class KimiTUI { this.state.terminalState.progressActive = active; } + /** The `/football` easter egg swaps the moon loader for a spinning football. */ + private moonStyle(): SpinnerStyle { + return isFootballActive() ? 'football' : 'moon'; + } + private ensureActivitySpinner( style: SpinnerStyle, label = '', diff --git a/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts b/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts index ffdc5bcbe..692e4273c 100644 --- a/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/moon-loader.test.ts @@ -1,15 +1,17 @@ import type { TUI } from '@moonshot-ai/pi-tui'; +import { visibleWidth } from '@moonshot-ai/pi-tui'; import { afterEach, describe, expect, it } from 'vitest'; -import { MoonLoader } from '#/tui/components/chrome/moon-loader'; +import { MoonLoader, type SpinnerStyle } from '#/tui/components/chrome/moon-loader'; +import { FOOTBALL_SPINNER_FRAMES } from '#/tui/constant/rendering'; // MoonLoader starts a real setInterval in its constructor, so every loader // created in these tests must be stopped to avoid leaving live timers behind. const loaders: MoonLoader[] = []; -function createLoader(): MoonLoader { +function createLoader(style: SpinnerStyle = 'moon'): MoonLoader { const ui = { requestRender() {} } as unknown as TUI; - const loader = new MoonLoader(ui, 'moon'); + const loader = new MoonLoader(ui, style); loaders.push(loader); return loader; } @@ -39,4 +41,15 @@ describe('MoonLoader', () => { const row = loader.render(80).join('\n'); expect(row).toContain('Tip: ctrl+s: steer mid-turn'); }); + + it('renders a football for the football style', () => { + const loader = createLoader('football'); + + expect(loader.renderInline()).toContain('⚽'); + }); + + it('keeps every football frame the same width so the label never jitters', () => { + const widths = FOOTBALL_SPINNER_FRAMES.map((frame) => visibleWidth(frame)); + expect(new Set(widths).size).toBe(1); + }); }); diff --git a/apps/kimi-code/test/tui/easter-eggs/football.test.ts b/apps/kimi-code/test/tui/easter-eggs/football.test.ts new file mode 100644 index 000000000..da7b24ef9 --- /dev/null +++ b/apps/kimi-code/test/tui/easter-eggs/football.test.ts @@ -0,0 +1,192 @@ +import type { Container, TUI } from '@moonshot-ai/pi-tui'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { MoonLoader } from '#/tui/components/chrome/moon-loader'; +import { + disposeFootballKick, + FOOTBALL_KICK_MS, + FootballKick, + isFootballActive, + setFootballActive, + tryHandleFootballCommand, +} from '#/tui/easter-eggs/football'; + +function makeContainer(): { container: Container; children: unknown[] } { + const children: unknown[] = []; + const container = { + addChild: (child: unknown) => children.push(child), + removeChild: (child: unknown) => { + const index = children.indexOf(child); + if (index >= 0) children.splice(index, 1); + }, + } as unknown as Container; + return { container, children }; +} + +function makeUi(): { ui: TUI; requestRender: ReturnType } { + const requestRender = vi.fn(); + const ui = { requestRender } as unknown as TUI; + return { ui, requestRender }; +} + +describe('FootballKick', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + disposeFootballKick(); + vi.useRealTimers(); + }); + + it('adds a spinning football loader to the container on construction', () => { + const { ui } = makeUi(); + const { container, children } = makeContainer(); + + const kick = new FootballKick(ui, container); + + expect(children).toHaveLength(1); + expect(children[0]).toBeInstanceOf(MoonLoader); + + kick.dispose(); + }); + + it('requests renders while spinning', () => { + const { ui, requestRender } = makeUi(); + const { container } = makeContainer(); + + const kick = new FootballKick(ui, container); + kick.start(); + requestRender.mockClear(); + + vi.advanceTimersByTime(80 * 3); + + expect(requestRender).toHaveBeenCalled(); + }); + + it('settles on the easter-egg tip after the kick duration', () => { + const { ui } = makeUi(); + const { container, children } = makeContainer(); + + const kick = new FootballKick(ui, container); + kick.start(); + + vi.advanceTimersByTime(FOOTBALL_KICK_MS); + + const loader = children[0] as MoonLoader; + const row = loader.render(200).join('\n'); + expect(row).toContain('Goooal!'); + expect(row).toContain('spinning football'); + expect(row).toContain('/football off'); + }); + + it('dispose before settle removes the loader and skips the tip', () => { + const { ui } = makeUi(); + const { container, children } = makeContainer(); + + const kick = new FootballKick(ui, container); + kick.start(); + vi.advanceTimersByTime(80 * 2); + + kick.dispose(); + + expect(children).toHaveLength(0); + // The settle timer must not fire after dispose. + vi.advanceTimersByTime(FOOTBALL_KICK_MS + 1000); + expect(children).toHaveLength(0); + }); +}); + +function makeHost(): { + host: SlashCommandHost; + children: unknown[]; + status: string[]; + requestRender: ReturnType; +} { + const { ui, requestRender } = makeUi(); + const { container, children } = makeContainer(); + const status: string[] = []; + const host = { + state: { ui, transcriptContainer: container }, + showStatus: (msg: string) => status.push(msg), + } as unknown as SlashCommandHost; + return { host, children, status, requestRender }; +} + +describe('tryHandleFootballCommand', () => { + beforeEach(() => { + vi.useFakeTimers(); + setFootballActive(false); + }); + + afterEach(() => { + disposeFootballKick(); + setFootballActive(false); + vi.useRealTimers(); + }); + + it('claims /football, turns the loader into a football, and plays the animation', () => { + const { host, children, status } = makeHost(); + + const handled = tryHandleFootballCommand(host, { name: 'football', args: '' }); + + expect(handled).toBe(true); + expect(isFootballActive()).toBe(true); + expect(children).toHaveLength(1); + expect(children[0]).toBeInstanceOf(MoonLoader); + expect(status).toHaveLength(0); + }); + + it('accepts the typo-tolerant /footerball alias', () => { + const { host } = makeHost(); + + const handled = tryHandleFootballCommand(host, { name: 'footerball', args: '' }); + + expect(handled).toBe(true); + expect(isFootballActive()).toBe(true); + }); + + it('/football off restores the moon loader and shows a status', () => { + const { host, status } = makeHost(); + tryHandleFootballCommand(host, { name: 'football', args: '' }); + expect(isFootballActive()).toBe(true); + + const handled = tryHandleFootballCommand(host, { name: 'football', args: 'off' }); + + expect(handled).toBe(true); + expect(isFootballActive()).toBe(false); + expect(status.join(' ')).toContain('moon loader'); + }); + + it('ignores case and surrounding whitespace in the sub-command', () => { + const { host } = makeHost(); + setFootballActive(true); + + tryHandleFootballCommand(host, { name: 'football', args: ' OFF ' }); + + expect(isFootballActive()).toBe(false); + }); + + it('does not claim other commands', () => { + const { host, children } = makeHost(); + + const handled = tryHandleFootballCommand(host, { name: 'help', args: '' }); + + expect(handled).toBe(false); + expect(isFootballActive()).toBe(false); + expect(children).toHaveLength(0); + }); + + it('supersedes a previous in-flight kick so animations do not stack', () => { + const { host, children } = makeHost(); + + tryHandleFootballCommand(host, { name: 'football', args: '' }); + expect(children).toHaveLength(1); + + tryHandleFootballCommand(host, { name: 'football', args: '' }); + + // The first kick was disposed (removed); only the new loader remains. + expect(children).toHaveLength(1); + }); +});