用 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");
}
改动其实就两件事:
- 标题加粗:
chalk.bold("用法:") - 关键信息着色:
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() 返回一个对象,包含 waitUntilExit、clear、unmount 等方法。我封装了一层,方便其他地方调用:
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 中会用到。
DskSplash:会变色的 ASCII Logo
这个组件只做一件事:显示 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 支持 dots、line、bouncingBar、aesthetic 等内置样式。默认用 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 包装
handleSubmit 用 useCallback 包了一层。因为 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;
});
有两个变化值得注意:
async function而非箭头函数 ------因为要访问this拿到dskCtx。箭头函数没有自己的this,不能用。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 启动后,你会看到:
- 一个带边框和彩虹颜色跑马的 DSK ASCII logo
- 状态栏显示已加载的 Provider 数量和工具数量
- 底部有一个
⚡提示符后的输入框 - 输入文字回车后会出现在消息列表中

输入 /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 run和dsk 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 真正可用。