create-agent-tui

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Create Agent TUI

Create Agent TUI

Scaffolds a complete agent TUI in TypeScript targeting OpenRouter. The generated project uses
@openrouter/agent
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.
Architecture 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

前置要求



Decision Tree

决策树

User wants to...Action
Build a new agent from scratchPresent checklist below → follow Generation Workflow
Add tools to an existing harnessRead references/tools.md, present tool checklist only
Add a harness moduleRead references/modules.md, generate the module
Add an API server entry pointRead 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服务器端工具(服务端执行,无需客户端实现)

ToolType stringDefaultConfig
Web Search
openrouter:web_search
ONengine, max_results, domain filtering
Datetime
openrouter:datetime
ONtimezone
Image Generation
openrouter:image_generation
OFFmodel, quality, size, format
Server tools go in the
tools
array alongside user-defined tools. No client code needed — OpenRouter executes them.
工具类型字符串默认状态配置项
Web搜索
openrouter:web_search
ON引擎、最大结果数、域名过滤
日期时间
openrouter:datetime
ON时区
图片生成
openrouter:image_generation
OFF模型、质量、尺寸、格式
服务器端工具需与用户自定义工具一同放入
tools
数组中。无需客户端代码——由OpenRouter执行。

User-Defined Tools (client-side, generated into src/tools/)

用户自定义工具(客户端执行,生成至src/tools/目录)

ToolDefaultDescription
File ReadONRead files with offset/limit, detect images
File WriteONWrite/create files, auto-create directories
File EditONSearch-and-replace with diff validation
Glob/FindONFile discovery by glob pattern
Grep/SearchONContent search by regex
Directory ListONList directory contents
Shell/BashONExecute commands with timeout and output capture
JS REPLOFFPersistent Node.js environment
Sub-agent SpawnOFFDelegate tasks to child agents
Plan/TodoOFFTrack multi-step task progress
Request User InputOFFStructured multiple-choice questions
Web FetchOFFFetch and extract text from web pages
View ImageOFFRead local images as base64
Custom Tool TemplateONEmpty skeleton for domain-specific tools
工具默认状态描述
文件读取ON按偏移量/限制读取文件,支持图片检测
文件写入ON写入/创建文件,自动创建目录
文件编辑ON带差异验证的搜索替换
全局匹配/查找ON通过glob模式发现文件
内容搜索ON通过正则表达式搜索内容
目录列表ON列出目录内容
Shell/BashON执行命令并捕获输出,支持超时设置
JS REPLOFF持久化Node.js环境
子Agent生成OFF将任务委托给子Agent
计划/待办OFF跟踪多步骤任务进度
请求用户输入OFF结构化选择题交互
Web抓取OFF抓取并提取网页文本
图片查看OFF将本地图片读取为base64格式
自定义工具模板ON用于领域特定工具的空骨架

Harness Modules (architectural components)

框架模块(架构组件)

ModuleDefaultDescription
Session PersistenceONJSONL append-only conversation log
ASCII Logo BannerOFFCustom ASCII art banner on startup — ask for project name
Context CompactionOFFSummarize older messages when context is long
System Prompt CompositionOFFAssemble instructions from static + dynamic context
Tool Permissions / ApprovalOFFGate dangerous tools behind user confirmation
Structured Event LoggingOFFEmit events for tool calls, API requests, errors
@
-file References
OFF
@filename
to attach file content to next message
!
Shell Shortcut
OFF
!command
to run shell and inject output into context
Multi-line InputOFFShift+Enter for multi-line (requires raw terminal mode)
模块默认状态描述
会话持久化ONJSONL追加式对话日志
ASCII Logo横幅OFF启动时显示自定义ASCII艺术横幅——需询问项目名称
上下文压缩OFF当上下文过长时总结旧消息
系统提示词组合OFF从静态+动态上下文组装指令
工具权限/审批OFF危险工具需用户确认后方可执行
结构化事件日志OFF输出工具调用、API请求、错误等事件
@
-文件引用
OFF使用
@filename
将文件内容附加到下一条消息
!
Shell快捷指令
OFF使用
!command
执行Shell并将输出注入上下文
多行输入OFFShift+Enter触发多行输入(需原生终端模式)

Slash Commands (user-facing REPL commands)

斜杠命令(用户可见的REPL命令)

CommandDefaultDescription
/model
ONSwitch model via OpenRouter API
/new
ONStart a fresh conversation
/help
ONList available commands
/compact
OFFManually trigger context compaction
/session
OFFShow session metadata and token usage
/export
OFFSave conversation as Markdown
When slash commands are enabled, generate
src/commands.ts
with a command registry. See references/slash-commands.md for specs.
命令默认状态描述
/model
ON通过OpenRouter API切换模型
/new
ON开启新对话
/help
ON列出可用命令
/compact
OFF手动触发上下文压缩
/session
OFF显示会话元数据与令牌使用情况
/export
OFF将对话保存为Markdown格式
若启用斜杠命令,需生成包含命令注册表的
src/commands.ts
文件。具体规范请查看references/slash-commands.md

Visual Customization (present as single-select for each)

视觉自定义(每项为单选选择)

Input style — how the prompt looks. See references/input-styles.md:
StyleDefaultDescription
block
ONFull-width background box with
prompt, adapts to terminal theme
bordered
Horizontal
lines above and below input
plain
Simple
> 
readline prompt, no escape sequences
OtherUser describes what they want — implement a custom input style
Tool display — how tool calls appear during execution. See references/tool-display.md:
StyleDefaultDescription
grouped
ONBold action labels with tree-branch output
emoji
Per-call
/
markers with args and timing
minimal
Aggregated one-liner summaries
hidden
No tool output
OtherUser describes what they want — implement a custom display
Loader animation — shown while waiting for model response. See references/loader.md:
StyleDefaultDescription
spinner
ONBraille dot spinner (⠋⠙⠹…) to the left of the text
gradient
Scrolling color shimmer over the loader text
minimal
Trailing dots (
Working···
)
OtherUser describes what they want — implement a custom animation
Also ask for the loader text (default:
"Working"
).

输入样式 — 提示框外观。查看references/input-styles.md
样式默认状态描述
block
ON全宽背景框搭配
提示符,适配终端主题
bordered
输入框上下显示水平
线条
plain
简单的
> 
readline提示符,无转义序列
其他用户描述需求——实现自定义输入样式
工具显示 — 工具执行时的展示方式。查看references/tool-display.md
样式默认状态描述
grouped
ON加粗动作标签搭配树状分支输出
emoji
每个调用显示
/
标记,附带参数与耗时
minimal
聚合式单行摘要
hidden
不显示工具输出
其他用户描述需求——实现自定义显示方式
加载动画 — 等待模型响应时显示。查看references/loader.md
样式默认状态描述
spinner
ON文本左侧显示盲文点旋转动画(⠋⠙⠹…)
gradient
加载文本上显示滚动色彩渐变效果
minimal
尾部显示动态点(
Working···
其他用户描述需求——实现自定义动画
同时询问加载文本(默认值:
"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
@openrouter/agent/tool
. Here is one complete example — all other tools in references/tools.md follow the same shape:
typescript
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.

所有用户自定义工具均遵循使用
@openrouter/agent/tool
的模式。以下是完整示例——references/tools.md中的所有其他工具均采用相同结构:
typescript
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/node

tsconfig.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:
block
(background box),
bordered
(horizontal lines), and
plain
(simple caret). See references/input-styles.md for full implementations of
styledReadLine()
,
borderedReadLine()
, and the
getInput()
dispatcher.
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();

支持三种输入样式:
block
(背景框)、
bordered
(水平线条)和
plain
(简单提示符)。
styledReadLine()
borderedReadLine()
getInput()
调度器的完整实现请查看references/input-styles.md
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
ASCII Logo Banner
is selected, ask the user for their project name, then generate
src/banner.ts
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 Banner
时,询问用户项目名称,然后生成包含该名称ASCII艺术的
src/banner.ts
文件。使用
字符构建块状字母风格的艺术作品,横幅需适配60列终端宽度。

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
main()
, before the text banner, when
showBanner
is selected:
typescript
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
showBanner: boolean
to
AgentConfig
(default
false
). Enable via
agent.config.json
or
loadConfig({ showBanner: true })
.

当选中
showBanner
时,在
main()
函数顶部、文本横幅之前添加以下代码:
typescript
import { printBanner } from './banner.js';

// 在main()中,将文本横幅替换为:
if (config.showBanner) {
  printBanner(config.model);
} else {
  // 回退到cli.ts模板中的文本横幅
}
AgentConfig
中添加
showBanner: boolean
(默认值
false
)。可通过
agent.config.json
loadConfig({ 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、动态模型) ",