diff --git a/README.md b/README.md index e7ee01dc3..fa21f9f55 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ ALLOWED_USERS=your_telegram_user_id | `CLAUDE_COMMAND` | `claude` | Command to run in new windows | | `MONITOR_POLL_INTERVAL` | `2.0` | Polling interval in seconds | | `CCBOT_SHOW_HIDDEN_DIRS` | `false` | Show hidden (dot) directories in directory browser | +| `CCBOT_AUTO_TRUST_DIRS` | `true` | Mark the project trusted in `~/.claude.json` before launching `claude`, so the workspace trust dialog doesn't block new sessions | | `OPENAI_API_KEY` | _(none)_ | OpenAI API key for voice message transcription | | `OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI API base URL (for proxies or compatible APIs) | diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 809efcc9d..ce959e204 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -57,6 +57,7 @@ filters, ) +from .claude_config import ensure_trusted_directory from .config import config from .handlers.callback_data import ( CB_ASK_DOWN, @@ -1034,6 +1035,9 @@ async def _create_and_bind_window( assert isinstance(query, CallbackQuery) assert isinstance(user, User) + if config.auto_trust_dirs: + await asyncio.to_thread(ensure_trusted_directory, selected_path) + success, message, created_wname, created_wid = await tmux_manager.create_window( selected_path, resume_session_id=resume_session_id ) @@ -1047,6 +1051,12 @@ async def _create_and_bind_window( pending_thread_id, resume_session_id, ) + # Wait for Claude Code's input box first. This answers the workspace + # trust dialog if it still appears; the SessionStart hook only fires + # once Claude is past it, and a pending message must not be typed + # into the dialog (Enter on the default option exits Claude). + await tmux_manager.wait_for_claude_ready(created_wid, timeout=30.0) + # Wait for Claude Code's SessionStart hook to register in session_map. # Resume sessions take longer to start (loading session state), so use # a longer timeout to avoid silently dropping messages. diff --git a/src/ccbot/claude_config.py b/src/ccbot/claude_config.py new file mode 100644 index 000000000..42e23250b --- /dev/null +++ b/src/ccbot/claude_config.py @@ -0,0 +1,101 @@ +"""Helpers for Claude Code's own config file (~/.claude.json). + +Pre-trusts a working directory before launching `claude` in it so the +interactive "Quick safety check … Yes, I trust this folder" dialog never +appears. Since ~2.1.260 that dialog defaults to "No, exit", which left +bot-created sessions stuck at the prompt with the SessionStart hook never +firing. + +Key functions: + - claude_config_file(): path of ~/.claude.json (honours CLAUDE_CONFIG_DIR) + - trust_key_for(work_dir): the projects[] key Claude uses (git root or dir) + - ensure_trusted_directory(work_dir): set hasTrustDialogAccepted=true +""" + +import json +import logging +import os +import subprocess +from pathlib import Path +from typing import Any + +from .utils import atomic_write_json + +logger = logging.getLogger(__name__) + + +def claude_config_file() -> Path: + """Location of Claude Code's user config JSON.""" + config_dir = os.environ.get("CLAUDE_CONFIG_DIR", "") + if config_dir: + return Path(config_dir).expanduser() / ".claude.json" + return Path.home() / ".claude.json" + + +def trust_key_for(work_dir: str | Path) -> str: + """Return the ``projects`` key Claude Code trusts ``work_dir`` under. + + Trust is keyed on the git repository root (main checkout root for + worktrees); outside a repo, on the directory itself. + """ + path = Path(work_dir).expanduser().resolve() + try: + result = subprocess.run( + ["git", "-C", str(path), "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode == 0 and result.stdout.strip(): + return str(Path(result.stdout.strip()).resolve()) + except (OSError, subprocess.SubprocessError): + pass + return str(path) + + +def ensure_trusted_directory(work_dir: str | Path) -> bool: + """Mark ``work_dir`` as trusted in ~/.claude.json. + + Returns True when the directory is trusted after the call (already + trusted or newly written), False when the config could not be updated + (missing/malformed file is left untouched — the pane-level dialog + handling in TmuxManager remains as the fallback). + """ + key = trust_key_for(work_dir) + cfg_path = claude_config_file() + if not cfg_path.exists(): + logger.info("No %s yet; skipping trust pre-accept for %s", cfg_path, key) + return False + try: + data: Any = json.loads(cfg_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as e: + logger.warning("Cannot read %s (%s); not touching it", cfg_path, e) + return False + if not isinstance(data, dict): + logger.warning("%s is not a JSON object; not touching it", cfg_path) + return False + + projects = data.get("projects") + if projects is None: + projects = data["projects"] = {} + if not isinstance(projects, dict): + logger.warning("%s: 'projects' is not an object; not touching it", cfg_path) + return False + + entry = projects.get(key) + if isinstance(entry, dict) and entry.get("hasTrustDialogAccepted") is True: + return True + if not isinstance(entry, dict): + entry = projects[key] = {} + entry["hasTrustDialogAccepted"] = True + + try: + mode = cfg_path.stat().st_mode & 0o777 + atomic_write_json(cfg_path, data) + os.chmod(cfg_path, mode) + except OSError as e: + logger.warning("Failed to write %s: %s", cfg_path, e) + return False + logger.info("Pre-trusted %s in %s", key, cfg_path) + return True diff --git a/src/ccbot/config.py b/src/ccbot/config.py index 22d1de768..241b217ac 100644 --- a/src/ccbot/config.py +++ b/src/ccbot/config.py @@ -96,6 +96,12 @@ def __init__(self) -> None: os.getenv("CCBOT_SHOW_TOOL_CALLS", "true").lower() != "false" ) + # Pre-trust directories in ~/.claude.json before launching claude, + # so Claude Code's workspace trust dialog doesn't block new sessions + self.auto_trust_dirs = ( + os.getenv("CCBOT_AUTO_TRUST_DIRS", "true").lower() != "false" + ) + # Show hidden (dot) directories in directory browser self.show_hidden_dirs = ( os.getenv("CCBOT_SHOW_HIDDEN_DIRS", "").lower() == "true" diff --git a/src/ccbot/handlers/status_polling.py b/src/ccbot/handlers/status_polling.py index c4de1c6ec..101a40dd5 100644 --- a/src/ccbot/handlers/status_polling.py +++ b/src/ccbot/handlers/status_polling.py @@ -24,7 +24,12 @@ from telegram.error import BadRequest from ..session import session_manager -from ..terminal_parser import is_interactive_ui, parse_status_line +from ..terminal_parser import ( + AUTO_ANSWER_DIALOGS, + extract_interactive_content, + is_interactive_ui, + parse_status_line, +) from ..tmux_manager import tmux_manager from .interactive_ui import ( clear_interactive_msg, @@ -90,6 +95,14 @@ async def update_status_message( # Clear stale interactive mode await clear_interactive_msg(user_id, bot, thread_id) + # Startup dialogs (workspace trust) are answered by the bot itself + # rather than rendered as a keyboard. + if should_check_new_ui: + ui = extract_interactive_content(pane_text) + if ui is not None and ui.name in AUTO_ANSWER_DIALOGS: + await tmux_manager.auto_answer_dialog(window_id, pane_text) + return + # Check for permission prompt (interactive UI not triggered via JSONL) # ALWAYS check UI, regardless of skip_status if should_check_new_ui and is_interactive_ui(pane_text): diff --git a/src/ccbot/terminal_parser.py b/src/ccbot/terminal_parser.py index 1afefed05..a6be3ce31 100644 --- a/src/ccbot/terminal_parser.py +++ b/src/ccbot/terminal_parser.py @@ -47,6 +47,17 @@ class UIPattern: # ── UI pattern definitions (order matters — first match wins) ──────────── UI_PATTERNS: list[UIPattern] = [ + UIPattern( + # Workspace trust dialog shown on first launch in a directory. + # Since ~2.1.260 the options are unnumbered and "No, exit" is the + # default, so an unanswered or blindly-confirmed dialog ends the session. + name="TrustDialog", + top=( + re.compile(r"^\s*Quick safety check"), + re.compile(r"^\s*Do you trust the files in this folder"), + ), + bottom=(re.compile(r"Enter to confirm"),), + ), UIPattern( name="ExitPlanMode", top=( @@ -118,6 +129,14 @@ class UIPattern: ] +# Dialogs the bot answers itself: name -> text of the option to select. +AUTO_ANSWER_DIALOGS: dict[str, str] = { + "TrustDialog": "Yes, I trust", +} + +_RE_MENU_CURSOR = re.compile(r"^\s*❯\s*\S") + + # ── Post-processing ────────────────────────────────────────────────────── _RE_LONG_DASH = re.compile(r"^─{5,}$") @@ -193,6 +212,65 @@ def is_interactive_ui(pane_text: str) -> bool: return extract_interactive_content(pane_text) is not None +def find_menu_option(pane_text: str, needle: str) -> tuple[int, int] | None: + """Locate the highlighted (``❯``) line and the option containing ``needle``. + + Returns ``(cursor_idx, option_idx)`` line indices; pressing Down + ``option_idx - cursor_idx`` times moves the cursor onto the option. + The option is searched from the bottom (dialogs render at the end of the + pane) and the cursor is the ``❯`` nearest to it, so an earlier shell + prompt such as ``❯ claude`` is never mistaken for the menu cursor. + """ + lines = pane_text.split("\n") + option_idx = next( + (i for i in range(len(lines) - 1, -1, -1) if needle in lines[i]), None + ) + if option_idx is None: + return None + lo, hi = max(0, option_idx - 10), min(len(lines), option_idx + 11) + cursor_idx = min( + (i for i in range(lo, hi) if _RE_MENU_CURSOR.match(lines[i])), + key=lambda i: abs(i - option_idx), + default=None, + ) + if cursor_idx is None: + return None + return cursor_idx, option_idx + + +def _is_separator(line: str) -> bool: + """A chrome border line. + + Named sessions (--resume , --name, /rename) put the name in the + input box's top border: "──────── my-session ─" (one trailing dash). + """ + stripped = line.strip() + if len(stripped) < 20 or not stripped.startswith("────"): + return False + return stripped.endswith("─") and stripped.count("─") / len(stripped) >= 0.6 + + +def is_prompt_ready(pane_text: str) -> bool: + """True when Claude Code shows its idle input box. + + Layout at the bottom of the pane:: + + ──────────────── (separator) + ❯ … (input line) + ──────────────── (separator) + ⏵⏵ … (footer) + """ + if not pane_text: + return False + tail = pane_text.rstrip().split("\n")[-8:] + for i in range(len(tail) - 2): + if not _is_separator(tail[i]) or not tail[i + 1].lstrip().startswith("❯"): + continue + if any(_is_separator(tail[j]) for j in range(i + 2, min(i + 5, len(tail)))): + return True + return False + + # ── Status line parsing ───────────────────────────────────────────────── # Spinner characters Claude Code uses in its status line diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index 1b880baca..eb5bdcb1d 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -16,12 +16,19 @@ import asyncio import logging import re +import time from dataclasses import dataclass from pathlib import Path import libtmux from .config import SENSITIVE_ENV_VARS, config +from .terminal_parser import ( + AUTO_ANSWER_DIALOGS, + extract_interactive_content, + find_menu_option, + is_prompt_ready, +) logger = logging.getLogger(__name__) @@ -229,6 +236,65 @@ def _sync_capture() -> str | None: return await asyncio.to_thread(_sync_capture) + async def auto_answer_dialog(self, window_id: str, pane_text: str) -> str | None: + """Answer a startup dialog the bot handles itself (workspace trust). + + Moves the ``❯`` cursor onto the wanted option with Up/Down, re-captures + to verify it landed there, then presses Enter. Never types a digit — + the option order has changed between Claude Code versions. + + Returns the dialog name when answered, else None. + """ + ui = extract_interactive_content(pane_text) + if ui is None or ui.name not in AUTO_ANSWER_DIALOGS: + return None + needle = AUTO_ANSWER_DIALOGS[ui.name] + offsets = find_menu_option(pane_text, needle) + if offsets is None: + logger.warning("%s in %s: option %r not found", ui.name, window_id, needle) + return None + cursor, target = offsets + steps = target - cursor + key = "Down" if steps > 0 else "Up" + for _ in range(abs(steps)): + await self.send_keys(window_id, key, enter=False, literal=False) + await asyncio.sleep(0.15) + if steps: + await asyncio.sleep(0.3) + check = find_menu_option(await self.capture_pane(window_id) or "", needle) + if check is None or check[0] != check[1]: + logger.warning( + "%s in %s: cursor did not reach %r, not confirming", + ui.name, + window_id, + needle, + ) + return None + await self.send_keys(window_id, "Enter", enter=False, literal=False) + logger.info("Answered %s in %s with %r", ui.name, window_id, needle) + return ui.name + + async def wait_for_claude_ready( + self, window_id: str, timeout: float = 30.0 + ) -> bool: + """Wait until Claude Code shows its input box, answering startup dialogs. + + Returns False on timeout (the caller still proceeds; this only avoids + typing the first message into a dialog or a half-started TUI). + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + pane = await self.capture_pane(window_id) + if pane: + if is_prompt_ready(pane): + return True + if await self.auto_answer_dialog(window_id, pane): + await asyncio.sleep(1.0) + continue + await asyncio.sleep(0.5) + logger.warning("Claude Code not ready in %s after %.0fs", window_id, timeout) + return False + async def send_keys( self, window_id: str, text: str, enter: bool = True, literal: bool = True ) -> bool: diff --git a/tests/ccbot/fixtures/panes/ready_bypass.txt b/tests/ccbot/fixtures/panes/ready_bypass.txt new file mode 100644 index 000000000..15ab75f61 --- /dev/null +++ b/tests/ccbot/fixtures/panes/ready_bypass.txt @@ -0,0 +1,24 @@ + + ▐▛███▛█ Claude Code v2.1.272 +▝▜██████▀ Opus 5 with high effort · Claude Pro + ▝▝ ▝▝ /private/tmp + + + + + + + + + + + + + + + + ● high · /effort +──────────────────────────────────────────────────────────────────────────────── +❯  +──────────────────────────────────────────────────────────────────────────────── + ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents diff --git a/tests/ccbot/fixtures/panes/ready_labelled_separator.txt b/tests/ccbot/fixtures/panes/ready_labelled_separator.txt new file mode 100644 index 000000000..48ce047f2 --- /dev/null +++ b/tests/ccbot/fixtures/panes/ready_labelled_separator.txt @@ -0,0 +1,51 @@ + + ✕ +● Everything I need is done except committing the docs. Two things remain, both depending only on the + edit that just landed: a structural sanity check on the file, and the commit/push. I'll chain them so + the commit only runs if the file still parses as expected. + + Thought for 7s, committed 11d9edc, pushed to main + +● Nothing further needed — the work is complete and verified end to end. Both commits are pushed. + + Done: TON sweep floor 0.2 → 0.05 + + The change — MIN_TRANSFER_THRESHOLD: "0.05" on the x-ton-shkeeper-env anchor in + deploy/shkeeper/docker-compose.yml. It wasn't in any of our files before: the image reads it from the + environment with the default baked in (config.py:45), which is why grepping the repo and .env found + nothing. Synced to /root/shkeeper/shkeeper/ with matching checksums, YAML validated, and confirmed to + render into both ton-shkeeper and ton-shkeeper-worker — the worker matters, it's what runs the drain. + + It worked on the real stranded payment. Invoice 17 swept on the first triggered run: + + - 197172600 → 0 nanotons, txid 1c7b1261…, task status: 'success' + - forwarded on to the payout wallet, payout #10 → SUCCESS + - invoice 11 (0.00342317) correctly left alone — worth less than the gas to move + + Two things I checked that could have gone wrong quietly: No changes this session + + The fee-deposit wallet barely moved (+41 nanotons), which looks like a failed sweep. It isn't — + wallet.ppolicy=LIMIT with pcond=0.1 auto-forwards anything over 0.1 TON straight to wallet.pdest. I + confirmed that destination against the wallet table rather than inferring it from history, since money + was moving to an address I hadn't verified. + + The scanner kept its place across the restart: gap 10 blocks, zero 404/500/429. + + One decision left with you: jettons use a separate floor, MIN_TOKEN_TRANSFER_THRESHOLD, still at its + default 0.5 — and that's USDT, not TON. I deliberately didn't touch it, since "0.05" in a different + unit is a different call. Say the word if you want TON-USDT lowered too. + + Commits: 74a4e0a (config), 11d9edc (lessons). + + Two corrections worth recording about my own work here. I claimed a scanner gap of -92,846,348 + mid-session — that was my parse bug, not a fault: getMasterchainInfo returns an init block with + "seqno":0 after the real one and my tail -1 grabbed the zero. And I'd believed that trap was already + in agent-lessons.md from earlier this session; it never actually got written, which is exactly why I + repeated it. Both are now entries in the file. + +✻ Baked for 12m 20s · done 12:41 PM + +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── royal-vpn-main ─ +❯  +───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ⏵⏵ accept edits on (shift+tab to cycle) · ← for agents diff --git a/tests/ccbot/fixtures/panes/ready_manual_mode.txt b/tests/ccbot/fixtures/panes/ready_manual_mode.txt new file mode 100644 index 000000000..91e39b686 --- /dev/null +++ b/tests/ccbot/fixtures/panes/ready_manual_mode.txt @@ -0,0 +1,24 @@ + + + Ran 1 shell command + +⏺ Darwin host.local 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:14 + PST 2026; root:xnu-12377.81.4~5/RELEASE_X86_64 x86_64 + +✻ Worked for 5s · done 1:49 PM + +❯ Create a file /tmp/ccbot-test-perm.txt containing the single word hello, + using the Write tool. + +⏺ Write(/tmp/ccbot-test-perm.txt) + ⎿  Wrote 1 line to ../../tmp/ccbot-test-perm.txt + 1 hello + +⏺ Created /tmp/ccbot-test-perm.txt containing hello. + +✻ Churned for 4s · done 1:50 PM + +──────────────────────────────────────────────────────────────────────────────── +❯ Read the file back with the Read tool +──────────────────────────────────────────────────────────────────────────────── + ⏸ manual mode on · ? for shortcuts · ← for agents diff --git a/tests/ccbot/fixtures/panes/trust_dialog.txt b/tests/ccbot/fixtures/panes/trust_dialog.txt new file mode 100644 index 000000000..e89a99c2e --- /dev/null +++ b/tests/ccbot/fixtures/panes/trust_dialog.txt @@ -0,0 +1,24 @@ + /tmp ୨୧ Mh  01:44:07 PM +❯ claude + +──────────────────────────────────────────────────────────────────────────────── + Accessing workspace: + + /private/tmp + + Quick safety check: Is this a project you created or one you trust? (Like your + own code, a well-known open source project, or work from your team). If not, + take a moment to review what's in this folder first. + + Claude Code'll be able to read, edit, and execute files here. + + Security guide + + ❯ No, exit + Yes, I trust this folder + + Enter to confirm · Esc to cancel + + + + diff --git a/tests/ccbot/test_claude_config.py b/tests/ccbot/test_claude_config.py new file mode 100644 index 000000000..3b6480d93 --- /dev/null +++ b/tests/ccbot/test_claude_config.py @@ -0,0 +1,83 @@ +"""Tests for claude_config.ensure_trusted_directory.""" + +import json +import subprocess + +import pytest + +import ccbot.claude_config as cc + + +@pytest.fixture +def cfg(tmp_path, monkeypatch): + path = tmp_path / ".claude.json" + monkeypatch.setattr(cc, "claude_config_file", lambda: path) + # No git: key is the directory itself + monkeypatch.setattr( + cc.subprocess, + "run", + lambda *a, **k: subprocess.CompletedProcess(a, 128, stdout="", stderr=""), + ) + return path + + +class TestEnsureTrustedDirectory: + def test_adds_entry_and_keeps_other_keys(self, cfg, tmp_path): + cfg.write_text(json.dumps({"theme": "dark", "projects": {"/x": {"a": 1}}})) + cfg.chmod(0o600) + work = tmp_path / "proj" + work.mkdir() + assert cc.ensure_trusted_directory(work) is True + data = json.loads(cfg.read_text()) + assert data["theme"] == "dark" + assert data["projects"]["/x"] == {"a": 1} + assert data["projects"][str(work.resolve())] == {"hasTrustDialogAccepted": True} + assert cfg.stat().st_mode & 0o777 == 0o600 + + def test_noop_when_already_trusted(self, cfg, tmp_path): + work = tmp_path / "proj" + work.mkdir() + cfg.write_text( + json.dumps( + {"projects": {str(work.resolve()): {"hasTrustDialogAccepted": True}}} + ) + ) + before = cfg.read_text() + assert cc.ensure_trusted_directory(work) is True + assert cfg.read_text() == before + + def test_existing_entry_gets_flag(self, cfg, tmp_path): + work = tmp_path / "proj" + work.mkdir() + key = str(work.resolve()) + cfg.write_text( + json.dumps({"projects": {key: {"hasTrustDialogAccepted": False, "k": 2}}}) + ) + assert cc.ensure_trusted_directory(work) is True + data = json.loads(cfg.read_text()) + assert data["projects"][key] == {"hasTrustDialogAccepted": True, "k": 2} + + def test_missing_file_untouched(self, cfg, tmp_path): + assert cc.ensure_trusted_directory(tmp_path) is False + assert not cfg.exists() + + def test_malformed_file_untouched(self, cfg, tmp_path): + cfg.write_text("{not json") + assert cc.ensure_trusted_directory(tmp_path) is False + assert cfg.read_text() == "{not json" + + def test_git_root_used_as_key(self, cfg, tmp_path, monkeypatch): + cfg.write_text(json.dumps({"projects": {}})) + root = tmp_path / "repo" + sub = root / "src" / "pkg" + sub.mkdir(parents=True) + monkeypatch.setattr( + cc.subprocess, + "run", + lambda *a, **k: subprocess.CompletedProcess( + a, 0, stdout=f"{root}\n", stderr="" + ), + ) + assert cc.ensure_trusted_directory(sub) is True + data = json.loads(cfg.read_text()) + assert list(data["projects"]) == [str(root.resolve())] diff --git a/tests/ccbot/test_trust_dialog.py b/tests/ccbot/test_trust_dialog.py new file mode 100644 index 000000000..120c441aa --- /dev/null +++ b/tests/ccbot/test_trust_dialog.py @@ -0,0 +1,99 @@ +"""Tests for workspace-trust dialog detection and auto-answering. + +Fixtures are real `tmux capture-pane` outputs from Claude Code 2.1.272. +""" + +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +import ccbot.tmux_manager as tm +from ccbot.terminal_parser import ( + extract_interactive_content, + find_menu_option, + is_prompt_ready, +) + +FIXTURES = Path(__file__).parent / "fixtures" / "panes" + + +def load(name: str) -> str: + return (FIXTURES / name).read_text(encoding="utf-8") + + +class TestDetection: + def test_trust_dialog_detected(self): + ui = extract_interactive_content(load("trust_dialog.txt")) + assert ui is not None + assert ui.name == "TrustDialog" + assert "Yes, I trust this folder" in ui.content + + def test_trust_dialog_is_not_prompt_ready(self): + assert not is_prompt_ready(load("trust_dialog.txt")) + + @pytest.mark.parametrize( + "name", + ["ready_bypass.txt", "ready_manual_mode.txt", "ready_labelled_separator.txt"], + ) + def test_ready_prompt(self, name): + pane = load(name) + assert is_prompt_ready(pane) + assert extract_interactive_content(pane) is None + + def test_option_is_one_below_cursor(self): + # "❯ No, exit" is highlighted; the shell's "❯ claude" line above + # must not be taken for the menu cursor. + offsets = find_menu_option(load("trust_dialog.txt"), "Yes, I trust") + assert offsets is not None + cursor, option = offsets + assert option - cursor == 1 + + def test_missing_option(self): + assert find_menu_option(load("trust_dialog.txt"), "nope") is None + + +class TestAutoAnswer: + @pytest.fixture + def mgr(self, monkeypatch): + monkeypatch.setattr(tm.asyncio, "sleep", AsyncMock()) + m = tm.TmuxManager(session_name="ccbot") + m.send_keys = AsyncMock(return_value=True) # type: ignore[method-assign] + return m + + @staticmethod + def _moved(pane: str) -> str: + return pane.replace(" ❯ No, exit", " No, exit").replace( + " Yes, I trust this folder", " ❯ Yes, I trust this folder" + ) + + @pytest.mark.asyncio + async def test_moves_down_verifies_then_enter(self, mgr): + pane = load("trust_dialog.txt") + mgr.capture_pane = AsyncMock(return_value=self._moved(pane)) # type: ignore[method-assign] + assert await mgr.auto_answer_dialog("@1", pane) == "TrustDialog" + keys = [c.args[1] for c in mgr.send_keys.await_args_list] + assert keys == ["Down", "Enter"] + + @pytest.mark.asyncio + async def test_does_not_confirm_if_cursor_did_not_move(self, mgr): + pane = load("trust_dialog.txt") + mgr.capture_pane = AsyncMock(return_value=pane) # type: ignore[method-assign] + assert await mgr.auto_answer_dialog("@1", pane) is None + keys = [c.args[1] for c in mgr.send_keys.await_args_list] + assert "Enter" not in keys + + @pytest.mark.asyncio + async def test_ignores_other_panes(self, mgr): + assert await mgr.auto_answer_dialog("@1", load("ready_bypass.txt")) is None + mgr.send_keys.assert_not_awaited() + + @pytest.mark.asyncio + async def test_wait_for_ready_answers_dialog(self, mgr): + trust = load("trust_dialog.txt") + mgr.capture_pane = AsyncMock( # type: ignore[method-assign] + side_effect=[trust, self._moved(trust), load("ready_bypass.txt")] + ) + assert await mgr.wait_for_claude_ready("@1", timeout=5) is True + keys = [c.args[1] for c in mgr.send_keys.await_args_list] + assert keys == ["Down", "Enter"]