Compare commits

..

3 Commits

Author SHA1 Message Date
Joe Fleming
f072120961 update readme, fix typos and try to increase clarity 2026-03-08 14:28:22 -06:00
Joe Fleming
c768caa025 database id fixes 2026-03-08 14:22:39 -06:00
Joe Fleming
d04af8d6dc handle errors better 2026-03-08 13:41:35 -06:00
4 changed files with 171 additions and 70 deletions

View File

@@ -1,12 +1,12 @@
# opencode-session-manager
*USE AT YOUR OWN RUSK*
*USE AT YOUR OWN RISK*
⚠️ This is entirely vibe coded and mostly untested. It should backup yout database before changing anything, but you should make your own backup first ⚠️
⚠️ This is entirely vibe coded and mostly untested. It should backup your database before changing anything, but you should make your own backup first ⚠️
A terminal UI for managing [OpenCode](https://opencode.ai) sessions — move, copy, or delete sessions across projects without writing SQL.
![iscreenshot](assets/opencode-session-manager.png)
![screenshot](assets/opencode-session-manager.png)
## Requirements
@@ -20,22 +20,25 @@ OpenCode stores its data in an SQLite database, usually at `~/.local/share/openc
## Running
```bash
# Install dependencies With mise
mise install
# With mise (installs Python, SQLite, and dependencies)
mise install && pip install -r requirements.txt
python main.py /path/to/opencode.db
# Install dependencies and run
# Without mise
pip install -r requirements.txt
python main.py /path/to/opencode.db
```
If no path is given it looks for `opencode.db` in the current directory.
If no path is given it looks for `opencode.db` in the current directory. If the database is not found, an error is shown in the UI and the app exits with a non-zero status code.
## Layout
The UI has two panels:
- **Left — All Projects**: a list of your OpenCode projects with active session counts
- **Right — Sessions**: sessions for the selected project, with a filter bar at the top
- **Left — All Projects**: a list of your OpenCode projects, sorted alphabetically by directory name, with active session counts
- **Right — Sessions**: sessions for the selected project, sorted newest first, with a filter bar at the top
Select a project from the left panel to load its sessions. The session panel shows ID, title, project, workspace, message count, and creation date.
## Keybindings
@@ -65,12 +68,16 @@ Use `f` to filter sessions by title or slug (case-insensitive). Filtering and se
1. Select one or more sessions with `Space`
2. Press `m` (move) or `c` (copy)
3. Choose a destination project from the dropdown (sorted alphabetically, current project excluded)
4. Optionally choose a workspace within that project
4. Optionally choose a destination workspace
5. Click Confirm or press `Enter`
A timestamped backup is created automatically before the first write operation in a session: `opencode-YYYYMMDD-HHMMSS.db`
A timestamped backup is created automatically before the first write operation: `opencode-YYYYMMDD-HHMMSS.db`, written next to the source database.
Copy duplicates the session and all related records (messages, parts, todos). Move updates the session's project/workspace in place.
**Move** updates the session's `project_id` (and optionally `workspace_id`) in place. All related records stay attached to the session unchanged.
**Copy** duplicates the session and all related records (messages, message parts, todos) into the destination project. Copied sessions get new IDs. The original is not modified.
> Note: copied session IDs will not match OpenCode's native ID format. The sessions will work normally in the app but will look different in the database.
## Delete
@@ -79,16 +86,20 @@ Copy duplicates the session and all related records (messages, parts, todos). Mo
3. Review the list of sessions to be deleted in the confirmation dialog
4. Click Delete to confirm, or Cancel / `Escape` to abort
Deletion is permanent and removes all related records (messages, todos, etc). A backup is created automatically before the first delete.
Deletion is permanent and cascades to all related records (messages, parts, todos). A backup is created automatically before the first delete in a session.
## Archived Sessions
OpenCode can archive sessions. By default they are hidden. Press `a` to show them alongside active sessions — the session panel header will update to indicate archived mode is on.
OpenCode can archive sessions. By default they are hidden. Press `a` to show them alongside active sessions — the session panel header will update to indicate archived mode is on. Archived sessions can be moved, copied, or deleted like any other session.
## Backups
A backup is created automatically the first time you perform any write operation (move, copy, or delete) in a given run of the app. You can also create one manually at any time with `b`. Backups are timestamped copies of the database file placed in the same directory as the source: `opencode-YYYYMMDD-HHMMSS.db`.
## Files
| File | Purpose |
|------|---------|
| `main.py` | Entry point |
| `main.py` | Entry point, CLI argument handling |
| `app.py` | Textual TUI — layout, keybindings, UI logic |
| `database.py` | SQLite queries — sessions, move, copy, delete, backup |

62
app.py
View File

@@ -7,7 +7,7 @@ from textual.app import App, ComposeResult
from textual.containers import Container, Horizontal, Vertical
from textual.widgets import Header, Footer, Input, Button, Select, ListView, ListItem, DataTable, Label, Static
from textual.screen import ModalScreen
from typing import List, Optional
from typing import List, Optional, cast
from database import Database, Session
from datetime import datetime
import os
@@ -31,7 +31,8 @@ class MoveCopyDialog(ModalScreen):
def compose(self) -> ComposeResult:
# Get currently selected project from tree to exclude from destination list
current_project_id = self.app.filter_project_id if hasattr(self.app, 'filter_project_id') else None
main_app = cast("SessionMoverApp", self.app)
current_project_id = main_app.filter_project_id if hasattr(main_app, 'filter_project_id') else None
project_options = sorted(
[
@@ -68,7 +69,7 @@ class MoveCopyDialog(ModalScreen):
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"
self.query_one("#dialog", Container).border_title = f"{'Copy' if self.is_copy else 'Move'} Sessions"
def on_select_changed(self, event):
if event.select.id == "project-select":
@@ -77,12 +78,12 @@ class MoveCopyDialog(ModalScreen):
self.target_workspace = event.value if event.value else None
self.update_preview()
confirm_btn = self.query_one("#confirm-btn")
confirm_btn = self.query_one("#confirm-btn", Button)
confirm_btn.disabled = not (self.target_project is not None)
def update_preview(self):
if not self.target_project:
preview = self.query_one("#sql-preview")
preview = self.query_one("#sql-preview", Static)
preview.update("Select a project to see preview")
return
@@ -111,7 +112,7 @@ class MoveCopyDialog(ModalScreen):
if self.is_copy:
lines.append("\nNote: Copy creates new sessions with all related data.")
self.query_one("#sql-preview").update("\n".join(lines))
self.query_one("#sql-preview", Static).update("\n".join(lines))
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel-btn":
@@ -141,13 +142,11 @@ class MoveCopyDialog(ModalScreen):
)
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()
# Get reference to main app and refresh
app = self.app
if hasattr(app, 'refresh_sessions'):
app.refresh_project_list()
app.refresh_sessions()
main_app = cast("SessionMoverApp", self.app)
main_app.notify(f"{'Copied' if self.is_copy else 'Moved'} {len(self.sessions)} session(s) successfully!", severity="information")
main_app.pop_screen()
main_app.refresh_project_list()
main_app.refresh_sessions()
else:
self.app.notify(f"Operation failed: {sql}", severity="error")
@@ -194,7 +193,7 @@ class DeleteConfirmDialog(ModalScreen):
yield Button("Delete", variant="error", id="confirm-btn")
def on_mount(self) -> None:
self.query_one("#dialog").border_title = "Confirm Delete"
self.query_one("#dialog", Container).border_title = "Confirm Delete"
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "cancel-btn":
@@ -282,6 +281,12 @@ class SessionMoverApp(App):
color: $text;
}
#current-selection.error {
background: $error;
color: $text;
text-style: bold;
}
#selection-count {
height: auto;
padding: 1;
@@ -385,10 +390,13 @@ class SessionMoverApp(App):
self.db.connect()
self.db.load_reference_data()
except FileNotFoundError as e:
self.notify(str(e), severity="error", timeout=10)
self.set_timer(1, self.exit)
widget = self.query_one("#current-selection", Static)
widget.add_class("error")
widget.update(f"Error: {e}\n\nPress q to quit.")
self._db_error = True
return
self._db_error = False
self._project_ids: List[str] = []
self.refresh_project_list()
@@ -398,15 +406,14 @@ class SessionMoverApp(App):
self.query_one("#archived-toggle", Button).can_focus = False
# Setup sessions table (but don't load data yet)
table = self.query_one("#sessions-table")
table = self.query_one("#sessions-table", DataTable)
table.zebra_stripes = True
table.cursor_type = "row"
table.show_cursor = False
table.add_columns(" ", "ID", "Title", "Project", "Workspace", "Msgs", "Created")
# Don't load sessions until a project is selected
status = self.query_one("#current-selection")
status.update("Select a project from the left")
self.query_one("#current-selection", Static).update("Select a project from the left")
def on_input_changed(self, event: Input.Changed) -> None:
if event.input.id == "filter-input":
@@ -462,8 +469,8 @@ class SessionMoverApp(App):
def refresh_sessions(self) -> None:
"""Update the sessions table."""
table = self.query_one("#sessions-table")
table = self.query_one("#sessions-table", DataTable)
# Save cursor position before clearing
cursor_row = table.cursor_row
cursor_column = table.cursor_column
@@ -501,7 +508,7 @@ class SessionMoverApp(App):
# Update selection count
count = len(self.selected_session_ids)
count_widget = self.query_one("#selection-count")
count_widget = self.query_one("#selection-count", Static)
if count > 0:
count_widget.update(f"{count} session(s) selected")
else:
@@ -534,7 +541,7 @@ class SessionMoverApp(App):
self.filter_project_id = proj_id
self.filter_workspace_id = None
status = self.query_one("#current-selection")
status = self.query_one("#current-selection", Static)
if proj:
status.update(f"📁 {proj.worktree}")
@@ -547,7 +554,7 @@ class SessionMoverApp(App):
def action_toggle_selection(self) -> None:
"""Toggle selection of current row."""
table = self.query_one("#sessions-table")
table = self.query_one("#sessions-table", DataTable)
row_key = table.cursor_row
if row_key is not None:
sessions = self.db.get_sessions(
@@ -655,6 +662,11 @@ class SessionMoverApp(App):
self.refresh_sessions()
self.notify("Data refreshed", severity="information")
async def action_quit(self) -> None:
"""Exit the app, with a non-zero return code if there was a DB error."""
return_code = 1 if getattr(self, "_db_error", False) else 0
self.exit(return_code)
def on_unmount(self) -> None:
"""Cleanup on exit."""
self.db.close()

View File

@@ -74,7 +74,8 @@ class Database:
def load_reference_data(self):
"""Load projects and workspaces into memory for fast lookups."""
assert self.conn is not None
if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
cursor = self.conn.cursor()
# Load projects
@@ -125,7 +126,8 @@ class Database:
Returns:
List of Session objects with computed counts
"""
assert self.conn is not None, "Database not connected"
if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
cursor = self.conn.cursor()
query = """
@@ -205,7 +207,8 @@ class Database:
def get_session_counts_by_project(self) -> dict:
"""Return a dict of project_id -> active session count."""
assert self.conn is not None
if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
cursor = self.conn.cursor()
cursor.execute(
"SELECT project_id, COUNT(*) FROM session WHERE time_archived IS NULL GROUP BY project_id"
@@ -214,11 +217,43 @@ class Database:
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
if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
cursor = self.conn.cursor()
cursor.execute("SELECT * FROM session WHERE id = ?", (session_id,))
row = cursor.fetchone()
if not row:
return None
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
cursor.execute("SELECT COUNT(*) FROM message WHERE session_id = ?", (session_id,))
sess.message_count = cursor.fetchone()[0]
# Project name
if sess.project_id in self.projects:
proj = self.projects[sess.project_id]
sess.project_name = proj.name or proj.worktree
# 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
return sess
def move_sessions(self, session_ids: List[str], target_project_id: str,
target_workspace_id: Optional[str] = None) -> Tuple[bool, List[str]]:
@@ -228,7 +263,8 @@ class Database:
Returns:
(success, list of SQL statements executed)
"""
assert self.conn is not None, "Database not connected"
if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
cursor = self.conn.cursor()
# Verify target project exists
@@ -265,7 +301,8 @@ class Database:
Returns:
(success, list of SQL statements, list of new session IDs)
"""
assert self.conn is not None, "Database not connected"
if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
import uuid
cursor = self.conn.cursor()
@@ -292,9 +329,7 @@ class Database:
continue
# Create new session ID
new_id = str(uuid.uuid4()).replace("-", "")
while new_id[:3] != "ses":
new_id = "ses_" + new_id
new_id = "ses_" + uuid.uuid4().hex
# Insert new session
cols = [k for k in row.keys() if k != "id"]
@@ -320,28 +355,70 @@ class Database:
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")]:
try:
cursor.execute(f"SELECT * FROM {table} WHERE {foreign_key} = ?", (sess_id,))
except sqlite3.OperationalError:
continue # Table doesn't exist in this schema version
rows = cursor.fetchall()
for r in rows:
# Include foreign_key in cols so the new session_id is written
# Copy messages first, building old_id -> new_id map so parts can be relinked
msg_id_map: dict = {}
try:
cursor.execute("SELECT * FROM message WHERE session_id = ?", (sess_id,))
except sqlite3.OperationalError:
pass
else:
for r in cursor.fetchall():
new_msg_id = uuid.uuid4().hex
msg_id_map[r["id"]] = new_msg_id
cols = [k for k in r.keys() if k != "id"]
values = [new_id if k == foreign_key else r[k] for k in cols]
values = [new_id if k == "session_id" else r[k] for k in cols]
col_list = ", ".join(cols)
placeholders = ", ".join(["?"] * len(cols))
cursor.execute(
f"INSERT INTO message (id, {col_list}) VALUES (?, {placeholders})",
[new_msg_id] + values,
)
# Generate new ID for tables with id column
# Copy parts, relinking both session_id and message_id
try:
cursor.execute("SELECT * FROM part WHERE session_id = ?", (sess_id,))
except sqlite3.OperationalError:
pass
else:
for r in cursor.fetchall():
new_part_id = uuid.uuid4().hex
cols = [k for k in r.keys() if k != "id"]
values = []
for k in cols:
if k == "session_id":
values.append(new_id)
elif k == "message_id":
values.append(msg_id_map.get(r[k], r[k]))
else:
values.append(r[k])
col_list = ", ".join(cols)
placeholders = ", ".join(["?"] * len(cols))
cursor.execute(
f"INSERT INTO part (id, {col_list}) VALUES (?, {placeholders})",
[new_part_id] + values,
)
# Copy todo and session_share (session_id only, no secondary FKs)
for table in ("todo", "session_share"):
try:
cursor.execute(f"SELECT * FROM {table} WHERE session_id = ?", (sess_id,))
except sqlite3.OperationalError:
continue
for r in cursor.fetchall():
cols = [k for k in r.keys() if k != "id"]
values = [new_id if k == "session_id" else r[k] for k in cols]
col_list = ", ".join(cols)
placeholders = ", ".join(["?"] * len(cols))
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)
cursor.execute(
f"INSERT INTO {table} (id, {col_list}) VALUES (?, {placeholders})",
[uuid.uuid4().hex] + values,
)
else:
sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})"
cursor.execute(sql, values)
cursor.execute(
f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
values,
)
new_session_ids.append(new_id)
@@ -355,7 +432,8 @@ class Database:
Returns:
(success, error_message)
"""
assert self.conn is not None, "Database not connected"
if self.conn is None:
raise RuntimeError("Database is not connected. Call connect() first.")
cursor = self.conn.cursor()
try:

0
session-mover.py Normal file → Executable file
View File