فهرست منبع

fix(tui): harden resume picker input

ZiyaZhang 2 ماه پیش
والد
کامیت
12dbc00166
2فایلهای تغییر یافته به همراه110 افزوده شده و 5 حذف شده
  1. 51 5
      packages/ui/tui/src/index.ts
  2. 59 0
      packages/ui/tui/tests/tui.spec.ts

+ 51 - 5
packages/ui/tui/src/index.ts

@@ -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 - 0
packages/ui/tui/tests/tui.spec.ts

@@ -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({