feat: 💄 Unified Terminal (#7383)
This commit is contained in:
555
gui/src/components/UnifiedTerminal/UnifiedTerminal.tsx
Normal file
555
gui/src/components/UnifiedTerminal/UnifiedTerminal.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
import Anser, { AnserJsonEntry } from "anser";
|
||||
import { ToolCallState } from "core";
|
||||
import { escapeCarriageReturn } from "escape-carriage";
|
||||
import { useMemo, useState } from "react";
|
||||
import styled, { keyframes } from "styled-components";
|
||||
import { useAppDispatch } from "../../redux/hooks";
|
||||
import { moveTerminalProcessToBackground } from "../../redux/thunks/moveTerminalProcessToBackground";
|
||||
import { getFontSize } from "../../util";
|
||||
import {
|
||||
defaultBorderRadius,
|
||||
vscBackground,
|
||||
vscEditorBackground,
|
||||
vscForeground,
|
||||
} from "../index";
|
||||
import { CopyButton } from "../StyledMarkdownPreview/StepContainerPreToolbar/CopyButton";
|
||||
import { RunInTerminalButton } from "../StyledMarkdownPreview/StepContainerPreToolbar/RunInTerminalButton";
|
||||
import { ButtonContent, SpoilerButton } from "../ui/SpoilerButton";
|
||||
|
||||
const blinkCursor = keyframes`
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
`;
|
||||
|
||||
const BlinkingCursor = styled.span`
|
||||
&::after {
|
||||
content: "█";
|
||||
animation: ${blinkCursor} 1s infinite;
|
||||
color: ${vscForeground};
|
||||
}
|
||||
`;
|
||||
|
||||
const AnsiSpan = styled.span<{
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
decoration?: string;
|
||||
}>`
|
||||
${({ bg }) => bg && `background-color: rgb(${bg});`}
|
||||
${({ fg }) => fg && `color: rgb(${fg});`}
|
||||
${({ decoration }) => {
|
||||
switch (decoration) {
|
||||
case "bold":
|
||||
return "font-weight: bold;";
|
||||
case "dim":
|
||||
return "opacity: 0.5;";
|
||||
case "italic":
|
||||
return "font-style: italic;";
|
||||
case "hidden":
|
||||
return "visibility: hidden;";
|
||||
case "strikethrough":
|
||||
return "text-decoration: line-through;";
|
||||
case "underline":
|
||||
return "text-decoration: underline;";
|
||||
case "blink":
|
||||
return "text-decoration: blink;";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
const AnsiLink = styled.a`
|
||||
color: var(--vscode-textLink-foreground, #3794ff);
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTerminalContainer = styled.div<{
|
||||
fontSize?: number;
|
||||
bgColor: string;
|
||||
}>`
|
||||
background-color: ${(props) => props.bgColor};
|
||||
font-family:
|
||||
var(--vscode-font-family),
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: ${(props) => props.fontSize || getFontSize()}px;
|
||||
color: ${vscForeground};
|
||||
line-height: 1.5;
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const TerminalContent = styled.div`
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
background-color: ${vscEditorBackground};
|
||||
border-radius: ${defaultBorderRadius};
|
||||
border: 1px solid
|
||||
var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3));
|
||||
max-width: calc(100vw - 24px);
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
span.line:empty {
|
||||
display: none;
|
||||
}
|
||||
word-wrap: break-word;
|
||||
border-radius: ${defaultBorderRadius};
|
||||
background-color: ${vscEditorBackground};
|
||||
font-size: ${getFontSize() - 2}px;
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
}
|
||||
|
||||
code:not(pre > code) {
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
`;
|
||||
|
||||
const ToolbarContainer = styled.div<{ isExpanded: boolean }>`
|
||||
font-size: ${getFontSize() - 2}px;
|
||||
`;
|
||||
|
||||
const CommandLine = styled.div`
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--vscode-terminal-ansiGreen, #0dbc79);
|
||||
`;
|
||||
|
||||
function ansiToJSON(
|
||||
input: string,
|
||||
use_classes: boolean = false,
|
||||
): AnserJsonEntry[] {
|
||||
input = escapeCarriageReturn(fixBackspace(input));
|
||||
return Anser.ansiToJson(input, {
|
||||
json: true,
|
||||
remove_empty: true,
|
||||
use_classes,
|
||||
});
|
||||
}
|
||||
|
||||
function createClass(bundle: AnserJsonEntry): string | null {
|
||||
let classNames: string = "";
|
||||
|
||||
if (bundle.bg) {
|
||||
classNames += `${bundle.bg}-bg `;
|
||||
}
|
||||
if (bundle.fg) {
|
||||
classNames += `${bundle.fg}-fg `;
|
||||
}
|
||||
if (bundle.decoration) {
|
||||
classNames += `ansi-${bundle.decoration} `;
|
||||
}
|
||||
|
||||
if (classNames === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
classNames = classNames.substring(0, classNames.length - 1);
|
||||
return classNames;
|
||||
}
|
||||
|
||||
function convertBundleIntoReact(
|
||||
linkify: boolean,
|
||||
useClasses: boolean,
|
||||
bundle: AnserJsonEntry,
|
||||
key: number,
|
||||
): JSX.Element {
|
||||
const className = useClasses ? createClass(bundle) : null;
|
||||
const decorationProp = bundle.decoration
|
||||
? String(bundle.decoration)
|
||||
: undefined;
|
||||
|
||||
if (!linkify) {
|
||||
return (
|
||||
<AnsiSpan
|
||||
key={key}
|
||||
className={className || undefined}
|
||||
bg={useClasses ? undefined : bundle.bg}
|
||||
fg={useClasses ? undefined : bundle.fg}
|
||||
decoration={decorationProp}
|
||||
>
|
||||
{bundle.content}
|
||||
</AnsiSpan>
|
||||
);
|
||||
}
|
||||
|
||||
const content: React.ReactNode[] = [];
|
||||
const linkRegex =
|
||||
/(\s|^)(https?:\/\/(?:www\.|(?!www))[^\s.]+\.[^\s]{2,}|www\.[^\s]+\.[^\s]{2,})/g;
|
||||
|
||||
let index = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = linkRegex.exec(bundle.content)) !== null) {
|
||||
const [, pre, url] = match;
|
||||
|
||||
const startIndex = match.index + pre.length;
|
||||
if (startIndex > index) {
|
||||
content.push(bundle.content.substring(index, startIndex));
|
||||
}
|
||||
|
||||
const href = url.startsWith("www.") ? `http://${url}` : url;
|
||||
|
||||
content.push(
|
||||
<AnsiLink key={index} href={href} target="_blank">
|
||||
{url}
|
||||
</AnsiLink>,
|
||||
);
|
||||
|
||||
index = linkRegex.lastIndex;
|
||||
}
|
||||
|
||||
if (index < bundle.content.length) {
|
||||
content.push(bundle.content.substring(index));
|
||||
}
|
||||
|
||||
return (
|
||||
<AnsiSpan
|
||||
key={key}
|
||||
className={className || undefined}
|
||||
bg={useClasses ? undefined : bundle.bg}
|
||||
fg={useClasses ? undefined : bundle.fg}
|
||||
decoration={decorationProp}
|
||||
>
|
||||
{content}
|
||||
</AnsiSpan>
|
||||
);
|
||||
}
|
||||
|
||||
function fixBackspace(txt: string) {
|
||||
let tmp = txt;
|
||||
do {
|
||||
txt = tmp;
|
||||
tmp = txt.replace(/[^\n]\x08/gm, "");
|
||||
} while (tmp.length < txt.length);
|
||||
return txt;
|
||||
}
|
||||
|
||||
function AnsiRenderer({
|
||||
children,
|
||||
linkify = false,
|
||||
}: {
|
||||
children?: string;
|
||||
linkify?: boolean;
|
||||
}) {
|
||||
const ansiContent = ansiToJSON(children ?? "", false).map((bundle, i) =>
|
||||
convertBundleIntoReact(linkify, false, bundle, i),
|
||||
);
|
||||
|
||||
return <>{ansiContent}</>;
|
||||
}
|
||||
|
||||
interface StatusIconProps {
|
||||
status: "running" | "completed" | "failed" | "background";
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: StatusIconProps) {
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-success";
|
||||
case "completed":
|
||||
return "bg-success";
|
||||
case "background":
|
||||
return "bg-accent";
|
||||
case "failed":
|
||||
return "bg-error";
|
||||
default:
|
||||
return "bg-success";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`mr-2 h-2 w-2 rounded-full ${getStatusColor()} ${
|
||||
status === "running" ? "animate-pulse" : ""
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface IndicatorOnlyProps {
|
||||
hiddenLinesCount: number;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function IndicatorOnly({
|
||||
hiddenLinesCount,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: IndicatorOnlyProps) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<SpoilerButton onClick={onToggle}>
|
||||
<ButtonContent>
|
||||
<span>
|
||||
{isExpanded ? "Collapse" : `+${hiddenLinesCount} more lines`}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={`h-3 w-3 ${isExpanded ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</ButtonContent>
|
||||
</SpoilerButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CollapsibleOutputContainerProps {
|
||||
limitedContent: string;
|
||||
fullContent: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function CollapsibleOutputContainer({
|
||||
limitedContent,
|
||||
fullContent,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: CollapsibleOutputContainerProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Gradient overlay when collapsed */}
|
||||
{!isExpanded && (
|
||||
<div className="from-editor pointer-events-none absolute left-0 right-0 top-0 z-[5] h-[100px] rounded-t-md bg-gradient-to-b to-transparent" />
|
||||
)}
|
||||
|
||||
<div onClick={onToggle} className="cursor-pointer">
|
||||
<div>
|
||||
<AnsiRenderer linkify>
|
||||
{isExpanded ? fullContent : limitedContent}
|
||||
</AnsiRenderer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UnifiedTerminalCommandProps {
|
||||
command: string;
|
||||
output?: string;
|
||||
status?: "running" | "completed" | "failed" | "background";
|
||||
statusMessage?: string;
|
||||
toolCallState?: ToolCallState;
|
||||
toolCallId?: string;
|
||||
displayLines?: number;
|
||||
}
|
||||
|
||||
export function UnifiedTerminalCommand({
|
||||
command,
|
||||
output = "",
|
||||
status = "completed",
|
||||
statusMessage = "",
|
||||
toolCallState,
|
||||
toolCallId,
|
||||
displayLines = 15,
|
||||
}: UnifiedTerminalCommandProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [outputExpanded, setOutputExpanded] = useState(false);
|
||||
|
||||
// Determine running state
|
||||
const isRunning = toolCallState?.status === "calling" || status === "running";
|
||||
const hasOutput = output.length > 0;
|
||||
|
||||
// Process terminal content for line limiting
|
||||
const processedTerminalContent = useMemo(() => {
|
||||
if (!output) {
|
||||
return {
|
||||
fullContent: "",
|
||||
limitedContent: "",
|
||||
totalLines: 0,
|
||||
isLimited: false,
|
||||
hiddenLinesCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const lines = output.split("\n");
|
||||
const totalLines = lines.length;
|
||||
|
||||
if (totalLines > displayLines) {
|
||||
const lastLines = lines.slice(-displayLines);
|
||||
return {
|
||||
fullContent: output,
|
||||
limitedContent: lastLines.join("\n"),
|
||||
totalLines,
|
||||
isLimited: true,
|
||||
hiddenLinesCount: totalLines - displayLines,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fullContent: output,
|
||||
limitedContent: output,
|
||||
totalLines,
|
||||
isLimited: false,
|
||||
hiddenLinesCount: 0,
|
||||
};
|
||||
}, [output, displayLines]);
|
||||
|
||||
// Determine status type
|
||||
let statusType: "running" | "completed" | "failed" | "background" = status;
|
||||
if (isRunning) {
|
||||
statusType = "running";
|
||||
} else if (statusMessage?.includes("failed")) {
|
||||
statusType = "failed";
|
||||
} else if (statusMessage?.includes("background")) {
|
||||
statusType = "background";
|
||||
}
|
||||
|
||||
const handleMoveToBackground = () => {
|
||||
if (toolCallId) {
|
||||
void dispatch(
|
||||
moveTerminalProcessToBackground({
|
||||
toolCallId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Create combined content for copying (command + output)
|
||||
const copyContent = useMemo(() => {
|
||||
let content = `$ ${command}`;
|
||||
if (hasOutput) {
|
||||
content += `\n\n${output}`;
|
||||
}
|
||||
return content;
|
||||
}, [command, output, hasOutput]);
|
||||
|
||||
return (
|
||||
<StyledTerminalContainer
|
||||
fontSize={getFontSize()}
|
||||
bgColor={vscBackground}
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="outline-command-border -outline-offset-0.5 rounded-default bg-editor !my-2 flex min-w-0 flex-col outline outline-1">
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className={`find-widget-skip bg-editor sticky -top-2 z-10 m-0 flex items-center justify-between gap-3 px-1.5 py-1 ${
|
||||
isExpanded
|
||||
? "rounded-t-default border-command-border border-b"
|
||||
: "rounded-default"
|
||||
}`}
|
||||
style={{ fontSize: `${getFontSize() - 2}px` }}
|
||||
>
|
||||
<div className="flex max-w-[50%] flex-row items-center">
|
||||
<ChevronDownIcon
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={`text-lightgray h-3.5 w-3.5 flex-shrink-0 cursor-pointer hover:brightness-125 ${
|
||||
isExpanded ? "rotate-0" : "-rotate-90"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-lightgray ml-2 select-none">Terminal</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
{!isRunning && (
|
||||
<div className="xs:flex hidden items-center gap-2.5">
|
||||
<CopyButton text={copyContent} />
|
||||
<RunInTerminalButton command={command} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<TerminalContent>
|
||||
<pre>
|
||||
<code>
|
||||
{/* Command is always visible */}
|
||||
<div
|
||||
className="pb-2"
|
||||
style={{ color: "var(--vscode-terminal-ansiGreen, #0DBC79)" }}
|
||||
>
|
||||
$ {command}
|
||||
</div>
|
||||
|
||||
{/* Running state with cursor */}
|
||||
{isRunning && !hasOutput && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<BlinkingCursor />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output with optional collapsible functionality */}
|
||||
{hasOutput && (
|
||||
<div className="mt-1">
|
||||
{/* Expand/Collapse indicator positioned between command and output */}
|
||||
{processedTerminalContent.isLimited && (
|
||||
<IndicatorOnly
|
||||
hiddenLinesCount={
|
||||
processedTerminalContent.hiddenLinesCount
|
||||
}
|
||||
isExpanded={outputExpanded}
|
||||
onToggle={() => setOutputExpanded(!outputExpanded)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
{processedTerminalContent.isLimited ? (
|
||||
<CollapsibleOutputContainer
|
||||
limitedContent={
|
||||
processedTerminalContent.limitedContent
|
||||
}
|
||||
fullContent={processedTerminalContent.fullContent}
|
||||
isExpanded={outputExpanded}
|
||||
onToggle={() => setOutputExpanded(!outputExpanded)}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<AnsiRenderer linkify>
|
||||
{processedTerminalContent.fullContent}
|
||||
</AnsiRenderer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
</TerminalContent>
|
||||
)}
|
||||
|
||||
{/* Status information */}
|
||||
{(statusMessage || isRunning) && (
|
||||
<div className="text-description mt-2 flex items-center px-2 pb-2 text-xs">
|
||||
<StatusIcon status={statusType} />
|
||||
{isRunning ? "Running" : statusMessage}
|
||||
{isRunning && toolCallId && (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleMoveToBackground();
|
||||
}}
|
||||
className="text-link ml-3 cursor-pointer text-xs no-underline hover:underline"
|
||||
>
|
||||
Move to background
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledTerminalContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import Anser, { AnserJsonEntry } from "anser";
|
||||
import { escapeCarriageReturn } from "escape-carriage";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
defaultBorderRadius,
|
||||
vscBackground,
|
||||
vscEditorBackground,
|
||||
vscForeground,
|
||||
} from "../../components";
|
||||
import { getFontSize } from "../../util";
|
||||
|
||||
const AnsiSpan = styled.span<{
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
decoration?: string;
|
||||
}>`
|
||||
${({ bg }) => bg && `background-color: rgb(${bg});`}
|
||||
${({ fg }) => fg && `color: rgb(${fg});`}
|
||||
${({ decoration }) => {
|
||||
switch (decoration) {
|
||||
case "bold":
|
||||
return "font-weight: bold;";
|
||||
case "dim":
|
||||
return "opacity: 0.5;";
|
||||
case "italic":
|
||||
return "font-style: italic;";
|
||||
case "hidden":
|
||||
return "visibility: hidden;";
|
||||
case "strikethrough":
|
||||
return "text-decoration: line-through;";
|
||||
case "underline":
|
||||
return "text-decoration: underline;";
|
||||
case "blink":
|
||||
return "text-decoration: blink;";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
const AnsiLink = styled.a`
|
||||
color: var(--vscode-textLink-foreground, #3794ff);
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
// Using the same styled component structure as StyledMarkdown
|
||||
const StyledAnsi = styled.div<{
|
||||
fontSize?: number;
|
||||
whiteSpace: string;
|
||||
bgColor: string;
|
||||
}>`
|
||||
pre {
|
||||
white-space: ${(props) => props.whiteSpace};
|
||||
background-color: ${vscEditorBackground};
|
||||
border-radius: ${defaultBorderRadius};
|
||||
border: 1px solid
|
||||
var(--vscode-editorWidget-border, rgba(127, 127, 127, 0.3));
|
||||
max-width: calc(100vw - 24px);
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
code {
|
||||
span.line:empty {
|
||||
display: none;
|
||||
}
|
||||
word-wrap: break-word;
|
||||
border-radius: ${defaultBorderRadius};
|
||||
background-color: ${vscEditorBackground};
|
||||
font-size: ${getFontSize() - 2}px;
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
}
|
||||
|
||||
code:not(pre > code) {
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
|
||||
background-color: ${(props) => props.bgColor};
|
||||
font-family:
|
||||
var(--vscode-font-family),
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Open Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: ${(props) => props.fontSize || getFontSize()}px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
color: ${vscForeground};
|
||||
line-height: 1.5;
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Converts ANSI strings into JSON output.
|
||||
* @name ansiToJSON
|
||||
* @function
|
||||
* @param {String} input The input string.
|
||||
* @param {boolean} use_classes If `true`, HTML classes will be appended
|
||||
* to the HTML output.
|
||||
* @return {Array} The parsed input.
|
||||
*/
|
||||
function ansiToJSON(
|
||||
input: string,
|
||||
use_classes: boolean = false,
|
||||
): AnserJsonEntry[] {
|
||||
input = escapeCarriageReturn(fixBackspace(input));
|
||||
return Anser.ansiToJson(input, {
|
||||
json: true,
|
||||
remove_empty: true,
|
||||
use_classes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a class string.
|
||||
* @name createClass
|
||||
* @function
|
||||
* @param {AnserJsonEntry} bundle
|
||||
* @return {String} class name(s)
|
||||
*/
|
||||
function createClass(bundle: AnserJsonEntry): string | null {
|
||||
let classNames: string = "";
|
||||
|
||||
if (bundle.bg) {
|
||||
classNames += `${bundle.bg}-bg `;
|
||||
}
|
||||
if (bundle.fg) {
|
||||
classNames += `${bundle.fg}-fg `;
|
||||
}
|
||||
if (bundle.decoration) {
|
||||
classNames += `ansi-${bundle.decoration} `;
|
||||
}
|
||||
|
||||
if (classNames === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
classNames = classNames.substring(0, classNames.length - 1);
|
||||
return classNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an Anser bundle into a React Node.
|
||||
* @param linkify whether links should be converting into clickable anchor tags.
|
||||
* @param useClasses should render the span with a class instead of style.
|
||||
* @param bundle Anser output.
|
||||
* @param key
|
||||
*/
|
||||
|
||||
function convertBundleIntoReact(
|
||||
linkify: boolean,
|
||||
useClasses: boolean,
|
||||
bundle: AnserJsonEntry,
|
||||
key: number,
|
||||
): JSX.Element {
|
||||
const className = useClasses ? createClass(bundle) : null;
|
||||
// Convert bundle.decoration to string or undefined (not null) to match the prop type
|
||||
const decorationProp = bundle.decoration
|
||||
? String(bundle.decoration)
|
||||
: undefined;
|
||||
|
||||
if (!linkify) {
|
||||
return (
|
||||
<AnsiSpan
|
||||
key={key}
|
||||
className={className || undefined}
|
||||
bg={useClasses ? undefined : bundle.bg}
|
||||
fg={useClasses ? undefined : bundle.fg}
|
||||
decoration={decorationProp}
|
||||
>
|
||||
{bundle.content}
|
||||
</AnsiSpan>
|
||||
);
|
||||
}
|
||||
|
||||
const content: React.ReactNode[] = [];
|
||||
const linkRegex =
|
||||
/(\s|^)(https?:\/\/(?:www\.|(?!www))[^\s.]+\.[^\s]{2,}|www\.[^\s]+\.[^\s]{2,})/g;
|
||||
|
||||
let index = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = linkRegex.exec(bundle.content)) !== null) {
|
||||
const [, pre, url] = match;
|
||||
|
||||
const startIndex = match.index + pre.length;
|
||||
if (startIndex > index) {
|
||||
content.push(bundle.content.substring(index, startIndex));
|
||||
}
|
||||
|
||||
// Make sure the href we generate from the link is fully qualified. We assume http
|
||||
// if it starts with a www because many sites don't support https
|
||||
const href = url.startsWith("www.") ? `http://${url}` : url;
|
||||
|
||||
content.push(
|
||||
<AnsiLink key={index} href={href} target="_blank">
|
||||
{url}
|
||||
</AnsiLink>,
|
||||
);
|
||||
|
||||
index = linkRegex.lastIndex;
|
||||
}
|
||||
|
||||
if (index < bundle.content.length) {
|
||||
content.push(bundle.content.substring(index));
|
||||
}
|
||||
|
||||
return (
|
||||
<AnsiSpan
|
||||
key={key}
|
||||
className={className || undefined}
|
||||
bg={useClasses ? undefined : bundle.bg}
|
||||
fg={useClasses ? undefined : bundle.fg}
|
||||
decoration={decorationProp}
|
||||
>
|
||||
{content}
|
||||
</AnsiSpan>
|
||||
);
|
||||
}
|
||||
|
||||
declare interface Props {
|
||||
children?: string;
|
||||
linkify?: boolean;
|
||||
className?: string;
|
||||
useClasses?: boolean;
|
||||
}
|
||||
|
||||
export default function Ansi(props: Props): JSX.Element {
|
||||
const { className, useClasses, children, linkify } = props;
|
||||
|
||||
// Create the ANSI content
|
||||
const ansiContent = ansiToJSON(children ?? "", useClasses ?? false).map(
|
||||
(bundle, i) =>
|
||||
convertBundleIntoReact(linkify ?? false, useClasses ?? false, bundle, i),
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledAnsi
|
||||
contentEditable="false"
|
||||
fontSize={getFontSize()}
|
||||
whiteSpace="pre-wrap"
|
||||
bgColor={vscBackground}
|
||||
>
|
||||
<pre>
|
||||
<code className={className}>{ansiContent}</code>
|
||||
</pre>
|
||||
</StyledAnsi>
|
||||
);
|
||||
}
|
||||
|
||||
// This is copied from the Jupyter Classic source code
|
||||
// notebook/static/base/js/utils.js to handle \b in a way
|
||||
// that is **compatible with Jupyter classic**. One can
|
||||
// argue that this behavior is questionable:
|
||||
// https://stackoverflow.com/questions/55440152/multiple-b-doesnt-work-as-expected-in-jupyter#
|
||||
function fixBackspace(txt: string) {
|
||||
let tmp = txt;
|
||||
do {
|
||||
txt = tmp;
|
||||
// Cancel out anything-but-newline followed by backspace
|
||||
tmp = txt.replace(/[^\n]\x08/gm, "");
|
||||
} while (tmp.length < txt.length);
|
||||
return txt;
|
||||
}
|
||||
@@ -5,38 +5,10 @@ import { ChatHistoryItem } from "core";
|
||||
import { useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { defaultBorderRadius, lightGray, vscBackground } from "../..";
|
||||
import { getFontSize } from "../../../util";
|
||||
import { vscBackground } from "../..";
|
||||
import { AnimatedEllipsis } from "../../AnimatedEllipsis";
|
||||
import StyledMarkdownPreview from "../../StyledMarkdownPreview";
|
||||
|
||||
const SpoilerButton = styled.div`
|
||||
background-color: ${vscBackground};
|
||||
width: fit-content;
|
||||
margin: 8px 6px 0px 2px;
|
||||
font-size: ${getFontSize() - 2}px;
|
||||
border: 0.5px solid ${lightGray};
|
||||
border-radius: ${defaultBorderRadius};
|
||||
padding: 4px 8px;
|
||||
color: ${lightGray};
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.1),
|
||||
0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
0 6px 8px rgba(0, 0, 0, 0.15),
|
||||
0 3px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
const ButtonContent = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`;
|
||||
import { ButtonContent, SpoilerButton } from "../../ui/SpoilerButton";
|
||||
|
||||
const ThinkingTextContainer = styled.span`
|
||||
display: inline-block;
|
||||
|
||||
31
gui/src/components/ui/SpoilerButton.tsx
Normal file
31
gui/src/components/ui/SpoilerButton.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import styled from "styled-components";
|
||||
import { defaultBorderRadius, lightGray, vscBackground } from "../index";
|
||||
import { getFontSize } from "../../util";
|
||||
|
||||
export const SpoilerButton = styled.div`
|
||||
background-color: ${vscBackground};
|
||||
width: fit-content;
|
||||
margin: 8px 6px 0px 2px;
|
||||
font-size: ${getFontSize() - 2}px;
|
||||
border: 0.5px solid ${lightGray};
|
||||
border-radius: ${defaultBorderRadius};
|
||||
padding: 4px 8px;
|
||||
color: ${lightGray};
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.1),
|
||||
0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
0 6px 8px rgba(0, 0, 0, 0.15),
|
||||
0 3px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
export const ButtonContent = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
`;
|
||||
@@ -1,10 +1,5 @@
|
||||
import { ToolCallState } from "core";
|
||||
import { useMemo } from "react";
|
||||
import Ansi from "../../../components/ansiTerminal/Ansi";
|
||||
import StyledMarkdownPreview from "../../../components/StyledMarkdownPreview";
|
||||
import { useAppDispatch } from "../../../redux/hooks";
|
||||
import { moveTerminalProcessToBackground } from "../../../redux/thunks/moveTerminalProcessToBackground";
|
||||
import { TerminalCollapsibleContainer } from "./TerminalCollapsibleContainer";
|
||||
import { UnifiedTerminalCommand } from "../../../components/UnifiedTerminal/UnifiedTerminal";
|
||||
|
||||
interface RunTerminalCommandToolCallProps {
|
||||
command: string;
|
||||
@@ -12,38 +7,7 @@ interface RunTerminalCommandToolCallProps {
|
||||
toolCallId: string | undefined;
|
||||
}
|
||||
|
||||
interface StatusIconProps {
|
||||
status: "running" | "completed" | "failed" | "background";
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: StatusIconProps) {
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "bg-success";
|
||||
case "completed":
|
||||
return "bg-success";
|
||||
case "background":
|
||||
return "bg-accent";
|
||||
case "failed":
|
||||
return "bg-error";
|
||||
default:
|
||||
return "bg-success";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`mr-2 h-2 w-2 rounded-full ${getStatusColor()} ${
|
||||
status === "running" ? "animate-pulse" : ""
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunTerminalCommand(props: RunTerminalCommandToolCallProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Find the terminal output from context items if available
|
||||
const terminalItem = props.toolCallState.output?.find(
|
||||
(item) => item.name === "Terminal",
|
||||
@@ -52,43 +16,6 @@ export function RunTerminalCommand(props: RunTerminalCommandToolCallProps) {
|
||||
const terminalContent = terminalItem?.content || "";
|
||||
const statusMessage = terminalItem?.status || "";
|
||||
const isRunning = props.toolCallState.status === "calling";
|
||||
const hasOutput = terminalContent.length > 0;
|
||||
|
||||
const displayLines = 15;
|
||||
|
||||
// Process terminal content for line limiting
|
||||
const processedTerminalContent = useMemo(() => {
|
||||
if (!terminalContent)
|
||||
return {
|
||||
fullContent: "",
|
||||
limitedContent: "",
|
||||
totalLines: 0,
|
||||
isLimited: false,
|
||||
hiddenLinesCount: 0,
|
||||
};
|
||||
|
||||
const lines = terminalContent.split("\n");
|
||||
const totalLines = lines.length;
|
||||
|
||||
if (totalLines > displayLines) {
|
||||
const lastTwentyLines = lines.slice(-displayLines);
|
||||
return {
|
||||
fullContent: terminalContent,
|
||||
limitedContent: lastTwentyLines.join("\n"),
|
||||
totalLines,
|
||||
isLimited: true,
|
||||
hiddenLinesCount: totalLines - displayLines,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fullContent: terminalContent,
|
||||
limitedContent: terminalContent,
|
||||
totalLines,
|
||||
isLimited: false,
|
||||
hiddenLinesCount: 0,
|
||||
};
|
||||
}, [terminalContent]);
|
||||
|
||||
// Determine status type
|
||||
let statusType: "running" | "completed" | "failed" | "background" =
|
||||
@@ -102,55 +29,13 @@ export function RunTerminalCommand(props: RunTerminalCommandToolCallProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{/* Command */}
|
||||
<StyledMarkdownPreview
|
||||
isRenderingInStepContainer
|
||||
source={`\`\`\`bash .sh\n$ ${props.command ?? ""}\n\`\`\``}
|
||||
/>
|
||||
|
||||
{/* Terminal output with ANSI support */}
|
||||
{isRunning && !hasOutput && (
|
||||
<div className="mt-2 px-4 py-2">Waiting for output...</div>
|
||||
)}
|
||||
{hasOutput && (
|
||||
<TerminalCollapsibleContainer
|
||||
collapsible={processedTerminalContent.isLimited}
|
||||
hiddenLinesCount={processedTerminalContent.hiddenLinesCount}
|
||||
className={
|
||||
processedTerminalContent.hiddenLinesCount === 0 ? "-mt-3" : "-mt-5"
|
||||
}
|
||||
collapsedContent={
|
||||
<Ansi>{processedTerminalContent.limitedContent}</Ansi>
|
||||
}
|
||||
expandedContent={<Ansi>{processedTerminalContent.fullContent}</Ansi>}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status information */}
|
||||
{(statusMessage || isRunning) && (
|
||||
<div className="text-description mt-2 flex items-center px-2 text-xs">
|
||||
<StatusIcon status={statusType} />
|
||||
{isRunning ? "Running" : statusMessage}
|
||||
{isRunning && props.toolCallId && (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Dispatch the action to move the command to the background
|
||||
void dispatch(
|
||||
moveTerminalProcessToBackground({
|
||||
toolCallId: props.toolCallId as string,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
className="text-link ml-3 cursor-pointer text-xs no-underline hover:underline"
|
||||
>
|
||||
Move to background
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<UnifiedTerminalCommand
|
||||
command={props.command}
|
||||
output={terminalContent}
|
||||
status={statusType}
|
||||
statusMessage={statusMessage}
|
||||
toolCallState={props.toolCallState}
|
||||
toolCallId={props.toolCallId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user