From edc13e2d27028186dadbcb249cf08f5950bbc129 Mon Sep 17 00:00:00 2001 From: DexterFromLab Date: Wed, 4 Mar 2026 18:34:36 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20BTerminal=20=E2=80=94=20GTK?= =?UTF-8?q?3=20terminal=20with=20SSH=20&=20Claude=20Code=20session=20manag?= =?UTF-8?q?ement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + README.md | 59 ++ bterminal.py | 1734 ++++++++++++++++++++++++++++++++++++++++++++++++ screenshot.png | Bin 0 -> 26508 bytes 4 files changed, 1796 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bterminal.py create mode 100644 screenshot.png 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 0000000000000000000000000000000000000000..24775cb1d7355555b65e86ded7c5374634a6b972 GIT binary patch literal 26508 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 ST!%LSyKH*>V$p@$PyP$IAYe-X literal 0 HcmV?d00001