BTerminal/bterminal.py
DexterFromLab 31fed163d0 Add selective import/export for Ctx Manager with checkbox tree UI
Export dialog lets users pick specific projects, entries, summaries,
and shared context to save as JSON. Import dialog previews file
contents with checkboxes and supports overwrite/skip conflict mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:02:17 +01:00

3663 lines
128 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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} <key> <value>\n"
f"Przed zakończeniem sesji: ctx summary {basename} \"<co zrobiliśmy>\""
)
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(
'<small>\u2713 Ctx project "<b>'
+ GLib.markup_escape_text(name)
+ '</b>" is registered</small>'
)
else:
self.lbl_ctx_status.set_markup(
"<small>\u2139 New project \u2014 ctx wizard will guide you after save</small>"
)
# ─── 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 <b>project name</b> is used in all ctx commands "
"(e.g. <tt>ctx get MyProject</tt>).\n"
"<b>Description</b> 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(
'<small>\u26a0 Case matters! "<tt>MyProject</tt>" \u2260 '
'"<tt>myproject</tt>". The name must match exactly in all commands.</small>'
)
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 <b>first context entry</b>. Claude reads these at the start "
"of each session to understand the project.\n\n"
"Examples:\n"
' Key: <tt>repo</tt> Value: <tt>GitHub: .../MyRepo, branch: main</tt>\n'
' Key: <tt>stack</tt> Value: <tt>Python 3.12, Flask, PostgreSQL</tt>'
)
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"<b>{headers[idx]}</b>")
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"<tt>1.</tt> <tt>ctx init</tt> \u2014 register project "
f"<b>{GLib.markup_escape_text(name)}</b>\n"
f" {GLib.markup_escape_text(desc)}\n\n"
f"<tt>2.</tt> <tt>ctx set</tt> \u2014 add entry "
f"<b>{GLib.markup_escape_text(key)}</b>\n"
f" {GLib.markup_escape_text(val_preview)}\n\n"
f"<tt>3.</tt> Create <tt>CLAUDE.md</tt> 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(
'<span foreground="red">Project name is required.</span>'
)
self.w_name.grab_focus()
return False
if not desc:
self.lbl_status.set_markup(
'<span foreground="red">Description is required.</span>'
)
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(
'<span foreground="red">Key is required. '
'E.g. "repo", "stack", "notes".</span>'
)
self.w_key.grab_focus()
return False
if not value:
self.lbl_status.set_markup(
'<span foreground="red">Value is required. '
"Describe something about the project.</span>"
)
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'<span foreground="red">ctx init failed: '
f"{GLib.markup_escape_text(r.stderr.strip())}</span>"
)
return False
except FileNotFoundError:
self.lbl_status.set_markup(
'<span foreground="red">ctx command not found.</span>'
)
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'<span foreground="red">ctx set failed: '
f"{GLib.markup_escape_text(r.stderr.strip())}</span>"
)
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} <key> <value>`\n"
f"- Append to existing: `ctx append {name} <key> <value>`\n"
f'- Before ending session: `ctx summary {name} "<what was done>"`\n'
)
except IOError as e:
self.lbl_status.set_markup(
f'<span foreground="red">CLAUDE.md: {GLib.markup_escape_text(str(e))}</span>'
)
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} <key> <value>\n"
f'Przed zako\u0144czeniem sesji: ctx summary {name} "<co zrobili\u015bmy>"'
)
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(True)
self.terminal.set_scroll_on_keystroke(True)
self.terminal.set_audible_bell(False)
# Catppuccin colors
fg = _parse_color(CATPPUCCIN["text"])
bg = _parse_color(CATPPUCCIN["base"])
palette = [_parse_color(c) for c in TERMINAL_PALETTE]
self.terminal.set_colors(fg, bg, palette)
# Cursor color
self.terminal.set_color_cursor(_parse_color(CATPPUCCIN["rosewater"]))
self.terminal.set_color_cursor_foreground(_parse_color(CATPPUCCIN["crust"]))
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scrolled.add(self.terminal)
self.pack_start(scrolled, True, True, 0)
self.terminal.connect("child-exited", self._on_child_exited)
self.terminal.connect("window-title-changed", self._on_title_changed)
self.terminal.connect("key-press-event", self._on_key_press)
self.terminal.connect("button-press-event", self._on_button_press)
self.show_all()
if claude_config:
self.spawn_claude(claude_config)
elif session:
self.spawn_ssh(
session["host"],
session.get("port", 22),
session["username"],
session.get("key_file", ""),
)
else:
self.spawn_local_shell()
def spawn_local_shell(self):
shell = os.environ.get("SHELL", "/bin/bash")
self.terminal.spawn_async(
Vte.PtyFlags.DEFAULT,
os.environ.get("HOME", "/"),
[shell],
None,
GLib.SpawnFlags.DEFAULT,
None,
None,
-1,
None,
None,
)
def spawn_ssh(self, host, port, username, key_file=""):
argv = [SSH_PATH]
if key_file:
argv += ["-i", key_file]
argv += ["-p", str(port), f"{username}@{host}"]
self.terminal.spawn_async(
Vte.PtyFlags.DEFAULT,
os.environ.get("HOME", "/"),
argv,
None,
GLib.SpawnFlags.DEFAULT,
None,
None,
-1,
None,
None,
)
def spawn_claude(self, config):
"""Spawn Claude Code session — with sudo askpass helper or direct.
Always runs inside bash so that when claude exits, the shell
stays alive and the tab doesn't auto-close.
"""
flags = []
if config.get("resume"):
flags.append("--resume")
if config.get("skip_permissions"):
flags.append("--dangerously-skip-permissions")
prompt = config.get("prompt", "")
prompt_arg = ""
if prompt:
escaped = prompt.replace("'", "'\\''")
prompt_arg = f" '{escaped}'"
flags_str = " ".join(flags)
if config.get("sudo"):
script = (
'set -euo pipefail\n'
'read -rsp "Podaj hasło sudo: " SUDO_PW\n'
'echo\n'
'ASKPASS=$(mktemp /tmp/claude-askpass.XXXXXX)\n'
'chmod 700 "$ASKPASS"\n'
'cat > "$ASKPASS" <<ASKEOF\n'
'#!/bin/bash\n'
'echo \'$SUDO_PW\'\n'
'ASKEOF\n'
'export SUDO_ASKPASS="$ASKPASS"\n'
'if ! sudo -A true 2>/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"))
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("<b>\U0001f517 Shared</b>")
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"<b>\U0001f4c1 {GLib.markup_escape_text(project)}</b>"
)
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"<b>{GLib.markup_escape_text(key)}</b>"
)
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()