feat: scroll aware sticky prompt
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
Box,
|
||||
NoSelect,
|
||||
ScrollBox,
|
||||
type ScrollBoxHandle,
|
||||
Text,
|
||||
useApp,
|
||||
useHasSelection,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
useSelection,
|
||||
useStdout
|
||||
} from '@hermes/ink'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
import { Banner, Panel, SessionPanel } from './components/branding.js'
|
||||
import { MaskedPrompt } from './components/maskedPrompt.js'
|
||||
@@ -44,7 +45,8 @@ import {
|
||||
pick,
|
||||
sameToolTrailGroup,
|
||||
stripTrailingPasteNewlines,
|
||||
toolTrailLabel
|
||||
toolTrailLabel,
|
||||
userDisplay
|
||||
} from './lib/text.js'
|
||||
import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js'
|
||||
import type {
|
||||
@@ -70,6 +72,7 @@ const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
|
||||
const LARGE_PASTE = { chars: 8000, lines: 80 }
|
||||
const MAX_HISTORY = 800
|
||||
const REASONING_PULSE_MS = 700
|
||||
const STREAM_BATCH_MS = 16
|
||||
const WHEEL_SCROLL_STEP = 3
|
||||
const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase())
|
||||
const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g
|
||||
@@ -78,14 +81,18 @@ const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded']
|
||||
|
||||
const parseDetailsMode = (v: unknown): DetailsMode | null => {
|
||||
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''
|
||||
|
||||
return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null
|
||||
}
|
||||
|
||||
const resolveDetailsMode = (d: any): DetailsMode =>
|
||||
parseDetailsMode(d?.details_mode)
|
||||
?? ({ full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[
|
||||
String(d?.thinking_mode ?? '').trim().toLowerCase()
|
||||
] ?? 'collapsed')
|
||||
parseDetailsMode(d?.details_mode) ??
|
||||
{ full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[
|
||||
String(d?.thinking_mode ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
] ??
|
||||
'collapsed'
|
||||
|
||||
const nextDetailsMode = (m: DetailsMode): DetailsMode =>
|
||||
DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]!
|
||||
@@ -98,6 +105,7 @@ 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
|
||||
|
||||
return path.length <= max ? path : `…${path.slice(-(max - 1))}`
|
||||
}
|
||||
|
||||
@@ -288,6 +296,78 @@ function PromptBox({ children, color }: { children: React.ReactNode; color: stri
|
||||
)
|
||||
}
|
||||
|
||||
const upperBound = (arr: ArrayLike<number>, target: number) => {
|
||||
let lo = 0
|
||||
let hi = arr.length
|
||||
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
|
||||
if (arr[mid]! <= target) {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
|
||||
return lo
|
||||
}
|
||||
|
||||
function StickyPromptTracker({
|
||||
messages,
|
||||
offsets,
|
||||
scrollRef,
|
||||
onChange
|
||||
}: {
|
||||
messages: readonly Msg[]
|
||||
offsets: ArrayLike<number>
|
||||
scrollRef: RefObject<ScrollBoxHandle | null>
|
||||
onChange: (text: string) => void
|
||||
}) {
|
||||
useSyncExternalStore(
|
||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]),
|
||||
() => {
|
||||
const s = scrollRef.current
|
||||
|
||||
if (!s) {
|
||||
return NaN
|
||||
}
|
||||
const top = Math.max(0, s.getScrollTop() + s.getPendingDelta())
|
||||
|
||||
return s.isSticky() ? -1 - top : top
|
||||
},
|
||||
() => NaN
|
||||
)
|
||||
|
||||
const s = scrollRef.current
|
||||
const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0))
|
||||
|
||||
let text = ''
|
||||
|
||||
if (!(s?.isSticky() ?? true) && messages.length) {
|
||||
const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1))
|
||||
|
||||
if (!(messages[first]?.role === 'user' && (offsets[first] ?? 0) + 1 >= top)) {
|
||||
for (let i = first - 1; i >= 0; i--) {
|
||||
if (messages[i]?.role !== 'user') {
|
||||
continue
|
||||
}
|
||||
|
||||
if ((offsets[i] ?? 0) + 1 >= top) {
|
||||
continue
|
||||
}
|
||||
text = userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim()
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => onChange(text), [onChange, text])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── App ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function App({ gw }: { gw: GatewayClient }) {
|
||||
@@ -343,6 +423,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
const [reasoningStreaming, setReasoningStreaming] = useState(false)
|
||||
const [statusBar, setStatusBar] = useState(true)
|
||||
const [lastUserMsg, setLastUserMsg] = useState('')
|
||||
const [stickyPrompt, setStickyPrompt] = useState('')
|
||||
const [pasteSnips, setPasteSnips] = useState<PasteSnippet[]>([])
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [turnTrail, setTurnTrail] = useState<string[]>([])
|
||||
@@ -371,11 +452,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
const colsRef = useRef(cols)
|
||||
const turnToolsRef = useRef<string[]>([])
|
||||
const persistedToolLabelsRef = useRef<Set<string>>(new Set())
|
||||
const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const reasoningTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const reasoningStreamingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const busyRef = useRef(busy)
|
||||
const sidRef = useRef<string | null>(sid)
|
||||
const scrollRef = useRef<import('@hermes/ink').ScrollBoxHandle | null>(null)
|
||||
const scrollRef = useRef<ScrollBoxHandle | null>(null)
|
||||
const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {})
|
||||
const configMtimeRef = useRef(0)
|
||||
colsRef.current = cols
|
||||
@@ -407,6 +490,28 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
}, REASONING_PULSE_MS)
|
||||
}, [])
|
||||
|
||||
const scheduleStreaming = useCallback(() => {
|
||||
if (streamTimerRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
streamTimerRef.current = setTimeout(() => {
|
||||
streamTimerRef.current = null
|
||||
setStreaming(buf.current.trimStart())
|
||||
}, STREAM_BATCH_MS)
|
||||
}, [])
|
||||
|
||||
const scheduleReasoning = useCallback(() => {
|
||||
if (reasoningTimerRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
reasoningTimerRef.current = setTimeout(() => {
|
||||
reasoningTimerRef.current = null
|
||||
setReasoning(reasoningRef.current)
|
||||
}, STREAM_BATCH_MS)
|
||||
}, [])
|
||||
|
||||
const endReasoningPhase = useCallback(() => {
|
||||
if (reasoningStreamingTimerRef.current) {
|
||||
clearTimeout(reasoningStreamingTimerRef.current)
|
||||
@@ -419,6 +524,14 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current)
|
||||
}
|
||||
|
||||
if (reasoningTimerRef.current) {
|
||||
clearTimeout(reasoningTimerRef.current)
|
||||
}
|
||||
|
||||
if (reasoningStreamingTimerRef.current) {
|
||||
clearTimeout(reasoningStreamingTimerRef.current)
|
||||
}
|
||||
@@ -432,6 +545,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
|
||||
const empty = !messages.length
|
||||
const isBlocked = blocked()
|
||||
|
||||
const virtualRows = useMemo(
|
||||
() =>
|
||||
historyItems.map((msg, index) => ({
|
||||
@@ -441,6 +555,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
})),
|
||||
[historyItems]
|
||||
)
|
||||
|
||||
const virtualHistory = useVirtualHistory(scrollRef, virtualRows)
|
||||
|
||||
// ── Resize RPC ───────────────────────────────────────────────────
|
||||
@@ -651,10 +766,26 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
setApproval(null)
|
||||
setSudo(null)
|
||||
setSecret(null)
|
||||
|
||||
if (streamTimerRef.current) {
|
||||
clearTimeout(streamTimerRef.current)
|
||||
streamTimerRef.current = null
|
||||
}
|
||||
|
||||
setStreaming('')
|
||||
buf.current = ''
|
||||
}
|
||||
|
||||
const clearReasoning = () => {
|
||||
if (reasoningTimerRef.current) {
|
||||
clearTimeout(reasoningTimerRef.current)
|
||||
reasoningTimerRef.current = null
|
||||
}
|
||||
|
||||
reasoningRef.current = ''
|
||||
setReasoning('')
|
||||
}
|
||||
|
||||
const die = () => {
|
||||
gw.kill()
|
||||
exit()
|
||||
@@ -670,13 +801,14 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
|
||||
const resetSession = () => {
|
||||
idle()
|
||||
setReasoning('')
|
||||
clearReasoning()
|
||||
setVoiceRecording(false)
|
||||
setVoiceProcessing(false)
|
||||
setSid(null as any) // will be set by caller
|
||||
setInfo(null)
|
||||
setHistoryItems([])
|
||||
setMessages([])
|
||||
setStickyPrompt('')
|
||||
setPasteSnips([])
|
||||
setActivity([])
|
||||
setBgTasks(new Set())
|
||||
@@ -688,11 +820,12 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
|
||||
const resetVisibleHistory = (info: SessionInfo | null = null) => {
|
||||
idle()
|
||||
setReasoning('')
|
||||
clearReasoning()
|
||||
setMessages([])
|
||||
setHistoryItems(info ? [introMsg(info)] : [])
|
||||
setInfo(info)
|
||||
setUsage(info?.usage ? { ...ZERO, ...info.usage } : ZERO)
|
||||
setStickyPrompt('')
|
||||
setPasteSnips([])
|
||||
setActivity([])
|
||||
setLastUserMsg('')
|
||||
@@ -888,10 +1021,12 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
const send = (text: string) => {
|
||||
const expandPasteSnips = (value: string) => {
|
||||
const byLabel = new Map<string, string[]>()
|
||||
|
||||
for (const item of pasteSnips) {
|
||||
const list = byLabel.get(item.label)
|
||||
list ? list.push(item.text) : byLabel.set(item.label, [item.text])
|
||||
}
|
||||
|
||||
return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token)
|
||||
}
|
||||
|
||||
@@ -1192,11 +1327,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
|
||||
if (key.wheelUp) {
|
||||
scrollRef.current?.scrollBy(-WHEEL_SCROLL_STEP)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (key.wheelDown) {
|
||||
scrollRef.current?.scrollBy(WHEEL_SCROLL_STEP)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1204,6 +1341,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
const viewport = scrollRef.current?.getViewportHeight() ?? Math.max(6, (stdout?.rows ?? 24) - 8)
|
||||
const step = Math.max(4, viewport - 2)
|
||||
scrollRef.current?.scrollBy(key.pageUp ? -step : step)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1272,7 +1410,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
partial ? appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) : sys('interrupted')
|
||||
|
||||
idle()
|
||||
setReasoning('')
|
||||
clearReasoning()
|
||||
setActivity([])
|
||||
turnToolsRef.current = []
|
||||
setStatus('interrupted')
|
||||
@@ -1287,6 +1425,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
}, 1500)
|
||||
} else if (hasSelection) {
|
||||
const copied = selection.copySelection()
|
||||
|
||||
if (copied) {
|
||||
sys('copied selection')
|
||||
}
|
||||
@@ -1457,7 +1596,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
case 'message.start':
|
||||
setBusy(true)
|
||||
endReasoningPhase()
|
||||
setReasoning('')
|
||||
clearReasoning()
|
||||
setActivity([])
|
||||
setTurnTrail([])
|
||||
turnToolsRef.current = []
|
||||
@@ -1534,7 +1673,8 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
|
||||
case 'reasoning.delta':
|
||||
if (p?.text) {
|
||||
setReasoning(prev => prev + p.text)
|
||||
reasoningRef.current += p.text
|
||||
scheduleReasoning()
|
||||
pulseReasoningStreaming()
|
||||
}
|
||||
|
||||
@@ -1657,7 +1797,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
|
||||
if (p?.text && !interruptedRef.current) {
|
||||
buf.current = p.rendered ?? buf.current + p.text
|
||||
setStreaming(buf.current.trimStart())
|
||||
scheduleStreaming()
|
||||
}
|
||||
|
||||
break
|
||||
@@ -1673,7 +1813,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart()
|
||||
|
||||
idle()
|
||||
setReasoning('')
|
||||
clearReasoning()
|
||||
setStreaming('')
|
||||
|
||||
if (!wasInterrupted) {
|
||||
@@ -1715,7 +1855,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
|
||||
case 'error':
|
||||
idle()
|
||||
setReasoning('')
|
||||
clearReasoning()
|
||||
turnToolsRef.current = []
|
||||
persistedToolLabelsRef.current.clear()
|
||||
|
||||
@@ -1734,6 +1874,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
[
|
||||
appendMessage,
|
||||
bellOnComplete,
|
||||
clearReasoning,
|
||||
dequeue,
|
||||
endReasoningPhase,
|
||||
gw,
|
||||
@@ -1743,6 +1884,8 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
pushActivity,
|
||||
pushTrail,
|
||||
rpc,
|
||||
scheduleReasoning,
|
||||
scheduleStreaming,
|
||||
sendQueued,
|
||||
sys,
|
||||
stdout
|
||||
@@ -1883,8 +2026,10 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
|
||||
{
|
||||
const mode = arg.trim().toLowerCase()
|
||||
|
||||
if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) {
|
||||
sys('usage: /details [hidden|collapsed|expanded|cycle]')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1895,12 +2040,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
case 'copy': {
|
||||
if (!arg && hasSelection) {
|
||||
const copied = selection.copySelection()
|
||||
|
||||
if (copied) {
|
||||
sys('copied selection')
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1949,6 +2095,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
case 'sb':
|
||||
if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) {
|
||||
sys('usage: /statusbar [on|off|toggle]')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2798,7 +2945,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
}
|
||||
|
||||
idle()
|
||||
setReasoning('')
|
||||
clearReasoning()
|
||||
setActivity([])
|
||||
turnToolsRef.current = []
|
||||
setStatus('interrupted')
|
||||
@@ -2855,16 +3002,17 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : ''
|
||||
const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}`
|
||||
const cwdLabel = shortCwd(info?.cwd || process.env.HERMES_CWD || process.cwd())
|
||||
const showStreamingArea = Boolean(streaming)
|
||||
const visibleHistory = virtualRows.slice(virtualHistory.start, virtualHistory.end)
|
||||
const showStickyPrompt = !!stickyPrompt
|
||||
|
||||
const hasReasoning = Boolean(reasoning.trim())
|
||||
|
||||
const showProgressArea =
|
||||
detailsMode === 'hidden'
|
||||
? activity.some(i => i.tone !== 'info')
|
||||
: Boolean(busy || tools.length || turnTrail.length || hasReasoning || activity.length)
|
||||
|
||||
const showStreamingArea = Boolean(streaming)
|
||||
const visibleHistory = virtualRows.slice(virtualHistory.start, virtualHistory.end)
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
@@ -2891,23 +3039,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
|
||||
{virtualHistory.bottomSpacer > 0 ? <Box height={virtualHistory.bottomSpacer} /> : null}
|
||||
|
||||
{showStreamingArea && (
|
||||
<Box marginTop={1}>
|
||||
<MessageLine
|
||||
cols={cols}
|
||||
compact={compact}
|
||||
detailsMode={detailsMode}
|
||||
msg={{ role: 'assistant', text: streaming }}
|
||||
t={theme}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollBox>
|
||||
|
||||
<NoSelect flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
|
||||
{showProgressArea && (
|
||||
<Box>
|
||||
{showProgressArea && (
|
||||
<ToolTrail
|
||||
activity={activity}
|
||||
busy={busy && !streaming}
|
||||
@@ -2919,9 +3051,29 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
tools={tools}
|
||||
trail={turnTrail}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
|
||||
{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 flexDirection="column" flexShrink={0} fromLeftEdge paddingX={1}>
|
||||
{clarify && (
|
||||
<PromptBox color={theme.color.bronze}>
|
||||
<ClarifyPrompt
|
||||
@@ -3026,7 +3178,14 @@ export function App({ gw }: { gw: GatewayClient }) {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text> </Text>
|
||||
{showStickyPrompt ? (
|
||||
<Text color={theme.color.dim} dimColor wrap="truncate-end">
|
||||
<Text color={theme.color.label}>↳ </Text>
|
||||
{stickyPrompt}
|
||||
</Text>
|
||||
) : (
|
||||
<Text> </Text>
|
||||
)}
|
||||
|
||||
{statusBar && (
|
||||
<StatusRule
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LONG_MSG, ROLE } from '../constants.js'
|
||||
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { DetailsMode, Msg } from '../types.js'
|
||||
|
||||
import { Md } from './markdown.js'
|
||||
import { ToolTrail } from './thinking.js'
|
||||
|
||||
@@ -12,12 +13,14 @@ export const MessageLine = memo(function MessageLine({
|
||||
cols,
|
||||
compact,
|
||||
detailsMode = 'collapsed',
|
||||
isStreaming = false,
|
||||
msg,
|
||||
t
|
||||
}: {
|
||||
cols: number
|
||||
compact?: boolean
|
||||
detailsMode?: DetailsMode
|
||||
isStreaming?: boolean
|
||||
msg: Msg
|
||||
t: Theme
|
||||
}) {
|
||||
@@ -33,7 +36,8 @@ export const MessageLine = memo(function MessageLine({
|
||||
return (
|
||||
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
|
||||
<Text color={t.color.dim} wrap="truncate-end">
|
||||
{compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || '(empty tool result)'}
|
||||
{compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) ||
|
||||
'(empty tool result)'}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -44,16 +48,27 @@ export const MessageLine = memo(function MessageLine({
|
||||
const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking))
|
||||
|
||||
const content = (() => {
|
||||
if (msg.kind === 'slash') return <Text color={t.color.dim}>{msg.text}</Text>
|
||||
if (msg.role !== 'user' && hasAnsi(msg.text)) return <Ansi>{msg.text}</Ansi>
|
||||
if (msg.role === 'assistant') return <Md compact={compact} t={t} text={msg.text} />
|
||||
if (msg.kind === 'slash') {
|
||||
return <Text color={t.color.dim}>{msg.text}</Text>
|
||||
}
|
||||
|
||||
if (msg.role !== 'user' && hasAnsi(msg.text)) {
|
||||
return <Ansi>{msg.text}</Ansi>
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
return isStreaming ? <Text color={body}>{msg.text}</Text> : <Md compact={compact} t={t} text={msg.text} />
|
||||
}
|
||||
|
||||
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
|
||||
const [head, ...rest] = userDisplay(msg.text).split('[long message]')
|
||||
|
||||
return (
|
||||
<Text color={body}>
|
||||
{head}
|
||||
<Text color={t.color.dim} dimColor>[long message]</Text>
|
||||
<Text color={t.color.dim} dimColor>
|
||||
[long message]
|
||||
</Text>
|
||||
{rest.join('')}
|
||||
</Text>
|
||||
)
|
||||
@@ -76,7 +91,9 @@ export const MessageLine = memo(function MessageLine({
|
||||
|
||||
<Box>
|
||||
<NoSelect flexShrink={0} fromLeftEdge width={3}>
|
||||
<Text bold={msg.role === 'user'} color={prefix}>{glyph}{' '}</Text>
|
||||
<Text bold={msg.role === 'user'} color={prefix}>
|
||||
{glyph}{' '}
|
||||
</Text>
|
||||
</NoSelect>
|
||||
|
||||
<Box width={Math.max(20, cols - 5)}>{content}</Box>
|
||||
|
||||
@@ -176,8 +176,10 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe
|
||||
if (line === targetRow) {
|
||||
return index
|
||||
}
|
||||
|
||||
line++
|
||||
column = 0
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -187,6 +189,7 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe
|
||||
if (line === targetRow) {
|
||||
return index
|
||||
}
|
||||
|
||||
line++
|
||||
column = 0
|
||||
}
|
||||
@@ -333,7 +336,9 @@ export function TextInput({
|
||||
}, [cur, display, focus, placeholder])
|
||||
|
||||
const clickCursor = (e: { localRow?: number; localCol?: number }) => {
|
||||
if (!focus) return
|
||||
if (!focus) {
|
||||
return
|
||||
}
|
||||
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
|
||||
setCur(next)
|
||||
curRef.current = next
|
||||
@@ -442,7 +447,6 @@ export function TextInput({
|
||||
k.upArrow ||
|
||||
k.downArrow ||
|
||||
(k.ctrl && inp === 'c') ||
|
||||
(k.ctrl && inp === 't') ||
|
||||
k.tab ||
|
||||
(k.shift && k.tab) ||
|
||||
k.pageUp ||
|
||||
@@ -568,7 +572,7 @@ export function TextInput({
|
||||
// ── Render ───────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Box ref={boxRef} onClick={clickCursor}>
|
||||
<Box onClick={clickCursor} ref={boxRef}>
|
||||
<Text wrap="wrap">{rendered}</Text>
|
||||
</Box>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
|
||||
import { FACES, VERBS } from '../constants.js'
|
||||
import {
|
||||
compactPreview,
|
||||
formatToolCall,
|
||||
parseToolTrailResultLine,
|
||||
pick,
|
||||
@@ -20,6 +19,7 @@ const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep',
|
||||
|
||||
const fmtElapsed = (ms: number) => {
|
||||
const sec = Math.max(0, ms) / 1000
|
||||
|
||||
return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s`
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const fmtElapsed = (ms: number) => {
|
||||
export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
|
||||
const [spin] = useState(() => {
|
||||
const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)]
|
||||
|
||||
return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') }
|
||||
})
|
||||
|
||||
@@ -35,6 +36,7 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?:
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [spin])
|
||||
|
||||
@@ -52,22 +54,46 @@ function Detail({ color, content, dimColor }: DetailRow) {
|
||||
)
|
||||
}
|
||||
|
||||
function StreamCursor({ color, dimColor, streaming = false, visible = false }: {
|
||||
color: string; dimColor?: boolean; streaming?: boolean; visible?: boolean
|
||||
function StreamCursor({
|
||||
color,
|
||||
dimColor,
|
||||
streaming = false,
|
||||
visible = false
|
||||
}: {
|
||||
color: string
|
||||
dimColor?: boolean
|
||||
streaming?: boolean
|
||||
visible?: boolean
|
||||
}) {
|
||||
const [on, setOn] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setOn(v => !v), 420)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
return visible ? <Text color={color} dimColor={dimColor}>{streaming && on ? '▍' : ' '}</Text> : null
|
||||
return visible ? (
|
||||
<Text color={color} dimColor={dimColor}>
|
||||
{streaming && on ? '▍' : ' '}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
|
||||
function Chevron({ count, onClick, open, summary, t, title, tone = 'dim' }: {
|
||||
count?: number; onClick: () => void; open: boolean; summary?: string
|
||||
t: Theme; title: string; tone?: 'dim' | 'error' | 'warn'
|
||||
function Chevron({
|
||||
count,
|
||||
onClick,
|
||||
open,
|
||||
t,
|
||||
title,
|
||||
tone = 'dim'
|
||||
}: {
|
||||
count?: number
|
||||
onClick: () => void
|
||||
open: boolean
|
||||
t: Theme
|
||||
title: string
|
||||
tone?: 'dim' | 'error' | 'warn'
|
||||
}) {
|
||||
const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim
|
||||
|
||||
@@ -75,8 +101,8 @@ function Chevron({ count, onClick, open, summary, t, title, tone = 'dim' }: {
|
||||
<Box onClick={onClick}>
|
||||
<Text color={color} dimColor={tone === 'dim'}>
|
||||
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
|
||||
{title}{typeof count === 'number' ? ` (${count})` : ''}
|
||||
{summary ? <Text color={t.color.dim}> · {summary}</Text> : null}
|
||||
{title}
|
||||
{typeof count === 'number' ? ` (${count})` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
@@ -85,14 +111,23 @@ function Chevron({ count, onClick, open, summary, t, title, tone = 'dim' }: {
|
||||
// ── Thinking ─────────────────────────────────────────────────────────
|
||||
|
||||
export const Thinking = memo(function Thinking({
|
||||
active = false, mode = 'truncated', reasoning, streaming = false, t
|
||||
active = false,
|
||||
mode = 'truncated',
|
||||
reasoning,
|
||||
streaming = false,
|
||||
t
|
||||
}: {
|
||||
active?: boolean; mode?: ThinkingMode; reasoning: string; streaming?: boolean; t: Theme
|
||||
active?: boolean
|
||||
mode?: ThinkingMode
|
||||
reasoning: string
|
||||
streaming?: boolean
|
||||
t: Theme
|
||||
}) {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(v => v + 1), 1100)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
@@ -126,13 +161,25 @@ export const Thinking = memo(function Thinking({
|
||||
type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string }
|
||||
|
||||
export const ToolTrail = memo(function ToolTrail({
|
||||
busy = false, detailsMode = 'collapsed', reasoningActive = false,
|
||||
reasoning = '', reasoningStreaming = false, t,
|
||||
tools = [], trail = [], activity = []
|
||||
busy = false,
|
||||
detailsMode = 'collapsed',
|
||||
reasoningActive = false,
|
||||
reasoning = '',
|
||||
reasoningStreaming = false,
|
||||
t,
|
||||
tools = [],
|
||||
trail = [],
|
||||
activity = []
|
||||
}: {
|
||||
busy?: boolean; detailsMode?: DetailsMode; reasoningActive?: boolean
|
||||
reasoning?: string; reasoningStreaming?: boolean; t: Theme
|
||||
tools?: ActiveTool[]; trail?: string[]; activity?: ActivityItem[]
|
||||
busy?: boolean
|
||||
detailsMode?: DetailsMode
|
||||
reasoningActive?: boolean
|
||||
reasoning?: string
|
||||
reasoningStreaming?: boolean
|
||||
t: Theme
|
||||
tools?: ActiveTool[]
|
||||
trail?: string[]
|
||||
activity?: ActivityItem[]
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [openThinking, setOpenThinking] = useState(false)
|
||||
@@ -140,19 +187,33 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
const [openMeta, setOpenMeta] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tools.length) return
|
||||
const id = setInterval(() => setNow(Date.now()), 200)
|
||||
if (!tools.length || (detailsMode === 'collapsed' && !openTools)) {
|
||||
return
|
||||
}
|
||||
const id = setInterval(() => setNow(Date.now()), 500)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [tools.length])
|
||||
}, [detailsMode, openTools, tools.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (detailsMode === 'expanded') { setOpenThinking(true); setOpenTools(true); setOpenMeta(true) }
|
||||
if (detailsMode === 'hidden') { setOpenThinking(false); setOpenTools(false); setOpenMeta(false) }
|
||||
if (detailsMode === 'expanded') {
|
||||
setOpenThinking(true)
|
||||
setOpenTools(true)
|
||||
setOpenMeta(true)
|
||||
}
|
||||
|
||||
if (detailsMode === 'hidden') {
|
||||
setOpenThinking(false)
|
||||
setOpenTools(false)
|
||||
setOpenMeta(false)
|
||||
}
|
||||
}, [detailsMode])
|
||||
|
||||
const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX)
|
||||
|
||||
if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) return null
|
||||
if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Build groups + meta ────────────────────────────────────────
|
||||
|
||||
@@ -167,12 +228,19 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
groups.push({
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk,
|
||||
content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`,
|
||||
details: [], key: `tr-${i}`
|
||||
})
|
||||
if (parsed.detail) pushDetail({
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.dim,
|
||||
content: parsed.detail, dimColor: parsed.mark !== '✗', key: `tr-${i}-d`
|
||||
details: [],
|
||||
key: `tr-${i}`
|
||||
})
|
||||
|
||||
if (parsed.detail) {
|
||||
pushDetail({
|
||||
color: parsed.mark === '✗' ? t.color.error : t.color.dim,
|
||||
content: parsed.detail,
|
||||
dimColor: parsed.mark !== '✗',
|
||||
key: `tr-${i}-d`
|
||||
})
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -183,16 +251,24 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }],
|
||||
key: `tr-${i}`
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (line === 'analyzing tool output…') {
|
||||
pushDetail({
|
||||
color: t.color.dim, dimColor: true, key: `tr-${i}`,
|
||||
content: groups.length
|
||||
? <><Spinner color={t.color.amber} variant="think" /> {line}</>
|
||||
: line
|
||||
color: t.color.dim,
|
||||
dimColor: true,
|
||||
key: `tr-${i}`,
|
||||
content: groups.length ? (
|
||||
<>
|
||||
<Spinner color={t.color.amber} variant="think" /> {line}
|
||||
</>
|
||||
) : (
|
||||
line
|
||||
)
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -201,7 +277,9 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
|
||||
for (const tool of tools) {
|
||||
groups.push({
|
||||
color: t.color.cornsilk, key: tool.id, details: [],
|
||||
color: t.color.cornsilk,
|
||||
key: tool.id,
|
||||
details: [],
|
||||
content: (
|
||||
<>
|
||||
<Spinner color={t.color.amber} variant="tool" /> {formatToolCall(tool.name, tool.context || '')}
|
||||
@@ -211,18 +289,6 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
})
|
||||
}
|
||||
|
||||
if (cot && groups.length) {
|
||||
pushDetail({
|
||||
color: t.color.dim, dimColor: true, key: 'cot',
|
||||
content: <>{cot}<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} /></>
|
||||
})
|
||||
} else if (reasoningActive && groups.length) {
|
||||
pushDetail({
|
||||
color: t.color.dim, dimColor: true, key: 'cot',
|
||||
content: <StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of activity.slice(-4)) {
|
||||
const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·'
|
||||
const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
|
||||
@@ -233,12 +299,13 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
|
||||
const hasTools = groups.length > 0
|
||||
const hasMeta = meta.length > 0
|
||||
const hasThinking = !hasTools && (busy || !!cot || reasoningActive)
|
||||
const hasThinking = !!cot || reasoningActive || (busy && !hasTools)
|
||||
|
||||
// ── Hidden: errors/warnings only ──────────────────────────────
|
||||
|
||||
if (detailsMode === 'hidden') {
|
||||
const alerts = activity.filter(i => i.tone !== 'info').slice(-2)
|
||||
|
||||
return alerts.length ? (
|
||||
<Box flexDirection="column">
|
||||
{alerts.map(i => (
|
||||
@@ -253,61 +320,95 @@ export const ToolTrail = memo(function ToolTrail({
|
||||
// ── Shared render fragments ────────────────────────────────────
|
||||
|
||||
const thinkingBlock = hasThinking ? (
|
||||
busy
|
||||
? <Thinking active={reasoningActive} mode="full" reasoning={reasoning} streaming={reasoningStreaming} t={t} />
|
||||
: cot
|
||||
? <Detail color={t.color.dim} content={cot} dimColor key="cot" />
|
||||
: <Detail color={t.color.dim} content={<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />} dimColor key="cot" />
|
||||
busy ? (
|
||||
<Thinking active={reasoningActive} mode="full" reasoning={reasoning} streaming={reasoningStreaming} t={t} />
|
||||
) : cot ? (
|
||||
<Detail color={t.color.dim} content={cot} dimColor key="cot" />
|
||||
) : (
|
||||
<Detail
|
||||
color={t.color.dim}
|
||||
content={<StreamCursor color={t.color.dim} dimColor streaming={reasoningStreaming} visible={reasoningActive} />}
|
||||
dimColor
|
||||
key="cot"
|
||||
/>
|
||||
)
|
||||
) : null
|
||||
|
||||
const toolBlock = hasTools ? groups.map(g => (
|
||||
<Box flexDirection="column" key={g.key}>
|
||||
<Text color={g.color}>
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
{g.content}
|
||||
</Text>
|
||||
{g.details.map(d => <Detail {...d} key={d.key} />)}
|
||||
</Box>
|
||||
)) : null
|
||||
const toolBlock = hasTools
|
||||
? groups.map(g => (
|
||||
<Box flexDirection="column" key={g.key}>
|
||||
<Text color={g.color}>
|
||||
<Text color={t.color.amber}>● </Text>
|
||||
{g.content}
|
||||
</Text>
|
||||
{g.details.map(d => (
|
||||
<Detail {...d} key={d.key} />
|
||||
))}
|
||||
</Box>
|
||||
))
|
||||
: null
|
||||
|
||||
const metaBlock = hasMeta ? meta.map((row, i) => (
|
||||
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
|
||||
<Text dimColor>{i === meta.length - 1 ? '└ ' : '├ '}</Text>
|
||||
{row.content}
|
||||
</Text>
|
||||
)) : null
|
||||
const metaBlock = hasMeta
|
||||
? meta.map((row, i) => (
|
||||
<Text color={row.color} dimColor={row.dimColor} key={row.key}>
|
||||
<Text dimColor>{i === meta.length - 1 ? '└ ' : '├ '}</Text>
|
||||
{row.content}
|
||||
</Text>
|
||||
))
|
||||
: null
|
||||
|
||||
// ── Expanded: flat, no accordions ──────────────────────────────
|
||||
|
||||
if (detailsMode === 'expanded') {
|
||||
return <Box flexDirection="column">{thinkingBlock}{toolBlock}{metaBlock}</Box>
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{thinkingBlock}
|
||||
{toolBlock}
|
||||
{metaBlock}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Collapsed: clickable accordions ────────────────────────────
|
||||
|
||||
const metaTone: 'dim' | 'error' | 'warn' =
|
||||
activity.some(i => i.tone === 'error') ? 'error'
|
||||
: activity.some(i => i.tone === 'warn') ? 'warn' : 'dim'
|
||||
const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error')
|
||||
? 'error'
|
||||
: activity.some(i => i.tone === 'warn')
|
||||
? 'warn'
|
||||
: 'dim'
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{hasThinking && (
|
||||
<>
|
||||
<Chevron onClick={() => setOpenThinking(v => !v)} open={openThinking} summary={cot ? compactPreview(cot, 56) : busy ? 'running…' : ''} t={t} title="Thinking" />
|
||||
<Chevron onClick={() => setOpenThinking(v => !v)} open={openThinking} t={t} title="Thinking" />
|
||||
{openThinking && thinkingBlock}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasTools && (
|
||||
<>
|
||||
<Chevron count={groups.length} onClick={() => setOpenTools(v => !v)} open={openTools} t={t} title="Tool calls" />
|
||||
<Chevron
|
||||
count={groups.length}
|
||||
onClick={() => setOpenTools(v => !v)}
|
||||
open={openTools}
|
||||
t={t}
|
||||
title="Tool calls"
|
||||
/>
|
||||
{openTools && toolBlock}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasMeta && (
|
||||
<>
|
||||
<Chevron count={meta.length} onClick={() => setOpenMeta(v => !v)} open={openMeta} t={t} title="Activity" tone={metaTone} />
|
||||
<Chevron
|
||||
count={meta.length}
|
||||
onClick={() => setOpenMeta(v => !v)}
|
||||
open={openMeta}
|
||||
t={t}
|
||||
title="Activity"
|
||||
tone={metaTone}
|
||||
/>
|
||||
{openMeta && metaBlock}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from 'react'
|
||||
|
||||
import type { ScrollBoxHandle } from '@hermes/ink'
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore
|
||||
} from 'react'
|
||||
|
||||
const ESTIMATE = 4
|
||||
const OVERSCAN = 40
|
||||
const MAX_MOUNTED = 260
|
||||
const COLD_START = 40
|
||||
const QUANTUM = 8
|
||||
const QUANTUM = OVERSCAN >> 1
|
||||
|
||||
const upperBound = (arr: number[], target: number) => {
|
||||
let lo = 0, hi = arr.length
|
||||
let lo = 0,
|
||||
hi = arr.length
|
||||
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
arr[mid]! <= target ? lo = mid + 1 : hi = mid
|
||||
arr[mid]! <= target ? (lo = mid + 1) : (hi = mid)
|
||||
}
|
||||
|
||||
return lo
|
||||
}
|
||||
|
||||
@@ -28,14 +39,15 @@ export function useVirtualHistory(
|
||||
const [ver, setVer] = useState(0)
|
||||
|
||||
useSyncExternalStore(
|
||||
useCallback(
|
||||
(cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => () => {}),
|
||||
[scrollRef]
|
||||
),
|
||||
useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => () => {}), [scrollRef]),
|
||||
() => {
|
||||
const s = scrollRef.current
|
||||
if (!s) return NaN
|
||||
|
||||
if (!s) {
|
||||
return NaN
|
||||
}
|
||||
const b = Math.floor(s.getScrollTop() / QUANTUM)
|
||||
|
||||
return s.isSticky() ? -b - 1 : b
|
||||
},
|
||||
() => NaN
|
||||
@@ -44,6 +56,7 @@ export function useVirtualHistory(
|
||||
useEffect(() => {
|
||||
const keep = new Set(items.map(i => i.key))
|
||||
let dirty = false
|
||||
|
||||
for (const k of heights.current.keys()) {
|
||||
if (!keep.has(k)) {
|
||||
heights.current.delete(k)
|
||||
@@ -52,13 +65,19 @@ export function useVirtualHistory(
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
if (dirty) setVer(v => v + 1)
|
||||
|
||||
if (dirty) {
|
||||
setVer(v => v + 1)
|
||||
}
|
||||
}, [items])
|
||||
|
||||
const offsets = useMemo(() => {
|
||||
const out = new Array<number>(items.length + 1).fill(0)
|
||||
for (let i = 0; i < items.length; i++)
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
out[i + 1] = out[i]! + Math.max(1, Math.floor(heights.current.get(items[i]!.key) ?? estimate))
|
||||
}
|
||||
|
||||
return out
|
||||
}, [estimate, items, ver])
|
||||
|
||||
@@ -67,7 +86,8 @@ export function useVirtualHistory(
|
||||
const vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0)
|
||||
const sticky = scrollRef.current?.isSticky() ?? true
|
||||
|
||||
let start = 0, end = items.length
|
||||
let start = 0,
|
||||
end = items.length
|
||||
|
||||
if (items.length > 0) {
|
||||
if (vp <= 0) {
|
||||
@@ -79,37 +99,46 @@ export function useVirtualHistory(
|
||||
}
|
||||
|
||||
if (end - start > maxMounted) {
|
||||
sticky
|
||||
? (start = Math.max(0, end - maxMounted))
|
||||
: (end = Math.min(items.length, start + maxMounted))
|
||||
sticky ? (start = Math.max(0, end - maxMounted)) : (end = Math.min(items.length, start + maxMounted))
|
||||
}
|
||||
|
||||
const measureRef = useCallback((key: string) => {
|
||||
let fn = refs.current.get(key)
|
||||
|
||||
if (!fn) {
|
||||
fn = (el: any) => el ? nodes.current.set(key, el) : nodes.current.delete(key)
|
||||
fn = (el: any) => (el ? nodes.current.set(key, el) : nodes.current.delete(key))
|
||||
refs.current.set(key, fn)
|
||||
}
|
||||
|
||||
return fn
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let dirty = false
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const k = items[i]?.key
|
||||
if (!k) continue
|
||||
|
||||
if (!k) {
|
||||
continue
|
||||
}
|
||||
const h = Math.ceil(nodes.current.get(k)?.yogaNode?.getComputedHeight?.() ?? 0)
|
||||
|
||||
if (h > 0 && heights.current.get(k) !== h) {
|
||||
heights.current.set(k, h)
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
if (dirty) setVer(v => v + 1)
|
||||
|
||||
if (dirty) {
|
||||
setVer(v => v + 1)
|
||||
}
|
||||
}, [end, items, start])
|
||||
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
offsets,
|
||||
topSpacer: offsets[start] ?? 0,
|
||||
bottomSpacer: Math.max(0, total - (offsets[end] ?? total)),
|
||||
measureRef
|
||||
|
||||
2
ui-tui/src/types/hermes-ink.d.ts
vendored
2
ui-tui/src/types/hermes-ink.d.ts
vendored
@@ -49,8 +49,10 @@ declare module '@hermes/ink' {
|
||||
export type ScrollBoxHandle = {
|
||||
readonly scrollTo: (y: number) => void
|
||||
readonly scrollBy: (dy: number) => void
|
||||
readonly scrollToElement: (el: unknown, offset?: number) => void
|
||||
readonly scrollToBottom: () => void
|
||||
readonly getScrollTop: () => number
|
||||
readonly getPendingDelta: () => number
|
||||
readonly getViewportHeight: () => number
|
||||
readonly isSticky: () => boolean
|
||||
readonly subscribe: (listener: () => void) => () => void
|
||||
|
||||
Reference in New Issue
Block a user