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:
+5
-5
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user