From 05a1d48a0c6aa14b77f30a438edf905038b9a585 Mon Sep 17 00:00:00 2001 From: Reinis Date: Fri, 16 Oct 2026 12:08:24 +0300 Subject: [PATCH 1/3] fix(sessions): fit list window to terminal; local-drop post-delete Bug 1 (list breaks past 8th row with 20 sessions): size the Select visible window to the terminal height (rows - 9 for Dialog chrome, min 1) instead of the fixed 5-row default, mirroring the /resume LogSelector pattern that already handles long lists. Regression coverage in sessions-windowing.test.tsx (20-row traversal); upstream sessions.test.tsx untouched. Bug 2 (double-d delete breaks the TUI flow): replace the post-delete disk reload + list remount with a local drop of the deleted entry (the /resume picker delete-flow shape from fork PR #25/#26), keeping the hardened deleteSessionFiles primitive (transcript + sidecars). Confirm dialog, d-d UX, and all user-facing strings unchanged. Per-surface delete primitives: /sessions -> deleteSessionFiles; /resume + /replay pickers + ResumeConversation inline copy -> SDK deleteSession (picker convergence onto deleteSessionFiles remains open as F1). --- .../sessions/sessions-windowing.test.tsx | 225 ++++++++++++++++++ src/commands/sessions/sessions.tsx | 18 +- 2 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 src/commands/sessions/sessions-windowing.test.tsx diff --git a/src/commands/sessions/sessions-windowing.test.tsx b/src/commands/sessions/sessions-windowing.test.tsx new file mode 100644 index 0000000000..906c7949c3 --- /dev/null +++ b/src/commands/sessions/sessions-windowing.test.tsx @@ -0,0 +1,225 @@ +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 UP = '\x1b[A' + +const SESSION_COUNT = 20 + +type TestStdin = PassThrough & { + isTTY: boolean + setRawMode: (mode: boolean) => void + ref: () => void + unref: () => void +} + +let projectDir: string +let allLogs: LogOption[] + +function sessionIdFor(n: number): string { + return '10000000-1000-4000-8000-' + String(n).padStart(12, '0') +} + +function titleFor(n: number): string { + return 'task-' + String(n).padStart(2, '0') +} + +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 < 5000) { + const value = read() + if (predicate(value)) return value + await Bun.sleep(20) + } + throw new Error('Timed out; last value: ' + String(read()).slice(0, 400)) +} + +async function makeLog(n: number): Promise { + const sessionId = sessionIdFor(n) + const title = titleFor(n) + const fullPath = join(projectDir, sessionId + '.jsonl') + await writeFile(fullPath, '{}\n') + await mkdir(join(projectDir, sessionId), { recursive: true }) + const modified = new Date(Date.now() - n * 60_000) + return { + date: modified.toISOString(), + messages: [], + fullPath, + value: n, + 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)) + const press = async (...keys: string[]) => { + for (const key of keys) { + stdin.write(key) + await Bun.sleep(60) + } + } + return { + done, + resume, + frame, + waitFor: (predicate: (f: string) => boolean) => waitUntil(frame, predicate), + press, + settle: () => Bun.sleep(300), + unmount: () => { + root.unmount() + stdin.end() + stdout.end() + }, + } +} + +describe('/sessions windowing (20 sessions)', () => { + let mounted: Awaited> | undefined + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'openclaude-sessions-win-')) + allLogs = [] + for (let n = 1; n <= SESSION_COUNT; n++) { + allLogs.push(await makeLog(n)) + } + const real = await import('../../utils/sessionStorage.js') + mock.module('../../utils/getWorktreePaths.js', () => ({ + getWorktreePaths: () => Promise.resolve([]), + })) + 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('scrolls past the 8th row without blanking and reaches every row', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (20)')) + await mounted.settle() + + for (let i = 0; i < 12; i++) await mounted.press(DOWN) + const mid = await mounted.waitFor(f => f.includes('task-13')) + expect(mid).toContain('Sessions (20)') + expect(mid).toContain('task-12') + expect(mid.trim().length).toBeGreaterThan(200) + + for (let i = 0; i < 7; i++) await mounted.press(DOWN) + const bottom = await mounted.waitFor(f => f.includes('task-20')) + expect(bottom).toContain('Sessions (20)') + expect(bottom.trim().length).toBeGreaterThan(200) + + for (let i = 0; i < 19; i++) await mounted.press(UP) + const top = await mounted.waitFor(f => f.includes('task-01')) + expect(top).toContain('Sessions (20)') + }, 60000) + + test('double-d delete returns a usable list instead of a stuck flow', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (20)')) + await mounted.settle() + + await mounted.press(DOWN, DOWN) + await mounted.press('d') + await mounted.waitFor(f => f.includes('Press d again to delete "task-03"')) + await mounted.press('d') + const after = await mounted.waitFor(f => f.includes('Deleted "task-03"')) + expect(after).toContain('Sessions (19)') + expect(existsSync(join(projectDir, sessionIdFor(3) + '.jsonl'))).toBe(false) + expect(existsSync(join(projectDir, sessionIdFor(1) + '.jsonl'))).toBe(true) + + 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 (19)'))).toContain('Sessions (19)') + expect(mounted.done).toEqual([]) + + await mounted.press(ESC) + await waitUntil(() => mounted!.done.length, n => n > 0) + expect(mounted.done).toEqual([undefined]) + }, 60000) +}) diff --git a/src/commands/sessions/sessions.tsx b/src/commands/sessions/sessions.tsx index b22e2b5da4..d6f82ddb62 100644 --- a/src/commands/sessions/sessions.tsx +++ b/src/commands/sessions/sessions.tsx @@ -3,6 +3,7 @@ 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 { useTerminalSize } from '../../hooks/useTerminalSize.js' import { Box, Text, useInput } from '../../ink.js' import type { LocalJSXCommandCall, @@ -63,8 +64,7 @@ function SessionsManager({ 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 { rows } = useTerminalSize() const loadLogs = React.useCallback(async (): Promise => { const paths = await getWorktreePaths(getOriginalCwd()) @@ -95,14 +95,12 @@ function SessionsManager({ 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 { @@ -116,17 +114,16 @@ function SessionsManager({ backToList(log) return } - const remaining = await loadLogs() + // Drop the entry locally like the resume picker delete flow: no disk + // reload and no list remount, so the TUI keeps focus after deletion. + const remaining = logs.filter(l => sessionKey(l) !== sessionKey(log)) 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 }) @@ -189,6 +186,9 @@ function SessionsManager({ return Loading conversations… } + // Size the visible window to the terminal like the resume picker does. + // A fixed 5-row window under the Dialog chrome starves short terminals. + const visibleOptionCount = Math.max(1, Math.min(logs.length, rows - 9)) const noticeLine = notice ? ( {notice.text} ) : null @@ -258,7 +258,7 @@ function SessionsManager({ {noticeLine} ({ - label: getLogDisplayTitle(log), + label: toListLabel(log), value: sessionKey(log), description: formatLogMetadata(log), + dimDescription: true, color: pendingDeleteKey === sessionKey(log) ? 'error' : undefined, }))} defaultFocusValue={focusedKey} From 7cb8f6caf95bbcac86386b8e50455c6df4ed1712 Mon Sep 17 00:00:00 2001 From: Reinis Date: Fri, 16 Oct 2026 14:07:48 +0300 Subject: [PATCH 3/3] fix(sessions): accept Delete key for double-press delete (fork picker parity) The list handler now fires on key.delete as well as 'd' (LogSelector fires onDeleteRequest from key.delete), so the second confirm press may be Delete or d. Hint updated to 'Del or d delete (press twice to confirm)'. Armed cue and Dialog footer untouched. New sessions-delete-key.test.tsx: 4/4 (arm, Del-Del, Del-d, Del-x disarm). --- .../sessions/sessions-delete-key.test.tsx | 215 ++++++++++++++++++ src/commands/sessions/sessions.test.tsx | 3 +- src/commands/sessions/sessions.tsx | 8 +- 3 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 src/commands/sessions/sessions-delete-key.test.tsx diff --git a/src/commands/sessions/sessions-delete-key.test.tsx b/src/commands/sessions/sessions-delete-key.test.tsx new file mode 100644 index 0000000000..8a31fcabdf --- /dev/null +++ b/src/commands/sessions/sessions-delete-key.test.tsx @@ -0,0 +1,215 @@ +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 DEL = '\x1b[3~' +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(60) + } + }, + settle: () => Bun.sleep(300), + unmount: () => { + root.unmount() + stdin.end() + stdout.end() + }, + } +} + +describe('/sessions Delete key (fork UX convergence)', () => { + let mounted: Awaited> | undefined + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'openclaude-sessions-delkey-')) + 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('Delete arms the pending delete (cue shows)', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.settle() + await mounted.press(DOWN, DOWN, DEL) + await mounted.waitFor(f => f.includes('Press d again to delete "gamma task"')) + expect(existsSync(join(projectDir, `${GAMMA}.jsonl`))).toBe(true) + }) + + test('Delete then Delete confirms the delete', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.settle() + await mounted.press(DOWN, DOWN, DEL) + await mounted.waitFor(f => f.includes('Press d again to delete "gamma task"')) + await mounted.press(DEL) + const after = await mounted.waitFor(f => f.includes('Deleted "gamma task"')) + expect(after).toContain('Sessions (2)') + expect(existsSync(join(projectDir, `${GAMMA}.jsonl`))).toBe(false) + expect(existsSync(join(projectDir, GAMMA))).toBe(false) + expect(existsSync(join(projectDir, `${ALPHA}.jsonl`))).toBe(true) + }) + + test('Delete then d confirms the delete', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.settle() + await mounted.press(DOWN, DOWN, DEL) + await mounted.waitFor(f => f.includes('Press d again to delete "gamma task"')) + await mounted.press('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('Delete then another key disarms it', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.settle() + await mounted.press(DOWN, DOWN, DEL) + 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) + }) +}) \ No newline at end of file diff --git a/src/commands/sessions/sessions.test.tsx b/src/commands/sessions/sessions.test.tsx index 4bc7d79845..bbe6d9db11 100644 --- a/src/commands/sessions/sessions.test.tsx +++ b/src/commands/sessions/sessions.test.tsx @@ -172,7 +172,8 @@ describe('/sessions', () => { 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') + // Fork UX convergence (round 3): hint now names both delete keys explicitly. + expect(frame).toContain('Del or d delete') expect(frame).toContain('Esc close') }) diff --git a/src/commands/sessions/sessions.tsx b/src/commands/sessions/sessions.tsx index 1153c63287..5c8693abcb 100644 --- a/src/commands/sessions/sessions.tsx +++ b/src/commands/sessions/sessions.tsx @@ -39,7 +39,7 @@ type OnResume = ( ) => Promise const SESSIONS_LIST_HINT = - '↑/↓ navigate · Enter options · d d delete · Esc close' + '↑/↓ navigate · Enter options · Del or d delete (press twice to confirm) · Esc close' /** Stable React key and focus id for a saved conversation row. */ function sessionKey(log: LogOption): string { @@ -48,7 +48,7 @@ function sessionKey(log: LogOption): string { /** * `/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 + * delete it, and go back without leaving the command. `d` or Delete pressed twice on * a row deletes it directly (#2223); Ctrl+D stays the global exit key. */ function SessionsManager({ @@ -165,7 +165,9 @@ function SessionsManager({ (input, key) => { if (!logs || key.ctrl || key.meta) return const focused = logs.find(l => sessionKey(l) === focusedKey) - if (input === 'd' && focused) { + // Fork picker parity: LogSelector fires onDeleteRequest from key.delete, + // so the second press may be Delete or d. + if ((input === 'd' || key.delete) && focused) { if (pendingDeleteKey === sessionKey(focused)) { void deleteLog(focused) } else {