Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**

Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ uv run ccbot
| `/history` | 当前话题的消息历史 |
| `/screenshot` | 截取终端屏幕 |
| `/esc` | 发送 Escape 键中断 Claude |
| `/kill` | 终止会话并解绑当前话题 |

**Claude Code 命令(通过 tmux 转发):**

Expand Down Expand Up @@ -175,7 +176,8 @@ uv run ccbot

**关闭会话:**

在 Telegram 中关闭(或删除)话题,关联的 tmux 窗口会自动终止,绑定也会被移除。
在话题里使用 `/kill`,可终止该话题绑定的 tmux 窗口并解绑话题。
也可以在 Telegram 中关闭(或删除)话题:关联的 tmux 窗口同样会自动终止,绑定会被移除。

### 消息历史

Expand Down
4 changes: 3 additions & 1 deletion README_RU.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ uv run ccbot
| `/history` | История сообщений для текущего topic |
| `/screenshot` | Снимок терминала |
| `/esc` | Отправить Escape для прерывания Claude |
| `/kill` | Завершить сессию и отвязать topic |

**Команды Claude Code (пробрасываются через tmux):**

Expand Down Expand Up @@ -177,7 +178,8 @@ uv run ccbot

**Завершение сессии:**

Закройте (или удалите) topic в Telegram. Связанное tmux-окно будет автоматически завершено, привязка удалена.
Используйте `/kill` в topic, чтобы завершить связанное tmux-окно и отвязать topic.
Также можно закрыть (или удалить) topic в Telegram — это тоже автоматически завершит связанное tmux-окно и удалит привязку.

### История сообщений

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
61 changes: 54 additions & 7 deletions src/ccbot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"),
]
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion src/ccbot/handlers/directory_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
112 changes: 83 additions & 29 deletions src/ccbot/tmux_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import asyncio
import logging
import shlex
from dataclasses import dataclass
from pathlib import Path

Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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,
Expand Down
Loading
Loading