mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
Add terminal jump-to-present control (#660)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fb5afc3aab
commit
d256879f3e
4 changed files with 161 additions and 0 deletions
|
|
@ -3,6 +3,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
|
|||
import { createPortal } from 'react-dom'
|
||||
import type { CSSProperties } from 'react'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
DEFAULT_TERMINAL_DIVIDER_DARK,
|
||||
|
|
@ -62,6 +63,7 @@ export default function TerminalPane({
|
|||
)
|
||||
const paneTransportsRef = useRef<Map<number, PtyTransport>>(new Map())
|
||||
const panePtyBindingsRef = useRef<Map<number, IDisposable>>(new Map())
|
||||
const paneScrollListenerDisposablesRef = useRef<Map<number, IDisposable>>(new Map())
|
||||
const pendingWritesRef = useRef<Map<number, string>>(new Map())
|
||||
const isActiveRef = useRef(isActive)
|
||||
isActiveRef.current = isActive
|
||||
|
|
@ -75,6 +77,9 @@ export default function TerminalPane({
|
|||
const searchStateRef = useRef<SearchState>({ query: '', caseSensitive: false, regex: false })
|
||||
const [closeConfirmPaneId, setCloseConfirmPaneId] = useState<number | null>(null)
|
||||
const [terminalError, setTerminalError] = useState<string | null>(null)
|
||||
const [paneJumpToPresentVisible, setPaneJumpToPresentVisible] = useState<Record<number, boolean>>(
|
||||
{}
|
||||
)
|
||||
|
||||
// Pane title state — keyed by ephemeral paneId, persisted via titlesByLeafId
|
||||
// in the layout snapshot. Ref keeps persistLayoutSnapshot closures fresh.
|
||||
|
|
@ -329,6 +334,7 @@ export default function TerminalPane({
|
|||
paneFontSizesRef,
|
||||
paneTransportsRef,
|
||||
panePtyBindingsRef,
|
||||
paneScrollListenerDisposablesRef,
|
||||
pendingWritesRef,
|
||||
isActiveRef,
|
||||
isVisibleRef,
|
||||
|
|
@ -349,6 +355,7 @@ export default function TerminalPane({
|
|||
setExpandedPane,
|
||||
syncExpandedLayout,
|
||||
persistLayoutSnapshot,
|
||||
setPaneJumpToPresentVisible,
|
||||
setPaneTitles,
|
||||
paneTitlesRef,
|
||||
setRenamingPaneId
|
||||
|
|
@ -479,6 +486,18 @@ export default function TerminalPane({
|
|||
toggleExpandPane
|
||||
})
|
||||
|
||||
const handleJumpToPresent = useCallback((paneId: number): void => {
|
||||
const manager = managerRef.current
|
||||
const pane = manager?.getPanes().find((candidate) => candidate.id === paneId)
|
||||
if (!manager || !pane) {
|
||||
return
|
||||
}
|
||||
|
||||
manager.setActivePane(paneId, { focus: false })
|
||||
pane.terminal.scrollToBottom()
|
||||
pane.terminal.focus()
|
||||
}, [])
|
||||
|
||||
// Intercept paste at the keydown level (Cmd+V / Ctrl+V) AND as a fallback
|
||||
// on the paste event. We must handle keydown because Chromium does not fire
|
||||
// a paste event when the clipboard contains only image data (no text
|
||||
|
|
@ -844,6 +863,26 @@ export default function TerminalPane({
|
|||
/>,
|
||||
activePane.container
|
||||
)}
|
||||
{managerRef.current?.getPanes().map((pane) => {
|
||||
if (!paneJumpToPresentVisible[pane.id]) {
|
||||
return null
|
||||
}
|
||||
return createPortal(
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll to bottom"
|
||||
title="Scroll to bottom"
|
||||
className="absolute bottom-3 right-3 z-20 inline-flex h-8 items-center gap-1.5 rounded-md border border-zinc-700/80 bg-zinc-900/90 pl-2 pr-3.5 text-xs font-medium text-zinc-100 shadow-lg backdrop-blur-sm transition hover:bg-zinc-800/95"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => handleJumpToPresent(pane.id)}
|
||||
>
|
||||
<ChevronDown className="size-3.5 shrink-0" />
|
||||
<span>Scroll to bottom</span>
|
||||
</button>,
|
||||
pane.container,
|
||||
`jump-to-present-${pane.id}`
|
||||
)
|
||||
})}
|
||||
<TerminalContextMenu
|
||||
open={contextMenu.open}
|
||||
onOpenChange={contextMenu.setOpen}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getPaneJumpState, SCROLLED_AWAY_THRESHOLD } from './jump-to-present'
|
||||
|
||||
describe('getPaneJumpState', () => {
|
||||
it('stays hidden when the viewport is already at the live bottom', () => {
|
||||
expect(getPaneJumpState({ baseY: 24, viewportY: 24 })).toEqual({
|
||||
showJumpToPresent: false,
|
||||
hiddenLineCount: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('stays hidden within the scroll threshold', () => {
|
||||
expect(getPaneJumpState({ baseY: 24, viewportY: 24 - SCROLLED_AWAY_THRESHOLD })).toEqual({
|
||||
showJumpToPresent: false,
|
||||
hiddenLineCount: SCROLLED_AWAY_THRESHOLD
|
||||
})
|
||||
})
|
||||
|
||||
it('shows once the viewport is meaningfully above the bottom', () => {
|
||||
expect(getPaneJumpState({ baseY: 24, viewportY: 24 - SCROLLED_AWAY_THRESHOLD - 1 })).toEqual({
|
||||
showJumpToPresent: true,
|
||||
hiddenLineCount: SCROLLED_AWAY_THRESHOLD + 1
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps negative hidden-line counts caused by transient viewport races', () => {
|
||||
expect(getPaneJumpState({ baseY: 8, viewportY: 12 })).toEqual({
|
||||
showJumpToPresent: false,
|
||||
hiddenLineCount: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
25
src/renderer/src/components/terminal-pane/jump-to-present.ts
Normal file
25
src/renderer/src/components/terminal-pane/jump-to-present.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Why: raised from 1 to 5 so the button only appears after a deliberate scroll
|
||||
// (a few visible lines), not on any transient viewport nudge.
|
||||
export const SCROLLED_AWAY_THRESHOLD = 20
|
||||
|
||||
export type PaneJumpState = {
|
||||
showJumpToPresent: boolean
|
||||
hiddenLineCount: number
|
||||
}
|
||||
|
||||
type TerminalBufferViewport = {
|
||||
baseY: number
|
||||
viewportY: number
|
||||
}
|
||||
|
||||
// Why: the design intentionally tolerates a one-line delta before showing the
|
||||
// affordance because xterm can transiently perturb viewportY during resize,
|
||||
// split, and refit flows. Without this threshold, the button would flash while
|
||||
// Orca is preserving the user's current bottom-of-buffer position.
|
||||
export function getPaneJumpState(buffer: TerminalBufferViewport): PaneJumpState {
|
||||
const hiddenLineCount = Math.max(0, buffer.baseY - buffer.viewportY)
|
||||
return {
|
||||
showJumpToPresent: hiddenLineCount > SCROLLED_AWAY_THRESHOLD,
|
||||
hiddenLineCount
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ import { connectPanePty } from './pty-connection'
|
|||
import type { PtyTransport } from './pty-transport'
|
||||
import { fitAndFocusPanes, fitPanes } from './pane-helpers'
|
||||
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { getPaneJumpState } from './jump-to-present'
|
||||
|
||||
type UseTerminalPaneLifecycleDeps = {
|
||||
tabId: string
|
||||
|
|
@ -49,6 +50,7 @@ type UseTerminalPaneLifecycleDeps = {
|
|||
paneFontSizesRef: React.RefObject<Map<number, number>>
|
||||
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
|
||||
panePtyBindingsRef: React.RefObject<Map<number, IDisposable>>
|
||||
paneScrollListenerDisposablesRef: React.RefObject<Map<number, IDisposable>>
|
||||
pendingWritesRef: React.RefObject<Map<number, string>>
|
||||
isActiveRef: React.RefObject<boolean>
|
||||
isVisibleRef: React.RefObject<boolean>
|
||||
|
|
@ -72,6 +74,7 @@ type UseTerminalPaneLifecycleDeps = {
|
|||
setExpandedPane: (paneId: number | null) => void
|
||||
syncExpandedLayout: () => void
|
||||
persistLayoutSnapshot: () => void
|
||||
setPaneJumpToPresentVisible: React.Dispatch<React.SetStateAction<Record<number, boolean>>>
|
||||
setPaneTitles: React.Dispatch<React.SetStateAction<Record<number, string>>>
|
||||
paneTitlesRef: React.RefObject<Record<number, string>>
|
||||
setRenamingPaneId: React.Dispatch<React.SetStateAction<number | null>>
|
||||
|
|
@ -95,6 +98,7 @@ export function useTerminalPaneLifecycle({
|
|||
paneFontSizesRef,
|
||||
paneTransportsRef,
|
||||
panePtyBindingsRef,
|
||||
paneScrollListenerDisposablesRef,
|
||||
pendingWritesRef,
|
||||
isActiveRef,
|
||||
isVisibleRef,
|
||||
|
|
@ -115,6 +119,7 @@ export function useTerminalPaneLifecycle({
|
|||
setExpandedPane,
|
||||
syncExpandedLayout,
|
||||
persistLayoutSnapshot,
|
||||
setPaneJumpToPresentVisible,
|
||||
setPaneTitles,
|
||||
paneTitlesRef,
|
||||
setRenamingPaneId
|
||||
|
|
@ -122,6 +127,7 @@ export function useTerminalPaneLifecycle({
|
|||
const systemPrefersDarkRef = useRef(systemPrefersDark)
|
||||
systemPrefersDarkRef.current = systemPrefersDark
|
||||
const linkProviderDisposablesRef = useRef(new Map<number, IDisposable>())
|
||||
const paneJumpToPresentVisibleRef = useRef(new Map<number, boolean>())
|
||||
|
||||
const applyAppearance = (manager: PaneManager): void => {
|
||||
const currentSettings = settingsRef.current
|
||||
|
|
@ -146,6 +152,7 @@ export function useTerminalPaneLifecycle({
|
|||
const expandedStyleSnapshots = expandedStyleSnapshotRef.current
|
||||
const paneTransports = paneTransportsRef.current
|
||||
const panePtyBindings = panePtyBindingsRef.current
|
||||
const paneScrollListenerDisposables = paneScrollListenerDisposablesRef.current
|
||||
const pendingWrites = pendingWritesRef.current
|
||||
const linkDisposables = linkProviderDisposablesRef.current
|
||||
const worktreePath =
|
||||
|
|
@ -257,6 +264,46 @@ export function useTerminalPaneLifecycle({
|
|||
restoredLeafId
|
||||
})
|
||||
panePtyBindings.set(pane.id, panePtyBinding)
|
||||
const syncPaneJumpStateFor = (): void => {
|
||||
const nextShowJumpToPresent = getPaneJumpState(
|
||||
pane.terminal.buffer.active
|
||||
).showJumpToPresent
|
||||
if (paneJumpToPresentVisibleRef.current.get(pane.id) === nextShowJumpToPresent) {
|
||||
return
|
||||
}
|
||||
paneJumpToPresentVisibleRef.current.set(pane.id, nextShowJumpToPresent)
|
||||
setPaneJumpToPresentVisible((prev) => ({
|
||||
...prev,
|
||||
[pane.id]: nextShowJumpToPresent
|
||||
}))
|
||||
}
|
||||
const scrollDisposable = pane.terminal.onScroll(syncPaneJumpStateFor)
|
||||
// Why: when the user is paused above the live bottom, new PTY output
|
||||
// advances baseY without moving viewportY. Listening only to onScroll
|
||||
// leaves the affordance stale until the user scrolls again, so writes
|
||||
// must also trigger a sync.
|
||||
const writeParsedDisposable = pane.terminal.onWriteParsed(syncPaneJumpStateFor)
|
||||
// Why: fitAddon.fit() drives terminal.resize(), which can reflow the
|
||||
// wrapped buffer and change baseY/viewportY without any explicit user
|
||||
// scroll. Watching xterm's resize event keeps the affordance aligned
|
||||
// with split, zoom, and container-resize refits.
|
||||
const resizeDisposable = pane.terminal.onResize(syncPaneJumpStateFor)
|
||||
paneScrollListenerDisposables.set(pane.id, {
|
||||
dispose: () => {
|
||||
scrollDisposable.dispose()
|
||||
writeParsedDisposable.dispose()
|
||||
resizeDisposable.dispose()
|
||||
}
|
||||
})
|
||||
// Why: openTerminal() and the layout replay both schedule work into the
|
||||
// next frame. Deferring the first sync avoids reading a transient
|
||||
// viewportY before xterm finishes its initial fit/reflow pass.
|
||||
requestAnimationFrame(() => {
|
||||
if (!paneScrollListenerDisposables.has(pane.id)) {
|
||||
return
|
||||
}
|
||||
syncPaneJumpStateFor()
|
||||
})
|
||||
scheduleRuntimeGraphSync()
|
||||
queueResizeAll(true)
|
||||
},
|
||||
|
|
@ -281,9 +328,23 @@ export function useTerminalPaneLifecycle({
|
|||
transport.destroy?.()
|
||||
paneTransportsRef.current.delete(paneId)
|
||||
}
|
||||
const scrollDisposable = paneScrollListenerDisposables.get(paneId)
|
||||
if (scrollDisposable) {
|
||||
scrollDisposable.dispose()
|
||||
paneScrollListenerDisposables.delete(paneId)
|
||||
}
|
||||
paneJumpToPresentVisibleRef.current.delete(paneId)
|
||||
clearRuntimePaneTitle(tabId, paneId)
|
||||
paneFontSizesRef.current.delete(paneId)
|
||||
pendingWritesRef.current.delete(paneId)
|
||||
setPaneJumpToPresentVisible((prev) => {
|
||||
if (!(paneId in prev)) {
|
||||
return prev
|
||||
}
|
||||
const next = { ...prev }
|
||||
delete next[paneId]
|
||||
return next
|
||||
})
|
||||
// Clean up pane title state so closed panes don't leave stale entries.
|
||||
setPaneTitles((prev) => {
|
||||
if (!(paneId in prev)) {
|
||||
|
|
@ -468,6 +529,10 @@ export function useTerminalPaneLifecycle({
|
|||
disposable.dispose()
|
||||
}
|
||||
linkDisposables.clear()
|
||||
for (const disposable of paneScrollListenerDisposables.values()) {
|
||||
disposable.dispose()
|
||||
}
|
||||
paneScrollListenerDisposables.clear()
|
||||
for (const transport of paneTransports.values()) {
|
||||
if (tabStillExists && transport.getPtyId()) {
|
||||
// Why: moving a terminal tab between groups currently rehomes the
|
||||
|
|
|
|||
Loading…
Reference in a new issue