feat: add scrollbar and fix selection on scroll

This commit is contained in:
Brooklyn Nicholson
2026-04-14 14:34:33 -05:00
parent 9804aa7443
commit 52c11d172a
10 changed files with 397 additions and 126 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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'
])

View 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
}
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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 && (

View File

@@ -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

View File

@@ -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)

View File

@@ -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) {