opencode-manager 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROJECT-SUMMARY.md +10 -4
- package/README.md +8 -2
- package/package.json +2 -2
- package/src/lib/opencode-data.ts +137 -0
- package/src/opencode-tui.tsx +267 -3
package/PROJECT-SUMMARY.md
CHANGED
|
@@ -31,9 +31,12 @@ Key Features
|
|
|
31
31
|
- Missing-only toggle and bulk selection.
|
|
32
32
|
- Sessions panel
|
|
33
33
|
- Shows session title (or falls back to ID) in the list.
|
|
34
|
-
- Sort toggle (S) between
|
|
34
|
+
- Sort toggle (S) between "updated" and "created".
|
|
35
35
|
- Each row shows a timestamp snippet for the active sort (created/updated).
|
|
36
36
|
- Details pane includes title, project ID, updated time, and directory.
|
|
37
|
+
- Rename sessions inline (Shift+R) with validation.
|
|
38
|
+
- Move sessions to another project (M) with project selector.
|
|
39
|
+
- Copy sessions to another project (P) with new session ID generation.
|
|
37
40
|
- Projects ↔ Sessions workflow
|
|
38
41
|
- Pressing Enter on a project jumps to the Sessions tab with the project filter set; status text confirms the active filter.
|
|
39
42
|
- `C` clears the filter (and notifies the user) so global searches go back to all sessions.
|
|
@@ -55,13 +58,16 @@ Work Completed
|
|
|
55
58
|
- Redesigned Help screen into two columns with color-coded sections and key chips; removed wall-of-text effect.
|
|
56
59
|
- Added a small color palette and helper components (Section, Row, Bullet, Columns, KeyChip) to simplify consistent styling.
|
|
57
60
|
- Implemented a global Search bar and input mode:
|
|
58
|
-
-
|
|
61
|
+
- "/" to start, Enter to apply, Esc or "X" to clear.
|
|
59
62
|
- Projects: search `projectId` and `worktree`; Sessions: search `title`, `sessionId`, `directory`, `projectId`.
|
|
60
63
|
- Sessions sorting & context:
|
|
61
|
-
-
|
|
64
|
+
- "S" toggles sort by `updated`/`created`.
|
|
62
65
|
- Show per-row description with the relevant timestamp.
|
|
63
66
|
- Fixed OpenTUI text rendering error by filtering whitespace-only raw text in layout helpers and by removing nested `<text>` nodes around key chips.
|
|
64
67
|
- Verified via `bun run tui` (tmux socket creation is blocked in this environment, but direct run works).
|
|
68
|
+
- Added session rename feature (Shift+R): inline text input with validation, updates JSON file, refreshes list.
|
|
69
|
+
- Added session move feature (M): select target project, relocate session JSON, update projectID field.
|
|
70
|
+
- Added session copy feature (P): select target project, create new session with generated ID, preserve original.
|
|
65
71
|
|
|
66
72
|
How To Run
|
|
67
73
|
----------
|
|
@@ -71,7 +77,7 @@ How To Run
|
|
|
71
77
|
- Keys:
|
|
72
78
|
- Global: `Tab`/`1`/`2` switch tabs, `/` search, `X` clear search, `R` reload, `Q` quit, `?` help
|
|
73
79
|
- Projects: `Space` select, `A` select all, `M` toggle missing, `D` delete, `Enter` view sessions
|
|
74
|
-
- Sessions: `Space` select, `S` sort, `D` delete, `
|
|
80
|
+
- Sessions: `Space` select, `S` sort, `D` delete, `Y` copy ID, `Shift+R` rename, `M` move, `P` copy, `C` clear filter
|
|
75
81
|
- Optional tmux usage (when permitted): `tmux new -s opencode-tui 'bun run tui'`
|
|
76
82
|
- CLI help: `bun run tui -- --help` (or `bunx opencode-manager -- --help`, or `manage_opencode_projects.py -- --help`) prints the built-in usage block with key bindings.
|
|
77
83
|
|
package/README.md
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
> **Note:** This is an independent, community-maintained project created by fans of OpenCode. We are not affiliated with SST Corp. or the official OpenCode project. For the official OpenCode CLI, visit [opencode.ai](https://opencode.ai).
|
|
2
|
+
|
|
3
|
+
|
|
1
4
|
# OpenCode Metadata Manager
|
|
2
5
|
|
|
3
6
|
Terminal UI for inspecting, filtering, and pruning OpenCode metadata stored on disk. The app is written in TypeScript, runs on Bun, and renders with [`@opentui/react`](https://github.com/open-tui/opentui).
|
|
@@ -18,9 +21,12 @@ Terminal UI for inspecting, filtering, and pruning OpenCode metadata stored on d
|
|
|
18
21
|
|
|
19
22
|
## Features
|
|
20
23
|
- List both OpenCode projects and sessions from a local metadata root.
|
|
21
|
-
- Filter by
|
|
24
|
+
- Filter by "missing only", bulk-select, and delete metadata safely.
|
|
22
25
|
- Jump from a project directly to its sessions and keep contextual filters.
|
|
23
26
|
- Global search bar (`/` to focus, `Enter` to apply, `Esc` or `X` to clear).
|
|
27
|
+
- Rename sessions inline (`Shift+R`) with title validation.
|
|
28
|
+
- Move sessions between projects (`M`) preserving session ID.
|
|
29
|
+
- Copy sessions to other projects (`P`) with new session ID generation.
|
|
24
30
|
- Rich help overlay with live key hints (`?` or `H`).
|
|
25
31
|
- Zero-install via `bunx` so even CI shells can run it without cloning.
|
|
26
32
|
|
|
@@ -56,7 +62,7 @@ bun run tui -- --root ~/.local/share/opencode
|
|
|
56
62
|
Keyboard reference:
|
|
57
63
|
- **Global**: `Tab`/`1`/`2` switch tabs, `/` search, `X` clear search, `R` reload, `Q` quit, `?` help.
|
|
58
64
|
- **Projects**: `Space` toggle selection, `A` select all, `M` missing-only filter, `D` delete, `Enter` jump to Sessions.
|
|
59
|
-
- **Sessions**: `Space` select, `S` toggle
|
|
65
|
+
- **Sessions**: `Space` select, `S` toggle sort, `D` delete, `Y` copy ID, `Shift+R` rename, `M` move to project, `P` copy to project, `C` clear filter.
|
|
60
66
|
|
|
61
67
|
## Development Workflow
|
|
62
68
|
1. Install dependencies with `bun install`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Terminal UI for inspecting OpenCode metadata stores.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"bun": ">=1.3.0"
|
|
10
10
|
},
|
|
11
11
|
"bin": {
|
|
12
|
-
"opencode-manager": "
|
|
12
|
+
"opencode-manager": "src/bin/opencode-manager.ts"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"src",
|
package/src/lib/opencode-data.ts
CHANGED
|
@@ -309,3 +309,140 @@ export function describeSession(record: SessionRecord, options?: { fullPath?: bo
|
|
|
309
309
|
export async function ensureDirectory(path: string): Promise<void> {
|
|
310
310
|
await fs.mkdir(dirname(path), { recursive: true })
|
|
311
311
|
}
|
|
312
|
+
|
|
313
|
+
export async function updateSessionTitle(filePath: string, newTitle: string): Promise<void> {
|
|
314
|
+
const payload = await readJsonFile<any>(filePath)
|
|
315
|
+
if (!payload) {
|
|
316
|
+
throw new Error(`Session file not found or unreadable: ${filePath}`)
|
|
317
|
+
}
|
|
318
|
+
payload.title = newTitle
|
|
319
|
+
payload.time = payload.time || {}
|
|
320
|
+
payload.time.updated = Date.now()
|
|
321
|
+
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function copySession(
|
|
325
|
+
session: SessionRecord,
|
|
326
|
+
targetProjectId: string,
|
|
327
|
+
root: string = DEFAULT_ROOT
|
|
328
|
+
): Promise<SessionRecord> {
|
|
329
|
+
const payload = await readJsonFile<any>(session.filePath)
|
|
330
|
+
if (!payload) {
|
|
331
|
+
throw new Error(`Session file not found: ${session.filePath}`)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Generate new session ID
|
|
335
|
+
const newSessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
336
|
+
|
|
337
|
+
// Update payload for new session
|
|
338
|
+
payload.id = newSessionId
|
|
339
|
+
payload.projectID = targetProjectId
|
|
340
|
+
payload.time = payload.time || {}
|
|
341
|
+
payload.time.created = Date.now()
|
|
342
|
+
payload.time.updated = Date.now()
|
|
343
|
+
|
|
344
|
+
// Ensure target directory exists
|
|
345
|
+
const targetDir = join(root, 'storage', 'session', targetProjectId)
|
|
346
|
+
await ensureDirectory(join(targetDir, 'dummy'))
|
|
347
|
+
|
|
348
|
+
// Write new session file
|
|
349
|
+
const targetPath = join(targetDir, `${newSessionId}.json`)
|
|
350
|
+
await fs.writeFile(targetPath, JSON.stringify(payload, null, 2), 'utf8')
|
|
351
|
+
|
|
352
|
+
// Return new session record
|
|
353
|
+
return {
|
|
354
|
+
index: 0,
|
|
355
|
+
filePath: targetPath,
|
|
356
|
+
sessionId: newSessionId,
|
|
357
|
+
projectId: targetProjectId,
|
|
358
|
+
directory: session.directory,
|
|
359
|
+
title: session.title,
|
|
360
|
+
version: session.version,
|
|
361
|
+
createdAt: new Date(),
|
|
362
|
+
updatedAt: new Date()
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function moveSession(
|
|
367
|
+
session: SessionRecord,
|
|
368
|
+
targetProjectId: string,
|
|
369
|
+
root: string = DEFAULT_ROOT
|
|
370
|
+
): Promise<SessionRecord> {
|
|
371
|
+
const payload = await readJsonFile<any>(session.filePath)
|
|
372
|
+
if (!payload) {
|
|
373
|
+
throw new Error(`Session file not found: ${session.filePath}`)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
payload.projectID = targetProjectId
|
|
377
|
+
payload.time = payload.time || {}
|
|
378
|
+
payload.time.updated = Date.now()
|
|
379
|
+
|
|
380
|
+
// Ensure target directory exists
|
|
381
|
+
const targetDir = join(root, 'storage', 'session', targetProjectId)
|
|
382
|
+
await ensureDirectory(join(targetDir, 'dummy'))
|
|
383
|
+
|
|
384
|
+
// Write to new location
|
|
385
|
+
const targetPath = join(targetDir, `${session.sessionId}.json`)
|
|
386
|
+
await fs.writeFile(targetPath, JSON.stringify(payload, null, 2), 'utf8')
|
|
387
|
+
|
|
388
|
+
// Remove old file
|
|
389
|
+
await fs.unlink(session.filePath)
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
...session,
|
|
393
|
+
filePath: targetPath,
|
|
394
|
+
projectId: targetProjectId,
|
|
395
|
+
updatedAt: new Date()
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export interface BatchOperationResult {
|
|
400
|
+
succeeded: { session: SessionRecord; newRecord: SessionRecord }[]
|
|
401
|
+
failed: { session: SessionRecord; error: string }[]
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function copySessions(
|
|
405
|
+
sessions: SessionRecord[],
|
|
406
|
+
targetProjectId: string,
|
|
407
|
+
root?: string
|
|
408
|
+
): Promise<BatchOperationResult> {
|
|
409
|
+
const succeeded: BatchOperationResult['succeeded'] = []
|
|
410
|
+
const failed: BatchOperationResult['failed'] = []
|
|
411
|
+
|
|
412
|
+
for (const session of sessions) {
|
|
413
|
+
try {
|
|
414
|
+
const newRecord = await copySession(session, targetProjectId, root)
|
|
415
|
+
succeeded.push({ session, newRecord })
|
|
416
|
+
} catch (error) {
|
|
417
|
+
failed.push({
|
|
418
|
+
session,
|
|
419
|
+
error: error instanceof Error ? error.message : String(error)
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return { succeeded, failed }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function moveSessions(
|
|
428
|
+
sessions: SessionRecord[],
|
|
429
|
+
targetProjectId: string,
|
|
430
|
+
root?: string
|
|
431
|
+
): Promise<BatchOperationResult> {
|
|
432
|
+
const succeeded: BatchOperationResult['succeeded'] = []
|
|
433
|
+
const failed: BatchOperationResult['failed'] = []
|
|
434
|
+
|
|
435
|
+
for (const session of sessions) {
|
|
436
|
+
try {
|
|
437
|
+
const newRecord = await moveSession(session, targetProjectId, root)
|
|
438
|
+
succeeded.push({ session, newRecord })
|
|
439
|
+
} catch (error) {
|
|
440
|
+
failed.push({
|
|
441
|
+
session,
|
|
442
|
+
error: error instanceof Error ? error.message : String(error)
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return { succeeded, failed }
|
|
448
|
+
}
|
package/src/opencode-tui.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import React, {
|
|
|
11
11
|
useState,
|
|
12
12
|
} from "react"
|
|
13
13
|
import { resolve } from "node:path"
|
|
14
|
+
import { exec } from "node:child_process"
|
|
14
15
|
import {
|
|
15
16
|
DEFAULT_ROOT,
|
|
16
17
|
ProjectRecord,
|
|
@@ -23,6 +24,12 @@ import {
|
|
|
23
24
|
formatDisplayPath,
|
|
24
25
|
loadProjectRecords,
|
|
25
26
|
loadSessionRecords,
|
|
27
|
+
updateSessionTitle,
|
|
28
|
+
copySession,
|
|
29
|
+
moveSession,
|
|
30
|
+
copySessions,
|
|
31
|
+
moveSessions,
|
|
32
|
+
BatchOperationResult,
|
|
26
33
|
} from "./lib/opencode-data"
|
|
27
34
|
|
|
28
35
|
type TabKey = "projects" | "sessions"
|
|
@@ -75,6 +82,18 @@ const PALETTE = {
|
|
|
75
82
|
muted: "#9ca3af", // gray
|
|
76
83
|
} as const
|
|
77
84
|
|
|
85
|
+
function copyToClipboard(text: string): void {
|
|
86
|
+
const cmd = process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard"
|
|
87
|
+
const proc = exec(cmd, (error) => {
|
|
88
|
+
if (error) {
|
|
89
|
+
// We can't easily notify from here without context, but it's a best effort
|
|
90
|
+
console.error("Failed to copy to clipboard:", error)
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
proc.stdin?.write(text)
|
|
94
|
+
proc.stdin?.end()
|
|
95
|
+
}
|
|
96
|
+
|
|
78
97
|
type ChildrenProps = { children: React.ReactNode }
|
|
79
98
|
|
|
80
99
|
const Section = ({ title, children }: { title: string } & ChildrenProps) => (
|
|
@@ -125,6 +144,62 @@ const Columns = ({ children }: ChildrenProps) => {
|
|
|
125
144
|
|
|
126
145
|
const KeyChip = ({ k }: { k: string }) => <text fg={PALETTE.key}>[{k}]</text>
|
|
127
146
|
|
|
147
|
+
type ProjectSelectorProps = {
|
|
148
|
+
projects: ProjectRecord[]
|
|
149
|
+
cursor: number
|
|
150
|
+
onCursorChange: (index: number) => void
|
|
151
|
+
onSelect: (project: ProjectRecord) => void
|
|
152
|
+
onCancel: () => void
|
|
153
|
+
operationMode: 'move' | 'copy'
|
|
154
|
+
sessionCount: number
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const ProjectSelector = ({
|
|
158
|
+
projects,
|
|
159
|
+
cursor,
|
|
160
|
+
onCursorChange,
|
|
161
|
+
onSelect,
|
|
162
|
+
onCancel,
|
|
163
|
+
operationMode,
|
|
164
|
+
sessionCount
|
|
165
|
+
}: ProjectSelectorProps) => {
|
|
166
|
+
const options: SelectOption[] = projects.map((p, idx) => ({
|
|
167
|
+
name: `${formatDisplayPath(p.worktree)} (${p.projectId})`,
|
|
168
|
+
description: p.state,
|
|
169
|
+
value: idx
|
|
170
|
+
}))
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<box
|
|
174
|
+
title={`Select Target Project (${operationMode} ${sessionCount} session${sessionCount > 1 ? 's' : ''})`}
|
|
175
|
+
style={{
|
|
176
|
+
border: true,
|
|
177
|
+
borderColor: operationMode === 'move' ? PALETTE.key : PALETTE.accent,
|
|
178
|
+
padding: 1,
|
|
179
|
+
position: 'absolute',
|
|
180
|
+
top: 5,
|
|
181
|
+
left: 5,
|
|
182
|
+
right: 5,
|
|
183
|
+
bottom: 5,
|
|
184
|
+
zIndex: 100
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<select
|
|
188
|
+
options={options}
|
|
189
|
+
selectedIndex={cursor}
|
|
190
|
+
onChange={onCursorChange}
|
|
191
|
+
onSelect={(idx) => {
|
|
192
|
+
const project = projects[idx]
|
|
193
|
+
if (project) onSelect(project)
|
|
194
|
+
}}
|
|
195
|
+
focused={true}
|
|
196
|
+
showScrollIndicator
|
|
197
|
+
/>
|
|
198
|
+
<text fg={PALETTE.muted}>Enter to select, Esc to cancel</text>
|
|
199
|
+
</box>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
128
203
|
const ProjectsPanel = forwardRef<PanelHandle, ProjectsPanelProps>(function ProjectsPanel(
|
|
129
204
|
{ root, active, locked, searchQuery, onNotify, requestConfirm, onNavigateToSessions },
|
|
130
205
|
ref,
|
|
@@ -390,6 +465,12 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
|
|
|
390
465
|
const [cursor, setCursor] = useState(0)
|
|
391
466
|
const [selectedIndexes, setSelectedIndexes] = useState<Set<number>>(new Set())
|
|
392
467
|
const [sortMode, setSortMode] = useState<"updated" | "created">("updated")
|
|
468
|
+
const [isRenaming, setIsRenaming] = useState(false)
|
|
469
|
+
const [renameValue, setRenameValue] = useState('')
|
|
470
|
+
const [isSelectingProject, setIsSelectingProject] = useState(false)
|
|
471
|
+
const [operationMode, setOperationMode] = useState<'move' | 'copy' | null>(null)
|
|
472
|
+
const [availableProjects, setAvailableProjects] = useState<ProjectRecord[]>([])
|
|
473
|
+
const [projectCursor, setProjectCursor] = useState(0)
|
|
393
474
|
|
|
394
475
|
const visibleRecords = useMemo(() => {
|
|
395
476
|
const sorted = [...records].sort((a, b) => {
|
|
@@ -527,12 +608,103 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
|
|
|
527
608
|
})
|
|
528
609
|
}, [selectedSessions, onNotify, requestConfirm, refreshRecords])
|
|
529
610
|
|
|
611
|
+
const executeRename = useCallback(async () => {
|
|
612
|
+
if (!currentSession || !renameValue.trim()) {
|
|
613
|
+
onNotify('Title cannot be empty', 'error')
|
|
614
|
+
setIsRenaming(false)
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
if (renameValue.length > 200) {
|
|
618
|
+
onNotify('Title too long (max 200 characters)', 'error')
|
|
619
|
+
return
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
await updateSessionTitle(currentSession.filePath, renameValue.trim())
|
|
623
|
+
onNotify(`Renamed to "${renameValue.trim()}"`)
|
|
624
|
+
setIsRenaming(false)
|
|
625
|
+
setRenameValue('')
|
|
626
|
+
await refreshRecords(true)
|
|
627
|
+
} catch (error) {
|
|
628
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
629
|
+
onNotify(`Rename failed: ${msg}`, 'error')
|
|
630
|
+
}
|
|
631
|
+
}, [currentSession, renameValue, onNotify, refreshRecords])
|
|
632
|
+
|
|
633
|
+
const executeTransfer = useCallback(async (
|
|
634
|
+
targetProject: ProjectRecord,
|
|
635
|
+
mode: 'move' | 'copy'
|
|
636
|
+
) => {
|
|
637
|
+
setIsSelectingProject(false)
|
|
638
|
+
setOperationMode(null)
|
|
639
|
+
|
|
640
|
+
const operationFn = mode === 'move' ? moveSessions : copySessions
|
|
641
|
+
const result = await operationFn(selectedSessions, targetProject.projectId, root)
|
|
642
|
+
|
|
643
|
+
setSelectedIndexes(new Set())
|
|
644
|
+
|
|
645
|
+
const successCount = result.succeeded.length
|
|
646
|
+
const failCount = result.failed.length
|
|
647
|
+
const verb = mode === 'move' ? 'moved' : 'copied'
|
|
648
|
+
|
|
649
|
+
if (failCount === 0) {
|
|
650
|
+
onNotify(`Successfully ${verb} ${successCount} session(s) to ${targetProject.projectId}`)
|
|
651
|
+
} else {
|
|
652
|
+
onNotify(
|
|
653
|
+
`${verb} ${successCount} session(s), ${failCount} failed`,
|
|
654
|
+
'error'
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
await refreshRecords(true)
|
|
659
|
+
}, [selectedSessions, root, onNotify, refreshRecords])
|
|
660
|
+
|
|
530
661
|
const handleKey = useCallback(
|
|
531
662
|
(key: KeyEvent) => {
|
|
532
663
|
if (!active || locked) {
|
|
533
664
|
return
|
|
534
665
|
}
|
|
535
666
|
|
|
667
|
+
// Handle project selection mode
|
|
668
|
+
if (isSelectingProject) {
|
|
669
|
+
if (key.name === 'escape') {
|
|
670
|
+
setIsSelectingProject(false)
|
|
671
|
+
setOperationMode(null)
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
675
|
+
const targetProject = availableProjects[projectCursor]
|
|
676
|
+
if (targetProject && operationMode) {
|
|
677
|
+
void executeTransfer(targetProject, operationMode)
|
|
678
|
+
}
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
// Let select component handle up/down via onCursorChange
|
|
682
|
+
return
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Handle rename mode - takes precedence over other key handling
|
|
686
|
+
if (isRenaming) {
|
|
687
|
+
if (key.name === 'escape') {
|
|
688
|
+
setIsRenaming(false)
|
|
689
|
+
setRenameValue('')
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
693
|
+
void executeRename()
|
|
694
|
+
return
|
|
695
|
+
}
|
|
696
|
+
if (key.name === 'backspace') {
|
|
697
|
+
setRenameValue(prev => prev.slice(0, -1))
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
const ch = key.sequence
|
|
701
|
+
if (ch && ch.length === 1 && !key.ctrl && !key.meta) {
|
|
702
|
+
setRenameValue(prev => prev + ch)
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
|
|
536
708
|
const letter = key.sequence?.toLowerCase()
|
|
537
709
|
if (key.name === "space") {
|
|
538
710
|
key.preventDefault()
|
|
@@ -555,6 +727,58 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
|
|
|
555
727
|
requestDeletion()
|
|
556
728
|
return
|
|
557
729
|
}
|
|
730
|
+
if (letter === "y") {
|
|
731
|
+
if (currentSession) {
|
|
732
|
+
copyToClipboard(currentSession.sessionId)
|
|
733
|
+
onNotify(`Copied ID ${currentSession.sessionId} to clipboard`)
|
|
734
|
+
}
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
// Rename with Shift+R (uppercase R)
|
|
738
|
+
if (key.sequence === 'R') {
|
|
739
|
+
if (currentSession) {
|
|
740
|
+
setIsRenaming(true)
|
|
741
|
+
setRenameValue(currentSession.title || '')
|
|
742
|
+
}
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
// Move with M key
|
|
746
|
+
if (letter === 'm') {
|
|
747
|
+
if (selectedSessions.length === 0) {
|
|
748
|
+
onNotify('No sessions selected for move', 'error')
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
// Load projects for selection
|
|
752
|
+
loadProjectRecords({ root }).then(projects => {
|
|
753
|
+
// Filter out current project if filtering by project
|
|
754
|
+
const filtered = projectFilter
|
|
755
|
+
? projects.filter(p => p.projectId !== projectFilter)
|
|
756
|
+
: projects
|
|
757
|
+
setAvailableProjects(filtered)
|
|
758
|
+
setProjectCursor(0)
|
|
759
|
+
setOperationMode('move')
|
|
760
|
+
setIsSelectingProject(true)
|
|
761
|
+
}).catch(err => {
|
|
762
|
+
onNotify(`Failed to load projects: ${err.message}`, 'error')
|
|
763
|
+
})
|
|
764
|
+
return
|
|
765
|
+
}
|
|
766
|
+
// Copy with P key
|
|
767
|
+
if (letter === 'p') {
|
|
768
|
+
if (selectedSessions.length === 0) {
|
|
769
|
+
onNotify('No sessions selected for copy', 'error')
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
loadProjectRecords({ root }).then(projects => {
|
|
773
|
+
setAvailableProjects(projects)
|
|
774
|
+
setProjectCursor(0)
|
|
775
|
+
setOperationMode('copy')
|
|
776
|
+
setIsSelectingProject(true)
|
|
777
|
+
}).catch(err => {
|
|
778
|
+
onNotify(`Failed to load projects: ${err.message}`, 'error')
|
|
779
|
+
})
|
|
780
|
+
return
|
|
781
|
+
}
|
|
558
782
|
if (key.name === "return" || key.name === "enter") {
|
|
559
783
|
if (currentSession) {
|
|
560
784
|
const title = currentSession.title && currentSession.title.trim().length > 0 ? currentSession.title : currentSession.sessionId
|
|
@@ -563,7 +787,7 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
|
|
|
563
787
|
return
|
|
564
788
|
}
|
|
565
789
|
},
|
|
566
|
-
[active, locked, currentSession, projectFilter, onClearFilter, onNotify, requestDeletion, toggleSelection],
|
|
790
|
+
[active, locked, currentSession, projectFilter, onClearFilter, onNotify, requestDeletion, toggleSelection, isRenaming, executeRename, isSelectingProject, availableProjects, projectCursor, operationMode, executeTransfer, selectedSessions, root],
|
|
567
791
|
)
|
|
568
792
|
|
|
569
793
|
useImperativeHandle(
|
|
@@ -590,9 +814,32 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
|
|
|
590
814
|
>
|
|
591
815
|
<box flexDirection="column" marginBottom={1}>
|
|
592
816
|
<text>Filter: {projectFilter ? `project ${projectFilter}` : "none"} | Sort: {sortMode} | Search: {searchQuery || "(none)"} | Selected: {selectedIndexes.size}</text>
|
|
593
|
-
<text>Keys: Space select, S sort, D delete,
|
|
817
|
+
<text>Keys: Space select, S sort, D delete, Y copy ID, Shift+R rename, M move, P copy, C clear filter</text>
|
|
594
818
|
</box>
|
|
595
819
|
|
|
820
|
+
{isRenaming ? (
|
|
821
|
+
<box style={{ border: true, borderColor: PALETTE.key, padding: 1, marginBottom: 1 }}>
|
|
822
|
+
<text>Rename: </text>
|
|
823
|
+
<text fg={PALETTE.key}>{renameValue}</text>
|
|
824
|
+
<text fg={PALETTE.muted}> (Enter confirm, Esc cancel)</text>
|
|
825
|
+
</box>
|
|
826
|
+
) : null}
|
|
827
|
+
|
|
828
|
+
{isSelectingProject && operationMode ? (
|
|
829
|
+
<ProjectSelector
|
|
830
|
+
projects={availableProjects}
|
|
831
|
+
cursor={projectCursor}
|
|
832
|
+
onCursorChange={setProjectCursor}
|
|
833
|
+
onSelect={(project) => executeTransfer(project, operationMode)}
|
|
834
|
+
onCancel={() => {
|
|
835
|
+
setIsSelectingProject(false)
|
|
836
|
+
setOperationMode(null)
|
|
837
|
+
}}
|
|
838
|
+
operationMode={operationMode}
|
|
839
|
+
sessionCount={selectedSessions.length}
|
|
840
|
+
/>
|
|
841
|
+
) : null}
|
|
842
|
+
|
|
596
843
|
{error ? (
|
|
597
844
|
<text fg="red">{error}</text>
|
|
598
845
|
) : loading ? (
|
|
@@ -613,7 +860,7 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
|
|
|
613
860
|
onNotify(`Session ${title} [${session.sessionId}] → ${formatDisplayPath(session.directory)}`)
|
|
614
861
|
}
|
|
615
862
|
}}
|
|
616
|
-
focused={active && !locked}
|
|
863
|
+
focused={active && !locked && !isSelectingProject && !isRenaming}
|
|
617
864
|
showScrollIndicator
|
|
618
865
|
showDescription={false}
|
|
619
866
|
wrapSelection={false}
|
|
@@ -628,6 +875,7 @@ const SessionsPanel = forwardRef<PanelHandle, SessionsPanelProps>(function Sessi
|
|
|
628
875
|
<text>Updated: {formatDate(currentSession.updatedAt || currentSession.createdAt)}</text>
|
|
629
876
|
<text>Directory:</text>
|
|
630
877
|
<text>{formatDisplayPath(currentSession.directory, { fullPath: true })}</text>
|
|
878
|
+
<text fg={PALETTE.muted} style={{ marginTop: 1 }}>Press Y to copy ID</text>
|
|
631
879
|
</box>
|
|
632
880
|
) : null}
|
|
633
881
|
</box>
|
|
@@ -755,6 +1003,22 @@ const HelpScreen = ({ onDismiss }: { onDismiss: () => void }) => {
|
|
|
755
1003
|
<KeyChip k="D" />
|
|
756
1004
|
<text> — With confirmation</text>
|
|
757
1005
|
</Bullet>
|
|
1006
|
+
<Bullet>
|
|
1007
|
+
<text>Copy ID: </text>
|
|
1008
|
+
<KeyChip k="Y" />
|
|
1009
|
+
</Bullet>
|
|
1010
|
+
<Bullet>
|
|
1011
|
+
<text>Rename: </text>
|
|
1012
|
+
<KeyChip k="Shift+R" />
|
|
1013
|
+
</Bullet>
|
|
1014
|
+
<Bullet>
|
|
1015
|
+
<text>Move to project: </text>
|
|
1016
|
+
<KeyChip k="M" />
|
|
1017
|
+
</Bullet>
|
|
1018
|
+
<Bullet>
|
|
1019
|
+
<text>Copy to project: </text>
|
|
1020
|
+
<KeyChip k="P" />
|
|
1021
|
+
</Bullet>
|
|
758
1022
|
<Bullet>
|
|
759
1023
|
<text>Show details: </text>
|
|
760
1024
|
<KeyChip k="Enter" />
|