diff --git a/cli.py b/cli.py index c719595336..389a1472b0 100644 --- a/cli.py +++ b/cli.py @@ -2947,8 +2947,8 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix self._status_bar_visible = _status_bar_visible_from_display_config(CLI_CONFIG.get("display")) self._battery_visible = bool(CLI_CONFIG["display"].get("battery", False)) - # Vi/vim editing mode for the input composer (toggled via /vim, persisted to - # display.vim_mode). Off by default: prompt_toolkit's standard emacs bindings. + # Vi/vim editing mode for the input composer (display.vim_mode, config-only). + # Off by default: prompt_toolkit's standard emacs bindings. self._vim_mode = bool(CLI_CONFIG["display"].get("vim_mode", False)) # Hide rules + status bar until the next input after a resize, so SIGWINCH cannot # stamp a fresh status bar over one the terminal just reflowed into scrollback. @@ -3763,7 +3763,7 @@ class HermesCLI(CLIProcessNotificationsMixin, CLIAgentSetupMixin, CLICommandsMix if _STEADY_CURSOR is not None: extra_kw["cursor"] = _STEADY_CURSOR if EditingMode is not None: - # Vi editing mode when display.vim_mode is on (toggled at runtime by /vim). + # Vi editing mode when display.vim_mode is on. # EMACS is prompt_toolkit's own default, so non-opted-in behaviour is unchanged. extra_kw["editing_mode"] = EditingMode.VI if self._vim_mode else EditingMode.EMACS return Application( diff --git a/hermes_cli/cli_status_bar_mixin.py b/hermes_cli/cli_status_bar_mixin.py index 7f1d4d13c3..3e532d3ca4 100644 --- a/hermes_cli/cli_status_bar_mixin.py +++ b/hermes_cli/cli_status_bar_mixin.py @@ -97,39 +97,6 @@ class CLIStatusBarMixin: except Exception: return "" - def _handle_vim_command(self, cmd_original: str) -> None: - """``/vim`` toggles vi keybindings in the composer, ``/vim on|off`` sets, ``/vim status`` - reports. Persisted to ``display.vim_mode``; applied to the live prompt_toolkit - Application immediately, no restart needed.""" - from cli import save_config_value - from prompt_toolkit.enums import EditingMode - parts = (cmd_original or "").split() - arg = parts[1].strip().lower() if len(parts) > 1 else "" - - if arg in ("status", "show"): - self._console_print(f" Vim mode {'on' if self._vim_mode else 'off'}") - return - - if arg in ("on", "true", "yes"): - target = True - elif arg in ("off", "false", "no"): - target = False - elif arg in ("", "toggle"): - target = not self._vim_mode - else: - self._console_print(" Usage: /vim [on|off|status]") - return - - self._vim_mode = target - save_config_value("display.vim_mode", target) - app = getattr(self, "_app", None) - if app is not None: - app.editing_mode = EditingMode.VI if target else EditingMode.EMACS - if target: - self._console_print(" Vim mode on — Esc for NORMAL, i to insert") - else: - self._console_print(" Vim mode off — standard keybindings") - def _handle_battery_command(self, cmd_original: str) -> None: """``/battery`` toggles, ``/battery on|off`` sets, ``/battery status`` reports the setting plus a live reading. Persisted to ``display.battery``.""" diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 853ee6c48c..5bc9eabeb4 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -163,9 +163,6 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("battery", "Toggle a color-coded battery indicator in the status bar", "Configuration", cli_only=True, args_hint="[on|off|status]", subcommands=("on", "off", "status")), - CommandDef("vim", "Toggle vim keybindings in the input composer", - "Configuration", cli_only=True, args_hint="[on|off|status]", - subcommands=("on", "off", "status")), CommandDef("timestamps", "Toggle [HH:MM] timestamps on messages and /history", "Configuration", cli_only=True, args_hint="[on|off|status]", subcommands=("on", "off", "status"), aliases=("ts",)), diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 3a5be26359..9c27bd40e5 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -837,7 +837,7 @@ DEFAULT_CONFIG = { # fights terminal auto-scroll in non-fullscreen mode. # See #45592. "cli_refresh_interval": 1.0, - # Vi/vim keybindings in the CLI input composer (toggled by /vim). + # Vi/vim keybindings in the CLI input composer (config-only, no slash command). # Off by default, preserving prompt_toolkit's standard emacs bindings. "vim_mode": False, "user_message_preview": { # CLI: submitted user-message lines echoed to scrollback diff --git a/tests/cli/test_slash_dispatch_table.py b/tests/cli/test_slash_dispatch_table.py index 0043a43ed1..5bf91fbb68 100644 --- a/tests/cli/test_slash_dispatch_table.py +++ b/tests/cli/test_slash_dispatch_table.py @@ -18,7 +18,6 @@ OLD_CHAIN_COMMANDS = [ "blueprint", "curator", "kanban", "skills", "learn", "init", "memory", "platforms", "status", "context", "egress", "statusbar", "diff", "battery", "timestamps", "verbose", "focus", "footer", "yolo", "approvals", "reasoning", - "vim", "fast", "compress", "usage", "subscription", "topup", "insights", "copy", "debug", "update", "version", "paste", "image", "reload", "reload-mcp", "reload-skills", "bundles", "browser", "plugins", "rollback", "snapshot", diff --git a/tests/cli/test_vim_mode_command.py b/tests/cli/test_vim_mode_command.py index 7ebca398cf..86190bd273 100644 --- a/tests/cli/test_vim_mode_command.py +++ b/tests/cli/test_vim_mode_command.py @@ -1,8 +1,7 @@ -"""Tests for the /vim CLI command and display.vim_mode config handling.""" +"""display.vim_mode: config-only vi keybindings for the CLI composer.""" import unittest from types import SimpleNamespace -from unittest.mock import patch from prompt_toolkit.enums import EditingMode @@ -13,48 +12,6 @@ def _import_cli(): return cli_mod -class TestHandleVimCommand(unittest.TestCase): - """/vim toggles vi editing mode, persists it, and applies it live.""" - - def _make_cli(self, vim_mode=False, app=None): - return SimpleNamespace( - _vim_mode=vim_mode, - _app=app, - _console_print=lambda *a, **k: None, - ) - - def test_toggle_persists_and_applies_to_running_app(self): - cli_mod = _import_cli() - app = SimpleNamespace(editing_mode=EditingMode.EMACS) - stub = self._make_cli(vim_mode=False, app=app) - - with patch.object(cli_mod, "save_config_value") as mock_save: - cli_mod.HermesCLI._handle_vim_command(stub, "/vim") - self.assertTrue(stub._vim_mode) - self.assertEqual(app.editing_mode, EditingMode.VI) - mock_save.assert_called_once_with("display.vim_mode", True) - - with patch.object(cli_mod, "save_config_value") as mock_save: - cli_mod.HermesCLI._handle_vim_command(stub, "/vim off") - self.assertFalse(stub._vim_mode) - self.assertEqual(app.editing_mode, EditingMode.EMACS) - mock_save.assert_called_once_with("display.vim_mode", False) - - def test_status_and_invalid_args_change_nothing(self): - cli_mod = _import_cli() - printed = [] - stub = self._make_cli(vim_mode=True) - stub._console_print = lambda msg: printed.append(str(msg)) - - with patch.object(cli_mod, "save_config_value") as mock_save: - cli_mod.HermesCLI._handle_vim_command(stub, "/vim status") - cli_mod.HermesCLI._handle_vim_command(stub, "/vim sideways") - - mock_save.assert_not_called() - self.assertTrue(stub._vim_mode) - self.assertIn("Usage", " ".join(printed)) - - class TestVimModeLabel(unittest.TestCase): """The status-bar label reflects the live vi input mode; off/no-app yields empty.""" @@ -76,5 +33,14 @@ class TestVimModeLabel(unittest.TestCase): self.assertEqual(cli_mod.HermesCLI._vim_mode_label(stub), "REPLACE") +class TestNoSlashCommand(unittest.TestCase): + """vim_mode is a config key only: no /vim command is registered.""" + + def test_vim_not_in_command_registry(self): + from hermes_cli.commands import COMMAND_REGISTRY + + self.assertNotIn("vim", {c.name for c in COMMAND_REGISTRY}) + + if __name__ == "__main__": unittest.main() diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index a1e9b186cd..61d5c3af2b 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -88,7 +88,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/import [--name ]` | **CLI only.** Install a profile archive as a new profile, inferring the name from the archive unless `--name` is given. Refuses to overwrite an existing profile and cannot import as `default`. Creates a shell wrapper when the name is free. See [Export and import a profile file](../user-guide/profile-distributions.md#export-and-import-a-profile-file). | | `/statusbar` (alias: `/sb`) | Toggle the context/model status bar on or off | | `/battery [on\|off\|status]` | Toggle a color-coded battery read-out as the first status-bar element (off by default; no-op without a battery). | -| `/vim [on\|off\|status]` | Toggle vi/vim keybindings in the input composer (off by default). The live NORMAL/INSERT/REPLACE mode shows at the right of the status bar; persisted to `display.vim_mode`. | | `/voice [on\|off\|tts\|status]` | Toggle CLI voice mode and spoken playback. Recording uses `voice.record_key` (default: `Ctrl+B`). | | `/yolo` | Toggle YOLO mode — skip all dangerous command approval prompts. | | `/approvals [manual\|smart\|off]` | Show or set the persistent dangerous-command approval mode. | @@ -308,7 +307,7 @@ The messaging gateway supports the following built-in commands inside Telegram, ## Notes -- `/skin`, `/snapshot`, `/export`, `/import`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/battery`, `/vim`, `/focus`, `/plugins`, `/indicator`, `/wake`, `/journey`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/prompt`, `/pet`, `/hatch`, `/timestamps`, `/subscription`, and `/quit` are **CLI-only** commands. +- `/skin`, `/snapshot`, `/export`, `/import`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/battery`, `/focus`, `/plugins`, `/indicator`, `/wake`, `/journey`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, `/handoff`, `/prompt`, `/pet`, `/hatch`, `/timestamps`, `/subscription`, and `/quit` are **CLI-only** commands. - `/skills` is **CLI-only for search/browse/install**; its write-approval review subcommands (`pending`, `approve`, `reject`, `diff`, `approval`) also work on messaging platforms when `skills.write_approval` is on. `/memory` works on **both** surfaces. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/focus` and `/verbose` share one suppression path (`display.tool_progress`), so they can never contradict each other: `/focus on` pins tool progress to `off` and stashes your mode under `display.focus_saved_tool_progress`; `/focus off` restores it; cycling `/verbose` while focus is on takes the mode back and clears the focus badge. Focus view is display-only — it never changes conversation history, the system prompt, or anything sent to the model, so it has zero prompt-cache impact. diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 959ff517fb..52cfa28403 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1993,6 +1993,7 @@ display: show_reasoning: true # Show model reasoning/thinking above each response (default: true; toggle with /reasoning show|hide) streaming: false # Stream tokens to terminal as they arrive (real-time output) show_cost: false # Show estimated $ cost in the CLI status bar + vim_mode: false # CLI only: vi/vim keybindings in the input composer (Esc → NORMAL, i → INSERT). The live NORMAL/INSERT/REPLACE mode shows at the right of the status bar. Config-only, read at startup. timestamps: false # When true, prefixes user and assistant labels with timestamps in the CLI / TUI transcript timestamp_format: "%H:%M" # strftime format for those timestamps (e.g. "%b-%d %H:%M" for month-day) tool_preview_length: 0 # Max chars for tool call previews (0 = no limit, show full paths/commands)