Files
Netcatty/application/state/useScriptRecorder.ts
T
cb06857480 feat(terminal): confirm before pasting multiple lines (#3398) (#3400)
* fix(#3398): automated AI fix

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix: address Codex review on PR #3400

* fix(terminal): protect popup and sanitized multiline pastes

* fix(terminal): preserve input state across paste confirmation

* fix(terminal): retain serial character-mode paste echo

* test(terminal): cover paced input write options

* fix(terminal): retain telnet echo for paced pastes

* test(terminal): scope serial buffer assertion to serial sessions

* fix(terminal): align runtime write options with bridge contract

* fix(terminal): preserve bookkeeping for all paced pastes

* fix(terminal): record confirmed paste lines in order

* fix: record paced paste commands after transport writes

* fix: omit paste receipts for filtered input

* fix: cancel superseded paste batches across input paths

* fix: classify each acknowledged paste line before recording

* fix: stop paced paste when editing buffered serial input

* fix: preserve recording pause boundaries and serial edit cancellation

* fix: treat paced terminal pastes as user input

* fix: preserve paste drafts until write and snippet broadcast origin

* fix: retain filtered paste drafts and invalidate urgent input ownership

* fix: broadcast paced user paste only on guarded line receipts

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-18 15:19:34 +08:00

279 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
import { DEFAULT_RECORDING_PROMPT_TIMEOUT_MS } from '@/domain/snippetScript.ts';
import type { ScriptRecordingStep } from '@/types/global/netcatty-bridge-script.d.ts';
import { notify } from '../notification';
export const MAX_PENDING_SCRIPT_RECORDING_INPUT_CHARS = 256 * 1024;
export const SCRIPT_RECORDING_LIMIT_EVENT = 'netcatty:script:recording:limit';
type ScriptRecordingLimitDetail = {
sessionId: string;
error: string;
steps: ScriptRecordingStep[];
code: string;
};
type ScriptRecordingResult = {
steps: ScriptRecordingStep[];
code: string;
};
const emptyScriptRecordingResult = (): ScriptRecordingResult => ({ steps: [], code: '' });
export function useScriptRecorder(sessionId: string | undefined) {
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [elapsedMs, setElapsedMs] = useState(0);
const startedAtRef = useRef<number | null>(null);
const inputBufferRef = useRef('');
const inputRevisionRef = useRef(0);
const lastStepAtRef = useRef<number>(Date.now());
const isRecordingRef = useRef(false);
const isPausedRef = useRef(false);
const isStoppingRef = useRef(false);
const stopPromiseRef = useRef<Promise<ScriptRecordingResult> | null>(null);
const sessionIdRef = useRef(sessionId);
const recordingGenerationRef = useRef(0);
const pendingRecordStepsRef = useRef(Promise.resolve());
const isDrainingRecordingRef = useRef(false);
sessionIdRef.current = sessionId;
useEffect(() => {
if (!isRecording || isPaused) return undefined;
const timer = window.setInterval(() => {
if (startedAtRef.current) {
setElapsedMs(Date.now() - startedAtRef.current);
}
}, 250);
return () => window.clearInterval(timer);
}, [isRecording, isPaused]);
const startRecording = useCallback(async () => {
const pendingStop = stopPromiseRef.current;
if (pendingStop) {
try {
await pendingStop;
} catch {
// A failed stop still closes the local recording. Starting again is
// the explicit recovery path and must happen after that stop settles.
}
}
const sid = sessionIdRef.current;
const bridge = netcattyBridge.get();
if (!sid || !bridge?.scriptRecordingStart) return;
await bridge.scriptRecordingStart(sid);
recordingGenerationRef.current += 1;
startedAtRef.current = Date.now();
lastStepAtRef.current = Date.now();
inputRevisionRef.current += 1;
inputBufferRef.current = '';
setElapsedMs(0);
setIsPaused(false);
isPausedRef.current = false;
isStoppingRef.current = false;
isRecordingRef.current = true;
setIsRecording(true);
}, []);
const stopRecording = useCallback(async () => {
if (stopPromiseRef.current) return stopPromiseRef.current;
const sid = sessionIdRef.current;
const bridge = netcattyBridge.get();
isStoppingRef.current = true;
isDrainingRecordingRef.current = true;
isRecordingRef.current = false;
isPausedRef.current = false;
setIsRecording(false);
setIsPaused(false);
inputRevisionRef.current += 1;
inputBufferRef.current = '';
startedAtRef.current = null;
if (!sid || !bridge?.scriptRecordingStop) {
isStoppingRef.current = false;
isDrainingRecordingRef.current = false;
return emptyScriptRecordingResult();
}
const stopRequest = pendingRecordStepsRef.current.catch(() => {}).then(() => bridge.scriptRecordingStop!(sid));
let stopPromise: Promise<ScriptRecordingResult>;
stopPromise = stopRequest.finally(() => {
if (stopPromiseRef.current === stopPromise) {
stopPromiseRef.current = null;
isStoppingRef.current = false;
isDrainingRecordingRef.current = false;
}
});
stopPromiseRef.current = stopPromise;
return stopPromise;
}, []);
const finishAutomaticStop = useCallback((detail: ScriptRecordingLimitDetail) => {
isDrainingRecordingRef.current = false;
isRecordingRef.current = false;
isPausedRef.current = false;
inputRevisionRef.current += 1;
inputBufferRef.current = '';
startedAtRef.current = null;
setIsRecording(false);
setIsPaused(false);
notify.error(detail.error, 'Scripts');
window.dispatchEvent(new CustomEvent(SCRIPT_RECORDING_LIMIT_EVENT, { detail }));
}, []);
const pauseRecording = useCallback(() => {
if (isStoppingRef.current) return;
isPausedRef.current = true;
setIsPaused(true);
}, []);
const resumeRecording = useCallback(() => {
if (isStoppingRef.current) return;
isPausedRef.current = false;
setIsPaused(false);
}, []);
const appendStep = useCallback(async (step: ScriptRecordingStep, accepted = false) => {
const sid = sessionIdRef.current;
if (!sid || (!(accepted && isDrainingRecordingRef.current)
&& (!isRecordingRef.current || (!accepted && isPausedRef.current) || isStoppingRef.current))) return;
const generation = recordingGenerationRef.current;
const result = await netcattyBridge.get()?.scriptRecordingAppendStep?.(sid, step);
if (generation !== recordingGenerationRef.current || sid !== sessionIdRef.current) return;
if (result?.stopped) {
finishAutomaticStop({
sessionId: sid,
error: result.error || 'Recording stopped because it reached the safety limit',
steps: result.steps ?? [],
code: result.code ?? '',
});
return;
}
lastStepAtRef.current = Date.now();
}, [finishAutomaticStop]);
const recordInput = useCallback((data: string) => {
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
const next = `${inputBufferRef.current}${data}`;
if (next.length <= MAX_PENDING_SCRIPT_RECORDING_INPUT_CHARS) {
inputBufferRef.current = next;
return;
}
const sid = sessionIdRef.current;
if (!sid) return;
isStoppingRef.current = true;
isRecordingRef.current = false;
isPausedRef.current = false;
inputRevisionRef.current += 1;
inputBufferRef.current = '';
startedAtRef.current = null;
setIsRecording(false);
setIsPaused(false);
const bridge = netcattyBridge.get();
let stopRequest: Promise<ScriptRecordingResult>;
try {
stopRequest = bridge?.scriptRecordingStop
? Promise.resolve(bridge.scriptRecordingStop(sid))
: Promise.resolve(emptyScriptRecordingResult());
} catch (error) {
stopRequest = Promise.reject(error);
}
const stopCompletion = stopRequest.catch(() => emptyScriptRecordingResult()).then((result) => {
finishAutomaticStop({
sessionId: sid,
error: 'Recording stopped because the current input exceeded the safety limit',
steps: result.steps,
code: result.code,
});
return result;
});
let stopPromise: Promise<ScriptRecordingResult>;
stopPromise = stopCompletion.finally(() => {
if (stopPromiseRef.current === stopPromise) {
stopPromiseRef.current = null;
isStoppingRef.current = false;
}
});
stopPromiseRef.current = stopPromise;
}, [finishAutomaticStop]);
const recordBackspace = useCallback(() => {
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
inputRevisionRef.current += 1;
inputBufferRef.current = inputBufferRef.current.slice(0, -1);
}, []);
const recordClearLine = useCallback(() => {
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return;
inputRevisionRef.current += 1;
inputBufferRef.current = '';
}, []);
const recordEnter = useCallback(async (options?: { sensitive?: boolean; submittedLine?: string }) => {
const sid = sessionIdRef.current;
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current || !sid) return;
const line = options?.submittedLine ?? inputBufferRef.current;
if (options?.submittedLine === undefined) {
inputRevisionRef.current += 1;
inputBufferRef.current = '';
}
const now = Date.now();
const gap = now - lastStepAtRef.current;
lastStepAtRef.current = now;
const generation = recordingGenerationRef.current;
const pending = pendingRecordStepsRef.current.catch(() => {}).then(async () => {
const isCurrent = () => generation === recordingGenerationRef.current && sid === sessionIdRef.current
&& (isRecordingRef.current || isDrainingRecordingRef.current);
if (!isCurrent()) return;
if (gap > 1000) await appendStep({ type: 'sleep', value: gap }, true);
if (!isCurrent()) return;
await appendStep({ type: 'send', value: line, sensitive: options?.sensitive }, true);
if (!isCurrent()) return;
await appendStep({ type: 'waitForPrompt', timeoutMs: DEFAULT_RECORDING_PROMPT_TIMEOUT_MS }, true);
});
pendingRecordStepsRef.current = pending;
await pending;
}, [appendStep]);
// Delayed paste receipts must not consume newly typed input or leak into a
// recording started after the paste. Each receipt records one actual write.
const captureSubmittedLineRecorder = useCallback(() => {
if (!isRecordingRef.current || isPausedRef.current || isStoppingRef.current) return undefined;
const generation = recordingGenerationRef.current;
const sid = sessionIdRef.current;
const pendingInput = inputBufferRef.current;
const revision = inputRevisionRef.current;
let consumed = false;
const isCurrent = () => generation === recordingGenerationRef.current
&& sid === sessionIdRef.current && isRecordingRef.current
&& !isPausedRef.current && !isStoppingRef.current;
return (line: string, options?: { sensitive?: boolean; includePendingInput?: boolean; consumePendingInput?: boolean }): Promise<void> => {
// Capturing a batch is not a write. Retain its prefix if no receipt
// arrives; only its prefixed first line can consume the still-owned draft.
if (options?.consumePendingInput !== false && !consumed && generation === recordingGenerationRef.current && sid === sessionIdRef.current
&& revision === inputRevisionRef.current && inputBufferRef.current.startsWith(pendingInput)) {
inputBufferRef.current = inputBufferRef.current.slice(pendingInput.length);
inputRevisionRef.current += 1;
}
if (options?.consumePendingInput !== false) consumed = true;
if (!isCurrent()) return Promise.resolve();
return recordEnter({ sensitive: options?.sensitive, submittedLine: options?.includePendingInput ? `${pendingInput}${line}` : line });
};
}, [recordEnter]);
return {
isRecording,
isPaused,
elapsedMs,
startRecording,
stopRecording,
pauseRecording,
resumeRecording,
recordInput,
recordBackspace,
recordClearLine,
recordEnter,
captureSubmittedLineRecorder,
};
}