Implemented the next missing parity slice around settings slash commands.

Adds /theme, /voice, /sandbox-toggle (alias /sandbox), /keybindings,
and /btw ported from npm src/commands/. Settings-touching commands
write through the existing ConfigRuntime to .claude/settings.local.json
(theme, voiceEnabled, sandbox.excludedCommands). /keybindings creates
a JSON template on first invocation. /btw flips into a prompt-style
result that asks the model to answer the side question without
mutating workspace state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Abdelrahman Abdallah
2026-04-19 23:50:11 +02:00
parent 1072793487
commit e3d9472113
3 changed files with 397 additions and 5 deletions
+5 -5
View File
@@ -324,7 +324,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total):
- [x] `/agents` — Inspect, create, update, and delete local agent definitions
- [x] `/branch` — Create a branch of the current conversation
- [ ] `/bridge` — Connect for remote-control sessions
- [ ] `/btw` — Quick side question without interrupting main conversation
- [x] `/btw` — Quick side question without interrupting main conversation
- [x] `/chrome` — Chrome extension settings
- [x] `/color` — Set the prompt bar color for this session
- [x] `/compact` — Clear history but keep a summary in context
@@ -343,7 +343,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total):
- [ ] `/ide` — Manage IDE integrations and show status
- [x] `/install-github-app` — Set up GitHub Actions
- [x] `/install-slack-app` — Install Slack app
- [ ] `/keybindings` — Open keybindings config file
- [x] `/keybindings` — Open keybindings config file
- [x] `/mobile` — Mobile app store links (aliases `/ios`, `/android`)
- [x] `/output-style` — Deprecation pointer to `/config`
- [x] `/passes` — Passes management
@@ -358,15 +358,15 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total):
- [x] `/rename` — Rename current conversation
- [x] `/resume`, `/continue` — Resume a previous conversation
- [x] `/rewind`, `/checkpoint` — Restore code/conversation to a previous point
- [ ] `/sandbox-toggle` — Toggle sandbox mode
- [x] `/sandbox-toggle` — Toggle sandbox mode (alias `/sandbox`)
- [x] `/skills` — List available skills
- [x] `/stats` — Usage statistics and activity
- [x] `/stickers` — Order stickers
- [x] `/tag` — Toggle a searchable tag on the session
- [ ] `/theme` — Change the theme
- [x] `/theme` — Change the theme
- [x] `/upgrade` — Upgrade to Max
- [x] `/vim` — Toggle Vim/Normal editing modes
- [ ] `/voice` — Toggle voice mode
- [x] `/voice` — Toggle voice mode
- [ ] Feature-gated: `/buddy`, `/fork`, `/peers`, `/proactive`, `/torch`, `/workflows` (full), etc.
- [ ] Internal: `/backfill-sessions`, `/break-cache`, `/bughunter`, `/commit-push-pr`, `/init-verifiers`, `/mock-limits`, `/version`, `/ultraplan`, `/autofix-pr`, etc.
- [x] `/commit` — Create a git commit (prompt-type with injected git context)
+213
View File
@@ -489,6 +489,31 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]:
description='Reload local plugin manifests and report counts.',
handler=_handle_reload_plugins,
),
SlashCommandSpec(
names=('theme',),
description='List available themes or set the current theme.',
handler=_handle_theme,
),
SlashCommandSpec(
names=('voice',),
description='Toggle voice-mode setting for this workspace.',
handler=_handle_voice,
),
SlashCommandSpec(
names=('sandbox-toggle', 'sandbox'),
description='Show sandbox status or exclude a command pattern.',
handler=_handle_sandbox_toggle,
),
SlashCommandSpec(
names=('keybindings',),
description='Print or create the local keybindings file.',
handler=_handle_keybindings,
),
SlashCommandSpec(
names=('btw',),
description='Ask Claude a quick side question without altering state.',
handler=_handle_btw,
),
)
@@ -1976,6 +2001,194 @@ def _handle_reload_plugins(
)
_AVAILABLE_THEMES = (
'light',
'dark',
'light-daltonized',
'dark-daltonized',
'light-ansi',
'dark-ansi',
)
def _config_get(runtime, key_path: str, default=None):
try:
return runtime.get_value(key_path)
except KeyError:
return default
def _handle_theme(
agent: 'LocalCodingAgent',
args: str,
input_text: str,
) -> SlashCommandResult:
runtime = agent.config_runtime
if runtime is None:
return _local_result(input_text, 'Config runtime is unavailable.')
requested = args.strip()
current = _config_get(runtime, 'theme') or 'light'
if not requested:
lines = ['Available themes:']
for name in _AVAILABLE_THEMES:
marker = ' (current)' if name == current else ''
lines.append(f' - {name}{marker}')
lines.append('')
lines.append('Usage: /theme <name>')
return _local_result(input_text, '\n'.join(lines))
if requested not in _AVAILABLE_THEMES:
return _local_result(
input_text,
f'Unknown theme "{requested}". Available: {", ".join(_AVAILABLE_THEMES)}.',
)
mutation = runtime.set_value('theme', requested, source='local')
return _local_result(
input_text,
f'Theme set to {requested} (saved to {mutation.store_path}).',
)
def _handle_voice(
agent: 'LocalCodingAgent',
args: str,
input_text: str,
) -> SlashCommandResult:
runtime = agent.config_runtime
if runtime is None:
return _local_result(input_text, 'Config runtime is unavailable.')
arg = args.strip().lower()
current = bool(_config_get(runtime, 'voiceEnabled', False))
if arg in {'on', 'enable', 'true'}:
new_value = True
elif arg in {'off', 'disable', 'false'}:
new_value = False
elif not arg:
new_value = not current
else:
return _local_result(input_text, 'Usage: /voice [on|off]')
mutation = runtime.set_value('voiceEnabled', new_value, source='local')
state = 'enabled' if new_value else 'disabled'
extra = ''
if new_value:
import platform
if platform.system() == 'Linux':
extra = (
'\nLinux note: confirm microphone access in your audio settings '
'before holding to talk.'
)
return _local_result(
input_text,
f'Voice mode {state} (saved to {mutation.store_path}).{extra}',
)
def _handle_sandbox_toggle(
agent: 'LocalCodingAgent',
args: str,
input_text: str,
) -> SlashCommandResult:
runtime = agent.config_runtime
if runtime is None:
return _local_result(input_text, 'Config runtime is unavailable.')
trimmed = args.strip()
if not trimmed:
enabled = bool(_config_get(runtime, 'sandbox.enabled', False))
excluded = _config_get(runtime, 'sandbox.excludedCommands') or []
lines = [
f'Sandbox: {"enabled" if enabled else "disabled"}',
f'Excluded commands ({len(excluded)}):',
]
for pattern in excluded:
lines.append(f' - {pattern}')
lines.append('')
lines.append(
'Usage: /sandbox-toggle exclude "<pattern>" '
'— append a command pattern to the local sandbox excludes.'
)
return _local_result(input_text, '\n'.join(lines))
parts = trimmed.split(None, 1)
subcommand = parts[0].lower()
if subcommand != 'exclude':
return _local_result(
input_text,
f'Unknown subcommand "{subcommand}". Available: exclude.',
)
if len(parts) < 2 or not parts[1].strip():
return _local_result(
input_text,
'Usage: /sandbox-toggle exclude "<pattern>" '
'(e.g., /sandbox-toggle exclude "npm run test:*").',
)
pattern = parts[1].strip().strip('"').strip("'")
existing = list(_config_get(runtime, 'sandbox.excludedCommands') or [])
if pattern in existing:
return _local_result(
input_text,
f'Pattern "{pattern}" is already in sandbox.excludedCommands.',
)
existing.append(pattern)
mutation = runtime.set_value(
'sandbox.excludedCommands', existing, source='local',
)
return _local_result(
input_text,
f'Added "{pattern}" to sandbox.excludedCommands in {mutation.store_path}.',
)
_KEYBINDINGS_TEMPLATE = (
'{\n'
' "$schema": "https://claude.ai/schemas/keybindings.json",\n'
' "bindings": {\n'
' // "chat:submit": "ctrl+enter"\n'
' }\n'
'}\n'
)
def _handle_keybindings(
agent: 'LocalCodingAgent',
_args: str,
input_text: str,
) -> SlashCommandResult:
from pathlib import Path
cwd = Path(agent.runtime_config.cwd)
path = cwd / '.claude' / 'keybindings.json'
created = False
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(_KEYBINDINGS_TEMPLATE, encoding='utf-8')
created = True
verb = 'Created' if created else 'Found'
import os
editor = os.environ.get('EDITOR') or os.environ.get('VISUAL') or '<your editor>'
return _local_result(
input_text,
f'{verb} {path}.\n'
f'Edit it with: {editor} {path}',
)
def _handle_btw(
agent: 'LocalCodingAgent',
args: str,
input_text: str,
) -> SlashCommandResult:
question = args.strip()
if not question:
return _local_result(input_text, 'Usage: /btw <your question>')
prompt = (
'Answer the following side question concisely. Do NOT modify any files, '
"run shell commands, or alter the workspace — just answer in text.\n\n"
f'Side question: {question}'
)
return _prompt_result(input_text, prompt)
def _prompt_result(input_text: str, prompt: str) -> SlashCommandResult:
"""Return a prompt-type result — the prompt gets sent to the model."""
return SlashCommandResult(
+179
View File
@@ -0,0 +1,179 @@
"""Tests for settings-touching slash commands ported from the npm source.
Covers /theme, /voice, /sandbox-toggle (alias /sandbox), /keybindings, /btw.
"""
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from src.agent_runtime import LocalCodingAgent
from src.agent_types import AgentRuntimeConfig, ModelConfig
def _make_agent(tmp_dir: str) -> LocalCodingAgent:
return LocalCodingAgent(
model_config=ModelConfig(model='test-model'),
runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)),
)
def _local_settings(tmp_dir: str) -> dict:
path = Path(tmp_dir) / '.claude' / 'settings.local.json'
if not path.exists():
return {}
return json.loads(path.read_text(encoding='utf-8'))
class ThemeCommandTest(unittest.TestCase):
def test_lists_themes_when_no_arg(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
out = agent.run('/theme').final_output
self.assertIn('Available themes', out)
self.assertIn('light', out)
self.assertIn('dark', out)
self.assertIn('Usage: /theme <name>', out)
def test_rejects_unknown_theme(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
out = agent.run('/theme neon').final_output
self.assertIn('Unknown theme', out)
self.assertIn('neon', out)
def test_sets_theme_and_persists(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
out = agent.run('/theme dark').final_output
self.assertIn('Theme set to dark', out)
settings = _local_settings(tmp)
self.assertEqual(settings.get('theme'), 'dark')
def test_marks_current_theme(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
agent.run('/theme dark')
out = agent.run('/theme').final_output
self.assertIn('dark (current)', out)
class VoiceCommandTest(unittest.TestCase):
def test_toggle_enables_when_unset(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
out = agent.run('/voice').final_output
self.assertIn('Voice mode enabled', out)
self.assertEqual(_local_settings(tmp).get('voiceEnabled'), True)
def test_toggle_disables_when_enabled(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
agent.run('/voice on')
out = agent.run('/voice').final_output
self.assertIn('Voice mode disabled', out)
self.assertEqual(_local_settings(tmp).get('voiceEnabled'), False)
def test_explicit_on_off(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
self.assertIn('enabled', agent.run('/voice on').final_output)
self.assertIn('disabled', agent.run('/voice off').final_output)
def test_rejects_unknown_arg(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
out = agent.run('/voice maybe').final_output
self.assertIn('Usage', out)
class SandboxToggleCommandTest(unittest.TestCase):
def test_status_with_no_args(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
out = agent.run('/sandbox-toggle').final_output
self.assertIn('Sandbox:', out)
self.assertIn('Excluded commands', out)
self.assertIn('Usage:', out)
def test_alias_sandbox(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
out = agent.run('/sandbox').final_output
self.assertIn('Sandbox:', out)
def test_exclude_appends_pattern(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
out = agent.run('/sandbox-toggle exclude "npm run test:*"').final_output
self.assertIn('Added "npm run test:*"', out)
settings = _local_settings(tmp)
excluded = settings.get('sandbox', {}).get('excludedCommands', [])
self.assertIn('npm run test:*', excluded)
def test_exclude_dedupes(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
agent.run('/sandbox-toggle exclude "rm -rf /"')
out = agent.run('/sandbox-toggle exclude "rm -rf /"').final_output
self.assertIn('already in', out)
def test_exclude_requires_pattern(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
out = agent.run('/sandbox-toggle exclude').final_output
self.assertIn('Usage', out)
def test_unknown_subcommand(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
out = agent.run('/sandbox-toggle wat').final_output
self.assertIn('Unknown subcommand', out)
class KeybindingsCommandTest(unittest.TestCase):
def test_creates_template_when_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
out = agent.run('/keybindings').final_output
path = Path(tmp) / '.claude' / 'keybindings.json'
self.assertTrue(path.exists())
self.assertIn('Created', out)
self.assertIn(str(path), out)
# Template is valid JSON-ish (has braces); strict json.loads would
# choke on the "//" comment, so just sanity-check structure.
text = path.read_text(encoding='utf-8')
self.assertIn('"bindings"', text)
def test_reports_existing_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
agent.run('/keybindings')
out = agent.run('/keybindings').final_output
self.assertIn('Found', out)
class BtwCommandTest(unittest.TestCase):
def test_no_question_shows_usage(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
result = agent.run('/btw').final_output
self.assertIn('Usage: /btw', result)
def test_question_returns_prompt_result(self) -> None:
from src.agent_slash_commands import preprocess_slash_command
with tempfile.TemporaryDirectory() as tmp:
agent = _make_agent(tmp)
result = preprocess_slash_command(agent, '/btw what does this codebase do?')
self.assertTrue(result.handled)
self.assertTrue(result.should_query)
self.assertIn('side question', (result.prompt or '').lower())
self.assertIn('what does this codebase do?', result.prompt or '')
if __name__ == '__main__':
unittest.main()