@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,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input Prompt Component
|
|
3
|
+
* Multi-line input field for entering tasks
|
|
4
|
+
* Supports:
|
|
5
|
+
* - File autocompletion (@filename)
|
|
6
|
+
* - Slash commands (handled by parent)
|
|
7
|
+
*/
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import { Box, Text } from "ink";
|
|
11
|
+
import React, { forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
|
12
|
+
import { useSession } from "../contexts/SessionContext.js";
|
|
13
|
+
import { useKeypress } from "../hooks/useKeypress.js";
|
|
14
|
+
import { getFiles } from "../utils/file-completion.js";
|
|
15
|
+
import { theme } from "../utils/theme.js";
|
|
16
|
+
export const InputPrompt = forwardRef(({ onSubmit, placeholder = "Enter your task (press Enter to submit, Shift+Enter for new line)...", disabled = false, onHelpToggle, currentFolder, gitBranch, onInputChange, }, ref) => {
|
|
17
|
+
const { messages, agentMode } = useSession();
|
|
18
|
+
const [value, setValue] = useState("");
|
|
19
|
+
const [cursorOffset, setCursorOffset] = useState(0);
|
|
20
|
+
// Autocomplete State
|
|
21
|
+
const [allFiles, setAllFiles] = useState([]);
|
|
22
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
23
|
+
const [activeSuggestion, setActiveSuggestion] = useState(0);
|
|
24
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
25
|
+
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
|
|
26
|
+
// Slash Command State
|
|
27
|
+
const SLASH_COMMANDS = [
|
|
28
|
+
{ name: "/help", desc: "Show help" },
|
|
29
|
+
{ name: "/resume", desc: "Resume session" },
|
|
30
|
+
{ name: "/clear", desc: "Clear history" },
|
|
31
|
+
{ name: "/setup", desc: "Install Playwright browsers" },
|
|
32
|
+
{ name: "/login", desc: "Authenticate with Supatest" },
|
|
33
|
+
{ name: "/logout", desc: "Log out" },
|
|
34
|
+
{ name: "/exit", desc: "Exit CLI" }
|
|
35
|
+
];
|
|
36
|
+
const [isSlashCommand, setIsSlashCommand] = useState(false);
|
|
37
|
+
useImperativeHandle(ref, () => ({
|
|
38
|
+
clear: () => {
|
|
39
|
+
setValue("");
|
|
40
|
+
setCursorOffset(0);
|
|
41
|
+
setShowSuggestions(false);
|
|
42
|
+
onInputChange?.("");
|
|
43
|
+
}
|
|
44
|
+
}));
|
|
45
|
+
// Load files on mount
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
// Load files asynchronously to avoid blocking initial render
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
try {
|
|
50
|
+
const files = getFiles();
|
|
51
|
+
setAllFiles(files);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
// Ignore errors
|
|
55
|
+
}
|
|
56
|
+
}, 100);
|
|
57
|
+
}, []);
|
|
58
|
+
const updateValue = (newValue, newCursor) => {
|
|
59
|
+
setValue(newValue);
|
|
60
|
+
setCursorOffset(newCursor);
|
|
61
|
+
checkSuggestions(newValue, newCursor);
|
|
62
|
+
onInputChange?.(newValue);
|
|
63
|
+
};
|
|
64
|
+
const checkSuggestions = (text, cursor) => {
|
|
65
|
+
// 1. Check for Slash Commands (must be at start)
|
|
66
|
+
if (text.startsWith("/") && cursor <= text.length && !text.includes(" ", 1)) {
|
|
67
|
+
const query = text.slice(1); // Remove '/'
|
|
68
|
+
const matches = SLASH_COMMANDS
|
|
69
|
+
.filter(cmd => cmd.name.slice(1).startsWith(query.toLowerCase()))
|
|
70
|
+
.map(cmd => `${cmd.name} ${cmd.desc}`);
|
|
71
|
+
if (matches.length > 0) {
|
|
72
|
+
setSuggestions(matches);
|
|
73
|
+
setShowSuggestions(true);
|
|
74
|
+
setActiveSuggestion(0);
|
|
75
|
+
setIsSlashCommand(true);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
setIsSlashCommand(false);
|
|
80
|
+
// 2. Check for File Mentions
|
|
81
|
+
const textBeforeCursor = text.slice(0, cursor);
|
|
82
|
+
const lastAt = textBeforeCursor.lastIndexOf("@");
|
|
83
|
+
if (lastAt !== -1) {
|
|
84
|
+
// Check if @ is at start or preceded by whitespace
|
|
85
|
+
const isValidStart = lastAt === 0 || /\s/.test(textBeforeCursor[lastAt - 1]);
|
|
86
|
+
if (isValidStart) {
|
|
87
|
+
const query = textBeforeCursor.slice(lastAt + 1);
|
|
88
|
+
// Stop suggestions if query contains whitespace (end of mention)
|
|
89
|
+
if (!/\s/.test(query)) {
|
|
90
|
+
const matches = allFiles
|
|
91
|
+
.filter((f) => f.toLowerCase().includes(query.toLowerCase()))
|
|
92
|
+
.slice(0, 5); // Limit to 5 suggestions
|
|
93
|
+
if (matches.length > 0) {
|
|
94
|
+
setSuggestions(matches);
|
|
95
|
+
setShowSuggestions(true);
|
|
96
|
+
setActiveSuggestion(0);
|
|
97
|
+
setMentionStartIndex(lastAt);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
setShowSuggestions(false);
|
|
104
|
+
};
|
|
105
|
+
const completeSuggestion = (submit = false) => {
|
|
106
|
+
if (!showSuggestions || suggestions.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
// Handle Slash Command Completion
|
|
109
|
+
if (isSlashCommand) {
|
|
110
|
+
const selectedCmd = suggestions[activeSuggestion].split(" ")[0];
|
|
111
|
+
if (submit) {
|
|
112
|
+
onSubmit(selectedCmd);
|
|
113
|
+
setValue("");
|
|
114
|
+
setCursorOffset(0);
|
|
115
|
+
setShowSuggestions(false);
|
|
116
|
+
onInputChange?.("");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
updateValue(selectedCmd, selectedCmd.length);
|
|
120
|
+
setShowSuggestions(false);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const selectedFile = suggestions[activeSuggestion];
|
|
124
|
+
const textBeforeMention = value.slice(0, mentionStartIndex);
|
|
125
|
+
const textAfterCursor = value.slice(cursorOffset);
|
|
126
|
+
// Add @ + filename + space
|
|
127
|
+
const newValue = textBeforeMention + "@" + selectedFile + " " + textAfterCursor;
|
|
128
|
+
const newCursor = mentionStartIndex + 1 + selectedFile.length + 1;
|
|
129
|
+
updateValue(newValue, newCursor);
|
|
130
|
+
setShowSuggestions(false);
|
|
131
|
+
};
|
|
132
|
+
useKeypress((key) => {
|
|
133
|
+
if (disabled)
|
|
134
|
+
return;
|
|
135
|
+
const input = key.sequence;
|
|
136
|
+
// Mouse events are now filtered by KeypressContext
|
|
137
|
+
// So we don't need to check for them here
|
|
138
|
+
// Handle Paste / Drag & Drop (file paths)
|
|
139
|
+
if (input.length > 1 && (input.includes("/") || input.includes("\\"))) {
|
|
140
|
+
let cleanPath = input.trim();
|
|
141
|
+
// Remove surrounding quotes
|
|
142
|
+
if ((cleanPath.startsWith('"') && cleanPath.endsWith('"')) ||
|
|
143
|
+
(cleanPath.startsWith("'") && cleanPath.endsWith("'"))) {
|
|
144
|
+
cleanPath = cleanPath.slice(1, -1);
|
|
145
|
+
}
|
|
146
|
+
// Remove escaped spaces
|
|
147
|
+
cleanPath = cleanPath.replace(/\\ /g, " ");
|
|
148
|
+
// If it looks like an absolute path, treat as file drop
|
|
149
|
+
if (path.isAbsolute(cleanPath)) {
|
|
150
|
+
try {
|
|
151
|
+
const cwd = process.cwd();
|
|
152
|
+
const rel = path.relative(cwd, cleanPath);
|
|
153
|
+
if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
|
|
154
|
+
cleanPath = rel;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
// Ignore
|
|
159
|
+
}
|
|
160
|
+
const charBefore = value[cursorOffset - 1];
|
|
161
|
+
const prefix = charBefore === "@" ? "" : "@";
|
|
162
|
+
const toInsert = prefix + cleanPath + " ";
|
|
163
|
+
const newValue = value.slice(0, cursorOffset) + toInsert + value.slice(cursorOffset);
|
|
164
|
+
updateValue(newValue, cursorOffset + toInsert.length);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Toggle Help
|
|
169
|
+
if (input === "?" && value.length === 0 && onHelpToggle) {
|
|
170
|
+
onHelpToggle();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Handle Autocomplete Navigation (only without shift modifier)
|
|
174
|
+
// Let Shift+Up/Down pass through for scrolling
|
|
175
|
+
if (showSuggestions && !key.shift) {
|
|
176
|
+
if (key.name === 'up') {
|
|
177
|
+
setActiveSuggestion((prev) => prev > 0 ? prev - 1 : suggestions.length - 1);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (key.name === 'down') {
|
|
181
|
+
setActiveSuggestion((prev) => prev < suggestions.length - 1 ? prev + 1 : 0);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (key.name === 'tab' || key.name === 'return') {
|
|
185
|
+
completeSuggestion(key.name === 'return');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (key.name === 'escape') {
|
|
189
|
+
setShowSuggestions(false);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Standard Input Handling
|
|
194
|
+
if (key.name === 'return') {
|
|
195
|
+
if (key.shift) {
|
|
196
|
+
// Shift+Enter: Add newline
|
|
197
|
+
const newValue = value.slice(0, cursorOffset) + "\n" + value.slice(cursorOffset);
|
|
198
|
+
updateValue(newValue, cursorOffset + 1);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// Enter: Submit
|
|
202
|
+
if (value.trim()) {
|
|
203
|
+
onSubmit(value.trim());
|
|
204
|
+
setValue("");
|
|
205
|
+
setCursorOffset(0);
|
|
206
|
+
setShowSuggestions(false);
|
|
207
|
+
onInputChange?.("");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (key.name === 'backspace' || key.name === 'delete') {
|
|
212
|
+
if (cursorOffset > 0) {
|
|
213
|
+
const newValue = value.slice(0, cursorOffset - 1) + value.slice(cursorOffset);
|
|
214
|
+
updateValue(newValue, cursorOffset - 1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
else if (key.name === 'left') {
|
|
218
|
+
setCursorOffset(Math.max(0, cursorOffset - 1));
|
|
219
|
+
}
|
|
220
|
+
else if (key.name === 'right') {
|
|
221
|
+
setCursorOffset(Math.min(value.length, cursorOffset + 1));
|
|
222
|
+
}
|
|
223
|
+
else if (key.ctrl && input === "u") {
|
|
224
|
+
updateValue("", 0);
|
|
225
|
+
}
|
|
226
|
+
else if (key.name === 'tab' && !showSuggestions) {
|
|
227
|
+
// Ignore tab if no suggestions (prevents focus loss if mapped)
|
|
228
|
+
}
|
|
229
|
+
else if (key.paste) {
|
|
230
|
+
// Handle paste events (Cmd+V, bracketed paste)
|
|
231
|
+
const newValue = value.slice(0, cursorOffset) + input + value.slice(cursorOffset);
|
|
232
|
+
updateValue(newValue, cursorOffset + input.length);
|
|
233
|
+
}
|
|
234
|
+
else if (key.insertable && input) {
|
|
235
|
+
// Only insert if the key is actually insertable (not arrow keys, etc.)
|
|
236
|
+
const newValue = value.slice(0, cursorOffset) + input + value.slice(cursorOffset);
|
|
237
|
+
updateValue(newValue, cursorOffset + input.length);
|
|
238
|
+
}
|
|
239
|
+
}, { isActive: !disabled });
|
|
240
|
+
// Split into lines for display and calculate cursor position
|
|
241
|
+
const lines = value ? value.split("\n") : [];
|
|
242
|
+
const hasContent = value.trim().length > 0;
|
|
243
|
+
// Calculate which line and column the cursor should be on
|
|
244
|
+
let cursorLine = 0;
|
|
245
|
+
let cursorCol = cursorOffset;
|
|
246
|
+
let charCount = 0;
|
|
247
|
+
for (let i = 0; i < lines.length; i++) {
|
|
248
|
+
const lineLength = lines[i].length;
|
|
249
|
+
if (charCount + lineLength >= cursorOffset) {
|
|
250
|
+
cursorLine = i;
|
|
251
|
+
cursorCol = cursorOffset - charCount;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
charCount += lineLength + 1; // +1 for newline character
|
|
255
|
+
}
|
|
256
|
+
return (React.createElement(Box, { flexDirection: "column", width: "100%" },
|
|
257
|
+
showSuggestions && (React.createElement(Box, { borderColor: theme.border.default, borderStyle: "round", flexDirection: "column", marginBottom: 0, paddingX: 1 }, suggestions.map((file, idx) => (React.createElement(Text, { color: idx === activeSuggestion ? theme.text.accent : theme.text.dim, key: file },
|
|
258
|
+
idx === activeSuggestion ? "❯ " : " ",
|
|
259
|
+
" ",
|
|
260
|
+
file))))),
|
|
261
|
+
React.createElement(Box, { borderColor: disabled ? theme.border.default : theme.border.accent, borderStyle: "round", flexDirection: "column", marginBottom: 0, minHeight: 3, paddingX: 1, width: "100%" },
|
|
262
|
+
React.createElement(Box, { flexDirection: "row" },
|
|
263
|
+
React.createElement(Text, { color: theme.text.accent }, "\u276F "),
|
|
264
|
+
React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
|
|
265
|
+
!hasContent && !disabled && (React.createElement(Text, null,
|
|
266
|
+
chalk.inverse(placeholder.slice(0, 1)),
|
|
267
|
+
React.createElement(Text, { color: theme.text.dim, italic: true }, placeholder.slice(1)))),
|
|
268
|
+
lines.length > 0 && (React.createElement(Box, { flexDirection: "column" }, lines.map((line, idx) => {
|
|
269
|
+
// Insert cursor at the correct position
|
|
270
|
+
if (idx === cursorLine && !disabled) {
|
|
271
|
+
const before = line.slice(0, cursorCol);
|
|
272
|
+
const charAtCursor = line[cursorCol] || ' ';
|
|
273
|
+
const after = line.slice(cursorCol + 1);
|
|
274
|
+
return (React.createElement(Text, { color: theme.text.primary, key: idx },
|
|
275
|
+
before,
|
|
276
|
+
chalk.inverse(charAtCursor),
|
|
277
|
+
after));
|
|
278
|
+
}
|
|
279
|
+
return (React.createElement(Text, { color: theme.text.primary, key: idx }, line));
|
|
280
|
+
}))),
|
|
281
|
+
!hasContent && disabled && (React.createElement(Text, { color: theme.text.dim, italic: true }, "Waiting for agent to complete..."))))),
|
|
282
|
+
React.createElement(Box, { paddingX: 1 },
|
|
283
|
+
React.createElement(Text, { color: agentMode === "plan" ? theme.status.inProgress : theme.text.dim }, agentMode === "plan" ? "⏸ plan mode on" : "▶ build mode"),
|
|
284
|
+
React.createElement(Text, { color: theme.text.dim }, " (shift+tab to cycle)"))));
|
|
285
|
+
});
|
|
286
|
+
InputPrompt.displayName = "InputPrompt";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message List Component
|
|
3
|
+
* Displays all messages in the conversation
|
|
4
|
+
*/
|
|
5
|
+
import { Box } from "ink";
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { useSession } from "../contexts/SessionContext.js";
|
|
8
|
+
import { Header } from "./Header.js";
|
|
9
|
+
import { AssistantMessage } from "./messages/AssistantMessage.js";
|
|
10
|
+
import { ErrorMessage } from "./messages/ErrorMessage.js";
|
|
11
|
+
import { LoadingMessage } from "./messages/LoadingMessage.js";
|
|
12
|
+
import { ThinkingMessage } from "./messages/ThinkingMessage.js";
|
|
13
|
+
import { TodoMessage } from "./messages/TodoMessage.js";
|
|
14
|
+
import { ToolMessage } from "./messages/ToolMessage.js";
|
|
15
|
+
import { UserMessage } from "./messages/UserMessage.js";
|
|
16
|
+
import { Scrollable } from "./Scrollable.js";
|
|
17
|
+
export const MessageList = ({ terminalWidth, currentFolder, gitBranch }) => {
|
|
18
|
+
const { messages, updateMessageById, isAgentRunning } = useSession();
|
|
19
|
+
const renderMessage = (message) => {
|
|
20
|
+
switch (message.type) {
|
|
21
|
+
case "user":
|
|
22
|
+
return React.createElement(UserMessage, { key: message.id, text: message.content });
|
|
23
|
+
case "assistant":
|
|
24
|
+
return (React.createElement(AssistantMessage, { isPending: message.isPending, key: message.id, terminalWidth: terminalWidth, text: message.content }));
|
|
25
|
+
case "tool":
|
|
26
|
+
return (React.createElement(ToolMessage, { description: message.content, id: message.id, input: message.toolInput, isExpanded: message.isExpanded, key: message.id, onToggle: (id) => updateMessageById(id, { isExpanded: !message.isExpanded }), result: message.toolResult, toolName: message.toolName || "Unknown" }));
|
|
27
|
+
case "thinking":
|
|
28
|
+
return (React.createElement(ThinkingMessage, { content: message.content, id: message.id, isExpanded: message.isExpanded, key: message.id, onToggle: (id) => updateMessageById(id, { isExpanded: !message.isExpanded }) }));
|
|
29
|
+
case "error":
|
|
30
|
+
return (React.createElement(ErrorMessage, { key: message.id, message: message.content, type: message.errorType || "error" }));
|
|
31
|
+
case "todo":
|
|
32
|
+
return React.createElement(TodoMessage, { key: message.id, todos: message.todos || [] });
|
|
33
|
+
default:
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
return (React.createElement(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden" },
|
|
38
|
+
React.createElement(Scrollable, { scrollToBottom: true },
|
|
39
|
+
React.createElement(Header, { currentFolder: currentFolder, gitBranch: gitBranch, key: "header" }),
|
|
40
|
+
messages.map((message) => (React.createElement(Box, { flexDirection: "column", key: message.id, width: "100%" }, renderMessage(message)))),
|
|
41
|
+
isAgentRunning && !messages.some(m => m.type === "assistant" && m.isPending) && (React.createElement(LoadingMessage, { key: "loading" })))));
|
|
42
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { theme } from "../utils/theme.js";
|
|
4
|
+
const MAX_DISPLAYED_QUEUED_MESSAGES = 3;
|
|
5
|
+
export const QueuedMessageDisplay = ({ messageQueue, }) => {
|
|
6
|
+
if (messageQueue.length === 0) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
return (React.createElement(Box, { borderColor: theme.border.default, borderStyle: "round", flexDirection: "column", marginBottom: 1, marginTop: 0, paddingX: 1 },
|
|
10
|
+
React.createElement(Box, { marginBottom: 0 },
|
|
11
|
+
React.createElement(Text, { bold: true, color: theme.text.secondary },
|
|
12
|
+
"Queued (",
|
|
13
|
+
messageQueue.length,
|
|
14
|
+
")")),
|
|
15
|
+
messageQueue
|
|
16
|
+
.slice(0, MAX_DISPLAYED_QUEUED_MESSAGES)
|
|
17
|
+
.map((message, index) => {
|
|
18
|
+
const preview = message.replace(/\s+/g, " ");
|
|
19
|
+
return (React.createElement(Box, { key: index, width: "100%" },
|
|
20
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
21
|
+
" ",
|
|
22
|
+
index + 1,
|
|
23
|
+
". "),
|
|
24
|
+
React.createElement(Text, { color: theme.text.primary, wrap: "truncate" }, preview)));
|
|
25
|
+
}),
|
|
26
|
+
messageQueue.length > MAX_DISPLAYED_QUEUED_MESSAGES && (React.createElement(Box, null,
|
|
27
|
+
React.createElement(Text, { color: theme.text.dim, italic: true },
|
|
28
|
+
"... (+",
|
|
29
|
+
messageQueue.length - MAX_DISPLAYED_QUEUED_MESSAGES,
|
|
30
|
+
" more)")))));
|
|
31
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrollable wrapper component
|
|
3
|
+
* Provides smooth scrolling with a thin, animated scrollbar
|
|
4
|
+
*/
|
|
5
|
+
import { Box, getInnerHeight, getScrollHeight } from "ink";
|
|
6
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react";
|
|
7
|
+
import { useMouse } from "../contexts/MouseContext";
|
|
8
|
+
import { useAnimatedScrollbar } from "../hooks/useAnimatedScrollbar";
|
|
9
|
+
import { useBatchedScroll } from "../hooks/useBatchedScroll";
|
|
10
|
+
import { useKeypress } from "../hooks/useKeypress";
|
|
11
|
+
export const Scrollable = ({ children, flexGrow, scrollToBottom, hasFocus = true, }) => {
|
|
12
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
13
|
+
const ref = useRef(null);
|
|
14
|
+
const [size, setSize] = useState({
|
|
15
|
+
innerHeight: 0,
|
|
16
|
+
scrollHeight: 0,
|
|
17
|
+
});
|
|
18
|
+
const sizeRef = useRef(size);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
sizeRef.current = size;
|
|
21
|
+
}, [size]);
|
|
22
|
+
const childrenCountRef = useRef(0);
|
|
23
|
+
// Track previous values to avoid unnecessary state updates
|
|
24
|
+
const prevSizeRef = useRef({ innerHeight: 0, scrollHeight: 0 });
|
|
25
|
+
const prevScrollTopRef = useRef(0);
|
|
26
|
+
// Measure container and auto-scroll to bottom when content changes
|
|
27
|
+
// Use a ref to batch updates and avoid cascading re-renders
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
if (!ref.current) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const innerHeight = Math.round(getInnerHeight(ref.current));
|
|
33
|
+
const scrollHeight = Math.round(getScrollHeight(ref.current));
|
|
34
|
+
const prevSize = prevSizeRef.current;
|
|
35
|
+
const sizeChanged = prevSize.innerHeight !== innerHeight || prevSize.scrollHeight !== scrollHeight;
|
|
36
|
+
// Only update if size actually changed
|
|
37
|
+
if (sizeChanged) {
|
|
38
|
+
prevSizeRef.current = { innerHeight, scrollHeight };
|
|
39
|
+
// Check if we were at bottom before the change (use previous values)
|
|
40
|
+
const wasAtBottom = prevScrollTopRef.current >= prevSize.scrollHeight - prevSize.innerHeight - 1;
|
|
41
|
+
// Batch size and scroll updates together
|
|
42
|
+
const newScrollTop = wasAtBottom ? Math.max(0, scrollHeight - innerHeight) : scrollTop;
|
|
43
|
+
// Only trigger state updates if values actually changed
|
|
44
|
+
if (size.innerHeight !== innerHeight || size.scrollHeight !== scrollHeight) {
|
|
45
|
+
setSize({ innerHeight, scrollHeight });
|
|
46
|
+
}
|
|
47
|
+
if (wasAtBottom && newScrollTop !== scrollTop) {
|
|
48
|
+
prevScrollTopRef.current = newScrollTop;
|
|
49
|
+
setScrollTop(newScrollTop);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const childCountCurrent = React.Children.count(children);
|
|
53
|
+
// Only auto-scroll to bottom if user was already at bottom or near bottom
|
|
54
|
+
if (scrollToBottom && childrenCountRef.current !== childCountCurrent) {
|
|
55
|
+
const wasAtBottom = scrollTop >= size.scrollHeight - size.innerHeight - 1;
|
|
56
|
+
if (wasAtBottom) {
|
|
57
|
+
const newScrollTop = Math.max(0, scrollHeight - innerHeight);
|
|
58
|
+
if (newScrollTop !== scrollTop) {
|
|
59
|
+
prevScrollTopRef.current = newScrollTop;
|
|
60
|
+
setScrollTop(newScrollTop);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
childrenCountRef.current = childCountCurrent;
|
|
65
|
+
});
|
|
66
|
+
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
|
|
67
|
+
const scrollBy = useCallback((delta) => {
|
|
68
|
+
const { scrollHeight, innerHeight } = sizeRef.current;
|
|
69
|
+
const current = getScrollTop();
|
|
70
|
+
const next = Math.min(Math.max(0, current + delta), Math.max(0, scrollHeight - innerHeight));
|
|
71
|
+
setPendingScrollTop(next);
|
|
72
|
+
setScrollTop(next);
|
|
73
|
+
}, [getScrollTop, setPendingScrollTop]);
|
|
74
|
+
const { scrollbarColor, scrollByWithAnimation } = useAnimatedScrollbar(hasFocus, scrollBy, {
|
|
75
|
+
focusedColor: "#6C7086", // gemini-cli's text.secondary
|
|
76
|
+
unfocusedColor: "#454759", // interpolated between #6C7086 and #1E1E2E
|
|
77
|
+
});
|
|
78
|
+
// Handle keyboard scrolling with Shift+Up/Down
|
|
79
|
+
// Regular Up/Down is reserved for menu navigation (autocomplete)
|
|
80
|
+
// Uses KeypressContext to filter out mouse events
|
|
81
|
+
useKeypress((key) => {
|
|
82
|
+
if (key.name === 'up' && key.shift) {
|
|
83
|
+
scrollByWithAnimation(-3); // Scroll 3 lines at a time for smoother feel
|
|
84
|
+
}
|
|
85
|
+
else if (key.name === 'down' && key.shift) {
|
|
86
|
+
scrollByWithAnimation(3);
|
|
87
|
+
}
|
|
88
|
+
}, { isActive: hasFocus });
|
|
89
|
+
// Handle mouse/trackpad scrolling
|
|
90
|
+
useMouse((event) => {
|
|
91
|
+
if (event.name === 'scroll-up') {
|
|
92
|
+
scrollByWithAnimation(-3);
|
|
93
|
+
return true; // Mark as handled
|
|
94
|
+
}
|
|
95
|
+
else if (event.name === 'scroll-down') {
|
|
96
|
+
scrollByWithAnimation(3);
|
|
97
|
+
return true; // Mark as handled
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}, { isActive: hasFocus });
|
|
101
|
+
return (React.createElement(Box, { flexDirection: "column", flexGrow: flexGrow ?? 1, height: "100%", overflowX: "hidden", overflowY: "scroll", paddingRight: 1, ref: ref, scrollbarThumbColor: scrollbarColor, scrollTop: scrollTop, width: "100%" },
|
|
102
|
+
React.createElement(Box, { flexDirection: "column", flexShrink: 0, width: "100%" }, children)));
|
|
103
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Selector Component
|
|
3
|
+
* Displays a list of sessions for the user to select from with pagination
|
|
4
|
+
*/
|
|
5
|
+
import { Box, Text, useInput } from "ink";
|
|
6
|
+
import React, { useEffect, useState } from "react";
|
|
7
|
+
import { theme } from "../utils/theme.js";
|
|
8
|
+
const PAGE_SIZE = 20;
|
|
9
|
+
const VISIBLE_ITEMS = 10;
|
|
10
|
+
/**
|
|
11
|
+
* Determine if a session is "Me" or "Team" based on authMethod
|
|
12
|
+
*/
|
|
13
|
+
function getSessionPrefix(authMethod) {
|
|
14
|
+
return authMethod === "api-key" ? "[Team]" : "[Me]";
|
|
15
|
+
}
|
|
16
|
+
export const SessionSelector = ({ apiClient, onSelect, onCancel, }) => {
|
|
17
|
+
const [allSessions, setAllSessions] = useState([]);
|
|
18
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
19
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
20
|
+
const [hasMore, setHasMore] = useState(true);
|
|
21
|
+
const [totalSessions, setTotalSessions] = useState(0);
|
|
22
|
+
const [error, setError] = useState(null);
|
|
23
|
+
// Load initial page
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
loadMoreSessions();
|
|
26
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
27
|
+
const loadMoreSessions = async () => {
|
|
28
|
+
if (isLoading || !hasMore) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
setIsLoading(true);
|
|
32
|
+
setError(null);
|
|
33
|
+
try {
|
|
34
|
+
const offset = allSessions.length;
|
|
35
|
+
const result = await apiClient.getSessions(PAGE_SIZE, offset);
|
|
36
|
+
const newSessions = result.sessions.map((s) => ({
|
|
37
|
+
session: s,
|
|
38
|
+
prefix: getSessionPrefix(s.authMethod),
|
|
39
|
+
}));
|
|
40
|
+
setTotalSessions(result.pagination.total);
|
|
41
|
+
setHasMore(offset + result.sessions.length < result.pagination.total);
|
|
42
|
+
setAllSessions((prev) => [...prev, ...newSessions]);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
46
|
+
setHasMore(false);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
setIsLoading(false);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
useInput((input, key) => {
|
|
53
|
+
if (allSessions.length === 0) {
|
|
54
|
+
if (key.escape || input === "q") {
|
|
55
|
+
onCancel();
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (key.upArrow) {
|
|
60
|
+
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : allSessions.length - 1));
|
|
61
|
+
}
|
|
62
|
+
else if (key.downArrow) {
|
|
63
|
+
const newIndex = selectedIndex < allSessions.length - 1 ? selectedIndex + 1 : 0;
|
|
64
|
+
setSelectedIndex(newIndex);
|
|
65
|
+
// Load more when approaching the end
|
|
66
|
+
if (newIndex >= allSessions.length - 3 && hasMore && !isLoading) {
|
|
67
|
+
loadMoreSessions();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (key.return) {
|
|
71
|
+
if (allSessions[selectedIndex]) {
|
|
72
|
+
onSelect(allSessions[selectedIndex].session);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
else if (key.escape || input === "q") {
|
|
76
|
+
onCancel();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
if (error) {
|
|
80
|
+
return (React.createElement(Box, { borderColor: "red", borderStyle: "round", flexDirection: "column", padding: 1 },
|
|
81
|
+
React.createElement(Text, { bold: true, color: "red" }, "Error Loading Sessions"),
|
|
82
|
+
React.createElement(Text, { color: theme.text.dim }, error),
|
|
83
|
+
React.createElement(Box, { marginTop: 1 },
|
|
84
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
85
|
+
"Press ",
|
|
86
|
+
React.createElement(Text, { bold: true }, "ESC"),
|
|
87
|
+
" to cancel"))));
|
|
88
|
+
}
|
|
89
|
+
if (allSessions.length === 0 && isLoading) {
|
|
90
|
+
return (React.createElement(Box, { borderColor: "cyan", borderStyle: "round", flexDirection: "column", padding: 1 },
|
|
91
|
+
React.createElement(Text, { bold: true, color: "cyan" }, "Loading Sessions..."),
|
|
92
|
+
React.createElement(Text, { color: theme.text.dim }, "Fetching your sessions from the server")));
|
|
93
|
+
}
|
|
94
|
+
if (allSessions.length === 0 && !isLoading) {
|
|
95
|
+
return (React.createElement(Box, { borderColor: "yellow", borderStyle: "round", flexDirection: "column", padding: 1 },
|
|
96
|
+
React.createElement(Text, { bold: true, color: "yellow" }, "No Sessions Found"),
|
|
97
|
+
React.createElement(Text, { color: theme.text.dim }, "No previous sessions available. Start a new conversation!"),
|
|
98
|
+
React.createElement(Box, { marginTop: 1 },
|
|
99
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
100
|
+
"Press ",
|
|
101
|
+
React.createElement(Text, { bold: true }, "ESC"),
|
|
102
|
+
" to cancel"))));
|
|
103
|
+
}
|
|
104
|
+
// Calculate the visible window of sessions
|
|
105
|
+
const VISIBLE_ITEMS = 10;
|
|
106
|
+
// Calculate start and end indices for the visible window
|
|
107
|
+
// Always try to center the selected item, but adjust for boundaries
|
|
108
|
+
let startIndex;
|
|
109
|
+
let endIndex;
|
|
110
|
+
if (allSessions.length <= VISIBLE_ITEMS) {
|
|
111
|
+
// Show all sessions if we have fewer than the visible limit
|
|
112
|
+
startIndex = 0;
|
|
113
|
+
endIndex = allSessions.length;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
// Try to center the selected item
|
|
117
|
+
const halfWindow = Math.floor(VISIBLE_ITEMS / 2);
|
|
118
|
+
startIndex = Math.max(0, selectedIndex - halfWindow);
|
|
119
|
+
endIndex = startIndex + VISIBLE_ITEMS;
|
|
120
|
+
// If we're near the end, adjust to show the last N items
|
|
121
|
+
if (endIndex > allSessions.length) {
|
|
122
|
+
endIndex = allSessions.length;
|
|
123
|
+
startIndex = Math.max(0, endIndex - VISIBLE_ITEMS);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const visibleSessions = allSessions.slice(startIndex, endIndex);
|
|
127
|
+
// Calculate max title length for proper alignment
|
|
128
|
+
const MAX_TITLE_WIDTH = 50;
|
|
129
|
+
const PREFIX_WIDTH = 6; // "[Me] " or "[Team] "
|
|
130
|
+
return (React.createElement(Box, { borderColor: "cyan", borderStyle: "round", flexDirection: "column", padding: 1 },
|
|
131
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
132
|
+
React.createElement(Text, { bold: true, color: "cyan" }, "Select a Session to Resume")),
|
|
133
|
+
React.createElement(Box, { flexDirection: "column" }, visibleSessions.map((item, index) => {
|
|
134
|
+
const actualIndex = startIndex + index;
|
|
135
|
+
const isSelected = actualIndex === selectedIndex;
|
|
136
|
+
const title = item.session.title || "Untitled session";
|
|
137
|
+
// Truncate and pad title to fixed width for alignment
|
|
138
|
+
let displayTitle = title.length > MAX_TITLE_WIDTH
|
|
139
|
+
? `${title.slice(0, MAX_TITLE_WIDTH - 3)}...`
|
|
140
|
+
: title;
|
|
141
|
+
// Pad the title to fixed width
|
|
142
|
+
displayTitle = displayTitle.padEnd(MAX_TITLE_WIDTH, " ");
|
|
143
|
+
// Format date more compactly
|
|
144
|
+
const sessionDate = new Date(item.session.createdAt);
|
|
145
|
+
const now = new Date();
|
|
146
|
+
const isToday = sessionDate.toDateString() === now.toDateString();
|
|
147
|
+
const yesterday = new Date(now);
|
|
148
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
149
|
+
const isYesterday = sessionDate.toDateString() === yesterday.toDateString();
|
|
150
|
+
let dateStr;
|
|
151
|
+
if (isToday) {
|
|
152
|
+
dateStr = `Today ${sessionDate.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`;
|
|
153
|
+
}
|
|
154
|
+
else if (isYesterday) {
|
|
155
|
+
dateStr = `Yesterday ${sessionDate.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
dateStr = `${sessionDate.toLocaleDateString([], { month: "numeric", day: "numeric" })} ${sessionDate.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`;
|
|
159
|
+
}
|
|
160
|
+
const prefix = item.prefix.padEnd(PREFIX_WIDTH, " "); // Pad prefix to fixed width
|
|
161
|
+
const prefixColor = item.prefix === "[Me]" ? "cyan" : "yellow";
|
|
162
|
+
const indicator = isSelected ? "▶ " : " ";
|
|
163
|
+
const bgColor = isSelected ? theme.text.accent : undefined;
|
|
164
|
+
return (React.createElement(Box, { key: item.session.id, width: "100%" },
|
|
165
|
+
React.createElement(Text, { backgroundColor: bgColor, bold: isSelected, color: isSelected ? "black" : theme.text.primary }, indicator),
|
|
166
|
+
React.createElement(Text, { backgroundColor: bgColor, bold: isSelected, color: prefixColor }, prefix),
|
|
167
|
+
React.createElement(Text, { backgroundColor: bgColor, bold: isSelected, color: isSelected ? "black" : theme.text.primary }, displayTitle),
|
|
168
|
+
React.createElement(Text, { backgroundColor: bgColor, color: theme.text.dim },
|
|
169
|
+
"(",
|
|
170
|
+
dateStr,
|
|
171
|
+
")",
|
|
172
|
+
"\n")));
|
|
173
|
+
})),
|
|
174
|
+
React.createElement(Box, { flexDirection: "column", marginTop: 1 },
|
|
175
|
+
(allSessions.length > VISIBLE_ITEMS || totalSessions > allSessions.length) && (React.createElement(Box, { marginBottom: 1 },
|
|
176
|
+
React.createElement(Text, { color: "yellow" },
|
|
177
|
+
"Showing ",
|
|
178
|
+
startIndex + 1,
|
|
179
|
+
"-",
|
|
180
|
+
endIndex,
|
|
181
|
+
" of ",
|
|
182
|
+
totalSessions || allSessions.length,
|
|
183
|
+
" sessions",
|
|
184
|
+
hasMore && !isLoading && React.createElement(Text, { color: theme.text.dim }, " \u2022 Scroll for more")))),
|
|
185
|
+
React.createElement(Box, null,
|
|
186
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
187
|
+
"Use ",
|
|
188
|
+
React.createElement(Text, { bold: true }, "\u2191\u2193"),
|
|
189
|
+
" to navigate \u2022 ",
|
|
190
|
+
React.createElement(Text, { bold: true }, "Enter"),
|
|
191
|
+
" to select \u2022 ",
|
|
192
|
+
React.createElement(Text, { bold: true }, "ESC"),
|
|
193
|
+
" to cancel")),
|
|
194
|
+
isLoading && (React.createElement(Box, { marginTop: 1 },
|
|
195
|
+
React.createElement(Text, { color: "cyan" }, "Loading more sessions..."))))));
|
|
196
|
+
};
|