diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index a84b0f8..1288953 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -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) diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 156fea8..135c235 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -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 ') + 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 "" ' + '— 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 "" ' + '(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 '' + 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 ') + 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( diff --git a/tests/test_settings_slash_commands.py b/tests/test_settings_slash_commands.py new file mode 100644 index 0000000..2674bf0 --- /dev/null +++ b/tests/test_settings_slash_commands.py @@ -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 ', 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()