diff --git a/gui/src/components/UnifiedTerminal/UnifiedTerminal.tsx b/gui/src/components/UnifiedTerminal/UnifiedTerminal.tsx new file mode 100644 index 000000000..00e34114b --- /dev/null +++ b/gui/src/components/UnifiedTerminal/UnifiedTerminal.tsx @@ -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 ( + + {bundle.content} + + ); + } + + 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( + + {url} + , + ); + + index = linkRegex.lastIndex; + } + + if (index < bundle.content.length) { + content.push(bundle.content.substring(index)); + } + + return ( + + {content} + + ); +} + +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 ( + + ); +} + +interface IndicatorOnlyProps { + hiddenLinesCount: number; + isExpanded: boolean; + onToggle: () => void; +} + +function IndicatorOnly({ + hiddenLinesCount, + isExpanded, + onToggle, +}: IndicatorOnlyProps) { + return ( +
+ + + + {isExpanded ? "Collapse" : `+${hiddenLinesCount} more lines`} + + + + +
+ ); +} + +interface CollapsibleOutputContainerProps { + limitedContent: string; + fullContent: string; + isExpanded: boolean; + onToggle: () => void; +} + +function CollapsibleOutputContainer({ + limitedContent, + fullContent, + isExpanded, + onToggle, +}: CollapsibleOutputContainerProps) { + return ( +
+ {/* Gradient overlay when collapsed */} + {!isExpanded && ( +
+ )} + +
+
+ + {isExpanded ? fullContent : limitedContent} + +
+
+
+ ); +} + +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 ( + +
+ {/* Toolbar */} +
+
+ setIsExpanded(!isExpanded)} + className={`text-lightgray h-3.5 w-3.5 flex-shrink-0 cursor-pointer hover:brightness-125 ${ + isExpanded ? "rotate-0" : "-rotate-90" + }`} + /> + Terminal +
+ +
+ {!isRunning && ( +
+ + +
+ )} +
+
+ + {/* Content */} + {isExpanded && ( + +
+              
+                {/* Command is always visible */}
+                
+ $ {command} +
+ + {/* Running state with cursor */} + {isRunning && !hasOutput && ( +
+ +
+ )} + + {/* Output with optional collapsible functionality */} + {hasOutput && ( +
+ {/* Expand/Collapse indicator positioned between command and output */} + {processedTerminalContent.isLimited && ( + setOutputExpanded(!outputExpanded)} + /> + )} + +
+ {processedTerminalContent.isLimited ? ( + setOutputExpanded(!outputExpanded)} + /> + ) : ( +
+ + {processedTerminalContent.fullContent} + +
+ )} +
+
+ )} +
+
+
+ )} + + {/* Status information */} + {(statusMessage || isRunning) && ( +
+ + {isRunning ? "Running" : statusMessage} + {isRunning && toolCallId && ( + { + e.preventDefault(); + handleMoveToBackground(); + }} + className="text-link ml-3 cursor-pointer text-xs no-underline hover:underline" + > + Move to background + + )} +
+ )} +
+
+ ); +} diff --git a/gui/src/components/ansiTerminal/Ansi.tsx b/gui/src/components/ansiTerminal/Ansi.tsx deleted file mode 100644 index 35d4b0a43..000000000 --- a/gui/src/components/ansiTerminal/Ansi.tsx +++ /dev/null @@ -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 ( - - {bundle.content} - - ); - } - - 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( - - {url} - , - ); - - index = linkRegex.lastIndex; - } - - if (index < bundle.content.length) { - content.push(bundle.content.substring(index)); - } - - return ( - - {content} - - ); -} - -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 ( - -
-        {ansiContent}
-      
-
- ); -} - -// 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; -} diff --git a/gui/src/components/mainInput/belowMainInput/ThinkingBlockPeek.tsx b/gui/src/components/mainInput/belowMainInput/ThinkingBlockPeek.tsx index ba423bbc4..3dea83d0e 100644 --- a/gui/src/components/mainInput/belowMainInput/ThinkingBlockPeek.tsx +++ b/gui/src/components/mainInput/belowMainInput/ThinkingBlockPeek.tsx @@ -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; diff --git a/gui/src/components/ui/SpoilerButton.tsx b/gui/src/components/ui/SpoilerButton.tsx new file mode 100644 index 000000000..66b74bca9 --- /dev/null +++ b/gui/src/components/ui/SpoilerButton.tsx @@ -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; +`; diff --git a/gui/src/pages/gui/ToolCallDiv/RunTerminalCommand.tsx b/gui/src/pages/gui/ToolCallDiv/RunTerminalCommand.tsx index dfdcfb5ca..5975e4db9 100644 --- a/gui/src/pages/gui/ToolCallDiv/RunTerminalCommand.tsx +++ b/gui/src/pages/gui/ToolCallDiv/RunTerminalCommand.tsx @@ -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 ( - - ); -} - 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 ( -
- {/* Command */} - - - {/* Terminal output with ANSI support */} - {isRunning && !hasOutput && ( -
Waiting for output...
- )} - {hasOutput && ( - {processedTerminalContent.limitedContent} - } - expandedContent={{processedTerminalContent.fullContent}} - /> - )} - - {/* Status information */} - {(statusMessage || isRunning) && ( - - )} -
+ ); }