Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
142 changes: 142 additions & 0 deletions src/utils/__tests__/claude-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getClaudeSettingsPath,
getExistingStatusLine,
getRefreshInterval,
getVoiceConfig,
installStatusLine,
isClaudeCodeVersionAtLeast,
isInstalled,
Expand Down Expand Up @@ -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 });
});
});
});
81 changes: 81 additions & 0 deletions src/utils/claude-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -374,3 +375,83 @@ export async function setRefreshInterval(interval: number | null): Promise<void>

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. <user>/settings.json
* 2. <user>/settings.local.json
* 3. <cwd>/.claude/settings.json
* 4. <cwd>/.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: <bool> }` 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;
}
1 change: 1 addition & 0 deletions src/utils/widget-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down
Loading