feat: add usePaste composable with bracketed paste mode

- Ref-counted bracketed paste mode (\x1b[?2004h/l)
- usePaste acquires raw mode + paste mode, listens on paste channel
- Paste falls through to useInput when no paste listeners exist
- isActive option for conditional paste listening

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 15:25:07 +08:00
parent f51079a5ea
commit 1a888a8eb1
5 changed files with 138 additions and 0 deletions
@@ -0,0 +1,72 @@
import { defineComponent, shallowRef } from "vue";
import { describe, test, expect } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Text, useInput, usePaste } from "@vue-tui/runtime";
describe("usePaste", () => {
test("receives pasted text from bracketed paste", async () => {
const pasted = shallowRef("");
const App = defineComponent(() => {
usePaste((text) => {
pasted.value = text;
});
return () => <Text>{pasted.value || "waiting"}</Text>;
});
const { stdin } = await render(App);
await stdin.write("\x1b[200~hello world\x1b[201~");
expect(pasted.value).toBe("hello world");
});
test("paste falls through to useInput when no paste listeners", async () => {
const received = shallowRef("");
const App = defineComponent(() => {
useInput((input) => {
received.value += input;
});
return () => <Text>{received.value || "waiting"}</Text>;
});
const { stdin } = await render(App);
// Without usePaste, paste events fall through as regular input
await stdin.write("\x1b[200~pasted\x1b[201~");
expect(received.value).toBe("pasted");
});
test("respects isActive option", async () => {
const pasted = shallowRef("");
const active = shallowRef(false);
const App = defineComponent(() => {
usePaste(
(text) => {
pasted.value = text;
},
{ isActive: active },
);
return () => <Text>{pasted.value || "waiting"}</Text>;
});
const { stdin } = await render(App);
await stdin.write("\x1b[200~ignored\x1b[201~");
expect(pasted.value).toBe("");
active.value = true;
await stdin.write("\x1b[200~captured\x1b[201~");
expect(pasted.value).toBe("captured");
});
test("usePaste intercepts paste so useInput does not receive it", async () => {
const inputReceived: string[] = [];
const pasteReceived: string[] = [];
const App = defineComponent(() => {
useInput((input) => {
inputReceived.push(input);
});
usePaste((text) => {
pasteReceived.push(text);
});
return () => <Text>listening</Text>;
});
const { stdin } = await render(App);
await stdin.write("\x1b[200~pasted text\x1b[201~");
expect(pasteReceived).toEqual(["pasted text"]);
expect(inputReceived).toEqual([]);
});
});
@@ -0,0 +1,45 @@
import { inject, onScopeDispose, toValue, watch, type MaybeRefOrGetter } from "vue";
import { StdinContextKey } from "../context.ts";
export interface UsePasteOptions {
isActive?: MaybeRefOrGetter<boolean>;
}
export function usePaste(handler: (text: string) => void, options: UsePasteOptions = {}): void {
const stdin = inject(StdinContextKey);
if (!stdin) throw new Error("usePaste() must be called inside a vue-tui render tree");
let attached = false;
function listener(text: string) {
handler(text);
}
function attach() {
if (attached) return;
attached = true;
stdin!.acquireRawMode();
stdin!.setBracketedPasteMode(true);
stdin!.internal_eventEmitter.on("paste", listener);
}
function detach() {
if (!attached) return;
attached = false;
stdin!.internal_eventEmitter.off("paste", listener);
stdin!.setBracketedPasteMode(false);
stdin!.releaseRawMode();
}
const isActive = options.isActive ?? true;
watch(
() => toValue(isActive),
(value) => {
if (value) attach();
else detach();
},
{ immediate: true, flush: "sync" },
);
onScopeDispose(detach);
}
+1
View File
@@ -36,6 +36,7 @@ export interface StdinContext {
internal_exitOnCtrlC: boolean;
acquireRawMode: () => void;
releaseRawMode: () => void;
setBracketedPasteMode: (enabled: boolean) => void;
}
export const AppContextKey: InjectionKey<AppContext> = Symbol("vue-tui:app");
+1
View File
@@ -9,6 +9,7 @@ export { Transform } from "./components/Transform.ts";
export { useExit } from "./composables/useExit.ts";
export { useInput, type Key, type UseInputOptions } from "./composables/useInput.ts";
export { usePaste, type UsePasteOptions } from "./composables/usePaste.ts";
export { useFocus, type UseFocusOptions } from "./composables/useFocus.ts";
export { useFocusManager } from "./composables/useFocusManager.ts";
export { useStdin } from "./composables/useStdin.ts";
+19
View File
@@ -421,6 +421,7 @@ function createStdinController(
const inputParser = createInputParser();
let pendingFlushTimer: ReturnType<typeof setTimeout> | undefined;
const FLUSH_DELAY = 20; // ms, matching Ink
let bracketedPasteModeCount = 0;
function clearPendingFlush() {
if (pendingFlushTimer !== undefined) {
@@ -528,6 +529,20 @@ function createStdinController(
state.refs++;
localRefs++;
},
setBracketedPasteMode(enabled: boolean) {
if (enabled) {
if (bracketedPasteModeCount === 0 && appCtx.stdout.isTTY) {
appCtx.stdout.write("\x1b[?2004h");
}
bracketedPasteModeCount++;
} else {
if (bracketedPasteModeCount === 0) return;
bracketedPasteModeCount--;
if (bracketedPasteModeCount === 0 && appCtx.stdout.isTTY) {
appCtx.stdout.write("\x1b[?2004l");
}
}
},
releaseRawMode() {
if (!appCtx.isRawModeSupported) return;
if (localRefs === 0) return;
@@ -551,6 +566,10 @@ function createStdinController(
stdin.off("readable", handleReadable);
stdin.off("data", handleData);
emitter.off("input", focusInputListener);
if (bracketedPasteModeCount > 0 && appCtx.stdout.isTTY) {
appCtx.stdout.write("\x1b[?2004l");
}
bracketedPasteModeCount = 0;
if (localRefs > 0 && appCtx.isRawModeSupported) {
const state = getRawModeState(stdin);
state.refs = Math.max(0, state.refs - localRefs);