mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
Merge branch 'main' into pr/suger-m/213
This commit is contained in:
5
packages/@ant/claude-for-chrome-mcp/tsconfig.json
Normal file
5
packages/@ant/claude-for-chrome-mcp/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -5,9 +5,12 @@
|
||||
* mouse and keyboard via CoreGraphics events and System Events.
|
||||
*/
|
||||
|
||||
import { $ } from 'bun'
|
||||
import { execFile, execFileSync } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import type { FrontmostAppInfo, InputBackend } from '../types.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const KEY_MAP: Record<string, number> = {
|
||||
return: 36, enter: 36, tab: 48, space: 49, delete: 51, backspace: 51,
|
||||
escape: 53, esc: 53,
|
||||
@@ -25,13 +28,17 @@ const MODIFIER_MAP: Record<string, string> = {
|
||||
}
|
||||
|
||||
async function osascript(script: string): Promise<string> {
|
||||
const result = await $`osascript -e ${script}`.quiet().nothrow().text()
|
||||
return result.trim()
|
||||
const { stdout } = await execFileAsync('osascript', ['-e', script], {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
async function jxa(script: string): Promise<string> {
|
||||
const result = await $`osascript -l JavaScript -e ${script}`.quiet().nothrow().text()
|
||||
return result.trim()
|
||||
const { stdout } = await execFileAsync('osascript', ['-l', 'JavaScript', '-e', script], {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
function buildMouseJxa(eventType: string, x: number, y: number, btn: number, clickState?: number): string {
|
||||
@@ -115,19 +122,14 @@ export const typeText: InputBackend['typeText'] = async (text) => {
|
||||
|
||||
export const getFrontmostAppInfo: InputBackend['getFrontmostAppInfo'] = () => {
|
||||
try {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ['osascript', '-e', `
|
||||
tell application "System Events"
|
||||
set frontApp to first application process whose frontmost is true
|
||||
set appName to name of frontApp
|
||||
set bundleId to bundle identifier of frontApp
|
||||
return bundleId & "|" & appName
|
||||
end tell
|
||||
`],
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
const output = new TextDecoder().decode(result.stdout).trim()
|
||||
const output = execFileSync('osascript', ['-e', `
|
||||
tell application "System Events"
|
||||
set frontApp to first application process whose frontmost is true
|
||||
set appName to name of frontApp
|
||||
set bundleId to bundle identifier of frontApp
|
||||
return bundleId & "|" & appName
|
||||
end tell
|
||||
`], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim()
|
||||
if (!output || !output.includes('|')) return null
|
||||
const [bundleId, appName] = output.split('|', 2)
|
||||
return { bundleId: bundleId!, appName: appName! }
|
||||
|
||||
5
packages/@ant/computer-use-input/tsconfig.json
Normal file
5
packages/@ant/computer-use-input/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export interface DisplayGeometry {
|
||||
scaleFactor: number
|
||||
originX: number
|
||||
originY: number
|
||||
label?: string
|
||||
isPrimary?: boolean
|
||||
}
|
||||
|
||||
export interface ScreenshotResult {
|
||||
@@ -42,6 +44,7 @@ export interface ResolvePrepareCaptureResult extends ScreenshotResult {
|
||||
hidden: string[]
|
||||
activated?: string
|
||||
displayId: number
|
||||
captureError?: string
|
||||
}
|
||||
|
||||
export interface ComputerExecutorCapabilities {
|
||||
|
||||
@@ -37,6 +37,24 @@
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
/** Detect actual image MIME type from base64 data by decoding the magic bytes. */
|
||||
function detectMimeFromBase64(b64: string): string {
|
||||
// Decode first 12 raw bytes (16 base64 chars is enough) and check standard magic bytes.
|
||||
// PNG: 89 50 4E 47
|
||||
// JPEG: FF D8 FF
|
||||
// RIFF+WEBP: "RIFF" at 0..3 + "WEBP" at 8..11
|
||||
// GIF: "GIF" at 0..2
|
||||
const raw = Buffer.from(b64.slice(0, 16), "base64");
|
||||
if (raw[0] === 0x89 && raw[1] === 0x50 && raw[2] === 0x4e && raw[3] === 0x47) return "image/png";
|
||||
if (raw[0] === 0xff && raw[1] === 0xd8 && raw[2] === 0xff) return "image/jpeg";
|
||||
if (
|
||||
raw[0] === 0x52 && raw[1] === 0x49 && raw[2] === 0x46 && raw[3] === 0x46 && // RIFF
|
||||
raw[8] === 0x57 && raw[9] === 0x45 && raw[10] === 0x42 && raw[11] === 0x50 // WEBP
|
||||
) return "image/webp";
|
||||
if (raw[0] === 0x47 && raw[1] === 0x49 && raw[2] === 0x46) return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
import { getDefaultTierForApp, getDeniedCategoryForApp, isPolicyDenied } from "./deniedApps.js";
|
||||
import type {
|
||||
ComputerExecutor,
|
||||
@@ -88,6 +106,8 @@ export type CuErrorKind =
|
||||
| "state_conflict" // wrong state for action (call sequence, mouse already held)
|
||||
| "grant_flag_required" // action needs a grant flag (systemKeyCombos, clipboard*) from request_access
|
||||
| "display_error" // display enumeration failed (platform)
|
||||
| "launch_failed" // failed to launch an external process (e.g. terminal)
|
||||
| "element_not_found" // UI element not found (e.g. window, automation element)
|
||||
| "other";
|
||||
|
||||
/**
|
||||
@@ -906,9 +926,10 @@ async function handleRequestAccess(
|
||||
);
|
||||
}
|
||||
|
||||
const perms = recheck as { granted: false; accessibility: boolean; screenRecording: boolean };
|
||||
const missing: string[] = [];
|
||||
if (!recheck.accessibility) missing.push("Accessibility");
|
||||
if (!recheck.screenRecording) missing.push("Screen Recording");
|
||||
if (!perms.accessibility) missing.push("Accessibility");
|
||||
if (!perms.screenRecording) missing.push("Screen Recording");
|
||||
return errorResult(
|
||||
`macOS ${missing.join(" and ")} permission(s) not yet granted. ` +
|
||||
`The permission panel has been shown. Once the user grants the ` +
|
||||
@@ -1423,9 +1444,10 @@ async function handleRequestTeachAccess(
|
||||
);
|
||||
}
|
||||
|
||||
const perms = recheck as { granted: false; accessibility: boolean; screenRecording: boolean };
|
||||
const missing: string[] = [];
|
||||
if (!recheck.accessibility) missing.push("Accessibility");
|
||||
if (!recheck.screenRecording) missing.push("Screen Recording");
|
||||
if (!perms.accessibility) missing.push("Accessibility");
|
||||
if (!perms.screenRecording) missing.push("Screen Recording");
|
||||
return errorResult(
|
||||
`macOS ${missing.join(" and ")} permission(s) not yet granted. ` +
|
||||
`The permission panel has been shown. Once the user grants the ` +
|
||||
@@ -2144,7 +2166,7 @@ async function handleScreenshot(
|
||||
|
||||
const monitorNote = await buildMonitorNote(
|
||||
adapter,
|
||||
shot.displayId,
|
||||
shot.displayId ?? 0,
|
||||
overrides.lastScreenshot?.displayId,
|
||||
overrides.onDisplayPinned !== undefined,
|
||||
);
|
||||
@@ -2158,7 +2180,7 @@ async function handleScreenshot(
|
||||
{
|
||||
type: "image",
|
||||
data: shot.base64,
|
||||
mimeType: "image/jpeg",
|
||||
mimeType: detectMimeFromBase64(shot.base64),
|
||||
},
|
||||
],
|
||||
screenshot: shot,
|
||||
@@ -2213,7 +2235,7 @@ async function handleScreenshot(
|
||||
|
||||
const monitorNote = await buildMonitorNote(
|
||||
adapter,
|
||||
shot.displayId,
|
||||
shot.displayId ?? 0,
|
||||
overrides.lastScreenshot?.displayId,
|
||||
overrides.onDisplayPinned !== undefined,
|
||||
);
|
||||
@@ -2227,7 +2249,7 @@ async function handleScreenshot(
|
||||
{
|
||||
type: "image",
|
||||
data: shot.base64,
|
||||
mimeType: "image/jpeg",
|
||||
mimeType: detectMimeFromBase64(shot.base64),
|
||||
},
|
||||
],
|
||||
// Piggybacked for serverDef.ts to stash on InternalServerContext.
|
||||
@@ -2306,7 +2328,7 @@ async function handleZoom(
|
||||
|
||||
// Return the image. NO `.screenshot` piggyback — this is the invariant.
|
||||
return {
|
||||
content: [{ type: "image", data: zoomed.base64, mimeType: "image/jpeg" }],
|
||||
content: [{ type: "image", data: zoomed.base64, mimeType: detectMimeFromBase64(zoomed.base64) }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4082,8 +4104,8 @@ export async function handleToolCall(
|
||||
);
|
||||
}
|
||||
tccState = {
|
||||
accessibility: osPerms.accessibility,
|
||||
screenRecording: osPerms.screenRecording,
|
||||
accessibility: (osPerms as { granted: false; accessibility: boolean; screenRecording: boolean }).accessibility,
|
||||
screenRecording: (osPerms as { granted: false; accessibility: boolean; screenRecording: boolean }).screenRecording,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
5
packages/@ant/computer-use-mcp/tsconfig.json
Normal file
5
packages/@ant/computer-use-mcp/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -14,6 +14,17 @@ import type {
|
||||
SwiftBackend, WindowDisplayInfo,
|
||||
} from '../types.js'
|
||||
|
||||
export type {
|
||||
DisplayGeometry,
|
||||
PrepareDisplayResult,
|
||||
AppInfo,
|
||||
InstalledApp,
|
||||
RunningApp,
|
||||
ScreenshotResult,
|
||||
ResolvePrepareCaptureResult,
|
||||
WindowDisplayInfo,
|
||||
} from '../types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -263,4 +274,9 @@ export const screenshot: ScreenshotAPI = {
|
||||
if (displayId !== undefined) args.push('-D', String(displayId))
|
||||
return captureScreenToBase64(args)
|
||||
},
|
||||
|
||||
captureWindowTarget(_titleOrHwnd: string | number): ScreenshotResult | null {
|
||||
// Window capture not supported on macOS via this backend
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -275,4 +275,9 @@ export const screenshot: ScreenshotAPI = {
|
||||
return { base64: '', width: 0, height: 0 }
|
||||
}
|
||||
},
|
||||
|
||||
captureWindowTarget(_titleOrHwnd: string | number): ScreenshotResult | null {
|
||||
// Window capture not supported on Linux via this backend
|
||||
return null
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface DisplayGeometry {
|
||||
height: number
|
||||
scaleFactor: number
|
||||
displayId: number
|
||||
label?: string
|
||||
isPrimary?: boolean
|
||||
}
|
||||
|
||||
export interface PrepareDisplayResult {
|
||||
@@ -37,6 +39,9 @@ export interface ResolvePrepareCaptureResult {
|
||||
base64: string
|
||||
width: number
|
||||
height: number
|
||||
captureError?: string
|
||||
displayId?: number
|
||||
hidden?: string[]
|
||||
}
|
||||
|
||||
export interface WindowDisplayInfo {
|
||||
@@ -71,6 +76,7 @@ export interface ScreenshotAPI {
|
||||
x: number, y: number, w: number, h: number,
|
||||
outW: number, outH: number, quality: number, displayId?: number,
|
||||
): Promise<ScreenshotResult>
|
||||
captureWindowTarget(titleOrHwnd: string | number): ScreenshotResult | null
|
||||
}
|
||||
|
||||
export interface SwiftBackend {
|
||||
|
||||
5
packages/@ant/computer-use-swift/tsconfig.json
Normal file
5
packages/@ant/computer-use-swift/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
176
packages/@ant/ink/docs/01-getting-started.md
Normal file
176
packages/@ant/ink/docs/01-getting-started.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Chapter 1: Getting Started
|
||||
|
||||
## Installation
|
||||
|
||||
`@anthropic/ink` is a workspace package. It is consumed internally and not published to npm.
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@anthropic/ink": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Peer Dependencies
|
||||
|
||||
- `react` ^19.2.4
|
||||
- `react-reconciler` ^0.33.0
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `chalk` | ANSI color generation |
|
||||
| `cli-boxes` | Border style definitions |
|
||||
| `get-east-asian-width` | CJK character width measurement |
|
||||
| `wrap-ansi` | ANSI-aware word wrapping |
|
||||
| `bidi-js` | Bidirectional text support |
|
||||
| `lodash-es` | Utility functions (throttle, noop) |
|
||||
| `signal-exit` | Process exit handler cleanup |
|
||||
| `emoji-regex` | Emoji width handling |
|
||||
|
||||
## Basic Rendering
|
||||
|
||||
### `render(node, options?)`
|
||||
|
||||
The primary entry point. Renders a React element tree to the terminal.
|
||||
|
||||
```tsx
|
||||
import { render } from '@anthropic/ink'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
|
||||
const { unmount, rerender, waitUntilExit } = await render(
|
||||
<Box>
|
||||
<Text>Hello, World!</Text>
|
||||
</Box>
|
||||
)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `node` -- `ReactNode` to render
|
||||
- `options` -- `RenderOptions | NodeJS.WriteStream` (optional)
|
||||
|
||||
**Returns:** `Promise<Instance>` with:
|
||||
- `rerender(node)` -- Replace the root node
|
||||
- `unmount()` -- Unmount and clean up
|
||||
- `waitUntilExit()` -- `Promise<void>` that resolves on unmount
|
||||
- `cleanup()` -- Remove from instance registry
|
||||
|
||||
### `renderSync(node, options?)`
|
||||
|
||||
Synchronous version of render. Same API, returns `Instance` directly (no Promise).
|
||||
|
||||
```tsx
|
||||
import { renderSync } from '@anthropic/ink'
|
||||
|
||||
const instance = renderSync(<App />)
|
||||
// instance.rerender, instance.unmount, etc.
|
||||
```
|
||||
|
||||
### `createRoot(options?)`
|
||||
|
||||
Creates a managed Ink root without immediately rendering. Similar to `react-dom`'s `createRoot`.
|
||||
|
||||
```tsx
|
||||
import { createRoot } from '@anthropic/ink'
|
||||
|
||||
const root = await createRoot({ exitOnCtrlC: false })
|
||||
|
||||
// Later, render into it
|
||||
root.render(<App />)
|
||||
|
||||
// You can re-render into the same root
|
||||
root.render(<DifferentApp />)
|
||||
|
||||
// Clean up
|
||||
root.unmount()
|
||||
```
|
||||
|
||||
**Returns:** `Promise<Root>` with:
|
||||
- `render(node)` -- Mount or update the tree
|
||||
- `unmount()` -- Unmount
|
||||
- `waitUntilExit()` -- `Promise<void>`
|
||||
|
||||
## RenderOptions
|
||||
|
||||
```ts
|
||||
type RenderOptions = {
|
||||
/** Output stream. Default: process.stdout */
|
||||
stdout?: NodeJS.WriteStream
|
||||
|
||||
/** Input stream. Default: process.stdin */
|
||||
stdin?: NodeJS.ReadStream
|
||||
|
||||
/** Error stream. Default: process.stderr */
|
||||
stderr?: NodeJS.WriteStream
|
||||
|
||||
/** Handle Ctrl+C to exit. Default: true */
|
||||
exitOnCtrlC?: boolean
|
||||
|
||||
/** Patch console methods to prevent Ink output mixing. Default: true */
|
||||
patchConsole?: boolean
|
||||
|
||||
/** Called after each frame render with timing info. */
|
||||
onFrame?: (event: FrameEvent) => void
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Concepts
|
||||
|
||||
### Component Tree
|
||||
|
||||
Ink renders React components to a terminal using a custom reconciler. The tree structure maps to terminal output:
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="green">Header</Text>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text>Left</Text>
|
||||
<Text>Right</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
```
|
||||
|
||||
This produces terminal output with Flexbox layout (via Yoga).
|
||||
|
||||
### Rendering Pipeline
|
||||
|
||||
1. **React Reconciler** -- Standard React reconciliation; diffs virtual tree
|
||||
2. **Yoga Layout** -- Computes Flexbox positions/ sizes for every node
|
||||
3. **Render to Output** -- Walks the DOM tree, emits styled text into an `Output` buffer
|
||||
4. **Screen Diff** -- Compares new frame against previous frame in a screen buffer
|
||||
5. **Terminal Write** -- Emits minimal ANSI escape sequences to update only changed cells
|
||||
|
||||
### Module System
|
||||
|
||||
Import everything from the package root:
|
||||
|
||||
```tsx
|
||||
// Core rendering
|
||||
import { render, createRoot, renderSync } from '@anthropic/ink'
|
||||
|
||||
// Components (base, no theme)
|
||||
import { BaseBox, BaseText, ScrollBox, Button, Link, Newline, Spacer } from '@anthropic/ink'
|
||||
|
||||
// Theme-aware components (recommended)
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
|
||||
// Hooks
|
||||
import { useApp, useInput, useTerminalSize, useInterval } from '@anthropic/ink'
|
||||
|
||||
// Theme
|
||||
import { ThemeProvider, useTheme, color } from '@anthropic/ink'
|
||||
|
||||
// Keybindings
|
||||
import { useKeybinding, KeybindingProvider } from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### Naming Convention: Base vs Theme-aware
|
||||
|
||||
The package exports both raw and theme-aware versions of core components:
|
||||
|
||||
- **`BaseBox`** / **`BaseText`** -- Raw components that only accept raw color values (`rgb(...)`, `#hex`, `ansi:...`, `ansi256(...)`)
|
||||
- **`Box`** / **`Text`** -- Theme-aware wrappers that accept both theme keys (`'claude'`, `'success'`, `'error'`) and raw color values
|
||||
|
||||
Always prefer the theme-aware versions unless you have a specific reason to use raw components.
|
||||
348
packages/@ant/ink/docs/02-layout.md
Normal file
348
packages/@ant/ink/docs/02-layout.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# Chapter 2: Layout System
|
||||
|
||||
Ink uses [Yoga](https://yogalayout.com/) (Facebook's cross-platform layout engine) to implement CSS Flexbox in the terminal. Every layout is flexbox-based -- there is no CSS Grid or flow layout.
|
||||
|
||||
## Box Component
|
||||
|
||||
`Box` is the fundamental layout primitive. It is the terminal equivalent of `<div style="display: flex">`.
|
||||
|
||||
```tsx
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text>Left</Text>
|
||||
<Text>Right</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Box Props (Styles)
|
||||
|
||||
All layout props are passed directly as JSX props (no `style={}` wrapper needed):
|
||||
|
||||
#### Flex Direction
|
||||
|
||||
Controls the main axis direction.
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="row">...</Box> // Left to right (default)
|
||||
<Box flexDirection="column">...</Box> // Top to bottom
|
||||
<Box flexDirection="row-reverse">...</Box> // Right to left
|
||||
<Box flexDirection="column-reverse">...</Box> // Bottom to top
|
||||
```
|
||||
|
||||
#### Flex Grow / Shrink / Basis
|
||||
|
||||
```tsx
|
||||
<Box flexGrow={1}>...</Box> // Grow to fill available space
|
||||
<Box flexShrink={0}>...</Box> // Don't shrink below intrinsic size
|
||||
<Box flexBasis={20}>...</Box> // Initial size before flex distribution
|
||||
<Box flexBasis="50%">...</Box> // Percentage basis
|
||||
```
|
||||
|
||||
Default values: `flexGrow={0}`, `flexShrink={1}`, `flexBasis=auto`.
|
||||
|
||||
#### Flex Wrap
|
||||
|
||||
```tsx
|
||||
<Box flexWrap="nowrap">...</Box> // Single line (default)
|
||||
<Box flexWrap="wrap">...</Box> // Multiple lines
|
||||
<Box flexWrap="wrap-reverse">...</Box> // Reverse cross-axis stacking
|
||||
```
|
||||
|
||||
#### Alignment
|
||||
|
||||
```tsx
|
||||
<Box alignItems="flex-start">...</Box> // Cross-axis start
|
||||
<Box alignItems="center">...</Box> // Cross-axis center
|
||||
<Box alignItems="flex-end">...</Box> // Cross-axis end
|
||||
<Box alignItems="stretch">...</Box> // Stretch to fill (default)
|
||||
|
||||
<Box alignSelf="flex-start">...</Box> // Override parent's alignItems
|
||||
<Box alignSelf="center">...</Box>
|
||||
<Box alignSelf="flex-end">...</Box>
|
||||
<Box alignSelf="auto">...</Box> // Inherit from parent
|
||||
```
|
||||
|
||||
#### Justify Content
|
||||
|
||||
```tsx
|
||||
<Box justifyContent="flex-start">...</Box> // Main-axis start (default)
|
||||
<Box justifyContent="flex-end">...</Box> // Main-axis end
|
||||
<Box justifyContent="center">...</Box> // Center
|
||||
<Box justifyContent="space-between">...</Box> // Equal gaps, no edges
|
||||
<Box justifyContent="space-around">...</Box> // Equal gaps with edges
|
||||
<Box justifyContent="space-evenly">...</Box> // Evenly distributed
|
||||
```
|
||||
|
||||
#### Gap
|
||||
|
||||
Spacing between children (only accepts integers):
|
||||
|
||||
```tsx
|
||||
<Box gap={1}>...</Box> // Both row and column gap
|
||||
<Box columnGap={2}>...</Box> // Gap between columns only
|
||||
<Box rowGap={1}>...</Box> // Gap between rows only
|
||||
```
|
||||
|
||||
#### Padding
|
||||
|
||||
Inner spacing (only accepts integers):
|
||||
|
||||
```tsx
|
||||
<Box padding={1}>...</Box> // All sides
|
||||
<Box paddingX={2}>...</Box> // Left and right
|
||||
<Box paddingY={1}>...</Box> // Top and bottom
|
||||
<Box paddingLeft={2}>...</Box> // Left only
|
||||
<Box paddingRight={2}>...</Box> // Right only
|
||||
<Box paddingTop={1}>...</Box> // Top only
|
||||
<Box paddingBottom={1}>...</Box> // Bottom only
|
||||
```
|
||||
|
||||
#### Margin
|
||||
|
||||
Outer spacing (only accepts integers):
|
||||
|
||||
```tsx
|
||||
<Box margin={1}>...</Box> // All sides
|
||||
<Box marginX={2}>...</Box> // Left and right
|
||||
<Box marginY={1}>...</Box> // Top and bottom
|
||||
<Box marginLeft={2}>...</Box> // Left only
|
||||
<Box marginRight={2}>...</Box> // Right only
|
||||
<Box marginTop={1}>...</Box> // Top only
|
||||
<Box marginBottom={1}>...</Box> // Bottom only
|
||||
```
|
||||
|
||||
> **Note:** Fractional values for padding, margin, and gap are not supported. Ink will emit warnings if non-integer values are used.
|
||||
|
||||
#### Width & Height
|
||||
|
||||
```tsx
|
||||
<Box width={40}>...</Box> // Fixed 40 characters wide
|
||||
<Box height={10}>...</Box> // Fixed 10 rows tall
|
||||
<Box width="50%">...</Box> // 50% of parent's width
|
||||
<Box width="100%">...</Box> // Full parent width
|
||||
```
|
||||
|
||||
#### Min/Max Dimensions
|
||||
|
||||
```tsx
|
||||
<Box minWidth={20}>...</Box>
|
||||
<Box maxWidth={80}>...</Box>
|
||||
<Box minHeight={5}>...</Box>
|
||||
<Box maxHeight={20}>...</Box>
|
||||
```
|
||||
|
||||
Percentage values are supported: `minWidth="30%"`.
|
||||
|
||||
#### Position
|
||||
|
||||
```tsx
|
||||
<Box position="absolute" top={0} right={0}>...</Box>
|
||||
<Box position="absolute" top="10%" left="20%">...</Box>
|
||||
<Box position="relative">...</Box> // Default
|
||||
```
|
||||
|
||||
Position `absolute` removes the element from normal flow and positions it relative to its nearest positioned ancestor. Useful for overlays.
|
||||
|
||||
#### Display
|
||||
|
||||
```tsx
|
||||
<Box display="flex">...</Box> // Visible (default)
|
||||
<Box display="none">...</Box> // Hidden (removed from layout)
|
||||
```
|
||||
|
||||
#### Border
|
||||
|
||||
```tsx
|
||||
<Box borderStyle="single">...</Box> // Thin border
|
||||
<Box borderStyle="double">...</Box> // Double-line border
|
||||
<Box borderStyle="round">...</Box> // Rounded corners
|
||||
<Box borderStyle="bold">...</Box> // Bold border
|
||||
<Box borderStyle="singleDouble">...</Box> // Mixed
|
||||
<Box borderStyle="doubleSingle">...</Box> // Mixed
|
||||
<Box borderStyle="classic">...</Box> // ASCII art border
|
||||
```
|
||||
|
||||
Control individual sides and colors:
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={false} // Hide top border
|
||||
borderBottom={true} // Show bottom border
|
||||
borderColor="rgb(255,0,0)" // Red border
|
||||
borderDimColor={true} // Dim the border
|
||||
>
|
||||
...
|
||||
</Box>
|
||||
```
|
||||
|
||||
Per-side colors:
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTopColor="rgb(255,0,0)"
|
||||
borderBottomColor="ansi:green"
|
||||
borderLeftColor="#0000FF"
|
||||
borderRightColor="ansi256(200)"
|
||||
/>
|
||||
```
|
||||
|
||||
Border text (labels in the border):
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderText={{ title: "My Panel", align: "left" }}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Background
|
||||
|
||||
```tsx
|
||||
<Box backgroundColor="rgb(40,40,40)">...</Box>
|
||||
```
|
||||
|
||||
#### Overflow
|
||||
|
||||
```tsx
|
||||
<Box overflow="visible">...</Box> // Content expands container (default)
|
||||
<Box overflow="hidden">...</Box> // Clip without scrolling
|
||||
<Box overflow="scroll">...</Box> // Enable scrolling (use ScrollBox)
|
||||
```
|
||||
|
||||
`overflowX` and `overflowY` control each axis independently.
|
||||
|
||||
#### Opaque
|
||||
|
||||
```tsx
|
||||
<Box opaque={true}>...</Box>
|
||||
```
|
||||
|
||||
Fills the box interior with spaces (using terminal's default background) before rendering children. Useful for absolute-positioned overlays where gaps would otherwise be transparent.
|
||||
|
||||
#### NoSelect
|
||||
|
||||
```tsx
|
||||
<Box noSelect={true}>...</Box> // Exclude from text selection
|
||||
<Box noSelect="from-left-edge">...</Box> // Exclude from column 0 to box edge
|
||||
```
|
||||
|
||||
Only affects alt-screen text selection. Useful for gutters (line numbers, diff markers).
|
||||
|
||||
## Spacer
|
||||
|
||||
`Spacer` fills all available space along the main axis (equivalent to `flexGrow: 1`).
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="row">
|
||||
<Text>Left</Text>
|
||||
<Spacer />
|
||||
<Text>Right</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
## Newline
|
||||
|
||||
Inserts line breaks.
|
||||
|
||||
```tsx
|
||||
<Text>
|
||||
Line 1
|
||||
<Newline />
|
||||
Line 2
|
||||
<Newline count={2} />
|
||||
Line 4 (after double break)
|
||||
</Text>
|
||||
```
|
||||
|
||||
## Layout Examples
|
||||
|
||||
### Two-column layout
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="row" width={80}>
|
||||
<Box width="50%" padding={1}>
|
||||
<Text>Left column</Text>
|
||||
</Box>
|
||||
<Box width="50%" padding={1}>
|
||||
<Text>Right column</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Centered content
|
||||
|
||||
```tsx
|
||||
<Box justifyContent="center" alignItems="center" height={20}>
|
||||
<Text>Centered!</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Sticky footer
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="column" height={24}>
|
||||
<Box flexGrow={1}>
|
||||
<Text>Scrollable content area</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Status bar at bottom</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Bordered panel with title
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="rgb(87,105,247)"
|
||||
padding={1}
|
||||
width={60}
|
||||
>
|
||||
<Text bold>Panel Title</Text>
|
||||
<Text>Panel content goes here.</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
## NoSelect
|
||||
|
||||
Wraps a region to exclude it from text selection in alt-screen mode. A convenience wrapper around `Box` with `noSelect` set.
|
||||
|
||||
```tsx
|
||||
import { NoSelect } from '@anthropic/ink'
|
||||
|
||||
<Box flexDirection="row">
|
||||
<NoSelect>
|
||||
<Text dimColor>1 │ </Text>
|
||||
</NoSelect>
|
||||
<Text>selectable code here</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `children` | `ReactNode` | - | Content |
|
||||
| `fromLeftEdge` | `boolean` | `false` | Extend exclusion from column 0 to box's right edge |
|
||||
|
||||
Accepts all `BoxProps` except `noSelect`.
|
||||
|
||||
## BaseBox vs ThemedBox
|
||||
|
||||
Two versions of Box are exported:
|
||||
|
||||
- **`BaseBox`** (imported as `BaseBox`) -- Raw box, color props accept only raw `Color` values
|
||||
- **`Box`** (themed, imported as `Box`) -- Theme-aware, color props accept `keyof Theme | Color`
|
||||
|
||||
```tsx
|
||||
// Raw
|
||||
<BaseBox borderStyle="single" borderColor="rgb(255,0,0)" />
|
||||
|
||||
// Theme-aware (resolves 'permission' to the current theme's blue)
|
||||
<Box borderStyle="single" borderColor="permission" />
|
||||
```
|
||||
238
packages/@ant/ink/docs/03-text-and-styling.md
Normal file
238
packages/@ant/ink/docs/03-text-and-styling.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Chapter 3: Text & Styling
|
||||
|
||||
## Text Component
|
||||
|
||||
`Text` renders styled text content. It supports colors, emphasis, and text wrapping.
|
||||
|
||||
```tsx
|
||||
import { Text } from '@anthropic/ink'
|
||||
|
||||
<Text bold color="success">Operation complete</Text>
|
||||
```
|
||||
|
||||
### Text Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `color` | `keyof Theme \| Color` | - | Foreground color |
|
||||
| `backgroundColor` | `keyof Theme` | - | Background color (theme-aware) |
|
||||
| `bold` | `boolean` | `false` | Bold text |
|
||||
| `dimColor` | `boolean` | `false` | Dim text (uses theme's `inactive` color) |
|
||||
| `italic` | `boolean` | `false` | Italic text |
|
||||
| `underline` | `boolean` | `false` | Underlined text |
|
||||
| `strikethrough` | `boolean` | `false` | Strikethrough text |
|
||||
| `inverse` | `boolean` | `false` | Swap foreground/background |
|
||||
| `wrap` | `TextWrap` | `'wrap'` | Wrapping/truncation mode |
|
||||
| `children` | `ReactNode` | - | Text content |
|
||||
|
||||
> **Note:** `bold` and `dimColor` are mutually exclusive (ANSI terminals cannot render both simultaneously).
|
||||
|
||||
### BaseText vs ThemedText
|
||||
|
||||
- **`BaseText`** -- Accepts raw `Color` values only
|
||||
- **`Text`** (default export) -- Theme-aware, accepts `keyof Theme | Color` for `color`, and `keyof Theme` for `backgroundColor`
|
||||
|
||||
```tsx
|
||||
// Raw color
|
||||
<BaseText color="rgb(255,0,0)">Red text</BaseText>
|
||||
|
||||
// Theme key (resolved to current theme palette)
|
||||
<Text color="error">Error message</Text>
|
||||
|
||||
// Mixed
|
||||
<Text color="#FF0000">Custom red</Text>
|
||||
```
|
||||
|
||||
### Text Wrap Modes
|
||||
|
||||
```tsx
|
||||
<Text wrap="wrap">...</Text> // Word-wrap at container width (default)
|
||||
<Text wrap="wrap-trim">...</Text> // Wrap + trim trailing whitespace
|
||||
<Text wrap="end">...</Text> // Truncate with "..." at end
|
||||
<Text wrap="truncate-end">...</Text> // Same as "end"
|
||||
<Text wrap="truncate">...</Text> // Truncate (no ellipsis)
|
||||
<Text wrap="middle">...</Text> // "start...end"
|
||||
<Text wrap="truncate-middle">...</Text> // Same as "middle"
|
||||
<Text wrap="truncate-start">...</Text> // "...text"
|
||||
```
|
||||
|
||||
### TextHoverColorContext
|
||||
|
||||
Uncolored `Text` children inherit a hover color from context:
|
||||
|
||||
```tsx
|
||||
import { TextHoverColorContext } from '@anthropic/ink'
|
||||
|
||||
<TextHoverColorContext.Provider value="suggestion">
|
||||
<Text>Uncolored text gets the suggestion color</Text>
|
||||
<Text color="error">This stays red</Text>
|
||||
</TextHoverColorContext.Provider>
|
||||
```
|
||||
|
||||
Precedence: explicit `color` > `TextHoverColorContext` > `dimColor`.
|
||||
|
||||
## Color System
|
||||
|
||||
### Raw Color Formats
|
||||
|
||||
Four formats are supported for raw color values:
|
||||
|
||||
```tsx
|
||||
// RGB
|
||||
<Text color="rgb(255,107,128)">Bright red</Text>
|
||||
|
||||
// Hex
|
||||
<Text color="#FF6B80">Bright red</Text>
|
||||
|
||||
// ANSI 256-color
|
||||
<Text color="ansi256(196)">Red from 256-color palette</Text>
|
||||
|
||||
// Named ANSI 16-color
|
||||
<Text color="ansi:red">Red</Text>
|
||||
<Text color="ansi:greenBright">Bright green</Text>
|
||||
```
|
||||
|
||||
### ANSI Named Colors
|
||||
|
||||
Full list of `ansi:` prefixed names:
|
||||
|
||||
| Name | Color |
|
||||
|------|-------|
|
||||
| `ansi:black` | Black |
|
||||
| `ansi:red` | Red |
|
||||
| `ansi:green` | Green |
|
||||
| `ansi:yellow` | Yellow |
|
||||
| `ansi:blue` | Blue |
|
||||
| `ansi:magenta` | Magenta |
|
||||
| `ansi:cyan` | Cyan |
|
||||
| `ansi:white` | White |
|
||||
| `ansi:blackBright` | Dark gray |
|
||||
| `ansi:redBright` | Bright red |
|
||||
| `ansi:greenBright` | Bright green |
|
||||
| `ansi:yellowBright` | Bright yellow |
|
||||
| `ansi:blueBright` | Bright blue |
|
||||
| `ansi:magentaBright` | Bright magenta |
|
||||
| `ansi:cyanBright` | Bright cyan |
|
||||
| `ansi:whiteBright` | Bright white |
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### `color(colorValue, themeName, type?)`
|
||||
|
||||
Curried theme-aware color function. Resolves theme keys to raw color values.
|
||||
|
||||
```tsx
|
||||
import { color } from '@anthropic/ink'
|
||||
|
||||
const paint = color('error', 'dark') // Returns (text: string) => string
|
||||
console.log(paint('failed')) // 'failed' wrapped in ANSI red codes
|
||||
|
||||
const paintFg = color('rgb(255,0,0)', 'dark', 'foreground')
|
||||
const paintBg = color('success', 'dark', 'background')
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `c` -- `keyof Theme | Color | undefined` -- Theme key or raw color
|
||||
- `theme` -- `ThemeName` -- Current theme
|
||||
- `type` -- `'foreground' | 'background'` (default `'foreground'`)
|
||||
|
||||
### `stringWidth(text)`
|
||||
|
||||
Measures the visual width of a string in terminal columns, accounting for:
|
||||
- CJK characters (2 columns each)
|
||||
- Emoji (2 columns each)
|
||||
- ANSI escape sequences (0 columns)
|
||||
|
||||
```tsx
|
||||
import { stringWidth } from '@anthropic/ink'
|
||||
|
||||
stringWidth('hello') // 5
|
||||
stringWidth('你好') // 4
|
||||
stringWidth('\x1b[31mhi') // 2 (ANSI codes ignored)
|
||||
```
|
||||
|
||||
### `wrapText(text, width, textWrap)`
|
||||
|
||||
Wraps text to a given width with the specified wrapping mode.
|
||||
|
||||
```tsx
|
||||
import { wrapText } from '@anthropic/ink'
|
||||
|
||||
wrapText('Hello World', 5, 'wrap') // 'Hello\nWorld'
|
||||
wrapText('Hello World', 8, 'end') // 'Hello...'
|
||||
```
|
||||
|
||||
### `wrapAnsi(text, width)`
|
||||
|
||||
Wraps text containing ANSI escape codes while preserving styling.
|
||||
|
||||
```tsx
|
||||
import { wrapAnsi } from '@anthropic/ink'
|
||||
|
||||
wrapAnsi('\x1b[31mHello World\x1b[0m', 5)
|
||||
// Wraps at word boundaries, keeps color codes intact
|
||||
```
|
||||
|
||||
### `measureElement(node)`
|
||||
|
||||
Measures a rendered DOM element's dimensions.
|
||||
|
||||
```tsx
|
||||
import { measureElement } from '@anthropic/ink'
|
||||
|
||||
const { width, height } = measureElement(domElement)
|
||||
```
|
||||
|
||||
## Link Component
|
||||
|
||||
Renders an OSC 8 terminal hyperlink (clickable URL in supported terminals).
|
||||
|
||||
```tsx
|
||||
import { Link } from '@anthropic/ink'
|
||||
|
||||
<Link url="https://example.com">
|
||||
<Text underline color="suggestion">example.com</Text>
|
||||
</Link>
|
||||
```
|
||||
|
||||
Props:
|
||||
- `url` -- `string` (required) -- Target URL
|
||||
- `children` -- `ReactNode` -- Display content
|
||||
- `fallback` -- `ReactNode` -- Shown when hyperlinks are unsupported
|
||||
|
||||
## RawAnsi Component
|
||||
|
||||
Renders pre-formatted ANSI strings directly into the layout.
|
||||
|
||||
```tsx
|
||||
import { RawAnsi } from '@anthropic/ink'
|
||||
|
||||
<RawAnsi
|
||||
lines={['\x1b[31mRed line 1\x1b[0m', '\x1b[32mGreen line 2\x1b[0m']}
|
||||
width={40}
|
||||
/>
|
||||
```
|
||||
|
||||
Props:
|
||||
- `lines` -- `string[]` -- Pre-rendered ANSI lines (one terminal row each)
|
||||
- `width` -- `number` -- Column width the producer wrapped to
|
||||
|
||||
## Border Rendering
|
||||
|
||||
### `renderBorder(box, output, options?)`
|
||||
|
||||
Low-level border rendering function used internally by Box.
|
||||
|
||||
```tsx
|
||||
import { renderBorder } from '@anthropic/ink'
|
||||
import type { BorderTextOptions } from '@anthropic/ink'
|
||||
```
|
||||
|
||||
Border styles available (from `cli-boxes`):
|
||||
- `single` -- Thin lines `─│┌┐└┘`
|
||||
- `double` -- Double lines `═║╔╗╚╝`
|
||||
- `round` -- Rounded corners `─│╭╮╰╯`
|
||||
- `bold` -- Bold lines `━┃┏┓┗┛`
|
||||
- `singleDouble` -- Single horizontal, double vertical
|
||||
- `doubleSingle` -- Double horizontal, single vertical
|
||||
- `classic` -- ASCII `─|++++`
|
||||
213
packages/@ant/ink/docs/04-theme-system.md
Normal file
213
packages/@ant/ink/docs/04-theme-system.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Chapter 4: Theme System
|
||||
|
||||
The theme system provides consistent, accessible color palettes across the application. It supports dark mode, light mode, ANSI-only terminals, and colorblind-accessible variants.
|
||||
|
||||
## ThemeProvider
|
||||
|
||||
Wraps the application to provide theme context.
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider } from '@anthropic/ink'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider initialState="dark" onThemeSave={(setting) => saveConfig(setting)}>
|
||||
<MyComponent />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `children` | `ReactNode` | Child components |
|
||||
| `initialState` | `ThemeSetting` | Initial theme (default: loads from config) |
|
||||
| `onThemeSave` | `(setting: ThemeSetting) => void` | Called when theme is saved |
|
||||
|
||||
### Theme Configuration Injection
|
||||
|
||||
Before mounting, inject config persistence callbacks:
|
||||
|
||||
```tsx
|
||||
import { setThemeConfigCallbacks } from '@anthropic/ink'
|
||||
|
||||
setThemeConfigCallbacks({
|
||||
loadTheme: () => configStore.get('theme', 'dark'),
|
||||
saveTheme: (setting) => configStore.set('theme', setting),
|
||||
})
|
||||
```
|
||||
|
||||
## Theme Settings
|
||||
|
||||
```ts
|
||||
type ThemeSetting = 'auto' | 'dark' | 'light' | 'light-daltonized' | 'dark-daltonized' | 'light-ansi' | 'dark-ansi'
|
||||
type ThemeName = 'dark' | 'light' | 'light-daltonized' | 'dark-daltonized' | 'light-ansi' | 'dark-ansi'
|
||||
```
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| `dark` | Dark theme with RGB colors (default) |
|
||||
| `light` | Light theme with RGB colors |
|
||||
| `dark-daltonized` | Colorblind-accessible dark theme |
|
||||
| `light-daltonized` | Colorblind-accessible light theme |
|
||||
| `dark-ansi` | Dark theme using only 16 ANSI colors |
|
||||
| `light-ansi` | Light theme using only 16 ANSI colors |
|
||||
| `auto` | Follows terminal's dark/light mode (resolved at runtime) |
|
||||
|
||||
## Theme Hooks
|
||||
|
||||
### `useTheme()`
|
||||
|
||||
Returns the resolved theme name and setter.
|
||||
|
||||
```tsx
|
||||
const [currentTheme, setTheme] = useTheme()
|
||||
// currentTheme: ThemeName (never 'auto')
|
||||
// setTheme: (setting: ThemeSetting) => void
|
||||
```
|
||||
|
||||
### `useThemeSetting()`
|
||||
|
||||
Returns the raw setting (may be `'auto'`).
|
||||
|
||||
```tsx
|
||||
const setting = useThemeSetting() // 'auto' | 'dark' | ...
|
||||
```
|
||||
|
||||
### `usePreviewTheme()`
|
||||
|
||||
Returns preview controls for a theme picker UI.
|
||||
|
||||
```tsx
|
||||
const { setPreviewTheme, savePreview, cancelPreview } = usePreviewTheme()
|
||||
|
||||
// Show preview
|
||||
setPreviewTheme('light')
|
||||
|
||||
// User confirms
|
||||
savePreview()
|
||||
|
||||
// User cancels
|
||||
cancelPreview()
|
||||
```
|
||||
|
||||
## Theme Color Palette
|
||||
|
||||
Every theme defines these semantic color keys:
|
||||
|
||||
### Brand & Identity
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `claude` | Brand orange |
|
||||
| `claudeShimmer` | Lighter brand orange (animated) |
|
||||
| `permission` | Permission/blue |
|
||||
| `permissionShimmer` | Lighter permission blue |
|
||||
| `autoAccept` | Electric violet |
|
||||
| `planMode` | Teal/sage |
|
||||
| `ide` | Muted blue |
|
||||
|
||||
### Semantic Colors
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `text` | Primary text color |
|
||||
| `inverseText` | Text on inverse backgrounds |
|
||||
| `inactive` | Dimmed/disabled elements |
|
||||
| `inactiveShimmer` | Lighter inactive |
|
||||
| `subtle` | Very subtle text |
|
||||
| `suggestion` | Interactive/accent |
|
||||
| `background` | General background accent |
|
||||
| `success` | Positive/success |
|
||||
| `error` | Negative/error |
|
||||
| `warning` | Caution/warning |
|
||||
| `warningShimmer` | Lighter warning |
|
||||
| `merged` | Merged state |
|
||||
|
||||
### Diff Colors
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `diffAdded` | Added lines background |
|
||||
| `diffRemoved` | Removed lines background |
|
||||
| `diffAddedDimmed` | Dimmed added |
|
||||
| `diffRemovedDimmed` | Dimmed removed |
|
||||
| `diffAddedWord` | Word-level added |
|
||||
| `diffRemovedWord` | Word-level removed |
|
||||
|
||||
### UI Colors
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `promptBorder` | Input prompt border |
|
||||
| `promptBorderShimmer` | Lighter prompt border |
|
||||
| `bashBorder` | Shell block border |
|
||||
| `selectionBg` | Text selection highlight background |
|
||||
| `userMessageBackground` | User message background |
|
||||
| `userMessageBackgroundHover` | User message hover |
|
||||
| `messageActionsBackground` | Action buttons background |
|
||||
|
||||
### Agent Colors
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `red_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `blue_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `green_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `yellow_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `purple_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `orange_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `pink_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `cyan_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
|
||||
## Using Theme Colors in Components
|
||||
|
||||
### ThemedText
|
||||
|
||||
```tsx
|
||||
<Text color="success">Operation complete</Text>
|
||||
<Text color="error" bold>Failed!</Text>
|
||||
<Text color="claude">Claude says...</Text>
|
||||
<Text dimColor>Secondary info</Text>
|
||||
<Text backgroundColor="userMessageBackground">Highlighted</Text>
|
||||
```
|
||||
|
||||
### ThemedBox
|
||||
|
||||
```tsx
|
||||
<Box borderStyle="single" borderColor="permission" backgroundColor="userMessageBackground">
|
||||
<Text>Themed content</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### color() Utility
|
||||
|
||||
```tsx
|
||||
import { color, useTheme } from '@anthropic/ink'
|
||||
|
||||
function MyComponent() {
|
||||
const [themeName] = useTheme()
|
||||
const paint = color('success', themeName)
|
||||
// paint('text') returns ANSI-colored string
|
||||
}
|
||||
```
|
||||
|
||||
## Daltonized Themes
|
||||
|
||||
The daltonized themes (`light-daltonized`, `dark-daltonized`) are designed for users with protanopia/deuteranopia:
|
||||
|
||||
- Green/red diffs replaced with blue/red
|
||||
- Status colors use blue instead of green
|
||||
- Warning colors adjusted for better distinction
|
||||
- All color pairs verified for sufficient contrast
|
||||
|
||||
## System Theme Detection
|
||||
|
||||
When `ThemeSetting` is `'auto'`:
|
||||
|
||||
1. Seeds from `$COLORFGBG` environment variable
|
||||
2. Queries terminal via OSC 11 for live background color
|
||||
3. Watches for changes (terminal theme switch) in real-time
|
||||
4. Resolves to `'dark'` or `'light'` based on detected brightness
|
||||
390
packages/@ant/ink/docs/05-design-system.md
Normal file
390
packages/@ant/ink/docs/05-design-system.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# Chapter 5: Design System Components
|
||||
|
||||
Pre-built theme-aware UI components for common terminal interface patterns.
|
||||
|
||||
## Dialog
|
||||
|
||||
Modal dialog with border, title, and keyboard navigation.
|
||||
|
||||
```tsx
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
|
||||
<Dialog
|
||||
title="Confirm Action"
|
||||
subtitle="This cannot be undone"
|
||||
onCancel={() => setShowDialog(false)}
|
||||
color="warning"
|
||||
>
|
||||
<Text>Are you sure you want to proceed?</Text>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `title` | `ReactNode` | - | Dialog title (required) |
|
||||
| `subtitle` | `ReactNode` | - | Optional subtitle |
|
||||
| `children` | `ReactNode` | - | Dialog body content |
|
||||
| `onCancel` | `() => void` | - | Called on Esc/n (required) |
|
||||
| `color` | `keyof Theme` | `'permission'` | Title and border color |
|
||||
| `hideInputGuide` | `boolean` | `false` | Hide the keyboard hint footer |
|
||||
| `hideBorder` | `boolean` | `false` | Render without Pane border |
|
||||
| `inputGuide` | `(exitState) => ReactNode` | - | Custom input guide footer |
|
||||
| `isCancelActive` | `boolean` | `true` | Enable/disable cancel keybindings |
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
- **Enter** -- Confirm (consumer handles this)
|
||||
- **Esc / n** -- Cancel (calls `onCancel`)
|
||||
- **Ctrl+C / Ctrl+D** -- Double-press to exit
|
||||
|
||||
### Custom Input Guide
|
||||
|
||||
```tsx
|
||||
<Dialog
|
||||
title="Save file?"
|
||||
onCancel={handleCancel}
|
||||
inputGuide={(exitState) => (
|
||||
exitState.pending
|
||||
? <Text>Press {exitState.keyName} again to exit</Text>
|
||||
: <Text>Press Enter to save, Esc to cancel</Text>
|
||||
)}
|
||||
>
|
||||
...
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
## Pane
|
||||
|
||||
Bordered container with themed top border.
|
||||
|
||||
```tsx
|
||||
import { Pane } from '@anthropic/ink'
|
||||
|
||||
<Pane color="permission">
|
||||
<Text>Content inside a bordered pane</Text>
|
||||
</Pane>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `children` | `ReactNode` | - | Content |
|
||||
| `color` | `keyof Theme` | `'permission'` | Top border color |
|
||||
|
||||
## ProgressBar
|
||||
|
||||
Visual progress indicator.
|
||||
|
||||
```tsx
|
||||
import { ProgressBar } from '@anthropic/ink'
|
||||
|
||||
<ProgressBar
|
||||
ratio={0.65}
|
||||
width={40}
|
||||
fillColor="rate_limit_fill"
|
||||
emptyColor="rate_limit_empty"
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `ratio` | `number` | - | Progress 0..1 (required) |
|
||||
| `width` | `number` | - | Character width (required) |
|
||||
| `fillColor` | `keyof Theme` | - | Filled portion color |
|
||||
| `emptyColor` | `keyof Theme` | - | Empty portion color |
|
||||
|
||||
## Spinner
|
||||
|
||||
Animated loading spinner. No props.
|
||||
|
||||
```tsx
|
||||
import { Spinner } from '@anthropic/ink'
|
||||
|
||||
<Box gap={1}>
|
||||
<Spinner />
|
||||
<Text>Loading...</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
## LoadingState
|
||||
|
||||
Loading message with spinner and optional subtitle.
|
||||
|
||||
```tsx
|
||||
import { LoadingState } from '@anthropic/ink'
|
||||
|
||||
<LoadingState
|
||||
message="Installing dependencies"
|
||||
subtitle="This may take a moment"
|
||||
bold={true}
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `message` | `string` | - | Loading message (required) |
|
||||
| `bold` | `boolean` | `false` | Bold message |
|
||||
| `dimColor` | `boolean` | `false` | Dimmed message |
|
||||
| `subtitle` | `string` | - | Secondary text below |
|
||||
|
||||
## StatusIcon
|
||||
|
||||
Semantic status indicator with icon and color.
|
||||
|
||||
```tsx
|
||||
import { StatusIcon } from '@anthropic/ink'
|
||||
|
||||
<StatusIcon status="success" withSpace />
|
||||
<Text>Build complete</Text>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `status` | `'success' \| 'error' \| 'warning' \| 'info' \| 'pending' \| 'loading'` | - | Status type (required) |
|
||||
| `withSpace` | `boolean` | `false` | Add trailing space |
|
||||
|
||||
Status icons:
|
||||
- `success` -- Green checkmark
|
||||
- `error` -- Red cross
|
||||
- `warning` -- Yellow warning
|
||||
- `info` -- Blue info
|
||||
- `pending` -- Dimmed circle
|
||||
- `loading` -- Dimmed ellipsis
|
||||
|
||||
## FuzzyPicker
|
||||
|
||||
Full-featured fuzzy search selector with preview support.
|
||||
|
||||
```tsx
|
||||
import { FuzzyPicker } from '@anthropic/ink'
|
||||
|
||||
<FuzzyPicker
|
||||
title="Select a file"
|
||||
items={files}
|
||||
getKey={(f) => f.path}
|
||||
renderItem={(f, focused) => <Text>{f.name}</Text>}
|
||||
onQueryChange={(q) => setFilteredFiles(filterFiles(q))}
|
||||
onSelect={(f) => openFile(f)}
|
||||
onCancel={() => setShowPicker(false)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `title` | `string` | Picker title (required) |
|
||||
| `items` | `readonly T[]` | Items to display (required) |
|
||||
| `getKey` | `(item: T) => string` | Unique key extractor (required) |
|
||||
| `renderItem` | `(item: T, isFocused: boolean) => ReactNode` | Item renderer (required) |
|
||||
| `onQueryChange` | `(query: string) => void` | Filter callback (required) |
|
||||
| `onSelect` | `(item: T) => void` | Enter key handler (required) |
|
||||
| `onCancel` | `() => void` | Esc handler (required) |
|
||||
| `renderPreview` | `(item: T) => ReactNode` | Preview panel renderer |
|
||||
| `previewPosition` | `'bottom' \| 'right'` | Preview placement |
|
||||
| `visibleCount` | `number` | Max visible items |
|
||||
| `direction` | `'down' \| 'up'` | Item ordering |
|
||||
| `onTab` | `PickerAction<T>` | Tab key handler |
|
||||
| `onShiftTab` | `PickerAction<T>` | Shift+Tab handler |
|
||||
| `onFocus` | `(item: T \| undefined) => void` | Focus change callback |
|
||||
| `emptyMessage` | `string \| ((query: string) => string)` | Empty state message |
|
||||
| `matchLabel` | `string` | Status line below list |
|
||||
| `placeholder` | `string` | Input placeholder |
|
||||
| `initialQuery` | `string` | Initial search query |
|
||||
| `selectAction` | `string` | Action label for byline |
|
||||
| `extraHints` | `ReactNode` | Additional keyboard hints |
|
||||
|
||||
## Tabs / Tab
|
||||
|
||||
Tabbed interface with keyboard navigation.
|
||||
|
||||
```tsx
|
||||
import { Tabs, Tab } from '@anthropic/ink'
|
||||
|
||||
<Tabs title="Settings" color="claude">
|
||||
<Tab title="General" id="general">
|
||||
<GeneralSettings />
|
||||
</Tab>
|
||||
<Tab title="Advanced" id="advanced">
|
||||
<AdvancedSettings />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
### Tabs Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `children` | `ReactElement<TabProps>[]` | - | Tab elements |
|
||||
| `title` | `string` | - | Header title |
|
||||
| `color` | `keyof Theme` | - | Active tab indicator color |
|
||||
| `defaultTab` | `string` | - | Initial tab id |
|
||||
| `selectedTab` | `string` | - | Controlled selected tab |
|
||||
| `onTabChange` | `(tabId: string) => void` | - | Tab change callback |
|
||||
| `hidden` | `boolean` | `false` | Hide tab headers |
|
||||
| `useFullWidth` | `boolean` | `false` | Use full terminal width |
|
||||
| `banner` | `ReactNode` | - | Banner below tab headers |
|
||||
| `disableNavigation` | `boolean` | `false` | Disable keyboard nav |
|
||||
| `initialHeaderFocused` | `boolean` | `true` | Start with header focused |
|
||||
| `contentHeight` | `number` | - | Fixed content height |
|
||||
| `navFromContent` | `boolean` | `false` | Allow Tab/Arrow from content |
|
||||
|
||||
### Tab Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `title` | `string` | Tab label (required) |
|
||||
| `id` | `string` | Tab identifier |
|
||||
| `children` | `ReactNode` | Tab content |
|
||||
|
||||
### Tab Hooks
|
||||
|
||||
```tsx
|
||||
import { useTabsWidth, useTabHeaderFocus } from '@anthropic/ink'
|
||||
|
||||
const width = useTabsWidth() // Available content width
|
||||
const focused = useTabHeaderFocus() // Whether tab header is focused
|
||||
```
|
||||
|
||||
## ListItem
|
||||
|
||||
Selectable list item with focus/selection indicators.
|
||||
|
||||
```tsx
|
||||
import { ListItem } from '@anthropic/ink'
|
||||
|
||||
<ListItem isFocused={index === focusedIndex} isSelected={item.checked}>
|
||||
<Text>{item.label}</Text>
|
||||
</ListItem>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `isFocused` | `boolean` | - | Keyboard focus (required) |
|
||||
| `isSelected` | `boolean` | `false` | Checked/active state |
|
||||
| `children` | `ReactNode` | - | Content |
|
||||
| `description` | `string` | - | Secondary text below |
|
||||
| `styled` | `boolean` | `true` | Auto-style based on state |
|
||||
| `disabled` | `boolean` | `false` | Dimmed, non-interactive |
|
||||
| `showScrollDown` | `boolean` | `false` | Scroll-down hint arrow |
|
||||
| `showScrollUp` | `boolean` | `false` | Scroll-up hint arrow |
|
||||
| `declareCursor` | `boolean` | `true` | Declare terminal cursor |
|
||||
|
||||
## SearchBox
|
||||
|
||||
Search input with theme-aware styling.
|
||||
|
||||
```tsx
|
||||
import { SearchBox } from '@anthropic/ink'
|
||||
|
||||
<SearchBox
|
||||
query={searchQuery}
|
||||
placeholder="Search..."
|
||||
isFocused={true}
|
||||
isTerminalFocused={true}
|
||||
width="100%"
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `query` | `string` | - | Current search text |
|
||||
| `placeholder` | `string` | - | Placeholder text |
|
||||
| `isFocused` | `boolean` | - | Focus state |
|
||||
| `isTerminalFocused` | `boolean` | - | Terminal focus state |
|
||||
| `prefix` | `string` | - | Input prefix label |
|
||||
| `width` | `number \| string` | - | Input width |
|
||||
| `cursorOffset` | `number` | - | Cursor position offset |
|
||||
| `borderless` | `boolean` | `false` | Remove border |
|
||||
|
||||
## Divider
|
||||
|
||||
Horizontal/vertical divider line.
|
||||
|
||||
```tsx
|
||||
import { Divider } from '@anthropic/ink'
|
||||
|
||||
<Divider width={60} color="subtle" />
|
||||
<Divider title="Section Title" />
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `width` | `number` | Terminal width | Divider width |
|
||||
| `color` | `keyof Theme` | Dimmed | Line color |
|
||||
| `char` | `string` | `'─'` | Line character |
|
||||
| `padding` | `number` | `0` | Width reduction |
|
||||
| `title` | `string` | - | Centered title text |
|
||||
|
||||
## Byline
|
||||
|
||||
Footer with middot-separated items.
|
||||
|
||||
```tsx
|
||||
import { Byline } from '@anthropic/ink'
|
||||
|
||||
<Byline>
|
||||
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
|
||||
<KeyboardShortcutHint shortcut="Esc" action="cancel" />
|
||||
</Byline>
|
||||
```
|
||||
|
||||
## KeyboardShortcutHint
|
||||
|
||||
Display a keyboard shortcut with its action.
|
||||
|
||||
```tsx
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink'
|
||||
|
||||
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
|
||||
<KeyboardShortcutHint shortcut="↑/↓" action="navigate" parens />
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `shortcut` | `string` | - | Key or chord to display |
|
||||
| `action` | `string` | - | Action description |
|
||||
| `parens` | `boolean` | `false` | Wrap in parentheses |
|
||||
| `bold` | `boolean` | `false` | Bold shortcut text |
|
||||
|
||||
## ConfigurableShortcutHint
|
||||
|
||||
Displays a shortcut hint that reads the actual keybinding from config.
|
||||
|
||||
```tsx
|
||||
import { ConfigurableShortcutHint } from '@anthropic/ink'
|
||||
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Confirmation"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `action` | `string` | Keybinding action name |
|
||||
| `context` | `string` | Keybinding context |
|
||||
| `fallback` | `string` | Default shortcut if unbound |
|
||||
| `description` | `string` | Action description |
|
||||
| `parens` | `boolean` | Wrap in parentheses |
|
||||
| `bold` | `boolean` | Bold shortcut text |
|
||||
|
||||
## Ratchet
|
||||
|
||||
Animated counter component that prevents layout jumps.
|
||||
|
||||
```tsx
|
||||
import { Ratchet } from '@anthropic/ink'
|
||||
|
||||
<Ratchet lock="always">
|
||||
<Text>{count}</Text>
|
||||
</Ratchet>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `children` | `ReactNode` | - | Content |
|
||||
| `lock` | `'always' \| 'offscreen'` | `'always'` | Width locking strategy. `'always'` locks always; `'offscreen'` only locks when the element is scrolled off-screen |
|
||||
189
packages/@ant/ink/docs/06-scrolling.md
Normal file
189
packages/@ant/ink/docs/06-scrolling.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Chapter 6: Scrolling
|
||||
|
||||
## ScrollBox
|
||||
|
||||
A scrollable container with imperative scroll API, viewport culling, and sticky scroll support.
|
||||
|
||||
```tsx
|
||||
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)
|
||||
|
||||
```ts
|
||||
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.
|
||||
|
||||
```tsx
|
||||
scrollRef.current?.scrollTo(0) // Scroll to top
|
||||
```
|
||||
|
||||
#### `scrollBy(dy)`
|
||||
|
||||
Scroll by a relative amount. Accumulates deltas for smooth scrolling.
|
||||
|
||||
```tsx
|
||||
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).
|
||||
|
||||
```tsx
|
||||
const elementRef = useRef<DOMElement>(null)
|
||||
scrollRef.current?.scrollToElement(elementRef.current!, 2)
|
||||
```
|
||||
|
||||
#### `scrollToBottom()`
|
||||
|
||||
Pin scroll to bottom. Enables sticky mode.
|
||||
|
||||
```tsx
|
||||
scrollRef.current?.scrollToBottom()
|
||||
```
|
||||
|
||||
#### `isSticky()`
|
||||
|
||||
Returns `true` when scroll is pinned to the bottom.
|
||||
|
||||
```tsx
|
||||
if (scrollRef.current?.isSticky()) {
|
||||
// User hasn't scrolled up
|
||||
}
|
||||
```
|
||||
|
||||
#### `subscribe(listener)`
|
||||
|
||||
Subscribe to imperative scroll changes. Returns unsubscribe function.
|
||||
|
||||
```tsx
|
||||
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
|
||||
|
||||
```tsx
|
||||
<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:
|
||||
|
||||
```tsx
|
||||
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`.
|
||||
267
packages/@ant/ink/docs/07-user-input.md
Normal file
267
packages/@ant/ink/docs/07-user-input.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Chapter 7: User Input
|
||||
|
||||
## useInput
|
||||
|
||||
The primary hook for handling keyboard input.
|
||||
|
||||
```tsx
|
||||
import { useInput } from '@anthropic/ink'
|
||||
|
||||
function MyComponent() {
|
||||
useInput((input, key, event) => {
|
||||
if (input === 'q') {
|
||||
// 'q' key pressed
|
||||
}
|
||||
if (key.leftArrow) {
|
||||
// Left arrow
|
||||
}
|
||||
if (key.ctrl && input === 'c') {
|
||||
// Ctrl+C (only if exitOnCtrlC is false)
|
||||
}
|
||||
if (key.meta && input === 'b') {
|
||||
// Alt+B (Option+B on Mac)
|
||||
}
|
||||
if (key.shift && input === 'Tab') {
|
||||
// Shift+Tab
|
||||
}
|
||||
})
|
||||
|
||||
return <Text>Press keys...</Text>
|
||||
}
|
||||
```
|
||||
|
||||
### Signature
|
||||
|
||||
```ts
|
||||
function useInput(
|
||||
handler: (input: string, key: Key, event: InputEvent) => void,
|
||||
options?: { isActive?: boolean }
|
||||
): void
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- **`input`** (`string`) -- The character entered. Empty string for non-printable keys (arrows, function keys). For paste events, the entire pasted text.
|
||||
- **`key`** (`Key`) -- Parsed key metadata (see below)
|
||||
- **`event`** (`InputEvent`) -- Raw event with `stopImmediatePropagation()`
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `isActive` | `boolean` | `true` | Enable/disable input handling |
|
||||
|
||||
### Key Object
|
||||
|
||||
```ts
|
||||
type Key = {
|
||||
upArrow: boolean
|
||||
downArrow: boolean
|
||||
leftArrow: boolean
|
||||
rightArrow: boolean
|
||||
pageDown: boolean
|
||||
pageUp: boolean
|
||||
wheelUp: boolean // Mouse wheel in alt-screen
|
||||
wheelDown: boolean // Mouse wheel in alt-screen
|
||||
home: boolean
|
||||
end: boolean
|
||||
return: boolean
|
||||
escape: boolean
|
||||
ctrl: boolean
|
||||
shift: boolean
|
||||
fn: boolean
|
||||
tab: boolean
|
||||
backspace: boolean
|
||||
delete: boolean
|
||||
meta: boolean // Alt / Option
|
||||
super: boolean // Cmd (macOS) / Win key
|
||||
}
|
||||
```
|
||||
|
||||
### Event Propagation
|
||||
|
||||
Multiple `useInput` handlers form a chain. Call `event.stopImmediatePropagation()` to prevent downstream handlers from receiving the event:
|
||||
|
||||
```tsx
|
||||
useInput((input, key, event) => {
|
||||
if (input === 'j') {
|
||||
// Consumed by this handler
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
// Other handlers won't see 'j'
|
||||
})
|
||||
|
||||
useInput((input, key) => {
|
||||
// This won't fire for 'j'
|
||||
})
|
||||
```
|
||||
|
||||
### Raw Mode
|
||||
|
||||
`useInput` automatically enables raw mode on stdin when active. Raw mode is reference-counted -- it stays enabled as long as any hook has `isActive: true`.
|
||||
|
||||
In raw mode:
|
||||
- Keystrokes don't echo
|
||||
- Ctrl+C is not sent as signal (app must handle it)
|
||||
- Line buffering is disabled
|
||||
|
||||
## InputEvent
|
||||
|
||||
```ts
|
||||
class InputEvent extends Event {
|
||||
readonly input: string
|
||||
readonly key: Key
|
||||
readonly keypress: ParsedKey // Raw parsed keypress data
|
||||
}
|
||||
```
|
||||
|
||||
## KeyboardEvent
|
||||
|
||||
DOM-like keyboard event dispatched to focused elements:
|
||||
|
||||
```ts
|
||||
class KeyboardEvent extends Event {
|
||||
readonly key: Key
|
||||
}
|
||||
```
|
||||
|
||||
Used with `Box`'s `onKeyDown` and `onKeyDownCapture` props:
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key.return) {
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text>Press Enter to submit</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
## Key Parsing
|
||||
|
||||
Ink supports multiple keyboard protocols:
|
||||
|
||||
### Standard Escape Sequences
|
||||
- Arrow keys, function keys, Home/End, Page Up/Down
|
||||
- Ctrl+letter combinations
|
||||
- Shift, Alt, Meta modifiers
|
||||
|
||||
### Kitty Keyboard Protocol (CSI u)
|
||||
Extended key reporting with full modifier support:
|
||||
- Distinguishes Ctrl+Shift+A from Ctrl+A
|
||||
- Reports Super (Cmd/Win) key
|
||||
- Sends key release events
|
||||
|
||||
### xterm modifyOtherKeys
|
||||
Alternative extended key reporting for xterm-compatible terminals.
|
||||
|
||||
### Application Keypad Mode
|
||||
Numpad keys mapped to their digit characters.
|
||||
|
||||
## Paste Detection
|
||||
|
||||
When `Bracketed Paste` mode is enabled (DECSET 2004), pasted text is delivered as a single `InputEvent` with the full text in `input`. This distinguishes paste from rapid typing:
|
||||
|
||||
```tsx
|
||||
useInput((input, key, event) => {
|
||||
if (event.keypress.paste) {
|
||||
// User pasted text -- handle as a batch
|
||||
handlePaste(input)
|
||||
} else {
|
||||
// Regular keypress
|
||||
handleKey(input, key)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Mouse Events (Alt-Screen Only)
|
||||
|
||||
In alternate screen mode, mouse events are parsed and dispatched:
|
||||
|
||||
### Click Events
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
onClick={(event) => {
|
||||
console.log(`Clicked at (${event.x}, ${event.y})`)
|
||||
event.stopImmediatePropagation()
|
||||
}}
|
||||
>
|
||||
<Text>Click me</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Hover Events
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<Text>{hovered ? 'Hovered!' : 'Hover me'}</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
Hover events use `mouseenter`/`mouseleave` semantics (no bubbling between children).
|
||||
|
||||
### Wheel Events
|
||||
|
||||
Mouse wheel events arrive as `Key.wheelUp`/`Key.wheelDown`:
|
||||
|
||||
```tsx
|
||||
useInput((input, key) => {
|
||||
if (key.wheelUp) scrollUp()
|
||||
if (key.wheelDown) scrollDown()
|
||||
})
|
||||
```
|
||||
|
||||
## useStdin
|
||||
|
||||
Lower-level access to the stdin stream.
|
||||
|
||||
```tsx
|
||||
import { useStdin } from '@anthropic/ink'
|
||||
|
||||
const {
|
||||
stdin, // Raw stdin stream
|
||||
setRawMode, // (enabled: boolean) => void
|
||||
isRawModeSupported, // boolean
|
||||
internal_exitOnCtrlC, // boolean
|
||||
internal_eventEmitter, // EventEmitter | undefined
|
||||
internal_querier, // Terminal querier
|
||||
} = useStdin()
|
||||
```
|
||||
|
||||
> **Prefer `useInput` for keyboard handling.** `useStdin` is for advanced use cases like terminal querying or custom event handling.
|
||||
|
||||
## Button Component
|
||||
|
||||
Interactive button that responds to keyboard and mouse:
|
||||
|
||||
```tsx
|
||||
import { Button } from '@anthropic/ink'
|
||||
|
||||
<Button onAction={() => handleClick()} tabIndex={0} autoFocus>
|
||||
{(state) => (
|
||||
<Text bold={state.focused} color={state.focused ? 'claude' : 'text'}>
|
||||
{state.focused ? '> Click Me' : ' Click Me'}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
```
|
||||
|
||||
Button receives a render prop with state:
|
||||
|
||||
```ts
|
||||
type ButtonState = {
|
||||
focused: boolean // Has keyboard focus
|
||||
hovered: boolean // Mouse is over it (alt-screen)
|
||||
active: boolean // True for 100ms after activation (flash effect)
|
||||
}
|
||||
```
|
||||
|
||||
Activation triggers: Enter key, Space key, or mouse click.
|
||||
302
packages/@ant/ink/docs/08-keybindings.md
Normal file
302
packages/@ant/ink/docs/08-keybindings.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# Chapter 8: Keybinding System
|
||||
|
||||
The keybinding system provides configurable, context-aware keyboard shortcuts with chord sequence support.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
KeybindingSetup (loads config)
|
||||
└── KeybindingProvider (provides context)
|
||||
├── useKeybinding(action, handler)
|
||||
├── useKeybindings({ action: handler })
|
||||
├── useKeybindingContext()
|
||||
└── useRegisterKeybindingContext(name, isActive)
|
||||
```
|
||||
|
||||
## KeybindingSetup
|
||||
|
||||
Loads and validates keybinding configuration at app startup.
|
||||
|
||||
```tsx
|
||||
import { KeybindingSetup } from '@anthropic/ink'
|
||||
|
||||
<KeybindingSetup
|
||||
loadBindings={() => parseUserKeybindings(configFile)}
|
||||
subscribeToChanges={(cb) => watchConfigFile(cb)}
|
||||
onWarnings={(warnings, isReload) => {
|
||||
warnings.forEach(w => console.warn(w.message))
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</KeybindingSetup>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `children` | `ReactNode` | App tree |
|
||||
| `loadBindings` | `() => KeybindingsLoadResult` | Load bindings from config |
|
||||
| `subscribeToChanges` | `(cb) => unsubscribe` | Watch for config changes |
|
||||
| `initWatcher` | `() => void \| Promise<void>` | One-time setup (optional) |
|
||||
| `onWarnings` | `(warnings, isReload) => void` | Validation warnings (optional) |
|
||||
| `onDebugLog` | `(message) => void` | Debug logging (optional) |
|
||||
|
||||
### KeybindingsLoadResult
|
||||
|
||||
```ts
|
||||
type KeybindingsLoadResult = {
|
||||
bindings: ParsedBinding[]
|
||||
warnings: KeybindingWarning[]
|
||||
}
|
||||
```
|
||||
|
||||
### KeybindingWarning
|
||||
|
||||
```ts
|
||||
type KeybindingWarning = {
|
||||
type: 'parse_error' | 'duplicate' | 'reserved' | 'invalid_context' | 'invalid_action'
|
||||
severity: 'error' | 'warning'
|
||||
message: string
|
||||
key?: string
|
||||
context?: string
|
||||
action?: string
|
||||
suggestion?: string
|
||||
}
|
||||
```
|
||||
|
||||
## KeybindingProvider
|
||||
|
||||
Context provider that holds binding state and resolution logic. Automatically provided by `KeybindingSetup`.
|
||||
|
||||
## useKeybinding
|
||||
|
||||
Register a handler for a keybinding action.
|
||||
|
||||
```tsx
|
||||
import { useKeybinding } from '@anthropic/ink'
|
||||
|
||||
function MyComponent() {
|
||||
useKeybinding('app:toggleTodos', () => {
|
||||
setShowTodos(prev => !prev)
|
||||
}, { context: 'Global' })
|
||||
|
||||
// Return false to NOT consume the event (allow propagation)
|
||||
useKeybinding('scroll:lineDown', () => {
|
||||
if (!hasContent) return false // Don't consume
|
||||
scrollBy(1)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Signature
|
||||
|
||||
```ts
|
||||
function useKeybinding(
|
||||
action: string,
|
||||
handler: () => void | false | Promise<void>,
|
||||
options?: { context?: string; isActive?: boolean }
|
||||
): void
|
||||
```
|
||||
|
||||
### Handler Return Values
|
||||
|
||||
| Return | Effect |
|
||||
|--------|--------|
|
||||
| `undefined` / `void` | Event consumed, stop propagation |
|
||||
| `false` | Event NOT consumed, propagate to other handlers |
|
||||
| `Promise<void>` | Async handler, treated as consumed |
|
||||
|
||||
## useKeybindings
|
||||
|
||||
Register multiple handlers in one hook (reduces `useInput` overhead).
|
||||
|
||||
```tsx
|
||||
import { useKeybindings } from '@anthropic/ink'
|
||||
|
||||
useKeybindings({
|
||||
'chat:submit': () => handleSubmit(),
|
||||
'chat:cancel': () => handleCancel(),
|
||||
'scroll:pageDown': () => {
|
||||
scrollBy(viewportHeight)
|
||||
},
|
||||
'scroll:lineDown': () => {
|
||||
if (!hasContent) return false
|
||||
scrollBy(1)
|
||||
},
|
||||
}, { context: 'Chat' })
|
||||
```
|
||||
|
||||
## Keybinding Contexts
|
||||
|
||||
Contexts allow the same key to perform different actions depending on what's active.
|
||||
|
||||
```tsx
|
||||
// Register a context as active
|
||||
import { useRegisterKeybindingContext } from '@anthropic/ink'
|
||||
|
||||
function ThemePicker({ isOpen }) {
|
||||
useRegisterKeybindingContext('ThemePicker', isOpen)
|
||||
|
||||
// While open, 'ThemePicker' context bindings take precedence
|
||||
useKeybinding('picker:select', handleSelect, { context: 'ThemePicker' })
|
||||
|
||||
return isOpen ? <PickerUI /> : null
|
||||
}
|
||||
```
|
||||
|
||||
Context resolution order:
|
||||
1. Registered active contexts (most recent first)
|
||||
2. The hook's own `context` parameter
|
||||
3. `'Global'` (always checked last)
|
||||
|
||||
## Chord Sequences
|
||||
|
||||
Keybindings support multi-key sequences (chords):
|
||||
|
||||
```
|
||||
"ctrl+k ctrl+s" → Save (press Ctrl+K, then Ctrl+S)
|
||||
"ctrl+k ctrl+c" → Close (press Ctrl+K, then Ctrl+C)
|
||||
```
|
||||
|
||||
When a chord prefix is pressed:
|
||||
- `result.type === 'chord_started'` -- Show "Ctrl+K ..." pending indicator
|
||||
- Next key completes or cancels the chord
|
||||
- `result.type === 'chord_cancelled'` -- Invalid key, reset
|
||||
|
||||
## KeybindingContext Hook
|
||||
|
||||
```tsx
|
||||
import { useKeybindingContext, useOptionalKeybindingContext } from '@anthropic/ink'
|
||||
|
||||
const ctx = useKeybindingContext()
|
||||
// ctx.resolve(input, key, contexts) → ResolveResult
|
||||
// ctx.bindings → ParsedBinding[]
|
||||
// ctx.pendingChord → ParsedKeystroke[] | null
|
||||
// ctx.activeContexts → Set<string>
|
||||
// ctx.getDisplayText(action, context) → string | undefined
|
||||
// ctx.invokeAction(action) → boolean
|
||||
// ctx.registerHandler(registration) → () => void (unsubscribe)
|
||||
|
||||
// Returns null outside provider (no throw)
|
||||
const optionalCtx = useOptionalKeybindingContext()
|
||||
```
|
||||
|
||||
## Parser Functions
|
||||
|
||||
Parse and format keybinding strings:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseKeystroke,
|
||||
parseChord,
|
||||
keystrokeToString,
|
||||
chordToString,
|
||||
keystrokeToDisplayString,
|
||||
chordToDisplayString,
|
||||
parseBindings,
|
||||
} from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### `parseKeystroke(str)`
|
||||
|
||||
Parse a single keystroke string:
|
||||
|
||||
```ts
|
||||
parseKeystroke('ctrl+shift+enter')
|
||||
// → { key: 'enter', ctrl: true, alt: false, shift: true, meta: false, super: false }
|
||||
```
|
||||
|
||||
### `parseChord(str)`
|
||||
|
||||
Parse a chord (space-separated keystrokes):
|
||||
|
||||
```ts
|
||||
parseChord('ctrl+k ctrl+s')
|
||||
// → [{ key: 'k', ctrl: true, ... }, { key: 's', ctrl: true, ... }]
|
||||
```
|
||||
|
||||
### `keystrokeToString(ks)` / `chordToString(chord)`
|
||||
|
||||
Convert parsed keystroke/chord back to string.
|
||||
|
||||
### `keystrokeToDisplayString(ks)` / `chordToDisplayString(chord)`
|
||||
|
||||
Convert to human-readable display string (platform-aware).
|
||||
|
||||
### `parseBindings(blocks)`
|
||||
|
||||
Parse a keybinding configuration:
|
||||
|
||||
```ts
|
||||
parseBindings([
|
||||
{
|
||||
context: 'Global',
|
||||
bindings: {
|
||||
'ctrl+s': 'app:save',
|
||||
'ctrl+k ctrl+s': 'app:saveAs',
|
||||
}
|
||||
}
|
||||
])
|
||||
// → ParsedBinding[]
|
||||
```
|
||||
|
||||
## Match Functions
|
||||
|
||||
```tsx
|
||||
import { getKeyName, matchesKeystroke, matchesBinding } from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### `getKeyName(input, key)`
|
||||
|
||||
Get the canonical key name from raw input:
|
||||
|
||||
```ts
|
||||
getKeyName('\x1b[A', { upArrow: true }) // 'up'
|
||||
```
|
||||
|
||||
### `matchesKeystroke(input, key, target)`
|
||||
|
||||
Check if raw input matches a parsed keystroke:
|
||||
|
||||
```ts
|
||||
matchesKeystroke('s', { ctrl: true, shift: false }, { key: 's', ctrl: true })
|
||||
```
|
||||
|
||||
### `matchesBinding(input, key, binding)`
|
||||
|
||||
Check if raw input matches any keystroke in a binding's chord.
|
||||
|
||||
## Resolver Functions
|
||||
|
||||
```tsx
|
||||
import { resolveKey, resolveKeyWithChordState, getBindingDisplayText } from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### `resolveKey(input, key, contexts, bindings)`
|
||||
|
||||
Resolve input to a binding action:
|
||||
|
||||
```ts
|
||||
const result = resolveKey('s', { ctrl: true, shift: false }, ['Global'], bindings)
|
||||
// result.type: 'match' | 'none' | 'unbound'
|
||||
// result.action: string (when type === 'match')
|
||||
```
|
||||
|
||||
### `resolveKeyWithChordState(input, key, contexts, bindings, pendingChord)`
|
||||
|
||||
Resolve with chord state:
|
||||
|
||||
```ts
|
||||
const result = resolveKeyWithChordState('k', key, ['Global'], bindings, null)
|
||||
// result.type: 'match' | 'none' | 'unbound' | 'chord_started' | 'chord_cancelled'
|
||||
// result.pending: ParsedKeystroke[] (when type === 'chord_started')
|
||||
```
|
||||
|
||||
### `getBindingDisplayText(action, context, bindings)`
|
||||
|
||||
Get the display string for a binding:
|
||||
|
||||
```ts
|
||||
getBindingDisplayText('app:save', 'Global', bindings) // 'Ctrl+S'
|
||||
```
|
||||
407
packages/@ant/ink/docs/09-hooks-reference.md
Normal file
407
packages/@ant/ink/docs/09-hooks-reference.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# Chapter 9: Hooks Reference
|
||||
|
||||
Complete API reference for all hooks exported by `@anthropic/ink`.
|
||||
|
||||
---
|
||||
|
||||
## Application Hooks
|
||||
|
||||
### `useApp()`
|
||||
|
||||
Access app-level operations.
|
||||
|
||||
```ts
|
||||
function useApp(): {
|
||||
exit: (error?: Error) => void
|
||||
}
|
||||
```
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
const { exit } = useApp()
|
||||
// Gracefully unmount and exit
|
||||
exit()
|
||||
```
|
||||
|
||||
### `useStdin()`
|
||||
|
||||
Access the stdin stream and raw mode control.
|
||||
|
||||
```ts
|
||||
function useStdin(): {
|
||||
stdin: NodeJS.ReadStream
|
||||
isRawModeSupported: boolean
|
||||
setRawMode: (enabled: boolean) => void
|
||||
internal_exitOnCtrlC: boolean
|
||||
internal_eventEmitter: EventEmitter | undefined
|
||||
internal_querier: TerminalQuerier | null
|
||||
}
|
||||
```
|
||||
|
||||
> Prefer `useInput` for keyboard handling.
|
||||
|
||||
---
|
||||
|
||||
## Input Hooks
|
||||
|
||||
### `useInput(handler, options?)`
|
||||
|
||||
Handle keyboard input. See [Chapter 7](./07-user-input.md) for full details.
|
||||
|
||||
```ts
|
||||
function useInput(
|
||||
handler: (input: string, key: Key, event: InputEvent) => void,
|
||||
options?: { isActive?: boolean }
|
||||
): void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Terminal Hooks
|
||||
|
||||
### `useTerminalSize()`
|
||||
|
||||
Get current terminal dimensions.
|
||||
|
||||
```ts
|
||||
function useTerminalSize(): {
|
||||
columns: number
|
||||
rows: number
|
||||
}
|
||||
```
|
||||
|
||||
Throws if used outside `<App>`.
|
||||
|
||||
### `useTerminalFocus()`
|
||||
|
||||
Track whether the terminal window is focused.
|
||||
|
||||
```ts
|
||||
function useTerminalFocus(): boolean
|
||||
```
|
||||
|
||||
Uses DECSET 1004 focus reporting. Returns `true` when focused.
|
||||
|
||||
### `useTerminalTitle(title)`
|
||||
|
||||
Set the terminal window title.
|
||||
|
||||
```ts
|
||||
function useTerminalTitle(title: string | null): void
|
||||
```
|
||||
|
||||
Pass `null` to clear the title.
|
||||
|
||||
### `useTerminalViewport()`
|
||||
|
||||
Track element visibility in the terminal viewport.
|
||||
|
||||
```ts
|
||||
function useTerminalViewport(): [
|
||||
ref: (element: DOMElement | null) => void,
|
||||
entry: { isVisible: boolean }
|
||||
]
|
||||
```
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
const [viewportRef, { isVisible }] = useTerminalViewport()
|
||||
|
||||
<Box ref={viewportRef}>
|
||||
<Text>{isVisible ? 'Visible' : 'Scrolled off'}</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### `useTabStatus(kind)`
|
||||
|
||||
Set tab status indicator in terminal tab bar (OSC 21337).
|
||||
|
||||
```ts
|
||||
type TabStatusKind = 'idle' | 'busy' | 'waiting'
|
||||
function useTabStatus(kind: TabStatusKind | null): void
|
||||
```
|
||||
|
||||
### `useTerminalNotification()`
|
||||
|
||||
Send terminal notifications (iTerm2, Kitty, Ghostty, bell).
|
||||
|
||||
```ts
|
||||
function useTerminalNotification(): {
|
||||
notifyITerm2: (opts: { message: string; title?: string }) => void
|
||||
notifyKitty: (opts: { message: string; title: string; id: number }) => void
|
||||
notifyGhostty: (opts: { message: string; title: string }) => void
|
||||
notifyBell: () => void
|
||||
progress: (state: Progress['state'] | null, percentage?: number) => void
|
||||
}
|
||||
```
|
||||
|
||||
Requires `TerminalWriteProvider` in the tree.
|
||||
|
||||
Progress states: `'running'`, `'completed'`, `'error'`, `'indeterminate'`, `null` (clear).
|
||||
|
||||
---
|
||||
|
||||
## Animation & Timing Hooks
|
||||
|
||||
### `useInterval(callback, intervalMs)`
|
||||
|
||||
Clock-backed interval timer.
|
||||
|
||||
```ts
|
||||
function useInterval(callback: () => void, intervalMs: number | null): void
|
||||
```
|
||||
|
||||
Pass `null` to pause. Shares the application clock for efficient batching.
|
||||
|
||||
### `useAnimationTimer(intervalMs)`
|
||||
|
||||
Returns the current clock time, updating at the given interval.
|
||||
|
||||
```ts
|
||||
function useAnimationTimer(intervalMs: number): number
|
||||
```
|
||||
|
||||
Subscribes as non-keepAlive -- won't keep the clock running on its own.
|
||||
|
||||
### `useAnimationFrame(intervalMs?)`
|
||||
|
||||
Synchronized animation hook that pauses when offscreen.
|
||||
|
||||
```ts
|
||||
function useAnimationFrame(
|
||||
intervalMs?: number | null, // default 16
|
||||
): [ref: (element: DOMElement | null) => void, time: number]
|
||||
```
|
||||
|
||||
Returns a ref callback (attach to animated element) and the current animation time. All instances share the same clock. Pass `null` to pause.
|
||||
|
||||
```tsx
|
||||
const [ref, time] = useAnimationFrame(120)
|
||||
const frame = Math.floor(time / 120) % FRAMES.length
|
||||
return <Box ref={ref}>{FRAMES[frame]}</Box>
|
||||
```
|
||||
|
||||
### `useTimeout(delayMs, resetTrigger?)`
|
||||
|
||||
One-shot timer.
|
||||
|
||||
```ts
|
||||
function useTimeout(delay: number, resetTrigger?: number): boolean
|
||||
```
|
||||
|
||||
Returns `true` when the timeout has elapsed. Change `resetTrigger` to restart.
|
||||
|
||||
### `useMinDisplayTime(value, minMs)`
|
||||
|
||||
Ensure a value is displayed for at least `minMs` milliseconds.
|
||||
|
||||
```ts
|
||||
function useMinDisplayTime<T>(value: T, minMs: number): T
|
||||
```
|
||||
|
||||
Holds the previous value until `minMs` has elapsed, then switches to the new value.
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
// Keep showing "Loading" for at least 300ms to prevent flash
|
||||
const displayValue = useMinDisplayTime(isLoading ? 'loading' : 'done', 300)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interaction Hooks
|
||||
|
||||
### `useDoublePress(setPending, onDoublePress, onFirstPress?)`
|
||||
|
||||
Detect double-press (double-click equivalent for keyboard).
|
||||
|
||||
```ts
|
||||
export const DOUBLE_PRESS_TIMEOUT_MS = 800
|
||||
|
||||
function useDoublePress(
|
||||
setPending: (pending: boolean) => void,
|
||||
onDoublePress: () => void,
|
||||
onFirstPress?: () => void
|
||||
): () => void // Returns the press handler
|
||||
```
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
const [pendingExit, setPendingExit] = useState(false)
|
||||
const handlePress = useDoublePress(
|
||||
setPendingExit,
|
||||
() => exit(), // Double press
|
||||
() => {}, // First press
|
||||
)
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape) handlePress()
|
||||
})
|
||||
```
|
||||
|
||||
### `useExitOnCtrlCD(options?)`
|
||||
|
||||
Handle Ctrl+C / Ctrl+D with double-press confirmation.
|
||||
|
||||
```ts
|
||||
type ExitState = {
|
||||
pending: boolean
|
||||
keyName: 'Ctrl-C' | 'Ctrl-D' | null
|
||||
}
|
||||
|
||||
function useExitOnCtrlCDWithKeybindings(
|
||||
onExit?: () => void,
|
||||
onInterrupt?: () => boolean,
|
||||
isActive?: boolean
|
||||
): ExitState
|
||||
```
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
const exitState = useExitOnCtrlCDWithKeybindings(
|
||||
() => exit(),
|
||||
() => { /* return true to prevent exit */ }
|
||||
)
|
||||
|
||||
if (exitState.pending) {
|
||||
return <Text>Press {exitState.keyName} again to exit</Text>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Selection Hooks (Alt-Screen Only)
|
||||
|
||||
### `useSelection()`
|
||||
|
||||
Text selection operations.
|
||||
|
||||
```ts
|
||||
function useSelection(): {
|
||||
copySelection: () => string
|
||||
copySelectionNoClear: () => string
|
||||
clearSelection: () => void
|
||||
hasSelection: () => boolean
|
||||
getState: () => SelectionState | null
|
||||
subscribe: (cb: () => void) => () => void
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
moveFocus: (move: FocusMove) => void
|
||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||
setSelectionBgColor: (color: string) => void
|
||||
}
|
||||
```
|
||||
|
||||
### `useHasSelection()`
|
||||
|
||||
Reactive boolean for selection state.
|
||||
|
||||
```ts
|
||||
function useHasSelection(): boolean
|
||||
```
|
||||
|
||||
Re-renders when selection is created or cleared.
|
||||
|
||||
---
|
||||
|
||||
## Search Hooks
|
||||
|
||||
### `useSearchHighlight()`
|
||||
|
||||
Set and manage search highlighting.
|
||||
|
||||
```ts
|
||||
function useSearchHighlight(): {
|
||||
setQuery: (query: string) => void
|
||||
scanElement: (el: DOMElement) => MatchPosition[]
|
||||
setPositions: (state: { positions: MatchPosition[]; rowOffset: number; currentIdx: number } | null) => void
|
||||
}
|
||||
```
|
||||
|
||||
### `useSearchInput(options)`
|
||||
|
||||
Search input handler with cursor management.
|
||||
|
||||
```ts
|
||||
type UseSearchInputOptions = {
|
||||
isActive: boolean
|
||||
onExit: () => void
|
||||
onCancel?: () => void
|
||||
onExitUp?: () => void
|
||||
columns?: number
|
||||
passthroughCtrlKeys?: string[]
|
||||
initialQuery?: string
|
||||
backspaceExitsOnEmpty?: boolean
|
||||
}
|
||||
|
||||
type UseSearchInputReturn = {
|
||||
query: string
|
||||
setQuery: (q: string) => void
|
||||
cursorOffset: number
|
||||
handleKeyDown: (e: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
function useSearchInput(options: UseSearchInputOptions): UseSearchInputReturn
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cursor Hooks
|
||||
|
||||
### `useDeclaredCursor(options)`
|
||||
|
||||
Park the terminal cursor at a specific position for IME and accessibility.
|
||||
|
||||
```ts
|
||||
function useDeclaredCursor({
|
||||
line: number,
|
||||
column: number,
|
||||
active: boolean
|
||||
}): (element: DOMElement | null) => void
|
||||
```
|
||||
|
||||
Returns a ref callback. Position is relative to the ref'd element.
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
const cursorRef = useDeclaredCursor({
|
||||
line: 0,
|
||||
column: cursorPosition,
|
||||
active: isFocused,
|
||||
})
|
||||
|
||||
return <Box ref={cursorRef}>...</Box>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tab Status Hooks
|
||||
|
||||
### `useTabStatus(kind)`
|
||||
|
||||
Set tab status indicator (OSC 21337) for terminal tab bars.
|
||||
|
||||
```ts
|
||||
type TabStatusKind = 'idle' | 'busy' | 'waiting'
|
||||
|
||||
function useTabStatus(kind: TabStatusKind | null): void
|
||||
```
|
||||
|
||||
Pass `null` to clear.
|
||||
|
||||
---
|
||||
|
||||
## Viewport Hooks
|
||||
|
||||
### `useTerminalViewport()`
|
||||
|
||||
Track element visibility within the terminal viewport.
|
||||
|
||||
```ts
|
||||
function useTerminalViewport(): [
|
||||
ref: (element: DOMElement | null) => void,
|
||||
entry: { isVisible: boolean }
|
||||
]
|
||||
```
|
||||
|
||||
Returns a ref callback and visibility state.
|
||||
232
packages/@ant/ink/docs/10-events-and-focus.md
Normal file
232
packages/@ant/ink/docs/10-events-and-focus.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Chapter 10: Events & Focus
|
||||
|
||||
## Event System
|
||||
|
||||
Ink implements a DOM-like event system with capture/bubble phases, propagation control, and prioritized dispatch.
|
||||
|
||||
### Event Classes
|
||||
|
||||
All events extend the base `Event` class:
|
||||
|
||||
```ts
|
||||
class Event {
|
||||
stopImmediatePropagation(): void
|
||||
}
|
||||
```
|
||||
|
||||
### InputEvent
|
||||
|
||||
Emitted for every keystroke or input action.
|
||||
|
||||
```ts
|
||||
class InputEvent extends Event {
|
||||
readonly input: string // Character(s) entered
|
||||
readonly key: Key // Parsed key metadata
|
||||
readonly keypress: ParsedKey // Raw keypress data
|
||||
}
|
||||
```
|
||||
|
||||
### KeyboardEvent
|
||||
|
||||
DOM-like keyboard event for focused elements.
|
||||
|
||||
```ts
|
||||
class KeyboardEvent extends Event {
|
||||
readonly key: Key
|
||||
}
|
||||
```
|
||||
|
||||
Dispatched via `onKeyDown` / `onKeyDownCapture` on `Box`.
|
||||
|
||||
### ClickEvent
|
||||
|
||||
Mouse click event (alt-screen only).
|
||||
|
||||
```ts
|
||||
class ClickEvent extends Event {
|
||||
readonly x: number // Column (0-indexed)
|
||||
readonly y: number // Row (0-indexed)
|
||||
}
|
||||
```
|
||||
|
||||
Clicks bubble from the deepest hit Box up through ancestors.
|
||||
|
||||
### FocusEvent
|
||||
|
||||
Focus change event.
|
||||
|
||||
```ts
|
||||
class FocusEvent extends Event {
|
||||
readonly relatedTarget: DOMElement | null
|
||||
}
|
||||
```
|
||||
|
||||
### TerminalFocusEvent
|
||||
|
||||
Terminal window focus change.
|
||||
|
||||
```ts
|
||||
class TerminalFocusEvent extends Event {
|
||||
readonly type: 'terminalfocus' | 'terminalblur'
|
||||
}
|
||||
```
|
||||
|
||||
### ResizeEvent
|
||||
|
||||
Terminal resize event (internal).
|
||||
|
||||
### PasteEvent
|
||||
|
||||
Pasted text event (bracketed paste mode).
|
||||
|
||||
## Event Dispatch Flow
|
||||
|
||||
```
|
||||
stdin data → parse-keypress → InputEvent
|
||||
↓
|
||||
App.handleInput (useInput handlers)
|
||||
↓
|
||||
Box.onKeyDown (focused element, bubble)
|
||||
```
|
||||
|
||||
### Capture and Bubble Phases
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
onKeyDownCapture={(e) => {
|
||||
// Capture phase: fires top-down
|
||||
console.log('Parent captures key')
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Bubble phase: fires bottom-up
|
||||
console.log('Parent receives bubbled key')
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onKeyDown={(e) => {
|
||||
// Target: fires first in bubble phase
|
||||
console.log('Child handles key')
|
||||
e.stopImmediatePropagation() // Stop here
|
||||
}}
|
||||
>
|
||||
<Text>Focus here</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Event Propagation Methods
|
||||
|
||||
| Method | Effect |
|
||||
|--------|--------|
|
||||
| `event.stopImmediatePropagation()` | Stop all subsequent handlers |
|
||||
| `event.preventDefault()` | Not supported in terminal context |
|
||||
|
||||
## FocusManager
|
||||
|
||||
DOM-like focus management system.
|
||||
|
||||
### How Focus Works
|
||||
|
||||
1. Elements with `tabIndex >= 0` participate in Tab/Shift+Tab cycling
|
||||
2. Elements with `tabIndex === -1` are programmatically focusable only
|
||||
3. Elements with `autoFocus` receive focus on mount
|
||||
4. Clicking a focusable element focuses it
|
||||
|
||||
### Focus API
|
||||
|
||||
```ts
|
||||
class FocusManager {
|
||||
activeElement: DOMElement | null
|
||||
|
||||
focus(node: DOMElement): void
|
||||
blur(): void
|
||||
focusNext(root: DOMElement): void // Tab
|
||||
focusPrevious(root: DOMElement): void // Shift+Tab
|
||||
|
||||
handleNodeRemoved(node: DOMElement, root: DOMElement): void
|
||||
handleAutoFocus(node: DOMElement): void
|
||||
handleClickFocus(node: DOMElement): void
|
||||
|
||||
enable(): void
|
||||
disable(): void
|
||||
}
|
||||
```
|
||||
|
||||
### Tab Navigation
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="column">
|
||||
<Button tabIndex={0} onAction={handleSave}>
|
||||
{(s) => <Text>{s.focused ? '> Save' : ' Save'}</Text>}
|
||||
</Button>
|
||||
<Button tabIndex={0} onAction={handleCancel}>
|
||||
{(s) => <Text>{s.focused ? '> Cancel' : ' Cancel'}</Text>}
|
||||
</Button>
|
||||
<Button tabIndex={-1} onAction={handleSecret}>
|
||||
{/* Not reachable via Tab */}
|
||||
{(s) => <Text>Secret</Text>}
|
||||
</Button>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Auto Focus
|
||||
|
||||
```tsx
|
||||
<Box tabIndex={0} autoFocus onKeyDown={handleKey}>
|
||||
<Text>Receives focus immediately on mount</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Focus Events
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
tabIndex={0}
|
||||
onFocus={(e) => console.log('Got focus')}
|
||||
onBlur={(e) => console.log('Lost focus')}
|
||||
onFocusCapture={(e) => console.log('Capture: focus in')}
|
||||
onBlurCapture={(e) => console.log('Capture: focus out')}
|
||||
>
|
||||
<Text>Focusable element</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
## Hit Testing
|
||||
|
||||
Mouse click/hover resolution:
|
||||
|
||||
1. Screen coordinates are mapped to DOM elements via Yoga layout
|
||||
2. The deepest element at the click position is the target
|
||||
3. Click events bubble upward through ancestors
|
||||
4. Hover events use `mouseenter`/`mouseleave` semantics (no bubbling between children)
|
||||
|
||||
### Click Hit Testing
|
||||
|
||||
```ts
|
||||
dispatchClick(rootNode, col, row): void
|
||||
```
|
||||
|
||||
Walks the DOM tree, finds the deepest Box at (col, row), fires `onClick`, then bubbles to ancestors.
|
||||
|
||||
### Hover Hit Testing
|
||||
|
||||
```ts
|
||||
dispatchHover(rootNode, col, row, hoveredNodes): void
|
||||
```
|
||||
|
||||
Tracks which nodes are under the pointer. Fires `onMouseEnter`/`onMouseLeave` as the pointer moves between elements.
|
||||
|
||||
## EventEmitter
|
||||
|
||||
Custom event emitter for internal use:
|
||||
|
||||
```ts
|
||||
class EventEmitter {
|
||||
on(event: string, handler: Function): void
|
||||
off(event: string, handler: Function): void
|
||||
emit(event: string, ...args: any[]): void
|
||||
removeListener(event: string, handler: Function): void
|
||||
}
|
||||
```
|
||||
|
||||
Used internally by the Ink instance for `input` events.
|
||||
301
packages/@ant/ink/docs/11-core-architecture.md
Normal file
301
packages/@ant/ink/docs/11-core-architecture.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# Chapter 11: Core Architecture
|
||||
|
||||
This chapter covers the internal rendering pipeline, DOM model, and screen buffer system. This is advanced material -- most users only need the component and hooks APIs.
|
||||
|
||||
## Rendering Pipeline
|
||||
|
||||
```
|
||||
React Component Tree
|
||||
↓ (React reconciler)
|
||||
Ink DOM Tree (virtual terminal DOM)
|
||||
↓ (Yoga layout)
|
||||
Positioned DOM Tree (computed x, y, width, height)
|
||||
↓ (renderNodeToOutput)
|
||||
Output Buffer (styled characters)
|
||||
↓ (renderer → Screen)
|
||||
Screen Buffer (Int32Array of cells)
|
||||
↓ (diffEach)
|
||||
ANSI Diff Patches (minimal escape sequences)
|
||||
↓ (writeDiffToTerminal)
|
||||
Terminal stdout
|
||||
```
|
||||
|
||||
### Frame Lifecycle
|
||||
|
||||
Each render cycle (`onRender`) follows these phases:
|
||||
|
||||
1. **React Commit** -- React reconciles the virtual tree; host config updates Ink DOM
|
||||
2. **Yoga Layout** -- All dirty nodes have their styles applied and layout computed
|
||||
3. **Renderer** -- Creates Output buffer, calls `renderNodeToOutput` for the full tree
|
||||
4. **Screen Diff** -- New frame is compared against previous frame cell-by-cell
|
||||
5. **Optimize** -- Patches are merged and ordered for minimal cursor movement
|
||||
6. **Write** -- ANSI escape sequences are written to stdout
|
||||
|
||||
### Frame Timing
|
||||
|
||||
```ts
|
||||
const FRAME_INTERVAL_MS = 16 // ~60fps cap
|
||||
```
|
||||
|
||||
Renders are throttled. Multiple state updates in one frame are batched.
|
||||
|
||||
### Double Buffering
|
||||
|
||||
Two frames are maintained:
|
||||
|
||||
- **`frontFrame`** -- The currently displayed frame
|
||||
- **`backFrame`** -- The frame being rendered
|
||||
|
||||
After rendering, they are swapped. This prevents partial updates from being visible.
|
||||
|
||||
## Ink DOM
|
||||
|
||||
### Node Types
|
||||
|
||||
```ts
|
||||
type ElementNames =
|
||||
| 'ink-root' // Root container
|
||||
| 'ink-box' // Box component
|
||||
| 'ink-text' // Text component
|
||||
| 'ink-virtual-text' // Intermediate text wrapper
|
||||
| 'ink-link' // Link component
|
||||
| 'ink-raw-ansi' // Raw ANSI content
|
||||
```
|
||||
|
||||
### DOMElement
|
||||
|
||||
```ts
|
||||
type DOMElement = {
|
||||
nodeName: ElementNames
|
||||
attributes: Record<string, unknown>
|
||||
childNodes: DOMNode[] // DOMElement | TextNode
|
||||
yogaNode?: LayoutNode // Yoga layout node
|
||||
textStyles?: TextStyles // Inherited text styles
|
||||
|
||||
// Scroll state
|
||||
scrollTop?: number
|
||||
scrollHeight?: number
|
||||
scrollViewportHeight?: number
|
||||
scrollViewportTop?: number
|
||||
stickyScroll?: boolean
|
||||
pendingScrollDelta?: number
|
||||
scrollAnchor?: { el: DOMElement; offset: number }
|
||||
|
||||
// Dirty tracking
|
||||
dirty: boolean
|
||||
|
||||
// Event handlers (stored separately)
|
||||
onClick?: (event: ClickEvent) => void
|
||||
onFocus?: (event: FocusEvent) => void
|
||||
onBlur?: (event: FocusEvent) => void
|
||||
onKeyDown?: (event: KeyboardEvent) => void
|
||||
onMouseEnter?: () => void
|
||||
onMouseLeave?: () => void
|
||||
}
|
||||
```
|
||||
|
||||
### TextNode
|
||||
|
||||
```ts
|
||||
type TextNode = {
|
||||
nodeName: '#text'
|
||||
nodeValue: string
|
||||
yogaNode?: LayoutNode
|
||||
}
|
||||
```
|
||||
|
||||
### DOM Operations
|
||||
|
||||
```ts
|
||||
// Node creation
|
||||
createNode(nodeName: string): DOMElement
|
||||
createTextNode(text: string): TextNode
|
||||
|
||||
// Tree manipulation
|
||||
appendChildNode(parent: DOMElement, child: DOMNode): void
|
||||
insertBeforeNode(parent: DOMElement, child: DOMNode, before: DOMNode): void
|
||||
removeChildNode(parent: DOMElement, child: DOMNode): void
|
||||
|
||||
// Attribute manipulation
|
||||
setAttribute(node: DOMElement, key: string, value: unknown): void
|
||||
setStyle(node: DOMElement, style: Styles): void
|
||||
setTextStyles(node: DOMElement, styles: TextStyles): void
|
||||
|
||||
// Dirty tracking
|
||||
markDirty(node: DOMElement): void
|
||||
scheduleRenderFrom(node: DOMElement): void
|
||||
```
|
||||
|
||||
## Screen Buffer
|
||||
|
||||
### Cell Storage
|
||||
|
||||
The screen buffer uses packed `Int32Array` storage for memory efficiency:
|
||||
|
||||
```ts
|
||||
type Screen = {
|
||||
width: number
|
||||
height: number
|
||||
cells: Int32Array // 2 Int32s per cell: [charId, packed_style_hyperlink_width]
|
||||
cells64: BigInt64Array // For bulk fill operations
|
||||
charPool: CharPool // String interning
|
||||
stylePool: StylePool // ANSI code interning
|
||||
hyperlinkPool: HyperlinkPool
|
||||
emptyStyleId: number
|
||||
damage: Rectangle | undefined // Bounding box of changed cells
|
||||
noSelect: Uint8Array // Per-cell no-select bitmap
|
||||
softWrap: Int32Array // Per-row soft-wrap markers
|
||||
}
|
||||
```
|
||||
|
||||
### Cell Width
|
||||
|
||||
```ts
|
||||
enum CellWidth {
|
||||
Narrow = 0, // Regular character (1 column)
|
||||
Wide = 1, // CJK/emoji (2 columns)
|
||||
SpacerTail = 2, // Right half of wide character
|
||||
SpacerHead = 3, // Soft-wrapped wide character
|
||||
}
|
||||
```
|
||||
|
||||
### Style Pool
|
||||
|
||||
ANSI style codes are interned for efficiency:
|
||||
|
||||
```ts
|
||||
class StylePool {
|
||||
intern(codes: AnsiCode[]): number // Returns compact ID
|
||||
get(id: number): AnsiCode[]
|
||||
transition(from: number, to: number): string // Cached ANSI transition
|
||||
withInverse(id: number): number // Selection overlay
|
||||
setSelectionBg(bg: AnsiCode): void // Theme-aware selection bg
|
||||
}
|
||||
```
|
||||
|
||||
### Diff Algorithm
|
||||
|
||||
```ts
|
||||
diffEach(prev: Screen, next: Screen, callback: (x, y, oldCell, newCell) => void): void
|
||||
```
|
||||
|
||||
Only iterates cells within the damage bounding box. Unchanged regions are skipped entirely.
|
||||
|
||||
### Screen Operations
|
||||
|
||||
```ts
|
||||
createScreen(width, height, stylePool, charPool, hyperlinkPool): Screen
|
||||
setCellAt(screen, x, y, cell): void
|
||||
cellAt(screen, x, y): Cell
|
||||
clearRegion(screen, x, y, width, height): void
|
||||
blitRegion(dst, src, x, y, maxX, maxY): void
|
||||
shiftRows(screen, top, bottom, n): void
|
||||
```
|
||||
|
||||
## Layout Engine
|
||||
|
||||
### Yoga Integration
|
||||
|
||||
Ink wraps Facebook's Yoga layout engine for Flexbox computation:
|
||||
|
||||
```ts
|
||||
// Layout node types
|
||||
enum LayoutDisplay { Flex, None }
|
||||
enum LayoutPositionType { Absolute, Relative }
|
||||
enum LayoutOverflow { Visible, Hidden, Scroll }
|
||||
enum LayoutFlexDirection { Row, Column, RowReverse, ColumnReverse }
|
||||
enum LayoutWrap { NoWrap, Wrap, WrapReverse }
|
||||
enum LayoutAlign { FlexStart, Center, FlexEnd, Stretch }
|
||||
enum LayoutJustify { FlexStart, Center, FlexEnd, SpaceBetween, SpaceAround, SpaceEvenly }
|
||||
enum LayoutEdge { Top, Bottom, Left, Right, Start, End, Horizontal, Vertical, All }
|
||||
enum LayoutGutter { Column, Row, All }
|
||||
```
|
||||
|
||||
### Style Application
|
||||
|
||||
Styles from React props are applied to Yoga nodes during the commit phase:
|
||||
|
||||
```ts
|
||||
function styles(node: LayoutNode, style: Styles, resolvedStyle?: Styles): void
|
||||
```
|
||||
|
||||
This function maps each CSS-like prop to the corresponding Yoga setter.
|
||||
|
||||
## Output Buffer
|
||||
|
||||
Intermediate rendering target before screen diff:
|
||||
|
||||
```ts
|
||||
class Output {
|
||||
write(text: string, x: number, y: number, styles: TextStyles): void
|
||||
wrap(width: number, textWrap: TextWrap): void
|
||||
}
|
||||
```
|
||||
|
||||
`renderNodeToOutput` walks the DOM tree and writes styled characters into this buffer.
|
||||
|
||||
## Reconciler
|
||||
|
||||
Custom React reconciler that bridges React and the Ink DOM:
|
||||
|
||||
- **Host config** -- Defines how React operations map to Ink DOM mutations
|
||||
- **Concurrent mode** -- Supports `ConcurrentRoot` for React 19 features
|
||||
- **Yoga integration** -- Applies styles during commit phase
|
||||
- **DevTools** -- Connected in development mode
|
||||
|
||||
### Host Config Methods
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `createInstance` | Create `ink-box`, `ink-text`, etc. |
|
||||
| `createTextInstance` | Create `#text` node |
|
||||
| `appendChildNode` | Add child to parent |
|
||||
| `removeChildNode` | Remove child from parent |
|
||||
| `insertBefore` | Insert child before sibling |
|
||||
| `commitUpdate` | Update element attributes/styles |
|
||||
| `commitTextUpdate` | Update text content |
|
||||
| `getPublicInstance` | Return DOMElement for refs |
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
1. **String Interning** -- CharPool deduplicates character strings across frames
|
||||
2. **Style Caching** -- StylePool caches ANSI transition strings
|
||||
3. **Damage Tracking** -- Only diff cells within the changed bounding box
|
||||
4. **Bulk Operations** -- `Int32Array.set()` for fast region blit
|
||||
5. **Throttled Rendering** -- Frame rate capped at ~60fps
|
||||
6. **Viewport Culling** -- ScrollBox only renders visible children
|
||||
7. **Microtask Coalescing** -- Multiple scroll deltas merged into one render
|
||||
|
||||
## Frame Events
|
||||
|
||||
Debug instrumentation for render performance:
|
||||
|
||||
```ts
|
||||
type FrameEvent = {
|
||||
durationMs: number
|
||||
phases: {
|
||||
renderer: number // Yoga + renderNodeToOutput
|
||||
diff: number // Screen diff
|
||||
optimize: number // Patch optimization
|
||||
write: number // Terminal write
|
||||
patches: number // Number of ANSI patches
|
||||
yoga: number // Yoga layout time
|
||||
commit: number // React commit time
|
||||
yogaVisited: number // Yoga nodes visited
|
||||
yogaMeasured: number // Yoga nodes measured
|
||||
yogaCacheHits: number // Cached measurements
|
||||
yogaLive: number // Active Yoga nodes
|
||||
}
|
||||
flickers: FlickerReason[]
|
||||
}
|
||||
```
|
||||
|
||||
Enable with `onFrame` in RenderOptions:
|
||||
|
||||
```tsx
|
||||
render(<App />, {
|
||||
onFrame: (event) => {
|
||||
console.log(`Frame: ${event.durationMs}ms`)
|
||||
}
|
||||
})
|
||||
```
|
||||
381
packages/@ant/ink/docs/12-terminal-integration.md
Normal file
381
packages/@ant/ink/docs/12-terminal-integration.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Chapter 12: Terminal Integration
|
||||
|
||||
This chapter covers terminal-specific features: alternate screen, mouse tracking, clipboard, notifications, and terminal querying.
|
||||
|
||||
## Alternate Screen
|
||||
|
||||
Enter a fullscreen alternate screen buffer (like vim, less, htop).
|
||||
|
||||
```tsx
|
||||
import { AlternateScreen } from '@anthropic/ink'
|
||||
|
||||
<AlternateScreen mouseTracking={true}>
|
||||
<Box flexDirection="column" height="100%">
|
||||
<Text>Fullscreen content</Text>
|
||||
</Box>
|
||||
</AlternateScreen>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `children` | `ReactNode` | - | Content |
|
||||
| `mouseTracking` | `boolean` | `true` | Enable SGR mouse tracking |
|
||||
|
||||
### Behavior
|
||||
|
||||
On mount:
|
||||
1. Enters DEC 1049 alternate screen buffer
|
||||
2. Hides cursor
|
||||
3. Enables mouse tracking (if `mouseTracking=true`)
|
||||
4. Constrains rendering height to terminal rows
|
||||
|
||||
On unmount:
|
||||
1. Exits alternate screen buffer
|
||||
2. Shows cursor
|
||||
3. Disables mouse tracking
|
||||
4. Restores original terminal content
|
||||
|
||||
### Mouse Tracking Modes
|
||||
|
||||
When enabled:
|
||||
- **Mode 1003** -- Button press/release + motion (hover)
|
||||
- **Mode 1006** -- SGR extended mouse format (coordinates > 223)
|
||||
- **Wheel events** -- Scroll up/down
|
||||
|
||||
### External Editor Handoff
|
||||
|
||||
The Ink instance supports pausing for an external editor:
|
||||
|
||||
```ts
|
||||
// Pause Ink, run external command, resume
|
||||
ink.enterAlternateScreen() // Save state
|
||||
// ... external editor runs ...
|
||||
ink.reassertTerminalModes() // Restore on resume
|
||||
```
|
||||
|
||||
This is triggered by Ctrl+Z (SIGTSTP) and SIGCONT.
|
||||
|
||||
## Mouse Events
|
||||
|
||||
### Click Events
|
||||
|
||||
```tsx
|
||||
<Box onClick={(event) => {
|
||||
console.log(`Clicked at col=${event.x}, row=${event.y}`)
|
||||
event.stopImmediatePropagation()
|
||||
}}>
|
||||
<Text>Clickable area</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Multi-Click
|
||||
|
||||
Double-click selects a word, triple-click selects a line. Handled by the App component:
|
||||
|
||||
```ts
|
||||
// App prop
|
||||
onMultiClick: (col: number, row: number, count: 2 | 3) => void
|
||||
```
|
||||
|
||||
### Hover Events
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<Text>{hovered ? 'Hovered!' : 'Hover me'}</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
Hover uses `mouseenter`/`mouseleave` semantics (no bubbling between children).
|
||||
|
||||
### Drag-to-Select
|
||||
|
||||
In alt-screen mode, click-drag creates a text selection:
|
||||
|
||||
```ts
|
||||
// App prop
|
||||
onSelectionDrag: (col: number, row: number) => void
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
### OSC 52 Clipboard
|
||||
|
||||
```tsx
|
||||
import { setClipboard } from '@anthropic/ink'
|
||||
|
||||
await setClipboard('Copied text')
|
||||
```
|
||||
|
||||
### Copy Selection
|
||||
|
||||
```tsx
|
||||
const { copySelection } = useSelection()
|
||||
const text = copySelection() // Copies to clipboard and clears highlight
|
||||
```
|
||||
|
||||
### Copy Without Clear
|
||||
|
||||
```tsx
|
||||
const { copySelectionNoClear } = useSelection()
|
||||
const text = copySelectionNoClear() // Copies but keeps highlight
|
||||
```
|
||||
|
||||
## Terminal Notifications
|
||||
|
||||
Send desktop notifications from the terminal.
|
||||
|
||||
```tsx
|
||||
import { useTerminalNotification } from '@anthropic/ink'
|
||||
|
||||
function MyComponent() {
|
||||
const { notifyBell, progress } = useTerminalNotification()
|
||||
|
||||
// Terminal bell (audible/system notification)
|
||||
notifyBell()
|
||||
|
||||
// Progress bar in terminal title/tab
|
||||
progress('running', 65) // 65% complete
|
||||
progress('completed') // Done
|
||||
progress('error') // Error state
|
||||
progress('indeterminate') // Unknown progress
|
||||
progress(null) // Clear
|
||||
}
|
||||
```
|
||||
|
||||
### Terminal-Specific Notifications
|
||||
|
||||
```tsx
|
||||
const { notifyITerm2, notifyKitty, notifyGhostty } = useTerminalNotification()
|
||||
|
||||
// iTerm2
|
||||
notifyITerm2({ message: 'Build complete', title: 'My App' })
|
||||
|
||||
// Kitty
|
||||
notifyKitty({ message: 'Build complete', title: 'My App', id: 1 })
|
||||
|
||||
// Ghostty
|
||||
notifyGhostty({ message: 'Build complete', title: 'My App' })
|
||||
```
|
||||
|
||||
## Terminal Queries
|
||||
|
||||
### Background Color (OSC 11)
|
||||
|
||||
Used for auto-theme detection:
|
||||
|
||||
```ts
|
||||
import { getTerminalBackground } from '@anthropic/ink'
|
||||
const bg = await getTerminalBackground()
|
||||
// e.g., 'rgb:0000/0000/0000' (dark) or 'rgb:ffff/ffff/ffff' (light)
|
||||
```
|
||||
|
||||
### Terminal Version (XTVERSION)
|
||||
|
||||
```ts
|
||||
import { isXtermJs, setXtversionName, getXtversionName } from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### Feature Detection
|
||||
|
||||
```ts
|
||||
import { supportsHyperlinks } from '@anthropic/ink'
|
||||
|
||||
if (supportsHyperlinks()) {
|
||||
// OSC 8 hyperlinks supported
|
||||
}
|
||||
|
||||
import { supportsExtendedKeys } from '@anthropic/ink'
|
||||
|
||||
if (supportsExtendedKeys()) {
|
||||
// Kitty keyboard protocol / modifyOtherKeys available
|
||||
}
|
||||
```
|
||||
|
||||
## Terminal Focus
|
||||
|
||||
Track terminal window focus/unfocus:
|
||||
|
||||
```tsx
|
||||
import { useTerminalFocus } from '@anthropic/ink'
|
||||
|
||||
const isFocused = useTerminalFocus()
|
||||
```
|
||||
|
||||
Low-level API:
|
||||
|
||||
```ts
|
||||
import { getTerminalFocused, subscribeTerminalFocus } from '@anthropic/ink'
|
||||
|
||||
getTerminalFocused() // boolean
|
||||
subscribeTerminalFocus((focused: boolean) => {
|
||||
// Called on focus change
|
||||
})
|
||||
```
|
||||
|
||||
Uses DECSET 1004 focus reporting.
|
||||
|
||||
## Terminal Title
|
||||
|
||||
Set the terminal window title:
|
||||
|
||||
```tsx
|
||||
import { useTerminalTitle } from '@anthropic/ink'
|
||||
|
||||
useTerminalTitle('My App - Dashboard')
|
||||
```
|
||||
|
||||
Clear:
|
||||
|
||||
```tsx
|
||||
useTerminalTitle(null)
|
||||
```
|
||||
|
||||
## Terminal I/O Sequences
|
||||
|
||||
Low-level ANSI sequence constants for advanced use.
|
||||
|
||||
### Cursor Control
|
||||
|
||||
```ts
|
||||
import {
|
||||
SHOW_CURSOR,
|
||||
HIDE_CURSOR,
|
||||
CURSOR_HOME,
|
||||
} from '@anthropic/ink'
|
||||
|
||||
// cursorPosition(row, col) -- Move cursor to absolute position
|
||||
// cursorMove(dx, dy) -- Move cursor relative
|
||||
```
|
||||
|
||||
### Screen Control
|
||||
|
||||
```ts
|
||||
import {
|
||||
ENTER_ALT_SCREEN,
|
||||
EXIT_ALT_SCREEN,
|
||||
ERASE_SCREEN,
|
||||
} from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### Mouse Control
|
||||
|
||||
```ts
|
||||
import {
|
||||
ENABLE_MOUSE_TRACKING,
|
||||
DISABLE_MOUSE_TRACKING,
|
||||
} from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### Keyboard Protocols
|
||||
|
||||
```ts
|
||||
import {
|
||||
ENABLE_KITTY_KEYBOARD,
|
||||
DISABLE_KITTY_KEYBOARD,
|
||||
ENABLE_MODIFY_OTHER_KEYS,
|
||||
DISABLE_MODIFY_OTHER_KEYS,
|
||||
} from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### Clipboard & Tab Status
|
||||
|
||||
```ts
|
||||
import {
|
||||
CLEAR_ITERM2_PROGRESS,
|
||||
CLEAR_TAB_STATUS,
|
||||
CLEAR_TERMINAL_TITLE,
|
||||
wrapForMultiplexer,
|
||||
} from '@anthropic/ink'
|
||||
```
|
||||
|
||||
`wrapForMultiplexer` wraps OSC sequences for tmux compatibility.
|
||||
|
||||
## Terminal Compatibility
|
||||
|
||||
### Supported Terminals
|
||||
|
||||
| Terminal | Features |
|
||||
|----------|----------|
|
||||
| iTerm2 | Full support (hyperlinks, notifications, progress) |
|
||||
| Kitty | Full support (keyboard protocol, notifications) |
|
||||
| Ghostty | Full support |
|
||||
| WezTerm | Full support |
|
||||
| Alacritty | Most features |
|
||||
| Windows Terminal | Most features |
|
||||
| Apple Terminal | 256-color fallback |
|
||||
| xterm.js (VS Code) | Detected and special-cased |
|
||||
| tmux | Wrapped sequences via `wrapForMultiplexer` |
|
||||
| Screen | Basic support |
|
||||
|
||||
### Feature Degradation
|
||||
|
||||
The framework gracefully degrades:
|
||||
- No true color → Falls back to ANSI 16-color themes
|
||||
- No OSC 52 → Clipboard operations silently fail
|
||||
- No mouse tracking → Click/hover events are no-ops
|
||||
- No extended keys → Standard escape sequences used
|
||||
- No bracketed paste → Paste detected by timing heuristic
|
||||
|
||||
### Synchronized Output
|
||||
|
||||
```ts
|
||||
import { isSynchronizedOutputSupported } from '@anthropic/ink'
|
||||
|
||||
if (isSynchronizedOutputSupported()) {
|
||||
// BSU/ESU for tear-free rendering
|
||||
}
|
||||
```
|
||||
|
||||
Uses DECSET 2026 synchronized output to prevent partial frame display.
|
||||
|
||||
### Bracketed Paste
|
||||
|
||||
Uses DECSET 2004 to distinguish paste events from rapid typing. Automatically enabled by the App component.
|
||||
|
||||
## Text Selection (Alt-Screen)
|
||||
|
||||
### Selection State
|
||||
|
||||
```ts
|
||||
type SelectionState = {
|
||||
anchor: Point | null // Drag start
|
||||
focus: Point | null // Current position
|
||||
isDragging: boolean
|
||||
anchorSpan: { lo: Point; hi: Point; kind: 'word' | 'line' } | null
|
||||
scrolledOffAbove: string[] // Text scrolled out above
|
||||
scrolledOffBelow: string[] // Text scrolled out below
|
||||
}
|
||||
```
|
||||
|
||||
### Selection Operations
|
||||
|
||||
- **Click-drag** -- Free-form selection
|
||||
- **Double-click** -- Word selection
|
||||
- **Triple-click** -- Line selection
|
||||
- **Shift+Arrow** -- Extend selection from keyboard
|
||||
- **Drag-to-scroll** -- Auto-scroll when dragging near edges
|
||||
|
||||
### noSelect Regions
|
||||
|
||||
Exclude areas from selection (gutters, line numbers):
|
||||
|
||||
```tsx
|
||||
<Box noSelect={true}>
|
||||
<Text>1 │</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>code here</Text> {/* Only this is selectable */}
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Soft-Wrap Awareness
|
||||
|
||||
Selection correctly handles text that was wrapped across multiple rows:
|
||||
- Wrapped lines are joined when copied
|
||||
- Trailing whitespace is trimmed
|
||||
- The `softWrap` bitmap tracks which rows are continuations
|
||||
46
packages/@ant/ink/docs/README.md
Normal file
46
packages/@ant/ink/docs/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# @anthropic/ink Documentation
|
||||
|
||||
A terminal React rendering framework for building rich command-line interfaces.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
`@anthropic/ink` is a forked/internal Ink framework that renders React components directly to the terminal using ANSI escape sequences. It uses Yoga (via a custom layout engine) for Flexbox layout, a custom React reconciler for terminal DOM, and a screen-buffer differ for efficient updates.
|
||||
|
||||
### Three-Layer Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Layer 3: Theme │
|
||||
│ ThemeProvider, ThemedBox, ThemedText, │
|
||||
│ Dialog, FuzzyPicker, ProgressBar, etc. │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Layer 2: Components │
|
||||
│ Box, Text, ScrollBox, Button, Link, │
|
||||
│ Newline, Spacer, AlternateScreen │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Layer 1: Core │
|
||||
│ Reconciler, Layout (Yoga), Terminal │
|
||||
│ I/O, Screen Buffer, Event System │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Core** (`src/core/`) -- Rendering engine: React reconciler, Yoga flexbox layout, terminal I/O, screen buffer with diff-based updates, event system (keyboard, mouse, focus, click).
|
||||
- **Components** (`src/components/`) -- UI primitives: `Box`, `Text`, `ScrollBox`, `Button`, `Link`, `Newline`, `Spacer`, etc. Plus context providers (`App`, `StdinContext`).
|
||||
- **Theme** (`src/theme/`) -- Theme system: `ThemeProvider`, theme-aware `Box`/`Text` wrappers, and design-system components (`Dialog`, `FuzzyPicker`, `ProgressBar`, `Tabs`, etc.).
|
||||
|
||||
### Documentation
|
||||
|
||||
| Chapter | File | Contents |
|
||||
|---------|------|----------|
|
||||
| 1 | [Getting Started](./01-getting-started.md) | Installation, rendering, basic concepts |
|
||||
| 2 | [Layout System](./02-layout.md) | Box, Flexbox, Yoga, positioning, dimensions |
|
||||
| 3 | [Text & Styling](./03-text-and-styling.md) | Text component, colors, text wrapping, ANSI styling |
|
||||
| 4 | [Theme System](./04-theme-system.md) | ThemeProvider, themes, ThemedBox, ThemedText, color() |
|
||||
| 5 | [Design System Components](./05-design-system.md) | Dialog, ProgressBar, FuzzyPicker, Tabs, Spinner, etc. |
|
||||
| 6 | [Scrolling](./06-scrolling.md) | ScrollBox, sticky scroll, imperative scroll API |
|
||||
| 7 | [User Input](./07-user-input.md) | useInput, Key types, raw mode, mouse events |
|
||||
| 8 | [Keybinding System](./08-keybindings.md) | KeybindingProvider, useKeybinding, chord sequences, parser |
|
||||
| 9 | [Hooks Reference](./09-hooks-reference.md) | All hooks with full API signatures |
|
||||
| 10 | [Events & Focus](./10-events-and-focus.md) | Event system, FocusManager, click/hover, tab navigation |
|
||||
| 11 | [Core Architecture](./11-core-architecture.md) | Reconciler, screen buffer, terminal I/O, rendering pipeline |
|
||||
| 12 | [Terminal Integration](./12-terminal-integration.md) | Alternate screen, mouse tracking, clipboard, notifications |
|
||||
@@ -286,6 +286,15 @@ export default class App extends PureComponent<Props, State> {
|
||||
// ignore calling setRawMode on an handle stdin it cannot be called
|
||||
if (this.isRawModeSupported()) {
|
||||
this.handleSetRawMode(false)
|
||||
} else {
|
||||
// Even when raw mode was never enabled (e.g. non-TTY stdin on
|
||||
// Windows Node.js), ensure stdin is unref'd so the process can
|
||||
// exit. earlyInput may have called ref() before Ink mounted.
|
||||
try {
|
||||
this.props.stdin.unref()
|
||||
} catch {
|
||||
// stdin may already be destroyed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,14 +92,14 @@ function Box({
|
||||
tabIndex={tabIndex}
|
||||
autoFocus={autoFocus}
|
||||
onClick={onClick}
|
||||
onFocus={onFocus}
|
||||
onFocusCapture={onFocusCapture}
|
||||
onBlur={onBlur}
|
||||
onBlurCapture={onBlurCapture}
|
||||
onFocus={onFocus as unknown as (event: React.FocusEvent<Element, Element>) => void}
|
||||
onFocusCapture={onFocusCapture as unknown as (event: React.FocusEvent<Element, Element>) => void}
|
||||
onBlur={onBlur as unknown as (event: React.FocusEvent<Element, Element>) => void}
|
||||
onBlurCapture={onBlurCapture as unknown as (event: React.FocusEvent<Element, Element>) => void}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
onKeyDown={onKeyDown as unknown as (event: React.KeyboardEvent<Element>) => void}
|
||||
onKeyDownCapture={onKeyDownCapture as unknown as (event: React.KeyboardEvent<Element>) => void}
|
||||
style={{
|
||||
flexWrap,
|
||||
flexDirection,
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
*/
|
||||
import bidiFactory from 'bidi-js'
|
||||
|
||||
type BidiInstance = {
|
||||
getEmbeddingLevels: (text: string, defaultDirection?: string) => { paragraphLevel: number; levels: Uint8Array }
|
||||
getReorderSegments: (text: string, embeddingLevels: { paragraphLevel: number; levels: Uint8Array }, start?: number, end?: number) => [number, number][]
|
||||
getVisualOrder: (reorderSegments: [number, number][]) => number[]
|
||||
}
|
||||
|
||||
type ClusteredChar = {
|
||||
value: string
|
||||
width: number
|
||||
@@ -23,7 +29,7 @@ type ClusteredChar = {
|
||||
hyperlink: string | undefined
|
||||
}
|
||||
|
||||
let bidiInstance: ReturnType<typeof bidiFactory> | undefined
|
||||
let bidiInstance: BidiInstance | undefined
|
||||
let needsSoftwareBidi: boolean | undefined
|
||||
|
||||
function needsBidi(): boolean {
|
||||
@@ -38,7 +44,7 @@ function needsBidi(): boolean {
|
||||
|
||||
function getBidi() {
|
||||
if (!bidiInstance) {
|
||||
bidiInstance = bidiFactory()
|
||||
bidiInstance = (bidiFactory as unknown as () => BidiInstance)()
|
||||
}
|
||||
return bidiInstance
|
||||
}
|
||||
|
||||
@@ -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 { MouseActionEvent } from './mouse-action-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 MouseActionEventHandler = (event: MouseActionEvent) => void
|
||||
type HoverEventHandler = () => void
|
||||
|
||||
/**
|
||||
@@ -33,6 +35,9 @@ export type EventHandlerProps = {
|
||||
onResize?: ResizeEventHandler
|
||||
|
||||
onClick?: ClickEventHandler
|
||||
onMouseDown?: MouseActionEventHandler
|
||||
onMouseUp?: MouseActionEventHandler
|
||||
onMouseDrag?: MouseActionEventHandler
|
||||
onMouseEnter?: HoverEventHandler
|
||||
onMouseLeave?: HoverEventHandler
|
||||
}
|
||||
@@ -51,6 +56,9 @@ export const HANDLER_FOR_EVENT: Record<
|
||||
paste: { bubble: 'onPaste', capture: 'onPasteCapture' },
|
||||
resize: { bubble: 'onResize' },
|
||||
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',
|
||||
])
|
||||
|
||||
44
packages/@ant/ink/src/core/events/mouse-action-event.ts
Normal file
44
packages/@ant/ink/src/core/events/mouse-action-event.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Event } from './event.js'
|
||||
import type { EventTarget } from './terminal-event.js'
|
||||
|
||||
/**
|
||||
* Mouse action event (mousedown, mouseup, mousedrag).
|
||||
* Bubbles from the deepest hit node up through parentNode.
|
||||
*/
|
||||
export class MouseActionEvent extends Event {
|
||||
/** Action type */
|
||||
readonly type: 'mousedown' | 'mouseup' | 'mousedrag'
|
||||
/** 0-indexed screen column */
|
||||
readonly col: number
|
||||
/** 0-indexed screen row */
|
||||
readonly row: number
|
||||
/** Mouse button number */
|
||||
readonly button: number
|
||||
/**
|
||||
* Column relative to the current handler's Box.
|
||||
* Recomputed before each handler fires.
|
||||
*/
|
||||
localCol = 0
|
||||
/** Row relative to the current handler's Box. */
|
||||
localRow = 0
|
||||
|
||||
constructor(
|
||||
type: 'mousedown' | 'mouseup' | 'mousedrag',
|
||||
col: number,
|
||||
row: number,
|
||||
button: number,
|
||||
) {
|
||||
super()
|
||||
this.type = type
|
||||
this.col = col
|
||||
this.row = row
|
||||
this.button = button
|
||||
}
|
||||
|
||||
/** Recompute local coords relative to the target Box. */
|
||||
prepareForTarget(target: EventTarget): void {
|
||||
const dom = target as unknown as { yogaNode?: { getComputedLeft?(): number; getComputedTop?(): number } }
|
||||
this.localCol = this.col - (dom.yogaNode?.getComputedLeft?.() ?? 0)
|
||||
this.localRow = this.row - (dom.yogaNode?.getComputedTop?.() ?? 0)
|
||||
}
|
||||
}
|
||||
@@ -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 { MouseActionEvent } from './events/mouse-action-event.js'
|
||||
import { nodeCache } from './node-cache.js'
|
||||
|
||||
/**
|
||||
@@ -128,3 +129,43 @@ export function dispatchHover(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchMouseAction(
|
||||
root: DOMElement,
|
||||
col: number,
|
||||
row: number,
|
||||
button: number,
|
||||
type: 'mousedown' | 'mouseup' | 'mousedrag',
|
||||
targetOverride?: DOMElement,
|
||||
): DOMElement | null {
|
||||
let target: DOMElement | undefined =
|
||||
targetOverride ?? hitTest(root, col, row) ?? undefined
|
||||
if (!target) return null
|
||||
|
||||
const propName =
|
||||
type === 'mousedown'
|
||||
? 'onMouseDown'
|
||||
: type === 'mouseup'
|
||||
? 'onMouseUp'
|
||||
: 'onMouseDrag'
|
||||
|
||||
const event = new MouseActionEvent(type, col, row, button)
|
||||
let handledBy: DOMElement | null = null
|
||||
|
||||
while (target) {
|
||||
const handler = target._eventHandlers?.[propName] as
|
||||
| ((event: MouseActionEvent) => void)
|
||||
| undefined
|
||||
if (handler) {
|
||||
handledBy ??= target
|
||||
event.prepareForTarget(target)
|
||||
handler(event)
|
||||
if (event.didStopImmediatePropagation()) {
|
||||
return handledBy
|
||||
}
|
||||
}
|
||||
target = target.parentNode as DOMElement | undefined
|
||||
}
|
||||
|
||||
return handledBy
|
||||
}
|
||||
|
||||
@@ -352,8 +352,7 @@ export default class Ink {
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error @types/react-reconciler@0.32.3 declares 11 args with transitionCallbacks,
|
||||
// but react-reconciler 0.33.0 source only accepts 10 args (no transitionCallbacks)
|
||||
// @ts-ignore createContainer arg count varies across react-reconciler versions
|
||||
this.container = reconciler.createContainer(
|
||||
this.rootNode,
|
||||
ConcurrentRoot,
|
||||
@@ -367,6 +366,7 @@ export default class Ink {
|
||||
noop, // onDefaultTransitionIndicator
|
||||
)
|
||||
|
||||
// @ts-ignore MACRO-replaced comparison — always false in production builds
|
||||
if ("production" === 'development') {
|
||||
reconciler.injectIntoDevTools({
|
||||
bundleType: 0,
|
||||
@@ -952,7 +952,7 @@ export default class Ink {
|
||||
|
||||
pause(): void {
|
||||
// Flush pending React updates and render before pausing.
|
||||
// @ts-expect-error flushSyncFromReconciler exists in react-reconciler 0.31 but not in @types/react-reconciler
|
||||
// @ts-ignore flushSyncFromReconciler exists in react-reconciler but not in @types
|
||||
reconciler.flushSyncFromReconciler()
|
||||
this.onRender()
|
||||
|
||||
@@ -1701,9 +1701,9 @@ export default class Ink {
|
||||
</App>
|
||||
)
|
||||
|
||||
// @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler
|
||||
// @ts-ignore updateContainerSync exists in react-reconciler but not in @types
|
||||
reconciler.updateContainerSync(tree, this.container, null, noop)
|
||||
// @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler
|
||||
// @ts-ignore flushSyncWork exists in react-reconciler but not in @types
|
||||
reconciler.flushSyncWork()
|
||||
}
|
||||
|
||||
@@ -1773,9 +1773,9 @@ export default class Ink {
|
||||
this.drainTimer = null
|
||||
}
|
||||
|
||||
// @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler
|
||||
// @ts-ignore updateContainerSync exists in react-reconciler but not in @types
|
||||
reconciler.updateContainerSync(null, this.container, null, noop)
|
||||
// @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler
|
||||
// @ts-ignore flushSyncWork exists in react-reconciler but not in @types
|
||||
reconciler.flushSyncWork()
|
||||
instances.delete(this.options.stdout)
|
||||
|
||||
@@ -1883,8 +1883,8 @@ export default class Ink {
|
||||
let reentered = false
|
||||
const intercept = (
|
||||
chunk: Uint8Array | string,
|
||||
encodingOrCb?: BufferEncoding | ((err?: Error) => void),
|
||||
cb?: (err?: Error) => void,
|
||||
encodingOrCb?: BufferEncoding | ((err?: Error | null) => void),
|
||||
cb?: (err?: Error | null) => void,
|
||||
): boolean => {
|
||||
const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb
|
||||
// Reentrancy guard: logger.debug → writeToStderr → here. Pass
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { KeyboardEvent } from '../core/events/keyboard-event.js'
|
||||
import { KeyboardEvent } from '../core/events/keyboard-event.js'
|
||||
import type { Key, InputEvent } from '../core/events/input-event.js'
|
||||
import type { ParsedKey } from '../core/parse-keypress.js'
|
||||
import useInput from './use-input.js'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
|
||||
@@ -212,8 +214,8 @@ export function useSearchInput({
|
||||
|
||||
// Bridge: subscribe via useInput and adapt to KeyboardEvent
|
||||
useInput(
|
||||
(_input: string, _key: unknown, event: { keypress: string }) => {
|
||||
handleKeyDown(new KeyboardEvent(event.keypress))
|
||||
(_input: string, _key: Key, event: InputEvent) => {
|
||||
handleKeyDown(new KeyboardEvent(event.keypress as ParsedKey))
|
||||
},
|
||||
{ isActive },
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// ============================================================
|
||||
export { default as wrappedRender, renderSync, createRoot } from './core/root.js'
|
||||
export type { RenderOptions, Instance, Root } from './core/root.js'
|
||||
|
||||
export * from './theme/theme-types.js'
|
||||
// InkCore class
|
||||
export { default as Ink } from './core/ink.js'
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import type { InputEvent } from '../core/events/input-event.js'
|
||||
// ChordInterceptor intentionally uses useInput to intercept all keystrokes before
|
||||
// other handlers process them - this is required for chord sequence support
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings
|
||||
import useInput, { type Key } from '../hooks/use-input.js'
|
||||
import useInput from '../hooks/use-input.js'
|
||||
import type { Key } from '../core/events/input-event.js'
|
||||
import { KeybindingProvider } from './KeybindingContext.js'
|
||||
import { resolveKeyWithChordState } from './resolver.js'
|
||||
import type {
|
||||
|
||||
5
packages/@ant/ink/tsconfig.json
Normal file
5
packages/@ant/ink/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
12
packages/@ant/ink/utils/systemThemeWatcher.ts
Normal file
12
packages/@ant/ink/utils/systemThemeWatcher.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { SystemTheme } from '../src/theme/systemTheme.js'
|
||||
|
||||
/**
|
||||
* Watch for live terminal theme changes via OSC 11 polling.
|
||||
* Stub implementation for the standalone @anthropic/ink package.
|
||||
*/
|
||||
export function watchSystemTheme(
|
||||
_querier: unknown,
|
||||
_setTheme: React.Dispatch<React.SetStateAction<SystemTheme>>,
|
||||
): () => void {
|
||||
return () => {}
|
||||
}
|
||||
18
packages/@ant/model-provider/package.json
Normal file
18
packages/@ant/model-provider/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@ant/model-provider",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types/index.ts",
|
||||
"./hooks": "./src/hooks/index.ts",
|
||||
"./client": "./src/client/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.81.0",
|
||||
"openai": "^6.33.0"
|
||||
}
|
||||
}
|
||||
27
packages/@ant/model-provider/src/client/index.ts
Normal file
27
packages/@ant/model-provider/src/client/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ClientFactories } from './types.js'
|
||||
|
||||
let registeredFactories: ClientFactories | null = null
|
||||
|
||||
/**
|
||||
* Register client factories from the main project.
|
||||
* Call this during application initialization.
|
||||
*/
|
||||
export function registerClientFactories(factories: ClientFactories): void {
|
||||
registeredFactories = factories
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered client factories.
|
||||
* Throws if not registered (fail-fast).
|
||||
*/
|
||||
export function getClientFactories(): ClientFactories {
|
||||
if (!registeredFactories) {
|
||||
throw new Error(
|
||||
'Client factories not registered. ' +
|
||||
'Call registerClientFactories() during app initialization.',
|
||||
)
|
||||
}
|
||||
return registeredFactories
|
||||
}
|
||||
|
||||
export type { ClientFactories }
|
||||
35
packages/@ant/model-provider/src/client/types.ts
Normal file
35
packages/@ant/model-provider/src/client/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Client factory interfaces.
|
||||
* Authentication is handled externally — main project provides factory implementations.
|
||||
*/
|
||||
export interface ClientFactories {
|
||||
/** Get Anthropic client (1st party, Bedrock, Foundry, Vertex) */
|
||||
getAnthropicClient: (params: {
|
||||
model?: string
|
||||
maxRetries: number
|
||||
fetchOverride?: unknown
|
||||
source?: string
|
||||
}) => Promise<unknown>
|
||||
|
||||
/** Get OpenAI-compatible client */
|
||||
getOpenAIClient: (params: {
|
||||
maxRetries: number
|
||||
fetchOverride?: unknown
|
||||
source?: string
|
||||
}) => unknown
|
||||
|
||||
/** Stream Gemini generate content */
|
||||
streamGeminiGenerateContent: (params: {
|
||||
model: string
|
||||
signal?: AbortSignal
|
||||
fetchOverride?: unknown
|
||||
body: Record<string, unknown>
|
||||
}) => AsyncIterable<unknown>
|
||||
|
||||
/** Get Grok client (OpenAI-compatible) */
|
||||
getGrokClient: (params: {
|
||||
maxRetries: number
|
||||
fetchOverride?: unknown
|
||||
source?: string
|
||||
}) => unknown
|
||||
}
|
||||
238
packages/@ant/model-provider/src/errorUtils.ts
Normal file
238
packages/@ant/model-provider/src/errorUtils.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type { APIError } from '@anthropic-ai/sdk'
|
||||
|
||||
// SSL/TLS error codes from OpenSSL (used by both Node.js and Bun)
|
||||
// See: https://www.openssl.org/docs/man3.1/man3/X509_STORE_CTX_get_error.html
|
||||
const SSL_ERROR_CODES = new Set([
|
||||
// Certificate verification errors
|
||||
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
||||
'UNABLE_TO_GET_ISSUER_CERT',
|
||||
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
||||
'CERT_SIGNATURE_FAILURE',
|
||||
'CERT_NOT_YET_VALID',
|
||||
'CERT_HAS_EXPIRED',
|
||||
'CERT_REVOKED',
|
||||
'CERT_REJECTED',
|
||||
'CERT_UNTRUSTED',
|
||||
// Self-signed certificate errors
|
||||
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
||||
'SELF_SIGNED_CERT_IN_CHAIN',
|
||||
// Chain errors
|
||||
'CERT_CHAIN_TOO_LONG',
|
||||
'PATH_LENGTH_EXCEEDED',
|
||||
// Hostname/altname errors
|
||||
'ERR_TLS_CERT_ALTNAME_INVALID',
|
||||
'HOSTNAME_MISMATCH',
|
||||
// TLS handshake errors
|
||||
'ERR_TLS_HANDSHAKE_TIMEOUT',
|
||||
'ERR_SSL_WRONG_VERSION_NUMBER',
|
||||
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC',
|
||||
])
|
||||
|
||||
export type ConnectionErrorDetails = {
|
||||
code: string
|
||||
message: string
|
||||
isSSLError: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts connection error details from the error cause chain.
|
||||
* The Anthropic SDK wraps underlying errors in the `cause` property.
|
||||
* This function walks the cause chain to find the root error code/message.
|
||||
*/
|
||||
export function extractConnectionErrorDetails(
|
||||
error: unknown,
|
||||
): ConnectionErrorDetails | null {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Walk the cause chain to find the root error with a code
|
||||
let current: unknown = error
|
||||
const maxDepth = 5 // Prevent infinite loops
|
||||
let depth = 0
|
||||
|
||||
while (current && depth < maxDepth) {
|
||||
if (
|
||||
current instanceof Error &&
|
||||
'code' in current &&
|
||||
typeof current.code === 'string'
|
||||
) {
|
||||
const code = current.code
|
||||
const isSSLError = SSL_ERROR_CODES.has(code)
|
||||
return {
|
||||
code,
|
||||
message: current.message,
|
||||
isSSLError,
|
||||
}
|
||||
}
|
||||
|
||||
// Move to the next cause in the chain
|
||||
if (
|
||||
current instanceof Error &&
|
||||
'cause' in current &&
|
||||
current.cause !== current
|
||||
) {
|
||||
current = current.cause
|
||||
depth++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an actionable hint for SSL/TLS errors, intended for contexts outside
|
||||
* the main API client (OAuth token exchange, preflight connectivity checks)
|
||||
* where `formatAPIError` doesn't apply.
|
||||
*/
|
||||
export function getSSLErrorHint(error: unknown): string | null {
|
||||
const details = extractConnectionErrorDetails(error)
|
||||
if (!details?.isSSLError) {
|
||||
return null
|
||||
}
|
||||
return `SSL certificate error (${details.code}). If you are behind a corporate proxy or TLS-intercepting firewall, set NODE_EXTRA_CA_CERTS to your CA bundle path, or ask IT to allowlist *.anthropic.com. Run /doctor for details.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips HTML content (e.g., CloudFlare error pages) from a message string,
|
||||
* returning a user-friendly title or empty string if HTML is detected.
|
||||
* Returns the original message unchanged if no HTML is found.
|
||||
*/
|
||||
function sanitizeMessageHTML(message: string): string {
|
||||
if (message.includes('<!DOCTYPE html') || message.includes('<html')) {
|
||||
const titleMatch = message.match(/<title>([^<]+)<\/title>/)
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
return titleMatch[1].trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if an error message contains HTML content (e.g., CloudFlare error pages)
|
||||
* and returns a user-friendly message instead
|
||||
*/
|
||||
export function sanitizeAPIError(apiError: APIError): string {
|
||||
const message = apiError.message
|
||||
if (!message) {
|
||||
return ''
|
||||
}
|
||||
return sanitizeMessageHTML(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shapes of deserialized API errors from session JSONL.
|
||||
*/
|
||||
type NestedAPIError = {
|
||||
error?: {
|
||||
message?: string
|
||||
error?: { message?: string }
|
||||
}
|
||||
}
|
||||
|
||||
function hasNestedError(value: unknown): value is NestedAPIError {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'error' in value &&
|
||||
typeof value.error === 'object' &&
|
||||
value.error !== null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a human-readable message from a deserialized API error that lacks
|
||||
* a top-level `.message`.
|
||||
*/
|
||||
function extractNestedErrorMessage(error: APIError): string | null {
|
||||
if (!hasNestedError(error)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const narrowed: NestedAPIError = error
|
||||
const nested = narrowed.error
|
||||
|
||||
// Standard Anthropic API shape: { error: { error: { message } } }
|
||||
const deepMsg = nested?.error?.message
|
||||
if (typeof deepMsg === 'string' && deepMsg.length > 0) {
|
||||
const sanitized = sanitizeMessageHTML(deepMsg)
|
||||
if (sanitized.length > 0) {
|
||||
return sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// Bedrock shape: { error: { message } }
|
||||
const msg = nested?.message
|
||||
if (typeof msg === 'string' && msg.length > 0) {
|
||||
const sanitized = sanitizeMessageHTML(msg)
|
||||
if (sanitized.length > 0) {
|
||||
return sanitized
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatAPIError(error: APIError): string {
|
||||
// Extract connection error details from the cause chain
|
||||
const connectionDetails = extractConnectionErrorDetails(error)
|
||||
|
||||
if (connectionDetails) {
|
||||
const { code, isSSLError } = connectionDetails
|
||||
|
||||
// Handle timeout errors
|
||||
if (code === 'ETIMEDOUT') {
|
||||
return 'Request timed out. Check your internet connection and proxy settings'
|
||||
}
|
||||
|
||||
// Handle SSL/TLS errors with specific messages
|
||||
if (isSSLError) {
|
||||
switch (code) {
|
||||
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
|
||||
case 'UNABLE_TO_GET_ISSUER_CERT':
|
||||
case 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY':
|
||||
return 'Unable to connect to API: SSL certificate verification failed. Check your proxy or corporate SSL certificates'
|
||||
case 'CERT_HAS_EXPIRED':
|
||||
return 'Unable to connect to API: SSL certificate has expired'
|
||||
case 'CERT_REVOKED':
|
||||
return 'Unable to connect to API: SSL certificate has been revoked'
|
||||
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
||||
case 'SELF_SIGNED_CERT_IN_CHAIN':
|
||||
return 'Unable to connect to API: Self-signed certificate detected. Check your proxy or corporate SSL certificates'
|
||||
case 'ERR_TLS_CERT_ALTNAME_INVALID':
|
||||
case 'HOSTNAME_MISMATCH':
|
||||
return 'Unable to connect to API: SSL certificate hostname mismatch'
|
||||
case 'CERT_NOT_YET_VALID':
|
||||
return 'Unable to connect to API: SSL certificate is not yet valid'
|
||||
default:
|
||||
return `Unable to connect to API: SSL error (${code})`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (error.message === 'Connection error.') {
|
||||
// If we have a code but it's not SSL, include it for debugging
|
||||
if (connectionDetails?.code) {
|
||||
return `Unable to connect to API (${connectionDetails.code})`
|
||||
}
|
||||
return 'Unable to connect to API. Check your internet connection'
|
||||
}
|
||||
|
||||
// Guard: when deserialized from JSONL (e.g. --resume), the error object may
|
||||
// be a plain object without a `.message` property.
|
||||
if (!error.message) {
|
||||
return (
|
||||
extractNestedErrorMessage(error) ??
|
||||
`API error (status ${error.status ?? 'unknown'})`
|
||||
)
|
||||
}
|
||||
|
||||
const sanitizedMessage = sanitizeAPIError(error)
|
||||
// Use sanitized message if it's different from the original (i.e., HTML was sanitized)
|
||||
return sanitizedMessage !== error.message && sanitizedMessage.length > 0
|
||||
? sanitizedMessage
|
||||
: error.message
|
||||
}
|
||||
27
packages/@ant/model-provider/src/hooks/index.ts
Normal file
27
packages/@ant/model-provider/src/hooks/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ModelProviderHooks } from './types.js'
|
||||
|
||||
let registeredHooks: ModelProviderHooks | null = null
|
||||
|
||||
/**
|
||||
* Register hooks from the main project.
|
||||
* Call this during application initialization.
|
||||
*/
|
||||
export function registerHooks(hooks: ModelProviderHooks): void {
|
||||
registeredHooks = hooks
|
||||
}
|
||||
|
||||
/**
|
||||
* Get registered hooks.
|
||||
* Throws if hooks not registered (fail-fast).
|
||||
*/
|
||||
export function getHooks(): ModelProviderHooks {
|
||||
if (!registeredHooks) {
|
||||
throw new Error(
|
||||
'ModelProvider hooks not registered. ' +
|
||||
'Call registerHooks() during app initialization.',
|
||||
)
|
||||
}
|
||||
return registeredHooks
|
||||
}
|
||||
|
||||
export type { ModelProviderHooks }
|
||||
48
packages/@ant/model-provider/src/hooks/types.ts
Normal file
48
packages/@ant/model-provider/src/hooks/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Hooks for dependency injection.
|
||||
* Main project provides implementations; model-provider calls them.
|
||||
*
|
||||
* This decouples the model-provider from main project specifics like
|
||||
* analytics, cost tracking, feature flags, etc.
|
||||
*/
|
||||
export interface ModelProviderHooks {
|
||||
/** Log an analytics event (replaces direct logEvent calls) */
|
||||
logEvent: (eventName: string, metadata?: Record<string, unknown>) => void
|
||||
|
||||
/** Report API cost after each response */
|
||||
reportCost: (params: {
|
||||
costUSD: number
|
||||
usage: Record<string, unknown>
|
||||
model: string
|
||||
}) => void
|
||||
|
||||
/** Get tool permission context */
|
||||
getToolPermissionContext?: () => Promise<Record<string, unknown>>
|
||||
|
||||
/** Debug logging */
|
||||
logForDebugging: (msg: string, opts?: { level?: string }) => void
|
||||
|
||||
/** Error logging */
|
||||
logError: (error: Error) => void
|
||||
|
||||
/** Get feature flag value */
|
||||
getFeatureFlag?: (flagName: string) => unknown
|
||||
|
||||
/** Get session ID */
|
||||
getSessionId: () => string
|
||||
|
||||
/** Add a notification */
|
||||
addNotification?: (notification: Record<string, unknown>) => void
|
||||
|
||||
/** Get API provider name */
|
||||
getAPIProvider: () => string
|
||||
|
||||
/** Get user ID */
|
||||
getOrCreateUserID: () => string
|
||||
|
||||
/** Check if non-interactive session */
|
||||
isNonInteractiveSession: () => boolean
|
||||
|
||||
/** Get OAuth account info */
|
||||
getOauthAccountInfo?: () => Record<string, unknown> | undefined
|
||||
}
|
||||
63
packages/@ant/model-provider/src/index.ts
Normal file
63
packages/@ant/model-provider/src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// @ant/model-provider
|
||||
// Model provider abstraction layer for Claude Code
|
||||
//
|
||||
// This package owns the model calling logic and provides:
|
||||
// - Core query functions (queryModelWithStreaming, etc.)
|
||||
// - Provider implementations (Anthropic, OpenAI, Gemini, Grok)
|
||||
// - Type definitions (Message, Tool, Usage, etc.)
|
||||
// - Dependency injection hooks (analytics, cost tracking, etc.)
|
||||
//
|
||||
// Initialization:
|
||||
// registerClientFactories({ ... }) // inject auth clients
|
||||
// registerHooks({ ... }) // inject analytics/cost/logging
|
||||
|
||||
// Hooks (dependency injection)
|
||||
export { registerHooks, getHooks } from './hooks/index.js'
|
||||
export type { ModelProviderHooks } from './hooks/types.js'
|
||||
|
||||
// Client factories
|
||||
export { registerClientFactories, getClientFactories } from './client/index.js'
|
||||
export type { ClientFactories } from './client/types.js'
|
||||
|
||||
// Types
|
||||
export * from './types/index.js'
|
||||
|
||||
// Provider model mappings
|
||||
export { resolveOpenAIModel } from './providers/openai/modelMapping.js'
|
||||
export { resolveGrokModel } from './providers/grok/modelMapping.js'
|
||||
export { resolveGeminiModel } from './providers/gemini/modelMapping.js'
|
||||
|
||||
// Gemini provider utilities
|
||||
export { anthropicMessagesToGemini } from './providers/gemini/convertMessages.js'
|
||||
export { anthropicToolsToGemini, anthropicToolChoiceToGemini } from './providers/gemini/convertTools.js'
|
||||
export { adaptGeminiStreamToAnthropic } from './providers/gemini/streamAdapter.js'
|
||||
export {
|
||||
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
||||
type GeminiContent,
|
||||
type GeminiGenerateContentRequest,
|
||||
type GeminiPart,
|
||||
type GeminiStreamChunk,
|
||||
type GeminiTool,
|
||||
type GeminiFunctionCallingConfig,
|
||||
type GeminiFunctionDeclaration,
|
||||
type GeminiFunctionCall,
|
||||
type GeminiFunctionResponse,
|
||||
type GeminiInlineData,
|
||||
type GeminiUsageMetadata,
|
||||
type GeminiCandidate,
|
||||
} from './providers/gemini/types.js'
|
||||
|
||||
// Error utilities
|
||||
export {
|
||||
formatAPIError,
|
||||
extractConnectionErrorDetails,
|
||||
sanitizeAPIError,
|
||||
getSSLErrorHint,
|
||||
type ConnectionErrorDetails,
|
||||
} from './errorUtils.js'
|
||||
|
||||
// Shared OpenAI conversion utilities
|
||||
export { anthropicMessagesToOpenAI } from './shared/openaiConvertMessages.js'
|
||||
export type { ConvertMessagesOptions } from './shared/openaiConvertMessages.js'
|
||||
export { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './shared/openaiConvertTools.js'
|
||||
export { adaptOpenAIStreamToAnthropic } from './shared/openaiStreamAdapter.js'
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
UserMessage,
|
||||
} from '../../../types/message.js'
|
||||
import { anthropicMessagesToGemini } from '../convertMessages.js'
|
||||
|
||||
function makeUserMsg(content: string | any[]): UserMessage {
|
||||
return {
|
||||
type: 'user',
|
||||
uuid: '00000000-0000-0000-0000-000000000000',
|
||||
message: { role: 'user', content },
|
||||
} as UserMessage
|
||||
}
|
||||
|
||||
function makeAssistantMsg(content: string | any[]): AssistantMessage {
|
||||
return {
|
||||
type: 'assistant',
|
||||
uuid: '00000000-0000-0000-0000-000000000001',
|
||||
message: { role: 'assistant', content },
|
||||
} as AssistantMessage
|
||||
}
|
||||
|
||||
describe('anthropicMessagesToGemini', () => {
|
||||
test('converts system prompt to systemInstruction', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[makeUserMsg('hello')],
|
||||
['You are helpful.'] as any,
|
||||
)
|
||||
|
||||
expect(result.systemInstruction).toEqual({
|
||||
parts: [{ text: 'You are helpful.' }],
|
||||
})
|
||||
})
|
||||
|
||||
test('converts assistant tool_use to functionCall', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
_geminiThoughtSignature: 'sig-tool',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
|
||||
expect(result.contents).toEqual([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'bash',
|
||||
args: { command: 'ls' },
|
||||
},
|
||||
thoughtSignature: 'sig-tool',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts tool_result to functionResponse using prior tool name', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_123',
|
||||
content: 'file.txt',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
|
||||
expect(result.contents[1]).toEqual({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'bash',
|
||||
response: {
|
||||
result: 'file.txt',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('converts thinking blocks with signatures', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal reasoning',
|
||||
signature: 'sig-thinking',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'visible answer',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
|
||||
expect(result.contents[0]).toEqual({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: 'internal reasoning',
|
||||
thought: true,
|
||||
thoughtSignature: 'sig-thinking',
|
||||
},
|
||||
{
|
||||
text: 'visible answer',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('filters empty assistant text and signature-only thinking parts', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
_geminiThoughtSignature: 'sig-empty-text',
|
||||
},
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: '',
|
||||
signature: 'sig-empty-thinking',
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'pwd' },
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
|
||||
expect(result.contents).toEqual([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'bash',
|
||||
args: { command: 'pwd' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('filters empty user text blocks', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
|
||||
expect(result.contents).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts base64 image to inlineData', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[makeUserMsg([
|
||||
{ type: 'text', text: 'describe this' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'iVBORw0KGgo=',
|
||||
},
|
||||
},
|
||||
])],
|
||||
[] as any,
|
||||
)
|
||||
expect(result.contents).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: 'describe this' },
|
||||
{ inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgo=' } },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts url image to text fallback', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/img.png',
|
||||
},
|
||||
},
|
||||
])],
|
||||
[] as any,
|
||||
)
|
||||
expect(result.contents).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: '[image: https://example.com/img.png]' }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('defaults to image/png when media_type is missing', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
data: 'ABC123',
|
||||
},
|
||||
},
|
||||
])],
|
||||
[] as any,
|
||||
)
|
||||
expect(result.contents[0].parts[0]).toEqual({
|
||||
inlineData: { mimeType: 'image/png', data: 'ABC123' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
anthropicToolChoiceToGemini,
|
||||
anthropicToolsToGemini,
|
||||
} from '../convertTools.js'
|
||||
|
||||
describe('anthropicToolsToGemini', () => {
|
||||
test('converts basic tool to parametersJsonSchema', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
expect(anthropicToolsToGemini(tools as any)).toEqual([
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
propertyOrdering: ['command'],
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('sanitizes unsupported JSON Schema fields for Gemini', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'complex',
|
||||
description: 'Complex schema',
|
||||
input_schema: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
propertyNames: { pattern: '^[a-z]+$' },
|
||||
properties: {
|
||||
mode: { const: 'strict' },
|
||||
retries: {
|
||||
type: 'integer',
|
||||
exclusiveMinimum: 0,
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'string',
|
||||
propertyNames: { pattern: '^[a-z]+$' },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['mode'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
expect(anthropicToolsToGemini(tools as any)).toEqual([
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'complex',
|
||||
description: 'Complex schema',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['strict'],
|
||||
},
|
||||
retries: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
propertyOrdering: ['mode', 'retries', 'metadata'],
|
||||
required: ['mode'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('returns empty array when no tools are provided', () => {
|
||||
expect(anthropicToolsToGemini([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('anthropicToolChoiceToGemini', () => {
|
||||
test('maps auto', () => {
|
||||
expect(anthropicToolChoiceToGemini({ type: 'auto' })).toEqual({
|
||||
mode: 'AUTO',
|
||||
})
|
||||
})
|
||||
|
||||
test('maps any', () => {
|
||||
expect(anthropicToolChoiceToGemini({ type: 'any' })).toEqual({
|
||||
mode: 'ANY',
|
||||
})
|
||||
})
|
||||
|
||||
test('maps explicit tool choice', () => {
|
||||
expect(
|
||||
anthropicToolChoiceToGemini({ type: 'tool', name: 'bash' }),
|
||||
).toEqual({
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: ['bash'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { resolveGeminiModel } from '../modelMapping.js'
|
||||
|
||||
describe('resolveGeminiModel', () => {
|
||||
const originalEnv = {
|
||||
GEMINI_MODEL: process.env.GEMINI_MODEL,
|
||||
GEMINI_DEFAULT_HAIKU_MODEL: process.env.GEMINI_DEFAULT_HAIKU_MODEL,
|
||||
GEMINI_DEFAULT_SONNET_MODEL: process.env.GEMINI_DEFAULT_SONNET_MODEL,
|
||||
GEMINI_DEFAULT_OPUS_MODEL: process.env.GEMINI_DEFAULT_OPUS_MODEL,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.GEMINI_MODEL
|
||||
delete process.env.GEMINI_DEFAULT_HAIKU_MODEL
|
||||
delete process.env.GEMINI_DEFAULT_SONNET_MODEL
|
||||
delete process.env.GEMINI_DEFAULT_OPUS_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(process.env, originalEnv)
|
||||
})
|
||||
|
||||
test('GEMINI_MODEL env var overrides family mappings', () => {
|
||||
process.env.GEMINI_MODEL = 'gemini-2.5-pro'
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
|
||||
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-pro')
|
||||
})
|
||||
|
||||
test('GEMINI_DEFAULT_*_MODEL takes precedence over ANTHROPIC_DEFAULT_*', () => {
|
||||
process.env.GEMINI_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash-priority'
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash-fallback'
|
||||
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe(
|
||||
'gemini-2.5-flash-priority',
|
||||
)
|
||||
})
|
||||
|
||||
test('resolves sonnet model from GEMINI_DEFAULT_SONNET_MODEL', () => {
|
||||
process.env.GEMINI_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-flash')
|
||||
})
|
||||
|
||||
test('resolves haiku model from GEMINI_DEFAULT_HAIKU_MODEL', () => {
|
||||
process.env.GEMINI_DEFAULT_HAIKU_MODEL = 'gemini-2.5-flash-lite'
|
||||
expect(resolveGeminiModel('claude-haiku-4-5-20251001')).toBe(
|
||||
'gemini-2.5-flash-lite',
|
||||
)
|
||||
})
|
||||
|
||||
test('resolves opus model from GEMINI_DEFAULT_OPUS_MODEL', () => {
|
||||
process.env.GEMINI_DEFAULT_OPUS_MODEL = 'gemini-2.5-pro'
|
||||
expect(resolveGeminiModel('claude-opus-4-6')).toBe('gemini-2.5-pro')
|
||||
})
|
||||
|
||||
test('falls back to ANTHROPIC_DEFAULT_* when GEMINI_DEFAULT_* not set', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-flash')
|
||||
})
|
||||
|
||||
test('resolves haiku from ANTHROPIC_DEFAULT_HAIKU_MODEL as fallback', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'gemini-2.5-flash-lite'
|
||||
expect(resolveGeminiModel('claude-haiku-4-5-20251001')).toBe(
|
||||
'gemini-2.5-flash-lite',
|
||||
)
|
||||
})
|
||||
|
||||
test('resolves opus from ANTHROPIC_DEFAULT_OPUS_MODEL as fallback', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'gemini-2.5-pro'
|
||||
expect(resolveGeminiModel('claude-opus-4-6')).toBe('gemini-2.5-pro')
|
||||
})
|
||||
|
||||
test('uses backward compatible family override', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'legacy-gemini-sonnet'
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('legacy-gemini-sonnet')
|
||||
})
|
||||
|
||||
test('strips [1m] suffix before resolving', () => {
|
||||
process.env.GEMINI_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6[1m]')).toBe('gemini-2.5-flash')
|
||||
})
|
||||
|
||||
test('passes through explicit Gemini model names', () => {
|
||||
expect(resolveGeminiModel('gemini-3.1-flash-lite-preview')).toBe(
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
)
|
||||
})
|
||||
|
||||
test('throws when no Gemini model configuration is available', () => {
|
||||
expect(() => resolveGeminiModel('claude-sonnet-4-6')).toThrow(
|
||||
'Gemini provider requires GEMINI_MODEL or GEMINI_DEFAULT_SONNET_MODEL (or ANTHROPIC_DEFAULT_SONNET_MODEL for backward compatibility) to be configured.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { adaptGeminiStreamToAnthropic } from '../streamAdapter.js'
|
||||
import type { GeminiStreamChunk } from '../types.js'
|
||||
|
||||
function mockStream(
|
||||
chunks: GeminiStreamChunk[],
|
||||
): AsyncIterable<GeminiStreamChunk> {
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
let index = 0
|
||||
return {
|
||||
async next() {
|
||||
if (index >= chunks.length) {
|
||||
return { done: true, value: undefined }
|
||||
}
|
||||
return { done: false, value: chunks[index++] }
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function collectEvents(chunks: GeminiStreamChunk[]) {
|
||||
const events: any[] = []
|
||||
for await (const event of adaptGeminiStreamToAnthropic(
|
||||
mockStream(chunks),
|
||||
'gemini-2.5-flash',
|
||||
)) {
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
describe('adaptGeminiStreamToAnthropic', () => {
|
||||
test('converts text chunks', async () => {
|
||||
const events = await collectEvents([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Hello' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: ' world' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const textDeltas = events.filter(
|
||||
event =>
|
||||
event.type === 'content_block_delta' && event.delta.type === 'text_delta',
|
||||
)
|
||||
|
||||
expect(events[0].type).toBe('message_start')
|
||||
expect(textDeltas).toHaveLength(2)
|
||||
expect(textDeltas[0].delta.text).toBe('Hello')
|
||||
expect(textDeltas[1].delta.text).toBe(' world')
|
||||
|
||||
const messageDelta = events.find(event => event.type === 'message_delta')
|
||||
expect(messageDelta.delta.stop_reason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('converts thinking chunks and signatures', async () => {
|
||||
const events = await collectEvents([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Think', thought: true }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ thought: true, thoughtSignature: 'sig-123' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const blockStart = events.find(event => event.type === 'content_block_start')
|
||||
expect(blockStart.content_block.type).toBe('thinking')
|
||||
|
||||
const signatureDelta = events.find(
|
||||
event =>
|
||||
event.type === 'content_block_delta' &&
|
||||
event.delta.type === 'signature_delta',
|
||||
)
|
||||
expect(signatureDelta.delta.signature).toBe('sig-123')
|
||||
})
|
||||
|
||||
test('converts function calls to tool_use blocks', async () => {
|
||||
const events = await collectEvents([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'bash',
|
||||
args: { command: 'ls' },
|
||||
},
|
||||
thoughtSignature: 'sig-tool',
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const blockStart = events.find(event => event.type === 'content_block_start')
|
||||
expect(blockStart.content_block.type).toBe('tool_use')
|
||||
expect(blockStart.content_block.name).toBe('bash')
|
||||
|
||||
const signatureDelta = events.find(
|
||||
event =>
|
||||
event.type === 'content_block_delta' &&
|
||||
event.delta.type === 'signature_delta',
|
||||
)
|
||||
expect(signatureDelta.delta.signature).toBe('sig-tool')
|
||||
|
||||
const inputDelta = events.find(
|
||||
event =>
|
||||
event.type === 'content_block_delta' &&
|
||||
event.delta.type === 'input_json_delta',
|
||||
)
|
||||
expect(inputDelta.delta.partial_json).toBe('{"command":"ls"}')
|
||||
|
||||
const messageDelta = events.find(event => event.type === 'message_delta')
|
||||
expect(messageDelta.delta.stop_reason).toBe('tool_use')
|
||||
})
|
||||
|
||||
test('maps usage metadata into output tokens', async () => {
|
||||
const events = await collectEvents([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Hello' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const messageStart = events.find(event => event.type === 'message_start')
|
||||
expect(messageStart.message.usage.input_tokens).toBe(10)
|
||||
|
||||
const messageDelta = events.find(event => event.type === 'message_delta')
|
||||
expect(messageDelta.usage.output_tokens).toBe(7)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,307 @@
|
||||
import type {
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolUseBlock,
|
||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { AssistantMessage, UserMessage } from '../../types/message.js'
|
||||
import type { SystemPrompt } from '../../types/systemPrompt.js'
|
||||
import {
|
||||
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
||||
type GeminiContent,
|
||||
type GeminiGenerateContentRequest,
|
||||
type GeminiPart,
|
||||
} from './types.js'
|
||||
|
||||
// Simple JSON parse utility (replaces safeParseJSON from main project)
|
||||
function safeParseJSON(json: string | null | undefined): unknown {
|
||||
if (!json) return null
|
||||
try {
|
||||
return JSON.parse(json)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function anthropicMessagesToGemini(
|
||||
messages: (UserMessage | AssistantMessage)[],
|
||||
systemPrompt: SystemPrompt,
|
||||
): Pick<GeminiGenerateContentRequest, 'contents' | 'systemInstruction'> {
|
||||
const contents: GeminiContent[] = []
|
||||
const toolNamesById = new Map<string, string>()
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'assistant') {
|
||||
const content = convertInternalAssistantMessage(msg)
|
||||
if (content.parts.length > 0) {
|
||||
contents.push(content)
|
||||
}
|
||||
|
||||
const assistantContent = msg.message.content
|
||||
if (Array.isArray(assistantContent)) {
|
||||
for (const block of assistantContent) {
|
||||
if (typeof block !== 'string' && block.type === 'tool_use') {
|
||||
toolNamesById.set(block.id, block.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.type === 'user') {
|
||||
const content = convertInternalUserMessage(msg, toolNamesById)
|
||||
if (content.parts.length > 0) {
|
||||
contents.push(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const systemText = systemPromptToText(systemPrompt)
|
||||
|
||||
return {
|
||||
contents,
|
||||
...(systemText
|
||||
? {
|
||||
systemInstruction: {
|
||||
parts: [{ text: systemText }],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
||||
if (!systemPrompt || systemPrompt.length === 0) return ''
|
||||
return systemPrompt.filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
function convertInternalUserMessage(
|
||||
msg: UserMessage,
|
||||
toolNamesById: ReadonlyMap<string, string>,
|
||||
): GeminiContent {
|
||||
const content = msg.message.content
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return {
|
||||
role: 'user',
|
||||
parts: createTextGeminiParts(content),
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return { role: 'user', parts: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'user',
|
||||
parts: content.flatMap(block =>
|
||||
convertUserContentBlockToGeminiParts(block as unknown as string | Record<string, unknown>, toolNamesById),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function convertUserContentBlockToGeminiParts(
|
||||
block: string | Record<string, unknown>,
|
||||
toolNamesById: ReadonlyMap<string, string>,
|
||||
): GeminiPart[] {
|
||||
if (typeof block === 'string') {
|
||||
return createTextGeminiParts(block)
|
||||
}
|
||||
|
||||
if (block.type === 'text') {
|
||||
return createTextGeminiParts(block.text)
|
||||
}
|
||||
|
||||
if (block.type === 'tool_result') {
|
||||
const toolResult = block as unknown as BetaToolResultBlockParam
|
||||
return [
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolNamesById.get(toolResult.tool_use_id) ?? toolResult.tool_use_id,
|
||||
response: toolResultToResponseObject(toolResult),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Convert Anthropic image blocks to Gemini inlineData
|
||||
if (block.type === 'image') {
|
||||
const source = block.source as Record<string, unknown> | undefined
|
||||
if (source?.type === 'base64' && typeof source.data === 'string') {
|
||||
const mediaType = (source.media_type as string) || 'image/png'
|
||||
return [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: mediaType,
|
||||
data: source.data,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
// URL images not directly supported by Gemini, convert to text description
|
||||
if (source?.type === 'url' && typeof source.url === 'string') {
|
||||
return createTextGeminiParts(`[image: ${source.url}]`)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function convertInternalAssistantMessage(msg: AssistantMessage): GeminiContent {
|
||||
const content = msg.message.content
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return {
|
||||
role: 'model',
|
||||
parts: createTextGeminiParts(content),
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return { role: 'model', parts: [] }
|
||||
}
|
||||
|
||||
const parts: GeminiPart[] = []
|
||||
for (const block of content) {
|
||||
if (typeof block === 'string') {
|
||||
parts.push(...createTextGeminiParts(block))
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'text') {
|
||||
parts.push(
|
||||
...createTextGeminiParts(
|
||||
block.text,
|
||||
getGeminiThoughtSignature(block as unknown as Record<string, unknown>),
|
||||
),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'thinking') {
|
||||
const thinkingPart = createThinkingGeminiPart(
|
||||
block.thinking,
|
||||
block.signature,
|
||||
)
|
||||
if (thinkingPart) {
|
||||
parts.push(thinkingPart)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'tool_use') {
|
||||
const toolUse = block as unknown as BetaToolUseBlock
|
||||
parts.push({
|
||||
functionCall: {
|
||||
name: toolUse.name,
|
||||
args: normalizeToolUseInput(toolUse.input),
|
||||
},
|
||||
...(getGeminiThoughtSignature(block as unknown as Record<string, unknown>) && {
|
||||
thoughtSignature: getGeminiThoughtSignature(block as unknown as Record<string, unknown>),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { role: 'model', parts }
|
||||
}
|
||||
|
||||
function createTextGeminiParts(
|
||||
value: unknown,
|
||||
thoughtSignature?: string,
|
||||
): GeminiPart[] {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text: value,
|
||||
...(thoughtSignature && { thoughtSignature }),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function createThinkingGeminiPart(
|
||||
value: unknown,
|
||||
thoughtSignature?: string,
|
||||
): GeminiPart | undefined {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
text: value,
|
||||
thought: true,
|
||||
...(thoughtSignature && { thoughtSignature }),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolUseInput(input: unknown): Record<string, unknown> {
|
||||
if (typeof input === 'string') {
|
||||
const parsed = safeParseJSON(input)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
return parsed === null ? {} : { value: parsed }
|
||||
}
|
||||
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||
return input as Record<string, unknown>
|
||||
}
|
||||
|
||||
return input === undefined ? {} : { value: input }
|
||||
}
|
||||
|
||||
function toolResultToResponseObject(
|
||||
block: BetaToolResultBlockParam,
|
||||
): Record<string, unknown> {
|
||||
const result = normalizeToolResultContent(block.content)
|
||||
if (
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
!Array.isArray(result)
|
||||
) {
|
||||
return block.is_error ? { ...(result as Record<string, unknown>), is_error: true } : result as Record<string, unknown>
|
||||
}
|
||||
|
||||
return {
|
||||
result,
|
||||
...(block.is_error ? { is_error: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolResultContent(content: unknown): unknown {
|
||||
if (typeof content === 'string') {
|
||||
const parsed = safeParseJSON(content)
|
||||
return parsed ?? content
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.map(part => {
|
||||
if (typeof part === 'string') return part
|
||||
if (
|
||||
part &&
|
||||
typeof part === 'object' &&
|
||||
'text' in part &&
|
||||
typeof part.text === 'string'
|
||||
) {
|
||||
return part.text
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const parsed = safeParseJSON(text)
|
||||
return parsed ?? text
|
||||
}
|
||||
|
||||
return content ?? ''
|
||||
}
|
||||
|
||||
function getGeminiThoughtSignature(block: Record<string, unknown>): string | undefined {
|
||||
const signature = block[GEMINI_THOUGHT_SIGNATURE_FIELD]
|
||||
return typeof signature === 'string' && signature.length > 0
|
||||
? signature
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type {
|
||||
GeminiFunctionCallingConfig,
|
||||
GeminiTool,
|
||||
} from './types.js'
|
||||
|
||||
const GEMINI_JSON_SCHEMA_TYPES = new Set([
|
||||
'string',
|
||||
'number',
|
||||
'integer',
|
||||
'boolean',
|
||||
'object',
|
||||
'array',
|
||||
'null',
|
||||
])
|
||||
|
||||
function normalizeGeminiJsonSchemaType(
|
||||
value: unknown,
|
||||
): string | string[] | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return GEMINI_JSON_SCHEMA_TYPES.has(value) ? value : undefined
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = value.filter(
|
||||
(item): item is string =>
|
||||
typeof item === 'string' && GEMINI_JSON_SCHEMA_TYPES.has(item),
|
||||
)
|
||||
const unique = Array.from(new Set(normalized))
|
||||
if (unique.length === 0) return undefined
|
||||
return unique.length === 1 ? unique[0] : unique
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function inferGeminiJsonSchemaTypeFromValue(value: unknown): string | undefined {
|
||||
if (value === null) return 'null'
|
||||
if (Array.isArray(value)) return 'array'
|
||||
if (typeof value === 'string') return 'string'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? 'integer' : 'number'
|
||||
}
|
||||
if (typeof value === 'object') return 'object'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function inferGeminiJsonSchemaTypeFromEnum(
|
||||
values: unknown[],
|
||||
): string | string[] | undefined {
|
||||
const inferred = values
|
||||
.map(inferGeminiJsonSchemaTypeFromValue)
|
||||
.filter((value): value is string => value !== undefined)
|
||||
const unique = Array.from(new Set(inferred))
|
||||
if (unique.length === 0) return undefined
|
||||
return unique.length === 1 ? unique[0] : unique
|
||||
}
|
||||
|
||||
function addNullToGeminiJsonSchemaType(
|
||||
value: string | string[] | undefined,
|
||||
): string | string[] | undefined {
|
||||
if (value === undefined) return ['null']
|
||||
if (Array.isArray(value)) {
|
||||
return value.includes('null') ? value : [...value, 'null']
|
||||
}
|
||||
return value === 'null' ? value : [value, 'null']
|
||||
}
|
||||
|
||||
function sanitizeGeminiJsonSchemaProperties(
|
||||
value: unknown,
|
||||
): Record<string, Record<string, unknown>> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sanitizedEntries = Object.entries(value as Record<string, unknown>)
|
||||
.map(([key, schema]) => [key, sanitizeGeminiJsonSchema(schema)] as const)
|
||||
.filter(([, schema]) => Object.keys(schema).length > 0)
|
||||
|
||||
if (sanitizedEntries.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Object.fromEntries(sanitizedEntries)
|
||||
}
|
||||
|
||||
function sanitizeGeminiJsonSchemaArray(
|
||||
value: unknown,
|
||||
): Record<string, unknown>[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
|
||||
const sanitized = value
|
||||
.map(item => sanitizeGeminiJsonSchema(item))
|
||||
.filter(item => Object.keys(item).length > 0)
|
||||
|
||||
return sanitized.length > 0 ? sanitized : undefined
|
||||
}
|
||||
|
||||
function sanitizeGeminiJsonSchema(
|
||||
schema: unknown,
|
||||
): Record<string, unknown> {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const source = schema as Record<string, unknown>
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
let type = normalizeGeminiJsonSchemaType(source.type)
|
||||
|
||||
if (source.const !== undefined) {
|
||||
result.enum = [source.const]
|
||||
type = type ?? inferGeminiJsonSchemaTypeFromValue(source.const)
|
||||
} else if (Array.isArray(source.enum) && source.enum.length > 0) {
|
||||
result.enum = source.enum
|
||||
type = type ?? inferGeminiJsonSchemaTypeFromEnum(source.enum)
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
if (source.properties && typeof source.properties === 'object') {
|
||||
type = 'object'
|
||||
} else if (source.items !== undefined || source.prefixItems !== undefined) {
|
||||
type = 'array'
|
||||
}
|
||||
}
|
||||
|
||||
if (source.nullable === true) {
|
||||
type = addNullToGeminiJsonSchemaType(type)
|
||||
}
|
||||
|
||||
if (type) {
|
||||
result.type = type
|
||||
}
|
||||
|
||||
if (typeof source.title === 'string') {
|
||||
result.title = source.title
|
||||
}
|
||||
if (typeof source.description === 'string') {
|
||||
result.description = source.description
|
||||
}
|
||||
if (typeof source.format === 'string') {
|
||||
result.format = source.format
|
||||
}
|
||||
if (typeof source.pattern === 'string') {
|
||||
result.pattern = source.pattern
|
||||
}
|
||||
if (typeof source.minimum === 'number') {
|
||||
result.minimum = source.minimum
|
||||
} else if (typeof source.exclusiveMinimum === 'number') {
|
||||
result.minimum = source.exclusiveMinimum
|
||||
}
|
||||
if (typeof source.maximum === 'number') {
|
||||
result.maximum = source.maximum
|
||||
} else if (typeof source.exclusiveMaximum === 'number') {
|
||||
result.maximum = source.exclusiveMaximum
|
||||
}
|
||||
if (typeof source.minItems === 'number') {
|
||||
result.minItems = source.minItems
|
||||
}
|
||||
if (typeof source.maxItems === 'number') {
|
||||
result.maxItems = source.maxItems
|
||||
}
|
||||
if (typeof source.minLength === 'number') {
|
||||
result.minLength = source.minLength
|
||||
}
|
||||
if (typeof source.maxLength === 'number') {
|
||||
result.maxLength = source.maxLength
|
||||
}
|
||||
if (typeof source.minProperties === 'number') {
|
||||
result.minProperties = source.minProperties
|
||||
}
|
||||
if (typeof source.maxProperties === 'number') {
|
||||
result.maxProperties = source.maxProperties
|
||||
}
|
||||
|
||||
const properties = sanitizeGeminiJsonSchemaProperties(source.properties)
|
||||
if (properties) {
|
||||
result.properties = properties
|
||||
result.propertyOrdering = Object.keys(properties)
|
||||
}
|
||||
|
||||
if (Array.isArray(source.required)) {
|
||||
const required = source.required.filter(
|
||||
(item): item is string => typeof item === 'string',
|
||||
)
|
||||
if (required.length > 0) {
|
||||
result.required = required
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof source.additionalProperties === 'boolean') {
|
||||
result.additionalProperties = source.additionalProperties
|
||||
} else {
|
||||
const additionalProperties = sanitizeGeminiJsonSchema(
|
||||
source.additionalProperties,
|
||||
)
|
||||
if (Object.keys(additionalProperties).length > 0) {
|
||||
result.additionalProperties = additionalProperties
|
||||
}
|
||||
}
|
||||
|
||||
const items = sanitizeGeminiJsonSchema(source.items)
|
||||
if (Object.keys(items).length > 0) {
|
||||
result.items = items
|
||||
}
|
||||
|
||||
const prefixItems = sanitizeGeminiJsonSchemaArray(source.prefixItems)
|
||||
if (prefixItems) {
|
||||
result.prefixItems = prefixItems
|
||||
}
|
||||
|
||||
const anyOf = sanitizeGeminiJsonSchemaArray(source.anyOf ?? source.oneOf)
|
||||
if (anyOf) {
|
||||
result.anyOf = anyOf
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function sanitizeGeminiFunctionParameters(
|
||||
schema: unknown,
|
||||
): Record<string, unknown> {
|
||||
const sanitized = sanitizeGeminiJsonSchema(schema)
|
||||
if (Object.keys(sanitized).length > 0) {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function anthropicToolsToGemini(tools: BetaToolUnion[]): GeminiTool[] {
|
||||
const functionDeclarations = tools
|
||||
.filter(tool => {
|
||||
const toolType = (tool as unknown as { type?: string }).type
|
||||
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||
})
|
||||
.map(tool => {
|
||||
const anyTool = tool as unknown as Record<string, unknown>
|
||||
const name = (anyTool.name as string) || ''
|
||||
const description = (anyTool.description as string) || ''
|
||||
const inputSchema =
|
||||
(anyTool.input_schema as Record<string, unknown> | undefined) ?? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
parametersJsonSchema: sanitizeGeminiFunctionParameters(inputSchema),
|
||||
}
|
||||
})
|
||||
|
||||
return functionDeclarations.length > 0
|
||||
? [{ functionDeclarations }]
|
||||
: []
|
||||
}
|
||||
|
||||
export function anthropicToolChoiceToGemini(
|
||||
toolChoice: unknown,
|
||||
): GeminiFunctionCallingConfig | undefined {
|
||||
if (!toolChoice || typeof toolChoice !== 'object') return undefined
|
||||
|
||||
const tc = toolChoice as Record<string, unknown>
|
||||
const type = tc.type as string
|
||||
|
||||
switch (type) {
|
||||
case 'auto':
|
||||
return { mode: 'AUTO' }
|
||||
case 'any':
|
||||
return { mode: 'ANY' }
|
||||
case 'tool':
|
||||
return {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames:
|
||||
typeof tc.name === 'string' ? [tc.name] : undefined,
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||
if (/haiku/i.test(model)) return 'haiku'
|
||||
if (/opus/i.test(model)) return 'opus'
|
||||
if (/sonnet/i.test(model)) return 'sonnet'
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveGeminiModel(anthropicModel: string): string {
|
||||
if (process.env.GEMINI_MODEL) {
|
||||
return process.env.GEMINI_MODEL
|
||||
}
|
||||
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/i, '')
|
||||
const family = getModelFamily(cleanModel)
|
||||
|
||||
if (!family) {
|
||||
return cleanModel
|
||||
}
|
||||
|
||||
const geminiEnvVar = `GEMINI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const geminiModel = process.env[geminiEnvVar]
|
||||
if (geminiModel) {
|
||||
return geminiModel
|
||||
}
|
||||
|
||||
const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const resolvedModel = process.env[sharedEnvVar]
|
||||
if (resolvedModel) {
|
||||
return resolvedModel
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Gemini provider requires GEMINI_MODEL or ${geminiEnvVar} (or ${sharedEnvVar} for backward compatibility) to be configured.`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { GeminiPart, GeminiStreamChunk } from './types.js'
|
||||
|
||||
export async function* adaptGeminiStreamToAnthropic(
|
||||
stream: AsyncIterable<GeminiStreamChunk>,
|
||||
model: string,
|
||||
): AsyncGenerator<BetaRawMessageStreamEvent, void> {
|
||||
const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
let started = false
|
||||
let stopped = false
|
||||
let nextContentIndex = 0
|
||||
let openTextLikeBlock:
|
||||
| { index: number; type: 'text' | 'thinking' }
|
||||
| null = null
|
||||
let sawToolUse = false
|
||||
let finishReason: string | undefined
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const usage = chunk.usageMetadata
|
||||
if (usage) {
|
||||
inputTokens = usage.promptTokenCount ?? inputTokens
|
||||
outputTokens =
|
||||
(usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0)
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
started = true
|
||||
yield {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
} as unknown as BetaRawMessageStreamEvent
|
||||
}
|
||||
const candidate = chunk.candidates?.[0]
|
||||
const parts = candidate?.content?.parts ?? []
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.functionCall) {
|
||||
if (openTextLikeBlock) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: openTextLikeBlock.index,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openTextLikeBlock = null
|
||||
}
|
||||
|
||||
sawToolUse = true
|
||||
const toolIndex = nextContentIndex++
|
||||
const toolId = `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: toolIndex,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: toolId,
|
||||
name: part.functionCall.name || '',
|
||||
input: {},
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
|
||||
if (part.thoughtSignature) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: toolIndex,
|
||||
delta: {
|
||||
type: 'signature_delta',
|
||||
signature: part.thoughtSignature,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
if (part.functionCall.args && Object.keys(part.functionCall.args).length > 0) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: toolIndex,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: JSON.stringify(part.functionCall.args),
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: toolIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
continue
|
||||
}
|
||||
|
||||
const textLikeType = getTextLikeBlockType(part)
|
||||
if (textLikeType) {
|
||||
if (!openTextLikeBlock || openTextLikeBlock.type !== textLikeType) {
|
||||
if (openTextLikeBlock) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: openTextLikeBlock.index,
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
openTextLikeBlock = {
|
||||
index: nextContentIndex++,
|
||||
type: textLikeType,
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: openTextLikeBlock.index,
|
||||
content_block:
|
||||
textLikeType === 'thinking'
|
||||
? {
|
||||
type: 'thinking',
|
||||
thinking: '',
|
||||
signature: '',
|
||||
}
|
||||
: {
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
if (part.text) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: openTextLikeBlock.index,
|
||||
delta:
|
||||
textLikeType === 'thinking'
|
||||
? {
|
||||
type: 'thinking_delta',
|
||||
thinking: part.text,
|
||||
}
|
||||
: {
|
||||
type: 'text_delta',
|
||||
text: part.text,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
if (part.thoughtSignature) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: openTextLikeBlock.index,
|
||||
delta: {
|
||||
type: 'signature_delta',
|
||||
signature: part.thoughtSignature,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.thoughtSignature && openTextLikeBlock) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: openTextLikeBlock.index,
|
||||
delta: {
|
||||
type: 'signature_delta',
|
||||
signature: part.thoughtSignature,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate?.finishReason) {
|
||||
finishReason = candidate.finishReason
|
||||
}
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
return
|
||||
}
|
||||
|
||||
if (openTextLikeBlock) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: openTextLikeBlock.index,
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
if (!stopped) {
|
||||
yield {
|
||||
type: 'message_delta',
|
||||
delta: {
|
||||
stop_reason: mapGeminiFinishReason(finishReason, sawToolUse),
|
||||
stop_sequence: null,
|
||||
},
|
||||
usage: {
|
||||
output_tokens: outputTokens,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
|
||||
yield {
|
||||
type: 'message_stop',
|
||||
} as BetaRawMessageStreamEvent
|
||||
stopped = true
|
||||
}
|
||||
}
|
||||
|
||||
function getTextLikeBlockType(
|
||||
part: GeminiPart,
|
||||
): 'text' | 'thinking' | null {
|
||||
if (typeof part.text !== 'string') {
|
||||
return null
|
||||
}
|
||||
return part.thought ? 'thinking' : 'text'
|
||||
}
|
||||
|
||||
function mapGeminiFinishReason(
|
||||
reason: string | undefined,
|
||||
sawToolUse: boolean,
|
||||
): string {
|
||||
switch (reason) {
|
||||
case 'MAX_TOKENS':
|
||||
return 'max_tokens'
|
||||
case 'STOP':
|
||||
case 'FINISH_REASON_UNSPECIFIED':
|
||||
case 'SAFETY':
|
||||
case 'RECITATION':
|
||||
case 'BLOCKLIST':
|
||||
case 'PROHIBITED_CONTENT':
|
||||
case 'SPII':
|
||||
case 'MALFORMED_FUNCTION_CALL':
|
||||
default:
|
||||
return sawToolUse ? 'tool_use' : 'end_turn'
|
||||
}
|
||||
}
|
||||
86
packages/@ant/model-provider/src/providers/gemini/types.ts
Normal file
86
packages/@ant/model-provider/src/providers/gemini/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export const GEMINI_THOUGHT_SIGNATURE_FIELD = '_geminiThoughtSignature'
|
||||
|
||||
export type GeminiFunctionCall = {
|
||||
name?: string
|
||||
args?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type GeminiFunctionResponse = {
|
||||
name?: string
|
||||
response?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type GeminiInlineData = {
|
||||
mimeType: string
|
||||
data: string
|
||||
}
|
||||
|
||||
export type GeminiPart = {
|
||||
text?: string
|
||||
thought?: boolean
|
||||
thoughtSignature?: string
|
||||
functionCall?: GeminiFunctionCall
|
||||
functionResponse?: GeminiFunctionResponse
|
||||
inlineData?: GeminiInlineData
|
||||
}
|
||||
|
||||
export type GeminiContent = {
|
||||
role: 'user' | 'model'
|
||||
parts: GeminiPart[]
|
||||
}
|
||||
|
||||
export type GeminiFunctionDeclaration = {
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, unknown>
|
||||
parametersJsonSchema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type GeminiTool = {
|
||||
functionDeclarations: GeminiFunctionDeclaration[]
|
||||
}
|
||||
|
||||
export type GeminiFunctionCallingConfig = {
|
||||
mode: 'AUTO' | 'ANY' | 'NONE'
|
||||
allowedFunctionNames?: string[]
|
||||
}
|
||||
|
||||
export type GeminiGenerateContentRequest = {
|
||||
contents: GeminiContent[]
|
||||
systemInstruction?: {
|
||||
parts: Array<{ text: string }>
|
||||
}
|
||||
tools?: GeminiTool[]
|
||||
toolConfig?: {
|
||||
functionCallingConfig: GeminiFunctionCallingConfig
|
||||
}
|
||||
generationConfig?: {
|
||||
temperature?: number
|
||||
thinkingConfig?: {
|
||||
includeThoughts?: boolean
|
||||
thinkingBudget?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type GeminiUsageMetadata = {
|
||||
promptTokenCount?: number
|
||||
candidatesTokenCount?: number
|
||||
thoughtsTokenCount?: number
|
||||
totalTokenCount?: number
|
||||
}
|
||||
|
||||
export type GeminiCandidate = {
|
||||
content?: {
|
||||
role?: string
|
||||
parts?: GeminiPart[]
|
||||
}
|
||||
finishReason?: string
|
||||
index?: number
|
||||
}
|
||||
|
||||
export type GeminiStreamChunk = {
|
||||
candidates?: GeminiCandidate[]
|
||||
usageMetadata?: GeminiUsageMetadata
|
||||
modelVersion?: string
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { resolveGrokModel } from '../modelMapping.js'
|
||||
|
||||
describe('resolveGrokModel', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.GROK_MODEL
|
||||
delete process.env.GROK_MODEL_MAP
|
||||
delete process.env.GROK_DEFAULT_SONNET_MODEL
|
||||
delete process.env.GROK_DEFAULT_OPUS_MODEL
|
||||
delete process.env.GROK_DEFAULT_HAIKU_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
test('GROK_MODEL env var takes highest priority', () => {
|
||||
process.env.GROK_MODEL = 'grok-custom'
|
||||
expect(resolveGrokModel('claude-sonnet-4-6')).toBe('grok-custom')
|
||||
})
|
||||
|
||||
test('maps opus models to grok-4.20-reasoning', () => {
|
||||
expect(resolveGrokModel('claude-opus-4-6')).toBe('grok-4.20-reasoning')
|
||||
})
|
||||
|
||||
test('maps sonnet models to grok-3-mini-fast', () => {
|
||||
expect(resolveGrokModel('claude-sonnet-4-6')).toBe('grok-3-mini-fast')
|
||||
})
|
||||
|
||||
test('maps haiku models to grok-3-mini-fast', () => {
|
||||
expect(resolveGrokModel('claude-haiku-4-5-20251001')).toBe('grok-3-mini-fast')
|
||||
})
|
||||
|
||||
test('GROK_MODEL_MAP overrides family mapping', () => {
|
||||
process.env.GROK_MODEL_MAP = '{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-mini"}'
|
||||
expect(resolveGrokModel('claude-opus-4-6')).toBe('grok-4')
|
||||
expect(resolveGrokModel('claude-sonnet-4-6')).toBe('grok-3')
|
||||
expect(resolveGrokModel('claude-haiku-4-5-20251001')).toBe('grok-mini')
|
||||
})
|
||||
|
||||
test('GROK_MODEL_MAP ignores invalid JSON', () => {
|
||||
process.env.GROK_MODEL_MAP = 'not-json'
|
||||
expect(resolveGrokModel('claude-opus-4-6')).toBe('grok-4.20-reasoning')
|
||||
})
|
||||
|
||||
test('GROK_DEFAULT_{FAMILY}_MODEL overrides default map', () => {
|
||||
process.env.GROK_DEFAULT_OPUS_MODEL = 'grok-2-latest'
|
||||
expect(resolveGrokModel('claude-opus-4-6')).toBe('grok-2-latest')
|
||||
})
|
||||
|
||||
test('passes through unknown model names', () => {
|
||||
expect(resolveGrokModel('some-unknown-model')).toBe('some-unknown-model')
|
||||
})
|
||||
|
||||
test('strips [1m] suffix before lookup', () => {
|
||||
expect(resolveGrokModel('claude-sonnet-4-6[1m]')).toBe('grok-3-mini-fast')
|
||||
})
|
||||
|
||||
test('falls back to family default for unlisted model', () => {
|
||||
expect(resolveGrokModel('claude-opus-99-20300101')).toBe('grok-4.20-reasoning')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Default mapping from Anthropic model names to Grok model names.
|
||||
*
|
||||
* Users can override per-family via GROK_DEFAULT_{FAMILY}_MODEL env vars,
|
||||
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string).
|
||||
*/
|
||||
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||
'claude-sonnet-4-20250514': 'grok-3-mini-fast',
|
||||
'claude-sonnet-4-5-20250929': 'grok-3-mini-fast',
|
||||
'claude-sonnet-4-6': 'grok-3-mini-fast',
|
||||
'claude-opus-4-20250514': 'grok-4.20-reasoning',
|
||||
'claude-opus-4-1-20250805': 'grok-4.20-reasoning',
|
||||
'claude-opus-4-5-20251101': 'grok-4.20-reasoning',
|
||||
'claude-opus-4-6': 'grok-4.20-reasoning',
|
||||
'claude-haiku-4-5-20251001': 'grok-3-mini-fast',
|
||||
'claude-3-5-haiku-20241022': 'grok-3-mini-fast',
|
||||
'claude-3-7-sonnet-20250219': 'grok-3-mini-fast',
|
||||
'claude-3-5-sonnet-20241022': 'grok-3-mini-fast',
|
||||
}
|
||||
|
||||
const DEFAULT_FAMILY_MAP: Record<string, string> = {
|
||||
opus: 'grok-4.20-reasoning',
|
||||
sonnet: 'grok-3-mini-fast',
|
||||
haiku: 'grok-3-mini-fast',
|
||||
}
|
||||
|
||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||
if (/haiku/i.test(model)) return 'haiku'
|
||||
if (/opus/i.test(model)) return 'opus'
|
||||
if (/sonnet/i.test(model)) return 'sonnet'
|
||||
return null
|
||||
}
|
||||
|
||||
function getUserModelMap(): Record<string, string> | null {
|
||||
const raw = process.env.GROK_MODEL_MAP
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, string>
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Grok model name for a given Anthropic model.
|
||||
*/
|
||||
export function resolveGrokModel(anthropicModel: string): string {
|
||||
if (process.env.GROK_MODEL) {
|
||||
return process.env.GROK_MODEL
|
||||
}
|
||||
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||
const family = getModelFamily(cleanModel)
|
||||
|
||||
const userMap = getUserModelMap()
|
||||
if (userMap && family && userMap[family]) {
|
||||
return userMap[family]
|
||||
}
|
||||
|
||||
if (family) {
|
||||
const grokEnvVar = `GROK_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const grokOverride = process.env[grokEnvVar]
|
||||
if (grokOverride) return grokOverride
|
||||
|
||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const anthropicOverride = process.env[anthropicEnvVar]
|
||||
if (anthropicOverride) return anthropicOverride
|
||||
}
|
||||
|
||||
if (DEFAULT_MODEL_MAP[cleanModel]) {
|
||||
return DEFAULT_MODEL_MAP[cleanModel]
|
||||
}
|
||||
|
||||
if (family && DEFAULT_FAMILY_MAP[family]) {
|
||||
return DEFAULT_FAMILY_MAP[family]
|
||||
}
|
||||
|
||||
return cleanModel
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { resolveOpenAIModel } from '../modelMapping.js'
|
||||
|
||||
describe('resolveOpenAIModel', () => {
|
||||
const originalEnv = {
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
OPENAI_DEFAULT_HAIKU_MODEL: process.env.OPENAI_DEFAULT_HAIKU_MODEL,
|
||||
OPENAI_DEFAULT_SONNET_MODEL: process.env.OPENAI_DEFAULT_SONNET_MODEL,
|
||||
OPENAI_DEFAULT_OPUS_MODEL: process.env.OPENAI_DEFAULT_OPUS_MODEL,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENAI_MODEL
|
||||
delete process.env.OPENAI_DEFAULT_HAIKU_MODEL
|
||||
delete process.env.OPENAI_DEFAULT_SONNET_MODEL
|
||||
delete process.env.OPENAI_DEFAULT_OPUS_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(process.env, originalEnv)
|
||||
})
|
||||
|
||||
test('OPENAI_MODEL env var overrides all', () => {
|
||||
process.env.OPENAI_MODEL = 'my-custom-model'
|
||||
expect(resolveOpenAIModel('claude-sonnet-4-6')).toBe('my-custom-model')
|
||||
})
|
||||
|
||||
test('ANTHROPIC_DEFAULT_SONNET_MODEL overrides default map', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'my-sonnet'
|
||||
expect(resolveOpenAIModel('claude-sonnet-4-6')).toBe('my-sonnet')
|
||||
})
|
||||
|
||||
test('ANTHROPIC_DEFAULT_HAIKU_MODEL overrides default map', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'my-haiku'
|
||||
expect(resolveOpenAIModel('claude-haiku-4-5-20251001')).toBe('my-haiku')
|
||||
})
|
||||
|
||||
test('ANTHROPIC_DEFAULT_OPUS_MODEL overrides default map', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'my-opus'
|
||||
expect(resolveOpenAIModel('claude-opus-4-6')).toBe('my-opus')
|
||||
})
|
||||
|
||||
test('maps known Anthropic model via DEFAULT_MODEL_MAP', () => {
|
||||
expect(resolveOpenAIModel('claude-sonnet-4-6')).toBe('gpt-4o')
|
||||
})
|
||||
|
||||
test('maps haiku model', () => {
|
||||
expect(resolveOpenAIModel('claude-haiku-4-5-20251001')).toBe('gpt-4o-mini')
|
||||
})
|
||||
|
||||
test('maps opus model', () => {
|
||||
expect(resolveOpenAIModel('claude-opus-4-6')).toBe('o3')
|
||||
})
|
||||
|
||||
test('passes through unknown model name', () => {
|
||||
expect(resolveOpenAIModel('some-random-model')).toBe('some-random-model')
|
||||
})
|
||||
|
||||
test('strips [1m] suffix', () => {
|
||||
expect(resolveOpenAIModel('claude-sonnet-4-6[1m]')).toBe('gpt-4o')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Default mapping from Anthropic model names to OpenAI model names.
|
||||
* Used only when ANTHROPIC_DEFAULT_*_MODEL env vars are not set.
|
||||
*/
|
||||
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||
'claude-sonnet-4-20250514': 'gpt-4o',
|
||||
'claude-sonnet-4-5-20250929': 'gpt-4o',
|
||||
'claude-sonnet-4-6': 'gpt-4o',
|
||||
'claude-opus-4-20250514': 'o3',
|
||||
'claude-opus-4-1-20250805': 'o3',
|
||||
'claude-opus-4-5-20251101': 'o3',
|
||||
'claude-opus-4-6': 'o3',
|
||||
'claude-haiku-4-5-20251001': 'gpt-4o-mini',
|
||||
'claude-3-5-haiku-20241022': 'gpt-4o-mini',
|
||||
'claude-3-7-sonnet-20250219': 'gpt-4o',
|
||||
'claude-3-5-sonnet-20241022': 'gpt-4o',
|
||||
}
|
||||
|
||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||
if (/haiku/i.test(model)) return 'haiku'
|
||||
if (/opus/i.test(model)) return 'opus'
|
||||
if (/sonnet/i.test(model)) return 'sonnet'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OpenAI model name for a given Anthropic model.
|
||||
*
|
||||
* Priority:
|
||||
* 1. OPENAI_MODEL env var (override all)
|
||||
* 2. OPENAI_DEFAULT_{FAMILY}_MODEL env var (e.g. OPENAI_DEFAULT_SONNET_MODEL)
|
||||
* 3. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (backward compatibility)
|
||||
* 4. DEFAULT_MODEL_MAP lookup
|
||||
* 5. Pass through original model name
|
||||
*/
|
||||
export function resolveOpenAIModel(anthropicModel: string): string {
|
||||
if (process.env.OPENAI_MODEL) {
|
||||
return process.env.OPENAI_MODEL
|
||||
}
|
||||
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||
|
||||
const family = getModelFamily(cleanModel)
|
||||
if (family) {
|
||||
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const openaiOverride = process.env[openaiEnvVar]
|
||||
if (openaiOverride) return openaiOverride
|
||||
|
||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const anthropicOverride = process.env[anthropicEnvVar]
|
||||
if (anthropicOverride) return anthropicOverride
|
||||
}
|
||||
|
||||
return DEFAULT_MODEL_MAP[cleanModel] ?? cleanModel
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { anthropicMessagesToOpenAI } from '../openaiConvertMessages.js'
|
||||
import type { UserMessage, AssistantMessage } from '../../types/message.js'
|
||||
|
||||
// Helpers to create internal-format messages
|
||||
function makeUserMsg(content: string | any[]): UserMessage {
|
||||
return {
|
||||
type: 'user',
|
||||
uuid: '00000000-0000-0000-0000-000000000000',
|
||||
message: { role: 'user', content },
|
||||
} as UserMessage
|
||||
}
|
||||
|
||||
function makeAssistantMsg(content: string | any[]): AssistantMessage {
|
||||
return {
|
||||
type: 'assistant',
|
||||
uuid: '00000000-0000-0000-0000-000000000001',
|
||||
message: { role: 'assistant', content },
|
||||
} as AssistantMessage
|
||||
}
|
||||
|
||||
describe('anthropicMessagesToOpenAI', () => {
|
||||
test('converts system prompt to system message', () => {
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hello')], [
|
||||
'You are helpful.',
|
||||
] as any)
|
||||
expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' })
|
||||
})
|
||||
|
||||
test('joins multiple system prompt strings', () => {
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [
|
||||
'Part 1',
|
||||
'Part 2',
|
||||
] as any)
|
||||
expect(result[0]).toEqual({ role: 'system', content: 'Part 1\n\nPart 2' })
|
||||
})
|
||||
|
||||
test('skips empty system prompt', () => {
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [] as any)
|
||||
expect(result[0].role).toBe('user')
|
||||
})
|
||||
|
||||
test('converts simple user text message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hello world')],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'user', content: 'hello world' }])
|
||||
})
|
||||
|
||||
test('converts user message with content array', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg([
|
||||
{ type: 'text', text: 'line 1' },
|
||||
{ type: 'text', text: 'line 2' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'user', content: 'line 1\nline 2' }])
|
||||
})
|
||||
|
||||
test('converts assistant message with text', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg('response text')],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'assistant', content: 'response text' }])
|
||||
})
|
||||
|
||||
test('converts assistant message with tool_use', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'text', text: 'Let me help.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me help.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'toolu_123',
|
||||
type: 'function',
|
||||
function: { name: 'bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts tool_result to tool message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('preserves thinking blocks as reasoning_content', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'assistant', content: 'visible response', reasoning_content: 'internal thoughts...' }] as any)
|
||||
})
|
||||
|
||||
test('handles full conversation with tools', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg('list files'),
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_abc',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_abc',
|
||||
content: 'file.txt',
|
||||
},
|
||||
]),
|
||||
],
|
||||
['You are helpful.'] as any,
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0].role).toBe('system')
|
||||
expect(result[1].role).toBe('user')
|
||||
expect(result[2].role).toBe('assistant')
|
||||
expect((result[2] as any).tool_calls).toBeDefined()
|
||||
expect(result[3].role).toBe('tool')
|
||||
})
|
||||
|
||||
test('converts base64 image to image_url', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg([
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'iVBORw0KGgo=',
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts url image to image_url', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/img.png',
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/img.png' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts image-only message without text', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/jpeg',
|
||||
data: '/9j/4AAQ',
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('defaults to image/png when media_type is missing', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
data: 'ABC123',
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect((result[0].content as any[])[0].image_url.url).toBe(
|
||||
'data:image/png;base64,ABC123',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
test('preserves thinking block as reasoning_content when enabled', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'thinking' as const,
|
||||
thinking: 'Let me reason about this...',
|
||||
},
|
||||
{ type: 'text', text: 'The answer is 42.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
// Should have: user, assistant with reasoning_content
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].role).toBe('user')
|
||||
const assistant = result[1] as any
|
||||
expect(assistant.role).toBe('assistant')
|
||||
expect(assistant.content).toBe('The answer is 42.')
|
||||
expect(assistant.reasoning_content).toBe('Let me reason about this...')
|
||||
})
|
||||
|
||||
test('preserves thinking block as reasoning_content even without enableThinking', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
const assistant = result[0] as any
|
||||
expect(assistant.content).toBe('visible response')
|
||||
expect(assistant.reasoning_content).toBe('internal thoughts...')
|
||||
})
|
||||
|
||||
test('preserves reasoning_content with tool_calls in same turn', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg('what is the weather?'),
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'thinking' as const,
|
||||
thinking: 'I need to call the weather tool.',
|
||||
},
|
||||
{ type: 'text', text: '' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_001',
|
||||
name: 'get_weather',
|
||||
input: { location: 'Hangzhou' },
|
||||
},
|
||||
]),
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_001',
|
||||
content: 'Cloudy 7~13°C',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
|
||||
// Find the assistant message
|
||||
const assistants = result.filter(m => m.role === 'assistant')
|
||||
expect(assistants.length).toBe(1)
|
||||
const assistant = assistants[0] as any
|
||||
expect(assistant.reasoning_content).toBe('I need to call the weather tool.')
|
||||
expect(assistant.tool_calls).toBeDefined()
|
||||
expect(assistant.tool_calls[0].function.name).toBe('get_weather')
|
||||
})
|
||||
|
||||
test('always preserves reasoning_content from all turns', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
// Turn 1: user → assistant (with thinking)
|
||||
makeUserMsg('question 1'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Turn 1 reasoning...' },
|
||||
{ type: 'text', text: 'Turn 1 answer' },
|
||||
]),
|
||||
// Turn 2: new user message → reasoning should still be preserved
|
||||
// (DeepSeek requires reasoning_content to be passed back when tool calls are involved)
|
||||
makeUserMsg('question 2'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Turn 2 reasoning...' },
|
||||
{ type: 'text', text: 'Turn 2 answer' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
|
||||
const assistants = result.filter(m => m.role === 'assistant')
|
||||
// Both turns preserve reasoning_content (DeepSeek API requires it for tool calls)
|
||||
expect((assistants[0] as any).reasoning_content).toBe('Turn 1 reasoning...')
|
||||
expect((assistants[0] as any).content).toBe('Turn 1 answer')
|
||||
expect((assistants[1] as any).reasoning_content).toBe('Turn 2 reasoning...')
|
||||
expect((assistants[1] as any).content).toBe('Turn 2 answer')
|
||||
})
|
||||
|
||||
test('preserves reasoning_content in multi-iteration tool call within same turn', () => {
|
||||
// Simulates a full DeepSeek tool call iteration:
|
||||
// user → assistant(thinking+tool_call) → tool_result → assistant(thinking+tool_call) → tool_result → assistant(thinking+text)
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg("tomorrow's weather in Hangzhou"),
|
||||
// Iteration 1: thinking + tool call
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'I need the date first.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_001',
|
||||
name: 'get_date',
|
||||
input: {},
|
||||
},
|
||||
]),
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_001',
|
||||
content: '2026-04-08',
|
||||
},
|
||||
]),
|
||||
// Iteration 2: thinking + tool call
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Now I can get the weather.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_002',
|
||||
name: 'get_weather',
|
||||
input: { location: 'Hangzhou', date: '2026-04-08' },
|
||||
},
|
||||
]),
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_002',
|
||||
content: 'Cloudy 7~13°C',
|
||||
},
|
||||
]),
|
||||
// Iteration 3: thinking + final answer
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'I have the info now.' },
|
||||
{ type: 'text', text: 'Tomorrow will be cloudy, 7-13°C.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
|
||||
const assistants = result.filter(m => m.role === 'assistant')
|
||||
expect(assistants.length).toBe(3)
|
||||
// All iterations within the same turn preserve reasoning
|
||||
expect((assistants[0] as any).reasoning_content).toBe(
|
||||
'I need the date first.',
|
||||
)
|
||||
expect((assistants[1] as any).reasoning_content).toBe(
|
||||
'Now I can get the weather.',
|
||||
)
|
||||
expect((assistants[2] as any).reasoning_content).toBe(
|
||||
'I have the info now.',
|
||||
)
|
||||
})
|
||||
|
||||
test('handles multiple thinking blocks in single assistant message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'First thought.' },
|
||||
{ type: 'thinking' as const, thinking: 'Second thought.' },
|
||||
{ type: 'text', text: 'Final answer.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
const assistant = result.filter(m => m.role === 'assistant')[0] as any
|
||||
expect(assistant.reasoning_content).toBe('First thought.\nSecond thought.')
|
||||
})
|
||||
|
||||
test('skips empty thinking blocks', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: '' },
|
||||
{ type: 'text', text: 'Answer.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
const assistant = result.filter(m => m.role === 'assistant')[0] as any
|
||||
expect(assistant.reasoning_content).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── fix: reorder tool and user messages for OpenAI API compatibility (#168) ──
|
||||
|
||||
test('tool messages come BEFORE user text when mixed in same turn', () => {
|
||||
// OpenAI requires: assistant(tool_calls) → tool → user
|
||||
// Bug: previously user text was emitted before tool messages
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg('run ls'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'tool_use' as const, id: 'toolu_1', name: 'bash', input: { command: 'ls' } },
|
||||
]),
|
||||
makeUserMsg([
|
||||
{ type: 'tool_result' as const, tool_use_id: 'toolu_1', content: 'file.txt' },
|
||||
{ type: 'text' as const, text: 'looks good' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
// Find the tool message and the user text message
|
||||
const toolIdx = result.findIndex(m => m.role === 'tool')
|
||||
const userTextIdx = result.findIndex(
|
||||
m => m.role === 'user' && typeof m.content === 'string' && m.content.includes('looks good'),
|
||||
)
|
||||
expect(toolIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(userTextIdx).toBeGreaterThanOrEqual(0)
|
||||
// Tool MUST come before user text
|
||||
expect(toolIdx).toBeLessThan(userTextIdx)
|
||||
})
|
||||
|
||||
test('tool message immediately follows assistant tool_calls (no user message in between)', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg('do something'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'tool_use' as const, id: 'toolu_2', name: 'bash', input: { command: 'pwd' } },
|
||||
]),
|
||||
makeUserMsg([
|
||||
{ type: 'tool_result' as const, tool_use_id: 'toolu_2', content: '/home/user' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
const assistantIdx = result.findIndex(m => m.role === 'assistant' && (m as any).tool_calls)
|
||||
const toolIdx = result.findIndex(m => m.role === 'tool')
|
||||
expect(assistantIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(toolIdx).toBe(assistantIdx + 1)
|
||||
})
|
||||
|
||||
test('sets content to null when only thinking and tool_calls present', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_001',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
const assistant = result.filter(m => m.role === 'assistant')[0] as any
|
||||
expect(assistant.content).toBeNull()
|
||||
expect(assistant.reasoning_content).toBe('Reasoning only.')
|
||||
expect(assistant.tool_calls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../openaiConvertTools.js'
|
||||
|
||||
describe('anthropicToolsToOpenAI', () => {
|
||||
test('converts basic tool', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('uses empty schema when input_schema missing', () => {
|
||||
const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect(
|
||||
(result[0] as { function: { parameters: unknown } }).function.parameters,
|
||||
).toEqual({ type: 'object', properties: {} })
|
||||
})
|
||||
|
||||
test('strips Anthropic-specific fields', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'bash',
|
||||
description: 'Run bash',
|
||||
input_schema: { type: 'object', properties: {} },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
defer_loading: true,
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect((result[0] as any).cache_control).toBeUndefined()
|
||||
expect((result[0] as any).defer_loading).toBeUndefined()
|
||||
})
|
||||
|
||||
test('handles empty tools array', () => {
|
||||
expect(anthropicToolsToOpenAI([])).toEqual([])
|
||||
})
|
||||
|
||||
test('sanitizes const to enum in tool schema', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'test',
|
||||
description: 'test tool',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mode: { const: 'read' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const props = (result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
expect(props.properties.mode).toEqual({ enum: ['read'] })
|
||||
expect(props.properties.mode.const).toBeUndefined()
|
||||
expect(props.properties.name).toEqual({ type: 'string' })
|
||||
})
|
||||
|
||||
test('sanitizes const in deeply nested schemas', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'deep',
|
||||
description: 'nested const',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
outer: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
inner: { const: 'fixed' },
|
||||
},
|
||||
},
|
||||
},
|
||||
definitions: {
|
||||
MyType: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
field: { const: 42 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const params = (result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
expect(params.properties.outer.properties.inner).toEqual({
|
||||
enum: ['fixed'],
|
||||
})
|
||||
expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] })
|
||||
})
|
||||
|
||||
test('sanitizes const in anyOf/oneOf/allOf', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'union',
|
||||
description: 'union test',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
val: {
|
||||
anyOf: [{ const: 'a' }, { const: 'b' }, { type: 'string' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const anyOf = (
|
||||
(result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
).properties.val.anyOf
|
||||
expect(anyOf[0]).toEqual({ enum: ['a'] })
|
||||
expect(anyOf[1]).toEqual({ enum: ['b'] })
|
||||
expect(anyOf[2]).toEqual({ type: 'string' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('anthropicToolChoiceToOpenAI', () => {
|
||||
test('maps auto', () => {
|
||||
expect(anthropicToolChoiceToOpenAI({ type: 'auto' })).toBe('auto')
|
||||
})
|
||||
|
||||
test('maps any to required', () => {
|
||||
expect(anthropicToolChoiceToOpenAI({ type: 'any' })).toBe('required')
|
||||
})
|
||||
|
||||
test('maps tool to function', () => {
|
||||
const result = anthropicToolChoiceToOpenAI({ type: 'tool', name: 'bash' })
|
||||
expect(result).toEqual({ type: 'function', function: { name: 'bash' } })
|
||||
})
|
||||
|
||||
test('returns undefined for undefined input', () => {
|
||||
expect(anthropicToolChoiceToOpenAI(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns undefined for unknown type', () => {
|
||||
expect(anthropicToolChoiceToOpenAI({ type: 'unknown' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,659 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
|
||||
import { adaptOpenAIStreamToAnthropic } from '../openaiStreamAdapter.js'
|
||||
|
||||
/** Helper to create a mock async iterable from chunk array */
|
||||
function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable<ChatCompletionChunk> {
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
let i = 0
|
||||
return {
|
||||
async next() {
|
||||
if (i >= chunks.length) return { done: true, value: undefined }
|
||||
return { done: false, value: chunks[i++] }
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a minimal ChatCompletionChunk */
|
||||
function makeChunk(overrides: Partial<ChatCompletionChunk> & any = {}): ChatCompletionChunk {
|
||||
return {
|
||||
id: 'chatcmpl-test',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1234567890,
|
||||
model: 'gpt-4o',
|
||||
choices: [],
|
||||
...overrides,
|
||||
} as ChatCompletionChunk
|
||||
}
|
||||
|
||||
/** Collect all emitted Anthropic events from the stream adapter for assertion */
|
||||
async function collectEvents(chunks: ChatCompletionChunk[]) {
|
||||
const events: any[] = []
|
||||
for await (const event of adaptOpenAIStreamToAnthropic(mockStream(chunks), 'gpt-4o')) {
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
describe('adaptOpenAIStreamToAnthropic', () => {
|
||||
test('emits message_start on first chunk', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { role: 'assistant', content: '' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: 'hello' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(events[0].type).toBe('message_start')
|
||||
expect(events[0].message.role).toBe('assistant')
|
||||
expect(events[0].message.model).toBe('gpt-4o')
|
||||
})
|
||||
|
||||
test('converts text content stream', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'Hello' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: ' world' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const types = events.map(e => e.type)
|
||||
expect(types).toContain('message_start')
|
||||
expect(types).toContain('content_block_start')
|
||||
expect(types.filter(t => t === 'content_block_delta').length).toBe(2)
|
||||
expect(types).toContain('content_block_stop')
|
||||
expect(types).toContain('message_delta')
|
||||
expect(types).toContain('message_stop')
|
||||
|
||||
const textDeltas = events.filter(e => e.type === 'content_block_delta') as any[]
|
||||
expect(textDeltas[0].delta.text).toBe('Hello')
|
||||
expect(textDeltas[1].delta.text).toBe(' world')
|
||||
})
|
||||
|
||||
test('converts tool_calls stream', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_abc',
|
||||
type: 'function',
|
||||
function: { name: 'bash', arguments: '' },
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
function: { arguments: '{"comm' },
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
function: { arguments: 'and":"ls"}' },
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const blockStart = events.find(e => e.type === 'content_block_start') as any
|
||||
expect(blockStart.content_block.type).toBe('tool_use')
|
||||
expect(blockStart.content_block.name).toBe('bash')
|
||||
|
||||
const jsonDeltas = events.filter(
|
||||
e => e.type === 'content_block_delta' && e.delta.type === 'input_json_delta',
|
||||
) as any[]
|
||||
const fullArgs = jsonDeltas.map(d => d.delta.partial_json).join('')
|
||||
expect(fullArgs).toBe('{"command":"ls"}')
|
||||
})
|
||||
|
||||
test('maps finish_reason stop to end_turn', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.delta.stop_reason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('forces tool_use stop_reason when tool_calls present but finish_reason is stop', async () => {
|
||||
// Some backends (e.g., certain OpenAI-compatible endpoints) incorrectly
|
||||
// return finish_reason "stop" when they actually made tool calls.
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.delta.stop_reason).toBe('tool_use')
|
||||
})
|
||||
|
||||
test('maps finish_reason tool_calls to tool_use', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{}' } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.delta.stop_reason).toBe('tool_use')
|
||||
})
|
||||
|
||||
test('maps finish_reason length to max_tokens', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'truncated' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'length' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.delta.stop_reason).toBe('max_tokens')
|
||||
})
|
||||
|
||||
test('handles mixed text and tool_calls', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'Thinking...' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'grep', arguments: '{"p":"test"}' } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||
expect(blockStarts.length).toBe(2)
|
||||
expect(blockStarts[0].content_block.type).toBe('text')
|
||||
expect(blockStarts[1].content_block.type).toBe('tool_use')
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinking support (reasoning_content)', () => {
|
||||
test('converts reasoning_content to thinking block', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { reasoning_content: 'Let me analyze this...' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { reasoning_content: ' step by step.' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
// Should have a thinking content block
|
||||
const blockStart = events.find(e => e.type === 'content_block_start') as any
|
||||
expect(blockStart.content_block.type).toBe('thinking')
|
||||
expect(blockStart.content_block.signature).toBe('')
|
||||
|
||||
// Should have thinking_delta events
|
||||
const thinkingDeltas = events.filter(
|
||||
e => e.type === 'content_block_delta' && e.delta.type === 'thinking_delta',
|
||||
) as any[]
|
||||
expect(thinkingDeltas.length).toBe(2)
|
||||
expect(thinkingDeltas[0].delta.thinking).toBe('Let me analyze this...')
|
||||
expect(thinkingDeltas[1].delta.thinking).toBe(' step by step.')
|
||||
})
|
||||
|
||||
test('converts reasoning then content (DeepSeek-style)', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { reasoning_content: 'Thinking about the answer...' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: 'Here is my answer.' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
// Should have two content blocks: thinking + text
|
||||
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||
expect(blockStarts.length).toBe(2)
|
||||
expect(blockStarts[0].content_block.type).toBe('thinking')
|
||||
expect(blockStarts[1].content_block.type).toBe('text')
|
||||
|
||||
// Thinking block should be closed before text block starts
|
||||
const blockStops = events.filter(e => e.type === 'content_block_stop') as any[]
|
||||
expect(blockStops[0].index).toBe(0) // thinking block closed at index 0
|
||||
expect(blockStarts[1].index).toBe(1) // text block starts at index 1
|
||||
|
||||
// Verify text delta
|
||||
const textDelta = events.find(
|
||||
e => e.type === 'content_block_delta' && e.delta.type === 'text_delta',
|
||||
) as any
|
||||
expect(textDelta.delta.text).toBe('Here is my answer.')
|
||||
})
|
||||
|
||||
test('handles reasoning then tool_calls', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { reasoning_content: 'I need to run a command.' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"c":"ls"}' } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||
expect(blockStarts.length).toBe(2)
|
||||
expect(blockStarts[0].content_block.type).toBe('thinking')
|
||||
expect(blockStarts[1].content_block.type).toBe('tool_use')
|
||||
})
|
||||
|
||||
test('thinking block index is 0, text block index is 1', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { reasoning_content: 'reason' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: 'answer' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||
expect(blockStarts[0].index).toBe(0)
|
||||
expect(blockStarts[1].index).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt caching support', () => {
|
||||
test('maps cached_tokens to cache_read_input_tokens', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: 'hi' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 1000,
|
||||
prompt_tokens_details: { cached_tokens: 800 },
|
||||
} as any,
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 1050,
|
||||
prompt_tokens_details: { cached_tokens: 800 },
|
||||
} as any,
|
||||
}),
|
||||
])
|
||||
|
||||
const msgStart = events.find(e => e.type === 'message_start') as any
|
||||
expect(msgStart.message.usage.cache_read_input_tokens).toBe(800)
|
||||
expect(msgStart.message.usage.input_tokens).toBe(1000)
|
||||
})
|
||||
|
||||
test('defaults cache_read_input_tokens to 0 when no cached_tokens', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
|
||||
usage: { prompt_tokens: 100, completion_tokens: 0, total_tokens: 100 },
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const msgStart = events.find(e => e.type === 'message_start') as any
|
||||
expect(msgStart.message.usage.cache_read_input_tokens).toBe(0)
|
||||
expect(msgStart.message.usage.cache_creation_input_tokens).toBe(0)
|
||||
})
|
||||
|
||||
test('updates cached_tokens from later chunks', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
|
||||
usage: {
|
||||
prompt_tokens: 500,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 500,
|
||||
} as any,
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
usage: {
|
||||
prompt_tokens: 500,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 510,
|
||||
prompt_tokens_details: { cached_tokens: 300 },
|
||||
} as any,
|
||||
}),
|
||||
])
|
||||
|
||||
const msgStart = events.find(e => e.type === 'message_start') as any
|
||||
// First chunk had no cached_tokens, so initially 0
|
||||
// But the message_start usage reflects the first chunk's data
|
||||
expect(msgStart.message.usage.cache_read_input_tokens).toBe(0)
|
||||
expect(msgStart.message.usage.input_tokens).toBe(500)
|
||||
})
|
||||
|
||||
test('captures output_tokens and input_tokens from trailing chunk sent after finish_reason', async () => {
|
||||
// Many OpenAI-compatible endpoints (e.g. DeepSeek) send usage in a separate
|
||||
// final chunk AFTER the finish_reason chunk, with choices: [].
|
||||
// message_delta must carry both input_tokens and output_tokens so that
|
||||
// queryModelOpenAI's spread can override the zeros from message_start — which is
|
||||
// emitted before the trailing chunk and always has input_tokens=0.
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'hello' }, finish_reason: null }],
|
||||
}),
|
||||
// finish_reason chunk — usage not yet available
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
// trailing usage-only chunk (choices: [])
|
||||
makeChunk({
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 123, completion_tokens: 45, total_tokens: 168 },
|
||||
}),
|
||||
])
|
||||
|
||||
// message_start emits on the first chunk before trailing usage arrives
|
||||
const msgStart = events.find(e => e.type === 'message_start') as any
|
||||
expect(msgStart.message.usage.input_tokens).toBe(0)
|
||||
|
||||
// message_delta is emitted after stream loop ends with final real values
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.usage.input_tokens).toBe(123)
|
||||
expect(msgDelta.usage.output_tokens).toBe(45)
|
||||
expect(msgDelta.delta.stop_reason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('captures input_tokens from trailing chunk (used by tokenCountWithEstimation for autocompact)', async () => {
|
||||
// input_tokens is the dominant term in tokenCountWithEstimation. Without it,
|
||||
// getTokenCountFromUsage returns only output_tokens (~100-700), which is far below
|
||||
// the autocompact threshold (~33k), so compaction never fires.
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 800, completion_tokens: 200, total_tokens: 1000 },
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.usage.input_tokens).toBe(800)
|
||||
expect(msgDelta.usage.output_tokens).toBe(200)
|
||||
})
|
||||
|
||||
test('trailing usage chunk with tool_calls: stop_reason stays tool_use', async () => {
|
||||
// Verifies that deferring message_delta does not break stop_reason mapping
|
||||
// when the model made tool calls and usage arrives in a trailing chunk.
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, id: 'call_x', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||
}),
|
||||
// trailing usage-only chunk
|
||||
makeChunk({
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 500, completion_tokens: 30, total_tokens: 530 },
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.delta.stop_reason).toBe('tool_use')
|
||||
expect(msgDelta.usage.output_tokens).toBe(30)
|
||||
})
|
||||
|
||||
test('message_delta always comes before message_stop', async () => {
|
||||
// Verifies event ordering is preserved after deferring to post-loop emission.
|
||||
const events = await collectEvents([
|
||||
makeChunk({ choices: [{ index: 0, delta: { content: 'x' }, finish_reason: null }] }),
|
||||
makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }),
|
||||
makeChunk({ choices: [], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }),
|
||||
])
|
||||
|
||||
const types = events.map(e => e.type)
|
||||
const deltaIdx = types.lastIndexOf('message_delta')
|
||||
const stopIdx = types.lastIndexOf('message_stop')
|
||||
expect(deltaIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(stopIdx).toBeGreaterThan(deltaIdx)
|
||||
})
|
||||
|
||||
// ── cache_read_input_tokens in message_delta (the core bug fix) ──────────
|
||||
|
||||
test('message_delta carries cache_read_input_tokens from trailing usage chunk', async () => {
|
||||
// Real-world case: DeepSeek-V3 returns cached_tokens=19904
|
||||
// in a trailing chunk with choices:[]. Previously message_delta only carried
|
||||
// input_tokens and output_tokens, so cache_read_input_tokens stayed 0 after
|
||||
// queryModelOpenAI's spread — even though cachedTokens was captured internally.
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
// trailing usage chunk matching the observed server response format
|
||||
makeChunk({
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: 30011,
|
||||
completion_tokens: 190,
|
||||
total_tokens: 30201,
|
||||
prompt_tokens_details: { audio_tokens: 0, cached_tokens: 19904 },
|
||||
} as any,
|
||||
}),
|
||||
])
|
||||
|
||||
// message_start is emitted before trailing chunk — cache fields are 0
|
||||
const msgStart = events.find(e => e.type === 'message_start') as any
|
||||
expect(msgStart.message.usage.cache_read_input_tokens).toBe(0)
|
||||
|
||||
// message_delta carries the real values from the trailing chunk
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.usage.input_tokens).toBe(30011)
|
||||
expect(msgDelta.usage.output_tokens).toBe(190)
|
||||
expect(msgDelta.usage.cache_read_input_tokens).toBe(19904)
|
||||
expect(msgDelta.usage.cache_creation_input_tokens).toBe(0)
|
||||
})
|
||||
|
||||
test('cache_read_input_tokens=0 in message_delta when cached_tokens is absent', async () => {
|
||||
// Non-caching requests should still have the field present and zero.
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 },
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.usage.cache_read_input_tokens).toBe(0)
|
||||
expect(msgDelta.usage.cache_creation_input_tokens).toBe(0)
|
||||
})
|
||||
|
||||
test('cache_read_input_tokens=0 in message_delta when cached_tokens is 0', async () => {
|
||||
// Explicit cached_tokens:0 should not be treated differently from absent.
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_tokens: 500,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 550,
|
||||
prompt_tokens_details: { cached_tokens: 0 },
|
||||
} as any,
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.usage.cache_read_input_tokens).toBe(0)
|
||||
})
|
||||
|
||||
test('cache_read_input_tokens updated when cached_tokens arrives in same chunk as finish_reason', async () => {
|
||||
// Some endpoints send usage in the finish_reason chunk instead of a trailing chunk.
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'result' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
usage: {
|
||||
prompt_tokens: 2000,
|
||||
completion_tokens: 100,
|
||||
total_tokens: 2100,
|
||||
prompt_tokens_details: { cached_tokens: 1500 },
|
||||
} as any,
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.usage.cache_read_input_tokens).toBe(1500)
|
||||
expect(msgDelta.usage.input_tokens).toBe(2000)
|
||||
expect(msgDelta.usage.output_tokens).toBe(100)
|
||||
})
|
||||
})
|
||||
266
packages/@ant/model-provider/src/shared/openaiConvertMessages.ts
Normal file
266
packages/@ant/model-provider/src/shared/openaiConvertMessages.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import type {
|
||||
BetaContentBlockParam,
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolUseBlock,
|
||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type {
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionToolMessageParam,
|
||||
ChatCompletionUserMessageParam,
|
||||
} from 'openai/resources/chat/completions/completions.mjs'
|
||||
import type { AssistantMessage, UserMessage } from '../types/message.js'
|
||||
import type { SystemPrompt } from '../types/systemPrompt.js'
|
||||
|
||||
export interface ConvertMessagesOptions {
|
||||
/** When true, preserve thinking blocks as reasoning_content on assistant messages
|
||||
* (required for DeepSeek thinking mode with tool calls). */
|
||||
enableThinking?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal (UserMessage | AssistantMessage)[] to OpenAI-format messages.
|
||||
*
|
||||
* Key conversions:
|
||||
* - system prompt → role: "system" message prepended
|
||||
* - tool_use blocks → tool_calls[] on assistant message
|
||||
* - tool_result blocks → role: "tool" messages
|
||||
* - thinking blocks → preserved as reasoning_content (DeepSeek requires passing it back)
|
||||
* - cache_control → stripped
|
||||
*/
|
||||
export function anthropicMessagesToOpenAI(
|
||||
messages: (UserMessage | AssistantMessage)[],
|
||||
systemPrompt: SystemPrompt,
|
||||
// options retained for API compatibility; thinking blocks are now always preserved
|
||||
_options?: ConvertMessagesOptions,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const result: ChatCompletionMessageParam[] = []
|
||||
|
||||
// Prepend system prompt as system message
|
||||
const systemText = systemPromptToText(systemPrompt)
|
||||
if (systemText) {
|
||||
result.push({
|
||||
role: 'system',
|
||||
content: systemText,
|
||||
} satisfies ChatCompletionSystemMessageParam)
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
result.push(...convertInternalUserMessage(msg))
|
||||
break
|
||||
case 'assistant':
|
||||
result.push(...convertInternalAssistantMessage(msg))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
||||
if (!systemPrompt || systemPrompt.length === 0) return ''
|
||||
return systemPrompt.filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
function convertInternalUserMessage(
|
||||
msg: UserMessage,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const result: ChatCompletionMessageParam[] = []
|
||||
const content = msg.message.content
|
||||
|
||||
if (typeof content === 'string') {
|
||||
result.push({
|
||||
role: 'user',
|
||||
content,
|
||||
} satisfies ChatCompletionUserMessageParam)
|
||||
} else if (Array.isArray(content)) {
|
||||
const textParts: string[] = []
|
||||
const toolResults: BetaToolResultBlockParam[] = []
|
||||
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> =
|
||||
[]
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === 'string') {
|
||||
textParts.push(block)
|
||||
} else if (block.type === 'text') {
|
||||
textParts.push(block.text)
|
||||
} else if (block.type === 'tool_result') {
|
||||
toolResults.push(block as BetaToolResultBlockParam)
|
||||
} else if (block.type === 'image') {
|
||||
const imagePart = convertImageBlockToOpenAI(
|
||||
block as unknown as Record<string, unknown>,
|
||||
)
|
||||
if (imagePart) {
|
||||
imageParts.push(imagePart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CRITICAL: tool messages must come BEFORE any user message in the result.
|
||||
// OpenAI API requires that a tool message immediately follows the assistant
|
||||
// message with tool_calls. If we emit a user message first, the API will
|
||||
// reject the request with "insufficient tool messages following tool_calls".
|
||||
for (const tr of toolResults) {
|
||||
result.push(convertToolResult(tr))
|
||||
}
|
||||
|
||||
// 如果有图片,构建多模态 content 数组
|
||||
if (imageParts.length > 0) {
|
||||
const multiContent: Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image_url'; image_url: { url: string } }
|
||||
> = []
|
||||
if (textParts.length > 0) {
|
||||
multiContent.push({ type: 'text', text: textParts.join('\n') })
|
||||
}
|
||||
multiContent.push(...imageParts)
|
||||
result.push({
|
||||
role: 'user',
|
||||
content: multiContent,
|
||||
} satisfies ChatCompletionUserMessageParam)
|
||||
} else if (textParts.length > 0) {
|
||||
result.push({
|
||||
role: 'user',
|
||||
content: textParts.join('\n'),
|
||||
} satisfies ChatCompletionUserMessageParam)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function convertToolResult(
|
||||
block: BetaToolResultBlockParam,
|
||||
): ChatCompletionToolMessageParam {
|
||||
let content: string
|
||||
if (typeof block.content === 'string') {
|
||||
content = block.content
|
||||
} else if (Array.isArray(block.content)) {
|
||||
content = block.content
|
||||
.map(c => {
|
||||
if (typeof c === 'string') return c
|
||||
if ('text' in c) return c.text
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
} else {
|
||||
content = ''
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'tool',
|
||||
tool_call_id: block.tool_use_id,
|
||||
content,
|
||||
} satisfies ChatCompletionToolMessageParam
|
||||
}
|
||||
|
||||
function convertInternalAssistantMessage(
|
||||
msg: AssistantMessage,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const content = msg.message.content
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return [
|
||||
{
|
||||
role: 'assistant',
|
||||
content,
|
||||
} satisfies ChatCompletionAssistantMessageParam,
|
||||
]
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
} satisfies ChatCompletionAssistantMessageParam,
|
||||
]
|
||||
}
|
||||
|
||||
const textParts: string[] = []
|
||||
const toolCalls: NonNullable<
|
||||
ChatCompletionAssistantMessageParam['tool_calls']
|
||||
> = []
|
||||
const reasoningParts: string[] = []
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === 'string') {
|
||||
textParts.push(block)
|
||||
} else if (block.type === 'text') {
|
||||
textParts.push(block.text)
|
||||
} else if (block.type === 'tool_use') {
|
||||
const tu = block as BetaToolUseBlock
|
||||
toolCalls.push({
|
||||
id: tu.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tu.name,
|
||||
arguments:
|
||||
typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input),
|
||||
},
|
||||
})
|
||||
} else if (block.type === 'thinking') {
|
||||
// DeepSeek thinking mode: always preserve reasoning_content.
|
||||
// DeepSeek requires reasoning_content to be passed back in subsequent requests,
|
||||
// especially when tool calls are involved (returns 400 if missing).
|
||||
const thinkingText = (block as unknown as Record<string, unknown>)
|
||||
.thinking
|
||||
if (typeof thinkingText === 'string' && thinkingText) {
|
||||
reasoningParts.push(thinkingText)
|
||||
}
|
||||
}
|
||||
// Skip redacted_thinking, server_tool_use, etc.
|
||||
}
|
||||
|
||||
const result: ChatCompletionAssistantMessageParam = {
|
||||
role: 'assistant',
|
||||
content: textParts.length > 0 ? textParts.join('\n') : null,
|
||||
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
||||
...(reasoningParts.length > 0 && {
|
||||
reasoning_content: reasoningParts.join('\n'),
|
||||
}),
|
||||
}
|
||||
|
||||
return [result]
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Anthropic image 块转换为 OpenAI image_url 格式。
|
||||
*
|
||||
* Anthropic 格式: { type: "image", source: { type: "base64", media_type: "image/png", data: "..." } }
|
||||
* OpenAI 格式: { type: "image_url", image_url: { url: "data:image/png;base64,..." } }
|
||||
*/
|
||||
function convertImageBlockToOpenAI(
|
||||
block: Record<string, unknown>,
|
||||
): { type: 'image_url'; image_url: { url: string } } | null {
|
||||
const source = block.source as Record<string, unknown> | undefined
|
||||
if (!source) return null
|
||||
|
||||
if (source.type === 'base64' && typeof source.data === 'string') {
|
||||
const mediaType = (source.media_type as string) || 'image/png'
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${mediaType};base64,${source.data}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// url 类型的图片直接传递
|
||||
if (source.type === 'url' && typeof source.url === 'string') {
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: source.url,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
150
packages/@ant/model-provider/src/shared/openaiConvertTools.ts
Normal file
150
packages/@ant/model-provider/src/shared/openaiConvertTools.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { ChatCompletionTool } from 'openai/resources/chat/completions/completions.mjs'
|
||||
|
||||
/**
|
||||
* Convert Anthropic tool schemas to OpenAI function calling format.
|
||||
*
|
||||
* Anthropic: { name, description, input_schema }
|
||||
* OpenAI: { type: "function", function: { name, description, parameters } }
|
||||
*
|
||||
* Anthropic-specific fields (cache_control, defer_loading, etc.) are stripped.
|
||||
*/
|
||||
export function anthropicToolsToOpenAI(
|
||||
tools: BetaToolUnion[],
|
||||
): ChatCompletionTool[] {
|
||||
return tools
|
||||
.filter(tool => {
|
||||
// Only convert standard tools (skip server tools like computer_use, etc.)
|
||||
const toolType = (tool as unknown as { type?: string }).type
|
||||
return (
|
||||
tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||
)
|
||||
})
|
||||
.map(tool => {
|
||||
// Handle the various tool shapes from Anthropic SDK
|
||||
const anyTool = tool as unknown as Record<string, unknown>
|
||||
const name = (anyTool.name as string) || ''
|
||||
const description = (anyTool.description as string) || ''
|
||||
const inputSchema = anyTool.input_schema as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name,
|
||||
description,
|
||||
parameters: sanitizeJsonSchema(
|
||||
inputSchema || { type: 'object', properties: {} },
|
||||
),
|
||||
},
|
||||
} satisfies ChatCompletionTool
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sanitize a JSON Schema for OpenAI-compatible providers.
|
||||
*
|
||||
* Many OpenAI-compatible endpoints (Ollama, DeepSeek, vLLM, etc.) do not
|
||||
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
|
||||
* single-element array, which is semantically equivalent.
|
||||
*/
|
||||
function sanitizeJsonSchema(
|
||||
schema: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!schema || typeof schema !== 'object') return schema
|
||||
|
||||
const result = { ...schema }
|
||||
|
||||
// Convert `const` → `enum: [value]`
|
||||
if ('const' in result) {
|
||||
result.enum = [result.const]
|
||||
delete result.const
|
||||
}
|
||||
|
||||
// Recursively process nested schemas
|
||||
const objectKeys = [
|
||||
'properties',
|
||||
'definitions',
|
||||
'$defs',
|
||||
'patternProperties',
|
||||
] as const
|
||||
for (const key of objectKeys) {
|
||||
const nested = result[key]
|
||||
if (nested && typeof nested === 'object') {
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
|
||||
sanitized[k] =
|
||||
v && typeof v === 'object'
|
||||
? sanitizeJsonSchema(v as Record<string, unknown>)
|
||||
: v
|
||||
}
|
||||
result[key] = sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process single-schema keys
|
||||
const singleKeys = [
|
||||
'items',
|
||||
'additionalProperties',
|
||||
'not',
|
||||
'if',
|
||||
'then',
|
||||
'else',
|
||||
'contains',
|
||||
'propertyNames',
|
||||
] as const
|
||||
for (const key of singleKeys) {
|
||||
const nested = result[key]
|
||||
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
||||
result[key] = sanitizeJsonSchema(nested as Record<string, unknown>)
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process array-of-schemas keys
|
||||
const arrayKeys = ['anyOf', 'oneOf', 'allOf'] as const
|
||||
for (const key of arrayKeys) {
|
||||
const nested = result[key]
|
||||
if (Array.isArray(nested)) {
|
||||
result[key] = nested.map(item =>
|
||||
item && typeof item === 'object'
|
||||
? sanitizeJsonSchema(item as Record<string, unknown>)
|
||||
: item,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Anthropic tool_choice to OpenAI tool_choice format.
|
||||
*
|
||||
* Anthropic → OpenAI:
|
||||
* - { type: "auto" } → "auto"
|
||||
* - { type: "any" } → "required"
|
||||
* - { type: "tool", name } → { type: "function", function: { name } }
|
||||
* - undefined → undefined (use provider default)
|
||||
*/
|
||||
export function anthropicToolChoiceToOpenAI(
|
||||
toolChoice: unknown,
|
||||
): string | { type: 'function'; function: { name: string } } | undefined {
|
||||
if (!toolChoice || typeof toolChoice !== 'object') return undefined
|
||||
|
||||
const tc = toolChoice as Record<string, unknown>
|
||||
const type = tc.type as string
|
||||
|
||||
switch (type) {
|
||||
case 'auto':
|
||||
return 'auto'
|
||||
case 'any':
|
||||
return 'required'
|
||||
case 'tool':
|
||||
return {
|
||||
type: 'function',
|
||||
function: { name: tc.name as string },
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
331
packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts
Normal file
331
packages/@ant/model-provider/src/shared/openaiStreamAdapter.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
/**
|
||||
* Adapt an OpenAI streaming response into Anthropic BetaRawMessageStreamEvent.
|
||||
*
|
||||
* Mapping:
|
||||
* First chunk → message_start
|
||||
* delta.reasoning_content → content_block_start(thinking) + thinking_delta + content_block_stop
|
||||
* delta.content → content_block_start(text) + text_delta + content_block_stop
|
||||
* delta.tool_calls → content_block_start(tool_use) + input_json_delta + content_block_stop
|
||||
* finish_reason → message_delta(stop_reason) + message_stop
|
||||
*
|
||||
* Usage field mapping (OpenAI → Anthropic):
|
||||
* prompt_tokens → input_tokens
|
||||
* completion_tokens → output_tokens
|
||||
* prompt_tokens_details.cached_tokens → cache_read_input_tokens
|
||||
* (no OpenAI equivalent) → cache_creation_input_tokens (always 0)
|
||||
*
|
||||
* All four fields are emitted in the post-loop message_delta (not message_start)
|
||||
* so that trailing usage chunks (sent after finish_reason by some
|
||||
* OpenAI-compatible endpoints) are fully captured before the final counts are reported.
|
||||
*
|
||||
* Thinking support:
|
||||
* DeepSeek and compatible providers send `delta.reasoning_content` for chain-of-thought.
|
||||
* This is mapped to Anthropic's `thinking` content blocks:
|
||||
* content_block_start: { type: 'thinking', thinking: '', signature: '' }
|
||||
* content_block_delta: { type: 'thinking_delta', thinking: '...' }
|
||||
*
|
||||
* Prompt caching:
|
||||
* OpenAI reports cached tokens in usage.prompt_tokens_details.cached_tokens.
|
||||
* This is mapped to Anthropic's cache_read_input_tokens.
|
||||
*/
|
||||
export async function* adaptOpenAIStreamToAnthropic(
|
||||
stream: AsyncIterable<ChatCompletionChunk>,
|
||||
model: string,
|
||||
): AsyncGenerator<BetaRawMessageStreamEvent, void> {
|
||||
const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
|
||||
let started = false
|
||||
let currentContentIndex = -1
|
||||
|
||||
// Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments }
|
||||
const toolBlocks = new Map<
|
||||
number,
|
||||
{ contentIndex: number; id: string; name: string; arguments: string }
|
||||
>()
|
||||
|
||||
// Track thinking block state
|
||||
let thinkingBlockOpen = false
|
||||
|
||||
// Track text block state
|
||||
let textBlockOpen = false
|
||||
|
||||
// Track usage — all four Anthropic fields, populated from OpenAI usage fields:
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cachedReadTokens = 0
|
||||
|
||||
// Track all open content block indices (for cleanup)
|
||||
const openBlockIndices = new Set<number>()
|
||||
|
||||
// Deferred finish state
|
||||
let pendingFinishReason: string | null = null
|
||||
let pendingHasToolCalls = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const choice = chunk.choices?.[0]
|
||||
const delta = choice?.delta
|
||||
|
||||
// Extract usage from any chunk that carries it.
|
||||
if (chunk.usage) {
|
||||
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
||||
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
||||
const details = (chunk.usage as any).prompt_tokens_details
|
||||
if (details?.cached_tokens != null) {
|
||||
cachedReadTokens = details.cached_tokens
|
||||
}
|
||||
}
|
||||
|
||||
// Emit message_start on first chunk
|
||||
if (!started) {
|
||||
started = true
|
||||
|
||||
yield {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: cachedReadTokens,
|
||||
},
|
||||
},
|
||||
} as unknown as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
// Skip chunks that carry only usage data (no delta content)
|
||||
if (!delta) continue
|
||||
|
||||
// Handle reasoning_content → Anthropic thinking block
|
||||
const reasoningContent = (delta as any).reasoning_content
|
||||
if (reasoningContent != null && reasoningContent !== '') {
|
||||
if (!thinkingBlockOpen) {
|
||||
currentContentIndex++
|
||||
thinkingBlockOpen = true
|
||||
openBlockIndices.add(currentContentIndex)
|
||||
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: currentContentIndex,
|
||||
content_block: {
|
||||
type: 'thinking',
|
||||
thinking: '',
|
||||
signature: '',
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: currentContentIndex,
|
||||
delta: {
|
||||
type: 'thinking_delta',
|
||||
thinking: reasoningContent,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
// Handle text content
|
||||
if (delta.content != null && delta.content !== '') {
|
||||
if (!textBlockOpen) {
|
||||
// Close thinking block if still open
|
||||
if (thinkingBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: currentContentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(currentContentIndex)
|
||||
thinkingBlockOpen = false
|
||||
}
|
||||
|
||||
currentContentIndex++
|
||||
textBlockOpen = true
|
||||
openBlockIndices.add(currentContentIndex)
|
||||
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: currentContentIndex,
|
||||
content_block: {
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: currentContentIndex,
|
||||
delta: {
|
||||
type: 'text_delta',
|
||||
text: delta.content,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
if (delta.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const tcIndex = tc.index
|
||||
|
||||
if (!toolBlocks.has(tcIndex)) {
|
||||
// Close thinking block if open
|
||||
if (thinkingBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: currentContentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(currentContentIndex)
|
||||
thinkingBlockOpen = false
|
||||
}
|
||||
|
||||
// Close text block if open
|
||||
if (textBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: currentContentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(currentContentIndex)
|
||||
textBlockOpen = false
|
||||
}
|
||||
|
||||
// Start new tool_use block
|
||||
currentContentIndex++
|
||||
const toolId =
|
||||
tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
const toolName = tc.function?.name || ''
|
||||
|
||||
toolBlocks.set(tcIndex, {
|
||||
contentIndex: currentContentIndex,
|
||||
id: toolId,
|
||||
name: toolName,
|
||||
arguments: '',
|
||||
})
|
||||
openBlockIndices.add(currentContentIndex)
|
||||
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: currentContentIndex,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: toolId,
|
||||
name: toolName,
|
||||
input: {},
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
// Stream argument fragments
|
||||
const argFragment = tc.function?.arguments
|
||||
if (argFragment) {
|
||||
toolBlocks.get(tcIndex)!.arguments += argFragment
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: toolBlocks.get(tcIndex)!.contentIndex,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: argFragment,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finish
|
||||
if (choice?.finish_reason) {
|
||||
if (thinkingBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: currentContentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(currentContentIndex)
|
||||
thinkingBlockOpen = false
|
||||
}
|
||||
|
||||
if (textBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: currentContentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(currentContentIndex)
|
||||
textBlockOpen = false
|
||||
}
|
||||
|
||||
for (const [, block] of toolBlocks) {
|
||||
if (openBlockIndices.has(block.contentIndex)) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: block.contentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(block.contentIndex)
|
||||
}
|
||||
}
|
||||
|
||||
pendingFinishReason = choice.finish_reason
|
||||
pendingHasToolCalls = toolBlocks.size > 0
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: close any remaining open blocks
|
||||
for (const idx of openBlockIndices) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: idx,
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
// Emit message_delta + message_stop
|
||||
if (pendingFinishReason !== null) {
|
||||
const stopReason =
|
||||
pendingFinishReason === 'length'
|
||||
? 'max_tokens'
|
||||
: pendingHasToolCalls
|
||||
? 'tool_use'
|
||||
: mapFinishReason(pendingFinishReason)
|
||||
|
||||
yield {
|
||||
type: 'message_delta',
|
||||
delta: {
|
||||
stop_reason: stopReason,
|
||||
stop_sequence: null,
|
||||
},
|
||||
usage: {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
cache_read_input_tokens: cachedReadTokens,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
|
||||
yield {
|
||||
type: 'message_stop',
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OpenAI finish_reason to Anthropic stop_reason.
|
||||
*/
|
||||
function mapFinishReason(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'stop':
|
||||
return 'end_turn'
|
||||
case 'tool_calls':
|
||||
return 'tool_use'
|
||||
case 'length':
|
||||
return 'max_tokens'
|
||||
case 'content_filter':
|
||||
return 'end_turn'
|
||||
default:
|
||||
return 'end_turn'
|
||||
}
|
||||
}
|
||||
54
packages/@ant/model-provider/src/types/errors.ts
Normal file
54
packages/@ant/model-provider/src/types/errors.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Error type constants for the model provider package.
|
||||
// Error string constants extracted from src/services/api/errors.ts.
|
||||
// The full error handling functions remain in the main project (Phase 4).
|
||||
|
||||
export const API_ERROR_MESSAGE_PREFIX = 'API Error'
|
||||
|
||||
export const PROMPT_TOO_LONG_ERROR_MESSAGE = 'Prompt is too long'
|
||||
|
||||
export const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE = 'Credit balance is too low'
|
||||
export const INVALID_API_KEY_ERROR_MESSAGE = 'Not logged in · Please run /login'
|
||||
export const INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL =
|
||||
'Invalid API key · Fix external API key'
|
||||
export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH =
|
||||
'Your ANTHROPIC_API_KEY belongs to a disabled organization · Unset the environment variable to use your subscription instead'
|
||||
export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY =
|
||||
'Your ANTHROPIC_API_KEY belongs to a disabled organization · Update or unset the environment variable'
|
||||
export const TOKEN_REVOKED_ERROR_MESSAGE =
|
||||
'OAuth token revoked · Please run /login'
|
||||
export const CCR_AUTH_ERROR_MESSAGE =
|
||||
'Authentication error · This may be a temporary network issue, please try again'
|
||||
export const REPEATED_529_ERROR_MESSAGE = 'Repeated 529 Overloaded errors'
|
||||
export const CUSTOM_OFF_SWITCH_MESSAGE =
|
||||
'Opus is experiencing high load, please use /model to switch to Sonnet'
|
||||
export const API_TIMEOUT_ERROR_MESSAGE = 'Request timed out'
|
||||
export const OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE =
|
||||
'Your account does not have access to Claude Code. Please run /login.'
|
||||
|
||||
/** Error classification types returned by classifyAPIError */
|
||||
export type APIErrorClassification =
|
||||
| 'aborted'
|
||||
| 'api_timeout'
|
||||
| 'repeated_529'
|
||||
| 'capacity_off_switch'
|
||||
| 'rate_limit'
|
||||
| 'server_overload'
|
||||
| 'prompt_too_long'
|
||||
| 'pdf_too_large'
|
||||
| 'pdf_password_protected'
|
||||
| 'image_too_large'
|
||||
| 'tool_use_mismatch'
|
||||
| 'unexpected_tool_result'
|
||||
| 'duplicate_tool_use_id'
|
||||
| 'invalid_model'
|
||||
| 'credit_balance_low'
|
||||
| 'invalid_api_key'
|
||||
| 'token_revoked'
|
||||
| 'oauth_org_not_allowed'
|
||||
| 'auth_error'
|
||||
| 'bedrock_model_access'
|
||||
| 'server_error'
|
||||
| 'client_error'
|
||||
| 'ssl_cert_error'
|
||||
| 'connection_error'
|
||||
| 'unknown'
|
||||
6
packages/@ant/model-provider/src/types/index.ts
Normal file
6
packages/@ant/model-provider/src/types/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Type definitions for @ant/model-provider
|
||||
|
||||
export * from './message.js'
|
||||
export * from './usage.js'
|
||||
export * from './errors.js'
|
||||
export * from './systemPrompt.js'
|
||||
129
packages/@ant/model-provider/src/types/message.ts
Normal file
129
packages/@ant/model-provider/src/types/message.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// Core message types for the model provider package.
|
||||
// Moved from src/types/message.ts to decouple the API layer from the main project.
|
||||
|
||||
import type { UUID } from 'crypto'
|
||||
import type {
|
||||
ContentBlockParam,
|
||||
ContentBlock,
|
||||
} from '@anthropic-ai/sdk/resources/index.mjs'
|
||||
import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
|
||||
/**
|
||||
* Base message type with discriminant `type` field and common properties.
|
||||
* Individual message subtypes (UserMessage, AssistantMessage, etc.) extend
|
||||
* this with narrower `type` literals and additional fields.
|
||||
*/
|
||||
export type MessageType = 'user' | 'assistant' | 'system' | 'attachment' | 'progress' | 'grouped_tool_use' | 'collapsed_read_search'
|
||||
|
||||
/** A single content element inside message.content arrays. */
|
||||
export type ContentItem = ContentBlockParam | ContentBlock
|
||||
|
||||
export type MessageContent = string | ContentBlockParam[] | ContentBlock[]
|
||||
|
||||
/**
|
||||
* Typed content array — used in narrowed message subtypes so that
|
||||
* `message.content[0]` resolves to `ContentItem` instead of
|
||||
* `string | ContentBlockParam | ContentBlock`.
|
||||
*/
|
||||
export type TypedMessageContent = ContentItem[]
|
||||
|
||||
export type Message = {
|
||||
type: MessageType
|
||||
uuid: UUID
|
||||
isMeta?: boolean
|
||||
isCompactSummary?: boolean
|
||||
toolUseResult?: unknown
|
||||
isVisibleInTranscriptOnly?: boolean
|
||||
attachment?: { type: string; toolUseID?: string; [key: string]: unknown; addedNames: string[]; addedLines: string[]; removedNames: string[] }
|
||||
message?: {
|
||||
role?: string
|
||||
id?: string
|
||||
content?: MessageContent
|
||||
usage?: BetaUsage | Record<string, unknown>
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type AssistantMessage = Message & {
|
||||
type: 'assistant'
|
||||
message: NonNullable<Message['message']>
|
||||
}
|
||||
export type AttachmentMessage<T = { type: string; [key: string]: unknown }> = Message & { type: 'attachment'; attachment: T }
|
||||
export type ProgressMessage<T = unknown> = Message & { type: 'progress'; data: T }
|
||||
export type SystemLocalCommandMessage = Message & { type: 'system' }
|
||||
export type SystemMessage = Message & { type: 'system' }
|
||||
export type UserMessage = Message & {
|
||||
type: 'user'
|
||||
message: NonNullable<Message['message']>
|
||||
imagePasteIds?: number[]
|
||||
}
|
||||
export type NormalizedUserMessage = UserMessage
|
||||
export type RequestStartEvent = { type: string; [key: string]: unknown }
|
||||
export type StreamEvent = { type: string; [key: string]: unknown }
|
||||
export type SystemCompactBoundaryMessage = Message & {
|
||||
type: 'system'
|
||||
compactMetadata: {
|
||||
preservedSegment?: {
|
||||
headUuid: UUID
|
||||
tailUuid: UUID
|
||||
anchorUuid: UUID
|
||||
[key: string]: unknown
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
export type TombstoneMessage = Message
|
||||
export type ToolUseSummaryMessage = Message
|
||||
export type MessageOrigin = string
|
||||
export type CompactMetadata = Record<string, unknown>
|
||||
export type SystemAPIErrorMessage = Message & { type: 'system' }
|
||||
export type SystemFileSnapshotMessage = Message & { type: 'system' }
|
||||
export type NormalizedAssistantMessage<T = unknown> = AssistantMessage
|
||||
export type NormalizedMessage = Message
|
||||
export type PartialCompactDirection = string
|
||||
|
||||
export type StopHookInfo = {
|
||||
command?: string
|
||||
durationMs?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SystemAgentsKilledMessage = Message & { type: 'system' }
|
||||
export type SystemApiMetricsMessage = Message & { type: 'system' }
|
||||
export type SystemAwaySummaryMessage = Message & { type: 'system' }
|
||||
export type SystemBridgeStatusMessage = Message & { type: 'system' }
|
||||
export type SystemInformationalMessage = Message & { type: 'system' }
|
||||
export type SystemMemorySavedMessage = Message & { type: 'system' }
|
||||
export type SystemMessageLevel = string
|
||||
export type SystemMicrocompactBoundaryMessage = Message & { type: 'system' }
|
||||
export type SystemPermissionRetryMessage = Message & { type: 'system' }
|
||||
export type SystemScheduledTaskFireMessage = Message & { type: 'system' }
|
||||
|
||||
export type SystemStopHookSummaryMessage = Message & {
|
||||
type: 'system'
|
||||
subtype: string
|
||||
hookLabel: string
|
||||
hookCount: number
|
||||
totalDurationMs?: number
|
||||
hookInfos: StopHookInfo[]
|
||||
}
|
||||
|
||||
export type SystemTurnDurationMessage = Message & { type: 'system' }
|
||||
|
||||
export type GroupedToolUseMessage = Message & {
|
||||
type: 'grouped_tool_use'
|
||||
toolName: string
|
||||
messages: NormalizedAssistantMessage[]
|
||||
results: NormalizedUserMessage[]
|
||||
displayMessage: NormalizedAssistantMessage | NormalizedUserMessage
|
||||
}
|
||||
|
||||
// CollapsibleMessage is used by the main project's CollapsedReadSearchGroup
|
||||
export type CollapsibleMessage =
|
||||
| AssistantMessage
|
||||
| UserMessage
|
||||
| GroupedToolUseMessage
|
||||
|
||||
export type HookResultMessage = Message
|
||||
export type SystemThinkingMessage = Message & { type: 'system' }
|
||||
10
packages/@ant/model-provider/src/types/systemPrompt.ts
Normal file
10
packages/@ant/model-provider/src/types/systemPrompt.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// System prompt branded type.
|
||||
// Dependency-free so it can be imported from anywhere without circular imports.
|
||||
|
||||
export type SystemPrompt = readonly string[] & {
|
||||
readonly __brand: 'SystemPrompt'
|
||||
}
|
||||
|
||||
export function asSystemPrompt(value: readonly string[]): SystemPrompt {
|
||||
return value as SystemPrompt
|
||||
}
|
||||
49
packages/@ant/model-provider/src/types/usage.ts
Normal file
49
packages/@ant/model-provider/src/types/usage.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Usage types for the model provider package.
|
||||
// Moved from src/entrypoints/sdk/sdkUtilityTypes.ts and src/services/api/emptyUsage.ts
|
||||
|
||||
/**
|
||||
* Non-nullable usage object representing token consumption from an API response.
|
||||
* Moved from src/entrypoints/sdk/sdkUtilityTypes.ts
|
||||
*/
|
||||
export type NonNullableUsage = {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cacheReadInputTokens?: number
|
||||
cacheCreationInputTokens?: number
|
||||
input_tokens: number
|
||||
cache_creation_input_tokens: number
|
||||
cache_read_input_tokens: number
|
||||
output_tokens: number
|
||||
server_tool_use: { web_search_requests: number; web_fetch_requests: number }
|
||||
service_tier: string
|
||||
cache_creation: {
|
||||
ephemeral_1h_input_tokens: number
|
||||
ephemeral_5m_input_tokens: number
|
||||
}
|
||||
inference_geo: string
|
||||
iterations: unknown[]
|
||||
speed: string
|
||||
cache_deleted_input_tokens?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-initialized usage object. Extracted from logging.ts so that
|
||||
* bridge/replBridge.ts can import it without transitively pulling in
|
||||
* api/errors.ts → utils/messages.ts → BashTool.tsx → the world.
|
||||
*/
|
||||
export const EMPTY_USAGE: Readonly<NonNullableUsage> = {
|
||||
input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
|
||||
service_tier: 'standard',
|
||||
cache_creation: {
|
||||
ephemeral_1h_input_tokens: 0,
|
||||
ephemeral_5m_input_tokens: 0,
|
||||
},
|
||||
inference_geo: '',
|
||||
iterations: [],
|
||||
speed: 'standard',
|
||||
}
|
||||
7
packages/@ant/model-provider/tsconfig.json
Normal file
7
packages/@ant/model-provider/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
34
packages/acp-link/.gitignore
vendored
Normal file
34
packages/acp-link/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
125
packages/acp-link/README.md
Normal file
125
packages/acp-link/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# acp-link
|
||||
|
||||
ACP proxy server that bridges WebSocket clients to ACP (Agent Client Protocol) agents.
|
||||
|
||||
> Source code adapted from [chrome-acp](https://github.com/Areo-Joe/chrome-acp).
|
||||
|
||||
## Installation
|
||||
|
||||
### From source
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
bun install
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Via global install
|
||||
acp-link /path/to/agent
|
||||
|
||||
# Via source
|
||||
bun src/cli/bin.ts /path/to/agent
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Basic usage
|
||||
acp-link /path/to/agent
|
||||
|
||||
# With custom port and host
|
||||
acp-link --port 9000 --host 0.0.0.0 /path/to/agent
|
||||
|
||||
# With debug logging
|
||||
acp-link --debug /path/to/agent
|
||||
|
||||
# Enable HTTPS with self-signed certificate
|
||||
acp-link --https /path/to/agent
|
||||
|
||||
# Disable authentication (dangerous)
|
||||
acp-link --no-auth /path/to/agent
|
||||
|
||||
# Register to RCS with a specific channel group
|
||||
acp-link --group my-team /path/to/agent
|
||||
|
||||
# Pass arguments to the agent (use -- to separate)
|
||||
acp-link /path/to/agent -- --verbose --model gpt-4
|
||||
```
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```
|
||||
USAGE
|
||||
acp-link [--port value] [--host value] [--debug] [--no-auth] [--https] [--group value] <command>...
|
||||
acp-link --help
|
||||
acp-link --version
|
||||
|
||||
FLAGS
|
||||
[--port] Port to listen on [default = 9315]
|
||||
[--host] Host to bind to [default = localhost]
|
||||
[--debug] Enable debug logging to file
|
||||
[--no-auth] Disable authentication (dangerous)
|
||||
[--https] Enable HTTPS with self-signed cert
|
||||
[--group] Channel group ID for RCS registration (letters, digits, hyphens, underscores only)
|
||||
-h --help Print help information and exit
|
||||
-v --version Print version information and exit
|
||||
|
||||
ARGUMENTS
|
||||
command... Agent command followed by its arguments
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Listens for WebSocket connections from clients
|
||||
2. When a "connect" message is received, spawns the configured ACP agent as a subprocess
|
||||
3. Bridges messages between the WebSocket (client) and stdin/stdout (agent via ACP protocol)
|
||||
4. Supports session management: create, load, resume, list sessions
|
||||
5. Handles permission approval flow and heartbeat keepalive
|
||||
|
||||
## Authentication
|
||||
|
||||
By default, a random token is auto-generated on startup. Connect to the
|
||||
WebSocket endpoint without putting the token in the URL:
|
||||
|
||||
```
|
||||
ws://localhost:9315/ws
|
||||
```
|
||||
|
||||
Set `ACP_AUTH_TOKEN` env var to use a fixed token, or use `--no-auth` to
|
||||
disable (not recommended). Clients that cannot send an `Authorization` header
|
||||
must send the token in a WebSocket subprotocol named
|
||||
`rcs.auth.<base64url-token>`.
|
||||
|
||||
## RCS Upstream
|
||||
|
||||
acp-link can register to a Remote Control Server (RCS) for remote access. Set the following environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ACP_RCS_URL` | RCS server URL (e.g. `http://rcs.example.com:3000`) |
|
||||
| `ACP_RCS_TOKEN` | API token for RCS authentication |
|
||||
| `ACP_RCS_GROUP` | Channel group ID to lock the agent into (letters, digits, `-`, `_` only) |
|
||||
|
||||
You can also use `--group <id>` on the CLI. The CLI flag takes priority over the env var.
|
||||
|
||||
## Manager UI
|
||||
|
||||
通过 `--manager` flag 启动独立的管理服务(不启动代理):
|
||||
|
||||
```bash
|
||||
# 启动 Manager(默认端口 9315)
|
||||
acp-link --manager
|
||||
|
||||
# 指定端口
|
||||
acp-link --manager --port 3210
|
||||
```
|
||||
|
||||
在浏览器打开 `http://localhost:<port>` 即可访问管理界面,创建、停止、删除多个 acp-link 子进程实例并实时查看日志。
|
||||
|
||||
通过 Manager UI 创建的子进程会自动跳过 Manager UI。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
42
packages/acp-link/package.json
Normal file
42
packages/acp-link/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "acp-link",
|
||||
"version": "2.0.0",
|
||||
"description": "ACP proxy server that bridges WebSocket clients to ACP agents",
|
||||
"author": "claude-code-best",
|
||||
"type": "module",
|
||||
"main": "./dist/server.js",
|
||||
"types": "./dist/server.d.ts",
|
||||
"bin": {
|
||||
"acp-link": "dist/cli/bin.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
|
||||
"dev:remote": "ACP_RCS_URL=https://remote-control.claude-code-best.win/ ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts ccb-bun -- --acp",
|
||||
"dev:manager": "ACP_RCS_URL=http://localhost:3000 ACP_RCS_TOKEN=test-my-key bun run src/cli/bin.ts --manager",
|
||||
"prepublishOnly": "bun run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/selfsigned": "^2.0.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/bun": "^1.3.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.19.0",
|
||||
"@hono/node-server": "^2.0.0",
|
||||
"@hono/node-ws": "^1.0.5",
|
||||
"@stricli/auto-complete": "^1.2.4",
|
||||
"@stricli/core": "^1.2.4",
|
||||
"hono": "^4.12.15",
|
||||
"pino": "^10.3.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"selfsigned": "^5.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
28
packages/acp-link/src/__tests__/cert.test.ts
Normal file
28
packages/acp-link/src/__tests__/cert.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { getLanIPs } from "../cert.js";
|
||||
|
||||
describe("getLanIPs", () => {
|
||||
test("returns an array", () => {
|
||||
const ips = getLanIPs();
|
||||
expect(Array.isArray(ips)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns only IPv4 addresses", () => {
|
||||
const ips = getLanIPs();
|
||||
for (const ip of ips) {
|
||||
// IPv4 format: x.x.x.x
|
||||
expect(ip).toMatch(/^\d+\.\d+\.\d+\.\d+$/);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not include loopback addresses", () => {
|
||||
const ips = getLanIPs();
|
||||
expect(ips).not.toContain("127.0.0.1");
|
||||
});
|
||||
|
||||
test("may be empty in isolated environments", () => {
|
||||
// This test just ensures it doesn't throw
|
||||
const ips = getLanIPs();
|
||||
expect(ips.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
287
packages/acp-link/src/__tests__/server.test.ts
Normal file
287
packages/acp-link/src/__tests__/server.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { describe, test, expect, mock } from "bun:test";
|
||||
import {
|
||||
__testing,
|
||||
decodeClientWsMessage,
|
||||
MAX_CLIENT_WS_PAYLOAD_BYTES,
|
||||
resolveNewSessionPermissionMode,
|
||||
type ServerConfig,
|
||||
} from "../server.js";
|
||||
import {
|
||||
authTokensEqual,
|
||||
decodeWebSocketAuthProtocol,
|
||||
encodeWebSocketAuthProtocol,
|
||||
extractWebSocketAuthToken,
|
||||
} from "../ws-auth.js";
|
||||
import { buildRcsWsUrl } from "../rcs-upstream.js";
|
||||
|
||||
function makeTestWs(sent: unknown[]) {
|
||||
type TestWs = Parameters<typeof __testing.dispatchClientMessage>[0];
|
||||
|
||||
return {
|
||||
readyState: 1,
|
||||
send: mock((message: string) => {
|
||||
sent.push(JSON.parse(message));
|
||||
}),
|
||||
close: mock(() => {}),
|
||||
raw: null,
|
||||
isInner: false,
|
||||
url: "",
|
||||
origin: "",
|
||||
protocol: "",
|
||||
} as unknown as TestWs;
|
||||
}
|
||||
|
||||
describe("Server HTTP endpoints", () => {
|
||||
test("package.json has correct bin and main entries", async () => {
|
||||
const pkg = await import("../../package.json", { with: { type: "json" } });
|
||||
expect(pkg.default.name).toBe("acp-link");
|
||||
expect(pkg.default.main).toBe("./dist/server.js");
|
||||
expect(pkg.default.bin).toBeDefined();
|
||||
expect(pkg.default.bin["acp-link"]).toBe("dist/cli/bin.js");
|
||||
});
|
||||
|
||||
test("ServerConfig interface accepts all expected fields", () => {
|
||||
const config: ServerConfig = {
|
||||
port: 9315,
|
||||
host: "localhost",
|
||||
command: "echo",
|
||||
args: [],
|
||||
cwd: "/tmp",
|
||||
debug: false,
|
||||
token: "test-token",
|
||||
https: false,
|
||||
};
|
||||
expect(config.port).toBe(9315);
|
||||
expect(config.token).toBe("test-token");
|
||||
});
|
||||
|
||||
test("ServerConfig allows optional fields to be omitted", () => {
|
||||
const config: ServerConfig = {
|
||||
port: 9315,
|
||||
host: "localhost",
|
||||
command: "echo",
|
||||
args: [],
|
||||
cwd: "/tmp",
|
||||
};
|
||||
expect(config.debug).toBeUndefined();
|
||||
expect(config.token).toBeUndefined();
|
||||
expect(config.https).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket message types", () => {
|
||||
const clientMessageTypes = [
|
||||
"connect",
|
||||
"disconnect",
|
||||
"new_session",
|
||||
"prompt",
|
||||
"permission_response",
|
||||
"cancel",
|
||||
"set_session_model",
|
||||
"list_sessions",
|
||||
"load_session",
|
||||
"resume_session",
|
||||
"ping",
|
||||
];
|
||||
|
||||
test("all client message types are recognized", () => {
|
||||
expect(clientMessageTypes.length).toBe(11);
|
||||
expect(clientMessageTypes).toContain("ping");
|
||||
expect(clientMessageTypes).toContain("connect");
|
||||
expect(clientMessageTypes).toContain("cancel");
|
||||
});
|
||||
|
||||
test("decodes supported client message payloads", () => {
|
||||
expect(decodeClientWsMessage('{"type":"ping"}')).toEqual({ type: "ping" });
|
||||
expect(
|
||||
decodeClientWsMessage(Buffer.from('{"type":"prompt","payload":{"content":[]}}')),
|
||||
).toEqual({ type: "prompt", payload: { content: [] } });
|
||||
expect(
|
||||
decodeClientWsMessage(new TextEncoder().encode('{"type":"cancel"}').buffer),
|
||||
).toEqual({ type: "cancel" });
|
||||
expect(
|
||||
decodeClientWsMessage([
|
||||
Buffer.from('{"type":"list_sessions","payload":{"cursor":"'),
|
||||
Buffer.from('next"}}'),
|
||||
]),
|
||||
).toEqual({ type: "list_sessions", payload: { cwd: undefined, cursor: "next" } });
|
||||
});
|
||||
|
||||
test("rejects malformed typed client payloads", () => {
|
||||
expect(() => decodeClientWsMessage('{"type":"prompt"}')).toThrow(
|
||||
"Invalid prompt payload",
|
||||
);
|
||||
expect(() =>
|
||||
decodeClientWsMessage('{"type":"load_session","payload":{}}'),
|
||||
).toThrow("Invalid load_session payload");
|
||||
expect(() => decodeClientWsMessage('{"type":"unknown"}')).toThrow(
|
||||
"Unknown message type",
|
||||
);
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":123}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":{}}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
expect(() =>
|
||||
decodeClientWsMessage(
|
||||
'{"type":"new_session","payload":{"permissionMode":null}}',
|
||||
),
|
||||
).toThrow("Invalid new_session.permissionMode");
|
||||
});
|
||||
|
||||
test("rejects oversized client message payloads before decoding", () => {
|
||||
const payload = "x".repeat(MAX_CLIENT_WS_PAYLOAD_BYTES + 1);
|
||||
expect(() => decodeClientWsMessage(payload)).toThrow("WebSocket message too large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket auth protocol", () => {
|
||||
test("round-trips tokens through a WebSocket subprotocol token", () => {
|
||||
const protocol = encodeWebSocketAuthProtocol("secret/token+with=symbols");
|
||||
expect(protocol).toStartWith("rcs.auth.");
|
||||
expect(protocol).not.toContain("secret/token");
|
||||
expect(decodeWebSocketAuthProtocol(protocol)).toBe("secret/token+with=symbols");
|
||||
});
|
||||
|
||||
test("ignores query-token style inputs", () => {
|
||||
expect(decodeWebSocketAuthProtocol(undefined)).toBeUndefined();
|
||||
expect(decodeWebSocketAuthProtocol("token=secret")).toBeUndefined();
|
||||
expect(decodeWebSocketAuthProtocol("other, rcs.auth.")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("prefers Authorization headers and supports protocol auth", () => {
|
||||
expect(
|
||||
extractWebSocketAuthToken({
|
||||
authorization: "Bearer header-token",
|
||||
protocol: encodeWebSocketAuthProtocol("protocol-token"),
|
||||
}),
|
||||
).toBe("header-token");
|
||||
expect(
|
||||
extractWebSocketAuthToken({
|
||||
protocol: encodeWebSocketAuthProtocol("protocol-token"),
|
||||
}),
|
||||
).toBe("protocol-token");
|
||||
});
|
||||
|
||||
test("compares auth tokens through the shared constant-time path", () => {
|
||||
expect(authTokensEqual("secret-token", "secret-token")).toBe(true);
|
||||
expect(authTokensEqual("secret-token", "wrong-token")).toBe(false);
|
||||
expect(authTokensEqual(undefined, "secret-token")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RCS upstream URL normalization", () => {
|
||||
test("removes legacy token query params from WebSocket URLs", () => {
|
||||
expect(
|
||||
buildRcsWsUrl("http://example.test/acp/ws?token=old-secret&x=1"),
|
||||
).toBe("ws://example.test/acp/ws?x=1");
|
||||
});
|
||||
|
||||
test("adds /acp/ws for base URLs", () => {
|
||||
expect(buildRcsWsUrl("https://example.test/")).toBe(
|
||||
"wss://example.test/acp/ws",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permission mode resolution", () => {
|
||||
test("uses client requested non-bypass modes", () => {
|
||||
expect(resolveNewSessionPermissionMode("plan", "acceptEdits")).toBe("plan");
|
||||
});
|
||||
|
||||
test("uses local default when client does not request a mode", () => {
|
||||
expect(resolveNewSessionPermissionMode(undefined, "acceptEdits")).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("rejects client requested bypassPermissions without local default", () => {
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypassPermissions", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypass", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypasspermissions", "acceptEdits"),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("bypassPermissions", undefined),
|
||||
).toThrow("bypassPermissions requires local ACP_PERMISSION_MODE");
|
||||
});
|
||||
|
||||
test("rejects unknown client permission modes before forwarding", () => {
|
||||
expect(() =>
|
||||
resolveNewSessionPermissionMode("unknown-mode", "acceptEdits"),
|
||||
).toThrow("Invalid permissionMode: unknown-mode");
|
||||
});
|
||||
|
||||
test("allows bypassPermissions when local default already enables it", () => {
|
||||
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypassPermissions")).toBe("bypassPermissions");
|
||||
expect(resolveNewSessionPermissionMode("bypass", "bypassPermissions")).toBe("bypassPermissions");
|
||||
expect(resolveNewSessionPermissionMode("bypassPermissions", "bypass")).toBe("bypassPermissions");
|
||||
});
|
||||
|
||||
test("new_session rejects client bypass before forwarding to the agent", async () => {
|
||||
const sent: unknown[] = [];
|
||||
const ws = makeTestWs(sent);
|
||||
const originalTestInternals = process.env.ACP_LINK_TEST_INTERNALS;
|
||||
process.env.ACP_LINK_TEST_INTERNALS = "1";
|
||||
let unregisterClient = () => {};
|
||||
let restoreMode = () => {};
|
||||
|
||||
try {
|
||||
const newSession = mock(async () => ({
|
||||
sessionId: "should-not-be-created",
|
||||
}));
|
||||
unregisterClient = __testing.registerClient(ws, {
|
||||
connection: { newSession },
|
||||
});
|
||||
restoreMode = __testing.setDefaultPermissionMode("acceptEdits");
|
||||
|
||||
await __testing.dispatchClientMessage(ws, {
|
||||
type: "new_session",
|
||||
payload: {
|
||||
cwd: "/tmp",
|
||||
permissionMode: "bypass",
|
||||
},
|
||||
});
|
||||
|
||||
expect(newSession).not.toHaveBeenCalled();
|
||||
expect(__testing.getClientSessionId(ws)).toBeNull();
|
||||
expect(sent).toEqual([
|
||||
{
|
||||
type: "error",
|
||||
payload: {
|
||||
message: expect.stringContaining(
|
||||
"bypassPermissions requires local ACP_PERMISSION_MODE",
|
||||
),
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
restoreMode();
|
||||
unregisterClient();
|
||||
if (originalTestInternals === undefined) {
|
||||
delete process.env.ACP_LINK_TEST_INTERNALS;
|
||||
} else {
|
||||
process.env.ACP_LINK_TEST_INTERNALS = originalTestInternals;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Heartbeat constants", () => {
|
||||
test("PERMISSION_TIMEOUT_MS is 5 minutes", () => {
|
||||
const PERMISSION_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
expect(PERMISSION_TIMEOUT_MS).toBe(300_000);
|
||||
});
|
||||
|
||||
test("HEARTBEAT_INTERVAL_MS is 30 seconds", () => {
|
||||
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
expect(HEARTBEAT_INTERVAL_MS).toBe(30_000);
|
||||
});
|
||||
});
|
||||
69
packages/acp-link/src/__tests__/types.test.ts
Normal file
69
packages/acp-link/src/__tests__/types.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { isRequest, isResponse, isNotification } from "../types.js";
|
||||
import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from "../types.js";
|
||||
|
||||
describe("isRequest", () => {
|
||||
test("returns true for a valid JSON-RPC request", () => {
|
||||
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||
expect(isRequest(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for request with params", () => {
|
||||
const msg = { jsonrpc: "2.0" as const, id: "abc", method: "test", params: { x: 1 } };
|
||||
expect(isRequest(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for response (no method)", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: {} };
|
||||
expect(isRequest(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for notification (no id)", () => {
|
||||
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
|
||||
expect(isRequest(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isResponse", () => {
|
||||
test("returns true for a valid JSON-RPC response with result", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: "ok" };
|
||||
expect(isResponse(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for a valid JSON-RPC error response", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 2, error: { code: -32600, message: "bad" } };
|
||||
expect(isResponse(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for request (has method)", () => {
|
||||
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||
expect(isResponse(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for notification", () => {
|
||||
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "notify" };
|
||||
expect(isResponse(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNotification", () => {
|
||||
test("returns true for a valid JSON-RPC notification", () => {
|
||||
const msg: JsonRpcNotification = { jsonrpc: "2.0", method: "update" };
|
||||
expect(isNotification(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for notification with params", () => {
|
||||
const msg = { jsonrpc: "2.0" as const, method: "progress", params: { pct: 50 } };
|
||||
expect(isNotification(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for request (has id)", () => {
|
||||
const msg: JsonRpcRequest = { jsonrpc: "2.0", id: 1, method: "test" };
|
||||
expect(isNotification(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for response (no method)", () => {
|
||||
const msg: JsonRpcResponse = { jsonrpc: "2.0", id: 1, result: null };
|
||||
expect(isNotification(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
174
packages/acp-link/src/cert.ts
Normal file
174
packages/acp-link/src/cert.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Self-signed certificate generation for HTTPS support
|
||||
*/
|
||||
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, networkInterfaces } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { generate } from "selfsigned";
|
||||
|
||||
/**
|
||||
* Get all LAN IPv4 addresses
|
||||
*/
|
||||
export function getLanIPs(): string[] {
|
||||
const ips: string[] = [];
|
||||
const nets = networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name] || []) {
|
||||
// Skip internal (loopback) and non-IPv4 addresses
|
||||
if (!net.internal && net.family === "IPv4") {
|
||||
ips.push(net.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract IP addresses from certificate's Subject Alternative Name (SAN)
|
||||
* SAN format: "IP Address:192.168.1.100, IP Address:127.0.0.1, DNS:localhost"
|
||||
*/
|
||||
function extractSanIPs(x509: X509Certificate): string[] {
|
||||
const san = x509.subjectAltName;
|
||||
if (!san) return [];
|
||||
|
||||
const ips: string[] = [];
|
||||
// Parse "IP Address:x.x.x.x" entries from SAN string
|
||||
const parts = san.split(", ");
|
||||
for (const part of parts) {
|
||||
const match = part.match(/^IP Address:(.+)$/);
|
||||
if (match && match[1]) {
|
||||
ips.push(match[1]);
|
||||
}
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
const CERT_DIR = join(homedir(), ".acp-proxy");
|
||||
const KEY_PATH = join(CERT_DIR, "key.pem");
|
||||
const CERT_PATH = join(CERT_DIR, "cert.pem");
|
||||
|
||||
// Certificate validity in days
|
||||
const CERT_VALIDITY_DAYS = 365;
|
||||
|
||||
export interface TlsOptions {
|
||||
key: string;
|
||||
cert: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate self-signed certificate
|
||||
* Certificates are cached in ~/.acp-proxy/
|
||||
*/
|
||||
export async function getOrCreateCertificate(): Promise<TlsOptions> {
|
||||
// Ensure directory exists
|
||||
if (!existsSync(CERT_DIR)) {
|
||||
mkdirSync(CERT_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Check if certificates already exist and are still valid
|
||||
if (existsSync(KEY_PATH) && existsSync(CERT_PATH)) {
|
||||
const certPem = readFileSync(CERT_PATH, "utf-8");
|
||||
const keyPem = readFileSync(KEY_PATH, "utf-8");
|
||||
|
||||
try {
|
||||
const x509 = new X509Certificate(certPem);
|
||||
const validTo = new Date(x509.validTo);
|
||||
const now = new Date();
|
||||
|
||||
// Check if cert is expired or will expire within 7 days
|
||||
const daysUntilExpiry = Math.floor((validTo.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilExpiry <= 7) {
|
||||
// Certificate expired or expiring soon
|
||||
console.log(`⚠️ Certificate ${daysUntilExpiry <= 0 ? "expired" : `expires in ${daysUntilExpiry} days`}, regenerating...`);
|
||||
} else {
|
||||
// Check if current LAN IPs are in the certificate's SAN
|
||||
const currentLanIPs = getLanIPs();
|
||||
const certSanIPs = extractSanIPs(x509);
|
||||
|
||||
// Check if all current LAN IPs are covered by the certificate
|
||||
const missingIPs = currentLanIPs.filter(ip => !certSanIPs.includes(ip));
|
||||
|
||||
if (missingIPs.length === 0) {
|
||||
console.log(`🔐 Using existing certificate from ${CERT_DIR}`);
|
||||
console.log(` Valid for ${daysUntilExpiry} more days`);
|
||||
return { key: keyPem, cert: certPem };
|
||||
}
|
||||
|
||||
// LAN IP changed, regenerate
|
||||
console.log(`⚠️ LAN IP changed (missing: ${missingIPs.join(", ")}), regenerating certificate...`);
|
||||
}
|
||||
} catch {
|
||||
// Failed to parse certificate, regenerate
|
||||
console.log(`⚠️ Invalid certificate, regenerating...`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new self-signed certificate
|
||||
console.log(`🔐 Generating self-signed certificate...`);
|
||||
|
||||
const attrs = [{ name: "commonName", value: "ACP Proxy Server" }];
|
||||
|
||||
// Calculate expiry date
|
||||
const notAfterDate = new Date();
|
||||
notAfterDate.setDate(notAfterDate.getDate() + CERT_VALIDITY_DAYS);
|
||||
|
||||
// Build altNames: localhost + loopback + all LAN IPs
|
||||
const altNames: Array<{ type: 1 | 2 | 6 | 7; value?: string; ip?: string }> = [
|
||||
{ type: 2, value: "localhost" },
|
||||
{ type: 7, ip: "127.0.0.1" },
|
||||
{ type: 7, ip: "::1" },
|
||||
];
|
||||
|
||||
// Add all current LAN IPs
|
||||
const lanIPs = getLanIPs();
|
||||
for (const ip of lanIPs) {
|
||||
altNames.push({ type: 7, ip });
|
||||
}
|
||||
|
||||
if (lanIPs.length > 0) {
|
||||
console.log(` Including LAN IPs: ${lanIPs.join(", ")}`);
|
||||
}
|
||||
|
||||
const pems = await generate(attrs, {
|
||||
keySize: 2048,
|
||||
notAfterDate,
|
||||
algorithm: "sha256",
|
||||
extensions: [
|
||||
{
|
||||
name: "basicConstraints",
|
||||
cA: true,
|
||||
},
|
||||
{
|
||||
name: "keyUsage",
|
||||
keyCertSign: true,
|
||||
digitalSignature: true,
|
||||
keyEncipherment: true,
|
||||
},
|
||||
{
|
||||
name: "extKeyUsage",
|
||||
serverAuth: true,
|
||||
},
|
||||
{
|
||||
name: "subjectAltName",
|
||||
altNames,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Save certificates
|
||||
writeFileSync(KEY_PATH, pems.private);
|
||||
writeFileSync(CERT_PATH, pems.cert);
|
||||
|
||||
console.log(`✅ Certificate saved to ${CERT_DIR}`);
|
||||
console.log(` Valid for ${CERT_VALIDITY_DAYS} days`);
|
||||
console.log(` ⚠️ First access will show a security warning - click "Advanced" → "Proceed"`);
|
||||
|
||||
return {
|
||||
key: pems.private,
|
||||
cert: pems.cert,
|
||||
};
|
||||
}
|
||||
|
||||
18
packages/acp-link/src/cli/app.ts
Normal file
18
packages/acp-link/src/cli/app.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { buildApplication } from "@stricli/core";
|
||||
import { createRequire } from "node:module";
|
||||
import { command } from "./command.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require("../../package.json") as { version: string };
|
||||
|
||||
export const app = buildApplication(command, {
|
||||
name: "acp-link",
|
||||
versionInfo: {
|
||||
currentVersion: pkg.version,
|
||||
},
|
||||
scanner: {
|
||||
caseStyle: "allow-kebab-for-camel",
|
||||
allowArgumentEscapeSequence: true,
|
||||
},
|
||||
});
|
||||
|
||||
7
packages/acp-link/src/cli/bin.ts
Normal file
7
packages/acp-link/src/cli/bin.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { run } from "@stricli/core";
|
||||
import { app } from "./app.js";
|
||||
import { buildContext } from "./context.js";
|
||||
|
||||
await run(app, process.argv.slice(2), buildContext());
|
||||
|
||||
123
packages/acp-link/src/cli/command.ts
Normal file
123
packages/acp-link/src/cli/command.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { buildCommand, numberParser } from "@stricli/core";
|
||||
import type { LocalContext } from "./context.js";
|
||||
|
||||
export const command = buildCommand({
|
||||
docs: {
|
||||
brief: "Start the ACP proxy server",
|
||||
fullDescription:
|
||||
"Starts a WebSocket proxy server that bridges clients to ACP agents. " +
|
||||
"The agent command is spawned as a subprocess and communicates via stdin/stdout.\n\n" +
|
||||
"Use -- to pass arguments to the agent:\n" +
|
||||
" acp-link /path/to/agent -- --verbose --model gpt-4\n\n" +
|
||||
"Use --manager to start the Manager Web UI instead:\n" +
|
||||
" acp-link --manager\n\n" +
|
||||
"For remote access, set ACP_AUTH_TOKEN environment variable or let it auto-generate.",
|
||||
},
|
||||
parameters: {
|
||||
flags: {
|
||||
port: {
|
||||
kind: "parsed",
|
||||
parse: numberParser,
|
||||
brief: "Port to listen on",
|
||||
default: "9315",
|
||||
},
|
||||
host: {
|
||||
kind: "parsed",
|
||||
parse: String,
|
||||
brief: "Host to bind to (use 0.0.0.0 for remote access)",
|
||||
default: "localhost",
|
||||
},
|
||||
debug: {
|
||||
kind: "boolean",
|
||||
brief: "Enable debug logging to file",
|
||||
default: false,
|
||||
},
|
||||
"no-auth": {
|
||||
kind: "boolean",
|
||||
brief: "DANGEROUS: Disable authentication (not recommended)",
|
||||
default: false,
|
||||
},
|
||||
https: {
|
||||
kind: "boolean",
|
||||
brief: "Enable HTTPS with auto-generated self-signed certificate",
|
||||
default: false,
|
||||
},
|
||||
manager: {
|
||||
kind: "boolean",
|
||||
brief: "Start Manager Web UI (no proxy)",
|
||||
default: false,
|
||||
},
|
||||
group: {
|
||||
kind: "parsed",
|
||||
parse: (value: string) => {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||
throw new Error(`Invalid group "${value}": only letters, digits, hyphens, and underscores are allowed`);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
brief: "Channel group ID for RCS registration (env: ACP_RCS_GROUP)",
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
positional: {
|
||||
kind: "array",
|
||||
parameter: {
|
||||
brief: "Agent command and arguments (use -- before agent flags)",
|
||||
parse: String,
|
||||
placeholder: "command",
|
||||
},
|
||||
minimum: 0,
|
||||
},
|
||||
},
|
||||
func: async function (
|
||||
this: LocalContext,
|
||||
flags: { port: number; host: string; debug: boolean; "no-auth": boolean; https: boolean; manager: boolean; group: string | undefined },
|
||||
...args: readonly string[]
|
||||
) {
|
||||
const port = flags.port;
|
||||
const host = flags.host;
|
||||
const debug = flags.debug;
|
||||
const noAuth = flags["no-auth"];
|
||||
const https = flags.https;
|
||||
const manager = flags.manager;
|
||||
const group = flags.group;
|
||||
|
||||
// Manager mode: start web UI only, no proxy
|
||||
if (manager) {
|
||||
const { startManager } = await import("../manager/index.js");
|
||||
await startManager(port);
|
||||
return;
|
||||
}
|
||||
|
||||
// Proxy mode: agent command is required
|
||||
if (args.length === 0) {
|
||||
console.error("Error: agent command is required (or use --manager)");
|
||||
process.exit(1);
|
||||
}
|
||||
const [command, ...agentArgs] = args;
|
||||
const cwd = process.cwd();
|
||||
|
||||
// Determine auth token
|
||||
// Priority: ACP_AUTH_TOKEN env var > auto-generate (unless --no-auth)
|
||||
let token: string | undefined;
|
||||
if (noAuth) {
|
||||
console.warn("⚠️ WARNING: Authentication disabled. This is dangerous for remote access!");
|
||||
token = undefined;
|
||||
} else {
|
||||
token = process.env.ACP_AUTH_TOKEN;
|
||||
if (!token) {
|
||||
// Auto-generate random token
|
||||
const { randomBytes } = await import("node:crypto");
|
||||
token = randomBytes(32).toString("hex");
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize logger
|
||||
const { initLogger } = await import("../logger.js");
|
||||
initLogger({ debug });
|
||||
|
||||
// Import and run the server
|
||||
const { startServer } = await import("../server.js");
|
||||
await startServer({ port, host, command: command!, args: [...agentArgs], cwd, debug, token, https, group });
|
||||
},
|
||||
});
|
||||
10
packages/acp-link/src/cli/context.ts
Normal file
10
packages/acp-link/src/cli/context.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { CommandContext } from "@stricli/core";
|
||||
|
||||
export interface LocalContext extends CommandContext {}
|
||||
|
||||
export function buildContext(): LocalContext {
|
||||
return {
|
||||
process,
|
||||
};
|
||||
}
|
||||
|
||||
83
packages/acp-link/src/logger.ts
Normal file
83
packages/acp-link/src/logger.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import pino from "pino";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
|
||||
let rootLogger: pino.Logger;
|
||||
|
||||
export interface LoggerConfig {
|
||||
debug: boolean;
|
||||
logDir?: string;
|
||||
}
|
||||
|
||||
/** Pretty-print config for console output */
|
||||
const PRETTY_CONFIG = {
|
||||
colorize: true,
|
||||
translateTime: "SYS:HH:MM:ss.l",
|
||||
ignore: "pid,hostname",
|
||||
} as const;
|
||||
|
||||
export function initLogger(config: LoggerConfig): pino.Logger {
|
||||
const { debug, logDir } = config;
|
||||
|
||||
if (debug) {
|
||||
const dir = logDir || join(process.cwd(), ".acp-proxy");
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString()
|
||||
.replace(/T/, "_")
|
||||
.replace(/:/g, "-")
|
||||
.replace(/\..+/, "");
|
||||
const logFile = join(dir, `acp-proxy-${timestamp}.log`);
|
||||
|
||||
// Debug mode: JSON to file + pretty to console (multistream)
|
||||
rootLogger = pino(
|
||||
{
|
||||
level: "trace",
|
||||
timestamp: pino.stdTimeFunctions.isoTime,
|
||||
},
|
||||
pino.transport({
|
||||
targets: [
|
||||
{ target: "pino/file", options: { destination: logFile } },
|
||||
{ target: "pino-pretty", options: { ...PRETTY_CONFIG, destination: 1 } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(`📝 Debug logging enabled: ${logFile}`);
|
||||
} else {
|
||||
rootLogger = pino(
|
||||
{ level: "info", timestamp: pino.stdTimeFunctions.isoTime },
|
||||
pino.transport({
|
||||
target: "pino-pretty",
|
||||
options: { ...PRETTY_CONFIG, destination: 1 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return rootLogger;
|
||||
}
|
||||
|
||||
/** Get the root logger (auto-creates a default one if not initialized). */
|
||||
export function getLogger(): pino.Logger {
|
||||
if (!rootLogger) {
|
||||
rootLogger = pino(
|
||||
{ level: "info" },
|
||||
pino.transport({
|
||||
target: "pino-pretty",
|
||||
options: { ...PRETTY_CONFIG, destination: 1 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return rootLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger scoped to a module.
|
||||
* Usage: `const log = createLogger("agent"); log.info({ pid }, "spawned")`
|
||||
*/
|
||||
export function createLogger(module: string): pino.Logger {
|
||||
return getLogger().child({ module });
|
||||
}
|
||||
345
packages/acp-link/src/manager/html.ts
Normal file
345
packages/acp-link/src/manager/html.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
export const MANAGER_HTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ACP Manager</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #f8f7f5;
|
||||
color: #1a1a1a;
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
h1 { font-size: 20px; font-weight: 600; margin-bottom: 20px; color: #1a1a1a; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.create-form {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e2de;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.form-group label { font-size: 12px; color: #888; }
|
||||
.form-group input {
|
||||
background: #fff;
|
||||
border: 1px solid #d5d2ce;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
color: #1a1a1a;
|
||||
font-size: 14px;
|
||||
width: 200px;
|
||||
}
|
||||
.form-group input.wide { width: 400px; }
|
||||
button {
|
||||
background: #d77757;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button:hover { background: #c4694b; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
button.danger { background: #a63d3d; }
|
||||
button.danger:hover { background: #c44a4a; }
|
||||
button.small { padding: 4px 10px; font-size: 12px; }
|
||||
.instances { display: flex; flex-direction: column; gap: 8px; }
|
||||
.instance-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e2de;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.instance-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.instance-header:hover { background: #f5f3f0; }
|
||||
.status-dot {
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.running { background: #4ade80; box-shadow: 0 0 6px #4ade8066; }
|
||||
.status-dot.stopped { background: #aaa; }
|
||||
.status-dot.failed { background: #f87171; box-shadow: 0 0 6px #f8717166; }
|
||||
.instance-info { flex: 1; display: flex; gap: 16px; align-items: center; font-size: 13px; }
|
||||
.instance-info .group { font-weight: 600; color: #d77757; }
|
||||
.instance-info .cmd { color: #888; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.instance-info .pid { color: #999; font-size: 12px; }
|
||||
.instance-info .uptime { color: #999; font-size: 12px; }
|
||||
.instance-actions { display: flex; gap: 6px; }
|
||||
.expand-icon { color: #999; font-size: 12px; transition: transform 0.2s; }
|
||||
.expand-icon.open { transform: rotate(90deg); }
|
||||
.log-panel {
|
||||
display: none;
|
||||
border-top: 1px solid #e5e2de;
|
||||
background: #faf9f7;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.log-panel.visible { display: block; }
|
||||
.log-line { white-space: pre-wrap; word-break: break-all; }
|
||||
.log-line.stdout { color: #333; }
|
||||
.log-line.stderr { color: #d94040; }
|
||||
.empty { color: #999; text-align: center; padding: 40px; font-size: 14px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body { padding: 12px; }
|
||||
.create-form { flex-wrap: wrap; }
|
||||
.form-group input, .form-group input.wide { width: 100%; }
|
||||
.form-group { flex: 1 1 120px; min-width: 0; }
|
||||
.instance-header { flex-wrap: wrap; padding: 10px 12px; gap: 8px; }
|
||||
.instance-info { flex-wrap: wrap; gap: 6px; font-size: 12px; }
|
||||
.instance-info .cmd { max-width: 100%; }
|
||||
button.small { padding: 8px 14px; min-height: 44px; font-size: 13px; }
|
||||
.log-panel { max-height: 50vh; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>ACP Manager</h1>
|
||||
</div>
|
||||
|
||||
<div class="create-form">
|
||||
<div class="form-group">
|
||||
<label>Group</label>
|
||||
<input type="text" id="inp-group" placeholder="my-group" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>ACP Command</label>
|
||||
<input type="text" id="inp-command" class="wide" placeholder="/path/to/agent --verbose" />
|
||||
</div>
|
||||
<button id="btn-create">Create</button>
|
||||
</div>
|
||||
|
||||
<div class="instances" id="instance-list"></div>
|
||||
|
||||
<script>
|
||||
var listEl = document.getElementById('instance-list');
|
||||
var esMap = {};
|
||||
var instances = [];
|
||||
var inpGroup = document.getElementById('inp-group');
|
||||
var inpCommand = document.getElementById('inp-command');
|
||||
var btnCreate = document.getElementById('btn-create');
|
||||
|
||||
// localStorage persistence
|
||||
function loadForm() {
|
||||
try {
|
||||
inpGroup.value = localStorage.getItem('acp-mgr-group') || '';
|
||||
inpCommand.value = localStorage.getItem('acp-mgr-command') || '';
|
||||
} catch(e) {}
|
||||
}
|
||||
function saveForm() {
|
||||
try {
|
||||
localStorage.setItem('acp-mgr-group', inpGroup.value);
|
||||
localStorage.setItem('acp-mgr-command', inpCommand.value);
|
||||
} catch(e) {}
|
||||
}
|
||||
inpGroup.addEventListener('input', saveForm);
|
||||
inpCommand.addEventListener('input', saveForm);
|
||||
loadForm();
|
||||
|
||||
btnCreate.addEventListener('click', function() {
|
||||
var group = inpGroup.value.trim();
|
||||
var command = inpCommand.value.trim();
|
||||
if (!group || !command) return alert('Both fields required');
|
||||
btnCreate.disabled = true;
|
||||
fetch('/api/instances', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ group: group, command: command }),
|
||||
}).then(function() { fetchInstances(); })
|
||||
.finally(function() { btnCreate.disabled = false; });
|
||||
});
|
||||
|
||||
// event delegation for instance actions
|
||||
listEl.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('[data-action]');
|
||||
if (btn) {
|
||||
e.stopPropagation();
|
||||
var id = btn.getAttribute('data-id');
|
||||
var action = btn.getAttribute('data-action');
|
||||
if (action === 'stop') stopInstance(id);
|
||||
else if (action === 'delete') deleteInstance(id);
|
||||
return;
|
||||
}
|
||||
var header = e.target.closest('.instance-header');
|
||||
if (header) {
|
||||
var cardId = header.closest('.instance-card').getAttribute('data-id');
|
||||
toggleLog(cardId);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchInstances() {
|
||||
var res = await fetch('/api/instances');
|
||||
instances = await res.json();
|
||||
render();
|
||||
}
|
||||
|
||||
function uptime(start) {
|
||||
var s = Math.floor((Date.now() - start) / 1000);
|
||||
if (s < 60) return s + 's';
|
||||
if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
|
||||
return Math.floor(s/3600) + 'h ' + Math.floor((s%3600)/60) + 'm';
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (instances.length === 0) {
|
||||
listEl.innerHTML = '<div class="empty">No instances. Create one above.</div>';
|
||||
return;
|
||||
}
|
||||
// Diff-based update: only rebuild cards whose status changed
|
||||
var existingCards = {};
|
||||
listEl.querySelectorAll('.instance-card').forEach(function(card) {
|
||||
existingCards[card.getAttribute('data-id')] = card;
|
||||
});
|
||||
|
||||
var newIds = new Set(instances.map(function(i) { return i.id; }));
|
||||
|
||||
// Remove cards that no longer exist
|
||||
for (var eid in existingCards) {
|
||||
if (!newIds.has(eid)) {
|
||||
closeLog(eid);
|
||||
existingCards[eid].remove();
|
||||
delete existingCards[eid];
|
||||
}
|
||||
}
|
||||
|
||||
// Update or create cards in order
|
||||
instances.forEach(function(inst) {
|
||||
var card = existingCards[inst.id];
|
||||
if (!card) {
|
||||
// New instance — create card
|
||||
card = document.createElement('div');
|
||||
card.className = 'instance-card';
|
||||
card.setAttribute('data-id', inst.id);
|
||||
card.innerHTML =
|
||||
'<div class="instance-header">' +
|
||||
'<span class="expand-icon">▶</span>' +
|
||||
'<span class="status-dot"></span>' +
|
||||
'<div class="instance-info">' +
|
||||
'<span class="group"></span>' +
|
||||
'<span class="cmd"></span>' +
|
||||
'<span class="pid"></span>' +
|
||||
'<span class="uptime"></span>' +
|
||||
'</div>' +
|
||||
'<div class="instance-actions"></div>' +
|
||||
'</div>' +
|
||||
'<div class="log-panel" id="log-' + inst.id + '"></div>';
|
||||
listEl.appendChild(card);
|
||||
}
|
||||
// Update card content
|
||||
card.querySelector('.status-dot').className = 'status-dot ' + inst.status;
|
||||
card.querySelector('.group').textContent = inst.group;
|
||||
card.querySelector('.cmd').textContent = inst.command;
|
||||
card.querySelector('.pid').textContent = inst.pid ? 'PID ' + inst.pid : '';
|
||||
card.querySelector('.uptime').textContent = inst.status === 'running' ? uptime(inst.startTime) : '';
|
||||
|
||||
// Update action buttons
|
||||
var actions = card.querySelector('.instance-actions');
|
||||
var prevStatus = card.getAttribute('data-status');
|
||||
if (prevStatus !== inst.status) {
|
||||
card.setAttribute('data-status', inst.status);
|
||||
actions.innerHTML = inst.status === 'running'
|
||||
? '<button class="small danger" data-action="stop" data-id="' + inst.id + '">Stop</button>'
|
||||
: '<button class="small danger" data-action="delete" data-id="' + inst.id + '">Delete</button>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function stopInstance(id) {
|
||||
var btn = listEl.querySelector('[data-action="stop"][data-id="' + id + '"]');
|
||||
if (btn) btn.disabled = true;
|
||||
await fetch('/api/instances/' + id + '/stop', { method: 'POST' });
|
||||
await fetchInstances();
|
||||
}
|
||||
|
||||
async function deleteInstance(id) {
|
||||
var btn = listEl.querySelector('[data-action="delete"][data-id="' + id + '"]');
|
||||
if (btn) btn.disabled = true;
|
||||
await fetch('/api/instances/' + id, { method: 'DELETE' });
|
||||
closeLog(id);
|
||||
await fetchInstances();
|
||||
}
|
||||
|
||||
function toggleLog(id) {
|
||||
var panel = document.getElementById('log-' + id);
|
||||
if (!panel) return;
|
||||
if (panel.classList.contains('visible')) {
|
||||
closeLog(id);
|
||||
} else {
|
||||
openLog(id);
|
||||
}
|
||||
var icon = listEl.querySelector('[data-id="' + id + '"] .expand-icon');
|
||||
if (icon) icon.classList.toggle('open', panel.classList.contains('visible'));
|
||||
}
|
||||
|
||||
function openLog(id) {
|
||||
var panel = document.getElementById('log-' + id);
|
||||
if (!panel) return;
|
||||
panel.classList.add('visible');
|
||||
panel.innerHTML = '';
|
||||
var es = new EventSource('/api/instances/' + id + '/logs');
|
||||
esMap[id] = es;
|
||||
var scrollPending = false;
|
||||
es.onmessage = function(e) {
|
||||
try {
|
||||
var entry = JSON.parse(e.data);
|
||||
var line = document.createElement('div');
|
||||
line.className = 'log-line ' + entry.stream;
|
||||
var time = new Date(entry.timestamp).toLocaleTimeString();
|
||||
line.textContent = '[' + time + '] ' + entry.text;
|
||||
panel.appendChild(line);
|
||||
if (panel.children.length > 500) panel.removeChild(panel.firstChild);
|
||||
if (!scrollPending) {
|
||||
scrollPending = true;
|
||||
requestAnimationFrame(function() {
|
||||
panel.scrollTop = panel.scrollHeight;
|
||||
scrollPending = false;
|
||||
});
|
||||
}
|
||||
} catch(err) {}
|
||||
};
|
||||
es.onerror = function() {
|
||||
es.close();
|
||||
delete esMap[id];
|
||||
};
|
||||
}
|
||||
|
||||
function closeLog(id) {
|
||||
if (esMap[id]) {
|
||||
esMap[id].close();
|
||||
delete esMap[id];
|
||||
}
|
||||
var panel = document.getElementById('log-' + id);
|
||||
if (panel) panel.classList.remove('visible');
|
||||
}
|
||||
|
||||
fetchInstances();
|
||||
setInterval(fetchInstances, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
44
packages/acp-link/src/manager/index.ts
Normal file
44
packages/acp-link/src/manager/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Hono } from "hono";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { ProcessManager } from "./manager.js";
|
||||
import { createApp } from "./routes.js";
|
||||
|
||||
export async function startManager(port: number): Promise<void> {
|
||||
const manager = new ProcessManager();
|
||||
const app = createApp(manager);
|
||||
|
||||
// Health check
|
||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log("Shutting down...");
|
||||
await manager.shutdownAll();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
const server = serve({ fetch: app.fetch, port });
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
console.error(`\n Error: port ${port} is already in use. Use --port to specify a different port.\n`);
|
||||
} else {
|
||||
console.error(`\n Error: ${err.message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
console.log();
|
||||
console.log(` 🖥️ ACP Manager`);
|
||||
console.log();
|
||||
console.log(` URL: http://localhost:${port}`);
|
||||
console.log();
|
||||
console.log(` Press Ctrl+C to stop`);
|
||||
console.log();
|
||||
|
||||
// Keep running
|
||||
await new Promise(() => {});
|
||||
}
|
||||
233
packages/acp-link/src/manager/manager.ts
Normal file
233
packages/acp-link/src/manager/manager.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import type { AcpInstance, InstanceSummary, LogEntry } from "./types.js";
|
||||
|
||||
function log(tag: string, msg: string) {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}] [${tag}] ${msg}`);
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 2000;
|
||||
const SHUTDOWN_TIMEOUT_MS = 5000;
|
||||
|
||||
export class ProcessManager {
|
||||
private instances = new Map<string, AcpInstance>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private processes = new Map<string, any>();
|
||||
|
||||
create(group: string, command: string): AcpInstance {
|
||||
const id = crypto.randomUUID();
|
||||
const instance: AcpInstance = {
|
||||
id,
|
||||
group,
|
||||
command,
|
||||
status: "running",
|
||||
pid: undefined,
|
||||
startTime: Date.now(),
|
||||
exitCode: null,
|
||||
logs: [],
|
||||
subscribers: new Set(),
|
||||
};
|
||||
|
||||
const args = this.parseCommand(command);
|
||||
const fullArgs = ["--group", group, ...args];
|
||||
|
||||
const proc = Bun.spawn(["acp-link", ...fullArgs], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...Bun.env, ACP_CHILD: "1" },
|
||||
});
|
||||
|
||||
instance.pid = proc.pid;
|
||||
this.instances.set(id, instance);
|
||||
this.processes.set(id, proc);
|
||||
log("manager", `created instance ${id.slice(0, 8)} group=${group} pid=${proc.pid} cmd="acp-link ${fullArgs.join(" ")}"`);
|
||||
|
||||
this.pipeStream(proc.stdout, id, "stdout");
|
||||
this.pipeStream(proc.stderr, id, "stderr");
|
||||
|
||||
proc.exited.then((code) => {
|
||||
instance.status = code === 0 ? "stopped" : "failed";
|
||||
instance.exitCode = code;
|
||||
instance.pid = undefined;
|
||||
this.processes.delete(id);
|
||||
log("manager", `instance ${id.slice(0, 8)} ${instance.status} exit=${code}`);
|
||||
this.notifyStatus(instance);
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
stop(id: string): boolean {
|
||||
const proc = this.processes.get(id);
|
||||
if (!proc) return false;
|
||||
const inst = this.instances.get(id);
|
||||
log("manager", `stopping instance ${id.slice(0, 8)} pid=${proc.pid}`);
|
||||
proc.kill("SIGTERM");
|
||||
// Immediately mark as stopped to prevent stale state
|
||||
if (inst) {
|
||||
inst.status = "stopped";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
remove(id: string): boolean {
|
||||
const instance = this.instances.get(id);
|
||||
if (!instance) return false;
|
||||
if (instance.status === "running") return false;
|
||||
instance.subscribers.clear();
|
||||
this.instances.delete(id);
|
||||
log("manager", `removed instance ${id.slice(0, 8)} group=${instance.group}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
list(): InstanceSummary[] {
|
||||
return Array.from(this.instances.values()).map(this.toSummary);
|
||||
}
|
||||
|
||||
get(id: string): AcpInstance | undefined {
|
||||
return this.instances.get(id);
|
||||
}
|
||||
|
||||
subscribe(id: string, callback: (entry: LogEntry) => void): () => void {
|
||||
const instance = this.instances.get(id);
|
||||
if (!instance) return () => {};
|
||||
instance.subscribers.add(callback);
|
||||
return () => instance.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
async shutdownAll(): Promise<void> {
|
||||
const running = Array.from(this.processes.entries());
|
||||
if (running.length === 0) return;
|
||||
|
||||
log("manager", `shutting down ${running.length} running instance(s)...`);
|
||||
for (const [id, proc] of running) {
|
||||
try {
|
||||
proc.kill("SIGTERM");
|
||||
log("manager", `sent SIGTERM to ${id.slice(0, 8)} pid=${proc.pid}`);
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = new Promise<void>((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS));
|
||||
await Promise.race([
|
||||
Promise.all(running.map(([, proc]) => proc.exited.catch(() => {}))),
|
||||
timeout,
|
||||
]);
|
||||
|
||||
for (const [id, proc] of running) {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
log("manager", `sent SIGKILL to ${id.slice(0, 8)}`);
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
}
|
||||
log("manager", "all instances shut down");
|
||||
}
|
||||
|
||||
private parseCommand(command: string): string[] {
|
||||
const args: string[] = [];
|
||||
let current = "";
|
||||
let inQuote: string | null = null;
|
||||
|
||||
for (const ch of command) {
|
||||
if (inQuote) {
|
||||
if (ch === inQuote) {
|
||||
inQuote = null;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
inQuote = ch;
|
||||
} else if (ch === " " || ch === "\t") {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
current = "";
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current) args.push(current);
|
||||
return args;
|
||||
}
|
||||
|
||||
private pipeStream(
|
||||
readable: ReadableStream<Uint8Array>,
|
||||
instanceId: string,
|
||||
stream: "stdout" | "stderr",
|
||||
) {
|
||||
const reader = readable.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
const processChunk = () => {
|
||||
reader
|
||||
.read()
|
||||
.then(({ done, value }) => {
|
||||
if (done) {
|
||||
if (buffer) this.appendLog(instanceId, buffer, stream);
|
||||
return;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line) this.appendLog(instanceId, line, stream);
|
||||
}
|
||||
processChunk();
|
||||
})
|
||||
.catch(() => {
|
||||
// stream ended or error
|
||||
});
|
||||
};
|
||||
processChunk();
|
||||
}
|
||||
|
||||
private appendLog(instanceId: string, text: string, stream: "stdout" | "stderr") {
|
||||
const instance = this.instances.get(instanceId);
|
||||
if (!instance) return;
|
||||
|
||||
const entry: LogEntry = { timestamp: Date.now(), stream, text };
|
||||
instance.logs.push(entry);
|
||||
if (instance.logs.length > MAX_LOG_LINES) {
|
||||
instance.logs.splice(0, instance.logs.length - MAX_LOG_LINES);
|
||||
}
|
||||
|
||||
for (const sub of instance.subscribers) {
|
||||
try {
|
||||
sub(entry);
|
||||
} catch {
|
||||
// subscriber error, remove it
|
||||
instance.subscribers.delete(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private notifyStatus(instance: AcpInstance) {
|
||||
const statusEntry: LogEntry = {
|
||||
timestamp: Date.now(),
|
||||
stream: "stderr",
|
||||
text: `[${instance.status}] exit code: ${instance.exitCode}`,
|
||||
};
|
||||
for (const sub of instance.subscribers) {
|
||||
try {
|
||||
sub(statusEntry);
|
||||
} catch {
|
||||
instance.subscribers.delete(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toSummary(inst: AcpInstance): InstanceSummary {
|
||||
return {
|
||||
id: inst.id,
|
||||
group: inst.group,
|
||||
command: inst.command,
|
||||
status: inst.status,
|
||||
pid: inst.pid,
|
||||
startTime: inst.startTime,
|
||||
exitCode: inst.exitCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
153
packages/acp-link/src/manager/routes.ts
Normal file
153
packages/acp-link/src/manager/routes.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Hono } from "hono";
|
||||
import type { ProcessManager } from "./manager.js";
|
||||
import { MANAGER_HTML } from "./html.js";
|
||||
|
||||
function logReq(method: string, path: string, status?: number) {
|
||||
const ts = new Date().toISOString();
|
||||
const suffix = status != null ? ` -> ${status}` : "";
|
||||
console.log(`[${ts}] [http] ${method} ${path}${suffix}`);
|
||||
}
|
||||
|
||||
export function createApp(manager: ProcessManager): Hono {
|
||||
const app = new Hono();
|
||||
|
||||
app.get("/", (c) => {
|
||||
logReq("GET", "/", 200);
|
||||
return c.html(MANAGER_HTML);
|
||||
});
|
||||
|
||||
app.get("/api/instances", (c) => {
|
||||
const list = manager.list();
|
||||
logReq("GET", "/api/instances", 200);
|
||||
return c.json(list);
|
||||
});
|
||||
|
||||
app.post("/api/instances", async (c) => {
|
||||
let body: { group?: string; command?: string };
|
||||
try {
|
||||
body = await c.req.json<{ group?: string; command?: string }>();
|
||||
} catch {
|
||||
logReq("POST", "/api/instances", 400);
|
||||
return c.json({ error: "invalid JSON body" }, 400);
|
||||
}
|
||||
if (!body.group?.trim() || !body.command?.trim()) {
|
||||
logReq("POST", "/api/instances", 400);
|
||||
return c.json({ error: "group and command are required" }, 400);
|
||||
}
|
||||
const instance = manager.create(body.group.trim(), body.command.trim());
|
||||
logReq("POST", `/api/instances group=${body.group}`, 201);
|
||||
return c.json(
|
||||
{
|
||||
id: instance.id,
|
||||
group: instance.group,
|
||||
command: instance.command,
|
||||
status: instance.status,
|
||||
pid: instance.pid,
|
||||
startTime: instance.startTime,
|
||||
exitCode: instance.exitCode,
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/instances/:id/stop", (c) => {
|
||||
const id = c.req.param("id");
|
||||
const inst = manager.get(id);
|
||||
if (!inst) {
|
||||
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 404);
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
if (inst.status !== "running") {
|
||||
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 400);
|
||||
return c.json({ error: "not running" }, 400);
|
||||
}
|
||||
manager.stop(inst.id);
|
||||
logReq("POST", `/api/instances/${id.slice(0, 8)}/stop`, 200);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete("/api/instances/:id", (c) => {
|
||||
const id = c.req.param("id");
|
||||
const inst = manager.get(id);
|
||||
if (!inst) {
|
||||
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 404);
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
if (inst.status === "running") {
|
||||
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 400);
|
||||
return c.json({ error: "still running" }, 400);
|
||||
}
|
||||
manager.remove(inst.id);
|
||||
logReq("DELETE", `/api/instances/${id.slice(0, 8)}`, 200);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/instances/:id/logs", (c) => {
|
||||
const id = c.req.param("id");
|
||||
const inst = manager.get(id);
|
||||
if (!inst) {
|
||||
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs`, 404);
|
||||
return c.json({ error: "not found" }, 404);
|
||||
}
|
||||
logReq("GET", `/api/instances/${id.slice(0, 8)}/logs SSE`);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const send = (data: string) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(data));
|
||||
} catch {
|
||||
// stream closed
|
||||
}
|
||||
};
|
||||
|
||||
// send historical logs
|
||||
for (const log of inst.logs) {
|
||||
send(`data: ${JSON.stringify(log)}\n\n`);
|
||||
}
|
||||
|
||||
// subscribe to new logs
|
||||
const unsub = manager.subscribe(inst.id, (entry) => {
|
||||
send(`data: ${JSON.stringify(entry)}\n\n`);
|
||||
});
|
||||
|
||||
// keepalive every 15s
|
||||
const keepalive = setInterval(() => {
|
||||
send(": keepalive\n\n");
|
||||
}, 15000);
|
||||
|
||||
const cleanup = () => {
|
||||
unsub();
|
||||
clearInterval(keepalive);
|
||||
logReq("SSE", `/api/instances/${id.slice(0, 8)}/logs closed`);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
};
|
||||
|
||||
c.req.raw.signal.addEventListener("abort", cleanup, { once: true });
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Catch-all: log unmatched routes for debugging
|
||||
app.all("*", (c) => {
|
||||
logReq(c.req.method, c.req.path, 404);
|
||||
return c.json({ error: "not found", path: c.req.path }, 404);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
34
packages/acp-link/src/manager/types.ts
Normal file
34
packages/acp-link/src/manager/types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export type InstanceStatus = "running" | "stopped" | "failed";
|
||||
|
||||
export interface AcpInstance {
|
||||
id: string;
|
||||
group: string;
|
||||
command: string;
|
||||
status: InstanceStatus;
|
||||
pid: number | undefined;
|
||||
startTime: number;
|
||||
exitCode: number | null;
|
||||
logs: LogEntry[];
|
||||
subscribers: Set<(entry: LogEntry) => void>;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
stream: "stdout" | "stderr";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface CreateInstanceRequest {
|
||||
group: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface InstanceSummary {
|
||||
id: string;
|
||||
group: string;
|
||||
command: string;
|
||||
status: InstanceStatus;
|
||||
pid: number | undefined;
|
||||
startTime: number;
|
||||
exitCode: number | null;
|
||||
}
|
||||
265
packages/acp-link/src/rcs-upstream.ts
Normal file
265
packages/acp-link/src/rcs-upstream.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { createLogger } from "./logger.js";
|
||||
import { decodeJsonWsMessage, WsPayloadTooLargeError } from "./ws-message.js";
|
||||
import { encodeWebSocketAuthProtocol } from "./ws-auth.js";
|
||||
|
||||
export interface RcsUpstreamConfig {
|
||||
rcsUrl: string; // e.g. "http://localhost:3000"
|
||||
apiToken: string;
|
||||
agentName: string;
|
||||
channelGroupId?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
maxSessions?: number;
|
||||
}
|
||||
|
||||
export function buildRcsWsUrl(rcsUrl: string): string {
|
||||
let raw = rcsUrl;
|
||||
raw = raw.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
|
||||
const url = new URL(raw);
|
||||
const path = url.pathname.replace(/\/+$/, "");
|
||||
if (!path || path === "/") {
|
||||
url.pathname = "/acp/ws";
|
||||
}
|
||||
url.searchParams.delete("token");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* RCS upstream client — connects acp-link to a Remote Control Server.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. connect() — opens WS to RCS
|
||||
* 2. Sends register message
|
||||
* 3. Waits for registered response
|
||||
* 4. Forwards all ACP events via send()
|
||||
* 5. Reconnects with exponential backoff on failure
|
||||
*/
|
||||
export class RcsUpstreamClient {
|
||||
private static log = createLogger("rcs-upstream");
|
||||
private ws: WebSocket | null = null;
|
||||
private registered = false;
|
||||
private reconnectAttempts = 0;
|
||||
private closed = false;
|
||||
private readonly maxReconnectDelay = 30_000;
|
||||
private readonly baseReconnectDelay = 1_000;
|
||||
/** Agent ID obtained from REST registration */
|
||||
private agentId: string | null = null;
|
||||
/** Session ID from REST registration (ACP agents auto-create a session) */
|
||||
private sessionId: string | undefined;
|
||||
|
||||
/** Handler for incoming ACP messages from RCS relay */
|
||||
private messageHandler: ((message: Record<string, unknown>) => void) | null = null;
|
||||
|
||||
constructor(private config: RcsUpstreamConfig) {}
|
||||
|
||||
/** Get the agent ID from REST registration */
|
||||
getAgentId(): string | null {
|
||||
return this.agentId;
|
||||
}
|
||||
|
||||
/** Set handler for incoming ACP messages from RCS relay */
|
||||
setMessageHandler(handler: (message: Record<string, unknown>) => void): void {
|
||||
this.messageHandler = handler;
|
||||
}
|
||||
|
||||
/** Register via REST API before establishing WS connection */
|
||||
private async registerViaRest(): Promise<string> {
|
||||
const baseUrl = this.config.rcsUrl
|
||||
.replace(/^ws:\/\//, "http://")
|
||||
.replace(/^wss:\/\//, "https://")
|
||||
.replace(/\/acp\/ws.*$/, "")
|
||||
.replace(/\/$/, "");
|
||||
|
||||
const url = `${baseUrl}/v1/environments/bridge`;
|
||||
RcsUpstreamClient.log.info({ url }, "REST register");
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${this.config.apiToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
machine_name: this.config.agentName,
|
||||
worker_type: "acp",
|
||||
bridge_id: this.config.channelGroupId || undefined,
|
||||
max_sessions: this.config.maxSessions,
|
||||
capabilities: this.config.capabilities,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`REST register failed (${resp.status}): ${text}`);
|
||||
}
|
||||
|
||||
const data = await resp.json() as { environment_id: string; environment_secret: string; status: string; session_id?: string };
|
||||
this.agentId = data.environment_id;
|
||||
this.sessionId = data.session_id;
|
||||
RcsUpstreamClient.log.info({ agentId: this.agentId, sessionId: this.sessionId }, "REST register success");
|
||||
return data.environment_id;
|
||||
}
|
||||
|
||||
/** Normalize RCS URL: accept http(s) base URL and convert to ws(s) + /acp/ws path */
|
||||
private buildWsUrl(): string {
|
||||
return buildRcsWsUrl(this.config.rcsUrl);
|
||||
}
|
||||
|
||||
/** Open connection to RCS: REST register → WS identify */
|
||||
async connect(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
|
||||
// Step 1: REST registration
|
||||
try {
|
||||
await this.registerViaRest();
|
||||
} catch (err) {
|
||||
RcsUpstreamClient.log.error({ err }, "REST registration failed");
|
||||
if (!this.closed) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: WebSocket connection with identify
|
||||
const wsUrl = this.buildWsUrl();
|
||||
RcsUpstreamClient.log.info({ url: wsUrl }, "connecting WS");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.ws = new WebSocket(wsUrl, [
|
||||
encodeWebSocketAuthProtocol(this.config.apiToken),
|
||||
]);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
RcsUpstreamClient.log.debug("ws open — sending identify");
|
||||
this.ws!.send(
|
||||
JSON.stringify({
|
||||
type: "identify",
|
||||
agent_id: this.agentId,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
let data: Record<string, unknown>;
|
||||
try {
|
||||
data = decodeJsonWsMessage(event.data);
|
||||
} catch (err) {
|
||||
if (err instanceof WsPayloadTooLargeError) {
|
||||
RcsUpstreamClient.log.warn({ error: err.message }, "server message too large");
|
||||
this.ws?.close(1009, "message too large");
|
||||
return;
|
||||
}
|
||||
RcsUpstreamClient.log.warn({ raw: String(event.data).slice(0, 200) }, "invalid JSON from server");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "identified") {
|
||||
RcsUpstreamClient.log.info({ agent_id: data.agent_id, channel_group_id: data.channel_group_id }, "identified");
|
||||
this.registered = true;
|
||||
this.reconnectAttempts = 0;
|
||||
const webBase = this.config.rcsUrl
|
||||
.replace(/^ws:\/\//, "http://")
|
||||
.replace(/^wss:\/\//, "https://")
|
||||
.replace(/\/acp\/ws.*$/, "")
|
||||
.replace(/\/$/, "");
|
||||
console.log();
|
||||
console.log(` 🔗 Dashboard: ${webBase}/code/`);
|
||||
if (this.agentId) {
|
||||
console.log(` Agent ID: ${this.agentId}`);
|
||||
}
|
||||
console.log();
|
||||
resolve();
|
||||
} else if (data.type === "registered") {
|
||||
// Legacy fallback: server still uses old register flow
|
||||
RcsUpstreamClient.log.info({ agent_id: data.agent_id }, "registered (legacy)");
|
||||
this.agentId = (data.agent_id as string) || this.agentId;
|
||||
this.registered = true;
|
||||
this.reconnectAttempts = 0;
|
||||
resolve();
|
||||
} else if (data.type === "error") {
|
||||
RcsUpstreamClient.log.error({ message: data.message }, "server error");
|
||||
if (!this.registered) {
|
||||
reject(new Error(data.message as string));
|
||||
}
|
||||
} else if (data.type === "keep_alive") {
|
||||
// ignore keepalive
|
||||
} else {
|
||||
// Forward ACP protocol messages to handler (for RCS relay support)
|
||||
RcsUpstreamClient.log.debug({ type: data.type }, "forwarding to relay handler");
|
||||
this.messageHandler?.(data);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = () => {
|
||||
// onclose fires after onerror with the actual close code, so we log there
|
||||
if (!this.registered) {
|
||||
reject(new Error("WebSocket connection failed"));
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
RcsUpstreamClient.log.info({ code: event.code, reason: event.reason || undefined }, "ws closed");
|
||||
this.registered = false;
|
||||
this.ws = null;
|
||||
if (!this.closed) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
RcsUpstreamClient.log.error({ err }, "connect threw");
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Send an ACP message to RCS for broadcast */
|
||||
send(message: object): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.registered) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
} catch (err) {
|
||||
RcsUpstreamClient.log.error({ err }, "send failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if registered with RCS */
|
||||
isRegistered(): boolean {
|
||||
return this.registered && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/** Close the RCS connection permanently */
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.registered = false;
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, "client shutdown");
|
||||
this.ws = null;
|
||||
}
|
||||
RcsUpstreamClient.log.info("closed");
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.closed) return;
|
||||
|
||||
const delay = Math.min(
|
||||
this.baseReconnectDelay * 2 ** this.reconnectAttempts,
|
||||
this.maxReconnectDelay,
|
||||
);
|
||||
const jitter = delay * Math.random() * 0.2;
|
||||
const actualDelay = delay + jitter;
|
||||
this.reconnectAttempts++;
|
||||
|
||||
RcsUpstreamClient.log.warn({ attempt: this.reconnectAttempts, delayMs: Math.round(actualDelay) }, "reconnecting");
|
||||
|
||||
setTimeout(async () => {
|
||||
if (this.closed) return;
|
||||
try {
|
||||
await this.connect();
|
||||
} catch {
|
||||
// connect() itself logs the error; nothing to add here
|
||||
}
|
||||
}, actualDelay);
|
||||
}
|
||||
}
|
||||
1167
packages/acp-link/src/server.ts
Normal file
1167
packages/acp-link/src/server.ts
Normal file
File diff suppressed because it is too large
Load Diff
150
packages/acp-link/src/types.ts
Normal file
150
packages/acp-link/src/types.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// JSON-RPC 2.0 Types
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
id: string | number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: string | number;
|
||||
result?: unknown;
|
||||
error?: JsonRpcError;
|
||||
}
|
||||
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: "2.0";
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface JsonRpcError {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export type JsonRpcMessage =
|
||||
| JsonRpcRequest
|
||||
| JsonRpcResponse
|
||||
| JsonRpcNotification;
|
||||
|
||||
// Helper to check message types
|
||||
export function isRequest(msg: JsonRpcMessage): msg is JsonRpcRequest {
|
||||
return "method" in msg && "id" in msg;
|
||||
}
|
||||
|
||||
export function isResponse(msg: JsonRpcMessage): msg is JsonRpcResponse {
|
||||
return "id" in msg && !("method" in msg);
|
||||
}
|
||||
|
||||
export function isNotification(
|
||||
msg: JsonRpcMessage,
|
||||
): msg is JsonRpcNotification {
|
||||
return "method" in msg && !("id" in msg);
|
||||
}
|
||||
|
||||
// ACP Protocol Types
|
||||
|
||||
// Client -> Server messages (from extension to proxy)
|
||||
export interface ProxyConnectParams {
|
||||
command: string; // Command to launch the agent (e.g., "claude-agent")
|
||||
args?: string[]; // Optional arguments
|
||||
cwd?: string; // Working directory for the agent
|
||||
}
|
||||
|
||||
export interface ProxyMessage {
|
||||
type: "connect" | "disconnect" | "message";
|
||||
payload?: ProxyConnectParams | JsonRpcMessage;
|
||||
}
|
||||
|
||||
// Server -> Client messages (from proxy to extension)
|
||||
export interface ProxyStatus {
|
||||
type: "status";
|
||||
connected: boolean;
|
||||
agentInfo?: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProxyAgentMessage {
|
||||
type: "agent_message";
|
||||
payload: JsonRpcMessage;
|
||||
}
|
||||
|
||||
export interface ProxyError {
|
||||
type: "error";
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export type ProxyResponse = ProxyStatus | ProxyAgentMessage | ProxyError;
|
||||
|
||||
// ACP Initialization
|
||||
export interface InitializeParams {
|
||||
protocolVersion: string;
|
||||
clientInfo: {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
capabilities?: ClientCapabilities;
|
||||
}
|
||||
|
||||
export interface ClientCapabilities {
|
||||
streaming?: boolean;
|
||||
toolApproval?: boolean;
|
||||
}
|
||||
|
||||
export interface InitializeResult {
|
||||
protocolVersion: string;
|
||||
serverInfo: {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
capabilities?: ServerCapabilities;
|
||||
}
|
||||
|
||||
export interface ServerCapabilities {
|
||||
streaming?: boolean;
|
||||
tools?: boolean;
|
||||
}
|
||||
|
||||
// ACP Session
|
||||
export interface SessionSetupParams {
|
||||
sessionId?: string;
|
||||
context?: SessionContext;
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
workingDirectory?: string;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
// ACP Prompt
|
||||
export interface PromptParams {
|
||||
sessionId: string;
|
||||
messages: PromptMessage[];
|
||||
}
|
||||
|
||||
export interface PromptMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string | ContentPart[];
|
||||
}
|
||||
|
||||
export interface ContentPart {
|
||||
type: "text" | "image" | "file";
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
// Content streaming notification
|
||||
export interface ContentNotification {
|
||||
sessionId: string;
|
||||
content: string;
|
||||
done?: boolean;
|
||||
}
|
||||
62
packages/acp-link/src/ws-auth.ts
Normal file
62
packages/acp-link/src/ws-auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
|
||||
|
||||
function sha256(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
}
|
||||
|
||||
export function encodeWebSocketAuthProtocol(token: string): string {
|
||||
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
|
||||
}
|
||||
|
||||
export function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
|
||||
if (!protocolHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const protocol of protocolHeader.split(",")) {
|
||||
const trimmed = protocol.trim();
|
||||
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
|
||||
if (!encoded) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
return token.length > 0 ? token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractBearerToken(authorizationHeader: string | undefined): string | undefined {
|
||||
return authorizationHeader?.startsWith("Bearer ")
|
||||
? authorizationHeader.slice("Bearer ".length)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function extractWebSocketAuthToken(headers: {
|
||||
authorization?: string;
|
||||
protocol?: string;
|
||||
}): string | undefined {
|
||||
return extractBearerToken(headers.authorization) ??
|
||||
decodeWebSocketAuthProtocol(headers.protocol);
|
||||
}
|
||||
|
||||
export function authTokensEqual(
|
||||
providedToken: string | undefined,
|
||||
expectedToken: string | undefined,
|
||||
): boolean {
|
||||
if (!providedToken || !expectedToken) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(sha256(providedToken), sha256(expectedToken));
|
||||
}
|
||||
60
packages/acp-link/src/ws-message.ts
Normal file
60
packages/acp-link/src/ws-message.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
export const MAX_CLIENT_WS_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export class WsPayloadTooLargeError extends Error {
|
||||
constructor(byteLength: number) {
|
||||
super(`WebSocket message too large: ${byteLength} bytes`);
|
||||
this.name = "WsPayloadTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface JsonWsMessage {
|
||||
type: string;
|
||||
payload?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function assertPayloadSize(byteLength: number): void {
|
||||
if (byteLength > MAX_CLIENT_WS_PAYLOAD_BYTES) {
|
||||
throw new WsPayloadTooLargeError(byteLength);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWsText(data: unknown): string {
|
||||
if (typeof data === "string") {
|
||||
assertPayloadSize(Buffer.byteLength(data, "utf8"));
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data instanceof ArrayBuffer) {
|
||||
assertPayloadSize(data.byteLength);
|
||||
return new TextDecoder().decode(new Uint8Array(data));
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
assertPayloadSize(data.byteLength);
|
||||
return new TextDecoder().decode(
|
||||
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(data) && data.every(Buffer.isBuffer)) {
|
||||
const byteLength = data.reduce((total, chunk) => total + chunk.byteLength, 0);
|
||||
assertPayloadSize(byteLength);
|
||||
return Buffer.concat(data, byteLength).toString("utf8");
|
||||
}
|
||||
|
||||
throw new Error("Unsupported WebSocket message payload");
|
||||
}
|
||||
|
||||
export function decodeJsonWsMessage(data: unknown): JsonWsMessage {
|
||||
const parsed = JSON.parse(decodeWsText(data)) as unknown;
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!("type" in parsed) ||
|
||||
typeof parsed.type !== "string"
|
||||
) {
|
||||
throw new Error("Invalid WebSocket message payload");
|
||||
}
|
||||
return parsed as JsonWsMessage;
|
||||
}
|
||||
38
packages/acp-link/tsconfig.json
Normal file
38
packages/acp-link/tsconfig.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ES2022",
|
||||
"module": "esnext",
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
|
||||
// Node.js module resolution
|
||||
"moduleResolution": "bundler",
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
// Output
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"types": ["bun"],
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/__tests__"]
|
||||
}
|
||||
11
packages/agent-tools/package.json
Normal file
11
packages/agent-tools/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@claude-code-best/agent-tools",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"zod": "^3.25.0"
|
||||
}
|
||||
}
|
||||
34
packages/agent-tools/src/__tests__/compat.test.ts
Normal file
34
packages/agent-tools/src/__tests__/compat.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { CoreTool, Tool, Tools, AnyObject, ToolResult, ValidationResult, PermissionResult } from '@claude-code-best/agent-tools'
|
||||
import type { Tool as HostTool } from '../../../../src/Tool.js'
|
||||
|
||||
describe('agent-tools compatibility', () => {
|
||||
test('CoreTool structural compatibility with host Tool', () => {
|
||||
// The host's Tool should structurally satisfy CoreTool
|
||||
// because it has all required fields (name, call, description, etc.)
|
||||
// This test verifies the type-level compatibility at runtime
|
||||
const mockHostTool: HostTool = {
|
||||
name: 'test',
|
||||
aliases: [],
|
||||
searchHint: 'test tool',
|
||||
inputSchema: {} as any,
|
||||
async call() { return { data: 'ok' } as any },
|
||||
async description() { return 'test' },
|
||||
async prompt() { return 'test prompt' },
|
||||
isConcurrencySafe: () => false,
|
||||
isEnabled: () => true,
|
||||
isReadOnly: () => false,
|
||||
async checkPermissions() { return { behavior: 'allow' as const, updatedInput: {} } },
|
||||
toAutoClassifierInput: () => '',
|
||||
userFacingName: () => 'test',
|
||||
maxResultSizeChars: 100000,
|
||||
mapToolResultToToolResultBlockParam: () => ({ type: 'tool_result', tool_use_id: '1', content: 'ok' }),
|
||||
renderToolUseMessage: () => null,
|
||||
}
|
||||
|
||||
// This assignment should work if HostTool structurally extends CoreTool
|
||||
const coreTool: CoreTool = mockHostTool as unknown as CoreTool
|
||||
expect(coreTool.name).toBe('test')
|
||||
expect(coreTool.isEnabled()).toBe(true)
|
||||
})
|
||||
})
|
||||
63
packages/agent-tools/src/__tests__/registry.test.ts
Normal file
63
packages/agent-tools/src/__tests__/registry.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { findToolByName, toolMatchesName } from '../registry.js'
|
||||
import type { CoreTool, Tools } from '../types.js'
|
||||
|
||||
describe('toolMatchesName', () => {
|
||||
test('matches primary name', () => {
|
||||
expect(toolMatchesName({ name: 'bash' }, 'bash')).toBe(true)
|
||||
})
|
||||
|
||||
test('does not match different name', () => {
|
||||
expect(toolMatchesName({ name: 'bash' }, 'read')).toBe(false)
|
||||
})
|
||||
|
||||
test('matches alias', () => {
|
||||
expect(toolMatchesName({ name: 'bash', aliases: ['shell', 'sh'] }, 'shell')).toBe(true)
|
||||
expect(toolMatchesName({ name: 'bash', aliases: ['shell', 'sh'] }, 'sh')).toBe(true)
|
||||
})
|
||||
|
||||
test('handles empty aliases', () => {
|
||||
expect(toolMatchesName({ name: 'bash', aliases: [] }, 'bash')).toBe(true)
|
||||
expect(toolMatchesName({ name: 'bash', aliases: [] }, 'shell')).toBe(false)
|
||||
})
|
||||
|
||||
test('handles undefined aliases', () => {
|
||||
expect(toolMatchesName({ name: 'bash' }, 'bash')).toBe(true)
|
||||
expect(toolMatchesName({ name: 'bash' }, 'shell')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findToolByName', () => {
|
||||
const tools: Tools = [
|
||||
{ name: 'bash' } as CoreTool,
|
||||
{ name: 'read', aliases: ['cat'] } as CoreTool,
|
||||
{ name: 'write', aliases: ['edit'] } as CoreTool,
|
||||
]
|
||||
|
||||
test('finds tool by primary name', () => {
|
||||
expect(findToolByName(tools, 'bash')?.name).toBe('bash')
|
||||
})
|
||||
|
||||
test('finds tool by alias', () => {
|
||||
expect(findToolByName(tools, 'cat')?.name).toBe('read')
|
||||
expect(findToolByName(tools, 'edit')?.name).toBe('write')
|
||||
})
|
||||
|
||||
test('returns undefined for unknown name', () => {
|
||||
expect(findToolByName(tools, 'unknown')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('handles empty tools array', () => {
|
||||
expect(findToolByName([], 'bash')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns first match for duplicate names', () => {
|
||||
const dupTools: Tools = [
|
||||
{ name: 'tool', aliases: ['a'] } as CoreTool,
|
||||
{ name: 'tool', aliases: ['b'] } as CoreTool,
|
||||
]
|
||||
const found = findToolByName(dupTools, 'tool')
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.aliases).toContain('a')
|
||||
})
|
||||
})
|
||||
18
packages/agent-tools/src/index.ts
Normal file
18
packages/agent-tools/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// agent-tools — Tool interface definitions and registry utilities
|
||||
// Pure types + pure functions, zero runtime dependencies
|
||||
|
||||
export type {
|
||||
AnyObject,
|
||||
ToolInputJSONSchema,
|
||||
ToolProgressData,
|
||||
ToolProgress,
|
||||
ToolCallProgress,
|
||||
ToolResult,
|
||||
ValidationResult,
|
||||
PermissionResult,
|
||||
CoreTool,
|
||||
Tool,
|
||||
Tools,
|
||||
} from './types.js'
|
||||
|
||||
export { findToolByName, toolMatchesName } from './registry.js'
|
||||
21
packages/agent-tools/src/registry.ts
Normal file
21
packages/agent-tools/src/registry.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CoreTool, Tools } from './types.js'
|
||||
|
||||
/**
|
||||
* Checks if a tool matches the given name (primary name or alias).
|
||||
*/
|
||||
export function toolMatchesName(
|
||||
tool: { name: string; aliases?: string[] },
|
||||
name: string,
|
||||
): boolean {
|
||||
return tool.name === name || (tool.aliases?.includes(name) ?? false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a tool by name or alias from a list of tools.
|
||||
*/
|
||||
export function findToolByName(
|
||||
tools: Tools,
|
||||
name: string,
|
||||
): CoreTool | undefined {
|
||||
return tools.find(t => toolMatchesName(t, name))
|
||||
}
|
||||
221
packages/agent-tools/src/types.ts
Normal file
221
packages/agent-tools/src/types.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// agent-tools — Core Tool interface definitions
|
||||
// Protocol-level types, independent of any host framework
|
||||
|
||||
import type { z } from 'zod/v4'
|
||||
|
||||
// ============================================================================
|
||||
// Schema types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Zod schema type for any object with string keys.
|
||||
* Used as the Input generic constraint for Tool.
|
||||
*/
|
||||
export type AnyObject = z.ZodType<{ [key: string]: unknown }>
|
||||
|
||||
/**
|
||||
* JSON Schema format for MCP tool input schemas.
|
||||
* MCP servers provide this directly instead of Zod schemas.
|
||||
*/
|
||||
export type ToolInputJSONSchema = {
|
||||
[x: string]: unknown
|
||||
type: 'object'
|
||||
properties?: {
|
||||
[x: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Progress types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Progress data from a running tool. Host defines concrete subtypes.
|
||||
* Typed as `any` at the protocol level — the host assigns real shapes.
|
||||
*/
|
||||
export type ToolProgressData = any
|
||||
|
||||
/**
|
||||
* A progress event from a tool execution.
|
||||
*/
|
||||
export type ToolProgress<P extends ToolProgressData = ToolProgressData> = {
|
||||
toolUseID: string
|
||||
data: P
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for receiving progress updates during tool execution.
|
||||
*/
|
||||
export type ToolCallProgress<P extends ToolProgressData = ToolProgressData> = (
|
||||
progress: ToolProgress<P>,
|
||||
) => void
|
||||
|
||||
// ============================================================================
|
||||
// Result types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result returned by a tool's call() method.
|
||||
* @template T - The output data type
|
||||
* @template Message - The message type (host-specific, defaults to unknown)
|
||||
*/
|
||||
export type ToolResult<T, Message = unknown> = {
|
||||
data: T
|
||||
newMessages?: Message[]
|
||||
contextModifier?: (context: any) => any
|
||||
/** MCP protocol metadata (structuredContent, _meta) */
|
||||
mcpMeta?: {
|
||||
_meta?: Record<string, unknown>
|
||||
structuredContent?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation & Permission types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result of tool input validation.
|
||||
*/
|
||||
export type ValidationResult =
|
||||
| { result: true }
|
||||
| { result: false; message: string; errorCode: number }
|
||||
|
||||
/**
|
||||
* Result of a permission check for a tool invocation.
|
||||
*/
|
||||
export type PermissionResult =
|
||||
| { behavior: 'allow'; updatedInput: Record<string, unknown> }
|
||||
| { behavior: 'deny'; message: string }
|
||||
| { behavior: 'passthrough' }
|
||||
|
||||
// ============================================================================
|
||||
// Core Tool interface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* The host-agnostic core Tool interface.
|
||||
*
|
||||
* This defines the protocol-level contract for any tool — independent of
|
||||
* React rendering, specific context types, or host infrastructure.
|
||||
*
|
||||
* The host (Claude Code) extends this with render methods, richer context
|
||||
* types, and other host-specific features. Host tools structurally satisfy
|
||||
* this interface because they implement all required fields.
|
||||
*
|
||||
* @template Input - Zod schema type for tool input
|
||||
* @template Output - Tool output data type
|
||||
* @template P - Tool progress data type
|
||||
* @template Context - Tool execution context type (host-specific)
|
||||
*/
|
||||
export interface CoreTool<
|
||||
Input extends AnyObject = AnyObject,
|
||||
Output = unknown,
|
||||
P extends ToolProgressData = ToolProgressData,
|
||||
Context = unknown,
|
||||
> {
|
||||
// ── Identity ──
|
||||
readonly name: string
|
||||
aliases?: string[]
|
||||
searchHint?: string
|
||||
|
||||
// ── Schema ──
|
||||
readonly inputSchema: Input
|
||||
readonly inputJSONSchema?: ToolInputJSONSchema
|
||||
outputSchema?: z.ZodType<unknown>
|
||||
|
||||
// ── Execution ──
|
||||
call(
|
||||
args: z.infer<Input>,
|
||||
context: Context,
|
||||
canUseTool: (...args: any[]) => Promise<any>,
|
||||
parentMessage: any,
|
||||
onProgress?: ToolCallProgress<P>,
|
||||
): Promise<ToolResult<Output>>
|
||||
|
||||
// ── Description ──
|
||||
description(
|
||||
input: z.infer<Input>,
|
||||
options: {
|
||||
isNonInteractiveSession: boolean
|
||||
toolPermissionContext: any
|
||||
tools: readonly CoreTool[]
|
||||
},
|
||||
): Promise<string>
|
||||
|
||||
prompt(options: {
|
||||
getToolPermissionContext: () => Promise<any>
|
||||
tools: readonly CoreTool[]
|
||||
agents: any[]
|
||||
allowedAgentTypes?: string[]
|
||||
}): Promise<string>
|
||||
|
||||
// ── Behavioral properties ──
|
||||
isConcurrencySafe(input: z.infer<Input>): boolean
|
||||
isEnabled(): boolean
|
||||
isReadOnly(input: z.infer<Input>): boolean
|
||||
isDestructive?(input: z.infer<Input>): boolean
|
||||
isOpenWorld?(input: z.infer<Input>): boolean
|
||||
interruptBehavior?(): 'cancel' | 'block'
|
||||
requiresUserInteraction?(): boolean
|
||||
|
||||
// ── MCP markers ──
|
||||
isMcp?: boolean
|
||||
isLsp?: boolean
|
||||
readonly shouldDefer?: boolean
|
||||
readonly alwaysLoad?: boolean
|
||||
mcpInfo?: { serverName: string; toolName: string }
|
||||
|
||||
// ── Permissions ──
|
||||
validateInput?(
|
||||
input: z.infer<Input>,
|
||||
context: Context,
|
||||
): Promise<ValidationResult>
|
||||
|
||||
checkPermissions(
|
||||
input: z.infer<Input>,
|
||||
context: Context,
|
||||
): Promise<PermissionResult>
|
||||
|
||||
// ── Utility ──
|
||||
inputsEquivalent?(a: z.infer<Input>, b: z.infer<Input>): boolean
|
||||
getPath?(input: z.infer<Input>): string
|
||||
toAutoClassifierInput(input: z.infer<Input>): unknown
|
||||
backfillObservableInput?(input: Record<string, unknown>): void
|
||||
|
||||
// ── Output ──
|
||||
maxResultSizeChars: number
|
||||
userFacingName(input: Partial<z.infer<Input>> | undefined): string
|
||||
mapToolResultToToolResultBlockParam(
|
||||
content: Output,
|
||||
toolUseID: string,
|
||||
): any
|
||||
|
||||
// ── Optional output helpers ──
|
||||
isResultTruncated?(output: Output): boolean
|
||||
getToolUseSummary?(input: Partial<z.infer<Input>> | undefined): string | null
|
||||
getActivityDescription?(
|
||||
input: Partial<z.infer<Input>> | undefined,
|
||||
): string | null
|
||||
isTransparentWrapper?(): boolean
|
||||
isSearchOrReadCommand?(input: z.infer<Input>): {
|
||||
isSearch: boolean
|
||||
isRead: boolean
|
||||
isList?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool with a generic context type.
|
||||
* This is the default export — hosts can specify their own Context type.
|
||||
*/
|
||||
export type Tool<
|
||||
Input extends AnyObject = AnyObject,
|
||||
Output = unknown,
|
||||
P extends ToolProgressData = ToolProgressData,
|
||||
> = CoreTool<Input, Output, P>
|
||||
|
||||
/**
|
||||
* A collection of tools.
|
||||
*/
|
||||
export type Tools = readonly CoreTool[]
|
||||
5
packages/agent-tools/tsconfig.json
Normal file
5
packages/agent-tools/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,3 +1,32 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
// createRequire works in both Bun and Node.js ESM contexts.
|
||||
// Needed because this package is "type": "module" but uses require() for
|
||||
// loading native .node addons — bare require is not available in Node.js ESM.
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
|
||||
/**
|
||||
* Resolve the "vendor root" directory where native .node binaries live.
|
||||
*
|
||||
* - Dev mode: import.meta.url → packages/audio-capture-napi/src/index.ts
|
||||
* → vendor root = <project>/vendor/
|
||||
* - Bun build: import.meta.url → dist/chunk-xxx.js
|
||||
* → vendor root = <project>/dist/vendor/
|
||||
* - Vite build: import.meta.url → dist/chunks/chunk-xxx.js
|
||||
* → vendor root = <project>/dist/vendor/
|
||||
*/
|
||||
function getVendorRoot(): string {
|
||||
const filePath = fileURLToPath(import.meta.url)
|
||||
const dir = dirname(filePath)
|
||||
const parts = dir.split(sep)
|
||||
const distIdx = parts.lastIndexOf('dist')
|
||||
if (distIdx !== -1) {
|
||||
return parts.slice(0, distIdx + 1).join(sep) + sep + 'vendor'
|
||||
}
|
||||
// Dev mode — go up from packages/audio-capture-napi/src/ to project root
|
||||
return resolve(dir, '..', '..', '..', 'vendor')
|
||||
}
|
||||
|
||||
type AudioCaptureNapi = {
|
||||
startRecording(
|
||||
@@ -41,7 +70,7 @@ function loadModule(): AudioCaptureNapi | null {
|
||||
if (process.env.AUDIO_CAPTURE_NODE_PATH) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
cachedModule = require(
|
||||
cachedModule = nodeRequire(
|
||||
process.env.AUDIO_CAPTURE_NODE_PATH,
|
||||
) as AudioCaptureNapi
|
||||
return cachedModule
|
||||
@@ -50,20 +79,23 @@ function loadModule(): AudioCaptureNapi | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Candidates 2-4: npm-install, dev/source, and workspace layouts.
|
||||
// In bundled output, require() resolves relative to cli.js at the package root.
|
||||
// In dev, it resolves relative to this file. When loaded from a workspace
|
||||
// package (packages/audio-capture-napi/src/), we need an absolute path fallback.
|
||||
// Candidates 2-5: resolved vendor path + relative fallbacks.
|
||||
// The primary candidate uses getVendorRoot() to find the correct dist root
|
||||
// regardless of chunk nesting depth. Relative fallbacks cover edge cases.
|
||||
const platformDir = `${process.arch}-${platform}`
|
||||
const binaryRel = `audio-capture/${platformDir}/audio-capture.node`
|
||||
const vendorRoot = getVendorRoot()
|
||||
const fallbacks = [
|
||||
`./vendor/audio-capture/${platformDir}/audio-capture.node`,
|
||||
`../audio-capture/${platformDir}/audio-capture.node`,
|
||||
`${process.cwd()}/vendor/audio-capture/${platformDir}/audio-capture.node`,
|
||||
resolve(vendorRoot, binaryRel),
|
||||
`./vendor/${binaryRel}`,
|
||||
`../vendor/${binaryRel}`,
|
||||
`../../vendor/${binaryRel}`,
|
||||
`${process.cwd()}/vendor/${binaryRel}`,
|
||||
]
|
||||
for (const p of fallbacks) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
cachedModule = require(p) as AudioCaptureNapi
|
||||
cachedModule = nodeRequire(p) as AudioCaptureNapi
|
||||
return cachedModule
|
||||
} catch {
|
||||
// try next
|
||||
|
||||
5
packages/audio-capture-napi/tsconfig.json
Normal file
5
packages/audio-capture-napi/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
16
packages/builtin-tools/package.json
Normal file
16
packages/builtin-tools/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@claude-code-best/builtin-tools",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./tools/*": "./src/tools/*",
|
||||
"./utils": "./src/utils.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@claude-code-best/agent-tools": "workspace:*"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user