1- import { Box , Text , useInput } from "ink" ;
2- import { useMemo , useRef , useState } from "react" ;
1+ import { Box , Text , useInput , useStdin } from "ink" ;
2+ import { type ReactNode , useEffect , useMemo , useRef , useState } from "react" ;
33import { logInputEvent } from "./debug-input.js" ;
44import {
55 backspace ,
@@ -126,6 +126,8 @@ export function Input({
126126 const [ pathIdx , setPathIdx ] = useState ( 0 ) ;
127127 const lastAtTokenRef = useRef < { buffer : string ; cursor : number } | null > ( null ) ;
128128
129+ useBracketedPasteHandler ( ( content ) => setState ( ( s ) => insertPaste ( s , content ) ) ) ;
130+
129131 // Autocomplete only fires when the buffer starts with `/` AND there's
130132 // no whitespace yet (so once the user types a space, they're past the
131133 // command name and into args — no more suggestions).
@@ -347,6 +349,11 @@ export function Input({
347349 // Printable text — Ink's useInput delivers individual chars (or pasted runs).
348350 // Strip control bytes (0x00–0x1f, 0x7f) so a stray Backspace or escape
349351 // fragment doesn't end up inserted as a glyph in the buffer.
352+ // CSI 200~ / 201~ remnants from a bracketed paste: ink's keypress
353+ // parser surfaces them here as `[200~` / `[201~` (escape stripped).
354+ // The actual content was already handled by useBracketedPasteHandler,
355+ // so swallow these so they don't end up in the buffer.
356+ if ( input === "[200~" || input === "[201~" ) return ;
350357 const isPrintable = input && ! key . ctrl && ! key . meta && ! / [ \x00 - \x1f \x7f ] / . test ( input ) ;
351358 if ( isPrintable ) {
352359 setSuggestionIdx ( 0 ) ;
@@ -452,7 +459,11 @@ function RenderedBuffer({ buffer, cursor }: RenderedBufferProps) {
452459 const cursorOnThisLine = cursor >= lineStart && cursor <= lineEnd ;
453460 consumed = lineEnd + 1 ;
454461 if ( ! cursorOnThisLine ) {
455- return < Text key = { `line-${ idx } -${ line . slice ( 0 , 8 ) } ` } > { line . length === 0 ? " " : line } </ Text > ;
462+ return (
463+ < Box key = { `line-${ idx } -${ line . slice ( 0 , 8 ) } ` } >
464+ { line . length === 0 ? < Text > </ Text > : < StyledRun text = { line } keyPrefix = { `l${ idx } ` } /> }
465+ </ Box >
466+ ) ;
456467 }
457468 return (
458469 < SingleLineBuffer
@@ -470,7 +481,7 @@ function SingleLineBuffer({ buffer, cursor }: RenderedBufferProps) {
470481 if ( cursor >= buffer . length ) {
471482 return (
472483 < >
473- < Text > { buffer } </ Text >
484+ < StyledRun text = { buffer } keyPrefix = "slb-end" / >
474485 < Text color = "cyan" > ▎</ Text >
475486 </ >
476487 ) ;
@@ -480,9 +491,98 @@ function SingleLineBuffer({ buffer, cursor }: RenderedBufferProps) {
480491 const after = buffer . slice ( cursor + 1 ) ;
481492 return (
482493 < >
483- < Text > { before } </ Text >
494+ < StyledRun text = { before } keyPrefix = "slb-b" / >
484495 < Text inverse > { onCursor } </ Text >
485- < Text > { after } </ Text >
496+ < StyledRun text = { after } keyPrefix = "slb-a" / >
486497 </ >
487498 ) ;
488499}
500+
501+ const BRACKETED_PASTE_START = "\x1b[200~" ;
502+ const BRACKETED_PASTE_END = "\x1b[201~" ;
503+
504+ /**
505+ * Subscribe to ink's raw stdin event emitter so we can carve paste content
506+ * out of bracketed-paste markers (CSI 200~ … 201~) before ink's keypress
507+ * parser mangles them. Necessary because parseKeypress sees `\x1b[200~`
508+ * as a single keypress and *discards* whatever bytes followed it in the
509+ * same chunk — so without intercepting at this layer, the pasted content
510+ * is gone before useInput ever sees it.
511+ *
512+ * Buffers across multiple stdin chunks for large pastes that the OS
513+ * fragments. The callback fires once per complete paste, with the full
514+ * content between the markers.
515+ */
516+ function useBracketedPasteHandler ( onPaste : ( content : string ) => void ) {
517+ const { internal_eventEmitter } = useStdin ( ) ;
518+ const pasteRef = useRef < { active : boolean ; chunks : string [ ] } > ( { active : false , chunks : [ ] } ) ;
519+ const onPasteRef = useRef ( onPaste ) ;
520+ onPasteRef . current = onPaste ;
521+
522+ useEffect ( ( ) => {
523+ if ( ! internal_eventEmitter ) return ;
524+ const handler = ( raw : unknown ) => {
525+ const chunk = typeof raw === "string" ? raw : String ( raw ) ;
526+ let pos = 0 ;
527+ while ( pos < chunk . length ) {
528+ if ( pasteRef . current . active ) {
529+ const endIdx = chunk . indexOf ( BRACKETED_PASTE_END , pos ) ;
530+ if ( endIdx === - 1 ) {
531+ pasteRef . current . chunks . push ( chunk . slice ( pos ) ) ;
532+ return ;
533+ }
534+ pasteRef . current . chunks . push ( chunk . slice ( pos , endIdx ) ) ;
535+ const content = pasteRef . current . chunks . join ( "" ) ;
536+ pasteRef . current = { active : false , chunks : [ ] } ;
537+ if ( content . length > 0 ) onPasteRef . current ( content ) ;
538+ pos = endIdx + BRACKETED_PASTE_END . length ;
539+ } else {
540+ const startIdx = chunk . indexOf ( BRACKETED_PASTE_START , pos ) ;
541+ if ( startIdx === - 1 ) return ;
542+ // Bytes before the start marker are normal input; ink's
543+ // useInput handles them in parallel. We only take over
544+ // once the paste begins.
545+ pasteRef . current = { active : true , chunks : [ ] } ;
546+ pos = startIdx + BRACKETED_PASTE_START . length ;
547+ }
548+ }
549+ } ;
550+ internal_eventEmitter . on ( "input" , handler ) ;
551+ return ( ) => {
552+ internal_eventEmitter . removeListener ( "input" , handler ) ;
553+ } ;
554+ } , [ internal_eventEmitter ] ) ;
555+ }
556+
557+ const PASTE_PLACEHOLDER_RE_DISPLAY = / \[ P a s t e d # \d + · \d + (?: l i n e s | c h a r s ) \] / g;
558+
559+ /**
560+ * Render a text run, dim-styling any paste-placeholder substrings.
561+ * Cursor handling lives outside — RenderedBuffer / SingleLineBuffer split
562+ * around the cursor first, then hand the halves here. A placeholder split
563+ * across the cursor boundary won't match its regex in either half and
564+ * falls back to plain text — fine because the user is actively editing it.
565+ */
566+ function StyledRun ( { text, keyPrefix } : { text : string ; keyPrefix : string } ) {
567+ if ( ! text ) return null ;
568+ const parts : ReactNode [ ] = [ ] ;
569+ let lastEnd = 0 ;
570+ let idx = 0 ;
571+ for ( const m of text . matchAll ( PASTE_PLACEHOLDER_RE_DISPLAY ) ) {
572+ const start = m . index ?? 0 ;
573+ if ( start > lastEnd ) {
574+ parts . push ( < Text key = { `${ keyPrefix } -t-${ idx ++ } ` } > { text . slice ( lastEnd , start ) } </ Text > ) ;
575+ }
576+ parts . push (
577+ < Text key = { `${ keyPrefix } -p-${ idx ++ } ` } dimColor italic >
578+ { m [ 0 ] }
579+ </ Text > ,
580+ ) ;
581+ lastEnd = start + m [ 0 ] . length ;
582+ }
583+ if ( lastEnd === 0 ) return < Text > { text } </ Text > ;
584+ if ( lastEnd < text . length ) {
585+ parts . push ( < Text key = { `${ keyPrefix } -t-${ idx ++ } ` } > { text . slice ( lastEnd ) } </ Text > ) ;
586+ }
587+ return < > { parts } </ > ;
588+ }
0 commit comments