diff --git a/AGENTS.md b/AGENTS.md index 5672b1c1..663d9c0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,7 @@ All widgets must implement: - `isKnownWidgetType()`: Validates if a type is registered **Available Widgets:** -- Model, Version, OutputStyle - Claude Code metadata display +- Model, Version, OutputStyle, VoiceStatus - Claude Code metadata display - GitBranch, GitChanges, GitInsertions, GitDeletions, GitWorktree - Git repository status - TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics - ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models) diff --git a/src/utils/__tests__/claude-settings.test.ts b/src/utils/__tests__/claude-settings.test.ts index 59efb0f8..e313589e 100644 --- a/src/utils/__tests__/claude-settings.test.ts +++ b/src/utils/__tests__/claude-settings.test.ts @@ -20,6 +20,7 @@ import { getClaudeSettingsPath, getExistingStatusLine, getRefreshInterval, + getVoiceConfig, installStatusLine, isClaudeCodeVersionAtLeast, isInstalled, @@ -563,3 +564,144 @@ describe('isClaudeCodeVersionAtLeast', () => { expect(isClaudeCodeVersionAtLeast('2.1.97')).toBe(false); }); }); + +describe('getVoiceConfig', () => { + let testProjectDir = ''; + + 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(testProjectDir, '.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(testProjectDir, '.claude', 'settings.local.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, content, 'utf-8'); + } + + beforeEach(() => { + testProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-voice-project-')); + }); + + afterEach(() => { + if (testProjectDir) { + fs.rmSync(testProjectDir, { recursive: true, force: true }); + } + }); + + describe('user-global layer only', () => { + it('returns null when no candidate file exists', () => { + expect(getVoiceConfig(testProjectDir)).toBeNull(); + }); + + it('returns { enabled: false } when settings.json has no voice field', () => { + writeRawClaudeSettings(JSON.stringify({ effortLevel: 'high' })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false }); + }); + + it('returns { enabled: true } when voice.enabled is true', () => { + writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true, mode: 'hold' } })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true }); + }); + + it('returns { enabled: false } when voice.enabled is false', () => { + writeRawClaudeSettings(JSON.stringify({ voice: { enabled: false, mode: 'hold' } })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false }); + }); + + it('returns { enabled: false } when voice.enabled is missing but voice exists', () => { + writeRawClaudeSettings(JSON.stringify({ voice: { mode: 'hold' } })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false }); + }); + + it('treats malformed JSON as "no override"', () => { + // Malformed file is silently skipped; with no other layers, no override is found + // and we fall back to the Claude Code default of `enabled: false`. The file's mere + // existence still flips the overall result away from `null`. + writeRawClaudeSettings('{ this is not json'); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false }); + }); + + it('treats unexpected voice shape as "no override"', () => { + // voice is a string instead of an object — Zod schema fails, no override extracted. + writeRawClaudeSettings(JSON.stringify({ voice: 'enabled' })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false }); + }); + + it('respects CLAUDE_CONFIG_DIR env var', () => { + writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } })); + expect(getClaudeSettingsPath().startsWith(testClaudeConfigDir)).toBe(true); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true }); + }); + }); + + describe('layer precedence', () => { + it('user-local overrides user-global', () => { + writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } })); + writeRawUserLocalSettings(JSON.stringify({ voice: { enabled: false } })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false }); + }); + + it('project overrides user-local', () => { + writeRawClaudeSettings(JSON.stringify({ voice: { enabled: false } })); + writeRawUserLocalSettings(JSON.stringify({ voice: { enabled: false } })); + writeRawProjectSettings(JSON.stringify({ voice: { enabled: true } })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true }); + }); + + it('project-local overrides project', () => { + writeRawProjectSettings(JSON.stringify({ voice: { enabled: true } })); + writeRawProjectLocalSettings(JSON.stringify({ voice: { enabled: false } })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false }); + }); + + it('layer without voice.enabled does not override a lower layer', () => { + // user-global sets enabled:true, project layer has voice but no `enabled` field + // → project should NOT clobber the user-global value. + writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } })); + writeRawProjectSettings(JSON.stringify({ voice: { mode: 'hold' } })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true }); + }); + + it('malformed higher-priority layer does not clobber a lower layer', () => { + writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } })); + writeRawProjectLocalSettings('{ corrupt'); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true }); + }); + + it('returns { enabled: false } when only project layer exists with voice but no enabled', () => { + writeRawProjectSettings(JSON.stringify({ voice: { mode: 'hold' } })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false }); + }); + + it('returns null when no candidate file exists in any layer', () => { + // testProjectDir is freshly created and empty, testClaudeConfigDir too + expect(getVoiceConfig(testProjectDir)).toBeNull(); + }); + + it('full stack: project-local wins over all three lower layers', () => { + writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } })); + writeRawUserLocalSettings(JSON.stringify({ voice: { enabled: false } })); + writeRawProjectSettings(JSON.stringify({ voice: { enabled: true } })); + writeRawProjectLocalSettings(JSON.stringify({ voice: { enabled: false } })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: false }); + }); + + it('falls through layers without voice.enabled until it finds a defined value', () => { + // user-global defines enabled:true; the three higher-priority layers exist but + // contribute nothing usable (no voice field, only mode, or unrelated keys). + writeRawClaudeSettings(JSON.stringify({ voice: { enabled: true } })); + writeRawUserLocalSettings(JSON.stringify({ effortLevel: 'high' })); + writeRawProjectSettings(JSON.stringify({ voice: { mode: 'hold' } })); + writeRawProjectLocalSettings(JSON.stringify({ effortLevel: 'low' })); + expect(getVoiceConfig(testProjectDir)).toEqual({ enabled: true }); + }); + }); +}); diff --git a/src/utils/claude-settings.ts b/src/utils/claude-settings.ts index 944ae9fe..67b53dec 100644 --- a/src/utils/claude-settings.ts +++ b/src/utils/claude-settings.ts @@ -2,6 +2,7 @@ import { execSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { z } from 'zod'; import type { ClaudeSettings } from '../types/ClaudeSettings'; import { @@ -374,3 +375,83 @@ export async function setRefreshInterval(interval: number | null): Promise await saveClaudeSettings(settings); } + +const VoiceConfigSchema = z.object({ enabled: z.boolean().optional() }); + +function getVoiceConfigCandidatePathsByPriority(cwd: string): string[] { + const userDir = getClaudeConfigDir(); + const projectDir = path.join(cwd, '.claude'); + // Highest priority first — `getVoiceConfig` returns on the first defined override. + const candidates = [ + path.join(projectDir, 'settings.local.json'), + path.join(projectDir, 'settings.json'), + path.join(userDir, 'settings.local.json'), + path.join(userDir, 'settings.json') + ]; + return Array.from(new Set(candidates)); +} + +interface VoiceLayerResult { + fileExisted: boolean; + enabled: boolean | undefined; +} + +function tryReadVoiceLayer(filePath: string): VoiceLayerResult { + 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 { voice?: unknown }; + const voice = parsed.voice; + if (voice === undefined || voice === null) { + return { fileExisted: true, enabled: undefined }; + } + const result = VoiceConfigSchema.safeParse(voice); + 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 `voice.enabled` setting from Claude Code's layered configuration. + * + * Claude Code merges settings from up to four files, in increasing order of priority: + * 1. /settings.json + * 2. /settings.local.json + * 3. /.claude/settings.json + * 4. /.claude/settings.local.json + * + * The user dir respects `CLAUDE_CONFIG_DIR` (fallback `~/.claude`). + * Lookup walks layers from highest priority to lowest and returns on the first + * one that defines `voice.enabled` — so the typical case (one file with the field) + * costs a single read instead of four. + * + * - Returns `null` if no candidate file exists (Claude Code never initialised). + * - Returns `{ enabled: false }` if files exist but none defines `voice.enabled` + * (Claude Code's default — `/voice` not yet touched). + * - Returns `{ enabled: }` reflecting the highest-priority override otherwise. + * + * The `voice.mode` (`hold` / `toggle`) field is not exposed; widgets only need the on/off state. + */ +export function getVoiceConfig(cwd: string = process.cwd()): { enabled: boolean } | null { + let anyFileExisted = false; + for (const filePath of getVoiceConfigCandidatePathsByPriority(cwd)) { + const layer = tryReadVoiceLayer(filePath); + if (layer.fileExisted) { + anyFileExisted = true; + } + if (layer.enabled !== undefined) { + return { enabled: layer.enabled }; + } + } + return anyFileExisted ? { enabled: false } : null; +} diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index ac8cb87f..d55e48be 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -85,6 +85,7 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [ { type: 'skills', create: () => new widgets.SkillsWidget() }, { type: 'thinking-effort', create: () => new widgets.ThinkingEffortWidget() }, { type: 'vim-mode', create: () => new widgets.VimModeWidget() }, + { type: 'voice-status', create: () => new widgets.VoiceStatusWidget() }, { type: 'worktree-mode', create: () => new widgets.GitWorktreeModeWidget() }, { type: 'worktree-name', create: () => new widgets.GitWorktreeNameWidget() }, { type: 'worktree-branch', create: () => new widgets.GitWorktreeBranchWidget() }, diff --git a/src/widgets/VoiceStatus.ts b/src/widgets/VoiceStatus.ts new file mode 100644 index 00000000..b74c8734 --- /dev/null +++ b/src/widgets/VoiceStatus.ts @@ -0,0 +1,168 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { getVoiceConfig } from '../utils/claude-settings'; + +const MIC_EMOJI = '🎤'; +const MIC_NERD_FONT = ''; +const MIC_SLASH_NERD_FONT = ''; +const STATE_DOT_OFF = '○'; +const STATE_DOT_ON = '◉'; + +const FORMATS = ['icon', 'icon-text', 'text', 'word'] as const; +type VoiceFormat = typeof FORMATS[number]; + +const DEFAULT_FORMAT: VoiceFormat = 'icon'; +const CYCLE_FORMAT_ACTION = 'cycle-format'; +const TOGGLE_NERD_FONT_ACTION = 'toggle-nerd-font'; +const NERD_FONT_METADATA_KEY = 'nerdFont'; + +function getFormat(item: WidgetItem): VoiceFormat { + const f = item.metadata?.format; + return (FORMATS as readonly string[]).includes(f ?? '') ? (f as VoiceFormat) : DEFAULT_FORMAT; +} + +function setFormat(item: WidgetItem, format: VoiceFormat): 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: VoiceFormat, nerdFont: boolean): string { + const stateText = enabled ? 'on' : 'off'; + const stateDot = enabled ? STATE_DOT_ON : STATE_DOT_OFF; + const icon = nerdFont + ? (enabled ? MIC_NERD_FONT : MIC_SLASH_NERD_FONT) + : MIC_EMOJI; + + switch (format) { + case 'icon': + return nerdFont ? icon : `${icon} ${stateDot}`; + case 'icon-text': + return `${icon} ${stateText}`; + case 'text': + return stateText; + case 'word': + return `voice ${stateText}`; + } +} + +function resolveVoiceConfigCwd(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 VoiceStatusWidget implements Widget { + getDefaultColor(): string { return 'magenta'; } + getDescription(): string { return 'Shows whether Claude Code voice input is enabled'; } + getDisplayName(): string { return 'Voice 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 = getVoiceConfig(resolveVoiceConfigCwd(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__/VoiceStatus.test.ts b/src/widgets/__tests__/VoiceStatus.test.ts new file mode 100644 index 00000000..2ab2be26 --- /dev/null +++ b/src/widgets/__tests__/VoiceStatus.test.ts @@ -0,0 +1,263 @@ +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 { VoiceStatusWidget } from '../VoiceStatus'; + +const ITEM: WidgetItem = { id: 'voice-status', type: 'voice-status' }; + +function makeContext(overrides: Partial = {}): RenderContext { + return { ...overrides }; +} + +beforeEach(() => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: false }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('VoiceStatusWidget', () => { + describe('metadata', () => { + it('has correct display name', () => { + expect(new VoiceStatusWidget().getDisplayName()).toBe('Voice Status'); + }); + + it('has correct description', () => { + expect(new VoiceStatusWidget().getDescription()).toBe('Shows whether Claude Code voice input is enabled'); + }); + + it('has correct category', () => { + expect(new VoiceStatusWidget().getCategory()).toBe('Core'); + }); + + it('has magenta default color', () => { + expect(new VoiceStatusWidget().getDefaultColor()).toBe('magenta'); + }); + + it('supports raw value', () => { + expect(new VoiceStatusWidget().supportsRawValue()).toBe(true); + }); + + it('supports colors', () => { + expect(new VoiceStatusWidget().supportsColors(ITEM)).toBe(true); + }); + }); + + describe('editor configuration', () => { + it('exposes f and n keybinds', () => { + expect(new VoiceStatusWidget().getCustomKeybinds()).toEqual([ + { key: 'f', label: '(f)ormat', action: 'cycle-format' }, + { key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' } + ]); + }); + + it('defaults to icon in the editor display', () => { + expect(new VoiceStatusWidget().getEditorDisplay(ITEM)).toEqual({ + displayText: 'Voice Status', + modifierText: '(icon)' + }); + }); + + it('shows the configured format and nerd font in the editor display', () => { + expect(new VoiceStatusWidget().getEditorDisplay({ + ...ITEM, + metadata: { format: 'word', nerdFont: 'true' } + })).toEqual({ + displayText: 'Voice Status', + modifierText: '(word, nerd font)' + }); + }); + + it('cycles icon -> icon-text -> text -> word -> icon', () => { + const widget = new VoiceStatusWidget(); + const iconText = widget.handleEditorAction('cycle-format', ITEM); + const text = widget.handleEditorAction('cycle-format', iconText ?? ITEM); + const word = widget.handleEditorAction('cycle-format', text ?? ITEM); + const back = widget.handleEditorAction('cycle-format', word ?? ITEM); + + expect(iconText?.metadata?.format).toBe('icon-text'); + expect(text?.metadata?.format).toBe('text'); + expect(word?.metadata?.format).toBe('word'); + expect(back?.metadata?.format).toBeUndefined(); + }); + + it('toggles nerd font metadata on and off', () => { + const widget = new VoiceStatusWidget(); + 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 VoiceStatusWidget().handleEditorAction('unknown', ITEM)).toBeNull(); + }); + }); + + describe('render() - format icon (default), standard glyphs', () => { + it('renders 🎤 ○ when OFF', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: false }); + expect(new VoiceStatusWidget().render(ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('🎤 ○'); + }); + + it('renders 🎤 ◉ when ON', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: true }); + expect(new VoiceStatusWidget().render(ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('🎤 ◉'); + }); + }); + + describe('render() - format icon, Nerd Font', () => { + const NERD_ITEM: WidgetItem = { ...ITEM, metadata: { nerdFont: 'true' } }; + + it('renders mic-slash glyph (U+F131) when OFF', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: false }); + expect(new VoiceStatusWidget().render(NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(''); + }); + + it('renders mic glyph (U+F130) when ON', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: true }); + expect(new VoiceStatusWidget().render(NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(''); + }); + }); + + describe('render() - format icon-text', () => { + const FORMAT_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'icon-text' } }; + const FORMAT_NERD_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'icon-text', nerdFont: 'true' } }; + + it('renders 🎤 off when OFF (standard)', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: false }); + expect(new VoiceStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('🎤 off'); + }); + + it('renders 🎤 on when ON (standard)', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: true }); + expect(new VoiceStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('🎤 on'); + }); + + it('renders mic-slash off when OFF (nerd font)', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: false }); + expect(new VoiceStatusWidget().render(FORMAT_NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(' off'); + }); + + it('renders mic on when ON (nerd font)', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: true }); + expect(new VoiceStatusWidget().render(FORMAT_NERD_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe(' on'); + }); + }); + + describe('render() - format text', () => { + const FORMAT_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'text' } }; + + it('renders "off" when OFF', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: false }); + expect(new VoiceStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('off'); + }); + + it('renders "on" when ON', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: true }); + expect(new VoiceStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('on'); + }); + }); + + describe('render() - format word', () => { + const FORMAT_ITEM: WidgetItem = { ...ITEM, metadata: { format: 'word' } }; + + it('renders "voice off" when OFF', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: false }); + expect(new VoiceStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('voice off'); + }); + + it('renders "voice on" when ON', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: true }); + expect(new VoiceStatusWidget().render(FORMAT_ITEM, makeContext(), DEFAULT_SETTINGS)).toBe('voice on'); + }); + }); + + describe('render() - voice 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 VoiceStatusWidget().render(ITEM, context, DEFAULT_SETTINGS); + + expect(claudeSettings.getVoiceConfig).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 VoiceStatusWidget().render(ITEM, context, DEFAULT_SETTINGS); + + expect(claudeSettings.getVoiceConfig).toHaveBeenCalledWith('/repo/subdir'); + }); + }); + + describe('render() - preview mode', () => { + it('renders the ON state for the default format', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: false }); + expect(new VoiceStatusWidget().render(ITEM, makeContext({ isPreview: true }), DEFAULT_SETTINGS)).toBe('🎤 ◉'); + }); + + it('renders the ON state for word format with nerd font', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue({ enabled: false }); + const item: WidgetItem = { ...ITEM, metadata: { format: 'word', nerdFont: 'true' } }; + expect(new VoiceStatusWidget().render(item, makeContext({ isPreview: true }), DEFAULT_SETTINGS)).toBe('voice on'); + }); + }); + + describe('render() - missing config', () => { + it('returns null when getVoiceConfig returns null', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue(null); + expect(new VoiceStatusWidget().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, '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'); + }); + + it('returns null when config is null', () => { + vi.spyOn(claudeSettings, 'getVoiceConfig').mockReturnValue(null); + expect(new VoiceStatusWidget().render(RAW_ITEM, makeContext(), DEFAULT_SETTINGS)).toBeNull(); + }); + }); +}); diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 0ae3ba2b..9791efa9 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -71,3 +71,4 @@ export { GitWorktreeNameWidget } from './GitWorktreeName'; export { GitWorktreeBranchWidget } from './GitWorktreeBranch'; export { GitWorktreeOriginalBranchWidget } from './GitWorktreeOriginalBranch'; export { CompactionCounterWidget } from './CompactionCounter'; +export { VoiceStatusWidget } from './VoiceStatus';