|
|
|
|
@@ -35,6 +35,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|
|
|
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
|
|
|
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
|
|
|
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
|
|
|
const [isAutoFollow, setIsAutoFollow] = useState(true);
|
|
|
|
|
const isAtBottomRef = useRef(true);
|
|
|
|
|
// Tracks user intent to follow output — only set to false by explicit user
|
|
|
|
|
// actions (mouse wheel up), not by xterm scroll events during writes.
|
|
|
|
|
const autoFollowRef = useRef(true);
|
|
|
|
|
const lastUserScrollTimeRef = useRef(0);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!containerRef.current) return;
|
|
|
|
|
@@ -131,10 +137,40 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|
|
|
|
sendInput(sessionId, data);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Track scroll position to show "Jump to Current" button
|
|
|
|
|
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
|
|
|
|
|
// Captured during capture phase so it fires before xterm's own handler.
|
|
|
|
|
const handleWheel = (e: WheelEvent) => {
|
|
|
|
|
lastUserScrollTimeRef.current = Date.now();
|
|
|
|
|
if (e.deltaY < 0) {
|
|
|
|
|
autoFollowRef.current = false;
|
|
|
|
|
setIsAutoFollow(false);
|
|
|
|
|
isAtBottomRef.current = false;
|
|
|
|
|
setIsAtBottom(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
containerRef.current.addEventListener("wheel", handleWheel, { capture: true, passive: true });
|
|
|
|
|
|
|
|
|
|
// Track scroll position to show "Jump to Current" button.
|
|
|
|
|
// Debounce state updates via rAF to avoid excessive re-renders during rapid output.
|
|
|
|
|
let scrollStateRafId: number | null = null;
|
|
|
|
|
const scrollDisposable = term.onScroll(() => {
|
|
|
|
|
const buf = term.buffer.active;
|
|
|
|
|
setIsAtBottom(buf.viewportY >= buf.baseY);
|
|
|
|
|
const atBottom = buf.viewportY >= buf.baseY;
|
|
|
|
|
isAtBottomRef.current = atBottom;
|
|
|
|
|
|
|
|
|
|
// Re-enable auto-follow only when USER scrolls to bottom (not write-triggered)
|
|
|
|
|
const isUserScroll = (Date.now() - lastUserScrollTimeRef.current) < 300;
|
|
|
|
|
if (atBottom && isUserScroll && !autoFollowRef.current) {
|
|
|
|
|
autoFollowRef.current = true;
|
|
|
|
|
setIsAutoFollow(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (scrollStateRafId === null) {
|
|
|
|
|
scrollStateRafId = requestAnimationFrame(() => {
|
|
|
|
|
scrollStateRafId = null;
|
|
|
|
|
setIsAtBottom(isAtBottomRef.current);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Track text selection to show copy hint in status bar
|
|
|
|
|
@@ -187,7 +223,15 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|
|
|
|
|
|
|
|
|
const outputPromise = onOutput(sessionId, (data) => {
|
|
|
|
|
if (aborted) return;
|
|
|
|
|
term.write(data);
|
|
|
|
|
term.write(data, () => {
|
|
|
|
|
if (autoFollowRef.current) {
|
|
|
|
|
term.scrollToBottom();
|
|
|
|
|
if (!isAtBottomRef.current) {
|
|
|
|
|
isAtBottomRef.current = true;
|
|
|
|
|
setIsAtBottom(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
detector.feed(data);
|
|
|
|
|
|
|
|
|
|
// Scan for SSO refresh marker in terminal output
|
|
|
|
|
@@ -231,6 +275,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|
|
|
|
if (!containerRef.current || containerRef.current.offsetWidth === 0) return;
|
|
|
|
|
fitAddon.fit();
|
|
|
|
|
resize(sessionId, term.cols, term.rows);
|
|
|
|
|
if (autoFollowRef.current) {
|
|
|
|
|
term.scrollToBottom();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
resizeObserver.observe(containerRef.current);
|
|
|
|
|
@@ -246,9 +293,11 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|
|
|
|
scrollDisposable.dispose();
|
|
|
|
|
selectionDisposable.dispose();
|
|
|
|
|
setTerminalHasSelection(false);
|
|
|
|
|
containerRef.current?.removeEventListener("wheel", handleWheel, { capture: true });
|
|
|
|
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
|
|
|
|
outputPromise.then((fn) => fn?.());
|
|
|
|
|
exitPromise.then((fn) => fn?.());
|
|
|
|
|
if (scrollStateRafId !== null) cancelAnimationFrame(scrollStateRafId);
|
|
|
|
|
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
|
|
|
|
resizeObserver.disconnect();
|
|
|
|
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
|
|
|
|
@@ -280,6 +329,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
fitRef.current?.fit();
|
|
|
|
|
if (autoFollowRef.current) {
|
|
|
|
|
term.scrollToBottom();
|
|
|
|
|
}
|
|
|
|
|
term.focus();
|
|
|
|
|
} else {
|
|
|
|
|
// Release WebGL context for inactive terminals
|
|
|
|
|
@@ -314,8 +366,30 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|
|
|
|
}, [detectedUrl]);
|
|
|
|
|
|
|
|
|
|
const handleScrollToBottom = useCallback(() => {
|
|
|
|
|
termRef.current?.scrollToBottom();
|
|
|
|
|
setIsAtBottom(true);
|
|
|
|
|
const term = termRef.current;
|
|
|
|
|
if (term) {
|
|
|
|
|
autoFollowRef.current = true;
|
|
|
|
|
setIsAutoFollow(true);
|
|
|
|
|
fitRef.current?.fit();
|
|
|
|
|
term.scrollToBottom();
|
|
|
|
|
isAtBottomRef.current = true;
|
|
|
|
|
setIsAtBottom(true);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleToggleAutoFollow = useCallback(() => {
|
|
|
|
|
const next = !autoFollowRef.current;
|
|
|
|
|
autoFollowRef.current = next;
|
|
|
|
|
setIsAutoFollow(next);
|
|
|
|
|
if (next) {
|
|
|
|
|
const term = termRef.current;
|
|
|
|
|
if (term) {
|
|
|
|
|
fitRef.current?.fit();
|
|
|
|
|
term.scrollToBottom();
|
|
|
|
|
isAtBottomRef.current = true;
|
|
|
|
|
setIsAtBottom(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
@@ -338,6 +412,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|
|
|
|
{imagePasteMsg}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{/* Auto-follow toggle - top right */}
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleToggleAutoFollow}
|
|
|
|
|
className={`absolute top-2 right-4 z-50 px-2 py-1 rounded text-[10px] font-medium border shadow-sm transition-colors cursor-pointer ${
|
|
|
|
|
isAutoFollow
|
|
|
|
|
? "bg-[#1a2332] text-[#3fb950] border-[#238636] hover:bg-[#1f2d3d]"
|
|
|
|
|
: "bg-[#1f2937] text-[#8b949e] border-[#30363d] hover:bg-[#2d3748]"
|
|
|
|
|
}`}
|
|
|
|
|
title={isAutoFollow ? "Auto-scrolling to latest output (click to pause)" : "Auto-scroll paused (click to resume)"}
|
|
|
|
|
>
|
|
|
|
|
{isAutoFollow ? "▼ Following" : "▽ Paused"}
|
|
|
|
|
</button>
|
|
|
|
|
{/* Jump to Current - bottom right, when scrolled up */}
|
|
|
|
|
{!isAtBottom && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleScrollToBottom}
|
|
|
|
|
|