@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,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Banner Component
|
|
3
|
+
* Shows authentication status when user is not logged in
|
|
4
|
+
*/
|
|
5
|
+
import { Box, Text } from "ink";
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { AuthState } from "../types/auth.js";
|
|
8
|
+
import { theme } from "../utils/theme.js";
|
|
9
|
+
export const AuthBanner = ({ authState }) => {
|
|
10
|
+
// Don't show banner if authenticated
|
|
11
|
+
if (authState === AuthState.Authenticated) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const message = authState === AuthState.Authenticating
|
|
15
|
+
? "Authenticating..."
|
|
16
|
+
: "Not logged in. Type /login to authenticate.";
|
|
17
|
+
const color = authState === AuthState.Authenticating ? theme.text.info : theme.text.warning;
|
|
18
|
+
return (React.createElement(Box, { marginBottom: 0, paddingX: 1 },
|
|
19
|
+
React.createElement(Text, { color: color }, message)));
|
|
20
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Dialog Component
|
|
3
|
+
* Prompts user to login (authentication is required)
|
|
4
|
+
*
|
|
5
|
+
* Based on Gemini CLI (Apache 2.0 License)
|
|
6
|
+
* https://github.com/google-gemini/gemini-cli
|
|
7
|
+
* Copyright 2025 Google LLC
|
|
8
|
+
*/
|
|
9
|
+
import { Box, Text } from "ink";
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { useKeypress } from "../hooks/useKeypress.js";
|
|
12
|
+
import { theme } from "../utils/theme.js";
|
|
13
|
+
export const AuthDialog = ({ onLogin }) => {
|
|
14
|
+
useKeypress((key) => {
|
|
15
|
+
// Any key press triggers login (Enter, L, or any other key)
|
|
16
|
+
if (key.name === "return" || key.name === "l" || key.sequence === "l") {
|
|
17
|
+
onLogin();
|
|
18
|
+
}
|
|
19
|
+
}, { isActive: true });
|
|
20
|
+
return (React.createElement(Box, { borderColor: theme.border.accent, borderStyle: "round", flexDirection: "column", paddingX: 2, paddingY: 1 },
|
|
21
|
+
React.createElement(Text, { bold: true, color: theme.text.primary }, "Welcome to Supatest CLI"),
|
|
22
|
+
React.createElement(Box, { marginTop: 1 },
|
|
23
|
+
React.createElement(Text, { color: theme.text.secondary }, "Authentication is required to use Supatest CLI.")),
|
|
24
|
+
React.createElement(Box, { flexDirection: "column", marginTop: 1 },
|
|
25
|
+
React.createElement(Box, null,
|
|
26
|
+
React.createElement(Text, { color: theme.text.accent },
|
|
27
|
+
">",
|
|
28
|
+
" "),
|
|
29
|
+
React.createElement(Text, { color: theme.text.primary }, "[L] Login with browser"))),
|
|
30
|
+
React.createElement(Box, { marginTop: 1 },
|
|
31
|
+
React.createElement(Text, { color: theme.text.dim, italic: true }, "Press Enter or L to login"))));
|
|
32
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { theme } from "../utils/theme.js";
|
|
4
|
+
export const Banner = ({ text, type = "info" }) => {
|
|
5
|
+
let borderColor = theme.border.default;
|
|
6
|
+
if (type === "warning")
|
|
7
|
+
borderColor = theme.text.warning;
|
|
8
|
+
if (type === "error")
|
|
9
|
+
borderColor = theme.border.error;
|
|
10
|
+
return (React.createElement(Box, { borderColor: borderColor, borderStyle: "round", flexDirection: "column", marginBottom: 1, paddingX: 1, width: "100%" },
|
|
11
|
+
React.createElement(Text, { color: type === "warning" ? theme.text.warning : type === "error" ? theme.text.error : theme.text.primary }, text)));
|
|
12
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expandable Section Component
|
|
3
|
+
* Shows a collapsible section with summary and expandable details
|
|
4
|
+
*/
|
|
5
|
+
import { Box, Text } from "ink";
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { theme } from "../utils/theme.js";
|
|
8
|
+
export const ExpandableSection = ({ id, summary, summaryColor = theme.text.dim, details, isExpanded = false, onToggle, }) => {
|
|
9
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
10
|
+
React.createElement(Box, { flexDirection: "row" },
|
|
11
|
+
React.createElement(Text, { color: summaryColor }, summary),
|
|
12
|
+
details && (React.createElement(Text, { color: theme.text.dim },
|
|
13
|
+
" ",
|
|
14
|
+
isExpanded ? "▼" : "▶"))),
|
|
15
|
+
isExpanded && details && (React.createElement(Box, { flexDirection: "column", marginLeft: 2 },
|
|
16
|
+
React.createElement(Text, { color: theme.text.dim }, details)))));
|
|
17
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import Gradient from "ink-gradient";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { getBanner } from "../../utils/banner.js";
|
|
5
|
+
import { theme } from "../utils/theme.js";
|
|
6
|
+
export const Header = ({ version, currentFolder, gitBranch }) => {
|
|
7
|
+
const banner = getBanner();
|
|
8
|
+
// Build the info line: "v0.0.2 • ~/path/to/folder on branch"
|
|
9
|
+
const infoParts = [`v${version}`];
|
|
10
|
+
if (currentFolder) {
|
|
11
|
+
let locationStr = currentFolder;
|
|
12
|
+
if (gitBranch) {
|
|
13
|
+
locationStr += ` on ${gitBranch}`;
|
|
14
|
+
}
|
|
15
|
+
infoParts.push(locationStr);
|
|
16
|
+
}
|
|
17
|
+
const infoLine = infoParts.join(" • ");
|
|
18
|
+
return (React.createElement(Box, { alignItems: "center", flexDirection: "column", marginBottom: 1, marginTop: 5, width: "100%" },
|
|
19
|
+
React.createElement(Gradient, { colors: ["#C96868", "#FF8C94"] },
|
|
20
|
+
React.createElement(Text, null, banner)),
|
|
21
|
+
React.createElement(Box, { justifyContent: "center", marginTop: 0 },
|
|
22
|
+
React.createElement(Text, { color: theme.text.dim }, infoLine)),
|
|
23
|
+
React.createElement(Box, { flexDirection: "column", marginTop: 1, paddingX: 2, width: "100%" },
|
|
24
|
+
React.createElement(Box, { flexDirection: "column", marginBottom: 0 },
|
|
25
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
26
|
+
"\uD83D\uDCA1 ",
|
|
27
|
+
React.createElement(Text, { color: theme.text.secondary }, "Tip:"),
|
|
28
|
+
" Use ",
|
|
29
|
+
React.createElement(Text, { bold: true, color: theme.text.accent }, "@filename"),
|
|
30
|
+
" to reference files, or ",
|
|
31
|
+
React.createElement(Text, { bold: true, color: theme.text.accent }, "/help"),
|
|
32
|
+
" for commands")),
|
|
33
|
+
React.createElement(Box, { flexDirection: "column", marginTop: 0 },
|
|
34
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
35
|
+
"\u2328\uFE0F ",
|
|
36
|
+
React.createElement(Text, { color: theme.text.secondary }, "Shortcuts:"),
|
|
37
|
+
" ",
|
|
38
|
+
React.createElement(Text, { bold: true, color: theme.text.accent }, "Ctrl+H"),
|
|
39
|
+
" help, ",
|
|
40
|
+
React.createElement(Text, { bold: true, color: theme.text.accent }, "Ctrl+C"),
|
|
41
|
+
" exit, ",
|
|
42
|
+
React.createElement(Text, { bold: true, color: theme.text.accent }, "ESC"),
|
|
43
|
+
" interrupt")),
|
|
44
|
+
React.createElement(Box, { flexDirection: "column", marginTop: 0 },
|
|
45
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
46
|
+
"\uD83D\uDE80 ",
|
|
47
|
+
React.createElement(Text, { color: theme.text.secondary }, "Prompt Tips:"),
|
|
48
|
+
" Be explicit with instructions, provide context, use examples, and think step-by-step")))));
|
|
49
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help Menu Component
|
|
3
|
+
* Displays available commands, keyboard shortcuts, and usage tips
|
|
4
|
+
*/
|
|
5
|
+
import { Box, Text } from "ink";
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { theme } from "../utils/theme.js";
|
|
8
|
+
export const HelpMenu = ({ isAuthenticated }) => {
|
|
9
|
+
return (React.createElement(Box, { borderColor: theme.border.accent, borderStyle: "round", flexDirection: "column", marginBottom: 1, marginTop: 1, paddingX: 2, paddingY: 1 },
|
|
10
|
+
React.createElement(Text, { bold: true, color: theme.text.accent }, "\uD83D\uDCD6 Supatest AI CLI - Help"),
|
|
11
|
+
React.createElement(Box, { marginTop: 1 }),
|
|
12
|
+
React.createElement(Text, { bold: true, color: theme.text.secondary }, "Slash Commands:"),
|
|
13
|
+
React.createElement(Box, { flexDirection: "column", marginLeft: 2, marginTop: 0 },
|
|
14
|
+
React.createElement(Text, null,
|
|
15
|
+
React.createElement(Text, { color: theme.text.accent }, "/help"),
|
|
16
|
+
React.createElement(Text, { color: theme.text.dim }, " or "),
|
|
17
|
+
React.createElement(Text, { color: theme.text.accent }, "/?"),
|
|
18
|
+
React.createElement(Text, { color: theme.text.dim }, " - Toggle this help menu")),
|
|
19
|
+
React.createElement(Text, null,
|
|
20
|
+
React.createElement(Text, { color: theme.text.accent }, "/resume"),
|
|
21
|
+
React.createElement(Text, { color: theme.text.dim }, " - Resume a previous session")),
|
|
22
|
+
React.createElement(Text, null,
|
|
23
|
+
React.createElement(Text, { color: theme.text.accent }, "/clear"),
|
|
24
|
+
React.createElement(Text, { color: theme.text.dim }, " - Clear message history")),
|
|
25
|
+
React.createElement(Text, null,
|
|
26
|
+
React.createElement(Text, { color: theme.text.accent }, "/setup"),
|
|
27
|
+
React.createElement(Text, { color: theme.text.dim }, " - Initial setup for Supatest CLI")),
|
|
28
|
+
isAuthenticated ? (React.createElement(Text, null,
|
|
29
|
+
React.createElement(Text, { color: theme.text.accent }, "/logout"),
|
|
30
|
+
React.createElement(Text, { color: theme.text.dim }, " - Log out of Supatest"))) : (React.createElement(Text, null,
|
|
31
|
+
React.createElement(Text, { color: theme.text.accent }, "/login"),
|
|
32
|
+
React.createElement(Text, { color: theme.text.dim }, " - Authenticate with Supatest"))),
|
|
33
|
+
React.createElement(Text, null,
|
|
34
|
+
React.createElement(Text, { color: theme.text.accent }, "/exit"),
|
|
35
|
+
React.createElement(Text, { color: theme.text.dim }, " - Exit the CLI"))),
|
|
36
|
+
React.createElement(Box, { marginTop: 1 }),
|
|
37
|
+
React.createElement(Text, { bold: true, color: theme.text.secondary }, "Keyboard Shortcuts:"),
|
|
38
|
+
React.createElement(Box, { flexDirection: "column", marginLeft: 2, marginTop: 0 },
|
|
39
|
+
React.createElement(Text, null,
|
|
40
|
+
React.createElement(Text, { color: theme.text.accent }, "?"),
|
|
41
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
42
|
+
" ",
|
|
43
|
+
"- Toggle help (when input is empty)")),
|
|
44
|
+
React.createElement(Text, null,
|
|
45
|
+
React.createElement(Text, { color: theme.text.accent }, "Ctrl+H"),
|
|
46
|
+
React.createElement(Text, { color: theme.text.dim }, " - Toggle help")),
|
|
47
|
+
React.createElement(Text, null,
|
|
48
|
+
React.createElement(Text, { color: theme.text.accent }, "Ctrl+C"),
|
|
49
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
50
|
+
" ",
|
|
51
|
+
"- Exit (or clear input if not empty)")),
|
|
52
|
+
React.createElement(Text, null,
|
|
53
|
+
React.createElement(Text, { color: theme.text.accent }, "Ctrl+D"),
|
|
54
|
+
React.createElement(Text, { color: theme.text.dim }, " - Exit immediately")),
|
|
55
|
+
React.createElement(Text, null,
|
|
56
|
+
React.createElement(Text, { color: theme.text.accent }, "Ctrl+L"),
|
|
57
|
+
React.createElement(Text, { color: theme.text.dim }, " - Clear terminal screen")),
|
|
58
|
+
React.createElement(Text, null,
|
|
59
|
+
React.createElement(Text, { color: theme.text.accent }, "Ctrl+U"),
|
|
60
|
+
React.createElement(Text, { color: theme.text.dim }, " - Clear current input line")),
|
|
61
|
+
React.createElement(Text, null,
|
|
62
|
+
React.createElement(Text, { color: theme.text.accent }, "ESC"),
|
|
63
|
+
React.createElement(Text, { color: theme.text.dim }, " - Interrupt running agent")),
|
|
64
|
+
React.createElement(Text, null,
|
|
65
|
+
React.createElement(Text, { color: theme.text.accent }, "Shift+Up/Down"),
|
|
66
|
+
React.createElement(Text, { color: theme.text.dim }, " - Scroll through messages")),
|
|
67
|
+
React.createElement(Text, null,
|
|
68
|
+
React.createElement(Text, { color: theme.text.accent }, "Shift+Enter"),
|
|
69
|
+
React.createElement(Text, { color: theme.text.dim }, " - Add new line in input")),
|
|
70
|
+
React.createElement(Text, null,
|
|
71
|
+
React.createElement(Text, { color: theme.text.accent }, "ctrl+o"),
|
|
72
|
+
React.createElement(Text, { color: theme.text.dim }, " - Toggle tool outputs"))),
|
|
73
|
+
React.createElement(Box, { marginTop: 1 }),
|
|
74
|
+
React.createElement(Text, { bold: true, color: theme.text.secondary }, "File References:"),
|
|
75
|
+
React.createElement(Box, { flexDirection: "column", marginLeft: 2, marginTop: 0 },
|
|
76
|
+
React.createElement(Text, null,
|
|
77
|
+
React.createElement(Text, { color: theme.text.accent }, "@filename"),
|
|
78
|
+
React.createElement(Text, { color: theme.text.dim },
|
|
79
|
+
" ",
|
|
80
|
+
"- Reference a file (autocomplete with Tab)")),
|
|
81
|
+
React.createElement(Text, { color: theme.text.dim }, "Example: \"Fix the bug in @src/app.ts\"")),
|
|
82
|
+
React.createElement(Box, { marginTop: 1 }),
|
|
83
|
+
React.createElement(Text, { bold: true, color: theme.text.secondary }, "Tips:"),
|
|
84
|
+
React.createElement(Box, { flexDirection: "column", marginLeft: 2, marginTop: 0 },
|
|
85
|
+
React.createElement(Text, { color: theme.text.dim }, "\u2022 Press Enter to submit your task"),
|
|
86
|
+
React.createElement(Text, { color: theme.text.dim }, "\u2022 Use Shift+Enter to write multi-line prompts"),
|
|
87
|
+
React.createElement(Text, { color: theme.text.dim }, "\u2022 Drag and drop files into the terminal to add file paths"),
|
|
88
|
+
React.createElement(Text, { color: theme.text.dim }, "\u2022 The agent will automatically run tools and fix issues"))));
|
|
89
|
+
};
|
|
@@ -0,0 +1,292 @@
|
|
|
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, stats } = 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, justifyContent: "space-between" },
|
|
283
|
+
React.createElement(Box, null,
|
|
284
|
+
React.createElement(Text, { color: agentMode === "plan" ? theme.status.inProgress : theme.text.dim }, agentMode === "plan" ? "⏸ plan mode on" : "▶ build mode"),
|
|
285
|
+
React.createElement(Text, { color: theme.text.dim }, " (shift+tab to cycle)")),
|
|
286
|
+
React.createElement(Text, { color: theme.text.dim }, stats.totalTokens >= 1000000
|
|
287
|
+
? `${(stats.totalTokens / 1000000).toFixed(1)}M tokens`
|
|
288
|
+
: stats.totalTokens >= 1000
|
|
289
|
+
? `${(stats.totalTokens / 1000).toFixed(1)}k tokens`
|
|
290
|
+
: `${stats.totalTokens} tokens`))));
|
|
291
|
+
});
|
|
292
|
+
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", version: "0.0.2" }),
|
|
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
|
+
};
|