@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.
Files changed (69) hide show
  1. package/dist/commands/login.js +392 -0
  2. package/dist/commands/setup.js +234 -0
  3. package/dist/config.js +29 -0
  4. package/dist/core/agent.js +259 -0
  5. package/dist/index.js +154 -6586
  6. package/dist/modes/headless.js +117 -0
  7. package/dist/modes/interactive.js +418 -0
  8. package/dist/presenters/composite.js +32 -0
  9. package/dist/presenters/console.js +163 -0
  10. package/dist/presenters/react.js +217 -0
  11. package/dist/presenters/types.js +1 -0
  12. package/dist/presenters/web.js +78 -0
  13. package/dist/prompts/builder.js +181 -0
  14. package/dist/prompts/fixer.js +148 -0
  15. package/dist/prompts/index.js +3 -0
  16. package/dist/prompts/planner.js +70 -0
  17. package/dist/services/api-client.js +244 -0
  18. package/dist/services/event-streamer.js +130 -0
  19. package/dist/types.js +1 -0
  20. package/dist/ui/App.js +322 -0
  21. package/dist/ui/components/AuthBanner.js +24 -0
  22. package/dist/ui/components/AuthDialog.js +32 -0
  23. package/dist/ui/components/Banner.js +12 -0
  24. package/dist/ui/components/ExpandableSection.js +17 -0
  25. package/dist/ui/components/Header.js +51 -0
  26. package/dist/ui/components/HelpMenu.js +89 -0
  27. package/dist/ui/components/InputPrompt.js +286 -0
  28. package/dist/ui/components/MessageList.js +42 -0
  29. package/dist/ui/components/QueuedMessageDisplay.js +31 -0
  30. package/dist/ui/components/Scrollable.js +103 -0
  31. package/dist/ui/components/SessionSelector.js +196 -0
  32. package/dist/ui/components/StatusBar.js +34 -0
  33. package/dist/ui/components/messages/AssistantMessage.js +20 -0
  34. package/dist/ui/components/messages/ErrorMessage.js +26 -0
  35. package/dist/ui/components/messages/LoadingMessage.js +28 -0
  36. package/dist/ui/components/messages/ThinkingMessage.js +17 -0
  37. package/dist/ui/components/messages/TodoMessage.js +44 -0
  38. package/dist/ui/components/messages/ToolMessage.js +218 -0
  39. package/dist/ui/components/messages/UserMessage.js +14 -0
  40. package/dist/ui/contexts/KeypressContext.js +527 -0
  41. package/dist/ui/contexts/MouseContext.js +98 -0
  42. package/dist/ui/contexts/SessionContext.js +129 -0
  43. package/dist/ui/hooks/useAnimatedScrollbar.js +83 -0
  44. package/dist/ui/hooks/useBatchedScroll.js +22 -0
  45. package/dist/ui/hooks/useBracketedPaste.js +31 -0
  46. package/dist/ui/hooks/useFocus.js +50 -0
  47. package/dist/ui/hooks/useKeypress.js +26 -0
  48. package/dist/ui/hooks/useModeToggle.js +25 -0
  49. package/dist/ui/types/auth.js +13 -0
  50. package/dist/ui/utils/file-completion.js +56 -0
  51. package/dist/ui/utils/input.js +50 -0
  52. package/dist/ui/utils/markdown.js +376 -0
  53. package/dist/ui/utils/mouse.js +189 -0
  54. package/dist/ui/utils/theme.js +59 -0
  55. package/dist/utils/banner.js +9 -0
  56. package/dist/utils/encryption.js +71 -0
  57. package/dist/utils/events.js +36 -0
  58. package/dist/utils/keychain-storage.js +120 -0
  59. package/dist/utils/logger.js +209 -0
  60. package/dist/utils/node-version.js +89 -0
  61. package/dist/utils/plan-file.js +75 -0
  62. package/dist/utils/project-instructions.js +23 -0
  63. package/dist/utils/rich-logger.js +208 -0
  64. package/dist/utils/stdin.js +25 -0
  65. package/dist/utils/stdio.js +80 -0
  66. package/dist/utils/summary.js +94 -0
  67. package/dist/utils/token-storage.js +242 -0
  68. package/dist/version.js +6 -0
  69. 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
+ };