feat: first attempt at a gui for managing sessions

This commit is contained in:
Joe Fleming
2026-03-07 11:52:39 -07:00
commit ad7030ae38
9 changed files with 1046 additions and 0 deletions

430
app.py Normal file
View File

@@ -0,0 +1,430 @@
"""Session Mover TUI Application.
A Textual-based TUI for managing OpenCode sessions.
"""
from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Header, Footer, Static, Input, Button, Select, Tree, DataTable, Label
from textual.screen import ModalScreen
from textual import events
from typing import List, Optional
from database import Database, Session, Project
from datetime import datetime
from pathlib import Path
class MoveCopyDialog(ModalScreen):
"""Dialog for moving or copying sessions."""
BINDINGS = [
("escape", "cancel", "Cancel"),
("enter", "confirm", "Confirm"),
]
def __init__(self, db: Database, sessions: List[Session], is_copy: bool = False):
super().__init__()
self.db = db
self.sessions = sessions
self.is_copy = is_copy
self.target_project: Optional[str] = None
self.target_workspace: Optional[str] = None
self.sql_preview: List[str] = []
def compose(self) -> ComposeResult:
with Container(id="dialog"):
yield Label(f"{'Copy' if self.is_copy else 'Move'} Sessions", id="dialog-title")
yield Label("Target Project:")
yield Select(
[("-- Select Project --", "")] + [
(f"{p.name or p.worktree} ({p.id})", p.id)
for p in self.db.projects.values()
],
id="project-select",
prompt="Select a project"
)
yield Label("Target Workspace (optional):")
yield Select(
[("-- No workspace --", "")] + [
(f"{w.name or w.branch or w.id}", w.id)
for w in self.db.workspaces.values()
],
id="workspace-select",
prompt="Select a workspace"
)
yield Label("Preview:", id="preview-label")
yield Static("", id="sql-preview")
with Horizontal():
yield Button("Cancel", variant="error", id="cancel-btn")
yield Button("Confirm", variant="primary", id="confirm-btn", disabled=True)
def on_mount(self) -> None:
self.query_one("#dialog").border_title = f"{'Copy' if self.is_copy else 'Move'} Sessions"
def on_select_changed(self, event: Select.Changed) -> None:
if event.select.id == "project-select":
self.target_project = event.value if event.value else None
elif event.select.id == "workspace-select":
self.target_workspace = event.value if event.value else None
self.update_preview()
confirm_btn = self.query_one("#confirm-btn")
confirm_btn.disabled = not (self.target_project is not None)
def update_preview(self) -> None:
if not self.target_project:
self.sql_preview = []
self.query_one("#sql-preview").update("Select a project to see preview")
return
target_proj = self.db.projects.get(self.target_project)
target_ws = self.db.workspaces.get(self.target_workspace) if self.target_workspace else None
preview_lines = [
f"Source: {len(self.sessions)} session(s)",
f"Destination Project: {target_proj.name or target_proj.worktree if target_proj else 'Unknown'}",
]
if target_ws:
preview_lines.append(f"Destination Workspace: {target_ws.name or target_ws.branch or target_ws.id}")
else:
preview_lines.append("Destination Workspace: (keep current or none)")
preview_lines.append("")
preview_lines.append("SQL statements:")
for sess in self.sessions:
if self.is_copy:
preview_lines.append(f" COPY session {sess.id[:8]}... → new ID (with all related data)")
else:
if target_ws:
preview_lines.append(f" UPDATE session SET project_id = {self.target_project}, workspace_id = {self.target_workspace} WHERE id = {sess.id[:16]}...;")
else:
preview_lines.append(f" UPDATE session SET project_id = {self.target_project} WHERE id = {sess.id[:16]}...;")
if self.is_copy:
preview_lines.append("\nNote: Copy creates new sessions with all associated messages, parts, todos, and shares.")
self.sql_preview = preview_lines
self.query_one("#sql-preview").update("\n".join(preview_lines))
def action_cancel(self) -> None:
self.app.pop_screen()
def action_confirm(self) -> None:
if not self.target_project:
return
# Execute operation
if self.is_copy:
success, sql, new_ids = self.db.copy_sessions(
[s.id for s in self.sessions],
self.target_project,
self.target_workspace
)
else:
success, sql = self.db.move_sessions(
[s.id for s in self.sessions],
self.target_project,
self.target_workspace
)
if success:
self.app.notify(f"{'Copied' if self.is_copy else 'Moved'} {len(self.sessions)} session(s) successfully!", severity="information")
self.app.pop_screen()
# Trigger refresh in main app
self.app.query_one(SessionMoverApp).refresh_sessions()
else:
self.app.notify(f"Operation failed: {sql}", severity="error")
class BackupDialog(ModalScreen):
"""Dialog showing backup creation."""
def __init__(self, backup_path: Path):
super().__init__()
self.backup_path = backup_path
def compose(self) -> ComposeResult:
with Container(id="backup-dialog"):
yield Label("Backup Created!", id="backup-title")
yield Static(f"Database backed up to:\n{self.backup_path}", id="backup-path")
with Horizontal():
yield Button("OK", variant="primary", id="ok-btn")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "ok-btn":
self.app.pop_screen()
class SessionMoverApp(App):
"""Main TUI Application."""
CSS = """
Screen {
layout: horizontal;
}
#project-panel {
width: 30%;
border-right: solid $primary;
padding: 1;
}
#session-panel {
width: 70%;
padding: 1;
}
#project-tree {
height: 1fr;
border: solid $primary-lighten-2;
}
Tree {
padding: 0 1;
}
#filter-input {
width: 100%;
margin-bottom: 1;
}
#sessions-table {
height: 1fr;
}
DataTable {
border: solid $primary-lighten-2;
}
#action-bar {
height: auto;
padding: 1;
background: $surface;
}
Button {
margin-right: 1;
}
#hotkeys {
color: $text-muted;
}
/* Dialog styles */
#dialog, #backup-dialog {
width: 60;
height: auto;
border: thick $primary;
background: $surface;
padding: 2;
}
#dialog-title, #backup-title {
text-align: center;
text-style: bold;
margin-bottom: 1;
}
#sql-preview {
height: 10;
border: solid $primary-lighten-3;
padding: 1;
overflow: auto;
}
#preview-label {
margin-top: 1;
text-style: bold;
}
.hidden {
display: none;
}
"""
BINDINGS = [
("q", "quit", "Quit"),
("b", "backup", "Backup DB"),
("m", "move", "Move"),
("c", "copy", "Copy"),
("a", "toggle_archived", "Show Archived"),
("r", "refresh", "Refresh"),
("escape", "cancel_selection", "Deselect"),
]
def __init__(self, db_path: str = "opencode.db"):
super().__init__()
self.db = Database(db_path)
self.selected_sessions: List[Session] = []
self.show_archived = False
self.current_filter = ""
self.backup_made = False
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
with Vertical(id="project-panel"):
yield Static("Projects & Workspaces", id="projects-title")
yield Tree("Projects", id="project-tree")
with Vertical(id="session-panel"):
with Horizontal():
yield Input(placeholder="Filter sessions...", id="filter-input")
yield Button("Clear", id="filter-clear", variant="default")
yield DataTable(id="sessions-table")
with Container(id="action-bar"):
yield Static(
"Hotkeys: [b] Backup [m] Move [c] Copy [a] Archive [r] Refresh [q] Quit",
id="hotkeys"
)
yield Footer()
def on_mount(self) -> None:
"""Initialize app."""
self.db.connect()
self.db.load_reference_data()
# Setup project tree
tree = self.query_one("#project-tree")
tree.root.expand()
self.refresh_project_tree()
# Setup sessions table
table = self.query_one("#sessions-table")
table.zebra_stripes = True
table.cursor_type = "row"
table.add_columns("ID", "Title", "Project", "Workspace", "Msgs", "Todos", "Created", "Archived")
self.refresh_sessions()
def refresh_project_tree(self) -> None:
"""Update the project/workspace tree."""
tree = self.query_one("#project-tree")
tree.clear()
root = tree.root
root.data = None
for proj_name, ws_names, has_workspaces in self.db.get_project_tree():
proj_node = root.add(proj_name, {"type": "project", "name": proj_name})
proj_node.expand()
if has_workspaces:
for ws_name in ws_names:
proj_node.add_leaf(ws_name, {"type": "workspace", "name": ws_name})
def refresh_sessions(self) -> None:
"""Update the sessions table."""
table = self.query_one("#sessions-table")
table.clear(columns=True)
table.add_columns("ID", "Title", "Project", "Workspace", "Msgs", "Todos", "Created", "Archived")
sessions = self.db.get_sessions(
include_archived=self.show_archived,
search=self.current_filter
)
for sess in sessions:
created_dt = datetime.fromtimestamp(sess.time_created / 1000).strftime("%Y-%m-%d %H:%M") if sess.time_created else "N/A"
archived_str = "🗓️" if sess.time_archived else ""
table.add_row(
sess.id[:8],
sess.title[:50],
sess.project_name or sess.project_id[:8],
sess.workspace_name or "",
str(sess.message_count),
str(sess.todo_count),
created_dt,
archived_str,
key=sess.id
)
self.notify(f"Loaded {len(sessions)} sessions", severity="information")
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection."""
session_id = event.row_key
if session_id:
session = self.db.get_session(session_id)
if session:
if session in self.selected_sessions:
self.selected_sessions.remove(session)
else:
self.selected_sessions.append(session)
self.update_status()
def update_status(self) -> None:
"""Update footer/status with selection count."""
count = len(self.selected_sessions)
if count > 0:
self.notify(f"{count} session(s) selected", severity="information")
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle filter input."""
if event.input.id == "filter-input":
self.current_filter = event.value
self.refresh_sessions()
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button clicks."""
if event.button.id == "filter-clear":
self.query_one("#filter-input").value = ""
self.current_filter = ""
self.refresh_sessions()
def action_backup(self) -> None:
"""Create database backup."""
backup_path = self.db.create_backup()
self.push_screen(BackupDialog(backup_path))
def action_move(self) -> None:
"""Open move dialog."""
if not self.selected_sessions:
self.notify("No sessions selected", severity="warning")
return
# Ensure backup exists before write operation
if not self.backup_made:
self.db.create_backup()
self.backup_made = True
self.notify("Backup created", severity="information")
self.push_screen(MoveCopyDialog(self.db, self.selected_sessions, is_copy=False))
def action_copy(self) -> None:
"""Open copy dialog."""
if not self.selected_sessions:
self.notify("No sessions selected", severity="warning")
return
# Ensure backup exists before write operation
if not self.backup_made:
self.db.create_backup()
self.backup_made = True
self.notify("Backup created", severity="information")
self.push_screen(MoveCopyDialog(self.db, self.selected_sessions, is_copy=True))
def action_toggle_archived(self) -> None:
"""Toggle archived sessions visibility."""
self.show_archived = not self.show_archived
self.refresh_sessions()
status = "shown" if self.show_archived else "hidden"
self.notify(f"Archived sessions {status}", severity="information")
def action_refresh(self) -> None:
"""Reload all data from database."""
self.db.load_reference_data()
self.refresh_project_tree()
self.refresh_sessions()
self.notify("Data refreshed", severity="information")
def on_unmount(self) -> None:
"""Cleanup on exit."""
self.db.close()
if __name__ == "__main__":
app = SessionMoverApp()
app.run()