///
import * as path from 'node:path'
import { matchesKey } from '@earendil-works/pi-tui'
import type { UndoOperation } from './reverse-last-state.ts'
type PickerContext = {
cwd: string
hasUI: boolean
ui: {
setWidget?: (key: string, content: string[] | undefined) => void
onTerminalInput?: (
handler: (data: string) => { consume?: boolean; data?: string } | undefined
) => () => void
}
}
function formatCandidateLabel(cwd: string, operation: UndoOperation): string {
const stamp = new Date(operation.timestamp).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
const sample = operation.files
.slice(0, 2)
.map((file) => pathRelative(cwd, file.absPath))
.join(', ')
const suffix = operation.files.length > 2 ? ` +${operation.files.length - 2} more` : ''
return `${stamp} ${operation.toolName} (${operation.files.length} file${operation.files.length === 1 ? '' : 's'}) ${sample}${suffix}`
}
function pathRelative(cwd: string, absPath: string): string {
return path.relative(cwd, absPath) || absPath
}
export async function selectOperationsFromUI(
ctx: PickerContext,
operations: UndoOperation[]
): Promise | undefined> {
const setWidget = ctx.ui.setWidget
const onTerminalInput = ctx.ui.onTerminalInput
if (!ctx.hasUI || !setWidget || !onTerminalInput) {
return undefined
}
const widgetKey = 'undo-picker'
const selected = operations.map(() => false)
let cursor = 0
return new Promise((resolve) => {
const render = () => {
const lines = [
'Select changes to reverse (since last user message).',
'Space toggle • a select all/none • Enter apply • Esc cancel',
'',
...operations.map(
(operation, index) =>
`${index === cursor ? '>' : ' '} ${selected[index] ? '[x]' : '[ ]'} ${formatCandidateLabel(ctx.cwd, operation)}`
)
]
setWidget(widgetKey, lines)
}
const finish = (value: Set | undefined) => {
unsubscribe()
setWidget(widgetKey, undefined)
resolve(value)
}
render()
const unsubscribe = onTerminalInput((data) => {
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) {
finish(undefined)
return { consume: true }
}
if (matchesKey(data, 'enter') || matchesKey(data, 'return')) {
const ids = new Set()
for (let index = 0; index < operations.length; index++) {
if (selected[index]) {
ids.add(operations[index].id)
}
}
finish(ids)
return { consume: true }
}
if (data === ' ') {
selected[cursor] = !selected[cursor]
render()
return { consume: true }
}
if (data === 'a' || data === 'A') {
const allSelected = selected.every(Boolean)
for (let index = 0; index < selected.length; index++) {
selected[index] = !allSelected
}
render()
return { consume: true }
}
if (matchesKey(data, 'up') || data === 'k') {
cursor = cursor > 0 ? cursor - 1 : operations.length - 1
render()
return { consume: true }
}
if (matchesKey(data, 'down') || data === 'j') {
cursor = cursor < operations.length - 1 ? cursor + 1 : 0
render()
return { consume: true }
}
return undefined
})
})
}