diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index d288d28bad..3a0381a729 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -5,6 +5,7 @@ import { logForDebugging } from '../../utils/debug.js' import { stopCapturingEarlyInput } from '../../utils/earlyInput.js' import { isMouseClicksDisabled } from '../../utils/fullscreen.js' import { logError } from '../../utils/log.js' +import type { DOMElement } from '../dom.js' import { EventEmitter } from '../events/emitter.js' import { InputEvent } from '../events/input-event.js' import { TerminalFocusEvent } from '../events/terminal-focus-event.js' @@ -67,6 +68,9 @@ type Props = { // No-op (returns false) outside fullscreen mode (Ink.dispatchClick // gates on altScreenActive). readonly onClickAt: (col: number, row: number) => boolean + readonly onMouseDownAt: (col: number, row: number, button: number) => DOMElement | undefined + readonly onMouseUpAt: (target: DOMElement, col: number, row: number, button: number) => void + readonly onMouseDragAt: (target: DOMElement, col: number, row: number, button: number) => void // Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over // DOM elements. Called for mode-1003 motion events with no button held. // No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive). @@ -155,6 +159,7 @@ export default class App extends PureComponent { // repeat events (drag-then-release at same cell, etc.). lastHoverCol = -1 lastHoverRow = -1 + mouseCaptureTarget: DOMElement | undefined // Timestamp of last stdin chunk. Used to detect long gaps (tmux attach, // ssh reconnect, laptop wake) and trigger terminal mode re-assert. @@ -578,6 +583,11 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { if (m.action === 'press') { if ((m.button & 0x20) !== 0 && baseButton === 3) { + if (app.mouseCaptureTarget) { + app.props.onMouseUpAt(app.mouseCaptureTarget, col, row, baseButton) + app.mouseCaptureTarget = undefined + } + // Mode-1003 motion with no button held. Dispatch hover; skip the // rest of this handler (no selection, no click-count side effects). // Lost-release recovery: no-button motion while isDragging=true means @@ -611,6 +621,12 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { } if ((m.button & 0x20) !== 0) { + if (app.mouseCaptureTarget) { + app.props.onMouseDragAt(app.mouseCaptureTarget, col, row, baseButton) + + return + } + // Drag motion: mode-aware extension (char/word/line). onSelectionDrag // calls notifySelectionChange internally — no extra onSelectionChange. app.props.onSelectionDrag(col, row) @@ -628,6 +644,15 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { app.props.onSelectionChange() } + const capture = app.props.onMouseDownAt(col, row, baseButton) + + if (capture) { + app.mouseCaptureTarget = capture + app.clickCount = 0 + + return + } + // Fresh left press. Detect multi-click HERE (not on release) so the // word/line highlight appears immediately and a subsequent drag can // extend by word/line like native macOS. Previously detected on @@ -677,6 +702,13 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // isDragging=true and leave drag-to-scroll's timer running until the // scroll boundary. Only act on non-left releases when we ARE dragging // (so an unrelated middle/right click-release doesn't touch selection). + if (app.mouseCaptureTarget) { + app.props.onMouseUpAt(app.mouseCaptureTarget, col, row, baseButton) + app.mouseCaptureTarget = undefined + + return + } + if (baseButton !== 0) { if (!sel.isDragging) { return diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx index 68ba67ea54..408d23c227 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx @@ -8,6 +8,7 @@ import type { DOMElement } from '../dom.js' import type { ClickEvent } from '../events/click-event.js' import type { FocusEvent } from '../events/focus-event.js' import type { KeyboardEvent } from '../events/keyboard-event.js' +import type { MouseEvent } from '../events/mouse-event.js' import type { Styles } from '../styles.js' import * as warn from '../warn.js' export type Props = Except & { @@ -31,6 +32,9 @@ export type Props = Except & { * ancestors; call `event.stopImmediatePropagation()` to stop bubbling. */ onClick?: (event: ClickEvent) => void + onMouseDown?: (event: MouseEvent) => void + onMouseUp?: (event: MouseEvent) => void + onMouseDrag?: (event: MouseEvent) => void onFocus?: (event: FocusEvent) => void onFocusCapture?: (event: FocusEvent) => void onBlur?: (event: FocusEvent) => void @@ -52,7 +56,7 @@ export type Props = Except & { * `` is an essential Ink component to build your layout. It's like `
` in the browser. */ function Box(t0: Props) { - const $ = _c(42) + const $ = _c(48) let autoFocus let children let flexDirection @@ -66,8 +70,11 @@ function Box(t0: Props) { let onFocusCapture let onKeyDown let onKeyDownCapture + let onMouseDown + let onMouseDrag let onMouseEnter let onMouseLeave + let onMouseUp let ref let style let tabIndex @@ -87,11 +94,14 @@ function Box(t0: Props) { onFocusCapture: t11, onBlur: t12, onBlurCapture: t13, - onMouseEnter: t14, - onMouseLeave: t15, - onKeyDown: t16, - onKeyDownCapture: t17, - ...t18 + onMouseDown: t14, + onMouseUp: t15, + onMouseDrag: t16, + onMouseEnter: t17, + onMouseLeave: t18, + onKeyDown: t19, + onKeyDownCapture: t20, + ...t21 } = t0 children = t1 @@ -103,11 +113,14 @@ function Box(t0: Props) { onFocusCapture = t11 onBlur = t12 onBlurCapture = t13 - onMouseEnter = t14 - onMouseLeave = t15 - onKeyDown = t16 - onKeyDownCapture = t17 - style = t18 + onMouseDown = t14 + onMouseUp = t15 + onMouseDrag = t16 + onMouseEnter = t17 + onMouseLeave = t18 + onKeyDown = t19 + onKeyDownCapture = t20 + style = t21 flexWrap = t2 === undefined ? 'nowrap' : t2 flexDirection = t3 === undefined ? 'row' : t3 flexGrow = t4 === undefined ? 0 : t4 @@ -143,11 +156,14 @@ function Box(t0: Props) { $[11] = onFocusCapture $[12] = onKeyDown $[13] = onKeyDownCapture - $[14] = onMouseEnter - $[15] = onMouseLeave - $[16] = ref - $[17] = style - $[18] = tabIndex + $[14] = onMouseDown + $[15] = onMouseUp + $[16] = onMouseDrag + $[17] = onMouseEnter + $[18] = onMouseLeave + $[19] = ref + $[20] = style + $[21] = tabIndex } else { autoFocus = $[1] children = $[2] @@ -162,11 +178,14 @@ function Box(t0: Props) { onFocusCapture = $[11] onKeyDown = $[12] onKeyDownCapture = $[13] - onMouseEnter = $[14] - onMouseLeave = $[15] - ref = $[16] - style = $[17] - tabIndex = $[18] + onMouseDown = $[14] + onMouseUp = $[15] + onMouseDrag = $[16] + onMouseEnter = $[17] + onMouseLeave = $[18] + ref = $[19] + style = $[20] + tabIndex = $[21] } const t1 = style.overflowX ?? style.overflow ?? 'visible' @@ -174,13 +193,13 @@ function Box(t0: Props) { let t3 if ( - $[19] !== flexDirection || - $[20] !== flexGrow || - $[21] !== flexShrink || - $[22] !== flexWrap || - $[23] !== style || - $[24] !== t1 || - $[25] !== t2 + $[22] !== flexDirection || + $[23] !== flexGrow || + $[24] !== flexShrink || + $[25] !== flexWrap || + $[26] !== style || + $[27] !== t1 || + $[28] !== t2 ) { t3 = { flexWrap, @@ -191,35 +210,38 @@ function Box(t0: Props) { overflowX: t1, overflowY: t2 } - $[19] = flexDirection - $[20] = flexGrow - $[21] = flexShrink - $[22] = flexWrap - $[23] = style - $[24] = t1 - $[25] = t2 - $[26] = t3 + $[22] = flexDirection + $[23] = flexGrow + $[24] = flexShrink + $[25] = flexWrap + $[26] = style + $[27] = t1 + $[28] = t2 + $[29] = t3 } else { - t3 = $[26] + t3 = $[29] } let t4 if ( - $[27] !== autoFocus || - $[28] !== children || - $[29] !== onBlur || - $[30] !== onBlurCapture || - $[31] !== onClick || - $[32] !== onFocus || - $[33] !== onFocusCapture || - $[34] !== onKeyDown || - $[35] !== onKeyDownCapture || - $[36] !== onMouseEnter || - $[37] !== onMouseLeave || - $[38] !== ref || - $[39] !== t3 || - $[40] !== tabIndex + $[30] !== autoFocus || + $[31] !== children || + $[32] !== onBlur || + $[33] !== onBlurCapture || + $[34] !== onClick || + $[35] !== onFocus || + $[36] !== onFocusCapture || + $[37] !== onKeyDown || + $[38] !== onKeyDownCapture || + $[39] !== onMouseDown || + $[40] !== onMouseUp || + $[41] !== onMouseDrag || + $[42] !== onMouseEnter || + $[43] !== onMouseLeave || + $[44] !== ref || + $[45] !== t3 || + $[46] !== tabIndex ) { t4 = ( ) - $[27] = autoFocus - $[28] = children - $[29] = onBlur - $[30] = onBlurCapture - $[31] = onClick - $[32] = onFocus - $[33] = onFocusCapture - $[34] = onKeyDown - $[35] = onKeyDownCapture - $[36] = onMouseEnter - $[37] = onMouseLeave - $[38] = ref - $[39] = t3 - $[40] = tabIndex - $[41] = t4 + $[30] = autoFocus + $[31] = children + $[32] = onBlur + $[33] = onBlurCapture + $[34] = onClick + $[35] = onFocus + $[36] = onFocusCapture + $[37] = onKeyDown + $[38] = onKeyDownCapture + $[39] = onMouseDown + $[40] = onMouseUp + $[41] = onMouseDrag + $[42] = onMouseEnter + $[43] = onMouseLeave + $[44] = ref + $[45] = t3 + $[46] = tabIndex + $[47] = t4 } else { - t4 = $[41] + t4 = $[47] } return t4 diff --git a/ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts b/ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts index 42d59d0353..1750dbeee5 100644 --- a/ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts +++ b/ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts @@ -1,6 +1,7 @@ import type { ClickEvent } from './click-event.js' import type { FocusEvent } from './focus-event.js' import type { KeyboardEvent } from './keyboard-event.js' +import type { MouseEvent } from './mouse-event.js' import type { PasteEvent } from './paste-event.js' import type { ResizeEvent } from './resize-event.js' @@ -9,6 +10,7 @@ type FocusEventHandler = (event: FocusEvent) => void type PasteEventHandler = (event: PasteEvent) => void type ResizeEventHandler = (event: ResizeEvent) => void type ClickEventHandler = (event: ClickEvent) => void +type MouseEventHandler = (event: MouseEvent) => void type HoverEventHandler = () => void /** @@ -33,6 +35,9 @@ export type EventHandlerProps = { onResize?: ResizeEventHandler onClick?: ClickEventHandler + onMouseDown?: MouseEventHandler + onMouseUp?: MouseEventHandler + onMouseDrag?: MouseEventHandler onMouseEnter?: HoverEventHandler onMouseLeave?: HoverEventHandler } @@ -50,7 +55,10 @@ export const HANDLER_FOR_EVENT: Record< blur: { bubble: 'onBlur', capture: 'onBlurCapture' }, paste: { bubble: 'onPaste', capture: 'onPasteCapture' }, resize: { bubble: 'onResize' }, - click: { bubble: 'onClick' } + click: { bubble: 'onClick' }, + mousedown: { bubble: 'onMouseDown' }, + mouseup: { bubble: 'onMouseUp' }, + mousedrag: { bubble: 'onMouseDrag' } } /** @@ -68,6 +76,9 @@ export const EVENT_HANDLER_PROPS = new Set([ 'onPasteCapture', 'onResize', 'onClick', + 'onMouseDown', + 'onMouseUp', + 'onMouseDrag', 'onMouseEnter', 'onMouseLeave' ]) diff --git a/ui-tui/packages/hermes-ink/src/ink/events/mouse-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/mouse-event.ts new file mode 100644 index 0000000000..d42839b5fb --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/mouse-event.ts @@ -0,0 +1,18 @@ +import { Event } from './event.js' + +export class MouseEvent extends Event { + readonly col: number + readonly row: number + localCol = 0 + localRow = 0 + readonly cellIsBlank: boolean + readonly button: number + + constructor(col: number, row: number, cellIsBlank: boolean, button: number) { + super() + this.col = col + this.row = row + this.cellIsBlank = cellIsBlank + this.button = button + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/hit-test.ts b/ui-tui/packages/hermes-ink/src/ink/hit-test.ts index f0d9a31792..c23ce34fe0 100644 --- a/ui-tui/packages/hermes-ink/src/ink/hit-test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/hit-test.ts @@ -1,6 +1,7 @@ import type { DOMElement } from './dom.js' import { ClickEvent } from './events/click-event.js' import type { EventHandlerProps } from './events/event-handlers.js' +import { MouseEvent } from './events/mouse-event.js' import { nodeCache } from './node-cache.js' /** @@ -101,6 +102,51 @@ export function dispatchClick(root: DOMElement, col: number, row: number, cellIs return handled } +type MouseHandler = 'onMouseDown' | 'onMouseUp' | 'onMouseDrag' + +export function dispatchMouse( + root: DOMElement, + col: number, + row: number, + handlerName: MouseHandler, + button: number, + cellIsBlank = false, + target?: DOMElement +): DOMElement | undefined { + let node: DOMElement | undefined = target ?? hitTest(root, col, row) ?? undefined + + if (!node) { + return undefined + } + + const event = new MouseEvent(col, row, cellIsBlank, button) + let handled: DOMElement | undefined + + while (node) { + const handler = node._eventHandlers?.[handlerName] as ((event: MouseEvent) => void) | undefined + + if (handler) { + handled ??= node + const rect = nodeCache.get(node) + + if (rect) { + event.localCol = col - rect.x + event.localRow = row - rect.y + } + + handler(event) + + if (event.didStopImmediatePropagation()) { + return handled + } + } + + node = node.parentNode + } + + return handled +} + /** * Fire onMouseEnter/onMouseLeave as the pointer moves. Like DOM * mouseenter/mouseleave: does NOT bubble — moving between children does diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 96898cee31..ff2507ac65 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -22,7 +22,7 @@ import * as dom from './dom.js' import { KeyboardEvent } from './events/keyboard-event.js' import { FocusManager } from './focus.js' import { emptyFrame, type Frame, type FrameEvent } from './frame.js' -import { dispatchClick, dispatchHover } from './hit-test.js' +import { dispatchClick, dispatchHover, dispatchMouse } from './hit-test.js' import instances from './instances.js' import { LogUpdate } from './log-update.js' import { nodeCache } from './node-cache.js' @@ -1538,6 +1538,42 @@ export default class Ink { return dispatchClick(this.rootNode, col, row, blank) } + dispatchMouseDown(col: number, row: number, button: number): dom.DOMElement | undefined { + if (!this.altScreenActive) { + return undefined + } + + return dispatchMouse( + this.rootNode, + col, + row, + 'onMouseDown', + button, + isEmptyCellAt(this.frontFrame.screen, col, row) + ) + } + dispatchMouseUp(target: dom.DOMElement, col: number, row: number, button: number): void { + if (!this.altScreenActive) { + return + } + + dispatchMouse(this.rootNode, col, row, 'onMouseUp', button, isEmptyCellAt(this.frontFrame.screen, col, row), target) + } + dispatchMouseDrag(target: dom.DOMElement, col: number, row: number, button: number): void { + if (!this.altScreenActive) { + return + } + + dispatchMouse( + this.rootNode, + col, + row, + 'onMouseDrag', + button, + isEmptyCellAt(this.frontFrame.screen, col, row), + target + ) + } dispatchHover(col: number, row: number): void { if (!this.altScreenActive) { return @@ -1764,6 +1800,9 @@ export default class Ink { onCursorDeclaration={this.setCursorDeclaration} onExit={this.unmount} onHoverAt={this.dispatchHover} + onMouseDownAt={this.dispatchMouseDown} + onMouseDragAt={this.dispatchMouseDrag} + onMouseUpAt={this.dispatchMouseUp} onMultiClick={this.handleMultiClick} onOpenHyperlink={this.openHyperlink} onSelectionChange={this.notifySelectionChange} diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 86f5c7a2e2..ca65830053 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -99,14 +99,14 @@ const nextDetailsMode = (m: DetailsMode): DetailsMode => // ── Pure helpers ───────────────────────────────────────────────────── -const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) type PasteSnippet = { label: string; text: string } -const shortCwd = (cwd: string, max = 28) => { - const home = process.env.HOME - const path = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd +const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) - return path.length <= max ? path : `…${path.slice(-(max - 1))}` +const shortCwd = (cwd: string, max = 28) => { + const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd + + return p.length <= max ? p : `…${p.slice(-(max - 1))}` } const imageTokenMeta = (info: { height?: number; token_estimate?: number; width?: number } | null | undefined) => { @@ -332,6 +332,7 @@ function StickyPromptTracker({ if (!s) { return NaN } + const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) return s.isSticky() ? -1 - top : top @@ -356,6 +357,7 @@ function StickyPromptTracker({ if ((offsets[i] ?? 0) + 1 >= top) { continue } + text = userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() break @@ -368,6 +370,85 @@ function StickyPromptTracker({ return null } +function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject; t: Theme }) { + useSyncExternalStore( + useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), + () => { + const s = scrollRef.current + + if (!s) { + return NaN + } + + return `${s.getScrollTop() + s.getPendingDelta()}:${s.getViewportHeight()}:${s.getScrollHeight()}` + }, + () => '' + ) + + const [hover, setHover] = useState(false) + const [grab, setGrab] = useState(null) + + const s = scrollRef.current + const vp = Math.max(0, s?.getViewportHeight() ?? 0) + + if (!vp) { + return + } + + const total = Math.max(vp, s?.getScrollHeight() ?? vp) + const scrollable = total > vp + const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp + const travel = Math.max(1, vp - thumb) + const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) + const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0 + + const jump = (row: number, offset: number) => { + if (!s || !scrollable) { + return + } + s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp))) + } + + return ( + { + const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0)) + const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2) + setGrab(off) + jump(row, off) + }} + onMouseDrag={(e: { localRow?: number }) => + jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2)) + } + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + onMouseUp={() => setGrab(null)} + width={1} + > + {Array.from({ length: vp }, (_, i) => { + const active = i >= thumbTop && i < thumbTop + thumb + + const color = active + ? grab !== null + ? t.color.gold + : hover + ? t.color.amber + : t.color.bronze + : hover + ? t.color.bronze + : t.color.dim + + return ( + + {scrollable ? (active ? '┃' : '│') : ' '} + + ) + })} + + ) +} + // ── App ────────────────────────────────────────────────────────────── export function App({ gw }: { gw: GatewayClient }) { @@ -561,12 +642,16 @@ export function App({ gw }: { gw: GatewayClient }) { const scrollWithSelection = useCallback( (delta: number) => { const s = scrollRef.current - const sel = selection.getState() as - | { anchor?: { row: number }; focus?: { row: number }; isDragging?: boolean } - | null + + const sel = selection.getState() as { + anchor?: { row: number } + focus?: { row: number } + isDragging?: boolean + } | null if (!s || !sel?.anchor || !sel.focus) { s?.scrollBy(delta) + return } @@ -575,11 +660,13 @@ export function App({ gw }: { gw: GatewayClient }) { if (sel.anchor.row < top || sel.anchor.row > bottom) { s.scrollBy(delta) + return } if (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) { s.scrollBy(delta) + return } @@ -3065,60 +3152,66 @@ export function App({ gw }: { gw: GatewayClient }) { return ( - - - {virtualHistory.topSpacer > 0 ? : null} + + + + {virtualHistory.topSpacer > 0 ? : null} - {visibleHistory.map(row => ( - - {row.msg.kind === 'intro' && row.msg.info ? ( - - - - - ) : row.msg.kind === 'panel' && row.msg.panelData ? ( - - ) : ( - - )} - - ))} + {visibleHistory.map(row => ( + + {row.msg.kind === 'intro' && row.msg.info ? ( + + + + + ) : row.msg.kind === 'panel' && row.msg.panelData ? ( + + ) : ( + + )} + + ))} - {virtualHistory.bottomSpacer > 0 ? : null} + {virtualHistory.bottomSpacer > 0 ? : null} - {showProgressArea && ( - - )} + {showProgressArea && ( + + )} - {showStreamingArea && ( - - )} - - + {showStreamingArea && ( + + )} + + - + + + + + + {clarify && ( diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index e6e23cc45a..cfb91b0597 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -339,6 +339,7 @@ export function TextInput({ if (!focus) { return } + const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) setCur(next) curRef.current = next diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 418ee0c547..005d8cc4c9 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -190,6 +190,7 @@ export const ToolTrail = memo(function ToolTrail({ if (!tools.length || (detailsMode === 'collapsed' && !openTools)) { return } + const id = setInterval(() => setNow(Date.now()), 500) return () => clearInterval(id) diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 868a35c4af..4f91a386b4 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -46,6 +46,7 @@ export function useVirtualHistory( if (!s) { return NaN } + const b = Math.floor(s.getScrollTop() / QUANTUM) return s.isSticky() ? -b - 1 : b @@ -122,6 +123,7 @@ export function useVirtualHistory( if (!k) { continue } + const h = Math.ceil(nodes.current.get(k)?.yogaNode?.getComputedHeight?.() ?? 0) if (h > 0 && heights.current.get(k) !== h) {