feat: first attempt at a gui for managing sessions
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal 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
140
PLAN.md
Normal 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
63
README.md
Normal 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
430
app.py
Normal 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
350
database.py
Normal 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
8
main.py
Normal 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()
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
textual==0.44.0
|
||||||
10
session-mover.py
Normal file
10
session-mover.py
Normal 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()
|
||||||
Reference in New Issue
Block a user