feat: add scrollbar and fix selection on scroll
This commit is contained in:
@@ -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<Props, State> {
|
||||
// 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
|
||||
|
||||
@@ -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<Styles, 'textWrap'> & {
|
||||
@@ -31,6 +32,9 @@ export type Props = Except<Styles, 'textWrap'> & {
|
||||
* 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<Styles, 'textWrap'> & {
|
||||
* `<Box>` is an essential Ink component to build your layout. It's like `<div style="display: flex">` 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 = (
|
||||
<ink-box
|
||||
@@ -231,8 +253,11 @@ function Box(t0: Props) {
|
||||
onFocusCapture={onFocusCapture}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseDrag={onMouseDrag}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseUp={onMouseUp}
|
||||
ref={ref}
|
||||
style={t3}
|
||||
tabIndex={tabIndex}
|
||||
@@ -240,23 +265,26 @@ function Box(t0: Props) {
|
||||
{children}
|
||||
</ink-box>
|
||||
)
|
||||
$[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
|
||||
|
||||
@@ -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<string>([
|
||||
'onPasteCapture',
|
||||
'onResize',
|
||||
'onClick',
|
||||
'onMouseDown',
|
||||
'onMouseUp',
|
||||
'onMouseDrag',
|
||||
'onMouseEnter',
|
||||
'onMouseLeave'
|
||||
])
|
||||
|
||||
18
ui-tui/packages/hermes-ink/src/ink/events/mouse-event.ts
Normal file
18
ui-tui/packages/hermes-ink/src/ink/events/mouse-event.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<ScrollBoxHandle | null>; 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<number | null>(null)
|
||||
|
||||
const s = scrollRef.current
|
||||
const vp = Math.max(0, s?.getViewportHeight() ?? 0)
|
||||
|
||||
if (!vp) {
|
||||
return <Box width={1} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
onMouseDown={(e: { localRow?: number }) => {
|
||||
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 (
|
||||
<Text color={color} dimColor={!active && !hover} key={i}>
|
||||
{scrollable ? (active ? '┃' : '│') : ' '}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<AlternateScreen mouseTracking={MOUSE_TRACKING}>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<ScrollBox flexDirection="column" flexGrow={1} ref={scrollRef} stickyScroll width="100%">
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{virtualHistory.topSpacer > 0 ? <Box height={virtualHistory.topSpacer} /> : null}
|
||||
<Box flexDirection="row" flexGrow={1}>
|
||||
<ScrollBox flexDirection="column" flexGrow={1} flexShrink={1} ref={scrollRef} stickyScroll>
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{virtualHistory.topSpacer > 0 ? <Box height={virtualHistory.topSpacer} /> : null}
|
||||
|
||||
{visibleHistory.map(row => (
|
||||
<Box flexDirection="column" key={row.key} ref={virtualHistory.measureRef(row.key)}>
|
||||
{row.msg.kind === 'intro' && row.msg.info ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Banner t={theme} />
|
||||
<SessionPanel info={row.msg.info} sid={sid} t={theme} />
|
||||
</Box>
|
||||
) : row.msg.kind === 'panel' && row.msg.panelData ? (
|
||||
<Panel sections={row.msg.panelData.sections} t={theme} title={row.msg.panelData.title} />
|
||||
) : (
|
||||
<MessageLine cols={cols} compact={compact} detailsMode={detailsMode} msg={row.msg} t={theme} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{visibleHistory.map(row => (
|
||||
<Box flexDirection="column" key={row.key} ref={virtualHistory.measureRef(row.key)}>
|
||||
{row.msg.kind === 'intro' && row.msg.info ? (
|
||||
<Box flexDirection="column" paddingTop={1}>
|
||||
<Banner t={theme} />
|
||||
<SessionPanel info={row.msg.info} sid={sid} t={theme} />
|
||||
</Box>
|
||||
) : row.msg.kind === 'panel' && row.msg.panelData ? (
|
||||
<Panel sections={row.msg.panelData.sections} t={theme} title={row.msg.panelData.title} />
|
||||
) : (
|
||||
<MessageLine cols={cols} compact={compact} detailsMode={detailsMode} msg={row.msg} t={theme} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{virtualHistory.bottomSpacer > 0 ? <Box height={virtualHistory.bottomSpacer} /> : null}
|
||||
{virtualHistory.bottomSpacer > 0 ? <Box height={virtualHistory.bottomSpacer} /> : null}
|
||||
|
||||
{showProgressArea && (
|
||||
<ToolTrail
|
||||
activity={activity}
|
||||
busy={busy && !streaming}
|
||||
detailsMode={detailsMode}
|
||||
reasoning={reasoning}
|
||||
reasoningActive={reasoningActive}
|
||||
reasoningStreaming={reasoningStreaming}
|
||||
t={theme}
|
||||
tools={tools}
|
||||
trail={turnTrail}
|
||||
/>
|
||||
)}
|
||||
{showProgressArea && (
|
||||
<ToolTrail
|
||||
activity={activity}
|
||||
busy={busy && !streaming}
|
||||
detailsMode={detailsMode}
|
||||
reasoning={reasoning}
|
||||
reasoningActive={reasoningActive}
|
||||
reasoningStreaming={reasoningStreaming}
|
||||
t={theme}
|
||||
tools={tools}
|
||||
trail={turnTrail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showStreamingArea && (
|
||||
<MessageLine
|
||||
cols={cols}
|
||||
compact={compact}
|
||||
detailsMode={detailsMode}
|
||||
isStreaming
|
||||
msg={{ role: 'assistant', text: streaming }}
|
||||
t={theme}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollBox>
|
||||
{showStreamingArea && (
|
||||
<MessageLine
|
||||
cols={cols}
|
||||
compact={compact}
|
||||
detailsMode={detailsMode}
|
||||
isStreaming
|
||||
msg={{ role: 'assistant', text: streaming }}
|
||||
t={theme}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollBox>
|
||||
|
||||
<StickyPromptTracker
|
||||
messages={historyItems}
|
||||
offsets={virtualHistory.offsets}
|
||||
onChange={setStickyPrompt}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
<NoSelect flexShrink={0} marginLeft={1}>
|
||||
<TranscriptScrollbar scrollRef={scrollRef} t={theme} />
|
||||
</NoSelect>
|
||||
|
||||
<StickyPromptTracker
|
||||
messages={historyItems}
|
||||
offsets={virtualHistory.offsets}
|
||||
onChange={setStickyPrompt}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
|
||||
{clarify && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user