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.
- 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 -567
- package/dist/repl.js.map +1 -1
- package/dist/tui/App.d.ts +12 -0
- package/dist/tui/App.d.ts.map +1 -0
- package/dist/tui/App.js +154 -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 +14 -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 +14 -0
- package/dist/tui/Viewport.d.ts.map +1 -0
- package/dist/tui/Viewport.js +48 -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 +12 -0
- package/dist/tui/useCommand.d.ts.map +1 -0
- package/dist/tui/useCommand.js +137 -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 -676
- package/src/tui/App.tsx +214 -0
- package/src/tui/Footer.tsx +18 -0
- package/src/tui/Header.tsx +30 -0
- package/src/tui/InputLine.tsx +164 -0
- package/src/tui/Viewport.tsx +70 -0
- package/src/tui/index.tsx +72 -0
- package/src/tui/useChat.ts +103 -0
- package/src/tui/useCommand.ts +148 -0
- package/src/tui/useScroll.ts +65 -0
- package/tsconfig.json +6 -1
package/src/tui/App.tsx
ADDED
|
@@ -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
|
+
}
|