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/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", ] diff --git a/src/ccbot/bot.py b/src/ccbot/bot.py index a747425e..ca8a3781 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 --- @@ -243,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 @@ -837,7 +883,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 +1155,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 +1185,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 +1218,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 +1234,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 +1455,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 @@ -1715,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"), ] @@ -1789,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/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..516ead46 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 @@ -38,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. @@ -92,6 +97,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. @@ -170,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 @@ -179,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: @@ -410,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") @@ -418,16 +470,18 @@ 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}" - pane.send_keys(cmd, enter=True) + launch_cmd = self._build_claude_launch_command( + path, resume_session_id + ) + 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_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) diff --git a/tests/ccbot/test_tmux_manager.py b/tests/ccbot/test_tmux_manager.py new file mode 100644 index 00000000..205d1186 --- /dev/null +++ b/tests/ccbot/test_tmux_manager.py @@ -0,0 +1,88 @@ +"""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: pytest.MonkeyPatch, +) -> 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: pytest.MonkeyPatch, +) -> 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") + + +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"]