Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/init-no-animation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

Respect reduced-motion preferences in `openspec init`: the welcome animation is skipped when the OS reduced-motion setting is on (macOS Reduce Motion, GNOME animations disabled), when `OPENSPEC_NO_ANIMATION` is set, or when the new `--no-animation` flag is passed. The static welcome screen is shown instead.
4 changes: 4 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,12 @@ openspec init [path] [options]
| `--tools <list>` | Configure AI tools non-interactively. Use `all`, `none`, or comma-separated list |
| `--force` | Auto-cleanup legacy files without prompting |
| `--profile <profile>` | Override global profile for this init run (`core` or `custom`) |
| `--no-animation` | Show a static welcome screen instead of the animated one |

`--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`).

The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set, when `NO_COLOR` is set, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled).

**Supported tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`, `zcode`

> This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths.
Expand Down Expand Up @@ -1200,6 +1203,7 @@ openspec completion uninstall
| `OPENSPEC_CONCURRENCY` | Default concurrency for bulk validation (default: 6) |
| `EDITOR` or `VISUAL` | Editor for `openspec config edit` |
| `NO_COLOR` | Disable color output when set |
| `OPENSPEC_NO_ANIMATION` | Disable the `openspec init` welcome animation when set |

---

Expand Down
4 changes: 3 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ program
.option('--tools <tools>', toolsOptionDescription)
.option('--force', 'Auto-cleanup legacy files without prompting')
.option('--profile <profile>', 'Override global config profile (core or custom)')
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string }) => {
.option('--no-animation', 'Show a static welcome screen instead of the animated one')
.action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean }) => {
try {
// Validate that the path is a valid directory
const resolvedPath = path.resolve(targetPath);
Expand All @@ -170,6 +171,7 @@ program
tools: options?.tools,
force: options?.force,
profile: options?.profile,
animation: options?.animation,
});
await initCommand.execute(targetPath);
} catch (error) {
Expand Down
4 changes: 4 additions & 0 deletions src/core/completions/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [
takesValue: true,
values: ['core', 'custom'],
},
{
name: 'no-animation',
description: 'Show a static welcome screen instead of the animated one',
},
],
},
{
Expand Down
6 changes: 5 additions & 1 deletion src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ type InitCommandOptions = {
force?: boolean;
interactive?: boolean;
profile?: string;
/** Commander's --no-animation flag: false disables the welcome animation. */
animation?: boolean;
};

/**
Expand All @@ -116,12 +118,14 @@ export class InitCommand {
private readonly force: boolean;
private readonly interactiveOption?: boolean;
private readonly profileOverride?: string;
private readonly animation: boolean;

constructor(options: InitCommandOptions = {}) {
this.toolsArg = options.tools;
this.force = options.force ?? false;
this.interactiveOption = options.interactive;
this.profileOverride = options.profile;
this.animation = options.animation ?? true;
}

async execute(targetPath: string): Promise<void> {
Expand Down Expand Up @@ -184,7 +188,7 @@ export class InitCommand {
const canPrompt = this.canPromptInteractively();
if (canPrompt) {
const { showWelcomeScreen } = await import('../ui/welcome-screen.js');
await showWelcomeScreen(this.getActiveWorkflows());
await showWelcomeScreen(this.getActiveWorkflows(), { animate: this.animation });
}

// Get tool states before processing
Expand Down
59 changes: 57 additions & 2 deletions src/ui/welcome-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
*/

import chalk from 'chalk';
import {
execFileSync,
type ExecFileSyncOptionsWithStringEncoding,
} from 'node:child_process';
import { WELCOME_ANIMATION } from './ascii-patterns.js';
import { getOnboardingCommands } from '../core/onboarding-commands.js';

Expand Down Expand Up @@ -66,6 +70,47 @@ function renderFrame(artLines: string[], textLines: string[]): string {
return lines.join('\n');
}

const REDUCED_MOTION_EXEC_OPTIONS: ExecFileSyncOptionsWithStringEncoding = {
encoding: 'utf8',
timeout: 500,
// SIGKILL so a wedged lookup can never outlive the timeout and stall init.
killSignal: 'SIGKILL',
stdio: ['ignore', 'pipe', 'ignore'],
};

/**
* Best-effort check of the OS-level reduced-motion preference (#722).
* Any lookup failure (missing binary, unset key, timeout) means
* "no preference detected" and animation stays enabled.
*/
export function prefersReducedMotion(
platform: NodeJS.Platform = process.platform
): boolean {
try {
if (platform === 'darwin') {
// The key only exists once the user has toggled Reduce Motion; when it
// is unset `defaults` exits non-zero and lands in the catch below.
const out = execFileSync(
'defaults',
['read', 'com.apple.universalaccess', 'reduceMotion'],
REDUCED_MOTION_EXEC_OPTIONS
);
return out.trim() === '1';
}
if (platform === 'linux') {
const out = execFileSync(
'gsettings',
['get', 'org.gnome.desktop.interface', 'enable-animations'],
REDUCED_MOTION_EXEC_OPTIONS
);
return out.trim() === 'false';
}
} catch {
// Detection is best-effort only.
}
return false;
}

/**
* Checks if the terminal supports animation
*/
Expand All @@ -76,10 +121,17 @@ function canAnimate(): boolean {
// Respect NO_COLOR
if (process.env.NO_COLOR) return false;

// Manual override for users who need reduced motion (#722). Like NO_COLOR,
// presence is what counts: even an empty value disables the animation.
if (process.env.OPENSPEC_NO_ANIMATION !== undefined) return false;

// Check terminal width
const columns = process.stdout.columns || 80;
if (columns < MIN_WIDTH) return false;

// Last so only interactive terminals pay for the OS lookup
if (prefersReducedMotion()) return false;

return true;
}

Expand Down Expand Up @@ -116,10 +168,13 @@ async function waitForEnter(): Promise<void> {
* Shows the animated welcome screen.
* Returns when user presses Enter.
*/
export async function showWelcomeScreen(workflows: readonly string[]): Promise<void> {
export async function showWelcomeScreen(
workflows: readonly string[],
options: { animate?: boolean } = {}
): Promise<void> {
const textLines = getWelcomeText(workflows);

if (!canAnimate()) {
if (options.animate === false || !canAnimate()) {
// Fallback: show static welcome
const frame = WELCOME_ANIMATION.frames[3]; // Peak frame
process.stdout.write('\n' + renderFrame(frame, textLines) + '\n\n');
Expand Down
2 changes: 1 addition & 1 deletion test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,7 @@ describe('InitCommand - profile and detection features', () => {
expect(showWelcomeScreenMock).toHaveBeenCalled();
// The welcome screen must be handed the profile's workflows, otherwise it
// advertises commands this profile never installs.
expect(showWelcomeScreenMock).toHaveBeenCalledWith(['explore', 'new']);
expect(showWelcomeScreenMock).toHaveBeenCalledWith(['explore', 'new'], { animate: true });
expect(confirmMock).not.toHaveBeenCalled();

const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
Expand Down
117 changes: 116 additions & 1 deletion test/ui/welcome-screen.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ALL_WORKFLOWS, CORE_WORKFLOWS } from '../../src/core/profiles.js';

const { useKeypressMock } = vi.hoisted(() => ({
const { useKeypressMock, execFileSyncMock } = vi.hoisted(() => ({
useKeypressMock: vi.fn(),
execFileSyncMock: vi.fn(),
}));

vi.mock('node:child_process', () => ({
execFileSync: execFileSyncMock,
}));

vi.mock('@inquirer/core', () => ({
Expand All @@ -23,6 +28,7 @@ vi.mock('@inquirer/core', () => ({

describe('welcome screen', () => {
const originalNoColor = process.env.NO_COLOR;
const originalNoAnimation = process.env.OPENSPEC_NO_ANIMATION;
const originalStdinIsTTY = process.stdin.isTTY;
const originalStdoutIsTTY = process.stdout.isTTY;
const originalColumns = process.stdout.columns;
Expand All @@ -38,11 +44,18 @@ describe('welcome screen', () => {

beforeEach(() => {
delete process.env.NO_COLOR;
delete process.env.OPENSPEC_NO_ANIMATION;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
Object.defineProperty(process.stdout, 'columns', { value: 100, configurable: true });
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
useKeypressMock.mockClear();
// Deterministic default: no OS-level reduced-motion preference detectable,
// so animated-path tests behave the same on every machine.
execFileSyncMock.mockReset();
execFileSyncMock.mockImplementation(() => {
throw new Error('not available in tests');
});
});

afterEach(() => {
Expand All @@ -51,6 +64,11 @@ describe('welcome screen', () => {
} else {
process.env.NO_COLOR = originalNoColor;
}
if (originalNoAnimation === undefined) {
delete process.env.OPENSPEC_NO_ANIMATION;
} else {
process.env.OPENSPEC_NO_ANIMATION = originalNoAnimation;
}
Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinIsTTY, configurable: true });
Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutIsTTY, configurable: true });
Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true });
Expand Down Expand Up @@ -119,4 +137,101 @@ describe('welcome screen', () => {
expect(line.length).toBeLessThanOrEqual(59);
}
});

it('renders statically when OPENSPEC_NO_ANIMATION is set', async () => {
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
process.env.OPENSPEC_NO_ANIMATION = '1';

await showWelcomeScreen(CORE_WORKFLOWS);

expect(useKeypressMock).not.toHaveBeenCalled();
const output = writtenOutput();
expect(output).toContain('Welcome to OpenSpec');
// No cursor-up repaints: the frame is drawn exactly once.
expect(output).not.toMatch(/\x1b\[\d+A/);
});

it('honors OPENSPEC_NO_ANIMATION even when set to an empty value', async () => {
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
process.env.OPENSPEC_NO_ANIMATION = '';

await showWelcomeScreen(CORE_WORKFLOWS);

expect(useKeypressMock).not.toHaveBeenCalled();
expect(writtenOutput()).not.toMatch(/\x1b\[\d+A/);
});

it('renders statically when animate is disabled via options', async () => {
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');

await showWelcomeScreen(CORE_WORKFLOWS, { animate: false });

expect(useKeypressMock).not.toHaveBeenCalled();
const output = writtenOutput();
expect(output).toContain('Welcome to OpenSpec');
expect(output).not.toMatch(/\x1b\[\d+A/);
});

it.runIf(process.platform === 'darwin' || process.platform === 'linux')(
'renders statically when the OS prefers reduced motion',
async () => {
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
execFileSyncMock.mockImplementation((file: string) =>
file === 'defaults' ? '1\n' : 'false\n'
);

await showWelcomeScreen(CORE_WORKFLOWS);

expect(useKeypressMock).not.toHaveBeenCalled();
expect(writtenOutput()).toContain('Welcome to OpenSpec');
}
);
});

describe('prefersReducedMotion', () => {
beforeEach(() => {
execFileSyncMock.mockReset();
});

it('detects macOS Reduce Motion', async () => {
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');
execFileSyncMock.mockReturnValue('1\n');

expect(prefersReducedMotion('darwin')).toBe(true);
expect(execFileSyncMock).toHaveBeenCalledWith(
'defaults',
['read', 'com.apple.universalaccess', 'reduceMotion'],
expect.objectContaining({ timeout: 500 })
);
});

it('treats a disabled or unset macOS preference as no preference', async () => {
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');

execFileSyncMock.mockReturnValue('0\n');
expect(prefersReducedMotion('darwin')).toBe(false);

// `defaults read` exits non-zero while the key has never been toggled.
execFileSyncMock.mockImplementation(() => {
throw new Error('The domain/default pair does not exist');
});
expect(prefersReducedMotion('darwin')).toBe(false);
});

it('detects GNOME reduced motion via disabled animations', async () => {
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');

execFileSyncMock.mockReturnValue('false\n');
expect(prefersReducedMotion('linux')).toBe(true);

execFileSyncMock.mockReturnValue('true\n');
expect(prefersReducedMotion('linux')).toBe(false);
});

it('returns false without spawning anything on other platforms', async () => {
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');

expect(prefersReducedMotion('win32')).toBe(false);
expect(execFileSyncMock).not.toHaveBeenCalled();
});
});
Loading