From 987c7314c67f960e83f521b59720390014f404cb Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 16:59:50 +0800 Subject: [PATCH 01/18] docs: design animated CLI banner --- .../2026-07-12-cli-banner-animation-design.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md diff --git a/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md b/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md new file mode 100644 index 00000000..26520a55 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md @@ -0,0 +1,71 @@ +# CLI Banner 品牌动效设计 + +## 背景 + +`comet init` 当前输出单色静态 `COMET` ASCII Banner,并使用两行标语 `Agent Skill Harness Phase-Guarded Automation / From Idea To Archive`。它能表达产品名称,但没有体现 Comet 图标由品牌蓝、扫光和粒子消散构成的视觉特征。CLI `--help` 与 `package.json` 也使用同一旧标语,需要随 Banner 一起保持一致。 + +## 目标 + +- 为交互式 `comet init` 增加一次短促、克制的彗星扫光动效。 +- 使用与 Comet favicon 同风格的深蓝、品牌蓝和亮青蓝层次。 +- 将 CLI 标语统一为 `Agent Skill Harness For Turning Ideas Into Evaluated Workflows`。 +- 保证自动化、无颜色和不支持动态刷新的终端输出稳定、可读。 +- 动效或终端能力检测失败不得阻断安装流程。 + +## 交互与视觉 + +Banner 保留现有六行 `COMET` ASCII 字形。交互式终端中,Logo 从左向右逐列完成一次约 600–800ms 的扫光:未扫过区域使用深蓝,扫光前沿使用亮青蓝,扫过区域稳定为品牌蓝。扫光到达右侧时,Logo 右边短暂显示少量 `·`、`•` 粒子并快速消失,呼应 favicon 右侧的像素拖尾。 + +动效结束后保留稳定的品牌蓝 Logo,并在下方居中显示单行标语: + +```text +Agent Skill Harness For Turning Ideas Into Evaluated Workflows +``` + +动画只播放一次,不循环,不加入声音,也不延长后续安装步骤的输出节奏。 + +## 架构 + +新增独立的 CLI Banner 模块,负责以下边界明确的能力: + +1. 保存 Logo 字形、标语和品牌色板。 +2. 生成无 ANSI 控制符的静态 Banner。 +3. 根据当前动画进度生成单帧彩色 Banner。 +4. 判断运行环境是否允许动画并播放帧序列。 +5. 清理临时帧并留下最终静态彩色 Banner。 + +`init` 命令只调用一个 Banner 输出入口,不持有颜色、计时或光标控制逻辑。CLI 根命令描述和 `package.json` 描述直接使用新标语;不在本次改动中调整 README 或 website 内容。 + +## 降级与错误处理 + +以下情况不播放动画,直接输出无控制字符的静态 Banner: + +- 使用 `--json`;该模式继续抑制全部 Banner 输出。 +- 标准输出不是 TTY。 +- 检测到 CI 环境。 +- 设置了 `NO_COLOR`。 +- 终端能力不足或动画播放期间发生异常。 + +动画入口必须在写入帧前完成能力判断。播放异常被局部捕获并回退到静态输出,不改变 `comet init` 的退出码,也不影响安装步骤。静态文本不得依赖 ANSI 支持,便于日志采集与快照测试。 + +## 测试 + +- 单元测试验证新 Logo 标语、静态输出和标语居中规则。 +- 使用可控时钟与输出适配器验证扫光帧顺序、品牌色层次和最终稳定帧,避免真实等待导致测试变慢。 +- 覆盖非 TTY、CI、`NO_COLOR` 和动画异常时的静态降级。 +- 保留并扩展 `--json` 不输出 Banner 的端到端测试。 +- 验证 CLI `--help` 描述与 `package.json` 描述使用同一新标语。 + +## 发布说明与版本 + +这是从当前 `master` 可见静态 Banner 到品牌化动效 Banner 的用户可见变化,应追加到现有 `0.4.0-beta.4` Changelog 条目下,描述最终用户看到的视觉与文案升级。当前分支版本已经比最近发布的 `0.4.0-beta.3` 高一个预发布版本,因此不新增版本号。 + +普通回归测试和内部模块拆分不单独写入 Changelog。现有未提交的 `website` 子模块指针变化不属于本次工作,保持不动。 + +## 非目标 + +- 不在其他命令启动时重复播放动画。 +- 不引入终端动画第三方依赖。 +- 不修改 favicon、README 图片或 website 主题。 +- 不为不同语言维护不同品牌标语。 +- 不改变 `comet init` 的安装语义、提示顺序或 JSON 输出协议。 From 16272e01453da3332c229e28ee30d6adf3f3a26e Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 17:04:20 +0800 Subject: [PATCH 02/18] docs: plan animated CLI banner --- .../plans/2026-07-12-cli-banner-animation.md | 547 ++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-12-cli-banner-animation.md diff --git a/docs/superpowers/plans/2026-07-12-cli-banner-animation.md b/docs/superpowers/plans/2026-07-12-cli-banner-animation.md new file mode 100644 index 00000000..be84cc2e --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-cli-banner-animation.md @@ -0,0 +1,547 @@ +# CLI Banner Animation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 为交互式 `comet init` 增加品牌蓝彗星扫光与粒子拖尾,并将 CLI 标语统一为 `Agent Skill Harness For Turning Ideas Into Evaluated Workflows`。 + +**Architecture:** 在 `app/cli/comet-banner.ts` 中隔离纯渲染、环境判定和帧播放;`init` 只等待一个 Banner 输出入口。播放器通过可注入的输出、环境和 sleep 依赖实现确定性测试,在非 TTY、CI、`NO_COLOR`、`TERM=dumb` 或异常时输出无 ANSI 静态 Banner。 + +**Tech Stack:** TypeScript ES modules、Node.js 20 标准终端 API、Vitest、Commander、Prettier、ESLint。 + +## Global Constraints + +- 品牌标语必须逐字为 `Agent Skill Harness For Turning Ideas Into Evaluated Workflows`。 +- 动画总时长控制在 600–800ms,只播放一次且不循环。 +- 色板固定为深蓝 `#164E9A`、品牌蓝 `#0B6FFB`、亮青蓝 `#58B8FF`。 +- `--json` 不输出 Banner;非 TTY、CI、`NO_COLOR`、`TERM=dumb` 输出无 ANSI 静态 Banner。 +- 动画异常不得改变 `comet init` 的退出码或安装流程。 +- 不引入新的运行时依赖,不修改 favicon、README、website 或安装语义。 +- 现有 `website` 子模块工作区变化不纳入任何提交。 +- Changelog 追加到现有 `0.4.0-beta.4` 条目,不升级 `package.json` 版本。 + +--- + +## File Map + +- Create: `app/cli/comet-banner.ts` — Logo、标语、色板、纯帧渲染、能力判定和动画播放器。 +- Create: `test/app/comet-banner.test.ts` — 静态渲染、彩色帧、环境降级、播放完成与异常恢复测试。 +- Modify: `app/commands/init.ts` — 删除内联 Banner,调用异步 Banner 输出入口。 +- Modify: `test/app/init-e2e.test.ts` — mock Banner 边界并验证普通模式与 JSON 模式接入。 +- Modify: `app/cli/index.ts` — 更新 Commander 根命令描述。 +- Modify: `test/app/cli-help.test.ts` — 验证根帮助中的新标语。 +- Modify: `package.json` — 更新包描述,不改变版本。 +- Modify: `CHANGELOG.md` — 在 `0.4.0-beta.4` 的 `Changed` 下记录最终用户可见的 Banner 与标语升级。 + +### Task 1: 纯 Banner 渲染与品牌色 + +**Files:** +- Create: `app/cli/comet-banner.ts` +- Create: `test/app/comet-banner.test.ts` + +**Interfaces:** +- Produces: `COMET_TAGLINE: string` +- Produces: `COMET_LOGO: readonly string[]` +- Produces: `renderCometBanner(options?: { color?: boolean }): string` +- Produces: `renderCometBannerFrame(litColumns: number, particleFrame?: number): string` +- Produces: `COMET_BANNER_LINE_COUNT: number` + +- [ ] **Step 1: 写静态 Banner 与彩色帧的失败测试** + +```ts +import { describe, expect, it } from 'vitest'; +import { + COMET_BANNER_LINE_COUNT, + COMET_LOGO, + COMET_TAGLINE, + renderCometBanner, + renderCometBannerFrame, +} from '../../app/cli/comet-banner.js'; + +describe('Comet CLI banner rendering', () => { + it('renders the exact tagline centered beneath the logo without ANSI by default', () => { + const banner = renderCometBanner(); + const lines = banner.split('\n'); + + expect(COMET_TAGLINE).toBe('Agent Skill Harness For Turning Ideas Into Evaluated Workflows'); + expect(lines).toHaveLength(COMET_BANNER_LINE_COUNT); + expect(lines.slice(0, COMET_LOGO.length)).toEqual(COMET_LOGO); + expect(lines.at(-1)?.trim()).toBe(COMET_TAGLINE); + expect(lines.at(-1)?.length).toBe(Math.max(...COMET_LOGO.map((line) => line.length))); + expect(banner).not.toContain('\u001b['); + }); + + it('uses deep blue, bright cyan-blue, and brand blue across a sweep frame', () => { + const frame = renderCometBannerFrame(24, 1); + + expect(frame).toContain('\u001b[38;2;22;78;154m'); + expect(frame).toContain('\u001b[38;2;88;184;255m'); + expect(frame).toContain('\u001b[38;2;11;111;251m'); + expect(frame).toContain('·'); + expect(frame).toContain('\u001b[0m'); + }); +}); +``` + +- [ ] **Step 2: 运行测试并确认因模块不存在而失败** + +Run: `npx vitest run test/app/comet-banner.test.ts` + +Expected: FAIL,错误包含 `Failed to load url ../../app/cli/comet-banner.js`。 + +- [ ] **Step 3: 实现 Logo、居中规则和纯帧渲染** + +在 `app/cli/comet-banner.ts` 中定义固定数据和无副作用渲染函数: + +```ts +export const COMET_TAGLINE = 'Agent Skill Harness For Turning Ideas Into Evaluated Workflows'; + +export const COMET_LOGO = [ + ' ██████╗ ██████╗ ███╗ ███╗███████╗████████╗', + ' ██╔════╝██╔═══██╗████╗ ████║██╔════╝╚══██╔══╝', + ' ██║ ██║ ██║██╔████╔██║█████╗ ██║ ', + ' ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║ ', + ' ╚██████╗╚██████╔╝██║ ╚═╝ ██║███████╗ ██║ ', + ' ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ', +] as const; + +const RESET = '\u001b[0m'; +const DEEP_BLUE = '\u001b[38;2;22;78;154m'; +const BRAND_BLUE = '\u001b[38;2;11;111;251m'; +const BRIGHT_BLUE = '\u001b[38;2;88;184;255m'; +const LOGO_WIDTH = Math.max(...COMET_LOGO.map((line) => line.length), COMET_TAGLINE.length); +const PARTICLES = ['', ' · •', ' ·']; + +export const COMET_BANNER_LINE_COUNT = COMET_LOGO.length + 1; + +function center(text: string): string { + const left = Math.max(0, Math.floor((LOGO_WIDTH - text.length) / 2)); + return `${' '.repeat(left)}${text}`.padEnd(LOGO_WIDTH); +} + +export function renderCometBanner(options: { color?: boolean } = {}): string { + const logo = options.color + ? COMET_LOGO.map((line) => `${BRAND_BLUE}${line}${RESET}`) + : [...COMET_LOGO]; + const tagline = options.color ? `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}` : center(COMET_TAGLINE); + return [...logo, tagline].join('\n'); +} + +export function renderCometBannerFrame(litColumns: number, particleFrame = 0): string { + const logo = COMET_LOGO.map((line, row) => { + let result = ''; + for (let column = 0; column < line.length; column += 1) { + const color = column < litColumns - 2 + ? BRAND_BLUE + : column <= litColumns + ? BRIGHT_BLUE + : DEEP_BLUE; + result += `${color}${line[column]}`; + } + const particles = row === 2 ? (PARTICLES[particleFrame] ?? '') : ''; + return `${result}${BRIGHT_BLUE}${particles}${RESET}`; + }); + return [...logo, `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}`].join('\n'); +} +``` + +- [ ] **Step 4: 运行渲染测试并确认通过** + +Run: `npx vitest run test/app/comet-banner.test.ts` + +Expected: PASS,2 tests passed。 + +- [ ] **Step 5: 提交纯渲染实现** + +```bash +git add app/cli/comet-banner.ts test/app/comet-banner.test.ts +git commit -m "feat: add branded CLI banner renderer" +``` + +### Task 2: TTY 动画播放器与安全降级 + +**Files:** +- Modify: `app/cli/comet-banner.ts` +- Modify: `test/app/comet-banner.test.ts` + +**Interfaces:** +- Consumes: `renderCometBanner()`、`renderCometBannerFrame()`、`COMET_BANNER_LINE_COUNT` +- Produces: `BannerRuntime` 类型 +- Produces: `canAnimateCometBanner(runtime: Pick): boolean` +- Produces: `printCometBanner(options?: { enabled?: boolean; runtime?: Partial }): Promise` + +- [ ] **Step 1: 写环境判定、完整播放和异常回退的失败测试** + +把以下测试追加到 `test/app/comet-banner.test.ts`: + +```ts +import { canAnimateCometBanner, printCometBanner } from '../../app/cli/comet-banner.js'; + +it.each([ + [{ isTTY: false, env: {} }, false], + [{ isTTY: true, env: { CI: '1' } }, false], + [{ isTTY: true, env: { NO_COLOR: '' } }, false], + [{ isTTY: true, env: { TERM: 'dumb' } }, false], + [{ isTTY: true, env: {} }, true], +] as const)('decides whether animation is safe for %o', (runtime, expected) => { + expect(canAnimateCometBanner(runtime)).toBe(expected); +}); + +it('plays one sweep, restores the cursor, and leaves a stable final frame', async () => { + const chunks: string[] = []; + const sleeps: number[] = []; + + await printCometBanner({ + runtime: { + isTTY: true, + env: {}, + write: (chunk) => chunks.push(chunk), + sleep: async (milliseconds) => { sleeps.push(milliseconds); }, + }, + }); + + const output = chunks.join(''); + expect(output).toContain('\u001b[?25l'); + expect(output).toContain('\u001b[?25h'); + expect(output).toContain('·'); + expect(output).toContain('•'); + expect(output).toContain(COMET_TAGLINE); + expect(sleeps.reduce((sum, value) => sum + value, 0)).toBeGreaterThanOrEqual(600); + expect(sleeps.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(800); +}); + +it('prints plain static output when animation is unavailable or fails', async () => { + const plainChunks: string[] = []; + await printCometBanner({ + runtime: { isTTY: false, env: {}, write: (chunk) => plainChunks.push(chunk) }, + }); + expect(plainChunks.join('')).toContain(COMET_TAGLINE); + expect(plainChunks.join('')).not.toContain('\u001b['); + + const fallbackChunks: string[] = []; + await expect(printCometBanner({ + runtime: { + isTTY: true, + env: {}, + write: (chunk) => fallbackChunks.push(chunk), + sleep: async () => { throw new Error('timer failed'); }, + }, + })).resolves.toBeUndefined(); + expect(fallbackChunks.join('')).toContain('\u001b[?25h'); + expect(fallbackChunks.join('')).toContain(COMET_TAGLINE); +}); + +it('writes nothing when disabled for JSON mode', async () => { + const chunks: string[] = []; + await printCometBanner({ enabled: false, runtime: { write: (chunk) => chunks.push(chunk) } }); + expect(chunks).toEqual([]); +}); +``` + +- [ ] **Step 2: 运行测试并确认新导出不存在** + +Run: `npx vitest run test/app/comet-banner.test.ts` + +Expected: FAIL,错误指向缺少 `canAnimateCometBanner` 或 `printCometBanner`。 + +- [ ] **Step 3: 实现可注入播放器、光标恢复和静态回退** + +在 `app/cli/comet-banner.ts` 追加以下结构;帧序列使用 13 个 45ms 扫光等待和 2 个 55ms 粒子等待,总等待 695ms: + +```ts +export type BannerRuntime = { + isTTY: boolean; + env: NodeJS.ProcessEnv; + write: (chunk: string) => void; + sleep: (milliseconds: number) => Promise; +}; + +const HIDE_CURSOR = '\u001b[?25l'; +const SHOW_CURSOR = '\u001b[?25h'; +const ERASE_LINE = '\u001b[2K'; + +const defaultRuntime: BannerRuntime = { + isTTY: Boolean(process.stdout.isTTY), + env: process.env, + write: (chunk) => { process.stdout.write(chunk); }, + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), +}; + +export function canAnimateCometBanner( + runtime: Pick, +): boolean { + const ciEnabled = Boolean(runtime.env.CI && runtime.env.CI !== 'false'); + const noColor = Object.prototype.hasOwnProperty.call(runtime.env, 'NO_COLOR'); + return runtime.isTTY && !ciEnabled && !noColor && runtime.env.TERM !== 'dumb'; +} + +function replaceFrame(frame: string, first: boolean): string { + const moveUp = first ? '' : `\u001b[${COMET_BANNER_LINE_COUNT}A`; + return `${moveUp}${frame.split('\n').map((line) => `\r${ERASE_LINE}${line}`).join('\n')}\n`; +} + +function clearRenderedFrame(): string { + const lines = Array.from( + { length: COMET_BANNER_LINE_COUNT }, + () => `\r${ERASE_LINE}`, + ).join('\n'); + return `\u001b[${COMET_BANNER_LINE_COUNT}A${lines}\n`; +} + +export async function printCometBanner( + options: { enabled?: boolean; runtime?: Partial } = {}, +): Promise { + if (options.enabled === false) return; + const runtime = { ...defaultRuntime, ...options.runtime }; + if (!canAnimateCometBanner(runtime)) { + runtime.write(`\n${renderCometBanner()}\n\n`); + return; + } + + let started = false; + try { + runtime.write(`\n${HIDE_CURSOR}`); + for (let column = 0; column <= 48; column += 4) { + runtime.write(replaceFrame(renderCometBannerFrame(column), !started)); + started = true; + await runtime.sleep(45); + } + for (const particleFrame of [1, 2]) { + runtime.write(replaceFrame(renderCometBannerFrame(50, particleFrame), false)); + await runtime.sleep(55); + } + runtime.write(replaceFrame(renderCometBanner({ color: true }), false)); + runtime.write(`${RESET}${SHOW_CURSOR}\n`); + } catch { + const cleanup = started ? clearRenderedFrame() : ''; + runtime.write(`${RESET}${SHOW_CURSOR}${cleanup}\n${renderCometBanner()}\n\n`); + } +} +``` + +- [ ] **Step 4: 运行 Banner 测试并确认通过** + +Run: `npx vitest run test/app/comet-banner.test.ts` + +Expected: PASS,所有渲染、判定、播放和回退测试通过。 + +- [ ] **Step 5: 提交动画播放器** + +```bash +git add app/cli/comet-banner.ts test/app/comet-banner.test.ts +git commit -m "feat: animate the Comet CLI banner" +``` + +### Task 3: 接入 `comet init` 并保持 JSON 协议 + +**Files:** +- Modify: `app/commands/init.ts` +- Modify: `test/app/init-e2e.test.ts` + +**Interfaces:** +- Consumes: `printCometBanner({ enabled: boolean }): Promise` +- Produces: `initCommand()` 在版本检查前等待 Banner 输出,JSON 模式传入 `enabled: false` + +- [ ] **Step 1: mock Banner 边界并写失败的接入测试** + +在 `test/app/init-e2e.test.ts` 顶层加入: + +```ts +vi.mock('../../app/cli/comet-banner.js', () => ({ + printCometBanner: vi.fn(async () => undefined), +})); +``` + +在 describe 内加入测试: + +```ts +it('enables the banner for text output and disables it for JSON output', async () => { + mockExternalSuccess(); + await fs.mkdir(path.join(tmpDir, '.claude'), { recursive: true }); + const { printCometBanner } = await import('../../app/cli/comet-banner.js'); + const { initCommand } = await import('../../app/commands/init.js'); + + await captureTextOutput(() => initCommand(tmpDir, { yes: true, language: 'en' })); + expect(printCometBanner).toHaveBeenLastCalledWith({ enabled: true }); + + await captureJsonOutput(() => initCommand(tmpDir, { yes: true, json: true })); + expect(printCometBanner).toHaveBeenLastCalledWith({ enabled: false }); +}); +``` + +- [ ] **Step 2: 运行接入测试并确认旧实现未调用模块** + +Run: `npx vitest run test/app/init-e2e.test.ts -t "enables the banner"` + +Expected: FAIL,`printCometBanner` 调用次数为 0。 + +- [ ] **Step 3: 删除内联 Banner 并等待新入口** + +在 `app/commands/init.ts` 删除 `COMET_BANNER` 常量,添加: + +```ts +import { printCometBanner } from '../cli/comet-banner.js'; +``` + +把 `initCommand` 开头替换为: + +```ts +export async function initCommand(targetPath: string, options: InitOptions = {}): Promise { + const projectPath = path.resolve(targetPath); + const log = options.json ? () => undefined : console.log; + + await printCometBanner({ enabled: !options.json }); + if (!options.json) { + await printVersionInfo(log); + } +``` + +- [ ] **Step 4: 运行 Banner 与 init 接入测试** + +Run: `npx vitest run test/app/comet-banner.test.ts test/app/init-e2e.test.ts` + +Expected: PASS,Banner 单元测试和 init E2E 全部通过。 + +- [ ] **Step 5: 提交 init 接入** + +```bash +git add app/commands/init.ts test/app/init-e2e.test.ts +git commit -m "feat: show animated banner during comet init" +``` + +### Task 4: 同步品牌文案与发布说明 + +**Files:** +- Modify: `app/cli/index.ts` +- Modify: `test/app/cli-help.test.ts` +- Modify: `package.json` +- Modify: `CHANGELOG.md` + +**Interfaces:** +- Consumes: `COMET_TAGLINE` +- Produces: CLI 根帮助和 npm 包元数据中的一致标语 + +- [ ] **Step 1: 写 CLI 帮助和包描述的失败断言** + +在 `test/app/cli-help.test.ts` 中导入 `readFileSync` 并加入: + +```ts +import { readFileSync } from 'fs'; + +it('uses the evaluated-workflows tagline in CLI and package metadata', () => { + const help = runCli('--help'); + const packageJson = JSON.parse( + readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8'), + ) as { description: string }; + const tagline = 'Agent Skill Harness For Turning Ideas Into Evaluated Workflows'; + + expect(help.status, help.stderr).toBe(0); + expect(help.stdout).toContain(tagline); + expect(packageJson.description).toBe(tagline); +}); +``` + +- [ ] **Step 2: 运行帮助测试并确认旧标语导致失败** + +Run: `npx vitest run test/app/cli-help.test.ts -t "evaluated-workflows tagline"` + +Expected: FAIL,输出或 `package.json.description` 仍包含旧标语。 + +- [ ] **Step 3: 更新 CLI 与包描述** + +在 `app/cli/index.ts` 复用 Banner 常量: + +```ts +import { COMET_TAGLINE } from './comet-banner.js'; + +program.name('comet').description(COMET_TAGLINE).version( + getCurrentVersion(), + '-v, --version', + 'output the current version', +); +``` + +在 `package.json` 中更新: + +```json +"description": "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" +``` + +- [ ] **Step 4: 在当前版本 Changelog 中记录最终用户可见变化** + +在 `CHANGELOG.md` 的 `0.4.0-beta.4` → `### Changed` 下追加: + +```markdown +- **CLI brand experience**: `comet init` now introduces Comet with a brief blue comet sweep and particle trail in interactive terminals, falls back to a stable static banner in automated output, and uses the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline across CLI and package metadata. +``` + +- [ ] **Step 5: 运行帮助测试并提交文案与 Changelog** + +Run: `npx vitest run test/app/cli-help.test.ts` + +Expected: PASS,CLI 帮助测试全部通过。 + +```bash +git add app/cli/index.ts test/app/cli-help.test.ts package.json CHANGELOG.md +git commit -m "feat: refresh Comet CLI branding" +``` + +### Task 5: 完整验证与真实 CLI 冒烟 + +**Files:** +- Verify only; no planned source changes + +**Interfaces:** +- Consumes: Tasks 1–4 的最终实现 +- Produces: 可审计的格式、lint、构建、测试和真实终端输出证据 + +- [ ] **Step 1: 检查相关文件格式和空白错误** + +Run: `npx prettier --check app/cli/comet-banner.ts app/commands/init.ts app/cli/index.ts test/app/comet-banner.test.ts test/app/init-e2e.test.ts test/app/cli-help.test.ts package.json CHANGELOG.md` + +Expected: `All matched files use Prettier code style!` + +Run: `git diff --check` + +Expected: 无输出,退出码 0。 + +- [ ] **Step 2: 运行 lint 与架构约束** + +Run: `npx eslint app/ domains/ platform/` + +Expected: 无错误,退出码 0。 + +Run: `node scripts/lint/architecture.mjs` + +Expected: 架构检查通过,退出码 0。 + +- [ ] **Step 3: 构建并运行相关测试** + +Run: `node build.js` + +Expected: 输出 `Build completed successfully!`。 + +Run: `npx vitest run test/app/comet-banner.test.ts test/app/init-e2e.test.ts test/app/cli-help.test.ts` + +Expected: 相关测试全部通过。 + +- [ ] **Step 4: 运行全量测试** + +Run: `npx vitest run` + +Expected: 全量测试通过;若默认并行模式触发已知共享 `dist` 竞争,必须另行串行验证并分别报告两组结果,不能把串行通过表述为默认并行通过。 + +- [ ] **Step 5: 检查真实静态输出和 CLI 文案** + +Run: `node bin/comet.js --help` + +Expected: 输出包含 `Agent Skill Harness For Turning Ideas Into Evaluated Workflows`。 + +Run: `$env:CI='1'; node bin/comet.js init --yes --skip-existing .; Remove-Item Env:CI` + +Expected: Banner 只出现一次,不含可见 ANSI 转义碎片,安装流程继续执行;命令不得修改 `website` 子模块指针。 + +- [ ] **Step 6: 最终工作区审计** + +Run: `git status --short; git log -5 --oneline` + +Expected: 只保留用户原有的 `website` 子模块变化;实现提交符合 `: ` 规范,计划内文件无未提交修改。 From 7783d67cabe5adf73228fbd266dd6e5072b1f0cb Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 17:15:18 +0800 Subject: [PATCH 03/18] feat: add branded CLI banner renderer --- app/cli/comet-banner.ts | 48 +++++++++++++++++++++++++++++++++++ test/app/comet-banner.test.ts | 34 +++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 app/cli/comet-banner.ts create mode 100644 test/app/comet-banner.test.ts diff --git a/app/cli/comet-banner.ts b/app/cli/comet-banner.ts new file mode 100644 index 00000000..5b6f35ed --- /dev/null +++ b/app/cli/comet-banner.ts @@ -0,0 +1,48 @@ +export const COMET_TAGLINE = 'Agent Skill Harness For Turning Ideas Into Evaluated Workflows'; + +export const COMET_LOGO = [ + ' ██████╗ ██████╗ ███╗ ███╗███████╗████████╗', + ' ██╔════╝██╔═══██╗████╗ ████║██╔════╝╚══██╔══╝', + ' ██║ ██║ ██║██╔████╔██║█████╗ ██║ ', + ' ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║ ', + ' ╚██████╗╚██████╔╝██║ ╚═╝ ██║███████╗ ██║ ', + ' ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ', +] as const; + +const RESET = '\u001b[0m'; +const DEEP_BLUE = '\u001b[38;2;22;78;154m'; +const BRAND_BLUE = '\u001b[38;2;11;111;251m'; +const BRIGHT_BLUE = '\u001b[38;2;88;184;255m'; +const LOGO_WIDTH = Math.max(...COMET_LOGO.map((line) => line.length), COMET_TAGLINE.length); +const PARTICLES = ['', ' · •', ' ·']; + +export const COMET_BANNER_LINE_COUNT = COMET_LOGO.length + 1; + +function center(text: string): string { + const left = Math.max(0, Math.floor((LOGO_WIDTH - text.length) / 2)); + return `${' '.repeat(left)}${text}`.padEnd(LOGO_WIDTH); +} + +export function renderCometBanner(options: { color?: boolean } = {}): string { + const logo = options.color + ? COMET_LOGO.map((line) => `${BRAND_BLUE}${line}${RESET}`) + : [...COMET_LOGO]; + const tagline = options.color + ? `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}` + : center(COMET_TAGLINE); + return [...logo, tagline].join('\n'); +} + +export function renderCometBannerFrame(litColumns: number, particleFrame = 0): string { + const logo = COMET_LOGO.map((line, row) => { + let result = ''; + for (let column = 0; column < line.length; column += 1) { + const color = + column < litColumns - 2 ? BRAND_BLUE : column <= litColumns ? BRIGHT_BLUE : DEEP_BLUE; + result += `${color}${line[column]}`; + } + const particles = row === 2 ? (PARTICLES[particleFrame] ?? '') : ''; + return `${result}${BRIGHT_BLUE}${particles}${RESET}`; + }); + return [...logo, `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}`].join('\n'); +} diff --git a/test/app/comet-banner.test.ts b/test/app/comet-banner.test.ts new file mode 100644 index 00000000..8eb704c3 --- /dev/null +++ b/test/app/comet-banner.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + COMET_BANNER_LINE_COUNT, + COMET_LOGO, + COMET_TAGLINE, + renderCometBanner, + renderCometBannerFrame, +} from '../../app/cli/comet-banner.js'; + +describe('Comet CLI banner rendering', () => { + it('renders the exact tagline centered beneath the logo without ANSI by default', () => { + const banner = renderCometBanner(); + const lines = banner.split('\n'); + + expect(COMET_TAGLINE).toBe('Agent Skill Harness For Turning Ideas Into Evaluated Workflows'); + expect(lines).toHaveLength(COMET_BANNER_LINE_COUNT); + expect(lines.slice(0, COMET_LOGO.length)).toEqual(COMET_LOGO); + expect(lines.at(-1)?.trim()).toBe(COMET_TAGLINE); + expect(lines.at(-1)?.length).toBe( + Math.max(...COMET_LOGO.map((line) => line.length), COMET_TAGLINE.length), + ); + expect(banner).not.toContain('\u001b['); + }); + + it('uses deep blue, bright cyan-blue, and brand blue across a sweep frame', () => { + const frame = renderCometBannerFrame(24, 1); + + expect(frame).toContain('\u001b[38;2;22;78;154m'); + expect(frame).toContain('\u001b[38;2;88;184;255m'); + expect(frame).toContain('\u001b[38;2;11;111;251m'); + expect(frame).toContain('·'); + expect(frame).toContain('\u001b[0m'); + }); +}); From e93fd8eeef1e3fa6401625f56b8edb73d173b8d9 Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 17:21:45 +0800 Subject: [PATCH 04/18] feat: animate the Comet CLI banner --- app/cli/comet-banner.ts | 69 ++++++++++++++++++++++++++++++++++ test/app/comet-banner.test.ts | 71 +++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/app/cli/comet-banner.ts b/app/cli/comet-banner.ts index 5b6f35ed..226f1084 100644 --- a/app/cli/comet-banner.ts +++ b/app/cli/comet-banner.ts @@ -46,3 +46,72 @@ export function renderCometBannerFrame(litColumns: number, particleFrame = 0): s }); return [...logo, `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}`].join('\n'); } + +export type BannerRuntime = { + isTTY: boolean; + env: NodeJS.ProcessEnv; + write: (chunk: string) => void; + sleep: (milliseconds: number) => Promise; +}; + +const HIDE_CURSOR = '\u001b[?25l'; +const SHOW_CURSOR = '\u001b[?25h'; +const ERASE_LINE = '\u001b[2K'; + +const defaultRuntime: BannerRuntime = { + isTTY: Boolean(process.stdout.isTTY), + env: process.env, + write: (chunk) => { + process.stdout.write(chunk); + }, + sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), +}; + +export function canAnimateCometBanner(runtime: Pick): boolean { + const ciEnabled = Boolean(runtime.env.CI && runtime.env.CI !== 'false'); + const noColor = Object.prototype.hasOwnProperty.call(runtime.env, 'NO_COLOR'); + return runtime.isTTY && !ciEnabled && !noColor && runtime.env.TERM !== 'dumb'; +} + +function replaceFrame(frame: string, first: boolean): string { + const moveUp = first ? '' : `\u001b[${COMET_BANNER_LINE_COUNT}A`; + return `${moveUp}${frame + .split('\n') + .map((line) => `\r${ERASE_LINE}${line}`) + .join('\n')}\n`; +} + +function clearRenderedFrame(): string { + const lines = Array.from({ length: COMET_BANNER_LINE_COUNT }, () => `\r${ERASE_LINE}`).join('\n'); + return `\u001b[${COMET_BANNER_LINE_COUNT}A${lines}\n`; +} + +export async function printCometBanner( + options: { enabled?: boolean; runtime?: Partial } = {}, +): Promise { + if (options.enabled === false) return; + const runtime = { ...defaultRuntime, ...options.runtime }; + if (!canAnimateCometBanner(runtime)) { + runtime.write(`\n${renderCometBanner()}\n\n`); + return; + } + + let started = false; + try { + runtime.write(`\n${HIDE_CURSOR}`); + for (let column = 0; column <= 48; column += 4) { + runtime.write(replaceFrame(renderCometBannerFrame(column), !started)); + started = true; + await runtime.sleep(45); + } + for (const particleFrame of [1, 2]) { + runtime.write(replaceFrame(renderCometBannerFrame(50, particleFrame), false)); + await runtime.sleep(55); + } + runtime.write(replaceFrame(renderCometBanner({ color: true }), false)); + runtime.write(`${RESET}${SHOW_CURSOR}\n`); + } catch { + const cleanup = started ? clearRenderedFrame() : ''; + runtime.write(`${RESET}${SHOW_CURSOR}${cleanup}\n${renderCometBanner()}\n\n`); + } +} diff --git a/test/app/comet-banner.test.ts b/test/app/comet-banner.test.ts index 8eb704c3..a48f0424 100644 --- a/test/app/comet-banner.test.ts +++ b/test/app/comet-banner.test.ts @@ -3,6 +3,8 @@ import { COMET_BANNER_LINE_COUNT, COMET_LOGO, COMET_TAGLINE, + canAnimateCometBanner, + printCometBanner, renderCometBanner, renderCometBannerFrame, } from '../../app/cli/comet-banner.js'; @@ -31,4 +33,73 @@ describe('Comet CLI banner rendering', () => { expect(frame).toContain('·'); expect(frame).toContain('\u001b[0m'); }); + + it.each([ + [{ isTTY: false, env: {} }, false], + [{ isTTY: true, env: { CI: '1' } }, false], + [{ isTTY: true, env: { NO_COLOR: '' } }, false], + [{ isTTY: true, env: { TERM: 'dumb' } }, false], + [{ isTTY: true, env: {} }, true], + ] as const)('decides whether animation is safe for %o', (runtime, expected) => { + expect(canAnimateCometBanner(runtime)).toBe(expected); + }); + + it('plays one sweep, restores the cursor, and leaves a stable final frame', async () => { + const chunks: string[] = []; + const sleeps: number[] = []; + + await printCometBanner({ + runtime: { + isTTY: true, + env: {}, + write: (chunk) => chunks.push(chunk), + sleep: async (milliseconds) => { + sleeps.push(milliseconds); + }, + }, + }); + + const output = chunks.join(''); + expect(output).toContain('\u001b[?25l'); + expect(output).toContain('\u001b[?25h'); + expect(output).toContain('·'); + expect(output).toContain('•'); + expect(output).toContain(COMET_TAGLINE); + expect(sleeps.reduce((sum, value) => sum + value, 0)).toBeGreaterThanOrEqual(600); + expect(sleeps.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(800); + }); + + it('prints plain static output when animation is unavailable or fails', async () => { + const plainChunks: string[] = []; + await printCometBanner({ + runtime: { isTTY: false, env: {}, write: (chunk) => plainChunks.push(chunk) }, + }); + expect(plainChunks.join('')).toContain(COMET_TAGLINE); + expect(plainChunks.join('')).not.toContain('\u001b['); + + const fallbackChunks: string[] = []; + await expect( + printCometBanner({ + runtime: { + isTTY: true, + env: {}, + write: (chunk) => fallbackChunks.push(chunk), + sleep: async () => { + throw new Error('timer failed'); + }, + }, + }), + ).resolves.toBeUndefined(); + expect(fallbackChunks.join('')).toContain('\u001b[?25h'); + expect(fallbackChunks.join('')).toContain(COMET_TAGLINE); + }); + + it('writes nothing when disabled for JSON mode', async () => { + const chunks: string[] = []; + await printCometBanner({ + enabled: false, + runtime: { write: (chunk) => chunks.push(chunk) }, + }); + expect(chunks).toEqual([]); + }); }); From 4b57660fd6b837e6ca4068b8add6da36aead2eab Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 17:28:57 +0800 Subject: [PATCH 05/18] fix: harden Comet banner output recovery --- app/cli/comet-banner.ts | 12 +++++++++-- test/app/comet-banner.test.ts | 39 +++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/app/cli/comet-banner.ts b/app/cli/comet-banner.ts index 226f1084..4f6cce9c 100644 --- a/app/cli/comet-banner.ts +++ b/app/cli/comet-banner.ts @@ -86,13 +86,21 @@ function clearRenderedFrame(): string { return `\u001b[${COMET_BANNER_LINE_COUNT}A${lines}\n`; } +function writeSafely(runtime: Pick, chunk: string): void { + try { + runtime.write(chunk); + } catch { + // Output failures cannot be recovered when the stream itself is unavailable. + } +} + export async function printCometBanner( options: { enabled?: boolean; runtime?: Partial } = {}, ): Promise { if (options.enabled === false) return; const runtime = { ...defaultRuntime, ...options.runtime }; if (!canAnimateCometBanner(runtime)) { - runtime.write(`\n${renderCometBanner()}\n\n`); + writeSafely(runtime, `\n${renderCometBanner()}\n\n`); return; } @@ -112,6 +120,6 @@ export async function printCometBanner( runtime.write(`${RESET}${SHOW_CURSOR}\n`); } catch { const cleanup = started ? clearRenderedFrame() : ''; - runtime.write(`${RESET}${SHOW_CURSOR}${cleanup}\n${renderCometBanner()}\n\n`); + writeSafely(runtime, `${RESET}${SHOW_CURSOR}${cleanup}\n${renderCometBanner()}\n\n`); } } diff --git a/test/app/comet-banner.test.ts b/test/app/comet-banner.test.ts index a48f0424..66c11fe2 100644 --- a/test/app/comet-banner.test.ts +++ b/test/app/comet-banner.test.ts @@ -65,8 +65,13 @@ describe('Comet CLI banner rendering', () => { expect(output).toContain('·'); expect(output).toContain('•'); expect(output).toContain(COMET_TAGLINE); - expect(sleeps.reduce((sum, value) => sum + value, 0)).toBeGreaterThanOrEqual(600); - expect(sleeps.reduce((sum, value) => sum + value, 0)).toBeLessThanOrEqual(800); + expect(sleeps).toEqual([...Array(13).fill(45), 55, 55]); + + const lastParticleFrame = chunks.findLastIndex((chunk) => chunk.includes(' ·')); + const stableFrame = chunks.findIndex((chunk) => chunk.includes(COMET_LOGO[0])); + expect(lastParticleFrame).toBeGreaterThan(-1); + expect(stableFrame).toBeGreaterThan(lastParticleFrame); + expect(output.endsWith('\u001b[0m\u001b[?25h\n')).toBe(true); }); it('prints plain static output when animation is unavailable or fails', async () => { @@ -90,8 +95,34 @@ describe('Comet CLI banner rendering', () => { }, }), ).resolves.toBeUndefined(); - expect(fallbackChunks.join('')).toContain('\u001b[?25h'); - expect(fallbackChunks.join('')).toContain(COMET_TAGLINE); + const fallback = fallbackChunks.at(-1) ?? ''; + const cleanupStart = fallback.indexOf(`\u001b[${COMET_BANNER_LINE_COUNT}A`); + const staticBannerStart = fallback.indexOf(`\n${renderCometBanner()}\n\n`); + expect(fallback).toContain('\u001b[?25h'); + expect(cleanupStart).toBeGreaterThan(-1); + expect(fallback.split('\u001b[2K')).toHaveLength(COMET_BANNER_LINE_COUNT + 1); + expect(cleanupStart).toBeLessThan(staticBannerStart); + }); + + it('does not reject when animation and fallback writes both fail', async () => { + let writeAttempts = 0; + + await expect( + printCometBanner({ + runtime: { + isTTY: true, + env: {}, + write: () => { + writeAttempts += 1; + if (writeAttempts >= 3) throw new Error('output failed'); + }, + sleep: async () => { + throw new Error('timer failed'); + }, + }, + }), + ).resolves.toBeUndefined(); + expect(writeAttempts).toBe(3); }); it('writes nothing when disabled for JSON mode', async () => { From 3cdf0f8a702f7f3ef6987f12bb8be444e6f46fa6 Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 17:34:51 +0800 Subject: [PATCH 06/18] feat: show animated banner during comet init --- app/commands/init.ts | 14 ++------------ test/app/init-e2e.test.ts | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/app/commands/init.ts b/app/commands/init.ts index b7006e4a..b8f2628b 100644 --- a/app/commands/init.ts +++ b/app/commands/init.ts @@ -31,6 +31,7 @@ import { resolveCodegraphCommand, } from '../../domains/integrations/codegraph.js'; import { printVersionInfo } from '../../platform/version/version.js'; +import { printCometBanner } from '../cli/comet-banner.js'; import { t, type TranslationKey } from './i18n.js'; import { detectInstalledCometTargets } from './update.js'; @@ -62,17 +63,6 @@ type ComponentPlan = { cmAction: ComponentAction; }; -const COMET_BANNER = [ - ` ██████╗ ██████╗ ███╗ ███╗███████╗████████╗`, - ` ██╔════╝██╔═══██╗████╗ ████║██╔════╝╚══██╔══╝`, - ` ██║ ██║ ██║██╔████╔██║█████╗ ██║ `, - ` ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║ `, - ` ╚██████╗╚██████╔╝██║ ╚═╝ ██║███████╗ ██║ `, - ` ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ `, - ` Agent Skill Harness Phase-Guarded Automation`, - ` From Idea To Archive `, -].join('\n'); - async function selectScope(options: InitOptions, lang: string): Promise { if (options.scope) return options.scope; if (options.yes) return 'project'; @@ -340,7 +330,7 @@ export async function initCommand(targetPath: string, options: InitOptions = {}) const projectPath = path.resolve(targetPath); const log = options.json ? () => undefined : console.log; - log(`\n${COMET_BANNER}\n`); + await printCometBanner({ enabled: !options.json }); if (!options.json) { await printVersionInfo(log); } diff --git a/test/app/init-e2e.test.ts b/test/app/init-e2e.test.ts index 205b95b6..9252f787 100644 --- a/test/app/init-e2e.test.ts +++ b/test/app/init-e2e.test.ts @@ -33,6 +33,10 @@ vi.mock('../../platform/version/version.js', () => ({ }), })); +vi.mock('../../app/cli/comet-banner.js', () => ({ + printCometBanner: vi.fn(async () => undefined), +})); + const manifestPath = path.resolve('assets', 'manifest.json'); const INIT_E2E_TIMEOUT_MS = 60_000; @@ -118,6 +122,19 @@ describe('comet init E2E', () => { await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); + it('enables the banner for text output and disables it for JSON output', async () => { + mockExternalSuccess(); + await fs.mkdir(path.join(tmpDir, '.claude'), { recursive: true }); + const { printCometBanner } = await import('../../app/cli/comet-banner.js'); + const { initCommand } = await import('../../app/commands/init.js'); + + await captureTextOutput(() => initCommand(tmpDir, { yes: true, language: 'en' })); + expect(printCometBanner).toHaveBeenLastCalledWith({ enabled: true }); + + await captureJsonOutput(() => initCommand(tmpDir, { yes: true, json: true })); + expect(printCometBanner).toHaveBeenLastCalledWith({ enabled: false }); + }); + it( 'installs Comet skills at project scope with --yes --json', async () => { From 07230fe868d9f12c3baaf67e0438f296d9765bcf Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 17:40:47 +0800 Subject: [PATCH 07/18] test: verify init waits for animated banner --- test/app/init-e2e.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/app/init-e2e.test.ts b/test/app/init-e2e.test.ts index 9252f787..73fef946 100644 --- a/test/app/init-e2e.test.ts +++ b/test/app/init-e2e.test.ts @@ -135,6 +135,30 @@ describe('comet init E2E', () => { expect(printCometBanner).toHaveBeenLastCalledWith({ enabled: false }); }); + it('waits for the banner before printing version info', async () => { + mockExternalSuccess(); + await fs.mkdir(path.join(tmpDir, '.claude'), { recursive: true }); + let resolveBanner!: () => void; + const bannerDone = new Promise((resolve) => { + resolveBanner = resolve; + }); + const { printCometBanner } = await import('../../app/cli/comet-banner.js'); + const { printVersionInfo } = await import('../../platform/version/version.js'); + vi.mocked(printCometBanner).mockImplementationOnce(() => bannerDone); + const { initCommand } = await import('../../app/commands/init.js'); + + const initPromise = captureTextOutput(() => initCommand(tmpDir, { yes: true, language: 'en' })); + await vi.waitFor(() => expect(printCometBanner).toHaveBeenCalledWith({ enabled: true })); + expect(printVersionInfo).not.toHaveBeenCalled(); + + resolveBanner(); + await initPromise; + + expect(vi.mocked(printCometBanner).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(printVersionInfo).mock.invocationCallOrder[0], + ); + }); + it( 'installs Comet skills at project scope with --yes --json', async () => { From 8672565faa469bd604bc85c6386cb75a7f33ab7e Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 17:47:29 +0800 Subject: [PATCH 08/18] feat: refresh Comet CLI branding --- CHANGELOG.md | 1 + app/cli/index.ts | 3 ++- package.json | 2 +- test/app/cli-help.test.ts | 13 +++++++++++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15abed8a..ca78e0fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to @rpamis/comet will be documented in this file. ### Changed +- **CLI brand experience**: `comet init` now introduces Comet with a brief blue comet sweep and particle trail in interactive terminals, falls back to a stable static banner in automated output, and uses the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline across CLI and package metadata. - **Mixed change status**: `comet status` now distinguishes Comet-managed and plain OpenSpec changes and recommends the correct archive command for ready changes ([#187](https://github.com/rpamis/comet/issues/187)). ### Fixed diff --git a/app/cli/index.ts b/app/cli/index.ts index 6ddcc396..6c64e03c 100644 --- a/app/cli/index.ts +++ b/app/cli/index.ts @@ -13,6 +13,7 @@ import { type PublicClassicCommand, } from '../commands/classic.js'; import { getCurrentVersion } from '../../platform/version/version.js'; +import { COMET_TAGLINE } from './comet-banner.js'; import { skillCheckCommand, skillInstallCommand, @@ -56,7 +57,7 @@ const collect = (value: string, previous: string[]): string[] => [...previous, v program .name('comet') - .description('Agent Skill Harness Phase-Guarded Automation From Idea To Archive') + .description(COMET_TAGLINE) .version(getCurrentVersion(), '-v, --version', 'output the current version'); program diff --git a/package.json b/package.json index 5809f7a8..ad9ecd89 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@rpamis/comet", "version": "0.4.0-beta.4", - "description": "Agent Skill Harness Phase-Guarded Automation From Idea To Archive", + "description": "Agent Skill Harness For Turning Ideas Into Evaluated Workflows", "keywords": [ "comet", "openspec", diff --git a/test/app/cli-help.test.ts b/test/app/cli-help.test.ts index a5a8f025..f5c4e82e 100644 --- a/test/app/cli-help.test.ts +++ b/test/app/cli-help.test.ts @@ -1,4 +1,5 @@ import { spawnSync } from 'child_process'; +import { readFileSync } from 'fs'; import path from 'path'; import { beforeAll, describe, expect, it } from 'vitest'; import { ensureCliBuilt } from '../helpers/ensure-cli-built.js'; @@ -18,6 +19,18 @@ describe('CLI help text', () => { await ensureCliBuilt(repositoryRoot); }, 120_000); + it('uses the evaluated-workflows tagline in CLI and package metadata', () => { + const help = runCli('--help'); + const packageJson = JSON.parse( + readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8'), + ) as { description: string }; + const tagline = 'Agent Skill Harness For Turning Ideas Into Evaluated Workflows'; + + expect(help.status, help.stderr).toBe(0); + expect(help.stdout).toContain(tagline); + expect(packageJson.description).toBe(tagline); + }); + it('marks bundle as the advanced backend and skill Engine runs as advanced', () => { const creatorHelp = runCli('creator', '--help'); const publishHelp = runCli('publish', '--help'); From 7afcf223b504c3f48754064f6a280ad25473f4bc Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 18:29:17 +0800 Subject: [PATCH 09/18] fix: harden CLI banner rendering --- CHANGELOG.md | 7 +- app/cli/comet-banner.ts | 118 ++++++++--- .../plans/2026-07-12-cli-banner-animation.md | 191 +++++++++++------- .../2026-07-12-cli-banner-animation-design.md | 3 +- package-lock.json | 4 +- package.json | 2 +- test/app/comet-banner.test.ts | 93 +++++++-- 7 files changed, 300 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca78e0fd..ddb78efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to @rpamis/comet will be documented in this file. +## What's Changed [0.4.0-beta.5] - 2026-07-12 + +### Changed + +- **CLI brand experience**: `comet init` now introduces Comet with a brief blue comet sweep and particle trail in compatible interactive terminals, falls back to a stable centered static banner in automated or narrow output, and uses the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline across CLI and package metadata. + ## What's Changed [0.4.0-beta.4] - 2026-07-11 ### Added @@ -13,7 +19,6 @@ All notable changes to @rpamis/comet will be documented in this file. ### Changed -- **CLI brand experience**: `comet init` now introduces Comet with a brief blue comet sweep and particle trail in interactive terminals, falls back to a stable static banner in automated output, and uses the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline across CLI and package metadata. - **Mixed change status**: `comet status` now distinguishes Comet-managed and plain OpenSpec changes and recommends the correct archive command for ready changes ([#187](https://github.com/rpamis/comet/issues/187)). ### Fixed diff --git a/app/cli/comet-banner.ts b/app/cli/comet-banner.ts index 4f6cce9c..07cab400 100644 --- a/app/cli/comet-banner.ts +++ b/app/cli/comet-banner.ts @@ -1,3 +1,5 @@ +import type { Writable } from 'node:stream'; + export const COMET_TAGLINE = 'Agent Skill Harness For Turning Ideas Into Evaluated Workflows'; export const COMET_LOGO = [ @@ -13,20 +15,28 @@ const RESET = '\u001b[0m'; const DEEP_BLUE = '\u001b[38;2;22;78;154m'; const BRAND_BLUE = '\u001b[38;2;11;111;251m'; const BRIGHT_BLUE = '\u001b[38;2;88;184;255m'; -const LOGO_WIDTH = Math.max(...COMET_LOGO.map((line) => line.length), COMET_TAGLINE.length); -const PARTICLES = ['', ' · •', ' ·']; +const BANNER_WIDTH = Math.max(...COMET_LOGO.map((line) => line.length), COMET_TAGLINE.length); +const PARTICLES = [ + [], + [ + [1, '·'], + [5, '•'], + ], + [[4, '·']], +] as const; export const COMET_BANNER_LINE_COUNT = COMET_LOGO.length + 1; function center(text: string): string { - const left = Math.max(0, Math.floor((LOGO_WIDTH - text.length) / 2)); - return `${' '.repeat(left)}${text}`.padEnd(LOGO_WIDTH); + const visibleText = text.trimEnd(); + const left = Math.max(0, Math.floor((BANNER_WIDTH - visibleText.length) / 2)); + return `${' '.repeat(left)}${visibleText}`.padEnd(BANNER_WIDTH); } export function renderCometBanner(options: { color?: boolean } = {}): string { const logo = options.color - ? COMET_LOGO.map((line) => `${BRAND_BLUE}${line}${RESET}`) - : [...COMET_LOGO]; + ? COMET_LOGO.map((line) => `${BRAND_BLUE}${center(line)}${RESET}`) + : COMET_LOGO.map(center); const tagline = options.color ? `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}` : center(COMET_TAGLINE); @@ -35,14 +45,22 @@ export function renderCometBanner(options: { color?: boolean } = {}): string { export function renderCometBannerFrame(litColumns: number, particleFrame = 0): string { const logo = COMET_LOGO.map((line, row) => { + const canvas = [...center(line)]; + const logoEnd = Math.floor((BANNER_WIDTH - line.trimEnd().length) / 2) + line.trimEnd().length; + const particles = row === 2 ? (PARTICLES[particleFrame] ?? []) : []; + for (const [offset, particle] of particles) { + const column = logoEnd + offset; + if (column < BANNER_WIDTH) canvas[column] = particle; + } + let result = ''; - for (let column = 0; column < line.length; column += 1) { + for (let column = 0; column < canvas.length; column += 1) { const color = column < litColumns - 2 ? BRAND_BLUE : column <= litColumns ? BRIGHT_BLUE : DEEP_BLUE; - result += `${color}${line[column]}`; + const isParticle = canvas[column] === '·' || canvas[column] === '•'; + result += `${isParticle ? BRIGHT_BLUE : color}${canvas[column]}`; } - const particles = row === 2 ? (PARTICLES[particleFrame] ?? '') : ''; - return `${result}${BRIGHT_BLUE}${particles}${RESET}`; + return `${result}${RESET}`; }); return [...logo, `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}`].join('\n'); } @@ -50,27 +68,70 @@ export function renderCometBannerFrame(litColumns: number, particleFrame = 0): s export type BannerRuntime = { isTTY: boolean; env: NodeJS.ProcessEnv; - write: (chunk: string) => void; + columns: number | undefined; + write: (chunk: string) => void | Promise; sleep: (milliseconds: number) => Promise; }; -const HIDE_CURSOR = '\u001b[?25l'; -const SHOW_CURSOR = '\u001b[?25h'; const ERASE_LINE = '\u001b[2K'; +export function createBannerStreamWriter(stream: Writable): BannerRuntime['write'] { + return (chunk) => + new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => stream.removeListener('error', onError); + const onError = (error: Error) => { + cleanup(); + if (settled) return; + settled = true; + reject(error); + }; + stream.once('error', onError); + + try { + stream.write(chunk, (error?: Error | null) => { + if (error) { + if (!settled) { + settled = true; + reject(error); + } + setImmediate(cleanup); + return; + } + cleanup(); + if (settled) return; + settled = true; + resolve(); + }); + } catch (error) { + cleanup(); + settled = true; + reject(error); + } + }); +} + const defaultRuntime: BannerRuntime = { isTTY: Boolean(process.stdout.isTTY), env: process.env, - write: (chunk) => { - process.stdout.write(chunk); - }, + columns: process.stdout.columns, + write: createBannerStreamWriter(process.stdout), sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), }; -export function canAnimateCometBanner(runtime: Pick): boolean { +export function canAnimateCometBanner( + runtime: Pick, +): boolean { const ciEnabled = Boolean(runtime.env.CI && runtime.env.CI !== 'false'); const noColor = Object.prototype.hasOwnProperty.call(runtime.env, 'NO_COLOR'); - return runtime.isTTY && !ciEnabled && !noColor && runtime.env.TERM !== 'dumb'; + return ( + runtime.isTTY && + !ciEnabled && + !noColor && + runtime.env.TERM !== 'dumb' && + runtime.columns !== undefined && + runtime.columns >= BANNER_WIDTH + ); } function replaceFrame(frame: string, first: boolean): string { @@ -86,9 +147,9 @@ function clearRenderedFrame(): string { return `\u001b[${COMET_BANNER_LINE_COUNT}A${lines}\n`; } -function writeSafely(runtime: Pick, chunk: string): void { +async function writeSafely(runtime: Pick, chunk: string): Promise { try { - runtime.write(chunk); + await runtime.write(chunk); } catch { // Output failures cannot be recovered when the stream itself is unavailable. } @@ -100,26 +161,27 @@ export async function printCometBanner( if (options.enabled === false) return; const runtime = { ...defaultRuntime, ...options.runtime }; if (!canAnimateCometBanner(runtime)) { - writeSafely(runtime, `\n${renderCometBanner()}\n\n`); + await writeSafely(runtime, `\n${renderCometBanner()}\n\n`); return; } let started = false; try { - runtime.write(`\n${HIDE_CURSOR}`); - for (let column = 0; column <= 48; column += 4) { - runtime.write(replaceFrame(renderCometBannerFrame(column), !started)); + await runtime.write('\n'); + for (let step = 0; step <= 12; step += 1) { + const column = Math.round((BANNER_WIDTH * step) / 12); + await runtime.write(replaceFrame(renderCometBannerFrame(column), !started)); started = true; await runtime.sleep(45); } for (const particleFrame of [1, 2]) { - runtime.write(replaceFrame(renderCometBannerFrame(50, particleFrame), false)); + await runtime.write(replaceFrame(renderCometBannerFrame(BANNER_WIDTH, particleFrame), false)); await runtime.sleep(55); } - runtime.write(replaceFrame(renderCometBanner({ color: true }), false)); - runtime.write(`${RESET}${SHOW_CURSOR}\n`); + await runtime.write(replaceFrame(renderCometBanner({ color: true }), false)); + await runtime.write(`${RESET}\n`); } catch { const cleanup = started ? clearRenderedFrame() : ''; - writeSafely(runtime, `${RESET}${SHOW_CURSOR}${cleanup}\n${renderCometBanner()}\n\n`); + await writeSafely(runtime, `${RESET}${cleanup}\n${renderCometBanner()}\n\n`); } } diff --git a/docs/superpowers/plans/2026-07-12-cli-banner-animation.md b/docs/superpowers/plans/2026-07-12-cli-banner-animation.md index be84cc2e..bdcdde98 100644 --- a/docs/superpowers/plans/2026-07-12-cli-banner-animation.md +++ b/docs/superpowers/plans/2026-07-12-cli-banner-animation.md @@ -17,7 +17,8 @@ - 动画异常不得改变 `comet init` 的退出码或安装流程。 - 不引入新的运行时依赖,不修改 favicon、README、website 或安装语义。 - 现有 `website` 子模块工作区变化不纳入任何提交。 -- Changelog 追加到现有 `0.4.0-beta.4` 条目,不升级 `package.json` 版本。 +- 当前 `master` 为 `0.4.0-beta.4`;本分支将 `package.json`、锁文件与 Changelog 升级为只高一个预发布版本的 `0.4.0-beta.5`。 +- 动画不发送光标 hide/show 控制序列,避免终止信号绕过 JavaScript 清理时遗留隐藏光标。 --- @@ -29,16 +30,18 @@ - Modify: `test/app/init-e2e.test.ts` — mock Banner 边界并验证普通模式与 JSON 模式接入。 - Modify: `app/cli/index.ts` — 更新 Commander 根命令描述。 - Modify: `test/app/cli-help.test.ts` — 验证根帮助中的新标语。 -- Modify: `package.json` — 更新包描述,不改变版本。 -- Modify: `CHANGELOG.md` — 在 `0.4.0-beta.4` 的 `Changed` 下记录最终用户可见的 Banner 与标语升级。 +- Modify: `package.json` / `package-lock.json` — 更新包描述,并将版本同步升级为 `0.4.0-beta.5`。 +- Modify: `CHANGELOG.md` — 在顶部新增 `0.4.0-beta.5` 的 `Changed`,记录最终用户可见的 Banner 与标语升级。 ### Task 1: 纯 Banner 渲染与品牌色 **Files:** + - Create: `app/cli/comet-banner.ts` - Create: `test/app/comet-banner.test.ts` **Interfaces:** + - Produces: `COMET_TAGLINE: string` - Produces: `COMET_LOGO: readonly string[]` - Produces: `renderCometBanner(options?: { color?: boolean }): string` @@ -58,15 +61,22 @@ import { } from '../../app/cli/comet-banner.js'; describe('Comet CLI banner rendering', () => { - it('renders the exact tagline centered beneath the logo without ANSI by default', () => { + it('centers the logo and tagline on one shared canvas without ANSI by default', () => { const banner = renderCometBanner(); const lines = banner.split('\n'); + const canvasWidth = COMET_TAGLINE.length; expect(COMET_TAGLINE).toBe('Agent Skill Harness For Turning Ideas Into Evaluated Workflows'); expect(lines).toHaveLength(COMET_BANNER_LINE_COUNT); - expect(lines.slice(0, COMET_LOGO.length)).toEqual(COMET_LOGO); + for (const [index, logoLine] of COMET_LOGO.entries()) { + const visibleLogo = logoLine.trimEnd(); + expect(lines[index]).toHaveLength(canvasWidth); + expect(lines[index]?.indexOf(visibleLogo)).toBe( + Math.floor((canvasWidth - visibleLogo.length) / 2), + ); + } expect(lines.at(-1)?.trim()).toBe(COMET_TAGLINE); - expect(lines.at(-1)?.length).toBe(Math.max(...COMET_LOGO.map((line) => line.length))); + expect(lines.at(-1)?.length).toBe(canvasWidth); expect(banner).not.toContain('\u001b['); }); @@ -78,6 +88,8 @@ describe('Comet CLI banner rendering', () => { expect(frame).toContain('\u001b[38;2;11;111;251m'); expect(frame).toContain('·'); expect(frame).toContain('\u001b[0m'); + const visibleLines = frame.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').split('\n'); + expect(visibleLines.every((line) => line.length === COMET_TAGLINE.length)).toBe(true); }); }); ``` @@ -108,37 +120,52 @@ const RESET = '\u001b[0m'; const DEEP_BLUE = '\u001b[38;2;22;78;154m'; const BRAND_BLUE = '\u001b[38;2;11;111;251m'; const BRIGHT_BLUE = '\u001b[38;2;88;184;255m'; -const LOGO_WIDTH = Math.max(...COMET_LOGO.map((line) => line.length), COMET_TAGLINE.length); -const PARTICLES = ['', ' · •', ' ·']; +const BANNER_WIDTH = Math.max(...COMET_LOGO.map((line) => line.length), COMET_TAGLINE.length); +const PARTICLES = [ + [], + [ + [1, '·'], + [5, '•'], + ], + [[4, '·']], +] as const; export const COMET_BANNER_LINE_COUNT = COMET_LOGO.length + 1; function center(text: string): string { - const left = Math.max(0, Math.floor((LOGO_WIDTH - text.length) / 2)); - return `${' '.repeat(left)}${text}`.padEnd(LOGO_WIDTH); + const visibleText = text.trimEnd(); + const left = Math.max(0, Math.floor((BANNER_WIDTH - visibleText.length) / 2)); + return `${' '.repeat(left)}${visibleText}`.padEnd(BANNER_WIDTH); } export function renderCometBanner(options: { color?: boolean } = {}): string { const logo = options.color - ? COMET_LOGO.map((line) => `${BRAND_BLUE}${line}${RESET}`) - : [...COMET_LOGO]; - const tagline = options.color ? `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}` : center(COMET_TAGLINE); + ? COMET_LOGO.map((line) => `${BRAND_BLUE}${center(line)}${RESET}`) + : COMET_LOGO.map(center); + const tagline = options.color + ? `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}` + : center(COMET_TAGLINE); return [...logo, tagline].join('\n'); } export function renderCometBannerFrame(litColumns: number, particleFrame = 0): string { const logo = COMET_LOGO.map((line, row) => { + const canvas = [...center(line)]; + const logoEnd = Math.floor((BANNER_WIDTH - line.trimEnd().length) / 2) + line.trimEnd().length; + const particles = row === 2 ? (PARTICLES[particleFrame] ?? []) : []; + for (const [offset, particle] of particles) { + const column = logoEnd + offset; + if (column < BANNER_WIDTH) canvas[column] = particle; + } + let result = ''; - for (let column = 0; column < line.length; column += 1) { - const color = column < litColumns - 2 - ? BRAND_BLUE - : column <= litColumns - ? BRIGHT_BLUE - : DEEP_BLUE; - result += `${color}${line[column]}`; + for (let column = 0; column < canvas.length; column += 1) { + const color = + column < litColumns - 2 ? BRAND_BLUE : column <= litColumns ? BRIGHT_BLUE : DEEP_BLUE; + const isParticle = canvas[column] === '·' || canvas[column] === '•'; + result += `${isParticle ? BRIGHT_BLUE : color}${canvas[column]}`; } - const particles = row === 2 ? (PARTICLES[particleFrame] ?? '') : ''; - return `${result}${BRIGHT_BLUE}${particles}${RESET}`; + return `${result}${RESET}`; }); return [...logo, `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}`].join('\n'); } @@ -160,13 +187,15 @@ git commit -m "feat: add branded CLI banner renderer" ### Task 2: TTY 动画播放器与安全降级 **Files:** + - Modify: `app/cli/comet-banner.ts` - Modify: `test/app/comet-banner.test.ts` **Interfaces:** + - Consumes: `renderCometBanner()`、`renderCometBannerFrame()`、`COMET_BANNER_LINE_COUNT` - Produces: `BannerRuntime` 类型 -- Produces: `canAnimateCometBanner(runtime: Pick): boolean` +- Produces: `canAnimateCometBanner(runtime: Pick): boolean` - Produces: `printCometBanner(options?: { enabled?: boolean; runtime?: Partial }): Promise` - [ ] **Step 1: 写环境判定、完整播放和异常回退的失败测试** @@ -177,16 +206,18 @@ git commit -m "feat: add branded CLI banner renderer" import { canAnimateCometBanner, printCometBanner } from '../../app/cli/comet-banner.js'; it.each([ - [{ isTTY: false, env: {} }, false], - [{ isTTY: true, env: { CI: '1' } }, false], - [{ isTTY: true, env: { NO_COLOR: '' } }, false], - [{ isTTY: true, env: { TERM: 'dumb' } }, false], - [{ isTTY: true, env: {} }, true], + [{ isTTY: false, env: {}, columns: 80 }, false], + [{ isTTY: true, env: { CI: '1' }, columns: 80 }, false], + [{ isTTY: true, env: { NO_COLOR: '' }, columns: 80 }, false], + [{ isTTY: true, env: { TERM: 'dumb' }, columns: 80 }, false], + [{ isTTY: true, env: {}, columns: 61 }, false], + [{ isTTY: true, env: {}, columns: undefined }, false], + [{ isTTY: true, env: {}, columns: 80 }, true], ] as const)('decides whether animation is safe for %o', (runtime, expected) => { expect(canAnimateCometBanner(runtime)).toBe(expected); }); -it('plays one sweep, restores the cursor, and leaves a stable final frame', async () => { +it('plays one sweep without changing cursor visibility and leaves a stable final frame', async () => { const chunks: string[] = []; const sleeps: number[] = []; @@ -194,14 +225,17 @@ it('plays one sweep, restores the cursor, and leaves a stable final frame', asyn runtime: { isTTY: true, env: {}, + columns: 80, write: (chunk) => chunks.push(chunk), - sleep: async (milliseconds) => { sleeps.push(milliseconds); }, + sleep: async (milliseconds) => { + sleeps.push(milliseconds); + }, }, }); const output = chunks.join(''); - expect(output).toContain('\u001b[?25l'); - expect(output).toContain('\u001b[?25h'); + expect(output).not.toContain('\u001b[?25l'); + expect(output).not.toContain('\u001b[?25h'); expect(output).toContain('·'); expect(output).toContain('•'); expect(output).toContain(COMET_TAGLINE); @@ -212,21 +246,26 @@ it('plays one sweep, restores the cursor, and leaves a stable final frame', asyn it('prints plain static output when animation is unavailable or fails', async () => { const plainChunks: string[] = []; await printCometBanner({ - runtime: { isTTY: false, env: {}, write: (chunk) => plainChunks.push(chunk) }, + runtime: { isTTY: false, env: {}, columns: 80, write: (chunk) => plainChunks.push(chunk) }, }); expect(plainChunks.join('')).toContain(COMET_TAGLINE); expect(plainChunks.join('')).not.toContain('\u001b['); const fallbackChunks: string[] = []; - await expect(printCometBanner({ - runtime: { - isTTY: true, - env: {}, - write: (chunk) => fallbackChunks.push(chunk), - sleep: async () => { throw new Error('timer failed'); }, - }, - })).resolves.toBeUndefined(); - expect(fallbackChunks.join('')).toContain('\u001b[?25h'); + await expect( + printCometBanner({ + runtime: { + isTTY: true, + env: {}, + columns: 80, + write: (chunk) => fallbackChunks.push(chunk), + sleep: async () => { + throw new Error('timer failed'); + }, + }, + }), + ).resolves.toBeUndefined(); + expect(fallbackChunks.join('')).not.toContain('\u001b[?25h'); expect(fallbackChunks.join('')).toContain(COMET_TAGLINE); }); @@ -243,47 +282,55 @@ Run: `npx vitest run test/app/comet-banner.test.ts` Expected: FAIL,错误指向缺少 `canAnimateCometBanner` 或 `printCometBanner`。 -- [ ] **Step 3: 实现可注入播放器、光标恢复和静态回退** +- [ ] **Step 3: 实现可注入播放器、异步写入保护和静态回退** 在 `app/cli/comet-banner.ts` 追加以下结构;帧序列使用 13 个 45ms 扫光等待和 2 个 55ms 粒子等待,总等待 695ms: +`createBannerStreamWriter()` 同时监听真实 Writable 的 write callback 与异步 `error` 事件,把失败转换为 Promise rejection,并在完成后移除监听器。 ```ts export type BannerRuntime = { isTTY: boolean; env: NodeJS.ProcessEnv; - write: (chunk: string) => void; + columns: number | undefined; + write: (chunk: string) => void | Promise; sleep: (milliseconds: number) => Promise; }; -const HIDE_CURSOR = '\u001b[?25l'; -const SHOW_CURSOR = '\u001b[?25h'; const ERASE_LINE = '\u001b[2K'; const defaultRuntime: BannerRuntime = { isTTY: Boolean(process.stdout.isTTY), env: process.env, - write: (chunk) => { process.stdout.write(chunk); }, + columns: process.stdout.columns, + write: createBannerStreamWriter(process.stdout), sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), }; export function canAnimateCometBanner( - runtime: Pick, + runtime: Pick, ): boolean { const ciEnabled = Boolean(runtime.env.CI && runtime.env.CI !== 'false'); const noColor = Object.prototype.hasOwnProperty.call(runtime.env, 'NO_COLOR'); - return runtime.isTTY && !ciEnabled && !noColor && runtime.env.TERM !== 'dumb'; + return ( + runtime.isTTY && + !ciEnabled && + !noColor && + runtime.env.TERM !== 'dumb' && + runtime.columns !== undefined && + runtime.columns >= BANNER_WIDTH + ); } function replaceFrame(frame: string, first: boolean): string { const moveUp = first ? '' : `\u001b[${COMET_BANNER_LINE_COUNT}A`; - return `${moveUp}${frame.split('\n').map((line) => `\r${ERASE_LINE}${line}`).join('\n')}\n`; + return `${moveUp}${frame + .split('\n') + .map((line) => `\r${ERASE_LINE}${line}`) + .join('\n')}\n`; } function clearRenderedFrame(): string { - const lines = Array.from( - { length: COMET_BANNER_LINE_COUNT }, - () => `\r${ERASE_LINE}`, - ).join('\n'); + const lines = Array.from({ length: COMET_BANNER_LINE_COUNT }, () => `\r${ERASE_LINE}`).join('\n'); return `\u001b[${COMET_BANNER_LINE_COUNT}A${lines}\n`; } @@ -293,27 +340,28 @@ export async function printCometBanner( if (options.enabled === false) return; const runtime = { ...defaultRuntime, ...options.runtime }; if (!canAnimateCometBanner(runtime)) { - runtime.write(`\n${renderCometBanner()}\n\n`); + await writeSafely(runtime, `\n${renderCometBanner()}\n\n`); return; } let started = false; try { - runtime.write(`\n${HIDE_CURSOR}`); - for (let column = 0; column <= 48; column += 4) { - runtime.write(replaceFrame(renderCometBannerFrame(column), !started)); + await runtime.write('\n'); + for (let step = 0; step <= 12; step += 1) { + const column = Math.round((BANNER_WIDTH * step) / 12); + await runtime.write(replaceFrame(renderCometBannerFrame(column), !started)); started = true; await runtime.sleep(45); } for (const particleFrame of [1, 2]) { - runtime.write(replaceFrame(renderCometBannerFrame(50, particleFrame), false)); + await runtime.write(replaceFrame(renderCometBannerFrame(BANNER_WIDTH, particleFrame), false)); await runtime.sleep(55); } - runtime.write(replaceFrame(renderCometBanner({ color: true }), false)); - runtime.write(`${RESET}${SHOW_CURSOR}\n`); + await runtime.write(replaceFrame(renderCometBanner({ color: true }), false)); + await runtime.write(`${RESET}\n`); } catch { const cleanup = started ? clearRenderedFrame() : ''; - runtime.write(`${RESET}${SHOW_CURSOR}${cleanup}\n${renderCometBanner()}\n\n`); + await writeSafely(runtime, `${RESET}${cleanup}\n${renderCometBanner()}\n\n`); } } ``` @@ -334,10 +382,12 @@ git commit -m "feat: animate the Comet CLI banner" ### Task 3: 接入 `comet init` 并保持 JSON 协议 **Files:** + - Modify: `app/commands/init.ts` - Modify: `test/app/init-e2e.test.ts` **Interfaces:** + - Consumes: `printCometBanner({ enabled: boolean }): Promise` - Produces: `initCommand()` 在版本检查前等待 Banner 输出,JSON 模式传入 `enabled: false` @@ -411,12 +461,14 @@ git commit -m "feat: show animated banner during comet init" ### Task 4: 同步品牌文案与发布说明 **Files:** + - Modify: `app/cli/index.ts` - Modify: `test/app/cli-help.test.ts` - Modify: `package.json` - Modify: `CHANGELOG.md` **Interfaces:** + - Consumes: `COMET_TAGLINE` - Produces: CLI 根帮助和 npm 包元数据中的一致标语 @@ -453,11 +505,10 @@ Expected: FAIL,输出或 `package.json.description` 仍包含旧标语。 ```ts import { COMET_TAGLINE } from './comet-banner.js'; -program.name('comet').description(COMET_TAGLINE).version( - getCurrentVersion(), - '-v, --version', - 'output the current version', -); +program + .name('comet') + .description(COMET_TAGLINE) + .version(getCurrentVersion(), '-v, --version', 'output the current version'); ``` 在 `package.json` 中更新: @@ -468,10 +519,10 @@ program.name('comet').description(COMET_TAGLINE).version( - [ ] **Step 4: 在当前版本 Changelog 中记录最终用户可见变化** -在 `CHANGELOG.md` 的 `0.4.0-beta.4` → `### Changed` 下追加: +在 `CHANGELOG.md` 顶部新增 `0.4.0-beta.5` → `### Changed`: ```markdown -- **CLI brand experience**: `comet init` now introduces Comet with a brief blue comet sweep and particle trail in interactive terminals, falls back to a stable static banner in automated output, and uses the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline across CLI and package metadata. +- **CLI brand experience**: `comet init` now introduces Comet with a brief blue comet sweep and particle trail in compatible interactive terminals, falls back to a stable centered static banner in automated or narrow output, and uses the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline across CLI and package metadata. ``` - [ ] **Step 5: 运行帮助测试并提交文案与 Changelog** @@ -488,9 +539,11 @@ git commit -m "feat: refresh Comet CLI branding" ### Task 5: 完整验证与真实 CLI 冒烟 **Files:** + - Verify only; no planned source changes **Interfaces:** + - Consumes: Tasks 1–4 的最终实现 - Produces: 可审计的格式、lint、构建、测试和真实终端输出证据 diff --git a/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md b/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md index 26520a55..aae6cc92 100644 --- a/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md +++ b/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md @@ -23,6 +23,7 @@ Agent Skill Harness For Turning Ideas Into Evaluated Workflows ``` 动画只播放一次,不循环,不加入声音,也不延长后续安装步骤的输出节奏。 +动画不发送隐藏或显示光标的控制序列,避免 Ctrl+C 或终止信号在 JavaScript 清理路径之外中断时遗留隐藏光标。 ## 架构 @@ -58,7 +59,7 @@ Agent Skill Harness For Turning Ideas Into Evaluated Workflows ## 发布说明与版本 -这是从当前 `master` 可见静态 Banner 到品牌化动效 Banner 的用户可见变化,应追加到现有 `0.4.0-beta.4` Changelog 条目下,描述最终用户看到的视觉与文案升级。当前分支版本已经比最近发布的 `0.4.0-beta.3` 高一个预发布版本,因此不新增版本号。 +这是从当前 `master` 的 `0.4.0-beta.4` 可见静态 Banner 到品牌化动效 Banner 的用户可见变化。本分支将 `package.json` 与锁文件升级为只高一个预发布版本的 `0.4.0-beta.5`,并在 Changelog 顶部新增 beta.5 条目描述最终用户看到的视觉与文案升级。 普通回归测试和内部模块拆分不单独写入 Changelog。现有未提交的 `website` 子模块指针变化不属于本次工作,保持不动。 diff --git a/package-lock.json b/package-lock.json index 72b0c9c8..9916db76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.4", + "version": "0.4.0-beta.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rpamis/comet", - "version": "0.4.0-beta.4", + "version": "0.4.0-beta.5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index ad9ecd89..916eacc5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.4", + "version": "0.4.0-beta.5", "description": "Agent Skill Harness For Turning Ideas Into Evaluated Workflows", "keywords": [ "comet", diff --git a/test/app/comet-banner.test.ts b/test/app/comet-banner.test.ts index 66c11fe2..49e14fbd 100644 --- a/test/app/comet-banner.test.ts +++ b/test/app/comet-banner.test.ts @@ -1,26 +1,33 @@ +import { Writable } from 'node:stream'; import { describe, expect, it } from 'vitest'; import { COMET_BANNER_LINE_COUNT, COMET_LOGO, COMET_TAGLINE, canAnimateCometBanner, + createBannerStreamWriter, printCometBanner, renderCometBanner, renderCometBannerFrame, } from '../../app/cli/comet-banner.js'; describe('Comet CLI banner rendering', () => { - it('renders the exact tagline centered beneath the logo without ANSI by default', () => { + it('centers the logo and tagline on one shared canvas without ANSI by default', () => { const banner = renderCometBanner(); const lines = banner.split('\n'); + const canvasWidth = COMET_TAGLINE.length; expect(COMET_TAGLINE).toBe('Agent Skill Harness For Turning Ideas Into Evaluated Workflows'); expect(lines).toHaveLength(COMET_BANNER_LINE_COUNT); - expect(lines.slice(0, COMET_LOGO.length)).toEqual(COMET_LOGO); + for (const [index, logoLine] of COMET_LOGO.entries()) { + const visibleLogo = logoLine.trimEnd(); + expect(lines[index]).toHaveLength(canvasWidth); + expect(lines[index]?.indexOf(visibleLogo)).toBe( + Math.floor((canvasWidth - visibleLogo.length) / 2), + ); + } expect(lines.at(-1)?.trim()).toBe(COMET_TAGLINE); - expect(lines.at(-1)?.length).toBe( - Math.max(...COMET_LOGO.map((line) => line.length), COMET_TAGLINE.length), - ); + expect(lines.at(-1)?.length).toBe(canvasWidth); expect(banner).not.toContain('\u001b['); }); @@ -32,19 +39,46 @@ describe('Comet CLI banner rendering', () => { expect(frame).toContain('\u001b[38;2;11;111;251m'); expect(frame).toContain('·'); expect(frame).toContain('\u001b[0m'); + + const visibleLines = frame.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').split('\n'); + const particleLine = visibleLines[2] ?? ''; + const visibleLogo = COMET_LOGO[2].trimEnd(); + const logoEnd = + Math.floor((COMET_TAGLINE.length - visibleLogo.length) / 2) + visibleLogo.length; + expect(visibleLines.every((line) => line.length === COMET_TAGLINE.length)).toBe(true); + expect(particleLine.indexOf('·')).toBeGreaterThanOrEqual(logoEnd); + expect(particleLine.indexOf('•')).toBeGreaterThanOrEqual(logoEnd); }); it.each([ - [{ isTTY: false, env: {} }, false], - [{ isTTY: true, env: { CI: '1' } }, false], - [{ isTTY: true, env: { NO_COLOR: '' } }, false], - [{ isTTY: true, env: { TERM: 'dumb' } }, false], - [{ isTTY: true, env: {} }, true], + [{ isTTY: false, env: {}, columns: 80 }, false], + [{ isTTY: true, env: { CI: '1' }, columns: 80 }, false], + [{ isTTY: true, env: { NO_COLOR: '' }, columns: 80 }, false], + [{ isTTY: true, env: { TERM: 'dumb' }, columns: 80 }, false], + [{ isTTY: true, env: {}, columns: COMET_TAGLINE.length - 1 }, false], + [{ isTTY: true, env: {}, columns: undefined }, false], + [{ isTTY: true, env: {}, columns: 80 }, true], ] as const)('decides whether animation is safe for %o', (runtime, expected) => { expect(canAnimateCometBanner(runtime)).toBe(expected); }); - it('plays one sweep, restores the cursor, and leaves a stable final frame', async () => { + it('prints plain static output for narrow or unknown terminal widths', async () => { + for (const columns of [COMET_TAGLINE.length - 1, undefined]) { + const chunks: string[] = []; + await printCometBanner({ + runtime: { + isTTY: true, + env: {}, + columns, + write: (chunk) => chunks.push(chunk), + }, + }); + expect(chunks.join('')).toContain(COMET_TAGLINE); + expect(chunks.join('')).not.toContain('\u001b['); + } + }); + + it('plays one sweep without changing cursor visibility and leaves a stable final frame', async () => { const chunks: string[] = []; const sleeps: number[] = []; @@ -52,6 +86,7 @@ describe('Comet CLI banner rendering', () => { runtime: { isTTY: true, env: {}, + columns: 80, write: (chunk) => chunks.push(chunk), sleep: async (milliseconds) => { sleeps.push(milliseconds); @@ -60,18 +95,18 @@ describe('Comet CLI banner rendering', () => { }); const output = chunks.join(''); - expect(output).toContain('\u001b[?25l'); - expect(output).toContain('\u001b[?25h'); + expect(output).not.toContain('\u001b[?25l'); + expect(output).not.toContain('\u001b[?25h'); expect(output).toContain('·'); expect(output).toContain('•'); expect(output).toContain(COMET_TAGLINE); expect(sleeps).toEqual([...Array(13).fill(45), 55, 55]); - const lastParticleFrame = chunks.findLastIndex((chunk) => chunk.includes(' ·')); + const lastParticleFrame = chunks.findLastIndex((chunk) => chunk.includes('·')); const stableFrame = chunks.findIndex((chunk) => chunk.includes(COMET_LOGO[0])); expect(lastParticleFrame).toBeGreaterThan(-1); expect(stableFrame).toBeGreaterThan(lastParticleFrame); - expect(output.endsWith('\u001b[0m\u001b[?25h\n')).toBe(true); + expect(output.endsWith('\u001b[0m\n')).toBe(true); }); it('prints plain static output when animation is unavailable or fails', async () => { @@ -88,6 +123,7 @@ describe('Comet CLI banner rendering', () => { runtime: { isTTY: true, env: {}, + columns: 80, write: (chunk) => fallbackChunks.push(chunk), sleep: async () => { throw new Error('timer failed'); @@ -98,7 +134,7 @@ describe('Comet CLI banner rendering', () => { const fallback = fallbackChunks.at(-1) ?? ''; const cleanupStart = fallback.indexOf(`\u001b[${COMET_BANNER_LINE_COUNT}A`); const staticBannerStart = fallback.indexOf(`\n${renderCometBanner()}\n\n`); - expect(fallback).toContain('\u001b[?25h'); + expect(fallback).not.toContain('\u001b[?25h'); expect(cleanupStart).toBeGreaterThan(-1); expect(fallback.split('\u001b[2K')).toHaveLength(COMET_BANNER_LINE_COUNT + 1); expect(cleanupStart).toBeLessThan(staticBannerStart); @@ -112,6 +148,7 @@ describe('Comet CLI banner rendering', () => { runtime: { isTTY: true, env: {}, + columns: 80, write: () => { writeAttempts += 1; if (writeAttempts >= 3) throw new Error('output failed'); @@ -125,6 +162,30 @@ describe('Comet CLI banner rendering', () => { expect(writeAttempts).toBe(3); }); + it.each([false, true])( + 'swallows asynchronous errors from a real Writable when isTTY is %s and removes listeners', + async (isTTY) => { + const stdout = new Writable({ + write(_chunk, _encoding, callback) { + setImmediate(() => callback(new Error('async stdout failure'))); + }, + }); + + await expect( + printCometBanner({ + runtime: { + isTTY, + env: {}, + columns: 80, + write: createBannerStreamWriter(stdout), + }, + }), + ).resolves.toBeUndefined(); + await new Promise((resolve) => setImmediate(resolve)); + expect(stdout.listenerCount('error')).toBe(0); + }, + ); + it('writes nothing when disabled for JSON mode', async () => { const chunks: string[] = []; await printCometBanner({ From 5d5c0876ec46c84cfae6562c9cf5f834a4a523d4 Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 19:23:29 +0800 Subject: [PATCH 10/18] docs: revise animated CLI banner design --- .../2026-07-12-cli-banner-animation-design.md | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md b/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md index aae6cc92..b868c560 100644 --- a/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md +++ b/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md @@ -2,38 +2,47 @@ ## 背景 -`comet init` 当前输出单色静态 `COMET` ASCII Banner,并使用两行标语 `Agent Skill Harness Phase-Guarded Automation / From Idea To Archive`。它能表达产品名称,但没有体现 Comet 图标由品牌蓝、扫光和粒子消散构成的视觉特征。CLI `--help` 与 `package.json` 也使用同一旧标语,需要随 Banner 一起保持一致。 +`comet init` 的初版品牌动效已经使用蓝色扫光和新标语,但真实终端截图暴露出两个视觉问题:62 列 Banner 只完成了内部画布居中,没有相对终端总列宽居中;约 695ms 的单段扫光也过短、层次偏少,尚未形成 favicon 所表达的彗星进入、扫过和粒子消散感。 ## 目标 -- 为交互式 `comet init` 增加一次短促、克制的彗星扫光动效。 +- 为交互式 `comet init` 提供一次约 1.8 秒、层次清晰的三幕式彗星动效。 - 使用与 Comet favicon 同风格的深蓝、品牌蓝和亮青蓝层次。 - 将 CLI 标语统一为 `Agent Skill Harness For Turning Ideas Into Evaluated Workflows`。 +- 让整个 Logo、粒子与标语相对实时终端列宽居中,而不只是相对内部画布居中。 - 保证自动化、无颜色和不支持动态刷新的终端输出稳定、可读。 - 动效或终端能力检测失败不得阻断安装流程。 ## 交互与视觉 -Banner 保留现有六行 `COMET` ASCII 字形。交互式终端中,Logo 从左向右逐列完成一次约 600–800ms 的扫光:未扫过区域使用深蓝,扫光前沿使用亮青蓝,扫过区域稳定为品牌蓝。扫光到达右侧时,Logo 右边短暂显示少量 `·`、`•` 粒子并快速消失,呼应 favicon 右侧的像素拖尾。 +Banner 保留现有六行 `COMET` ASCII 字形,采用一次约 1.8 秒的三幕式动效: -动效结束后保留稳定的品牌蓝 Logo,并在下方居中显示单行标语: +1. **粒子预热(0–300ms)**:少量深蓝与亮青蓝粒子从 Logo 左侧进入,Logo 由暗到亮显现深蓝轮廓,建立彗星接近的方向感。 +2. **彗星扫过(300–1100ms)**:较宽的亮青色彗星头从左向右穿过 Logo,前沿有高亮核心,后方字符依次稳定为品牌蓝,并保留长度逐步变化的短尾焰。 +3. **扩散与落版(1100–1800ms)**:Logo 右侧粒子先扩散、再回落消失;最后 300ms 标语从中心向左右展开,落版为稳定的品牌蓝 Logo 与亮青蓝标语。 + +动画只播放一次,不循环、不闪烁、不加入声音。粒子使用终端宽度稳定的 `·`、`•` 与 `*`,不使用宽度不确定的 Emoji。 + +动效结束后保留稳定的品牌蓝 Logo,并在下方显示单行标语: ```text Agent Skill Harness For Turning Ideas Into Evaluated Workflows ``` -动画只播放一次,不循环,不加入声音,也不延长后续安装步骤的输出节奏。 动画不发送隐藏或显示光标的控制序列,避免 Ctrl+C 或终止信号在 JavaScript 清理路径之外中断时遗留隐藏光标。 +每一帧在渲染内部画布后,根据实时终端列宽计算 `floor((terminalColumns - frameWidth) / 2)` 个左侧空格,并把同一缩进应用到 Logo、粒子和标语的每一行。最终静态落版也使用相同规则,因此整个视觉块相对终端居中。终端宽度变化时,下一帧重新计算缩进。 + ## 架构 新增独立的 CLI Banner 模块,负责以下边界明确的能力: 1. 保存 Logo 字形、标语和品牌色板。 2. 生成无 ANSI 控制符的静态 Banner。 -3. 根据当前动画进度生成单帧彩色 Banner。 -4. 判断运行环境是否允许动画并播放帧序列。 -5. 清理临时帧并留下最终静态彩色 Banner。 +3. 根据三幕式时间线生成粒子预热、彗星扫过、粒子扩散和标语展开帧。 +4. 根据实时终端列宽为完整帧添加统一左侧缩进。 +5. 判断运行环境是否允许动画并播放帧序列。 +6. 清理临时帧并留下最终静态彩色 Banner。 `init` 命令只调用一个 Banner 输出入口,不持有颜色、计时或光标控制逻辑。CLI 根命令描述和 `package.json` 描述直接使用新标语;不在本次改动中调整 README 或 website 内容。 @@ -45,21 +54,22 @@ Agent Skill Harness For Turning Ideas Into Evaluated Workflows - 标准输出不是 TTY。 - 检测到 CI 环境。 - 设置了 `NO_COLOR`。 -- 终端能力不足或动画播放期间发生异常。 +- 终端能力不足、实时列宽无法容纳完整视觉块,或动画播放期间发生异常。 -动画入口必须在写入帧前完成能力判断。播放异常被局部捕获并回退到静态输出,不改变 `comet init` 的退出码,也不影响安装步骤。静态文本不得依赖 ANSI 支持,便于日志采集与快照测试。 +动画入口必须在写入帧前完成能力判断。交互式终端宽度足够时,静态落版也相对终端居中;窄终端缩减粒子范围后仍无法容纳时输出不带缩进的静态 Banner。非 TTY 输出不假定可靠列宽,不添加终端级居中空格。播放异常被局部捕获并回退到静态输出,不改变 `comet init` 的退出码,也不影响安装步骤。静态文本不得依赖 ANSI 支持,便于日志采集与快照测试。 ## 测试 -- 单元测试验证新 Logo 标语、静态输出和标语居中规则。 -- 使用可控时钟与输出适配器验证扫光帧顺序、品牌色层次和最终稳定帧,避免真实等待导致测试变慢。 +- 单元测试验证新 Logo 标语、内部共同画布,以及 80、100、120 列终端中的整块居中缩进。 +- 使用可控时钟与输出适配器验证三幕时间线、约 1.8 秒总时长、品牌色层次、粒子扩散、标语展开和最终稳定帧,避免真实等待导致测试变慢。 +- 验证每帧重新读取终端列宽,并在终端宽度变化后更新左侧缩进。 - 覆盖非 TTY、CI、`NO_COLOR` 和动画异常时的静态降级。 - 保留并扩展 `--json` 不输出 Banner 的端到端测试。 - 验证 CLI `--help` 描述与 `package.json` 描述使用同一新标语。 ## 发布说明与版本 -这是从当前 `master` 的 `0.4.0-beta.4` 可见静态 Banner 到品牌化动效 Banner 的用户可见变化。本分支将 `package.json` 与锁文件升级为只高一个预发布版本的 `0.4.0-beta.5`,并在 Changelog 顶部新增 beta.5 条目描述最终用户看到的视觉与文案升级。 +按用户对本次 CLI 品牌改动的明确版本要求,`package.json` 与锁文件保持 `0.4.0-beta.4`。Changelog 不新增 beta.5,而是把最终的居中、1.8 秒三幕动效与新标语合并为 beta.4 下的一条用户可见变化。 普通回归测试和内部模块拆分不单独写入 Changelog。现有未提交的 `website` 子模块指针变化不属于本次工作,保持不动。 From 8222836665730be7f7c564e1b95624bc4363ece6 Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 19:50:24 +0800 Subject: [PATCH 11/18] feat: enrich animated CLI banner --- CHANGELOG.md | 7 +- app/cli/comet-banner.ts | 223 ++++++++++++++++++++++++++-------- package-lock.json | 4 +- package.json | 2 +- test/app/cli-help.test.ts | 8 +- test/app/comet-banner.test.ts | 91 ++++++++++---- 6 files changed, 254 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb78efa..60e234d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,6 @@ All notable changes to @rpamis/comet will be documented in this file. -## What's Changed [0.4.0-beta.5] - 2026-07-12 - -### Changed - -- **CLI brand experience**: `comet init` now introduces Comet with a brief blue comet sweep and particle trail in compatible interactive terminals, falls back to a stable centered static banner in automated or narrow output, and uses the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline across CLI and package metadata. - ## What's Changed [0.4.0-beta.4] - 2026-07-11 ### Added @@ -19,6 +13,7 @@ All notable changes to @rpamis/comet will be documented in this file. ### Changed +- **CLI brand experience**: `comet init` now centers Comet across the terminal and introduces it with a vivid 1.8-second blue comet approach, sweep, particle release, and tagline reveal in compatible interactive terminals, while automated, colorless, or narrow output receives a stable static banner. CLI and package metadata use the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline. - **Mixed change status**: `comet status` now distinguishes Comet-managed and plain OpenSpec changes and recommends the correct archive command for ready changes ([#187](https://github.com/rpamis/comet/issues/187)). ### Fixed diff --git a/app/cli/comet-banner.ts b/app/cli/comet-banner.ts index 07cab400..45fdac70 100644 --- a/app/cli/comet-banner.ts +++ b/app/cli/comet-banner.ts @@ -11,64 +11,137 @@ export const COMET_LOGO = [ ' ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ', ] as const; +type Rgb = readonly [red: number, green: number, blue: number]; +type AnimationPhase = 'preheat' | 'sweep' | 'settle'; + const RESET = '\u001b[0m'; -const DEEP_BLUE = '\u001b[38;2;22;78;154m'; -const BRAND_BLUE = '\u001b[38;2;11;111;251m'; -const BRIGHT_BLUE = '\u001b[38;2;88;184;255m'; +const DIM_BLUE: Rgb = [7, 31, 63]; +const DEEP_BLUE: Rgb = [22, 78, 154]; +const BRAND_BLUE: Rgb = [11, 111, 251]; +const BRIGHT_BLUE: Rgb = [88, 184, 255]; const BANNER_WIDTH = Math.max(...COMET_LOGO.map((line) => line.length), COMET_TAGLINE.length); -const PARTICLES = [ - [], - [ - [1, '·'], - [5, '•'], - ], - [[4, '·']], -] as const; +const FRAME_DELAY_MS = 50; +const PREHEAT_FRAMES = 6; +const SWEEP_FRAMES = 16; +const SETTLE_FRAMES = 14; export const COMET_BANNER_LINE_COUNT = COMET_LOGO.length + 1; +function ansi([red, green, blue]: Rgb): string { + return `\u001b[38;2;${red};${green};${blue}m`; +} + +function mix(from: Rgb, to: Rgb, progress: number): Rgb { + const amount = Math.max(0, Math.min(1, progress)); + return from.map((value, index) => + Math.round(value + (to[index] - value) * amount), + ) as unknown as Rgb; +} + function center(text: string): string { const visibleText = text.trimEnd(); const left = Math.max(0, Math.floor((BANNER_WIDTH - visibleText.length) / 2)); return `${' '.repeat(left)}${visibleText}`.padEnd(BANNER_WIDTH); } +function revealFromCenter(text: string, progress: number): string { + const count = Math.min(text.length, Math.max(0, Math.ceil(text.length * progress))); + const start = Math.floor((text.length - count) / 2); + return `${' '.repeat(start)}${text.slice(start, start + count)}`.padEnd(BANNER_WIDTH); +} + +function setParticle(canvas: string[], column: number, particle: string): void { + if (column >= 0 && column < canvas.length) canvas[column] = particle; +} + +function logoBounds(line: string): { start: number; end: number } { + const visible = line.trimEnd(); + const start = Math.floor((BANNER_WIDTH - visible.length) / 2); + return { start, end: start + visible.length }; +} + +function particlesForPhase( + canvas: string[], + line: string, + row: number, + phase: AnimationPhase, + progress: number, +): void { + const { start, end } = logoBounds(line); + + if (phase === 'preheat') { + const lead = Math.round((start - 1) * progress); + if (row === 1) setParticle(canvas, lead, '*'); + if (row === 3) setParticle(canvas, lead - 3, '·'); + if (row === 4) setParticle(canvas, lead - 5, '•'); + return; + } + + if (phase === 'sweep') { + const head = Math.round((BANNER_WIDTH + 8) * progress) - 4; + if (row === 1) setParticle(canvas, head - 5, '·'); + if (row === 2) setParticle(canvas, head - 3, '*'); + if (row === 4) setParticle(canvas, head - 7, '•'); + return; + } + + if (progress >= 1) return; + const energy = progress < 0.55 ? progress / 0.55 : (1 - progress) / 0.45; + const spread = Math.max(1, Math.round(energy * Math.max(1, BANNER_WIDTH - end - 1))); + if (row === 1) setParticle(canvas, end + spread, '·'); + if (row === 2) setParticle(canvas, end + Math.max(1, spread - 2), '*'); + if (row === 4) setParticle(canvas, end + Math.max(1, Math.floor(spread / 2)), '•'); +} + +function colorForColumn(phase: AnimationPhase, progress: number, column: number): Rgb { + if (phase === 'preheat') return mix(DIM_BLUE, DEEP_BLUE, progress); + if (phase === 'settle') return BRAND_BLUE; + + const head = Math.round((BANNER_WIDTH + 8) * progress) - 4; + const distance = head - column; + if (distance < -2) return DEEP_BLUE; + if (distance <= 2) return BRIGHT_BLUE; + if (distance <= 8) return mix(BRIGHT_BLUE, BRAND_BLUE, distance / 8); + return BRAND_BLUE; +} + +export function renderCometAnimationFrame(phase: AnimationPhase, progress: number): string { + const amount = Math.max(0, Math.min(1, progress)); + const logo = COMET_LOGO.map((line, row) => { + const canvas = [...center(line)]; + particlesForPhase(canvas, line, row, phase, amount); + return `${canvas + .map((character, column) => { + const particle = character === '·' || character === '•' || character === '*'; + return `${ansi(particle ? BRIGHT_BLUE : colorForColumn(phase, amount, column))}${character}`; + }) + .join('')}${RESET}`; + }); + + const taglineProgress = phase === 'settle' ? Math.max(0, (amount - 0.57) / 0.43) : 0; + const tagline = revealFromCenter(COMET_TAGLINE, taglineProgress); + return [...logo, `${ansi(BRIGHT_BLUE)}${tagline}${RESET}`].join('\n'); +} + export function renderCometBanner(options: { color?: boolean } = {}): string { const logo = options.color - ? COMET_LOGO.map((line) => `${BRAND_BLUE}${center(line)}${RESET}`) + ? COMET_LOGO.map((line) => `${ansi(BRAND_BLUE)}${center(line)}${RESET}`) : COMET_LOGO.map(center); const tagline = options.color - ? `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}` + ? `${ansi(BRIGHT_BLUE)}${center(COMET_TAGLINE)}${RESET}` : center(COMET_TAGLINE); return [...logo, tagline].join('\n'); } export function renderCometBannerFrame(litColumns: number, particleFrame = 0): string { - const logo = COMET_LOGO.map((line, row) => { - const canvas = [...center(line)]; - const logoEnd = Math.floor((BANNER_WIDTH - line.trimEnd().length) / 2) + line.trimEnd().length; - const particles = row === 2 ? (PARTICLES[particleFrame] ?? []) : []; - for (const [offset, particle] of particles) { - const column = logoEnd + offset; - if (column < BANNER_WIDTH) canvas[column] = particle; - } - - let result = ''; - for (let column = 0; column < canvas.length; column += 1) { - const color = - column < litColumns - 2 ? BRAND_BLUE : column <= litColumns ? BRIGHT_BLUE : DEEP_BLUE; - const isParticle = canvas[column] === '·' || canvas[column] === '•'; - result += `${isParticle ? BRIGHT_BLUE : color}${canvas[column]}`; - } - return `${result}${RESET}`; - }); - return [...logo, `${BRIGHT_BLUE}${center(COMET_TAGLINE)}${RESET}`].join('\n'); + const progress = Math.max(0, Math.min(1, litColumns / BANNER_WIDTH)); + return renderCometAnimationFrame('sweep', Math.min(1, progress + particleFrame * 0.01)); } export type BannerRuntime = { isTTY: boolean; env: NodeJS.ProcessEnv; - columns: number | undefined; + getColumns: () => number | undefined; write: (chunk: string) => void | Promise; sleep: (milliseconds: number) => Promise; }; @@ -114,26 +187,62 @@ export function createBannerStreamWriter(stream: Writable): BannerRuntime['write const defaultRuntime: BannerRuntime = { isTTY: Boolean(process.stdout.isTTY), env: process.env, - columns: process.stdout.columns, + getColumns: () => process.stdout.columns, write: createBannerStreamWriter(process.stdout), sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), }; +function readColumns(runtime: Pick): number | undefined { + return runtime.getColumns(); +} + +function isAutomated(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.CI && env.CI !== 'false'); +} + export function canAnimateCometBanner( - runtime: Pick, + runtime: Pick, ): boolean { - const ciEnabled = Boolean(runtime.env.CI && runtime.env.CI !== 'false'); const noColor = Object.prototype.hasOwnProperty.call(runtime.env, 'NO_COLOR'); + const columns = readColumns(runtime); return ( runtime.isTTY && - !ciEnabled && + !isAutomated(runtime.env) && !noColor && runtime.env.TERM !== 'dumb' && - runtime.columns !== undefined && - runtime.columns >= BANNER_WIDTH + columns !== undefined && + columns >= BANNER_WIDTH ); } +function centerFrameInTerminal(frame: string, columns: number): string { + const indent = ' '.repeat(Math.max(0, Math.floor((columns - BANNER_WIDTH) / 2))); + return frame + .split('\n') + .map((line) => `${indent}${line}`) + .join('\n'); +} + +function staticFrameForRuntime(runtime: BannerRuntime): string { + const frame = renderCometBanner(); + const columns = readColumns(runtime); + if ( + runtime.isTTY && + !isAutomated(runtime.env) && + columns !== undefined && + columns >= BANNER_WIDTH + ) { + return centerFrameInTerminal(frame, columns); + } + return frame; +} + +function animationFrameForRuntime(runtime: BannerRuntime, frame: string): string { + const columns = readColumns(runtime); + if (columns === undefined || columns < BANNER_WIDTH) throw new Error('terminal width changed'); + return centerFrameInTerminal(frame, columns); +} + function replaceFrame(frame: string, first: boolean): string { const moveUp = first ? '' : `\u001b[${COMET_BANNER_LINE_COUNT}A`; return `${moveUp}${frame @@ -155,33 +264,47 @@ async function writeSafely(runtime: Pick, chunk: string) } } +function timeline(): Array<{ phase: AnimationPhase; progress: number }> { + const frames: Array<{ phase: AnimationPhase; progress: number }> = []; + for (let index = 0; index < PREHEAT_FRAMES; index += 1) { + frames.push({ phase: 'preheat', progress: index / (PREHEAT_FRAMES - 1) }); + } + for (let index = 0; index < SWEEP_FRAMES; index += 1) { + frames.push({ phase: 'sweep', progress: index / (SWEEP_FRAMES - 1) }); + } + for (let index = 0; index < SETTLE_FRAMES; index += 1) { + frames.push({ phase: 'settle', progress: index / (SETTLE_FRAMES - 1) }); + } + return frames; +} + export async function printCometBanner( options: { enabled?: boolean; runtime?: Partial } = {}, ): Promise { if (options.enabled === false) return; const runtime = { ...defaultRuntime, ...options.runtime }; if (!canAnimateCometBanner(runtime)) { - await writeSafely(runtime, `\n${renderCometBanner()}\n\n`); + await writeSafely(runtime, `\n${staticFrameForRuntime(runtime)}\n\n`); return; } let started = false; try { await runtime.write('\n'); - for (let step = 0; step <= 12; step += 1) { - const column = Math.round((BANNER_WIDTH * step) / 12); - await runtime.write(replaceFrame(renderCometBannerFrame(column), !started)); + for (const frame of timeline()) { + const rendered = animationFrameForRuntime( + runtime, + renderCometAnimationFrame(frame.phase, frame.progress), + ); + await runtime.write(replaceFrame(rendered, !started)); started = true; - await runtime.sleep(45); - } - for (const particleFrame of [1, 2]) { - await runtime.write(replaceFrame(renderCometBannerFrame(BANNER_WIDTH, particleFrame), false)); - await runtime.sleep(55); + await runtime.sleep(FRAME_DELAY_MS); } - await runtime.write(replaceFrame(renderCometBanner({ color: true }), false)); + const stable = animationFrameForRuntime(runtime, renderCometBanner({ color: true })); + await runtime.write(replaceFrame(stable, false)); await runtime.write(`${RESET}\n`); } catch { const cleanup = started ? clearRenderedFrame() : ''; - await writeSafely(runtime, `${RESET}${cleanup}\n${renderCometBanner()}\n\n`); + await writeSafely(runtime, `${RESET}${cleanup}\n${staticFrameForRuntime(runtime)}\n\n`); } } diff --git a/package-lock.json b/package-lock.json index 9916db76..72b0c9c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.5", + "version": "0.4.0-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@rpamis/comet", - "version": "0.4.0-beta.5", + "version": "0.4.0-beta.4", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 916eacc5..ad9ecd89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rpamis/comet", - "version": "0.4.0-beta.5", + "version": "0.4.0-beta.4", "description": "Agent Skill Harness For Turning Ideas Into Evaluated Workflows", "keywords": [ "comet", diff --git a/test/app/cli-help.test.ts b/test/app/cli-help.test.ts index f5c4e82e..36b6c821 100644 --- a/test/app/cli-help.test.ts +++ b/test/app/cli-help.test.ts @@ -23,12 +23,18 @@ describe('CLI help text', () => { const help = runCli('--help'); const packageJson = JSON.parse( readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8'), - ) as { description: string }; + ) as { description: string; version: string }; + const packageLock = JSON.parse( + readFileSync(path.join(repositoryRoot, 'package-lock.json'), 'utf8'), + ) as { version: string; packages: { '': { version: string } } }; const tagline = 'Agent Skill Harness For Turning Ideas Into Evaluated Workflows'; expect(help.status, help.stderr).toBe(0); expect(help.stdout).toContain(tagline); expect(packageJson.description).toBe(tagline); + expect(packageJson.version).toBe('0.4.0-beta.4'); + expect(packageLock.version).toBe('0.4.0-beta.4'); + expect(packageLock.packages[''].version).toBe('0.4.0-beta.4'); }); it('marks bundle as the advanced backend and skill Engine runs as advanced', () => { diff --git a/test/app/comet-banner.test.ts b/test/app/comet-banner.test.ts index 49e14fbd..11e40159 100644 --- a/test/app/comet-banner.test.ts +++ b/test/app/comet-banner.test.ts @@ -41,23 +41,19 @@ describe('Comet CLI banner rendering', () => { expect(frame).toContain('\u001b[0m'); const visibleLines = frame.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').split('\n'); - const particleLine = visibleLines[2] ?? ''; - const visibleLogo = COMET_LOGO[2].trimEnd(); - const logoEnd = - Math.floor((COMET_TAGLINE.length - visibleLogo.length) / 2) + visibleLogo.length; expect(visibleLines.every((line) => line.length === COMET_TAGLINE.length)).toBe(true); - expect(particleLine.indexOf('·')).toBeGreaterThanOrEqual(logoEnd); - expect(particleLine.indexOf('•')).toBeGreaterThanOrEqual(logoEnd); + expect(visibleLines.some((line) => line.includes('*'))).toBe(true); + expect(visibleLines.some((line) => line.includes('•'))).toBe(true); }); it.each([ - [{ isTTY: false, env: {}, columns: 80 }, false], - [{ isTTY: true, env: { CI: '1' }, columns: 80 }, false], - [{ isTTY: true, env: { NO_COLOR: '' }, columns: 80 }, false], - [{ isTTY: true, env: { TERM: 'dumb' }, columns: 80 }, false], - [{ isTTY: true, env: {}, columns: COMET_TAGLINE.length - 1 }, false], - [{ isTTY: true, env: {}, columns: undefined }, false], - [{ isTTY: true, env: {}, columns: 80 }, true], + [{ isTTY: false, env: {}, getColumns: () => 80 }, false], + [{ isTTY: true, env: { CI: '1' }, getColumns: () => 80 }, false], + [{ isTTY: true, env: { NO_COLOR: '' }, getColumns: () => 80 }, false], + [{ isTTY: true, env: { TERM: 'dumb' }, getColumns: () => 80 }, false], + [{ isTTY: true, env: {}, getColumns: () => COMET_TAGLINE.length - 1 }, false], + [{ isTTY: true, env: {}, getColumns: () => undefined }, false], + [{ isTTY: true, env: {}, getColumns: () => 80 }, true], ] as const)('decides whether animation is safe for %o', (runtime, expected) => { expect(canAnimateCometBanner(runtime)).toBe(expected); }); @@ -69,7 +65,7 @@ describe('Comet CLI banner rendering', () => { runtime: { isTTY: true, env: {}, - columns, + getColumns: () => columns, write: (chunk) => chunks.push(chunk), }, }); @@ -78,7 +74,49 @@ describe('Comet CLI banner rendering', () => { } }); - it('plays one sweep without changing cursor visibility and leaves a stable final frame', async () => { + it('centers the entire static banner within the terminal width', async () => { + const chunks: string[] = []; + const terminalColumns = 100; + const expectedIndent = ' '.repeat(Math.floor((terminalColumns - COMET_TAGLINE.length) / 2)); + + await printCometBanner({ + runtime: { + isTTY: true, + env: { NO_COLOR: '' }, + getColumns: () => terminalColumns, + write: (chunk) => chunks.push(chunk), + }, + }); + + const renderedLines = chunks.join('').split('\n').filter(Boolean); + const baseLines = renderCometBanner().split('\n'); + expect(renderedLines).toEqual(baseLines.map((line) => `${expectedIndent}${line}`)); + }); + + it('re-reads terminal width and re-centers every animation frame', async () => { + const chunks: string[] = []; + let reads = 0; + + await printCometBanner({ + runtime: { + isTTY: true, + env: {}, + getColumns: () => { + reads += 1; + return reads < 10 ? 100 : 120; + }, + write: (chunk) => chunks.push(chunk), + sleep: async () => undefined, + }, + }); + + const output = chunks.join(''); + expect(reads).toBeGreaterThan(30); + expect(output).toContain(`\r\u001b[2K${' '.repeat(19)}\u001b[`); + expect(output).toContain(`\r\u001b[2K${' '.repeat(29)}\u001b[`); + }); + + it('plays a vivid 1.8 second three-act sequence and leaves a stable final frame', async () => { const chunks: string[] = []; const sleeps: number[] = []; @@ -86,7 +124,7 @@ describe('Comet CLI banner rendering', () => { runtime: { isTTY: true, env: {}, - columns: 80, + getColumns: () => 100, write: (chunk) => chunks.push(chunk), sleep: async (milliseconds) => { sleeps.push(milliseconds); @@ -100,7 +138,17 @@ describe('Comet CLI banner rendering', () => { expect(output).toContain('·'); expect(output).toContain('•'); expect(output).toContain(COMET_TAGLINE); - expect(sleeps).toEqual([...Array(13).fill(45), 55, 55]); + expect(sleeps).toEqual(Array(36).fill(50)); + expect(sleeps.reduce((sum, value) => sum + value, 0)).toBe(1800); + expect(output).toContain('*'); + + const animationFrames = chunks.filter((chunk) => chunk.includes('\u001b[2K')); + expect(animationFrames.some((frame) => !frame.includes(COMET_TAGLINE))).toBe(true); + expect( + animationFrames.some( + (frame) => frame.includes('Turning Ideas') && !frame.includes(COMET_TAGLINE), + ), + ).toBe(true); const lastParticleFrame = chunks.findLastIndex((chunk) => chunk.includes('·')); const stableFrame = chunks.findIndex((chunk) => chunk.includes(COMET_LOGO[0])); @@ -123,7 +171,7 @@ describe('Comet CLI banner rendering', () => { runtime: { isTTY: true, env: {}, - columns: 80, + getColumns: () => 100, write: (chunk) => fallbackChunks.push(chunk), sleep: async () => { throw new Error('timer failed'); @@ -133,7 +181,8 @@ describe('Comet CLI banner rendering', () => { ).resolves.toBeUndefined(); const fallback = fallbackChunks.at(-1) ?? ''; const cleanupStart = fallback.indexOf(`\u001b[${COMET_BANNER_LINE_COUNT}A`); - const staticBannerStart = fallback.indexOf(`\n${renderCometBanner()}\n\n`); + const centeredFirstLine = `${' '.repeat(19)}${renderCometBanner().split('\n')[0]}`; + const staticBannerStart = fallback.indexOf(centeredFirstLine); expect(fallback).not.toContain('\u001b[?25h'); expect(cleanupStart).toBeGreaterThan(-1); expect(fallback.split('\u001b[2K')).toHaveLength(COMET_BANNER_LINE_COUNT + 1); @@ -148,7 +197,7 @@ describe('Comet CLI banner rendering', () => { runtime: { isTTY: true, env: {}, - columns: 80, + getColumns: () => 100, write: () => { writeAttempts += 1; if (writeAttempts >= 3) throw new Error('output failed'); @@ -176,7 +225,7 @@ describe('Comet CLI banner rendering', () => { runtime: { isTTY, env: {}, - columns: 80, + getColumns: () => 100, write: createBannerStreamWriter(stdout), }, }), From c5b7b41e80821569bb683e1c71fc00938b453afe Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 20:07:40 +0800 Subject: [PATCH 12/18] fix: preserve CLI banner alignment --- CHANGELOG.md | 2 +- app/cli/comet-banner.ts | 77 ++++++++++++++++------------------- test/app/comet-banner.test.ts | 51 ++++++++++++++--------- 3 files changed, 68 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e234d3..8663eb66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to @rpamis/comet will be documented in this file. ### Changed -- **CLI brand experience**: `comet init` now centers Comet across the terminal and introduces it with a vivid 1.8-second blue comet approach, sweep, particle release, and tagline reveal in compatible interactive terminals, while automated, colorless, or narrow output receives a stable static banner. CLI and package metadata use the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline. +- **CLI brand experience**: `comet init` now keeps a fixed-grid Comet logo aligned with the rest of the CLI and introduces it with a vivid 1.8-second blue comet approach, sweep, particle release, and tagline reveal in compatible interactive terminals, while automated, colorless, or narrow output receives a stable static banner. CLI and package metadata use the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline. - **Mixed change status**: `comet status` now distinguishes Comet-managed and plain OpenSpec changes and recommends the correct archive command for ready changes ([#187](https://github.com/rpamis/comet/issues/187)). ### Fixed diff --git a/app/cli/comet-banner.ts b/app/cli/comet-banner.ts index 45fdac70..1dd268fd 100644 --- a/app/cli/comet-banner.ts +++ b/app/cli/comet-banner.ts @@ -19,7 +19,16 @@ const DIM_BLUE: Rgb = [7, 31, 63]; const DEEP_BLUE: Rgb = [22, 78, 154]; const BRAND_BLUE: Rgb = [11, 111, 251]; const BRIGHT_BLUE: Rgb = [88, 184, 255]; -const BANNER_WIDTH = Math.max(...COMET_LOGO.map((line) => line.length), COMET_TAGLINE.length); +const CLI_INDENT = ' '; +const LOGO_WIDTH = Math.max(...COMET_LOGO.map((line) => line.length)); +const BANNER_WIDTH = Math.max(LOGO_WIDTH, CLI_INDENT.length + COMET_TAGLINE.length); +const LOGO_CONTENT_LEFT = Math.min( + ...COMET_LOGO.map((line) => { + const firstCharacter = line.search(/\S/); + return firstCharacter < 0 ? LOGO_WIDTH : firstCharacter; + }), +); +const LOGO_CONTENT_RIGHT = Math.max(...COMET_LOGO.map((line) => line.trimEnd().length)); const FRAME_DELAY_MS = 50; const PREHEAT_FRAMES = 6; const SWEEP_FRAMES = 16; @@ -38,36 +47,38 @@ function mix(from: Rgb, to: Rgb, progress: number): Rgb { ) as unknown as Rgb; } -function center(text: string): string { +function taglineOnCanvas(text: string): string { const visibleText = text.trimEnd(); - const left = Math.max(0, Math.floor((BANNER_WIDTH - visibleText.length) / 2)); - return `${' '.repeat(left)}${visibleText}`.padEnd(BANNER_WIDTH); + return `${CLI_INDENT}${visibleText}`.padEnd(BANNER_WIDTH); } -function revealFromCenter(text: string, progress: number): string { +function logoLineOnCanvas(line: string): string { + return line.padEnd(BANNER_WIDTH); +} + +function revealTaglineFromCenter(text: string, progress: number): string { const count = Math.min(text.length, Math.max(0, Math.ceil(text.length * progress))); const start = Math.floor((text.length - count) / 2); - return `${' '.repeat(start)}${text.slice(start, start + count)}`.padEnd(BANNER_WIDTH); + return `${CLI_INDENT}${' '.repeat(start)}${text.slice(start, start + count)}`.padEnd( + BANNER_WIDTH, + ); } function setParticle(canvas: string[], column: number, particle: string): void { if (column >= 0 && column < canvas.length) canvas[column] = particle; } -function logoBounds(line: string): { start: number; end: number } { - const visible = line.trimEnd(); - const start = Math.floor((BANNER_WIDTH - visible.length) / 2); - return { start, end: start + visible.length }; +function logoBounds(): { start: number; end: number } { + return { start: LOGO_CONTENT_LEFT, end: LOGO_CONTENT_RIGHT }; } function particlesForPhase( canvas: string[], - line: string, row: number, phase: AnimationPhase, progress: number, ): void { - const { start, end } = logoBounds(line); + const { start, end } = logoBounds(); if (phase === 'preheat') { const lead = Math.round((start - 1) * progress); @@ -108,8 +119,8 @@ function colorForColumn(phase: AnimationPhase, progress: number, column: number) export function renderCometAnimationFrame(phase: AnimationPhase, progress: number): string { const amount = Math.max(0, Math.min(1, progress)); const logo = COMET_LOGO.map((line, row) => { - const canvas = [...center(line)]; - particlesForPhase(canvas, line, row, phase, amount); + const canvas = [...logoLineOnCanvas(line)]; + particlesForPhase(canvas, row, phase, amount); return `${canvas .map((character, column) => { const particle = character === '·' || character === '•' || character === '*'; @@ -119,17 +130,17 @@ export function renderCometAnimationFrame(phase: AnimationPhase, progress: numbe }); const taglineProgress = phase === 'settle' ? Math.max(0, (amount - 0.57) / 0.43) : 0; - const tagline = revealFromCenter(COMET_TAGLINE, taglineProgress); + const tagline = revealTaglineFromCenter(COMET_TAGLINE, taglineProgress); return [...logo, `${ansi(BRIGHT_BLUE)}${tagline}${RESET}`].join('\n'); } export function renderCometBanner(options: { color?: boolean } = {}): string { const logo = options.color - ? COMET_LOGO.map((line) => `${ansi(BRAND_BLUE)}${center(line)}${RESET}`) - : COMET_LOGO.map(center); + ? COMET_LOGO.map((line) => `${ansi(BRAND_BLUE)}${logoLineOnCanvas(line)}${RESET}`) + : COMET_LOGO.map(logoLineOnCanvas); const tagline = options.color - ? `${ansi(BRIGHT_BLUE)}${center(COMET_TAGLINE)}${RESET}` - : center(COMET_TAGLINE); + ? `${ansi(BRIGHT_BLUE)}${taglineOnCanvas(COMET_TAGLINE)}${RESET}` + : taglineOnCanvas(COMET_TAGLINE); return [...logo, tagline].join('\n'); } @@ -215,32 +226,14 @@ export function canAnimateCometBanner( ); } -function centerFrameInTerminal(frame: string, columns: number): string { - const indent = ' '.repeat(Math.max(0, Math.floor((columns - BANNER_WIDTH) / 2))); - return frame - .split('\n') - .map((line) => `${indent}${line}`) - .join('\n'); -} - -function staticFrameForRuntime(runtime: BannerRuntime): string { - const frame = renderCometBanner(); - const columns = readColumns(runtime); - if ( - runtime.isTTY && - !isAutomated(runtime.env) && - columns !== undefined && - columns >= BANNER_WIDTH - ) { - return centerFrameInTerminal(frame, columns); - } - return frame; +function staticFrameForRuntime(): string { + return renderCometBanner(); } function animationFrameForRuntime(runtime: BannerRuntime, frame: string): string { const columns = readColumns(runtime); if (columns === undefined || columns < BANNER_WIDTH) throw new Error('terminal width changed'); - return centerFrameInTerminal(frame, columns); + return frame; } function replaceFrame(frame: string, first: boolean): string { @@ -284,7 +277,7 @@ export async function printCometBanner( if (options.enabled === false) return; const runtime = { ...defaultRuntime, ...options.runtime }; if (!canAnimateCometBanner(runtime)) { - await writeSafely(runtime, `\n${staticFrameForRuntime(runtime)}\n\n`); + await writeSafely(runtime, `\n${staticFrameForRuntime()}\n\n`); return; } @@ -305,6 +298,6 @@ export async function printCometBanner( await runtime.write(`${RESET}\n`); } catch { const cleanup = started ? clearRenderedFrame() : ''; - await writeSafely(runtime, `${RESET}${cleanup}\n${staticFrameForRuntime(runtime)}\n\n`); + await writeSafely(runtime, `${RESET}${cleanup}\n${staticFrameForRuntime()}\n\n`); } } diff --git a/test/app/comet-banner.test.ts b/test/app/comet-banner.test.ts index 11e40159..617a38ca 100644 --- a/test/app/comet-banner.test.ts +++ b/test/app/comet-banner.test.ts @@ -11,22 +11,36 @@ import { renderCometBannerFrame, } from '../../app/cli/comet-banner.js'; +function stripAnsiSequences(text: string): string { + let visible = ''; + let insideSequence = false; + for (const character of text) { + if (character === '\u001b') { + insideSequence = true; + } else if (insideSequence) { + if (/^[A-Za-z]$/.test(character)) insideSequence = false; + } else { + visible += character; + } + } + return visible; +} + describe('Comet CLI banner rendering', () => { - it('centers the logo and tagline on one shared canvas without ANSI by default', () => { + it('keeps every ASCII logo row on the same fixed grid while left-aligning the block', () => { const banner = renderCometBanner(); const lines = banner.split('\n'); - const canvasWidth = COMET_TAGLINE.length; + const cliIndent = ' '; + const canvasWidth = cliIndent.length + COMET_TAGLINE.length; + const logoWidth = Math.max(...COMET_LOGO.map((line) => line.length)); expect(COMET_TAGLINE).toBe('Agent Skill Harness For Turning Ideas Into Evaluated Workflows'); expect(lines).toHaveLength(COMET_BANNER_LINE_COUNT); for (const [index, logoLine] of COMET_LOGO.entries()) { - const visibleLogo = logoLine.trimEnd(); expect(lines[index]).toHaveLength(canvasWidth); - expect(lines[index]?.indexOf(visibleLogo)).toBe( - Math.floor((canvasWidth - visibleLogo.length) / 2), - ); + expect(lines[index]?.slice(0, logoWidth)).toBe(logoLine.padEnd(logoWidth)); } - expect(lines.at(-1)?.trim()).toBe(COMET_TAGLINE); + expect(lines.at(-1)?.startsWith(`${cliIndent}${COMET_TAGLINE}`)).toBe(true); expect(lines.at(-1)?.length).toBe(canvasWidth); expect(banner).not.toContain('\u001b['); }); @@ -40,8 +54,8 @@ describe('Comet CLI banner rendering', () => { expect(frame).toContain('·'); expect(frame).toContain('\u001b[0m'); - const visibleLines = frame.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').split('\n'); - expect(visibleLines.every((line) => line.length === COMET_TAGLINE.length)).toBe(true); + const visibleLines = stripAnsiSequences(frame).split('\n'); + expect(visibleLines.every((line) => line.length === COMET_TAGLINE.length + 2)).toBe(true); expect(visibleLines.some((line) => line.includes('*'))).toBe(true); expect(visibleLines.some((line) => line.includes('•'))).toBe(true); }); @@ -51,7 +65,7 @@ describe('Comet CLI banner rendering', () => { [{ isTTY: true, env: { CI: '1' }, getColumns: () => 80 }, false], [{ isTTY: true, env: { NO_COLOR: '' }, getColumns: () => 80 }, false], [{ isTTY: true, env: { TERM: 'dumb' }, getColumns: () => 80 }, false], - [{ isTTY: true, env: {}, getColumns: () => COMET_TAGLINE.length - 1 }, false], + [{ isTTY: true, env: {}, getColumns: () => COMET_TAGLINE.length + 1 }, false], [{ isTTY: true, env: {}, getColumns: () => undefined }, false], [{ isTTY: true, env: {}, getColumns: () => 80 }, true], ] as const)('decides whether animation is safe for %o', (runtime, expected) => { @@ -59,7 +73,7 @@ describe('Comet CLI banner rendering', () => { }); it('prints plain static output for narrow or unknown terminal widths', async () => { - for (const columns of [COMET_TAGLINE.length - 1, undefined]) { + for (const columns of [COMET_TAGLINE.length + 1, undefined]) { const chunks: string[] = []; await printCometBanner({ runtime: { @@ -74,10 +88,9 @@ describe('Comet CLI banner rendering', () => { } }); - it('centers the entire static banner within the terminal width', async () => { + it('keeps the entire static banner left-aligned in a wide terminal', async () => { const chunks: string[] = []; const terminalColumns = 100; - const expectedIndent = ' '.repeat(Math.floor((terminalColumns - COMET_TAGLINE.length) / 2)); await printCometBanner({ runtime: { @@ -90,10 +103,10 @@ describe('Comet CLI banner rendering', () => { const renderedLines = chunks.join('').split('\n').filter(Boolean); const baseLines = renderCometBanner().split('\n'); - expect(renderedLines).toEqual(baseLines.map((line) => `${expectedIndent}${line}`)); + expect(renderedLines).toEqual(baseLines); }); - it('re-reads terminal width and re-centers every animation frame', async () => { + it('re-reads terminal width without shifting the animation away from the left edge', async () => { const chunks: string[] = []; let reads = 0; @@ -112,8 +125,8 @@ describe('Comet CLI banner rendering', () => { const output = chunks.join(''); expect(reads).toBeGreaterThan(30); - expect(output).toContain(`\r\u001b[2K${' '.repeat(19)}\u001b[`); - expect(output).toContain(`\r\u001b[2K${' '.repeat(29)}\u001b[`); + expect(output).not.toContain(`\r\u001b[2K${' '.repeat(19)}\u001b[`); + expect(output).not.toContain(`\r\u001b[2K${' '.repeat(29)}\u001b[`); }); it('plays a vivid 1.8 second three-act sequence and leaves a stable final frame', async () => { @@ -181,8 +194,8 @@ describe('Comet CLI banner rendering', () => { ).resolves.toBeUndefined(); const fallback = fallbackChunks.at(-1) ?? ''; const cleanupStart = fallback.indexOf(`\u001b[${COMET_BANNER_LINE_COUNT}A`); - const centeredFirstLine = `${' '.repeat(19)}${renderCometBanner().split('\n')[0]}`; - const staticBannerStart = fallback.indexOf(centeredFirstLine); + const firstStaticLine = renderCometBanner().split('\n')[0] ?? ''; + const staticBannerStart = fallback.indexOf(firstStaticLine); expect(fallback).not.toContain('\u001b[?25h'); expect(cleanupStart).toBeGreaterThan(-1); expect(fallback.split('\u001b[2K')).toHaveLength(COMET_BANNER_LINE_COUNT + 1); From cf7c3b30da46ccecb043ddad54db7942612473f8 Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 20:13:56 +0800 Subject: [PATCH 13/18] fix: center CLI logo over tagline --- CHANGELOG.md | 2 +- app/cli/comet-banner.ts | 16 ++++++++++++---- test/app/comet-banner.test.ts | 20 ++++++++++++++++++-- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8663eb66..d34ea9a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to @rpamis/comet will be documented in this file. ### Changed -- **CLI brand experience**: `comet init` now keeps a fixed-grid Comet logo aligned with the rest of the CLI and introduces it with a vivid 1.8-second blue comet approach, sweep, particle release, and tagline reveal in compatible interactive terminals, while automated, colorless, or narrow output receives a stable static banner. CLI and package metadata use the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline. +- **CLI brand experience**: `comet init` now keeps a fixed-grid Comet logo centered over a gold tagline while the banner remains aligned with the rest of the CLI, and introduces it with a vivid 1.8-second blue comet approach, sweep, particle release, and tagline reveal in compatible interactive terminals. Automated, colorless, or narrow output receives a stable static banner. CLI and package metadata use the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline. - **Mixed change status**: `comet status` now distinguishes Comet-managed and plain OpenSpec changes and recommends the correct archive command for ready changes ([#187](https://github.com/rpamis/comet/issues/187)). ### Fixed diff --git a/app/cli/comet-banner.ts b/app/cli/comet-banner.ts index 1dd268fd..3c25eb44 100644 --- a/app/cli/comet-banner.ts +++ b/app/cli/comet-banner.ts @@ -19,6 +19,7 @@ const DIM_BLUE: Rgb = [7, 31, 63]; const DEEP_BLUE: Rgb = [22, 78, 154]; const BRAND_BLUE: Rgb = [11, 111, 251]; const BRIGHT_BLUE: Rgb = [88, 184, 255]; +const TAGLINE_GOLD: Rgb = [208, 151, 53]; const CLI_INDENT = ' '; const LOGO_WIDTH = Math.max(...COMET_LOGO.map((line) => line.length)); const BANNER_WIDTH = Math.max(LOGO_WIDTH, CLI_INDENT.length + COMET_TAGLINE.length); @@ -29,6 +30,10 @@ const LOGO_CONTENT_LEFT = Math.min( }), ); const LOGO_CONTENT_RIGHT = Math.max(...COMET_LOGO.map((line) => line.trimEnd().length)); +const LOGO_OFFSET = + CLI_INDENT.length + + Math.floor((COMET_TAGLINE.length - (LOGO_CONTENT_RIGHT - LOGO_CONTENT_LEFT)) / 2) - + LOGO_CONTENT_LEFT; const FRAME_DELAY_MS = 50; const PREHEAT_FRAMES = 6; const SWEEP_FRAMES = 16; @@ -53,7 +58,7 @@ function taglineOnCanvas(text: string): string { } function logoLineOnCanvas(line: string): string { - return line.padEnd(BANNER_WIDTH); + return `${' '.repeat(LOGO_OFFSET)}${line}`.padEnd(BANNER_WIDTH); } function revealTaglineFromCenter(text: string, progress: number): string { @@ -69,7 +74,10 @@ function setParticle(canvas: string[], column: number, particle: string): void { } function logoBounds(): { start: number; end: number } { - return { start: LOGO_CONTENT_LEFT, end: LOGO_CONTENT_RIGHT }; + return { + start: LOGO_OFFSET + LOGO_CONTENT_LEFT, + end: LOGO_OFFSET + LOGO_CONTENT_RIGHT, + }; } function particlesForPhase( @@ -131,7 +139,7 @@ export function renderCometAnimationFrame(phase: AnimationPhase, progress: numbe const taglineProgress = phase === 'settle' ? Math.max(0, (amount - 0.57) / 0.43) : 0; const tagline = revealTaglineFromCenter(COMET_TAGLINE, taglineProgress); - return [...logo, `${ansi(BRIGHT_BLUE)}${tagline}${RESET}`].join('\n'); + return [...logo, `${ansi(TAGLINE_GOLD)}${tagline}${RESET}`].join('\n'); } export function renderCometBanner(options: { color?: boolean } = {}): string { @@ -139,7 +147,7 @@ export function renderCometBanner(options: { color?: boolean } = {}): string { ? COMET_LOGO.map((line) => `${ansi(BRAND_BLUE)}${logoLineOnCanvas(line)}${RESET}`) : COMET_LOGO.map(logoLineOnCanvas); const tagline = options.color - ? `${ansi(BRIGHT_BLUE)}${taglineOnCanvas(COMET_TAGLINE)}${RESET}` + ? `${ansi(TAGLINE_GOLD)}${taglineOnCanvas(COMET_TAGLINE)}${RESET}` : taglineOnCanvas(COMET_TAGLINE); return [...logo, tagline].join('\n'); } diff --git a/test/app/comet-banner.test.ts b/test/app/comet-banner.test.ts index 617a38ca..f755c60e 100644 --- a/test/app/comet-banner.test.ts +++ b/test/app/comet-banner.test.ts @@ -27,24 +27,40 @@ function stripAnsiSequences(text: string): string { } describe('Comet CLI banner rendering', () => { - it('keeps every ASCII logo row on the same fixed grid while left-aligning the block', () => { + it('centers one fixed-grid ASCII logo block over the left-aligned tagline', () => { const banner = renderCometBanner(); const lines = banner.split('\n'); const cliIndent = ' '; const canvasWidth = cliIndent.length + COMET_TAGLINE.length; const logoWidth = Math.max(...COMET_LOGO.map((line) => line.length)); + const logoOffset = lines[0]?.indexOf(COMET_LOGO[0]) ?? -1; expect(COMET_TAGLINE).toBe('Agent Skill Harness For Turning Ideas Into Evaluated Workflows'); expect(lines).toHaveLength(COMET_BANNER_LINE_COUNT); for (const [index, logoLine] of COMET_LOGO.entries()) { expect(lines[index]).toHaveLength(canvasWidth); - expect(lines[index]?.slice(0, logoWidth)).toBe(logoLine.padEnd(logoWidth)); + expect(lines[index]?.slice(logoOffset, logoOffset + logoWidth)).toBe( + logoLine.padEnd(logoWidth), + ); } expect(lines.at(-1)?.startsWith(`${cliIndent}${COMET_TAGLINE}`)).toBe(true); expect(lines.at(-1)?.length).toBe(canvasWidth); + + const logoLines = lines.slice(0, COMET_LOGO.length); + const logoLeft = Math.min(...logoLines.map((line) => line.search(/\S/))); + const logoRight = Math.max(...logoLines.map((line) => line.trimEnd().length)); + const taglineLeft = lines.at(-1)?.indexOf(COMET_TAGLINE) ?? -1; + const taglineRight = taglineLeft + COMET_TAGLINE.length; + expect(Math.abs(logoLeft + logoRight - (taglineLeft + taglineRight))).toBeLessThanOrEqual(1); expect(banner).not.toContain('\u001b['); }); + it('renders the tagline in rgb(208, 151, 53) when color is enabled', () => { + const banner = renderCometBanner({ color: true }); + + expect(banner).toContain(`\u001b[38;2;208;151;53m ${COMET_TAGLINE}`); + }); + it('uses deep blue, bright cyan-blue, and brand blue across a sweep frame', () => { const frame = renderCometBannerFrame(24, 1); From 4cb56ce0818ae254c0187be3054f168a47a9c12b Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 20:17:07 +0800 Subject: [PATCH 14/18] docs: sync website for beta4 --- website | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website b/website index f74062bf..24a3ae60 160000 --- a/website +++ b/website @@ -1 +1 @@ -Subproject commit f74062bf25dfb7002b0a1a93a56e45186f43c42b +Subproject commit 24a3ae607a471885c0f86559a8a66024f90f2020 From 3022154958cc7a194aa87f4c0725ba8c7ba0d9a2 Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 20:20:15 +0800 Subject: [PATCH 15/18] fix: refresh CLI logo theme color --- CHANGELOG.md | 2 +- app/cli/comet-banner.ts | 2 +- test/app/comet-banner.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d34ea9a4..46702de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to @rpamis/comet will be documented in this file. ### Changed -- **CLI brand experience**: `comet init` now keeps a fixed-grid Comet logo centered over a gold tagline while the banner remains aligned with the rest of the CLI, and introduces it with a vivid 1.8-second blue comet approach, sweep, particle release, and tagline reveal in compatible interactive terminals. Automated, colorless, or narrow output receives a stable static banner. CLI and package metadata use the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline. +- **CLI brand experience**: `comet init` now keeps a fixed-grid cyan-blue Comet logo centered over a gold tagline while the banner remains aligned with the rest of the CLI, and introduces it with a vivid 1.8-second comet approach, sweep, particle release, and tagline reveal in compatible interactive terminals. Automated, colorless, or narrow output receives a stable static banner. CLI and package metadata use the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline. - **Mixed change status**: `comet status` now distinguishes Comet-managed and plain OpenSpec changes and recommends the correct archive command for ready changes ([#187](https://github.com/rpamis/comet/issues/187)). ### Fixed diff --git a/app/cli/comet-banner.ts b/app/cli/comet-banner.ts index 3c25eb44..f4fdc503 100644 --- a/app/cli/comet-banner.ts +++ b/app/cli/comet-banner.ts @@ -17,7 +17,7 @@ type AnimationPhase = 'preheat' | 'sweep' | 'settle'; const RESET = '\u001b[0m'; const DIM_BLUE: Rgb = [7, 31, 63]; const DEEP_BLUE: Rgb = [22, 78, 154]; -const BRAND_BLUE: Rgb = [11, 111, 251]; +const BRAND_BLUE: Rgb = [77, 197, 242]; const BRIGHT_BLUE: Rgb = [88, 184, 255]; const TAGLINE_GOLD: Rgb = [208, 151, 53]; const CLI_INDENT = ' '; diff --git a/test/app/comet-banner.test.ts b/test/app/comet-banner.test.ts index f755c60e..ebda6d7b 100644 --- a/test/app/comet-banner.test.ts +++ b/test/app/comet-banner.test.ts @@ -61,12 +61,12 @@ describe('Comet CLI banner rendering', () => { expect(banner).toContain(`\u001b[38;2;208;151;53m ${COMET_TAGLINE}`); }); - it('uses deep blue, bright cyan-blue, and brand blue across a sweep frame', () => { + it('uses deep blue, bright cyan-blue, and rgb(77, 197, 242) across a sweep frame', () => { const frame = renderCometBannerFrame(24, 1); expect(frame).toContain('\u001b[38;2;22;78;154m'); expect(frame).toContain('\u001b[38;2;88;184;255m'); - expect(frame).toContain('\u001b[38;2;11;111;251m'); + expect(frame).toContain('\u001b[38;2;77;197;242m'); expect(frame).toContain('·'); expect(frame).toContain('\u001b[0m'); From ea0fb402dad3f39f9e48039feba5561c3ea98954 Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 20:28:56 +0800 Subject: [PATCH 16/18] fix: deepen CLI logo blue --- CHANGELOG.md | 2 +- app/cli/comet-banner.ts | 2 +- test/app/comet-banner.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46702de0..cde32c93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to @rpamis/comet will be documented in this file. ### Changed -- **CLI brand experience**: `comet init` now keeps a fixed-grid cyan-blue Comet logo centered over a gold tagline while the banner remains aligned with the rest of the CLI, and introduces it with a vivid 1.8-second comet approach, sweep, particle release, and tagline reveal in compatible interactive terminals. Automated, colorless, or narrow output receives a stable static banner. CLI and package metadata use the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline. +- **CLI brand experience**: `comet init` now keeps a fixed-grid vivid blue Comet logo centered over a gold tagline while the banner remains aligned with the rest of the CLI, and introduces it with a 1.8-second comet approach, sweep, particle release, and tagline reveal in compatible interactive terminals. Automated, colorless, or narrow output receives a stable static banner. CLI and package metadata use the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline. - **Mixed change status**: `comet status` now distinguishes Comet-managed and plain OpenSpec changes and recommends the correct archive command for ready changes ([#187](https://github.com/rpamis/comet/issues/187)). ### Fixed diff --git a/app/cli/comet-banner.ts b/app/cli/comet-banner.ts index f4fdc503..0dc6eca0 100644 --- a/app/cli/comet-banner.ts +++ b/app/cli/comet-banner.ts @@ -17,7 +17,7 @@ type AnimationPhase = 'preheat' | 'sweep' | 'settle'; const RESET = '\u001b[0m'; const DIM_BLUE: Rgb = [7, 31, 63]; const DEEP_BLUE: Rgb = [22, 78, 154]; -const BRAND_BLUE: Rgb = [77, 197, 242]; +const BRAND_BLUE: Rgb = [47, 140, 255]; const BRIGHT_BLUE: Rgb = [88, 184, 255]; const TAGLINE_GOLD: Rgb = [208, 151, 53]; const CLI_INDENT = ' '; diff --git a/test/app/comet-banner.test.ts b/test/app/comet-banner.test.ts index ebda6d7b..c6d62f9e 100644 --- a/test/app/comet-banner.test.ts +++ b/test/app/comet-banner.test.ts @@ -61,12 +61,12 @@ describe('Comet CLI banner rendering', () => { expect(banner).toContain(`\u001b[38;2;208;151;53m ${COMET_TAGLINE}`); }); - it('uses deep blue, bright cyan-blue, and rgb(77, 197, 242) across a sweep frame', () => { + it('uses deep blue, bright cyan-blue, and rgb(47, 140, 255) across a sweep frame', () => { const frame = renderCometBannerFrame(24, 1); expect(frame).toContain('\u001b[38;2;22;78;154m'); expect(frame).toContain('\u001b[38;2;88;184;255m'); - expect(frame).toContain('\u001b[38;2;77;197;242m'); + expect(frame).toContain('\u001b[38;2;47;140;255m'); expect(frame).toContain('·'); expect(frame).toContain('\u001b[0m'); From 62b9e281fb3fcf985c09ca5572901da6c28ff5b3 Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 20:31:22 +0800 Subject: [PATCH 17/18] Revert "fix: deepen CLI logo blue" This reverts commit ea0fb402dad3f39f9e48039feba5561c3ea98954. --- CHANGELOG.md | 2 +- app/cli/comet-banner.ts | 2 +- test/app/comet-banner.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cde32c93..46702de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to @rpamis/comet will be documented in this file. ### Changed -- **CLI brand experience**: `comet init` now keeps a fixed-grid vivid blue Comet logo centered over a gold tagline while the banner remains aligned with the rest of the CLI, and introduces it with a 1.8-second comet approach, sweep, particle release, and tagline reveal in compatible interactive terminals. Automated, colorless, or narrow output receives a stable static banner. CLI and package metadata use the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline. +- **CLI brand experience**: `comet init` now keeps a fixed-grid cyan-blue Comet logo centered over a gold tagline while the banner remains aligned with the rest of the CLI, and introduces it with a vivid 1.8-second comet approach, sweep, particle release, and tagline reveal in compatible interactive terminals. Automated, colorless, or narrow output receives a stable static banner. CLI and package metadata use the clearer "Agent Skill Harness For Turning Ideas Into Evaluated Workflows" tagline. - **Mixed change status**: `comet status` now distinguishes Comet-managed and plain OpenSpec changes and recommends the correct archive command for ready changes ([#187](https://github.com/rpamis/comet/issues/187)). ### Fixed diff --git a/app/cli/comet-banner.ts b/app/cli/comet-banner.ts index 0dc6eca0..f4fdc503 100644 --- a/app/cli/comet-banner.ts +++ b/app/cli/comet-banner.ts @@ -17,7 +17,7 @@ type AnimationPhase = 'preheat' | 'sweep' | 'settle'; const RESET = '\u001b[0m'; const DIM_BLUE: Rgb = [7, 31, 63]; const DEEP_BLUE: Rgb = [22, 78, 154]; -const BRAND_BLUE: Rgb = [47, 140, 255]; +const BRAND_BLUE: Rgb = [77, 197, 242]; const BRIGHT_BLUE: Rgb = [88, 184, 255]; const TAGLINE_GOLD: Rgb = [208, 151, 53]; const CLI_INDENT = ' '; diff --git a/test/app/comet-banner.test.ts b/test/app/comet-banner.test.ts index c6d62f9e..ebda6d7b 100644 --- a/test/app/comet-banner.test.ts +++ b/test/app/comet-banner.test.ts @@ -61,12 +61,12 @@ describe('Comet CLI banner rendering', () => { expect(banner).toContain(`\u001b[38;2;208;151;53m ${COMET_TAGLINE}`); }); - it('uses deep blue, bright cyan-blue, and rgb(47, 140, 255) across a sweep frame', () => { + it('uses deep blue, bright cyan-blue, and rgb(77, 197, 242) across a sweep frame', () => { const frame = renderCometBannerFrame(24, 1); expect(frame).toContain('\u001b[38;2;22;78;154m'); expect(frame).toContain('\u001b[38;2;88;184;255m'); - expect(frame).toContain('\u001b[38;2;47;140;255m'); + expect(frame).toContain('\u001b[38;2;77;197;242m'); expect(frame).toContain('·'); expect(frame).toContain('\u001b[0m'); From 9d2d9787c61005d781987ed66017f95ff106a126 Mon Sep 17 00:00:00 2001 From: benym Date: Sun, 12 Jul 2026 20:54:54 +0800 Subject: [PATCH 18/18] docs: align CLI banner color contract --- .../superpowers/specs/2026-07-12-cli-banner-animation-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md b/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md index b868c560..e049217c 100644 --- a/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md +++ b/docs/superpowers/specs/2026-07-12-cli-banner-animation-design.md @@ -19,7 +19,7 @@ Banner 保留现有六行 `COMET` ASCII 字形,采用一次约 1.8 秒的三 1. **粒子预热(0–300ms)**:少量深蓝与亮青蓝粒子从 Logo 左侧进入,Logo 由暗到亮显现深蓝轮廓,建立彗星接近的方向感。 2. **彗星扫过(300–1100ms)**:较宽的亮青色彗星头从左向右穿过 Logo,前沿有高亮核心,后方字符依次稳定为品牌蓝,并保留长度逐步变化的短尾焰。 -3. **扩散与落版(1100–1800ms)**:Logo 右侧粒子先扩散、再回落消失;最后 300ms 标语从中心向左右展开,落版为稳定的品牌蓝 Logo 与亮青蓝标语。 +3. **扩散与落版(1100–1800ms)**:Logo 右侧粒子先扩散、再回落消失;最后 300ms 标语从中心向左右展开,落版为稳定的品牌蓝 Logo 与金色标语。 动画只播放一次,不循环、不闪烁、不加入声音。粒子使用终端宽度稳定的 `·`、`•` 与 `*`,不使用宽度不确定的 Emoji。