Compare commits

...
Author SHA1 Message Date
陈大猫 7b11394e91 fix(terminal): preserve active SSH input mode in attached popups 2026-10-26 15:39:34 +00:00
10 changed files with 329 additions and 1 deletions

No files matched your search

+1
View File
@@ -335,6 +335,7 @@ export const useTerminalBackend = () => {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
},
+10
View File
@@ -158,6 +158,8 @@ import {
} from "./terminal/runtime/createTerminalSessionStarters";
import {
createXTermRuntime,
getTerminalPerCharacterInput,
restoreTerminalPerCharacterInput,
resetKittyKeyboardModeStateForSession,
type XTermRuntime,
} from "./terminal/runtime/createXTermRuntime";
@@ -1683,6 +1685,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
passwordPromptActiveRef.current,
knownCwdRef.current ?? null,
terminalTitleRef.current ?? null,
xtermRuntimeRef.current?.getPerCharacterInput()
?? getTerminalPerCharacterInput(terminalSettingsRef),
);
}));
}
@@ -1704,6 +1708,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
throw new Error("Terminal output did not settle before applying the snapshot");
}
}
if (typeof payload.perCharacterInput === "boolean") {
restoreTerminalPerCharacterInput(terminalSettingsRef, payload.perCharacterInput);
xtermRuntimeRef.current?.restorePerCharacterInput(payload.perCharacterInput);
}
if (typeof payload.kittyKeyboardProtocolEnabled === "boolean") {
xtermRuntimeRef.current?.setKittyKeyboardProtocolEnabled(
payload.kittyKeyboardProtocolEnabled,
@@ -1873,6 +1881,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snap,
{
...finalContext,
perCharacterInput: xtermRuntimeRef.current?.getPerCharacterInput()
?? getTerminalPerCharacterInput(terminalSettingsRef),
kittyKeyboardModeState: xtermRuntimeRef.current?.getKittyKeyboardModeState(),
kittyKeyboardProtocolEnabled:
xtermRuntimeRef.current?.getKittyKeyboardProtocolEnabled(),
@@ -256,6 +256,14 @@ const kittyKeyboardStates = createKittyKeyboardSessionStateStore();
// when hibernation destroys xterm. Only a real reconnect refreshes this choice.
const inputCompatibilityStates = new WeakMap<object, boolean>();
// The stable session owner survives xterm disposal while its home renderer sleeps.
export const getTerminalPerCharacterInput = (owner: object): boolean | undefined =>
inputCompatibilityStates.get(owner);
export const restoreTerminalPerCharacterInput = (owner: object, enabled: boolean): void => {
inputCompatibilityStates.set(owner, enabled);
};
const resolveKittyKeyboardModeState = (
ctx: Pick<
CreateXTermRuntimeContext,
@@ -312,6 +320,8 @@ export type XTermRuntime = {
hasInlineImages: () => boolean;
/** Reset keyboard state and snapshot endpoint settings for a fresh connection. */
resetConnectionInputState: (host: Host) => void;
getPerCharacterInput: () => boolean;
restorePerCharacterInput: (enabled: boolean) => void;
/** Emit any owed releases before detaching or closing this renderer. */
flushKittyKeyboardReleases: () => void;
/** Transfer negotiated keyboard state across renderer attach handoffs. */
@@ -3494,6 +3504,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
clearKittyConnectionInputState();
},
flushKittyKeyboardReleases: clearKittyTransientInputState,
getPerCharacterInput: () => usePerCharacterInput,
restorePerCharacterInput: (enabled) => {
usePerCharacterInput = enabled;
restoreTerminalPerCharacterInput(ctx.terminalSettingsRef, enabled);
},
getKittyKeyboardModeState: () => snapshotKittyKeyboardModeState(kittyKeyboardMode),
restoreKittyKeyboardModeState: (state) => restoreKittyKeyboardModeState(
kittyKeyboardMode,
@@ -0,0 +1,214 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
import { buildSync } from "esbuild";
import { JSDOM } from "jsdom";
import React, { useEffect } from "react";
import { act, create } from "react-test-renderer";
import ts from "typescript";
import type { Host } from "../../../domain/models";
import { resolveTerminalPopupHost } from "../../TerminalPopupPage";
import { useTerminalEffects } from "../useTerminalEffects";
import { isTerminalAlternateScreenActive, readTerminalHibernateContext, resolveTerminalSnapshotCapture } from "../terminalHibernateRuntime";
const root = fileURLToPath(new URL("../../..", import.meta.url));
const terminalSource = readFileSync(path.join(root, "components/Terminal.tsx"), "utf8");
const homeStart = terminalSource.indexOf(" useEffect(() => {", terminalSource.indexOf("// Home renderer for AI observe popups"));
const homeEnd = terminalSource.indexOf(" const cleanupSession =", homeStart);
const homeEffect = ts.transpileModule(terminalSource.slice(homeStart, homeEnd), {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const cleanupEnd = terminalSource.indexOf(" const cleanupSessionRef =", homeEnd);
const popupCleanup = ts.transpileModule(`${terminalSource.slice(homeEnd, cleanupEnd)}
globalThis.cleanup = cleanupSession;`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const noop = () => {};
const ref = <T,>(current: T) => ({ current });
// Actual popup host resolution, home snapshot callbacks, attach boot effect,
// and real xterm writes. Unrelated visual effects are omitted by the hook seam.
test("attached popups retain the active mode across snapshots and return, including hibernated homes", async (t) => {
const bundle = buildSync({
stdin: { contents: 'export * from "./components/terminal/runtime/createXTermRuntime"; export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal";', loader: "ts", resolveDir: root },
bundle: true, format: "iife", globalName: "runtimeTest", platform: "browser", write: false,
alias: {
"node:diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
"diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
},
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
const dom = new JSDOM("<body/>", { url: "http://localhost", runScripts: "outside-only", pretendToBeVisual: true });
const keys = ["window", "document", "navigator", "IS_REACT_ACT_ENVIRONMENT"];
const previous = new Map(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, navigator: dom.window.navigator, IS_REACT_ACT_ENVIRONMENT: true })) {
Object.defineProperty(globalThis, key, { configurable: true, value });
}
Object.assign(dom.window, { structuredClone });
dom.window.eval(`
window.matchMedia=()=>({matches:false,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){}});
window.ResizeObserver=window.IntersectionObserver=class {observe(){} unobserve(){} disconnect(){}};
HTMLCanvasElement.prototype.getContext=function(type){
if(type!=='2d')return null;
return new Proxy({canvas:this,measureText:()=>({width:8,fontBoundingBoxAscent:12,fontBoundingBoxDescent:3}),
getImageData:()=>({data:new Uint8ClampedArray(4)}),createLinearGradient:()=>({addColorStop(){}})},
{get:(target,key)=>key in target?target[key]:()=>{}});
};
`);
dom.window.eval(bundle);
const runtimeModule = dom.window.eval("runtimeTest");
try {
for (const mode of [false, true]) for (const hibernated of [false, true]) {
await t.test(`connected=${mode}, saved=${!mode}, home hibernated=${hibernated}`, async () => {
const sessionId = `popup-${mode}-${hibernated}`;
const connectedHost: Host = { id: "host", label: "Host", hostname: "127.0.0.1", port: 22379, username: "agent", protocol: "ssh" as const, tags: [], os: "linux" as const, sshInputCompatibility: mode };
const savedHost = { ...connectedHost, sshInputCompatibility: !mode };
const session = { id: sessionId, hostId: "host", hostLabel: "Host", hostname: "127.0.0.1", username: "agent", protocol: "ssh" as const, status: "connected" as const, createdAt: 1 };
const popupHost = resolveTerminalPopupHost({ title: "Host", startupCommand: "", parentSessionId: sessionId, sourceSession: session, attachSessionId: sessionId }, [savedHost]);
assert.equal(popupHost.sshInputCompatibility, !mode, "the popup sees the latest vault preference");
const settings = { ...runtimeModule.DEFAULT_TERMINAL_SETTINGS, rendererType: "dom", fontLigatures: false, cursorBlink: false };
const homeSettings = ref(settings);
const writes: string[] = [];
const makeContext = (host: Host, settingsRef = ref(settings)) => ({
container: document.body.appendChild(document.createElement("div")), host,
fontFamilyId: "jetbrains-mono", resolvedFontFamily: "monospace", fontSize: 14,
terminalTheme: { colors: { background: "#111111", foreground: "#eeeeee", cursor: "#ffffff", selection: "#444444" } },
terminalSettingsRef: settingsRef,
terminalBackend: { writeToSession: (_id: string, data: string) => writes.push(data), resizeSession: noop, openExternalAvailable: false },
sessionRef: ref<string | null>(sessionId), sessionId, statusRef: ref("connected"),
hotkeySchemeRef: ref("disabled"), disableTerminalFontZoomRef: ref(false), keyBindingsRef: ref([]),
onHotkeyActionRef: ref(undefined), isBroadcastEnabledRef: ref(false), onBroadcastInputRef: ref(undefined),
commandBufferRef: ref(""), requestSearchFocus: noop, kittyKeyboardProtocolEnabled: false,
});
const homeContext = makeContext(connectedHost, homeSettings);
const homeRuntime = runtimeModule.createXTermRuntime(homeContext);
const homeRuntimeRef = ref(hibernated ? null : homeRuntime);
const homeTermRef = ref(hibernated ? null : homeRuntime.term);
if (hibernated) homeRuntime.dispose();
let snapshotRequest: (payload: object) => Promise<void>;
let applySnapshot: (payload: object) => Promise<boolean>;
let snapshot: Record<string, unknown> = {};
const bridge = {
onTerminalSessionSnapshotRequest(callback: typeof snapshotRequest) { snapshotRequest = callback; return noop; },
onTerminalSessionApplySnapshot(callback: typeof applySnapshot) { applySnapshot = callback; return noop; },
respondTerminalSessionSnapshot(_request: string, text: string, keyboard: unknown, enabled: boolean, sensitive: boolean, cwd: string, title: string, perCharacterInput: boolean) {
snapshot = { success: true, snapshot: text, kittyKeyboardModeState: keyboard, kittyKeyboardProtocolEnabled: enabled, passwordPromptActive: sensitive, cwd, title, perCharacterInput };
},
};
const homeEnv = {
useEffect: (effect: () => void) => effect(), netcattyBridge: { get: () => bridge },
attachExistingSession: false, sessionId, terminalSettingsRef: homeSettings,
xtermRuntimeRef: homeRuntimeRef, termRef: homeTermRef,
serializeAddonRef: ref(hibernated ? null : homeRuntime.serializeAddon),
hibernatedRef: ref(hibernated), softHiddenRef: ref(false),
hibernateSnapshotRef: ref(""), hibernatePendingBufferRef: ref(""), hibernateViewportSnapshotRef: ref(""), hibernateScrollbackSnapshotRef: ref(""),
hibernateContextSnapshotRef: ref(""), hibernateContextViewportSnapshotRef: ref(""), hibernateContextScrollbackSnapshotRef: ref(""), hibernateAlternateScreenRef: ref(false),
kittyKeyboardProtocolEnabledForSession: false, passwordPromptActiveRef: ref(false), knownCwdRef: ref(null), terminalTitleRef: ref(null), pluginTerminalLifecycleRef: ref(null),
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, terminalCwdTracker: { setRendererCwd: noop },
flushPendingTerminalWritesBeforeHibernate: async () => true, resetTerminalLineTimestamps: noop,
applyAuthoritativeHibernateSnapshot: noop, logger: { warn: noop },
getTerminalPerCharacterInput: runtimeModule.getTerminalPerCharacterInput,
restoreTerminalPerCharacterInput: runtimeModule.restoreTerminalPerCharacterInput,
};
vm.runInNewContext(homeEffect, homeEnv);
const popupContext = makeContext(popupHost);
popupContext.sessionRef.current = null;
popupContext.statusRef.current = "connecting";
const values = {
...popupContext, host: popupHost, terminalSettings: settings,
containerRef: ref(popupContext.container), xtermRuntimeRef: ref(null), termRef: ref(null), fitAddonRef: ref(null), serializeAddonRef: ref(null), searchAddonRef: ref(null), hasRuntimeRef: ref(false),
attachExistingSession: true, attachAuthorization: "owned-popup", vaultInitialized: true,
CONNECTION_TIMEOUT: 30000, Error, XTERM_PERFORMANCE_CONFIG: {}, effectiveTheme: popupContext.terminalTheme,
effectiveFontSize: 14, effectiveFontWeight: 400, fontWeightFixupDoneRef: ref(false),
isVisible: true, isVisibleRef: ref(true), isFocused: true, inWorkspace: true, isLocalConnection: false, isNetworkDevice: false, isSerialConnection: false,
pluginDecorationRules: [], pluginTerminalLifecycle: new Proxy({}, { get: () => noop }),
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, keys: [], identities: [], serialConfig: {}, zmodem: {},
connectionLogBufferRef: ref({ reset: noop }), terminalOutputHistory: { clear: noop }, terminalDataCapturedRef: ref(false), hasConnectedRef: ref(false), pendingOutputScrollRef: ref(false), promptLineBreakStateRef: ref(null), terminalLogSanitizerRef: ref(null),
kittyKeyboardProtocolEnabledForSession: false, terminalCwdTracker: { getRendererCwd: () => null, setRendererCwd: noop }, passwordPromptActiveRef: ref(false),
createPromptLineBreakState: () => ({}), createReplaySafeTerminalLogSanitizer: () => ({}),
prepareInitialCwdIntent: noop, prepareRestoredReconnect: noop,
applyUserCursorPreference: noop, applyUserCursorWidthPreference: noop, shouldApplyUserCursorPreference: () => false,
terminalAltKeyOptions: () => ({}), shouldStartTerminalBackend: () => true,
createXTermRuntime: runtimeModule.createXTermRuntime,
terminalBackend: {
...popupContext.terminalBackend,
acquireSessionFlowPauseLease: async () => ({ waitForPause: async () => ({ success: true }), release: noop }),
requestSessionSnapshot: async () => { await snapshotRequest!({ sessionId, requestId: "request" }); return snapshot; },
rebindSessionOutput: async () => ({ success: true, previousWebContentsId: 10 }),
restoreSessionOutput: async () => ({ success: true }),
},
sessionStarters: { reattachSession: (term: { paste: (data: string) => void }) => { term.paste("中文"); return true; } },
setStatus: noop, setProgressLogs: noop, setShowLogs: noop, setIsCancelling: noop, setIsDisconnectedDialogDismissed: noop,
setError: (error: unknown) => { if (error) errors.push(error); },
updateStatus: (status: string) => { popupContext.statusRef.current = status; },
safeFit: noop, logger: { warn: noop, error: (...args: unknown[]) => errors.push(args) },
onPluginRuntimeCwdChange: noop, onOpenExternalError: noop, requestSearchFocus: noop,
requestPluginTerminalProviders: undefined, isPluginTerminalProviderAvailable: () => false,
useEffect: (effect: React.EffectCallback, dependencies: React.DependencyList) => {
useEffect(() => {
if (effect.toString().includes("bootAbort")) return effect();
// The injected hook owns this dependency list; unrelated effects stay inert.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
},
useLayoutEffect: noop,
};
const errors: unknown[] = [];
const defaultRefs = new Map<PropertyKey, ReturnType<typeof ref>>();
const context = new Proxy(values, { get: (target, key) => {
if (key in target) return target[key as keyof typeof target];
if (!defaultRefs.has(key)) defaultRefs.set(key, ref(null));
return defaultRefs.get(key);
} });
const Harness = () => { useTerminalEffects(context); return null; };
let renderer: ReturnType<typeof create>;
await act(async () => { renderer = create(React.createElement(Harness)); });
for (let attempt = 0; popupContext.statusRef.current !== "connected" && attempt < 30 && !errors.length; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
try {
assert.deepEqual(errors, [], "actual attach boot succeeded");
assert.equal(popupContext.sessionRef.current, sessionId, "attach retains the original SSH backend");
assert.deepEqual(writes.splice(0), mode ? ["中", "文"] : ["中文"], "first attached input must retain the connection mode");
assert.equal(snapshot.perCharacterInput, mode, "home snapshot exports captured mode, including false and no live xterm");
const popupRuntime = values.xtermRuntimeRef.current as typeof homeRuntime;
assert.equal(popupRuntime.getPerCharacterInput(), mode);
const cleanupEnv = {
...homeEnv, attachExistingSession: true, attachAuthorization: "owned-popup",
sessionRef: popupContext.sessionRef, xtermRuntimeRef: values.xtermRuntimeRef,
terminalSettingsRef: popupContext.terminalSettingsRef, termRef: values.termRef,
serializeAddonRef: values.serializeAddonRef, attachHomeWebContentsIdRef: ref(10),
disposeDataRef: ref(null), disposeExitRef: ref(null), disposeTelnetEchoModeRef: ref(null),
telnetLocalEchoRef: ref(false), sessionCleanupPromiseRef: ref(null),
isTerminalAlternateScreenActive, readTerminalHibernateContext, resolveTerminalSnapshotCapture,
releaseTerminalFlowBeforeHibernate: noop,
terminalBackend: {
...values.terminalBackend,
applySessionSnapshot: async (_id: string, text: string, context: { perCharacterInput?: boolean }) => {
assert.equal(context.perCharacterInput, mode, "actual close exports the popup's active choice");
return { success: await applySnapshot!({ sessionId, snapshot: text, ...context }) };
},
},
};
vm.runInNewContext(popupCleanup, cleanupEnv);
await (cleanupEnv as typeof cleanupEnv & { cleanup: () => Promise<void> }).cleanup();
assert.equal(runtimeModule.getTerminalPerCharacterInput(homeSettings), mode, "actual popup close retains the home captured state");
const restoredHome = hibernated ? runtimeModule.createXTermRuntime({ ...homeContext, host: savedHost }) : homeRuntime;
restoredHome.term.paste("中文");
assert.deepEqual(writes.splice(0), mode ? ["中", "文"] : ["中文"], "home input after return and wake retains the active choice");
if (hibernated) restoredHome.dispose();
} finally {
values.hasConnectedRef.current = false;
await act(async () => { renderer!.unmount(); });
homeRuntime.dispose();
}
});
}
} finally {
dom.window.close();
for (const key of keys) { const descriptor = previous.get(key); if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -716,6 +716,11 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (!snap?.success) {
throw new Error(snap?.error || "Failed to capture terminal snapshot");
}
// Restore the active connection policy before the attached session
// accepts input. Saved host changes still require a reconnect.
if (typeof snap.perCharacterInput === "boolean") {
runtime.restorePerCharacterInput(snap.perCharacterInput);
}
if (typeof snap.kittyKeyboardProtocolEnabled === "boolean") {
runtime.setKittyKeyboardProtocolEnabled(snap.kittyKeyboardProtocolEnabled);
}
+5 -1
View File
@@ -19,6 +19,8 @@ New host creation paths save `false`. Duplicates copy the source value. JSON vau
The connection captures the host's setting under its session-owned terminal settings ref. This snapshot survives xterm disposal and the actual hibernate wake/reattach path. Reconnect refreshes the setting through `resetConnectionInputState(host)`, including reconnects that reuse the existing runtime.
Attaching an observe popup transfers this captured choice through the existing terminal snapshot IPC. The popup restores it before attaching input to the live backend. Closing the popup returns it with the display snapshot. A hibernated home reads and restores the same session-owned state without consulting the current Vault preference. These handoffs do not reconnect or apply a newly saved choice.
## Historical evidence
[Issue #3077](https://github.com/binaricat/Netcatty/issues/3077#issuecomment-5433744875) reports that individual hanzi worked in a Qianxin search prompt where multi-character input failed. The report also says Windows with Xshell worked. Those observations do not establish the remote parser's SSH-write semantics or Xshell's default implementation.
@@ -29,6 +31,8 @@ The [Xshell 7 manual](https://cdn.netsarang.net/docs/Xshell7_manual.pdf), sectio
`sshInputCompatibility.integration.test.ts` runs real xterm and `createXTermRuntime` callbacks under JSDOM. It covers raw paste, literal `insertText`, key code 229, composition events, composition fallback, long IME text, terminal controls, bracketed paste, Kitty text, reconnect with a reused runtime, and the actual `wakeTerminalFromHibernate` entry after a saved setting changes. Local, Telnet, serial, Mosh, and Eternal Terminal cases verify that the SSH field leaves their legacy text and control-containing payload behavior unchanged. Canvas measurement is stubbed. These tests do not exercise native input methods or application windows.
`sshInputCompatibility.popup.test.ts` runs the actual popup host resolver, home snapshot callbacks, attach boot effect, popup close callback, and real xterm paste handlers. Both saved-setting directions are covered with a live or hibernated home, including input after returning and waking. `terminalBridge.rebindOutput.test.cjs` exercises the preload and authorized main-process snapshot round trip, preserving explicit `false` and rejecting invalid metadata. These automated popup checks do not constitute native multi-window or OS IME validation.
`useVaultState.inputCompatibility.test.ts` exercises the actual vault hook's asynchronous save, reload, duplicate, JSON backup, local backup payload, and cloud payload apply paths. It uses test localStorage and no native safeStorage key. CSV and endpoint selection have separate round-trip and jump-host tests.
## Isolated OpenSSH timing fixture
@@ -76,4 +80,4 @@ Independent review confirmed the three reported P2 findings resolved, with no ne
## CI result
The `test` workflow completed successfully for documentation commit `38c5f4744cb5fc3ecdd4e624b323587a471e4333`: [run 40319492139](https://github.com/binaricat/Netcatty/actions/runs/40319492139). That workflow includes `npm run lint`, `npm test`, generated-spec checks, and `npm run build`. The production code is unchanged from the native-tested `bd5f27ff` commit. Subsequent acceptance-text edits change documentation only. The PR remains draft and unmerged.
The `test` workflow completed successfully for documentation commit `38c5f4744cb5fc3ecdd4e624b323587a471e4333`: [run 40319492139](https://github.com/binaricat/Netcatty/actions/runs/40319492139). That workflow includes `npm run lint`, `npm test`, generated-spec checks, and `npm run build`. The production code is unchanged from the native-tested `bd5f27ff` commit. Subsequent acceptance-text edits change documentation only. The original feature was merged in [PR #3977](https://github.com/binaricat/Netcatty/pull/3977); those results predate the popup snapshot follow-up.
+7
View File
@@ -257,6 +257,9 @@ function handleTerminalSessionSnapshotResponse(event, payload) {
pending.resolve({
success: true,
snapshot: typeof payload?.snapshot === "string" ? payload.snapshot : "",
perCharacterInput: typeof payload?.perCharacterInput === "boolean"
? payload.perCharacterInput
: undefined,
kittyKeyboardModeState: normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState),
kittyKeyboardProtocolEnabled: typeof payload?.kittyKeyboardProtocolEnabled === "boolean"
? payload.kittyKeyboardProtocolEnabled
@@ -316,6 +319,9 @@ function applyTerminalSessionSnapshot(event, payload, terminalWorkerManager = nu
const contextScrollbackSnapshot = hasContextScrollbackSnapshot ? payload.contextScrollbackSnapshot : "";
const hasAlternateScreen = typeof payload?.alternateScreen === "boolean";
const alternateScreen = payload?.alternateScreen === true;
const perCharacterInput = typeof payload?.perCharacterInput === "boolean"
? payload.perCharacterInput
: undefined;
const kittyKeyboardModeState = normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState);
const kittyKeyboardProtocolEnabled = typeof payload?.kittyKeyboardProtocolEnabled === "boolean"
? payload.kittyKeyboardProtocolEnabled
@@ -368,6 +374,7 @@ function applyTerminalSessionSnapshot(event, payload, terminalWorkerManager = nu
contextViewportSnapshot,
contextScrollbackSnapshot,
alternateScreen,
perCharacterInput,
kittyKeyboardModeState,
kittyKeyboardProtocolEnabled,
passwordPromptActive,
@@ -667,3 +667,65 @@ test("attach snapshots preserve cwd and title updates including explicit clears"
/payload\.cwd !== undefined[\s\S]*?setRendererCwd\(payload\.cwd,\s*"snapshot"\)[\s\S]*?payload\.title !== undefined/,
);
});
test("active input policy roundtrips through preload and authorized snapshot IPC", async () => {
const { EventEmitter } = require("node:events");
const { createPreloadApi } = require("../preload/api.cjs");
const { bridge, fakeChannel } = loadTerminalBridgeWithMocks();
const handles = new Map();
const listeners = new Map();
const homeIpc = new EventEmitter();
const home = { id: 71, isDestroyed: () => false, send: (channel, payload) => homeIpc.emit(channel, {}, payload) };
const popup = { id: 72, isDestroyed: () => false };
const sessionId = "input-policy-snapshot";
const authorization = "input-policy-popup";
bridge.init({
sessions: new Map([[sessionId, { webContentsId: home.id }]]),
electronModule: { webContents: { fromId: (id) => id === home.id ? home : popup } },
terminalOutputChannel: fakeChannel,
});
bridge.registerHandlers({
handle: (channel, handler) => handles.set(channel, handler),
on: (channel, handler) => listeners.set(channel, handler),
}, { terminalWorkerManager: { getAttachHomeWebContentsId: () => home.id } });
const registry = require("./terminalAttachRestore.cjs");
registry.registerAttachPopupAuthorization(authorization, sessionId, popup.id);
homeIpc.send = (channel, payload) => listeners.get(channel)({ sender: home }, payload);
const homeApi = createPreloadApi({ ipcRenderer: homeIpc, webUtils: {} });
const invoke = (channel, payload) => {
const originalRequire = Module.prototype.require;
Module.prototype.require = function(request) {
if (request === "./windowManager.cjs") return { getMainWindows: () => [{ webContents: home }] };
return originalRequire.apply(this, arguments);
};
try { return handles.get(channel)({ sender: popup }, payload); }
finally { Module.prototype.require = originalRequire; }
};
const popupApi = createPreloadApi({ ipcRenderer: { invoke }, webUtils: {} });
let mode;
let received;
const unsubscribeRequest = homeApi.onTerminalSessionSnapshotRequest(({ requestId }) => {
homeApi.respondTerminalSessionSnapshot(requestId, "screen", undefined, false, false, null, null, mode);
});
const unsubscribeApply = homeApi.onTerminalSessionApplySnapshot((payload) => { received = payload; return true; });
try {
for (mode of [false, true, undefined, "false"]) {
const expected = typeof mode === "boolean" ? mode : undefined;
const snapshot = await popupApi.requestTerminalSessionSnapshot(sessionId, authorization);
assert.equal(snapshot.success, true);
assert.equal(snapshot.perCharacterInput, expected, "snapshot keeps explicit false and drops invalid metadata");
const applied = await popupApi.applyTerminalSessionSnapshot(sessionId, "screen", {
contextSnapshot: "", contextViewportSnapshot: "", contextScrollbackSnapshot: "", alternateScreen: false,
perCharacterInput: mode,
}, authorization);
assert.equal(applied.success, true);
assert.equal(received.perCharacterInput, expected, "return snapshot carries the same active choice");
}
assert.equal((await popupApi.requestTerminalSessionSnapshot(sessionId, "wrong-grant")).success, false);
assert.equal((await popupApi.applyTerminalSessionSnapshot(sessionId, "", { perCharacterInput: false }, "wrong-grant")).success, false);
} finally {
unsubscribeRequest();
unsubscribeApply();
registry.releaseAttachPopupAuthorization(authorization);
}
});
+5
View File
@@ -611,10 +611,12 @@ function createPreloadApi(ctx) {
passwordPromptActive,
cwd,
title,
perCharacterInput,
) => {
ipcRenderer.send("netcatty:terminal:snapshot-response", {
requestId,
snapshot: typeof snapshot === "string" ? snapshot : "",
perCharacterInput: typeof perCharacterInput === "boolean" ? perCharacterInput : undefined,
kittyKeyboardModeState: sanitizeKittyKeyboardModeState(kittyKeyboardModeState),
kittyKeyboardProtocolEnabled: typeof kittyKeyboardProtocolEnabled === "boolean"
? kittyKeyboardProtocolEnabled
@@ -638,6 +640,9 @@ function createPreloadApi(ctx) {
? context.contextScrollbackSnapshot
: "",
alternateScreen: context?.alternateScreen === true,
perCharacterInput: typeof context?.perCharacterInput === "boolean"
? context.perCharacterInput
: undefined,
kittyKeyboardModeState: sanitizeKittyKeyboardModeState(context?.kittyKeyboardModeState),
kittyKeyboardProtocolEnabled: typeof context?.kittyKeyboardProtocolEnabled === "boolean"
? context.kittyKeyboardProtocolEnabled
+5
View File
@@ -428,6 +428,8 @@ declare global {
requestTerminalSessionSnapshot?(sessionId: string, authorization: string): Promise<{
success: boolean;
snapshot?: string;
/** Captured input policy for the existing backend connection. */
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
passwordPromptActive?: boolean;
@@ -448,6 +450,7 @@ declare global {
passwordPromptActive?: boolean,
cwd?: string | null,
title?: string | null,
perCharacterInput?: boolean,
): void;
/** Observe popup: push current state back to the home renderer before restore. */
applyTerminalSessionSnapshot?(
@@ -458,6 +461,7 @@ declare global {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
passwordPromptActive?: boolean;
@@ -480,6 +484,7 @@ declare global {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
passwordPromptActive?: boolean;