fix(tui): harden resume picker input

This commit is contained in:
ZiyaZhang
2026-07-24 01:29:35 -07:00
parent 33ee34b58e
commit 12dbc00166
2 changed files with 110 additions and 5 deletions
+51 -5
View File
@@ -393,6 +393,11 @@ function ansi(open: string, close: string, enabled: boolean): (text: string) =>
}
const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu
const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu
const BRACKETED_PASTE_START = '\u001B[200~'
const BRACKETED_PASTE_END = '\u001B[201~'
/**
* Escape external C0/C1 controls before pi-tui adds application-owned ANSI.
@@ -408,6 +413,15 @@ function displayInlineText(text: string): string {
return displayText(text).replaceAll('\n', '\\x0a')
}
/** Remove terminal controls from clipboard text before an editable field stores it. */
function sanitizePastedText(text: string): string {
return text
.replace(TERMINAL_OSC_PATTERN, '')
.replace(TERMINAL_CSI_PATTERN, '')
.replace(TERMINAL_ESCAPE_PATTERN, '')
.replace(TERMINAL_CONTROL_PATTERN, '')
}
/**
* Theme-agnostic palette built from the standard 16-color ANSI set plus SGR
* attributes, which every terminal remaps to its active color scheme. Body
@@ -1335,6 +1349,7 @@ function summarizeResumeCandidate(
/** Full-viewport keyboard selector over detached, preflighted resume summaries. */
class ResumePicker implements Component, Focusable {
private readonly search = new Input()
private pasteBuffer: string | undefined
private selectedIndex = 0
private error = ''
focused = false
@@ -1360,7 +1375,39 @@ class ResumePicker implements Component, Focusable {
|| candidate.record.header.id.toLocaleLowerCase().includes(query))
}
private visibleCandidateCount(): number {
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4))
return Math.min(this.maxVisible, candidateBudget)
}
private handleBracketedPaste(data: string): boolean {
const start = data.indexOf(BRACKETED_PASTE_START)
if (this.pasteBuffer === undefined && start < 0) return false
if (this.pasteBuffer === undefined) {
const prefix = data.slice(0, start)
if (prefix !== '') this.handleInput(prefix)
this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length)
} else {
this.pasteBuffer += data
}
const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END)
if (end < 0) return true
const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end))
const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length)
this.pasteBuffer = undefined
const previous = this.search.getValue()
this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`)
if (this.search.getValue() !== previous) {
this.selectedIndex = 0
this.error = ''
}
if (remaining !== '') this.handleInput(remaining)
this.invalidate()
return true
}
handleInput(data: string): void {
if (this.handleBracketedPaste(data)) return
const filtered = this.filtered()
if (matchesKey(data, Key.ctrl('c'))) {
this.cancel()
@@ -1380,11 +1427,11 @@ class ResumePicker implements Component, Focusable {
} else if (matchesKey(data, Key.down)) {
this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length
} else if (matchesKey(data, Key.pageUp)) {
this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible)
this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount())
} else if (matchesKey(data, Key.pageDown)) {
this.selectedIndex = Math.min(
Math.max(0, filtered.length - 1),
this.selectedIndex + this.maxVisible,
this.selectedIndex + this.visibleCandidateCount(),
)
} else if (matchesKey(data, Key.enter)) {
const selected = filtered[this.selectedIndex]
@@ -1421,7 +1468,7 @@ class ResumePicker implements Component, Focusable {
const searchInnerWidth = Math.max(1, contentWidth - 4)
lines.push(`${indent}${this.palette.dim(`${'─'.repeat(Math.max(0, contentWidth - 2))}`)}`)
const searchContent = (this.search.render(searchInnerWidth)[0] ?? '').replace(/^> /u, ' ')
const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, ' ')
const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '')
lines.push(
`${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`,
@@ -1431,8 +1478,7 @@ class ResumePicker implements Component, Focusable {
'',
)
const candidateBudget = Math.max(1, Math.floor((height - 13) / 4))
const visibleCount = Math.min(this.maxVisible, candidateBudget)
const visibleCount = this.visibleCandidateCount()
const start = Math.max(0, Math.min(
this.selectedIndex - Math.floor(visibleCount / 2),
filtered.length - visibleCount,
+59
View File
@@ -333,6 +333,65 @@ describe('resume command and /resume', () => {
await dispose(result)
})
it('sanitizes bracketed-paste terminal controls before storing the search query', async () => {
const target = header('safe-target', 10, '/workspace')
const result = await setup({
cwd: '/workspace',
sessionPersistence: {
list: async () => [target],
load: async () => ({ meta: target, events: resumeEvents('Safe target') }),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('\x1b[200~Safe\x1b]0;own')
result.terminal.send('ed\x07 target\x1b[31m\x1b[201~')
await tick()
const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
expect(rendered).toContain('⌕ Safe target')
expect(rendered).not.toContain('owned')
expect(rendered).not.toContain('[31m')
result.terminal.send('\x1b')
result.terminal.send('Safe\x1b[200~\x1b[201~ target')
await tick()
expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
.toContain('⌕ Safe target')
await dispose(result)
})
it('pages by the number of candidates that fit the current viewport', async () => {
const targets = Array.from({ length: 8 }, (_, index) =>
header(`paged-${index}`, 1000 - index, '/workspace'))
const result = await setup({
cwd: '/workspace',
sessionPersistence: {
list: async () => targets,
load: async id => ({
meta: targets.find(target => target.id === id)!,
events: resumeEvents(`Paged ${id.slice('paged-'.length)}`, 'deepseek', 1000 - Number(id.slice('paged-'.length)) * 10),
}),
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
result.terminal.send('\x1b[6~')
await tick()
const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
expect(rendered).toContain(' Paged 3')
result.terminal.send('\x1b[5~')
await tick()
expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
.toContain(' Untitled session')
result.terminal.resize(10)
await tick()
expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
.toContain('⌕')
result.terminal.send('\x03')
await dispose(result)
})
it('clips candidate count through the configured visible-session limit', async () => {
const targets = [header('limited-a', 10, '/workspace'), header('limited-b', 20, '/workspace')]
const result = await setup({