Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/renderer/components/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -397,8 +398,9 @@ const ChatInterface: React.FC<Props> = ({
};
}, [activeConversationId, conversations, task.id]);

// Ref to control terminal focus imperatively if needed
const terminalRef = useRef<{ focus: () => void }>(null);
// Ref to control terminal focus and viewport scrolling imperatively.
const terminalRef = useRef<TerminalPaneHandle>(null);
const handleTerminalViewportWheelForwarding = useTerminalViewportWheelForwarding(terminalRef);

// Auto-focus terminal when switching to this task
useEffect(() => {
Expand Down Expand Up @@ -1114,7 +1116,10 @@ const ChatInterface: React.FC<Props> = ({
})()}
</div>
</div>
<div className="mt-4 min-h-0 flex-1 px-6">
<div
className="mt-4 min-h-0 flex-1 px-6"
onWheelCapture={handleTerminalViewportWheelForwarding}
>
<div
className={`mx-auto h-full max-w-4xl overflow-hidden rounded-md ${
agent === 'charm'
Expand Down
14 changes: 10 additions & 4 deletions src/renderer/components/MultiAgentTask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { type Agent } from '../types';
import { Button } from './ui/button';
import { Input } from './ui/input';
import OpenInMenu from './titlebar/OpenInMenu';
import { TerminalPane } from './TerminalPane';
import { TerminalPane, type TerminalPaneHandle } from './TerminalPane';
import { agentMeta } from '@/providers/meta';
import { agentAssets } from '@/providers/assets';
import AgentLogo from './AgentLogo';
Expand All @@ -16,6 +16,7 @@ import { BUSY_HOLD_MS, CLEAR_BUSY_MS, INJECT_ENTER_DELAY_MS } from '@/lib/activi
import { CornerDownLeft } from 'lucide-react';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip';
import { useAutoScrollOnTaskSwitch } from '@/hooks/useAutoScrollOnTaskSwitch';
import { useTerminalViewportWheelForwarding } from '@/hooks/useTerminalViewportWheelForwarding';
import { getTaskEnvVars } from '@shared/task/envVars';
import { rpc } from '@/lib/rpc';

Expand Down Expand Up @@ -399,8 +400,10 @@ const MultiAgentTask: React.FC<Props> = ({
activityStore.setTaskBusy(task.id, anyBusy);
}, [variantBusy, task.id]);

// Ref to the active terminal
const activeTerminalRef = useRef<{ focus: () => void }>(null);
// Ref to control terminal focus and viewport scrolling imperatively.
const activeTerminalRef = useRef<TerminalPaneHandle>(null);
const handleTerminalViewportWheelForwarding =
useTerminalViewportWheelForwarding(activeTerminalRef);

// Auto-scroll and focus when task or active tab changes
useEffect(() => {
Expand Down Expand Up @@ -536,7 +539,10 @@ const MultiAgentTask: React.FC<Props> = ({
</div>
</TooltipProvider>
</div>
<div className="min-h-0 flex-1 px-6 pt-4">
<div
className="min-h-0 flex-1 px-6 pt-4"
onWheelCapture={handleTerminalViewportWheelForwarding}
>
<div
className={`mx-auto h-full max-w-4xl overflow-hidden rounded-md ${
v.agent === 'mistral'
Expand Down
9 changes: 8 additions & 1 deletion src/renderer/components/TerminalPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { terminalSessionRegistry } from '../terminal/SessionRegistry';
import type { SessionTheme } from '../terminal/TerminalSessionManager';
import { log } from '../lib/logger';

export type TerminalPaneHandle = {
focus: () => void;
scrollViewportFromWheelDelta: (deltaY: number, deltaMode: number) => boolean;
};

type Props = {
id: string;
cwd?: string;
Expand Down Expand Up @@ -30,7 +35,7 @@ type Props = {
onFirstMessage?: (message: string) => void;
};

const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>(
const TerminalPaneComponent = forwardRef<TerminalPaneHandle, Props>(
(
{
id,
Expand Down Expand Up @@ -112,6 +117,8 @@ const TerminalPaneComponent = forwardRef<{ focus: () => void }, Props>(
focus: () => {
sessionRef.current?.focus();
},
scrollViewportFromWheelDelta: (deltaY: number, deltaMode: number) =>
sessionRef.current?.scrollViewportFromWheelDelta(deltaY, deltaMode) ?? false,
}),
[]
);
Expand Down
25 changes: 25 additions & 0 deletions src/renderer/hooks/useTerminalViewportWheelForwarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useCallback, type RefObject, type WheelEventHandler } from 'react';
import type { TerminalPaneHandle } from '../components/TerminalPane';

export function useTerminalViewportWheelForwarding(
terminalRef: RefObject<TerminalPaneHandle | null>
): WheelEventHandler<HTMLDivElement> {
return useCallback(
(event) => {
if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return;
if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
Comment on lines +9 to +10
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

metaKey (Cmd+scroll) not guarded against

The hook correctly short-circuits for ctrlKey (pinch-zoom on some platforms) and for dominant-horizontal swipes, but metaKey (Cmd+scroll) is not checked. On macOS inside Electron, Cmd+scroll is sometimes used for zoom or OS-level gestures. Forwarding those events to the terminal scroll path will produce unexpected behaviour.

Suggested change
if (!Number.isFinite(event.deltaY) || event.deltaY === 0) return;
if (event.ctrlKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
if (event.ctrlKey || event.metaKey || 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]
);
}
67 changes: 67 additions & 0 deletions src/renderer/terminal/TerminalSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export class TerminalSessionManager {
private selectionChangeDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private terminalConfigFontSize: number | null = null;
private lastTheme: SessionTheme;
private wheelLineRemainder = 0;

// Timing for startup performance measurement
private initStartTime: number = 0;
Expand Down Expand Up @@ -539,6 +540,18 @@ export class TerminalSessionManager {
}
}

scrollViewportFromWheelDelta(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;
Comment on lines +547 to +549
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accumulated remainder not cleared on direction reversal

wheelLineRemainder is a running carry-over value that is never reset when the scroll direction changes. This means after a sustained scroll in one direction that stops on a fractional boundary, reversing direction will silently "burn through" up to one line's worth of accumulated carry before the terminal actually moves the other way.

For example, if wheelLineRemainder = 0.85 (scrolling down) and the user immediately starts scrolling up with a single-line pixel delta of -0.15:

totalDelta = 0.85 + (-0.15) = 0.70   →   wholeLines = 0   (no movement)

The terminal won't respond until the carry is fully cancelled. Consider resetting the remainder when the incoming lineDelta has the opposite sign to wheelLineRemainder:

const totalDelta = this.wheelLineRemainder + lineDelta;

could become:

// Reset carry on direction reversal to avoid an invisible "dead zone"
if ((this.wheelLineRemainder > 0 && lineDelta < 0) || (this.wheelLineRemainder < 0 && lineDelta > 0)) {
  this.wheelLineRemainder = 0;
}
const totalDelta = this.wheelLineRemainder + lineDelta;

This is a UX polish issue rather than a correctness bug, but it can feel noticeably sluggish on trackpads where sub-line scroll events are very common.


if (wholeLines === 0) return false;
return this.scrollViewportByLineDelta(wholeLines);
}

registerActivityListener(listener: () => void): () => void {
this.activityListeners.add(listener);
return () => {
Expand Down Expand Up @@ -766,6 +779,60 @@ export class TerminalSessionManager {
this.terminal.options.fontFamily = selected ? `${selected}, ${FALLBACK_FONTS}` : FALLBACK_FONTS;
}

private scrollViewportByLineDelta(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(() => {
Expand Down
Loading