From 1bd0bb2cf7a954149825c7ab04edb177781ea033 Mon Sep 17 00:00:00 2001 From: weishu Date: Wed, 20 May 2026 20:20:27 +0800 Subject: [PATCH] Add interactive resume session picker --- cli/src/commands/resume.test.ts | 100 ++++++++++ cli/src/commands/resume.ts | 38 ++-- cli/src/ui/ink/ResumeSessionPicker.tsx | 187 ++++++++++++++++++ .../ui/ink/resumeSessionPickerState.test.ts | 139 +++++++++++++ cli/src/ui/ink/resumeSessionPickerState.ts | 143 ++++++++++++++ 5 files changed, 588 insertions(+), 19 deletions(-) create mode 100644 cli/src/ui/ink/ResumeSessionPicker.tsx create mode 100644 cli/src/ui/ink/resumeSessionPickerState.test.ts create mode 100644 cli/src/ui/ink/resumeSessionPickerState.ts diff --git a/cli/src/commands/resume.test.ts b/cli/src/commands/resume.test.ts index c9fc0650..fa0f254d 100644 --- a/cli/src/commands/resume.test.ts +++ b/cli/src/commands/resume.test.ts @@ -7,6 +7,7 @@ const { listResumableSessionsMock, getLocalResumeTargetMock, handoffSessionToLocalMock, + renderMock, runCodexMock, runClaudeMock, assertCodexLocalSupportedMock, @@ -18,6 +19,7 @@ const { listResumableSessionsMock: vi.fn(), getLocalResumeTargetMock: vi.fn(), handoffSessionToLocalMock: vi.fn(async () => {}), + renderMock: vi.fn(), runCodexMock: vi.fn(async () => {}), runClaudeMock: vi.fn(async () => {}), assertCodexLocalSupportedMock: vi.fn(), @@ -36,6 +38,10 @@ vi.mock('@/api/api', () => ({ }) } })) +vi.mock('ink', () => ({ render: renderMock })) +vi.mock('@/ui/ink/ResumeSessionPicker', () => ({ + ResumeSessionPicker: 'ResumeSessionPicker' +})) vi.mock('@/codex/runCodex', () => ({ runCodex: runCodexMock })) vi.mock('@/claude/runClaude', () => ({ runClaude: runClaudeMock })) vi.mock('@/codex/utils/codexVersion', () => ({ assertCodexLocalSupported: assertCodexLocalSupportedMock })) @@ -59,6 +65,11 @@ describe('resumeCommand', () => { listResumableSessionsMock.mockReset() getLocalResumeTargetMock.mockReset() handoffSessionToLocalMock.mockClear() + renderMock.mockReset() + renderMock.mockImplementation((element: { props?: { onSelect?: (sessionId: string) => void } }) => { + queueMicrotask(() => element.props?.onSelect?.('picked-session')) + return { unmount: vi.fn() } + }) runCodexMock.mockClear() runClaudeMock.mockClear() assertCodexLocalSupportedMock.mockClear() @@ -187,4 +198,93 @@ describe('resumeCommand', () => { exitSpy.mockRestore() } }) + + it('uses the interactive picker when no session id is provided on a TTY', async () => { + const originalIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: true + }) + listResumableSessionsMock.mockResolvedValue([ + { + sessionId: 'picked-session', + flavor: 'codex', + directory: '/tmp/project', + machineId: 'machine-1', + active: false, + thinking: false, + controlledByUser: false, + agentSessionId: 'codex-thread-1', + updatedAt: 2, + name: 'Picked' + } + ]) + getLocalResumeTargetMock.mockResolvedValue({ + sessionId: 'picked-session', + flavor: 'codex', + directory: '/tmp/project', + machineId: 'machine-1', + active: false, + thinking: false, + controlledByUser: false, + agentSessionId: 'codex-thread-1' + }) + + try { + await resumeCommand.run(createContext([])) + + expect(renderMock).toHaveBeenCalledOnce() + expect(getLocalResumeTargetMock).toHaveBeenCalledWith('picked-session') + expect(runCodexMock).toHaveBeenCalledWith(expect.objectContaining({ + existingSessionId: 'picked-session', + resumeSessionId: 'codex-thread-1' + })) + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: originalIsTTY + }) + } + }) + + it('keeps the non-TTY fallback and asks for an explicit session id', async () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 'undefined'}`) + }) as never) + const originalIsTTY = process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: false + }) + listResumableSessionsMock.mockResolvedValue([ + { + sessionId: 'hapi-session-1', + flavor: 'claude', + directory: '/tmp/project', + machineId: 'machine-1', + active: false, + thinking: false, + controlledByUser: false, + agentSessionId: 'claude-session-1', + updatedAt: 1 + } + ]) + + try { + await expect(resumeCommand.run(createContext([]))).rejects.toThrow('process.exit:1') + expect(renderMock).not.toHaveBeenCalled() + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('hapi-session-1')) + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), 'Run: hapi resume ') + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + configurable: true, + value: originalIsTTY + }) + consoleLogSpy.mockRestore() + consoleErrorSpy.mockRestore() + exitSpy.mockRestore() + } + }) }) diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index a775b3cf..a14e5caa 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -1,7 +1,7 @@ import chalk from 'chalk' +import React from 'react' +import { render } from 'ink' import { existsSync } from 'node:fs' -import * as readline from 'node:readline/promises' -import { stdin as input, stdout as output } from 'node:process' import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol' import type { ClaudePermissionMode, @@ -16,6 +16,7 @@ import { authAndSetupMachineIfNeeded } from '@/ui/auth' import { initializeToken } from '@/ui/tokenInit' import { maybeAutoStartServer } from '@/utils/autoStartServer' import { assertCodexLocalSupported } from '@/codex/utils/codexVersion' +import { ResumeSessionPicker } from '@/ui/ink/ResumeSessionPicker' import type { CommandDefinition } from './types' function formatSessionLine(session: ResumableSession, index: number): string { @@ -27,24 +28,23 @@ function formatSessionLine(session: ResumableSession, index: number): string { } async function selectSession(sessions: ResumableSession[]): Promise { - console.log(chalk.bold('Resumable sessions')) - console.log('') - sessions.forEach((session, index) => { - console.log(formatSessionLine(session, index)) - }) - console.log('') - - const rl = readline.createInterface({ input, output }) - try { - const answer = await rl.question(chalk.cyan('Select session: ')) - const index = Number(answer.trim()) - 1 - if (!Number.isInteger(index) || index < 0 || index >= sessions.length) { - throw new Error('Invalid selection') + return await new Promise((resolve, reject) => { + let settled = false + const complete = (callback: () => void) => { + if (settled) return + settled = true + instance.unmount() + callback() } - return sessions[index].sessionId - } finally { - rl.close() - } + const instance = render(React.createElement(ResumeSessionPicker, { + sessions, + onSelect: (sessionId: string) => complete(() => resolve(sessionId)), + onCancel: () => complete(() => reject(new Error('Selection cancelled'))) + }), { + patchConsole: false, + exitOnCtrlC: false + }) + }) } function assertTargetMachine(target: LocalResumeTarget, machineId: string): void { diff --git a/cli/src/ui/ink/ResumeSessionPicker.tsx b/cli/src/ui/ink/ResumeSessionPicker.tsx new file mode 100644 index 00000000..3be19759 --- /dev/null +++ b/cli/src/ui/ink/ResumeSessionPicker.tsx @@ -0,0 +1,187 @@ +import React, { useMemo, useState } from 'react' +import { Box, Text, useInput, useStdout } from 'ink' +import type { ResumableSession } from '@hapi/protocol' +import { + filterResumeSessions, + getResumeSessionName, + getResumeSessionState, + normalizeScrollOffset, + reducePickerState, + type PickerState +} from './resumeSessionPickerState' + +type ExtendedKey = { + upArrow?: boolean + downArrow?: boolean + return?: boolean + escape?: boolean + backspace?: boolean + delete?: boolean + ctrl?: boolean + pageUp?: boolean + pageDown?: boolean + home?: boolean + end?: boolean + name?: string + sequence?: string +} + +export type ResumeSessionPickerProps = { + sessions: ResumableSession[] + onSelect: (sessionId: string) => void + onCancel: () => void +} + +function truncateText(value: string, maxLength: number): string { + if (maxLength <= 0) return '' + if (value.length <= maxLength) return value + if (maxLength <= 3) return '.'.repeat(maxLength) + return `${value.slice(0, maxLength - 3)}...` +} + +function formatSessionLine(session: ResumableSession, width: number): string { + const state = getResumeSessionState(session) + const prefix = `${session.flavor.padEnd(8)} ${state.padEnd(8)} ` + const directoryBudget = Math.max(16, Math.floor(width * 0.35)) + const nameBudget = Math.max(12, width - prefix.length - directoryBudget - 2) + const name = truncateText(getResumeSessionName(session), nameBudget) + const directory = truncateText(session.directory, directoryBudget) + return `${prefix}${name.padEnd(nameBudget)} ${directory}` +} + +function isPrintableInput(input: string, key: ExtendedKey): boolean { + if (key.ctrl || key.return || key.escape || key.backspace || key.delete) return false + if (key.upArrow || key.downArrow || key.pageUp || key.pageDown || key.home || key.end) return false + if (input.length !== 1) return false + return input >= ' ' && input !== '\u007f' +} + +export const ResumeSessionPicker: React.FC = ({ + sessions, + onSelect, + onCancel +}) => { + const { stdout } = useStdout() + const terminalWidth = stdout.columns || 80 + const terminalHeight = stdout.rows || 24 + const visibleCount = Math.max(5, terminalHeight - 8) + const [state, setState] = useState({ + query: '', + selectedIndex: 0, + scrollOffset: 0 + }) + + const filteredSessions = useMemo( + () => filterResumeSessions(sessions, state.query), + [sessions, state.query] + ) + const selectedIndex = filteredSessions.length === 0 + ? 0 + : Math.min(state.selectedIndex, filteredSessions.length - 1) + const scrollOffset = normalizeScrollOffset( + selectedIndex, + state.scrollOffset, + visibleCount, + filteredSessions.length + ) + const visibleSessions = filteredSessions.slice(scrollOffset, scrollOffset + visibleCount) + + useInput((input, key: ExtendedKey) => { + if (key.ctrl && input === 'c') { + onCancel() + return + } + + if (key.return) { + const selected = filteredSessions[selectedIndex] + if (selected) { + onSelect(selected.sessionId) + } + return + } + + if (key.escape) { + if (state.query.length === 0) { + onCancel() + return + } + setState((current) => reducePickerState(current, { + type: 'key', + key: 'escape' + }, { + itemCount: filteredSessions.length, + visibleCount + })) + return + } + + const keyName = key.name + const mappedKey = + key.upArrow || keyName === 'up' ? 'up' + : key.downArrow || keyName === 'down' ? 'down' + : key.pageUp || keyName === 'pageup' ? 'pageUp' + : key.pageDown || keyName === 'pagedown' ? 'pageDown' + : key.home || keyName === 'home' ? 'home' + : key.end || keyName === 'end' ? 'end' + : key.backspace || key.delete || keyName === 'backspace' || keyName === 'delete' ? 'backspace' + : null + + if (mappedKey) { + setState((current) => reducePickerState(current, { + type: 'key', + key: mappedKey + }, { + itemCount: filteredSessions.length, + visibleCount + })) + return + } + + if (isPrintableInput(input, key)) { + setState((current) => reducePickerState(current, { + type: 'char', + value: input + }, { + itemCount: filteredSessions.length, + visibleCount + })) + } + }) + + const width = Math.max(40, terminalWidth - 4) + const shownStart = filteredSessions.length === 0 ? 0 : scrollOffset + 1 + const shownEnd = Math.min(filteredSessions.length, scrollOffset + visibleSessions.length) + + return ( + + Resumable sessions + + Search: {state.query || 'type to filter'} + + + {filteredSessions.length === 0 + ? 'No matching sessions' + : `${shownStart}-${shownEnd} of ${filteredSessions.length}`} + + + {visibleSessions.map((session, index) => { + const absoluteIndex = scrollOffset + index + const selected = absoluteIndex === selectedIndex + return ( + + {selected ? '> ' : ' '} + {formatSessionLine(session, width - 2)} + + ) + })} + + + Up/Down move | PageUp/PageDown scroll | type search | Enter resume | Esc clear/cancel | Ctrl-C cancel + + + ) +} diff --git a/cli/src/ui/ink/resumeSessionPickerState.test.ts b/cli/src/ui/ink/resumeSessionPickerState.test.ts new file mode 100644 index 00000000..4ca2ab9c --- /dev/null +++ b/cli/src/ui/ink/resumeSessionPickerState.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import type { ResumableSession } from '@hapi/protocol' +import { + filterResumeSessions, + reducePickerState, + type PickerState +} from './resumeSessionPickerState' + +function session(overrides: Partial): ResumableSession { + return { + sessionId: 'session-1', + flavor: 'codex', + directory: '/tmp/project', + machineId: 'machine-1', + active: false, + thinking: false, + controlledByUser: false, + agentSessionId: 'agent-1', + updatedAt: 1, + ...overrides + } +} + +describe('resumeSessionPickerState', () => { + it('filters sessions by searchable fields case-insensitively', () => { + const sessions = [ + session({ + sessionId: 'alpha', + name: 'Payment Refactor', + directory: '/repo/api', + agentSessionId: 'thread-a' + }), + session({ + sessionId: 'beta', + flavor: 'claude', + directory: '/repo/mobile', + summary: 'Fix login screen', + agentSessionId: 'thread-b' + }), + session({ + sessionId: 'gamma', + active: true, + controlledByUser: false, + directory: '/repo/web', + agentSessionId: 'thread-c' + }) + ] + + expect(filterResumeSessions(sessions, 'payment').map((item) => item.sessionId)).toEqual(['alpha']) + expect(filterResumeSessions(sessions, 'MOBILE').map((item) => item.sessionId)).toEqual(['beta']) + expect(filterResumeSessions(sessions, 'thread-c').map((item) => item.sessionId)).toEqual(['gamma']) + expect(filterResumeSessions(sessions, 'remote').map((item) => item.sessionId)).toEqual(['gamma']) + }) + + it('resets selection and scroll when query changes', () => { + const initial: PickerState = { + query: 'abc', + selectedIndex: 5, + scrollOffset: 3 + } + + expect(reducePickerState(initial, { + type: 'char', + value: 'd' + }, { + itemCount: 10, + visibleCount: 5 + })).toEqual({ + query: 'abcd', + selectedIndex: 0, + scrollOffset: 0 + }) + + expect(reducePickerState(initial, { + type: 'key', + key: 'backspace' + }, { + itemCount: 10, + visibleCount: 5 + })).toEqual({ + query: 'ab', + selectedIndex: 0, + scrollOffset: 0 + }) + }) + + it('keeps keyboard navigation inside list bounds and visible window', () => { + let state: PickerState = { + query: '', + selectedIndex: 0, + scrollOffset: 0 + } + + state = reducePickerState(state, { type: 'key', key: 'up' }, { + itemCount: 20, + visibleCount: 5 + }) + expect(state.selectedIndex).toBe(0) + expect(state.scrollOffset).toBe(0) + + state = reducePickerState(state, { type: 'key', key: 'pageDown' }, { + itemCount: 20, + visibleCount: 5 + }) + expect(state.selectedIndex).toBe(5) + expect(state.scrollOffset).toBe(1) + + state = reducePickerState(state, { type: 'key', key: 'end' }, { + itemCount: 20, + visibleCount: 5 + }) + expect(state.selectedIndex).toBe(19) + expect(state.scrollOffset).toBe(15) + + state = reducePickerState(state, { type: 'key', key: 'down' }, { + itemCount: 20, + visibleCount: 5 + }) + expect(state.selectedIndex).toBe(19) + expect(state.scrollOffset).toBe(15) + }) + + it('uses null-equivalent selection when there are no items', () => { + const state = reducePickerState({ + query: '', + selectedIndex: 0, + scrollOffset: 0 + }, { + type: 'key', + key: 'down' + }, { + itemCount: 0, + visibleCount: 5 + }) + + expect(state.selectedIndex).toBe(0) + expect(state.scrollOffset).toBe(0) + }) +}) diff --git a/cli/src/ui/ink/resumeSessionPickerState.ts b/cli/src/ui/ink/resumeSessionPickerState.ts new file mode 100644 index 00000000..935e0ae2 --- /dev/null +++ b/cli/src/ui/ink/resumeSessionPickerState.ts @@ -0,0 +1,143 @@ +import type { ResumableSession } from '@hapi/protocol' + +export type PickerState = { + query: string + selectedIndex: number + scrollOffset: number +} + +export type PickerKey = + | 'up' + | 'down' + | 'pageUp' + | 'pageDown' + | 'home' + | 'end' + | 'backspace' + | 'escape' + +export function getResumeSessionName(session: ResumableSession): string { + return session.name ?? session.summary ?? session.sessionId +} + +export function getResumeSessionState(session: ResumableSession): string { + if (!session.active) return 'inactive' + return session.controlledByUser ? 'local' : 'remote' +} + +export function filterResumeSessions( + sessions: ResumableSession[], + query: string +): ResumableSession[] { + const normalized = query.trim().toLowerCase() + if (normalized.length === 0) return sessions + + return sessions.filter((session) => { + const fields = [ + session.name, + session.summary, + session.sessionId, + session.agentSessionId, + session.directory, + session.flavor, + getResumeSessionState(session) + ] + return fields.some((field) => field?.toLowerCase().includes(normalized)) + }) +} + +export function clampSelectedIndex(index: number, itemCount: number): number { + if (itemCount <= 0) return 0 + return Math.max(0, Math.min(index, itemCount - 1)) +} + +export function normalizeScrollOffset( + selectedIndex: number, + scrollOffset: number, + visibleCount: number, + itemCount: number +): number { + if (itemCount <= 0) return 0 + + const safeVisibleCount = Math.max(1, visibleCount) + const maxOffset = Math.max(0, itemCount - safeVisibleCount) + let nextOffset = Math.max(0, Math.min(scrollOffset, maxOffset)) + + if (selectedIndex < nextOffset) { + nextOffset = selectedIndex + } else if (selectedIndex >= nextOffset + safeVisibleCount) { + nextOffset = selectedIndex - safeVisibleCount + 1 + } + + return Math.max(0, Math.min(nextOffset, maxOffset)) +} + +export function reducePickerState( + state: PickerState, + event: { type: 'char'; value: string } | { type: 'key'; key: PickerKey }, + opts: { + itemCount: number + visibleCount: number + } +): PickerState { + const { itemCount, visibleCount } = opts + + if (event.type === 'char') { + return { + query: state.query + event.value, + selectedIndex: 0, + scrollOffset: 0 + } + } + + if (event.key === 'backspace') { + if (state.query.length === 0) return state + return { + query: state.query.slice(0, -1), + selectedIndex: 0, + scrollOffset: 0 + } + } + + if (event.key === 'escape') { + if (state.query.length === 0) return state + return { + query: '', + selectedIndex: 0, + scrollOffset: 0 + } + } + + const currentIndex = clampSelectedIndex(state.selectedIndex, itemCount) + const pageSize = Math.max(1, visibleCount) + const nextSelectedIndex = (() => { + switch (event.key) { + case 'up': + return currentIndex - 1 + case 'down': + return currentIndex + 1 + case 'pageUp': + return currentIndex - pageSize + case 'pageDown': + return currentIndex + pageSize + case 'home': + return 0 + case 'end': + return itemCount - 1 + default: + return currentIndex + } + })() + + const selectedIndex = clampSelectedIndex(nextSelectedIndex, itemCount) + return { + ...state, + selectedIndex, + scrollOffset: normalizeScrollOffset( + selectedIndex, + state.scrollOffset, + visibleCount, + itemCount + ) + } +}