@supatest/cli 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -315
- package/dist/agent-runner.js +224 -52
- 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 +270 -0
- package/dist/index.js +118 -31
- package/dist/modes/headless.js +117 -0
- package/dist/modes/interactive.js +430 -0
- package/dist/presenters/composite.js +32 -0
- package/dist/presenters/console.js +163 -0
- package/dist/presenters/react.js +220 -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/headless.md +97 -0
- package/dist/prompts/index.js +3 -0
- package/dist/prompts/interactive.md +43 -0
- package/dist/prompts/plan.md +41 -0
- package/dist/prompts/planner.js +70 -0
- package/dist/prompts/prompts/builder.md +97 -0
- package/dist/prompts/prompts/fixer.md +100 -0
- package/dist/prompts/prompts/plan.md +41 -0
- package/dist/prompts/prompts/planner.md +41 -0
- package/dist/services/api-client.js +244 -0
- package/dist/services/event-streamer.js +130 -0
- package/dist/ui/App.js +322 -0
- package/dist/ui/components/AuthBanner.js +20 -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 +49 -0
- package/dist/ui/components/HelpMenu.js +89 -0
- package/dist/ui/components/InputPrompt.js +292 -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 +45 -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 +131 -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 +7 -14
- 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 +103 -1
- package/dist/utils/node-version.js +1 -3
- package/dist/utils/plan-file.js +75 -0
- package/dist/utils/project-instructions.js +23 -0
- package/dist/utils/rich-logger.js +1 -1
- package/dist/utils/stdio.js +80 -0
- package/dist/utils/summary.js +1 -5
- package/dist/utils/token-storage.js +242 -0
- package/package.json +35 -15
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status Bar Component
|
|
3
|
+
* Displays session stats, web URL, and agent status
|
|
4
|
+
*/
|
|
5
|
+
import { Box, Text } from "ink";
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { useSession } from "../contexts/SessionContext.js";
|
|
8
|
+
import { theme } from "../utils/theme.js";
|
|
9
|
+
function formatTokens(tokens) {
|
|
10
|
+
if (tokens >= 1000000)
|
|
11
|
+
return `${(tokens / 1000000).toFixed(1)}M`;
|
|
12
|
+
if (tokens >= 1000)
|
|
13
|
+
return `${(tokens / 1000).toFixed(1)}k`;
|
|
14
|
+
return tokens.toString();
|
|
15
|
+
}
|
|
16
|
+
export const StatusBar = () => {
|
|
17
|
+
const { stats, webUrl, isAgentRunning } = useSession();
|
|
18
|
+
const elapsedSeconds = Math.floor((Date.now() - stats.startTime) / 1000);
|
|
19
|
+
const minutes = Math.floor(elapsedSeconds / 60);
|
|
20
|
+
const seconds = elapsedSeconds % 60;
|
|
21
|
+
const timeStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
|
22
|
+
return (React.createElement(Box, { borderColor: theme.border.default, borderStyle: "round", flexDirection: "column", paddingX: 1 },
|
|
23
|
+
React.createElement(Box, { flexDirection: "row", justifyContent: "space-between" },
|
|
24
|
+
React.createElement(Box, null,
|
|
25
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
26
|
+
"Files: ",
|
|
27
|
+
React.createElement(Text, { color: theme.text.accent }, stats.filesModified.size)),
|
|
28
|
+
React.createElement(Text, { color: theme.text.dim }, " \u2022 "),
|
|
29
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
30
|
+
"Commands: ",
|
|
31
|
+
React.createElement(Text, { color: theme.text.accent }, stats.commandsRun.length)),
|
|
32
|
+
React.createElement(Text, { color: theme.text.dim }, " \u2022 "),
|
|
33
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
34
|
+
"Time: ",
|
|
35
|
+
React.createElement(Text, { color: theme.text.accent }, timeStr)),
|
|
36
|
+
React.createElement(Text, { color: theme.text.dim }, " \u2022 "),
|
|
37
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
38
|
+
"Tokens: ",
|
|
39
|
+
React.createElement(Text, { color: theme.text.accent }, formatTokens(stats.totalTokens)))),
|
|
40
|
+
React.createElement(Box, null, isAgentRunning ? (React.createElement(Text, { color: theme.status.inProgress }, "\u25CF Running")) : (React.createElement(Text, { color: theme.status.completed }, "\u25CF Ready")))),
|
|
41
|
+
webUrl && (React.createElement(Box, { marginTop: 0 },
|
|
42
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
43
|
+
"Web: ",
|
|
44
|
+
React.createElement(Text, { color: theme.text.info, underline: true }, webUrl))))));
|
|
45
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assistant message component
|
|
3
|
+
* Displays AI responses with markdown rendering
|
|
4
|
+
*/
|
|
5
|
+
import { Box, Text } from "ink";
|
|
6
|
+
import Spinner from "ink-spinner";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { MarkdownDisplay } from "../../utils/markdown.js";
|
|
9
|
+
import { theme } from "../../utils/theme.js";
|
|
10
|
+
export const AssistantMessage = ({ text, isPending = false, terminalWidth = 80, }) => {
|
|
11
|
+
// Use "dots" spinner to match the screenshot look (animating dots)
|
|
12
|
+
// When pending, show the spinner. When done, show the static accent icon.
|
|
13
|
+
const prefix = isPending ? React.createElement(Spinner, { type: "dots" }) : "✦ ";
|
|
14
|
+
const prefixWidth = 2;
|
|
15
|
+
return (React.createElement(Box, { flexDirection: "row", marginTop: 1 },
|
|
16
|
+
React.createElement(Box, { width: prefixWidth },
|
|
17
|
+
React.createElement(Text, { color: isPending ? theme.text.dim : theme.text.accent }, prefix)),
|
|
18
|
+
React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
|
|
19
|
+
React.createElement(MarkdownDisplay, { isPending: isPending, terminalWidth: terminalWidth - prefixWidth, text: text }))));
|
|
20
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error message component
|
|
3
|
+
* Displays errors and warnings
|
|
4
|
+
*/
|
|
5
|
+
import { Box, Text } from "ink";
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { theme } from "../../utils/theme.js";
|
|
8
|
+
export const ErrorMessage = ({ message, type = "error", }) => {
|
|
9
|
+
const { icon, color } = getErrorStyle(type);
|
|
10
|
+
return (React.createElement(Box, { flexDirection: "row", marginTop: 1 },
|
|
11
|
+
React.createElement(Text, { color: color },
|
|
12
|
+
icon,
|
|
13
|
+
" ",
|
|
14
|
+
message)));
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Get icon and color based on error type
|
|
18
|
+
*/
|
|
19
|
+
function getErrorStyle(type) {
|
|
20
|
+
const styles = {
|
|
21
|
+
error: { icon: "✗", color: theme.text.error },
|
|
22
|
+
warning: { icon: "⚠", color: theme.text.warning },
|
|
23
|
+
info: { icon: "ℹ", color: theme.text.info },
|
|
24
|
+
};
|
|
25
|
+
return styles[type];
|
|
26
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loading message component
|
|
3
|
+
* Displays a spinner with static text while waiting for assistant response
|
|
4
|
+
* Note: We avoid text rotation to prevent layout shifts and flickering
|
|
5
|
+
*/
|
|
6
|
+
import { Box, Text } from "ink";
|
|
7
|
+
import Spinner from "ink-spinner";
|
|
8
|
+
import React, { useMemo } from "react";
|
|
9
|
+
import { theme } from "../../utils/theme.js";
|
|
10
|
+
// Fun loading messages - pick one randomly on mount (no rotation to avoid flicker)
|
|
11
|
+
const LOADING_MESSAGES = [
|
|
12
|
+
"Thinking...",
|
|
13
|
+
"Working...",
|
|
14
|
+
"Processing...",
|
|
15
|
+
];
|
|
16
|
+
export const LoadingMessage = () => {
|
|
17
|
+
// Pick a random message once on mount - no rotation to prevent layout shifts
|
|
18
|
+
const message = useMemo(() => LOADING_MESSAGES[Math.floor(Math.random() * LOADING_MESSAGES.length)], []);
|
|
19
|
+
return (React.createElement(Box, { flexDirection: "row", marginTop: 1 },
|
|
20
|
+
React.createElement(Box, { width: 2 },
|
|
21
|
+
React.createElement(Text, { color: theme.text.accent },
|
|
22
|
+
React.createElement(Spinner, { type: "dots" }))),
|
|
23
|
+
React.createElement(Box, { flexDirection: "column", flexGrow: 1 },
|
|
24
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
25
|
+
message,
|
|
26
|
+
" ",
|
|
27
|
+
React.createElement(Text, { color: theme.text.dim }, "(esc to interrupt)")))));
|
|
28
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thinking message component
|
|
3
|
+
* Displays Claude's thinking process (collapsible by default)
|
|
4
|
+
*/
|
|
5
|
+
import { Box, Text } from "ink";
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { theme } from "../../utils/theme.js";
|
|
8
|
+
export const ThinkingMessage = ({ id, content, isExpanded = false, onToggle, }) => {
|
|
9
|
+
return (React.createElement(Box, { flexDirection: "column", marginTop: 1 },
|
|
10
|
+
React.createElement(Box, { flexDirection: "row" },
|
|
11
|
+
React.createElement(Text, { color: theme.text.dim }, "\u25CF Thinking"),
|
|
12
|
+
content && (React.createElement(Text, { color: theme.text.dim },
|
|
13
|
+
" ",
|
|
14
|
+
isExpanded ? "▼" : "▶"))),
|
|
15
|
+
isExpanded && content && (React.createElement(Box, { flexDirection: "column", marginLeft: 2 },
|
|
16
|
+
React.createElement(Text, { color: theme.text.dim }, content)))));
|
|
17
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo message component
|
|
3
|
+
* Displays todo list with progress
|
|
4
|
+
*/
|
|
5
|
+
import { Box, Text } from "ink";
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { theme } from "../../utils/theme.js";
|
|
8
|
+
export const TodoMessage = ({ todos }) => {
|
|
9
|
+
const completed = todos.filter((t) => t.status === "completed");
|
|
10
|
+
const inProgress = todos.filter((t) => t.status === "in_progress");
|
|
11
|
+
const pending = todos.filter((t) => t.status === "pending");
|
|
12
|
+
const total = todos.length;
|
|
13
|
+
const completedCount = completed.length;
|
|
14
|
+
const progress = total > 0 ? Math.round((completedCount / total) * 100) : 0;
|
|
15
|
+
// Progress bar
|
|
16
|
+
const barLength = 20;
|
|
17
|
+
const filledLength = Math.round((barLength * completedCount) / total);
|
|
18
|
+
const bar = "█".repeat(filledLength) + "░".repeat(barLength - filledLength);
|
|
19
|
+
return (React.createElement(Box, { flexDirection: "column", marginY: 0 },
|
|
20
|
+
React.createElement(Box, { flexDirection: "row" },
|
|
21
|
+
React.createElement(Text, { color: theme.text.info }, "\uD83D\uDCDD "),
|
|
22
|
+
React.createElement(Text, { color: theme.text.dim }, "Todo Progress: "),
|
|
23
|
+
React.createElement(Text, { color: theme.text.accent }, bar),
|
|
24
|
+
React.createElement(Text, { color: theme.text.primary },
|
|
25
|
+
" ",
|
|
26
|
+
progress,
|
|
27
|
+
"%"),
|
|
28
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
29
|
+
" ",
|
|
30
|
+
"(",
|
|
31
|
+
completedCount,
|
|
32
|
+
"/",
|
|
33
|
+
total,
|
|
34
|
+
")")),
|
|
35
|
+
inProgress.length > 0 && (React.createElement(Box, { flexDirection: "column", marginLeft: 2 }, inProgress.map((todo, idx) => (React.createElement(Box, { flexDirection: "row", key: `in-progress-${idx}` },
|
|
36
|
+
React.createElement(Text, { color: theme.status.inProgress }, "\u2192 "),
|
|
37
|
+
React.createElement(Text, { color: theme.text.primary }, todo.activeForm || todo.content)))))),
|
|
38
|
+
completed.length > 0 && completed.length <= 2 && (React.createElement(Box, { flexDirection: "column", marginLeft: 2 }, completed.map((todo, idx) => (React.createElement(Box, { flexDirection: "row", key: `completed-${idx}` },
|
|
39
|
+
React.createElement(Text, { color: theme.status.completed }, "\u2713 "),
|
|
40
|
+
React.createElement(Text, { color: theme.text.dim }, todo.content)))))),
|
|
41
|
+
pending.length > 0 && pending.length <= 3 && (React.createElement(Box, { flexDirection: "column", marginLeft: 2 }, pending.map((todo, idx) => (React.createElement(Box, { flexDirection: "row", key: `pending-${idx}` },
|
|
42
|
+
React.createElement(Text, { color: theme.status.pending }, "\u23F3 "),
|
|
43
|
+
React.createElement(Text, { color: theme.text.secondary }, todo.content))))))));
|
|
44
|
+
};
|