From 4b52e39c1567a9d646bc6dad2f1ec13eaa8c49a1 Mon Sep 17 00:00:00 2001 From: Vadim Mitroshkin Date: Sun, 1 Mar 2026 11:46:34 +0300 Subject: [PATCH 1/5] chore: add mistletoe dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index f02ba25c..27ac8cf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "libtmux>=0.37.0", "Pillow>=10.0.0", "aiofiles>=24.0.0", + "mistletoe>=1.4.0", "telegramify-markdown>=0.5.0", ] From 5ad5da2a1516568076c2bcc2069a3e221755d99d Mon Sep 17 00:00:00 2001 From: Vadim Mitroshkin Date: Sat, 28 Feb 2026 17:37:23 +0300 Subject: [PATCH 2/5] Fix tmux session cwd isolation for Claude/uv startup --- src/ccbot/bot.py | 17 +++++++++++------ src/ccbot/handlers/directory_browser.py | 2 +- src/ccbot/tmux_manager.py | 6 +++++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index a747425e..8d5577d8 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -170,6 +170,11 @@ def _get_thread_id(update: Update) -> int | None: return tid +def _default_browse_path() -> str: + """Default root for directory browser navigation.""" + return str(Path.home()) + + # --- Command handlers --- @@ -837,7 +842,7 @@ async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No user.id, thread_id, ) - start_path = str(Path.cwd()) + start_path = _default_browse_path() msg_text, keyboard, subdirs = build_directory_browser(start_path) if context.user_data is not None: context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY @@ -1109,7 +1114,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - return subdir_name = cached_dirs[idx] - default_path = str(Path.cwd()) + default_path = _default_browse_path() current_path = ( context.user_data.get(BROWSE_PATH_KEY, default_path) if context.user_data @@ -1139,7 +1144,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - if pending_tid is not None and _get_thread_id(update) != pending_tid: await query.answer("Stale browser (topic mismatch)", show_alert=True) return - default_path = str(Path.cwd()) + default_path = _default_browse_path() current_path = ( context.user_data.get(BROWSE_PATH_KEY, default_path) if context.user_data @@ -1172,7 +1177,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - except ValueError: await query.answer("Invalid data") return - default_path = str(Path.cwd()) + default_path = _default_browse_path() current_path = ( context.user_data.get(BROWSE_PATH_KEY, default_path) if context.user_data @@ -1188,7 +1193,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - await query.answer() elif data == CB_DIR_CONFIRM: - default_path = str(Path.cwd()) + default_path = _default_browse_path() selected_path = ( context.user_data.get(BROWSE_PATH_KEY, default_path) if context.user_data @@ -1409,7 +1414,7 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) - return # Preserve pending thread info, clear only picker state clear_window_picker_state(context.user_data) - start_path = str(Path.cwd()) + start_path = _default_browse_path() msg_text, keyboard, subdirs = build_directory_browser(start_path) if context.user_data is not None: context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY diff --git a/src/ccbot/handlers/directory_browser.py b/src/ccbot/handlers/directory_browser.py index b443e16e..24e31b6c 100644 --- a/src/ccbot/handlers/directory_browser.py +++ b/src/ccbot/handlers/directory_browser.py @@ -128,7 +128,7 @@ def build_directory_browser( """ path = Path(current_path).expanduser().resolve() if not path.exists() or not path.is_dir(): - path = Path.cwd() + path = Path.home() try: subdirs = sorted( diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index f05b4f3a..28ba7828 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -15,6 +15,7 @@ import asyncio import logging +import shlex from dataclasses import dataclass from pathlib import Path @@ -421,7 +422,10 @@ def _create_and_start() -> tuple[bool, str, str, str]: cmd = config.claude_command if resume_session_id: cmd = f"{cmd} --resume {resume_session_id}" - pane.send_keys(cmd, enter=True) + # Force cwd before launching Claude to avoid inheriting + # an unexpected project context in nested tmux setups. + launch_cmd = f"cd {shlex.quote(str(path))} && {cmd}" + pane.send_keys(launch_cmd, enter=True) logger.info( "Created window '%s' (id=%s) at %s", From b7986c81f96284fa2f5c5421385e06375350b42b Mon Sep 17 00:00:00 2001 From: Vadim Mitroshkin Date: Sat, 28 Feb 2026 17:48:42 +0300 Subject: [PATCH 3/5] Implement /kill command handling in bot --- src/ccbot/bot.py | 44 ++++++++++++++- tests/ccbot/test_kill_command.py | 95 ++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/ccbot/test_kill_command.py diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index 8d5577d8..ca8a3781 100644 --- a/src/ccbot/bot.py +++ b/src/ccbot/bot.py @@ -248,6 +248,47 @@ async def screenshot_command( ) +async def kill_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Kill the bound tmux window for this topic and unbind the thread.""" + user = update.effective_user + if not user or not is_user_allowed(user.id): + return + if not update.message: + return + + thread_id = _get_thread_id(update) + if thread_id is None: + await safe_reply(update.message, "❌ This command only works in a topic.") + return + + wid = session_manager.get_window_for_thread(user.id, thread_id) + if not wid: + await safe_reply(update.message, "❌ No session bound to this topic.") + return + + display = session_manager.get_display_name(wid) + w = await tmux_manager.find_window_by_id(wid) + if w: + killed = await tmux_manager.kill_window(w.window_id) + if not killed: + await safe_reply(update.message, f"❌ Failed to kill window '{display}'.") + return + else: + logger.info( + "Kill requested: window %s already gone (user=%d, thread=%d)", + display, + user.id, + thread_id, + ) + + session_manager.unbind_thread(user.id, thread_id) + await clear_topic_state(user.id, thread_id, context.bot, context.user_data) + await safe_reply( + update.message, + f"✅ Killed window '{display}' and unbound this topic.", + ) + + async def unbind_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Unbind this topic from its Claude session without killing the window.""" user = update.effective_user @@ -1720,7 +1761,7 @@ async def post_init(application: Application) -> None: BotCommand("history", "Message history for this topic"), BotCommand("screenshot", "Terminal screenshot with control keys"), BotCommand("esc", "Send Escape to interrupt Claude"), - BotCommand("kill", "Kill session and delete topic"), + BotCommand("kill", "Kill session window and unbind topic"), BotCommand("unbind", "Unbind topic from session (keeps window running)"), BotCommand("usage", "Show Claude Code usage remaining"), ] @@ -1794,6 +1835,7 @@ def create_bot() -> Application: application.add_handler(CommandHandler("history", history_command)) application.add_handler(CommandHandler("screenshot", screenshot_command)) application.add_handler(CommandHandler("esc", esc_command)) + application.add_handler(CommandHandler("kill", kill_command)) application.add_handler(CommandHandler("unbind", unbind_command)) application.add_handler(CommandHandler("usage", usage_command)) application.add_handler(CallbackQueryHandler(callback_handler)) diff --git a/tests/ccbot/test_kill_command.py b/tests/ccbot/test_kill_command.py new file mode 100644 index 00000000..9899eb3d --- /dev/null +++ b/tests/ccbot/test_kill_command.py @@ -0,0 +1,95 @@ +"""Tests for /kill bot command handler.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +def _make_update(user_id: int = 1, thread_id: int = 42) -> MagicMock: + update = MagicMock() + update.effective_user = MagicMock() + update.effective_user.id = user_id + update.message = MagicMock() + update.message.message_thread_id = thread_id + return update + + +def _make_context() -> MagicMock: + context = MagicMock() + context.bot = AsyncMock() + context.user_data = {} + return context + + +class TestKillCommand: + @pytest.mark.asyncio + async def test_kills_window_and_unbinds_topic(self): + update = _make_update() + context = _make_context() + + with ( + patch("ccbot.bot.is_user_allowed", return_value=True), + patch("ccbot.bot._get_thread_id", return_value=42), + patch("ccbot.bot.session_manager") as mock_sm, + patch("ccbot.bot.tmux_manager") as mock_tmux, + patch("ccbot.bot.clear_topic_state", new_callable=AsyncMock), + patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply, + ): + mock_sm.get_window_for_thread.return_value = "@5" + mock_sm.get_display_name.return_value = "project" + mock_tmux.find_window_by_id = AsyncMock( + return_value=MagicMock(window_id="@5") + ) + mock_tmux.kill_window = AsyncMock(return_value=True) + + from ccbot.bot import kill_command + + await kill_command(update, context) + + mock_tmux.kill_window.assert_called_once_with("@5") + mock_sm.unbind_thread.assert_called_once_with(1, 42) + mock_reply.assert_called() + + @pytest.mark.asyncio + async def test_no_binding_returns_error(self): + update = _make_update() + context = _make_context() + + with ( + patch("ccbot.bot.is_user_allowed", return_value=True), + patch("ccbot.bot._get_thread_id", return_value=42), + patch("ccbot.bot.session_manager") as mock_sm, + patch("ccbot.bot.safe_reply", new_callable=AsyncMock) as mock_reply, + ): + mock_sm.get_window_for_thread.return_value = None + + from ccbot.bot import kill_command + + await kill_command(update, context) + + mock_reply.assert_called_once() + assert "No session bound" in mock_reply.call_args.args[1] + + @pytest.mark.asyncio + async def test_window_already_gone_still_unbinds(self): + update = _make_update() + context = _make_context() + + with ( + patch("ccbot.bot.is_user_allowed", return_value=True), + patch("ccbot.bot._get_thread_id", return_value=42), + patch("ccbot.bot.session_manager") as mock_sm, + patch("ccbot.bot.tmux_manager") as mock_tmux, + patch("ccbot.bot.clear_topic_state", new_callable=AsyncMock), + patch("ccbot.bot.safe_reply", new_callable=AsyncMock), + ): + mock_sm.get_window_for_thread.return_value = "@5" + mock_sm.get_display_name.return_value = "project" + mock_tmux.find_window_by_id = AsyncMock(return_value=None) + + from ccbot.bot import kill_command + + await kill_command(update, context) + + mock_tmux.kill_window.assert_not_called() + mock_sm.unbind_thread.assert_called_once_with(1, 42) From 95ecaf7835cefcc680c065164c4679e038de3015 Mon Sep 17 00:00:00 2001 From: Vadim Mitroshkin Date: Sat, 28 Feb 2026 18:01:37 +0300 Subject: [PATCH 4/5] Sanitize UV env when launching Claude in tmux window --- src/ccbot/tmux_manager.py | 27 ++++++++++++++++++++------ tests/ccbot/test_tmux_manager.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 tests/ccbot/test_tmux_manager.py diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index 28ba7828..730eb8d2 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -93,6 +93,24 @@ def _scrub_session_env(session: libtmux.Session) -> None: except Exception: pass # var not set in session env — nothing to remove + def _build_claude_launch_command( + self, path: Path, resume_session_id: str | None = None + ) -> str: + """Build shell command to launch Claude in a clean project context. + + `uv run ccbot` exports VIRTUAL_ENV and UV_* variables from the bot's + own project. Unset them so `uv` calls inside Claude resolve against + the selected directory, not the bot workspace. + """ + cmd = config.claude_command + if resume_session_id: + cmd = f"{cmd} --resume {resume_session_id}" + return ( + f"cd {shlex.quote(str(path))} && " + "unset VIRTUAL_ENV UV_PROJECT UV_WORKING_DIRECTORY && " + f"{cmd}" + ) + async def list_windows(self) -> list[TmuxWindow]: """List all windows in the session with their working directories. @@ -419,12 +437,9 @@ def _create_and_start() -> tuple[bool, str, str, str]: if start_claude: pane = window.active_pane if pane: - cmd = config.claude_command - if resume_session_id: - cmd = f"{cmd} --resume {resume_session_id}" - # Force cwd before launching Claude to avoid inheriting - # an unexpected project context in nested tmux setups. - launch_cmd = f"cd {shlex.quote(str(path))} && {cmd}" + launch_cmd = self._build_claude_launch_command( + path, resume_session_id + ) pane.send_keys(launch_cmd, enter=True) logger.info( diff --git a/tests/ccbot/test_tmux_manager.py b/tests/ccbot/test_tmux_manager.py new file mode 100644 index 00000000..eff8c751 --- /dev/null +++ b/tests/ccbot/test_tmux_manager.py @@ -0,0 +1,33 @@ +"""Unit tests for tmux manager helpers.""" + +from pathlib import Path + +from ccbot.config import config +from ccbot.tmux_manager import TmuxManager + + +def test_build_claude_launch_command_unsets_env_vars(monkeypatch: object) -> None: + manager = TmuxManager(session_name="test") + monkeypatch.setattr(config, "claude_command", "claude") + + cmd = manager._build_claude_launch_command(Path("/tmp/my project")) + + assert cmd.startswith("cd '/tmp/my project' && ") + assert "unset VIRTUAL_ENV UV_PROJECT UV_WORKING_DIRECTORY && " in cmd + assert cmd.endswith("claude") + + +def test_build_claude_launch_command_preserves_custom_claude_command( + monkeypatch: object, +) -> None: + manager = TmuxManager(session_name="test") + monkeypatch.setattr( + config, + "claude_command", + "IS_SANDBOX=1 claude --dangerously-skip-permissions", + ) + + cmd = manager._build_claude_launch_command(Path("/tmp/work")) + + assert "unset VIRTUAL_ENV UV_PROJECT UV_WORKING_DIRECTORY" in cmd + assert cmd.endswith("IS_SANDBOX=1 claude --dangerously-skip-permissions") From 69dc18e987f08ea9338d47af3b797e9a695db98b Mon Sep 17 00:00:00 2001 From: Vadim Mitroshkin Date: Sun, 1 Mar 2026 09:20:45 +0300 Subject: [PATCH 5/5] Improve tmux pane capture resilience and set fixed window size --- README.md | 4 +- README_CN.md | 4 +- README_RU.md | 4 +- src/ccbot/tmux_manager.py | 85 ++++++++++++++++++++++---------- tests/ccbot/test_tmux_manager.py | 59 +++++++++++++++++++++- 5 files changed, 126 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 95cc340f..df529e97 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ uv run ccbot | `/history` | Message history for this topic | | `/screenshot` | Capture terminal screenshot | | `/esc` | Send Escape to interrupt Claude | +| `/kill` | Kill session and unbind topic | **Claude Code commands (forwarded via tmux):** @@ -179,7 +180,8 @@ Once a topic is bound to a session, just send text in that topic — it gets for **Killing a session:** -Close (or delete) the topic in Telegram. The associated tmux window is automatically killed and the binding is removed. +Use `/kill` in the topic to kill its bound tmux window and unbind the topic. +You can also close (or delete) the topic in Telegram — this also auto-kills the associated tmux window and removes the binding. ### Message History diff --git a/README_CN.md b/README_CN.md index 37882f0c..542067ca 100644 --- a/README_CN.md +++ b/README_CN.md @@ -144,6 +144,7 @@ uv run ccbot | `/history` | 当前话题的消息历史 | | `/screenshot` | 截取终端屏幕 | | `/esc` | 发送 Escape 键中断 Claude | +| `/kill` | 终止会话并解绑当前话题 | **Claude Code 命令(通过 tmux 转发):** @@ -175,7 +176,8 @@ uv run ccbot **关闭会话:** -在 Telegram 中关闭(或删除)话题,关联的 tmux 窗口会自动终止,绑定也会被移除。 +在话题里使用 `/kill`,可终止该话题绑定的 tmux 窗口并解绑话题。 +也可以在 Telegram 中关闭(或删除)话题:关联的 tmux 窗口同样会自动终止,绑定会被移除。 ### 消息历史 diff --git a/README_RU.md b/README_RU.md index a473365d..20fcf851 100644 --- a/README_RU.md +++ b/README_RU.md @@ -146,6 +146,7 @@ uv run ccbot | `/history` | История сообщений для текущего topic | | `/screenshot` | Снимок терминала | | `/esc` | Отправить Escape для прерывания Claude | +| `/kill` | Завершить сессию и отвязать topic | **Команды Claude Code (пробрасываются через tmux):** @@ -177,7 +178,8 @@ uv run ccbot **Завершение сессии:** -Закройте (или удалите) topic в Telegram. Связанное tmux-окно будет автоматически завершено, привязка удалена. +Используйте `/kill` в topic, чтобы завершить связанное tmux-окно и отвязать topic. +Также можно закрыть (или удалить) topic в Telegram — это тоже автоматически завершит связанное tmux-окно и удалит привязку. ### История сообщений diff --git a/src/ccbot/tmux_manager.py b/src/ccbot/tmux_manager.py index 730eb8d2..516ead46 100644 --- a/src/ccbot/tmux_manager.py +++ b/src/ccbot/tmux_manager.py @@ -39,6 +39,10 @@ class TmuxWindow: class TmuxManager: """Manages tmux windows for Claude Code sessions.""" + DEFAULT_WINDOW_WIDTH = 200 + DEFAULT_WINDOW_HEIGHT = 50 + DEFAULT_CAPTURE_SCROLLBACK_LINES = 2000 + def __init__(self, session_name: str | None = None): """Initialize tmux manager. @@ -189,7 +193,7 @@ async def find_window_by_id(self, window_id: str) -> TmuxWindow | None: return None async def capture_pane(self, window_id: str, with_ansi: bool = False) -> str | None: - """Capture the visible text content of a window's active pane. + """Capture pane text for a window. Args: window_id: The window ID to capture @@ -198,31 +202,37 @@ async def capture_pane(self, window_id: str, with_ansi: bool = False) -> str | N Returns: The captured text, or None on failure. """ + # Use tmux capture-pane for both ANSI and plain captures. + # For plain text, include recent scrollback to reduce missed content + # when pane viewport is smaller than the active output region. + args = ["tmux", "capture-pane"] if with_ansi: - # Use async subprocess to call tmux capture-pane -e for ANSI colors - try: - proc = await asyncio.create_subprocess_exec( - "tmux", - "capture-pane", - "-e", - "-p", - "-t", - window_id, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await proc.communicate() - if proc.returncode == 0: - return stdout.decode("utf-8") - logger.error( - f"Failed to capture pane {window_id}: {stderr.decode('utf-8')}" - ) - return None - except Exception as e: - logger.error(f"Unexpected error capturing pane {window_id}: {e}") - return None + args.append("-e") + else: + args.extend(["-S", f"-{self.DEFAULT_CAPTURE_SCROLLBACK_LINES}"]) + args.extend(["-p", "-t", window_id]) + + try: + proc = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode == 0: + return stdout.decode("utf-8") + logger.error( + "Failed to capture pane %s: %s", + window_id, + stderr.decode("utf-8"), + ) + except Exception as e: + logger.error(f"Unexpected error capturing pane {window_id}: {e}") + + # Fallback for plain-text path. + if with_ansi: + return None - # Original implementation for plain text - wrap in thread def _sync_capture() -> str | None: session = self.get_session() if not session: @@ -429,6 +439,29 @@ def _create_and_start() -> tuple[bool, str, str, str]: ) wid = window.window_id or "" + target = wid or f"{self.session_name}:{final_window_name}" + + # Keep this window at a stable manual size to reduce TUI wrapping + # variance and improve parser reliability. + try: + self.server.cmd( + "set-option", + "-t", + self.session_name, + "window-size", + "manual", + ) + self.server.cmd( + "resize-window", + "-t", + target, + "-x", + str(self.DEFAULT_WINDOW_WIDTH), + "-y", + str(self.DEFAULT_WINDOW_HEIGHT), + ) + except Exception as e: + logger.warning("Failed to apply window size for %s: %s", target, e) # Prevent Claude Code from overriding window name window.set_window_option("allow-rename", "off") @@ -443,10 +476,12 @@ def _create_and_start() -> tuple[bool, str, str, str]: pane.send_keys(launch_cmd, enter=True) logger.info( - "Created window '%s' (id=%s) at %s", + "Created window '%s' (id=%s) at %s (%sx%s)", final_window_name, wid, path, + self.DEFAULT_WINDOW_WIDTH, + self.DEFAULT_WINDOW_HEIGHT, ) return ( True, diff --git a/tests/ccbot/test_tmux_manager.py b/tests/ccbot/test_tmux_manager.py index eff8c751..205d1186 100644 --- a/tests/ccbot/test_tmux_manager.py +++ b/tests/ccbot/test_tmux_manager.py @@ -1,12 +1,18 @@ """Unit tests for tmux manager helpers.""" +import asyncio from pathlib import Path +from types import SimpleNamespace + +import pytest from ccbot.config import config from ccbot.tmux_manager import TmuxManager -def test_build_claude_launch_command_unsets_env_vars(monkeypatch: object) -> None: +def test_build_claude_launch_command_unsets_env_vars( + monkeypatch: pytest.MonkeyPatch, +) -> None: manager = TmuxManager(session_name="test") monkeypatch.setattr(config, "claude_command", "claude") @@ -18,7 +24,7 @@ def test_build_claude_launch_command_unsets_env_vars(monkeypatch: object) -> Non def test_build_claude_launch_command_preserves_custom_claude_command( - monkeypatch: object, + monkeypatch: pytest.MonkeyPatch, ) -> None: manager = TmuxManager(session_name="test") monkeypatch.setattr( @@ -31,3 +37,52 @@ def test_build_claude_launch_command_preserves_custom_claude_command( assert "unset VIRTUAL_ENV UV_PROJECT UV_WORKING_DIRECTORY" in cmd assert cmd.endswith("IS_SANDBOX=1 claude --dangerously-skip-permissions") + + +async def _fake_proc(returncode: int = 0) -> SimpleNamespace: + async def communicate() -> tuple[bytes, bytes]: + return b"captured", b"" + + return SimpleNamespace(returncode=returncode, communicate=communicate) + + +@pytest.mark.asyncio +async def test_capture_pane_plain_uses_scrollback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = TmuxManager(session_name="test") + called: dict[str, list[str]] = {} + + async def _fake_exec(*args: str, **kwargs: object) -> SimpleNamespace: + called["args"] = list(args) + return await _fake_proc() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + result = await manager.capture_pane("@1", with_ansi=False) + + assert result == "captured" + assert called["args"][:2] == ["tmux", "capture-pane"] + assert "-S" in called["args"] + assert f"-{manager.DEFAULT_CAPTURE_SCROLLBACK_LINES}" in called["args"] + assert called["args"][-2:] == ["-t", "@1"] + + +@pytest.mark.asyncio +async def test_capture_pane_ansi_does_not_use_scrollback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = TmuxManager(session_name="test") + called: dict[str, list[str]] = {} + + async def _fake_exec(*args: str, **kwargs: object) -> SimpleNamespace: + called["args"] = list(args) + return await _fake_proc() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) + result = await manager.capture_pane("@2", with_ansi=True) + + assert result == "captured" + assert called["args"][:2] == ["tmux", "capture-pane"] + assert "-e" in called["args"] + assert "-S" not in called["args"] + assert called["args"][-2:] == ["-t", "@2"]