Files
claude-code/packages/@ant/ink/docs/06-scrolling.md
claude-code-best 2fb1c9dcd8 feat: 工具层及 mcp 大重构 (#252)
* feat: 第一版大重构

* fix: 修复类型问题

* chore: 更新版本到 1.3.2

* Add brave as alternative WebSearchTool

* fix: 修正顺序

* fix: 修复对穷鬼模式的 auto dream 和 session memory 越过

* feat: 穷鬼模式去除 session-summary

* feat: 创建 builtin-tools 包,搬运所有工具实现

将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/,
内部导入路径已更新为 src/ alias 模式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/

- src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/
- 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock

- tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射
- 新增 packages/builtin-tools/src 至 include

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀

所有包名及 import 路径统一添加 @claude-code-best/ 前缀:
- builtin-tools → @claude-code-best/builtin-tools
- mcp-client → @claude-code-best/mcp-client
- agent-tools → @claude-code-best/agent-tools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复 node 环境没有 bun 的问题

---------

Co-authored-by: Eric-Guo <eric.guocz@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 09:52:05 +08:00

4.8 KiB

Chapter 6: Scrolling

ScrollBox

A scrollable container with imperative scroll API, viewport culling, and sticky scroll support.

import { ScrollBox } from '@anthropic/ink'
import type { ScrollBoxHandle } from '@anthropic/ink'

function MessageList({ messages }) {
  const scrollRef = useRef<ScrollBoxHandle>(null)

  // Auto-scroll to bottom on new messages
  useEffect(() => {
    scrollRef.current?.scrollToBottom()
  }, [messages.length])

  return (
    <ScrollBox ref={scrollRef} stickyScroll flexDirection="column" height={20}>
      {messages.map(msg => (
        <Text key={msg.id}>{msg.text}</Text>
      ))}
    </ScrollBox>
  )
}

Props

ScrollBox accepts all Box layout props except textWrap, overflow, overflowX, overflowY (these are managed internally):

Prop Type Default Description
ref Ref<ScrollBoxHandle> - Imperative handle
stickyScroll boolean false Auto-follow new content
(layout props) Styles - Width, height, padding, etc.

ScrollBoxHandle (Imperative API)

interface ScrollBoxHandle {
  // Absolute positioning
  scrollTo(y: number): void
  scrollToElement(el: DOMElement, offset?: number): void
  scrollToBottom(): void

  // Relative positioning
  scrollBy(dy: number): void

  // Query state
  getScrollTop(): number
  getPendingDelta(): number
  getScrollHeight(): number
  getFreshScrollHeight(): number
  getViewportHeight(): number
  getViewportTop(): number
  isSticky(): boolean

  // Events
  subscribe(listener: () => void): () => void

  // Virtual scroll support
  setClampBounds(min?: number, max?: number): void
}

Method Details

scrollTo(y)

Jump to an absolute position. Breaks sticky scroll.

scrollRef.current?.scrollTo(0)  // Scroll to top

scrollBy(dy)

Scroll by a relative amount. Accumulates deltas for smooth scrolling.

scrollRef.current?.scrollBy(3)   // Scroll down 3 rows
scrollRef.current?.scrollBy(-5)  // Scroll up 5 rows

scrollToElement(el, offset?)

Scroll so a specific DOM element is at the viewport top. More reliable than scrollTo because it reads the element's position at render time (avoids stale layout values).

const elementRef = useRef<DOMElement>(null)
scrollRef.current?.scrollToElement(elementRef.current!, 2)

scrollToBottom()

Pin scroll to bottom. Enables sticky mode.

scrollRef.current?.scrollToBottom()

isSticky()

Returns true when scroll is pinned to the bottom.

if (scrollRef.current?.isSticky()) {
  // User hasn't scrolled up
}

subscribe(listener)

Subscribe to imperative scroll changes. Returns unsubscribe function.

useEffect(() => {
  return scrollRef.current?.subscribe(() => {
    console.log('Scroll position changed')
  })
}, [])

Sticky Scroll

When stickyScroll is enabled:

  1. Scroll automatically follows new content at the bottom
  2. User scroll (via scrollBy/scrollTo) breaks stickiness
  3. scrollToBottom() re-enables stickiness
  4. Content growth at the bottom is detected and followed automatically
<ScrollBox stickyScroll height={20}>
  {/* New items auto-scroll to bottom */}
  {items.map(renderItem)}
</ScrollBox>

Viewport Culling

ScrollBox only renders children that intersect the visible viewport. Children outside the viewport are still mounted in React but skipped during terminal rendering. This makes large lists performant.

Virtual Scrolling

For very large lists, use setClampBounds in combination with a virtual scrolling hook:

const scrollRef = useRef<ScrollBoxHandle>(null)

// After computing visible range
scrollRef.current?.setClampBounds(firstVisibleRow, lastVisibleRow)

This prevents burst scrollTo calls from showing blank space beyond mounted content.

Scroll Events

ScrollBox bypasses React state for scroll operations. Instead:

  1. scrollTo/scrollBy mutate scrollTop directly on the DOM node
  2. The node is marked dirty
  3. A microtask-deferred render fires to coalesce multiple scroll events
  4. The Ink renderer reads scrollTop during layout

This avoids React reconciler overhead per wheel event.

Integration with Mouse Wheel

In alt-screen mode, mouse wheel events are captured by the App component and forwarded to the focused ScrollBox:

Wheel event → App.handleMouseEvent → ScrollBox.scrollBy(delta)

Layout Structure

ScrollBox creates a two-level DOM structure:

ink-box (overflow: scroll, constrained height)
└── Box (flexGrow: 1, flexShrink: 0, width: 100%)
    ├── Child 1
    ├── Child 2
    └── ...

The outer ink-box is the viewport with constrained size. The inner Box grows to fit all content. The renderer computes scrollHeight from the inner box and translates content by -scrollTop.