@meshxdata/fops 0.0.1
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/README.md +98 -0
- package/STRUCTURE.md +43 -0
- package/foundation.mjs +16 -0
- package/package.json +52 -0
- package/src/agent/agent.js +367 -0
- package/src/agent/agent.test.js +233 -0
- package/src/agent/context.js +143 -0
- package/src/agent/context.test.js +81 -0
- package/src/agent/index.js +2 -0
- package/src/agent/llm.js +127 -0
- package/src/agent/llm.test.js +139 -0
- package/src/auth/index.js +4 -0
- package/src/auth/keychain.js +58 -0
- package/src/auth/keychain.test.js +185 -0
- package/src/auth/login.js +421 -0
- package/src/auth/login.test.js +192 -0
- package/src/auth/oauth.js +203 -0
- package/src/auth/oauth.test.js +118 -0
- package/src/auth/resolve.js +78 -0
- package/src/auth/resolve.test.js +153 -0
- package/src/commands/index.js +268 -0
- package/src/config.js +24 -0
- package/src/config.test.js +70 -0
- package/src/doctor.js +487 -0
- package/src/doctor.test.js +134 -0
- package/src/plugins/api.js +37 -0
- package/src/plugins/api.test.js +95 -0
- package/src/plugins/discovery.js +78 -0
- package/src/plugins/discovery.test.js +92 -0
- package/src/plugins/hooks.js +13 -0
- package/src/plugins/hooks.test.js +118 -0
- package/src/plugins/index.js +3 -0
- package/src/plugins/loader.js +110 -0
- package/src/plugins/manifest.js +26 -0
- package/src/plugins/manifest.test.js +106 -0
- package/src/plugins/registry.js +14 -0
- package/src/plugins/registry.test.js +43 -0
- package/src/plugins/skills.js +126 -0
- package/src/plugins/skills.test.js +173 -0
- package/src/project.js +61 -0
- package/src/project.test.js +196 -0
- package/src/setup/aws.js +369 -0
- package/src/setup/aws.test.js +280 -0
- package/src/setup/index.js +3 -0
- package/src/setup/setup.js +161 -0
- package/src/setup/wizard.js +119 -0
- package/src/shell.js +9 -0
- package/src/shell.test.js +72 -0
- package/src/skills/foundation/SKILL.md +107 -0
- package/src/ui/banner.js +56 -0
- package/src/ui/banner.test.js +97 -0
- package/src/ui/confirm.js +97 -0
- package/src/ui/index.js +5 -0
- package/src/ui/input.js +199 -0
- package/src/ui/spinner.js +170 -0
- package/src/ui/spinner.test.js +29 -0
- package/src/ui/streaming.js +106 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { render, Box, Text, useInput } from "ink";
|
|
3
|
+
|
|
4
|
+
const h = React.createElement;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Select prompt component (arrow-key list picker like Claude Code)
|
|
8
|
+
*/
|
|
9
|
+
export function SelectPrompt({ message, options, onResult }) {
|
|
10
|
+
const [cursor, setCursor] = useState(0);
|
|
11
|
+
const [done, setDone] = useState(false);
|
|
12
|
+
|
|
13
|
+
useInput((ch, key) => {
|
|
14
|
+
if (done) return;
|
|
15
|
+
|
|
16
|
+
if (key.upArrow) {
|
|
17
|
+
setCursor((c) => (c <= 0 ? options.length - 1 : c - 1));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (key.downArrow) {
|
|
21
|
+
setCursor((c) => (c >= options.length - 1 ? 0 : c + 1));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (key.return) {
|
|
26
|
+
setDone(true);
|
|
27
|
+
onResult(options[cursor]);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (key.escape || (key.ctrl && ch === "c")) {
|
|
32
|
+
setDone(true);
|
|
33
|
+
onResult(null);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (done) {
|
|
39
|
+
return h(Box, null,
|
|
40
|
+
h(Text, { color: "green" }, "? "),
|
|
41
|
+
h(Text, null, message + " "),
|
|
42
|
+
h(Text, { color: "cyan" }, options[cursor]?.label || "")
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return h(Box, { flexDirection: "column" },
|
|
47
|
+
h(Box, null,
|
|
48
|
+
h(Text, { color: "green" }, "? "),
|
|
49
|
+
h(Text, { bold: true }, message)
|
|
50
|
+
),
|
|
51
|
+
...options.map((opt, i) =>
|
|
52
|
+
h(Box, { key: i },
|
|
53
|
+
h(Text, { color: i === cursor ? "cyan" : "gray" },
|
|
54
|
+
` ${i === cursor ? "❯" : " "} ${opt.label}`
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Show a select option picker.
|
|
63
|
+
* options: Array of { label, value } or strings
|
|
64
|
+
* Returns selected option's value, or null on escape.
|
|
65
|
+
*/
|
|
66
|
+
export async function selectOption(message, options) {
|
|
67
|
+
const normalized = options.map((o) =>
|
|
68
|
+
typeof o === "string" ? { label: o, value: o } : o
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
let resolved = false;
|
|
73
|
+
const onResult = (selected) => {
|
|
74
|
+
if (resolved) return;
|
|
75
|
+
resolved = true;
|
|
76
|
+
clear();
|
|
77
|
+
unmount();
|
|
78
|
+
setTimeout(() => resolve(selected ? selected.value : null), 50);
|
|
79
|
+
};
|
|
80
|
+
const { unmount, clear } = render(
|
|
81
|
+
h(SelectPrompt, { message, options: normalized, onResult })
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Show a confirm prompt (Yes/No picker).
|
|
88
|
+
* Returns true/false.
|
|
89
|
+
*/
|
|
90
|
+
export async function confirm(message, defaultValue = false) {
|
|
91
|
+
const options = defaultValue
|
|
92
|
+
? [{ label: "Yes", value: true }, { label: "No", value: false }]
|
|
93
|
+
: [{ label: "No", value: false }, { label: "Yes", value: true }];
|
|
94
|
+
|
|
95
|
+
const result = await selectOption(message, options);
|
|
96
|
+
return result === null ? defaultValue : result;
|
|
97
|
+
}
|
package/src/ui/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { BANNER, QUOTES, renderBanner, getRandomQuote } from "./banner.js";
|
|
2
|
+
export { ThinkingSpinner, renderSpinner, renderThinking, VERBS } from "./spinner.js";
|
|
3
|
+
export { ResponseBox, renderResponse, StreamingResponse, renderStreaming } from "./streaming.js";
|
|
4
|
+
export { InputBox, StandaloneInput, promptInput, clearInputHistory } from "./input.js";
|
|
5
|
+
export { SelectPrompt, selectOption, confirm } from "./confirm.js";
|
package/src/ui/input.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { render, Box, Text, useInput, useApp } from "ink";
|
|
3
|
+
|
|
4
|
+
const h = React.createElement;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Input box with history support
|
|
8
|
+
*/
|
|
9
|
+
export function InputBox({ onSubmit, onExit, history = [], placeholder = "Type a message..." }) {
|
|
10
|
+
const [input, setInput] = useState("");
|
|
11
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
12
|
+
const [cursorVisible, setCursorVisible] = useState(true);
|
|
13
|
+
const { exit } = useApp();
|
|
14
|
+
|
|
15
|
+
// Blinking cursor effect
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const interval = setInterval(() => {
|
|
18
|
+
setCursorVisible(v => !v);
|
|
19
|
+
}, 500);
|
|
20
|
+
return () => clearInterval(interval);
|
|
21
|
+
}, []);
|
|
22
|
+
|
|
23
|
+
useInput((ch, key) => {
|
|
24
|
+
if (key.return) {
|
|
25
|
+
if (input.trim()) {
|
|
26
|
+
onSubmit(input.trim());
|
|
27
|
+
setInput("");
|
|
28
|
+
setHistoryIndex(-1);
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (key.escape || (key.ctrl && ch === "c")) {
|
|
34
|
+
if (onExit) onExit();
|
|
35
|
+
exit();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (key.upArrow) {
|
|
40
|
+
if (history.length > 0 && historyIndex < history.length - 1) {
|
|
41
|
+
const newIndex = historyIndex + 1;
|
|
42
|
+
setHistoryIndex(newIndex);
|
|
43
|
+
setInput(history[history.length - 1 - newIndex] || "");
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (key.downArrow) {
|
|
49
|
+
if (historyIndex > 0) {
|
|
50
|
+
const newIndex = historyIndex - 1;
|
|
51
|
+
setHistoryIndex(newIndex);
|
|
52
|
+
setInput(history[history.length - 1 - newIndex] || "");
|
|
53
|
+
} else if (historyIndex === 0) {
|
|
54
|
+
setHistoryIndex(-1);
|
|
55
|
+
setInput("");
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (key.backspace || key.delete) {
|
|
61
|
+
setInput(input.slice(0, -1));
|
|
62
|
+
setHistoryIndex(-1);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (ch && !key.ctrl && !key.meta) {
|
|
67
|
+
setInput(input + ch);
|
|
68
|
+
setHistoryIndex(-1);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const cursor = cursorVisible ? "▋" : " ";
|
|
73
|
+
const displayText = input || "";
|
|
74
|
+
|
|
75
|
+
return h(Box, {
|
|
76
|
+
flexDirection: "column",
|
|
77
|
+
borderStyle: "round",
|
|
78
|
+
borderColor: "cyan",
|
|
79
|
+
paddingX: 1,
|
|
80
|
+
marginTop: 1,
|
|
81
|
+
},
|
|
82
|
+
h(Box, null,
|
|
83
|
+
h(Text, { color: "cyan", bold: true }, "❯ "),
|
|
84
|
+
h(Text, null, displayText),
|
|
85
|
+
h(Text, { color: "cyan" }, cursor)
|
|
86
|
+
),
|
|
87
|
+
!input && h(Text, { dimColor: true }, placeholder)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// State for standalone input
|
|
92
|
+
let inputState = { resolve: null, history: [] };
|
|
93
|
+
|
|
94
|
+
export function StandaloneInput({ placeholder, onResult }) {
|
|
95
|
+
const [input, setInput] = useState("");
|
|
96
|
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
97
|
+
const [cursorVisible, setCursorVisible] = useState(true);
|
|
98
|
+
const [done, setDone] = useState(false);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const interval = setInterval(() => setCursorVisible(v => !v), 500);
|
|
102
|
+
return () => clearInterval(interval);
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
useInput((ch, key) => {
|
|
106
|
+
if (done) return;
|
|
107
|
+
|
|
108
|
+
if (key.return && input.trim()) {
|
|
109
|
+
inputState.history.push(input.trim());
|
|
110
|
+
setDone(true);
|
|
111
|
+
onResult(input.trim());
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (key.escape || (key.ctrl && ch === "c")) {
|
|
116
|
+
setDone(true);
|
|
117
|
+
onResult(null);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (key.upArrow && inputState.history.length > 0) {
|
|
122
|
+
if (historyIndex < inputState.history.length - 1) {
|
|
123
|
+
const idx = historyIndex + 1;
|
|
124
|
+
setHistoryIndex(idx);
|
|
125
|
+
setInput(inputState.history[inputState.history.length - 1 - idx] || "");
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (key.downArrow) {
|
|
131
|
+
if (historyIndex > 0) {
|
|
132
|
+
const idx = historyIndex - 1;
|
|
133
|
+
setHistoryIndex(idx);
|
|
134
|
+
setInput(inputState.history[inputState.history.length - 1 - idx] || "");
|
|
135
|
+
} else if (historyIndex === 0) {
|
|
136
|
+
setHistoryIndex(-1);
|
|
137
|
+
setInput("");
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (key.backspace || key.delete) {
|
|
143
|
+
setInput(i => i.slice(0, -1));
|
|
144
|
+
setHistoryIndex(-1);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (ch && !key.ctrl && !key.meta) {
|
|
149
|
+
setInput(i => i + ch);
|
|
150
|
+
setHistoryIndex(-1);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return h(Box, {
|
|
155
|
+
flexDirection: "column",
|
|
156
|
+
borderStyle: "round",
|
|
157
|
+
borderColor: "cyan",
|
|
158
|
+
paddingX: 1,
|
|
159
|
+
},
|
|
160
|
+
h(Box, null,
|
|
161
|
+
h(Text, { color: "cyan", bold: true }, "❯ "),
|
|
162
|
+
h(Text, null, input),
|
|
163
|
+
h(Text, { color: "cyan" }, cursorVisible ? "▋" : " ")
|
|
164
|
+
),
|
|
165
|
+
!input && placeholder && h(Text, { dimColor: true }, placeholder)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Prompt for input with history support
|
|
171
|
+
* Returns the input string or null if cancelled
|
|
172
|
+
*/
|
|
173
|
+
export async function promptInput(placeholder = "Type a message... (↑↓ history, esc to exit)") {
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
let resolved = false;
|
|
176
|
+
let userInput = null;
|
|
177
|
+
const onResult = (result) => {
|
|
178
|
+
if (resolved) return;
|
|
179
|
+
resolved = true;
|
|
180
|
+
userInput = result;
|
|
181
|
+
clear();
|
|
182
|
+
unmount();
|
|
183
|
+
// Echo what user typed and reset terminal
|
|
184
|
+
process.stdout.write("\x1b[0m\n");
|
|
185
|
+
if (result) {
|
|
186
|
+
console.log("\x1b[36m❯\x1b[0m " + result + "\n");
|
|
187
|
+
}
|
|
188
|
+
setTimeout(() => resolve(result), 50);
|
|
189
|
+
};
|
|
190
|
+
const { unmount, clear } = render(h(StandaloneInput, { placeholder, onResult }));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Clear input history
|
|
196
|
+
*/
|
|
197
|
+
export function clearInputHistory() {
|
|
198
|
+
inputState.history = [];
|
|
199
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { render, Box, Text } from "ink";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
const SPARKLE_FRAMES = ["✻", "✼", "✻", "✦"];
|
|
8
|
+
const SPARKLE_INTERVAL = 120;
|
|
9
|
+
const INTENT_LINE = chalk.cyan("⏺") + chalk.gray(" Thinking...");
|
|
10
|
+
|
|
11
|
+
// Claude-style verbs for the spinner
|
|
12
|
+
export const VERBS = [
|
|
13
|
+
"Hacking the mainframe",
|
|
14
|
+
"Decrypting signals",
|
|
15
|
+
"Tracing packets",
|
|
16
|
+
"Scanning ports",
|
|
17
|
+
"Parsing logs",
|
|
18
|
+
"Interrogating containers",
|
|
19
|
+
"Brute-forcing a solution",
|
|
20
|
+
"Reverse engineering",
|
|
21
|
+
"Infiltrating the stack",
|
|
22
|
+
"Compiling intel",
|
|
23
|
+
"Sniffing traffic",
|
|
24
|
+
"Cracking the cipher",
|
|
25
|
+
"Exfiltrating data",
|
|
26
|
+
"Pivoting laterally",
|
|
27
|
+
"Enumerating services",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function getRandomVerb() {
|
|
31
|
+
return VERBS[Math.floor(Math.random() * VERBS.length)];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Spinner component with rotating verbs
|
|
36
|
+
*/
|
|
37
|
+
export function ThinkingSpinner({ message }) {
|
|
38
|
+
const [verb, setVerb] = useState(getRandomVerb());
|
|
39
|
+
const [frame, setFrame] = useState(0);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const interval = setInterval(() => {
|
|
43
|
+
setVerb(getRandomVerb());
|
|
44
|
+
}, 2000);
|
|
45
|
+
return () => clearInterval(interval);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const interval = setInterval(() => {
|
|
50
|
+
setFrame((f) => (f + 1) % SPARKLE_FRAMES.length);
|
|
51
|
+
}, SPARKLE_INTERVAL);
|
|
52
|
+
return () => clearInterval(interval);
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
return h(Box, { flexDirection: "column" },
|
|
56
|
+
h(Box, null,
|
|
57
|
+
h(Text, { color: "cyan" }, "⏺"),
|
|
58
|
+
h(Text, { color: "gray" }, " Thinking...")
|
|
59
|
+
),
|
|
60
|
+
h(Box, null,
|
|
61
|
+
h(Text, { color: "magenta" }, SPARKLE_FRAMES[frame]),
|
|
62
|
+
h(Text, { color: "gray" }, ` ${message || `${verb}…`} `),
|
|
63
|
+
h(Text, { dimColor: true }, "(esc to interrupt)")
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Render the thinking spinner
|
|
70
|
+
* Returns a function to update/stop it
|
|
71
|
+
*/
|
|
72
|
+
export function renderSpinner(message) {
|
|
73
|
+
const { rerender, unmount, clear } = render(
|
|
74
|
+
h(ThinkingSpinner, { message })
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
update: (newMessage) => {
|
|
79
|
+
rerender(h(ThinkingSpinner, { message: newMessage }));
|
|
80
|
+
},
|
|
81
|
+
stop: () => {
|
|
82
|
+
clear();
|
|
83
|
+
unmount();
|
|
84
|
+
// Re-print intent line as static text so it persists after Ink clears
|
|
85
|
+
console.log(INTENT_LINE);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Thinking display component - shows what the agent is doing
|
|
92
|
+
*/
|
|
93
|
+
function ThinkingDisplay({ status, detail, content }) {
|
|
94
|
+
const [verb, setVerb] = useState(getRandomVerb());
|
|
95
|
+
const [frame, setFrame] = useState(0);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const interval = setInterval(() => setVerb(getRandomVerb()), 2000);
|
|
99
|
+
return () => clearInterval(interval);
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const interval = setInterval(() => {
|
|
104
|
+
setFrame((f) => (f + 1) % SPARKLE_FRAMES.length);
|
|
105
|
+
}, SPARKLE_INTERVAL);
|
|
106
|
+
return () => clearInterval(interval);
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
return h(Box, { flexDirection: "column" },
|
|
110
|
+
// Status line with spinner
|
|
111
|
+
h(Box, null,
|
|
112
|
+
h(Text, { color: "magenta" }, SPARKLE_FRAMES[frame]),
|
|
113
|
+
h(Text, { color: "yellow" }, ` ${status || verb}... `),
|
|
114
|
+
detail && h(Text, { dimColor: true }, detail)
|
|
115
|
+
),
|
|
116
|
+
// Content preview (truncated)
|
|
117
|
+
content && h(Box, { marginTop: 1, marginLeft: 2 },
|
|
118
|
+
h(Text, { dimColor: true },
|
|
119
|
+
content.length > 100 ? content.slice(0, 100) + "..." : content
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// State for thinking display
|
|
126
|
+
let thinkingState = { status: "", detail: "", content: "", rerender: null };
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Render thinking display
|
|
130
|
+
* Returns controls to update status and content
|
|
131
|
+
*/
|
|
132
|
+
export function renderThinking() {
|
|
133
|
+
thinkingState = { status: "", detail: "", content: "" };
|
|
134
|
+
|
|
135
|
+
const update = () => {
|
|
136
|
+
if (thinkingState.rerender) {
|
|
137
|
+
thinkingState.rerender(h(ThinkingDisplay, {
|
|
138
|
+
status: thinkingState.status,
|
|
139
|
+
detail: thinkingState.detail,
|
|
140
|
+
content: thinkingState.content,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const { rerender, unmount, clear } = render(
|
|
146
|
+
h(ThinkingDisplay, { status: "", detail: "", content: "" })
|
|
147
|
+
);
|
|
148
|
+
thinkingState.rerender = rerender;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
setStatus: (status, detail = "") => {
|
|
152
|
+
thinkingState.status = status;
|
|
153
|
+
thinkingState.detail = detail;
|
|
154
|
+
update();
|
|
155
|
+
},
|
|
156
|
+
setContent: (content) => {
|
|
157
|
+
thinkingState.content = content;
|
|
158
|
+
update();
|
|
159
|
+
},
|
|
160
|
+
appendContent: (text) => {
|
|
161
|
+
thinkingState.content += text;
|
|
162
|
+
update();
|
|
163
|
+
},
|
|
164
|
+
stop: () => {
|
|
165
|
+
clear();
|
|
166
|
+
unmount();
|
|
167
|
+
thinkingState.rerender = null;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { VERBS } from "./spinner.js";
|
|
3
|
+
|
|
4
|
+
describe("ui/spinner", () => {
|
|
5
|
+
describe("VERBS", () => {
|
|
6
|
+
it("is a non-empty array of strings", () => {
|
|
7
|
+
expect(Array.isArray(VERBS)).toBe(true);
|
|
8
|
+
expect(VERBS.length).toBeGreaterThan(5);
|
|
9
|
+
for (const v of VERBS) {
|
|
10
|
+
expect(typeof v).toBe("string");
|
|
11
|
+
expect(v.length).toBeGreaterThan(0);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("has no duplicates", () => {
|
|
16
|
+
const unique = new Set(VERBS);
|
|
17
|
+
expect(unique.size).toBe(VERBS.length);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("contains hacker-themed verbs", () => {
|
|
21
|
+
const hasHacker = VERBS.some((v) =>
|
|
22
|
+
v.toLowerCase().includes("hack") ||
|
|
23
|
+
v.toLowerCase().includes("decrypt") ||
|
|
24
|
+
v.toLowerCase().includes("crack")
|
|
25
|
+
);
|
|
26
|
+
expect(hasHacker).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { render, Box, Text } from "ink";
|
|
3
|
+
import { ThinkingSpinner } from "./spinner.js";
|
|
4
|
+
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Response box component
|
|
9
|
+
*/
|
|
10
|
+
export function ResponseBox({ content, title = "Claude" }) {
|
|
11
|
+
return h(Box, {
|
|
12
|
+
flexDirection: "column",
|
|
13
|
+
borderStyle: "round",
|
|
14
|
+
borderColor: "gray",
|
|
15
|
+
paddingX: 1,
|
|
16
|
+
marginY: 1,
|
|
17
|
+
},
|
|
18
|
+
h(Box, { marginBottom: 1 },
|
|
19
|
+
h(Text, { bold: true, color: "cyan" }, title)
|
|
20
|
+
),
|
|
21
|
+
h(Text, null, content)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Render a response in a box
|
|
27
|
+
*/
|
|
28
|
+
export function renderResponse(content, title) {
|
|
29
|
+
const { unmount, clear } = render(
|
|
30
|
+
h(ResponseBox, { content, title })
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
stop: () => {
|
|
35
|
+
clear();
|
|
36
|
+
unmount();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Global state for streaming (workaround for lack of refs in functional approach)
|
|
42
|
+
let streamingState = { content: "", thinking: true, rerender: null };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Streaming response component
|
|
46
|
+
*/
|
|
47
|
+
export function StreamingResponse({ title = "Claude" }) {
|
|
48
|
+
const [content, setContent] = useState(streamingState.content);
|
|
49
|
+
const [thinking, setThinking] = useState(streamingState.thinking);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
streamingState.setContent = setContent;
|
|
53
|
+
streamingState.setThinking = setThinking;
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
if (thinking) {
|
|
57
|
+
return h(ThinkingSpinner, null);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!content) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return h(Box, {
|
|
65
|
+
flexDirection: "column",
|
|
66
|
+
borderStyle: "round",
|
|
67
|
+
borderColor: "gray",
|
|
68
|
+
paddingX: 1,
|
|
69
|
+
marginTop: 1,
|
|
70
|
+
},
|
|
71
|
+
h(Box, { marginBottom: 1 },
|
|
72
|
+
h(Text, { bold: true, color: "cyan" }, title)
|
|
73
|
+
),
|
|
74
|
+
h(Text, null, content)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render streaming response
|
|
80
|
+
* Returns controls to append text and finish
|
|
81
|
+
*/
|
|
82
|
+
export function renderStreaming(title = "Claude") {
|
|
83
|
+
// Reset state
|
|
84
|
+
streamingState = { content: "", thinking: true, setContent: null, setThinking: null };
|
|
85
|
+
|
|
86
|
+
const { unmount, clear } = render(h(StreamingResponse, { title }));
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
append: (text) => {
|
|
90
|
+
streamingState.content += text;
|
|
91
|
+
if (streamingState.setContent) {
|
|
92
|
+
streamingState.setContent(streamingState.content);
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
setThinking: (val) => {
|
|
96
|
+
streamingState.thinking = val;
|
|
97
|
+
if (streamingState.setThinking) {
|
|
98
|
+
streamingState.setThinking(val);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
stop: () => {
|
|
102
|
+
clear();
|
|
103
|
+
unmount();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|