Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
cd9fe36
🤖 feat: add background bash process execution with SSH support
ethanndickson Dec 1, 2025
97ac438
feat: enable background bash execution on Windows
ethanndickson Dec 5, 2025
cc16389
fix: SSH background spawn env vars and tilde path expansion
ethanndickson Dec 5, 2025
9db7d21
refactor: remove nullable handle from BackgroundProcess
ethanndickson Dec 5, 2025
b665cfb
fix: use polling instead of fixed delays in background process tests
ethanndickson Dec 5, 2025
14491fc
fix: revert double-quoting of SSH background spawn paths
ethanndickson Dec 5, 2025
e277809
fix: use platform temp dir for background process output
ethanndickson Dec 5, 2025
8e55187
fix: support tilde paths in SSH bgOutputDir
ethanndickson Dec 5, 2025
a12c8ed
fix: use bash shell for POSIX commands on Windows
ethanndickson Dec 5, 2025
798ced5
fix: quote bash path and validate SSH cwd in spawnBackground
ethanndickson Dec 5, 2025
656c68d
docs: clarify in-memory process tracking scope
ethanndickson Dec 5, 2025
2bb296b
fix: expand tilde in SSH terminate exitCodePath
ethanndickson Dec 5, 2025
2f3cbb1
fix: convert Windows paths to POSIX for Git Bash commands
ethanndickson Dec 5, 2025
352ded9
fix: Windows path handling and MSYS2 process termination
ethanndickson Dec 5, 2025
4b9128f
fix: add quotePath to buildTerminateCommand for SSH tilde support
ethanndickson Dec 5, 2025
a3b971b
🤖 refactor: simplify SSHRuntime tilde resolution to single cached method
ethanndickson Dec 5, 2025
f0575a7
🤖 feat: add display_name field for background processes
ethanndickson Dec 5, 2025
2c09936
🤖 fix: use PGID for Windows MSYS2 process termination (no /proc on ma…
ethanndickson Dec 8, 2025
6d815be
🤖 fix: update backgroundCommands test for new PID PGID output format
ethanndickson Dec 8, 2025
a1e7320
🤖 docs: improve background process tool descriptions for clarity
ethanndickson Dec 8, 2025
f6ac07c
🤖 test: skip flaky AI-dependent background bash integration tests
ethanndickson Dec 8, 2025
5dbc14c
Revert "🤖 test: skip flaky AI-dependent background bash integration t…
ethanndickson Dec 8, 2025
211d10c
fix: background bash tests can't start with sleep command
ethanndickson Dec 8, 2025
38f629c
fix: toolOutputContains should check message field for terminate result
ethanndickson Dec 8, 2025
846b365
🤖 refactor: universal PGID lookup and terminate for background processes
ethanndickson Dec 8, 2025
6becd7f
🤖 fix: use set -m for process group isolation
ethanndickson Dec 8, 2025
df15f39
refactor: simplify background process - remove PGID lookup
ethanndickson Dec 8, 2025
384ff92
fix: write exit code after process exits in terminate command
ethanndickson Dec 8, 2025
68dd866
fix: check process group instead of parent PID in terminate
ethanndickson Dec 8, 2025
13ed15c
docs: clarify PID === PGID in SSHBackgroundHandle comment
ethanndickson Dec 8, 2025
63dfc3a
fix: address Copilot review comments
ethanndickson Dec 8, 2025
badae77
test: add process group termination and display_name tests
ethanndickson Dec 8, 2025
228d927
fix: background bash tests can't start with sleep command
ethanndickson Dec 8, 2025
b5b7e19
🤖 feat: add UI styling for background bash processes
ethanndickson Dec 5, 2025
58e30ea
refactor: extract shared tool primitives (ToolIcon, ErrorBox, OutputP…
ethanndickson Dec 5, 2025
76ae329
refactor: migrate existing tool components to shared primitives
ethanndickson Dec 5, 2025
64b0842
refactor: use OutputPaths compact mode in BashBackgroundListToolCall
ethanndickson Dec 8, 2025
144a77f
🤖 fix: count only running processes in background list header
ethanndickson Dec 8, 2025
32ee67b
Merge remote-tracking branch 'origin/main' into bash-background-ui-st…
ethanndickson Dec 8, 2025
10b3b09
🤖 fix: use process_id as React key for uniqueness
ethanndickson Dec 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions src/browser/components/Messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@ import { ProposePlanToolCall } from "../tools/ProposePlanToolCall";
import { TodoToolCall } from "../tools/TodoToolCall";
import { StatusSetToolCall } from "../tools/StatusSetToolCall";
import { WebFetchToolCall } from "../tools/WebFetchToolCall";
import { BashBackgroundListToolCall } from "../tools/BashBackgroundListToolCall";
import { BashBackgroundTerminateToolCall } from "../tools/BashBackgroundTerminateToolCall";
import type {
BashToolArgs,
BashToolResult,
BashBackgroundListArgs,
BashBackgroundListResult,
BashBackgroundTerminateArgs,
BashBackgroundTerminateResult,
FileReadToolArgs,
FileReadToolResult,
FileEditReplaceStringToolArgs,
Expand Down Expand Up @@ -89,6 +95,19 @@ function isWebFetchTool(toolName: string, args: unknown): args is WebFetchToolAr
return TOOL_DEFINITIONS.web_fetch.schema.safeParse(args).success;
}

function isBashBackgroundListTool(toolName: string, args: unknown): args is BashBackgroundListArgs {
if (toolName !== "bash_background_list") return false;
return TOOL_DEFINITIONS.bash_background_list.schema.safeParse(args).success;
}

function isBashBackgroundTerminateTool(
toolName: string,
args: unknown
): args is BashBackgroundTerminateArgs {
if (toolName !== "bash_background_terminate") return false;
return TOOL_DEFINITIONS.bash_background_terminate.schema.safeParse(args).success;
}

export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, workspaceId }) => {
// Route to specialized components based on tool name
if (isBashTool(message.toolName, message.args)) {
Expand Down Expand Up @@ -204,6 +223,30 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, wo
);
}

if (isBashBackgroundListTool(message.toolName, message.args)) {
return (
<div className={className}>
<BashBackgroundListToolCall
args={message.args}
result={message.result as BashBackgroundListResult | undefined}
status={message.status}
/>
</div>
);
}

if (isBashBackgroundTerminateTool(message.toolName, message.args)) {
return (
<div className={className}>
<BashBackgroundTerminateToolCall
args={message.args}
result={message.result as BashBackgroundTerminateResult | undefined}
status={message.status}
/>
</div>
);
}

// Fallback to generic tool call
return (
<div className={className}>
Expand Down
122 changes: 122 additions & 0 deletions src/browser/components/tools/BashBackgroundListToolCall.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React from "react";
import type {
BashBackgroundListArgs,
BashBackgroundListResult,
BashBackgroundListProcess,
} from "@/common/types/tools";
import {
ToolContainer,
ToolHeader,
ExpandIcon,
StatusIndicator,
ToolDetails,
DetailSection,
LoadingDots,
ToolIcon,
ErrorBox,
OutputPaths,
} from "./shared/ToolPrimitives";
import {
useToolExpansion,
getStatusDisplay,
formatDuration,
type ToolStatus,
} from "./shared/toolUtils";
import { cn } from "@/common/lib/utils";

interface BashBackgroundListToolCallProps {
args: BashBackgroundListArgs;
result?: BashBackgroundListResult;
status?: ToolStatus;
}

function getProcessStatusStyle(status: BashBackgroundListProcess["status"]) {
switch (status) {
case "running":
return "bg-success text-on-success";
case "exited":
return "bg-[hsl(0,0%,40%)] text-white";
case "killed":
case "failed":
return "bg-danger text-on-danger";
}
}

export const BashBackgroundListToolCall: React.FC<BashBackgroundListToolCallProps> = ({
args: _args,
result,
status = "pending",
}) => {
const { expanded, toggleExpanded } = useToolExpansion(false);

const processes = result?.success ? result.processes : [];
const runningCount = processes.filter((p) => p.status === "running").length;

return (
<ToolContainer expanded={expanded}>
<ToolHeader onClick={toggleExpanded}>
<ExpandIcon expanded={expanded}>▶</ExpandIcon>
<ToolIcon emoji="📋" toolName="bash_background_list" />
<span className="text-text-secondary">
{result?.success
? runningCount === 0
? "No background processes"
: `${runningCount} background process${runningCount !== 1 ? "es" : ""}`
: "Listing background processes"}
</span>
<StatusIndicator status={status}>{getStatusDisplay(status)}</StatusIndicator>
</ToolHeader>

{expanded && (
<ToolDetails>
{result?.success === false && (
<DetailSection>
<ErrorBox>{result.error}</ErrorBox>
</DetailSection>
)}

{result?.success && processes.length > 0 && (
<DetailSection>
<div className="space-y-2">
{processes.map((proc) => (
<div key={proc.process_id} className="bg-code-bg rounded px-2 py-1.5 text-[11px]">
<div className="mb-1 flex items-center gap-2">
<span className="text-text font-mono">
{proc.display_name ?? proc.process_id}
</span>
<span
className={cn(
"inline-block rounded px-1.5 py-0.5 text-[9px] font-medium uppercase",
getProcessStatusStyle(proc.status)
)}
>
{proc.status}
{proc.exitCode !== undefined && ` (${proc.exitCode})`}
</span>
<span className="text-text-secondary ml-auto">
{formatDuration(proc.uptime_ms)}
</span>
</div>
<div className="text-text-secondary truncate font-mono" title={proc.script}>
{proc.script}
</div>
<OutputPaths stdout={proc.stdout_path} stderr={proc.stderr_path} compact />
</div>
))}
</div>
</DetailSection>
)}

{status === "executing" && !result && (
<DetailSection>
<div className="text-[11px]">
Listing processes
<LoadingDots />
</div>
</DetailSection>
)}
</ToolDetails>
)}
</ToolContainer>
);
};
39 changes: 39 additions & 0 deletions src/browser/components/tools/BashBackgroundTerminateToolCall.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from "react";
import type {
BashBackgroundTerminateArgs,
BashBackgroundTerminateResult,
} from "@/common/types/tools";
import { ToolContainer, ToolHeader, StatusIndicator, ToolIcon } from "./shared/ToolPrimitives";
import { getStatusDisplay, type ToolStatus } from "./shared/toolUtils";

interface BashBackgroundTerminateToolCallProps {
args: BashBackgroundTerminateArgs;
result?: BashBackgroundTerminateResult;
status?: ToolStatus;
}

export const BashBackgroundTerminateToolCall: React.FC<BashBackgroundTerminateToolCallProps> = ({
args,
result,
status = "pending",
}) => {
const statusDisplay = getStatusDisplay(status);

return (
<ToolContainer expanded={false}>
<ToolHeader>
<ToolIcon emoji="⏹️" toolName="bash_background_terminate" />
<span className="text-text font-mono">
{result?.success === true ? (result.display_name ?? args.process_id) : args.process_id}
</span>
{result?.success === true && (
<span className="text-text-secondary text-[10px]">terminated</span>
)}
{result?.success === false && (
<span className="text-danger text-[10px]">{result.error}</span>
)}
<StatusIndicator status={status}>{statusDisplay}</StatusIndicator>
</ToolHeader>
</ToolContainer>
);
};
93 changes: 54 additions & 39 deletions src/browser/components/tools/BashToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ import {
DetailLabel,
DetailContent,
LoadingDots,
ToolIcon,
ErrorBox,
OutputPaths,
} from "./shared/ToolPrimitives";
import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils";
import {
useToolExpansion,
getStatusDisplay,
formatDuration,
type ToolStatus,
} from "./shared/toolUtils";
import { cn } from "@/common/lib/utils";
import { TooltipWrapper, Tooltip } from "../Tooltip";

interface BashToolCallProps {
args: BashToolArgs;
Expand All @@ -23,13 +30,6 @@ interface BashToolCallProps {
startedAt?: number;
}

function formatDuration(ms: number): string {
if (ms < 1000) {
return `${Math.round(ms)}ms`;
}
return `${Math.round(ms / 1000)}s`;
}

export const BashToolCall: React.FC<BashToolCallProps> = ({
args,
result,
Expand Down Expand Up @@ -59,35 +59,43 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
}, [status, startedAt]);

const isPending = status === "executing" || status === "pending";
const isBackground = args.run_in_background ?? (result && "backgroundProcessId" in result);

return (
<ToolContainer expanded={expanded}>
<ToolHeader onClick={toggleExpanded}>
<ExpandIcon expanded={expanded}>▶</ExpandIcon>
<TooltipWrapper inline>
<span>🔧</span>
<Tooltip>bash</Tooltip>
</TooltipWrapper>
<ToolIcon emoji="🔧" toolName="bash" />
<span className="text-text font-monospace max-w-96 truncate">{args.script}</span>
<span
className={cn(
"ml-2 text-[10px] whitespace-nowrap [@container(max-width:500px)]:hidden",
isPending ? "text-pending" : "text-text-secondary"
)}
>
timeout: {args.timeout_secs ?? BASH_DEFAULT_TIMEOUT_SECS}s
{result && ` • took ${formatDuration(result.wall_duration_ms)}`}
{!result && isPending && elapsedTime > 0 && ` • ${formatDuration(elapsedTime)}`}
</span>
{result && (
<span
className={cn(
"ml-2 inline-block shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium whitespace-nowrap",
result.exitCode === 0 ? "bg-success text-on-success" : "bg-danger text-on-danger"
)}
>
{result.exitCode}
{isBackground ? (
// Background mode: show background badge and optional display name
<span className="text-text-secondary ml-2 text-[10px] whitespace-nowrap">
⚡ background{args.display_name && ` • ${args.display_name}`}
</span>
) : (
// Normal mode: show timeout and duration
<>
<span
className={cn(
"ml-2 text-[10px] whitespace-nowrap [@container(max-width:500px)]:hidden",
isPending ? "text-pending" : "text-text-secondary"
)}
>
timeout: {args.timeout_secs ?? BASH_DEFAULT_TIMEOUT_SECS}s
{result && ` • took ${formatDuration(result.wall_duration_ms)}`}
{!result && isPending && elapsedTime > 0 && ` • ${formatDuration(elapsedTime)}`}
</span>
{result && (
<span
className={cn(
"ml-2 inline-block shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium whitespace-nowrap",
result.exitCode === 0 ? "bg-success text-on-success" : "bg-danger text-on-danger"
)}
>
{result.exitCode}
</span>
)}
</>
)}
<StatusIndicator status={status}>{getStatusDisplay(status)}</StatusIndicator>
</ToolHeader>
Expand All @@ -104,19 +112,26 @@ export const BashToolCall: React.FC<BashToolCallProps> = ({
{result.success === false && result.error && (
<DetailSection>
<DetailLabel>Error</DetailLabel>
<div className="text-danger bg-danger-overlay border-danger rounded border-l-2 px-2 py-1.5 text-[11px]">
{result.error}
</div>
<ErrorBox>{result.error}</ErrorBox>
</DetailSection>
)}

{result.output && (
{"backgroundProcessId" in result ? (
// Background process: show file paths
<DetailSection>
<DetailLabel>Output</DetailLabel>
<pre className="bg-code-bg border-success m-0 max-h-[200px] overflow-y-auto rounded border-l-2 px-2 py-1.5 text-[11px] leading-[1.4] break-words whitespace-pre-wrap">
{result.output}
</pre>
<DetailLabel>Output Files</DetailLabel>
<OutputPaths stdout={result.stdout_path} stderr={result.stderr_path} />
</DetailSection>
) : (
// Normal process: show output
result.output && (
<DetailSection>
<DetailLabel>Output</DetailLabel>
<pre className="bg-code-bg border-success m-0 max-h-[200px] overflow-y-auto rounded border-l-2 px-2 py-1.5 text-[11px] leading-[1.4] break-words whitespace-pre-wrap">
{result.output}
</pre>
</DetailSection>
)
)}
</>
)}
Expand Down
Loading