Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/paste-all-text-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@nanocollective/nanocoder': patch
---

Pasting now works in every text field, not just the main prompt. The terminal's bracketed-paste payload is lifted off stdin before Ink sees it and handed to whoever subscribes, and only the main prompt did, so a paste into the MCP and provider wizards, the settings panels, the `ask_user` prompt or the architect review's revise box was silently dropped. The shared text input now takes the paste whenever it has focus and inserts it at the caret; the main prompt keeps its own handling, which collapses long pastes into placeholders. Closes #1456.
91 changes: 91 additions & 0 deletions source/components/text-input-keys.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import test from 'ava';
import {render} from 'ink-testing-library';
import React, {useState} from 'react';
import {pasteEvents} from '../utils/terminal-paste';
import TextInput from './text-input';

/**
Expand Down Expand Up @@ -353,6 +354,96 @@ test('component Ctrl+A moves to start when showCursor is true (positive control)
});


// --- Bracketed paste ---
//
// cli.tsx lifts bracketed pastes off stdin and publishes them on pasteEvents,
// so a field only ever sees a paste if it subscribes. Only the composer used
// to, so pasting into any other field did nothing.

test('component paste inserts at the caret of a focused field', async t => {
const valueRef: ValueRef = {current: ''};
const {stdin, unmount} = render(
<ControlledTextInput valueRef={valueRef} initialValue="ac" />,
);
t.teardown(unmount);

await press(stdin, '\u001b[D'); // left -> caret between a and c
pasteEvents.emit('paste', 'b');
await waitForValue(valueRef, v => v === 'abc');
t.is(valueRef.current, 'abc');

// The caret ends after the pasted text, so typing continues from there.
await press(stdin, 'X');
await waitForValue(valueRef, v => v === 'abXc');
t.is(valueRef.current, 'abXc');
});

test('component paste turns the CR line breaks a terminal sends into newlines', async t => {
const valueRef: ValueRef = {current: ''};
const {unmount} = render(<ControlledTextInput valueRef={valueRef} />);
t.teardown(unmount);

await new Promise(resolve => setTimeout(resolve, 20));
pasteEvents.emit('paste', 'one\rtwo\r\nthree');
await waitForValue(valueRef, v => v.length > 0);
t.is(valueRef.current, 'one\ntwo\nthree');
});

test('component paste is ignored by a field without focus', async t => {
const changes: string[] = [];
const {unmount} = render(
<TextInput value="" onChange={v => changes.push(v)} focus={false} />,
);
t.teardown(unmount);

await new Promise(resolve => setTimeout(resolve, 20));
pasteEvents.emit('paste', 'nope');
await new Promise(resolve => setTimeout(resolve, 50));
t.deepEqual(changes, []);
});

test('component onPaste replaces the default insert', async t => {
const changes: string[] = [];
const pastes: string[] = [];
const {unmount} = render(
<TextInput
value=""
onChange={v => changes.push(v)}
onPaste={payload => pastes.push(payload)}
/>,
);
t.teardown(unmount);

await new Promise(resolve => setTimeout(resolve, 20));
pasteEvents.emit('paste', 'handled elsewhere');
await new Promise(resolve => setTimeout(resolve, 50));
t.deepEqual(pastes, ['handled elsewhere']);
t.deepEqual(changes, []);
});

test('component paste listener is removed on blur and on unmount', async t => {
// pasteEvents is a process-wide singleton, so a leaked listener would make
// a later field's paste land twice. Effects flush after render returns, so
// settle before each count.
const settle = () => new Promise(resolve => setTimeout(resolve, 20));
const baseline = pasteEvents.listenerCount('paste');
const {rerender, unmount} = render(<TextInput value="" onChange={() => {}} />);
await settle();
t.is(pasteEvents.listenerCount('paste'), baseline + 1);

rerender(<TextInput value="" onChange={() => {}} focus={false} />);
await settle();
t.is(pasteEvents.listenerCount('paste'), baseline);

rerender(<TextInput value="" onChange={() => {}} />);
await settle();
t.is(pasteEvents.listenerCount('paste'), baseline + 1);

unmount();
await settle();
t.is(pasteEvents.listenerCount('paste'), baseline);
});

// --- Ctrl+A/E/U/K stay scoped to the current line in multi-line input ---
//
// Each readline shortcut used to compute its offset from the start/end of
Expand Down
41 changes: 41 additions & 0 deletions source/components/text-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useState,
} from 'react';
import {isNewlineKey} from '@/utils/newline-key';
import {pasteEvents} from '@/utils/terminal-paste';
import {
getVisualLineSegments,
moveCursorToVisualLine,
Expand Down Expand Up @@ -56,6 +57,8 @@ export type Props = {
readonly wrapWidth?: number;
readonly handleEnter?: boolean;
readonly onEdgeArrow?: (direction: 'up' | 'down') => void;
/** Replaces the default paste handling (insert at the caret). */
readonly onPaste?: (payload: string) => void;
};

/**
Expand Down Expand Up @@ -89,6 +92,7 @@ const TextInput = forwardRef<TextInputHandle, Props>(function TextInput(
wrapWidth,
handleEnter = true,
onEdgeArrow,
onPaste,
}: Props,
ref,
) {
Expand Down Expand Up @@ -157,6 +161,43 @@ const TextInput = forwardRef<TextInputHandle, Props>(function TextInput(
// state — and only clamp it against the new value's bounds.
const skipNextCursorResetRef = useRef(false);

// cli.tsx lifts bracketed pastes off stdin before Ink sees them, so a field
// that doesn't take them from pasteEvents never receives them at all.
// Subscribe while focused, the same condition that gates keystrokes, so a
// paste lands in exactly the field that typing would. The handler is read
// through a ref so the subscription doesn't churn on every render.
const handlePasteRef = useRef<(payload: string) => void>(() => {});
// Reassigned on every render, deliberately outside an effect: the listener
// must always call the latest onPaste/onChange. Moving this into an effect
// would bring back the stale closure the ref is here to avoid.
handlePasteRef.current = (payload: string) => {
if (onPaste) {
onPaste(payload);
return;
}
// Terminals send line breaks in a paste as CR.
const text = payload.replace(/\r\n?/g, '\n');
const current = originalValueRef.current;
const offset = cursorOffsetRef.current;
const next = current.slice(0, offset) + text + current.slice(offset);
cursorOffsetRef.current = offset + text.length;
originalValueRef.current = next;
recordEmit(next);
setState({cursorOffset: offset + text.length, cursorWidth: 0});
onChange(next);
};

useEffect(() => {
if (!focus) {
return;
}
const handlePaste = (payload: string) => handlePasteRef.current(payload);
pasteEvents.on('paste', handlePaste);
return () => {
pasteEvents.off('paste', handlePaste);
};
}, [focus]);

useEffect(() => {
if (!focus || !showCursor) {
return;
Expand Down
56 changes: 26 additions & 30 deletions source/components/user-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ import {fuzzyScoreFilePath} from '@/utils/fuzzy-matching';
import {isNewlineKey} from '@/utils/newline-key';
import {assemblePrompt} from '@/utils/prompt-processor';
import {handleResourceMention} from '@/utils/resource-mention-handler';
import {pasteEvents} from '@/utils/terminal-paste';
import {getVisualLineSegments} from '@/utils/text-wrapping';
import type {ActiveEditorState} from '@/vscode/vscode-server';

Expand Down Expand Up @@ -343,35 +342,28 @@ export default function UserInput({
// Real pastes, as reported by the terminal via bracketed paste. The
// payload is lifted off stdin before Ink's keypress parser sees it, so
// a multi-line paste can no longer submit the prompt on its first
// newline — it arrives here whole, in one event.
useEffect(() => {
if (disabled || !effectiveFocus) {
return;
// newline — it arrives here whole, in one event. TextInput subscribes
// while focused and hands the payload here instead of inserting it, so
// long pastes can collapse into placeholders.
const handleTerminalPaste = (payload: string) => {
// Read the caret off TextInput so the splice lands where the user
// was editing, not at the end of the value. insertPaste returns the
// new cursor offset; fall back to a remount only if no cursor is
// available (TextInput not yet mounted).
const cursorOffset = textInputRef.current?.getCursorOffset();
const result = insertPaste(payload, cursorOffset);
if (result && textInputRef.current) {
// Hand TextInput the pasted value along with the caret: keys in the
// same stdin batch as the paste land before it re-renders, and
// must edit the pasted value, not the one it last rendered.
textInputRef.current.setCursorOffset(
result.cursorOffset,
currentStateRef.current.displayValue,
);
} else if (!result) {
setTextInputKey(prev => prev + 1);
}
const handleTerminalPaste = (payload: string) => {
// Read the caret off TextInput so the splice lands where the user
// was editing, not at the end of the value. insertPaste returns the
// new cursor offset; fall back to a remount only if no cursor is
// available (TextInput not yet mounted).
const cursorOffset = textInputRef.current?.getCursorOffset();
const result = insertPaste(payload, cursorOffset);
if (result && textInputRef.current) {
// Hand TextInput the pasted value along with the caret: keys in the
// same stdin batch as the paste land before it re-renders, and
// must edit the pasted value, not the one it last rendered.
textInputRef.current.setCursorOffset(
result.cursorOffset,
currentStateRef.current.displayValue,
);
} else if (!result) {
setTextInputKey(prev => prev + 1);
}
};
pasteEvents.on('paste', handleTerminalPaste);
return () => {
pasteEvents.off('paste', handleTerminalPaste);
};
}, [disabled, effectiveFocus, insertPaste, currentStateRef]);
};

useEffect(() => {
if (
Expand Down Expand Up @@ -1367,9 +1359,13 @@ export default function UserInput({
? `Try ${suggestedCommand} · Tab to insert · Esc to dismiss`
: 'Ask anything...'
}
focus={effectiveFocus}
// TextInput takes keys and pastes only while focused, so fold
// `disabled` in here rather than relying on the disabled branch
// above never mounting it.
focus={effectiveFocus && !disabled}
wrapWidth={inputWrapWidth}
handleEnter={false}
onPaste={handleTerminalPaste}
/>
</Box>

Expand Down
2 changes: 1 addition & 1 deletion source/utils/terminal-paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const MAX_PASTE_CHARS = 10_000_000;
*/
const MIN_PARTIAL_START = 3;

/** Singleton bus: cli.tsx publishes payloads, UserInput subscribes. */
/** Singleton bus: cli.tsx publishes payloads, the focused TextInput subscribes. */
export const pasteEvents = new EventEmitter();

/**
Expand Down
18 changes: 18 additions & 0 deletions source/wizards/steps/field-input-view.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import test from 'ava';
import {renderWithTheme as render} from '@/test-utils/render-with-theme';
import React from 'react';
import stripAnsi from 'strip-ansi';
import {pasteEvents} from '@/utils/terminal-paste';
import type {TemplateField} from '../templates/provider-templates';
import {
FieldInputView,
Expand Down Expand Up @@ -278,3 +279,20 @@ test('renders narrow keyboard hints when isNarrow is true', t => {
t.notRegex(output, /Press Enter to continue \|/);
unmount();
});

test('a terminal paste reaches a wizard field, masked or not', async t => {
// cli.tsx lifts bracketed pastes off stdin, so this field only gets one if
// its TextInput subscribes to pasteEvents; before, only the composer did.
for (const sensitive of [false, true]) {
const {changes, unmount} = renderField({
currentField: {name: 'apiKey', prompt: 'API key', sensitive},
});

await wait(50);
pasteEvents.emit('paste', 'sk-pasted-key');
await wait(50);

t.deepEqual(changes, ['sk-pasted-key'], `sensitive=${sensitive}`);
unmount();
}
});
Loading