create-agent-tui
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseCreate Agent TUI
Create Agent TUI
Scaffolds a complete agent TUI in TypeScript targeting OpenRouter. The generated project uses for the inner loop (model calls, tool execution, stop conditions) and provides the outer shell: a customizable terminal interface, configuration, session management, tool definitions, and an entry point.
@openrouter/agentArchitecture draws from three production agent systems:
- pi-mono/coding-agent — three-layer separation, JSONL sessions, pluggable tool operations
- Claude Code — tool metadata (read-only, destructive, approval), system prompt composition
- Codex CLI — layered config, approval flow with session caching, structured logging
针对OpenRouter,在TypeScript中搭建完整的Agent TUI。生成的项目使用处理内部循环(模型调用、工具执行、终止条件),并提供外层框架:可自定义的终端界面、配置、会话管理、工具定义以及入口文件。
@openrouter/agent架构借鉴自三个生产级Agent系统:
- pi-mono/coding-agent — 三层分离、JSONL会话、可插拔工具操作
- Claude Code — 工具元数据(只读、破坏性、需审批)、系统提示词组合
- Codex CLI — 分层配置、带会话缓存的审批流程、结构化日志
Prerequisites
前置要求
- Node.js 18+
- from openrouter.ai/settings/keys
OPENROUTER_API_KEY - For full SDK reference, see the skill
openrouter-typescript-sdk
- Node.js 18+
- 从openrouter.ai/settings/keys获取
OPENROUTER_API_KEY - 如需完整SDK参考,请查看技能
openrouter-typescript-sdk
Decision Tree
决策树
| User wants to... | Action |
|---|---|
| Build a new agent from scratch | Present checklist below → follow Generation Workflow |
| Add tools to an existing harness | Read references/tools.md, present tool checklist only |
| Add a harness module | Read references/modules.md, generate the module |
| Add an API server entry point | Read references/server-entry-points.md |
| 用户想要... | 操作 |
|---|---|
| 从零开始构建新Agent | 展示下方检查清单 → 遵循生成流程 |
| 为现有框架添加工具 | 阅读references/tools.md,仅展示工具检查清单 |
| 添加框架模块 | 阅读references/modules.md,生成对应模块 |
| 添加API服务器入口 | 阅读references/server-entry-points.md |
Interactive Tool Checklist
交互式工具检查清单
Present this as a multi-select checklist. Items marked ON are pre-selected defaults.
以多选检查清单形式展示。标记为ON的是预选中的默认项。
OpenRouter Server Tools (server-side, zero implementation)
OpenRouter服务器端工具(服务端执行,无需客户端实现)
| Tool | Type string | Default | Config |
|---|---|---|---|
| Web Search | | ON | engine, max_results, domain filtering |
| Datetime | | ON | timezone |
| Image Generation | | OFF | model, quality, size, format |
Server tools go in the array alongside user-defined tools. No client code needed — OpenRouter executes them.
tools| 工具 | 类型字符串 | 默认状态 | 配置项 |
|---|---|---|---|
| Web搜索 | | ON | 引擎、最大结果数、域名过滤 |
| 日期时间 | | ON | 时区 |
| 图片生成 | | OFF | 模型、质量、尺寸、格式 |
服务器端工具需与用户自定义工具一同放入数组中。无需客户端代码——由OpenRouter执行。
toolsUser-Defined Tools (client-side, generated into src/tools/)
用户自定义工具(客户端执行,生成至src/tools/目录)
| Tool | Default | Description |
|---|---|---|
| File Read | ON | Read files with offset/limit, detect images |
| File Write | ON | Write/create files, auto-create directories |
| File Edit | ON | Search-and-replace with diff validation |
| Glob/Find | ON | File discovery by glob pattern |
| Grep/Search | ON | Content search by regex |
| Directory List | ON | List directory contents |
| Shell/Bash | ON | Execute commands with timeout and output capture |
| JS REPL | OFF | Persistent Node.js environment |
| Sub-agent Spawn | OFF | Delegate tasks to child agents |
| Plan/Todo | OFF | Track multi-step task progress |
| Request User Input | OFF | Structured multiple-choice questions |
| Web Fetch | OFF | Fetch and extract text from web pages |
| View Image | OFF | Read local images as base64 |
| Custom Tool Template | ON | Empty skeleton for domain-specific tools |
| 工具 | 默认状态 | 描述 |
|---|---|---|
| 文件读取 | ON | 按偏移量/限制读取文件,支持图片检测 |
| 文件写入 | ON | 写入/创建文件,自动创建目录 |
| 文件编辑 | ON | 带差异验证的搜索替换 |
| 全局匹配/查找 | ON | 通过glob模式发现文件 |
| 内容搜索 | ON | 通过正则表达式搜索内容 |
| 目录列表 | ON | 列出目录内容 |
| Shell/Bash | ON | 执行命令并捕获输出,支持超时设置 |
| JS REPL | OFF | 持久化Node.js环境 |
| 子Agent生成 | OFF | 将任务委托给子Agent |
| 计划/待办 | OFF | 跟踪多步骤任务进度 |
| 请求用户输入 | OFF | 结构化选择题交互 |
| Web抓取 | OFF | 抓取并提取网页文本 |
| 图片查看 | OFF | 将本地图片读取为base64格式 |
| 自定义工具模板 | ON | 用于领域特定工具的空骨架 |
Harness Modules (architectural components)
框架模块(架构组件)
| Module | Default | Description |
|---|---|---|
| Session Persistence | ON | JSONL append-only conversation log |
| ASCII Logo Banner | OFF | Custom ASCII art banner on startup — ask for project name |
| Context Compaction | OFF | Summarize older messages when context is long |
| System Prompt Composition | OFF | Assemble instructions from static + dynamic context |
| Tool Permissions / Approval | OFF | Gate dangerous tools behind user confirmation |
| Structured Event Logging | OFF | Emit events for tool calls, API requests, errors |
| OFF | |
| OFF | |
| Multi-line Input | OFF | Shift+Enter for multi-line (requires raw terminal mode) |
| 模块 | 默认状态 | 描述 |
|---|---|---|
| 会话持久化 | ON | JSONL追加式对话日志 |
| ASCII Logo横幅 | OFF | 启动时显示自定义ASCII艺术横幅——需询问项目名称 |
| 上下文压缩 | OFF | 当上下文过长时总结旧消息 |
| 系统提示词组合 | OFF | 从静态+动态上下文组装指令 |
| 工具权限/审批 | OFF | 危险工具需用户确认后方可执行 |
| 结构化事件日志 | OFF | 输出工具调用、API请求、错误等事件 |
| OFF | 使用 |
| OFF | 使用 |
| 多行输入 | OFF | Shift+Enter触发多行输入(需原生终端模式) |
Slash Commands (user-facing REPL commands)
斜杠命令(用户可见的REPL命令)
| Command | Default | Description |
|---|---|---|
| ON | Switch model via OpenRouter API |
| ON | Start a fresh conversation |
| ON | List available commands |
| OFF | Manually trigger context compaction |
| OFF | Show session metadata and token usage |
| OFF | Save conversation as Markdown |
When slash commands are enabled, generate with a command registry. See references/slash-commands.md for specs.
src/commands.ts| 命令 | 默认状态 | 描述 |
|---|---|---|
| ON | 通过OpenRouter API切换模型 |
| ON | 开启新对话 |
| ON | 列出可用命令 |
| OFF | 手动触发上下文压缩 |
| OFF | 显示会话元数据与令牌使用情况 |
| OFF | 将对话保存为Markdown格式 |
若启用斜杠命令,需生成包含命令注册表的文件。具体规范请查看references/slash-commands.md。
src/commands.tsVisual Customization (present as single-select for each)
视觉自定义(每项为单选选择)
Input style — how the prompt looks. See references/input-styles.md:
| Style | Default | Description |
|---|---|---|
| ON | Full-width background box with |
| Horizontal | |
| Simple | |
| Other | User describes what they want — implement a custom input style |
Tool display — how tool calls appear during execution. See references/tool-display.md:
| Style | Default | Description |
|---|---|---|
| ON | Bold action labels with tree-branch output |
| Per-call | |
| Aggregated one-liner summaries | |
| No tool output | |
| Other | User describes what they want — implement a custom display |
Loader animation — shown while waiting for model response. See references/loader.md:
| Style | Default | Description |
|---|---|---|
| ON | Braille dot spinner (⠋⠙⠹…) to the left of the text |
| Scrolling color shimmer over the loader text | |
| Trailing dots ( | |
| Other | User describes what they want — implement a custom animation |
Also ask for the loader text (default: ).
"Working"输入样式 — 提示框外观。查看references/input-styles.md:
| 样式 | 默认状态 | 描述 |
|---|---|---|
| ON | 全宽背景框搭配 |
| 输入框上下显示水平 | |
| 简单的 | |
| 其他 | 用户描述需求——实现自定义输入样式 |
工具显示 — 工具执行时的展示方式。查看references/tool-display.md:
| 样式 | 默认状态 | 描述 |
|---|---|---|
| ON | 加粗动作标签搭配树状分支输出 |
| 每个调用显示 | |
| 聚合式单行摘要 | |
| 不显示工具输出 | |
| 其他 | 用户描述需求——实现自定义显示方式 |
加载动画 — 等待模型响应时显示。查看references/loader.md:
| 样式 | 默认状态 | 描述 |
|---|---|---|
| ON | 文本左侧显示盲文点旋转动画(⠋⠙⠹…) |
| 加载文本上显示滚动色彩渐变效果 | |
| 尾部显示动态点( | |
| 其他 | 用户描述需求——实现自定义动画 |
同时询问加载文本(默认值:)。
"Working"Generation Workflow
生成流程
After getting checklist selections, follow this workflow:
- [ ] Generate package.json with dependencies
- [ ] Generate src/config.ts (add showBanner field if ASCII Logo Banner is ON)
- [ ] Generate src/tools/index.ts wiring selected tools + server tools
- [ ] Generate selected tool files in src/tools/ (see Tool Pattern below, specs in references/tools.md)
- [ ] Generate src/agent.ts (core runner)
- [ ] Generate selected harness modules (specs in references/modules.md)
- [ ] Generate src/terminal-bg.ts (adaptive input background — see references/tui.md)
- [ ] Generate input style functions in src/cli.ts (block/bordered/plain — see references/input-styles.md)
- [ ] Generate src/renderer.ts (tool display — see references/tool-display.md)
- [ ] Generate src/loader.ts (loader animation — see references/loader.md)
- [ ] If slash commands selected: generate src/commands.ts (see references/slash-commands.md)
- [ ] If ASCII Logo Banner is ON: generate src/banner.ts (see ASCII Logo Banner section below)
- [ ] Generate src/cli.ts entry point (or src/server.ts — see references/server-entry-points.md)
- [ ] Generate .env.example with OPENROUTER_API_KEY=
- [ ] Verify: run npx tsc --noEmit to check types获取检查清单选择后,遵循以下流程:
- [ ] 生成包含依赖的package.json
- [ ] 生成src/config.ts(若启用ASCII Logo横幅则添加showBanner字段)
- [ ] 生成src/tools/index.ts,关联选中的工具与服务器端工具
- [ ] 在src/tools/目录生成选中的工具文件(遵循下方工具模式,规范见references/tools.md)
- [ ] 生成src/agent.ts(核心运行器)
- [ ] 生成选中的框架模块(规范见references/modules.md)
- [ ] 生成src/terminal-bg.ts(自适应输入背景——见references/tui.md)
- [ ] 在src/cli.ts中生成输入样式函数(block/bordered/plain——见references/input-styles.md)
- [ ] 生成src/renderer.ts(工具显示——见references/tool-display.md)
- [ ] 生成src/loader.ts(加载动画——见references/loader.md)
- [ ] 若选中斜杠命令:生成src/commands.ts(见references/slash-commands.md)
- [ ] 若启用ASCII Logo横幅:生成src/banner.ts(见下方ASCII Logo横幅章节)
- [ ] 生成src/cli.ts入口文件(或src/server.ts——见references/server-entry-points.md)
- [ ] 生成包含OPENROUTER_API_KEY=的.env.example
- [ ] 验证:运行npx tsc --noEmit检查类型Tool Pattern
工具模式
All user-defined tools follow this pattern using . Here is one complete example — all other tools in references/tools.md follow the same shape:
@openrouter/agent/tooltypescript
import { tool } from '@openrouter/agent/tool';
import { z } from 'zod';
import { readFile, stat } from 'fs/promises';
export const fileReadTool = tool({
name: 'file_read',
description: 'Read the contents of a file at the given path',
inputSchema: z.object({
path: z.string().describe('Absolute path to the file'),
offset: z.number().optional().describe('Start reading from this line (1-indexed)'),
limit: z.number().optional().describe('Maximum number of lines to return'),
}),
execute: async ({ path, offset, limit }) => {
try {
const content = await readFile(path, 'utf-8');
const lines = content.split('\n');
const start = offset ? offset - 1 : 0;
const end = limit ? start + limit : lines.length;
const slice = lines.slice(start, end);
return {
content: slice.join('\n'),
totalLines: lines.length,
...(end < lines.length && { truncated: true, nextOffset: end + 1 }),
};
} catch (err: any) {
if (err.code === 'ENOENT') return { error: `File not found: ${path}` };
if (err.code === 'EACCES') return { error: `Permission denied: ${path}` };
return { error: err.message };
}
},
});For specs of all other tools, see references/tools.md.
所有用户自定义工具均遵循使用的模式。以下是完整示例——references/tools.md中的所有其他工具均采用相同结构:
@openrouter/agent/tooltypescript
import { tool } from '@openrouter/agent/tool';
import { z } from 'zod';
import { readFile, stat } from 'fs/promises';
export const fileReadTool = tool({
name: 'file_read',
description: 'Read the contents of a file at the given path',
inputSchema: z.object({
path: z.string().describe('Absolute path to the file'),
offset: z.number().optional().describe('Start reading from this line (1-indexed)'),
limit: z.number().optional().describe('Maximum number of lines to return'),
}),
execute: async ({ path, offset, limit }) => {
try {
const content = await readFile(path, 'utf-8');
const lines = content.split('\
');
const start = offset ? offset - 1 : 0;
const end = limit ? start + limit : lines.length;
const slice = lines.slice(start, end);
return {
content: slice.join('\
'),
totalLines: lines.length,
...(end < lines.length && { truncated: true, nextOffset: end + 1 }),
};
} catch (err: any) {
if (err.code === 'ENOENT') return { error: `File not found: ${path}` };
if (err.code === 'EACCES') return { error: `Permission denied: ${path}` };
return { error: err.message };
}
},
});所有其他工具的规范请查看references/tools.md。
Core Files
核心文件
These files are always generated. The agent adapts them based on checklist selections.
以下文件会始终生成。Agent会根据检查清单选择适配这些文件。
package.json
package.json
Initialize the project and install dependencies at their latest versions:
bash
npm init -y
npm pkg set type=module
npm pkg set scripts.start="tsx src/cli.ts"
npm pkg set scripts.dev="tsx watch src/cli.ts"
npm install @openrouter/agent glob zod
npm install -D tsx typescript @types/node初始化项目并安装最新版本的依赖:
bash
npm init -y
npm pkg set type=module
npm pkg set scripts.start="tsx src/cli.ts"
npm pkg set scripts.dev="tsx watch src/cli.ts"
npm install @openrouter/agent glob zod
npm install -D tsx typescript @types/nodetsconfig.json
tsconfig.json
json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}src/config.ts
src/config.ts
typescript
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
export interface DisplayConfig {
toolDisplay: 'emoji' | 'grouped' | 'minimal' | 'hidden';
reasoning: boolean;
inputStyle: 'block' | 'bordered' | 'plain';
}
export interface AgentConfig {
apiKey: string;
model: string;
systemPrompt: string;
maxSteps: number;
maxCost: number;
sessionDir: string;
showBanner: boolean;
display: DisplayConfig;
slashCommands: boolean;
}
const DEFAULTS: AgentConfig = {
apiKey: '',
model: 'anthropic/claude-opus-4.7',
systemPrompt: [
'You are a coding assistant with access to tools for reading, writing, editing, and searching files, and running shell commands.',
'',
'Current working directory: {cwd}',
'',
'Guidelines:',
'- Use your tools proactively. Explore the codebase to find answers instead of asking the user.',
'- Keep working until the task is fully resolved before responding.',
'- Do not guess or make up information — use your tools to verify.',
'- Be concise and direct.',
'- Show file paths clearly when working with files.',
'- Prefer grep and glob tools over shell commands for file search.',
'- When editing code, make minimal targeted changes consistent with the existing style.',
].join('\n'),
maxSteps: 20,
maxCost: 1.0,
sessionDir: '.sessions',
showBanner: false,
display: { toolDisplay: 'grouped', reasoning: false, inputStyle: 'block' },
slashCommands: true,
};
export function loadConfig(overrides: Partial<AgentConfig> = {}): AgentConfig {
let config = { ...DEFAULTS };
const configPath = resolve('agent.config.json');
if (existsSync(configPath)) {
const file = JSON.parse(readFileSync(configPath, 'utf-8'));
if (file.display) {
config.display = { ...config.display, ...file.display };
}
config = { ...config, ...file, display: config.display };
}
if (process.env.OPENROUTER_API_KEY) config.apiKey = process.env.OPENROUTER_API_KEY;
if (process.env.AGENT_MODEL) config.model = process.env.AGENT_MODEL;
if (process.env.AGENT_MAX_STEPS) config.maxSteps = Number(process.env.AGENT_MAX_STEPS);
if (process.env.AGENT_MAX_COST) config.maxCost = Number(process.env.AGENT_MAX_COST);
if (overrides.display) {
config.display = { ...config.display, ...overrides.display };
}
config = { ...config, ...overrides, display: config.display };
if (!config.apiKey) throw new Error('OPENROUTER_API_KEY is required.');
return config;
}typescript
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
export interface DisplayConfig {
toolDisplay: 'emoji' | 'grouped' | 'minimal' | 'hidden';
reasoning: boolean;
inputStyle: 'block' | 'bordered' | 'plain';
}
export interface AgentConfig {
apiKey: string;
model: string;
systemPrompt: string;
maxSteps: number;
maxCost: number;
sessionDir: string;
showBanner: boolean;
display: DisplayConfig;
slashCommands: boolean;
}
const DEFAULTS: AgentConfig = {
apiKey: '',
model: 'anthropic/claude-opus-4.7',
systemPrompt: [
'You are a coding assistant with access to tools for reading, writing, editing, and searching files, and running shell commands.',
'',
'Current working directory: {cwd}',
'',
'Guidelines:',
'- Use your tools proactively. Explore the codebase to find answers instead of asking the user.',
'- Keep working until the task is fully resolved before responding.',
'- Do not guess or make up information — use your tools to verify.',
'- Be concise and direct.',
'- Show file paths clearly when working with files.',
'- Prefer grep and glob tools over shell commands for file search.',
'- When editing code, make minimal targeted changes consistent with the existing style.',
].join('\
'),
maxSteps: 20,
maxCost: 1.0,
sessionDir: '.sessions',
showBanner: false,
display: { toolDisplay: 'grouped', reasoning: false, inputStyle: 'block' },
slashCommands: true,
};
export function loadConfig(overrides: Partial<AgentConfig> = {}): AgentConfig {
let config = { ...DEFAULTS };
const configPath = resolve('agent.config.json');
if (existsSync(configPath)) {
const file = JSON.parse(readFileSync(configPath, 'utf-8'));
if (file.display) {
config.display = { ...config.display, ...file.display };
}
config = { ...config, ...file, display: config.display };
}
if (process.env.OPENROUTER_API_KEY) config.apiKey = process.env.OPENROUTER_API_KEY;
if (process.env.AGENT_MODEL) config.model = process.env.AGENT_MODEL;
if (process.env.AGENT_MAX_STEPS) config.maxSteps = Number(process.env.AGENT_MAX_STEPS);
if (process.env.AGENT_MAX_COST) config.maxCost = Number(process.env.AGENT_MAX_COST);
if (overrides.display) {
config.display = { ...config.display, ...overrides.display };
}
config = { ...config, ...overrides, display: config.display };
if (!config.apiKey) throw new Error('OPENROUTER_API_KEY is required.');
return config;
}src/tools/index.ts
src/tools/index.ts
Adapt imports based on checklist selections. This example includes all default-ON tools:
typescript
import { serverTool } from '@openrouter/agent';
import { fileReadTool } from './file-read.js';
import { fileWriteTool } from './file-write.js';
import { fileEditTool } from './file-edit.js';
import { globTool } from './glob.js';
import { grepTool } from './grep.js';
import { listDirTool } from './list-dir.js';
import { shellTool } from './shell.js';
export const tools = [
// User-defined tools — executed client-side
fileReadTool,
fileWriteTool,
fileEditTool,
globTool,
grepTool,
listDirTool,
shellTool,
// Server tools — executed by OpenRouter, no client implementation needed
serverTool({ type: 'openrouter:web_search' }),
serverTool({ type: 'openrouter:datetime', parameters: { timezone: 'UTC' } }),
];根据检查清单选择适配导入内容。以下示例包含所有默认启用的工具:
typescript
import { serverTool } from '@openrouter/agent';
import { fileReadTool } from './file-read.js';
import { fileWriteTool } from './file-write.js';
import { fileEditTool } from './file-edit.js';
import { globTool } from './glob.js';
import { grepTool } from './grep.js';
import { listDirTool } from './list-dir.js';
import { shellTool } from './shell.js';
export const tools = [
// 用户自定义工具——客户端执行
fileReadTool,
fileWriteTool,
fileEditTool,
globTool,
grepTool,
listDirTool,
shellTool,
// 服务器端工具——由OpenRouter执行,无需客户端实现
serverTool({ type: 'openrouter:web_search' }),
serverTool({ type: 'openrouter:datetime', parameters: { timezone: 'UTC' } }),
];src/agent.ts
src/agent.ts
typescript
import { OpenRouter } from '@openrouter/agent';
import type { Item } from '@openrouter/agent';
import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions';
import type { AgentConfig } from './config.js';
import { tools } from './tools/index.js';
export type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string };
export type AgentEvent =
| { type: 'text'; delta: string }
| { type: 'tool_call'; name: string; callId: string; args: Record<string, unknown> }
| { type: 'tool_result'; name: string; callId: string; output: string }
| { type: 'reasoning'; delta: string };
export async function runAgent(
config: AgentConfig,
input: string | ChatMessage[],
options?: { onEvent?: (event: AgentEvent) => void; signal?: AbortSignal },
) {
const client = new OpenRouter({ apiKey: config.apiKey });
const result = client.callModel({
model: config.model,
instructions: config.systemPrompt.replace('{cwd}', process.cwd()),
input: input as string | Item[],
tools,
stopWhen: [stepCountIs(config.maxSteps), maxCost(config.maxCost)],
});
if (options?.onEvent) {
let lastTextLen = 0;
const callNames = new Map<string, string>();
for await (const item of result.getItemsStream()) {
if (options?.signal?.aborted) break;
if (item.type === 'message') {
const text = item.content
?.filter((c): c is { type: 'output_text'; text: string } => 'text' in c)
.map((c) => c.text)
.join('') ?? '';
if (text.length > lastTextLen) {
options.onEvent({ type: 'text', delta: text.slice(lastTextLen) });
lastTextLen = text.length;
}
} else if (item.type === 'function_call') {
callNames.set(item.callId, item.name);
if (item.status === 'completed') {
const args = (() => { try { return item.arguments ? JSON.parse(item.arguments) : {}; } catch { return {}; } })();
options.onEvent({ type: 'tool_call', name: item.name, callId: item.callId, args });
}
} else if (item.type === 'function_call_output') {
const out = typeof item.output === 'string' ? item.output : JSON.stringify(item.output);
options.onEvent({
type: 'tool_result',
name: callNames.get(item.callId) ?? 'unknown',
callId: item.callId,
output: out.length > 200 ? out.slice(0, 200) + '…' : out,
});
} else if (item.type === 'reasoning') {
const text = item.summary?.map((s: { text: string }) => s.text).join('') ?? '';
if (text) options.onEvent({ type: 'reasoning', delta: text });
}
}
}
const response = await result.getResponse();
return { text: response.outputText ?? '', usage: response.usage, output: response.output };
}
export async function runAgentWithRetry(
config: AgentConfig,
input: string | ChatMessage[],
options?: { onEvent?: (event: AgentEvent) => void; signal?: AbortSignal; maxRetries?: number },
) {
for (let attempt = 0, max = options?.maxRetries ?? 3; attempt <= max; attempt++) {
try { return await runAgent(config, input, options); }
catch (err: any) {
const s = err?.status ?? err?.statusCode;
if (!(s === 429 || (s >= 500 && s < 600)) || attempt === max) throw err;
await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** attempt, 30000)));
}
}
throw new Error('Unreachable');
}typescript
import { OpenRouter } from '@openrouter/agent';
import type { Item } from '@openrouter/agent';
import { stepCountIs, maxCost } from '@openrouter/agent/stop-conditions';
import type { AgentConfig } from './config.js';
import { tools } from './tools/index.js';
export type ChatMessage = { role: 'user' | 'assistant' | 'system'; content: string };
export type AgentEvent =
| { type: 'text'; delta: string }
| { type: 'tool_call'; name: string; callId: string; args: Record<string, unknown> }
| { type: 'tool_result'; name: string; callId: string; output: string }
| { type: 'reasoning'; delta: string };
export async function runAgent(
config: AgentConfig,
input: string | ChatMessage[],
options?: { onEvent?: (event: AgentEvent) => void; signal?: AbortSignal },
) {
const client = new OpenRouter({ apiKey: config.apiKey });
const result = client.callModel({
model: config.model,
instructions: config.systemPrompt.replace('{cwd}', process.cwd()),
input: input as string | Item[],
tools,
stopWhen: [stepCountIs(config.maxSteps), maxCost(config.maxCost)],
});
if (options?.onEvent) {
let lastTextLen = 0;
const callNames = new Map<string, string>();
for await (const item of result.getItemsStream()) {
if (options?.signal?.aborted) break;
if (item.type === 'message') {
const text = item.content
?.filter((c): c is { type: 'output_text'; text: string } => 'text' in c)
.map((c) => c.text)
.join('') ?? '';
if (text.length > lastTextLen) {
options.onEvent({ type: 'text', delta: text.slice(lastTextLen) });
lastTextLen = text.length;
}
} else if (item.type === 'function_call') {
callNames.set(item.callId, item.name);
if (item.status === 'completed') {
const args = (() => { try { return item.arguments ? JSON.parse(item.arguments) : {}; } catch { return {}; } })();
options.onEvent({ type: 'tool_call', name: item.name, callId: item.callId, args });
}
} else if (item.type === 'function_call_output') {
const out = typeof item.output === 'string' ? item.output : JSON.stringify(item.output);
options.onEvent({
type: 'tool_result',
name: callNames.get(item.callId) ?? 'unknown',
callId: item.callId,
output: out.length > 200 ? out.slice(0, 200) + '…' : out,
});
} else if (item.type === 'reasoning') {
const text = item.summary?.map((s: { text: string }) => s.text).join('') ?? '';
if (text) options.onEvent({ type: 'reasoning', delta: text });
}
}
}
const response = await result.getResponse();
return { text: response.outputText ?? '', usage: response.usage, output: response.output };
}
export async function runAgentWithRetry(
config: AgentConfig,
input: string | ChatMessage[],
options?: { onEvent?: (event: AgentEvent) => void; signal?: AbortSignal; maxRetries?: number },
) {
for (let attempt = 0, max = options?.maxRetries ?? 3; attempt <= max; attempt++) {
try { return await runAgent(config, input, options); }
catch (err: any) {
const s = err?.status ?? err?.statusCode;
if (!(s === 429 || (s >= 500 && s < 600)) || attempt === max) throw err;
await new Promise((r) => setTimeout(r, Math.min(1000 * 2 ** attempt, 30000)));
}
}
throw new Error('Unreachable');
}src/cli.ts
src/cli.ts
Three input styles are supported: (background box), (horizontal lines), and (simple caret). See references/input-styles.md for full implementations of , , and the dispatcher.
blockborderedplainstyledReadLine()borderedReadLine()getInput()typescript
import { createInterface } from 'readline';
import { loadConfig } from './config.js';
import { runAgentWithRetry, type AgentEvent } from './agent.js';
import { detectBg } from './terminal-bg.js';
// import { styledReadLine, borderedReadLine } from ... — see references/input-styles.md
const DIM = '\x1b[2m';
const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
const CYAN = '\x1b[36m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const GRAY = '\x1b[90m';
function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
function summarizeArgs(name: string, args: Record<string, unknown>): string {
const key = { shell: 'command', file_read: 'path', file_write: 'path',
file_edit: 'path', glob: 'pattern', grep: 'pattern', web_search: 'query',
}[name] ?? Object.keys(args)[0];
if (!key || !(key in args)) return '';
const val = String(args[key]);
return `${key}=${val.length > 40 ? val.slice(0, 40) + '…' : val}`;
}
async function main() {
const config = loadConfig();
const BG_INPUT = config.display.inputStyle === 'block' ? await detectBg() : '';
// Banner
const width = Math.min(process.stdout.columns || 60, 60);
const line = GRAY + '─'.repeat(width) + RESET;
console.log(`\n${line}`);
console.log(` ${BOLD}My Agent${RESET} ${DIM}v0.1.0${RESET}`);
console.log(` ${DIM}model${RESET} ${CYAN}${config.model}${RESET}`);
if (config.slashCommands) console.log(` ${DIM}/model to change${RESET}`);
console.log(`${line}\n`);
const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: `${GREEN}>${RESET} ` });
async function getInput(): Promise<string> {
switch (config.display.inputStyle) {
case 'block': return styledReadLine(BG_INPUT);
case 'bordered': return borderedReadLine();
case 'plain':
default:
return new Promise((r) => { rl.prompt(); rl.once('line', r); });
}
}
while (true) {
const input = await getInput();
const trimmed = input.trim();
if (!trimmed) continue;
if (config.display.inputStyle !== 'plain') {
const cwd = process.cwd().replace(process.env.HOME ?? '', '~');
process.stdout.write(`\x1b[K ${DIM}${cwd}${RESET}\n`);
}
if (trimmed.toLowerCase() === 'exit') { process.exit(0); }
console.log();
let streaming = false, started = false;
const toolStart = new Map<string, number>();
const dots = ['·', '··', '···'];
let di = 0;
const spin = setInterval(() => {
if (!started) process.stdout.write(`\r${DIM}${dots[di++ % 3]}${RESET}`);
}, 300);
const handleEvent = (event: AgentEvent) => {
if (!started) { started = true; process.stdout.write('\r\x1b[K'); }
if (event.type === 'text') { streaming = true; process.stdout.write(event.delta); }
else if (event.type === 'tool_call') {
if (streaming) { process.stdout.write('\n'); streaming = false; }
toolStart.set(event.callId, Date.now());
const args = summarizeArgs(event.name, event.args);
console.log(` ${YELLOW}⚡${RESET} ${DIM}${event.name}${args ? ' ' + args : ''}${RESET}`);
} else if (event.type === 'tool_result') {
const ms = Date.now() - (toolStart.get(event.callId) ?? Date.now());
console.log(` ${GREEN}✓${RESET} ${DIM}${event.name} (${(ms / 1000).toFixed(1)}s)${RESET}`);
started = false;
}
};
try {
const result = await runAgentWithRetry(config, trimmed, { onEvent: handleEvent });
clearInterval(spin);
if (streaming) process.stdout.write(RESET);
const inT = result.usage?.inputTokens ?? 0;
const outT = result.usage?.outputTokens ?? 0;
console.log(`\n${GRAY} ${formatTokens(inT)} in · ${formatTokens(outT)} out${RESET}\n`);
} catch (err: any) {
clearInterval(spin);
if (streaming) process.stdout.write(RESET);
console.log(`\n${YELLOW} Error: ${err.message}${RESET}\n`);
}
}
}
main();支持三种输入样式:(背景框)、(水平线条)和(简单提示符)。、和调度器的完整实现请查看references/input-styles.md。
blockborderedplainstyledReadLine()borderedReadLine()getInput()typescript
import { createInterface } from 'readline';
import { loadConfig } from './config.js';
import { runAgentWithRetry, type AgentEvent } from './agent.js';
import { detectBg } from './terminal-bg.js';
// import { styledReadLine, borderedReadLine } from ... — see references/input-styles.md
const DIM = '\\x1b[2m';
const RESET = '\\x1b[0m';
const BOLD = '\\x1b[1m';
const CYAN = '\\x1b[36m';
const GREEN = '\\x1b[32m';
const YELLOW = '\\x1b[33m';
const GRAY = '\\x1b[90m';
function formatTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
function summarizeArgs(name: string, args: Record<string, unknown>): string {
const key = { shell: 'command', file_read: 'path', file_write: 'path',
file_edit: 'path', glob: 'pattern', grep: 'pattern', web_search: 'query',
}[name] ?? Object.keys(args)[0];
if (!key || !(key in args)) return '';
const val = String(args[key]);
return `${key}=${val.length > 40 ? val.slice(0, 40) + '…' : val}`;
}
async function main() {
const config = loadConfig();
const BG_INPUT = config.display.inputStyle === 'block' ? await detectBg() : '';
// 横幅
const width = Math.min(process.stdout.columns || 60, 60);
const line = GRAY + '─'.repeat(width) + RESET;
console.log(`\
${line}`);
console.log(` ${BOLD}My Agent${RESET} ${DIM}v0.1.0${RESET}`);
console.log(` ${DIM}model${RESET} ${CYAN}${config.model}${RESET}`);
if (config.slashCommands) console.log(` ${DIM}/model to change${RESET}`);
console.log(`${line}\
`);
const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: `${GREEN}>${RESET} ` });
async function getInput(): Promise<string> {
switch (config.display.inputStyle) {
case 'block': return styledReadLine(BG_INPUT);
case 'bordered': return borderedReadLine();
case 'plain':
default:
return new Promise((r) => { rl.prompt(); rl.once('line', r); });
}
}
while (true) {
const input = await getInput();
const trimmed = input.trim();
if (!trimmed) continue;
if (config.display.inputStyle !== 'plain') {
const cwd = process.cwd().replace(process.env.HOME ?? '', '~');
process.stdout.write(`\\x1b[K ${DIM}${cwd}${RESET}\
`);
}
if (trimmed.toLowerCase() === 'exit') { process.exit(0); }
console.log();
let streaming = false, started = false;
const toolStart = new Map<string, number>();
const dots = ['·', '··', '···'];
let di = 0;
const spin = setInterval(() => {
if (!started) process.stdout.write(`\\r${DIM}${dots[di++ % 3]}${RESET}`);
}, 300);
const handleEvent = (event: AgentEvent) => {
if (!started) { started = true; process.stdout.write('\\r\\x1b[K'); }
if (event.type === 'text') { streaming = true; process.stdout.write(event.delta); }
else if (event.type === 'tool_call') {
if (streaming) { process.stdout.write('\
'); streaming = false; }
toolStart.set(event.callId, Date.now());
const args = summarizeArgs(event.name, event.args);
console.log(` ${YELLOW}⚡${RESET} ${DIM}${event.name}${args ? ' ' + args : ''}${RESET}`);
} else if (event.type === 'tool_result') {
const ms = Date.now() - (toolStart.get(event.callId) ?? Date.now());
console.log(` ${GREEN}✓${RESET} ${DIM}${event.name} (${(ms / 1000).toFixed(1)}s)${RESET}`);
started = false;
}
};
try {
const result = await runAgentWithRetry(config, trimmed, { onEvent: handleEvent });
clearInterval(spin);
if (streaming) process.stdout.write(RESET);
const inT = result.usage?.inputTokens ?? 0;
const outT = result.usage?.outputTokens ?? 0;
console.log(`\
${GRAY} ${formatTokens(inT)} in · ${formatTokens(outT)} out${RESET}\
`);
} catch (err: any) {
clearInterval(spin);
if (streaming) process.stdout.write(RESET);
console.log(`\
${YELLOW} Error: ${err.message}${RESET}\
`);
}
}
}
main();ASCII Logo Banner
ASCII Logo横幅
When is selected, ask the user for their project name, then generate with ASCII art of that name. Use a block-letter style with the character for the art. The banner should fit in a 60-column terminal.
ASCII Logo Bannersrc/banner.ts█当选中时,询问用户项目名称,然后生成包含该名称ASCII艺术的文件。使用字符构建块状字母风格的艺术作品,横幅需适配60列终端宽度。
ASCII Logo Bannersrc/banner.ts█src/banner.ts
src/banner.ts
Generate ASCII art for the user's project name. Example for a project called "ACME":
typescript
const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
const DIM = '\x1b[2m';
const CYAN = '\x1b[36m';
const LOGO = `
█████╗ ██████╗███╗ ███╗███████╗
██╔══██╗██╔════╝████╗ ████║██╔════╝
███████║██║ ██╔████╔██║█████╗
██╔══██║██║ ██║╚██╔╝██║██╔══╝
██║ ██║╚██████╗██║ ╚═╝ ██║███████╗
╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝`;
export function printBanner(model: string): void {
console.log(CYAN + BOLD + LOGO + RESET);
console.log(` ${DIM}model ${RESET}${model}\n`);
}Adapt the ASCII art to the user's actual project name. Keep it to one or two short words that fit in 60 columns.
为用户项目名称生成ASCII艺术。以下是项目名为"ACME"的示例:
typescript
const RESET = '\\x1b[0m';
const BOLD = '\\x1b[1m';
const DIM = '\\x1b[2m';
const CYAN = '\\x1b[36m';
const LOGO = `
█████╗ ██████╗███╗ ███╗███████╗
██╔══██╗██╔════╝████╗ ████║██╔════╝
███████║██║ ██╔████╔██║█████╗
██╔══██║██║ ██║╚██╔╝██║██╔══╝
██║ ██║╚██████╗██║ ╚═╝ ██║███████╗
╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚══════╝`;
export function printBanner(model: string): void {
console.log(CYAN + BOLD + LOGO + RESET);
console.log(` ${DIM}model ${RESET}${model}\
`);
}根据用户实际项目名称调整ASCII艺术,限制为1-2个短词,确保适配60列宽度。
Wire into src/cli.ts
接入src/cli.ts
Add at the top of , before the text banner, when is selected:
main()showBannertypescript
import { printBanner } from './banner.js';
// In main(), replace the text banner with:
if (config.showBanner) {
printBanner(config.model);
} else {
// fall back to the text banner from the cli.ts template above
}Add to (default ). Enable via or .
showBanner: booleanAgentConfigfalseagent.config.jsonloadConfig({ showBanner: true })当选中时,在函数顶部、文本横幅之前添加以下代码:
showBannermain()typescript
import { printBanner } from './banner.js';
// 在main()中,将文本横幅替换为:
if (config.showBanner) {
printBanner(config.model);
} else {
// 回退到cli.ts模板中的文本横幅
}在中添加(默认值)。可通过或启用。
AgentConfigshowBanner: booleanfalseagent.config.jsonloadConfig({ showBanner: true })Reference Files
参考文件
For content beyond the core files:
- references/tools.md — Specs for all user-defined tools: file-read, file-write, file-edit, glob, grep, list-dir, shell, js-repl, sub-agent, plan, request-input, web-fetch, view-image, custom template
- references/modules.md — Harness modules: session persistence, context compaction, system prompt composition, tool approval, structured logging
- references/tui.md — Terminal background detection, adaptive input background
- references/tool-display.md — Tool display styles: emoji, grouped, minimal; TuiRenderer class, per-tool colors, formatters
- references/input-styles.md — Input styles: block (background box), bordered (horizontal lines), plain (simple caret)
- references/loader.md — Loader animations: gradient (scrolling shimmer), spinner (braille dots), minimal (trailing dots)
- references/slash-commands.md — Slash command registry: /model, /new, /help, /compact, /session, /export
- references/system-prompt.md — Default system prompt, buildSystemPrompt(), customization guide
- references/server-entry-points.md — Express/Hono API server entry point with SSE streaming, plus extension points (MCP, WebSocket, dynamic models)
如需核心文件之外的内容,请查看:
- references/tools.md — 所有用户自定义工具的规范:file-read、file-write、file-edit、glob、grep、list-dir、shell、js-repl、sub-agent、plan、request-input、web-fetch、view-image、自定义模板
- references/modules.md — 框架模块:会话持久化、上下文压缩、系统提示词组合、工具审批、结构化日志
- references/tui.md — 终端背景检测、自适应输入背景
- references/tool-display.md — 工具显示样式:emoji、grouped、minimal;TuiRenderer类、按工具分配颜色、格式化器
- references/input-styles.md — 输入样式:block(背景框)、bordered(水平线条)、plain(简单提示符)
- references/loader.md — 加载动画:gradient(滚动渐变)、spinner(盲文点旋转)、minimal(尾部动态点)
- references/slash-commands.md — 斜杠命令注册表:/model、/new、/help、/compact、/session、/export
- references/system-prompt.md — 默认系统提示词、buildSystemPrompt()、自定义指南
- references/server-entry-points.md — 带SSE流式传输的Express/Hono API服务器入口,以及扩展点(MCP、WebSocket、动态模型) ",