@supatest/cli 0.0.4 → 0.0.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/dist/commands/login.js +392 -0
- package/dist/commands/setup.js +234 -0
- package/dist/config.js +29 -0
- package/dist/core/agent.js +259 -0
- package/dist/index.js +154 -6586
- package/dist/modes/headless.js +117 -0
- package/dist/modes/interactive.js +418 -0
- package/dist/presenters/composite.js +32 -0
- package/dist/presenters/console.js +163 -0
- package/dist/presenters/react.js +217 -0
- package/dist/presenters/types.js +1 -0
- package/dist/presenters/web.js +78 -0
- package/dist/prompts/builder.js +181 -0
- package/dist/prompts/fixer.js +148 -0
- package/dist/prompts/index.js +3 -0
- package/dist/prompts/planner.js +70 -0
- package/dist/services/api-client.js +244 -0
- package/dist/services/event-streamer.js +130 -0
- package/dist/types.js +1 -0
- package/dist/ui/App.js +322 -0
- package/dist/ui/components/AuthBanner.js +24 -0
- package/dist/ui/components/AuthDialog.js +32 -0
- package/dist/ui/components/Banner.js +12 -0
- package/dist/ui/components/ExpandableSection.js +17 -0
- package/dist/ui/components/Header.js +51 -0
- package/dist/ui/components/HelpMenu.js +89 -0
- package/dist/ui/components/InputPrompt.js +286 -0
- package/dist/ui/components/MessageList.js +42 -0
- package/dist/ui/components/QueuedMessageDisplay.js +31 -0
- package/dist/ui/components/Scrollable.js +103 -0
- package/dist/ui/components/SessionSelector.js +196 -0
- package/dist/ui/components/StatusBar.js +34 -0
- package/dist/ui/components/messages/AssistantMessage.js +20 -0
- package/dist/ui/components/messages/ErrorMessage.js +26 -0
- package/dist/ui/components/messages/LoadingMessage.js +28 -0
- package/dist/ui/components/messages/ThinkingMessage.js +17 -0
- package/dist/ui/components/messages/TodoMessage.js +44 -0
- package/dist/ui/components/messages/ToolMessage.js +218 -0
- package/dist/ui/components/messages/UserMessage.js +14 -0
- package/dist/ui/contexts/KeypressContext.js +527 -0
- package/dist/ui/contexts/MouseContext.js +98 -0
- package/dist/ui/contexts/SessionContext.js +129 -0
- package/dist/ui/hooks/useAnimatedScrollbar.js +83 -0
- package/dist/ui/hooks/useBatchedScroll.js +22 -0
- package/dist/ui/hooks/useBracketedPaste.js +31 -0
- package/dist/ui/hooks/useFocus.js +50 -0
- package/dist/ui/hooks/useKeypress.js +26 -0
- package/dist/ui/hooks/useModeToggle.js +25 -0
- package/dist/ui/types/auth.js +13 -0
- package/dist/ui/utils/file-completion.js +56 -0
- package/dist/ui/utils/input.js +50 -0
- package/dist/ui/utils/markdown.js +376 -0
- package/dist/ui/utils/mouse.js +189 -0
- package/dist/ui/utils/theme.js +59 -0
- package/dist/utils/banner.js +9 -0
- package/dist/utils/encryption.js +71 -0
- package/dist/utils/events.js +36 -0
- package/dist/utils/keychain-storage.js +120 -0
- package/dist/utils/logger.js +209 -0
- package/dist/utils/node-version.js +89 -0
- package/dist/utils/plan-file.js +75 -0
- package/dist/utils/project-instructions.js +23 -0
- package/dist/utils/rich-logger.js +208 -0
- package/dist/utils/stdin.js +25 -0
- package/dist/utils/stdio.js +80 -0
- package/dist/utils/summary.js +94 -0
- package/dist/utils/token-storage.js +242 -0
- package/dist/version.js +6 -0
- package/package.json +3 -4
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown rendering for terminal UI
|
|
3
|
+
* Custom implementation with improved table rendering, code block syntax highlighting,
|
|
4
|
+
* and full theme integration
|
|
5
|
+
*/
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import { Box, Text } from "ink";
|
|
8
|
+
import { all, createLowlight } from "lowlight";
|
|
9
|
+
import React, { useMemo } from "react";
|
|
10
|
+
import { theme } from "./theme.js";
|
|
11
|
+
const lowlight = createLowlight(all);
|
|
12
|
+
/**
|
|
13
|
+
* Parse markdown and extract tables, code blocks, and text sections
|
|
14
|
+
*/
|
|
15
|
+
function parseMarkdownSections(text) {
|
|
16
|
+
const sections = [];
|
|
17
|
+
const lines = text.split(/\r?\n/);
|
|
18
|
+
let currentTextSection = "";
|
|
19
|
+
let inCodeBlock = false;
|
|
20
|
+
let codeBlockLanguage = "";
|
|
21
|
+
let codeBlockLines = [];
|
|
22
|
+
let inTable = false;
|
|
23
|
+
let tableRows = [];
|
|
24
|
+
const flushTextSection = () => {
|
|
25
|
+
if (currentTextSection.trim()) {
|
|
26
|
+
sections.push({
|
|
27
|
+
type: "text",
|
|
28
|
+
content: currentTextSection.trim(),
|
|
29
|
+
});
|
|
30
|
+
currentTextSection = "";
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const flushCodeBlock = () => {
|
|
34
|
+
if (codeBlockLines.length > 0) {
|
|
35
|
+
flushTextSection();
|
|
36
|
+
sections.push({
|
|
37
|
+
type: "code",
|
|
38
|
+
content: codeBlockLines.join("\n"),
|
|
39
|
+
codeLanguage: codeBlockLanguage,
|
|
40
|
+
});
|
|
41
|
+
codeBlockLines = [];
|
|
42
|
+
codeBlockLanguage = "";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const flushTable = () => {
|
|
46
|
+
if (tableRows.length > 0) {
|
|
47
|
+
flushTextSection();
|
|
48
|
+
sections.push({
|
|
49
|
+
type: "table",
|
|
50
|
+
content: "",
|
|
51
|
+
tableRows: [...tableRows],
|
|
52
|
+
});
|
|
53
|
+
tableRows = [];
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
// Code fence detection
|
|
58
|
+
const codeFenceMatch = line.match(/^```(\w*)/);
|
|
59
|
+
if (codeFenceMatch) {
|
|
60
|
+
if (!inCodeBlock) {
|
|
61
|
+
flushTextSection();
|
|
62
|
+
flushTable();
|
|
63
|
+
inCodeBlock = true;
|
|
64
|
+
codeBlockLanguage = codeFenceMatch[1] || "";
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
inCodeBlock = false;
|
|
68
|
+
flushCodeBlock();
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Inside code block
|
|
73
|
+
if (inCodeBlock) {
|
|
74
|
+
codeBlockLines.push(line);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Table detection
|
|
78
|
+
const trimmedLine = line.trim();
|
|
79
|
+
const isTableRow = trimmedLine.match(/^\|.+\|$/);
|
|
80
|
+
const isTableSeparator = trimmedLine.match(/^\|[\s\-:]+\|$/) && trimmedLine.includes("-");
|
|
81
|
+
if (isTableRow || isTableSeparator) {
|
|
82
|
+
if (!inTable) {
|
|
83
|
+
flushTextSection();
|
|
84
|
+
inTable = true;
|
|
85
|
+
}
|
|
86
|
+
// Skip separator rows
|
|
87
|
+
if (!isTableSeparator) {
|
|
88
|
+
tableRows.push(line);
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// End of table
|
|
93
|
+
if (inTable) {
|
|
94
|
+
flushTable();
|
|
95
|
+
inTable = false;
|
|
96
|
+
}
|
|
97
|
+
// Regular text
|
|
98
|
+
if (currentTextSection) {
|
|
99
|
+
currentTextSection += "\n" + line;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
currentTextSection = line;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Flush any remaining sections
|
|
106
|
+
if (inCodeBlock) {
|
|
107
|
+
flushCodeBlock();
|
|
108
|
+
}
|
|
109
|
+
if (inTable) {
|
|
110
|
+
flushTable();
|
|
111
|
+
}
|
|
112
|
+
flushTextSection();
|
|
113
|
+
return sections;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Parse and render markdown text for terminal display
|
|
117
|
+
*/
|
|
118
|
+
export const MarkdownDisplay = ({ text, isPending = false, terminalWidth = 80, }) => {
|
|
119
|
+
const sections = useMemo(() => parseMarkdownSections(text), [text]);
|
|
120
|
+
const elements = sections.map((section, index) => {
|
|
121
|
+
if (section.type === "table" && section.tableRows) {
|
|
122
|
+
return (React.createElement(Table, { key: `table-${index}`, rows: section.tableRows }));
|
|
123
|
+
}
|
|
124
|
+
if (section.type === "code") {
|
|
125
|
+
return (React.createElement(CodeBlock, { code: section.content, key: `code-${index}`, language: section.codeLanguage || "" }));
|
|
126
|
+
}
|
|
127
|
+
// Render text sections with custom markdown parser
|
|
128
|
+
return (React.createElement(TextSection, { content: section.content, key: `text-${index}` }));
|
|
129
|
+
});
|
|
130
|
+
// Add cursor for pending state
|
|
131
|
+
if (isPending) {
|
|
132
|
+
elements.push(React.createElement(Text, { color: theme.text.accent, key: "cursor" }, "\u258A"));
|
|
133
|
+
}
|
|
134
|
+
return React.createElement(Box, { flexDirection: "column" }, elements);
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Render text section with markdown formatting
|
|
138
|
+
*/
|
|
139
|
+
const TextSection = ({ content }) => {
|
|
140
|
+
const lines = content.split(/\r?\n/);
|
|
141
|
+
const elements = [];
|
|
142
|
+
let lineIndex = 0;
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
lineIndex++;
|
|
145
|
+
// Header detection (# H1, ## H2, etc.)
|
|
146
|
+
const headerMatch = line.match(/^(#{1,6})\s+(.+)/);
|
|
147
|
+
if (headerMatch) {
|
|
148
|
+
const level = headerMatch[1].length;
|
|
149
|
+
const content = headerMatch[2];
|
|
150
|
+
elements.push(React.createElement(Header, { content: content, key: `header-${lineIndex}`, level: level }));
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// List item detection (- item or * item or 1. item)
|
|
154
|
+
const listMatch = line.match(/^(\s*)([-*]|\d+\.)\s+(.+)/);
|
|
155
|
+
if (listMatch) {
|
|
156
|
+
const indent = listMatch[1].length;
|
|
157
|
+
const marker = listMatch[2];
|
|
158
|
+
const content = listMatch[3];
|
|
159
|
+
elements.push(React.createElement(ListItem, { content: content, indent: indent, key: `list-${lineIndex}`, marker: marker }));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// Horizontal rule
|
|
163
|
+
if (line.match(/^-{3,}$/)) {
|
|
164
|
+
elements.push(React.createElement(HorizontalRule, { key: `hr-${lineIndex}` }));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// Regular paragraph with inline formatting
|
|
168
|
+
if (line.trim()) {
|
|
169
|
+
elements.push(React.createElement(Paragraph, { content: line, key: `para-${lineIndex}` }));
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
// Empty line
|
|
173
|
+
elements.push(React.createElement(Text, { key: `empty-${lineIndex}` }, "\n"));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return React.createElement(Box, { flexDirection: "column" }, elements);
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Render a header (h1-h6)
|
|
180
|
+
*/
|
|
181
|
+
const Header = ({ level, content, }) => {
|
|
182
|
+
const colors = [
|
|
183
|
+
theme.text.accent, // H1
|
|
184
|
+
theme.text.accent, // H2
|
|
185
|
+
theme.text.primary, // H3
|
|
186
|
+
theme.text.secondary, // H4
|
|
187
|
+
theme.text.secondary, // H5
|
|
188
|
+
theme.text.dim, // H6
|
|
189
|
+
];
|
|
190
|
+
const color = colors[level - 1] || theme.text.primary;
|
|
191
|
+
const bold = level <= 2;
|
|
192
|
+
return (React.createElement(Text, { bold: bold, color: color },
|
|
193
|
+
content,
|
|
194
|
+
"\n"));
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Render a list item
|
|
198
|
+
*/
|
|
199
|
+
const ListItem = ({ indent, marker, content }) => {
|
|
200
|
+
const formattedContent = formatInline(content);
|
|
201
|
+
const indentStr = " ".repeat(indent);
|
|
202
|
+
return (React.createElement(Text, null,
|
|
203
|
+
indentStr,
|
|
204
|
+
React.createElement(Text, { color: theme.text.accent }, marker),
|
|
205
|
+
" ",
|
|
206
|
+
formattedContent,
|
|
207
|
+
"\n"));
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Render a horizontal rule
|
|
211
|
+
*/
|
|
212
|
+
const HorizontalRule = () => {
|
|
213
|
+
return (React.createElement(Text, { color: theme.border.default },
|
|
214
|
+
"─".repeat(60),
|
|
215
|
+
"\n"));
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* Render a paragraph with inline formatting
|
|
219
|
+
*/
|
|
220
|
+
const Paragraph = ({ content }) => {
|
|
221
|
+
const formattedContent = formatInline(content);
|
|
222
|
+
return (React.createElement(Text, null,
|
|
223
|
+
formattedContent,
|
|
224
|
+
"\n"));
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Format inline markdown (bold, italic, inline code, links)
|
|
228
|
+
*/
|
|
229
|
+
function formatInline(text) {
|
|
230
|
+
let result = text;
|
|
231
|
+
// Inline code: `code`
|
|
232
|
+
result = result.replace(/`([^`]+)`/g, (_, code) => {
|
|
233
|
+
return chalk.cyan(code);
|
|
234
|
+
});
|
|
235
|
+
// Bold: **text** or __text__
|
|
236
|
+
result = result.replace(/(\*\*|__)([^*_]+)\1/g, (_, __, text) => {
|
|
237
|
+
return chalk.bold(text);
|
|
238
|
+
});
|
|
239
|
+
// Italic: *text* or _text_ (but not **, __, or inside words)
|
|
240
|
+
result = result.replace(/(?<!\*)\*(?!\*)([^*]+)\*(?!\*)/g, (_, text) => {
|
|
241
|
+
return chalk.italic(text);
|
|
242
|
+
});
|
|
243
|
+
// Links: [text](url) - show as underlined cyan text
|
|
244
|
+
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text) => {
|
|
245
|
+
return chalk.cyan.underline(text);
|
|
246
|
+
});
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Render a markdown table with improved formatting
|
|
251
|
+
*/
|
|
252
|
+
const Table = ({ rows }) => {
|
|
253
|
+
if (rows.length === 0) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
// Parse all rows to extract cells
|
|
257
|
+
const parsedRows = rows.map((row) => {
|
|
258
|
+
const cells = row
|
|
259
|
+
.trim()
|
|
260
|
+
.replace(/^\||\|$/g, "")
|
|
261
|
+
.split("|")
|
|
262
|
+
.map((cell) => cell.trim());
|
|
263
|
+
return cells;
|
|
264
|
+
});
|
|
265
|
+
if (parsedRows.length === 0) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
// Determine column widths (calculate based on actual content length)
|
|
269
|
+
const numColumns = parsedRows[0]?.length || 0;
|
|
270
|
+
const columnWidths = [];
|
|
271
|
+
for (let col = 0; col < numColumns; col++) {
|
|
272
|
+
let maxWidth = 0;
|
|
273
|
+
for (const row of parsedRows) {
|
|
274
|
+
const cellContent = row[col] || "";
|
|
275
|
+
// Strip ANSI codes for width calculation
|
|
276
|
+
const plainText = cellContent.replace(/\u001b\[[0-9;]*m/g, "");
|
|
277
|
+
const width = plainText.length;
|
|
278
|
+
maxWidth = Math.max(maxWidth, width);
|
|
279
|
+
}
|
|
280
|
+
columnWidths.push(Math.max(maxWidth, 3)); // Minimum width of 3
|
|
281
|
+
}
|
|
282
|
+
// Build formatted table as text with better visual separation
|
|
283
|
+
const tableLines = [];
|
|
284
|
+
// Header row (first row)
|
|
285
|
+
if (parsedRows.length > 0) {
|
|
286
|
+
const headerRow = parsedRows[0];
|
|
287
|
+
const headerCells = headerRow.map((cell, colIndex) => {
|
|
288
|
+
const formatted = formatInline(cell);
|
|
289
|
+
const width = columnWidths[colIndex] || 10;
|
|
290
|
+
return formatted.padEnd(width);
|
|
291
|
+
});
|
|
292
|
+
tableLines.push(chalk.hex(theme.text.accent).bold(headerCells.join(" │ ")));
|
|
293
|
+
// Separator line with better visual design
|
|
294
|
+
const separator = columnWidths
|
|
295
|
+
.map((width) => "─".repeat(width))
|
|
296
|
+
.join("─┼─");
|
|
297
|
+
tableLines.push(chalk.hex(theme.border.default)(separator));
|
|
298
|
+
// Data rows
|
|
299
|
+
for (let rowIndex = 1; rowIndex < parsedRows.length; rowIndex++) {
|
|
300
|
+
const row = parsedRows[rowIndex];
|
|
301
|
+
const cells = row.map((cell, colIndex) => {
|
|
302
|
+
const formatted = formatInline(cell);
|
|
303
|
+
const width = columnWidths[colIndex] || 10;
|
|
304
|
+
return formatted.padEnd(width);
|
|
305
|
+
});
|
|
306
|
+
tableLines.push(cells.join(" │ "));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return (React.createElement(Text, null,
|
|
310
|
+
tableLines.join("\n"),
|
|
311
|
+
"\n"));
|
|
312
|
+
};
|
|
313
|
+
/**
|
|
314
|
+
* Code block with syntax highlighting
|
|
315
|
+
*/
|
|
316
|
+
const CodeBlock = ({ code, language, }) => {
|
|
317
|
+
// Try to highlight if language is specified
|
|
318
|
+
let highlighted = code;
|
|
319
|
+
if (language) {
|
|
320
|
+
try {
|
|
321
|
+
const result = lowlight.highlight(language, code);
|
|
322
|
+
highlighted = highlightTreeToString(result);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
// Language not supported, use plain text
|
|
326
|
+
highlighted = code;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return (React.createElement(Box, { borderColor: theme.border.default, borderStyle: "round", flexDirection: "column", marginY: 0, paddingX: 1 },
|
|
330
|
+
language && (React.createElement(Text, { color: theme.text.dim },
|
|
331
|
+
language,
|
|
332
|
+
"\n")),
|
|
333
|
+
React.createElement(Text, null, highlighted)));
|
|
334
|
+
};
|
|
335
|
+
/**
|
|
336
|
+
* Convert lowlight AST to colored string
|
|
337
|
+
*/
|
|
338
|
+
function highlightTreeToString(node) {
|
|
339
|
+
if (node.type === "text") {
|
|
340
|
+
return node.value;
|
|
341
|
+
}
|
|
342
|
+
if (node.type === "element") {
|
|
343
|
+
const className = node.properties?.className?.[0] || "";
|
|
344
|
+
let content = "";
|
|
345
|
+
if (node.children) {
|
|
346
|
+
content = node.children.map(highlightTreeToString).join("");
|
|
347
|
+
}
|
|
348
|
+
// Map token types to colors
|
|
349
|
+
if (className.includes("keyword")) {
|
|
350
|
+
return chalk.magenta(content);
|
|
351
|
+
}
|
|
352
|
+
if (className.includes("string")) {
|
|
353
|
+
return chalk.green(content);
|
|
354
|
+
}
|
|
355
|
+
if (className.includes("comment")) {
|
|
356
|
+
return chalk.gray(content);
|
|
357
|
+
}
|
|
358
|
+
if (className.includes("function")) {
|
|
359
|
+
return chalk.blue(content);
|
|
360
|
+
}
|
|
361
|
+
if (className.includes("number")) {
|
|
362
|
+
return chalk.cyan(content);
|
|
363
|
+
}
|
|
364
|
+
if (className.includes("operator")) {
|
|
365
|
+
return chalk.yellow(content);
|
|
366
|
+
}
|
|
367
|
+
if (className.includes("class-name") || className.includes("type")) {
|
|
368
|
+
return chalk.yellow(content);
|
|
369
|
+
}
|
|
370
|
+
return content;
|
|
371
|
+
}
|
|
372
|
+
if (Array.isArray(node)) {
|
|
373
|
+
return node.map(highlightTreeToString).join("");
|
|
374
|
+
}
|
|
375
|
+
return "";
|
|
376
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { writeToStdout } from '../../utils/stdio';
|
|
7
|
+
import { couldBeMouseSequence as inputCouldBeMouseSequence, SGR_EVENT_PREFIX, SGR_MOUSE_REGEX, X11_EVENT_PREFIX, X11_MOUSE_REGEX, } from './input';
|
|
8
|
+
export function getMouseEventName(buttonCode, isRelease) {
|
|
9
|
+
const isMove = (buttonCode & 32) !== 0;
|
|
10
|
+
if (buttonCode === 66) {
|
|
11
|
+
return 'scroll-left';
|
|
12
|
+
}
|
|
13
|
+
else if (buttonCode === 67) {
|
|
14
|
+
return 'scroll-right';
|
|
15
|
+
}
|
|
16
|
+
else if ((buttonCode & 64) === 64) {
|
|
17
|
+
if ((buttonCode & 1) === 0) {
|
|
18
|
+
return 'scroll-up';
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
return 'scroll-down';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
else if (isMove) {
|
|
25
|
+
return 'move';
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
const button = buttonCode & 3;
|
|
29
|
+
const type = isRelease ? 'release' : 'press';
|
|
30
|
+
switch (button) {
|
|
31
|
+
case 0:
|
|
32
|
+
return `left-${type}`;
|
|
33
|
+
case 1:
|
|
34
|
+
return `middle-${type}`;
|
|
35
|
+
case 2:
|
|
36
|
+
return `right-${type}`;
|
|
37
|
+
default:
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function getButtonFromCode(code) {
|
|
43
|
+
const button = code & 3;
|
|
44
|
+
switch (button) {
|
|
45
|
+
case 0:
|
|
46
|
+
return 'left';
|
|
47
|
+
case 1:
|
|
48
|
+
return 'middle';
|
|
49
|
+
case 2:
|
|
50
|
+
return 'right';
|
|
51
|
+
default:
|
|
52
|
+
return 'none';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function parseSGRMouseEvent(buffer) {
|
|
56
|
+
const match = buffer.match(SGR_MOUSE_REGEX);
|
|
57
|
+
if (match) {
|
|
58
|
+
const buttonCode = parseInt(match[1], 10);
|
|
59
|
+
const col = parseInt(match[2], 10);
|
|
60
|
+
const row = parseInt(match[3], 10);
|
|
61
|
+
const action = match[4];
|
|
62
|
+
const isRelease = action === 'm';
|
|
63
|
+
const shift = (buttonCode & 4) !== 0;
|
|
64
|
+
const meta = (buttonCode & 8) !== 0;
|
|
65
|
+
const ctrl = (buttonCode & 16) !== 0;
|
|
66
|
+
const name = getMouseEventName(buttonCode, isRelease);
|
|
67
|
+
if (name) {
|
|
68
|
+
return {
|
|
69
|
+
event: {
|
|
70
|
+
name,
|
|
71
|
+
ctrl,
|
|
72
|
+
meta,
|
|
73
|
+
shift,
|
|
74
|
+
col,
|
|
75
|
+
row,
|
|
76
|
+
button: getButtonFromCode(buttonCode),
|
|
77
|
+
},
|
|
78
|
+
length: match[0].length,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
export function parseX11MouseEvent(buffer) {
|
|
86
|
+
const match = buffer.match(X11_MOUSE_REGEX);
|
|
87
|
+
if (!match)
|
|
88
|
+
return null;
|
|
89
|
+
// The 3 bytes are in match[1]
|
|
90
|
+
const b = match[1].charCodeAt(0) - 32;
|
|
91
|
+
const col = match[1].charCodeAt(1) - 32;
|
|
92
|
+
const row = match[1].charCodeAt(2) - 32;
|
|
93
|
+
const shift = (b & 4) !== 0;
|
|
94
|
+
const meta = (b & 8) !== 0;
|
|
95
|
+
const ctrl = (b & 16) !== 0;
|
|
96
|
+
const isMove = (b & 32) !== 0;
|
|
97
|
+
const isWheel = (b & 64) !== 0;
|
|
98
|
+
let name = null;
|
|
99
|
+
if (isWheel) {
|
|
100
|
+
const button = b & 3;
|
|
101
|
+
switch (button) {
|
|
102
|
+
case 0:
|
|
103
|
+
name = 'scroll-up';
|
|
104
|
+
break;
|
|
105
|
+
case 1:
|
|
106
|
+
name = 'scroll-down';
|
|
107
|
+
break;
|
|
108
|
+
default:
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else if (isMove) {
|
|
113
|
+
name = 'move';
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
const button = b & 3;
|
|
117
|
+
if (button === 3) {
|
|
118
|
+
// X11 reports 'release' (3) for all button releases without specifying which one.
|
|
119
|
+
// We'll default to 'left-release' as a best-effort guess if we don't track state.
|
|
120
|
+
name = 'left-release';
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
switch (button) {
|
|
124
|
+
case 0:
|
|
125
|
+
name = 'left-press';
|
|
126
|
+
break;
|
|
127
|
+
case 1:
|
|
128
|
+
name = 'middle-press';
|
|
129
|
+
break;
|
|
130
|
+
case 2:
|
|
131
|
+
name = 'right-press';
|
|
132
|
+
break;
|
|
133
|
+
default:
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (name) {
|
|
139
|
+
let button = getButtonFromCode(b);
|
|
140
|
+
if (name === 'left-release' && button === 'none') {
|
|
141
|
+
button = 'left';
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
event: {
|
|
145
|
+
name,
|
|
146
|
+
ctrl,
|
|
147
|
+
meta,
|
|
148
|
+
shift,
|
|
149
|
+
col,
|
|
150
|
+
row,
|
|
151
|
+
button,
|
|
152
|
+
},
|
|
153
|
+
length: match[0].length,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
export function parseMouseEvent(buffer) {
|
|
159
|
+
return parseSGRMouseEvent(buffer) || parseX11MouseEvent(buffer);
|
|
160
|
+
}
|
|
161
|
+
export function isIncompleteMouseSequence(buffer) {
|
|
162
|
+
if (!inputCouldBeMouseSequence(buffer))
|
|
163
|
+
return false;
|
|
164
|
+
// If it matches a complete sequence, it's not incomplete.
|
|
165
|
+
if (parseMouseEvent(buffer))
|
|
166
|
+
return false;
|
|
167
|
+
if (buffer.startsWith(X11_EVENT_PREFIX)) {
|
|
168
|
+
// X11 needs exactly 3 bytes after prefix.
|
|
169
|
+
return buffer.length < X11_EVENT_PREFIX.length + 3;
|
|
170
|
+
}
|
|
171
|
+
if (buffer.startsWith(SGR_EVENT_PREFIX)) {
|
|
172
|
+
// SGR sequences end with 'm' or 'M'.
|
|
173
|
+
// If it doesn't have it yet, it's incomplete.
|
|
174
|
+
// Add a reasonable max length check to fail early on garbage.
|
|
175
|
+
return !/[mM]/.test(buffer) && buffer.length < 50;
|
|
176
|
+
}
|
|
177
|
+
// It's a prefix of the prefix (e.g. "ESC" or "ESC [")
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
export function enableMouseEvents() {
|
|
181
|
+
// Enable mouse tracking with SGR format
|
|
182
|
+
// ?1002h = button event tracking (clicks + drags + scroll wheel)
|
|
183
|
+
// ?1006h = SGR extended mouse mode (better coordinate handling)
|
|
184
|
+
writeToStdout('\u001b[?1002h\u001b[?1006h');
|
|
185
|
+
}
|
|
186
|
+
export function disableMouseEvents() {
|
|
187
|
+
// Disable mouse tracking with SGR format
|
|
188
|
+
writeToStdout('\u001b[?1006l\u001b[?1002l');
|
|
189
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI Theme for Supatest CLI
|
|
3
|
+
* Defines colors and styling for the interactive terminal UI
|
|
4
|
+
*/
|
|
5
|
+
export const theme = {
|
|
6
|
+
// Text colors
|
|
7
|
+
text: {
|
|
8
|
+
primary: "#FFFFFF",
|
|
9
|
+
secondary: "#A0AEC0",
|
|
10
|
+
dim: "#4A5568",
|
|
11
|
+
accent: "#38B2AC", // Cyan/teal accent
|
|
12
|
+
success: "#48BB78",
|
|
13
|
+
error: "#F56565",
|
|
14
|
+
warning: "#ED8936",
|
|
15
|
+
info: "#4299E1",
|
|
16
|
+
},
|
|
17
|
+
// Background colors (for boxed content)
|
|
18
|
+
bg: {
|
|
19
|
+
primary: "#1A202C",
|
|
20
|
+
secondary: "#2D3748",
|
|
21
|
+
accent: "#234E52",
|
|
22
|
+
},
|
|
23
|
+
// Borders
|
|
24
|
+
border: {
|
|
25
|
+
default: "#4A5568",
|
|
26
|
+
accent: "#38B2AC",
|
|
27
|
+
error: "#F56565",
|
|
28
|
+
},
|
|
29
|
+
// Tool/action colors
|
|
30
|
+
tool: {
|
|
31
|
+
read: "#4299E1", // Blue
|
|
32
|
+
write: "#48BB78", // Green
|
|
33
|
+
edit: "#ED8936", // Orange
|
|
34
|
+
bash: "#38B2AC", // Cyan
|
|
35
|
+
search: "#9F7AEA", // Purple
|
|
36
|
+
agent: "#ED64A6", // Pink
|
|
37
|
+
},
|
|
38
|
+
// Status colors
|
|
39
|
+
status: {
|
|
40
|
+
pending: "#A0AEC0",
|
|
41
|
+
inProgress: "#ED8936",
|
|
42
|
+
completed: "#48BB78",
|
|
43
|
+
failed: "#F56565",
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Get color value by path (e.g., 'text.primary', 'tool.read')
|
|
48
|
+
*/
|
|
49
|
+
export function getThemeColor(path) {
|
|
50
|
+
const parts = path.split(".");
|
|
51
|
+
let current = theme;
|
|
52
|
+
for (const part of parts) {
|
|
53
|
+
if (current[part] === undefined) {
|
|
54
|
+
return theme.text.primary;
|
|
55
|
+
}
|
|
56
|
+
current = current[part];
|
|
57
|
+
}
|
|
58
|
+
return typeof current === "string" ? current : theme.text.primary;
|
|
59
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function getBanner() {
|
|
2
|
+
const banner = `███████╗██╗ ██╗██████╗ █████╗ ████████╗███████╗███████╗████████╗
|
|
3
|
+
██╔════╝██║ ██║██╔══██╗██╔══██╗╚══██╔══╝██╔════╝██╔════╝╚══██╔══╝
|
|
4
|
+
███████╗██║ ██║██████╔╝███████║ ██║ █████╗ ███████╗ ██║
|
|
5
|
+
╚════██║██║ ██║██╔═══╝ ██╔══██║ ██║ ██╔══╝ ╚════██║ ██║
|
|
6
|
+
███████║╚██████╔╝██║ ██║ ██║ ██║ ███████╗███████║ ██║
|
|
7
|
+
╚══════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝ ╚═╝`;
|
|
8
|
+
return banner;
|
|
9
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token encryption utilities using AES-256-GCM with machine-specific key derivation.
|
|
3
|
+
* Inspired by Gemini CLI's file-token-storage implementation.
|
|
4
|
+
*/
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import { hostname, userInfo } from "node:os";
|
|
7
|
+
const ALGORITHM = "aes-256-gcm";
|
|
8
|
+
const KEY_LENGTH = 32;
|
|
9
|
+
const IV_LENGTH = 16;
|
|
10
|
+
/**
|
|
11
|
+
* Derive an encryption key from machine-specific identifiers.
|
|
12
|
+
* Uses scrypt for key derivation with a salt based on hostname and username.
|
|
13
|
+
* This means tokens encrypted on one machine cannot be decrypted on another.
|
|
14
|
+
*/
|
|
15
|
+
function deriveEncryptionKey() {
|
|
16
|
+
const salt = `${hostname()}-${userInfo().username}-supatest-cli`;
|
|
17
|
+
return crypto.scryptSync("supatest-cli-token", salt, KEY_LENGTH);
|
|
18
|
+
}
|
|
19
|
+
// Cache the derived key for performance
|
|
20
|
+
let cachedKey = null;
|
|
21
|
+
function getEncryptionKey() {
|
|
22
|
+
if (!cachedKey) {
|
|
23
|
+
cachedKey = deriveEncryptionKey();
|
|
24
|
+
}
|
|
25
|
+
return cachedKey;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Encrypt plaintext using AES-256-GCM with a machine-derived key.
|
|
29
|
+
* Returns a colon-separated string: iv:authTag:ciphertext (all hex encoded)
|
|
30
|
+
*/
|
|
31
|
+
export function encrypt(plaintext) {
|
|
32
|
+
const key = getEncryptionKey();
|
|
33
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
34
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
35
|
+
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
|
36
|
+
encrypted += cipher.final("hex");
|
|
37
|
+
const authTag = cipher.getAuthTag();
|
|
38
|
+
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Decrypt ciphertext that was encrypted with the encrypt() function.
|
|
42
|
+
* Expects format: iv:authTag:ciphertext (all hex encoded)
|
|
43
|
+
*/
|
|
44
|
+
export function decrypt(encryptedData) {
|
|
45
|
+
const parts = encryptedData.split(":");
|
|
46
|
+
if (parts.length !== 3) {
|
|
47
|
+
throw new Error("Invalid encrypted data format");
|
|
48
|
+
}
|
|
49
|
+
const [ivHex, authTagHex, encrypted] = parts;
|
|
50
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
51
|
+
const authTag = Buffer.from(authTagHex, "hex");
|
|
52
|
+
const key = getEncryptionKey();
|
|
53
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
54
|
+
decipher.setAuthTag(authTag);
|
|
55
|
+
let decrypted = decipher.update(encrypted, "hex", "utf8");
|
|
56
|
+
decrypted += decipher.final("utf8");
|
|
57
|
+
return decrypted;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Check if a string appears to be in the encrypted format.
|
|
61
|
+
* Encrypted format has 3 hex segments separated by colons.
|
|
62
|
+
*/
|
|
63
|
+
export function isEncrypted(data) {
|
|
64
|
+
const parts = data.split(":");
|
|
65
|
+
if (parts.length !== 3) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
// Check each part looks like valid hex
|
|
69
|
+
const hexRegex = /^[0-9a-fA-F]+$/;
|
|
70
|
+
return parts.every((p) => p.length > 0 && hexRegex.test(p));
|
|
71
|
+
}
|