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:
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user