diff --git a/README.md b/README.md index 319cdfc8e7..a078c926b3 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,15 @@ openclaude --continue --fork-session Forking is conversation branching only. It does not create filesystem isolation, copy your working tree, or create a git worktree branch. +### Manage saved conversations + +Run `/sessions` inside OpenClaude to list this project's saved conversations. +Press Enter on one to **Resume**, **Delete** (asks for confirmation, defaulting +to Cancel), or go **Back** to the list. As a shortcut, press `d` on a row and +then `d` again to delete it; any other key in between cancels. Deleting removes +the transcript and its sidecar files from disk and cannot be undone. The +session you are currently in is not listed and cannot be deleted. + ### Background sessions diff --git a/src/commands.ts b/src/commands.ts index 9b046e9bd7..61b58a567d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -57,6 +57,7 @@ import resume, { continueCommand } from './commands/resume/index.js' import review, { ultrareview } from './commands/review.js' import search from './commands/search/index.js' import session from './commands/session/index.js' +import sessions from './commands/sessions/index.js' import setContextWindow from './commands/set-context-window/index.js' import share from './commands/share/index.js' import skills from './commands/skills/index.js' @@ -344,6 +345,7 @@ const COMMANDS = memoize((): Command[] => [ resume, search, session, + sessions, setContextWindow, skills, stats, diff --git a/src/commands/sessions/index.ts b/src/commands/sessions/index.ts new file mode 100644 index 0000000000..bb5ec3a254 --- /dev/null +++ b/src/commands/sessions/index.ts @@ -0,0 +1,10 @@ +import type { Command } from '../../commands.js' + +const sessions = { + type: 'local-jsx', + name: 'sessions', + description: 'List saved conversations to resume or delete them', + load: () => import('./sessions.js'), +} satisfies Command + +export default sessions diff --git a/src/commands/sessions/sessions.test.tsx b/src/commands/sessions/sessions.test.tsx new file mode 100644 index 0000000000..4bc7d79845 --- /dev/null +++ b/src/commands/sessions/sessions.test.tsx @@ -0,0 +1,284 @@ +import { existsSync } from 'fs' +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { PassThrough } from 'node:stream' +import { stripVTControlCharacters as stripAnsi } from 'node:util' + +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import * as React from 'react' + +import { createRoot } from '../../ink.js' +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' +import { AppStateProvider } from '../../state/AppState.js' +import type { LocalJSXCommandContext } from '../../types/command.js' +import type { LogOption } from '../../types/logs.js' + +const SYNC_START = '\x1B[?2026h' +const SYNC_END = '\x1B[?2026l' +const ENTER = '\r' +const ESC = '\x1b' +const DOWN = '\x1b[B' + +const ALPHA = '11111111-1111-4111-8111-111111111111' +const BETA = '22222222-2222-4222-8222-222222222222' +const GAMMA = '33333333-3333-4333-8333-333333333333' + +type TestStdin = PassThrough & { + isTTY: boolean + setRawMode: (mode: boolean) => void + ref: () => void + unref: () => void +} + +let projectDir: string +let allLogs: LogOption[] + +function extractLastFrame(output: string): string { + let lastFrame: string | null = null + let cursor = 0 + while (cursor < output.length) { + const start = output.indexOf(SYNC_START, cursor) + if (start === -1) break + const contentStart = start + SYNC_START.length + const end = output.indexOf(SYNC_END, contentStart) + if (end === -1) break + const frame = output.slice(contentStart, end) + if (frame.trim().length > 0) lastFrame = frame + cursor = end + SYNC_END.length + } + return lastFrame ?? output +} + +async function waitUntil(read: () => T, predicate: (value: T) => boolean): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < 2500) { + const value = read() + if (predicate(value)) return value + await Bun.sleep(10) + } + throw new Error(`Timed out; last value: ${String(read())}`) +} + +async function makeLog(sessionId: string, title: string, minutesAgo: number): Promise { + const fullPath = join(projectDir, `${sessionId}.jsonl`) + await writeFile(fullPath, '{}\n') + await mkdir(join(projectDir, sessionId), { recursive: true }) + const modified = new Date(Date.now() - minutesAgo * 60_000) + return { + date: modified.toISOString(), + messages: [], + fullPath, + value: minutesAgo, + created: modified, + modified, + firstPrompt: title, + customTitle: title, + messageCount: 2, + isSidechain: false, + sessionId, + } +} + +async function runSessions() { + const { call } = await import(`./sessions.js?${Date.now()}-${Math.random()}`) + const done: Array = [] + const resume = mock((_sessionId: string, _log: LogOption, _entrypoint: string) => + Promise.resolve(), + ) + const node = await call( + (result?: string) => { + done.push(result) + }, + { resume } as unknown as LocalJSXCommandContext, + '', + ) + + let output = '' + const stdout = new PassThrough() + const stdin = new PassThrough() as TestStdin + stdin.isTTY = true + stdin.setRawMode = () => {} + stdin.ref = () => {} + stdin.unref = () => {} + ;(stdout as unknown as { columns: number }).columns = 120 + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + root.render( + + {node} + , + ) + + const frame = (): string => stripAnsi(extractLastFrame(output)) + return { + done, + resume, + waitFor: (predicate: (f: string) => boolean) => waitUntil(frame, predicate), + press: async (...keys: string[]) => { + for (const key of keys) { + stdin.write(key) + await Bun.sleep(30) + } + }, + unmount: () => { + root.unmount() + stdin.end() + stdout.end() + }, + } +} + +describe('/sessions', () => { + let mounted: Awaited> | undefined + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'openclaude-sessions-cmd-')) + allLogs = [ + await makeLog(ALPHA, 'alpha task', 1), + await makeLog(BETA, 'beta task', 2), + await makeLog(GAMMA, 'gamma task', 3), + ] + const real = await import('../../utils/sessionStorage.js') + mock.module('../../utils/getWorktreePaths.js', () => ({ + getWorktreePaths: () => Promise.resolve([]), + })) + // Mirror the disk: a deleted transcript drops out of the next reload. + mock.module('../../utils/sessionStorage.js', () => ({ + ...real, + getSessionIdFromLog: (log: LogOption) => log.sessionId, + isLiteLog: () => false, + loadSameRepoMessageLogs: () => + Promise.resolve(allLogs.filter(log => existsSync(log.fullPath!))), + })) + }) + + afterEach(async () => { + mounted?.unmount() + mounted = undefined + mock.restore() + await rm(projectDir, { recursive: true, force: true }) + }) + + test('lists saved conversations with navigation and delete hints', async () => { + mounted = await runSessions() + const frame = await mounted.waitFor(f => f.includes('Sessions (3)')) + expect(frame).toContain('alpha task') + expect(frame).toContain('gamma task') + expect(frame).toContain('d d delete') + expect(frame).toContain('Esc close') + }) + + test('Enter opens Resume / Delete / Back, and Back returns to the list', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(ENTER) + const actions = await mounted.waitFor(f => f.includes('Return to the list')) + expect(actions).toContain('Resume') + expect(actions).toContain('Delete') + + await mounted.press(DOWN, DOWN, ENTER) + expect(await mounted.waitFor(f => f.includes('Sessions (3)'))).toContain('Sessions (3)') + }) + + test('Esc in the sub-menu goes back instead of closing', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + await mounted.press(ESC) + expect(await mounted.waitFor(f => f.includes('Sessions (3)'))).toContain('Sessions (3)') + expect(mounted.done).toEqual([]) + }) + + test('Delete asks for confirmation defaulting to Cancel, then removes the files', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(DOWN, ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + await mounted.press(DOWN, ENTER) + const confirm = await mounted.waitFor(f => f.includes('This cannot be undone')) + expect(confirm).toContain('Delete "beta task"?') + + // Enter on the default option cancels. + await mounted.press(ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + expect(existsSync(join(projectDir, `${BETA}.jsonl`))).toBe(true) + + await mounted.press(ENTER) + await mounted.waitFor(f => f.includes('This cannot be undone')) + await mounted.press(DOWN, ENTER) + const after = await mounted.waitFor(f => f.includes('Deleted "beta task"')) + expect(after).toContain('Sessions (2)') + expect(existsSync(join(projectDir, `${BETA}.jsonl`))).toBe(false) + expect(existsSync(join(projectDir, BETA))).toBe(false) + expect(existsSync(join(projectDir, `${ALPHA}.jsonl`))).toBe(true) + }) + + test('d pressed twice deletes the focused conversation; another key disarms it', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(DOWN, DOWN, 'd') + await mounted.waitFor(f => f.includes('Press d again to delete "gamma task"')) + + await mounted.press('x') + await mounted.waitFor(f => !f.includes('Press d again')) + expect(existsSync(join(projectDir, `${GAMMA}.jsonl`))).toBe(true) + + await mounted.press('d', 'd') + const after = await mounted.waitFor(f => f.includes('Deleted "gamma task"')) + expect(after).toContain('Sessions (2)') + expect(existsSync(join(projectDir, `${GAMMA}.jsonl`))).toBe(false) + }) + + test('Resume hands the session to the REPL resume flow', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(DOWN, ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + await mounted.press(ENTER) + await waitUntil(() => mounted!.resume.mock.calls.length, n => n > 0) + expect(mounted.resume.mock.calls[0]?.[0]).toBe(BETA) + expect(mounted.resume.mock.calls[0]?.[2]).toBe('slash_command_picker') + }) + + test('a transcript that fails to load is not resumed and leaves the list usable', async () => { + // Real loadFullLog (kept by beforeEach's spread): the test transcripts hold + // no messages, so it falls back to the lite log, as for unreadable files. + const real = await import('../../utils/sessionStorage.js') + mock.module('../../utils/sessionStorage.js', () => ({ + ...real, + getSessionIdFromLog: (log: LogOption) => log.sessionId, + // beforeEach stubs isLiteLog to false; restore the upstream check. + isLiteLog: (log: LogOption) => + log.messages.length === 0 && log.sessionId !== undefined, + loadSameRepoMessageLogs: () => + Promise.resolve(allLogs.filter(log => existsSync(log.fullPath!))), + })) + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(DOWN, ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + await mounted.press(ENTER) + await mounted.waitFor(f => f.includes('Transcript could not be loaded')) + expect(mounted.resume.mock.calls.length).toBe(0) + + // Not stuck as busy: the list still takes input. + await mounted.press(ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + }) + + test('reports when there is nothing to list', async () => { + allLogs = [] + mounted = await runSessions() + await waitUntil(() => mounted!.done.length, n => n > 0) + expect(mounted.done[0]).toBe('No saved conversations in this project.') + }) +}) diff --git a/src/commands/sessions/sessions.tsx b/src/commands/sessions/sessions.tsx new file mode 100644 index 0000000000..b22e2b5da4 --- /dev/null +++ b/src/commands/sessions/sessions.tsx @@ -0,0 +1,296 @@ +import type { UUID } from 'crypto' +import * as React from 'react' +import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js' +import { Select } from '../../components/CustomSelect/index.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { Box, Text, useInput } from '../../ink.js' +import type { + LocalJSXCommandCall, + LocalJSXCommandOnDone, + ResumeEntrypoint, +} from '../../types/command.js' +import type { LogOption } from '../../types/logs.js' +import { formatLogMetadata } from '../../utils/format.js' +import { getWorktreePaths } from '../../utils/getWorktreePaths.js' +import { getLogDisplayTitle, logError } from '../../utils/log.js' +import { deleteSessionFiles } from '../../utils/sessionDeletion.js' +import { + getSessionIdFromLog, + getTranscriptPathForSession, + isLiteLog, + loadFullLog, + loadSameRepoMessageLogs, +} from '../../utils/sessionStorage.js' +import { validateUuid } from '../../utils/uuid.js' +import { filterResumableSessions } from '../resume/resume.js' + +type View = + | { kind: 'list' } + | { kind: 'actions'; log: LogOption; focus?: 'resume' | 'delete' | 'back' } + | { kind: 'confirm-delete'; log: LogOption } + +type Notice = { text: string; error?: boolean } + +type OnResume = ( + sessionId: UUID, + log: LogOption, + entrypoint: ResumeEntrypoint, +) => Promise + +const SESSIONS_LIST_HINT = + '↑/↓ navigate · Enter options · d d delete · Esc close' + +/** Stable React key and focus id for a saved conversation row. */ +function sessionKey(log: LogOption): string { + return getSessionIdFromLog(log) ?? log.fullPath ?? String(log.value) +} + +/** + * `/sessions`: list this repo's saved conversations, open one to resume or + * delete it, and go back without leaving the command. `d` pressed twice on + * a row deletes it directly (#2223); Ctrl+D stays the global exit key. + */ +function SessionsManager({ + onDone, + onResume, +}: { + onDone: LocalJSXCommandOnDone + onResume: OnResume +}): React.ReactNode { + const [logs, setLogs] = React.useState(null) + const [view, setView] = React.useState({ kind: 'list' }) + const [focusedKey, setFocusedKey] = React.useState() + const [pendingDeleteKey, setPendingDeleteKey] = React.useState(null) + const [notice, setNotice] = React.useState(null) + const [busy, setBusy] = React.useState(false) + // Remounts the list so it picks up a new default focus after a reload. + const [listVersion, setListVersion] = React.useState(0) + + const loadLogs = React.useCallback(async (): Promise => { + const paths = await getWorktreePaths(getOriginalCwd()) + const all = await loadSameRepoMessageLogs(paths) + return filterResumableSessions(all, getSessionId()) + }, []) + + React.useEffect(() => { + loadLogs() + .then(loaded => { + if (loaded.length === 0) { + onDone('No saved conversations in this project.', { display: 'system' }) + return + } + setLogs(loaded) + setFocusedKey(sessionKey(loaded[0]!)) + }) + .catch((error: unknown) => { + logError(error as Error) + onDone('Failed to load conversations', { display: 'system' }) + }) + }, [loadLogs, onDone]) + + const close = React.useCallback(() => { + onDone(undefined, { display: 'skip' }) + }, [onDone]) + + const backToList = (log?: LogOption) => { + if (log) setFocusedKey(sessionKey(log)) + setView({ kind: 'list' }) + setListVersion(v => v + 1) + } + + const deleteLog = async (log: LogOption) => { + if (!logs) return + setPendingDeleteKey(null) + setBusy(true) + const index = logs.findIndex(l => sessionKey(l) === sessionKey(log)) + const sessionId = getSessionIdFromLog(log) ?? '' + const title = getLogDisplayTitle(log) + try { + const result = await deleteSessionFiles({ + sessionId, + transcriptPath: log.fullPath ?? getTranscriptPathForSession(sessionId), + currentSessionId: getSessionId(), + }) + if (!result.ok) { + setNotice({ text: result.reason, error: true }) + backToList(log) + return + } + const remaining = await loadLogs() + if (remaining.length === 0) { + onDone(`Deleted "${title}". No saved conversations left.`, { display: 'system' }) + return + } + setLogs(remaining) + // Keep focus on the same row position rather than jumping elsewhere. + setFocusedKey(sessionKey(remaining[Math.min(Math.max(index, 0), remaining.length - 1)]!)) + setNotice({ text: `Deleted "${title}".` }) + setView({ kind: 'list' }) + setListVersion(v => v + 1) + } catch (error) { + logError(error as Error) + setNotice({ text: `Failed to delete "${title}": ${(error as Error).message}`, error: true }) + backToList(log) + } finally { + setBusy(false) + } + } + + const resumeLog = async (log: LogOption) => { + const sessionId = validateUuid(getSessionIdFromLog(log)) + if (!sessionId) { + setNotice({ text: 'Failed to resume conversation', error: true }) + return + } + setBusy(true) + try { + const fullLog = isLiteLog(log) ? await loadFullLog(log) : log + // loadFullLog falls back to the message-less lite log when the + // transcript can't be read; resuming it would open an empty conversation. + if (isLiteLog(fullLog)) { + throw new Error('Transcript could not be loaded') + } + await onResume(sessionId, fullLog, 'slash_command_picker') + } catch (error) { + // Loading the transcript failed (onResume reports its own errors): stay + // usable instead of leaving the dialog stuck as busy. + logError(error as Error) + setNotice({ + text: `Failed to resume "${getLogDisplayTitle(log)}": ${(error as Error).message}`, + error: true, + }) + setBusy(false) + backToList(log) + } + } + + useInput( + (input, key) => { + if (!logs || key.ctrl || key.meta) return + const focused = logs.find(l => sessionKey(l) === focusedKey) + if (input === 'd' && focused) { + if (pendingDeleteKey === sessionKey(focused)) { + void deleteLog(focused) + } else { + setPendingDeleteKey(sessionKey(focused)) + setNotice({ text: `Press d again to delete "${getLogDisplayTitle(focused)}".` }) + } + return + } + if (pendingDeleteKey) { + setPendingDeleteKey(null) + setNotice(null) + } + }, + { isActive: view.kind === 'list' && !busy && logs !== null }, + ) + + if (!logs) { + return Loading conversations… + } + + const noticeLine = notice ? ( + {notice.text} + ) : null + + if (view.kind === 'confirm-delete') { + const { log } = view + return ( + setView({ kind: 'actions', log, focus: 'delete' })} + > + { + setNotice(null) + if (value === 'resume') void resumeLog(log) + else if (value === 'delete') setView({ kind: 'confirm-delete', log }) + else backToList(log) + }} + onCancel={() => backToList(log)} + /> + + + ) + } + + return ( + + + {noticeLine} +