Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
82 changes: 82 additions & 0 deletions src/__tests__/constants/figuresScreenReader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { BLACK_CIRCLE, getFigures } from "../../constants/figures.js";
import { resetSettingsCache } from "../../utils/settings/settingsCache.js";

// item 19 — screen-reader figure set. getFigures() is a lazy factory: first
// call reads getInitialSettings().prefersReducedMotion, returns a frozen
// Unicode set (default, byte-identical to existing consts) or an ASCII
// downgrade set (reducedMotion). Cached keyed on the boolean, recomputes on
// change. Existing `export const` constants are unchanged for unmigrated
// consumers — default-off behavior is preserved.

const ENV_KEYS = ["FUSION_SCREEN_READER", "FUSION_AX_SCREEN_READER"] as const;

function clearEnv(): void {
for (const k of ENV_KEYS) {
delete process.env[k];
}
}

describe("item 19 — screen-reader figure set (getFigures)", () => {
beforeEach(() => {
clearEnv();
(
globalThis as { __fusionScreenReaderOverride?: boolean }
).__fusionScreenReaderOverride = undefined;
resetSettingsCache();
});

afterEach(() => {
clearEnv();
(
globalThis as { __fusionScreenReaderOverride?: boolean }
).__fusionScreenReaderOverride = undefined;
resetSettingsCache();
});

it("default (no reduced motion) → byte-identical to existing consts", () => {
const fig = getFigures();
expect(fig.BLACK_CIRCLE).toBe(BLACK_CIRCLE);
// UP_ARROW/EFFORT_LOW are module consts too — ensure set matches.
expect(fig.UP_ARROW).toBe("↑");
expect(fig.EFFORT_LOW).toBe("○");
});

it("FUSION_AX_SCREEN_READER=1 → ASCII downgrades", () => {
process.env.FUSION_AX_SCREEN_READER = "1";
resetSettingsCache();
const fig = getFigures();
expect(fig.BLACK_CIRCLE).toBe("*");
expect(fig.UP_ARROW).toBe("^");
expect(fig.EFFORT_LOW).toBe("o");
expect(fig.BRIDGE_FAILED_INDICATOR).toBe("x");
});

it("switching env + cache reset reflects new state (cache invalidation)", () => {
// First: default Unicode.
expect(getFigures().BLACK_CIRCLE).toBe(BLACK_CIRCLE);
// Then activate reduced motion.
process.env.FUSION_AX_SCREEN_READER = "1";
resetSettingsCache();
expect(getFigures().BLACK_CIRCLE).toBe("*");
// Then back off.
delete process.env.FUSION_AX_SCREEN_READER;
resetSettingsCache();
expect(getFigures().BLACK_CIRCLE).toBe(BLACK_CIRCLE);
});

it("ASCII_SET and UNICODE_SET share the same key set (no missing symbol)", () => {
const off = getFigures();
process.env.FUSION_AX_SCREEN_READER = "1";
resetSettingsCache();
const on = getFigures();
const offKeys = Object.keys(off).sort();
const onKeys = Object.keys(on).sort();
expect(onKeys).toEqual(offKeys);
// Every value is a non-empty string (or frozen array for spinner frames).
for (const k of onKeys) {
const v = (on as Record<string, unknown>)[k];
expect(v).toBeDefined();
}
});
});
147 changes: 147 additions & 0 deletions src/__tests__/ink/colorizeScreenReader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import chalk, { type ColorSupportLevel } from "chalk";
import {
applyColor,
applyScreenReaderAnsiGate,
applyTextStyles,
colorize,
} from "../../ink/colorize.js";
import { getInitialSettings } from "../../utils/settings/settings.js";
import { resetSettingsCache } from "../../utils/settings/settingsCache.js";

// item 19 — screen-reader ANSI gate. When active, colorize()/applyTextStyles()
// return raw strings (no ANSI escape sequences), reusing NO_COLOR semantics
// locally to the Ink render path. Default off = byte-identical current behavior.
//
// The test env runs with NO_COLOR=1 → chalk.level===0, so chalk emits raw
// strings regardless of the gate. To exercise the gate deterministically we
// force chalk.level=3 (truecolor) for the "color applied" assertions and
// restore it after. The gate itself is toggled via the explicit
// applyScreenReaderAnsiGate() API (the module-load capture can't be
// retriggered by env alone without a fresh import).

const ENV_KEYS = ["FUSION_SCREEN_READER", "FUSION_AX_SCREEN_READER"] as const;

function clearEnv(): void {
for (const k of ENV_KEYS) {
delete process.env[k];
}
}

describe("item 19 — screen-reader ANSI gate (colorize)", () => {
let savedLevel: ColorSupportLevel;

beforeEach(() => {
clearEnv();
applyScreenReaderAnsiGate(false);
savedLevel = chalk.level;
});

afterEach(() => {
clearEnv();
applyScreenReaderAnsiGate(false);
chalk.level = savedLevel;
});

describe("gate OFF (default) — color applied when chalk emits", () => {
it("colorize('x','ansi:red','foreground') wraps with ANSI at chalk.level 3", () => {
chalk.level = 3 as ColorSupportLevel;
// NOTE: bare "red" (no ansi: prefix) is a colorize no-op that falls
// through every prefix branch and returns raw str — pre-existing
// behavior, not gate-related. The colored path is "ansi:red".
const out = colorize("x", "ansi:red", "foreground");
expect(out.length).toBeGreaterThan("x".length);
expect(out).toContain("x");
});

it("colorize handles hex/ansi256/rgb/ansi: formats at chalk.level 3", () => {
chalk.level = 3 as ColorSupportLevel;
expect(colorize("x", "#ff0000", "foreground").length).toBeGreaterThan(1);
expect(
colorize("x", "ansi256(196)", "foreground").length,
).toBeGreaterThan(1);
expect(
colorize("x", "rgb(255,0,0)", "foreground").length,
).toBeGreaterThan(1);
expect(colorize("x", "ansi:red", "foreground").length).toBeGreaterThan(1);
});

it("applyTextStyles('x',{bold:true}) wraps with ANSI at chalk.level 3", () => {
chalk.level = 3 as ColorSupportLevel;
const out = applyTextStyles("x", { bold: true });
expect(out.length).toBeGreaterThan("x".length);
});
});

describe("gate ON — raw strings, zero ANSI (even at chalk.level 3)", () => {
it("colorize returns raw string for every color format", () => {
chalk.level = 3 as ColorSupportLevel;
applyScreenReaderAnsiGate(true);
// Use the colored paths (ansi:/hex/ansi256/rgb); bare "red" is a
// colorize no-op regardless of gate, so it's excluded here.
expect(colorize("x", "ansi:red", "foreground")).toBe("x");
expect(colorize("x", "#ff0000", "foreground")).toBe("x");
expect(colorize("x", "ansi256(196)", "foreground")).toBe("x");
expect(colorize("x", "rgb(255,0,0)", "foreground")).toBe("x");
});

it("applyTextStyles returns raw text for full style set", () => {
chalk.level = 3 as ColorSupportLevel;
applyScreenReaderAnsiGate(true);
const out = applyTextStyles("x", {
bold: true,
italic: true,
underline: true,
color: "red" as never,
inverse: true,
});
expect(out).toBe("x");
});

it("applyColor inherits the gate (routes through colorize)", () => {
chalk.level = 3 as ColorSupportLevel;
applyScreenReaderAnsiGate(true);
expect(applyColor("x", "ansi:red")).toBe("x");
expect(applyColor("x", "#ff0000")).toBe("x");
});
});

describe("early-return not broken by gate", () => {
it("colorize(x, undefined) returns raw in both states", () => {
expect(colorize("x", undefined, "foreground")).toBe("x");
applyScreenReaderAnsiGate(true);
expect(colorize("x", undefined, "foreground")).toBe("x");
});

it("applyColor(x, undefined) returns raw in both states", () => {
expect(applyColor("x", undefined)).toBe("x");
applyScreenReaderAnsiGate(true);
expect(applyColor("x", undefined)).toBe("x");
});
});

describe("env entry-point chain locks the gate source", () => {
beforeEach(() => {
(
globalThis as { __fusionScreenReaderOverride?: boolean }
).__fusionScreenReaderOverride = undefined;
});
afterEach(() => {
(
globalThis as { __fusionScreenReaderOverride?: boolean }
).__fusionScreenReaderOverride = undefined;
resetSettingsCache();
});

it("FUSION_AX_SCREEN_READER=1 → getInitialSettings().prefersReducedMotion === true", () => {
process.env.FUSION_AX_SCREEN_READER = "1";
resetSettingsCache();
expect(getInitialSettings().prefersReducedMotion).toBe(true);
});

it("no env → prefersReducedMotion not forced true", () => {
resetSettingsCache();
expect(getInitialSettings().prefersReducedMotion ?? false).toBe(false);
});
});
});
29 changes: 17 additions & 12 deletions src/components/ContextVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ import { generateContextSuggestions } from '../utils/contextSuggestions.js';
import { getDisplayPath } from '../utils/file.js';
import { formatTokens } from '../utils/format.js';
import { getSourceDisplayName, type SettingSource } from '../utils/settings/constants.js';
import { getInitialSettings } from '../utils/settings/settings.js';
import { plural } from '../utils/stringUtils.js';
import { ContextSuggestions } from './ContextSuggestions.js';
const RESERVED_CATEGORY_NAME = 'Autocompact buffer';
// screen-reader tree branch: read once at module load (same module-scope
// pattern as LogoV2.tsx). /context is transient — re-mounts on each open, so
// toggling FUSION_AX_SCREEN_READER between sessions reflects on next render.
const TREE_BRANCH = getInitialSettings().prefersReducedMotion === true ? '- ' : '└ ';

/**
* One-liner for the legend header showing what context-collapse has done.
Expand Down Expand Up @@ -391,33 +396,33 @@ export function ContextVisualization(t0) {
return t18;
}
function _temp27(attachment, i_10) {
return <Box key={i_10} marginLeft={1}><Text>{attachment.name}: </Text><Text dimColor={true}>{formatTokens(attachment.tokens)} tokens</Text></Box>;
return <Box key={i_10} marginLeft={1}><Text>{TREE_BRANCH}{attachment.name}: </Text><Text dimColor={true}>{formatTokens(attachment.tokens)} tokens</Text></Box>;
}
function _temp26(tool_5, i_9) {
return <Box key={i_9} marginLeft={1}><Text>{tool_5.name}: </Text><Text dimColor={true}>calls {formatTokens(tool_5.callTokens)}, results{" "}{formatTokens(tool_5.resultTokens)}</Text></Box>;
return <Box key={i_9} marginLeft={1}><Text>{TREE_BRANCH}{tool_5.name}: </Text><Text dimColor={true}>calls {formatTokens(tool_5.callTokens)}, results{" "}{formatTokens(tool_5.resultTokens)}</Text></Box>;
}
function _temp25(t0) {
const [sourceDisplay_0, sourceSkills] = t0;
return <Box key={sourceDisplay_0} flexDirection="column" marginTop={1}><Text dimColor={true}>{sourceDisplay_0}</Text>{sourceSkills.map(_temp24)}</Box>;
}
function _temp24(skill, i_8) {
return <Box key={i_8}><Text>{skill.name}: </Text><Text dimColor={true}>{formatTokens(skill.tokens)} tokens</Text></Box>;
return <Box key={i_8}><Text>{TREE_BRANCH}{skill.name}: </Text><Text dimColor={true}>{formatTokens(skill.tokens)} tokens</Text></Box>;
}
function _temp23(file, i_7) {
return <Box key={i_7}><Text>{getDisplayPath(file.path)}: </Text><Text dimColor={true}>{formatTokens(file.tokens)} tokens</Text></Box>;
return <Box key={i_7}><Text>{TREE_BRANCH}{getDisplayPath(file.path)}: </Text><Text dimColor={true}>{formatTokens(file.tokens)} tokens</Text></Box>;
}
function _temp22(t0) {
const [sourceDisplay, sourceAgents] = t0;
return <Box key={sourceDisplay} flexDirection="column" marginTop={1}><Text dimColor={true}>{sourceDisplay}</Text>{sourceAgents.map(_temp21)}</Box>;
}
function _temp21(agent, i_6) {
return <Box key={i_6}><Text>{agent.agentType}: </Text><Text dimColor={true}>{formatTokens(agent.tokens)} tokens</Text></Box>;
return <Box key={i_6}><Text>{TREE_BRANCH}{agent.agentType}: </Text><Text dimColor={true}>{formatTokens(agent.tokens)} tokens</Text></Box>;
}
function _temp20(section, i_5) {
return <Box key={i_5}><Text>{section.name}: </Text><Text dimColor={true}>{formatTokens(section.tokens)} tokens</Text></Box>;
return <Box key={i_5}><Text>{TREE_BRANCH}{section.name}: </Text><Text dimColor={true}>{formatTokens(section.tokens)} tokens</Text></Box>;
}
function _temp19(tool_4, i_4) {
return <Box key={i_4}><Text dimColor={true}>{tool_4.name}</Text></Box>;
return <Box key={i_4}><Text dimColor={true}>{TREE_BRANCH}{tool_4.name}</Text></Box>;
}
function _temp18(t_4) {
return !t_4.isLoaded;
Expand All @@ -426,19 +431,19 @@ function _temp17(t_5) {
return !t_5.isLoaded;
}
function _temp16(tool_3, i_3) {
return <Box key={`def-${i_3}`}><Text>{tool_3.name}: </Text><Text dimColor={true}>{formatTokens(tool_3.tokens)} tokens</Text></Box>;
return <Box key={`def-${i_3}`}><Text>{TREE_BRANCH}{tool_3.name}: </Text><Text dimColor={true}>{formatTokens(tool_3.tokens)} tokens</Text></Box>;
}
function _temp15(t_3) {
return t_3.isLoaded;
}
function _temp14(tool_2, i_2) {
return <Box key={`sys-${i_2}`}><Text>{tool_2.name}: </Text><Text dimColor={true}>{formatTokens(tool_2.tokens)} tokens</Text></Box>;
return <Box key={`sys-${i_2}`}><Text>{TREE_BRANCH}{tool_2.name}: </Text><Text dimColor={true}>{formatTokens(tool_2.tokens)} tokens</Text></Box>;
}
function _temp13(tool_1, i_1) {
return <Box key={i_1}><Text>{tool_1.name}: </Text><Text dimColor={true}>{formatTokens(tool_1.tokens)} tokens</Text></Box>;
return <Box key={i_1}><Text>{TREE_BRANCH}{tool_1.name}: </Text><Text dimColor={true}>{formatTokens(tool_1.tokens)} tokens</Text></Box>;
}
function _temp12(tool_0, i_0) {
return <Box key={i_0}><Text dimColor={true}>{tool_0.name}</Text></Box>;
return <Box key={i_0}><Text dimColor={true}>{TREE_BRANCH}{tool_0.name}</Text></Box>;
}
function _temp11(t_1) {
return !t_1.isLoaded;
Expand All @@ -447,7 +452,7 @@ function _temp10(t_2) {
return !t_2.isLoaded;
}
function _temp1(tool, i) {
return <Box key={i}><Text>{tool.name}: </Text><Text dimColor={true}>{formatTokens(tool.tokens)} tokens</Text></Box>;
return <Box key={i}><Text>{TREE_BRANCH}{tool.name}: </Text><Text dimColor={true}>{formatTokens(tool.tokens)} tokens</Text></Box>;
}
function _temp0(t) {
return t.isLoaded;
Expand Down
18 changes: 7 additions & 11 deletions src/components/EffortIndicator.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
import {
EFFORT_HIGH,
EFFORT_LOW,
EFFORT_MAX,
EFFORT_MEDIUM,
} from '../constants/figures.js'
import { getFigures } from '../constants/figures.js'
import {
type EffortLevel,
type EffortValue,
Expand All @@ -25,18 +20,19 @@ export function getEffortNotificationText(
}

export function effortLevelToSymbol(level: EffortLevel): string {
const fig = getFigures()
switch (level) {
case 'low':
return EFFORT_LOW
return fig.EFFORT_LOW
case 'medium':
return EFFORT_MEDIUM
return fig.EFFORT_MEDIUM
case 'high':
return EFFORT_HIGH
return fig.EFFORT_HIGH
case 'max':
return EFFORT_MAX
return fig.EFFORT_MAX
default:
// Defensive: level can originate from remote config. If an unknown
// value slips through, render the high symbol rather than undefined.
return EFFORT_HIGH
return fig.EFFORT_HIGH
}
}
Loading
Loading