Ink は CLI アプリを React で書くためのライブラリです。Flexbox レイアウトエンジンである Yoga を使用しているため、Web アプリケーションと同じような CSS を使って UI を構築できることが特徴です。Codex や Claude Code といったコーディングエージェントの CLI アプリが Ink で書かれています。
プロジェクトを作成する
以下のコマンドで Node.js のプロジェクトを作成します。
mkdir ink-cli-app
cd ink-cli-app
npm init -y
package.json
の type
を module
に変更します。これにより、JavaScript ファイルがデフォルトで ES モジュールとして扱われるようになります。
{
"type": "module"
}
必要なパッケージをインストールします。
npm install ink react
npm install --save-dev typescript @types/react tsx @types/node
src/cli.tsx
というファイルを作成して、最初の Ink アプリを作成しましょう。単にテキストで「Hello, world!」と表示するだけのアプリです。文字を描画する場合必ず <Text>
コンポーネントを使用する必要があります。
Ink の render()
関数を使用して、ターミナル上に React コンポーネントをレンダリングします。
import React from "react";
import { render, Text } from "ink";
const App = () => {
return <Text color="green">Hello, world!</Text>;
};
render(<App />);
package.json
に以下のようにアプリケーションを実行するためのスクリプトを追加します。
{
"scripts": {
"dev": "tsx src/cli.tsx",
"build": "tsc src/cli.tsx --outDir dist",
"start": "node dist/cli.js"
}
}
dev
スクリプトは開発用のスクリプトで、tsx
を使って TypeScript ファイルを直接実行します。npm run dev
でアプリケーションを実行できます。
npm run dev
以下のように、緑色の文字で「Hello, world!」と表示されるはずです。
useState
や useEffect
などの React の基本的なフックを使うことができます。1 秒ごと現在時刻を表示するアプリを作成してみましょう。
import React, { useEffect, useState } from "react";
import { render, Text } from "ink";
const App = () => {
const [time, setTime] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(interval);
}, []);
return <Text color="green">{time.toLocaleTimeString()}</Text>;
};
render(<App />);
AI とチャットするアプリケーションを作成する
基本的な Ink の使い方がわかったところで、より実用的なアプリケーションを作成してみましょう。AI とチャットするアプリケーションを作成します。生成 AI モデルを呼び出すための SDK として Vercel AI SDK を使用します。Vercel AI SDK は AI モデルごとに差異を抽象化しているため、後から簡単に異なるモデルに切り替えることができます。必要なパッケージをインストールします。
npm install ai @ai-sdk/google
この記事では Google が提供する Gemini を使用するため、対応するパッケージである @ai-sdk/google
をインストールします。その他の AI モデルを使用したい場合には AI SDK Providers を参考に対応するモデルのパッケージをインストールしてください。
LLM を利用するには API キーが必要です。今回は Google Gemini を使用するため、Google AI Studio で API キーを取得します。選択するモデルによっては料金が発生する場合があるため、ご注意ください。
取得した API キーは、以下のように環境変数 GOOGLE_GENERATIVE_AI_API_KEY
として設定します。
export GOOGLE_GENERATIVE_AI_API_KEY=your_api_key
次に、src/cli.tsx
を以下のように変更します。
import React, { useEffect, useState } from "react";
import { Box, render, Text } from "ink";
import { google } from "@ai-sdk/google";
import { streamText } from "ai";
const App = () => {
const [response, setResponse] = useState("");
const prompt = "箱根のおすすめの観光地を教えてください。";
useEffect(() => {
const generateResponse = async () => {
// streamText 関数はテキスト生成をストリーミングで返す
const res = streamText({
// モデルを指定する
// ここでは Google Gemini の最新モデルを指定
model: google("gemini-2.5-pro-exp-03-25"),
// 一旦プロンプトのメッセージを固定で指定する
messages: [
{
role: "user",
content: prompt,
},
],
});
// ストリーミングされたテキストをチャンクごとに受け取る
for await (const chunk of res.textStream) {
setResponse((prev) => prev + chunk);
}
};
generateResponse();
}, []);
return (
<Box flexDirection="column">
<Text color="green">user: {prompt}</Text>
<Text color="blue">assistant:</Text>
<Text color="white">{response}</Text>
</Box>
);
};
render(<App />);
まずは簡単に固定したプロンプトの使用して AI にテキスト生成を実行してみます。streamText
関数を使用して、ストリーミングでテキストを生成します。ストリーミングされたテキストは for await
で受け取ることができるため、チャンクごとに受け取って表示できます。
<Box>
コンポーネントは Flexbox レイアウトを使用して、子要素を縦に並べるために使用します。flexDirection
プロパティを column
に設定することで、子要素を縦に並べることができます。
npm run dev
でアプリケーションを実行すると、以下のように AI が生成したテキストが表示されます。
ローディングスピナーを表示する
AI が生成したテキストの表示はできましたが、AI の応答を待っている間は何も表示されないため、ユーザーにとってはわかりづらいです。そこで、AI の応答を待っている間はローディングスピナーを表示するようにします。
link-spinner
パッケージをインストールします。link-spinner
の <Spinner>
コンポーネントを使用して、ローディングスピナーを表示します。
npm install ink-spinner
loading
という状態を追加して、AI の応答を待っている間はローディングスピナーを表示するようにします。
import React, { useEffect, useState } from "react";
import { Box, render, Text } from "ink";
import { google } from "@ai-sdk/google";
import { streamText } from "ai";
import Spinner from "ink-spinner";
const App = () => {
const [response, setResponse] = useState("");
const prompt = "箱根のおすすめの観光地を教えてください。";
const [loading, setLoading] = useState(false);
useEffect(() => {
const generateResponse = async () => {
setLoading(true);
const res = streamText({
model: google("gemini-2.5-pro-exp-03-25"),
messages: [
{
role: "user",
content: prompt,
},
],
});
for await (const chunk of res.textStream) {
// はじめのレスポンスが返ってきたら loadingをfalseにする
setLoading(false);
setResponse((prev) => prev + chunk);
}
};
generateResponse();
}, []);
return (
<Box flexDirection="column">
<Text color="green">user: {prompt}</Text>
<Text color="blue">assistant:</Text>
{loading && (
<Text color="yellow">
<Spinner type="dots" /> Loading...
</Text>
)}
<Text color="white">{response}</Text>
</Box>
);
};
render(<App />);
この変更により、ローディングスピナーが表示されるようになります。
ユーザーの入力を受け取る
現状ではただ固定されたプロンプトを使用した回答を生成するだけのアプリケーションなので退屈です。ユーザーからの入力を受け取る機能を追加して、AI と自由に会話できるようにしましょう。
ユーザーの入力を受け取るために ink-text-input
パッケージをインストールします。ブラウザの <input>
要素のように onChange
イベントを受け取ることができるコンポーネントです。
npm install ink-text-input
ink-text-input
を使用して、ユーザーの入力を受け取るようにします。ユーザーの入力を受け取るための状態 userInput
を追加し、onChange
イベントで更新します。ユーザーが Enter キーを押したときに onSubmit
が呼び出されるので、そのタイミングで AI にプロンプトを渡して応答を生成するようにします。
プロンプトは messages
の配列に role: "user"
として追加します。AI の応答が完了したら応答の全文を role: "assistant"
として追加します。これにより、AI は過去の会話をコンテキストとして保持して応答を生成できます。
messages
の配列を会話の履歴として表示するように変更します。
import React, { useEffect, useState } from "react";
import { Box, render, Text } from "ink";
import { google } from "@ai-sdk/google";
import { CoreMessage, streamText } from "ai";
import Spinner from "ink-spinner";
import TextInput from "ink-text-input";
const App = () => {
const [input, setInput] = useState("");
const [messages, setMessages] = useState<CoreMessage[]>([]);
const [response, setResponse] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
if (input.trim() === "") return;
setInput("");
const res = await generateResponse(input);
setMessages((prev) => [
...prev,
{ role: "user", content: input },
{ role: "assistant", content: res },
]);
};
const generateResponse = async (prompt: string): Promise<string> => {
setLoading(true);
const res = streamText({
model: google("gemini-2.5-pro-exp-03-25"),
messages: [...messages, { role: "user", content: prompt }],
});
let fullResponse = "";
for await (const chunk of res.textStream) {
setLoading(false);
setResponse((prev) => prev + chunk);
fullResponse += chunk;
}
setResponse("");
return fullResponse;
};
return (
<Box flexDirection="column">
{messages.map((message, index) => (
<Text key={index} color={message.role === "user" ? "green" : "white"}>
{message.role}:{" "}
{typeof message.content === "string" ? message.content : ""}
</Text>
))}
{loading && (
<Text color="yellow">
<Spinner type="dots" /> Loading...
</Text>
)}
<Text color="white">{response}</Text>
<Box marginRight={1} borderColor="gray" borderStyle="round">
<Text color="white">{">"} </Text>
<TextInput
value={input}
onChange={(input) => {
setInput(input);
}}
onSubmit={() => handleSubmit()}
placeholder="Type your message here..."
/>
</Box>
</Box>
);
};
render(<App />);
この変更により、テキストを入力できるようになったことが確認できます。
AI の口調を変更できるようにする
生成 AI はシステムプロンプトによって様々な口調で応答させることができる点が面白いところです。例えば、カジュアルな口調やフォーマルな口調、特定のキャラクターの口調など、様々なスタイルで応答させることができます。ユーザーが /tone
コマンドを入力したときに、口調を変更する選択肢を表示するようにしましょう。
ターミナルに選択肢を表示するために、ink-select-input
パッケージをインストールします。
npm install ink-select-input
まずは口調の一覧を配列で定義します。label
と value
は SelectInput に渡すためのものです。
const tones = [
{ label: "default", value: "default", prompt: "" },
{ label: "friendly", value: "friendly", prompt: "あなたはユーザーと近しい友人です。フレンドリーな口調で話します。" },
{ label: "business", value: "business", prompt: "あなたは有能なコンサルタントです。。ビジネスライクな口調で話します。" },
{ label: "pirate", value: "pirate", prompt: "あなたは愉快な海賊です。荒っぽく陽気な口調で話します" }
]
selectingTone
という状態を追加して、この状態が true
のときに口調の選択肢を表示するようにします。ユーザーが /tone
コマンドを入力したときに selectingTone
を true
にして、口調の選択肢を表示します。
handleSubmit
関数を修正して、ユーザーが /tone
コマンドを入力したときに selectingTone
を true
にして即座に return
します。
handleSelectTone
関数を追加して、ユーザーが選択した口調を selectedTone
に保存します。選択肢を選んだら、selectingTone
を false
にして、AI のプロンプトに選択した口調を追加します。
import SelectInput from "ink-select-input";
const App = () => {
const [selectingTone, setSelectingTone] = useState(false);
const [selectedTone, setSelectedTone] = useState(tones[0]);
const handleSubmit = async () => {
if (input.trim() === "") return;
setInput("");
if (input === "/tone") {
setSelectingTone(true);
return;
}
const res = await generateResponse(input);
setMessages((prev) => [
...prev,
{ role: "user", content: input },
{ role: "assistant", content: res },
]);
};
// ...
type SelectItem = {
label: string;
value: string;
};
const handleSelectTone = (item: SelectItem) => {
const selected = tones.find((tone) => tone.value === item.value);
if (!selected) return;
setSelectedTone(selected);
setSelectingTone(false);
};
if (selectingTone) {
return (
<Box flexDirection="column">
<Text>Select AI Tone:</Text>
<SelectInput items={tones} onSelect={handleSelectTone} />
</Box>
);
}
return (
<Box flexDirection="column">
{ ... }
</Box>
)
}
選択肢の一覧は以下のように表示されます。矢印キーで選択肢を移動し、Enter キーで選択できます。
最後に generateResponse
関数を修正して、選択した口調を role: "system"
としてプロンプトに追加します。
const generateResponse = async (prompt: string): Promise<string> => {
setLoading(true);
const systemMessage: CoreMessage | null = selectedTone.systemPrompt
? { role: "system", content: selectedTone.systemPrompt }
: null;
const messagesToSend: CoreMessage[] = systemMessage
? [systemMessage, ...messages, { role: "user", content: prompt }]
: [...messages, { role: "user", content: prompt }];
const res = streamText({
model: google("gemini-2.5-pro-exp-03-25"),
messages: messagesToSend,
});
let fullResponse = "";
for await (const chunk of res.textStream) {
setLoading(false);
setResponse((prev) => prev + chunk);
fullResponse += chunk;
}
return fullResponse;
};
口調を pirate
にした場合、以下のように AI が海賊の口調で応答します。
まとめ
- Ink は CLI アプリを React で書くためのライブラリ。Flexbox レイアウトエンジンである Yoga を使用しているため、Web アプリケーションと同じような CSS を使って UI を構築できる
- Ink の
render()
関数を使用して、ターミナル上に React コンポーネントをレンダリングする - テキストを表示する場合は
<Text>
コンポーネントを使用する <Box>
コンポーネントは Flexbox レイアウトを使用して、子要素を並べることができるuseState
やuseEffect
などの React の基本的なフックを使うことができるink-spinner
を使用してローディングスピナーを表示することができるink-text-input
を使用してユーザーの入力を受け取ることができる