arc402-cli 0.7.3 → 0.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/TUI-SPEC.md +214 -0
  2. package/dist/index.js +55 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/repl.d.ts.map +1 -1
  5. package/dist/repl.js +46 -558
  6. package/dist/repl.js.map +1 -1
  7. package/dist/tui/App.d.ts +20 -0
  8. package/dist/tui/App.d.ts.map +1 -0
  9. package/dist/tui/App.js +166 -0
  10. package/dist/tui/App.js.map +1 -0
  11. package/dist/tui/Footer.d.ts +11 -0
  12. package/dist/tui/Footer.d.ts.map +1 -0
  13. package/dist/tui/Footer.js +13 -0
  14. package/dist/tui/Footer.js.map +1 -0
  15. package/dist/tui/Header.d.ts +15 -0
  16. package/dist/tui/Header.d.ts.map +1 -0
  17. package/dist/tui/Header.js +19 -0
  18. package/dist/tui/Header.js.map +1 -0
  19. package/dist/tui/InputLine.d.ts +11 -0
  20. package/dist/tui/InputLine.d.ts.map +1 -0
  21. package/dist/tui/InputLine.js +145 -0
  22. package/dist/tui/InputLine.js.map +1 -0
  23. package/dist/tui/Viewport.d.ts +13 -0
  24. package/dist/tui/Viewport.d.ts.map +1 -0
  25. package/dist/tui/Viewport.js +38 -0
  26. package/dist/tui/Viewport.js.map +1 -0
  27. package/dist/tui/index.d.ts +2 -0
  28. package/dist/tui/index.d.ts.map +1 -0
  29. package/dist/tui/index.js +55 -0
  30. package/dist/tui/index.js.map +1 -0
  31. package/dist/tui/useChat.d.ts +11 -0
  32. package/dist/tui/useChat.d.ts.map +1 -0
  33. package/dist/tui/useChat.js +91 -0
  34. package/dist/tui/useChat.js.map +1 -0
  35. package/dist/tui/useCommand.d.ts +17 -0
  36. package/dist/tui/useCommand.d.ts.map +1 -0
  37. package/dist/tui/useCommand.js +219 -0
  38. package/dist/tui/useCommand.js.map +1 -0
  39. package/dist/tui/useScroll.d.ts +17 -0
  40. package/dist/tui/useScroll.d.ts.map +1 -0
  41. package/dist/tui/useScroll.js +46 -0
  42. package/dist/tui/useScroll.js.map +1 -0
  43. package/package.json +5 -1
  44. package/src/index.ts +21 -1
  45. package/src/repl.ts +50 -663
  46. package/src/tui/App.tsx +240 -0
  47. package/src/tui/Footer.tsx +18 -0
  48. package/src/tui/Header.tsx +37 -0
  49. package/src/tui/InputLine.tsx +164 -0
  50. package/src/tui/Viewport.tsx +66 -0
  51. package/src/tui/index.tsx +72 -0
  52. package/src/tui/useChat.ts +103 -0
  53. package/src/tui/useCommand.ts +245 -0
  54. package/src/tui/useScroll.ts +65 -0
  55. package/tsconfig.json +6 -1
@@ -0,0 +1,240 @@
1
+ import React, { useState, useCallback } from "react";
2
+ import { Box, Text, useApp, useStdout } from "ink";
3
+ import { Header } from "./Header";
4
+ import { Viewport } from "./Viewport";
5
+ import { InputLine } from "./InputLine";
6
+ import { useCommand } from "./useCommand";
7
+ import { useChat } from "./useChat";
8
+ import { useScroll } from "./useScroll";
9
+ import { createProgram } from "../program";
10
+ import chalk from "chalk";
11
+
12
+ const BUILTIN_CMDS = ["help", "exit", "quit", "clear", "status"];
13
+
14
+ interface AppProps {
15
+ version: string;
16
+ network?: string;
17
+ wallet?: string;
18
+ balance?: string;
19
+ }
20
+
21
+ /**
22
+ * Root TUI component — box-drawn frame with fixed header/footer, scrollable viewport.
23
+ *
24
+ * ┌─────────────────────────────────────────────┐
25
+ * │ ASCII banner + status info │ ← FIXED header
26
+ * ├─────────────────────────────────────────────┤
27
+ * │ scrollable output │ ← VIEWPORT
28
+ * ├─────────────────────────────────────────────┤
29
+ * │ ◈ arc402 > _ │ ← FIXED footer
30
+ * └─────────────────────────────────────────────┘
31
+ */
32
+ export function App({ version, network, wallet, balance }: AppProps) {
33
+ const { exit } = useApp();
34
+ const { stdout } = useStdout();
35
+ const cols = stdout?.columns ?? 60;
36
+ const W = Math.min(cols, 80); // inner width between │ chars
37
+ const inner = W - 2; // space between the two │ border chars
38
+
39
+ const [outputBuffer, setOutputBuffer] = useState<string[]>([
40
+ chalk.dim(" Type 'help' to see available commands"),
41
+ "",
42
+ ]);
43
+ const [isProcessing, setIsProcessing] = useState(false);
44
+
45
+ const { execute, isRunning } = useCommand();
46
+ const { send, isSending } = useChat();
47
+
48
+ // Approximate viewport height for scroll management
49
+ const HEADER_ROWS = 17;
50
+ const FOOTER_ROWS = 3;
51
+ const rows = process.stdout.rows ?? 24;
52
+ const viewportHeight = Math.max(1, rows - HEADER_ROWS - FOOTER_ROWS);
53
+
54
+ const { scrollOffset, isAutoScroll, scrollUp, scrollDown, snapToBottom } =
55
+ useScroll(viewportHeight);
56
+
57
+ // Get top-level command names for dispatch detection
58
+ const [topCmds] = useState<string[]>(() => {
59
+ try {
60
+ const prog = createProgram();
61
+ return prog.commands.map((cmd) => cmd.name());
62
+ } catch {
63
+ return [];
64
+ }
65
+ });
66
+
67
+ const appendLine = useCallback((line: string) => {
68
+ setOutputBuffer((prev) => [...prev, line]);
69
+ }, []);
70
+
71
+ const handleCommand = useCallback(
72
+ async (input: string): Promise<void> => {
73
+ // Echo command to viewport
74
+ appendLine(
75
+ chalk.cyanBright("◈") +
76
+ " " +
77
+ chalk.dim("arc402") +
78
+ " " +
79
+ chalk.white(">") +
80
+ " " +
81
+ chalk.white(input)
82
+ );
83
+
84
+ // Snap to bottom on new command
85
+ snapToBottom();
86
+ setIsProcessing(true);
87
+
88
+ const firstWord = input.split(/\s+/)[0];
89
+ const allKnown = [...BUILTIN_CMDS, ...topCmds];
90
+
91
+ // ── Built-in: exit/quit ────────────────────────────────────────────────
92
+ if (input === "exit" || input === "quit") {
93
+ appendLine(
94
+ " " + chalk.cyanBright("◈") + chalk.dim(" goodbye")
95
+ );
96
+ setIsProcessing(false);
97
+ setTimeout(() => exit(), 100);
98
+ return;
99
+ }
100
+
101
+ // ── Built-in: clear ────────────────────────────────────────────────────
102
+ if (input === "clear") {
103
+ setOutputBuffer([]);
104
+ setIsProcessing(false);
105
+ return;
106
+ }
107
+
108
+ // ── Built-in: help ─────────────────────────────────────────────────────
109
+ if (input === "help" || input === "/help") {
110
+ const lines: string[] = [];
111
+ try {
112
+ const prog = createProgram();
113
+ prog.exitOverride();
114
+ const helpText: string[] = [];
115
+ prog.configureOutput({
116
+ writeOut: (str) => helpText.push(str),
117
+ writeErr: (str) => helpText.push(str),
118
+ });
119
+ try {
120
+ await prog.parseAsync(["node", "arc402", "--help"]);
121
+ } catch {
122
+ /* commander throws after printing help */
123
+ }
124
+ for (const chunk of helpText) {
125
+ for (const l of chunk.split("\n")) lines.push(l);
126
+ }
127
+ } catch {
128
+ lines.push(chalk.red("Failed to load help"));
129
+ }
130
+ lines.push("");
131
+ lines.push(chalk.cyanBright("Chat"));
132
+ lines.push(
133
+ " " +
134
+ chalk.white("<message>") +
135
+ chalk.dim(" Send message to OpenClaw gateway")
136
+ );
137
+ lines.push(
138
+ " " +
139
+ chalk.white("/chat <message>") +
140
+ chalk.dim(" Explicitly route to chat")
141
+ );
142
+ lines.push(
143
+ chalk.dim(
144
+ " Gateway: http://localhost:19000 (openclaw gateway start)"
145
+ )
146
+ );
147
+ lines.push("");
148
+ for (const l of lines) appendLine(l);
149
+ setIsProcessing(false);
150
+ return;
151
+ }
152
+
153
+ // ── Built-in: status ───────────────────────────────────────────────────
154
+ if (input === "status") {
155
+ if (network)
156
+ appendLine(
157
+ ` ${chalk.dim("Network")} ${chalk.white(network)}`
158
+ );
159
+ if (wallet)
160
+ appendLine(` ${chalk.dim("Wallet")} ${chalk.white(wallet)}`);
161
+ if (balance)
162
+ appendLine(` ${chalk.dim("Balance")} ${chalk.white(balance)}`);
163
+ if (!network && !wallet && !balance)
164
+ appendLine(chalk.dim(" No config found. Run 'config init' to get started."));
165
+ appendLine("");
166
+ setIsProcessing(false);
167
+ return;
168
+ }
169
+
170
+ // ── /chat prefix or unknown command → chat ─────────────────────────────
171
+ const isExplicitChat = input.startsWith("/chat ");
172
+ const isChatInput =
173
+ isExplicitChat || (!allKnown.includes(firstWord) && firstWord !== "");
174
+
175
+ if (isChatInput) {
176
+ const msg = isExplicitChat ? input.slice(6).trim() : input;
177
+ if (msg) {
178
+ await send(msg, appendLine);
179
+ }
180
+ appendLine("");
181
+ setIsProcessing(false);
182
+ return;
183
+ }
184
+
185
+ // ── Dispatch to commander ──────────────────────────────────────────────
186
+ await execute(input, appendLine);
187
+ appendLine("");
188
+ setIsProcessing(false);
189
+ },
190
+ [appendLine, execute, send, snapToBottom, topCmds, network, wallet, balance, exit]
191
+ );
192
+
193
+ const isDisabled = isProcessing || isRunning || isSending;
194
+
195
+ const topBorder = "┌" + "─".repeat(inner) + "┐";
196
+ const midBorder = "├" + "─".repeat(inner) + "┤";
197
+ const botBorder = "└" + "─".repeat(inner) + "┘";
198
+
199
+ return (
200
+ <Box flexDirection="column" height="100%">
201
+ {/* Top border */}
202
+ <Text dimColor>{topBorder}</Text>
203
+
204
+ {/* HEADER — fixed, never scrolls */}
205
+ <Header
206
+ version={version}
207
+ network={network}
208
+ wallet={wallet}
209
+ balance={balance}
210
+ innerWidth={inner}
211
+ />
212
+
213
+ {/* Mid separator */}
214
+ <Text dimColor>{midBorder}</Text>
215
+
216
+ {/* VIEWPORT — fills remaining space */}
217
+ <Viewport
218
+ lines={outputBuffer}
219
+ scrollOffset={scrollOffset}
220
+ isAutoScroll={isAutoScroll}
221
+ innerWidth={inner}
222
+ />
223
+
224
+ {/* Footer separator */}
225
+ <Text dimColor>{midBorder}</Text>
226
+
227
+ {/* FOOTER — input pinned */}
228
+ <Box>
229
+ <Text dimColor>│</Text>
230
+ <Box flexGrow={1}>
231
+ <InputLine onSubmit={handleCommand} isDisabled={isDisabled} />
232
+ </Box>
233
+ <Text dimColor>│</Text>
234
+ </Box>
235
+
236
+ {/* Bottom border */}
237
+ <Text dimColor>{botBorder}</Text>
238
+ </Box>
239
+ );
240
+ }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import { Box } from "ink";
3
+
4
+ interface FooterProps {
5
+ children: React.ReactNode;
6
+ }
7
+
8
+ /**
9
+ * Fixed footer containing the input line.
10
+ * Pinned at the bottom of the screen.
11
+ */
12
+ export function Footer({ children }: FooterProps) {
13
+ return (
14
+ <Box flexShrink={0}>
15
+ {children}
16
+ </Box>
17
+ );
18
+ }
@@ -0,0 +1,37 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { getBannerLines } from "../ui/banner";
4
+
5
+ interface HeaderProps {
6
+ version: string;
7
+ network?: string;
8
+ wallet?: string;
9
+ balance?: string;
10
+ innerWidth?: number;
11
+ }
12
+
13
+ /**
14
+ * Fixed header showing the ASCII art banner + status info.
15
+ * Each line is framed with │ … │ box-drawing borders.
16
+ */
17
+ export const Header = React.memo(function Header({
18
+ network,
19
+ wallet,
20
+ balance,
21
+ innerWidth = 58,
22
+ }: HeaderProps) {
23
+ const bannerLines = getBannerLines({ network, wallet, balance });
24
+
25
+ return (
26
+ <Box flexDirection="column">
27
+ {bannerLines.map((line, i) => (
28
+ <Box key={i}>
29
+ <Text dimColor>│</Text>
30
+ <Text>{" " + line}</Text>
31
+ <Box flexGrow={1} />
32
+ <Text dimColor>│</Text>
33
+ </Box>
34
+ ))}
35
+ </Box>
36
+ );
37
+ });
@@ -0,0 +1,164 @@
1
+ import React, { useState, useCallback } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import TextInput from "ink-text-input";
4
+ import { createProgram } from "../program";
5
+
6
+ const BUILTIN_CMDS = ["help", "exit", "quit", "clear", "status"];
7
+
8
+ interface InputLineProps {
9
+ onSubmit: (value: string) => void;
10
+ isDisabled?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Input line with command history navigation and tab completion.
15
+ * Uses ink-text-input for text input with cursor.
16
+ */
17
+ export function InputLine({ onSubmit, isDisabled = false }: InputLineProps) {
18
+ const [value, setValue] = useState("");
19
+ const [history, setHistory] = useState<string[]>([]);
20
+ const [historyIdx, setHistoryIdx] = useState(-1);
21
+ const [historyTemp, setHistoryTemp] = useState("");
22
+
23
+ // Lazily build command list for tab completion
24
+ const [topCmds] = useState<string[]>(() => {
25
+ try {
26
+ const prog = createProgram();
27
+ return prog.commands.map((cmd) => cmd.name());
28
+ } catch {
29
+ return [];
30
+ }
31
+ });
32
+ const [subCmds] = useState<Map<string, string[]>>(() => {
33
+ try {
34
+ const prog = createProgram();
35
+ const map = new Map<string, string[]>();
36
+ for (const cmd of prog.commands) {
37
+ if (cmd.commands.length > 0) {
38
+ map.set(cmd.name(), cmd.commands.map((s) => s.name()));
39
+ }
40
+ }
41
+ return map;
42
+ } catch {
43
+ return new Map();
44
+ }
45
+ });
46
+
47
+ const handleSubmit = useCallback(
48
+ (val: string) => {
49
+ const trimmed = val.trim();
50
+ if (!trimmed) return;
51
+
52
+ // Add to history (avoid duplicate of last entry)
53
+ setHistory((prev) => {
54
+ if (prev[prev.length - 1] === trimmed) return prev;
55
+ return [...prev, trimmed];
56
+ });
57
+ setHistoryIdx(-1);
58
+ setHistoryTemp("");
59
+ setValue("");
60
+
61
+ onSubmit(trimmed);
62
+ },
63
+ [onSubmit]
64
+ );
65
+
66
+ useInput(
67
+ (_input, key) => {
68
+ if (isDisabled) return;
69
+
70
+ // Up arrow — history prev
71
+ if (key.upArrow) {
72
+ setHistory((hist) => {
73
+ setHistoryIdx((idx) => {
74
+ if (idx === -1) {
75
+ setHistoryTemp(value);
76
+ const newIdx = hist.length - 1;
77
+ if (newIdx >= 0) setValue(hist[newIdx]);
78
+ return newIdx;
79
+ } else if (idx > 0) {
80
+ const newIdx = idx - 1;
81
+ setValue(hist[newIdx]);
82
+ return newIdx;
83
+ }
84
+ return idx;
85
+ });
86
+ return hist;
87
+ });
88
+ return;
89
+ }
90
+
91
+ // Down arrow — history next
92
+ if (key.downArrow) {
93
+ setHistory((hist) => {
94
+ setHistoryIdx((idx) => {
95
+ if (idx >= 0) {
96
+ const newIdx = idx + 1;
97
+ if (newIdx >= hist.length) {
98
+ setValue(historyTemp);
99
+ return -1;
100
+ } else {
101
+ setValue(hist[newIdx]);
102
+ return newIdx;
103
+ }
104
+ }
105
+ return idx;
106
+ });
107
+ return hist;
108
+ });
109
+ return;
110
+ }
111
+
112
+ // Tab — completion
113
+ if (_input === "\t") {
114
+ const allTop = [...BUILTIN_CMDS, ...topCmds];
115
+ const trimmed = value.trimStart();
116
+ const spaceIdx = trimmed.indexOf(" ");
117
+
118
+ let completions: string[];
119
+ if (spaceIdx === -1) {
120
+ completions = allTop.filter((cmd) => cmd.startsWith(trimmed));
121
+ } else {
122
+ const parent = trimmed.slice(0, spaceIdx);
123
+ const rest = trimmed.slice(spaceIdx + 1);
124
+ const subs = subCmds.get(parent) ?? [];
125
+ completions = subs
126
+ .filter((s) => s.startsWith(rest))
127
+ .map((s) => `${parent} ${s}`);
128
+ }
129
+
130
+ if (completions.length === 0) return;
131
+
132
+ if (completions.length === 1) {
133
+ setValue(completions[0] + " ");
134
+ return;
135
+ }
136
+
137
+ // Find common prefix
138
+ const common = completions.reduce((a, b) => {
139
+ let i = 0;
140
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
141
+ return a.slice(0, i);
142
+ });
143
+ if (common.length > value.trimStart().length) {
144
+ setValue(common);
145
+ }
146
+ }
147
+ },
148
+ { isActive: !isDisabled }
149
+ );
150
+
151
+ return (
152
+ <Box>
153
+ <Text color="cyan">◈</Text>
154
+ <Text dimColor> arc402 </Text>
155
+ <Text color="white">{">"} </Text>
156
+ <TextInput
157
+ value={value}
158
+ onChange={setValue}
159
+ onSubmit={handleSubmit}
160
+ focus={!isDisabled}
161
+ />
162
+ </Box>
163
+ );
164
+ }
@@ -0,0 +1,66 @@
1
+ import React from "react";
2
+ import { Box, Text, useStdout } from "ink";
3
+
4
+ interface ViewportProps {
5
+ lines: string[];
6
+ scrollOffset: number;
7
+ isAutoScroll: boolean;
8
+ innerWidth?: number;
9
+ }
10
+
11
+ /**
12
+ * Scrollable output area framed with │ … │ box-drawing borders.
13
+ * Fills remaining terminal space between header and footer separators.
14
+ */
15
+ export function Viewport({ lines, scrollOffset, isAutoScroll, innerWidth = 58 }: ViewportProps) {
16
+ const { stdout } = useStdout();
17
+ const termRows = stdout?.rows ?? 24;
18
+
19
+ // Header (~14 rows) + top/mid/mid/bot borders (4) + footer input (1) = ~19 fixed rows
20
+ const FIXED_ROWS = 19;
21
+ const viewportHeight = Math.max(1, termRows - FIXED_ROWS);
22
+
23
+ // Compute the window slice
24
+ const totalLines = lines.length;
25
+ let endIdx: number;
26
+ let startIdx: number;
27
+
28
+ if (scrollOffset === 0) {
29
+ endIdx = totalLines;
30
+ startIdx = Math.max(0, endIdx - viewportHeight);
31
+ } else {
32
+ endIdx = Math.max(0, totalLines - scrollOffset);
33
+ startIdx = Math.max(0, endIdx - viewportHeight);
34
+ }
35
+
36
+ const visibleLines = lines.slice(startIdx, endIdx);
37
+
38
+ // Pad with empty lines if fewer than viewportHeight
39
+ const padCount = Math.max(0, viewportHeight - visibleLines.length);
40
+ const paddedLines = [
41
+ ...Array(padCount).fill(""),
42
+ ...visibleLines,
43
+ ];
44
+
45
+ const canScrollDown = scrollOffset > 0;
46
+
47
+ return (
48
+ <Box flexDirection="column" flexGrow={1}>
49
+ {paddedLines.map((line, i) => (
50
+ <Box key={i}>
51
+ <Text dimColor>│</Text>
52
+ <Text>{" " + line}</Text>
53
+ <Box flexGrow={1} />
54
+ <Text dimColor>│</Text>
55
+ </Box>
56
+ ))}
57
+ {canScrollDown && !isAutoScroll && (
58
+ <Box justifyContent="flex-end">
59
+ <Text dimColor>│ ↓ more</Text>
60
+ <Box flexGrow={1} />
61
+ <Text dimColor>│</Text>
62
+ </Box>
63
+ )}
64
+ </Box>
65
+ );
66
+ }
@@ -0,0 +1,72 @@
1
+ import React from "react";
2
+ import { render } from "ink";
3
+ import { App } from "./App";
4
+ import fs from "fs";
5
+ import path from "path";
6
+ import os from "os";
7
+
8
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
9
+ const pkg = require("../../package.json") as { version: string };
10
+
11
+ const CONFIG_PATH = path.join(os.homedir(), ".arc402", "config.json");
12
+
13
+ interface Config {
14
+ network?: string;
15
+ walletContractAddress?: string;
16
+ rpcUrl?: string;
17
+ }
18
+
19
+ async function loadConfig(): Promise<Config> {
20
+ if (!fs.existsSync(CONFIG_PATH)) return {};
21
+ try {
22
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8")) as Config;
23
+ } catch {
24
+ return {};
25
+ }
26
+ }
27
+
28
+ async function getBalance(
29
+ rpcUrl: string,
30
+ address: string
31
+ ): Promise<string | undefined> {
32
+ try {
33
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
34
+ const ethersLib = require("ethers") as typeof import("ethers");
35
+ const provider = new ethersLib.ethers.JsonRpcProvider(rpcUrl);
36
+ const bal = await Promise.race([
37
+ provider.getBalance(address),
38
+ new Promise<never>((_, r) =>
39
+ setTimeout(() => r(new Error("timeout")), 2000)
40
+ ),
41
+ ]);
42
+ return `${parseFloat(ethersLib.ethers.formatEther(bal)).toFixed(4)} ETH`;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ export async function launchTUI(): Promise<void> {
49
+ const config = await loadConfig();
50
+
51
+ let walletDisplay: string | undefined;
52
+ if (config.walletContractAddress) {
53
+ const w = config.walletContractAddress;
54
+ walletDisplay = `${w.slice(0, 6)}...${w.slice(-4)}`;
55
+ }
56
+
57
+ let balance: string | undefined;
58
+ if (config.rpcUrl && config.walletContractAddress) {
59
+ balance = await getBalance(config.rpcUrl, config.walletContractAddress);
60
+ }
61
+
62
+ const { waitUntilExit } = render(
63
+ <App
64
+ version={pkg.version}
65
+ network={config.network}
66
+ wallet={walletDisplay}
67
+ balance={balance}
68
+ />
69
+ );
70
+
71
+ await waitUntilExit();
72
+ }
@@ -0,0 +1,103 @@
1
+ import { useState, useCallback } from "react";
2
+ import chalk from "chalk";
3
+ import { c } from "../ui/colors";
4
+
5
+ interface UseChatResult {
6
+ send: (message: string, onLine: (line: string) => void) => Promise<void>;
7
+ isSending: boolean;
8
+ }
9
+
10
+ /**
11
+ * Sends messages to the OpenClaw gateway and streams responses
12
+ * line-by-line into the viewport buffer via onLine callback.
13
+ */
14
+ export function useChat(): UseChatResult {
15
+ const [isSending, setIsSending] = useState(false);
16
+
17
+ const send = useCallback(
18
+ async (message: string, onLine: (line: string) => void): Promise<void> => {
19
+ setIsSending(true);
20
+ const trimmed = message.trim().slice(0, 10000);
21
+
22
+ // Show thinking placeholder
23
+ onLine(chalk.dim(" ◈ ") + chalk.dim("thinking..."));
24
+
25
+ let res: Response;
26
+ try {
27
+ res = await fetch("http://localhost:19000/api/agent", {
28
+ method: "POST",
29
+ headers: { "Content-Type": "application/json" },
30
+ body: JSON.stringify({ message: trimmed, session: "arc402-tui" }),
31
+ signal: AbortSignal.timeout(30000),
32
+ });
33
+ } catch (err: unknown) {
34
+ const msg = err instanceof Error ? err.message : String(err);
35
+ const isDown =
36
+ msg.includes("ECONNREFUSED") ||
37
+ msg.includes("fetch failed") ||
38
+ msg.includes("ENOTFOUND") ||
39
+ msg.includes("UND_ERR_SOCKET");
40
+ if (isDown) {
41
+ onLine(
42
+ " " +
43
+ chalk.yellow("⚠") +
44
+ " " +
45
+ chalk.dim("OpenClaw gateway not running. Start with: ") +
46
+ chalk.white("openclaw gateway start")
47
+ );
48
+ } else {
49
+ onLine(` ${c.failure} ${chalk.red(msg)}`);
50
+ }
51
+ setIsSending(false);
52
+ return;
53
+ }
54
+
55
+ if (!res.body) {
56
+ onLine(chalk.dim(" ◈ ") + chalk.white("(empty response)"));
57
+ setIsSending(false);
58
+ return;
59
+ }
60
+
61
+ const flushLine = (line: string): void => {
62
+ // Unwrap SSE data lines
63
+ if (line.startsWith("data: ")) {
64
+ line = line.slice(6);
65
+ if (line === "[DONE]") return;
66
+ try {
67
+ const j = JSON.parse(line) as {
68
+ text?: string;
69
+ content?: string;
70
+ delta?: { text?: string };
71
+ };
72
+ line = j.text ?? j.content ?? j.delta?.text ?? line;
73
+ } catch {
74
+ /* use raw */
75
+ }
76
+ }
77
+ if (line.trim()) {
78
+ onLine(chalk.dim(" ◈ ") + chalk.white(line));
79
+ }
80
+ };
81
+
82
+ const reader = res.body.getReader();
83
+ const decoder = new TextDecoder();
84
+ let buffer = "";
85
+
86
+ while (true) {
87
+ const { done, value } = await reader.read();
88
+ if (done) break;
89
+ buffer += decoder.decode(value, { stream: true });
90
+ const lines = buffer.split("\n");
91
+ buffer = lines.pop() ?? "";
92
+ for (const line of lines) flushLine(line);
93
+ }
94
+
95
+ if (buffer.trim()) flushLine(buffer);
96
+
97
+ setIsSending(false);
98
+ },
99
+ []
100
+ );
101
+
102
+ return { send, isSending };
103
+ }