@cruxy/cli 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,16 +37,30 @@ export async function promptForApproval(request, io) {
37
37
  export function render(request, color) {
38
38
  const t = themeForColor(color);
39
39
  const destructive = request.tier === "destructive";
40
- // The risk mark carries meaning by shape (`!` vs `?`), not by color alone —
41
- // so it survives NO_COLOR (U.1 accessibility groundwork).
40
+ // Risk survives all three degradations (U.11): the mark carries it by *shape*
41
+ // (`!` vs `?`) for NO_COLOR, and the label carries it by *word*
42
+ // (`(destructive)` / `(mutate)` / `(read)`) — never by hue alone, and legible
43
+ // to a screen reader that would otherwise read `!` as "exclamation mark".
42
44
  const mark = destructive ? t.danger(t.strong("!")) : t.warning("?");
43
- const label = destructive ? t.danger(t.strong(" (destructive)")) : "";
45
+ const label = tierLabel(request.tier, t);
44
46
  const lines = [];
45
47
  lines.push(`${mark} cruxy wants to ${t.strong(request.summary)}${label}`);
46
48
  lines.push(detail(request, t));
47
49
  lines.push(choices(request.scope, t));
48
50
  return lines.filter((l) => l !== "").join("\n") + " ";
49
51
  }
52
+ /** The worded risk tag, colored by tier — always present, so meaning never
53
+ * rides on the `!`/`?` shape or its color alone. */
54
+ function tierLabel(tier, t) {
55
+ switch (tier) {
56
+ case "destructive":
57
+ return t.danger(t.strong(" (destructive)"));
58
+ case "mutate":
59
+ return t.warning(" (mutate)");
60
+ case "read":
61
+ return t.muted(" (read)");
62
+ }
63
+ }
50
64
  /** The action detail: a diff for file actions, the command + cwd for shell/test. */
51
65
  function detail(request, t) {
52
66
  if (request.action.kind === "shell" || request.action.kind === "test") {
@@ -106,7 +106,9 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
106
106
  lines.push(t.heading(opts.title));
107
107
  lines.push(`${t.accent(g.caret)} ${query}${t.muted(g.cursorBar)}`);
108
108
  if (ranked.length === 0) {
109
- lines.push(t.muted(" no results backspace to widen"));
109
+ // No dead-end: the only escapes (backspace, esc) stay advertised even
110
+ // when nothing matches — the user is never stranded with no visible exit.
111
+ lines.push(t.muted(" no results — backspace to widen · esc cancel"));
110
112
  }
111
113
  else {
112
114
  // Keep the highlighted row inside the viewport.
@@ -122,6 +124,10 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
122
124
  if (hidden > 0)
123
125
  lines.push(t.muted(` ${g.ellipsis} ${hidden} more`));
124
126
  }
127
+ // Persistent key hint (U.11 keyboard-completeness): every operable key is
128
+ // advertised, mirroring selectList — type filters, arrows move, enter
129
+ // selects, esc cancels. Present in both the results and no-results states.
130
+ lines.push(t.muted(` type to filter ${g.sep} ${g.caretUp}/${g.caretDown} move ${g.sep} enter select ${g.sep} esc cancel`));
125
131
  frame.render(lines);
126
132
  };
127
133
  io.keys.begin();
@@ -93,7 +93,10 @@ export async function firstWinStep(io, deps) {
93
93
  io.write(`\nRun a quick demo now — let cruxy summarize this repo? ${col.muted("[Y/n]")} `);
94
94
  const key = (await io.readKey()).toLowerCase();
95
95
  io.write("\n");
96
- if (key === "n")
96
+ // Decline on an explicit `n`, and treat Ctrl-C / EOF / escape (readKey "")
97
+ // as a clean cancel rather than "proceed": a cancel must never launch a task.
98
+ // Enter (readKey → "\n") keeps the `[Y/n]` default and runs the demo.
99
+ if (key === "n" || key === "")
97
100
  return { status: "skipped" };
98
101
  await deps.runTask(FIRST_WIN_PROMPT);
99
102
  return { status: "ok" };
@@ -1,4 +1,11 @@
1
1
  import type { RenderCapabilities, RenderStream } from "./types.js";
2
+ /**
3
+ * Reduced-motion (U.11) — the ecosystem `NO_MOTION` signal, the explicit cruxy
4
+ * knob `CRUXY_REDUCED_MOTION`, and `CRUXY_NO_SPINNER` kept as an alias flowing
5
+ * through this one axis. A screen reader implies it (no live region to animate).
6
+ * The single source of the spinner gate.
7
+ */
8
+ export declare function detectReducedMotion(env?: NodeJS.ProcessEnv): boolean;
2
9
  /**
3
10
  * Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
4
11
  * given its inputs (stream + env are injectable), so every row of the
@@ -6,7 +13,8 @@ import type { RenderCapabilities, RenderStream } from "./types.js";
6
13
  *
7
14
  * Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
8
15
  * refinement: `TERM=dumb` terminals get no color even though they are TTYs.
9
- * Cursor control and color are independent axes a NO_COLOR terminal still
10
- * supports in-place status updates; a dumb terminal supports neither.
16
+ * The axes are independent (U.1/U.11): a NO_COLOR terminal still supports
17
+ * in-place status updates; a dumb terminal supports neither; reduced motion and
18
+ * screen-reader mode compose orthogonally with color and unicode.
11
19
  */
12
20
  export declare function detectCapabilities(stream?: RenderStream, env?: NodeJS.ProcessEnv): RenderCapabilities;
@@ -1,5 +1,21 @@
1
1
  import { shouldUseColor } from "../errors/index.js";
2
- import { detectUnicode } from "../theme/index.js";
2
+ import { detectScreenReader, detectUnicode } from "../theme/index.js";
3
+ /** Set-and-non-empty (the NO_COLOR convention): any non-empty value counts. */
4
+ function isSet(value) {
5
+ return value !== undefined && value !== "";
6
+ }
7
+ /**
8
+ * Reduced-motion (U.11) — the ecosystem `NO_MOTION` signal, the explicit cruxy
9
+ * knob `CRUXY_REDUCED_MOTION`, and `CRUXY_NO_SPINNER` kept as an alias flowing
10
+ * through this one axis. A screen reader implies it (no live region to animate).
11
+ * The single source of the spinner gate.
12
+ */
13
+ export function detectReducedMotion(env = process.env) {
14
+ return (isSet(env.NO_MOTION) ||
15
+ isSet(env.CRUXY_REDUCED_MOTION) ||
16
+ isSet(env.CRUXY_NO_SPINNER) ||
17
+ detectScreenReader(env));
18
+ }
3
19
  /**
4
20
  * Probe the environment once and reduce it to {@link RenderCapabilities}. Pure
5
21
  * given its inputs (stream + env are injectable), so every row of the
@@ -7,20 +23,24 @@ import { detectUnicode } from "../theme/index.js";
7
23
  *
8
24
  * Color reuses {@link shouldUseColor} (NO_COLOR / FORCE_COLOR / TTY) with one
9
25
  * refinement: `TERM=dumb` terminals get no color even though they are TTYs.
10
- * Cursor control and color are independent axes a NO_COLOR terminal still
11
- * supports in-place status updates; a dumb terminal supports neither.
26
+ * The axes are independent (U.1/U.11): a NO_COLOR terminal still supports
27
+ * in-place status updates; a dumb terminal supports neither; reduced motion and
28
+ * screen-reader mode compose orthogonally with color and unicode.
12
29
  */
13
30
  export function detectCapabilities(stream = process.stdout, env = process.env) {
14
31
  const tty = Boolean(stream.isTTY);
15
32
  const dumb = env.TERM === "dumb";
16
33
  const cursor = tty && !dumb;
34
+ const reducedMotion = detectReducedMotion(env);
17
35
  return {
18
36
  tty,
19
37
  color: shouldUseColor(stream, env) && !dumb,
20
38
  cursor,
21
- // Same set-and-non-empty convention as NO_COLOR: any value disables.
22
- spinner: cursor &&
23
- !(env.CRUXY_NO_SPINNER !== undefined && env.CRUXY_NO_SPINNER !== ""),
39
+ // Motion is the single gate now: CRUXY_NO_SPINNER flows through it (alias),
40
+ // as do NO_MOTION / CRUXY_REDUCED_MOTION and an implied screen reader.
41
+ spinner: cursor && !reducedMotion,
42
+ reducedMotion,
43
+ screenReader: detectScreenReader(env),
24
44
  // Unicode glyph safety (U.1) — independent of color. dumb / CRUXY_ASCII →
25
45
  // ASCII glyphs; everything else (incl. pipes) keeps unicode.
26
46
  unicode: detectUnicode(env),
@@ -1,15 +1,19 @@
1
1
  import type { RenderStream, StreamRenderer } from "./types.js";
2
2
  export type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, TokenUsage, ToolLifecycleEvent, } from "./types.js";
3
- export { detectCapabilities } from "./capabilities.js";
3
+ export { detectCapabilities, detectReducedMotion } from "./capabilities.js";
4
4
  export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
5
5
  export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
6
6
  export { createStreamHighlighter, defaultLineHighlighter, type HighlightCarry, type LineHighlighter, type StreamHighlighter, } from "./highlight.js";
7
7
  export { PlainRenderer } from "./plain-renderer.js";
8
+ export { ScreenReaderRenderer } from "./screen-reader-renderer.js";
8
9
  export { TtyRenderer } from "./tty-renderer.js";
9
10
  /**
10
- * Build the renderer for the detected environment: the managed-live-region
11
- * {@link TtyRenderer} when cursor control is safe, otherwise the append-only
12
- * {@link PlainRenderer} (pipes, CI, `TERM=dumb`). Everything downstream talks
13
- * to the {@link StreamRenderer} interface and never re-probes the terminal.
11
+ * Build the renderer for the detected environment (U.11 adds the first branch):
12
+ * - `screenReader` the linear, worded {@link ScreenReaderRenderer}, regardless
13
+ * of cursor support (a screen-reader TTY must not get the live region);
14
+ * - else cursor-safe → the managed-live-region {@link TtyRenderer} (static, no
15
+ * timer, when `reducedMotion`);
16
+ * - else → the append-only {@link PlainRenderer} (pipes, CI, `TERM=dumb`).
17
+ * Everything downstream talks to {@link StreamRenderer} and never re-probes.
14
18
  */
15
19
  export declare function createRenderer(out?: RenderStream, err?: RenderStream, env?: NodeJS.ProcessEnv): StreamRenderer;
@@ -1,20 +1,27 @@
1
1
  import { detectCapabilities } from "./capabilities.js";
2
2
  import { PlainRenderer } from "./plain-renderer.js";
3
+ import { ScreenReaderRenderer } from "./screen-reader-renderer.js";
3
4
  import { TtyRenderer } from "./tty-renderer.js";
4
- export { detectCapabilities } from "./capabilities.js";
5
+ export { detectCapabilities, detectReducedMotion } from "./capabilities.js";
5
6
  export { composeStatusLine, describePhase, ELAPSED_AFTER_MS, formatElapsed, formatTokens, phaseIdentity, } from "./state.js";
6
7
  export { renderActionPreview, PREVIEW_MAX_LINES } from "./diff.js";
7
8
  export { createStreamHighlighter, defaultLineHighlighter, } from "./highlight.js";
8
9
  export { PlainRenderer } from "./plain-renderer.js";
10
+ export { ScreenReaderRenderer } from "./screen-reader-renderer.js";
9
11
  export { TtyRenderer } from "./tty-renderer.js";
10
12
  /**
11
- * Build the renderer for the detected environment: the managed-live-region
12
- * {@link TtyRenderer} when cursor control is safe, otherwise the append-only
13
- * {@link PlainRenderer} (pipes, CI, `TERM=dumb`). Everything downstream talks
14
- * to the {@link StreamRenderer} interface and never re-probes the terminal.
13
+ * Build the renderer for the detected environment (U.11 adds the first branch):
14
+ * - `screenReader` the linear, worded {@link ScreenReaderRenderer}, regardless
15
+ * of cursor support (a screen-reader TTY must not get the live region);
16
+ * - else cursor-safe → the managed-live-region {@link TtyRenderer} (static, no
17
+ * timer, when `reducedMotion`);
18
+ * - else → the append-only {@link PlainRenderer} (pipes, CI, `TERM=dumb`).
19
+ * Everything downstream talks to {@link StreamRenderer} and never re-probes.
15
20
  */
16
21
  export function createRenderer(out = process.stdout, err = process.stderr, env = process.env) {
17
22
  const caps = detectCapabilities(out, env);
23
+ if (caps.screenReader)
24
+ return new ScreenReaderRenderer(caps, out, err);
18
25
  return caps.cursor
19
26
  ? new TtyRenderer(caps, out)
20
27
  : new PlainRenderer(caps, out, err);
@@ -1,6 +1,6 @@
1
1
  import type { ActionPreview } from "../tools/types.js";
2
2
  import { type Theme } from "../theme/index.js";
3
- import type { RenderCapabilities, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
3
+ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, StreamRenderer, ToolLifecycleEvent } from "./types.js";
4
4
  /**
5
5
  * The append-only renderer for pipes, CI, and cursor-less terminals. Emits no
6
6
  * cursor-control sequences ever, and no color unless the capabilities say so
@@ -30,8 +30,8 @@ export declare class PlainRenderer implements StreamRenderer {
30
30
  note(text: string): void;
31
31
  preview(preview: ActionPreview): void;
32
32
  status(): void;
33
- setPhase(): void;
34
- progress(): void;
33
+ setPhase(phase: RenderPhase | null): void;
34
+ progress(state: ProgressState | null): void;
35
35
  toolLifecycle(event: ToolLifecycleEvent): void;
36
36
  promptResolved(): void;
37
37
  endTurn(): void;
@@ -59,12 +59,20 @@ export class PlainRenderer {
59
59
  status() {
60
60
  // Append-only medium: transient state is dropped by design.
61
61
  }
62
- setPhase() {
62
+ // setPhase / progress declare their StreamRenderer params because the
63
+ // ScreenReaderRenderer subclass overrides them to announce (the override must
64
+ // be signature-compatible). In the plain medium they are no-ops: there is no
65
+ // live region to update, so the guard simply returns.
66
+ setPhase(phase) {
63
67
  // Phases are live-region state; there is no live region here (U.4).
68
+ if (phase !== null)
69
+ return;
64
70
  }
65
- progress() {
71
+ progress(state) {
66
72
  // The committed plan trail (C.31, via PromptIO) is the record in this
67
73
  // medium; a live [i/n] prefix would just duplicate it line by line.
74
+ if (state !== null)
75
+ return;
68
76
  }
69
77
  toolLifecycle(event) {
70
78
  if (event.event === "start") {
@@ -0,0 +1,45 @@
1
+ import { PlainRenderer } from "./plain-renderer.js";
2
+ import type { ProgressState, RenderCapabilities, RenderPhase, RenderStream, ToolLifecycleEvent } from "./types.js";
3
+ /**
4
+ * The screen-reader renderer (U.11): {@link PlainRenderer}'s linear,
5
+ * append-only, zero-cursor-control output — plus two things a screen reader
6
+ * needs that the plain path drops.
7
+ *
8
+ * 1. **Worded status.** Its theme resolves the {@link SCREEN_READER_GLYPHS}
9
+ * table (via `caps.screenReader`), so every inherited code path that writes
10
+ * `theme.glyph.success` prints `done` instead of `✓`, `failed` for `✗`, etc.
11
+ * Nothing here special-cases it — the glyph table does the work.
12
+ *
13
+ * 2. **Announced state.** A screen reader cannot see an in-place live region, so
14
+ * each state change is emitted as its own committed line (never a redraw):
15
+ * a phase becomes `working: read_file src/x.ts`, a tool run brackets as
16
+ * `working: …` → `done …`, and plan progress announces `step 2 of 5: title`.
17
+ * Announcements are deduped by phase *identity* so token-count updates within
18
+ * the same activity don't repeat a line.
19
+ *
20
+ * Everything else — text streaming, `endSegment`, `note`, `preview` — is
21
+ * inherited unchanged. This is the whole of screen-reader mode: no parallel
22
+ * path, no behavior change to the plain or TTY renderers.
23
+ */
24
+ export declare class ScreenReaderRenderer extends PlainRenderer {
25
+ /** Identity of the last phase announced, so we speak each activity once. */
26
+ private lastPhaseId;
27
+ /** Text of the last progress line announced, to avoid repeats. */
28
+ private lastProgress;
29
+ constructor(caps: RenderCapabilities, out: RenderStream, err: RenderStream);
30
+ /**
31
+ * Announce a phase transition as a committed line. `null`, `awaiting-approval`
32
+ * (the prompt block is its own announcement), and `calling-tool` (announced by
33
+ * {@link toolLifecycle}) are silent; repeats of the same activity are deduped.
34
+ */
35
+ setPhase(phase: RenderPhase | null): void;
36
+ /** Announce plan-step progress as `step i of n: title`, deduped. */
37
+ progress(state: ProgressState | null): void;
38
+ /**
39
+ * Bracket a tool call with two committed lines — `working: label` on start,
40
+ * `done/failed label` on end — so a screen-reader user hears both that a call
41
+ * began (crucial for long ones) and how it resolved. The end note + honest
42
+ * duration is the inherited plain behavior; only the start line is added.
43
+ */
44
+ toolLifecycle(event: ToolLifecycleEvent): void;
45
+ }
@@ -0,0 +1,75 @@
1
+ import { PlainRenderer } from "./plain-renderer.js";
2
+ import { describePhase, phaseIdentity } from "./state.js";
3
+ /**
4
+ * The screen-reader renderer (U.11): {@link PlainRenderer}'s linear,
5
+ * append-only, zero-cursor-control output — plus two things a screen reader
6
+ * needs that the plain path drops.
7
+ *
8
+ * 1. **Worded status.** Its theme resolves the {@link SCREEN_READER_GLYPHS}
9
+ * table (via `caps.screenReader`), so every inherited code path that writes
10
+ * `theme.glyph.success` prints `done` instead of `✓`, `failed` for `✗`, etc.
11
+ * Nothing here special-cases it — the glyph table does the work.
12
+ *
13
+ * 2. **Announced state.** A screen reader cannot see an in-place live region, so
14
+ * each state change is emitted as its own committed line (never a redraw):
15
+ * a phase becomes `working: read_file src/x.ts`, a tool run brackets as
16
+ * `working: …` → `done …`, and plan progress announces `step 2 of 5: title`.
17
+ * Announcements are deduped by phase *identity* so token-count updates within
18
+ * the same activity don't repeat a line.
19
+ *
20
+ * Everything else — text streaming, `endSegment`, `note`, `preview` — is
21
+ * inherited unchanged. This is the whole of screen-reader mode: no parallel
22
+ * path, no behavior change to the plain or TTY renderers.
23
+ */
24
+ export class ScreenReaderRenderer extends PlainRenderer {
25
+ /** Identity of the last phase announced, so we speak each activity once. */
26
+ lastPhaseId = "";
27
+ /** Text of the last progress line announced, to avoid repeats. */
28
+ lastProgress = "";
29
+ constructor(caps, out, err) {
30
+ super(caps, out, err);
31
+ }
32
+ /**
33
+ * Announce a phase transition as a committed line. `null`, `awaiting-approval`
34
+ * (the prompt block is its own announcement), and `calling-tool` (announced by
35
+ * {@link toolLifecycle}) are silent; repeats of the same activity are deduped.
36
+ */
37
+ setPhase(phase) {
38
+ const id = phaseIdentity(phase);
39
+ if (id === this.lastPhaseId)
40
+ return;
41
+ this.lastPhaseId = id;
42
+ if (phase === null ||
43
+ phase.kind === "awaiting-approval" ||
44
+ phase.kind === "calling-tool") {
45
+ return;
46
+ }
47
+ this.note(describePhase(phase, this.theme.glyph));
48
+ }
49
+ /** Announce plan-step progress as `step i of n: title`, deduped. */
50
+ progress(state) {
51
+ if (state === null) {
52
+ this.lastProgress = "";
53
+ return;
54
+ }
55
+ const line = `step ${state.step} of ${state.of}: ${state.title}`;
56
+ if (line === this.lastProgress)
57
+ return;
58
+ this.lastProgress = line;
59
+ this.note(line);
60
+ }
61
+ /**
62
+ * Bracket a tool call with two committed lines — `working: label` on start,
63
+ * `done/failed label` on end — so a screen-reader user hears both that a call
64
+ * began (crucial for long ones) and how it resolved. The end note + honest
65
+ * duration is the inherited plain behavior; only the start line is added.
66
+ */
67
+ toolLifecycle(event) {
68
+ if (event.event === "start") {
69
+ super.toolLifecycle(event); // records the start time (silent in plain)
70
+ this.note(`${this.theme.glyph.running}: ${event.label}`);
71
+ return;
72
+ }
73
+ super.toolLifecycle(event); // commits `done/failed label (duration)`
74
+ }
75
+ }
@@ -20,8 +20,22 @@ export interface RenderCapabilities {
20
20
  color: boolean;
21
21
  /** Cursor-control sequences are safe (`tty` and not `TERM=dumb`). */
22
22
  cursor: boolean;
23
- /** Animation is welcome (`cursor` and CRUXY_NO_SPINNER unset). */
23
+ /** Animation is welcome (`cursor` and motion is not reduced). */
24
24
  spinner: boolean;
25
+ /**
26
+ * Motion is reduced (U.11): no spinner animation/timer, no in-place
27
+ * re-animation — state survives as static text, only movement stops. True
28
+ * under `CRUXY_NO_SPINNER` (alias) / `NO_MOTION` / `CRUXY_REDUCED_MOTION`, and
29
+ * implied by `screenReader`. The one axis the spinner gate keys off.
30
+ */
31
+ reducedMotion: boolean;
32
+ /**
33
+ * Screen-reader mode (U.11): plain, linear, announce-friendly output — no
34
+ * live-region redraws (each state change is a committed line), no spinners,
35
+ * status glyphs rendered as words. Opt-in via `CRUXY_SCREEN_READER` /
36
+ * `ACCESSIBLE`; routes rendering to the linear path regardless of `cursor`.
37
+ */
38
+ screenReader: boolean;
25
39
  /** Unicode glyphs are safe (U.1) — false under `TERM=dumb` / `CRUXY_ASCII`;
26
40
  * independent of `color`. Drives the theme's glyph table, not its stylers. */
27
41
  unicode: boolean;
@@ -1,14 +1,15 @@
1
1
  import { type Theme, type ThemeCapabilities } from "./tokens.js";
2
2
  /**
3
- * Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
3
+ * Theme resolution (U.1/U.11) — the ONE place picocolors is used. The axes are
4
4
  * fully independent by construction:
5
5
  *
6
6
  * - `color` drives the stylers only. `pc.createColors(false)` is the identity,
7
7
  * so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
8
8
  * tests.
9
- * - `unicode` drives the glyph table only. It never touches color: a NO_COLOR
10
- * unicode terminal still prints ✓/✗ (glyphs aren't ANSI), and a colored
11
- * CRUXY_ASCII terminal prints a colored `[ok]`.
9
+ * - the glyph table is chosen by `screenReader` (words) else `unicode`
10
+ * (✓/✗) else ASCII (`[ok]`/`[x]`). Never touches color: a NO_COLOR unicode
11
+ * terminal still prints ✓/✗ (glyphs aren't ANSI), a colored CRUXY_ASCII
12
+ * terminal prints a colored `[ok]`, and a screen reader gets colorless words.
12
13
  */
13
14
  export declare function resolveTheme(caps: ThemeCapabilities): Theme;
14
15
  /**
@@ -23,10 +24,20 @@ export declare function resolveTheme(caps: ThemeCapabilities): Theme;
23
24
  * signature change.
24
25
  */
25
26
  export declare function detectUnicode(env?: NodeJS.ProcessEnv): boolean;
27
+ /**
28
+ * Whether the user asked for screen-reader mode (U.11) — opt-in via
29
+ * `CRUXY_SCREEN_READER` or the ecosystem `ACCESSIBLE` flag (set-and-non-empty).
30
+ * Deliberately NOT inferred from a pipe: auto-wording every non-TTY stream would
31
+ * change existing CI/plain output. The single source of the rule, called by
32
+ * `render/capabilities.ts` (to fill `RenderCapabilities.screenReader`) and by
33
+ * the boolean-seam {@link themeForColor} surfaces (approval, plan, onboarding).
34
+ */
35
+ export declare function detectScreenReader(env?: NodeJS.ProcessEnv): boolean;
26
36
  /**
27
37
  * Build a theme for a surface that only knows a `color` boolean (the U.3
28
- * PromptIO, error formatting, plan/onboarding/CLI copy). Unicode is detected
29
- * from the environment so these surfaces still degrade on dumb terminals,
30
- * with no change to their public boolean signatures.
38
+ * PromptIO, error formatting, plan/onboarding/CLI copy). Unicode and
39
+ * screen-reader mode are detected from the environment so these surfaces still
40
+ * degrade on dumb terminals and speak words to a screen reader, with no change
41
+ * to their public boolean signatures.
31
42
  */
32
43
  export declare function themeForColor(color: boolean, env?: NodeJS.ProcessEnv): Theme;
@@ -1,19 +1,24 @@
1
1
  import pc from "picocolors";
2
- import { ASCII_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
2
+ import { ASCII_GLYPHS, SCREEN_READER_GLYPHS, UNICODE_GLYPHS, } from "./tokens.js";
3
3
  /**
4
- * Theme resolution (U.1) — the ONE place picocolors is used. The two axes are
4
+ * Theme resolution (U.1/U.11) — the ONE place picocolors is used. The axes are
5
5
  * fully independent by construction:
6
6
  *
7
7
  * - `color` drives the stylers only. `pc.createColors(false)` is the identity,
8
8
  * so NO_COLOR yields zero ANSI bytes (dim/bold vanish too) — asserted in the
9
9
  * tests.
10
- * - `unicode` drives the glyph table only. It never touches color: a NO_COLOR
11
- * unicode terminal still prints ✓/✗ (glyphs aren't ANSI), and a colored
12
- * CRUXY_ASCII terminal prints a colored `[ok]`.
10
+ * - the glyph table is chosen by `screenReader` (words) else `unicode`
11
+ * (✓/✗) else ASCII (`[ok]`/`[x]`). Never touches color: a NO_COLOR unicode
12
+ * terminal still prints ✓/✗ (glyphs aren't ANSI), a colored CRUXY_ASCII
13
+ * terminal prints a colored `[ok]`, and a screen reader gets colorless words.
13
14
  */
14
15
  export function resolveTheme(caps) {
15
16
  const c = pc.createColors(caps.color);
16
- const glyph = caps.unicode ? UNICODE_GLYPHS : ASCII_GLYPHS;
17
+ const glyph = caps.screenReader
18
+ ? SCREEN_READER_GLYPHS
19
+ : caps.unicode
20
+ ? UNICODE_GLYPHS
21
+ : ASCII_GLYPHS;
17
22
  const strong = c.bold;
18
23
  const indent = (text, level = 1) => {
19
24
  const pad = " ".repeat(Math.max(0, level));
@@ -62,12 +67,29 @@ export function detectUnicode(env = process.env) {
62
67
  return false;
63
68
  return true;
64
69
  }
70
+ /**
71
+ * Whether the user asked for screen-reader mode (U.11) — opt-in via
72
+ * `CRUXY_SCREEN_READER` or the ecosystem `ACCESSIBLE` flag (set-and-non-empty).
73
+ * Deliberately NOT inferred from a pipe: auto-wording every non-TTY stream would
74
+ * change existing CI/plain output. The single source of the rule, called by
75
+ * `render/capabilities.ts` (to fill `RenderCapabilities.screenReader`) and by
76
+ * the boolean-seam {@link themeForColor} surfaces (approval, plan, onboarding).
77
+ */
78
+ export function detectScreenReader(env = process.env) {
79
+ const set = (v) => v !== undefined && v !== "";
80
+ return set(env.CRUXY_SCREEN_READER) || set(env.ACCESSIBLE);
81
+ }
65
82
  /**
66
83
  * Build a theme for a surface that only knows a `color` boolean (the U.3
67
- * PromptIO, error formatting, plan/onboarding/CLI copy). Unicode is detected
68
- * from the environment so these surfaces still degrade on dumb terminals,
69
- * with no change to their public boolean signatures.
84
+ * PromptIO, error formatting, plan/onboarding/CLI copy). Unicode and
85
+ * screen-reader mode are detected from the environment so these surfaces still
86
+ * degrade on dumb terminals and speak words to a screen reader, with no change
87
+ * to their public boolean signatures.
70
88
  */
71
89
  export function themeForColor(color, env = process.env) {
72
- return resolveTheme({ color, unicode: detectUnicode(env) });
90
+ return resolveTheme({
91
+ color,
92
+ unicode: detectUnicode(env),
93
+ screenReader: detectScreenReader(env),
94
+ });
73
95
  }
@@ -90,10 +90,16 @@ export interface Theme {
90
90
  readonly color: boolean;
91
91
  readonly unicode: boolean;
92
92
  }
93
- /** The two axes a theme is resolved from — a structural subset of RenderCapabilities. */
93
+ /** The axes a theme is resolved from — a structural subset of RenderCapabilities. */
94
94
  export interface ThemeCapabilities {
95
95
  color: boolean;
96
96
  unicode: boolean;
97
+ /**
98
+ * Screen-reader mode (U.11): status glyphs render as words. Optional so every
99
+ * existing `{color, unicode}` caller is unchanged (defaults to off). When set
100
+ * it takes precedence over `unicode` for the glyph table.
101
+ */
102
+ screenReader?: boolean;
97
103
  }
98
104
  /** The unicode glyph table (real terminals). */
99
105
  export declare const UNICODE_GLYPHS: ThemeGlyphs;
@@ -102,3 +108,12 @@ export declare const UNICODE_GLYPHS: ThemeGlyphs;
102
108
  * mojibake — the intentional U.1 degradation for unicode-unsafe terminals.
103
109
  */
104
110
  export declare const ASCII_GLYPHS: ThemeGlyphs;
111
+ /**
112
+ * The screen-reader glyph table (U.11): the status glyphs a screen reader would
113
+ * otherwise announce as bare punctuation are spelled as words — `done`,
114
+ * `failed`, `working`, `pending` — so `✓ read_file` becomes `done read_file`.
115
+ * Structural glyphs stay ASCII-legible (a pointer/caret has no useful word).
116
+ * This is the entire "worded status" of screen-reader mode: swap the table,
117
+ * reuse every existing renderer/theme code path unchanged.
118
+ */
119
+ export declare const SCREEN_READER_GLYPHS: ThemeGlyphs;
@@ -50,3 +50,30 @@ export const ASCII_GLYPHS = {
50
50
  spinnerFrames: ["-", "\\", "|", "/"],
51
51
  spinnerStatic: "~",
52
52
  };
53
+ /**
54
+ * The screen-reader glyph table (U.11): the status glyphs a screen reader would
55
+ * otherwise announce as bare punctuation are spelled as words — `done`,
56
+ * `failed`, `working`, `pending` — so `✓ read_file` becomes `done read_file`.
57
+ * Structural glyphs stay ASCII-legible (a pointer/caret has no useful word).
58
+ * This is the entire "worded status" of screen-reader mode: swap the table,
59
+ * reuse every existing renderer/theme code path unchanged.
60
+ */
61
+ export const SCREEN_READER_GLYPHS = {
62
+ success: "done",
63
+ failure: "failed",
64
+ pending: "pending",
65
+ running: "working",
66
+ pointer: ">",
67
+ caret: ">",
68
+ arrow: "->",
69
+ caretUp: "up",
70
+ caretDown: "down",
71
+ cursorBar: "",
72
+ bullet: "-",
73
+ sep: "-",
74
+ ellipsis: "...",
75
+ play: ">",
76
+ // Unused in the linear screen-reader path (no live region), kept legible.
77
+ spinnerFrames: ["working"],
78
+ spinnerStatic: "working",
79
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {