diff --git a/packages/app/src/web/api-auth-schema.ts b/packages/app/src/web/api-auth-schema.ts index 5ecf2e64..1a60f80c 100644 --- a/packages/app/src/web/api-auth-schema.ts +++ b/packages/app/src/web/api-auth-schema.ts @@ -28,8 +28,8 @@ const AuthProviderSnapshotFields = { claudeAuthPath: Schema.String, geminiAuthEntries: Schema.Number, geminiAuthPath: Schema.String, - grokAuthEntries: Schema.Number, - grokAuthPath: Schema.String, + grokAuthEntries: Schema.optionalWith(Schema.Number, { default: () => 0 }), + grokAuthPath: Schema.optionalWith(Schema.String, { default: () => "" }), githubTokenEntries: Schema.Number, gitTokenEntries: Schema.Number } diff --git a/packages/app/src/web/terminal-copy-interaction.ts b/packages/app/src/web/terminal-copy-interaction.ts new file mode 100644 index 00000000..4899acf0 --- /dev/null +++ b/packages/app/src/web/terminal-copy-interaction.ts @@ -0,0 +1,136 @@ +export type TerminalMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10" + +type TerminalSelectionTarget = { + readonly getSelection: () => string + readonly hasSelection: () => boolean +} + +export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { + readonly modes: { + readonly mouseTrackingMode: TerminalMouseTrackingMode + } +} + +type TerminalMouseButtonEvent = { + readonly button: number +} + +type TerminalSelectionModifierEvent = { + readonly altKey: boolean + readonly shiftKey: boolean +} + +type TerminalCopyClipboardData = { + readonly setData: (format: string, data: string) => void +} + +type TerminalCopyClipboardEvent = { + readonly clipboardData: TerminalCopyClipboardData | null + readonly preventDefault: () => void + readonly stopPropagation: () => void +} + +type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent + +type TerminalCopyInteractionHost = { + readonly addEventListener: { + (type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void + (type: "mousedown", listener: (event: TerminalCopyMouseEvent) => void, options: true): void + } + readonly removeEventListener: { + (type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void + (type: "mousedown", listener: (event: TerminalCopyMouseEvent) => void, options: true): void + } +} + +type TerminalCopyInteractionArgs = { + readonly host: TerminalCopyInteractionHost + readonly terminal: TerminalCopyInteractionTerminal +} + +const primaryMouseButton = 0 +const secondaryMouseButton = 2 + +const macPlatformNames = new Set(["Mac68K", "MacIntel", "Macintosh", "MacPPC"]) + +const currentNavigatorPlatform = (): string => { + if (typeof navigator === "undefined") { + return "" + } + return navigator.platform +} + +const isPrimaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === primaryMouseButton + +const isSecondaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === secondaryMouseButton + +const hasActiveMouseTracking = (terminal: TerminalCopyInteractionTerminal): boolean => + terminal.modes.mouseTrackingMode !== "none" + +export const shouldForceBrowserTerminalSelection = ( + event: TerminalMouseButtonEvent, + terminal: TerminalCopyInteractionTerminal +): boolean => isPrimaryMouseButton(event) && hasActiveMouseTracking(terminal) + +export const shouldForceTerminalSelectionContext = ( + event: TerminalMouseButtonEvent, + terminal: TerminalCopyInteractionTerminal +): boolean => isSecondaryMouseButton(event) && terminal.hasSelection() + +const terminalSelectionModifier = (platform: string): keyof TerminalSelectionModifierEvent => + macPlatformNames.has(platform) ? "altKey" : "shiftKey" + +export const forceTerminalSelectionModifier = ( + event: TerminalSelectionModifierEvent, + platform: string = currentNavigatorPlatform() +): boolean => + Reflect.defineProperty(event, terminalSelectionModifier(platform), { + configurable: true, + value: true + }) + +export const writeTerminalSelectionToClipboardData = ( + terminal: TerminalSelectionTarget, + clipboardData: TerminalCopyClipboardData | null +): boolean => { + if (clipboardData === null || !terminal.hasSelection()) { + return false + } + const selection = terminal.getSelection() + if (selection.length === 0) { + return false + } + clipboardData.setData("text/plain", selection) + return true +} + +export const attachTerminalCopyInteraction = ( + args: TerminalCopyInteractionArgs +): { readonly dispose: () => void } => { + const onMouseDown = (event: TerminalCopyMouseEvent): void => { + if ( + !shouldForceBrowserTerminalSelection(event, args.terminal) && + !shouldForceTerminalSelectionContext(event, args.terminal) + ) { + return + } + forceTerminalSelectionModifier(event) + } + const onCopy = (event: TerminalCopyClipboardEvent): void => { + if (!writeTerminalSelectionToClipboardData(args.terminal, event.clipboardData)) { + return + } + event.preventDefault() + event.stopPropagation() + } + + args.host.addEventListener("mousedown", onMouseDown, true) + args.host.addEventListener("copy", onCopy, true) + + return { + dispose: () => { + args.host.removeEventListener("mousedown", onMouseDown, true) + args.host.removeEventListener("copy", onCopy, true) + } + } +} diff --git a/packages/app/src/web/terminal-panel-input.ts b/packages/app/src/web/terminal-panel-input.ts new file mode 100644 index 00000000..4fade175 --- /dev/null +++ b/packages/app/src/web/terminal-panel-input.ts @@ -0,0 +1,60 @@ +import type { TerminalPasteGuard } from "./terminal-panel-runtime-types.js" + +export type TerminalClientMessage = + | { readonly data: string; readonly type: "input" } + | { readonly cols: number; readonly rows: number; readonly type: "resize" } + +type TerminalClientSocket = { + readonly readyState: number + readonly send: (data: string) => void +} + +export type TerminalClientSocketRef = { readonly current: TerminalClientSocket | null } + +type TerminalInputTarget = { + readonly onData: (handler: (data: string) => void) => { readonly dispose: () => void } + readonly scrollToBottom: () => void +} + +const csiPrefix = "\u001B[" +const x10MouseReportPrefix = `${csiPrefix}M` +const x10MouseReportLength = 6 +const sgrMouseReportBodyPattern = /^<\d+;\d+;\d+[Mm]$/u +const urxvtMouseReportBodyPattern = /^\d+;\d+;\d+M$/u + +export const isTerminalMouseReportInput = (data: string): boolean => { + if (data.startsWith(x10MouseReportPrefix)) { + return data.length === x10MouseReportLength + } + if (!data.startsWith(csiPrefix)) { + return false + } + const body = data.slice(csiPrefix.length) + return sgrMouseReportBodyPattern.test(body) || urxvtMouseReportBodyPattern.test(body) +} + +export const sendTerminalClientMessage = ( + socketRef: TerminalClientSocketRef, + message: TerminalClientMessage +): void => { + const socket = socketRef.current + if (socket === null || socket.readyState !== WebSocket.OPEN) { + return + } + socket.send(JSON.stringify(message)) +} + +export const attachTerminalInput = ( + terminal: TerminalInputTarget, + socketRef: TerminalClientSocketRef, + pasteGuard: TerminalPasteGuard +) => + terminal.onData((data) => { + if (pasteGuard.shouldSuppressTerminalInput(data)) { + return + } + if (!isTerminalMouseReportInput(data)) { + terminal.scrollToBottom() + } + sendTerminalClientMessage(socketRef, { data, type: "input" }) + }) diff --git a/packages/app/src/web/terminal-panel-runtime-core.ts b/packages/app/src/web/terminal-panel-runtime-core.ts index 345a7346..88741103 100644 --- a/packages/app/src/web/terminal-panel-runtime-core.ts +++ b/packages/app/src/web/terminal-panel-runtime-core.ts @@ -18,13 +18,13 @@ import { unavailableTerminalInlineImageEntry } from "./terminal-inline-images.js" import type { TerminalInlineImageEntry } from "./terminal-inline-images.js" +import { sendTerminalClientMessage } from "./terminal-panel-input.js" import type { TerminalCleanupArgs, TerminalExitInfo, TerminalInputController, TerminalLifecycleState, TerminalMessageHandlers, - TerminalPasteGuard, TerminalRuntime, TerminalSocketConnectArgs, TerminalSocketListenerArgs, @@ -34,9 +34,7 @@ import { installTerminalQuerySuppression, type TerminalQuerySuppressionOptions } import { resolveTerminalReconnectDelay, terminalReconnectGraceMs } from "./terminal-reconnect.js" import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "./terminal.js" -type TerminalClientMessage = - | { readonly data: string; readonly type: "input" } - | { readonly cols: number; readonly rows: number; readonly type: "resize" } +export { attachTerminalInput, isTerminalMouseReportInput } from "./terminal-panel-input.js" type TerminalRuntimeOptions = { readonly querySuppression?: TerminalQuerySuppressionOptions @@ -90,7 +88,9 @@ export const createTerminalRuntime = ( cursorBlink: true, fontFamily: "'IBM Plex Mono', 'SFMono-Regular', monospace", fontSize: 14, + macOptionClickForcesSelection: true, scrollback: 50_000, + scrollOnUserInput: false, theme: { background: "#080a0d", foreground: "#f4f7fb" } }) installTerminalQuerySuppression(terminal, options.querySuppression) @@ -122,17 +122,6 @@ const createTerminalSocket = ( terminal: Terminal ): WebSocket => new WebSocket(resolveTerminalWebSocketUrl(session.websocketPath, terminal.cols, terminal.rows)) -const sendTerminalClientMessage = ( - socketRef: TerminalSocketRef, - message: TerminalClientMessage -): void => { - const socket = socketRef.current - if (socket === null || socket.readyState !== WebSocket.OPEN) { - return - } - socket.send(JSON.stringify(message)) -} - export const sendTerminalResize = ( fitAddon: FitAddon, socketRef: TerminalSocketRef, @@ -164,18 +153,6 @@ export const observeTerminalResize = ( return resizeObserver } -export const attachTerminalInput = ( - terminal: Terminal, - socketRef: TerminalSocketRef, - pasteGuard: TerminalPasteGuard -) => - terminal.onData((data) => { - if (pasteGuard.shouldSuppressTerminalInput(data)) { - return - } - sendTerminalClientMessage(socketRef, { data, type: "input" }) - }) - const notifyTerminalReady = ( handlers: TerminalMessageHandlers ): void => { diff --git a/packages/app/src/web/terminal-panel-runtime.ts b/packages/app/src/web/terminal-panel-runtime.ts index 09c46828..7c24282f 100644 --- a/packages/app/src/web/terminal-panel-runtime.ts +++ b/packages/app/src/web/terminal-panel-runtime.ts @@ -1,5 +1,6 @@ import { useEffect } from "react" +import { attachTerminalCopyInteraction } from "./terminal-copy-interaction.js" import { attachTerminalImagePaste, createTerminalPasteGuard } from "./terminal-image-paste.js" import { attachTerminalImageLinks } from "./terminal-inline-images.js" import { @@ -20,21 +21,34 @@ import type { TerminalSocketConnectArgs, TerminalSocketRef } from "./terminal-panel-runtime-types.js" +import { attachTerminalWheelScroll } from "./terminal-wheel-scroll.js" import { isPendingActiveTerminalSession } from "./terminal.js" +type TerminalDisposable = { readonly dispose: () => void } + type TerminalCleanupFactoryArgs = { readonly cleanupArgs: Omit< Parameters[0], "removeImageLinks" | "removeImagePaste" | "removeInput" | "removeResize" > - readonly imageLinkDisposable: { readonly dispose: () => void } - readonly imagePasteDisposable: { readonly dispose: () => void } - readonly inputDisposable: { readonly dispose: () => void } + readonly copyInteractionDisposable: TerminalDisposable + readonly imageLinkDisposable: TerminalDisposable + readonly imagePasteDisposable: TerminalDisposable + readonly inputDisposable: TerminalDisposable + readonly wheelScrollDisposable: TerminalDisposable readonly sendResize: () => void } const createTerminalCleanup = ( - { cleanupArgs, imageLinkDisposable, imagePasteDisposable, inputDisposable, sendResize }: TerminalCleanupFactoryArgs + { + cleanupArgs, + copyInteractionDisposable, + imageLinkDisposable, + imagePasteDisposable, + inputDisposable, + sendResize, + wheelScrollDisposable + }: TerminalCleanupFactoryArgs ): () => void => (): void => { cleanupTerminalResources({ @@ -46,7 +60,9 @@ const createTerminalCleanup = ( imagePasteDisposable.dispose() }, removeInput: () => { + copyInteractionDisposable.dispose() inputDisposable.dispose() + wheelScrollDisposable.dispose() }, removeResize: () => { globalThis.removeEventListener("resize", sendResize) @@ -87,9 +103,11 @@ const createTerminalMessageHandlers = ( }) type MountedTerminalDisposables = { - readonly imageLinkDisposable: { readonly dispose: () => void } - readonly imagePasteDisposable: { readonly dispose: () => void } - readonly inputDisposable: { readonly dispose: () => void } + readonly copyInteractionDisposable: TerminalDisposable + readonly imageLinkDisposable: TerminalDisposable + readonly imagePasteDisposable: TerminalDisposable + readonly inputDisposable: TerminalDisposable + readonly wheelScrollDisposable: TerminalDisposable } type MountedTerminalCleanupArgs = { @@ -109,6 +127,7 @@ const createMountedTerminalDisposables = ( socketRef: TerminalSocketRef, terminal: TerminalMessageHandlers["terminal"] ): MountedTerminalDisposables => ({ + copyInteractionDisposable: attachTerminalCopyInteraction({ host, terminal }), imageLinkDisposable: attachTerminalImageLinks(terminal, args.session), imagePasteDisposable: attachTerminalImagePaste({ host, @@ -117,7 +136,8 @@ const createMountedTerminalDisposables = ( socketRef, terminal }), - inputDisposable: attachTerminalInput(terminal, socketRef, pasteGuard) + inputDisposable: attachTerminalInput(terminal, socketRef, pasteGuard), + wheelScrollDisposable: attachTerminalWheelScroll({ host, terminal }) }) const createMountedTerminalConnector = ( @@ -153,10 +173,12 @@ const createMountedTerminalCleanup = ( socketRef, terminal }, + copyInteractionDisposable: disposables.copyInteractionDisposable, imageLinkDisposable: disposables.imageLinkDisposable, imagePasteDisposable: disposables.imagePasteDisposable, inputDisposable: disposables.inputDisposable, - sendResize + sendResize, + wheelScrollDisposable: disposables.wheelScrollDisposable }) const resolveMountHost = ( @@ -171,6 +193,9 @@ const resolveMountHost = ( const shouldAllowTerminalMouseTracking = (session: TerminalLifecycleArgs["session"]): boolean => session.browserProjectId !== undefined +const shouldSuppressTerminalAlternateScreen = (session: TerminalLifecycleArgs["session"]): boolean => + session.browserProjectId !== undefined + const mountTerminalSession = (args: TerminalLifecycleArgs): (() => void) | undefined => { const host = resolveMountHost(args) if (host === null) { @@ -182,7 +207,8 @@ const mountTerminalSession = (args: TerminalLifecycleArgs): (() => void) | undef const socketRef: TerminalSocketRef = { current: null } const { fitAddon, terminal } = createTerminalRuntime(host, { querySuppression: { - allowMouseTracking: shouldAllowTerminalMouseTracking(args.session) + allowMouseTracking: shouldAllowTerminalMouseTracking(args.session), + suppressAlternateScreen: shouldSuppressTerminalAlternateScreen(args.session) } }) const terminalInputController = createTerminalInputController(terminal, socketRef) diff --git a/packages/app/src/web/terminal-query-suppression.ts b/packages/app/src/web/terminal-query-suppression.ts index 44d411c3..a6417b7b 100644 --- a/packages/app/src/web/terminal-query-suppression.ts +++ b/packages/app/src/web/terminal-query-suppression.ts @@ -2,6 +2,7 @@ export type TerminalQuerySuppression = { readonly dispose: () => void } export type TerminalQuerySuppressionOptions = { readonly allowMouseTracking?: boolean + readonly suppressAlternateScreen?: boolean } type Disposable = { readonly dispose: () => void } @@ -44,19 +45,16 @@ const MOUSE_TRACKING_PRIVATE_MODES: ReadonlySet = new Set([ 1016 ]) const FOCUS_REPORTING_PRIVATE_MODE = 1004 +const ALTERNATE_SCREEN_PRIVATE_MODES: ReadonlySet = new Set([47, 1047, 1049]) // Suppressing SET leaves xterm.js in the default state (no event emission); // suppressing RESET is harmless and kept for symmetry. // Modes intentionally left to fall through to xterm's built-in handlers: // 25 — cursor visibility -// 1049 — alternate screen buffer // 2004 — bracketed paste // 2026 — synchronized output (Ink uses BSU/ESU around every frame) // 1007 — alternate scroll (only changes wheel semantics, no leak) -const SUPPRESSED_PRIVATE_MODES: ReadonlySet = new Set([ - ...MOUSE_TRACKING_PRIVATE_MODES, - FOCUS_REPORTING_PRIVATE_MODE -]) +// 47/1047/1049 — alternate screen, unless project terminals opt out to keep xterm scrollback visible const isColorQuery = (data: string): boolean => { for (const segment of data.split(";")) { @@ -80,7 +78,8 @@ const shouldSuppressPrivateMode = ( options: TerminalQuerySuppressionOptions ): boolean => mode === FOCUS_REPORTING_PRIVATE_MODE || - (options.allowMouseTracking !== true && MOUSE_TRACKING_PRIVATE_MODES.has(mode)) + (options.allowMouseTracking !== true && MOUSE_TRACKING_PRIVATE_MODES.has(mode)) || + (options.suppressAlternateScreen === true && ALTERNATE_SCREEN_PRIVATE_MODES.has(mode)) const containsSuppressedPrivateMode = ( params: CsiParams, @@ -168,4 +167,7 @@ export const installTerminalQuerySuppression = ( export const isTerminalColorQuery = isColorQuery -export const isSuppressedDecPrivateMode = (mode: number): boolean => SUPPRESSED_PRIVATE_MODES.has(mode) +export const isSuppressedDecPrivateMode = ( + mode: number, + options: TerminalQuerySuppressionOptions = {} +): boolean => shouldSuppressPrivateMode(mode, options) diff --git a/packages/app/src/web/terminal-wheel-scroll.ts b/packages/app/src/web/terminal-wheel-scroll.ts new file mode 100644 index 00000000..d038615a --- /dev/null +++ b/packages/app/src/web/terminal-wheel-scroll.ts @@ -0,0 +1,152 @@ +export type TerminalWheelMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10" + +export type TerminalWheelScrollBufferType = "alternate" | "normal" + +export type TerminalWheelScrollBuffer = { + readonly active: { + readonly baseY: number + readonly type: TerminalWheelScrollBufferType + readonly viewportY: number + } +} + +export type TerminalWheelScrollTerminal = { + readonly buffer?: TerminalWheelScrollBuffer | undefined + readonly element?: TerminalWheelScrollTarget | null | undefined + readonly modes: { + readonly mouseTrackingMode: TerminalWheelMouseTrackingMode + } + readonly rows: number + readonly scrollLines: (amount: number) => void +} + +type TerminalWheelScrollEvent = { + readonly deltaMode: number + readonly deltaY: number + readonly stopImmediatePropagation?: () => void + readonly preventDefault: () => void + readonly stopPropagation: () => void +} + +type TerminalWheelScrollTarget = { + readonly addEventListener: ( + type: "wheel", + listener: (event: TerminalWheelScrollEvent) => void, + options: AddEventListenerOptions + ) => void + readonly removeEventListener: ( + type: "wheel", + listener: (event: TerminalWheelScrollEvent) => void, + options: boolean + ) => void +} + +type TerminalWheelScrollDelta = { + readonly deltaMode: number + readonly deltaY: number + readonly previousPixelDeltaY: number + readonly rows: number +} + +export type ResolvedTerminalWheelScrollDelta = { + readonly lines: number + readonly nextPixelDeltaY: number +} + +type TerminalWheelScrollArgs = { + readonly host: TerminalWheelScrollTarget + readonly terminal: TerminalWheelScrollTerminal +} + +const wheelPixelDeltaMode = 0 +const wheelLineDeltaMode = 1 +const wheelPageDeltaMode = 2 +const pixelsPerTerminalLine = 15 + +const hasActiveMouseTracking = (terminal: TerminalWheelScrollTerminal): boolean => + terminal.modes.mouseTrackingMode !== "none" + +const hasActiveAlternateBuffer = (terminal: TerminalWheelScrollTerminal): boolean => + terminal.buffer?.active.type === "alternate" + +const hasScrollableTerminalHistory = (terminal: TerminalWheelScrollTerminal): boolean => { + const activeBuffer = terminal.buffer?.active + return activeBuffer !== undefined && activeBuffer.type === "normal" && activeBuffer.baseY > 0 +} + +export const shouldHandleTerminalWheelScroll = (terminal: TerminalWheelScrollTerminal): boolean => + hasActiveMouseTracking(terminal) || + hasActiveAlternateBuffer(terminal) || + hasScrollableTerminalHistory(terminal) + +const resolveTerminalWheelScrollTarget = ( + { host, terminal }: TerminalWheelScrollArgs +): TerminalWheelScrollTarget => terminal.element ?? host + +const validTerminalRows = (rows: number): number => { + if (!Number.isFinite(rows) || rows < 1) { + return 1 + } + return Math.trunc(rows) +} + +const finiteDelta = (delta: number): number => { + if (!Number.isFinite(delta)) { + return 0 + } + return delta +} + +export const resolveTerminalWheelScrollDelta = ( + delta: TerminalWheelScrollDelta +): ResolvedTerminalWheelScrollDelta => { + const deltaY = finiteDelta(delta.deltaY) + if (delta.deltaMode === wheelLineDeltaMode) { + return { lines: Math.trunc(deltaY), nextPixelDeltaY: 0 } + } + if (delta.deltaMode === wheelPageDeltaMode) { + return { lines: Math.trunc(deltaY * validTerminalRows(delta.rows)), nextPixelDeltaY: 0 } + } + if (delta.deltaMode !== wheelPixelDeltaMode) { + return { lines: Math.trunc(deltaY), nextPixelDeltaY: 0 } + } + const nextPixelDeltaY = finiteDelta(delta.previousPixelDeltaY) + deltaY + const lines = Math.trunc(nextPixelDeltaY / pixelsPerTerminalLine) + return { + lines, + nextPixelDeltaY: nextPixelDeltaY - lines * pixelsPerTerminalLine + } +} + +export const attachTerminalWheelScroll = ( + args: TerminalWheelScrollArgs +): { readonly dispose: () => void } => { + let previousPixelDeltaY = 0 + const target = resolveTerminalWheelScrollTarget(args) + const onWheel = (event: TerminalWheelScrollEvent): void => { + if (!shouldHandleTerminalWheelScroll(args.terminal)) { + return + } + const scrollDelta = resolveTerminalWheelScrollDelta({ + deltaMode: event.deltaMode, + deltaY: event.deltaY, + previousPixelDeltaY, + rows: args.terminal.rows + }) + previousPixelDeltaY = scrollDelta.nextPixelDeltaY + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation?.() + if (scrollDelta.lines !== 0) { + args.terminal.scrollLines(scrollDelta.lines) + } + } + + target.addEventListener("wheel", onWheel, { capture: true, passive: false }) + + return { + dispose: () => { + target.removeEventListener("wheel", onWheel, true) + } + } +} diff --git a/packages/app/tests/docker-git/api-auth-schema.test.ts b/packages/app/tests/docker-git/api-auth-schema.test.ts new file mode 100644 index 00000000..cf9ba1d7 --- /dev/null +++ b/packages/app/tests/docker-git/api-auth-schema.test.ts @@ -0,0 +1,47 @@ +import * as ParseResult from "@effect/schema/ParseResult" +import * as Schema from "@effect/schema/Schema" +import { describe, expect, it } from "@effect/vitest" +import { Either } from "effect" + +import { AuthSnapshotResponseSchema } from "../../src/web/api-auth-schema.js" + +type LegacyAuthSnapshotResponse = { + readonly snapshot: { + readonly claudeAuthEntries: number + readonly claudeAuthPath: string + readonly geminiAuthEntries: number + readonly geminiAuthPath: string + readonly gitTokenEntries: number + readonly gitUserEntries: number + readonly githubTokenEntries: number + readonly globalEnvPath: string + readonly totalEntries: number + } +} + +const decodeAuthSnapshotResponse = (payload: LegacyAuthSnapshotResponse) => + ParseResult.decodeUnknownEither(Schema.parseJson(AuthSnapshotResponseSchema))(JSON.stringify(payload)) + +describe("web auth api schema", () => { + it("accepts auth snapshots from controllers without Grok fields", () => { + const decoded = decodeAuthSnapshotResponse({ + snapshot: { + claudeAuthEntries: 3, + claudeAuthPath: "/home/dev/.docker-git/.orch/auth/claude", + geminiAuthEntries: 2, + geminiAuthPath: "/home/dev/.docker-git/.orch/auth/gemini", + gitTokenEntries: 0, + gitUserEntries: 0, + githubTokenEntries: 1, + globalEnvPath: "/home/dev/.docker-git/.orch/env/global.env", + totalEntries: 1 + } + }) + + expect(Either.isRight(decoded)).toBe(true) + if (Either.isRight(decoded)) { + expect(decoded.right.snapshot.grokAuthEntries).toBe(0) + expect(decoded.right.snapshot.grokAuthPath).toBe("") + } + }) +}) diff --git a/packages/app/tests/docker-git/terminal-copy-interaction.test.ts b/packages/app/tests/docker-git/terminal-copy-interaction.test.ts new file mode 100644 index 00000000..4a8f8db6 --- /dev/null +++ b/packages/app/tests/docker-git/terminal-copy-interaction.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "@effect/vitest" + +import { + forceTerminalSelectionModifier, + shouldForceBrowserTerminalSelection, + shouldForceTerminalSelectionContext, + type TerminalCopyInteractionTerminal, + type TerminalMouseTrackingMode, + writeTerminalSelectionToClipboardData +} from "../../src/web/terminal-copy-interaction.js" + +const terminalWithSelection = ( + mouseTrackingMode: TerminalMouseTrackingMode, + selection: string +): TerminalCopyInteractionTerminal => ({ + getSelection: () => selection, + hasSelection: () => selection.length > 0, + modes: { mouseTrackingMode } +}) + +describe("terminal copy interaction", () => { + it("forces browser selection for primary mouse input while terminal mouse tracking is active", () => { + expect(shouldForceBrowserTerminalSelection({ button: 0 }, terminalWithSelection("any", ""))).toBe(true) + expect(shouldForceBrowserTerminalSelection({ button: 0 }, terminalWithSelection("drag", ""))).toBe(true) + expect(shouldForceBrowserTerminalSelection({ button: 0 }, terminalWithSelection("none", ""))).toBe(false) + expect(shouldForceBrowserTerminalSelection({ button: 2 }, terminalWithSelection("any", ""))).toBe(false) + }) + + it("forces context-menu clicks into selection mode only when selected terminal text exists", () => { + expect(shouldForceTerminalSelectionContext({ button: 2 }, terminalWithSelection("any", "selected"))).toBe(true) + expect(shouldForceTerminalSelectionContext({ button: 2 }, terminalWithSelection("any", ""))).toBe(false) + expect(shouldForceTerminalSelectionContext({ button: 0 }, terminalWithSelection("any", "selected"))).toBe(false) + }) + + it("uses Shift as the forced selection modifier on non-Mac platforms", () => { + const event = { altKey: false, shiftKey: false } + + expect(forceTerminalSelectionModifier(event, "Win32")).toBe(true) + expect(event).toEqual({ altKey: false, shiftKey: true }) + }) + + it("uses Alt as the forced selection modifier on Mac platforms", () => { + const event = { altKey: false, shiftKey: false } + + expect(forceTerminalSelectionModifier(event, "MacIntel")).toBe(true) + expect(event).toEqual({ altKey: true, shiftKey: false }) + }) + + it("writes xterm selection text into clipboard data", () => { + const writes: Array<{ readonly data: string; readonly format: string }> = [] + const clipboardData = { + setData: (format: string, data: string) => { + writes.push({ data, format }) + } + } + + expect(writeTerminalSelectionToClipboardData(terminalWithSelection("any", "line one\nline two"), clipboardData)) + .toBe( + true + ) + expect(writes).toEqual([{ data: "line one\nline two", format: "text/plain" }]) + }) + + it("does not handle copy when xterm has no selection or clipboard data", () => { + const clipboardData = { + setData: () => { + expect.fail("clipboard data should not be written") + } + } + + expect(writeTerminalSelectionToClipboardData(terminalWithSelection("any", ""), clipboardData)).toBe(false) + expect(writeTerminalSelectionToClipboardData(terminalWithSelection("any", "selected"), null)).toBe(false) + }) +}) diff --git a/packages/app/tests/docker-git/terminal-panel-runtime-core.test.ts b/packages/app/tests/docker-git/terminal-panel-runtime-core.test.ts new file mode 100644 index 00000000..700d258b --- /dev/null +++ b/packages/app/tests/docker-git/terminal-panel-runtime-core.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "@effect/vitest" +import { afterEach, beforeEach, vi } from "vitest" + +import { attachTerminalInput, isTerminalMouseReportInput } from "../../src/web/terminal-panel-input.js" + +type TerminalDataHandler = (data: string) => void + +const noop = (): void => undefined + +const createTerminalInputHarness = () => { + let handler: TerminalDataHandler = noop + const state = { disposed: 0, scrolls: 0 } + const terminal = { + onData: (next: TerminalDataHandler) => { + handler = next + return { + dispose: () => { + state.disposed += 1 + } + } + }, + scrollToBottom: () => { + state.scrolls += 1 + } + } + return { + emit: (data: string) => { + handler(data) + }, + state, + terminal + } +} + +const createOpenSocketRef = () => { + const sent: Array = [] + return { + sent, + socketRef: { + current: { + readyState: 1, + send: (data: string) => { + sent.push(data) + } + } + } + } +} + +const passThroughPasteGuard = { + shouldSuppressTerminalInput: () => false, + suppressNextNativeImagePaste: noop +} + +describe("terminal panel runtime core", () => { + beforeEach(() => { + vi.stubGlobal("WebSocket", { OPEN: 1 }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it("detects xterm mouse report input encodings", () => { + expect(isTerminalMouseReportInput("\u001B[M !!")).toBe(true) + expect(isTerminalMouseReportInput("\u001B[<64;10;5M")).toBe(true) + expect(isTerminalMouseReportInput("\u001B[<0;10;5m")).toBe(true) + expect(isTerminalMouseReportInput("\u001B[64;10;5M")).toBe(true) + expect(isTerminalMouseReportInput("\u001B[2M")).toBe(false) + expect(isTerminalMouseReportInput("a")).toBe(false) + }) + + it("scrolls to bottom for regular terminal input before sending it to the socket", () => { + const input = createTerminalInputHarness() + const { sent, socketRef } = createOpenSocketRef() + + const disposable = attachTerminalInput(input.terminal, socketRef, passThroughPasteGuard) + input.emit("a") + disposable.dispose() + + expect(input.state.scrolls).toBe(1) + expect(input.state.disposed).toBe(1) + expect(sent).toEqual([JSON.stringify({ data: "a", type: "input" })]) + }) + + it("keeps the viewport stable for terminal mouse click reports", () => { + const input = createTerminalInputHarness() + const { sent, socketRef } = createOpenSocketRef() + + attachTerminalInput(input.terminal, socketRef, passThroughPasteGuard) + input.emit("\u001B[<0;10;5M") + + expect(input.state.scrolls).toBe(0) + expect(sent).toEqual([JSON.stringify({ data: "\u001B[<0;10;5M", type: "input" })]) + }) + + it("does not scroll or send input suppressed by the paste guard", () => { + const input = createTerminalInputHarness() + const { sent, socketRef } = createOpenSocketRef() + const pasteGuard = { + shouldSuppressTerminalInput: () => true, + suppressNextNativeImagePaste: noop + } + + attachTerminalInput(input.terminal, socketRef, pasteGuard) + input.emit("\u0016") + + expect(input.state.scrolls).toBe(0) + expect(sent).toEqual([]) + }) +}) diff --git a/packages/app/tests/docker-git/terminal-query-suppression.test.ts b/packages/app/tests/docker-git/terminal-query-suppression.test.ts index 90b800ca..60046c06 100644 --- a/packages/app/tests/docker-git/terminal-query-suppression.test.ts +++ b/packages/app/tests/docker-git/terminal-query-suppression.test.ts @@ -127,9 +127,30 @@ const MOUSE_TRACKING_MODES: ReadonlyArray = [1000, 1002, 1003, 1006, 101 const FOCUS_REPORTING_MODE = 1004 +const ALTERNATE_SCREEN_MODES: ReadonlyArray = [47, 1047, 1049] + const SUPPRESSED_MODES: ReadonlyArray = [...MOUSE_TRACKING_MODES, FOCUS_REPORTING_MODE] -const PASS_THROUGH_MODES: ReadonlyArray = [25, 1007, 1049, 2004, 2026] +const PASS_THROUGH_MODES: ReadonlyArray = [25, 1007, ...ALTERNATE_SCREEN_MODES, 2004, 2026] + +const privateModeHandlers = ( + mock: MockTerminal +): readonly [RegisteredCsiHandler, RegisteredCsiHandler] => [ + findCsi(mock, { final: "h", prefix: "?" }), + findCsi(mock, { final: "l", prefix: "?" }) +] + +const expectPrivateModesHandled = ( + handlers: readonly [RegisteredCsiHandler, RegisteredCsiHandler], + modes: ReadonlyArray, + handled: boolean +): void => { + const [setHandler, resetHandler] = handlers + for (const mode of modes) { + expect(setHandler.callback([mode])).toBe(handled) + expect(resetHandler.callback([mode])).toBe(handled) + } +} describe("terminal query suppression", () => { it("detects color query payloads with the '?' placeholder", () => { @@ -225,26 +246,28 @@ describe("terminal query suppression", () => { it("allows DEC private mouse tracking when explicitly enabled for tmux project terminals", () => { const mock = createMockTerminal() installTerminalQuerySuppression(mock.terminal, { allowMouseTracking: true }) - const setHandler = findCsi(mock, { final: "h", prefix: "?" }) - const resetHandler = findCsi(mock, { final: "l", prefix: "?" }) + const handlers = privateModeHandlers(mock) - for (const mode of MOUSE_TRACKING_MODES) { - expect(setHandler.callback([mode])).toBe(false) - expect(resetHandler.callback([mode])).toBe(false) - } - expect(setHandler.callback([FOCUS_REPORTING_MODE])).toBe(true) - expect(resetHandler.callback([FOCUS_REPORTING_MODE])).toBe(true) + expectPrivateModesHandled(handlers, MOUSE_TRACKING_MODES, false) + expectPrivateModesHandled(handlers, [FOCUS_REPORTING_MODE], true) + }) + + it("blocks alternate screen modes when project terminals preserve xterm scrollback", () => { + const mock = createMockTerminal() + installTerminalQuerySuppression(mock.terminal, { + allowMouseTracking: true, + suppressAlternateScreen: true + }) + const handlers = privateModeHandlers(mock) + + expectPrivateModesHandled(handlers, ALTERNATE_SCREEN_MODES, true) + expectPrivateModesHandled(handlers, MOUSE_TRACKING_MODES, false) }) it("lets benign DEC private modes fall through to the built-in handler", () => { const mock = createMockTerminal() installTerminalQuerySuppression(mock.terminal) - const setHandler = findCsi(mock, { final: "h", prefix: "?" }) - const resetHandler = findCsi(mock, { final: "l", prefix: "?" }) - for (const mode of PASS_THROUGH_MODES) { - expect(setHandler.callback([mode])).toBe(false) - expect(resetHandler.callback([mode])).toBe(false) - } + expectPrivateModesHandled(privateModeHandlers(mock), PASS_THROUGH_MODES, false) }) it("treats sub-parameters (nested arrays) as the parameter head", () => { @@ -258,6 +281,9 @@ describe("terminal query suppression", () => { it("exposes the suppressed private mode set", () => { expect(isSuppressedDecPrivateMode(1004)).toBe(true) expect(isSuppressedDecPrivateMode(1000)).toBe(true) + expect(isSuppressedDecPrivateMode(1000, { allowMouseTracking: true })).toBe(false) + expect(isSuppressedDecPrivateMode(1049)).toBe(false) + expect(isSuppressedDecPrivateMode(1049, { suppressAlternateScreen: true })).toBe(true) expect(isSuppressedDecPrivateMode(25)).toBe(false) expect(isSuppressedDecPrivateMode(2026)).toBe(false) }) diff --git a/packages/app/tests/docker-git/terminal-wheel-scroll.test.ts b/packages/app/tests/docker-git/terminal-wheel-scroll.test.ts new file mode 100644 index 00000000..5d919b6e --- /dev/null +++ b/packages/app/tests/docker-git/terminal-wheel-scroll.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "@effect/vitest" + +import { + attachTerminalWheelScroll, + resolveTerminalWheelScrollDelta, + type TerminalWheelMouseTrackingMode, + type TerminalWheelScrollTerminal +} from "../../src/web/terminal-wheel-scroll.js" + +type TestWheelEvent = { + readonly deltaMode: number + readonly deltaY: number + readonly preventDefault: () => void + readonly stopImmediatePropagation?: () => void + readonly stopPropagation: () => void +} + +type TestWheelListener = (event: TestWheelEvent) => void + +type TestWheelEventState = { + readonly immediatePropagationStopped: number + readonly prevented: number + readonly propagationStopped: number +} + +type WheelDispatchArgs = { + readonly deltaMode: number + readonly deltaY: number + readonly terminal: TerminalWheelScrollTerminal + readonly target?: ReturnType | undefined +} + +const noop = (): void => undefined + +const expectWheelEventStopped = (state: TestWheelEventState): void => { + expect(state.prevented).toBe(1) + expect(state.propagationStopped).toBe(1) + expect(state.immediatePropagationStopped).toBe(1) +} + +const expectWheelEventPassedThrough = (state: TestWheelEventState): void => { + expect(state.prevented).toBe(0) + expect(state.propagationStopped).toBe(0) + expect(state.immediatePropagationStopped).toBe(0) +} + +const createWheelHost = () => { + let listener: TestWheelListener = noop + const state: { + added: number + removed: number + wheelOptions: AddEventListenerOptions | null + } = { + added: 0, + removed: 0, + wheelOptions: null + } + return { + dispatch: (event: TestWheelEvent) => { + listener(event) + }, + host: { + addEventListener: ( + type: "wheel", + next: TestWheelListener, + options: AddEventListenerOptions + ) => { + expect(type).toBe("wheel") + listener = next + state.added += 1 + state.wheelOptions = options + }, + removeEventListener: ( + type: "wheel", + next: TestWheelListener, + options: boolean + ) => { + expect(type).toBe("wheel") + expect(next).toBe(listener) + expect(options).toBe(true) + state.removed += 1 + } + }, + state + } +} + +const createWheelEvent = (deltaMode: number, deltaY: number) => { + const state = { + immediatePropagationStopped: 0, + prevented: 0, + propagationStopped: 0 + } + return { + event: { + deltaMode, + deltaY, + preventDefault: () => { + state.prevented += 1 + }, + stopImmediatePropagation: () => { + state.immediatePropagationStopped += 1 + }, + stopPropagation: () => { + state.propagationStopped += 1 + } + }, + state + } +} + +const createTerminal = ( + mouseTrackingMode: TerminalWheelMouseTrackingMode, + rows = 24, + overrides: Partial> = {} +): { readonly scrolls: ReadonlyArray; readonly terminal: TerminalWheelScrollTerminal } => { + const scrolls: Array = [] + return { + scrolls, + terminal: { + ...overrides, + modes: { mouseTrackingMode }, + rows, + scrollLines: (amount) => { + scrolls.push(amount) + } + } + } +} + +const terminalBuffer = ( + type: "alternate" | "normal", + baseY: number +): TerminalWheelScrollTerminal["buffer"] => ({ + active: { + baseY, + type, + viewportY: baseY + } +}) + +const dispatchWheel = ( + { deltaMode, deltaY, target = createWheelHost(), terminal }: WheelDispatchArgs +): { readonly host: ReturnType; readonly state: TestWheelEventState } => { + const { event, state } = createWheelEvent(deltaMode, deltaY) + attachTerminalWheelScroll({ host: target.host, terminal }) + target.dispatch(event) + return { host: target, state } +} + +describe("terminal wheel scroll", () => { + it("converts line and page wheel deltas into terminal scroll lines", () => { + expect(resolveTerminalWheelScrollDelta({ + deltaMode: 1, + deltaY: 3.8, + previousPixelDeltaY: 9, + rows: 24 + })).toEqual({ lines: 3, nextPixelDeltaY: 0 }) + expect(resolveTerminalWheelScrollDelta({ + deltaMode: 1, + deltaY: -2.8, + previousPixelDeltaY: 9, + rows: 24 + })).toEqual({ lines: -2, nextPixelDeltaY: 0 }) + expect(resolveTerminalWheelScrollDelta({ + deltaMode: 2, + deltaY: 1, + previousPixelDeltaY: 9, + rows: 24 + })).toEqual({ lines: 24, nextPixelDeltaY: 0 }) + expect(resolveTerminalWheelScrollDelta({ + deltaMode: 2, + deltaY: 1, + previousPixelDeltaY: 9, + rows: 0 + })).toEqual({ lines: 1, nextPixelDeltaY: 0 }) + }) + + it("accumulates pixel wheel deltas until they cross a terminal line", () => { + const first = resolveTerminalWheelScrollDelta({ + deltaMode: 0, + deltaY: 7, + previousPixelDeltaY: 0, + rows: 24 + }) + const second = resolveTerminalWheelScrollDelta({ + deltaMode: 0, + deltaY: 8, + previousPixelDeltaY: first.nextPixelDeltaY, + rows: 24 + }) + + expect(first).toEqual({ lines: 0, nextPixelDeltaY: 7 }) + expect(second).toEqual({ lines: 1, nextPixelDeltaY: 0 }) + expect(resolveTerminalWheelScrollDelta({ + deltaMode: 0, + deltaY: -30, + previousPixelDeltaY: 0, + rows: 24 + })).toEqual({ lines: -2, nextPixelDeltaY: 0 }) + expect(resolveTerminalWheelScrollDelta({ + deltaMode: 0, + deltaY: Number.NaN, + previousPixelDeltaY: 0, + rows: 24 + })).toEqual({ lines: 0, nextPixelDeltaY: 0 }) + }) + + it("scrolls xterm history and stops wheel mouse reports while mouse tracking is active", () => { + const activeModes: ReadonlyArray = ["x10", "vt200", "drag", "any"] + + for (const mode of activeModes) { + const host = createWheelHost() + const { scrolls, terminal } = createTerminal(mode) + const { event, state } = createWheelEvent(1, 3) + + const disposable = attachTerminalWheelScroll({ host: host.host, terminal }) + host.dispatch(event) + disposable.dispose() + + expect(host.state.added).toBe(1) + expect(host.state.removed).toBe(1) + expect(host.state.wheelOptions).toEqual({ capture: true, passive: false }) + expectWheelEventStopped(state) + expect(scrolls).toEqual([3]) + } + }) + + it("still stops sub-line wheel events while accumulating pixel deltas", () => { + const { scrolls, terminal } = createTerminal("any") + const { state } = dispatchWheel({ deltaMode: 0, deltaY: 1, terminal }) + + expectWheelEventStopped(state) + expect(scrolls).toEqual([]) + }) + + it("registers the wheel handler on xterm's terminal element when available", () => { + const host = createWheelHost() + const element = createWheelHost() + const { scrolls, terminal } = createTerminal("any", 24, { element: element.host }) + const { event, state } = createWheelEvent(1, 2) + + const disposable = attachTerminalWheelScroll({ host: host.host, terminal }) + element.dispatch(event) + disposable.dispose() + + expect(host.state.added).toBe(0) + expect(host.state.removed).toBe(0) + expect(element.state.added).toBe(1) + expect(element.state.removed).toBe(1) + expectWheelEventStopped(state) + expect(scrolls).toEqual([2]) + }) + + it("stops wheel events while the alternate buffer is active", () => { + const { scrolls, terminal } = createTerminal("none", 24, { + buffer: terminalBuffer("alternate", 0) + }) + const { state } = dispatchWheel({ deltaMode: 1, deltaY: 4, terminal }) + + expectWheelEventStopped(state) + expect(scrolls).toEqual([4]) + }) + + it("scrolls normal-buffer history even when mouse tracking is inactive", () => { + const { scrolls, terminal } = createTerminal("none", 24, { + buffer: terminalBuffer("normal", 20) + }) + const { state } = dispatchWheel({ deltaMode: 1, deltaY: -2, terminal }) + + expectWheelEventStopped(state) + expect(scrolls).toEqual([-2]) + }) + + it("lets xterm handle wheel events normally when mouse tracking is inactive", () => { + const { scrolls, terminal } = createTerminal("none", 24, { + buffer: terminalBuffer("normal", 0) + }) + const { state } = dispatchWheel({ deltaMode: 0, deltaY: 45, terminal }) + + expectWheelEventPassedThrough(state) + expect(scrolls).toEqual([]) + }) +})