From 904a9b1c72df7946d190ed65849b8d747ee70c17 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 25 Jun 2026 06:09:10 +0000 Subject: [PATCH] fix(unit-only): add Unicode-aware symbol abstraction with ASCII fallback for legacy Windows consoles Route all TUI selection cursors, checkboxes, scroll arrows, step-indicator marks, and deploy progress glyphs through a single src/cli/tui/utils/symbols.ts helper that detects Unicode support (honoring an AGENTCORE_ASCII override) and falls back to ASCII ('>', 'x', '^'/'v', '*'/'o', '->', '[x]'/'[ ]') when the terminal cannot render BMP glyphs. Prevents mojibake on legacy Windows CMD / non-UTF-8 PowerShell while preserving the original glyphs on modern terminals. No new runtime dependency. Fixes #29 --- src/cli/commands/deploy/progress.ts | 5 +- src/cli/tui/components/AwsTargetConfigUI.tsx | 13 ++- src/cli/tui/components/MultiSelectList.tsx | 19 ++- src/cli/tui/components/SelectList.tsx | 17 ++- src/cli/tui/components/StepIndicator.tsx | 5 +- src/cli/tui/screens/home/HomeScreen.tsx | 3 +- src/cli/tui/screens/mcp/AddGatewayScreen.tsx | 6 +- .../payment/AddPaymentManagerScreen.tsx | 6 +- src/cli/tui/utils/__tests__/symbols.test.tsx | 109 ++++++++++++++++++ src/cli/tui/utils/index.ts | 1 + src/cli/tui/utils/symbols.ts | 77 +++++++++++++ 11 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 src/cli/tui/utils/__tests__/symbols.test.tsx create mode 100644 src/cli/tui/utils/symbols.ts diff --git a/src/cli/commands/deploy/progress.ts b/src/cli/commands/deploy/progress.ts index da7eb7b3c..987302172 100644 --- a/src/cli/commands/deploy/progress.ts +++ b/src/cli/commands/deploy/progress.ts @@ -3,6 +3,7 @@ import { ANSI } from '../../constants'; import { getErrorMessage } from '../../errors'; import { ensureDefaultDeploymentTarget } from '../../operations/deploy'; import { canSkipDeploy } from '../../operations/deploy/change-detection'; +import { symbols } from '../../tui/utils/symbols'; import { handleDeploy } from './actions'; export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; @@ -34,9 +35,9 @@ export function createSpinnerProgress(): SpinnerProgress { process.stdout.write(`\r${SPINNER_FRAMES[i]} ${step}...`); }, 80); } else if (status === 'success') { - console.log(`✓ ${step}`); + console.log(`${symbols.success} ${step}`); } else { - console.log(`✗ ${step}`); + console.log(`${symbols.failure} ${step}`); } }; diff --git a/src/cli/tui/components/AwsTargetConfigUI.tsx b/src/cli/tui/components/AwsTargetConfigUI.tsx index 570dc42f0..1c039a9bb 100644 --- a/src/cli/tui/components/AwsTargetConfigUI.tsx +++ b/src/cli/tui/components/AwsTargetConfigUI.tsx @@ -3,6 +3,7 @@ import type { AgentCoreRegion as _AgentCoreRegion } from '../../../schema'; import { useListNavigation } from '../hooks'; import type { AwsConfigPhase, AwsTargetConfigState } from '../hooks/useAwsTargetConfig'; import { INTERACTIVE_COLORS } from '../theme'; +import { symbols } from '../utils'; import { Cursor } from './Cursor'; import { SelectList } from './SelectList'; import { TextInput } from './TextInput'; @@ -153,10 +154,10 @@ export function AwsTargetConfigUI({ config, onExit, isActive }: AwsTargetConfigU - {focusedRow === 0 ? '❯ ' : ' '} + {focusedRow === 0 ? `${symbols.cursor} ` : ' '} - ▶ All Targets + {symbols.pointer} All Targets — Deploy to all {config.availableTargets.length} targets @@ -172,8 +173,10 @@ export function AwsTargetConfigUI({ config, onExit, isActive }: AwsTargetConfigU const isFocused = focusedRow === i + 1; return ( - {isFocused ? '❯ ' : ' '} - {isChecked ? '[✓]' : '[ ]'} + + {isFocused ? `${symbols.cursor} ` : ' '} + + {isChecked ? symbols.checkboxOn : symbols.checkboxOff} {target.name} {' '} @@ -222,7 +225,7 @@ export function AwsTargetConfigUI({ config, onExit, isActive }: AwsTargetConfigU ) : ( filteredRegions.map((region, i) => ( - {i === regionIndex ? '❯' : ' '} {region} + {i === regionIndex ? symbols.cursor : ' '} {region} )) )} diff --git a/src/cli/tui/components/MultiSelectList.tsx b/src/cli/tui/components/MultiSelectList.tsx index 1f2994f22..29c072a4b 100644 --- a/src/cli/tui/components/MultiSelectList.tsx +++ b/src/cli/tui/components/MultiSelectList.tsx @@ -1,3 +1,4 @@ +import { symbols } from '../utils'; import type { SelectableItem } from './SelectList'; import { Box, Text } from 'ink'; @@ -39,16 +40,21 @@ export function MultiSelectList(props: MultiSelectList return ( - {needsScroll && viewportStart > 0 && ↑ {viewportStart} more} + {needsScroll && viewportStart > 0 && ( + + {' '} + {symbols.arrowUp} {viewportStart} more + + )} {visibleItems.map((item, idx) => { const actualIndex = viewportStart + idx; const isCursor = actualIndex === selectedIndex; const isChecked = selectedIds.has(item.id); - const checkbox = isChecked ? '[✓]' : '[ ]'; + const checkbox = isChecked ? symbols.checkboxOn : symbols.checkboxOff; return ( - {isCursor ? '❯' : ' '} + {isCursor ? symbols.cursor : ' '} {checkbox} {item.title} {item.description && - {item.description}} @@ -56,7 +62,12 @@ export function MultiSelectList(props: MultiSelectList ); })} - {needsScroll && viewportEnd < items.length && ↓ {items.length - viewportEnd} more} + {needsScroll && viewportEnd < items.length && ( + + {' '} + {symbols.arrowDown} {items.length - viewportEnd} more + + )} ); } diff --git a/src/cli/tui/components/SelectList.tsx b/src/cli/tui/components/SelectList.tsx index feea63248..d57d620ef 100644 --- a/src/cli/tui/components/SelectList.tsx +++ b/src/cli/tui/components/SelectList.tsx @@ -1,3 +1,4 @@ +import { symbols } from '../utils'; import { Box, Text } from 'ink'; export interface SelectableItem { @@ -45,7 +46,12 @@ export function SelectList(props: { return ( - {needsScroll && viewportStart > 0 && ↑ {viewportStart} more} + {needsScroll && viewportStart > 0 && ( + + {' '} + {symbols.arrowUp} {viewportStart} more + + )} {visibleItems.map((item, idx) => { const actualIndex = viewportStart + idx; const selected = actualIndex === selectedIndex; @@ -54,7 +60,7 @@ export function SelectList(props: { - {selected ? '❯' : ' '}{' '} + {selected ? symbols.cursor : ' '}{' '} {item.title} @@ -64,7 +70,12 @@ export function SelectList(props: { ); })} - {needsScroll && viewportEnd < items.length && ↓ {items.length - viewportEnd} more} + {needsScroll && viewportEnd < items.length && ( + + {' '} + {symbols.arrowDown} {items.length - viewportEnd} more + + )} ); } diff --git a/src/cli/tui/components/StepIndicator.tsx b/src/cli/tui/components/StepIndicator.tsx index a4618df9d..3467b1b3e 100644 --- a/src/cli/tui/components/StepIndicator.tsx +++ b/src/cli/tui/components/StepIndicator.tsx @@ -1,4 +1,5 @@ import { useResponsive } from '../hooks/useResponsive'; +import { symbols } from '../utils'; import { Box, Text } from 'ink'; interface StepIndicatorProps { @@ -71,7 +72,7 @@ export function StepIndicator({ const isLastInRow = idx === rowSteps.length - 1; const isLastStep = stepIdx === steps.length - 1; - const icon = isDone ? '✓' : isCurrent ? '●' : '○'; + const icon = isDone ? symbols.stepDone : isCurrent ? symbols.stepCurrent : symbols.stepPending; const color = isCurrent ? 'cyan' : isDone ? 'green' : 'gray'; return ( @@ -81,7 +82,7 @@ export function StepIndicator({ {' '} {label} - {showArrows && !isLastStep && !isLastInRow && } + {showArrows && !isLastStep && !isLastInRow && {symbols.branch} } ); })} diff --git a/src/cli/tui/screens/home/HomeScreen.tsx b/src/cli/tui/screens/home/HomeScreen.tsx index 7c67222fa..a3409de3c 100644 --- a/src/cli/tui/screens/home/HomeScreen.tsx +++ b/src/cli/tui/screens/home/HomeScreen.tsx @@ -1,6 +1,7 @@ import { findConfigRoot } from '../../../../lib'; import { Cursor, ScreenLayout } from '../../components'; import { HINTS } from '../../copy'; +import { symbols } from '../../utils'; import { Box, Text, useApp, useInput } from 'ink'; import React from 'react'; @@ -28,7 +29,7 @@ function QuickStart() { - + {symbols.flag} Press Enter to create a new project diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 557e560a0..6ce8a41e9 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -14,7 +14,7 @@ import type { SelectableItem } from '../../components'; import { JwtConfigInput, useJwtConfigFlow } from '../../components/jwt-config'; import { HELP_TEXT } from '../../constants'; import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; -import { generateUniqueName } from '../../utils'; +import { generateUniqueName, symbols } from '../../utils'; import type { AddGatewayConfig } from './types'; import { AUTHORIZER_TYPE_OPTIONS, @@ -283,11 +283,11 @@ export function AddGatewayScreen({ {advancedConfigItems.map((item, idx) => { const isCursor = idx === advancedNav.cursorIndex; const isChecked = advancedNav.selectedIds.has(item.id); - const checkbox = isChecked ? '[✓]' : '[ ]'; + const checkbox = isChecked ? symbols.checkboxOn : symbols.checkboxOff; return ( - {isCursor ? '❯' : ' '} + {isCursor ? symbols.cursor : ' '} {checkbox} {item.title} diff --git a/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx b/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx index c5b195dc7..c0db682e5 100644 --- a/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx +++ b/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx @@ -4,7 +4,7 @@ import { Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../com import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; -import { generateUniqueName } from '../../utils'; +import { generateUniqueName, symbols } from '../../utils'; import type { AddPaymentManagerConfig } from './types'; import { AUTH_TYPE_OPTIONS, @@ -218,11 +218,11 @@ export function AddPaymentManagerScreen({ {advancedConfigItems.map((item, idx) => { const isCursor = idx === advancedNav.cursorIndex; const isChecked = advancedNav.selectedIds.has(item.id); - const checkbox = isChecked ? '[✓]' : '[ ]'; + const checkbox = isChecked ? symbols.checkboxOn : symbols.checkboxOff; return ( - {isCursor ? '❯' : ' '} + {isCursor ? symbols.cursor : ' '} {checkbox} {item.title} diff --git a/src/cli/tui/utils/__tests__/symbols.test.tsx b/src/cli/tui/utils/__tests__/symbols.test.tsx new file mode 100644 index 000000000..4d0210eb5 --- /dev/null +++ b/src/cli/tui/utils/__tests__/symbols.test.tsx @@ -0,0 +1,109 @@ +import { MultiSelectList } from '../../components/MultiSelectList.js'; +import { SelectList } from '../../components/SelectList.js'; +import { StepIndicator } from '../../components/StepIndicator.js'; +import { isUnicodeSupported, symbols } from '../symbols.js'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../hooks/useResponsive.js', () => ({ + useResponsive: () => ({ width: 120, height: 40, isNarrow: false }), +})); + +// Every non-ASCII glyph the TUI/console renders. None of these may appear when +// Unicode support is forced off (the legacy-Windows path). +const NON_ASCII_GLYPHS = ['❯', '↑', '↓', '✓', '✗', '●', '○', '→', '⚑', '▶']; + +function withAscii(off: boolean, fn: () => void) { + const prev = process.env.AGENTCORE_ASCII; + process.env.AGENTCORE_ASCII = off ? '1' : '0'; + try { + fn(); + } finally { + if (prev === undefined) delete process.env.AGENTCORE_ASCII; + else process.env.AGENTCORE_ASCII = prev; + } +} + +afterEach(() => { + delete process.env.AGENTCORE_ASCII; +}); + +describe('symbols', () => { + it('AGENTCORE_ASCII override flips Unicode detection', () => { + withAscii(true, () => expect(isUnicodeSupported()).toBe(false)); + withAscii(false, () => expect(isUnicodeSupported()).toBe(true)); + }); + + it('emits ASCII fallbacks when Unicode is off', () => { + withAscii(true, () => { + expect(symbols.cursor).toBe('>'); + expect(symbols.checkboxOn).toBe('[x]'); + expect(symbols.checkboxOff).toBe('[ ]'); + expect(symbols.arrowUp).toBe('^'); + expect(symbols.arrowDown).toBe('v'); + expect(symbols.stepDone).toBe('x'); + expect(symbols.stepCurrent).toBe('*'); + expect(symbols.stepPending).toBe('o'); + expect(symbols.branch).toBe('->'); + expect(symbols.success).toBe('OK'); + expect(symbols.failure).toBe('X'); + // checked and unchecked stay distinguishable + expect(symbols.checkboxOn).not.toBe(symbols.checkboxOff); + }); + }); + + it('emits original glyphs when Unicode is on', () => { + withAscii(false, () => { + expect(symbols.cursor).toBe('❯'); + expect(symbols.checkboxOn).toBe('[✓]'); + expect(symbols.stepCurrent).toBe('●'); + }); + }); +}); + +describe('TUI glyph fallback (legacy Windows)', () => { + const items = [ + { id: 'a', title: 'Alpha' }, + { id: 'b', title: 'Bravo' }, + ]; + + it('SelectList renders no non-ASCII codepoints with Unicode off', () => { + withAscii(true, () => { + const { lastFrame } = render(); + const frame = lastFrame()!; + expect(frame).toContain('>'); + for (const g of NON_ASCII_GLYPHS) expect(frame).not.toContain(g); + }); + }); + + it('MultiSelectList keeps checkbox state distinguishable in both modes', () => { + withAscii(true, () => { + const { lastFrame } = render( + + ); + const frame = lastFrame()!; + expect(frame).toContain('[x]'); + expect(frame).toContain('[ ]'); + for (const g of NON_ASCII_GLYPHS) expect(frame).not.toContain(g); + }); + withAscii(false, () => { + const { lastFrame } = render( + + ); + const frame = lastFrame()!; + expect(frame).toContain('[✓]'); + expect(frame).toContain('[ ]'); + }); + }); + + it('StepIndicator renders no non-ASCII codepoints with Unicode off', () => { + withAscii(true, () => { + const steps = ['one', 'two', 'three'] as const; + const labels = { one: 'One', two: 'Two', three: 'Three' }; + const { lastFrame } = render(); + const frame = lastFrame()!; + for (const g of NON_ASCII_GLYPHS) expect(frame).not.toContain(g); + }); + }); +}); diff --git a/src/cli/tui/utils/index.ts b/src/cli/tui/utils/index.ts index 545bccbef..650d8ba1a 100644 --- a/src/cli/tui/utils/index.ts +++ b/src/cli/tui/utils/index.ts @@ -2,4 +2,5 @@ export { getCommandsForUI, type CommandMeta } from './commands'; export { diffLines, type DiffLine } from './diff'; export { generateUniqueName } from './naming'; export { isProcessRunning, cleanupStaleLockFiles } from './process'; +export { symbols, isUnicodeSupported } from './symbols'; export { withMinDuration } from './timing'; diff --git a/src/cli/tui/utils/symbols.ts b/src/cli/tui/utils/symbols.ts new file mode 100644 index 000000000..2152d7903 --- /dev/null +++ b/src/cli/tui/utils/symbols.ts @@ -0,0 +1,77 @@ +/** + * Single source of truth for the Unicode glyphs the TUI and console output use, + * each paired with an ASCII fallback for terminals that cannot render them + * (legacy Windows CMD / older PowerShell on a non-UTF-8 code page). + * + * Detection mirrors the `is-unicode-supported` heuristic but is hand-rolled to + * avoid a new dependency. The result is evaluated lazily on every access so that + * a process can switch modes (used by tests and the AGENTCORE_ASCII override). + */ + +/** + * Whether the current terminal can render the BMP Unicode glyphs we use. + * + * Honors an explicit override first: + * - AGENTCORE_ASCII=1 / true forces ASCII fallbacks (off) + * - AGENTCORE_ASCII=0 / false forces Unicode (on) + * Otherwise: always on for non-Windows (except the bare Linux kernel console); + * on Windows only for terminals known to support Unicode (Windows Terminal, + * VS Code, modern emulators, CI), matching `is-unicode-supported`. + */ +export function isUnicodeSupported(): boolean { + const override = process.env.AGENTCORE_ASCII; + if (override === '1' || override?.toLowerCase() === 'true') return false; + if (override === '0' || override?.toLowerCase() === 'false') return true; + + if (process.platform !== 'win32') { + return process.env.TERM !== 'linux'; + } + + return [ + Boolean(process.env.WT_SESSION), + process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm', + process.env.TERM_PROGRAM === 'vscode', + process.env.ConEmuTask === '{cmd::Cmder}', + process.env.TERM === 'xterm-256color', + process.env.TERM === 'alacritty', + Boolean(process.env.CI), + ].some(Boolean); +} + +interface Glyph { + readonly unicode: string; + readonly ascii: string; +} + +const GLYPHS = { + cursor: { unicode: '❯', ascii: '>' }, + checkboxOn: { unicode: '[✓]', ascii: '[x]' }, + checkboxOff: { unicode: '[ ]', ascii: '[ ]' }, + arrowUp: { unicode: '↑', ascii: '^' }, + arrowDown: { unicode: '↓', ascii: 'v' }, + stepDone: { unicode: '✓', ascii: 'x' }, + stepCurrent: { unicode: '●', ascii: '*' }, + stepPending: { unicode: '○', ascii: 'o' }, + branch: { unicode: '→', ascii: '->' }, + pointer: { unicode: '▶', ascii: '>' }, + flag: { unicode: '⚑', ascii: '*' }, + success: { unicode: '✓', ascii: 'OK' }, + failure: { unicode: '✗', ascii: 'X' }, +} satisfies Record; + +type SymbolName = keyof typeof GLYPHS; +type SymbolMap = Readonly>; + +/** + * Live, lazily-evaluated glyph map. Each access re-checks Unicode support so the + * rendered string always matches the current terminal (and test overrides). + */ +export const symbols: SymbolMap = Object.defineProperties( + {}, + Object.fromEntries( + (Object.keys(GLYPHS) as SymbolName[]).map(name => [ + name, + { enumerable: true, get: () => (isUnicodeSupported() ? GLYPHS[name].unicode : GLYPHS[name].ascii) }, + ]) + ) +) as SymbolMap;