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

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
.venv/
pip-log.txt
pip-delete-this-directory.txt
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache/
.pytest_cache/
.hypothesis/
# Session Mover specific
opencode-*.db
*.db-wal
*.db-shm
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Application
opencode.db*

140
PLAN.md Normal file
View File

@@ -0,0 +1,140 @@
# Session Mover TUI - Project Plan
## Overview
A Python Textual TUI application to visually manage OpenCode sessions (move/copy between projects and workspaces) without writing SQL.
## Database Schema (opencode.db)
### Core Tables
- **session**: `id, project_id, workspace_id, parent_id, slug, directory, title, version, time_created, time_updated, time_archived`
- **project**: `id, worktree, name, vcs`
- **workspace**: `id, project_id, branch, type, name, directory`
### Related Data (cascade on session delete)
- **message** → **part**: Conversation content
- **todo**: Session todos
- **session_share**: Share links
## Goals
1. **Visual browsing** - Tree view of Projects → Workspaces → Sessions
2. **Easy session movement** - Drag-and-drop or keyboard-driven moving between projects
3. **Batch operations** - Select multiple sessions and move them together
4. **Safety** - Confirmation dialogs, preview SQL, automatic backup
5. **Discoverability** - Hotkeys always visible, clear status indicators
## Key Features
### Display
- Split pane: Left = Projects/Workspaces tree, Right = Sessions table
- Sessions table columns: Title, Project, Workspace, Messages, Created, Archived status
- Filter/search box for sessions
- Toggle for archived sessions (hidden by default)
- Selected sessions highlighted/checked
### Navigation
- Mouse: Click to select, double-click to open details
- Keyboard: Tab/Shift+Tab between panels, arrows to navigate
- Hotkeys always visible at bottom
### Actions
- **Backup** (`B`) - Create timestamped DB copy
- **Move** (`M`) - Move selected sessions to another project/workspace
- **Copy** (`C`) - Duplicate sessions to another project
- **Show archived** (`A`) - Toggle archived sessions visibility
- **Refresh** (`R`) - Reload data from database
- **Quit** (`Q`)
### Move/Copy Dialog
- Dropdown selectors for destination Project (required) and Workspace (optional)
- Preview of affected sessions and SQL statements that will execute
- Confirmation before executing
- Summary after completion
### Backup Strategy
- Before first write operation, create backup: `opencode-YYYYMMDD-HHMMSS.db`
- Location: same directory as source DB
- Only one backup per session (don't spam)
- User can manually backup at any time with `B`
## Technical Implementation
### Stack
- **Python 3.9+**
- **Textual** (TUI framework)
- **sqlite3** (stdlib)
### Project Structure
```
session-mover/
├── main.py # Application entry point
├── app.py # Textual App class
├── database.py # DB connection, queries, models
├── widgets/
│ ├── project_tree.py # Projects/Workspaces tree
│ ├── sessions_table.py # Sessions data table
│ └── dialogs.py # Move/Copy dialogs
├── utils.py # Backup, formatting helpers
└── requirements.txt
```
### Database Operations
**Move session:**
```sql
UPDATE session SET project_id = ? WHERE id = ?;
-- Optionally: UPDATE session SET workspace_id = ? WHERE id = ?;
```
**Copy session:**
```sql
BEGIN TRANSACTION;
-- Copy session with new UUID
INSERT INTO session (...)
SELECT ... FROM session WHERE id = ?;
-- Copy related records with new IDs
INSERT INTO message ... SELECT ... FROM message WHERE session_id = ?;
INSERT INTO part ... SELECT ... FROM part WHERE session_id = ?;
INSERT INTO todo ... SELECT ... FROM todo WHERE session_id = ?;
INSERT INTO session_share ... SELECT ... FROM session_share WHERE session_id = ?;
COMMIT;
```
**Validation checks:**
- Target project exists
- If workspace specified: exists and belongs to target project
- Sessions exist and aren't already in target
## Implementation Steps
1. **Setup** - Create project structure, requirements.txt with `textual`
2. **Database layer** - `database.py` with connection, session/project/workspace queries
3. **TUI skeleton** - Basic Textual app with split layout
4. **Project tree widget** - Load projects/workspaces, show hierarchy
5. **Sessions table** - Load sessions with counts, implement filtering
6. **Selection** - Multi-select mechanism (checkboxes or highlight+shift)
7. **Action dialogs** - Move/Copy dialog with preview
8. **Backup system** - Before first write operation
9. **SQL execution** - Safe updates with transactions
10. **Polish** - Status messages, error handling, hotkeys display
## Open Questions (Resolved)
- Use Textual: **Yes**
- Show hotkeys: **Always at bottom**
- Include archived: **hidden by default, toggle显示**
- Single DB path: **Default opencode.db in current dir, --db flag to override**
- Copy command: **Yes, duplicate sessions**
- Batch move: **All selected move to same destination**
- Confirmation needed: **Yes, with SQL preview**
- Backup: **Timestamped copy before first write**
- Workspace on move: **Optional - can keep current or select new**
## Success Criteria
- Can browse all sessions with project/workspace context
- Can select session(s) and move them to different project with one keystroke
- Can see exactly what SQL will run before confirming
- Backup created automatically before any change
- All operations display clear success/error feedback
- No need to write SQL manually

63
README.md Normal file
View File

@@ -0,0 +1,63 @@
# Session Mover TUI
A Python Textual TUI for easily moving OpenCode sessions between projects and workspaces without writing SQL.
## Quick Start
```bash
# Install dependencies
pip install -r requirements.txt
# Run (looks for opencode.db in current directory)
python session-mover.py
# Or specify a database path
python session-mover.py /path/to/opencode.db
```
## How to Use
1. **Browse** - See all sessions organized by project/workspace on the left
2. **Select** - Click or use arrow keys + Enter to select sessions (multiple with Shift+Click)
3. **Filter** - Type in the filter box to search session titles
4. **Move** - Press `m` to move selected sessions to another project
5. **Copy** - Press `c` to duplicate sessions to another project
6. **Backup** - Press `b` to manually create a database backup
### Hotkeys (always visible at bottom)
- `b` - Backup database
- `m` - Move selected sessions
- `c` - Copy selected sessions
- `a` - Toggle archived sessions visibility
- `r` - Refresh all data
- `q` - Quit
- `Escape` - Deselect / close dialogs
### Move/Copy Dialog
1. Select destination project (dropdown)
2. Optionally select a workspace within that project
3. Review the SQL preview to see what will execute
4. Press `Enter` or click Confirm to execute
5. A backup is automatically created before the first write operation
### Safety
- Before any move/copy, a timestamped backup is created: `opencode-YYYYMMDD-HHMMSS.db`
- Confirmation dialog shows exactly what will happen
- Changes appear immediately in the UI after success
## Database Schema Support
Works with OpenCode's `opencode.db` with tables:
- `session` (with project_id, workspace_id, parent_id)
- `project`, `workspace`
- `message``part`, `todo`, `session_share` (all cascade automatically)
## Tips
- Filter by typing in the search box (searches title and slug)
- Toggle archived sessions with `a` (hidden by default)
- The left panel shows Projects → Workspaces hierarchy
- Selected sessions remain selected when filtering, so you can search, select, then clear filter

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()

350
database.py Normal file
View File

@@ -0,0 +1,350 @@
"""Database layer for OpenCode session operations."""
import sqlite3
from pathlib import Path
from typing import Optional, List, Tuple
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Project:
"""Project data structure."""
id: str
worktree: str
name: Optional[str]
vcs: Optional[str]
time_created: int
time_updated: int
@dataclass
class Workspace:
"""Workspace data structure."""
id: str
project_id: str
branch: Optional[str]
type: str
name: Optional[str]
directory: Optional[str]
@dataclass
class Session:
"""Session data structure."""
id: str
project_id: str
workspace_id: Optional[str]
parent_id: Optional[str]
slug: str
directory: str
title: str
version: str
time_created: int
time_updated: int
time_archived: Optional[int] = None
# Computed fields
message_count: int = 0
todo_count: int = 0
workspace_name: Optional[str] = None
project_name: Optional[str] = None
class Database:
"""Database connection and query handler."""
def __init__(self, db_path: str = "opencode.db"):
self.db_path = Path(db_path)
self.conn: Optional[sqlite3.Connection] = None
self.projects: dict[str, Project] = {}
self.workspaces: dict[str, Workspace] = {}
self.workspaces_by_project: dict[str, List[Workspace]] = {}
def connect(self):
"""Establish database connection."""
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row
def close(self):
"""Close database connection."""
if self.conn:
self.conn.close()
def load_reference_data(self):
"""Load projects and workspaces into memory for fast lookups."""
assert self.conn is not None
cursor = self.conn.cursor()
# Load projects
cursor.execute("SELECT id, worktree, name, vcs, time_created, time_updated FROM project")
for row in cursor.fetchall():
proj = Project(
id=row["id"],
worktree=row["worktree"],
name=row["name"],
vcs=row["vcs"],
time_created=row["time_created"],
time_updated=row["time_updated"]
)
self.projects[proj.id] = proj
# Load workspaces
cursor.execute("SELECT id, project_id, branch, type, name, directory FROM workspace")
for row in cursor.fetchall():
ws = Workspace(
id=row["id"],
project_id=row["project_id"],
branch=row["branch"],
type=row["type"],
name=row["name"],
directory=row["directory"]
)
self.workspaces[ws.id] = ws
# Build workspace lookup by project
self.workspaces_by_project = {}
for ws in self.workspaces.values():
self.workspaces_by_project.setdefault(ws.project_id, []).append(ws)
def get_sessions(self,
include_archived: bool = False,
project_id: Optional[str] = None,
workspace_id: Optional[str] = None,
search: str = "") -> List[Session]:
"""
Fetch sessions with optional filtering.
Args:
include_archived: Include archived sessions
project_id: Filter by project
workspace_id: Filter by workspace
search: Text search in title or slug
Returns:
List of Session objects with computed counts
"""
assert self.conn is not None, "Database not connected"
cursor = self.conn.cursor()
query = """
SELECT s.*,
(SELECT COUNT(*) FROM message WHERE session_id = s.id) as msg_count,
(SELECT COUNT(*) FROM todo WHERE session_id = s.id) as todo_count
FROM session s
WHERE 1=1
"""
params = []
if not include_archived:
query += " AND s.time_archived IS NULL"
if project_id:
query += " AND s.project_id = ?"
params.append(project_id)
if workspace_id:
query += " AND s.workspace_id = ?"
params.append(workspace_id)
if search:
query += " AND (s.title LIKE ? OR s.slug LIKE ?)"
params.extend([f"%{search}%", f"%{search}%"])
query += " ORDER BY s.time_created DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
sessions = []
for row in rows:
sess = Session(
id=row["id"],
project_id=row["project_id"],
workspace_id=row["workspace_id"],
parent_id=row["parent_id"],
slug=row["slug"],
directory=row["directory"],
title=row["title"],
version=row["version"],
time_created=row["time_created"],
time_updated=row["time_updated"],
time_archived=row["time_archived"],
message_count=row["msg_count"],
todo_count=row["todo_count"]
)
# Add project name
if sess.project_id in self.projects:
sess.project_name = self.projects[sess.project_id].name or self.projects[sess.project_id].worktree
# Add workspace name
if sess.workspace_id and sess.workspace_id in self.workspaces:
ws = self.workspaces[sess.workspace_id]
sess.workspace_name = ws.name or ws.branch or ws.id
sessions.append(sess)
return sessions
def get_project_tree(self) -> List[Tuple[str, List[str], bool]]:
"""
Build a hierarchical view of projects and workspaces.
Returns:
List of tuples: (project_name, [workspace_names], has_workspaces)
"""
result = []
for proj in self.projects.values():
ws_list = self.workspaces_by_project.get(proj.id, [])
ws_names = [w.name or w.branch or w.id for w in ws_list]
has_workspaces = len(ws_list) > 0
result.append((proj.name or proj.worktree, ws_names, has_workspaces))
return sorted(result)
def get_session(self, session_id: str) -> Optional[Session]:
"""Get a single session by ID."""
sessions = self.get_sessions(include_archived=True)
for s in sessions:
if s.id == session_id:
return s
return None
def move_sessions(self, session_ids: List[str], target_project_id: str,
target_workspace_id: Optional[str] = None) -> Tuple[bool, List[str]]:
"""
Move sessions to a different project (and optionally workspace).
Returns:
(success, list of SQL statements executed)
"""
assert self.conn is not None, "Database not connected"
cursor = self.conn.cursor()
# Verify target project exists
if target_project_id not in self.projects:
return False, [f"ERROR: Project {target_project_id} not found"]
# Verify workspace exists and belongs to target project
if target_workspace_id:
if target_workspace_id not in self.workspaces:
return False, [f"ERROR: Workspace {target_workspace_id} not found"]
if self.workspaces[target_workspace_id].project_id != target_project_id:
return False, [f"ERROR: Workspace {target_workspace_id} does not belong to project {target_project_id}"]
sql_statements = []
for sess_id in session_ids:
if target_workspace_id:
sql = "UPDATE session SET project_id = ?, workspace_id = ? WHERE id = ?"
cursor.execute(sql, (target_project_id, target_workspace_id, sess_id))
sql_statements.append(f"UPDATE session SET project_id = {target_project_id}, workspace_id = {target_workspace_id} WHERE id = {sess_id}")
else:
# Keep current workspace if any, or set to NULL
sql = "UPDATE session SET project_id = ? WHERE id = ?"
cursor.execute(sql, (target_project_id, sess_id))
sql_statements.append(f"UPDATE session SET project_id = {target_project_id} WHERE id = {sess_id}")
self.conn.commit()
return True, sql_statements
def copy_sessions(self, session_ids: List[str], target_project_id: str,
target_workspace_id: Optional[str] = None) -> Tuple[bool, List[str], List[str]]:
"""
Copy sessions to a target project.
Returns:
(success, list of SQL statements, list of new session IDs)
"""
assert self.conn is not None, "Database not connected"
import uuid
cursor = self.conn.cursor()
# Verify target project exists
if target_project_id not in self.projects:
return False, [f"ERROR: Project {target_project_id} not found"], []
# Verify workspace exists and belongs to target project
if target_workspace_id:
if target_workspace_id not in self.workspaces:
return False, [f"ERROR: Workspace {target_workspace_id} not found"], []
if self.workspaces[target_workspace_id].project_id != target_project_id:
return False, [f"ERROR: Workspace {target_workspace_id} does not belong to project {target_project_id}"], []
new_session_ids = []
sql_statements = []
for sess_id in session_ids:
# Get original session
cursor.execute("SELECT * FROM session WHERE id = ?", (sess_id,))
row = cursor.fetchone()
if not row:
continue
# Create new session ID
new_id = str(uuid.uuid4()).replace("-", "")
while new_id[:3] != "ses":
new_id = "ses_" + new_id
# Insert new session
cols = [k for k in row.keys() if k != "id"]
col_list = ", ".join(cols)
placeholders = ", ".join(["?"] * len(cols))
values = [row[col] for col in cols]
# Override project_id and optionally workspace_id
project_idx = cols.index("project_id")
values[project_idx] = target_project_id
if target_workspace_id:
if "workspace_id" in cols:
ws_idx = cols.index("workspace_id")
values[ws_idx] = target_workspace_id
else:
# Clear workspace if not specified
if "workspace_id" in cols:
ws_idx = cols.index("workspace_id")
values[ws_idx] = None
sql = f"INSERT INTO session (id, {col_list}) VALUES (?, {placeholders})"
cursor.execute(sql, [new_id] + values)
sql_statements.append(f"INSERT INTO session ... VALUES ({new_id}, ...)")
# Copy related records
for table, foreign_key in [("message", "session_id"), ("part", "session_id"), ("todo", "session_id"), ("session_share", "session_id")]:
cursor.execute(f"SELECT * FROM {table} WHERE {foreign_key} = ?", (sess_id,))
rows = cursor.fetchall()
for r in rows:
cols = [k for k in r.keys() if k != "id" and k != foreign_key]
col_list = ", ".join(cols)
placeholders = ", ".join(["?"] * len(cols))
values = [r[col] for col in cols]
# Set foreign key to new session ID
fk_idx = cols.index(foreign_key) if foreign_key in cols else -1
if fk_idx >= 0:
values[fk_idx] = new_id
# Generate new ID for tables with id column
if "id" in r.keys():
new_table_id = str(uuid.uuid4()).replace("-", "")
sql = f"INSERT INTO {table} (id, {col_list}) VALUES (?, {placeholders})"
cursor.execute(sql, [new_table_id] + values)
else:
sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})"
cursor.execute(sql, values)
new_session_ids.append(new_id)
self.conn.commit()
return True, sql_statements, new_session_ids
def create_backup(self) -> Path:
"""Create a timestamped backup of the database."""
if not self.db_path.exists():
raise FileNotFoundError(f"Database not found: {self.db_path}")
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = self.db_path.parent / f"{self.db_path.stem}-{timestamp}{self.db_path.suffix}"
import shutil
shutil.copy2(self.db_path, backup_path)
return backup_path

8
main.py Normal file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""Session Mover TUI - Move OpenCode sessions between projects without SQL."""
from app import SessionMoverApp
if __name__ == "__main__":
app = SessionMoverApp()
app.run()

3
mise.toml Normal file
View File

@@ -0,0 +1,3 @@
[tools]
python = "3.14.3"
sqlite = "3.51.2"

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
textual==0.44.0

10
session-mover.py Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env python3
"""Session Mover TUI - Move OpenCode sessions between projects without SQL."""
from app import SessionMoverApp
import sys
if __name__ == "__main__":
db_path = sys.argv[1] if len(sys.argv) > 1 else "opencode.db"
app = SessionMoverApp(db_path)
app.run()