#!/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 subprocess 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" def _find_claude_path(): for p in [ os.path.expanduser("~/.local/bin/claude"), "/usr/local/bin/claude", "/usr/bin/claude", ]: if os.path.isfile(p) and os.access(p, os.X_OK): return p import shutil return shutil.which("claude") or "claude" CLAUDE_PATH = _find_claude_path() 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']}; }} stackswitcher {{ background: {CATPPUCCIN['crust']}; border-bottom: 1px solid {CATPPUCCIN['surface0']}; }} stackswitcher button {{ background: {CATPPUCCIN['crust']}; color: {CATPPUCCIN['subtext0']}; border: none; border-radius: 0; padding: 6px 16px; border-bottom: 2px solid transparent; font-weight: bold; font-size: 12px; }} stackswitcher button:checked {{ background: {CATPPUCCIN['mantle']}; color: {CATPPUCCIN['blue']}; border-bottom: 2px solid {CATPPUCCIN['blue']}; }} stackswitcher button:hover {{ background: {CATPPUCCIN['surface0']}; }} textview.ctx-detail {{ font-family: monospace; font-size: 10pt; }} textview.ctx-detail text {{ background-color: {CATPPUCCIN['crust']}; color: {CATPPUCCIN['subtext1']}; }} """ 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:", "Project dir:"]): 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) dir_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) self.entry_project_dir = Gtk.Entry(hexpand=True) self.entry_project_dir.set_placeholder_text("(optional) path to project directory") dir_box.pack_start(self.entry_project_dir, True, True, 0) btn_browse = Gtk.Button(label="Browse…") btn_browse.connect("clicked", self._on_browse_dir) dir_box.pack_start(btn_browse, False, False, 0) grid.attach(dir_box, 1, 3, 1, 1) self.lbl_ctx_status = Gtk.Label(xalign=0) grid.attach(self.lbl_ctx_status, 1, 4, 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)) self.entry_project_dir.set_text(session.get("project_dir", "")) prompt = session.get("prompt", "") if prompt: self.textview.get_buffer().set_text(prompt) self.show_all() self._update_ctx_status() 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, "project_dir": self.entry_project_dir.get_text().strip(), } 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() def _on_browse_dir(self, button): dlg = Gtk.FileChooserDialog( title="Select project directory", parent=self, action=Gtk.FileChooserAction.SELECT_FOLDER, ) dlg.add_buttons( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK, ) if dlg.run() == Gtk.ResponseType.OK: path = dlg.get_filename() self.entry_project_dir.set_text(path) basename = os.path.basename(path.rstrip("/")) if not self.entry_name.get_text().strip(): self.entry_name.set_text(basename) # Auto-fill prompt with ctx instructions if empty or auto-generated buf = self.textview.get_buffer() start, end = buf.get_bounds() current = buf.get_text(start, end, False).strip() if not current or current.startswith("Wczytaj kontekst"): prompt = ( f"Wczytaj kontekst projektu poleceniem: ctx get {basename}\n" f"Wykonaj tę komendę i zapoznaj się z kontekstem zanim zaczniesz pracę.\n" f"Kontekst zarządzasz przez: ctx --help\n" f"Ważne odkrycia zapisuj: ctx set {basename} \n" f"Przed zakończeniem sesji: ctx summary {basename} \"\"" ) buf.set_text(prompt) self._update_ctx_status() dlg.destroy() def _update_ctx_status(self): project_dir = self.entry_project_dir.get_text().strip() if not project_dir: self.lbl_ctx_status.set_text("") return name = os.path.basename(project_dir.rstrip("/")) if _is_ctx_project_registered(name): self.lbl_ctx_status.set_markup( '\u2713 Ctx project "' + GLib.markup_escape_text(name) + '" is registered' ) else: self.lbl_ctx_status.set_markup( "\u2139 New project \u2014 ctx wizard will guide you after save" ) # ─── CtxEditDialog ──────────────────────────────────────────────────────────── CTX_DB = os.path.join(os.path.expanduser("~"), ".claude-context", "context.db") def _detect_project_description(project_dir): """Detect project description from README or directory name.""" for name in ["README.md", "README.rst", "README.txt", "README"]: readme_path = os.path.join(project_dir, name) if os.path.isfile(readme_path): try: with open(readme_path, "r") as f: for line in f: line = line.strip().lstrip("#").strip() if line: return line[:100] except (IOError, UnicodeDecodeError): pass return os.path.basename(project_dir.rstrip("/")) def _is_ctx_project_registered(project_name): """Check if a ctx project is already registered in the database.""" if not os.path.exists(CTX_DB): return False import sqlite3 try: db = sqlite3.connect(CTX_DB) row = db.execute( "SELECT 1 FROM sessions WHERE name = ?", (project_name,) ).fetchone() db.close() return row is not None except sqlite3.Error: return False def _is_ctx_available(): """Check if ctx command is available.""" try: subprocess.run(["ctx", "--version"], capture_output=True, timeout=5) return True except (FileNotFoundError, subprocess.TimeoutExpired): return False def _run_ctx_wizard_if_needed(parent, data): """Launch ctx wizard if project_dir is set but ctx not registered. Returns updated data.""" project_dir = data.get("project_dir", "") if not project_dir or not _is_ctx_available(): return data project_name = os.path.basename(project_dir.rstrip("/")) if _is_ctx_project_registered(project_name): return data wizard = CtxSetupWizard(parent, project_dir) if wizard.run_wizard(): if not data["prompt"] or data["prompt"].startswith("Wczytaj kontekst"): data["prompt"] = wizard.result_prompt return data _WIZARD_BACK = 1 _WIZARD_NEXT = 2 class CtxSetupWizard(Gtk.Dialog): """Step-by-step wizard for initial ctx project setup.""" def __init__(self, parent, project_dir): super().__init__( title="Ctx — New Project Setup", transient_for=parent, modal=True, destroy_with_parent=True, ) self.set_default_size(540, -1) self.set_resizable(False) self.project_dir = project_dir self.project_name = os.path.basename(project_dir.rstrip("/")) self.success = False self.result_prompt = "" self._current_page = 0 box = self.get_content_area() box.set_border_width(16) box.set_spacing(12) # Page header self.lbl_header = Gtk.Label(xalign=0) box.pack_start(self.lbl_header, False, False, 0) box.pack_start(Gtk.Separator(), False, False, 0) # Stack for pages self.stack = Gtk.Stack() self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT) box.pack_start(self.stack, True, True, 0) # Status bar (for errors) self.lbl_status = Gtk.Label(xalign=0, wrap=True, max_width_chars=60) box.pack_start(self.lbl_status, False, False, 0) self._build_page_project() self._build_page_entry() self._build_page_confirm() # Navigation buttons self.btn_cancel = self.add_button("Cancel", Gtk.ResponseType.CANCEL) self.btn_back = self.add_button("\u2190 Back", _WIZARD_BACK) self.btn_next = self.add_button("Next \u2192", _WIZARD_NEXT) self.btn_finish = self.add_button("\u2713 Create", Gtk.ResponseType.OK) self._show_page(0) self.show_all() def _build_page_project(self): page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) info = Gtk.Label(wrap=True, xalign=0, max_width_chars=58) info.set_markup( "Register the project in the ctx database.\n" "The project name is used in all ctx commands " "(e.g. ctx get MyProject).\n" "Description helps Claude understand the project purpose." ) page.pack_start(info, False, False, 0) warn = Gtk.Label(wrap=True, xalign=0, max_width_chars=58) warn.set_markup( '\u26a0 Case matters! "MyProject" \u2260 ' '"myproject". The name must match exactly in all commands.' ) page.pack_start(warn, False, False, 4) grid = Gtk.Grid(column_spacing=12, row_spacing=8) grid.attach(Gtk.Label(label="Directory:", halign=Gtk.Align.END), 0, 0, 1, 1) lbl_dir = Gtk.Label( label=self.project_dir, halign=Gtk.Align.START, selectable=True, ellipsize=Pango.EllipsizeMode.MIDDLE, ) grid.attach(lbl_dir, 1, 0, 1, 1) grid.attach(Gtk.Label(label="Project name:", halign=Gtk.Align.END), 0, 1, 1, 1) self.w_name = Gtk.Entry(hexpand=True) self.w_name.set_text(self.project_name) grid.attach(self.w_name, 1, 1, 1, 1) grid.attach(Gtk.Label(label="Description:", halign=Gtk.Align.END), 0, 2, 1, 1) self.w_desc = Gtk.Entry(hexpand=True) self.w_desc.set_text(_detect_project_description(self.project_dir)) grid.attach(self.w_desc, 1, 2, 1, 1) page.pack_start(grid, False, False, 0) self.stack.add_named(page, "project") def _build_page_entry(self): page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) info = Gtk.Label(wrap=True, xalign=0, max_width_chars=58) info.set_markup( "Add the first context entry. Claude reads these at the start " "of each session to understand the project.\n\n" "Examples:\n" ' Key: repo Value: GitHub: .../MyRepo, branch: main\n' ' Key: stack Value: Python 3.12, Flask, PostgreSQL' ) page.pack_start(info, False, False, 0) grid = Gtk.Grid(column_spacing=12, row_spacing=8) grid.attach(Gtk.Label(label="Key:", halign=Gtk.Align.END), 0, 0, 1, 1) self.w_key = Gtk.Entry(hexpand=True) self.w_key.set_placeholder_text("e.g. repo, stack, architecture") grid.attach(self.w_key, 1, 0, 1, 1) grid.attach( Gtk.Label(label="Value:", halign=Gtk.Align.END, valign=Gtk.Align.START), 0, 1, 1, 1, ) scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled.set_min_content_height(90) self.w_value = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR) scrolled.add(self.w_value) grid.attach(scrolled, 1, 1, 1, 1) page.pack_start(grid, True, True, 0) self.stack.add_named(page, "entry") def _build_page_confirm(self): page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) info = Gtk.Label(wrap=True, xalign=0, max_width_chars=58) info.set_text("Review and confirm. The following actions will be performed:") page.pack_start(info, False, False, 0) self.lbl_summary = Gtk.Label(wrap=True, xalign=0, max_width_chars=58) page.pack_start(self.lbl_summary, False, False, 0) page.pack_start(Gtk.Separator(), False, False, 4) self.stack.add_named(page, "confirm") def _show_page(self, idx): self._current_page = idx pages = ["project", "entry", "confirm"] self.stack.set_visible_child_name(pages[idx]) self.lbl_status.set_text("") headers = [ "Step 1 of 3: Project registration", "Step 2 of 3: First context entry", "Step 3 of 3: Confirm and create", ] self.lbl_header.set_markup(f"{headers[idx]}") if idx == 2: self._update_summary() def _update_buttons(self): idx = self._current_page self.btn_back.set_visible(idx > 0) self.btn_next.set_visible(idx < 2) self.btn_finish.set_visible(idx == 2) def _update_summary(self): name = self.w_name.get_text().strip() desc = self.w_desc.get_text().strip() key = self.w_key.get_text().strip() buf = self.w_value.get_buffer() s, e = buf.get_bounds() value = buf.get_text(s, e, False).strip() val_preview = value[:150] + ("\u2026" if len(value) > 150 else "") self.lbl_summary.set_markup( f"1. ctx init \u2014 register project " f"{GLib.markup_escape_text(name)}\n" f" {GLib.markup_escape_text(desc)}\n\n" f"2. ctx set \u2014 add entry " f"{GLib.markup_escape_text(key)}\n" f" {GLib.markup_escape_text(val_preview)}\n\n" f"3. Create CLAUDE.md in project directory\n" f" (will be skipped if file already exists)" ) def _validate_page(self, idx): if idx == 0: name = self.w_name.get_text().strip() desc = self.w_desc.get_text().strip() if not name: self.lbl_status.set_markup( 'Project name is required.' ) self.w_name.grab_focus() return False if not desc: self.lbl_status.set_markup( 'Description is required.' ) self.w_desc.grab_focus() return False elif idx == 1: key = self.w_key.get_text().strip() buf = self.w_value.get_buffer() s, e = buf.get_bounds() value = buf.get_text(s, e, False).strip() if not key: self.lbl_status.set_markup( 'Key is required. ' 'E.g. "repo", "stack", "notes".' ) self.w_key.grab_focus() return False if not value: self.lbl_status.set_markup( 'Value is required. ' "Describe something about the project." ) self.w_value.grab_focus() return False return True def _execute(self): """Run ctx init, ctx set, and create CLAUDE.md.""" name = self.w_name.get_text().strip() desc = self.w_desc.get_text().strip() key = self.w_key.get_text().strip() buf = self.w_value.get_buffer() s, e = buf.get_bounds() value = buf.get_text(s, e, False).strip() # 1. ctx init try: r = subprocess.run( ["ctx", "init", name, desc, self.project_dir], capture_output=True, text=True, ) if r.returncode != 0: self.lbl_status.set_markup( f'ctx init failed: ' f"{GLib.markup_escape_text(r.stderr.strip())}" ) return False except FileNotFoundError: self.lbl_status.set_markup( 'ctx command not found.' ) return False # 2. ctx set try: r = subprocess.run( ["ctx", "set", name, key, value], capture_output=True, text=True, ) if r.returncode != 0: self.lbl_status.set_markup( f'ctx set failed: ' f"{GLib.markup_escape_text(r.stderr.strip())}" ) return False except FileNotFoundError: return False # 3. CLAUDE.md claude_md = os.path.join(self.project_dir, "CLAUDE.md") if not os.path.exists(claude_md): try: with open(claude_md, "w") as f: f.write( f"# {name}\n\n" f"On session start, load context:\n" f"```bash\n" f"ctx get {name}\n" f"```\n\n" f"Context manager: `ctx --help`\n\n" f"During work:\n" f"- Save important discoveries: `ctx set {name} `\n" f"- Append to existing: `ctx append {name} `\n" f'- Before ending session: `ctx summary {name} ""`\n' ) except IOError as e: self.lbl_status.set_markup( f'CLAUDE.md: {GLib.markup_escape_text(str(e))}' ) return False self.project_name = name self.result_prompt = ( f"Wczytaj kontekst projektu poleceniem: ctx get {name}\n" f"Wykonaj t\u0119 komend\u0119 i zapoznaj si\u0119 z kontekstem zanim zaczniesz prac\u0119.\n" f"Kontekst zarz\u0105dzasz przez: ctx --help\n" f"Wa\u017cne odkrycia zapisuj: ctx set {name} \n" f'Przed zako\u0144czeniem sesji: ctx summary {name} ""' ) self.success = True return True def run_wizard(self): """Run the wizard. Returns True if completed successfully.""" while True: self._update_buttons() resp = self.run() if resp == _WIZARD_NEXT: if self._validate_page(self._current_page): self._show_page(self._current_page + 1) elif resp == _WIZARD_BACK: self._show_page(self._current_page - 1) elif resp == Gtk.ResponseType.OK: if self._execute(): self.destroy() return True else: self.destroy() return False class _CtxEntryDialog(Gtk.Dialog): """Small dialog for adding/editing a ctx key-value entry.""" def __init__(self, parent, title, key="", value=""): 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(400, -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) grid.attach(Gtk.Label(label="Key:", halign=Gtk.Align.END), 0, 0, 1, 1) self.entry_key = Gtk.Entry(hexpand=True) self.entry_key.set_text(key) grid.attach(self.entry_key, 1, 0, 1, 1) grid.attach(Gtk.Label(label="Value:", halign=Gtk.Align.END, valign=Gtk.Align.START), 0, 1, 1, 1) scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled.set_min_content_height(100) self.textview = Gtk.TextView() self.textview.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) if value: self.textview.get_buffer().set_text(value) scrolled.add(self.textview) grid.attach(scrolled, 1, 1, 1, 1) self.show_all() def get_data(self): key = self.entry_key.get_text().strip() buf = self.textview.get_buffer() start, end = buf.get_bounds() value = buf.get_text(start, end, False).strip() return key, value class _CtxProjectDialog(Gtk.Dialog): """Dialog for adding/editing a ctx project.""" def __init__(self, parent, title="New Project", name="", description="", work_dir=""): 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(450, -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) grid.attach(Gtk.Label(label="Name:", halign=Gtk.Align.END), 0, 0, 1, 1) self.entry_name = Gtk.Entry(hexpand=True) self.entry_name.set_text(name) grid.attach(self.entry_name, 1, 0, 1, 1) grid.attach(Gtk.Label(label="Description:", halign=Gtk.Align.END), 0, 1, 1, 1) self.entry_desc = Gtk.Entry(hexpand=True) self.entry_desc.set_text(description) grid.attach(self.entry_desc, 1, 1, 1, 1) grid.attach(Gtk.Label(label="Directory:", halign=Gtk.Align.END), 0, 2, 1, 1) dir_box = Gtk.Box(spacing=4) self.entry_dir = Gtk.Entry(hexpand=True) self.entry_dir.set_text(work_dir) self.entry_dir.set_placeholder_text("(optional) path to project directory") dir_box.pack_start(self.entry_dir, True, True, 0) btn_browse = Gtk.Button(label="Browse\u2026") btn_browse.connect("clicked", self._on_browse) dir_box.pack_start(btn_browse, False, False, 0) grid.attach(dir_box, 1, 2, 1, 1) self.show_all() def _on_browse(self, button): dlg = Gtk.FileChooserDialog( title="Select directory", parent=self, action=Gtk.FileChooserAction.SELECT_FOLDER, ) dlg.add_buttons( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK, ) if dlg.run() == Gtk.ResponseType.OK: path = dlg.get_filename() self.entry_dir.set_text(path) if not self.entry_name.get_text().strip(): self.entry_name.set_text(os.path.basename(path.rstrip("/"))) dlg.destroy() def get_data(self): return ( self.entry_name.get_text().strip(), self.entry_desc.get_text().strip(), self.entry_dir.get_text().strip(), ) class CtxEditDialog(Gtk.Dialog): """Dialog to view and edit ctx project entries.""" def __init__(self, parent, ctx_project, project_dir=""): super().__init__( title=f"Ctx: {ctx_project}", transient_for=parent, modal=True, destroy_with_parent=True, ) self.add_buttons(Gtk.STOCK_CLOSE, Gtk.ResponseType.CLOSE) self.set_default_size(550, 400) self.ctx_project = ctx_project self.project_dir = project_dir box = self.get_content_area() box.set_border_width(12) box.set_spacing(8) # Description desc_box = Gtk.Box(spacing=8) desc_box.pack_start(Gtk.Label(label="Description:"), False, False, 0) self.entry_desc = Gtk.Entry(hexpand=True) desc_box.pack_start(self.entry_desc, True, True, 0) btn_save_desc = Gtk.Button(label="Save") btn_save_desc.connect("clicked", self._on_save_desc) desc_box.pack_start(btn_save_desc, False, False, 0) box.pack_start(desc_box, False, False, 0) box.pack_start(Gtk.Separator(), False, False, 2) # Entries list self.store = Gtk.ListStore(str, str) self.tree = Gtk.TreeView(model=self.store) self.tree.set_headers_visible(True) renderer_key = Gtk.CellRendererText() col_key = Gtk.TreeViewColumn("Key", renderer_key, text=0) col_key.set_min_width(120) self.tree.append_column(col_key) renderer_val = Gtk.CellRendererText() renderer_val.set_property("ellipsize", Pango.EllipsizeMode.END) col_val = Gtk.TreeViewColumn("Value", renderer_val, text=1) col_val.set_expand(True) self.tree.append_column(col_val) scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled.add(self.tree) box.pack_start(scrolled, True, True, 0) # Buttons btn_box = Gtk.Box(spacing=4) for label_text, cb in [("Add", self._on_add), ("Edit", self._on_edit), ("Delete", self._on_delete)]: 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) self._load_data() self.show_all() def _load_data(self): import sqlite3 self.store.clear() if not os.path.exists(CTX_DB): return db = sqlite3.connect(CTX_DB) db.row_factory = sqlite3.Row session = db.execute( "SELECT description FROM sessions WHERE name = ?", (self.ctx_project,) ).fetchone() if session: self.entry_desc.set_text(session["description"] or "") entries = db.execute( "SELECT key, value FROM contexts WHERE project = ? ORDER BY key", (self.ctx_project,), ).fetchall() for row in entries: self.store.append([row["key"], row["value"]]) db.close() def _on_save_desc(self, button): desc = self.entry_desc.get_text().strip() if desc: subprocess.run( ["ctx", "init", self.ctx_project, desc, self.project_dir], capture_output=True, text=True, ) def _on_add(self, button): dlg = _CtxEntryDialog(self, "Add entry") if dlg.run() == Gtk.ResponseType.OK: key, value = dlg.get_data() if key: subprocess.run( ["ctx", "set", self.ctx_project, key, value], capture_output=True, text=True, ) self._load_data() dlg.destroy() def _on_edit(self, button): sel = self.tree.get_selection() model, it = sel.get_selected() if it is None: return old_key = model.get_value(it, 0) old_value = model.get_value(it, 1) dlg = _CtxEntryDialog(self, "Edit entry", old_key, old_value) if dlg.run() == Gtk.ResponseType.OK: key, value = dlg.get_data() if key: if key != old_key: subprocess.run( ["ctx", "delete", self.ctx_project, old_key], capture_output=True, text=True, ) subprocess.run( ["ctx", "set", self.ctx_project, key, value], capture_output=True, text=True, ) self._load_data() dlg.destroy() def _on_delete(self, button): sel = self.tree.get_selection() model, it = sel.get_selected() if it is None: return key = model.get_value(it, 0) dlg = Gtk.MessageDialog( transient_for=self, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO, text=f'Delete entry "{key}"?', ) if dlg.run() == Gtk.ResponseType.YES: subprocess.run( ["ctx", "delete", self.ctx_project, key], capture_output=True, text=True, ) self._load_data() 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(False) 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' work_dir = config.get("project_dir") or os.environ.get("HOME", "/") self.terminal.spawn_async( Vte.PtyFlags.DEFAULT, work_dir, ["/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")) elif self.claude_config: # Claude Code tab: keep config name instead of generic VTE title self.app.update_tab_title(self, self.claude_config.get("name", "Claude Code")) 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 # 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.append(Gtk.SeparatorMenuItem()) item_ctx = Gtk.MenuItem(label="Edit ctx\u2026") item_ctx.connect("activate", lambda _, cid=claude_id: self._edit_ctx(cid)) menu.append(item_ctx) 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(): data = dlg.get_data() data = _run_ctx_wizard_if_needed(dlg, data) self.app.claude_manager.add(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 _edit_ctx(self, claude_id): config = self.app.claude_manager.get(claude_id) if not config: return project_dir = config.get("project_dir", "") if not project_dir: return ctx_project = os.path.basename(project_dir.rstrip("/")) dlg = CtxEditDialog(self.app, ctx_project, project_dir) dlg.run() dlg.destroy() 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() data = _run_ctx_wizard_if_needed(dlg, 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) # Ask about ctx cleanup project_dir = config.get("project_dir", "") if project_dir: ctx_name = os.path.basename(project_dir.rstrip("/")) if _is_ctx_available() and _is_ctx_project_registered(ctx_name): ctx_dlg = Gtk.MessageDialog( transient_for=self.app, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO, text=f"Also delete ctx project \"{ctx_name}\"?", ) ctx_dlg.format_secondary_text( "This will remove all context entries for this project from the ctx database." ) if ctx_dlg.run() == Gtk.ResponseType.YES: subprocess.run( ["ctx", "delete", ctx_name], capture_output=True, text=True, ) ctx_dlg.destroy() self.refresh() dlg.destroy() # ─── Ctx Import / Export ────────────────────────────────────────────────────── class _CtxExportDialog(Gtk.Dialog): """Dialog for selectively exporting ctx data to a JSON file.""" def __init__(self, parent): super().__init__( title="Export Context", transient_for=parent, modal=True, destroy_with_parent=True, ) self.add_buttons( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, "Export", Gtk.ResponseType.OK, ) self.set_default_size(500, 450) self.set_default_response(Gtk.ResponseType.OK) box = self.get_content_area() box.set_border_width(12) box.set_spacing(8) # Select all / Deselect all sel_box = Gtk.Box(spacing=8) btn_all = Gtk.Button(label="Select All") btn_all.connect("clicked", lambda _: self._set_all(True)) btn_none = Gtk.Button(label="Deselect All") btn_none.connect("clicked", lambda _: self._set_all(False)) sel_box.pack_start(btn_all, False, False, 0) sel_box.pack_start(btn_none, False, False, 0) box.pack_start(sel_box, False, False, 0) # Tree with checkboxes: toggle, icon, name, data_type, data_key self.store = Gtk.TreeStore(bool, str, str, str, str) self.tree = Gtk.TreeView(model=self.store) self.tree.set_headers_visible(False) col = Gtk.TreeViewColumn() cell_toggle = Gtk.CellRendererToggle() cell_toggle.connect("toggled", self._on_toggled) col.pack_start(cell_toggle, False) col.add_attribute(cell_toggle, "active", 0) cell_icon = Gtk.CellRendererText() col.pack_start(cell_icon, False) col.add_attribute(cell_icon, "text", 1) cell_name = Gtk.CellRendererText() cell_name.set_property("ellipsize", Pango.EllipsizeMode.END) col.pack_start(cell_name, True) col.add_attribute(cell_name, "text", 2) self.tree.append_column(col) scroll = Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) scroll.add(self.tree) box.pack_start(scroll, True, True, 0) self._load_data() self.show_all() def _load_data(self): import sqlite3 if not os.path.exists(CTX_DB): return db = sqlite3.connect(CTX_DB) db.row_factory = sqlite3.Row projects = db.execute( "SELECT name FROM sessions ORDER BY name" ).fetchall() for proj in projects: pname = proj["name"] proj_iter = self.store.append(None, [ True, "\U0001f4c1", pname, "project", pname, ]) entries = db.execute( "SELECT key FROM contexts WHERE project = ? ORDER BY key", (pname,), ).fetchall() for entry in entries: self.store.append(proj_iter, [ True, " ", entry["key"], "entry", entry["key"], ]) scount = db.execute( "SELECT COUNT(*) as c FROM summaries WHERE project = ?", (pname,), ).fetchone()["c"] if scount: self.store.append(proj_iter, [ True, "\U0001f4cb", f"Summaries ({scount})", "summaries", pname, ]) shared = db.execute("SELECT key FROM shared ORDER BY key").fetchall() if shared: shared_iter = self.store.append(None, [ True, "\U0001f517", "Shared", "shared", "", ]) for entry in shared: self.store.append(shared_iter, [ True, " ", entry["key"], "shared_entry", entry["key"], ]) db.close() self.tree.expand_all() def _on_toggled(self, renderer, path): it = self.store.get_iter(path) new_val = not self.store.get_value(it, 0) self.store.set_value(it, 0, new_val) # Propagate to children child = self.store.iter_children(it) while child: self.store.set_value(child, 0, new_val) child = self.store.iter_next(child) # Update parent based on children parent = self.store.iter_parent(it) if parent: any_checked = False child = self.store.iter_children(parent) while child: if self.store.get_value(child, 0): any_checked = True break child = self.store.iter_next(child) self.store.set_value(parent, 0, any_checked) def _set_all(self, val): def _walk(it): while it: self.store.set_value(it, 0, val) child = self.store.iter_children(it) if child: _walk(child) it = self.store.iter_next(it) root = self.store.get_iter_first() if root: _walk(root) def get_export_data(self): """Collect checked items and return export dict.""" import sqlite3 if not os.path.exists(CTX_DB): return None db = sqlite3.connect(CTX_DB) db.row_factory = sqlite3.Row data = {"sessions": [], "contexts": [], "shared": [], "summaries": []} root = self.store.get_iter_first() while root: dtype = self.store.get_value(root, 3) dkey = self.store.get_value(root, 4) if dtype == "project": proj_name = dkey child = self.store.iter_children(root) checked_entries = [] include_summaries = False while child: if self.store.get_value(child, 0): ctype = self.store.get_value(child, 3) ckey = self.store.get_value(child, 4) if ctype == "entry": checked_entries.append(ckey) elif ctype == "summaries": include_summaries = True child = self.store.iter_next(child) if checked_entries or include_summaries or self.store.get_value(root, 0): row = db.execute( "SELECT * FROM sessions WHERE name = ?", (proj_name,) ).fetchone() if row: data["sessions"].append(dict(row)) for ekey in checked_entries: row = db.execute( "SELECT project, key, value, updated_at FROM contexts " "WHERE project = ? AND key = ?", (proj_name, ekey), ).fetchone() if row: data["contexts"].append(dict(row)) if include_summaries: rows = db.execute( "SELECT project, summary, created_at FROM summaries " "WHERE project = ?", (proj_name,), ).fetchall() data["summaries"].extend(dict(r) for r in rows) elif dtype == "shared": child = self.store.iter_children(root) while child: if self.store.get_value(child, 0): skey = self.store.get_value(child, 4) row = db.execute( "SELECT * FROM shared WHERE key = ?", (skey,) ).fetchone() if row: data["shared"].append(dict(row)) child = self.store.iter_next(child) root = self.store.iter_next(root) db.close() data = {k: v for k, v in data.items() if v} if not data: return None data["_export_version"] = 1 return data class _CtxImportDialog(Gtk.Dialog): """Dialog for importing ctx data from a JSON file.""" def __init__(self, parent): super().__init__( title="Import Context", transient_for=parent, modal=True, destroy_with_parent=True, ) self.add_buttons( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, "Import", Gtk.ResponseType.OK, ) self.set_default_size(500, 450) self.set_default_response(Gtk.ResponseType.OK) self.set_response_sensitive(Gtk.ResponseType.OK, False) box = self.get_content_area() box.set_border_width(12) box.set_spacing(8) # File chooser file_box = Gtk.Box(spacing=8) file_box.pack_start(Gtk.Label(label="File:"), False, False, 0) self.file_entry = Gtk.Entry(hexpand=True) self.file_entry.set_placeholder_text("Select JSON file\u2026") self.file_entry.set_editable(False) file_box.pack_start(self.file_entry, True, True, 0) btn_browse = Gtk.Button(label="Browse\u2026") btn_browse.connect("clicked", self._on_browse) file_box.pack_start(btn_browse, False, False, 0) box.pack_start(file_box, False, False, 0) # Select all / Deselect all sel_box = Gtk.Box(spacing=8) btn_all = Gtk.Button(label="Select All") btn_all.connect("clicked", lambda _: self._set_all(True)) btn_none = Gtk.Button(label="Deselect All") btn_none.connect("clicked", lambda _: self._set_all(False)) sel_box.pack_start(btn_all, False, False, 0) sel_box.pack_start(btn_none, False, False, 0) box.pack_start(sel_box, False, False, 0) # Preview tree: toggle, icon, name, data_type, data_key self.store = Gtk.TreeStore(bool, str, str, str, str) self.tree = Gtk.TreeView(model=self.store) self.tree.set_headers_visible(False) col = Gtk.TreeViewColumn() cell_toggle = Gtk.CellRendererToggle() cell_toggle.connect("toggled", self._on_toggled) col.pack_start(cell_toggle, False) col.add_attribute(cell_toggle, "active", 0) cell_icon = Gtk.CellRendererText() col.pack_start(cell_icon, False) col.add_attribute(cell_icon, "text", 1) cell_name = Gtk.CellRendererText() cell_name.set_property("ellipsize", Pango.EllipsizeMode.END) col.pack_start(cell_name, True) col.add_attribute(cell_name, "text", 2) self.tree.append_column(col) scroll = Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) scroll.add(self.tree) box.pack_start(scroll, True, True, 0) # Overwrite option self.chk_overwrite = Gtk.CheckButton(label="Overwrite existing entries") box.pack_start(self.chk_overwrite, False, False, 0) self.import_data = None self.show_all() def _on_browse(self, button): dlg = Gtk.FileChooserDialog( title="Select context file", parent=self, action=Gtk.FileChooserAction.OPEN, ) dlg.add_buttons( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK, ) filt = Gtk.FileFilter() filt.set_name("JSON files") filt.add_pattern("*.json") dlg.add_filter(filt) filt_all = Gtk.FileFilter() filt_all.set_name("All files") filt_all.add_pattern("*") dlg.add_filter(filt_all) if dlg.run() == Gtk.ResponseType.OK: path = dlg.get_filename() self.file_entry.set_text(path) self._load_preview(path) dlg.destroy() def _load_preview(self, path): self.store.clear() self.import_data = None self.set_response_sensitive(Gtk.ResponseType.OK, False) try: with open(path, "r") as f: data = json.load(f) except (json.JSONDecodeError, OSError) as e: dlg = Gtk.MessageDialog( transient_for=self, modal=True, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, text=f"Failed to load file: {e}", ) dlg.run() dlg.destroy() return self.import_data = data # Group by project sessions = {s["name"]: s for s in data.get("sessions", [])} contexts_by_proj = {} for ctx in data.get("contexts", []): contexts_by_proj.setdefault(ctx["project"], []).append(ctx) summaries_by_proj = {} for s in data.get("summaries", []): summaries_by_proj.setdefault(s["project"], []).append(s) all_projects = sorted( set(sessions) | set(contexts_by_proj) | set(summaries_by_proj) ) for proj_name in all_projects: proj_iter = self.store.append(None, [ True, "\U0001f4c1", proj_name, "project", proj_name, ]) for ctx in contexts_by_proj.get(proj_name, []): self.store.append(proj_iter, [ True, " ", ctx["key"], "entry", ctx["key"], ]) scount = len(summaries_by_proj.get(proj_name, [])) if scount: self.store.append(proj_iter, [ True, "\U0001f4cb", f"Summaries ({scount})", "summaries", proj_name, ]) shared = data.get("shared", []) if shared: shared_iter = self.store.append(None, [ True, "\U0001f517", "Shared", "shared", "", ]) for entry in shared: self.store.append(shared_iter, [ True, " ", entry["key"], "shared_entry", entry["key"], ]) self.tree.expand_all() self.set_response_sensitive(Gtk.ResponseType.OK, True) def _on_toggled(self, renderer, path): it = self.store.get_iter(path) new_val = not self.store.get_value(it, 0) self.store.set_value(it, 0, new_val) child = self.store.iter_children(it) while child: self.store.set_value(child, 0, new_val) child = self.store.iter_next(child) parent = self.store.iter_parent(it) if parent: any_checked = False child = self.store.iter_children(parent) while child: if self.store.get_value(child, 0): any_checked = True break child = self.store.iter_next(child) self.store.set_value(parent, 0, any_checked) def _set_all(self, val): def _walk(it): while it: self.store.set_value(it, 0, val) child = self.store.iter_children(it) if child: _walk(child) it = self.store.iter_next(it) root = self.store.get_iter_first() if root: _walk(root) def get_selected_data(self): """Return (filtered_data_dict, overwrite_bool) or (None, False).""" if not self.import_data: return None, False data = self.import_data overwrite = self.chk_overwrite.get_active() sessions_map = {s["name"]: s for s in data.get("sessions", [])} contexts_by_proj = {} for ctx in data.get("contexts", []): contexts_by_proj.setdefault(ctx["project"], []).append(ctx) summaries_by_proj = {} for s in data.get("summaries", []): summaries_by_proj.setdefault(s["project"], []).append(s) shared_map = {s["key"]: s for s in data.get("shared", [])} result = {"sessions": [], "contexts": [], "shared": [], "summaries": []} root = self.store.get_iter_first() while root: dtype = self.store.get_value(root, 3) dkey = self.store.get_value(root, 4) if dtype == "project": proj_name = dkey child = self.store.iter_children(root) checked_entries = [] include_summaries = False while child: if self.store.get_value(child, 0): ctype = self.store.get_value(child, 3) ckey = self.store.get_value(child, 4) if ctype == "entry": checked_entries.append(ckey) elif ctype == "summaries": include_summaries = True child = self.store.iter_next(child) if checked_entries or include_summaries: if proj_name in sessions_map: result["sessions"].append(sessions_map[proj_name]) for ekey in checked_entries: for ctx in contexts_by_proj.get(proj_name, []): if ctx["key"] == ekey: result["contexts"].append(ctx) break if include_summaries: result["summaries"].extend( summaries_by_proj.get(proj_name, []) ) elif dtype == "shared": child = self.store.iter_children(root) while child: if self.store.get_value(child, 0): skey = self.store.get_value(child, 4) if skey in shared_map: result["shared"].append(shared_map[skey]) child = self.store.iter_next(child) root = self.store.iter_next(root) result = {k: v for k, v in result.items() if v} return (result if result else None), overwrite # ─── CtxManagerPanel ────────────────────────────────────────────────────────── class CtxManagerPanel(Gtk.Box): """Panel for browsing and managing ctx project contexts.""" def __init__(self, app): super().__init__(orientation=Gtk.Orientation.VERTICAL) self.app = app # Paned: tree on top, detail on bottom paned = Gtk.VPaned() self.pack_start(paned, True, True, 0) # ── Tree ── # Columns: icon, display_name, project, key, color, weight self.store = Gtk.TreeStore(str, str, str, str, str, int) self.tree = Gtk.TreeView(model=self.store) self.tree.set_headers_visible(False) self.tree.set_activate_on_single_click(False) col = Gtk.TreeViewColumn() cell_icon = Gtk.CellRendererText() col.pack_start(cell_icon, False) col.add_attribute(cell_icon, "text", 0) cell_name = Gtk.CellRendererText() cell_name.set_property("ellipsize", Pango.EllipsizeMode.END) col.pack_start(cell_name, True) col.add_attribute(cell_name, "text", 1) col.add_attribute(cell_name, "foreground", 4) col.add_attribute(cell_name, "weight", 5) self.tree.append_column(col) tree_scroll = Gtk.ScrolledWindow() tree_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) tree_scroll.add(self.tree) paned.pack1(tree_scroll, resize=True, shrink=False) # ── Detail pane ── detail_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) self.detail_header = Gtk.Label(xalign=0) self.detail_header.set_margin_start(8) self.detail_header.set_margin_top(4) detail_box.pack_start(self.detail_header, False, False, 0) detail_scroll = Gtk.ScrolledWindow() detail_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) detail_scroll.set_min_content_height(80) self.detail_view = Gtk.TextView() self.detail_view.set_editable(False) self.detail_view.set_cursor_visible(False) self.detail_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) self.detail_view.set_left_margin(8) self.detail_view.set_right_margin(8) self.detail_view.set_top_margin(4) self.detail_view.set_bottom_margin(4) self.detail_view.get_style_context().add_class("ctx-detail") detail_scroll.add(self.detail_view) detail_box.pack_start(detail_scroll, True, True, 0) paned.pack2(detail_box, resize=False, shrink=False) paned.set_position(300) # ── 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_proj = Gtk.MenuItem(label="New Project") item_proj.connect("activate", lambda _: self._on_add_project()) add_menu.append(item_proj) item_entry = Gtk.MenuItem(label="New Entry") item_entry.connect("activate", lambda _: self._on_add_entry()) add_menu.append(item_entry) 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", lambda _: self._on_edit()) btn_del = Gtk.Button(label="Delete") btn_del.get_style_context().add_class("sidebar-btn") btn_del.connect("clicked", lambda _: self._on_delete()) btn_refresh = Gtk.Button(label="\u21bb") btn_refresh.get_style_context().add_class("sidebar-btn") btn_refresh.set_tooltip_text("Refresh") btn_refresh.connect("clicked", lambda _: self.refresh()) btn_more = Gtk.MenuButton(label="\u22ee") btn_more.get_style_context().add_class("sidebar-btn") btn_more.set_tooltip_text("More actions") more_menu = Gtk.Menu() item_export = Gtk.MenuItem(label="Export\u2026") item_export.connect("activate", lambda _: self._on_export()) more_menu.append(item_export) item_import = Gtk.MenuItem(label="Import\u2026") item_import.connect("activate", lambda _: self._on_import()) more_menu.append(item_import) more_menu.show_all() btn_more.set_popup(more_menu) btn_box.pack_start(btn_add, True, True, 0) btn_box.pack_start(btn_edit, True, True, 0) btn_box.pack_start(btn_del, True, True, 0) btn_box.pack_start(btn_refresh, False, False, 0) btn_box.pack_start(btn_more, False, False, 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.tree.get_selection().connect("changed", self._on_selection_changed) self.refresh() def refresh(self): """Reload all data from the ctx database.""" self.store.clear() self.detail_header.set_text("") self.detail_view.get_buffer().set_text("") if not os.path.exists(CTX_DB): return import sqlite3 db = sqlite3.connect(CTX_DB) db.row_factory = sqlite3.Row projects = db.execute( "SELECT name, description, work_dir FROM sessions ORDER BY name" ).fetchall() for proj in projects: proj_iter = self.store.append(None, [ "\U0001f4c1", proj["name"], proj["name"], "", CATPPUCCIN["blue"], Pango.Weight.BOLD, ]) entries = db.execute( "SELECT key FROM contexts WHERE project = ? ORDER BY key", (proj["name"],), ).fetchall() for entry in entries: self.store.append(proj_iter, [ " ", entry["key"], proj["name"], entry["key"], CATPPUCCIN["text"], Pango.Weight.NORMAL, ]) # Shared entries shared = db.execute("SELECT key FROM shared ORDER BY key").fetchall() if shared: shared_iter = self.store.append(None, [ "\U0001f517", "Shared", "__shared__", "", CATPPUCCIN["peach"], Pango.Weight.BOLD, ]) for entry in shared: self.store.append(shared_iter, [ " ", entry["key"], "__shared__", entry["key"], CATPPUCCIN["text"], Pango.Weight.NORMAL, ]) db.close() self.tree.expand_all() def _get_selected_info(self): """Returns (project_name, entry_key) of selected row.""" sel = self.tree.get_selection() model, it = sel.get_selected() if it is None: return None, None return model.get_value(it, 2), model.get_value(it, 3) def _on_selection_changed(self, selection): model, it = selection.get_selected() if it is None: self.detail_header.set_text("") self.detail_view.get_buffer().set_text("") return project = model.get_value(it, 2) key = model.get_value(it, 3) if key: self._show_entry_detail(project, key) else: self._show_project_detail(project) def _show_project_detail(self, project): import sqlite3 if not os.path.exists(CTX_DB): return db = sqlite3.connect(CTX_DB) db.row_factory = sqlite3.Row if project == "__shared__": self.detail_header.set_markup("\U0001f517 Shared") self.detail_view.get_buffer().set_text( "Shared context entries available to all projects." ) db.close() return proj = db.execute( "SELECT description, work_dir FROM sessions WHERE name = ?", (project,), ).fetchone() if not proj: db.close() return self.detail_header.set_markup( f"\U0001f4c1 {GLib.markup_escape_text(project)}" ) lines = [] if proj["description"]: lines.append(proj["description"]) if proj["work_dir"]: lines.append(f"Dir: {proj['work_dir']}") count = db.execute( "SELECT COUNT(*) FROM contexts WHERE project = ?", (project,) ).fetchone()[0] lines.append(f"Entries: {count}") # Last summary summary = db.execute( "SELECT summary, created_at FROM summaries " "WHERE project = ? ORDER BY created_at DESC LIMIT 1", (project,), ).fetchone() if summary: lines.append( f"\n\u2500\u2500 Last summary ({summary['created_at'][:10]}) \u2500\u2500" ) lines.append(summary["summary"]) # Associated Claude session prompt for cs in self.app.claude_manager.all(): cs_dir = cs.get("project_dir", "").rstrip("/") if cs_dir and os.path.basename(cs_dir) == project: prompt = cs.get("prompt", "") if prompt: lines.append("\n\u2500\u2500 Introductory prompt \u2500\u2500") lines.append(prompt) break self.detail_view.get_buffer().set_text("\n".join(lines)) db.close() def _show_entry_detail(self, project, key): import sqlite3 if not os.path.exists(CTX_DB): return db = sqlite3.connect(CTX_DB) if project == "__shared__": row = db.execute( "SELECT value FROM shared WHERE key = ?", (key,) ).fetchone() else: row = db.execute( "SELECT value FROM contexts WHERE project = ? AND key = ?", (project, key), ).fetchone() if row: self.detail_header.set_markup( f"{GLib.markup_escape_text(key)}" ) self.detail_view.get_buffer().set_text(row[0]) db.close() def _on_row_activated(self, tree, path, column): self._on_edit() def _on_button_press(self, widget, event): if event.button != 3: return False path_info = self.tree.get_path_at_pos(int(event.x), int(event.y)) if not path_info: return True path = path_info[0] self.tree.get_selection().select_path(path) it = self.store.get_iter(path) project = self.store.get_value(it, 2) key = self.store.get_value(it, 3) menu = Gtk.Menu() if not key: # Project row item_add = Gtk.MenuItem(label="Add Entry") item_add.connect("activate", lambda _: self._on_add_entry()) menu.append(item_add) item_edit = Gtk.MenuItem(label="Edit Project") item_edit.connect("activate", lambda _: self._on_edit()) menu.append(item_edit) menu.append(Gtk.SeparatorMenuItem()) item_del = Gtk.MenuItem(label="Delete Project") item_del.connect("activate", lambda _: self._on_delete()) menu.append(item_del) else: # Entry row item_edit = Gtk.MenuItem(label="Edit Entry") item_edit.connect("activate", lambda _: self._on_edit()) menu.append(item_edit) item_del = Gtk.MenuItem(label="Delete Entry") item_del.connect("activate", lambda _: self._on_delete()) menu.append(item_del) menu.show_all() menu.popup_at_pointer(event) return True def _on_add_project(self): dlg = _CtxProjectDialog(self.app, "New Project") if dlg.run() == Gtk.ResponseType.OK: name, desc, work_dir = dlg.get_data() if name and desc: args = ["ctx", "init", name, desc] if work_dir: args.append(work_dir) subprocess.run(args, capture_output=True, text=True) self.refresh() dlg.destroy() def _on_add_entry(self): project, _ = self._get_selected_info() if not project or project == "__shared__": return dlg = _CtxEntryDialog(self.app, f"Add entry to {project}") if dlg.run() == Gtk.ResponseType.OK: key, value = dlg.get_data() if key: subprocess.run( ["ctx", "set", project, key, value], capture_output=True, text=True, ) self.refresh() dlg.destroy() def _on_edit(self): project, key = self._get_selected_info() if not project: return if project == "__shared__": if key: self._edit_shared_entry(key) return if key: self._edit_entry(project, key) else: self._edit_project(project) def _edit_project(self, project): import sqlite3 if not os.path.exists(CTX_DB): return db = sqlite3.connect(CTX_DB) row = db.execute( "SELECT description, work_dir FROM sessions WHERE name = ?", (project,), ).fetchone() db.close() if not row: return dlg = _CtxProjectDialog( self.app, "Edit Project", project, row[0] or "", row[1] or "" ) dlg.entry_name.set_sensitive(False) if dlg.run() == Gtk.ResponseType.OK: _, desc, work_dir = dlg.get_data() if desc: args = ["ctx", "init", project, desc] if work_dir: args.append(work_dir) subprocess.run(args, capture_output=True, text=True) self.refresh() dlg.destroy() def _edit_entry(self, project, key): import sqlite3 if not os.path.exists(CTX_DB): return db = sqlite3.connect(CTX_DB) row = db.execute( "SELECT value FROM contexts WHERE project = ? AND key = ?", (project, key), ).fetchone() db.close() if not row: return dlg = _CtxEntryDialog(self.app, f"Edit: {key}", key, row[0]) if dlg.run() == Gtk.ResponseType.OK: new_key, value = dlg.get_data() if new_key: if new_key != key: subprocess.run( ["ctx", "delete", project, key], capture_output=True, text=True, ) subprocess.run( ["ctx", "set", project, new_key, value], capture_output=True, text=True, ) self.refresh() dlg.destroy() def _edit_shared_entry(self, key): import sqlite3 if not os.path.exists(CTX_DB): return db = sqlite3.connect(CTX_DB) row = db.execute( "SELECT value FROM shared WHERE key = ?", (key,) ).fetchone() db.close() if not row: return dlg = _CtxEntryDialog(self.app, f"Edit shared: {key}", key, row[0]) if dlg.run() == Gtk.ResponseType.OK: new_key, value = dlg.get_data() if new_key: db = sqlite3.connect(CTX_DB) if new_key != key: db.execute("DELETE FROM shared WHERE key = ?", (key,)) db.execute( "INSERT OR REPLACE INTO shared (key, value, updated_at) " "VALUES (?, ?, datetime('now'))", (new_key, value), ) db.commit() db.close() self.refresh() dlg.destroy() def _on_delete(self): project, key = self._get_selected_info() if not project: return if key: self._delete_entry(project, key) elif project != "__shared__": self._delete_project(project) def _delete_entry(self, project, key): dlg = Gtk.MessageDialog( transient_for=self.app, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO, text=f'Delete entry "{key}" from {project}?', ) if dlg.run() == Gtk.ResponseType.YES: if project == "__shared__": import sqlite3 db = sqlite3.connect(CTX_DB) db.execute("DELETE FROM shared WHERE key = ?", (key,)) db.commit() db.close() else: subprocess.run( ["ctx", "delete", project, key], capture_output=True, text=True, ) self.refresh() dlg.destroy() def _delete_project(self, project): dlg = Gtk.MessageDialog( transient_for=self.app, modal=True, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO, text=f'Delete project "{project}" and all its entries?', ) if dlg.run() == Gtk.ResponseType.YES: subprocess.run( ["ctx", "delete", project], capture_output=True, text=True, ) self.refresh() dlg.destroy() def _on_export(self): dlg = _CtxExportDialog(self.app) if dlg.run() == Gtk.ResponseType.OK: data = dlg.get_export_data() if data: save_dlg = Gtk.FileChooserDialog( title="Save export file", parent=self.app, action=Gtk.FileChooserAction.SAVE, ) save_dlg.add_buttons( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK, ) save_dlg.set_do_overwrite_confirmation(True) save_dlg.set_current_name("ctx_export.json") filt = Gtk.FileFilter() filt.set_name("JSON files") filt.add_pattern("*.json") save_dlg.add_filter(filt) if save_dlg.run() == Gtk.ResponseType.OK: path = save_dlg.get_filename() try: with open(path, "w") as f: json.dump(data, f, indent=2, ensure_ascii=False) except OSError as e: err = Gtk.MessageDialog( transient_for=self.app, modal=True, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK, text=f"Failed to save: {e}", ) err.run() err.destroy() save_dlg.destroy() dlg.destroy() def _on_import(self): dlg = _CtxImportDialog(self.app) if dlg.run() == Gtk.ResponseType.OK: data, overwrite = dlg.get_selected_data() if data: self._do_import(data, overwrite) self.refresh() dlg.destroy() def _do_import(self, data, overwrite): import sqlite3 # Ensure database and tables exist subprocess.run(["ctx", "list"], capture_output=True, text=True) if not os.path.exists(CTX_DB): return db = sqlite3.connect(CTX_DB) mode = "REPLACE" if overwrite else "IGNORE" for session in data.get("sessions", []): db.execute( f"INSERT OR {mode} INTO sessions (name, description, work_dir, created_at) " "VALUES (?, ?, ?, ?)", ( session["name"], session.get("description", ""), session.get("work_dir", ""), session.get("created_at", ""), ), ) for ctx in data.get("contexts", []): db.execute( f"INSERT OR {mode} INTO contexts (project, key, value, updated_at) " "VALUES (?, ?, ?, ?)", ( ctx["project"], ctx["key"], ctx["value"], ctx.get("updated_at", ""), ), ) for shared in data.get("shared", []): db.execute( f"INSERT OR {mode} INTO shared (key, value, updated_at) " "VALUES (?, ?, ?)", ( shared["key"], shared["value"], shared.get("updated_at", ""), ), ) for summary in data.get("summaries", []): db.execute( "INSERT INTO summaries (project, summary, created_at) " "VALUES (?, ?, ?)", ( summary["project"], summary["summary"], summary.get("created_at", ""), ), ) db.commit() db.close() # ─── 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("bterminal") # 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 container with stack switcher sidebar_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) sidebar_box.get_style_context().add_class("sidebar") sidebar_box.set_size_request(250, -1) self.sidebar_stack = Gtk.Stack() self.sidebar_stack.set_transition_type( Gtk.StackTransitionType.SLIDE_LEFT_RIGHT ) self.sidebar = SessionSidebar(self) self.sidebar_stack.add_titled(self.sidebar, "sessions", "Sessions") self.ctx_panel = CtxManagerPanel(self) self.sidebar_stack.add_titled(self.ctx_panel, "ctx", "Ctx") switcher = Gtk.StackSwitcher() switcher.set_stack(self.sidebar_stack) switcher.set_halign(Gtk.Align.FILL) switcher.set_homogeneous(True) sidebar_box.pack_start(switcher, False, False, 0) sidebar_box.pack_start(self.sidebar_stack, True, True, 0) paned.pack1(sidebar_box, 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) # Auto-refresh ctx panel when switching to it self.sidebar_stack.connect( "notify::visible-child", lambda s, _: ( self.ctx_panel.refresh() if s.get_visible_child() is self.ctx_panel else None ), ) # 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): return False # ─── Main ───────────────────────────────────────────────────────────────────── def main(): GLib.set_prgname("bterminal") GLib.set_application_name("BTerminal") application = Gtk.Application( application_id="com.github.DexterFromLab.BTerminal", flags=Gio.ApplicationFlags.FLAGS_NONE, ) def on_activate(app): windows = app.get_windows() if windows: windows[0].present() return win = BTerminalApp() app.add_window(win) application.connect("activate", on_activate) application.run(None) if __name__ == "__main__": main()