diff --git a/CLAUDE.md b/CLAUDE.md index 838a2061d..3414de2a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -293,7 +293,7 @@ bun run typecheck # equivalent to bun run typecheck - **MACRO defines** — 集中管理在 `scripts/defines.ts`。Dev mode 通过 `bun -d` 注入,build 通过 `Bun.build({ define })` 注入。修改版本号等常量只改这个文件。 - **构建产物兼容 Node.js** — `build.ts` 会自动后处理 `import.meta.require`,产物可直接用 `node dist/cli.js` 运行。 - **Biome 配置** — 大量 lint 规则被关闭(decompiled 代码不适合严格 lint)。`.tsx` 文件用 120 行宽 + 强制分号;其他文件 80 行宽 + 按需分号。 -- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。 +- **Ink 框架在 `packages/@ant/ink/`** — 不是 `src/ink/`(该目录不存在)。Ink 相关的组件、hooks、keybindings 都在 packages 中。**开发任何 TUI 组件前,必须先查阅 `docs/ink-guide.md`**,该文档涵盖了双层组件设计(Base vs Themed)、布局系统、主题色、快捷键、所有 Hooks 和设计系统组件的用法。日常使用 `Box`/`Text`(Themed 版),用 `useKeybindings` 代替直接 `useInput`。 - **Provider 优先级** — `modelType` 参数 > 环境变量 > 默认 `firstParty`。新增 provider 需在 `src/utils/model/providers.ts` 注册。 ## Design Context diff --git a/bun.lock b/bun.lock index 18b951d06..6f9e1a6c7 100644 --- a/bun.lock +++ b/bun.lock @@ -270,7 +270,6 @@ "dependencies": { "@pkmn/client": "^0.7.2", "@pkmn/protocol": "^0.7.2", - "@pkmn/view": "^0.7.2", }, }, "packages/remote-control-server": { @@ -990,8 +989,6 @@ "@pkmn/types": ["@pkmn/types@4.0.0", "", {}, "sha512-gR2s/pxJYEegek1TtsYCQupNR3d5hMlcJFsiD+2LyfKr4tc+gETTql47tWLX5mFSbPcbXh7f4+7txlMIDoZx/g=="], - "@pkmn/view": ["@pkmn/view@0.7.2", "", { "dependencies": { "@pkmn/protocol": "^0.7.2", "@pkmn/types": "^4.0.0" }, "bin": { "format-battle": "format-battle" } }, "sha512-SBaBIAuyJ/iGfYQxfzQ6jXv64Qz1/pIo5gjCXT9AfsErkHT27VwIhEEd2vUlD0bdfx802sPNvzHvLYJWMtss1w=="], - "@pondwader/socks5-server": ["@pondwader/socks5-server@1.0.10", "https://registry.npmmirror.com/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz", {}, "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg=="], "@prisma/instrumentation": ["@prisma/instrumentation@7.6.0", "https://registry.npmmirror.com/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ=="], diff --git a/docs/ink-guide.md b/docs/ink-guide.md new file mode 100644 index 000000000..8de1f2f0f --- /dev/null +++ b/docs/ink-guide.md @@ -0,0 +1,1218 @@ +# @anthropic/ink 使用文档 + +> 本项目自定义的终端 React 渲染框架,基于 React 19 + react-reconciler + Yoga 布局引擎。 +> **不是** npm 上的 `ink` 官方库,API 有大量扩展和差异。 + +--- + +## 目录 + +1. [架构概览](#1-架构概览) +2. [快速开始](#2-快速开始) +3. [渲染 API](#3-渲染-api) +4. [组件体系](#4-组件体系) + - [双层组件设计](#双层组件设计) + - [Box / ThemedBox](#box--themedbox) + - [Text / ThemedText](#text--themedtext) + - [ScrollBox](#scrollbox) + - [Button](#button) + - [其他基础组件](#其他基础组件) + - [设计系统组件](#设计系统组件) +5. [布局系统 (Styles)](#5-布局系统-styles) +6. [Hooks](#6-hooks) +7. [快捷键系统](#7-快捷键系统) +8. [主题系统](#8-主题系统) +9. [事件系统](#9-事件系统) +10. [工具函数](#10-工具函数) +11. [与官方 Ink 的关键差异](#11-与官方-ink-的关键差异) +12. [最佳实践](#12-最佳实践) + +--- + +## 1. 架构概览 + +``` +packages/@ant/ink/src/ +├── core/ # Layer 1: 渲染引擎 +│ ├── events/ # 事件系统 (InputEvent, ClickEvent, FocusEvent...) +│ ├── layout/ # Yoga flexbox 布局 +│ ├── termio/ # 终端 I/O (ANSI 解析/输出) +│ ├── renderer.ts # 渲染器 (双缓冲 + diff) +│ ├── reconciler.ts # React reconciler +│ └── styles.ts # 样式类型定义 +├── components/ # Layer 2: UI 基础组件 (无主题) +│ ├── Box.tsx # BaseBox +│ ├── Text.tsx # BaseText +│ ├── ScrollBox.tsx # 滚动容器 +│ ├── Button.tsx # 按钮 +│ └── ... +├── theme/ # Layer 3: 主题 + 设计系统 +│ ├── ThemeProvider.tsx +│ ├── ThemedBox.tsx # 主题感知的 Box +│ ├── ThemedText.tsx # 主题感知的 Text +│ ├── Dialog.tsx # 对话框 +│ ├── Tabs.tsx # 标签页 +│ └── ... +├── hooks/ # React hooks +├── keybindings/ # 快捷键系统 +└── index.ts # 统一导出 +``` + +**三层架构**: +- **core** — React reconciler + Yoga 布局 + 双缓冲渲染 + 终端 I/O +- **components** — 原始 UI 组件 (无主题色,只接受 raw Color) +- **theme** — 主题感知组件 + 设计系统 (接受 Theme key 作为颜色) + +--- + +## 2. 快速开始 + +```tsx +import { + wrappedRender as render, + Box, + Text, + useApp, + useInput, +} from '@anthropic/ink' + +function App() { + const { exit } = useApp() + + useInput((input, key) => { + if (input === 'q') exit() + }) + + return ( + + Hello Terminal! + 按 q 退出 + + ) +} + +// 渲染 +const { unmount, waitUntilExit } = render() +await waitUntilExit() +``` + +**注意**: 项目中统一使用 `wrappedRender`(导出时别名为 `render`),而非 `renderSync`。 + +--- + +## 3. 渲染 API + +### `wrappedRender(node, options?)` + +异步渲染,返回 `Promise`: + +```ts +type Instance = { + rerender: (node: ReactNode) => void // 重新渲染 + unmount: () => void // 卸载 + waitUntilExit: () => Promise // 等待退出 + cleanup: () => void // 清理 +} + +type RenderOptions = { + stdout?: NodeJS.WriteStream // 默认 process.stdout + stdin?: NodeJS.ReadStream // 默认 process.stdin + stderr?: NodeJS.WriteStream // 默认 process.stderr + exitOnCtrlC?: boolean // 默认 true + patchConsole?: boolean // 默认 true + onFrame?: (event: FrameEvent) => void +} +``` + +### `createRoot(options?)` + +创建可复用的渲染根(类似 react-dom 的 `createRoot`): + +```ts +type Root = { + render: (node: ReactNode) => void + unmount: () => void + waitUntilExit: () => Promise +} + +const root = createRoot({ stdout: process.stdout }) +root.render() +// 后续可多次调用 root.render() 切换界面 +``` + +--- + +## 4. 组件体系 + +### 双层组件设计 + +本框架的组件分两层: + +| 层级 | 导出名 | 颜色类型 | 用途 | +|------|--------|----------|------| +| Base | `BaseBox`, `BaseText` | `Color` (rgb/hex/ansi) | 底层组件,无主题依赖 | +| Themed | `Box`, `Text` | `keyof Theme \| Color` | **日常使用的组件**,自动解析主题色 | + +**日常开发请始终使用 `Box` / `Text`(Themed 版本)**,它们是默认导出。 + +--- + +### Box / ThemedBox + +终端里的 `
`,是最核心的布局容器。 + +```tsx +import { Box, Text } from '@anthropic/ink' + +// 基础布局 + + + 左侧 + 右侧 + + + +// 主题色边框 + + 带主题色边框的盒子 + + +// 键盘交互 + { + if (e.key === 'escape') e.preventDefault() + }} +> + 可聚焦的盒子 + +``` + +**Props**: + +| 属性 | 类型 | 说明 | +|------|------|------| +| `ref` | `Ref` | DOM 元素引用 | +| `tabIndex` | `number` | Tab 键序(≥0 参与循环,-1 仅程序聚焦) | +| `autoFocus` | `boolean` | 挂载时自动聚焦 | +| `onClick` | `(e: ClickEvent) => void` | 鼠标点击(仅 AlternateScreen 内生效) | +| `onFocus` / `onBlur` | `(e: FocusEvent) => void` | 焦点事件 | +| `onFocusCapture` / `onBlurCapture` | 同上 | 捕获阶段焦点事件 | +| `onKeyDown` / `onKeyDownCapture` | `(e: KeyboardEvent) => void` | 键盘事件 | +| `onMouseEnter` / `onMouseLeave` | `() => void` | 鼠标进出(仅 AlternateScreen) | +| 所有 `Styles` 属性 | 见 [布局系统](#5-布局系统-styles) | | + +**ThemedBox 额外的颜色属性**: + +| 属性 | 类型 | 说明 | +|------|------|------| +| `borderColor` | `keyof Theme \| Color` | 边框颜色(接受主题 key) | +| `borderTopColor` 等 | 同上 | 单边边框颜色 | +| `backgroundColor` | `keyof Theme \| Color` | 背景颜色(接受主题 key) | + +--- + +### Text / ThemedText + +文本渲染组件。 + +```tsx +import { Text } from '@anthropic/ink' + +// 主题色文本 +Claude +错误信息 +次要信息(使用 inactive 色) + +// 原始色值 +橙色 +也是橙色 +256色橙色 + +// 文本截断 +很长的文本会被截断... + +// bold 和 dim 互斥(终端限制) +加粗文本 +暗淡文本 +// ❌ 不允许 +``` + +**Props**: + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `color` | `keyof Theme \| Color` | — | 文字颜色 | +| `backgroundColor` | `keyof Theme` | — | 背景色(仅接受主题 key) | +| `bold` | `boolean` | — | 加粗(与 dim 互斥) | +| `dim` | `boolean` | — | 暗淡(与 bold 互斥) | +| `dimColor` | `boolean` | — | 使用主题 inactive 色着色(不与 bold 互斥) | +| `italic` | `boolean` | — | 斜体 | +| `underline` | `boolean` | — | 下划线 | +| `strikethrough` | `boolean` | — | 删除线 | +| `inverse` | `boolean` | — | 反转前景/背景 | +| `wrap` | 见下方 | `'wrap'` | 文本换行/截断模式 | + +**wrap 模式**: + +| 值 | 说明 | +|----|------| +| `'wrap'` | 自动换行(默认) | +| `'wrap-trim'` | 换行并去除行尾空格 | +| `'end'` | 不换行,末尾省略 | +| `'middle'` | 不换行,中间省略 | +| `'truncate-end'` | 末尾截断 | +| `'truncate'` | 同 truncate-end | +| `'truncate-middle'` | 中间截断 | +| `'truncate-start'` | 开头截断 | + +**`TextHoverColorContext`**: 为子树中未设置 `color` 的 Text 提供级联颜色。 + +```tsx +import { Text, TextHoverColorContext } from '@anthropic/ink' + + + 这段文字会继承 suggestion 色 + 这段保持 error 色 + +``` + +--- + +### ScrollBox + +支持虚拟滚动的容器,用于显示超出视口的内容。 + +```tsx +import { ScrollBox, Box, Text } from '@anthropic/ink' +import { useRef } from 'react' + +function LogViewer({ lines }: { lines: string[] }) { + const scrollRef = useRef(null) + + // 编程式滚动 + useKeybinding('app:scrollUp', () => scrollRef.current?.scrollBy(-5)) + useKeybinding('app:scrollDown', () => scrollRef.current?.scrollBy(5)) + + return ( + + {lines.map((line, i) => ( + {line} + ))} + + ) +} +``` + +**ScrollBoxHandle 方法**: + +| 方法 | 返回 | 说明 | +|------|------|------| +| `scrollTo(y)` | `void` | 滚动到指定行 | +| `scrollBy(dy)` | `void` | 相对滚动 | +| `scrollToElement(el, offset?)` | `void` | 滚动到指定元素 | +| `scrollToBottom()` | `void` | 滚到底部 | +| `getScrollTop()` | `number` | 当前滚动位置 | +| `getScrollHeight()` | `number` | 内容总高度 | +| `getViewportHeight()` | `number` | 视口高度 | +| `isSticky()` | `boolean` | 是否粘在底部 | +| `setClampBounds(min, max)` | `void` | 限制滚动范围 | +| `subscribe(listener)` | `() => void` | 订阅滚动变化 | + +**关键 Props**: +- `stickyScroll` — 自动跟踪最新内容(适合日志/聊天) +- 继承所有 `Styles` 属性(需设置 `flexGrow={1}` 或固定 `height`) + +--- + +### Button + +可交互按钮,支持键盘(Enter/Space)和鼠标点击。 + +```tsx +import { Button, Text } from '@anthropic/ink' + +function ConfirmButton({ onConfirm }: { onConfirm: () => void }) { + return ( + + ) +} +``` + +**Props**: + +| 属性 | 类型 | 说明 | +|------|------|------| +| `onAction` | `() => void` | 按下 Enter/Space/点击时触发 | +| `tabIndex` | `number` | Tab 序号 | +| `autoFocus` | `boolean` | 自动聚焦 | +| `children` | `(state: ButtonState) => ReactNode \| ReactNode` | render prop 或静态内容 | + +**ButtonState**: `{ focused: boolean, hovered: boolean, active: boolean }` + +--- + +### 其他基础组件 + +#### `Newline` + +```tsx + // 插入一个空行 + // 插入三个空行 +``` + +#### `Spacer` + +```tsx + + + // 占据剩余空间 + + +``` + +#### `Link` — OSC 8 超链接 + +```tsx +点击打开 +``` + +#### `NoSelect` — 禁止文本选择 + +```tsx + + 这段文字无法被鼠标选中复制 + +``` + +#### `RawAnsi` — 预渲染 ANSI 内容 + +```tsx + +``` + +#### `AlternateScreen` — 全屏 TUI 模式 + +```tsx + + {/* 启用鼠标追踪、文本选择等高级功能 */} + + +``` + +--- + +### 设计系统组件 + +这些是 theme 层提供的高级 UI 组件。 + +#### `Dialog` — 对话框 + +```tsx +import { Dialog, Box, Text } from '@anthropic/ink' + + + + 确定要继续吗? + +