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-list-layout.test.tsx b/src/commands/sessions/sessions-list-layout.test.tsx new file mode 100644 index 0000000000..4cb2220769 --- /dev/null +++ b/src/commands/sessions/sessions-list-layout.test.tsx @@ -0,0 +1,227 @@ +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 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[] +let longTitles = false + +function sessionIdFor(n: number): string { + return '30000000-3000-4000-8000-' + String(n).padStart(12, '0') +} + +function titleFor(n: number): string { + const base = 'task-' + String(n).padStart(2, '0') + if (longTitles) return base + ' ' + 'w'.repeat(190) + ' TAIL-' + String(n).padStart(2, '0') + return base +} + +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 < 8000) { + 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, '{}') + 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, + gitBranch: 'feat/sessions-qa-fixes', + } +} + +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 { + frame, + waitFor: (predicate: (f: string) => boolean) => waitUntil(frame, predicate), + press, + settle: () => Bun.sleep(300), + unmount: () => { + root.unmount() + stdin.end() + stdout.end() + }, + } +} + +describe('/sessions list layout (resume-picker parity)', () => { + let mounted: Awaited> | undefined + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'openclaude-sessions-layout-')) + 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() + longTitles = false + await rm(projectDir, { recursive: true, force: true }) + }) + + test('options stack label over metadata like the resume picker, not side by side', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (20)')) + await mounted.settle() + const lines = mounted.frame().split('\n') + const labelLine = lines.find(l => l.includes('task-03')) + expect(labelLine).toBeDefined() + // Expanded layout: the metadata row sits below the label row, so the + // label line never carries the message count. Compact two-column puts + // them on one line. + expect(labelLine!).not.toContain('messages') + }, 60000) + + test('window is sized in rows (3 per option), so later rows stay reachable', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (20)')) + await mounted.settle() + // 24-row harness terminal -> floor((24 - 12) / 3) = 4 visible options. + expect(mounted.frame()).toContain('task-04') + expect(mounted.frame()).not.toContain('task-05') + + for (let i = 0; i < 4; i++) await mounted.press(DOWN) + expect(await mounted.waitFor(f => f.includes('task-05'))).toContain('task-05') + + for (let i = 0; i < 15; i++) await mounted.press(DOWN) + expect(await mounted.waitFor(f => f.includes('task-20'))).toContain('task-20') + + for (let i = 0; i < 19; i++) await mounted.press(UP) + expect(await mounted.waitFor(f => f.includes('task-01'))).toContain('task-01') + }, 60000) + + test('200-char titles are truncated to one terminal-width line', async () => { + longTitles = true + // Rebuild logs with long titles (beforeEach ran with short ones). + allLogs = [] + for (let n = 1; n <= SESSION_COUNT; n++) { + allLogs.push(await makeLog(n)) + } + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (20)')) + await mounted.settle() + const frame = mounted.frame() + expect(frame).toContain('task-01') + // The untruncated tail must never reach the screen. + expect(frame).not.toContain('TAIL-01') + }, 60000) +}) 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.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 b22e2b5da4..5c8693abcb 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, @@ -10,7 +11,7 @@ import type { ResumeEntrypoint, } from '../../types/command.js' import type { LogOption } from '../../types/logs.js' -import { formatLogMetadata } from '../../utils/format.js' +import { formatLogMetadata, truncateToWidth } from '../../utils/format.js' import { getWorktreePaths } from '../../utils/getWorktreePaths.js' import { getLogDisplayTitle, logError } from '../../utils/log.js' import { deleteSessionFiles } from '../../utils/sessionDeletion.js' @@ -38,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 { @@ -47,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({ @@ -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 { columns, 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 }) @@ -168,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 { @@ -189,6 +188,22 @@ function SessionsManager({ return Loading conversations… } + // List rendering follows the resume picker (LogSelector) flat path: each + // option takes a label row, a metadata row, and a blank separator row (3 + // rows), so the window is sized in rows, not options. The compact + // two-column default this list used before renders label and metadata + // side by side in a single row, a layout none of the working pickers use. + const ROWS_PER_OPTION = 3 + const LIST_CHROME_ROWS = 12 + const visibleOptionCount = Math.max( + 1, + Math.min(logs.length, Math.floor((rows - LIST_CHROME_ROWS) / ROWS_PER_OPTION)), + ) + // Mirror the picker label shaping: collapse whitespace and truncate to the + // terminal width so every option keeps its exact one-label-row shape. + const maxLabelWidth = Math.max(30, columns - 4) + const toListLabel = (log: LogOption): string => + truncateToWidth(getLogDisplayTitle(log).replace(/\s+/g, ' ').trim(), maxLabelWidth) const noticeLine = notice ? ( {notice.text} ) : null @@ -258,11 +273,13 @@ function SessionsManager({ {noticeLine}