fix: improve UX for subtasks with repo setup scripts (#1105)

This commit is contained in:
Carlos Florêncio
2026-05-28 16:07:51 +01:00
committed by GitHub
parent d97ec5b8fd
commit 905b9f9727
18 changed files with 355 additions and 13 deletions
@@ -55,7 +55,7 @@ func buildRunningFromExecution(execution *AgentExecution, prior *models.Executor
SessionID: execution.SessionID,
TaskID: execution.TaskID,
Runtime: execution.RuntimeName,
Status: "starting",
Status: models.ExecutorRunningStatusStarting,
Resumable: true,
AgentExecutionID: execution.ID,
ContainerID: execution.ContainerID,
+3 -3
View File
@@ -478,7 +478,7 @@ IMPORTANT:
- Pass base_branch explicitly to override either default. Use list_repositories_kandev to see each repo's default_branch.
- Top-level tasks need a repository via repository_url, repository_id, or local_path
- 'description' is the sub-agent's initial prompt — be specific and detailed
- Set start_agent=false to create without starting an agent`
- start_agent defaults to true and is what you want in nearly every case — the new task auto-launches an agent that immediately works on the description. Pass start_agent=false ONLY for an explicit placeholder (e.g. queuing work the user will start later, or creating a tracking task with no immediate work). When in doubt, leave it true.`
parentDesc := "Parent task ID for subtasks. Use 'self' to create a subtask of your current task (RECOMMENDED for plan phases, delegated work). Omit only for unrelated top-level tasks."
if s.mode == ModeExternal {
@@ -488,7 +488,7 @@ IMPORTANT:
- Provide a repository via repository_url, repository_id, or local_path
- workspace_id and workflow_id are auto-resolved if only one exists; provide explicitly if ambiguous
- 'description' is the agent's initial prompt — be specific and detailed
- Set start_agent=false to create without starting an agent
- start_agent defaults to true and is what you want in nearly every case — the new task auto-launches an agent that immediately works on the description. Pass start_agent=false ONLY for an explicit placeholder (e.g. queuing work the user will start later). When in doubt, leave it true.
- Use parent_id only when delegating to a known existing task by its ID`
parentDesc = "Optional parent task ID. Omit for top-level tasks; provide an existing task ID only to create a subtask of that task."
}
@@ -504,7 +504,7 @@ IMPORTANT:
mcp.WithString("description", mcp.Description("The initial prompt for the sub-agent. This is the ONLY context the agent receives when it starts — treat it as the agent's first user message. REQUIRED for subtasks: without a description the sub-agent starts with no context and cannot do useful work. Be specific and detailed.")),
mcp.WithString("agent_profile_id", mcp.Description("Agent profile ID to use. For subtasks, inherited from the parent session. For top-level tasks, ask the user which agent profile they want (e.g. Claude Code, OpenCode) if not already known.")),
mcp.WithString("executor_profile_id", mcp.Description("Executor profile ID to use (determines the runtime environment: local, worktree, docker, etc.). For subtasks, inherited from the parent session. For top-level tasks, ask the user which executor profile they want if not already known.")),
mcp.WithBoolean("start_agent", mcp.Description("Whether to auto-start an agent on the created task. Default: true. Set to false to create the task without starting an agent.")),
mcp.WithBoolean("start_agent", mcp.Description("Whether to auto-start an agent on the created task. Default: true — leave it true unless you specifically want a placeholder task with no agent running. Setting false leaves the task waiting for the user to click 'Start agent' in the UI; the description is preserved but no work happens automatically.")),
mcp.WithString("repository_id", mcp.Description("Repository ID. Required for top-level tasks unless local_path or repository_url is provided. For subtasks: optional — supply only when the subtask should target a different repo than the parent.")),
mcp.WithString("local_path", mcp.Description("Local repository folder path (e.g. '/Users/me/projects/myrepo'). Will create/find the repository automatically. Preferred for local worktree flow. For subtasks: supply only when the subtask should target a different repo than the parent.")),
mcp.WithString("repository_url", mcp.Description("GitHub repository URL (e.g. 'https://github.com/owner/repo'). The repository will be cloned automatically on first use. For subtasks: supply only when the subtask should target a different repo than the parent.")),
@@ -48,6 +48,7 @@ type executorStore interface {
UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error
HasExecutorRunningRow(ctx context.Context, sessionID string) (bool, error)
DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error
UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error
// Workspace
GetWorkspace(ctx context.Context, id string) (*models.Workspace, error)
// Task environment
@@ -570,6 +570,23 @@ func (e *Executor) finalizeLaunch(ctx context.Context, task *v1.Task, session *m
if startAgent {
e.startAgentProcessAsync(ctx, task.ID, sessionID, resp.AgentExecutionID)
} else {
// Prepare-only launch: the workspace + agentctl are up but the agent
// process is intentionally not being started. The lifecycle manager
// always writes status='starting' on row creation; flip it to
// 'prepared' so the row doesn't look stuck mid-launch. When the user
// later starts the agent (StartCreatedSession), Launch re-runs and
// rewrites the row with status='starting' via the usual path.
//
// Detach from the caller context so a client disconnect / WS timeout
// right after launch returns can't drop this write — that would leave
// the row stuck on "starting", which is the exact UX this fix closes.
statusCtx := context.WithoutCancel(ctx)
if err := e.repo.UpdateExecutorRunningStatus(statusCtx, sessionID, models.ExecutorRunningStatusPrepared); err != nil {
e.logger.Warn("failed to mark executors_running as prepared",
zap.String("session_id", sessionID),
zap.Error(err))
}
}
e.logger.Info("agent launched for prepared session",
@@ -705,6 +705,22 @@ func (m *mockRepository) HasExecutorRunningRow(ctx context.Context, sessionID st
return false, nil
}
// UpdateExecutorRunningStatus mirrors the production sqlite repo: returns
// ErrExecutorRunningNotFound when no row exists for the session. Tests that
// exercise the "no row" warning-log path can rely on this, and tests that want
// the status flip to land must seed m.executorsRunning[sessionID] first.
func (m *mockRepository) UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error {
if m.executorsRunning == nil {
return models.ErrExecutorRunningNotFound
}
row, ok := m.executorsRunning[sessionID]
if !ok {
return models.ErrExecutorRunningNotFound
}
row.Status = status
return nil
}
// Environment operations
func (m *mockRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error {
return nil
@@ -473,6 +473,55 @@ func TestLaunchPreparedSession_WorkspaceOnly(t *testing.T) {
}
}
// TestLaunchPreparedSession_WorkspaceOnly_FlipsExecutorRunningStatus asserts
// the prepare-only branch in finalizeLaunch updates executors_running.status
// from the lifecycle-manager default "starting" to "prepared", so the row
// doesn't look stuck mid-launch on a session that's actually ready by design.
func TestLaunchPreparedSession_WorkspaceOnly_FlipsExecutorRunningStatus(t *testing.T) {
repo := newMockRepository()
session := &models.TaskSession{
ID: "session-123",
TaskID: "task-123",
AgentProfileID: "profile-123",
State: models.TaskSessionStateCreated,
StartedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo.sessions[session.ID] = session
// Seed the executors_running row the way production's lifecycle manager
// would after LaunchAgent — status="starting" until something flips it.
repo.executorsRunning[session.ID] = &models.ExecutorRunning{
SessionID: session.ID,
TaskID: session.TaskID,
Status: models.ExecutorRunningStatusStarting,
}
agentManager := &mockAgentManager{
launchAgentFunc: func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) {
return &LaunchAgentResponse{
AgentExecutionID: "exec-123",
ContainerID: "container-123",
Status: v1.AgentStatusStarting,
}, nil
},
}
executor := newTestExecutor(t, agentManager, repo)
task := &v1.Task{ID: "task-123", WorkspaceID: "workspace-123"}
_, err := executor.LaunchPreparedSession(context.Background(), task, "session-123", LaunchOptions{AgentProfileID: "profile-123", StartAgent: false})
if err != nil {
t.Fatalf("LaunchPreparedSession(startAgent=false) failed: %v", err)
}
got := repo.executorsRunning["session-123"].Status
if got != models.ExecutorRunningStatusPrepared {
t.Errorf("executors_running.status = %q, want %q", got, models.ExecutorRunningStatusPrepared)
}
}
func TestLaunchPreparedSession_ExistingWorkspace_StartAgent(t *testing.T) {
repo := newMockRepository()
@@ -163,6 +163,7 @@ type sessionExecutorStore interface {
DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error
HasExecutorRunningRow(ctx context.Context, sessionID string) (bool, error)
UpdateResumeToken(ctx context.Context, sessionID, expectedExecID, resumeToken, lastMessageUUID string) error
UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error
// Executor
GetExecutor(ctx context.Context, id string) (*models.Executor, error)
// Task
@@ -365,6 +365,9 @@ func (m *mockRepository) HasExecutorRunningRow(ctx context.Context, sessionID st
func (m *mockRepository) UpdateResumeToken(ctx context.Context, sessionID, expectedExecID, resumeToken, lastMessageUUID string) error {
return nil
}
func (m *mockRepository) UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error {
return nil
}
func (m *mockRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error {
return nil
}
@@ -28,6 +28,15 @@ var ErrExecutorNotFound = errors.New("executor not found")
// will produce its own events.
var ErrExecutionRotated = errors.New("execution rotated; CAS write rejected")
// Status values for executors_running.status. The lifecycle manager defaults
// rows to "starting" on creation (see runtime/lifecycle/persistence.go); the
// orchestrator flips a row to "prepared" when a prepare-only launch finishes
// with the agent process intentionally not started.
const (
ExecutorRunningStatusStarting = "starting"
ExecutorRunningStatusPrepared = "prepared"
)
// ListMessagesOptions defines pagination options for listing messages
type ListMessagesOptions struct {
Limit int
@@ -212,6 +212,11 @@ type ExecutorRepository interface {
// and writes nothing. Use when persisting state from a specific execution that may have been
// replaced concurrently — typically resume tokens emitted by ACP session events.
UpdateResumeToken(ctx context.Context, sessionID, expectedExecID, resumeToken, lastMessageUUID string) error
// UpdateExecutorRunningStatus performs a narrow status update on the row.
// Used when the agent process is intentionally not being started (prepare-only
// launch) so the row doesn't sit on the misleading default "starting" forever.
// Returns models.ErrExecutorRunningNotFound if no row exists for the session.
UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error
}
// EnvironmentRepository handles environment CRUD.
@@ -411,6 +411,28 @@ func (r *Repository) UpdateResumeToken(ctx context.Context, sessionID, expectedE
return nil
}
// UpdateExecutorRunningStatus narrowly updates the status column.
// Returns ErrExecutorRunningNotFound when no row exists for the session.
func (r *Repository) UpdateExecutorRunningStatus(ctx context.Context, sessionID, status string) error {
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE executors_running
SET status = ?, updated_at = ?
WHERE session_id = ?
`), status, now, sessionID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("%w for session: %s", models.ErrExecutorRunningNotFound, sessionID)
}
return nil
}
func (r *Repository) HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) {
var exists int
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
@@ -1,10 +1,12 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Message } from "@/lib/types/http";
import type { PrepareStepInfo } from "@/lib/state/slices/session-runtime/types";
let mockSteps: PrepareStepInfo[] = [];
let mockPrepareStatus: "preparing" | "completed" | "failed" = "preparing";
let mockSessionState: string = "STARTING";
let mockMessages: Message[] = [];
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
@@ -29,13 +31,21 @@ vi.mock("@/components/state-provider", () => ({
sessionAgentctl: {
itemsBySessionId: {},
},
messages: {
bySession: {
"session-1": mockMessages,
},
},
}),
}));
import { PrepareProgress } from "./prepare-progress";
describe("PrepareProgress", () => {
afterEach(() => cleanup());
afterEach(() => {
cleanup();
mockMessages = [];
});
it("hides skipped steps that have no useful details", () => {
mockPrepareStatus = "preparing";
@@ -118,3 +128,59 @@ describe("PrepareProgress", () => {
expect(screen.queryByText("Environment prepared on a fresh sandbox")).toBeNull();
});
});
function makeSetupScriptMessage(overrides: Partial<Message["metadata"] & object> = {}): Message {
return {
id: "msg-1",
task_id: "task-1" as Message["task_id"],
session_id: "session-1" as Message["session_id"],
author_type: "agent",
content: "Installing deps...\nDone.",
type: "script_execution",
created_at: "2026-05-27T19:06:51Z",
metadata: {
script_type: "setup",
command: "make install",
status: "exited",
exit_code: 0,
started_at: "2026-05-27T19:06:51Z",
completed_at: "2026-05-27T19:06:54Z",
...overrides,
},
};
}
describe("PrepareProgress per-repo setup script", () => {
afterEach(() => {
cleanup();
mockMessages = [];
});
it("renders the per-repo setup script as a step inside the panel", () => {
// `preparing` keeps the panel auto-expanded so step rows render.
mockPrepareStatus = "preparing";
mockSessionState = "STARTING";
mockSteps = [
{ name: "Validate repository", status: "completed" },
{ name: "Create worktree", status: "completed" },
];
mockMessages = [makeSetupScriptMessage()];
render(<PrepareProgress sessionId="session-1" />);
expect(screen.getByText("Run repository setup script")).toBeTruthy();
expect(screen.getByText("make install")).toBeTruthy();
});
it("marks a failed setup script as a failed step with an error message", () => {
// `failed` keeps the panel auto-expanded.
mockPrepareStatus = "failed";
mockSessionState = "FAILED";
mockSteps = [{ name: "Create worktree", status: "completed" }];
mockMessages = [makeSetupScriptMessage({ exit_code: 2 })];
render(<PrepareProgress sessionId="session-1" />);
expect(screen.getByText("Script exited with code 2")).toBeTruthy();
});
});
@@ -14,16 +14,14 @@ import { useAppStore } from "@/components/state-provider";
import { ExpandableRow } from "@/components/task/chat/messages/expandable-row";
import { isFallbackNoticeStep } from "@/lib/prepare/summarize";
import { cn } from "@/lib/utils";
import { stripAnsi } from "@/lib/utils/ansi";
import { isSetupScriptMessage } from "@/hooks/use-processed-messages";
import type { Message } from "@/lib/types/http";
import type {
PrepareStepInfo,
SessionPrepareState,
} from "@/lib/state/slices/session-runtime/types";
const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g;
function stripAnsi(s: string): string {
return s.replace(ANSI_RE, "");
}
function formatStepDuration(startedAt?: string, endedAt?: string): string | null {
if (!startedAt || !endedAt) return null;
const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
@@ -316,6 +314,64 @@ function isVisibleStep(step: PrepareStepInfo): boolean {
return step.name.trim() !== "" || hasStepDetails(step);
}
type ScriptStatus = "starting" | "running" | "exited" | "failed";
// Map the script_execution status vocabulary (starting/running/exited/failed)
// onto the PrepareStep status vocabulary (running/completed/failed).
function scriptStatusToStepStatus(
status: ScriptStatus | undefined,
exitCode: number | undefined,
): string {
if (status === "starting" || status === "running") return "running";
if (status === "failed") return "failed";
if (status === "exited") {
if (exitCode === undefined || exitCode === 0) return "completed";
return "failed";
}
return status ?? "running";
}
type ScriptMetadata = {
script_type?: string;
command?: string;
status?: ScriptStatus;
exit_code?: number;
error?: string;
started_at?: string;
completed_at?: string;
};
// Convert a setup-script `script_execution` message into the same step shape
// the existing prepare panel renders, so it slots in alongside Validate/Sync/
// Create-worktree without a parallel render path.
function setupScriptMessageToStep(message: Message): PrepareStepInfo {
const meta = (message.metadata ?? {}) as ScriptMetadata;
const command = meta.command ?? "";
const status = scriptStatusToStepStatus(meta.status, meta.exit_code);
const stepError =
status === "failed"
? meta.error ||
(meta.exit_code !== undefined && meta.exit_code !== 0
? `Script exited with code ${meta.exit_code}`
: "Script failed")
: undefined;
return {
name: "Run repository setup script",
command,
status,
output: message.content || undefined,
error: stepError,
startedAt: meta.started_at,
endedAt: meta.completed_at,
};
}
function useSetupScriptSteps(sessionId: string): PrepareStepInfo[] {
const messages = useAppStore((state) => state.messages.bySession[sessionId]);
if (!messages || messages.length === 0) return [];
return messages.filter(isSetupScriptMessage).map(setupScriptMessageToStep);
}
function SessionInfo({ sessionId }: { sessionId: string }) {
const session = useAppStore((state) => state.taskSessions.items[sessionId]);
if (!session) return null;
@@ -358,6 +414,7 @@ function SessionInfo({ sessionId }: { sessionId: string }) {
export function PrepareProgress({ sessionId }: PrepareProgressProps) {
const { status, prepareState } = usePrepareStatus(sessionId);
const session = useAppStore((state) => state.taskSessions.items[sessionId]);
const setupScriptSteps = useSetupScriptSteps(sessionId);
// Only the clean-success status auto-collapses. Anything with running/error/
// warning context stays open by default so the user sees what's happening.
const autoExpand = status !== "completed";
@@ -366,7 +423,10 @@ export function PrepareProgress({ sessionId }: PrepareProgressProps) {
if (!prepareState) return null;
const hasSessionInfo = Boolean(session?.worktree_path || session?.worktree_branch);
const visibleSteps = prepareState.steps.filter(isVisibleStep);
// Per-repo setup scripts run inside worktree.Manager.Create, so visually they
// belong after Create-worktree finishes. Appending keeps the existing prepare
// step order intact.
const visibleSteps = [...prepareState.steps.filter(isVisibleStep), ...setupScriptSteps];
const headerLabel = getHeaderLabel(status, prepareState);
const isErrorStatus = status === "failed" || status === "completed_with_error";
const headerClass = cn("text-xs", isErrorStatus ? "text-destructive" : "text-muted-foreground");
@@ -4,6 +4,7 @@ import { memo } from "react";
import { IconCheck, IconX, IconTerminal } from "@tabler/icons-react";
import { GridSpinner } from "@/components/grid-spinner";
import type { Message } from "@/lib/types/http";
import { stripAnsi } from "@/lib/utils/ansi";
import { Badge } from "@kandev/ui/badge";
import { ExpandableRow } from "./expandable-row";
import { useExpandState } from "./use-expand-state";
@@ -167,7 +168,7 @@ function ScriptExpandedContent({
Output
</div>
<pre className="font-mono text-xs bg-muted/30 rounded px-3 py-2 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap break-words">
{content}
{stripAnsi(content)}
</pre>
</div>
)}
@@ -230,7 +231,7 @@ export const ScriptExecutionMessage = memo(function ScriptExecutionMessage({
{comment.content && (
<div className="pl-4 border-l-2 border-border/30">
<pre className="font-mono text-xs bg-muted/30 rounded px-3 py-2 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap break-words">
{comment.content}
{stripAnsi(comment.content)}
</pre>
</div>
)}
@@ -10,6 +10,7 @@ import {
collapseTodoSnapshotsPerTurn,
deduplicateAgentBootResumes,
isAgentBootResumeMessage,
isSetupScriptMessage,
} from "./use-processed-messages";
function makeMessage(
@@ -83,6 +84,27 @@ describe("isAgentBootResumeMessage", () => {
});
});
describe("isSetupScriptMessage", () => {
it("returns true for a script_execution with script_type=setup", () => {
const msg = makeMessage("x", "script_execution", { script_type: "setup", status: "exited" });
expect(isSetupScriptMessage(msg)).toBe(true);
});
it("returns false for agent_boot and cleanup scripts", () => {
expect(isSetupScriptMessage(bootStarted("s1"))).toBe(false);
const cleanup = makeMessage("c1", "script_execution", { script_type: "cleanup" });
expect(isSetupScriptMessage(cleanup)).toBe(false);
});
it("returns false for non-script messages", () => {
expect(isSetupScriptMessage(makeMessage("m1", "message"))).toBe(false);
});
it("returns false when metadata is missing", () => {
expect(isSetupScriptMessage(makeMessage("x", "script_execution"))).toBe(false);
});
});
describe("deduplicateAgentBootResumes", () => {
it("returns an empty list unchanged", () => {
expect(deduplicateAgentBootResumes([])).toEqual([]);
+13
View File
@@ -183,6 +183,18 @@ export function isAgentBootResumeMessage(message: Message): boolean {
return meta?.script_type === "agent_boot" && meta?.is_resuming === true;
}
/** Per-repo setup scripts (`Repository.setup_script`) run during worktree
* creation and are persisted as `script_execution` rows. They belong
* conceptually to environment preparation, not the chat thread — `PrepareProgress`
* surfaces them as steps inside the env-prep panel. Hiding them from the chat
* prevents the script from rendering above the env-prep panel (which happens
* when the user prompt hasn't been recorded yet, e.g. MCP-auto-started subtasks). */
export function isSetupScriptMessage(message: Message): boolean {
if (message.type !== "script_execution") return false;
const meta = message.metadata as { script_type?: string } | undefined;
return meta?.script_type === "setup";
}
/** A resumed session may produce many "Resumed agent …" boot messages over its
* lifetime (every backend restart emits one). They all convey the same info;
* keep only the most recent and drop the rest — unconditionally, even if user
@@ -207,6 +219,7 @@ function filterVisibleMessages(
): Message[] {
const filtered = messages.filter((message) => {
if (subagentChildIds.has(message.id)) return false;
if (isSetupScriptMessage(message)) return false;
if (message.type === "clarification_request") {
const metadata = message.metadata as ClarificationRequestMetadata | undefined;
return !(!metadata?.status || metadata.status === "pending");
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { stripAnsi } from "./ansi";
describe("stripAnsi", () => {
it("returns input unchanged when there are no escape sequences", () => {
expect(stripAnsi("hello world")).toBe("hello world");
});
it("strips SGR colour codes", () => {
const input = "\x1b[36mInstalling backend dependencies...\x1b[0m";
expect(stripAnsi(input)).toBe("Installing backend dependencies...");
});
it("strips bold + colour wrapped output across multiple lines", () => {
const input =
"\x1b[36mInstalling deps...\x1b[0m\nDownloading...\n\x1b[32m\x1b[1m✓ All dependencies installed!\x1b[0m";
expect(stripAnsi(input)).toBe(
"Installing deps...\nDownloading...\n✓ All dependencies installed!",
);
});
it("handles empty string", () => {
expect(stripAnsi("")).toBe("");
});
it("strips OSC hyperlinks terminated by ST", () => {
const input = "click \x1b]8;;https://example.com\x1b\\here\x1b]8;;\x1b\\!";
expect(stripAnsi(input)).toBe("click here!");
});
it("strips OSC window-title sequences terminated by BEL", () => {
const input = "\x1b]0;my-shell\x07ready";
expect(stripAnsi(input)).toBe("ready");
});
it("strips CSI private-prefix sequences (hide cursor, bracketed paste)", () => {
expect(stripAnsi("before\x1b[?25lafter")).toBe("beforeafter");
expect(stripAnsi("\x1b[?2004hpaste\x1b[?2004l")).toBe("paste");
});
});
+17
View File
@@ -0,0 +1,17 @@
// ANSI escape sequences. Covers:
// - CSI sequences per ECMA-48: `ESC [ param* intermediate* final` where
// param bytes are 0x30-0x3F (digits + `:;<=>?`), intermediate bytes are
// 0x20-0x2F (space + `!"#$%&'()*+,-./`), and final byte is 0x40-0x7E.
// This catches both vanilla SGR (`\x1b[36m`, `\x1b[0m`) and private-prefix
// sequences like `\x1b[?25l` (hide cursor) and `\x1b[?2004h` (bracketed
// paste) that tools like pnpm and Playwright emit. The narrower
// `[0-9;]*` pattern used to let those leak through and render as
// literal `[?25l` noise.
// - OSC sequences (hyperlinks, window title) terminated by ST or BEL:
// `\x1b]8;;url\x1b\\` or `\x1b]0;title\x07`. npm, pnpm, yarn and some
// make targets routinely emit the hyperlink variant.
const ANSI_RE = /\x1b(?:\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g;
export function stripAnsi(s: string): string {
return s.replace(ANSI_RE, "");
}