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 72cd76a4..db6b00e8 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -79,6 +79,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/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 new file mode 100644 index 00000000..c6fdda68 --- /dev/null +++ b/src/widgets/SandboxStatus.ts @@ -0,0 +1,182 @@ +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'] 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 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; + + updatedItem = { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; + } else { + updatedItem = { + ...item, + metadata: { + ...(item.metadata ?? {}), + format + } + }; + } + + return canUseNerdFont(updatedItem) ? updatedItem : removeNerdFont(updatedItem); +} + +function isNerdFontEnabled(item: WidgetItem): boolean { + 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, + metadata: { + ...(item.metadata ?? {}), + [NERD_FONT_METADATA_KEY]: 'true' + } + }; + } + + return removeNerdFont(item); +} + +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) + : (enabled ? DOT_ON : DOT_OFF); + + switch (format) { + case 'glyph': + return rawValue ? glyph : `SB: ${glyph}`; + case 'text': + return rawValue ? stateText : `SB: ${stateText}`; + case 'word': + return rawValue ? stateText : `Sandbox: ${stateText}`; + } +} + +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', + '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'; } + + 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) { + return formatStatus(true, format, nerdFont, item.rawValue ?? false); + } + + const config = getSandboxConfig(resolveSandboxConfigCwd(context)); + if (config === null) { + return null; + } + + return formatStatus(config.enabled, format, nerdFont, item.rawValue ?? false); + } + + 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; } + supportsColors(_item: WidgetItem): 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 new file mode 100644 index 00000000..6c08fc6e --- /dev/null +++ b/src/widgets/__tests__/SandboxStatus.test.ts @@ -0,0 +1,307 @@ +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', + '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', () => { + 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('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', + modifierText: '(glyph)' + }); + }); + + 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)' + }); + }); + + 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 back = widget.handleEditorAction('cycle-format', word ?? ITEM); + + expect(text?.metadata?.format).toBe('text'); + expect(word?.metadata?.format).toBe('word'); + 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); + const disabled = widget.handleEditorAction('toggle-nerd-font', enabled ?? ITEM); + + expect(enabled?.metadata?.nerdFont).toBe('true'); + 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(); + }); + }); + + 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() - 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 }; + 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', () => { + vi.spyOn(claudeSettings, 'getSandboxConfig').mockReturnValue(null); + expect(new SandboxStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBeNull(); + }); + }); +}); 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', () => { diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 6f703289..d6905fd7 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -80,5 +80,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';