From 8416b238a06e5a6cbf6734a43d8d8d45ba02616c Mon Sep 17 00:00:00 2001 From: liamhess Date: Mon, 9 Mar 2026 16:39:59 +0100 Subject: [PATCH 1/2] feat: handle whitespace scroll --- src/renderer/components/ChatInterface.tsx | 25 ++++++- src/renderer/components/MultiAgentTask.tsx | 25 ++++++- src/renderer/components/TerminalPane.tsx | 9 ++- .../terminal/TerminalSessionManager.ts | 67 +++++++++++++++++++ 4 files changed, 119 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index e9c25d98a..881d71686 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react' import { Plus, X } from 'lucide-react'; import { useToast } from '../hooks/use-toast'; import { useTheme } from '../hooks/useTheme'; -import { TerminalPane } from './TerminalPane'; +import { TerminalPane, type TerminalPaneHandle } from './TerminalPane'; import InstallBanner from './InstallBanner'; import { cn } from '@/lib/utils'; import { agentStatusStore } from '../lib/agentStatusStore'; @@ -398,7 +398,23 @@ const ChatInterface: React.FC = ({ }, [activeConversationId, conversations, task.id]); // Ref to control terminal focus imperatively if needed - const terminalRef = useRef<{ focus: () => void }>(null); + const terminalRef = useRef(null); + + const handleTerminalViewportScrollForwarding = (event: React.WheelEvent) => { + if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return; + if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return; + + const target = event.target; + if (target instanceof Element && target.closest('[data-terminal-container]')) { + return; + } + + const didScroll = + terminalRef.current?.scrollViewportByWheel(event.deltaY, event.deltaMode) ?? false; + if (didScroll) { + event.preventDefault(); + } + }; // Auto-focus terminal when switching to this task useEffect(() => { @@ -1114,7 +1130,10 @@ const ChatInterface: React.FC = ({ })()} -
+
= ({ }, [variantBusy, task.id]); // Ref to the active terminal - const activeTerminalRef = useRef<{ focus: () => void }>(null); + const activeTerminalRef = useRef(null); + + const handleTerminalViewportScrollForwarding = (event: React.WheelEvent) => { + if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return; + if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return; + + const target = event.target; + if (target instanceof Element && target.closest('[data-terminal-container]')) { + return; + } + + const didScroll = + activeTerminalRef.current?.scrollViewportByWheel(event.deltaY, event.deltaMode) ?? false; + if (didScroll) { + event.preventDefault(); + } + }; // Auto-scroll and focus when task or active tab changes useEffect(() => { @@ -536,7 +552,10 @@ const MultiAgentTask: React.FC = ({
-
+
void; + scrollViewportByWheel: (deltaY: number, deltaMode: number) => boolean; +}; + type Props = { id: string; cwd?: string; @@ -30,7 +35,7 @@ type Props = { onFirstMessage?: (message: string) => void; }; -const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>( +const TerminalPaneComponent = forwardRef( ( { id, @@ -112,6 +117,8 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>( focus: () => { sessionRef.current?.focus(); }, + scrollViewportByWheel: (deltaY: number, deltaMode: number) => + sessionRef.current?.scrollViewportByWheel(deltaY, deltaMode) ?? false, }), [] ); diff --git a/src/renderer/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index 8903f6054..e3184b873 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -109,6 +109,7 @@ export class TerminalSessionManager { private selectionChangeDebounceTimer: ReturnType | null = null; private terminalConfigFontSize: number | null = null; private lastTheme: SessionTheme; + private wheelLineRemainder = 0; // Timing for startup performance measurement private initStartTime: number = 0; @@ -539,6 +540,18 @@ export class TerminalSessionManager { } } + scrollViewportByWheel(deltaY: number, deltaMode: number): boolean { + const lineDelta = this.normalizeWheelDeltaToLines(deltaY, deltaMode); + if (!Number.isFinite(lineDelta) || lineDelta === 0) return false; + + const totalDelta = this.wheelLineRemainder + lineDelta; + const wholeLines = totalDelta > 0 ? Math.floor(totalDelta) : Math.ceil(totalDelta); + this.wheelLineRemainder = totalDelta - wholeLines; + + if (wholeLines === 0) return false; + return this.scrollViewportByLines(wholeLines); + } + registerActivityListener(listener: () => void): () => void { this.activityListeners.add(listener); return () => { @@ -766,6 +779,60 @@ export class TerminalSessionManager { this.terminal.options.fontFamily = selected ? `${selected}, ${FALLBACK_FONTS}` : FALLBACK_FONTS; } + private scrollViewportByLines(lines: number): boolean { + try { + const buffer = this.terminal.buffer?.active; + if ( + !buffer || + typeof buffer.baseY !== 'number' || + typeof buffer.viewportY !== 'number' || + lines === 0 + ) { + return false; + } + + const targetLine = Math.max(0, Math.min(buffer.baseY, buffer.viewportY + lines)); + if (targetLine === buffer.viewportY) return false; + + this.terminal.scrollToLine(targetLine); + return true; + } catch (error) { + log.warn('Failed to scroll terminal viewport', { id: this.id, error }); + return false; + } + } + + private normalizeWheelDeltaToLines(deltaY: number, deltaMode: number): number { + if (!Number.isFinite(deltaY) || deltaY === 0) return 0; + + if (deltaMode === WheelEvent.DOM_DELTA_LINE) { + return deltaY; + } + + if (deltaMode === WheelEvent.DOM_DELTA_PAGE) { + return deltaY * this.terminal.rows; + } + + return deltaY / this.getApproximateRowHeightPx(); + } + + private getApproximateRowHeightPx(): number { + const rowElement = this.container.querySelector('.xterm-rows > div'); + if (rowElement instanceof HTMLElement) { + const { height } = rowElement.getBoundingClientRect(); + if (height > 0) return height; + } + + const fontSize = + typeof this.terminal.options.fontSize === 'number' + ? this.terminal.options.fontSize + : DEFAULT_FONT_SIZE; + const lineHeight = + typeof this.terminal.options.lineHeight === 'number' ? this.terminal.options.lineHeight : 1.2; + + return Math.max(fontSize * lineHeight, 1); + } + private scheduleFit() { if (this.pendingFitFrame !== null) return; this.pendingFitFrame = requestAnimationFrame(() => { From 038e95b03f37c80960966825ebe2616b505b3e66 Mon Sep 17 00:00:00 2001 From: liamhess Date: Wed, 11 Mar 2026 11:07:36 +0100 Subject: [PATCH 2/2] clean up --- src/renderer/components/ChatInterface.tsx | 22 +++------------- src/renderer/components/MultiAgentTask.tsx | 23 ++++------------- src/renderer/components/TerminalPane.tsx | 6 ++--- .../useTerminalViewportWheelForwarding.ts | 25 +++++++++++++++++++ .../terminal/TerminalSessionManager.ts | 6 ++--- 5 files changed, 40 insertions(+), 42 deletions(-) create mode 100644 src/renderer/hooks/useTerminalViewportWheelForwarding.ts diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index 881d71686..487eb6d46 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -22,6 +22,7 @@ import { activityStore } from '@/lib/activityStore'; import { rpc } from '@/lib/rpc'; import { getInstallCommandForProvider } from '@shared/providers/registry'; import { useAutoScrollOnTaskSwitch } from '@/hooks/useAutoScrollOnTaskSwitch'; +import { useTerminalViewportWheelForwarding } from '@/hooks/useTerminalViewportWheelForwarding'; import { TaskScopeProvider } from './TaskScopeContext'; import { CreateChatModal } from './CreateChatModal'; import { type Conversation } from '../../main/services/DatabaseService'; @@ -397,24 +398,9 @@ const ChatInterface: React.FC = ({ }; }, [activeConversationId, conversations, task.id]); - // Ref to control terminal focus imperatively if needed + // Ref to control terminal focus and viewport scrolling imperatively. const terminalRef = useRef(null); - - const handleTerminalViewportScrollForwarding = (event: React.WheelEvent) => { - if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return; - if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return; - - const target = event.target; - if (target instanceof Element && target.closest('[data-terminal-container]')) { - return; - } - - const didScroll = - terminalRef.current?.scrollViewportByWheel(event.deltaY, event.deltaMode) ?? false; - if (didScroll) { - event.preventDefault(); - } - }; + const handleTerminalViewportWheelForwarding = useTerminalViewportWheelForwarding(terminalRef); // Auto-focus terminal when switching to this task useEffect(() => { @@ -1132,7 +1118,7 @@ const ChatInterface: React.FC = ({
= ({ activityStore.setTaskBusy(task.id, anyBusy); }, [variantBusy, task.id]); - // Ref to the active terminal + // Ref to control terminal focus and viewport scrolling imperatively. const activeTerminalRef = useRef(null); - - const handleTerminalViewportScrollForwarding = (event: React.WheelEvent) => { - if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return; - if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return; - - const target = event.target; - if (target instanceof Element && target.closest('[data-terminal-container]')) { - return; - } - - const didScroll = - activeTerminalRef.current?.scrollViewportByWheel(event.deltaY, event.deltaMode) ?? false; - if (didScroll) { - event.preventDefault(); - } - }; + const handleTerminalViewportWheelForwarding = + useTerminalViewportWheelForwarding(activeTerminalRef); // Auto-scroll and focus when task or active tab changes useEffect(() => { @@ -554,7 +541,7 @@ const MultiAgentTask: React.FC = ({
void; - scrollViewportByWheel: (deltaY: number, deltaMode: number) => boolean; + scrollViewportFromWheelDelta: (deltaY: number, deltaMode: number) => boolean; }; type Props = { @@ -117,8 +117,8 @@ const TerminalPaneComponent = forwardRef( focus: () => { sessionRef.current?.focus(); }, - scrollViewportByWheel: (deltaY: number, deltaMode: number) => - sessionRef.current?.scrollViewportByWheel(deltaY, deltaMode) ?? false, + scrollViewportFromWheelDelta: (deltaY: number, deltaMode: number) => + sessionRef.current?.scrollViewportFromWheelDelta(deltaY, deltaMode) ?? false, }), [] ); diff --git a/src/renderer/hooks/useTerminalViewportWheelForwarding.ts b/src/renderer/hooks/useTerminalViewportWheelForwarding.ts new file mode 100644 index 000000000..7b05a585b --- /dev/null +++ b/src/renderer/hooks/useTerminalViewportWheelForwarding.ts @@ -0,0 +1,25 @@ +import { useCallback, type RefObject, type WheelEventHandler } from 'react'; +import type { TerminalPaneHandle } from '../components/TerminalPane'; + +export function useTerminalViewportWheelForwarding( + terminalRef: RefObject +): WheelEventHandler { + return useCallback( + (event) => { + if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return; + if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return; + + const target = event.target; + if (target instanceof Element && target.closest('[data-terminal-container]')) { + return; + } + + const didScroll = + terminalRef.current?.scrollViewportFromWheelDelta(event.deltaY, event.deltaMode) ?? false; + if (didScroll) { + event.preventDefault(); + } + }, + [terminalRef] + ); +} diff --git a/src/renderer/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index e3184b873..865e93ff8 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -540,7 +540,7 @@ export class TerminalSessionManager { } } - scrollViewportByWheel(deltaY: number, deltaMode: number): boolean { + scrollViewportFromWheelDelta(deltaY: number, deltaMode: number): boolean { const lineDelta = this.normalizeWheelDeltaToLines(deltaY, deltaMode); if (!Number.isFinite(lineDelta) || lineDelta === 0) return false; @@ -549,7 +549,7 @@ export class TerminalSessionManager { this.wheelLineRemainder = totalDelta - wholeLines; if (wholeLines === 0) return false; - return this.scrollViewportByLines(wholeLines); + return this.scrollViewportByLineDelta(wholeLines); } registerActivityListener(listener: () => void): () => void { @@ -779,7 +779,7 @@ export class TerminalSessionManager { this.terminal.options.fontFamily = selected ? `${selected}, ${FALLBACK_FONTS}` : FALLBACK_FONTS; } - private scrollViewportByLines(lines: number): boolean { + private scrollViewportByLineDelta(lines: number): boolean { try { const buffer = this.terminal.buffer?.active; if (