Skip to content

Commit d8b5004

Browse files
anthhubclaude
andcommitted
feat: Chapter 8 — Interactive terminal UI with React + Ink
Demo code: - Add components/MessageList.tsx for conversation history rendering - Add components/PermissionRequest.tsx for permission dialogs - Add components/App.tsx as top-level entry with API key check - Add screens/REPL.tsx as main interactive interface with streaming, tool call display, and text input handling - Add repl.tsx as Ink render entry point - Two run modes: `bun run demo` (script) / `bun run start` (REPL) Documentation: - Add "Hands-on: Interactive Terminal UI" to Ch8 (zh-CN/en) - Explain why React for terminal, component architecture Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c901845 commit d8b5004

8 files changed

Lines changed: 448 additions & 2 deletions

File tree

demo/components/App.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* components/App.tsx - 应用入口组件
3+
*
4+
* 检查 API key,决定渲染 REPL 还是错误提示。
5+
*/
6+
7+
import React from "react";
8+
import { Box, Text } from "ink";
9+
import { REPL } from "../screens/REPL.js";
10+
11+
export function App() {
12+
if (!process.env.ANTHROPIC_API_KEY) {
13+
return (
14+
<Box flexDirection="column" padding={1}>
15+
<Text bold color="red">Missing API Key</Text>
16+
<Text>Set ANTHROPIC_API_KEY environment variable to use mini-claude:</Text>
17+
<Text color="cyan"> ANTHROPIC_API_KEY=sk-ant-xxx bun run start</Text>
18+
</Box>
19+
);
20+
}
21+
22+
return <REPL />;
23+
}

demo/components/MessageList.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/**
2+
* components/MessageList.tsx - 消息列表组件
3+
*
4+
* 对应真实 Claude Code: src/components/AssistantMessage/ + src/components/ToolResult/
5+
* 渲染完整的对话历史:用户消息、AI 回复、工具调用结果。
6+
*/
7+
8+
import React from "react";
9+
import { Box, Text } from "ink";
10+
import type { Message } from "../types/index.js";
11+
12+
export function MessageList({ messages }: { messages: Message[] }) {
13+
return (
14+
<Box flexDirection="column" gap={1}>
15+
{messages.map((msg, i) => (
16+
<MessageItem key={i} message={msg} />
17+
))}
18+
</Box>
19+
);
20+
}
21+
22+
function MessageItem({ message }: { message: Message }) {
23+
if (message.type === "user") {
24+
const content = message.message.content;
25+
if (typeof content === "string") {
26+
return (
27+
<Box>
28+
<Text bold color="blue">{"❯ "}</Text>
29+
<Text>{content}</Text>
30+
</Box>
31+
);
32+
}
33+
// tool_result 消息不单独渲染(已在工具调用中展示)
34+
return null;
35+
}
36+
37+
if (message.type === "assistant") {
38+
return (
39+
<Box flexDirection="column">
40+
{message.message.content.map((block, i) => {
41+
if (block.type === "text") {
42+
return <Text key={i}>{block.text}</Text>;
43+
}
44+
if (block.type === "tool_use") {
45+
return (
46+
<Box key={i}>
47+
<Text dimColor>{" 🔧 "}</Text>
48+
<Text color="yellow">{block.name}</Text>
49+
<Text dimColor>({JSON.stringify(block.input).substring(0, 60)}...)</Text>
50+
</Box>
51+
);
52+
}
53+
return null;
54+
})}
55+
</Box>
56+
);
57+
}
58+
59+
if (message.type === "system") {
60+
return (
61+
<Text dimColor italic>{" ℹ "}{message.message}</Text>
62+
);
63+
}
64+
65+
return null;
66+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* components/PermissionRequest.tsx - 权限确认对话框
3+
*
4+
* 对应真实 Claude Code: src/components/PermissionRequest/
5+
* 当工具需要 ask 权限时,显示确认对话框让用户选择。
6+
*/
7+
8+
import React, { useState } from "react";
9+
import { Box, Text, useInput } from "ink";
10+
11+
interface Props {
12+
toolName: string;
13+
input: Record<string, unknown>;
14+
message: string;
15+
onAllow: () => void;
16+
onDeny: () => void;
17+
}
18+
19+
export function PermissionRequest({ toolName, input, message, onAllow, onDeny }: Props) {
20+
const [selected, setSelected] = useState<"allow" | "deny">("allow");
21+
22+
useInput((_, key) => {
23+
if (key.leftArrow || key.rightArrow) {
24+
setSelected((prev) => (prev === "allow" ? "deny" : "allow"));
25+
}
26+
if (key.return) {
27+
if (selected === "allow") onAllow();
28+
else onDeny();
29+
}
30+
});
31+
32+
const inputStr = JSON.stringify(input).substring(0, 80);
33+
34+
return (
35+
<Box flexDirection="column" borderStyle="round" borderColor="yellow" paddingX={1}>
36+
<Text bold color="yellow">⚠ Permission Required</Text>
37+
<Text>Tool: <Text bold>{toolName}</Text></Text>
38+
<Text dimColor>Input: {inputStr}</Text>
39+
<Text>Reason: {message}</Text>
40+
<Box gap={2} marginTop={1}>
41+
<Text
42+
backgroundColor={selected === "allow" ? "green" : undefined}
43+
color={selected === "allow" ? "white" : "green"}
44+
>
45+
{selected === "allow" ? " ✓ Allow " : " Allow "}
46+
</Text>
47+
<Text
48+
backgroundColor={selected === "deny" ? "red" : undefined}
49+
color={selected === "deny" ? "white" : "red"}
50+
>
51+
{selected === "deny" ? " ✗ Deny " : " Deny "}
52+
</Text>
53+
</Box>
54+
<Text dimColor>← → to select, Enter to confirm</Text>
55+
</Box>
56+
);
57+
}

demo/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
"type": "module",
66
"scripts": {
77
"typecheck": "tsc --noEmit",
8-
"start": "bun run main.ts"
8+
"start": "bun run repl.tsx",
9+
"demo": "bun run main.ts"
910
},
1011
"dependencies": {
11-
"@anthropic-ai/sdk": "^0.52.0"
12+
"@anthropic-ai/sdk": "^0.52.0",
13+
"ink": "^6.8.0",
14+
"react": "^19.2.4"
1215
},
1316
"devDependencies": {
1417
"@types/bun": "^1.3.11",
18+
"@types/react": "^19.2.14",
1519
"typescript": "^5.7.0"
1620
}
1721
}

demo/repl.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* repl.tsx - 交互式 REPL 入口
3+
*
4+
* 对应真实 Claude Code: src/main.tsx
5+
*
6+
* 使用方式:
7+
* ANTHROPIC_API_KEY=sk-xxx bun run repl.tsx
8+
*/
9+
10+
import React from "react";
11+
import { render } from "ink";
12+
import { App } from "./components/App.js";
13+
14+
render(<App />);

demo/screens/REPL.tsx

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* screens/REPL.tsx - 交互式 REPL 界面
3+
*
4+
* 对应真实 Claude Code: src/screens/REPL.tsx
5+
* 这是 mini-claude 的主界面:用户输入 → AI 回复 → 工具调用 → 循环。
6+
*/
7+
8+
import React, { useState, useCallback } from "react";
9+
import { Box, Text, useInput, useApp } from "ink";
10+
import { MessageList } from "../components/MessageList.js";
11+
import { query } from "../query.js";
12+
import { allTools } from "../tools.js";
13+
import { createPermissionContext, createCheckPermissionFn } from "../utils/permissions.js";
14+
import { DEFAULT_MODEL } from "../types/index.js";
15+
import type { Message } from "../types/index.js";
16+
17+
export function REPL() {
18+
const { exit } = useApp();
19+
const [messages, setMessages] = useState<Message[]>([]);
20+
const [input, setInput] = useState("");
21+
const [isLoading, setIsLoading] = useState(false);
22+
const [streamText, setStreamText] = useState("");
23+
const [tokenUsage, setTokenUsage] = useState({ input: 0, output: 0 });
24+
25+
const permCtx = createPermissionContext("auto");
26+
const checkPerm = createCheckPermissionFn(permCtx);
27+
28+
const handleSubmit = useCallback(async (value: string) => {
29+
const trimmed = value.trim();
30+
if (!trimmed) return;
31+
32+
// 处理退出命令
33+
if (trimmed === "/exit" || trimmed === "/quit") {
34+
exit();
35+
return;
36+
}
37+
38+
setInput("");
39+
setIsLoading(true);
40+
setStreamText("");
41+
42+
try {
43+
const result = await query(trimmed, [...messages], {
44+
model: DEFAULT_MODEL,
45+
maxTokens: 4096,
46+
checkPermission: checkPerm,
47+
onText: (text) => {
48+
setStreamText((prev) => prev + text);
49+
},
50+
onToolUse: (name, toolInput) => {
51+
setStreamText((prev) => prev + `\n🔧 ${name}(${JSON.stringify(toolInput).substring(0, 60)}...)\n`);
52+
},
53+
onToolResult: (name, _result, isError) => {
54+
const icon = isError ? "❌" : "✅";
55+
setStreamText((prev) => prev + `${icon} ${name} done\n`);
56+
},
57+
});
58+
59+
setMessages(result.messages);
60+
setTokenUsage((prev) => ({
61+
input: prev.input + result.inputTokens,
62+
output: prev.output + result.outputTokens,
63+
}));
64+
} catch (err) {
65+
setStreamText(`Error: ${err}`);
66+
} finally {
67+
setIsLoading(false);
68+
setStreamText("");
69+
}
70+
}, [messages, checkPerm, exit]);
71+
72+
return (
73+
<Box flexDirection="column" padding={1}>
74+
{/* Header */}
75+
<Box marginBottom={1}>
76+
<Text bold color="cyan">mini-claude</Text>
77+
<Text dimColor> | {DEFAULT_MODEL} | {allTools.length} tools | tokens: {tokenUsage.input}{tokenUsage.output}</Text>
78+
</Box>
79+
80+
{/* Message History */}
81+
<MessageList messages={messages} />
82+
83+
{/* Streaming Output */}
84+
{streamText && (
85+
<Box marginTop={1}>
86+
<Text>{streamText}</Text>
87+
</Box>
88+
)}
89+
90+
{/* Loading Indicator */}
91+
{isLoading && !streamText && (
92+
<Text color="yellow">⏳ Thinking...</Text>
93+
)}
94+
95+
{/* Input */}
96+
<Box marginTop={1}>
97+
<Text bold color="blue">{"❯ "}</Text>
98+
{isLoading ? (
99+
<Text dimColor>(waiting for response...)</Text>
100+
) : (
101+
<TextInputFallback value={input} onChange={setInput} onSubmit={handleSubmit} />
102+
)}
103+
</Box>
104+
105+
{/* Help */}
106+
<Box marginTop={1}>
107+
<Text dimColor>Type your question. /exit to quit.</Text>
108+
</Box>
109+
</Box>
110+
);
111+
}
112+
113+
/**
114+
* 简单的文本输入组件(不依赖 ink-text-input)
115+
* 使用 useInput 手动处理按键
116+
*/
117+
function TextInputFallback({
118+
value,
119+
onChange,
120+
onSubmit,
121+
}: {
122+
value: string;
123+
onChange: (v: string) => void;
124+
onSubmit: (v: string) => void;
125+
}) {
126+
useInput((ch, key) => {
127+
if (key.return) {
128+
onSubmit(value);
129+
return;
130+
}
131+
if (key.backspace || key.delete) {
132+
onChange(value.slice(0, -1));
133+
return;
134+
}
135+
if (ch && !key.ctrl && !key.meta) {
136+
onChange(value + ch);
137+
}
138+
});
139+
140+
return (
141+
<Text>
142+
{value}
143+
<Text backgroundColor="white"> </Text>
144+
</Text>
145+
);
146+
}

0 commit comments

Comments
 (0)