From ea7557ea8c35d08e96aeba19ea73a3df01f0700c Mon Sep 17 00:00:00 2001 From: Kai Xu Date: Fri, 21 Aug 2026 04:05:12 +0000 Subject: [PATCH] feat: add vi mode detection and insert-mode prefix for tmux send-keys Detect Claude Code's vim editor mode from ~/.claude/settings.json and prefix tmux send-keys with Escape + 100ms delay + i to ensure insert mode before sending text. Uses two-step hex approach (text then -H 0d) for reliable Enter key delivery. Co-Authored-By: Claude Opus 4.6 --- factory/agents/prompts/refactory.md | 19 ++++- factory/agents/skills/sessions.md | 19 ++++- factory/runners/_tmux_persist.py | 32 +++++++- tests/test_tmux_persist.py | 117 +++++++++++++++++++++++----- 4 files changed, 156 insertions(+), 31 deletions(-) diff --git a/factory/agents/prompts/refactory.md b/factory/agents/prompts/refactory.md index 984366fb9..56da7178b 100644 --- a/factory/agents/prompts/refactory.md +++ b/factory/agents/prompts/refactory.md @@ -126,11 +126,24 @@ Periodically trigger playbook evolution via `factory ace` to distill experiment ### Input Submission -Always use `C-m` (not `Enter`) when sending keys to tmux sessions running Claude Code: +Use the two-step hex approach when sending keys to tmux sessions running Claude Code — send text first, then Enter as hex `0d`: ```bash -tmux send-keys -t "your input" C-m +tmux send-keys -t -- "your input" +tmux send-keys -t -H 0d ``` -`Enter` is unreliable inside Claude Code sessions — `C-m` is the canonical carriage return and works consistently. +This avoids ambiguity with `C-m` and `Enter` key interpretation inside Claude Code sessions. + +### Vi Mode Handling + +When Claude Code has vi/vim editor mode enabled (`editorMode: "vim"` in `~/.claude/settings.json`), the user may be in normal mode. Send Escape → wait 100ms → `i` before text to ensure insert mode: +```bash +tmux send-keys -t Escape +sleep 0.1 +tmux send-keys -t i +tmux send-keys -t -- "your input" +tmux send-keys -t -H 0d +``` +`factory tmux` handles this automatically — it detects vi mode from user settings and prefixes the Escape+i sequence when needed. ### Post-Dispatch Verification diff --git a/factory/agents/skills/sessions.md b/factory/agents/skills/sessions.md index bf804a6e8..ca7fbd00a 100644 --- a/factory/agents/skills/sessions.md +++ b/factory/agents/skills/sessions.md @@ -20,11 +20,24 @@ If the session exists but the CEO process has exited, the session is stale — s ### Sending Input to Sessions -Always use `C-m` (not `Enter`) when sending keys to tmux sessions running Claude Code: +Use the two-step hex approach when sending keys to tmux sessions running Claude Code — send text first, then Enter as hex `0d`: ```bash -tmux send-keys -t "your input" C-m +tmux send-keys -t -- "your input" +tmux send-keys -t -H 0d ``` -`Enter` is unreliable inside Claude Code sessions — `C-m` is the canonical carriage return. +This avoids ambiguity with `C-m` and `Enter` key interpretation inside Claude Code sessions. + +### Vi Mode Handling + +When Claude Code has vi/vim editor mode enabled (`editorMode: "vim"` in `~/.claude/settings.json`), prefix with Escape → 100ms delay → `i` to ensure insert mode: +```bash +tmux send-keys -t Escape +sleep 0.1 +tmux send-keys -t i +tmux send-keys -t -- "your input" +tmux send-keys -t -H 0d +``` +`factory tmux` handles this automatically — it detects vi mode from user settings and applies the prefix when needed. ### Capturing Output diff --git a/factory/runners/_tmux_persist.py b/factory/runners/_tmux_persist.py index 306219c3d..7f249360b 100644 --- a/factory/runners/_tmux_persist.py +++ b/factory/runners/_tmux_persist.py @@ -29,6 +29,31 @@ _WINDOW_POLL_TIMEOUT = 3.0 +def _detect_vim_mode() -> bool: + """Check if Claude Code has vi/vim editor mode enabled in user settings.""" + try: + settings_path = Path.home() / ".claude" / "settings.json" + settings = json.loads(settings_path.read_text()) + return settings.get("editorMode") == "vim" + except (json.JSONDecodeError, OSError): + logger.debug("vim mode detection failed, defaulting to normal mode") + return False + + +def _tmux_send_enter(session_window: str, text: str, *, vim_mode: bool = False) -> None: + """Send text followed by Enter (hex 0d) to a tmux target. + + When vim_mode is True, prefix with Escape + 100ms delay + i to ensure + the target pane is in insert mode before sending text. + """ + if vim_mode: + subprocess.run(["tmux", "send-keys", "-t", session_window, "Escape"], capture_output=True) + time.sleep(0.1) + subprocess.run(["tmux", "send-keys", "-t", session_window, "i"], capture_output=True) + subprocess.run(["tmux", "send-keys", "-t", session_window, "--", text], capture_output=True) + subprocess.run(["tmux", "send-keys", "-t", session_window, "-H", "0d"], capture_output=True) + + def find_project_path(cwd: Path) -> Path: """Find the project root by walking up from cwd looking for .factory/.""" path = cwd.resolve() @@ -153,6 +178,8 @@ async def run_in_tmux( Returns (stdout, return_code, None). Usage is always None for tmux mode. """ + vim_mode = _detect_vim_mode() + run_id = uuid.uuid4().hex[:8] path_hash = hashlib.sha1(str(project_path).encode()).hexdigest()[:6] session = f"{_SESSION_PREFIX}{project_path.name}-{path_hash}" @@ -230,10 +257,7 @@ async def run_in_tmux( _cleanup(tmpdir) return f"Agent timed out after {timeout}s", 1, None - subprocess.run( - ["tmux", "send-keys", "-t", f"{session}:{window}", "/exit", "C-m"], - capture_output=True, - ) + _tmux_send_enter(f"{session}:{window}", "/exit", vim_mode=vim_mode) await _wait_for_window_exit(session, window) if _window_exists(session, window): subprocess.run( diff --git a/tests/test_tmux_persist.py b/tests/test_tmux_persist.py index a27fdb72d..12b5c0e06 100644 --- a/tests/test_tmux_persist.py +++ b/tests/test_tmux_persist.py @@ -8,8 +8,10 @@ from unittest.mock import AsyncMock, MagicMock, patch from factory.runners._tmux_persist import ( + _detect_vim_mode, _generate_settings, _strip_ansi, + _tmux_send_enter, _wait_for_exitcode, _wait_for_sentinel, _window_exists, @@ -145,6 +147,65 @@ def test_handles_missing_project_settings(self, tmp_path: Path) -> None: assert len(data["hooks"]["StopFailure"]) == 1 +class TestDetectVimMode: + def test_returns_true_when_vim_enabled(self, tmp_path: Path) -> None: + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + (claude_dir / "settings.json").write_text(json.dumps({"editorMode": "vim"})) + with patch("factory.runners._tmux_persist.Path.home", return_value=tmp_path): + assert _detect_vim_mode() is True + + def test_returns_false_for_normal_mode(self, tmp_path: Path) -> None: + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + (claude_dir / "settings.json").write_text(json.dumps({"editorMode": "normal"})) + with patch("factory.runners._tmux_persist.Path.home", return_value=tmp_path): + assert _detect_vim_mode() is False + + def test_returns_false_when_settings_missing(self, tmp_path: Path) -> None: + with patch("factory.runners._tmux_persist.Path.home", return_value=tmp_path): + assert _detect_vim_mode() is False + + def test_returns_false_on_malformed_json(self, tmp_path: Path) -> None: + claude_dir = tmp_path / ".claude" + claude_dir.mkdir() + (claude_dir / "settings.json").write_text("{bad json") + with patch("factory.runners._tmux_persist.Path.home", return_value=tmp_path): + assert _detect_vim_mode() is False + + +class TestTmuxSendEnterVimMode: + def test_vim_mode_sends_escape_sleep_i_prefix(self) -> None: + with ( + patch("factory.runners._tmux_persist.subprocess.run") as mock_run, + patch("factory.runners._tmux_persist.time.sleep") as mock_sleep, + ): + mock_run.return_value = MagicMock(returncode=0) + _tmux_send_enter("sess:win", "hello", vim_mode=True) + + assert mock_run.call_count == 4 + calls = mock_run.call_args_list + assert calls[0][0][0] == ["tmux", "send-keys", "-t", "sess:win", "Escape"] + mock_sleep.assert_called_once_with(0.1) + assert calls[1][0][0] == ["tmux", "send-keys", "-t", "sess:win", "i"] + assert calls[2][0][0] == ["tmux", "send-keys", "-t", "sess:win", "--", "hello"] + assert calls[3][0][0] == ["tmux", "send-keys", "-t", "sess:win", "-H", "0d"] + + def test_normal_mode_sends_only_text_and_enter(self) -> None: + with ( + patch("factory.runners._tmux_persist.subprocess.run") as mock_run, + patch("factory.runners._tmux_persist.time.sleep") as mock_sleep, + ): + mock_run.return_value = MagicMock(returncode=0) + _tmux_send_enter("sess:win", "hello", vim_mode=False) + + assert mock_run.call_count == 2 + mock_sleep.assert_not_called() + calls = mock_run.call_args_list + assert calls[0][0][0] == ["tmux", "send-keys", "-t", "sess:win", "--", "hello"] + assert calls[1][0][0] == ["tmux", "send-keys", "-t", "sess:win", "-H", "0d"] + + class TestWaitForSentinel: async def test_returns_true_when_sentinel_exists(self, tmp_path: Path) -> None: sentinel = tmp_path / "sentinel" @@ -252,10 +313,12 @@ async def test_creates_new_session_when_none_exists(self, tmp_path: Path) -> Non patch("factory.runners._tmux_persist._wait_for_window_exit", new_callable=AsyncMock), patch("factory.runners._tmux_persist._wait_for_exitcode", new_callable=AsyncMock, return_value=0), patch("factory.runners._tmux_persist._window_exists", return_value=False), + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), ): mock_run.side_effect = [ MagicMock(returncode=0), # new-session succeeds - MagicMock(returncode=0), # send-keys /exit + MagicMock(returncode=0), # send-keys /exit text + MagicMock(returncode=0), # send-keys /exit hex 0d ] with patch("factory.runners._tmux_persist.tempfile.mkdtemp", return_value=str(tmp_path / "tmp")): @@ -284,11 +347,13 @@ async def test_creates_window_when_session_exists(self, tmp_path: Path) -> None: patch("factory.runners._tmux_persist._wait_for_window_exit", new_callable=AsyncMock), patch("factory.runners._tmux_persist._wait_for_exitcode", new_callable=AsyncMock, return_value=0), patch("factory.runners._tmux_persist._window_exists", return_value=False), + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), ): mock_run.side_effect = [ MagicMock(returncode=1, stderr=b"duplicate session"), # new-session fails MagicMock(returncode=0), # new-window fallback succeeds - MagicMock(returncode=0), # send-keys /exit + MagicMock(returncode=0), # send-keys /exit text + MagicMock(returncode=0), # send-keys /exit hex 0d ] with patch("factory.runners._tmux_persist.tempfile.mkdtemp", return_value=str(tmp_path / "tmp")): @@ -317,11 +382,13 @@ async def test_race_condition_fallback_to_new_window(self, tmp_path: Path) -> No patch("factory.runners._tmux_persist._wait_for_window_exit", new_callable=AsyncMock), patch("factory.runners._tmux_persist._wait_for_exitcode", new_callable=AsyncMock, return_value=0), patch("factory.runners._tmux_persist._window_exists", return_value=False), + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), ): mock_run.side_effect = [ MagicMock(returncode=1, stderr=b"duplicate session: factory-persist-my-project-abc123"), MagicMock(returncode=0), # new-window fallback - MagicMock(returncode=0), # send-keys /exit + MagicMock(returncode=0), # send-keys /exit text + MagicMock(returncode=0), # send-keys /exit hex 0d ] with patch("factory.runners._tmux_persist.tempfile.mkdtemp", return_value=str(tmp_path / "tmp")): @@ -335,7 +402,7 @@ async def test_race_condition_fallback_to_new_window(self, tmp_path: Path) -> No assert code == 0 assert "race condition output" in stdout - assert len(mock_run.call_args_list) == 3 + assert len(mock_run.call_args_list) == 4 assert "new-session" in mock_run.call_args_list[0][0][0] assert "new-window" in mock_run.call_args_list[1][0][0] assert "send-keys" in mock_run.call_args_list[2][0][0] @@ -359,13 +426,14 @@ def spy_write_text(self_path: Path, content: str, *args, **kwargs) -> None: patch("factory.runners._tmux_persist._wait_for_sentinel", new_callable=AsyncMock, return_value=True), patch("factory.runners._tmux_persist._wait_for_window_exit", new_callable=AsyncMock), patch("factory.runners._tmux_persist._wait_for_exitcode", new_callable=AsyncMock, return_value=0), - patch("factory.runners._tmux_persist._window_exists", return_value=False), + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), patch.object(Path, "write_text", spy_write_text), ): mock_run.side_effect = [ MagicMock(returncode=0), # new-session - MagicMock(returncode=0), # send-keys /exit + MagicMock(returncode=0), # send-keys /exit text + MagicMock(returncode=0), # send-keys /exit hex 0d ] with patch("factory.runners._tmux_persist.tempfile.mkdtemp", return_value=str(tmp_path / "tmp")): @@ -401,13 +469,14 @@ def spy_write_text(self_path: Path, content: str, *args, **kwargs) -> None: patch("factory.runners._tmux_persist._wait_for_sentinel", new_callable=AsyncMock, return_value=True), patch("factory.runners._tmux_persist._wait_for_window_exit", new_callable=AsyncMock), patch("factory.runners._tmux_persist._wait_for_exitcode", new_callable=AsyncMock, return_value=0), - patch("factory.runners._tmux_persist._window_exists", return_value=False), + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), patch.object(Path, "write_text", spy_write_text), ): mock_run.side_effect = [ MagicMock(returncode=0), # new-session - MagicMock(returncode=0), # send-keys /exit + MagicMock(returncode=0), # send-keys /exit text + MagicMock(returncode=0), # send-keys /exit hex 0d ] with patch("factory.runners._tmux_persist.tempfile.mkdtemp", return_value=str(tmp_path / "tmp")): @@ -430,6 +499,7 @@ async def test_returns_error_on_tmux_window_failure(self, tmp_path: Path) -> Non with ( patch("factory.runners._tmux_persist.subprocess.run") as mock_run, + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), ): mock_run.side_effect = [ MagicMock(returncode=1, stderr=b"error"), # new-session fails @@ -450,7 +520,7 @@ async def test_timeout_kills_tmux_window(self, tmp_path: Path) -> None: with ( patch("factory.runners._tmux_persist.subprocess.run") as mock_run, patch("factory.runners._tmux_persist._wait_for_sentinel", new_callable=AsyncMock, return_value=False), - + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), ): mock_run.side_effect = [ MagicMock(returncode=0), # new-session @@ -478,12 +548,13 @@ async def test_strips_ansi_from_output(self, tmp_path: Path) -> None: patch("factory.runners._tmux_persist._wait_for_sentinel", new_callable=AsyncMock, return_value=True), patch("factory.runners._tmux_persist._wait_for_window_exit", new_callable=AsyncMock), patch("factory.runners._tmux_persist._wait_for_exitcode", new_callable=AsyncMock, return_value=0), - patch("factory.runners._tmux_persist._window_exists", return_value=False), + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), ): mock_run.side_effect = [ MagicMock(returncode=0), # new-session - MagicMock(returncode=0), # send-keys /exit + MagicMock(returncode=0), # send-keys /exit text + MagicMock(returncode=0), # send-keys /exit hex 0d ] with patch("factory.runners._tmux_persist.tempfile.mkdtemp", return_value=str(tmp_path / "tmp")): @@ -508,12 +579,13 @@ async def test_sends_exit_after_sentinel(self, tmp_path: Path) -> None: patch("factory.runners._tmux_persist._wait_for_sentinel", new_callable=AsyncMock, return_value=True), patch("factory.runners._tmux_persist._wait_for_window_exit", new_callable=AsyncMock), patch("factory.runners._tmux_persist._wait_for_exitcode", new_callable=AsyncMock, return_value=0), - patch("factory.runners._tmux_persist._window_exists", return_value=False), + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), ): mock_run.side_effect = [ MagicMock(returncode=0), # new-session - MagicMock(returncode=0), # send-keys /exit + MagicMock(returncode=0), # send-keys /exit text + MagicMock(returncode=0), # send-keys /exit hex 0d ] with patch("factory.runners._tmux_persist.tempfile.mkdtemp", return_value=str(tmp_path / "tmp")): @@ -540,12 +612,13 @@ async def test_fallback_kill_window_when_window_still_alive(self, tmp_path: Path patch("factory.runners._tmux_persist._wait_for_sentinel", new_callable=AsyncMock, return_value=True), patch("factory.runners._tmux_persist._wait_for_window_exit", new_callable=AsyncMock), patch("factory.runners._tmux_persist._wait_for_exitcode", new_callable=AsyncMock, return_value=0), - patch("factory.runners._tmux_persist._window_exists", return_value=True), + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), ): mock_run.side_effect = [ MagicMock(returncode=0), # new-session - MagicMock(returncode=0), # send-keys /exit + MagicMock(returncode=0), # send-keys /exit text + MagicMock(returncode=0), # send-keys /exit hex 0d MagicMock(returncode=0), # kill-window fallback ] @@ -559,7 +632,7 @@ async def test_fallback_kill_window_when_window_still_alive(self, tmp_path: Path ) assert code == 0 - kill_call = mock_run.call_args_list[2] + kill_call = mock_run.call_args_list[3] cmd = kill_call[0][0] assert "kill-window" in cmd @@ -573,12 +646,13 @@ async def test_tmux_command_references_wrapper_script(self, tmp_path: Path) -> N patch("factory.runners._tmux_persist._wait_for_sentinel", new_callable=AsyncMock, return_value=True), patch("factory.runners._tmux_persist._wait_for_window_exit", new_callable=AsyncMock), patch("factory.runners._tmux_persist._wait_for_exitcode", new_callable=AsyncMock, return_value=0), - patch("factory.runners._tmux_persist._window_exists", return_value=False), + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), ): mock_run.side_effect = [ MagicMock(returncode=0), # new-session - MagicMock(returncode=0), # send-keys /exit + MagicMock(returncode=0), # send-keys /exit text + MagicMock(returncode=0), # send-keys /exit hex 0d ] with patch("factory.runners._tmux_persist.tempfile.mkdtemp", return_value=str(tmp_path / "tmp")): @@ -616,12 +690,13 @@ async def mock_wait_for_exitcode(exitcode_file: Path) -> int: patch("factory.runners._tmux_persist._wait_for_sentinel", new_callable=AsyncMock, return_value=True), patch("factory.runners._tmux_persist._wait_for_window_exit", new_callable=AsyncMock), patch("factory.runners._tmux_persist._wait_for_exitcode", side_effect=mock_wait_for_exitcode), - patch("factory.runners._tmux_persist._window_exists", return_value=False), + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), ): mock_run.side_effect = [ MagicMock(returncode=0), # new-session - MagicMock(returncode=0), # send-keys /exit + MagicMock(returncode=0), # send-keys /exit text + MagicMock(returncode=0), # send-keys /exit hex 0d ] with patch("factory.runners._tmux_persist.tempfile.mkdtemp", return_value=str(tmp_path / "tmp")): @@ -660,8 +735,8 @@ def track_subprocess_run(cmd, *args, **kwargs): with ( patch("factory.runners._tmux_persist.subprocess.run", side_effect=track_subprocess_run), patch("factory.runners._tmux_persist._wait_for_sentinel", side_effect=sentinel_raises_cancelled), - patch("factory.runners._tmux_persist._window_exists", return_value=True), + patch("factory.runners._tmux_persist._detect_vim_mode", return_value=False), ): with patch("factory.runners._tmux_persist.tempfile.mkdtemp", return_value=str(tmp_path / "tmp")): tmpdir = tmp_path / "tmp"