[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
270
components/terminal/TerminalSelectionAIOverlay.tsx
Normal file
270
components/terminal/TerminalSelectionAIOverlay.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Owns selection-driven "Add to AI" chrome so selection change does not re-render
|
||||
* the whole Terminal / TerminalView tree (common when focus moves to the AI input).
|
||||
*/
|
||||
import React, { memo, useEffect, useState, type RefObject } from 'react';
|
||||
import type { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
createCopyOnSelectUserGestureTracker,
|
||||
shouldWriteCopyOnSelect,
|
||||
subscribeCopyOnSelectUserCommand,
|
||||
subscribeCopyOnSelectUserGesture,
|
||||
} from './copyOnSelect';
|
||||
import { getTerminalSelectionForClipboard } from './normalizeTerminalSelection';
|
||||
import { resolveSelectionOverlayPosition } from './useTerminalEffects';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { shouldShowSelectionAIOverlay } from './TerminalView';
|
||||
|
||||
type SelectionOverlayPosition = { left: number; top: number } | null;
|
||||
|
||||
const areSelectionOverlayPositionsEqual = (
|
||||
a: SelectionOverlayPosition,
|
||||
b: SelectionOverlayPosition,
|
||||
): boolean => {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
return a.left === b.left && a.top === b.top;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
showSelectionAIAction?: boolean;
|
||||
onAddSelectionToAI?: () => void;
|
||||
copyOnSelect?: boolean;
|
||||
normalizeTextOnCopy?: boolean;
|
||||
/**
|
||||
* True while createXTermRuntime programmatically restores selection
|
||||
* (preserveSelectionOnInput). Copy-on-select must skip those events.
|
||||
*/
|
||||
isRestoringSelectionRef?: RefObject<boolean>;
|
||||
isVisible?: boolean;
|
||||
};
|
||||
|
||||
function TerminalSelectionAIOverlayInner({
|
||||
termRef,
|
||||
containerRef,
|
||||
showSelectionAIAction,
|
||||
onAddSelectionToAI,
|
||||
copyOnSelect,
|
||||
normalizeTextOnCopy = true,
|
||||
isRestoringSelectionRef,
|
||||
isVisible = true,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [selectionOverlayPosition, setSelectionOverlayPosition] = useState<SelectionOverlayPosition>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
let disposed = false;
|
||||
let overlayRafId: number | null = null;
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let waitRafId: number | null = null;
|
||||
let lastHasSelection: boolean | null = null;
|
||||
let lastOverlayPosition: SelectionOverlayPosition = null;
|
||||
let selectionDisposable: { dispose: () => void } | null = null;
|
||||
let scrollDisposable: { dispose: () => void } | null | undefined = null;
|
||||
let resizeDisposable: { dispose: () => void } | null | undefined = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let userGestureUnsubscribe: (() => void) | null = null;
|
||||
let userGestureTracker: ReturnType<typeof createCopyOnSelectUserGestureTracker> | null = null;
|
||||
|
||||
const requestFrame = typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame
|
||||
: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0) as unknown as number;
|
||||
const cancelFrame = typeof cancelAnimationFrame === 'function'
|
||||
? cancelAnimationFrame
|
||||
: (id: number) => clearTimeout(id);
|
||||
|
||||
const cleanupListeners = () => {
|
||||
if (overlayRafId !== null) {
|
||||
cancelFrame(overlayRafId);
|
||||
overlayRafId = null;
|
||||
}
|
||||
if (copyTimer) {
|
||||
clearTimeout(copyTimer);
|
||||
copyTimer = null;
|
||||
}
|
||||
selectionDisposable?.dispose();
|
||||
selectionDisposable = null;
|
||||
scrollDisposable?.dispose();
|
||||
scrollDisposable = null;
|
||||
resizeDisposable?.dispose();
|
||||
resizeDisposable = null;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
userGestureUnsubscribe?.();
|
||||
userGestureUnsubscribe = null;
|
||||
userGestureTracker?.dispose();
|
||||
userGestureTracker = null;
|
||||
};
|
||||
|
||||
const attach = (term: XTerm) => {
|
||||
cleanupListeners();
|
||||
userGestureTracker = createCopyOnSelectUserGestureTracker();
|
||||
const unsubscribePointer = subscribeCopyOnSelectUserGesture(term, userGestureTracker);
|
||||
const unsubscribeCommand = subscribeCopyOnSelectUserCommand(term, () => {
|
||||
userGestureTracker?.pulse();
|
||||
});
|
||||
userGestureUnsubscribe = () => {
|
||||
unsubscribePointer();
|
||||
unsubscribeCommand();
|
||||
};
|
||||
|
||||
const publishSelectionOverlayPosition = () => {
|
||||
overlayRafId = null;
|
||||
if (disposed) return;
|
||||
const nextPosition = resolveSelectionOverlayPosition(term, containerRef.current);
|
||||
if (areSelectionOverlayPositionsEqual(lastOverlayPosition, nextPosition)) return;
|
||||
lastOverlayPosition = nextPosition;
|
||||
setSelectionOverlayPosition(nextPosition);
|
||||
};
|
||||
|
||||
const scheduleSelectionOverlayPosition = () => {
|
||||
if (lastHasSelection === false) return;
|
||||
if (overlayRafId !== null) return;
|
||||
overlayRafId = requestFrame(publishSelectionOverlayPosition);
|
||||
};
|
||||
|
||||
const onSelectionChange = (options?: { allowCopy?: boolean }) => {
|
||||
if (disposed) return;
|
||||
const allowCopy = options?.allowCopy !== false;
|
||||
const rawSelection = term.getSelection();
|
||||
const hasText = !!rawSelection && rawSelection.length > 0;
|
||||
if (lastHasSelection !== hasText) {
|
||||
lastHasSelection = hasText;
|
||||
setHasSelection(hasText);
|
||||
}
|
||||
if (copyTimer) {
|
||||
clearTimeout(copyTimer);
|
||||
copyTimer = null;
|
||||
}
|
||||
if (!hasText) {
|
||||
if (lastOverlayPosition !== null) {
|
||||
lastOverlayPosition = null;
|
||||
setSelectionOverlayPosition(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
scheduleSelectionOverlayPosition();
|
||||
|
||||
// Skip programmatic selections: preserveSelectionOnInput restore,
|
||||
// SearchAddon match highlight (issue #3007), and the initial attach
|
||||
// snapshot so those writes cannot clobber a user copy.
|
||||
if (shouldWriteCopyOnSelect({
|
||||
allowCopy,
|
||||
hasText,
|
||||
copyOnSelect: !!copyOnSelect,
|
||||
isRestoringSelection: !!isRestoringSelectionRef?.current,
|
||||
isUserSelection: !!userGestureTracker?.isActive(),
|
||||
})) {
|
||||
const selection = getTerminalSelectionForClipboard(term, normalizeTextOnCopy);
|
||||
if (!selection) return;
|
||||
copyTimer = setTimeout(() => {
|
||||
void navigator.clipboard.writeText(selection).catch(() => {
|
||||
/* ignore clipboard failures */
|
||||
});
|
||||
}, 80);
|
||||
}
|
||||
};
|
||||
|
||||
selectionDisposable = term.onSelectionChange(() => onSelectionChange());
|
||||
scrollDisposable = term.onScroll?.(scheduleSelectionOverlayPosition);
|
||||
resizeDisposable = term.onResize?.(scheduleSelectionOverlayPosition);
|
||||
resizeObserver = typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(scheduleSelectionOverlayPosition);
|
||||
if (containerRef.current) {
|
||||
resizeObserver?.observe(containerRef.current);
|
||||
}
|
||||
// Sync UI only; do not write clipboard on reattach.
|
||||
onSelectionChange({ allowCopy: false });
|
||||
};
|
||||
|
||||
// Child effects run before parent useTerminalEffects assigns termRef.
|
||||
// Poll until the xterm runtime exists so copy-on-select / overlay attach
|
||||
// for sessions that mount already visible. Bound the wait so a failed
|
||||
// runtime never leaves a permanent rAF loop.
|
||||
const waitStartedAt = Date.now();
|
||||
const MAX_RUNTIME_WAIT_MS = 15_000;
|
||||
const tryAttach = () => {
|
||||
if (disposed) return;
|
||||
const term = termRef.current;
|
||||
if (!term) {
|
||||
if (Date.now() - waitStartedAt >= MAX_RUNTIME_WAIT_MS) {
|
||||
waitRafId = null;
|
||||
return;
|
||||
}
|
||||
waitRafId = requestFrame(tryAttach);
|
||||
return;
|
||||
}
|
||||
waitRafId = null;
|
||||
attach(term);
|
||||
};
|
||||
tryAttach();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (waitRafId !== null) cancelFrame(waitRafId);
|
||||
cleanupListeners();
|
||||
};
|
||||
}, [
|
||||
termRef,
|
||||
containerRef,
|
||||
copyOnSelect,
|
||||
normalizeTextOnCopy,
|
||||
isRestoringSelectionRef,
|
||||
isVisible,
|
||||
]);
|
||||
|
||||
if (!shouldShowSelectionAIOverlay({
|
||||
hasSelection,
|
||||
selectionOverlayPosition,
|
||||
onAddSelectionToAI,
|
||||
showSelectionAIAction,
|
||||
}) || !onAddSelectionToAI || !selectionOverlayPosition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-30 pointer-events-none"
|
||||
style={{
|
||||
left: selectionOverlayPosition.left,
|
||||
top: selectionOverlayPosition.top,
|
||||
transform: 'translate(-100%, -100%)',
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto inline-flex h-7 min-w-max items-center gap-1.5 whitespace-nowrap rounded-md border px-2 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-[color:var(--terminal-toolbar-btn-hover)]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg) 86%, transparent)',
|
||||
borderColor: 'var(--terminal-ui-border)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={onAddSelectionToAI}
|
||||
aria-label={t('terminal.selection.addToAI')}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
<span>{t('terminal.selection.addToAI')}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.selection.addToAIDesc')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TerminalSelectionAIOverlay = memo(TerminalSelectionAIOverlayInner);
|
||||
TerminalSelectionAIOverlay.displayName = 'TerminalSelectionAIOverlay';
|
||||
Reference in New Issue
Block a user