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.
- package/TUI-SPEC.md +214 -0
- package/dist/index.js +55 -1
- package/dist/index.js.map +1 -1
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +46 -558
- package/dist/repl.js.map +1 -1
- package/dist/tui/App.d.ts +20 -0
- package/dist/tui/App.d.ts.map +1 -0
- package/dist/tui/App.js +166 -0
- package/dist/tui/App.js.map +1 -0
- package/dist/tui/Footer.d.ts +11 -0
- package/dist/tui/Footer.d.ts.map +1 -0
- package/dist/tui/Footer.js +13 -0
- package/dist/tui/Footer.js.map +1 -0
- package/dist/tui/Header.d.ts +15 -0
- package/dist/tui/Header.d.ts.map +1 -0
- package/dist/tui/Header.js +19 -0
- package/dist/tui/Header.js.map +1 -0
- package/dist/tui/InputLine.d.ts +11 -0
- package/dist/tui/InputLine.d.ts.map +1 -0
- package/dist/tui/InputLine.js +145 -0
- package/dist/tui/InputLine.js.map +1 -0
- package/dist/tui/Viewport.d.ts +13 -0
- package/dist/tui/Viewport.d.ts.map +1 -0
- package/dist/tui/Viewport.js +38 -0
- package/dist/tui/Viewport.js.map +1 -0
- package/dist/tui/index.d.ts +2 -0
- package/dist/tui/index.d.ts.map +1 -0
- package/dist/tui/index.js +55 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/useChat.d.ts +11 -0
- package/dist/tui/useChat.d.ts.map +1 -0
- package/dist/tui/useChat.js +91 -0
- package/dist/tui/useChat.js.map +1 -0
- package/dist/tui/useCommand.d.ts +17 -0
- package/dist/tui/useCommand.d.ts.map +1 -0
- package/dist/tui/useCommand.js +219 -0
- package/dist/tui/useCommand.js.map +1 -0
- package/dist/tui/useScroll.d.ts +17 -0
- package/dist/tui/useScroll.d.ts.map +1 -0
- package/dist/tui/useScroll.js +46 -0
- package/dist/tui/useScroll.js.map +1 -0
- package/package.json +5 -1
- package/src/index.ts +21 -1
- package/src/repl.ts +50 -663
- package/src/tui/App.tsx +240 -0
- package/src/tui/Footer.tsx +18 -0
- package/src/tui/Header.tsx +37 -0
- package/src/tui/InputLine.tsx +164 -0
- package/src/tui/Viewport.tsx +66 -0
- package/src/tui/index.tsx +72 -0
- package/src/tui/useChat.ts +103 -0
- package/src/tui/useCommand.ts +245 -0
- package/src/tui/useScroll.ts +65 -0
- package/tsconfig.json +6 -1
package/src/tui/App.tsx
ADDED
|
@@ -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
|
+
}
|