⚡从零开发 Agent CLI(三):终端样式改造——从 console.log 到交互式 Ink UI

用 chalk 给 help 信息上色,用 Ink + React 搭建交互式终端 UI,实现了一个带 logo 动画、消息列表、文本输入和斜杠命令的 ChatSession 组件。

前言

如果你还记得上一篇的结尾,执行 dsk chat 只会看到一行干巴巴的 dsk chat --- 待实现(第07章)。这显然不行------一个标榜"AI 编程助手"的 CLI 工具,启动后连个像样的界面都没有,谁会用?

这一篇的目标很简单:

  • dsk --help 输出带颜色,至少别那么丑
  • dsk chat 启动后有一个可交互的终端界面
  • 用户能打字、能看到消息、能敲 /help
  • 底层的 Provider / Tool 状态能展示出来

实现手段是三个库:

arduino 复制代码
chalk       --- 终端着色
ink         --- 用 React 写终端 UI(类似 React Native 的思路)
ink-text-input  --- 文本输入组件
ink-spinner --- 加载动画

chalk:给 help 信息上色

先看一个最简单的改动。之前 help.ts 输出纯文本,现在用 chalk 包一层颜色:

typescript 复制代码
// src/cli/help.ts

import type { Command } from "commander";
import chalk from "chalk";

export function customHelp(program: Command): string {
  const lines: string[] = [];

  lines.push("");
  lines.push(chalk.bold("用法:"));
  lines.push(`  ${chalk.cyan("dsk")} ${chalk.dim("[global-options]")} ${chalk.green("<command>")} ${chalk.dim("[options]")}`);
  lines.push("");

  // 全局选项
  if (globalOpts.length > 0) {
    lines.push(chalk.bold("全局选项:"));
    for (const opt of globalOpts) {
      const flags = [opt.short, opt.long].filter(Boolean).join(", ");
      lines.push(`  ${chalk.cyan(flags.padEnd(24))} ${opt.description ?? ""}`);
    }
    lines.push("");
  }

  // 内置选项
  lines.push(chalk.bold("内置选项:"));
  for (const flag of ["-h, --help", "-V, --version"]) {
    // ...
    lines.push(`  ${chalk.cyan(flag.padEnd(24))} ${opt.description ?? ""}`);
  }
  lines.push("");

  // 命令列表
  if (cmds.length > 0) {
    lines.push(chalk.bold("命令:"));
    for (const cmd of cmds) {
      lines.push(`  ${chalk.green(cmd.name().padEnd(24))} ${cmd.description()}`);
    }
    lines.push("");
  }

  // 示例
  lines.push(chalk.bold("示例:"));
  lines.push(`  ${chalk.dim("# 启动交互式对话")}`);
  lines.push("  dsk chat");
  // ...

  return lines.join("\n");
}

改动其实就两件事:

  1. 标题加粗:chalk.bold("用法:")
  2. 关键信息着色:dsk 青色、<command> 绿色、注释灰色

chalk 5 是 ESM-only,用法上也变成了纯函数调用,不需要 new chalk.Instance() 之类的操作。而且 chalk 5 不再支持 chalk.level 检测,好在 Ink 底层已经做了颜色支持检测,用起来很省心。


Ink:用 React 写终端 UI

chalk 只能给文本上色,要做真正的交互式界面还得上 Ink。

Ink 是什么?一句话:React for terminal。它用 React 的组件模型来渲染终端界面,支持 JSX、Hooks、状态管理,所有你熟悉的前端模式都能用。

tsx 复制代码
import { Box, Text } from "ink";

function HelloWorld() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => setCount(c => c + 1), 1000);
    return () => clearInterval(timer);
  }, []);

  return (
    <Box flexDirection="column">
      <Text color="green">Running for {count}s</Text>
    </Box>
  );
}

关键概念对比:

Web Ink
div <Box>
span <Text>
CSS Flexbox <Box> 的 flex props
useState / useEffect 完全一样
document.body.appendChild render(<App />)
Virtual DOM reconciliation 有,但只针对终端行输出

Ink 不做像素级渲染------它只输出文本行。每次状态变化,Ink 计算出新旧 VNode 的差异,然后只重绘变化的行。这在终端里意味着不会闪烁,性能也不错。


组件架构

我在 src/ui/ 下建了 5 个组件:

bash 复制代码
src/ui/
├── index.ts             # 统一导出
├── RenderScope.tsx      # Ink 渲染生命周期封装
├── ChatSession.tsx      # 主聊天界面
├── DskSplash.tsx        # 启动 Logo 动画
├── Spinner.tsx          # 加载指示器
└── StatusMessage.tsx    # 状态消息

RenderScope:把 Ink 封装成可管理对象

Ink 的 render() 返回一个对象,包含 waitUntilExitclearunmount 等方法。我封装了一层,方便其他地方调用:

tsx 复制代码
// src/ui/RenderScope.tsx

import { render } from "ink";
import type { ReactNode } from "react";

export interface RenderScopeHandle {
  waitUntilExit: Promise<unknown>;
  unmount: () => void;
  clear: () => void;
}

export function renderApp(node: ReactNode): RenderScopeHandle {
  const { waitUntilExit, clear, unmount } = render(node);
  return { waitUntilExit: waitUntilExit(), clear, unmount };
}

export async function unmountApp(handle: RenderScopeHandle): Promise<void> {
  handle.unmount();
  await new Promise((resolve) => setTimeout(resolve, 50));
}

注意 render() 返回的 waitUntilExit 是一个函数,调用后返回 Promise。我把它包装成属性,这样调用方可以用 app.waitUntilExit 等待渲染结束。这在 dsk chat 的 action 中会用到。

这个组件只做一件事:显示 DSK 的 ASCII logo,然后每隔 500ms 让颜色在下标组里循环:

tsx 复制代码
const CYBER_PALETTE = ["#00ffff", "#ff00ff", "#00ff41", "#ff1493", "#8b00ff"];

export function DskSplash() {
  const [offset, setOffset] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setOffset((prev) => (prev + 1) % CYBER_PALETTE.length);
    }, 500);
    return () => clearInterval(timer);
  }, []);

  return (
    <Box flexDirection="column" paddingLeft={1}>
      {LOGO_LINES.map((line, i) => {
        const colorIndex = (i + offset) % CYBER_PALETTE.length;
        return (
          <Box key={i}>
            <Text bold color={CYBER_PALETTE[colorIndex]}>
              {line}
            </Text>
          </Box>
        );
      })}
    </Box>
  );
}

效果就是 logo 每行颜色像跑马灯一样轮换。五秒钟看下来,能感受到 CLI 工具有在"呼吸"------这是产品细节,不影响功能但影响体验。

StatusMessage:统一状态消息

tsx 复制代码
// src/ui/StatusMessage.tsx

const STYLES: Record<MessageType, { color: string; icon: string }> = {
  info: { color: "cyan", icon: "ℹ" },
  success: { color: "green", icon: "✔" },
  warning: { color: "yellow", icon: "⚠" },
  error: { color: "red", icon: "✖" },
};

export function StatusMessage({ type = "info", label, detail }) {
  const { color, icon } = STYLES[type];
  return (
    <Box>
      <Text color={color}>
        {icon} {label}
      </Text>
      {detail ? <Text dimColor>: {detail}</Text> : null}
    </Box>
  );
}

四种类型四种颜色四种图标,后续所有状态提示都走这个组件,不会出现一个地方绿色对号、另一个地方手动拼个 [OK]

Spinner:加载动画

tsx 复制代码
// src/ui/Spinner.tsx

import InkSpinner from "ink-spinner";

export function Spinner({ type = "dots", label }) {
  return (
    <Text>
      <Text color="cyan">
        <InkSpinner type={type} />
      </Text>
      {label ? <Text> {label}</Text> : null}
    </Text>
  );
}

ink-spinner 支持 dotslinebouncingBaraesthetic 等内置样式。默认用 dots 就是传统终端的点阵旋转动画。

ChatSession:核心交互界面

这是这章的重量级组件。先看完整代码,再拆解设计:

tsx 复制代码
// src/ui/ChatSession.tsx

import { Box, Text } from "ink";
import TextInput from "ink-text-input";
import { useEffect, useState, useCallback } from "react";

const CYBER_PALETTE = ["#00ffff", "#ff00ff", "#00ff41", "#ff1493", "#8b00ff"];

const LOGO_LINES = [
  "  ██████╗ ███████╗██╗  ██╗",
  "  ██╔══██╗██╔════╝██║ ██╔╝",
  "  ██║  ██║███████╗█████╔╝ ",
  "  ██║  ██║╚════██║██╔═██╗ ",
  "  ██████╔╝███████║██║  ██╗",
  "  ╚═════╝ ╚══════╝╚═╝  ╚═╝",
];

const COMMANDS: Record<string, { desc: string; handler: () => string }> = {
  "/exit": { desc: "退出对话", handler: () => "" },
  "/quit": { desc: "退出对话", handler: () => "" },
  "/help": {
    desc: "显示帮助信息",
    handler: () =>
      [
        "可用命令:",
        "  /exit, /quit  退出对话",
        "  /help          显示此帮助",
        "  /clear         清空对话历史",
        "  /version       显示版本信息",
      ].join("\n"),
  },
  "/clear": { desc: "清空对话历史", handler: () => "" },
  "/version": { desc: "显示版本信息", handler: () => "dsk v0.0.0" },
};

interface ChatMessage {
  role: "user" | "assistant";
  content: string;
}

interface ChatSessionProps {
  providerCount: number;
  toolCount: number;
  verbose: boolean;
}

export function ChatSession({ providerCount, toolCount, verbose }: ChatSessionProps) {
  const [offset, setOffset] = useState(0);
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [input, setInput] = useState("");

  // Logo 颜色循环
  useEffect(() => {
    const timer = setInterval(() => {
      setOffset((prev) => (prev + 1) % CYBER_PALETTE.length);
    }, 500);
    return () => clearInterval(timer);
  }, []);

  // 提交处理
  const handleSubmit = useCallback((value: string) => {
    const trimmed = value.trim();
    if (!trimmed) return;

    // 斜杠命令处理
    if (trimmed.startsWith("/")) {
      const cmd = COMMANDS[trimmed.toLowerCase()];
      if (cmd) {
        if (trimmed.toLowerCase() === "/exit" || trimmed.toLowerCase() === "/quit") {
          process.exit(0);
          return;
        }
        if (trimmed.toLowerCase() === "/clear") {
          setMessages([]);
          setInput("");
          return;
        }
        const result = cmd.handler();
        if (result) {
          setMessages((prev) => [
            ...prev,
            { role: "user", content: trimmed },
            { role: "assistant", content: result },
          ]);
        }
        setInput("");
        return;
      }
      setMessages((prev) => [
        ...prev,
        { role: "user", content: trimmed },
        { role: "assistant", content: `未知命令:${trimmed}。输入 /help 查看。` },
      ]);
      setInput("");
      return;
    }

    // 普通消息
    setMessages((prev) => [
      ...prev,
      { role: "user", content: trimmed },
      { role: "assistant", content: "dsk AI --- 待实现(第07章)。当前为 CLI 框架演示模式。" },
    ]);
    setInput("");
  }, []);

  return (
    <Box flexDirection="column" paddingLeft={1} paddingRight={1}>
      {/* Logo + 状态栏 --- 左右布局 */}
      <Box flexDirection="row" marginBottom={1}>
        {/* Logo */}
        <Box flexDirection="column" marginRight={4}>
          {LOGO_LINES.map((line, i) => {
            const colorIndex = (i + offset) % CYBER_PALETTE.length;
            return (
              <Box key={i}>
                <Text bold color={CYBER_PALETTE[colorIndex]}>
                  {line}
                </Text>
              </Box>
            );
          })}
        </Box>

        {/* 状态信息 */}
        <Box flexDirection="column" justifyContent="center">
          <Text color="#00ff41">{"  ✔ "}已加载 {providerCount} 个 Provider</Text>
          <Text color="#00ffff">{"  ℹ "}已就绪 {toolCount} 个工具</Text>
          {verbose ? <Text color="#ff1493">{"  ⚡ Verbose"}</Text> : null}
        </Box>
      </Box>

      {/* 消息列表 */}
      <Box flexDirection="column" marginTop={1}>
        {messages.map((msg, i) => (
          <Box key={i} marginTop={1}>
            <Box width={8} flexShrink={0}>
              <Text bold color={msg.role === "user" ? "#00ff41" : "#ff00ff"}>
                {msg.role === "user" ? "  👤" : "  🤖"}
              </Text>
            </Box>
            <Box flexGrow={1}>
              <Text wrap="wrap">{msg.content}</Text>
            </Box>
          </Box>
        ))}
      </Box>

      {/* 输入框 */}
      <Box marginTop={1}>
        <Box width={8} flexShrink={0}>
          <Text bold color="#00ff41">{"  ⚡"}</Text>
        </Box>
        <Box flexGrow={1}>
          <TextInput
            value={input}
            onChange={setInput}
            onSubmit={handleSubmit}
            placeholder="输入你的问题..."
          />
        </Box>
      </Box>

      <Box marginTop={1}>
        <Text color="#00ffff" dimColor>
          {"  " + "─".repeat(36)}
        </Text>
      </Box>
    </Box>
  );
}

几个设计点:

Logo 颜色循环

useEffect 里开了一个 500ms 的定时器,每次把 offset + 1 循环。渲染时每行 logo 取 (行号 + offset) % 5 作为颜色的下标。效果就是颜色像彩虹一样在 logo 上滚动。

消息数据结构

typescript 复制代码
interface ChatMessage {
  role: "user" | "assistant";
  content: string;
}

每条消息只有角色和内容两个字段。后续如果要支持流式输出(AI 打字效果),只需要在 role === "assistant" 的消息上追加 content 即可------Ink 的 diff 机制会自动重绘。

斜杠命令系统

COMMANDS 是一个 Record<string, handler> 的注册表。每个命令你只需提供描述和 handler 函数:

  • /exit / /quit → 直接 process.exit(0)
  • /clear → 清空 messages 数组
  • /help → 返回帮助文字作为 assistant 回复
  • /version → 返回版本号

未注册的斜杠命令会收到"未知命令"提示,而不是静默失败。这比直接 console.log 后界面出不来好多了。

输入框的 placeholder

ink-text-input 支持 placeholder 属性,未输入时显示灰色提示文字"输入你的问题..."。Ink 7 的 TextInput 行为有点奇怪------placeholder 不会自动消失直到你打字,所以我没额外处理它。

useCallback 包装

handleSubmituseCallback 包了一层。因为 setMessages 是数组的 ...prev 操作,本质上每次都是引用新数组,如果不 memoize,每次输入框的 onChange 都会重新创建函数,触发不必要的子组件重渲染。


集成到 CLI

index.tsx 中把 dsk chat 的 action 从 console.log 改为渲染 ChatSession:

typescript 复制代码
// src/cli/index.tsx

import { customHelp } from "./help.js";
import { renderApp, ChatSession } from "../ui/index.js";

export function createCli(): Command {
  const program = new Command();
  // ...

  program.helpInformation = () => customHelp(program);

  program.hook("preAction", async (thisCommand) => {
    const ctx = await loadConfigMiddleware.call(thisCommand);
    (thisCommand as unknown as Record<string, unknown>).dskCtx = ctx;
  });

  program
    .command("chat")
    .description("启动交互式对话会话")
    .action(async function () {
      if (!process.stdin.isTTY) {
        console.error("dsk chat 需要交互式终端。如需执行一次性任务,请使用 dsk run。");
        process.exit(1);
      }

      // 从中间件注入的上下文中读取配置
      const ctx = (this as unknown as Record<string, unknown>).dskCtx as
        | { verbose: boolean; config: { providers: unknown[]; tools: unknown[] } }
        | undefined;

      // 渲染 Ink 应用并等待用户退出
      const app = renderApp(
        <ChatSession
          providerCount={ctx?.config.providers.length ?? 1}
          toolCount={ctx?.config.tools.length ?? 0}
          verbose={ctx?.verbose ?? false}
        />,
      );

      await app.waitUntilExit;
    });

  // 其他子命令...
}

对比之前:

typescript 复制代码
// 之前
.action(async () => {
  console.log("dsk chat --- 待实现(第07章)");
});

// 之后
.action(async function () {
  // ...TTY 检测、读取配置...
  const app = renderApp(<ChatSession ... />);
  await app.waitUntilExit;
});

有两个变化值得注意:

  1. async function 而非箭头函数 ------因为要访问 this 拿到 dskCtx。箭头函数没有自己的 this,不能用。
  2. await app.waitUntilExit ------Ink 的 waitUntilExit 在用户按 Ctrl+C 或调用 process.exit 时才 resolve。这样 CLI 进程不会渲染完就退出。

tsconfig 调整

加了 JSX 之后 TypeScript 需要配置编译方式:

jsonc 复制代码
{
  "compilerOptions": {
    "jsx": "react-jsx",   // 新增
  },
  "include": ["src/**/*.ts", "src/**/*.tsx"],  // 已包含
}

jsx: "react-jsx" 是 React 17+ 的 JSX transform,不需要显式 import React from "react",编译器自动注入 jsx()jsxs() 函数。因为我们要用 ink-text-input 等库,它们内部是 ESM 的默认导出,react-jsx 模式不会破坏模块解析。


package.json 新增依赖

json 复制代码
{
  "dependencies": {
    "chalk": "^5.6.2",
    "ink": "^7.1.0",
    "ink-spinner": "^5.0.0",
    "ink-text-input": "^6.0.0",
    "react": "^19.2.7"
  },
  "devDependencies": {
    "@types/react": "^19.2.17"
  }
}

几个版本选择的考量:

  • Ink 7 要求 Node >= 22,我们的 CLI 之前设了 >= 18,需要留意。Ink 7 是 rewrite,API 更简洁(去掉了 Static 等概念),但 TextInput 的兼容性有些奇怪。
  • react 19 配合 Ink 7 的 react-reconciler 版本要求,必须 >= 19.2.0。
  • chalk 5 是 ESM-only,正好我们的 CLI 是 "type": "module",无冲突。
  • ink-spinner 5 + ink-text-input 6 都是适配 Ink 4+ 的版本,实测 Ink 7 下可用。

跑起来看看

bash 复制代码
$ npx dsk --help

$ npx dsk chat

dsk chat 启动后,你会看到:

  1. 一个带边框和彩虹颜色跑马的 DSK ASCII logo
  2. 状态栏显示已加载的 Provider 数量和工具数量
  3. 底部有一个 提示符后的输入框
  4. 输入文字回车后会出现在消息列表中

输入 /help 查看可用命令,输入 /clear 清空,输入 /exit 退出。

一切都还是"演示模式"------发消息后 AI 只会回复"待实现"。真正的 AI 对话能力留到第 07 章。


文件结构总结

python 复制代码
src/
├── cli/
│   ├── index.tsx        # 修改 --- chat action 改用 Ink 渲染
│   └── help.ts          # 修改 --- chalk 着色
├── ui/                  # 新增目录
│   ├── index.ts         # 新增 --- 统一导出
│   ├── RenderScope.tsx  # 新增 --- Ink 渲染生命周期
│   ├── ChatSession.tsx  # 新增 --- 聊天主界面
│   ├── DskSplash.tsx    # 新增 --- Logo 动画
│   ├── Spinner.tsx      # 新增 --- 加载动画
│   └── StatusMessage.tsx# 新增 --- 状态消息
├── index.ts             # 未改动
├── config/              # 未改动
├── provider/            # 未改动
├── tool/                # 未改动
├── plugin/              # 未改动
└── agent/               # 未改动

设计取舍

用 Ink 是不是太重了?

对于小工具来说,Ink + React 确实不轻------node_modules 里会多几百个文件。但好处是:

  • 组件可复用,Spinner 和 StatusMessage 后续在 dsk rundsk setup 中也能用
  • 状态管理干净,随时加新功能(比如流式输出 AI 回复)代码结构不会乱
  • 社区生态成熟,有 100+ 第三方 Ink 组件

如果只是简单的日志输出,console.log + chalk 足够了。但 dsk chat 是一个交互式会话,需要跑马灯、输入框、消息列表,Ink 是合理的选择。

为什么不用 blessed / neo-blessed?

blessed 更接近 ncurses,可以做更复杂的布局(窗口、边框、鼠标事件),但它的 API 太老了,而且和 React 结合需要额外适配层(如 react-blessed)。Ink 的 Box + Text + Flexbox 模型对于"聊天界面"这种需求已经足够。

为什么 Logo 颜色循环用 useEffect 而不是 CSS animation?

因为这是终端,不是浏览器。终端没有 @keyframes,所有的"动画"都得靠 JS 定时刷状态。Ink 在每次渲染时对比前后两帧的差异,只输出变化的部分到终端,所以 setInterval 虽然每 500ms 触发一次,实际写入终端的内容很少(每秒 5 行以内),性能可接受。


延伸阅读

下一章我们来实现配置系统------TOML 解析、多层级合并、环境变量覆盖,让 dsk 真正可用。

相关推荐
Awu12271 小时前
⚡从零开发 Agent CLI(二):CLI 框架搭建与子命令路由
人工智能·aigc
草帽lufei2 小时前
下班后把活交给AI,定时器让它晚上继续干活
aigc·ai编程
没有鸡汤吃不下饭2 小时前
告别手动对接口:我用 OpenAPI JSON 做了一个前端接口同步 Skill
前端·ai编程
ZJPRENO2 小时前
Claude Code 桌面版怎么使用第三方模型
ai编程
阿祖zu2 小时前
别再优化 RAG 了,适配 Agent 的 LLM Wiki 知识库理念
前端·后端·aigc
墨风如雪3 小时前
AI 火了,人人都想开源,GitHub 没你想的那么复杂
aigc
leeyi3 小时前
Document 组件:把文件喂给 AI 之前,必须先做这三步
aigc·agent·ai编程
孟健3 小时前
Fable 5 被暂停后,我反而更确定:不要把生产流程押在单一最强模型上
ai编程
卡卡罗特AI3 小时前
有了 DESIGN.md 后,大家也能写出高颜值的网站了!
ai编程·vibecoding