commit edc13e2d27028186dadbcb249cf08f5950bbc129 Author: DexterFromLab Date: Wed Mar 4 18:34:36 2026 +0100 Initial commit: BTerminal — GTK3 terminal with SSH & Claude Code session management Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bbe7b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*.pyo diff --git a/README.md b/README.md new file mode 100644 index 0000000..d543aea --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# BTerminal + +Terminal z panelem sesji w stylu MobaXterm, zbudowany w GTK 3 + VTE. Catppuccin Mocha theme. + +![BTerminal](screenshot.png) + +## Funkcje + +- **Sesje SSH** — zapisywane konfiguracje (host, port, user, klucz, folder, kolor), CRUD z panelem bocznym +- **Claude Code** — zapisywane konfiguracje Claude Code z opcjami sudo, resume, skip-permissions i initial prompt +- **Makra SSH** — wielokrokowe makra (text, key, delay) przypisane do sesji, uruchamiane z sidebara +- **Zakładki** — wiele terminali w tabach, Ctrl+T nowy, Ctrl+Shift+W zamknij, Ctrl+PageUp/Down przełączaj +- **Sudo askpass** — Claude Code z sudo: hasło podawane raz, tymczasowy askpass helper, automatyczne czyszczenie +- **Grupowanie folderami** — sesje SSH i Claude Code mogą być grupowane w foldery na sidebarze +- **Catppuccin Mocha** — pełny theme: terminal, sidebar, taby, kolory sesji + +## Wymagania + +``` +python3 >= 3.8 +python3-gi +gir1.2-gtk-3.0 +gir1.2-vte-2.91 +``` + +### Instalacja zależności (Debian/Ubuntu/Pop!_OS) + +```bash +sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-vte-2.91 +``` + +## Uruchomienie + +```bash +python3 bterminal.py +``` + +## Konfiguracja + +Pliki konfiguracyjne w `~/.config/bterminal/`: + +| Plik | Opis | +|------|------| +| `sessions.json` | Zapisane sesje SSH + makra | +| `claude_sessions.json` | Zapisane konfiguracje Claude Code | + +## Skróty klawiszowe + +| Skrót | Akcja | +|-------|-------| +| `Ctrl+T` | Nowa zakładka (local shell) | +| `Ctrl+Shift+W` | Zamknij zakładkę | +| `Ctrl+Shift+C` | Kopiuj | +| `Ctrl+Shift+V` | Wklej | +| `Ctrl+PageUp/Down` | Poprzednia/następna zakładka | + +## Licencja + +MIT diff --git a/bterminal.py b/bterminal.py new file mode 100755 index 0000000..b844288 --- /dev/null +++ b/bterminal.py @@ -0,0 +1,1734 @@ +#!/usr/bin/env python3 +"""BTerminal — Terminal SSH z panelem sesji, w stylu MobaXterm.""" + +import gi +gi.require_version("Gtk", "3.0") +gi.require_version("Vte", "2.91") +gi.require_version("Gdk", "3.0") + +import json +import os +import tempfile +import uuid + +from gi.repository import Gdk, Gio, GLib, Gtk, Pango, Vte + +# ─── Stałe i konfiguracja ──────────────────────────────────────────────────── + +APP_NAME = "BTerminal" +CONFIG_DIR = os.path.expanduser("~/.config/bterminal") +SESSIONS_FILE = os.path.join(CONFIG_DIR, "sessions.json") +CLAUDE_SESSIONS_FILE = os.path.join(CONFIG_DIR, "claude_sessions.json") +SSH_PATH = "/usr/bin/ssh" +CLAUDE_PATH = "/home/bartek/.local/bin/claude" + +FONT = "Monospace 11" +SCROLLBACK_LINES = 10000 + +# Catppuccin Mocha +CATPPUCCIN = { + "rosewater": "#f5e0dc", + "flamingo": "#f2cdcd", + "pink": "#f5c2e7", + "mauve": "#cba6f7", + "red": "#f38ba8", + "maroon": "#eba0ac", + "peach": "#fab387", + "yellow": "#f9e2af", + "green": "#a6e3a1", + "teal": "#94e2d5", + "sky": "#89dceb", + "sapphire": "#74c7ec", + "blue": "#89b4fa", + "lavender": "#b4befe", + "text": "#cdd6f4", + "subtext1": "#bac2de", + "subtext0": "#a6adc8", + "overlay2": "#9399b2", + "overlay1": "#7f849c", + "overlay0": "#6c7086", + "surface2": "#585b70", + "surface1": "#45475a", + "surface0": "#313244", + "base": "#1e1e2e", + "mantle": "#181825", + "crust": "#11111b", +} + +TERMINAL_PALETTE = [ + "#45475a", "#f38ba8", "#a6e3a1", "#f9e2af", + "#89b4fa", "#f5c2e7", "#94e2d5", "#bac2de", + "#585b70", "#f38ba8", "#a6e3a1", "#f9e2af", + "#89b4fa", "#f5c2e7", "#94e2d5", "#a6adc8", +] + +SESSION_COLORS = [ + "#89b4fa", "#a6e3a1", "#f9e2af", "#f38ba8", + "#f5c2e7", "#94e2d5", "#fab387", "#b4befe", + "#74c7ec", "#cba6f7", +] + +KEY_MAP = { + "Enter": "\n", + "Tab": "\t", + "Escape": "\x1b", + "Ctrl+C": "\x03", + "Ctrl+D": "\x04", +} + +CSS = f""" +window {{ + background-color: {CATPPUCCIN['base']}; +}} +.sidebar {{ + background-color: {CATPPUCCIN['mantle']}; + border-right: 1px solid {CATPPUCCIN['surface0']}; +}} +.sidebar-header {{ + background-color: {CATPPUCCIN['crust']}; + padding: 8px 12px; + font-weight: bold; + font-size: 13px; + color: {CATPPUCCIN['blue']}; + border-bottom: 1px solid {CATPPUCCIN['surface0']}; +}} +.sidebar-btn {{ + background: {CATPPUCCIN['surface0']}; + border: none; + border-radius: 4px; + color: {CATPPUCCIN['text']}; + padding: 4px 10px; + min-height: 28px; +}} +.sidebar-btn:hover {{ + background: {CATPPUCCIN['surface1']}; +}} +.sidebar-btn:active {{ + background: {CATPPUCCIN['surface2']}; +}} +notebook header tab {{ + background: {CATPPUCCIN['mantle']}; + color: {CATPPUCCIN['subtext0']}; + border: none; + padding: 4px 12px; + border-radius: 6px 6px 0 0; + margin: 0 1px; +}} +notebook header tab:checked {{ + background: {CATPPUCCIN['surface0']}; + color: {CATPPUCCIN['text']}; +}} +notebook header {{ + background: {CATPPUCCIN['crust']}; +}} +notebook {{ + background: {CATPPUCCIN['base']}; +}} +treeview {{ + background-color: {CATPPUCCIN['mantle']}; + color: {CATPPUCCIN['text']}; +}} +treeview:selected {{ + background-color: {CATPPUCCIN['surface1']}; + color: {CATPPUCCIN['text']}; +}} +treeview:hover {{ + background-color: {CATPPUCCIN['surface0']}; +}} +.tab-close-btn {{ + background: transparent; + border: none; + border-radius: 4px; + padding: 0; + min-width: 20px; + min-height: 20px; + color: {CATPPUCCIN['overlay1']}; +}} +.tab-close-btn:hover {{ + background: {CATPPUCCIN['surface2']}; + color: {CATPPUCCIN['red']}; +}} +""" + + +def _parse_color(hex_str): + """Parse hex color string to Gdk.RGBA.""" + c = Gdk.RGBA() + c.parse(hex_str) + return c + + +# ─── SessionManager ────────────────────────────────────────────────────────── + + +class SessionManager: + """Zarządzanie zapisanymi sesjami SSH (CRUD + plik JSON).""" + + def __init__(self): + os.makedirs(CONFIG_DIR, exist_ok=True) + self.sessions = [] + self.load() + + def load(self): + if os.path.exists(SESSIONS_FILE): + try: + with open(SESSIONS_FILE, "r") as f: + self.sessions = json.load(f) + except (json.JSONDecodeError, IOError): + self.sessions = [] + else: + self.sessions = [] + + def save(self): + os.makedirs(CONFIG_DIR, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=CONFIG_DIR, suffix=".tmp") + try: + with os.fdopen(fd, "w") as f: + json.dump(self.sessions, f, indent=2) + os.replace(tmp, SESSIONS_FILE) + except Exception: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + def add(self, session): + session["id"] = str(uuid.uuid4()) + self.sessions.append(session) + self.save() + return session + + def update(self, session_id, data): + for i, s in enumerate(self.sessions): + if s["id"] == session_id: + self.sessions[i].update(data) + self.save() + return self.sessions[i] + return None + + def delete(self, session_id): + self.sessions = [s for s in self.sessions if s["id"] != session_id] + self.save() + + def get(self, session_id): + for s in self.sessions: + if s["id"] == session_id: + return s + return None + + def all(self): + return list(self.sessions) + + +# ─── ClaudeSessionManager ──────────────────────────────────────────────────── + + +class ClaudeSessionManager: + """Zarządzanie zapisanymi konfiguracjami Claude Code (CRUD + plik JSON).""" + + def __init__(self): + os.makedirs(CONFIG_DIR, exist_ok=True) + self.sessions = [] + self.load() + + def load(self): + if os.path.exists(CLAUDE_SESSIONS_FILE): + try: + with open(CLAUDE_SESSIONS_FILE, "r") as f: + self.sessions = json.load(f) + except (json.JSONDecodeError, IOError): + self.sessions = [] + else: + self.sessions = [] + + def save(self): + os.makedirs(CONFIG_DIR, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=CONFIG_DIR, suffix=".tmp") + try: + with os.fdopen(fd, "w") as f: + json.dump(self.sessions, f, indent=2) + os.replace(tmp, CLAUDE_SESSIONS_FILE) + except Exception: + if os.path.exists(tmp): + os.unlink(tmp) + raise + + def add(self, session): + session["id"] = str(uuid.uuid4()) + self.sessions.append(session) + self.save() + return session + + def update(self, session_id, data): + for i, s in enumerate(self.sessions): + if s["id"] == session_id: + self.sessions[i].update(data) + self.save() + return self.sessions[i] + return None + + def delete(self, session_id): + self.sessions = [s for s in self.sessions if s["id"] != session_id] + self.save() + + def get(self, session_id): + for s in self.sessions: + if s["id"] == session_id: + return s + return None + + def all(self): + return list(self.sessions) + + +# ─── SessionDialog ──────────────────────────────────────────────────────────── + + +class SessionDialog(Gtk.Dialog): + """Dialog dodawania/edycji sesji SSH.""" + + def __init__(self, parent, session=None): + title = "Edit Session" if session else "Add Session" + super().__init__( + title=title, + transient_for=parent, + modal=True, + destroy_with_parent=True, + ) + self.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OK, Gtk.ResponseType.OK, + ) + self.set_default_size(420, -1) + self.set_default_response(Gtk.ResponseType.OK) + + box = self.get_content_area() + box.set_border_width(12) + box.set_spacing(8) + + grid = Gtk.Grid(column_spacing=12, row_spacing=8) + box.pack_start(grid, True, True, 0) + + labels = ["Name:", "Host:", "Port:", "Username:", "SSH Key:", "Folder:", "Color:"] + for i, text in enumerate(labels): + lbl = Gtk.Label(label=text, halign=Gtk.Align.END) + grid.attach(lbl, 0, i, 1, 1) + + self.entry_name = Gtk.Entry(hexpand=True) + self.entry_host = Gtk.Entry(hexpand=True) + self.entry_port = Gtk.SpinButton.new_with_range(1, 65535, 1) + self.entry_port.set_value(22) + self.entry_username = Gtk.Entry(hexpand=True) + self.entry_key = Gtk.Entry(hexpand=True) + self.entry_key.set_placeholder_text("(optional) path to private key") + self.entry_folder = Gtk.Entry(hexpand=True) + self.entry_folder.set_placeholder_text("(optional) folder for grouping") + + self.color_combo = Gtk.ComboBoxText() + for c in SESSION_COLORS: + self.color_combo.append(c, c) + self.color_combo.set_active(0) + + grid.attach(self.entry_name, 1, 0, 1, 1) + grid.attach(self.entry_host, 1, 1, 1, 1) + grid.attach(self.entry_port, 1, 2, 1, 1) + grid.attach(self.entry_username, 1, 3, 1, 1) + grid.attach(self.entry_key, 1, 4, 1, 1) + grid.attach(self.entry_folder, 1, 5, 1, 1) + grid.attach(self.color_combo, 1, 6, 1, 1) + + # Edit mode: fill fields + if session: + self.entry_name.set_text(session.get("name", "")) + self.entry_host.set_text(session.get("host", "")) + self.entry_port.set_value(int(session.get("port", 22))) + self.entry_username.set_text(session.get("username", "")) + self.entry_key.set_text(session.get("key_file", "")) + self.entry_folder.set_text(session.get("folder", "")) + color = session.get("color", SESSION_COLORS[0]) + self.color_combo.set_active_id(color) + + self.show_all() + + def get_data(self): + return { + "name": self.entry_name.get_text().strip(), + "host": self.entry_host.get_text().strip(), + "port": int(self.entry_port.get_value()), + "username": self.entry_username.get_text().strip(), + "key_file": self.entry_key.get_text().strip(), + "folder": self.entry_folder.get_text().strip(), + "color": self.color_combo.get_active_id() or SESSION_COLORS[0], + } + + def validate(self): + data = self.get_data() + if not data["name"]: + self._show_error("Name is required.") + return False + if not data["host"]: + self._show_error("Host is required.") + return False + if not data["username"]: + self._show_error("Username is required.") + return False + return True + + def _show_error(self, msg): + dlg = Gtk.MessageDialog( + transient_for=self, + modal=True, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text=msg, + ) + dlg.run() + dlg.destroy() + + +# ─── MacroDialog ───────────────────────────────────────────────────────────── + + +class MacroStepRow(Gtk.ListBoxRow): + """Single step row in the macro editor.""" + + def __init__(self, step=None): + super().__init__() + box = Gtk.Box(spacing=6) + box.set_border_width(4) + + self.type_combo = Gtk.ComboBoxText() + for t in ("text", "key", "delay"): + self.type_combo.append(t, t) + self.type_combo.set_active_id("text") + box.pack_start(self.type_combo, False, False, 0) + + self.stack = Gtk.Stack() + + # text entry + self.text_entry = Gtk.Entry(hexpand=True) + self.text_entry.set_placeholder_text("Text to send") + self.stack.add_named(self.text_entry, "text") + + # key combo + self.key_combo = Gtk.ComboBoxText() + for k in ("Enter", "Tab", "Escape", "Ctrl+C", "Ctrl+D"): + self.key_combo.append(k, k) + self.key_combo.set_active(0) + self.stack.add_named(self.key_combo, "key") + + # delay spin + self.delay_spin = Gtk.SpinButton.new_with_range(100, 10000, 100) + self.delay_spin.set_value(1000) + self.stack.add_named(self.delay_spin, "delay") + + box.pack_start(self.stack, True, True, 0) + self.add(box) + + self.type_combo.connect("changed", self._on_type_changed) + + if step: + self.type_combo.set_active_id(step["type"]) + if step["type"] == "text": + self.text_entry.set_text(step["value"]) + elif step["type"] == "key": + self.key_combo.set_active_id(step["value"]) + elif step["type"] == "delay": + self.delay_spin.set_value(int(step["value"])) + + self._on_type_changed(self.type_combo) + self.show_all() + + def _on_type_changed(self, combo): + active = combo.get_active_id() + if active: + self.stack.set_visible_child_name(active) + + def get_step(self): + t = self.type_combo.get_active_id() + if t == "text": + return {"type": "text", "value": self.text_entry.get_text()} + elif t == "key": + return {"type": "key", "value": self.key_combo.get_active_id()} + elif t == "delay": + return {"type": "delay", "value": int(self.delay_spin.get_value())} + return {"type": "text", "value": ""} + + +class MacroDialog(Gtk.Dialog): + """Dialog do dodawania/edycji makra SSH.""" + + def __init__(self, parent, macro=None): + title = "Edit Macro" if macro else "Add Macro" + super().__init__( + title=title, + transient_for=parent, + modal=True, + destroy_with_parent=True, + ) + self.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OK, Gtk.ResponseType.OK, + ) + self.set_default_size(500, 400) + self.set_default_response(Gtk.ResponseType.OK) + + box = self.get_content_area() + box.set_border_width(12) + box.set_spacing(8) + + # Name + name_box = Gtk.Box(spacing=8) + name_box.pack_start(Gtk.Label(label="Name:"), False, False, 0) + self.entry_name = Gtk.Entry(hexpand=True) + name_box.pack_start(self.entry_name, True, True, 0) + box.pack_start(name_box, False, False, 0) + + # Steps label + box.pack_start(Gtk.Label(label="Steps:", halign=Gtk.Align.START), False, False, 0) + + # Steps listbox in scrolled window + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.set_min_content_height(200) + self.listbox = Gtk.ListBox() + self.listbox.set_selection_mode(Gtk.SelectionMode.SINGLE) + scrolled.add(self.listbox) + box.pack_start(scrolled, True, True, 0) + + # Buttons + btn_box = Gtk.Box(spacing=4) + for label_text, cb in [ + ("Add Step", self._on_add), + ("Remove", self._on_remove), + ("Move Up", self._on_move_up), + ("Move Down", self._on_move_down), + ]: + btn = Gtk.Button(label=label_text) + btn.connect("clicked", cb) + btn_box.pack_start(btn, True, True, 0) + box.pack_start(btn_box, False, False, 0) + + # Quick-add shortcuts + box.pack_start(Gtk.Separator(), False, False, 2) + quick_label = Gtk.Label(label="Quick add:", halign=Gtk.Align.START) + quick_label.set_opacity(0.6) + box.pack_start(quick_label, False, False, 0) + + quick_box = Gtk.Box(spacing=4) + for key_name in ("Enter", "Tab", "Escape", "Ctrl+C", "Ctrl+D"): + btn = Gtk.Button(label=key_name) + btn.connect("clicked", self._on_quick_key, key_name) + quick_box.pack_start(btn, True, True, 0) + box.pack_start(quick_box, False, False, 0) + + delay_box = Gtk.Box(spacing=6) + btn_delay = Gtk.Button(label="+ Delay") + self.delay_spin = Gtk.SpinButton.new_with_range(100, 10000, 100) + self.delay_spin.set_value(500) + lbl_ms = Gtk.Label(label="ms") + btn_delay.connect("clicked", self._on_quick_delay) + delay_box.pack_start(btn_delay, False, False, 0) + delay_box.pack_start(self.delay_spin, False, False, 0) + delay_box.pack_start(lbl_ms, False, False, 0) + box.pack_start(delay_box, False, False, 0) + + # Fill if editing + if macro: + self.entry_name.set_text(macro.get("name", "")) + for step in macro.get("steps", []): + self.listbox.add(MacroStepRow(step)) + + self.show_all() + + def _on_quick_key(self, btn, key_name): + row = MacroStepRow({"type": "key", "value": key_name}) + self.listbox.add(row) + + def _on_quick_delay(self, btn): + ms = int(self.delay_spin.get_value()) + row = MacroStepRow({"type": "delay", "value": ms}) + self.listbox.add(row) + + def _on_add(self, btn): + row = MacroStepRow() + self.listbox.add(row) + + def _on_remove(self, btn): + row = self.listbox.get_selected_row() + if row: + self.listbox.remove(row) + + def _on_move_up(self, btn): + row = self.listbox.get_selected_row() + if row: + idx = row.get_index() + if idx > 0: + step = row.get_step() + self.listbox.remove(row) + new_row = MacroStepRow(step) + self.listbox.insert(new_row, idx - 1) + self.listbox.select_row(new_row) + + def _on_move_down(self, btn): + row = self.listbox.get_selected_row() + if row: + idx = row.get_index() + n = len(self.listbox.get_children()) + if idx < n - 1: + step = row.get_step() + self.listbox.remove(row) + new_row = MacroStepRow(step) + self.listbox.insert(new_row, idx + 1) + self.listbox.select_row(new_row) + + def get_data(self): + steps = [] + for row in self.listbox.get_children(): + steps.append(row.get_step()) + return { + "name": self.entry_name.get_text().strip(), + "steps": steps, + } + + def validate(self): + data = self.get_data() + if not data["name"]: + self._show_error("Macro name is required.") + return False + if not data["steps"]: + self._show_error("At least one step is required.") + return False + return True + + def _show_error(self, msg): + dlg = Gtk.MessageDialog( + transient_for=self, + modal=True, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text=msg, + ) + dlg.run() + dlg.destroy() + + +# ─── ClaudeCodeDialog ───────────────────────────────────────────────────────── + + +class ClaudeCodeDialog(Gtk.Dialog): + """Dialog konfiguracji sesji Claude Code.""" + + def __init__(self, parent, session=None): + title = "Edit Claude Session" if session else "Add Claude Session" + super().__init__( + title=title, + transient_for=parent, + modal=True, + destroy_with_parent=True, + ) + self.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_OK, Gtk.ResponseType.OK, + ) + self.set_default_size(460, -1) + self.set_default_response(Gtk.ResponseType.OK) + + box = self.get_content_area() + box.set_border_width(12) + box.set_spacing(10) + + # Name, Folder, Color grid + grid = Gtk.Grid(column_spacing=12, row_spacing=8) + box.pack_start(grid, False, False, 0) + + for i, text in enumerate(["Name:", "Folder:", "Color:"]): + lbl = Gtk.Label(label=text, halign=Gtk.Align.END) + grid.attach(lbl, 0, i, 1, 1) + + self.entry_name = Gtk.Entry(hexpand=True) + grid.attach(self.entry_name, 1, 0, 1, 1) + + self.entry_folder = Gtk.Entry(hexpand=True) + self.entry_folder.set_placeholder_text("(optional) folder for grouping") + grid.attach(self.entry_folder, 1, 1, 1, 1) + + self.color_combo = Gtk.ComboBoxText() + for c in SESSION_COLORS: + self.color_combo.append(c, c) + self.color_combo.set_active(0) + grid.attach(self.color_combo, 1, 2, 1, 1) + + # Separator + box.pack_start(Gtk.Separator(), False, False, 2) + + # Sudo checkbox + self.chk_sudo = Gtk.CheckButton(label="Run with sudo (asks for password)") + box.pack_start(self.chk_sudo, False, False, 0) + + # Resume session checkbox + self.chk_resume = Gtk.CheckButton(label="Resume last session (--resume)") + self.chk_resume.set_active(True) + box.pack_start(self.chk_resume, False, False, 0) + + # Skip permissions checkbox + self.chk_skip_perms = Gtk.CheckButton(label="Skip permissions (--dangerously-skip-permissions)") + self.chk_skip_perms.set_active(True) + box.pack_start(self.chk_skip_perms, False, False, 0) + + # Initial prompt + lbl = Gtk.Label(label="Initial prompt (optional):", halign=Gtk.Align.START) + box.pack_start(lbl, False, False, 0) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.set_min_content_height(80) + self.textview = Gtk.TextView() + self.textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + scrolled.add(self.textview) + box.pack_start(scrolled, True, True, 0) + + # Edit mode: fill fields + if session: + self.entry_name.set_text(session.get("name", "")) + self.entry_folder.set_text(session.get("folder", "")) + color = session.get("color", SESSION_COLORS[0]) + self.color_combo.set_active_id(color) + self.chk_sudo.set_active(session.get("sudo", False)) + self.chk_resume.set_active(session.get("resume", True)) + self.chk_skip_perms.set_active(session.get("skip_permissions", True)) + prompt = session.get("prompt", "") + if prompt: + self.textview.get_buffer().set_text(prompt) + + self.show_all() + + def get_data(self): + buf = self.textview.get_buffer() + start, end = buf.get_bounds() + prompt = buf.get_text(start, end, False).strip() + return { + "name": self.entry_name.get_text().strip(), + "folder": self.entry_folder.get_text().strip(), + "color": self.color_combo.get_active_id() or SESSION_COLORS[0], + "sudo": self.chk_sudo.get_active(), + "resume": self.chk_resume.get_active(), + "skip_permissions": self.chk_skip_perms.get_active(), + "prompt": prompt, + } + + def validate(self): + data = self.get_data() + if not data["name"]: + self._show_error("Name is required.") + return False + return True + + def _show_error(self, msg): + dlg = Gtk.MessageDialog( + transient_for=self, + modal=True, + message_type=Gtk.MessageType.ERROR, + buttons=Gtk.ButtonsType.OK, + text=msg, + ) + dlg.run() + dlg.destroy() + + +# ─── TerminalTab ────────────────────────────────────────────────────────────── + + +class TerminalTab(Gtk.Box): + """Zakładka terminala — lokalny shell lub SSH.""" + + def __init__(self, app, session=None, claude_config=None): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.app = app + self.session = session + self.claude_config = claude_config + + self.terminal = Vte.Terminal() + self.terminal.set_font(Pango.FontDescription(FONT)) + self.terminal.set_scrollback_lines(SCROLLBACK_LINES) + self.terminal.set_scroll_on_output(True) + self.terminal.set_scroll_on_keystroke(True) + self.terminal.set_audible_bell(False) + + # Catppuccin colors + fg = _parse_color(CATPPUCCIN["text"]) + bg = _parse_color(CATPPUCCIN["base"]) + palette = [_parse_color(c) for c in TERMINAL_PALETTE] + self.terminal.set_colors(fg, bg, palette) + + # Cursor color + self.terminal.set_color_cursor(_parse_color(CATPPUCCIN["rosewater"])) + self.terminal.set_color_cursor_foreground(_parse_color(CATPPUCCIN["crust"])) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.add(self.terminal) + self.pack_start(scrolled, True, True, 0) + + self.terminal.connect("child-exited", self._on_child_exited) + self.terminal.connect("window-title-changed", self._on_title_changed) + self.terminal.connect("key-press-event", self._on_key_press) + self.terminal.connect("button-press-event", self._on_button_press) + + self.show_all() + + if claude_config: + self.spawn_claude(claude_config) + elif session: + self.spawn_ssh( + session["host"], + session.get("port", 22), + session["username"], + session.get("key_file", ""), + ) + else: + self.spawn_local_shell() + + def spawn_local_shell(self): + shell = os.environ.get("SHELL", "/bin/bash") + self.terminal.spawn_async( + Vte.PtyFlags.DEFAULT, + os.environ.get("HOME", "/"), + [shell], + None, + GLib.SpawnFlags.DEFAULT, + None, + None, + -1, + None, + None, + ) + + def spawn_ssh(self, host, port, username, key_file=""): + argv = [SSH_PATH] + if key_file: + argv += ["-i", key_file] + argv += ["-p", str(port), f"{username}@{host}"] + + self.terminal.spawn_async( + Vte.PtyFlags.DEFAULT, + os.environ.get("HOME", "/"), + argv, + None, + GLib.SpawnFlags.DEFAULT, + None, + None, + -1, + None, + None, + ) + + def spawn_claude(self, config): + """Spawn Claude Code session — with sudo askpass helper or direct. + + Always runs inside bash so that when claude exits, the shell + stays alive and the tab doesn't auto-close. + """ + flags = [] + if config.get("resume"): + flags.append("--resume") + if config.get("skip_permissions"): + flags.append("--dangerously-skip-permissions") + + prompt = config.get("prompt", "") + prompt_arg = "" + if prompt: + escaped = prompt.replace("'", "'\\''") + prompt_arg = f" '{escaped}'" + + flags_str = " ".join(flags) + + if config.get("sudo"): + script = ( + 'set -euo pipefail\n' + 'read -rsp "Podaj hasło sudo: " SUDO_PW\n' + 'echo\n' + 'ASKPASS=$(mktemp /tmp/claude-askpass.XXXXXX)\n' + 'chmod 700 "$ASKPASS"\n' + 'cat > "$ASKPASS" </dev/null; then\n' + ' rm -f "$ASKPASS"\n' + ' echo "Błędne hasło sudo."\n' + ' read -p "Naciśnij Enter..."\n' + ' exit 1\n' + 'fi\n' + 'unset SUDO_PW\n' + 'trap \'rm -f "$ASKPASS"\' EXIT\n' + f'{CLAUDE_PATH} {flags_str}{prompt_arg}\n' + 'exec bash\n' + ) + else: + script = f'{CLAUDE_PATH} {flags_str}{prompt_arg}\nexec bash\n' + + self.terminal.spawn_async( + Vte.PtyFlags.DEFAULT, + os.environ.get("HOME", "/"), + ["/bin/bash", "-c", script], + None, + GLib.SpawnFlags.DEFAULT, + None, + None, + -1, + None, + None, + ) + + def run_macro(self, macro): + """Execute macro steps chained via GLib.timeout_add.""" + steps = macro.get("steps", []) + if not steps: + return + + def execute_steps(step_index): + if step_index >= len(steps): + return False + step = steps[step_index] + if step["type"] == "text": + self.terminal.feed_child(step["value"].encode()) + GLib.timeout_add(50, execute_steps, step_index + 1) + elif step["type"] == "key": + key_str = KEY_MAP.get(step["value"], "") + if key_str: + self.terminal.feed_child(key_str.encode()) + GLib.timeout_add(50, execute_steps, step_index + 1) + elif step["type"] == "delay": + GLib.timeout_add(int(step["value"]), execute_steps, step_index + 1) + return False + + GLib.timeout_add(500, execute_steps, 0) + + def _on_key_press(self, terminal, event): + mod = event.state & Gtk.accelerator_get_default_mod_mask() + ctrl = Gdk.ModifierType.CONTROL_MASK + shift = Gdk.ModifierType.SHIFT_MASK + + # Ctrl+Shift+C: copy + if mod == (ctrl | shift) and event.keyval in (Gdk.KEY_C, Gdk.KEY_c): + if terminal.get_has_selection(): + terminal.copy_clipboard_format(Vte.Format.TEXT) + return True + + # Ctrl+Shift+V: paste + if mod == (ctrl | shift) and event.keyval in (Gdk.KEY_V, Gdk.KEY_v): + terminal.paste_clipboard() + return True + + # Ctrl+T: new tab (forward to app) + if mod == ctrl and event.keyval == Gdk.KEY_t: + self.app.add_local_tab() + return True + + # Ctrl+Shift+W: close tab + if mod == (ctrl | shift) and event.keyval in (Gdk.KEY_W, Gdk.KEY_w): + self.app.close_tab(self) + return True + + # Ctrl+PageUp/PageDown: switch tabs + if mod == ctrl and event.keyval == Gdk.KEY_Page_Up: + idx = self.app.notebook.get_current_page() + if idx > 0: + self.app.notebook.set_current_page(idx - 1) + return True + if mod == ctrl and event.keyval == Gdk.KEY_Page_Down: + idx = self.app.notebook.get_current_page() + if idx < self.app.notebook.get_n_pages() - 1: + self.app.notebook.set_current_page(idx + 1) + return True + + return False + + def _on_button_press(self, terminal, event): + if event.button == 3: # right click + menu = Gtk.Menu() + + item_copy = Gtk.MenuItem(label="Copy") + item_copy.set_sensitive(terminal.get_has_selection()) + item_copy.connect("activate", lambda _: terminal.copy_clipboard_format(Vte.Format.TEXT)) + menu.append(item_copy) + + item_paste = Gtk.MenuItem(label="Paste") + item_paste.connect("activate", lambda _: terminal.paste_clipboard()) + menu.append(item_paste) + + menu.append(Gtk.SeparatorMenuItem()) + + item_select_all = Gtk.MenuItem(label="Select All") + item_select_all.connect("activate", lambda _: terminal.select_all()) + menu.append(item_select_all) + + menu.show_all() + menu.popup_at_pointer(event) + return True + return False + + def _on_child_exited(self, terminal, status): + self.app.on_tab_child_exited(self) + + def _on_title_changed(self, terminal): + title = terminal.get_window_title() + if title: + if self.session: + # SSH tab: keep session name, show VTE title in window title only + self.app.update_tab_title(self, self.session.get("name", "SSH")) + else: + self.app.update_tab_title(self, title) + + def get_label(self): + if self.claude_config: + return self.claude_config.get("name", "Claude Code") + if self.session: + return self.session.get("name", "SSH") + return "Terminal" + + +# ─── SessionSidebar ─────────────────────────────────────────────────────────── + +# TreeStore columns +COL_ICON = 0 +COL_NAME = 1 +COL_ID = 2 +COL_TOOLTIP = 3 +COL_COLOR = 4 +COL_WEIGHT = 5 + + +class SessionSidebar(Gtk.Box): + """Panel lewy z listą zapisanych sesji SSH.""" + + def __init__(self, app): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.app = app + self.get_style_context().add_class("sidebar") + self.set_size_request(250, -1) + + # Header + header = Gtk.Label(label=f" {APP_NAME} Sessions") + header.set_halign(Gtk.Align.START) + header.get_style_context().add_class("sidebar-header") + self.pack_start(header, False, False, 0) + + # TreeView + self.store = Gtk.TreeStore(str, str, str, str, str, int) # icon, name, id, tooltip, color, weight + self.tree = Gtk.TreeView(model=self.store) + self.tree.set_headers_visible(False) + self.tree.set_tooltip_column(COL_TOOLTIP) + self.tree.set_activate_on_single_click(False) + + # Renderer + col = Gtk.TreeViewColumn() + + cell_icon = Gtk.CellRendererText() + col.pack_start(cell_icon, False) + col.add_attribute(cell_icon, "text", COL_ICON) + + cell_name = Gtk.CellRendererText() + cell_name.set_property("ellipsize", Pango.EllipsizeMode.END) + col.pack_start(cell_name, True) + col.add_attribute(cell_name, "text", COL_NAME) + col.add_attribute(cell_name, "foreground", COL_COLOR) + col.add_attribute(cell_name, "weight", COL_WEIGHT) + + self.tree.append_column(col) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.add(self.tree) + self.pack_start(scrolled, True, True, 0) + + # Buttons + btn_box = Gtk.Box(spacing=4) + btn_box.set_border_width(6) + + btn_add = Gtk.MenuButton(label="Add \u25BE") + btn_add.get_style_context().add_class("sidebar-btn") + add_menu = Gtk.Menu() + item_session = Gtk.MenuItem(label="SSH Session") + item_session.connect("activate", lambda _: self._on_add(None)) + add_menu.append(item_session) + item_terminal = Gtk.MenuItem(label="Local Terminal") + item_terminal.connect("activate", lambda _: self.app.add_local_tab()) + add_menu.append(item_terminal) + item_claude = Gtk.MenuItem(label="Claude Code") + item_claude.connect("activate", lambda _: self._on_add_claude()) + add_menu.append(item_claude) + add_menu.show_all() + btn_add.set_popup(add_menu) + + btn_edit = Gtk.Button(label="Edit") + btn_edit.get_style_context().add_class("sidebar-btn") + btn_edit.connect("clicked", self._on_edit) + + btn_delete = Gtk.Button(label="Delete") + btn_delete.get_style_context().add_class("sidebar-btn") + btn_delete.connect("clicked", self._on_delete) + + btn_box.pack_start(btn_add, True, True, 0) + btn_box.pack_start(btn_edit, True, True, 0) + btn_box.pack_start(btn_delete, True, True, 0) + self.pack_start(btn_box, False, False, 0) + + # Signals + self.tree.connect("row-activated", self._on_row_activated) + self.tree.connect("button-press-event", self._on_button_press) + + self.refresh() + + def _append_session(self, parent_iter, session): + """Add a session node and its macro children to the tree store.""" + tooltip = f"{session.get('username', '')}@{session.get('host', '')}:{session.get('port', 22)}" + session_iter = self.store.append(parent_iter, [ + "\U0001F5A5", + session["name"], + session["id"], + tooltip, + session.get("color", SESSION_COLORS[0]), + Pango.Weight.NORMAL, + ]) + for macro in session.get("macros", []): + macro_id = f"macro:{session['id']}:{macro['id']}" + self.store.append(session_iter, [ + "\u25B6", # ▶ + macro["name"], + macro_id, + f"Macro: {macro['name']}", + CATPPUCCIN["green"], + Pango.Weight.NORMAL, + ]) + + def _append_claude_session(self, parent_iter, session): + """Add a Claude Code session node to the tree store.""" + opts = [] + if session.get("sudo"): + opts.append("sudo") + if session.get("resume"): + opts.append("resume") + if session.get("skip_permissions"): + opts.append("skip-perms") + tooltip = ", ".join(opts) if opts else "Claude Code" + self.store.append(parent_iter, [ + "\U0001F916", # 🤖 + session["name"], + f"claude:{session['id']}", + tooltip, + session.get("color", SESSION_COLORS[0]), + Pango.Weight.NORMAL, + ]) + + def refresh(self): + self.store.clear() + sessions = self.app.session_manager.all() + + folders = {} + ungrouped = [] + + for s in sessions: + folder = s.get("folder", "").strip() + if folder: + folders.setdefault(folder, []).append(s) + else: + ungrouped.append(s) + + # Grouped sessions + for folder_name in sorted(folders.keys()): + parent = self.store.append(None, [ + "\U0001F4C1", # folder icon + folder_name, + "", + folder_name, + CATPPUCCIN["subtext1"], + Pango.Weight.NORMAL, + ]) + for s in folders[folder_name]: + self._append_session(parent, s) + + # Ungrouped sessions + for s in ungrouped: + self._append_session(None, s) + + # ── Claude Code sessions ── + claude_sessions = self.app.claude_manager.all() + if claude_sessions: + # Visual separator + self.store.append(None, [ + "", + "\u2500" * 26, # ──────────── + "", + "", + CATPPUCCIN["surface2"], + Pango.Weight.NORMAL, + ]) + # Section header (bold) + self.store.append(None, [ + "\U0001F916", # 🤖 + "Claude Code", + "", + "Claude Code sessions", + CATPPUCCIN["mauve"], + Pango.Weight.BOLD, + ]) + + claude_folders = {} + claude_ungrouped = [] + for s in claude_sessions: + folder = s.get("folder", "").strip() + if folder: + claude_folders.setdefault(folder, []).append(s) + else: + claude_ungrouped.append(s) + + for folder_name in sorted(claude_folders.keys()): + parent = self.store.append(None, [ + "\U0001F4C1", + folder_name, + "", + folder_name, + CATPPUCCIN["subtext1"], + Pango.Weight.NORMAL, + ]) + for s in claude_folders[folder_name]: + self._append_claude_session(parent, s) + + for s in claude_ungrouped: + self._append_claude_session(None, s) + + self.tree.expand_all() + + def _get_selected_session_id(self): + sel = self.tree.get_selection() + model, it = sel.get_selected() + if it is None: + return None + col_id = model.get_value(it, COL_ID) + if col_id and not col_id.startswith("macro:"): + return col_id + return None + + def _on_row_activated(self, tree, path, column): + it = self.store.get_iter(path) + col_id = self.store.get_value(it, COL_ID) + if col_id and col_id.startswith("macro:"): + parts = col_id.split(":", 2) + self._run_macro(parts[1], parts[2]) + elif col_id and col_id.startswith("claude:"): + claude_id = col_id[7:] + config = self.app.claude_manager.get(claude_id) + if config: + self.app.open_claude_tab(config) + elif col_id: + session = self.app.session_manager.get(col_id) + if session: + self.app.open_ssh_tab(session) + + def _on_button_press(self, widget, event): + if event.button == 3: # right click + path_info = self.tree.get_path_at_pos(int(event.x), int(event.y)) + if path_info: + path = path_info[0] + self.tree.get_selection().select_path(path) + it = self.store.get_iter(path) + col_id = self.store.get_value(it, COL_ID) + + if col_id and col_id.startswith("macro:"): + # Macro context menu + parts = col_id.split(":", 2) + sid, mid = parts[1], parts[2] + menu = Gtk.Menu() + + item_run = Gtk.MenuItem(label="Run") + item_run.connect("activate", lambda _, s=sid, m=mid: self._run_macro(s, m)) + menu.append(item_run) + + item_edit = Gtk.MenuItem(label="Edit") + item_edit.connect("activate", lambda _, s=sid, m=mid: self._edit_macro(s, m)) + menu.append(item_edit) + + item_delete = Gtk.MenuItem(label="Delete") + item_delete.connect("activate", lambda _, s=sid, m=mid: self._delete_macro(s, m)) + menu.append(item_delete) + + menu.show_all() + menu.popup_at_pointer(event) + + elif col_id and col_id.startswith("claude:"): + # Claude Code session context menu + claude_id = col_id[7:] + menu = Gtk.Menu() + + item_connect = Gtk.MenuItem(label="Connect") + item_connect.connect("activate", lambda _, cid=claude_id: self._connect_claude(cid)) + menu.append(item_connect) + + item_edit = Gtk.MenuItem(label="Edit") + item_edit.connect("activate", lambda _, cid=claude_id: self._edit_claude(cid)) + menu.append(item_edit) + + item_delete = Gtk.MenuItem(label="Delete") + item_delete.connect("activate", lambda _, cid=claude_id: self._delete_claude(cid)) + menu.append(item_delete) + + menu.show_all() + menu.popup_at_pointer(event) + + elif col_id: + # Session context menu + session_id = col_id + menu = Gtk.Menu() + + item_connect = Gtk.MenuItem(label="Connect") + item_connect.connect("activate", lambda _: self._connect_session(session_id)) + menu.append(item_connect) + + item_edit = Gtk.MenuItem(label="Edit") + item_edit.connect("activate", lambda _: self._edit_session(session_id)) + menu.append(item_edit) + + item_delete = Gtk.MenuItem(label="Delete") + item_delete.connect("activate", lambda _: self._delete_session(session_id)) + menu.append(item_delete) + + menu.append(Gtk.SeparatorMenuItem()) + + item_add_macro = Gtk.MenuItem(label="Add Macro...") + item_add_macro.connect("activate", lambda _: self._add_macro(session_id)) + menu.append(item_add_macro) + + menu.show_all() + menu.popup_at_pointer(event) + return True + return False + + def _connect_session(self, session_id): + session = self.app.session_manager.get(session_id) + if session: + self.app.open_ssh_tab(session) + + def _on_add(self, button): + dlg = SessionDialog(self.app) + while True: + resp = dlg.run() + if resp != Gtk.ResponseType.OK: + break + if dlg.validate(): + self.app.session_manager.add(dlg.get_data()) + self.refresh() + break + dlg.destroy() + + def _on_add_claude(self): + dlg = ClaudeCodeDialog(self.app) + while True: + resp = dlg.run() + if resp != Gtk.ResponseType.OK: + break + if dlg.validate(): + self.app.claude_manager.add(dlg.get_data()) + self.refresh() + break + dlg.destroy() + + def _on_edit(self, button): + sel = self.tree.get_selection() + model, it = sel.get_selected() + if it is None: + return + col_id = model.get_value(it, COL_ID) + if col_id and col_id.startswith("claude:"): + self._edit_claude(col_id[7:]) + elif col_id and not col_id.startswith("macro:"): + self._edit_session(col_id) + + def _edit_session(self, session_id): + session = self.app.session_manager.get(session_id) + if not session: + return + dlg = SessionDialog(self.app, session) + while True: + resp = dlg.run() + if resp != Gtk.ResponseType.OK: + break + if dlg.validate(): + data = dlg.get_data() + self.app.session_manager.update(session_id, data) + self.refresh() + break + dlg.destroy() + + def _on_delete(self, button): + sel = self.tree.get_selection() + model, it = sel.get_selected() + if it is None: + return + col_id = model.get_value(it, COL_ID) + if col_id and col_id.startswith("claude:"): + self._delete_claude(col_id[7:]) + elif col_id and not col_id.startswith("macro:"): + self._delete_session(col_id) + + def _delete_session(self, session_id): + session = self.app.session_manager.get(session_id) + if not session: + return + dlg = Gtk.MessageDialog( + transient_for=self.app, + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text=f"Delete session \"{session['name']}\"?", + ) + if dlg.run() == Gtk.ResponseType.YES: + self.app.session_manager.delete(session_id) + self.refresh() + dlg.destroy() + + # ── Macro CRUD ── + + def _add_macro(self, session_id): + session = self.app.session_manager.get(session_id) + if not session: + return + dlg = MacroDialog(self.app) + while True: + resp = dlg.run() + if resp != Gtk.ResponseType.OK: + break + if dlg.validate(): + data = dlg.get_data() + data["id"] = str(uuid.uuid4()) + session.setdefault("macros", []).append(data) + self.app.session_manager.save() + self.refresh() + break + dlg.destroy() + + def _edit_macro(self, session_id, macro_id): + session = self.app.session_manager.get(session_id) + if not session: + return + macro = None + for m in session.get("macros", []): + if m["id"] == macro_id: + macro = m + break + if not macro: + return + dlg = MacroDialog(self.app, macro) + while True: + resp = dlg.run() + if resp != Gtk.ResponseType.OK: + break + if dlg.validate(): + data = dlg.get_data() + macro.update(data) + self.app.session_manager.save() + self.refresh() + break + dlg.destroy() + + def _delete_macro(self, session_id, macro_id): + session = self.app.session_manager.get(session_id) + if not session: + return + macro_name = "" + for m in session.get("macros", []): + if m["id"] == macro_id: + macro_name = m.get("name", "") + break + dlg = Gtk.MessageDialog( + transient_for=self.app, + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text=f'Delete macro "{macro_name}"?', + ) + if dlg.run() == Gtk.ResponseType.YES: + session["macros"] = [ + m for m in session.get("macros", []) if m["id"] != macro_id + ] + self.app.session_manager.save() + self.refresh() + dlg.destroy() + + def _run_macro(self, session_id, macro_id): + session = self.app.session_manager.get(session_id) + if not session: + return + macro = None + for m in session.get("macros", []): + if m["id"] == macro_id: + macro = m + break + if macro: + self.app.open_ssh_tab_with_macro(session, macro) + + # ── Claude Code CRUD ── + + def _connect_claude(self, claude_id): + config = self.app.claude_manager.get(claude_id) + if config: + self.app.open_claude_tab(config) + + def _edit_claude(self, claude_id): + config = self.app.claude_manager.get(claude_id) + if not config: + return + dlg = ClaudeCodeDialog(self.app, config) + while True: + resp = dlg.run() + if resp != Gtk.ResponseType.OK: + break + if dlg.validate(): + data = dlg.get_data() + self.app.claude_manager.update(claude_id, data) + self.refresh() + break + dlg.destroy() + + def _delete_claude(self, claude_id): + config = self.app.claude_manager.get(claude_id) + if not config: + return + dlg = Gtk.MessageDialog( + transient_for=self.app, + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text=f"Delete Claude session \"{config['name']}\"?", + ) + if dlg.run() == Gtk.ResponseType.YES: + self.app.claude_manager.delete(claude_id) + self.refresh() + dlg.destroy() + + +# ─── BTerminalApp ───────────────────────────────────────────────────────────── + + +class BTerminalApp(Gtk.Window): + """Główne okno aplikacji BTerminal.""" + + def __init__(self): + super().__init__(title=APP_NAME) + self.set_default_size(1200, 700) + self.set_icon_name("utilities-terminal") + + # Apply CSS + css_provider = Gtk.CssProvider() + css_provider.load_from_data(CSS.encode()) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + # Dark theme + settings = Gtk.Settings.get_default() + settings.set_property("gtk-application-prefer-dark-theme", True) + + # Session managers + self.session_manager = SessionManager() + self.claude_manager = ClaudeSessionManager() + + # Layout: HPaned + paned = Gtk.HPaned() + self.add(paned) + + # Sidebar + self.sidebar = SessionSidebar(self) + paned.pack1(self.sidebar, resize=False, shrink=False) + + # Notebook (tabs) + self.notebook = Gtk.Notebook() + self.notebook.set_scrollable(True) + self.notebook.set_show_border(False) + self.notebook.popup_disable() + paned.pack2(self.notebook, resize=True, shrink=False) + + paned.set_position(250) + + # Keyboard shortcuts + self.connect("key-press-event", self._on_key_press) + self.connect("delete-event", self._on_delete_event) + self.notebook.connect("switch-page", self._on_switch_page) + + # Open initial local shell + self.add_local_tab() + + self.show_all() + + def _update_window_title(self): + """Update window title bar: 'BTerminal — tab_name [n/total]'.""" + n = self.notebook.get_n_pages() + idx = self.notebook.get_current_page() + if idx < 0 or n == 0: + self.set_title(APP_NAME) + return + tab = self.notebook.get_nth_page(idx) + if isinstance(tab, TerminalTab): + name = tab.get_label() + else: + name = "Terminal" + if n > 1: + self.set_title(f"{APP_NAME} — {name} [{idx + 1}/{n}]") + else: + self.set_title(f"{APP_NAME} — {name}") + + def _on_switch_page(self, notebook, page, page_num): + GLib.idle_add(self._update_window_title) + + def _build_tab_label(self, text, tab_widget): + """Build a tab label with a close button.""" + box = Gtk.Box(spacing=4) + + label = Gtk.Label(label=text) + box.pack_start(label, True, True, 0) + + close_btn = Gtk.Button(label="×") + close_btn.get_style_context().add_class("tab-close-btn") + close_btn.set_relief(Gtk.ReliefStyle.NONE) + close_btn.connect("clicked", lambda _: self.close_tab(tab_widget)) + box.pack_start(close_btn, False, False, 0) + + box.show_all() + return box + + def add_local_tab(self): + tab = TerminalTab(self) + label = self._build_tab_label("Terminal", tab) + idx = self.notebook.append_page(tab, label) + self.notebook.set_current_page(idx) + self.notebook.set_tab_reorderable(tab, True) + tab.terminal.grab_focus() + self._update_window_title() + + def open_ssh_tab(self, session): + tab = TerminalTab(self, session=session) + name = session.get("name", "SSH") + label = self._build_tab_label(name, tab) + idx = self.notebook.append_page(tab, label) + self.notebook.set_current_page(idx) + self.notebook.set_tab_reorderable(tab, True) + tab.terminal.grab_focus() + self._update_window_title() + + def open_ssh_tab_with_macro(self, session, macro): + tab = TerminalTab(self, session=session) + name = f"{session.get('name', 'SSH')} \u2014 {macro.get('name', 'Macro')}" + label = self._build_tab_label(name, tab) + idx = self.notebook.append_page(tab, label) + self.notebook.set_current_page(idx) + self.notebook.set_tab_reorderable(tab, True) + tab.terminal.grab_focus() + tab.run_macro(macro) + self._update_window_title() + + def open_claude_tab(self, config): + tab = TerminalTab(self, claude_config=config) + tab_name = config.get("name", "Claude Code") + label = self._build_tab_label(tab_name, tab) + idx = self.notebook.append_page(tab, label) + self.notebook.set_current_page(idx) + self.notebook.set_tab_reorderable(tab, True) + tab.terminal.grab_focus() + self._update_window_title() + + def close_tab(self, tab): + idx = self.notebook.page_num(tab) + if idx >= 0: + self.notebook.remove_page(idx) + tab.destroy() + # If no tabs left, open a new local shell + if self.notebook.get_n_pages() == 0: + self.add_local_tab() + self._update_window_title() + + def on_tab_child_exited(self, tab): + """Called when a terminal's child process exits.""" + GLib.idle_add(self.close_tab, tab) + + def update_tab_title(self, tab, title): + """Update tab label when terminal title changes.""" + idx = self.notebook.page_num(tab) + if idx >= 0: + label_widget = self._build_tab_label(title, tab) + self.notebook.set_tab_label(tab, label_widget) + self._update_window_title() + + def _get_current_terminal(self): + idx = self.notebook.get_current_page() + if idx < 0: + return None + tab = self.notebook.get_nth_page(idx) + if isinstance(tab, TerminalTab): + return tab.terminal + return None + + def _on_key_press(self, widget, event): + mod = event.state & Gtk.accelerator_get_default_mod_mask() + ctrl = Gdk.ModifierType.CONTROL_MASK + shift = Gdk.ModifierType.SHIFT_MASK + + # Ctrl+T: new local tab + if mod == ctrl and event.keyval == Gdk.KEY_t: + self.add_local_tab() + return True + + # Ctrl+Shift+W: close current tab + if mod == (ctrl | shift) and event.keyval in (Gdk.KEY_W, Gdk.KEY_w): + idx = self.notebook.get_current_page() + if idx >= 0: + tab = self.notebook.get_nth_page(idx) + self.close_tab(tab) + return True + + # Ctrl+Shift+C: copy + if mod == (ctrl | shift) and event.keyval in (Gdk.KEY_C, Gdk.KEY_c): + term = self._get_current_terminal() + if term: + term.copy_clipboard_format(Vte.Format.TEXT) + return True + + # Ctrl+Shift+V: paste + if mod == (ctrl | shift) and event.keyval in (Gdk.KEY_V, Gdk.KEY_v): + term = self._get_current_terminal() + if term: + term.paste_clipboard() + return True + + # Ctrl+PageUp: previous tab + if mod == ctrl and event.keyval == Gdk.KEY_Page_Up: + idx = self.notebook.get_current_page() + if idx > 0: + self.notebook.set_current_page(idx - 1) + return True + + # Ctrl+PageDown: next tab + if mod == ctrl and event.keyval == Gdk.KEY_Page_Down: + idx = self.notebook.get_current_page() + if idx < self.notebook.get_n_pages() - 1: + self.notebook.set_current_page(idx + 1) + return True + + return False + + def _on_delete_event(self, widget, event): + Gtk.main_quit() + return False + + +# ─── Main ───────────────────────────────────────────────────────────────────── + +def main(): + app = BTerminalApp() + Gtk.main() + + +if __name__ == "__main__": + main() diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..24775cb Binary files /dev/null and b/screenshot.png differ