From 1a888a8eb153a12de311b6e998554a95e317adcc Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 26 May 2026 15:25:07 +0800 Subject: [PATCH] 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) --- .../composables/use-paste.test.tsx | 72 +++++++++++++++++++ packages/runtime/src/composables/usePaste.ts | 45 ++++++++++++ packages/runtime/src/context.ts | 1 + packages/runtime/src/index.ts | 1 + packages/runtime/src/render.ts | 19 +++++ 5 files changed, 138 insertions(+) create mode 100644 packages/runtime-tests/integration/composables/use-paste.test.tsx create mode 100644 packages/runtime/src/composables/usePaste.ts diff --git a/packages/runtime-tests/integration/composables/use-paste.test.tsx b/packages/runtime-tests/integration/composables/use-paste.test.tsx new file mode 100644 index 0000000..8dbbb67 --- /dev/null +++ b/packages/runtime-tests/integration/composables/use-paste.test.tsx @@ -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 () => {pasted.value || "waiting"}; + }); + 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 () => {received.value || "waiting"}; + }); + 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 () => {pasted.value || "waiting"}; + }); + 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 () => listening; + }); + const { stdin } = await render(App); + await stdin.write("\x1b[200~pasted text\x1b[201~"); + expect(pasteReceived).toEqual(["pasted text"]); + expect(inputReceived).toEqual([]); + }); +}); diff --git a/packages/runtime/src/composables/usePaste.ts b/packages/runtime/src/composables/usePaste.ts new file mode 100644 index 0000000..0512967 --- /dev/null +++ b/packages/runtime/src/composables/usePaste.ts @@ -0,0 +1,45 @@ +import { inject, onScopeDispose, toValue, watch, type MaybeRefOrGetter } from "vue"; +import { StdinContextKey } from "../context.ts"; + +export interface UsePasteOptions { + isActive?: MaybeRefOrGetter; +} + +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); +} diff --git a/packages/runtime/src/context.ts b/packages/runtime/src/context.ts index 3b8664d..fa7b5ed 100644 --- a/packages/runtime/src/context.ts +++ b/packages/runtime/src/context.ts @@ -36,6 +36,7 @@ export interface StdinContext { internal_exitOnCtrlC: boolean; acquireRawMode: () => void; releaseRawMode: () => void; + setBracketedPasteMode: (enabled: boolean) => void; } export const AppContextKey: InjectionKey = Symbol("vue-tui:app"); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index eb47cd2..aadd8c2 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -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"; diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 334bb38..0e9257c 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -421,6 +421,7 @@ function createStdinController( const inputParser = createInputParser(); let pendingFlushTimer: ReturnType | 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);