From 0c0828f7b9436f614a48706634d534738a0f115d Mon Sep 17 00:00:00 2001 From: CorticalCode <212432458+CorticalCode@users.noreply.github.com> Date: Wed, 24 Jun 2026 02:52:57 -0500 Subject: [PATCH 1/3] feat(widget): add Sandbox Status widget Shows whether Claude Code's bash sandbox mode is enabled, read from the effective sandbox.enabled setting across the layered Claude config (project-local -> project -> user-local -> user). Because /sandbox persists its toggle to .claude/settings.local.json, the widget reflects runtime toggles on each status refresh, not just the configured default. Display modes (cycle 'f'): glyph 'SB: dot' (default), text 'SB: ON/OFF', word 'Sandbox: ON/OFF', bare glyph-only; optional Nerd Font lock glyphs ('n'). Standard per-widget color picker; raw value on/off for composition. Reuses the layered-settings candidate-path reader shared with getVoiceConfig. --- src/utils/__tests__/claude-settings.test.ts | 111 +++++++++ src/utils/claude-settings.ts | 59 ++++- src/utils/widget-manifest.ts | 1 + src/widgets/SandboxStatus.ts | 166 +++++++++++++ src/widgets/__tests__/SandboxStatus.test.ts | 256 ++++++++++++++++++++ src/widgets/index.ts | 1 + 6 files changed, 592 insertions(+), 2 deletions(-) create mode 100644 src/widgets/SandboxStatus.ts create mode 100644 src/widgets/__tests__/SandboxStatus.test.ts diff --git a/src/utils/__tests__/claude-settings.test.ts b/src/utils/__tests__/claude-settings.test.ts index 9301ad7a..0630ba2a 100644 --- a/src/utils/__tests__/claude-settings.test.ts +++ b/src/utils/__tests__/claude-settings.test.ts @@ -22,6 +22,7 @@ import { getClaudeSettingsPath, getExistingStatusLine, getRefreshInterval, + getSandboxConfig, getVoiceConfig, installStatusLine, isClaudeCodeVersionAtLeast, @@ -812,3 +813,113 @@ describe('getVoiceConfig', () => { }); }); }); + +describe('getSandboxConfig', () => { + let testSandboxProjectDir = ''; + + function writeRawUserLocalSettings(content: string): void { + const settingsPath = path.join(testClaudeConfigDir, 'settings.local.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, content, 'utf-8'); + } + + function writeRawProjectSettings(content: string): void { + const settingsPath = path.join(testSandboxProjectDir, '.claude', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, content, 'utf-8'); + } + + function writeRawProjectLocalSettings(content: string): void { + const settingsPath = path.join(testSandboxProjectDir, '.claude', 'settings.local.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, content, 'utf-8'); + } + + beforeEach(() => { + testSandboxProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-sandbox-project-')); + }); + + afterEach(() => { + if (testSandboxProjectDir) { + fs.rmSync(testSandboxProjectDir, { recursive: true, force: true }); + } + }); + + it('returns null when no candidate file exists', () => { + expect(getSandboxConfig(testSandboxProjectDir)).toBeNull(); + }); + + it('returns { enabled: false } when settings.json has no sandbox field', () => { + writeRawClaudeSettings(JSON.stringify({ effortLevel: 'high' })); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false }); + }); + + it('returns { enabled: true } when sandbox.enabled is true', () => { + writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } })); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true }); + }); + + it('returns { enabled: false } when sandbox.enabled is false', () => { + writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: false } })); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false }); + }); + + it('returns { enabled: false } when sandbox exists but enabled is missing', () => { + writeRawClaudeSettings(JSON.stringify({ sandbox: { network: { allowAll: true } } })); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false }); + }); + + it('treats malformed JSON as "no override"', () => { + writeRawClaudeSettings('{ not json'); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false }); + }); + + it('treats an unexpected sandbox shape as "no override"', () => { + writeRawClaudeSettings(JSON.stringify({ sandbox: 'on' })); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false }); + }); + + it('respects CLAUDE_CONFIG_DIR env var', () => { + writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } })); + expect(getClaudeSettingsPath().startsWith(testClaudeConfigDir)).toBe(true); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true }); + }); + + it('project-local overrides project (where /sandbox writes)', () => { + writeRawProjectSettings(JSON.stringify({ sandbox: { enabled: false } })); + writeRawProjectLocalSettings(JSON.stringify({ sandbox: { enabled: true } })); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true }); + }); + + it('project overrides user-global', () => { + writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: false } })); + writeRawProjectSettings(JSON.stringify({ sandbox: { enabled: true } })); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true }); + }); + + it('user-local overrides user-global', () => { + writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } })); + writeRawUserLocalSettings(JSON.stringify({ sandbox: { enabled: false } })); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: false }); + }); + + it('a layer without sandbox.enabled does not clobber a lower layer', () => { + writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } })); + writeRawProjectSettings(JSON.stringify({ sandbox: { network: {} } })); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true }); + }); + + it('a malformed higher-priority layer does not clobber a defined lower layer', () => { + writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } })); + writeRawProjectLocalSettings('{ corrupt'); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true }); + }); + + it('falls through layers without sandbox.enabled until it finds a defined value', () => { + writeRawClaudeSettings(JSON.stringify({ sandbox: { enabled: true } })); + writeRawUserLocalSettings(JSON.stringify({ effortLevel: 'high' })); + writeRawProjectSettings(JSON.stringify({ sandbox: { network: {} } })); + writeRawProjectLocalSettings(JSON.stringify({ effortLevel: 'low' })); + expect(getSandboxConfig(testSandboxProjectDir)).toEqual({ enabled: true }); + }); +}); diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index 238ea24e..8aaecfce 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -505,7 +505,7 @@ export async function setRefreshInterval(interval: number | null): Promise const VoiceConfigSchema = z.object({ enabled: z.boolean().optional() }); -function getVoiceConfigCandidatePathsByPriority(cwd: string): string[] { +function getLayeredSettingsCandidatePathsByPriority(cwd: string): string[] { const userDir = getClaudeConfigDir(); const projectDir = path.join(cwd, '.claude'); // Highest priority first — `getVoiceConfig` returns on the first defined override. @@ -571,7 +571,7 @@ function tryReadVoiceLayer(filePath: string): VoiceLayerResult { */ export function getVoiceConfig(cwd: string = process.cwd()): { enabled: boolean } | null { let anyFileExisted = false; - for (const filePath of getVoiceConfigCandidatePathsByPriority(cwd)) { + for (const filePath of getLayeredSettingsCandidatePathsByPriority(cwd)) { const layer = tryReadVoiceLayer(filePath); if (layer.fileExisted) { anyFileExisted = true; @@ -583,6 +583,61 @@ export function getVoiceConfig(cwd: string = process.cwd()): { enabled: boolean return anyFileExisted ? { enabled: false } : null; } +const SandboxConfigSchema = z.object({ enabled: z.boolean().optional() }); + +function tryReadSandboxLayer(filePath: string): { fileExisted: boolean; enabled: boolean | undefined } { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch (error) { + // ENOENT is the common case (file just doesn't exist on this layer); + // any other I/O error is treated the same — caller has no recovery path. + const isMissing = (error as NodeJS.ErrnoException).code === 'ENOENT'; + return { fileExisted: !isMissing, enabled: undefined }; + } + + try { + const parsed = JSON.parse(content) as { sandbox?: unknown }; + const sandbox = parsed.sandbox; + if (sandbox === undefined || sandbox === null) { + return { fileExisted: true, enabled: undefined }; + } + const result = SandboxConfigSchema.safeParse(sandbox); + return { fileExisted: true, enabled: result.success ? result.data.enabled : undefined }; + } catch { + // Malformed JSON — file exists but contributes no override. + return { fileExisted: true, enabled: undefined }; + } +} + +/** + * Reads the effective `sandbox.enabled` setting — Claude Code's bash sandbox mode — + * from the same layered configuration as `getVoiceConfig` (project-local → project → + * user-local → user, highest priority first; user dir respects `CLAUDE_CONFIG_DIR`). + * + * `/sandbox` persists its toggle to `/.claude/settings.local.json` (the + * highest-priority layer), so re-reading on each status refresh reflects runtime + * toggles, not just the configured default. + * + * - Returns `null` if no candidate settings file exists (Claude Code never initialised). + * - Returns `{ enabled: false }` if files exist but none defines `sandbox.enabled` + * (Claude Code's default — sandbox disabled). + * - Returns `{ enabled: }` reflecting the highest-priority override otherwise. + */ +export function getSandboxConfig(cwd: string = process.cwd()): { enabled: boolean } | null { + let anyFileExisted = false; + for (const filePath of getLayeredSettingsCandidatePathsByPriority(cwd)) { + const layer = tryReadSandboxLayer(filePath); + if (layer.fileExisted) { + anyFileExisted = true; + } + if (layer.enabled !== undefined) { + return { enabled: layer.enabled }; + } + } + return anyFileExisted ? { enabled: false } : null; +} + const RemoteSessionFileSchema = z.object({ sessionId: z.string().optional(), bridgeSessionId: z.string().nullable().optional() diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 86cbad71..9c0fa885 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -78,6 +78,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'link', create: () => new widgets.LinkWidget() }, { type: 'claude-session-id', create: () => new widgets.ClaudeSessionIdWidget() }, { type: 'claude-account-email', create: () => new widgets.ClaudeAccountEmailWidget() }, + { type: 'sandbox-status', create: () => new widgets.SandboxStatusWidget() }, { type: 'session-name', create: () => new widgets.SessionNameWidget() }, { type: 'free-memory', create: () => new widgets.FreeMemoryWidget() }, { type: 'session-usage', create: () => new widgets.SessionUsageWidget() }, diff --git a/src/widgets/SandboxStatus.ts b/src/widgets/SandboxStatus.ts new file mode 100644 index 00000000..85c75759 --- /dev/null +++ b/src/widgets/SandboxStatus.ts @@ -0,0 +1,166 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { getSandboxConfig } from '../utils/claude-settings'; + +const DOT_ON = '●'; +const DOT_OFF = '○'; +const LOCK_NERD_FONT = ''; +const UNLOCK_NERD_FONT = ''; + +const FORMATS = ['glyph', 'text', 'word', 'bare'] as const; +type SandboxFormat = typeof FORMATS[number]; + +const DEFAULT_FORMAT: SandboxFormat = 'glyph'; +const CYCLE_FORMAT_ACTION = 'cycle-format'; +const TOGGLE_NERD_FONT_ACTION = 'toggle-nerd-font'; +const NERD_FONT_METADATA_KEY = 'nerdFont'; + +function getFormat(item: WidgetItem): SandboxFormat { + const f = item.metadata?.format; + return (FORMATS as readonly string[]).includes(f ?? '') ? (f as SandboxFormat) : DEFAULT_FORMAT; +} + +function setFormat(item: WidgetItem, format: SandboxFormat): WidgetItem { + if (format === DEFAULT_FORMAT) { + const { format: removedFormat, ...restMetadata } = item.metadata ?? {}; + void removedFormat; + + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; + } + + return { + ...item, + metadata: { + ...(item.metadata ?? {}), + format + } + }; +} + +function isNerdFontEnabled(item: WidgetItem): boolean { + return item.metadata?.[NERD_FONT_METADATA_KEY] === 'true'; +} + +function toggleNerdFont(item: WidgetItem): WidgetItem { + if (!isNerdFontEnabled(item)) { + return { + ...item, + metadata: { + ...(item.metadata ?? {}), + [NERD_FONT_METADATA_KEY]: 'true' + } + }; + } + + const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {}; + void removedNerdFont; + + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; +} + +function formatStatus(enabled: boolean, format: SandboxFormat, nerdFont: boolean): string { + const stateText = enabled ? 'ON' : 'OFF'; + const glyph = nerdFont + ? (enabled ? LOCK_NERD_FONT : UNLOCK_NERD_FONT) + : (enabled ? DOT_ON : DOT_OFF); + + switch (format) { + case 'glyph': + return `SB: ${glyph}`; + case 'text': + return `SB: ${stateText}`; + case 'word': + return `Sandbox: ${stateText}`; + case 'bare': + return glyph; + } +} + +function resolveSandboxConfigCwd(context: RenderContext): string | undefined { + const candidates = [ + context.data?.workspace?.project_dir, + context.data?.cwd, + context.data?.workspace?.current_dir + ]; + + return candidates.find(candidate => typeof candidate === 'string' && candidate.trim().length > 0); +} + +export class SandboxStatusWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows whether Claude Code bash sandbox mode is enabled'; } + getDisplayName(): string { return 'Sandbox Status'; } + getCategory(): string { return 'Core'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + const modifiers: string[] = [getFormat(item)]; + if (isNerdFontEnabled(item)) { + modifiers.push('nerd font'); + } + + return { + displayText: this.getDisplayName(), + modifierText: `(${modifiers.join(', ')})` + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === CYCLE_FORMAT_ACTION) { + const currentFormat = getFormat(item); + const nextFormat = FORMATS[(FORMATS.indexOf(currentFormat) + 1) % FORMATS.length] ?? DEFAULT_FORMAT; + + return setFormat(item, nextFormat); + } + + if (action === TOGGLE_NERD_FONT_ACTION) { + return toggleNerdFont(item); + } + + return null; + } + + render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null { + const format = getFormat(item); + const nerdFont = isNerdFontEnabled(item); + + if (context.isPreview) { + if (item.rawValue) { + return 'on'; + } + return formatStatus(true, format, nerdFont); + } + + const config = getSandboxConfig(resolveSandboxConfigCwd(context)); + if (config === null) { + return null; + } + + if (item.rawValue) { + return config.enabled ? 'on' : 'off'; + } + + return formatStatus(config.enabled, format, nerdFont); + } + + getCustomKeybinds(): CustomKeybind[] { + return [ + { key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }, + { key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION } + ]; + } + + supportsRawValue(): boolean { return true; } + supportsColors(_item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/__tests__/SandboxStatus.test.ts b/src/widgets/__tests__/SandboxStatus.test.ts new file mode 100644 index 00000000..67052cd8 --- /dev/null +++ b/src/widgets/__tests__/SandboxStatus.test.ts @@ -0,0 +1,256 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { + RenderContext, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import * as claudeSettings from '../../utils/claude-settings'; +import { SandboxStatusWidget } from '../SandboxStatus'; + +const ITEM: WidgetItem = { id: 'sandbox-status', type: 'sandbox-status' }; + +const LOCK = ''; +const UNLOCK = ''; + +function makeContext(overrides: Partial = {}): RenderContext { + return { ...overrides }; +} + +beforeEach(() => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('SandboxStatusWidget', () => { + describe('metadata', () => { + it('has correct display name', () => { + expect(new SandboxStatusWidget().getDisplayName()).toBe('Sandbox Status'); + }); + + it('has correct description', () => { + expect(new SandboxStatusWidget().getDescription()).toBe('Shows whether Claude Code bash sandbox mode is enabled'); + }); + + it('has correct category', () => { + expect(new SandboxStatusWidget().getCategory()).toBe('Core'); + }); + + it('has green default color', () => { + expect(new SandboxStatusWidget().getDefaultColor()).toBe('green'); + }); + + it('supports raw value', () => { + expect(new SandboxStatusWidget().supportsRawValue()).toBe(true); + }); + + it('supports colors', () => { + expect(new SandboxStatusWidget().supportsColors(ITEM)).toBe(true); + }); + }); + + describe('editor configuration', () => { + it('exposes f and n keybinds', () => { + expect(new SandboxStatusWidget().getCustomKeybinds()).toEqual([ + { key: 'f', label: '(f)ormat', action: 'cycle-format' }, + { key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' } + ]); + }); + + it('defaults to glyph in the editor display', () => { + expect(new SandboxStatusWidget().getEditorDisplay(ITEM)).toEqual({ + displayText: 'Sandbox Status', + modifierText: '(glyph)' + }); + }); + + it('shows the configured format and nerd font in the editor display', () => { + expect(new SandboxStatusWidget().getEditorDisplay({ + ...ITEM, + metadata: { format: 'word', nerdFont: 'true' } + })).toEqual({ + displayText: 'Sandbox Status', + modifierText: '(word, nerd font)' + }); + }); + + it('cycles glyph -> text -> word -> bare -> glyph', () => { + const widget = new SandboxStatusWidget(); + const text = widget.handleEditorAction('cycle-format', ITEM); + const word = widget.handleEditorAction('cycle-format', text ?? ITEM); + const bare = widget.handleEditorAction('cycle-format', word ?? ITEM); + const back = widget.handleEditorAction('cycle-format', bare ?? ITEM); + + expect(text?.metadata?.format).toBe('text'); + expect(word?.metadata?.format).toBe('word'); + expect(bare?.metadata?.format).toBe('bare'); + expect(back?.metadata?.format).toBeUndefined(); + }); + + it('toggles nerd font metadata on and off', () => { + const widget = new SandboxStatusWidget(); + const enabled = widget.handleEditorAction('toggle-nerd-font', ITEM); + const disabled = widget.handleEditorAction('toggle-nerd-font', enabled ?? ITEM); + + expect(enabled?.metadata?.nerdFont).toBe('true'); + expect(disabled?.metadata?.nerdFont).toBeUndefined(); + }); + + it('returns null for unknown editor actions', () => { + expect(new SandboxStatusWidget().handleEditorAction('unknown', ITEM)).toBeNull(); + }); + }); + + describe('render() - format glyph (default), standard glyphs', () => { + it('renders SB: ○ when OFF', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false }); + expect(new SandboxStatusWidget().render(ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('SB: ○'); + }); + + it('renders SB: ● when ON', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true }); + expect(new SandboxStatusWidget().render(ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('SB: ●'); + }); + }); + + describe('render() - format glyph, Nerd Font', () => { + const NERD_ITEM: WidgetItem = { ...ITEM, metadata: { nerdFont: 'true' } }; + + it('renders the unlock glyph when OFF', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false }); + expect(new SandboxStatusWidget().render(NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(`SB: ${UNLOCK}`); + }); + + it('renders the lock glyph when ON', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true }); + expect(new SandboxStatusWidget().render(NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(`SB: ${LOCK}`); + }); + }); + + describe('render() - format text', () => { + const FORMAT_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'text' } }; + + it('renders SB: OFF when OFF', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false }); + expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('SB: OFF'); + }); + + it('renders SB: ON when ON', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true }); + expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('SB: ON'); + }); + }); + + describe('render() - format word', () => { + const FORMAT_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'word' } }; + + it('renders Sandbox: OFF when OFF', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false }); + expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('Sandbox: OFF'); + }); + + it('renders Sandbox: ON when ON', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true }); + expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('Sandbox: ON'); + }); + }); + + describe('render() - format bare (glyph only)', () => { + const FORMAT_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'bare' } }; + + it('renders ○ when OFF', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false }); + expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('○'); + }); + + it('renders ● when ON', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true }); + expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('●'); + }); + }); + + describe('render() - sandbox config cwd', () => { + it('uses the project dir for project-local Claude settings', () => { + const context = makeContext({ + data: { + cwd: '/repo/subdir', + workspace: { + current_dir: '/repo/current-dir', + project_dir: '/repo' + } + } + }); + + new SandboxStatusWidget().render(ITEM, context, DEFAULT_SETTINGS); + + expect(claudeSettings.getSandboxConfig).toHaveBeenCalledWith('/repo'); + }); + + it('falls back to cwd when project dir is missing', () => { + const context = makeContext({ + data: { + cwd: '/repo/subdir', + workspace: { current_dir: '/repo/current-dir' } + } + }); + + new SandboxStatusWidget().render(ITEM, context, DEFAULT_SETTINGS); + + expect(claudeSettings.getSandboxConfig).toHaveBeenCalledWith('/repo/subdir'); + }); + + it('falls back to workspace.current_dir when project dir and cwd are missing', () => { + const context = makeContext({ data: { workspace: { current_dir: '/repo/current-dir' } } }); + + new SandboxStatusWidget().render(ITEM, context, DEFAULT_SETTINGS); + + expect(claudeSettings.getSandboxConfig).toHaveBeenCalledWith('/repo/current-dir'); + }); + }); + + describe('render() - preview mode', () => { + it('renders the ON state for the default format', () => { + expect(new SandboxStatusWidget().render(ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS)).toBe('SB: ●'); + }); + }); + + describe('render() - missing config', () => { + it('returns null when getSandboxConfig returns null', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue(null); + expect(new SandboxStatusWidget().render(ITEM, makeContext(), DEFAULT_SETTINGS)).toBeNull(); + }); + }); + + describe('render() - raw value', () => { + const RAW_ITEM: WidgetItem = { ...ITEM, rawValue: true }; + + it('returns "on" when ON', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true }); + expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('on'); + }); + + it('returns "off" when OFF', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false }); + expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('off'); + }); + + it('returns "on" in preview mode', () => { + expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS)).toBe('on'); + }); + + it('returns null when config is null', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue(null); + expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBeNull(); + }); + }); +}); diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 518e3c1d..0f98607a 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -79,5 +79,6 @@ export { GitWorktreeNameWidget } from './GitWorktreeName'; export { GitWorktreeBranchWidget } from './GitWorktreeBranch'; export { GitWorktreeOriginalBranchWidget } from './GitWorktreeOriginalBranch'; export { CompactionCounterWidget } from './CompactionCounter'; +export { SandboxStatusWidget } from './SandboxStatus'; export { VoiceStatusWidget } from './VoiceStatus'; export { RemoteControlStatusWidget } from './RemoteControlStatus'; From 419b94fee9532edab72746e94c73f84facbdf6f9 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Mon, 20 Jul 2026 00:48:30 -0400 Subject: [PATCH 2/3] docs(widget): clarify sandbox status limitations Add a second-line picker warning that the sandbox indicator is best effort when managed or CLI settings override local files or sandbox initialization fails. Expand Sandbox Status coverage for bare Nerd Font glyphs and the current raw-output precedence so its display states are explicit ahead of follow-up behavior changes. --- src/widgets/SandboxStatus.ts | 8 +++++- src/widgets/__tests__/SandboxStatus.test.ts | 27 ++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/widgets/SandboxStatus.ts b/src/widgets/SandboxStatus.ts index 85c75759..7c104be0 100644 --- a/src/widgets/SandboxStatus.ts +++ b/src/widgets/SandboxStatus.ts @@ -100,7 +100,13 @@ function resolveSandboxConfigCwd(context: RenderContext): string | undefined { export class SandboxStatusWidget implements Widget { getDefaultColor(): string { return 'green'; } - getDescription(): string { return 'Shows whether Claude Code bash sandbox mode is enabled'; } + getDescription(): string { + return [ + 'Shows whether Claude Code bash sandbox mode is enabled', + 'Best effort: may not reflect active sandboxing when managed or CLI settings override it, or when sandbox initialization fails.' + ].join('\n'); + } + getDisplayName(): string { return 'Sandbox Status'; } getCategory(): string { return 'Core'; } diff --git a/src/widgets/__tests__/SandboxStatus.test.ts b/src/widgets/__tests__/SandboxStatus.test.ts index 67052cd8..080e64fb 100644 --- a/src/widgets/__tests__/SandboxStatus.test.ts +++ b/src/widgets/__tests__/SandboxStatus.test.ts @@ -39,7 +39,10 @@ describe('SandboxStatusWidget', () => { }); it('has correct description', () => { - expect(new SandboxStatusWidget().getDescription()).toBe('Shows whether Claude Code bash sandbox mode is enabled'); + expect(new SandboxStatusWidget().getDescription()).toBe([ + 'Shows whether Claude Code bash sandbox mode is enabled', + 'Best effort: may not reflect active sandboxing when managed or CLI settings override it, or when sandbox initialization fails.' + ].join('\n')); }); it('has correct category', () => { @@ -167,6 +170,7 @@ describe('SandboxStatusWidget', () => { describe('render() - format bare (glyph only)', () => { const FORMAT_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'bare' } }; + const FORMAT_NERD_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'bare', nerdFont: 'true' } }; it('renders ○ when OFF', () => { vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false }); @@ -177,6 +181,14 @@ describe('SandboxStatusWidget', () => { vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true }); expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('●'); }); + + it('renders only the configured Nerd Font glyph', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValueOnce({ enabled: false }); + expect(new SandboxStatusWidget().render(FORMAT_NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(UNLOCK); + + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValueOnce({ enabled: true }); + expect(new SandboxStatusWidget().render(FORMAT_NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(LOCK); + }); }); describe('render() - sandbox config cwd', () => { @@ -244,6 +256,19 @@ describe('SandboxStatusWidget', () => { expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('off'); }); + it('returns the semantic value regardless of format and Nerd Font settings', () => { + const rawNerdItem: WidgetItem = { + ...RAW_ITEM, + metadata: { format: 'glyph', nerdFont: 'true' } + }; + + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValueOnce({ enabled: false }); + expect(new SandboxStatusWidget().render(rawNerdItem, makeContext(), DEFAULT_SETTINGS)).toBe('off'); + + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValueOnce({ enabled: true }); + expect(new SandboxStatusWidget().render(rawNerdItem, makeContext(), DEFAULT_SETTINGS)).toBe('on'); + }); + it('returns "on" in preview mode', () => { expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS)).toBe('on'); }); From 8f58674e2702b2b9778eb83422f2efd735b7c2f9 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Mon, 20 Jul 2026 00:51:17 -0400 Subject: [PATCH 3/3] fix(widgets): align raw and Nerd Font modes Make Sandbox Status, Voice Status, and Remote Control Status follow the documented raw-value contract by removing display labels without replacing the configured value representation. Expose and apply Nerd Font settings only while a configurable icon is visible across Sandbox Status, Voice Status, Remote Control Status, and Vim Mode. Hide the binding in text-only modes, guard direct actions, clear metadata when cycling away from icons, and ignore stale flags in rendering and editor labels. Remove the redundant Sandbox bare format now that raw glyph mode provides glyph-only output, and cover all affected formats and state transitions. --- src/widgets/RemoteControlStatus.ts | 83 ++++++----- src/widgets/SandboxStatus.ts | 82 +++++----- src/widgets/VimMode.ts | 60 +++++--- src/widgets/VoiceStatus.ts | 79 ++++++---- .../__tests__/RemoteControlStatus.test.ts | 141 ++++++++++++++++-- src/widgets/__tests__/SandboxStatus.test.ts | 134 ++++++++++------- src/widgets/__tests__/VimMode.test.ts | 50 +++++++ src/widgets/__tests__/VoiceStatus.test.ts | 129 ++++++++++++++-- 8 files changed, 550 insertions(+), 208 deletions(-) diff --git a/src/widgets/RemoteControlStatus.ts b/src/widgets/RemoteControlStatus.ts index b62c3652..344e794b 100644 --- a/src/widgets/RemoteControlStatus.ts +++ b/src/widgets/RemoteControlStatus.ts @@ -31,31 +31,54 @@ function getFormat(item: WidgetItem): RemoteFormat { return (FORMATS as readonly string[]).includes(f ?? '') ? (f as RemoteFormat) : DEFAULT_FORMAT; } +function canUseNerdFont(item: WidgetItem): boolean { + const format = getFormat(item); + return format === 'icon' || (format === 'icon-text' && !item.rawValue); +} + +function removeNerdFont(item: WidgetItem): WidgetItem { + const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {}; + void removedNerdFont; + + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; +} + function setFormat(item: WidgetItem, format: RemoteFormat): WidgetItem { + let updatedItem: WidgetItem; + if (format === DEFAULT_FORMAT) { const { format: removedFormat, ...restMetadata } = item.metadata ?? {}; void removedFormat; - return { + updatedItem = { ...item, metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined }; + } else { + updatedItem = { + ...item, + metadata: { + ...(item.metadata ?? {}), + format + } + }; } - return { - ...item, - metadata: { - ...(item.metadata ?? {}), - format - } - }; + return canUseNerdFont(updatedItem) ? updatedItem : removeNerdFont(updatedItem); } function isNerdFontEnabled(item: WidgetItem): boolean { - return item.metadata?.[NERD_FONT_METADATA_KEY] === 'true'; + return canUseNerdFont(item) && item.metadata?.[NERD_FONT_METADATA_KEY] === 'true'; } function toggleNerdFont(item: WidgetItem): WidgetItem { + if (!canUseNerdFont(item)) { + return removeNerdFont(item); + } + if (!isNerdFontEnabled(item)) { return { ...item, @@ -66,16 +89,10 @@ function toggleNerdFont(item: WidgetItem): WidgetItem { }; } - const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {}; - void removedNerdFont; - - return { - ...item, - metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined - }; + return removeNerdFont(item); } -function formatStatus(enabled: boolean, format: RemoteFormat, nerdFont: boolean): string { +function formatStatus(enabled: boolean, format: RemoteFormat, nerdFont: boolean, rawValue: boolean): string { const stateText = enabled ? 'on' : 'off'; const stateDot = enabled ? STATE_DOT_ON : STATE_DOT_OFF; const icon = nerdFont @@ -84,17 +101,17 @@ function formatStatus(enabled: boolean, format: RemoteFormat, nerdFont: boolean) switch (format) { case 'icon': - return nerdFont ? icon : `${icon} ${stateDot}`; + return nerdFont ? icon : (rawValue ? stateDot : `${icon} ${stateDot}`); case 'icon-text': - return `${icon} ${stateText}`; + return rawValue ? stateText : `${icon} ${stateText}`; case 'text': return stateText; case 'word': - return `remote ${stateText}`; + return rawValue ? stateText : `remote ${stateText}`; case 'label-check': - return `remote ${enabled ? CHECK_EMOJI : CROSS_EMOJI}`; + return rawValue ? (enabled ? CHECK_EMOJI : CROSS_EMOJI) : `remote ${enabled ? CHECK_EMOJI : CROSS_EMOJI}`; case 'label-mark': - return `remote ${enabled ? CHECK_MARK : CROSS_MARK}`; + return rawValue ? (enabled ? CHECK_MARK : CROSS_MARK) : `remote ${enabled ? CHECK_MARK : CROSS_MARK}`; } } @@ -136,10 +153,7 @@ export class RemoteControlStatusWidget implements Widget { const nerdFont = isNerdFontEnabled(item); if (context.isPreview) { - if (item.rawValue) { - return 'on'; - } - return formatStatus(true, format, nerdFont); + return formatStatus(true, format, nerdFont, item.rawValue ?? false); } const status = getRemoteControlStatus(context.data?.session_id); @@ -147,18 +161,17 @@ export class RemoteControlStatusWidget implements Widget { return null; } - if (item.rawValue) { - return status.enabled ? 'on' : 'off'; - } - - return formatStatus(status.enabled, format, nerdFont); + return formatStatus(status.enabled, format, nerdFont, item.rawValue ?? false); } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }, - { key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION } + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + const keybinds: CustomKeybind[] = [ + { key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION } ]; + if (item === undefined || canUseNerdFont(item)) { + keybinds.push({ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION }); + } + return keybinds; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/SandboxStatus.ts b/src/widgets/SandboxStatus.ts index 7c104be0..c6fdda68 100644 --- a/src/widgets/SandboxStatus.ts +++ b/src/widgets/SandboxStatus.ts @@ -13,7 +13,7 @@ const DOT_OFF = '○'; const LOCK_NERD_FONT = ''; const UNLOCK_NERD_FONT = ''; -const FORMATS = ['glyph', 'text', 'word', 'bare'] as const; +const FORMATS = ['glyph', 'text', 'word'] as const; type SandboxFormat = typeof FORMATS[number]; const DEFAULT_FORMAT: SandboxFormat = 'glyph'; @@ -26,31 +26,53 @@ function getFormat(item: WidgetItem): SandboxFormat { return (FORMATS as readonly string[]).includes(f ?? '') ? (f as SandboxFormat) : DEFAULT_FORMAT; } +function canUseNerdFont(item: WidgetItem): boolean { + return getFormat(item) === 'glyph'; +} + +function removeNerdFont(item: WidgetItem): WidgetItem { + const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {}; + void removedNerdFont; + + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; +} + function setFormat(item: WidgetItem, format: SandboxFormat): WidgetItem { + let updatedItem: WidgetItem; + if (format === DEFAULT_FORMAT) { const { format: removedFormat, ...restMetadata } = item.metadata ?? {}; void removedFormat; - return { + updatedItem = { ...item, metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined }; + } else { + updatedItem = { + ...item, + metadata: { + ...(item.metadata ?? {}), + format + } + }; } - return { - ...item, - metadata: { - ...(item.metadata ?? {}), - format - } - }; + return canUseNerdFont(updatedItem) ? updatedItem : removeNerdFont(updatedItem); } function isNerdFontEnabled(item: WidgetItem): boolean { - return item.metadata?.[NERD_FONT_METADATA_KEY] === 'true'; + return canUseNerdFont(item) && item.metadata?.[NERD_FONT_METADATA_KEY] === 'true'; } function toggleNerdFont(item: WidgetItem): WidgetItem { + if (!canUseNerdFont(item)) { + return removeNerdFont(item); + } + if (!isNerdFontEnabled(item)) { return { ...item, @@ -61,16 +83,10 @@ function toggleNerdFont(item: WidgetItem): WidgetItem { }; } - const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {}; - void removedNerdFont; - - return { - ...item, - metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined - }; + return removeNerdFont(item); } -function formatStatus(enabled: boolean, format: SandboxFormat, nerdFont: boolean): string { +function formatStatus(enabled: boolean, format: SandboxFormat, nerdFont: boolean, rawValue: boolean): string { const stateText = enabled ? 'ON' : 'OFF'; const glyph = nerdFont ? (enabled ? LOCK_NERD_FONT : UNLOCK_NERD_FONT) @@ -78,13 +94,11 @@ function formatStatus(enabled: boolean, format: SandboxFormat, nerdFont: boolean switch (format) { case 'glyph': - return `SB: ${glyph}`; + return rawValue ? glyph : `SB: ${glyph}`; case 'text': - return `SB: ${stateText}`; + return rawValue ? stateText : `SB: ${stateText}`; case 'word': - return `Sandbox: ${stateText}`; - case 'bare': - return glyph; + return rawValue ? stateText : `Sandbox: ${stateText}`; } } @@ -142,10 +156,7 @@ export class SandboxStatusWidget implements Widget { const nerdFont = isNerdFontEnabled(item); if (context.isPreview) { - if (item.rawValue) { - return 'on'; - } - return formatStatus(true, format, nerdFont); + return formatStatus(true, format, nerdFont, item.rawValue ?? false); } const config = getSandboxConfig(resolveSandboxConfigCwd(context)); @@ -153,18 +164,17 @@ export class SandboxStatusWidget implements Widget { return null; } - if (item.rawValue) { - return config.enabled ? 'on' : 'off'; - } - - return formatStatus(config.enabled, format, nerdFont); + return formatStatus(config.enabled, format, nerdFont, item.rawValue ?? false); } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }, - { key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION } + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + const keybinds: CustomKeybind[] = [ + { key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION } ]; + if (item === undefined || canUseNerdFont(item)) { + keybinds.push({ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION }); + } + return keybinds; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/VimMode.ts b/src/widgets/VimMode.ts index e36dbc20..b2fb471f 100644 --- a/src/widgets/VimMode.ts +++ b/src/widgets/VimMode.ts @@ -23,31 +23,54 @@ function getFormat(item: WidgetItem): VimFormat { return (FORMATS as readonly string[]).includes(f ?? '') ? (f as VimFormat) : DEFAULT_FORMAT; } +function canUseNerdFont(item: WidgetItem): boolean { + const format = getFormat(item); + return format === 'icon-dash-letter' || format === 'icon-letter' || format === 'icon'; +} + +function removeNerdFont(item: WidgetItem): WidgetItem { + const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {}; + void removedNerdFont; + + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; +} + function setFormat(item: WidgetItem, format: VimFormat): WidgetItem { + let updatedItem: WidgetItem; + if (format === DEFAULT_FORMAT) { const { format: removedFormat, ...restMetadata } = item.metadata ?? {}; void removedFormat; - return { + updatedItem = { ...item, metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined }; + } else { + updatedItem = { + ...item, + metadata: { + ...(item.metadata ?? {}), + format + } + }; } - return { - ...item, - metadata: { - ...(item.metadata ?? {}), - format - } - }; + return canUseNerdFont(updatedItem) ? updatedItem : removeNerdFont(updatedItem); } function isNerdFontEnabled(item: WidgetItem): boolean { - return item.metadata?.[NERD_FONT_METADATA_KEY] === 'true'; + return canUseNerdFont(item) && item.metadata?.[NERD_FONT_METADATA_KEY] === 'true'; } function toggleNerdFont(item: WidgetItem): WidgetItem { + if (!canUseNerdFont(item)) { + return removeNerdFont(item); + } + if (!isNerdFontEnabled(item)) { return { ...item, @@ -58,13 +81,7 @@ function toggleNerdFont(item: WidgetItem): WidgetItem { }; } - const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {}; - void removedNerdFont; - - return { - ...item, - metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined - }; + return removeNerdFont(item); } function formatMode(mode: string, format: VimFormat, icon: string): string { @@ -125,11 +142,14 @@ export class VimModeWidget implements Widget { return formatMode(mode, format, icon); } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }, - { key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION } + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + const keybinds: CustomKeybind[] = [ + { key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION } ]; + if (item === undefined || canUseNerdFont(item)) { + keybinds.push({ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION }); + } + return keybinds; } supportsRawValue(): boolean { return false; } diff --git a/src/widgets/VoiceStatus.ts b/src/widgets/VoiceStatus.ts index b74c8734..92117f9d 100644 --- a/src/widgets/VoiceStatus.ts +++ b/src/widgets/VoiceStatus.ts @@ -27,31 +27,54 @@ function getFormat(item: WidgetItem): VoiceFormat { return (FORMATS as readonly string[]).includes(f ?? '') ? (f as VoiceFormat) : DEFAULT_FORMAT; } +function canUseNerdFont(item: WidgetItem): boolean { + const format = getFormat(item); + return format === 'icon' || (format === 'icon-text' && !item.rawValue); +} + +function removeNerdFont(item: WidgetItem): WidgetItem { + const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {}; + void removedNerdFont; + + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; +} + function setFormat(item: WidgetItem, format: VoiceFormat): WidgetItem { + let updatedItem: WidgetItem; + if (format === DEFAULT_FORMAT) { const { format: removedFormat, ...restMetadata } = item.metadata ?? {}; void removedFormat; - return { + updatedItem = { ...item, metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined }; + } else { + updatedItem = { + ...item, + metadata: { + ...(item.metadata ?? {}), + format + } + }; } - return { - ...item, - metadata: { - ...(item.metadata ?? {}), - format - } - }; + return canUseNerdFont(updatedItem) ? updatedItem : removeNerdFont(updatedItem); } function isNerdFontEnabled(item: WidgetItem): boolean { - return item.metadata?.[NERD_FONT_METADATA_KEY] === 'true'; + return canUseNerdFont(item) && item.metadata?.[NERD_FONT_METADATA_KEY] === 'true'; } function toggleNerdFont(item: WidgetItem): WidgetItem { + if (!canUseNerdFont(item)) { + return removeNerdFont(item); + } + if (!isNerdFontEnabled(item)) { return { ...item, @@ -62,16 +85,10 @@ function toggleNerdFont(item: WidgetItem): WidgetItem { }; } - const { [NERD_FONT_METADATA_KEY]: removedNerdFont, ...restMetadata } = item.metadata ?? {}; - void removedNerdFont; - - return { - ...item, - metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined - }; + return removeNerdFont(item); } -function formatStatus(enabled: boolean, format: VoiceFormat, nerdFont: boolean): string { +function formatStatus(enabled: boolean, format: VoiceFormat, nerdFont: boolean, rawValue: boolean): string { const stateText = enabled ? 'on' : 'off'; const stateDot = enabled ? STATE_DOT_ON : STATE_DOT_OFF; const icon = nerdFont @@ -80,13 +97,13 @@ function formatStatus(enabled: boolean, format: VoiceFormat, nerdFont: boolean): switch (format) { case 'icon': - return nerdFont ? icon : `${icon} ${stateDot}`; + return nerdFont ? icon : (rawValue ? stateDot : `${icon} ${stateDot}`); case 'icon-text': - return `${icon} ${stateText}`; + return rawValue ? stateText : `${icon} ${stateText}`; case 'text': return stateText; case 'word': - return `voice ${stateText}`; + return rawValue ? stateText : `voice ${stateText}`; } } @@ -138,10 +155,7 @@ export class VoiceStatusWidget implements Widget { const nerdFont = isNerdFontEnabled(item); if (context.isPreview) { - if (item.rawValue) { - return 'on'; - } - return formatStatus(true, format, nerdFont); + return formatStatus(true, format, nerdFont, item.rawValue ?? false); } const config = getVoiceConfig(resolveVoiceConfigCwd(context)); @@ -149,18 +163,17 @@ export class VoiceStatusWidget implements Widget { return null; } - if (item.rawValue) { - return config.enabled ? 'on' : 'off'; - } - - return formatStatus(config.enabled, format, nerdFont); + return formatStatus(config.enabled, format, nerdFont, item.rawValue ?? false); } - getCustomKeybinds(): CustomKeybind[] { - return [ - { key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }, - { key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION } + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + const keybinds: CustomKeybind[] = [ + { key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION } ]; + if (item === undefined || canUseNerdFont(item)) { + keybinds.push({ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION }); + } + return keybinds; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/__tests__/RemoteControlStatus.test.ts b/src/widgets/__tests__/RemoteControlStatus.test.ts index 936c26f3..97ff9131 100644 --- a/src/widgets/__tests__/RemoteControlStatus.test.ts +++ b/src/widgets/__tests__/RemoteControlStatus.test.ts @@ -66,6 +66,25 @@ describe('RemoteControlStatusWidget', () => { ]); }); + it('exposes the Nerd Font keybind only when its icon is visible', () => { + const widget = new RemoteControlStatusWidget(); + const nerdFontKeybind = { key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' }; + + expect(widget.getCustomKeybinds(ITEM)).toContainEqual(nerdFontKeybind); + expect(widget.getCustomKeybinds({ ...ITEM, rawValue: true })).toContainEqual(nerdFontKeybind); + expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format: 'icon-text' } })) + .toContainEqual(nerdFontKeybind); + expect(widget.getCustomKeybinds({ + ...ITEM, + rawValue: true, + metadata: { format: 'icon-text' } + })).not.toContainEqual(nerdFontKeybind); + for (const format of ['text', 'word', 'label-check', 'label-mark']) { + expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format } })) + .not.toContainEqual(nerdFontKeybind); + } + }); + it('defaults to icon in the editor display', () => { expect(new RemoteControlStatusWidget().getEditorDisplay(ITEM)).toEqual({ displayText: 'Remote Control Status', @@ -76,13 +95,60 @@ describe('RemoteControlStatusWidget', () => { it('shows the configured format and nerd font in the editor display', () => { expect(new RemoteControlStatusWidget().getEditorDisplay({ ...ITEM, - metadata: { format: 'word', nerdFont: 'true' } + metadata: { format: 'icon-text', nerdFont: 'true' } })).toEqual({ displayText: 'Remote Control Status', - modifierText: '(word, nerd font)' + modifierText: '(icon-text, nerd font)' }); }); + it('hides stale Nerd Font metadata when raw or non-icon modes remove its icon', () => { + const widget = new RemoteControlStatusWidget(); + + expect(widget.getEditorDisplay({ + ...ITEM, + rawValue: true, + metadata: { format: 'icon-text', nerdFont: 'true' } + }).modifierText).toBe('(icon-text)'); + expect(widget.getEditorDisplay({ + ...ITEM, + metadata: { format: 'label-check', nerdFont: 'true' } + }).modifierText).toBe('(label-check)'); + }); + + it('keeps Nerd Font through icon formats and clears it before text formats', () => { + const widget = new RemoteControlStatusWidget(); + const item: WidgetItem = { ...ITEM, metadata: { nerdFont: 'true' } }; + const iconText = widget.handleEditorAction('cycle-format', item); + const text = widget.handleEditorAction('cycle-format', iconText ?? ITEM); + + expect(iconText?.metadata).toEqual({ format: 'icon-text', nerdFont: 'true' }); + expect(text?.metadata).toEqual({ format: 'text' }); + }); + + it('clears Nerd Font when raw mode makes icon-text text-only', () => { + const widget = new RemoteControlStatusWidget(); + const item: WidgetItem = { ...ITEM, rawValue: true, metadata: { nerdFont: 'true' } }; + + expect(widget.handleEditorAction('cycle-format', item)?.metadata) + .toEqual({ format: 'icon-text' }); + }); + + it('does not toggle Nerd Font when no configurable icon is visible', () => { + const widget = new RemoteControlStatusWidget(); + const textItem: WidgetItem = { ...ITEM, metadata: { format: 'text' } }; + const rawIconTextItem: WidgetItem = { + ...ITEM, + rawValue: true, + metadata: { format: 'icon-text', nerdFont: 'true' } + }; + + expect(widget.handleEditorAction('toggle-nerd-font', textItem)?.metadata) + .toEqual({ format: 'text' }); + expect(widget.handleEditorAction('toggle-nerd-font', rawIconTextItem)?.metadata) + .toEqual({ format: 'icon-text' }); + }); + it('cycles icon -> icon-text -> text -> word -> label-check -> label-mark -> icon', () => { const widget = new RemoteControlStatusWidget(); const iconText = widget.handleEditorAction('cycle-format', ITEM); @@ -246,20 +312,65 @@ describe('RemoteControlStatusWidget', () => { describe('render() - raw value', () => { const RAW_ITEM: WidgetItem = { ...ITEM, rawValue: true }; - - it('returns "on" when ON', () => { - vi.spyOn(claudeSettings, 'getRemoteControlStatus').mockReturnValue({ enabled: true }); - expect(new RemoteControlStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('on'); - }); - - it('returns "off" when OFF', () => { - vi.spyOn(claudeSettings, 'getRemoteControlStatus').mockReturnValue({ enabled: false }); - expect(new RemoteControlStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('off'); - }); - - it('returns "on" in preview mode', () => { + const cases: { name: string; item: WidgetItem; off: string; on: string }[] = [ + { name: 'icon', item: RAW_ITEM, off: '○', on: '◉' }, + { + name: 'icon with Nerd Font', + item: { ...RAW_ITEM, metadata: { nerdFont: 'true' } }, + off: '\uF6AC', + on: '\uF1EB' + }, + { + name: 'icon-text', + item: { ...RAW_ITEM, metadata: { format: 'icon-text' } }, + off: 'off', + on: 'on' + }, + { + name: 'icon-text with Nerd Font', + item: { ...RAW_ITEM, metadata: { format: 'icon-text', nerdFont: 'true' } }, + off: 'off', + on: 'on' + }, + { + name: 'text', + item: { ...RAW_ITEM, metadata: { format: 'text' } }, + off: 'off', + on: 'on' + }, + { + name: 'word', + item: { ...RAW_ITEM, metadata: { format: 'word' } }, + off: 'off', + on: 'on' + }, + { + name: 'label-check', + item: { ...RAW_ITEM, metadata: { format: 'label-check' } }, + off: '❌', + on: '✅' + }, + { + name: 'label-mark', + item: { ...RAW_ITEM, metadata: { format: 'label-mark' } }, + off: '✗', + on: '✓' + } + ]; + + it.each(cases)('removes only the label from $name format', ({ item, off, on }) => { + const statusSpy = vi.spyOn(claudeSettings, 'getRemoteControlStatus'); + + statusSpy.mockReturnValue({ enabled: false }); + expect(new RemoteControlStatusWidget().render(item, makeContext(), DEFAULT_SETTINGS)).toBe(off); + + statusSpy.mockReturnValue({ enabled: true }); + expect(new RemoteControlStatusWidget().render(item, makeContext(), DEFAULT_SETTINGS)).toBe(on); + }); + + it('preserves the default icon format in preview mode', () => { expect(new RemoteControlStatusWidget().render(RAW_ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS)) - .toBe('on'); + .toBe('◉'); }); it('returns null when manifest lookup is null', () => { diff --git a/src/widgets/__tests__/SandboxStatus.test.ts b/src/widgets/__tests__/SandboxStatus.test.ts index 080e64fb..6c08fc6e 100644 --- a/src/widgets/__tests__/SandboxStatus.test.ts +++ b/src/widgets/__tests__/SandboxStatus.test.ts @@ -70,6 +70,18 @@ describe('SandboxStatusWidget', () => { ]); }); + it('exposes the Nerd Font keybind only for glyph format', () => { + const widget = new SandboxStatusWidget(); + const nerdFontKeybind = { key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' }; + + expect(widget.getCustomKeybinds(ITEM)).toContainEqual(nerdFontKeybind); + expect(widget.getCustomKeybinds({ ...ITEM, rawValue: true })).toContainEqual(nerdFontKeybind); + expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format: 'text' } })) + .not.toContainEqual(nerdFontKeybind); + expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format: 'word' } })) + .not.toContainEqual(nerdFontKeybind); + }); + it('defaults to glyph in the editor display', () => { expect(new SandboxStatusWidget().getEditorDisplay(ITEM)).toEqual({ displayText: 'Sandbox Status', @@ -78,28 +90,43 @@ describe('SandboxStatusWidget', () => { }); it('shows the configured format and nerd font in the editor display', () => { + expect(new SandboxStatusWidget().getEditorDisplay({ + ...ITEM, + metadata: { nerdFont: 'true' } + })).toEqual({ + displayText: 'Sandbox Status', + modifierText: '(glyph, nerd font)' + }); + }); + + it('hides stale Nerd Font metadata in text-only editor displays', () => { expect(new SandboxStatusWidget().getEditorDisplay({ ...ITEM, metadata: { format: 'word', nerdFont: 'true' } })).toEqual({ displayText: 'Sandbox Status', - modifierText: '(word, nerd font)' + modifierText: '(word)' }); }); - it('cycles glyph -> text -> word -> bare -> glyph', () => { + it('cycles glyph -> text -> word -> glyph', () => { const widget = new SandboxStatusWidget(); const text = widget.handleEditorAction('cycle-format', ITEM); const word = widget.handleEditorAction('cycle-format', text ?? ITEM); - const bare = widget.handleEditorAction('cycle-format', word ?? ITEM); - const back = widget.handleEditorAction('cycle-format', bare ?? ITEM); + const back = widget.handleEditorAction('cycle-format', word ?? ITEM); expect(text?.metadata?.format).toBe('text'); expect(word?.metadata?.format).toBe('word'); - expect(bare?.metadata?.format).toBe('bare'); expect(back?.metadata?.format).toBeUndefined(); }); + it('clears Nerd Font metadata when cycling to text format', () => { + const item: WidgetItem = { ...ITEM, metadata: { nerdFont: 'true' } }; + const text = new SandboxStatusWidget().handleEditorAction('cycle-format', item); + + expect(text?.metadata).toEqual({ format: 'text' }); + }); + it('toggles nerd font metadata on and off', () => { const widget = new SandboxStatusWidget(); const enabled = widget.handleEditorAction('toggle-nerd-font', ITEM); @@ -109,6 +136,20 @@ describe('SandboxStatusWidget', () => { expect(disabled?.metadata?.nerdFont).toBeUndefined(); }); + it('does not toggle Nerd Font in text-only formats', () => { + const widget = new SandboxStatusWidget(); + const textItem: WidgetItem = { ...ITEM, metadata: { format: 'text' } }; + const staleWordItem: WidgetItem = { + ...ITEM, + metadata: { format: 'word', nerdFont: 'true' } + }; + + expect(widget.handleEditorAction('toggle-nerd-font', textItem)?.metadata) + .toEqual({ format: 'text' }); + expect(widget.handleEditorAction('toggle-nerd-font', staleWordItem)?.metadata) + .toEqual({ format: 'word' }); + }); + it('returns null for unknown editor actions', () => { expect(new SandboxStatusWidget().handleEditorAction('unknown', ITEM)).toBeNull(); }); @@ -168,29 +209,6 @@ describe('SandboxStatusWidget', () => { }); }); - describe('render() - format bare (glyph only)', () => { - const FORMAT_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'bare' } }; - const FORMAT_NERD_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'bare', nerdFont: 'true' } }; - - it('renders ○ when OFF', () => { - vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false }); - expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('○'); - }); - - it('renders ● when ON', () => { - vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true }); - expect(new SandboxStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('●'); - }); - - it('renders only the configured Nerd Font glyph', () => { - vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValueOnce({ enabled: false }); - expect(new SandboxStatusWidget().render(FORMAT_NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(UNLOCK); - - vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValueOnce({ enabled: true }); - expect(new SandboxStatusWidget().render(FORMAT_NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(LOCK); - }); - }); - describe('render() - sandbox config cwd', () => { it('uses the project dir for project-local Claude settings', () => { const context = makeContext({ @@ -245,32 +263,40 @@ describe('SandboxStatusWidget', () => { describe('render() - raw value', () => { const RAW_ITEM: WidgetItem = { ...ITEM, rawValue: true }; - - it('returns "on" when ON', () => { - vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: true }); - expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('on'); - }); - - it('returns "off" when OFF', () => { - vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue({ enabled: false }); - expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('off'); - }); - - it('returns the semantic value regardless of format and Nerd Font settings', () => { - const rawNerdItem: WidgetItem = { - ...RAW_ITEM, - metadata: { format: 'glyph', nerdFont: 'true' } - }; - - vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValueOnce({ enabled: false }); - expect(new SandboxStatusWidget().render(rawNerdItem, makeContext(), DEFAULT_SETTINGS)).toBe('off'); - - vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValueOnce({ enabled: true }); - expect(new SandboxStatusWidget().render(rawNerdItem, makeContext(), DEFAULT_SETTINGS)).toBe('on'); - }); - - it('returns "on" in preview mode', () => { - expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS)).toBe('on'); + const cases: { name: string; item: WidgetItem; off: string; on: string }[] = [ + { name: 'glyph', item: RAW_ITEM, off: '○', on: '●' }, + { + name: 'glyph with Nerd Font', + item: { ...RAW_ITEM, metadata: { nerdFont: 'true' } }, + off: UNLOCK, + on: LOCK + }, + { + name: 'text', + item: { ...RAW_ITEM, metadata: { format: 'text' } }, + off: 'OFF', + on: 'ON' + }, + { + name: 'word', + item: { ...RAW_ITEM, metadata: { format: 'word' } }, + off: 'OFF', + on: 'ON' + } + ]; + + it.each(cases)('removes only the label from $name format', ({ item, off, on }) => { + const configSpy = vi.spyOn(claudeSettings, 'getSandboxConfig'); + + configSpy.mockReturnValue({ enabled: false }); + expect(new SandboxStatusWidget().render(item, makeContext(), DEFAULT_SETTINGS)).toBe(off); + + configSpy.mockReturnValue({ enabled: true }); + expect(new SandboxStatusWidget().render(item, makeContext(), DEFAULT_SETTINGS)).toBe(on); + }); + + it('preserves the default glyph format in preview mode', () => { + expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS)).toBe('●'); }); it('returns null when config is null', () => { diff --git a/src/widgets/__tests__/VimMode.test.ts b/src/widgets/__tests__/VimMode.test.ts index bd220e4c..1d6f10e0 100644 --- a/src/widgets/__tests__/VimMode.test.ts +++ b/src/widgets/__tests__/VimMode.test.ts @@ -26,6 +26,20 @@ describe('VimModeWidget', () => { ]); }); + it('exposes the Nerd Font keybind only when an icon is visible', () => { + const widget = new VimModeWidget(); + const nerdFontKeybind = { key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' }; + + for (const format of ['icon-dash-letter', 'icon-letter', 'icon']) { + expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format } })) + .toContainEqual(nerdFontKeybind); + } + for (const format of ['letter', 'word']) { + expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format } })) + .not.toContainEqual(nerdFontKeybind); + } + }); + it('defaults to icon-dash-letter in the editor display', () => { expect(new VimModeWidget().getEditorDisplay(ITEM)).toEqual({ displayText: 'Vim Mode', @@ -43,6 +57,16 @@ describe('VimModeWidget', () => { }); }); + it('hides stale Nerd Font metadata in text-only editor displays', () => { + expect(new VimModeWidget().getEditorDisplay({ + ...ITEM, + metadata: { format: 'word', nerdFont: 'true' } + })).toEqual({ + displayText: 'Vim Mode', + modifierText: '(word)' + }); + }); + it('cycles icon-dash-letter -> icon-letter -> icon -> letter -> word -> icon-dash-letter', () => { const widget = new VimModeWidget(); const iconLetter = widget.handleEditorAction('cycle-format', ITEM); @@ -58,6 +82,18 @@ describe('VimModeWidget', () => { expect(iconDashLetter?.metadata?.format).toBeUndefined(); }); + it('keeps Nerd Font through icon formats and clears it before letter format', () => { + const widget = new VimModeWidget(); + const item: WidgetItem = { ...ITEM, metadata: { nerdFont: 'true' } }; + const iconLetter = widget.handleEditorAction('cycle-format', item); + const icon = widget.handleEditorAction('cycle-format', iconLetter ?? ITEM); + const letter = widget.handleEditorAction('cycle-format', icon ?? ITEM); + + expect(iconLetter?.metadata).toEqual({ format: 'icon-letter', nerdFont: 'true' }); + expect(icon?.metadata).toEqual({ format: 'icon', nerdFont: 'true' }); + expect(letter?.metadata).toEqual({ format: 'letter' }); + }); + it('toggles nerd font metadata on and off', () => { const widget = new VimModeWidget(); const enabled = widget.handleEditorAction('toggle-nerd-font', ITEM); @@ -66,6 +102,20 @@ describe('VimModeWidget', () => { expect(enabled?.metadata?.nerdFont).toBe('true'); expect(disabled?.metadata?.nerdFont).toBeUndefined(); }); + + it('does not toggle Nerd Font in text-only formats', () => { + const widget = new VimModeWidget(); + const letterItem: WidgetItem = { ...ITEM, metadata: { format: 'letter' } }; + const staleWordItem: WidgetItem = { + ...ITEM, + metadata: { format: 'word', nerdFont: 'true' } + }; + + expect(widget.handleEditorAction('toggle-nerd-font', letterItem)?.metadata) + .toEqual({ format: 'letter' }); + expect(widget.handleEditorAction('toggle-nerd-font', staleWordItem)?.metadata) + .toEqual({ format: 'word' }); + }); }); describe('metadata', () => { diff --git a/src/widgets/__tests__/VoiceStatus.test.ts b/src/widgets/__tests__/VoiceStatus.test.ts index 2ab2be26..d5712990 100644 --- a/src/widgets/__tests__/VoiceStatus.test.ts +++ b/src/widgets/__tests__/VoiceStatus.test.ts @@ -64,6 +64,25 @@ describe('VoiceStatusWidget', () => { ]); }); + it('exposes the Nerd Font keybind only when its icon is visible', () => { + const widget = new VoiceStatusWidget(); + const nerdFontKeybind = { key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' }; + + expect(widget.getCustomKeybinds(ITEM)).toContainEqual(nerdFontKeybind); + expect(widget.getCustomKeybinds({ ...ITEM, rawValue: true })).toContainEqual(nerdFontKeybind); + expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format: 'icon-text' } })) + .toContainEqual(nerdFontKeybind); + expect(widget.getCustomKeybinds({ + ...ITEM, + rawValue: true, + metadata: { format: 'icon-text' } + })).not.toContainEqual(nerdFontKeybind); + expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format: 'text' } })) + .not.toContainEqual(nerdFontKeybind); + expect(widget.getCustomKeybinds({ ...ITEM, metadata: { format: 'word' } })) + .not.toContainEqual(nerdFontKeybind); + }); + it('defaults to icon in the editor display', () => { expect(new VoiceStatusWidget().getEditorDisplay(ITEM)).toEqual({ displayText: 'Voice Status', @@ -74,13 +93,60 @@ describe('VoiceStatusWidget', () => { it('shows the configured format and nerd font in the editor display', () => { expect(new VoiceStatusWidget().getEditorDisplay({ ...ITEM, - metadata: { format: 'word', nerdFont: 'true' } + metadata: { format: 'icon-text', nerdFont: 'true' } })).toEqual({ displayText: 'Voice Status', - modifierText: '(word, nerd font)' + modifierText: '(icon-text, nerd font)' }); }); + it('hides stale Nerd Font metadata when raw or text-only modes remove the icon', () => { + const widget = new VoiceStatusWidget(); + + expect(widget.getEditorDisplay({ + ...ITEM, + rawValue: true, + metadata: { format: 'icon-text', nerdFont: 'true' } + }).modifierText).toBe('(icon-text)'); + expect(widget.getEditorDisplay({ + ...ITEM, + metadata: { format: 'word', nerdFont: 'true' } + }).modifierText).toBe('(word)'); + }); + + it('keeps Nerd Font through icon formats and clears it before text formats', () => { + const widget = new VoiceStatusWidget(); + const item: WidgetItem = { ...ITEM, metadata: { nerdFont: 'true' } }; + const iconText = widget.handleEditorAction('cycle-format', item); + const text = widget.handleEditorAction('cycle-format', iconText ?? ITEM); + + expect(iconText?.metadata).toEqual({ format: 'icon-text', nerdFont: 'true' }); + expect(text?.metadata).toEqual({ format: 'text' }); + }); + + it('clears Nerd Font when raw mode makes icon-text text-only', () => { + const widget = new VoiceStatusWidget(); + const item: WidgetItem = { ...ITEM, rawValue: true, metadata: { nerdFont: 'true' } }; + + expect(widget.handleEditorAction('cycle-format', item)?.metadata) + .toEqual({ format: 'icon-text' }); + }); + + it('does not toggle Nerd Font when no configurable icon is visible', () => { + const widget = new VoiceStatusWidget(); + const textItem: WidgetItem = { ...ITEM, metadata: { format: 'text' } }; + const rawIconTextItem: WidgetItem = { + ...ITEM, + rawValue: true, + metadata: { format: 'icon-text', nerdFont: 'true' } + }; + + expect(widget.handleEditorAction('toggle-nerd-font', textItem)?.metadata) + .toEqual({ format: 'text' }); + expect(widget.handleEditorAction('toggle-nerd-font', rawIconTextItem)?.metadata) + .toEqual({ format: 'icon-text' }); + }); + it('cycles icon -> icon-text -> text -> word -> icon', () => { const widget = new VoiceStatusWidget(); const iconText = widget.handleEditorAction('cycle-format', ITEM); @@ -240,19 +306,52 @@ describe('VoiceStatusWidget', () => { describe('render() - raw value', () => { const RAW_ITEM: WidgetItem = { ...ITEM, rawValue: true }; - - it('returns "on" when ON', () => { - vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: true }); - expect(new VoiceStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('on'); - }); - - it('returns "off" when OFF', () => { - vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: false }); - expect(new VoiceStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('off'); - }); - - it('returns "on" in preview mode', () => { - expect(new VoiceStatusWidget().render(RAW_ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS)).toBe('on'); + const cases: { name: string; item: WidgetItem; off: string; on: string }[] = [ + { name: 'icon', item: RAW_ITEM, off: '○', on: '◉' }, + { + name: 'icon with Nerd Font', + item: { ...RAW_ITEM, metadata: { nerdFont: 'true' } }, + off: '', + on: '' + }, + { + name: 'icon-text', + item: { ...RAW_ITEM, metadata: { format: 'icon-text' } }, + off: 'off', + on: 'on' + }, + { + name: 'icon-text with Nerd Font', + item: { ...RAW_ITEM, metadata: { format: 'icon-text', nerdFont: 'true' } }, + off: 'off', + on: 'on' + }, + { + name: 'text', + item: { ...RAW_ITEM, metadata: { format: 'text' } }, + off: 'off', + on: 'on' + }, + { + name: 'word', + item: { ...RAW_ITEM, metadata: { format: 'word' } }, + off: 'off', + on: 'on' + } + ]; + + it.each(cases)('removes only the label from $name format', ({ item, off, on }) => { + const configSpy = vi.spyOn(claudeSettings, 'getVoiceConfig'); + + configSpy.mockReturnValue({ enabled: false }); + expect(new VoiceStatusWidget().render(item, makeContext(), DEFAULT_SETTINGS)).toBe(off); + + configSpy.mockReturnValue({ enabled: true }); + expect(new VoiceStatusWidget().render(item, makeContext(), DEFAULT_SETTINGS)).toBe(on); + }); + + it('preserves the default icon format in preview mode', () => { + expect(new VoiceStatusWidget().render(RAW_ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS)).toBe('◉'); }); it('returns null when config is null', () => {