Skip to content
Merged
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
111 changes: 111 additions & 0 deletions src/utils/__tests__/claude-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
getClaudeSettingsPath,
getExistingStatusLine,
getRefreshInterval,
getSandboxConfig,
getVoiceConfig,
installStatusLine,
isClaudeCodeVersionAtLeast,
Expand Down Expand Up @@ -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 });
});
});
59 changes: 57 additions & 2 deletions src/utils/claude-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ export async function setRefreshInterval(interval: number | null): Promise<void>

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.
Expand Down Expand Up @@ -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;
Expand All @@ -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 `<cwd>/.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: <bool> }` 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()
Expand Down
1 change: 1 addition & 0 deletions src/utils/widget-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down
83 changes: 48 additions & 35 deletions src/widgets/RemoteControlStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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}`;
}
}

Expand Down Expand Up @@ -136,29 +153,25 @@ 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);
if (status === null) {
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; }
Expand Down
Loading