arc402-cli 0.7.2 → 0.7.4

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 -567
  6. package/dist/repl.js.map +1 -1
  7. package/dist/tui/App.d.ts +12 -0
  8. package/dist/tui/App.d.ts.map +1 -0
  9. package/dist/tui/App.js +154 -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 +14 -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 +14 -0
  24. package/dist/tui/Viewport.d.ts.map +1 -0
  25. package/dist/tui/Viewport.js +48 -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 +12 -0
  36. package/dist/tui/useCommand.d.ts.map +1 -0
  37. package/dist/tui/useCommand.js +137 -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 -676
  46. package/src/tui/App.tsx +214 -0
  47. package/src/tui/Footer.tsx +18 -0
  48. package/src/tui/Header.tsx +30 -0
  49. package/src/tui/InputLine.tsx +164 -0
  50. package/src/tui/Viewport.tsx +70 -0
  51. package/src/tui/index.tsx +72 -0
  52. package/src/tui/useChat.ts +103 -0
  53. package/src/tui/useCommand.ts +148 -0
  54. package/src/tui/useScroll.ts +65 -0
  55. package/tsconfig.json +6 -1
@@ -0,0 +1,214 @@
1
+ import React, { useState, useCallback, useEffect } from "react";
2
+ import { Box, Text, useApp } from "ink";
3
+ import { Header } from "./Header";
4
+ import { Viewport } from "./Viewport";
5
+ import { Footer } from "./Footer";
6
+ import { InputLine } from "./InputLine";
7
+ import { useCommand } from "./useCommand";
8
+ import { useChat } from "./useChat";
9
+ import { useScroll } from "./useScroll";
10
+ import { createProgram } from "../program";
11
+ import chalk from "chalk";
12
+
13
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
14
+ const pkg = require("../../package.json") as { version: string };
15
+
16
+ const BUILTIN_CMDS = ["help", "exit", "quit", "clear", "status"];
17
+
18
+ interface AppProps {
19
+ version: string;
20
+ network?: string;
21
+ wallet?: string;
22
+ balance?: string;
23
+ }
24
+
25
+ /**
26
+ * Root TUI component — fixed header/footer with scrollable viewport.
27
+ */
28
+ export function App({ version, network, wallet, balance }: AppProps) {
29
+ const { exit } = useApp();
30
+ const [outputBuffer, setOutputBuffer] = useState<string[]>([
31
+ chalk.dim(" Type 'help' to see available commands"),
32
+ "",
33
+ ]);
34
+ const [isProcessing, setIsProcessing] = useState(false);
35
+
36
+ const { execute, isRunning } = useCommand();
37
+ const { send, isSending } = useChat();
38
+
39
+ // Approximate viewport height for scroll management
40
+ const HEADER_ROWS = 15;
41
+ const FOOTER_ROWS = 1;
42
+ const rows = process.stdout.rows ?? 24;
43
+ const viewportHeight = Math.max(1, rows - HEADER_ROWS - FOOTER_ROWS);
44
+
45
+ const { scrollOffset, isAutoScroll, scrollUp, scrollDown, snapToBottom } =
46
+ useScroll(viewportHeight);
47
+
48
+ // Get top-level command names for dispatch detection
49
+ const [topCmds] = useState<string[]>(() => {
50
+ try {
51
+ const prog = createProgram();
52
+ return prog.commands.map((cmd) => cmd.name());
53
+ } catch {
54
+ return [];
55
+ }
56
+ });
57
+
58
+ const appendLine = useCallback((line: string) => {
59
+ setOutputBuffer((prev) => [...prev, line]);
60
+ }, []);
61
+
62
+ const handleCommand = useCallback(
63
+ async (input: string): Promise<void> => {
64
+ // Echo command to viewport
65
+ appendLine(
66
+ chalk.cyanBright("◈") +
67
+ " " +
68
+ chalk.dim("arc402") +
69
+ " " +
70
+ chalk.white(">") +
71
+ " " +
72
+ chalk.white(input)
73
+ );
74
+
75
+ // Snap to bottom on new command
76
+ snapToBottom();
77
+ setIsProcessing(true);
78
+
79
+ const firstWord = input.split(/\s+/)[0];
80
+ const allKnown = [...BUILTIN_CMDS, ...topCmds];
81
+
82
+ // ── Built-in: exit/quit ────────────────────────────────────────────────
83
+ if (input === "exit" || input === "quit") {
84
+ appendLine(
85
+ " " + chalk.cyanBright("◈") + chalk.dim(" goodbye")
86
+ );
87
+ setIsProcessing(false);
88
+ setTimeout(() => exit(), 100);
89
+ return;
90
+ }
91
+
92
+ // ── Built-in: clear ────────────────────────────────────────────────────
93
+ if (input === "clear") {
94
+ setOutputBuffer([]);
95
+ setIsProcessing(false);
96
+ return;
97
+ }
98
+
99
+ // ── Built-in: help ─────────────────────────────────────────────────────
100
+ if (input === "help" || input === "/help") {
101
+ const lines: string[] = [];
102
+ try {
103
+ const prog = createProgram();
104
+ prog.exitOverride();
105
+ const helpText: string[] = [];
106
+ prog.configureOutput({
107
+ writeOut: (str) => helpText.push(str),
108
+ writeErr: (str) => helpText.push(str),
109
+ });
110
+ try {
111
+ await prog.parseAsync(["node", "arc402", "--help"]);
112
+ } catch {
113
+ /* commander throws after printing help */
114
+ }
115
+ for (const chunk of helpText) {
116
+ for (const l of chunk.split("\n")) lines.push(l);
117
+ }
118
+ } catch {
119
+ lines.push(chalk.red("Failed to load help"));
120
+ }
121
+ lines.push("");
122
+ lines.push(chalk.cyanBright("Chat"));
123
+ lines.push(
124
+ " " +
125
+ chalk.white("<message>") +
126
+ chalk.dim(" Send message to OpenClaw gateway")
127
+ );
128
+ lines.push(
129
+ " " +
130
+ chalk.white("/chat <message>") +
131
+ chalk.dim(" Explicitly route to chat")
132
+ );
133
+ lines.push(
134
+ chalk.dim(
135
+ " Gateway: http://localhost:19000 (openclaw gateway start)"
136
+ )
137
+ );
138
+ lines.push("");
139
+ for (const l of lines) appendLine(l);
140
+ setIsProcessing(false);
141
+ return;
142
+ }
143
+
144
+ // ── Built-in: status ───────────────────────────────────────────────────
145
+ if (input === "status") {
146
+ if (network)
147
+ appendLine(
148
+ ` ${chalk.dim("Network")} ${chalk.white(network)}`
149
+ );
150
+ if (wallet)
151
+ appendLine(` ${chalk.dim("Wallet")} ${chalk.white(wallet)}`);
152
+ if (balance)
153
+ appendLine(` ${chalk.dim("Balance")} ${chalk.white(balance)}`);
154
+ if (!network && !wallet && !balance)
155
+ appendLine(chalk.dim(" No config found. Run 'config init' to get started."));
156
+ appendLine("");
157
+ setIsProcessing(false);
158
+ return;
159
+ }
160
+
161
+ // ── /chat prefix or unknown command → chat ─────────────────────────────
162
+ const isExplicitChat = input.startsWith("/chat ");
163
+ const isChatInput =
164
+ isExplicitChat || (!allKnown.includes(firstWord) && firstWord !== "");
165
+
166
+ if (isChatInput) {
167
+ const msg = isExplicitChat ? input.slice(6).trim() : input;
168
+ if (msg) {
169
+ await send(msg, appendLine);
170
+ }
171
+ appendLine("");
172
+ setIsProcessing(false);
173
+ return;
174
+ }
175
+
176
+ // ── Dispatch to commander ──────────────────────────────────────────────
177
+ await execute(input, appendLine);
178
+ appendLine("");
179
+ setIsProcessing(false);
180
+ },
181
+ [appendLine, execute, send, snapToBottom, topCmds, network, wallet, balance, exit]
182
+ );
183
+
184
+ const isDisabled = isProcessing || isRunning || isSending;
185
+
186
+ return (
187
+ <Box flexDirection="column" height="100%">
188
+ {/* HEADER — fixed, never scrolls */}
189
+ <Header
190
+ version={version}
191
+ network={network}
192
+ wallet={wallet}
193
+ balance={balance}
194
+ />
195
+
196
+ {/* Separator */}
197
+ <Box>
198
+ <Text dimColor>{"─".repeat(60)}</Text>
199
+ </Box>
200
+
201
+ {/* VIEWPORT — fills remaining space */}
202
+ <Viewport
203
+ lines={outputBuffer}
204
+ scrollOffset={scrollOffset}
205
+ isAutoScroll={isAutoScroll}
206
+ />
207
+
208
+ {/* FOOTER — fixed, input pinned */}
209
+ <Footer>
210
+ <InputLine onSubmit={handleCommand} isDisabled={isDisabled} />
211
+ </Footer>
212
+ </Box>
213
+ );
214
+ }
@@ -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,30 @@
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
+ }
11
+
12
+ /**
13
+ * Fixed header showing the ASCII art banner + status info.
14
+ * Never re-renders unless config changes.
15
+ */
16
+ export const Header = React.memo(function Header({
17
+ network,
18
+ wallet,
19
+ balance,
20
+ }: HeaderProps) {
21
+ const bannerLines = getBannerLines({ network, wallet, balance });
22
+
23
+ return (
24
+ <Box flexDirection="column">
25
+ {bannerLines.map((line, i) => (
26
+ <Text key={i}>{line}</Text>
27
+ ))}
28
+ </Box>
29
+ );
30
+ });
@@ -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,70 @@
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
+ }
9
+
10
+ /**
11
+ * Scrollable output area that fills remaining terminal space.
12
+ * Renders a window slice of the buffer, not terminal scroll.
13
+ * scrollOffset=0 means pinned to bottom (auto-scroll).
14
+ * Positive scrollOffset means scrolled up by that many lines.
15
+ */
16
+ export function Viewport({ lines, scrollOffset, isAutoScroll }: ViewportProps) {
17
+ const { stdout } = useStdout();
18
+ const termRows = stdout?.rows ?? 24;
19
+
20
+ // We'll compute the viewport height: total rows minus fixed areas
21
+ // Header is approximately bannerLines + separator (~14-16 rows)
22
+ // Footer is 1 row
23
+ // We'll use a reasonable estimate here; the parent App can pass exact height
24
+ const HEADER_ROWS = 15; // approximate
25
+ const FOOTER_ROWS = 1;
26
+ const viewportHeight = Math.max(1, termRows - HEADER_ROWS - FOOTER_ROWS);
27
+
28
+ // Compute the window slice
29
+ // scrollOffset=0 → show last viewportHeight lines
30
+ // scrollOffset=N → show lines ending viewportHeight+N from end
31
+ const totalLines = lines.length;
32
+ let endIdx: number;
33
+ let startIdx: number;
34
+
35
+ if (scrollOffset === 0) {
36
+ // Auto-scroll: pinned to bottom
37
+ endIdx = totalLines;
38
+ startIdx = Math.max(0, endIdx - viewportHeight);
39
+ } else {
40
+ // Scrolled up: scrollOffset lines from bottom
41
+ endIdx = Math.max(0, totalLines - scrollOffset);
42
+ startIdx = Math.max(0, endIdx - viewportHeight);
43
+ }
44
+
45
+ const visibleLines = lines.slice(startIdx, endIdx);
46
+
47
+ // Pad with empty lines if fewer than viewportHeight
48
+ const padCount = Math.max(0, viewportHeight - visibleLines.length);
49
+ const paddedLines = [
50
+ ...Array(padCount).fill(""),
51
+ ...visibleLines,
52
+ ];
53
+
54
+ const canScrollDown = scrollOffset > 0;
55
+
56
+ return (
57
+ <Box flexDirection="column" flexGrow={1}>
58
+ <Box flexDirection="column" flexGrow={1}>
59
+ {paddedLines.map((line, i) => (
60
+ <Text key={i}>{line}</Text>
61
+ ))}
62
+ </Box>
63
+ {canScrollDown && !isAutoScroll && (
64
+ <Box justifyContent="flex-end">
65
+ <Text dimColor>↓ more</Text>
66
+ </Box>
67
+ )}
68
+ </Box>
69
+ );
70
+ }
@@ -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
+ }