feat(site): humanize process_signal and show killed on processes (#23590)

Replace the raw JSON dump for process_signal with the standard
ToolCollapsible + ToolIcon + ToolLabel pipeline, matching process_list
and other generic tools. A thin ProcessSignalRenderer promotes soft
failures (success=false, isError=false) so the generic renderer shows
the error indicator. ToolLabel distinguishes running, success, and
failure states. TerminalIcon used for consistency with other process
tools.

When a process is killed via process_signal, the execute and
process_output blocks show a red OctagonX icon with signal details
on hover. The killedBySignal field is set on MergedTool during the
existing cross-message parsing pass, no new abstractions.

Stories for process_signal (10) and killed indicators (8). Unit
tests for the cross-tool annotation logic (3). Humanized labels
and TerminalIcon for process_list.
This commit is contained in:
Mathias Fredriksson
2026-03-30 15:35:39 +03:00
committed by GitHub
parent 076e97aa66
commit f2845f6622
12 changed files with 645 additions and 4 deletions
@@ -353,6 +353,7 @@ export const BlockList: FC<{
result={tool.result}
status={tool.status}
isError={tool.isError}
killedBySignal={tool.killedBySignal}
subagentTitles={subagentTitles}
computerUseSubagentIds={computerUseSubagentIds}
showDesktopPreviews={showDesktopPreviews}
@@ -394,6 +395,7 @@ export const BlockList: FC<{
result={tool.result}
status={tool.status}
isError={tool.isError}
killedBySignal={tool.killedBySignal}
subagentTitles={subagentTitles}
computerUseSubagentIds={computerUseSubagentIds}
showDesktopPreviews={showDesktopPreviews}
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import type { ChatMessage, ChatMessagePart } from "#/api/typesGenerated";
import {
mergeTools,
parseMessageContent,
parseMessagesWithMergedTools,
parseToolResultIsError,
} from "./messageParsing";
@@ -375,3 +377,137 @@ describe("mergeTools", () => {
expect(merged[0].status).toBe("completed");
});
});
describe("parseMessagesWithMergedTools — killedBySignal annotation", () => {
const msg = (
id: number,
role: "assistant" | "user",
parts: ChatMessagePart[],
): ChatMessage => ({
id,
chat_id: "chat-1",
created_at: new Date().toISOString(),
role,
content: parts,
});
// The generated types use Record<string, string> for args/result,
// but real tool data contains booleans and nulls. We widen the
// parameter types and cast back to ChatMessagePart.
const toolCall = (
id: string,
name: string,
args: Record<string, string>,
): ChatMessagePart => ({
type: "tool-call" as const,
tool_call_id: id,
tool_name: name,
args,
});
const toolResult = (
id: string,
name: string,
result: Record<string, unknown>,
isError = false,
): ChatMessagePart => ({
type: "tool-result" as const,
tool_call_id: id,
tool_name: name,
// The generated type uses Record<string, string> but real
// tool results contain booleans and nulls.
result: result as Record<string, string>,
is_error: isError,
});
it("annotates execute tool with killedBySignal from a later process_signal", () => {
const PID = "abc-123";
const parsed = parseMessagesWithMergedTools([
msg(1, "assistant", [
toolCall("tc1", "execute", { command: "make build" }),
]),
msg(2, "assistant", [
toolResult("tc1", "execute", {
success: true,
output: "",
background_process_id: PID,
}),
toolCall("tc2", "process_signal", {
process_id: PID,
signal: "kill",
}),
]),
msg(3, "assistant", [
toolResult("tc2", "process_signal", {
success: true,
message: `signal "kill" sent to process ${PID}`,
}),
]),
]);
const executeTool = parsed
.flatMap((e) => e.parsed.tools)
.find((t) => t.name === "execute");
expect(executeTool?.killedBySignal).toBe("kill");
});
it("does not annotate when process_signal failed", () => {
const PID = "abc-123";
const parsed = parseMessagesWithMergedTools([
msg(1, "assistant", [
toolCall("tc1", "execute", { command: "make build" }),
]),
msg(2, "assistant", [
toolResult("tc1", "execute", {
success: true,
output: "",
background_process_id: PID,
}),
toolCall("tc2", "process_signal", {
process_id: PID,
signal: "kill",
}),
]),
msg(3, "assistant", [
toolResult("tc2", "process_signal", {
success: false,
error: "process not found",
}),
]),
]);
const executeTool = parsed
.flatMap((e) => e.parsed.tools)
.find((t) => t.name === "execute");
expect(executeTool?.killedBySignal).toBeUndefined();
});
it("annotates process_output via args.process_id", () => {
const PID = "def-456";
const parsed = parseMessagesWithMergedTools([
msg(1, "assistant", [
toolCall("tc1", "process_output", { process_id: PID }),
]),
msg(2, "assistant", [
toolResult("tc1", "process_output", {
output: "some output",
exit_code: null,
}),
toolCall("tc2", "process_signal", {
process_id: PID,
signal: "terminate",
}),
]),
msg(3, "assistant", [
toolResult("tc2", "process_signal", {
success: true,
message: "signal sent",
}),
]),
]);
const procOut = parsed
.flatMap((e) => e.parsed.tools)
.find((t) => t.name === "process_output");
expect(procOut?.killedBySignal).toBe("terminate");
});
});
@@ -290,6 +290,37 @@ export const parseMessagesWithMergedTools = (
);
}
// Annotate execute/process_output tools whose process was
// later killed or terminated via process_signal.
const signaledProcesses = new Map<string, "kill" | "terminate">();
for (const { parsed } of rawParsed) {
for (const tool of parsed.tools) {
if (tool.name !== "process_signal") continue;
const args = asRecord(tool.args);
const result = asRecord(tool.result);
if (!args || !result || !result.success) continue;
const pid = asString(args.process_id);
const sig = asString(args.signal);
if (pid && (sig === "kill" || sig === "terminate"))
signaledProcesses.set(pid, sig);
}
}
if (signaledProcesses.size > 0) {
for (const { parsed } of rawParsed) {
for (const tool of parsed.tools) {
if (tool.name !== "execute" && tool.name !== "process_output") continue;
const rec = asRecord(tool.result);
const args = asRecord(tool.args);
const pid =
(rec ? asString(rec.background_process_id) : "") ||
(rec ? asString(rec.process_id) : "") ||
(args ? asString(args.process_id) : "");
const sig = pid ? signaledProcesses.get(pid) : undefined;
if (sig) tool.killedBySignal = sig;
}
}
}
return rawParsed;
};
@@ -26,6 +26,8 @@ export type MergedTool = {
status: "completed" | "error" | "running";
mcpServerConfigId?: string;
modelIntent?: string;
/** Set when a process_signal killed/terminated this process. */
killedBySignal?: "kill" | "terminate";
};
export type RenderBlock =
@@ -5,6 +5,7 @@ import {
ExternalLinkIcon,
LayersIcon,
LoaderIcon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import type React from "react";
@@ -21,6 +22,7 @@ import { cn } from "#/utils/cn";
import {
BORDER_BG_STYLE,
COLLAPSED_OUTPUT_HEIGHT,
signalTooltipLabel,
type ToolStatus,
} from "./utils";
@@ -35,7 +37,8 @@ export const ExecuteTool: React.FC<{
status: ToolStatus;
isError: boolean;
isBackgrounded?: boolean;
}> = ({ command, output, status, isBackgrounded = false }) => {
killedBySignal?: "kill" | "terminate";
}> = ({ command, output, status, isBackgrounded = false, killedBySignal }) => {
const [expanded, setExpanded] = useState(false);
const outputRef = useRef<HTMLPreElement | null>(null);
const hasOutput = output.length > 0;
@@ -132,6 +135,16 @@ export const ExecuteTool: React.FC<{
<TooltipContent>Running in background</TooltipContent>
</Tooltip>
)}
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{signalTooltipLabel(killedBySignal)}
</TooltipContent>
</Tooltip>
)}
<CopyButton
text={command}
label="Copy command"
@@ -0,0 +1,165 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { Tool } from "./Tool";
const PROCESS_ID = "376b2458-e318-4442-8b87-51a0f9727f0e";
const meta: Meta<typeof Tool> = {
title: "components/ai-elements/tool/ProcessKilledIndicator",
component: Tool,
decorators: [
(Story) => (
<div className="max-w-3xl rounded-lg border border-solid border-border-default bg-surface-primary p-4">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Tool>;
// ---------------------------------------------------------------------------
// Execute tool — killed indicator via killedBySignal prop
// ---------------------------------------------------------------------------
export const ExecuteKilled: Story = {
args: {
name: "execute",
status: "completed",
killedBySignal: "kill",
args: { command: "make pre-push 2>&1" },
result: {
success: true,
output: "pre-push (/tmp/coder-pre-push.CZ6K9A)\ntest + build site:",
exit_code: -1,
wall_duration_ms: 45000,
background_process_id: PROCESS_ID,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("make pre-push 2>&1")).toBeInTheDocument();
},
};
export const ExecuteTerminated: Story = {
args: {
name: "execute",
status: "completed",
killedBySignal: "terminate",
args: { command: "npm start" },
result: {
success: true,
output: "Starting dev server...",
exit_code: 0,
wall_duration_ms: 2000,
background_process_id: PROCESS_ID,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("npm start")).toBeInTheDocument();
},
};
/** Execute NOT signaled — no indicator. */
export const ExecuteNotSignaled: Story = {
args: {
name: "execute",
status: "completed",
args: { command: "echo hello" },
result: {
success: true,
output: "hello",
exit_code: 0,
wall_duration_ms: 100,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("echo hello")).toBeInTheDocument();
},
};
/** Running execute — killed indicator should NOT appear yet. */
export const ExecuteRunningNotYetKilled: Story = {
args: {
name: "execute",
status: "running",
killedBySignal: "kill",
args: { command: "make pre-push 2>&1" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("make pre-push 2>&1")).toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// ProcessOutput tool — killed indicator
// ---------------------------------------------------------------------------
export const ProcessOutputKilled: Story = {
args: {
name: "process_output",
status: "completed",
killedBySignal: "kill",
args: { process_id: PROCESS_ID },
result: {
output: "pre-push (/tmp/coder-pre-push.CZ6K9A)\ntest + build site:",
exit_code: null,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/pre-push/)).toBeInTheDocument();
},
};
export const ProcessOutputTerminated: Story = {
args: {
name: "process_output",
status: "completed",
killedBySignal: "terminate",
args: { process_id: PROCESS_ID },
result: {
output: "server output",
exit_code: null,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("server output")).toBeInTheDocument();
},
};
/** ProcessOutput with no output and killed — indicator in empty state. */
export const ProcessOutputKilledNoOutput: Story = {
args: {
name: "process_output",
status: "completed",
killedBySignal: "kill",
args: { process_id: PROCESS_ID },
result: {
output: "",
exit_code: null,
},
},
};
/** ProcessOutput NOT signaled. */
export const ProcessOutputNotSignaled: Story = {
args: {
name: "process_output",
status: "completed",
args: { process_id: PROCESS_ID },
result: {
output: "some output",
exit_code: 0,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("some output")).toBeInTheDocument();
},
};
@@ -1,10 +1,15 @@
import { ChevronDownIcon, LoaderIcon } from "lucide-react";
import { ChevronDownIcon, LoaderIcon, OctagonXIcon } from "lucide-react";
import type React from "react";
import { useRef, useState } from "react";
import { CopyButton } from "#/components/CopyButton/CopyButton";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { COLLAPSED_OUTPUT_HEIGHT } from "./utils";
import { COLLAPSED_OUTPUT_HEIGHT, signalTooltipLabel } from "./utils";
/**
* Specialized rendering for `process_output` tool calls. Shows
@@ -16,7 +21,8 @@ export const ProcessOutputTool: React.FC<{
isRunning: boolean;
exitCode: number | null;
isError: boolean;
}> = ({ output, isRunning, exitCode, isError }) => {
killedBySignal?: "kill" | "terminate";
}> = ({ output, isRunning, exitCode, isError, killedBySignal }) => {
const [expanded, setExpanded] = useState(false);
const outputRef = useRef<HTMLPreElement | null>(null);
const hasOutput = output.length > 0;
@@ -62,6 +68,16 @@ export const ProcessOutputTool: React.FC<{
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{signalTooltipLabel(killedBySignal)}
</TooltipContent>
</Tooltip>
)}
{showExitCode && (
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
exit {exitCode}
@@ -94,6 +110,16 @@ export const ProcessOutputTool: React.FC<{
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{signalTooltipLabel(killedBySignal)}
</TooltipContent>
</Tooltip>
)}
{showExitCode && (
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
exit {exitCode}
@@ -0,0 +1,188 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within } from "storybook/test";
import { Tool } from "./Tool";
const PROCESS_ID = "376b2458-e318-4442-8b87-51a0f9727f0e";
const meta: Meta<typeof Tool> = {
title: "components/ai-elements/tool/ProcessSignal",
component: Tool,
decorators: [
(Story) => (
<div className="max-w-3xl rounded-lg border border-solid border-border-default bg-surface-primary p-4">
<Story />
</div>
),
],
args: { name: "process_signal" },
};
export default meta;
type Story = StoryObj<typeof Tool>;
// ---------------------------------------------------------------------------
// Running states
// ---------------------------------------------------------------------------
export const RunningKill: Story = {
args: {
status: "running",
args: { process_id: PROCESS_ID, signal: "kill" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Killing process…")).toBeInTheDocument();
},
};
export const RunningTerminate: Story = {
args: {
status: "running",
args: { process_id: PROCESS_ID, signal: "terminate" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Terminating process…")).toBeInTheDocument();
},
};
export const RunningUnknownSignal: Story = {
args: {
status: "running",
args: { process_id: PROCESS_ID, signal: "" },
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Sending signal…")).toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// Success states
// ---------------------------------------------------------------------------
export const SuccessKill: Story = {
args: {
status: "completed",
args: { process_id: PROCESS_ID, signal: "kill" },
result: {
success: true,
message: `signal "kill" sent to process ${PROCESS_ID}`,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Killed process 376b2458")).toBeInTheDocument();
},
};
export const SuccessTerminate: Story = {
args: {
status: "completed",
args: { process_id: PROCESS_ID, signal: "terminate" },
result: {
success: true,
message: `signal "terminate" sent to process ${PROCESS_ID}`,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Terminated process 376b2458")).toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// Failure states
// ---------------------------------------------------------------------------
/**
* Backend errorResult() wraps failures in NewTextResponse (not
* NewTextErrorResponse), so isError stays false. The renderer
* detects this via the success=false field.
*/
export const SoftFailureKill: Story = {
args: {
status: "completed",
args: { process_id: PROCESS_ID, signal: "kill" },
result: {
success: false,
error: "signal process: process not found",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByText("Failed to kill process 376b2458"),
).toBeInTheDocument();
},
};
export const SoftFailureTerminate: Story = {
args: {
status: "completed",
args: { process_id: PROCESS_ID, signal: "terminate" },
result: {
success: false,
error: "signal process: process not found",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByText("Failed to terminate process 376b2458"),
).toBeInTheDocument();
},
};
/**
* Protocol-level error from NewTextErrorResponse (e.g. missing
* args). isError is true, result is a plain string.
*/
export const ProtocolError: Story = {
args: {
status: "completed",
isError: true,
args: { process_id: "", signal: "kill" },
result: "process_id is required",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Failed to kill process")).toBeInTheDocument();
},
};
/**
* Protocol-level error with a structured result body.
*/
export const ProtocolErrorStructured: Story = {
args: {
status: "completed",
isError: true,
args: { process_id: PROCESS_ID, signal: "terminate" },
result: {
success: false,
error: "workspace connection resolver is not configured",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(
canvas.getByText("Failed to terminate process 376b2458"),
).toBeInTheDocument();
},
};
// ---------------------------------------------------------------------------
// Edge cases
// ---------------------------------------------------------------------------
/** No args parsed yet (streamed tool call with partial data). */
export const NoArgs: Story = {
args: {
status: "running",
args: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("Sending signal…")).toBeInTheDocument();
},
};
@@ -56,6 +56,7 @@ interface ToolProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
args?: unknown;
result?: unknown;
isError?: boolean;
killedBySignal?: "kill" | "terminate";
/** Maps sub-agent chat IDs to their titles, built from spawn tool results. */
subagentTitles?: Map<string, string>;
/** Set of chat IDs spawned by `spawn_computer_use_agent`. */
@@ -81,6 +82,7 @@ type ToolRendererProps = {
args: unknown;
result: unknown;
isError: boolean;
killedBySignal?: "kill" | "terminate";
subagentTitles?: Map<string, string>;
computerUseSubagentIds?: Set<string>;
showDesktopPreviews?: boolean;
@@ -99,6 +101,7 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
args,
result,
isError,
killedBySignal,
}) => {
const parsedArgs = parseArgs(args);
const command = parsedArgs ? asString(parsedArgs.command) : "";
@@ -128,6 +131,7 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
output={output}
status={status}
isError={isError}
killedBySignal={killedBySignal}
/>
);
};
@@ -136,6 +140,7 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
status,
result,
isError,
killedBySignal,
}) => {
const rec = asRecord(result);
const output = rec ? asString(rec.output).trim() : "";
@@ -151,6 +156,7 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
isRunning={status === "running"}
exitCode={exitCode}
isError={isError}
killedBySignal={killedBySignal}
/>
);
};
@@ -614,6 +620,24 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
);
};
// ---------------------------------------------------------------------------
// process_signal — thin wrapper that promotes soft failures (success=false
// in the result body, isError=false at protocol level) so the generic
// renderer shows the error indicator and tooltip.
// ---------------------------------------------------------------------------
const ProcessSignalRenderer: FC<ToolRendererProps> = (props) => {
const rec = asRecord(props.result);
const isSoftFailure =
!props.isError &&
props.status !== "running" &&
rec !== null &&
!rec.success;
return (
<GenericToolRenderer {...props} isError={props.isError || isSoftFailure} />
);
};
// ---------------------------------------------------------------------------
// Renderer lookup map — maps tool names to their specialized renderers.
// ---------------------------------------------------------------------------
@@ -621,6 +645,7 @@ const GenericToolRenderer: FC<ToolRendererProps> = ({
const toolRenderers: Record<string, FC<ToolRendererProps>> = {
execute: ExecuteRenderer,
process_output: ProcessOutputRenderer,
process_signal: ProcessSignalRenderer,
wait_for_external_auth: WaitForExternalAuthRenderer,
read_file: ReadFileRenderer,
write_file: WriteFileRenderer,
@@ -650,6 +675,7 @@ export const Tool = memo(
args,
result,
isError = false,
killedBySignal,
subagentTitles,
computerUseSubagentIds,
showDesktopPreviews,
@@ -681,6 +707,7 @@ export const Tool = memo(
args={args}
result={result}
isError={isError}
killedBySignal={killedBySignal}
subagentTitles={subagentTitles}
computerUseSubagentIds={computerUseSubagentIds}
showDesktopPreviews={showDesktopPreviews}
@@ -70,6 +70,8 @@ export const ToolIcon: React.FC<{
switch (name) {
case "execute":
case "process_output":
case "process_list":
case "process_signal":
return <TerminalIcon className={base} />;
case "read_file":
case "list_templates":
@@ -32,6 +32,49 @@ export const ToolLabel: React.FC<{
Reading process output
</span>
);
case "process_signal": {
const signal = parsed ? asString(parsed.signal) : "";
const processId = parsed ? asString(parsed.process_id) : "";
const shortId = processId ? processId.slice(0, 8) : "";
const hasResult = result !== undefined && result !== null;
const success = parsedResult ? Boolean(parsedResult.success) : false;
if (hasResult && success) {
const verb = signal === "kill" ? "Killed" : "Terminated";
return (
<span className="truncate text-sm text-content-secondary">
{verb} process{shortId ? ` ${shortId}` : ""}
</span>
);
}
if (hasResult && !success) {
const verb =
signal === "kill"
? "kill"
: signal === "terminate"
? "terminate"
: "signal";
return (
<span className="truncate text-sm text-content-secondary">
Failed to {verb} process{shortId ? ` ${shortId}` : ""}
</span>
);
}
return (
<span className="truncate text-sm text-content-secondary">
{signal === "kill"
? "Killing process…"
: signal === "terminate"
? "Terminating process…"
: "Sending signal…"}
</span>
);
}
case "process_list":
return (
<span className="truncate text-sm text-content-secondary">
Listing processes
</span>
);
case "read_file":
return (
<span className="truncate text-sm text-content-secondary">
@@ -580,3 +580,9 @@ export function humanizeMCPToolName(
// Re-export runtime type utils used by sub-components so they
// can import from a single location.
export { asNumber, asRecord, asString } from "../runtimeTypeUtils";
/**
* Returns the tooltip label for a killed/terminated process signal.
*/
export const signalTooltipLabel = (signal: "kill" | "terminate"): string =>
signal === "kill" ? "Killed (SIGKILL)" : "Terminated (SIGTERM)";