feat: 💄 Unified Terminal (#7383)

This commit is contained in:
Shawn Smith
2025-08-25 09:58:51 -07:00
committed by GitHub
parent 41873f41d7
commit 4e82c59c03
5 changed files with 597 additions and 433 deletions

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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;

View 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;
`;

View File

@@ -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}
/>
);
}