- icona generata con agy (verificata): terminale scuro + freccia circolare ciano - asset PNG multi-dimensione in assets/ - integrazione: app-id GLib 'it.enne2.pi-session-launcher' == stem .desktop == nome icona hicolor (su Wayland l'icona di finestra viene dal .desktop, NON da set_icon_from_file) - install.sh: icone hicolor 48-512px + .desktop con StartupWMClass, rimozione vecchio nome - binario PyInstaller rebuilt con icona bundled (--add-data)
509 lines
17 KiB
Python
Executable File
509 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Pi Session Launcher
|
|
===================
|
|
GUI (GTK3) che elenca tutte le sessioni salvate di Pi Agent
|
|
(~/.pi/agent/sessions/**/*.jsonl) e, selezionandone una:
|
|
|
|
* apre un terminale (Konsole) nella cartella di lavoro della sessione (cwd)
|
|
* riprende la sessione con: pi --session <file di sessione>
|
|
|
|
Requisiti: python3-gi (GTK3), konsole (o xterm come fallback), pi installato.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
import gi
|
|
|
|
gi.require_version("Gtk", "3.0")
|
|
gi.require_version("Gdk", "3.0")
|
|
from gi.repository import Gdk, GLib, Gtk, Pango
|
|
|
|
APP_TITLE = "Pi Session Launcher"
|
|
SESSIONS_DIR = os.path.expanduser("~/.pi/agent/sessions/")
|
|
MAX_NAME_SCAN = 16 * 1024 * 1024 # oltre questa dimensione, scandisci solo inizio/fine
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lettura sessioni
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _decode(data: bytes) -> str:
|
|
return data.decode("utf-8", "replace")
|
|
|
|
|
|
def _first_user_message(text: str) -> str:
|
|
"""Cerca il primo messaggio con role=user nei primi chunk del file."""
|
|
for line in text.splitlines():
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if entry.get("type") != "message":
|
|
continue
|
|
msg = entry.get("message") or {}
|
|
if msg.get("role") != "user":
|
|
continue
|
|
content = msg.get("content", "")
|
|
if isinstance(content, list):
|
|
parts = []
|
|
for block in content:
|
|
if isinstance(block, dict) and block.get("type") == "text":
|
|
parts.append(block.get("text", ""))
|
|
return " ".join(parts).strip()
|
|
if isinstance(content, str):
|
|
return content.strip()
|
|
return ""
|
|
|
|
|
|
def _session_name(full_text: str) -> str:
|
|
"""Nome mostrato della sessione: come pi, l'ULTIMA entry session_info vince
|
|
(nome vuoto cancella il titolo)."""
|
|
name = ""
|
|
for line in reversed(full_text.splitlines()):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
if '"type":"session_info"' not in line and '"type": "session_info"' not in line:
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if entry.get("type") == "session_info":
|
|
return entry.get("name") or ""
|
|
return name
|
|
|
|
|
|
def parse_session(path: str):
|
|
"""Legge un file .jsonl di sessione e ne estrae le info utili."""
|
|
try:
|
|
stat = os.stat(path)
|
|
except OSError:
|
|
return None
|
|
if stat.st_size == 0:
|
|
return None
|
|
|
|
info = {
|
|
"path": path,
|
|
"cwd": "",
|
|
"sid": "",
|
|
"name": "",
|
|
"first": "",
|
|
"mtime": stat.st_mtime,
|
|
"size": stat.st_size,
|
|
}
|
|
|
|
try:
|
|
with open(path, "rb") as f:
|
|
header_raw = f.readline().decode("utf-8", "replace")
|
|
header = json.loads(header_raw)
|
|
if header.get("type") != "session":
|
|
return None
|
|
info["cwd"] = header.get("cwd", "") or ""
|
|
info["sid"] = header.get("id", "") or ""
|
|
|
|
# primo messaggio utente: leggiamo i primi ~64 KB (righe iniziali)
|
|
f.seek(0)
|
|
head = _decode(f.read(65536))
|
|
info["first"] = _first_user_message(head)
|
|
|
|
# nome sessione: scandisci tutto il file (con limite prudente)
|
|
if stat.st_size <= MAX_NAME_SCAN:
|
|
full = head + _decode(f.read())
|
|
else:
|
|
f.seek(max(0, stat.st_size - 1024 * 1024))
|
|
tail = _decode(f.read())
|
|
full = head + tail
|
|
info["name"] = _session_name(full)
|
|
except (OSError, json.JSONDecodeError, ValueError):
|
|
return None
|
|
|
|
return info
|
|
|
|
|
|
def rename_session(path, new_name):
|
|
"""Imposta (o cancella, con '') il nome visualizzato di una sessione,
|
|
appendendo una entry session_info al file JSONL (stesso meccanismo di pi).
|
|
pi usa il nome dell'ultima entry session_info presente nel file."""
|
|
last_id = None
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
e = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if e.get("type") != "session" and e.get("id"):
|
|
last_id = e["id"]
|
|
entry = {
|
|
"type": "session_info",
|
|
"id": os.urandom(4).hex(),
|
|
"parentId": last_id,
|
|
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z",
|
|
"name": new_name,
|
|
}
|
|
with open(path, "a", encoding="utf-8") as f:
|
|
f.write(json.dumps(entry, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
|
|
|
|
def scan_sessions():
|
|
"""Elenca tutte le sessioni, ordinate per ultima modifica (più recente prima)."""
|
|
sessions = []
|
|
if not os.path.isdir(SESSIONS_DIR):
|
|
return sessions
|
|
for root, _dirs, files in os.walk(SESSIONS_DIR):
|
|
for fn in files:
|
|
if not fn.endswith(".jsonl"):
|
|
continue
|
|
info = parse_session(os.path.join(root, fn))
|
|
if info:
|
|
sessions.append(info)
|
|
sessions.sort(key=lambda s: s["mtime"], reverse=True)
|
|
return sessions
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Formattazione
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def fmt_relative(ts: float) -> str:
|
|
d = time.time() - ts
|
|
if d < 0:
|
|
return "adesso"
|
|
if d < 60:
|
|
return f"{int(d)}s fa"
|
|
if d < 3600:
|
|
return f"{int(d // 60)}min fa"
|
|
if d < 86400:
|
|
return f"{int(d // 3600)}h fa"
|
|
if d < 172800:
|
|
return "ieri"
|
|
return f"{int(d // 86400)}g fa"
|
|
|
|
|
|
def fmt_absolute(ts: float) -> str:
|
|
return time.strftime("%d/%m/%Y %H:%M", time.localtime(ts))
|
|
|
|
|
|
def fmt_size(n: int) -> str:
|
|
if n < 1024:
|
|
return f"{n} B"
|
|
if n < 1024 * 1024:
|
|
return f"{n / 1024:.0f} KB"
|
|
return f"{n / (1024 * 1024):.1f} MB"
|
|
|
|
|
|
def display_text(info: dict, limit: int = 100) -> str:
|
|
"""Testo principale mostrato per la sessione: nome se presente, altrimenti primo messaggio."""
|
|
if info["name"]:
|
|
return info["name"]
|
|
first = info["first"].replace("\n", " ")
|
|
if len(first) > limit:
|
|
first = first[: limit - 1] + "…"
|
|
return first or "(sessione senza messaggi)"
|
|
|
|
|
|
def icon_path():
|
|
"""Percorso dell'icona: dati bundlati (PyInstaller) o cartella assets del sorgente."""
|
|
base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
|
|
for rel in ("assets/pi-session-launcher.png", "pi-session-launcher.png"):
|
|
p = os.path.join(base, rel)
|
|
if os.path.isfile(p):
|
|
return p
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GUI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LauncherWindow(Gtk.ApplicationWindow):
|
|
def __init__(self, app):
|
|
super().__init__(application=app, title=APP_TITLE)
|
|
self.set_default_size(1000, 600)
|
|
icon = icon_path()
|
|
if icon:
|
|
try:
|
|
self.set_icon_from_file(icon)
|
|
except Exception:
|
|
pass
|
|
|
|
# modello: markup_sessione, cwd, rel_time, abs_time, mtime(float), path, size(int), search_text
|
|
self.store = Gtk.ListStore(str, str, str, str, float, str, int, str)
|
|
self.by_path = {}
|
|
|
|
self.search_entry = Gtk.SearchEntry(placeholder_text="Cerca sessione o cartella…")
|
|
self.search_entry.connect("search-changed", self._on_search_changed)
|
|
|
|
# vista lista
|
|
self.filter = self.store.filter_new()
|
|
self.filter.set_visible_func(self._filter_visible)
|
|
self.tree = Gtk.TreeView(model=self.filter)
|
|
self.tree.set_search_column(0)
|
|
self.tree.set_headers_clickable(True)
|
|
|
|
rend = Gtk.CellRendererText(ellipsize="end")
|
|
col = Gtk.TreeViewColumn("Sessione", rend, markup=0)
|
|
col.set_expand(True)
|
|
col.set_min_width(420)
|
|
col.set_sort_column_id(7)
|
|
self.tree.append_column(col)
|
|
|
|
rend2 = Gtk.CellRendererText(ellipsize="middle")
|
|
col2 = Gtk.TreeViewColumn("Cartella", rend2, text=1)
|
|
col2.set_expand(True)
|
|
col2.set_min_width(280)
|
|
col2.set_sort_column_id(1)
|
|
self.tree.append_column(col2)
|
|
|
|
rend3 = Gtk.CellRendererText()
|
|
col3 = Gtk.TreeViewColumn("Modificata", rend3, text=2)
|
|
col3.set_sort_column_id(4)
|
|
col3.set_alignment(1.0)
|
|
self.tree.append_column(col3)
|
|
|
|
self.tree.get_selection().set_mode(Gtk.SelectionMode.SINGLE)
|
|
self.tree.connect("row-activated", self._on_row_activated)
|
|
self.tree.connect("key-press-event", self._on_key_press)
|
|
|
|
scrolled = Gtk.ScrolledWindow()
|
|
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
scrolled.add(self.tree)
|
|
|
|
# barra pulsanti
|
|
btn_refresh = Gtk.Button(label="Aggiorna", tooltip_text="Ricarica l'elenco (Ctrl+R)")
|
|
btn_refresh.connect("clicked", lambda *_: self.refresh())
|
|
btn_open_dir = Gtk.Button(label="Apri cartella", tooltip_text="Apre la cartella della sessione in Dolphin")
|
|
btn_open_dir.connect("clicked", lambda *_: self.open_folder())
|
|
btn_rename = Gtk.Button(label="Rinomina", tooltip_text="Rinomina la sessione selezionata (F2)")
|
|
btn_rename.connect("clicked", lambda *_: self.rename())
|
|
btn_launch = Gtk.Button(label="Riprendi sessione", tooltip_text="Apre Konsole nella cartella e riprende la sessione (Invio)")
|
|
btn_launch.get_style_context().add_class("suggested-action")
|
|
btn_launch.connect("clicked", lambda *_: self.launch())
|
|
btn_quit = Gtk.Button(label="Esci")
|
|
btn_quit.connect("clicked", lambda *_: self.close())
|
|
|
|
hbox = Gtk.Box(spacing=8)
|
|
hbox.pack_start(btn_refresh, False, False, 0)
|
|
hbox.pack_start(btn_open_dir, False, False, 0)
|
|
hbox.pack_start(btn_rename, False, False, 0)
|
|
hbox.pack_start(Gtk.Box(), True, True, 0) # spaziatore
|
|
hbox.pack_start(btn_launch, False, False, 0)
|
|
hbox.pack_start(btn_quit, False, False, 0)
|
|
|
|
# status bar
|
|
self.status = Gtk.Label(label="", xalign=0.0, halign=Gtk.Align.START)
|
|
self.status.set_ellipsize(Pango.EllipsizeMode.END)
|
|
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
|
box.set_border_width(8)
|
|
box.pack_start(self.search_entry, False, False, 0)
|
|
box.pack_start(scrolled, True, True, 0)
|
|
box.pack_start(hbox, False, False, 0)
|
|
sep = Gtk.Separator()
|
|
box.pack_start(sep, False, False, 0)
|
|
box.pack_start(self.status, False, False, 0)
|
|
self.add(box)
|
|
|
|
self.refresh()
|
|
self.show_all()
|
|
self.search_entry.grab_focus()
|
|
|
|
# -- popolamento ------------------------------------------------------
|
|
|
|
def refresh(self):
|
|
sessions = scan_sessions()
|
|
selected_path = self.selected_path()
|
|
self.store.clear()
|
|
self.by_path.clear()
|
|
for s in sessions:
|
|
text = display_text(s)
|
|
markup = GLib.markup_escape_text(text)
|
|
if s["name"]:
|
|
markup = f"<b>{markup}</b>"
|
|
self.store.append([markup, s["cwd"], fmt_relative(s["mtime"]),
|
|
fmt_absolute(s["mtime"]), s["mtime"], s["path"], s["size"], text.lower()])
|
|
self.by_path[s["path"]] = s
|
|
if selected_path in self.by_path:
|
|
self.select_path(selected_path)
|
|
self._update_status()
|
|
|
|
def _update_status(self):
|
|
n = len(self.store)
|
|
self.status.set_text(
|
|
f"{n} session{'i' if n != 1 else 'e'} · {SESSIONS_DIR}"
|
|
)
|
|
|
|
# -- selezione --------------------------------------------------------
|
|
|
|
def selected_iter(self):
|
|
model, it = self.tree.get_selection().get_selected()
|
|
return model, it
|
|
|
|
def selected_path(self):
|
|
model, it = self.selected_iter()
|
|
if it is None:
|
|
return None
|
|
return model[it][5]
|
|
|
|
def select_path(self, path):
|
|
it = self.store.get_iter_first()
|
|
while it is not None:
|
|
if self.store[it][5] == path:
|
|
self.tree.get_selection().select_iter(it)
|
|
self.tree.scroll_to_cell(self.store.get_path(it))
|
|
return
|
|
it = self.store.iter_next(it)
|
|
|
|
# -- filtri -----------------------------------------------------------
|
|
|
|
def _filter_visible(self, model, it, _data=None):
|
|
q = self.search_entry.get_text().strip().lower()
|
|
if not q:
|
|
return True
|
|
return q in model[it][7] or q in model[it][1].lower()
|
|
|
|
def _on_search_changed(self, _entry):
|
|
self.filter.refilter()
|
|
|
|
# -- azioni -----------------------------------------------------------
|
|
|
|
def _on_row_activated(self, _tree, _path, _col):
|
|
self.launch()
|
|
|
|
def _on_key_press(self, _widget, event):
|
|
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
|
|
self.launch()
|
|
return True
|
|
if event.keyval == Gdk.KEY_F2:
|
|
self.rename()
|
|
return True
|
|
if event.keyval == Gdk.KEY_r and event.state & Gdk.ModifierType.CONTROL_MASK:
|
|
self.refresh()
|
|
return True
|
|
return False
|
|
|
|
def launch(self):
|
|
path = self.selected_path()
|
|
if not path:
|
|
self._flash_status("Seleziona prima una sessione dalla lista.")
|
|
return
|
|
info = self.by_path.get(path, {})
|
|
cwd = info.get("cwd") or os.path.expanduser("~")
|
|
if not os.path.isdir(cwd):
|
|
cwd = os.path.expanduser("~")
|
|
|
|
konsole = shutil.which("konsole")
|
|
if konsole:
|
|
# wrapper bash: più affidabile del passaggio diretto di -e a Konsole
|
|
# ("exec bash" mantiene la shell aperta quando pi esce)
|
|
cmd = [konsole, "--workdir", cwd, "--hold", "--separate",
|
|
"-e", "bash", "-c", 'pi --session "$1"; exec bash', "_", path]
|
|
else:
|
|
term = shutil.which("xterm")
|
|
if not term:
|
|
self._flash_status("Nessun terminale trovato (installa konsole o xterm).")
|
|
return
|
|
cmd = [term, "-hold", "-e", "pi", "--session", path]
|
|
|
|
try:
|
|
subprocess.Popen(cmd, start_new_session=True)
|
|
except OSError as e:
|
|
self._flash_status(f"Impossibile aprire il terminale: {e}")
|
|
return
|
|
self._flash_status(f"Avviato: pi --session {os.path.basename(path)} in {cwd}")
|
|
|
|
def rename(self):
|
|
path = self.selected_path()
|
|
if not path:
|
|
self._flash_status("Seleziona prima una sessione dalla lista.")
|
|
return
|
|
info = self.by_path.get(path, {})
|
|
current = info.get("name", "")
|
|
|
|
dialog = Gtk.Dialog(title="Rinomina sessione", transient_for=self, modal=True)
|
|
dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
|
Gtk.STOCK_OK, Gtk.ResponseType.OK)
|
|
entry = Gtk.Entry(text=current)
|
|
entry.set_activates_default(True)
|
|
lbl = Gtk.Label(label="Nome mostrato (vuoto = cancella il nome):")
|
|
lbl.set_xalign(0.0)
|
|
box = dialog.get_content_area()
|
|
box.set_border_width(12)
|
|
box.set_spacing(8)
|
|
box.pack_start(lbl, False, False, 0)
|
|
box.pack_start(entry, False, False, 0)
|
|
dialog.set_default_response(Gtk.ResponseType.OK)
|
|
dialog.show_all()
|
|
response = dialog.run()
|
|
name = entry.get_text().strip()
|
|
dialog.destroy()
|
|
|
|
if response != Gtk.ResponseType.OK:
|
|
return
|
|
try:
|
|
rename_session(path, name)
|
|
except OSError as e:
|
|
self._flash_status(f"Errore nella rinomina: {e}")
|
|
return
|
|
self.refresh()
|
|
self._flash_status(f"Sessione rinominata: {name or '(nome cancellato)'}")
|
|
|
|
def open_folder(self):
|
|
path = self.selected_path()
|
|
if not path:
|
|
return
|
|
info = self.by_path.get(path, {})
|
|
cwd = info.get("cwd") or os.path.expanduser("~")
|
|
if not os.path.isdir(cwd):
|
|
cwd = os.path.expanduser("~")
|
|
dolphin = shutil.which("dolphin")
|
|
if dolphin:
|
|
subprocess.Popen([dolphin, cwd], start_new_session=True)
|
|
|
|
def _flash_status(self, text):
|
|
self.status.set_text(text)
|
|
GLib.timeout_add(5000, lambda: self._update_status() or False)
|
|
|
|
|
|
class LauncherApp(Gtk.Application):
|
|
def __init__(self):
|
|
# deve combaciare con il nome del file .desktop (senza estensione)
|
|
super().__init__(application_id=APP_ID)
|
|
self.win = None
|
|
|
|
def do_activate(self):
|
|
if self.win is None:
|
|
self.win = LauncherWindow(self)
|
|
self.win.present()
|
|
|
|
|
|
APP_ID = "it.enne2.pi-session-launcher"
|
|
|
|
|
|
def main():
|
|
# KDE Plasma/Wayland: l'icona di finestra viene associata via app-id
|
|
# + file .desktop con lo stesso nome stem (vedi ricerca Perplexity).
|
|
# GLib richiede un punto nell'application_id (reverse-DNS).
|
|
GLib.set_prgname(APP_ID)
|
|
Gtk.Window.set_default_icon_name(APP_ID)
|
|
app = LauncherApp()
|
|
app.run(sys.argv)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|