Skip to content

Commit 7d7f94f

Browse files
committed
feat(input): dim-style placeholders and enable bracketed paste mode
Two related paste-UX polishes that ship together because each is half- useful without the other. Visual: a new StyledRun helper splits buffer text around the placeholder regex and renders the placeholder segments dim + italic so they read as "this is a paste reference, not your typing." Cursor handling still splits around the cursor first, so a placeholder spanning the cursor falls back to plain text — fine, the user is editing inside it. Detection robustness: enable bracketed paste mode (CSI 2004h) at app start so the terminal wraps pasted content in 200~/201~ markers. ink's keypress parser eats those markers and discards the bytes that follow in the same chunk, so we bypass it: a useBracketedPasteHandler hook subscribes to ink's raw stdin event emitter, buffers content across fragmented chunks, and dispatches whole pastes through insertPaste. The malformed [200~/[201~ remnants that still leak through useInput get filtered in the printable-text branch.
1 parent e84e3e5 commit 7d7f94f

2 files changed

Lines changed: 111 additions & 6 deletions

File tree

src/cli.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ if (argv[0] === "--version" || argv[0] === "-v") {
8080
runHeadless({ prompt, outputFormat, autoApprove }).then((code) => process.exit(code));
8181
} else {
8282
setTerminalTitle("codebase");
83+
// Enable bracketed paste mode so the terminal wraps pasted content in
84+
// CSI 200~ / 201~ markers. The Input component listens for them and
85+
// collapses the content into a placeholder. terminal-restore.ts emits
86+
// the matching disable sequence on every exit path.
87+
if (process.stdout.isTTY) process.stdout.write("\x1b[?2004h");
8388
const instance = render(<App />);
8489
installTerminalRestoreHandlers(instance);
8590
instance.waitUntilExit().catch(() => {

src/ui/Input.tsx

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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";
33
import { logInputEvent } from "./debug-input.js";
44
import {
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 = /\[Pasted #\d+ · \d+ (?:lines|chars)\]/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

Comments
 (0)