fix(tui): harden resume picker input
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user