Add interactive resume session picker

This commit is contained in:
weishu
2026-05-20 20:20:27 +08:00
parent 1954920753
commit 1bd0bb2cf7
5 changed files with 588 additions and 19 deletions
+100
View File
@@ -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 <session-id>')
} finally {
Object.defineProperty(process.stdin, 'isTTY', {
configurable: true,
value: originalIsTTY
})
consoleLogSpy.mockRestore()
consoleErrorSpy.mockRestore()
exitSpy.mockRestore()
}
})
})
+19 -19
View File
@@ -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<string> {
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<string>((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 {
+187
View File
@@ -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<ResumeSessionPickerProps> = ({
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<PickerState>({
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 (
<Box flexDirection="column" width={terminalWidth}>
<Text bold>Resumable sessions</Text>
<Text color="gray">
Search: <Text color={state.query ? 'cyan' : 'gray'}>{state.query || 'type to filter'}</Text>
</Text>
<Text color="gray">
{filteredSessions.length === 0
? 'No matching sessions'
: `${shownStart}-${shownEnd} of ${filteredSessions.length}`}
</Text>
<Box flexDirection="column" marginTop={1}>
{visibleSessions.map((session, index) => {
const absoluteIndex = scrollOffset + index
const selected = absoluteIndex === selectedIndex
return (
<Text
key={session.sessionId}
color={selected ? 'cyan' : undefined}
inverse={selected}
>
{selected ? '> ' : ' '}
{formatSessionLine(session, width - 2)}
</Text>
)
})}
</Box>
<Box marginTop={1}>
<Text color="gray">Up/Down move | PageUp/PageDown scroll | type search | Enter resume | Esc clear/cancel | Ctrl-C cancel</Text>
</Box>
</Box>
)
}
@@ -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>): 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)
})
})
+143
View File
@@ -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
)
}
}